Skip to main content

bhtune_core/
pid_config.rs

1//! Enums for how a DCS/PLC expresses PID parameters, avoiding the fragile pattern of
2//! comparing live values against magic display strings (`"Kp - Proportional Gain"`, etc.).
3
4use serde::{Deserialize, Serialize};
5
6/// How a DCS expresses the proportional term.
7#[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    /// Kp: dimensionless gain.
13    Gain,
14    /// PB: proportional band, as a percentage. `PB = 100 / Kp`.
15    Band,
16}
17
18/// How a DCS expresses the integral term.
19#[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    /// Ti: reset time.
25    ResetTime,
26    /// Ri: reset rate, `Ri = 1 / Ti`.
27    ResetRate,
28    /// Ki: reset gain, `Ki = Kp / Ti`.
29    ResetGain,
30}
31
32/// How a DCS expresses the derivative term.
33#[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    /// Td: derivative time.
39    DerivativeTime,
40    /// Kd: derivative gain, `Kd = Kp * Td`.
41    DerivativeGain,
42}
43
44/// The time unit a DCS expects for integral/derivative parameters.
45#[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/// Precision shared by the active P/I/D terms in a template's final controller units.
55#[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/// A controller value and its canonical text; parsing `display` as `f32` yields `value`.
85#[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    /// Rounds the stored `f32` to nearest, with exact halfway cases away from zero.
134    /// Widening before scaling avoids overflow and false halfway cases from `f32`
135    /// multiplication. Calculations themselves are not changed.
136    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}