1use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
9#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
10#[serde(rename_all = "snake_case")]
11pub enum ProportionalType {
12 Gain,
14 Band,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
21#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
22#[serde(rename_all = "snake_case")]
23pub enum IntegralType {
24 ResetTime,
26 ResetRate,
28 ResetGain,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
35#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
36#[serde(rename_all = "snake_case")]
37pub enum DerivativeType {
38 DerivativeTime,
40 DerivativeGain,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
47#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
48#[serde(rename_all = "snake_case")]
49pub enum TimeUnit {
50 Seconds,
51 Minutes,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
56#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
57#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
58#[serde(rename_all = "snake_case")]
59pub enum PidRoundingKind {
60 DecimalPlaces,
61 SignificantDigits,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
66#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
67#[serde(deny_unknown_fields)]
68pub struct PidRounding {
69 pub kind: PidRoundingKind,
70 #[cfg_attr(feature = "utoipa", schema(minimum = 0, maximum = 7))]
71 #[cfg_attr(feature = "schemars", schemars(range(min = 0, max = 7)))]
72 pub digits: u8,
73}
74
75impl Default for PidRounding {
76 fn default() -> Self {
77 Self {
78 kind: PidRoundingKind::SignificantDigits,
79 digits: 3,
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
87pub struct RoundedPidValue {
88 pub value: f32,
89 pub display: String,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum PidRoundingError {
94 InvalidDigits { kind: PidRoundingKind, digits: u8 },
95 NonFinite,
96 NonPositive,
97 RoundedToZero,
98}
99
100impl std::fmt::Display for PidRoundingError {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 Self::InvalidDigits { kind, digits } => {
104 let bounds = match kind {
105 PidRoundingKind::DecimalPlaces => "0-7 decimal places",
106 PidRoundingKind::SignificantDigits => "1-7 significant digits",
107 };
108 write!(f, "PID rounding digits {digits} must be {bounds}")
109 }
110 Self::NonFinite => f.write_str("PID value must remain finite after rounding"),
111 Self::NonPositive => f.write_str("an active PID term must be positive"),
112 Self::RoundedToZero => {
113 f.write_str("template PID rounding would erase an active term to zero; increase the template precision")
114 }
115 }
116 }
117}
118
119impl std::error::Error for PidRoundingError {}
120
121impl PidRounding {
122 pub fn validate(self) -> Result<(), PidRoundingError> {
123 if self.digits > 7 || (self.kind == PidRoundingKind::SignificantDigits && self.digits == 0)
124 {
125 return Err(PidRoundingError::InvalidDigits {
126 kind: self.kind,
127 digits: self.digits,
128 });
129 }
130 Ok(())
131 }
132
133 pub fn round_value(self, value: f32) -> Result<RoundedPidValue, PidRoundingError> {
137 self.validate()?;
138 if !value.is_finite() {
139 return Err(PidRoundingError::NonFinite);
140 }
141 let wide = f64::from(value);
142 let places = match self.kind {
143 PidRoundingKind::DecimalPlaces => i32::from(self.digits),
144 PidRoundingKind::SignificantDigits if value != 0.0 => {
145 i32::from(self.digits) - 1 - wide.abs().log10().floor() as i32
146 }
147 PidRoundingKind::SignificantDigits => 0,
148 };
149 let scale = 10_f64.powi(places);
150 let rounded = (wide * scale).round() / scale;
151 let rounded = if rounded == 0.0 { 0.0 } else { rounded };
152 let target = rounded as f32;
153 if !target.is_finite() {
154 return Err(PidRoundingError::NonFinite);
155 }
156 let display = match self.kind {
157 PidRoundingKind::DecimalPlaces => {
158 format!("{rounded:.digits$}", digits = usize::from(self.digits))
159 }
160 PidRoundingKind::SignificantDigits if rounded != 0.0 => {
161 let exponent = rounded.abs().log10().floor() as i32;
162 let precision = usize::from(self.digits - 1);
163 if exponent >= i32::from(self.digits) || exponent < -4 {
164 format!("{rounded:.precision$e}")
165 } else {
166 let places = (i32::from(self.digits) - 1 - exponent) as usize;
167 format!("{rounded:.places$}")
168 }
169 }
170 PidRoundingKind::SignificantDigits => "0".to_string(),
171 };
172 Ok(RoundedPidValue {
173 value: target,
174 display,
175 })
176 }
177
178 pub(crate) fn disabled_value(self, value: f32) -> RoundedPidValue {
179 let display = match self.kind {
180 PidRoundingKind::DecimalPlaces => {
181 format!("{value:.digits$}", digits = usize::from(self.digits))
182 }
183 PidRoundingKind::SignificantDigits => value.to_string(),
184 };
185 RoundedPidValue { value, display }
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use proptest::prelude::*;
193
194 fn rounding(kind: PidRoundingKind, digits: u8) -> PidRounding {
195 PidRounding { kind, digits }
196 }
197
198 #[test]
199 fn rounding_defaults_to_three_significant_digits_and_round_trips() {
200 let policy = PidRounding::default();
201 assert_eq!(policy, rounding(PidRoundingKind::SignificantDigits, 3));
202 for kind in [
203 PidRoundingKind::DecimalPlaces,
204 PidRoundingKind::SignificantDigits,
205 ] {
206 let policy = rounding(kind, 3);
207 let json = serde_json::to_string(&policy).unwrap();
208 assert_eq!(serde_json::from_str::<PidRounding>(&json).unwrap(), policy);
209 assert!(json.contains(match kind {
210 PidRoundingKind::DecimalPlaces => "decimal_places",
211 PidRoundingKind::SignificantDigits => "significant_digits",
212 }));
213 }
214 }
215
216 #[test]
217 fn rounding_validates_precision_bounds() {
218 for digits in 0..=8 {
219 for kind in [
220 PidRoundingKind::DecimalPlaces,
221 PidRoundingKind::SignificantDigits,
222 ] {
223 let policy = rounding(kind, digits);
224 let valid = digits <= 7 && (kind == PidRoundingKind::DecimalPlaces || digits > 0);
225 assert_eq!(policy.validate().is_ok(), valid);
226 if !valid {
227 let error = policy.round_value(1.0).unwrap_err();
228 assert!(error.to_string().contains("digits"));
229 assert!(std::error::Error::source(&error).is_none());
230 }
231 }
232 }
233 }
234
235 #[test]
236 fn decimal_rounding_matches_yokogawa_results_and_keeps_one_decimal() {
237 let policy = rounding(PidRoundingKind::DecimalPlaces, 1);
238 for (raw, target, display) in [
239 (155.21378, 155.2, "155.2"),
240 (231.79277, 231.8, "231.8"),
241 (309.74078, 309.7, "309.7"),
242 (2.482169, 2.5, "2.5"),
243 (0.0, 0.0, "0.0"),
244 (-0.0, 0.0, "0.0"),
245 (1.25, 1.3, "1.3"),
246 (-1.25, -1.3, "-1.3"),
247 ] {
248 let rounded = policy.round_value(raw).unwrap();
249 assert_eq!(rounded.value, target);
250 assert_eq!(rounded.display, display);
251 assert_eq!(rounded.display.parse::<f32>().unwrap(), rounded.value);
252 }
253 }
254
255 #[test]
256 fn rounding_respects_the_stored_f32_on_either_side_of_a_halfway_case() {
257 let policy = rounding(PidRoundingKind::DecimalPlaces, 1);
258 for (raw, expected) in [
259 (f32::from_bits(1.25_f32.to_bits() - 1), 1.2),
260 (1.25, 1.3),
261 (f32::from_bits(1.25_f32.to_bits() + 1), 1.3),
262 (1.05_f32, 1.0),
263 (f32::from_bits(1.05_f32.to_bits() + 1), 1.1),
264 ] {
265 assert_eq!(policy.round_value(raw).unwrap().value, expected);
266 }
267 }
268
269 #[test]
270 fn significant_rounding_preserves_small_values_and_handles_carry() {
271 let policy = PidRounding::default();
272 for (raw, target, display) in [
273 (0.004873, 0.00487, "0.00487"),
274 (2.482169, 2.48, "2.48"),
275 (155.21378, 155.0, "155"),
276 (9.999, 10.0, "10.0"),
277 (999.9, 1000.0, "1.00e3"),
278 (0.0000004873, 0.000000487, "4.87e-7"),
279 (0.0, 0.0, "0"),
280 (-0.0, 0.0, "0"),
281 ] {
282 let rounded = policy.round_value(raw).unwrap();
283 assert_eq!(rounded.value, target);
284 assert_eq!(rounded.display, display);
285 assert_eq!(rounded.display.parse::<f32>().unwrap(), rounded.value);
286 }
287 }
288
289 #[test]
290 fn rounding_handles_extreme_finite_values_without_scaling_overflow() {
291 for policy in [
292 rounding(PidRoundingKind::DecimalPlaces, 7),
293 rounding(PidRoundingKind::SignificantDigits, 3),
294 ] {
295 for raw in [f32::MIN_POSITIVE, f32::from_bits(1), f32::MAX, -f32::MAX] {
296 let rounded = policy.round_value(raw).unwrap();
297 assert!(rounded.value.is_finite());
298 assert_eq!(rounded.display.parse::<f32>().unwrap(), rounded.value);
299 assert_eq!(policy.round_value(rounded.value).unwrap(), rounded);
300 }
301 }
302 let error = rounding(PidRoundingKind::SignificantDigits, 4)
303 .round_value(f32::MAX)
304 .unwrap_err();
305 assert!(error.to_string().contains("finite"));
306 }
307
308 #[test]
309 fn rounding_rejects_non_finite_values() {
310 for raw in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
311 let error = PidRounding::default().round_value(raw).unwrap_err();
312 assert!(error.to_string().contains("finite"));
313 }
314 }
315
316 proptest! {
317 #[test]
318 fn rounding_is_idempotent_and_display_is_the_same_controller_value(
319 raw in -1_000_000_f32..1_000_000_f32,
320 digits in 1_u8..=7,
321 decimal_places in any::<bool>(),
322 ) {
323 let kind = if decimal_places {
324 PidRoundingKind::DecimalPlaces
325 } else {
326 PidRoundingKind::SignificantDigits
327 };
328 let policy = rounding(kind, digits);
329 let rounded = policy.round_value(raw).unwrap();
330 prop_assert_eq!(rounded.display.parse::<f32>().unwrap(), rounded.value);
331 prop_assert_eq!(policy.round_value(rounded.value).unwrap(), rounded);
332 }
333 }
334
335 #[test]
336 fn proportional_type_serde_round_trip() {
337 for pt in [ProportionalType::Gain, ProportionalType::Band] {
338 let json = serde_json::to_string(&pt).unwrap();
339 let back: ProportionalType = serde_json::from_str(&json).unwrap();
340 assert_eq!(pt, back);
341 }
342 }
343
344 #[test]
345 fn integral_type_serde_round_trip() {
346 for it in [
347 IntegralType::ResetTime,
348 IntegralType::ResetRate,
349 IntegralType::ResetGain,
350 ] {
351 let json = serde_json::to_string(&it).unwrap();
352 let back: IntegralType = serde_json::from_str(&json).unwrap();
353 assert_eq!(it, back);
354 }
355 }
356
357 #[test]
358 fn derivative_type_serde_round_trip() {
359 for dt in [
360 DerivativeType::DerivativeTime,
361 DerivativeType::DerivativeGain,
362 ] {
363 let json = serde_json::to_string(&dt).unwrap();
364 let back: DerivativeType = serde_json::from_str(&json).unwrap();
365 assert_eq!(dt, back);
366 }
367 }
368
369 #[test]
370 fn time_unit_serde_round_trip() {
371 for tu in [TimeUnit::Seconds, TimeUnit::Minutes] {
372 let json = serde_json::to_string(&tu).unwrap();
373 let back: TimeUnit = serde_json::from_str(&json).unwrap();
374 assert_eq!(tu, back);
375 }
376 }
377
378 #[test]
379 fn serde_uses_snake_case() {
380 assert_eq!(
381 serde_json::to_string(&ProportionalType::Band).unwrap(),
382 "\"band\""
383 );
384 assert_eq!(
385 serde_json::to_string(&IntegralType::ResetGain).unwrap(),
386 "\"reset_gain\""
387 );
388 assert_eq!(
389 serde_json::to_string(&DerivativeType::DerivativeGain).unwrap(),
390 "\"derivative_gain\""
391 );
392 assert_eq!(
393 serde_json::to_string(&TimeUnit::Minutes).unwrap(),
394 "\"minutes\""
395 );
396 }
397}