Skip to main content

bhtune_core/
constants.rs

1//! Tuning-constant lookup matrices: per-process-type, per-response-level, per-controller
2//! type multipliers used to turn a measured relay-test oscillation into Kp/Ti/Td.
3//!
4//! These constants come from published relay-feedback tuning correlations, not from
5//! anything derived at runtime — they are a fixed lookup table indexed by
6//! [`crate::process_type::ProcessType`], [`ResponseLevel`], and
7//! [`crate::controller_type::ControllerType`].
8
9use serde::{Deserialize, Serialize};
10
11use crate::{controller_type::ControllerType, process_type::ProcessType};
12
13/// How aggressively a tuning result should push the loop: a faster response trades off
14/// stability margin, a more sluggish response trades off speed.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
17#[serde(rename_all = "snake_case")]
18pub enum ResponseLevel {
19    Aggressive = 0,
20    Moderate = 1,
21    Sluggish = 2,
22}
23
24impl ResponseLevel {
25    pub const ALL: [ResponseLevel; 3] = [
26        ResponseLevel::Aggressive,
27        ResponseLevel::Moderate,
28        ResponseLevel::Sluggish,
29    ];
30
31    fn index(self) -> usize {
32        self as usize
33    }
34}
35
36/// The resolved set of tuning constants for one (process type, response level, controller
37/// type) combination.
38#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
39pub struct TuningConstants {
40    /// Proportional-gain multiplier. The only constant that varies by controller type for
41    /// a P-only controller — a P-only controller has no integral or derivative term, so
42    /// `beta`/`c2`/`c3` are always zero for [`ControllerType::P`], but `c1` is not.
43    pub c1: f32,
44    /// Integral-time multiplier. Zero for [`ControllerType::P`] (no integral term).
45    pub c2: f32,
46    /// Derivative-time multiplier. Zero for every controller type except
47    /// [`ControllerType::Pid`], and only [`ProcessType::allows_pid`] process types have a
48    /// nonzero value there.
49    pub c3: f32,
50    /// Hysteresis multiplier used during the relay test itself. Zero for
51    /// [`ControllerType::P`] (no integral term to protect from switching noise).
52    pub beta: f32,
53}
54
55/// Default number of relay cycles to skip before counting begins, indexed by
56/// [`ProcessType::index`].
57pub(crate) const DEFAULT_CYCLES_SKIP: [u32; 6] = [1, 1, 1, 1, 1, 1];
58
59/// Default number of relay cycles to count/average, indexed by [`ProcessType::index`].
60pub(crate) const DEFAULT_CYCLES_TEST: [u32; 6] = [2, 2, 1, 1, 1, 1];
61
62/// Default noise-protection delay in seconds, indexed by [`ProcessType::index`].
63pub(crate) const DEFAULT_NOISE_PROTECTION_SECS: [u32; 6] = [3, 3, 10, 10, 20, 20];
64
65/// `C1[process_type][response_level][controller_type]` — the only matrix that varies by
66/// response level, since it is the proportional-gain multiplier.
67const C1: [[[f32; 3]; 3]; 6] = [
68    // Flow
69    [[0.5, 0.451, 0.0], [0.333, 0.302, 0.0], [0.25, 0.226, 0.0]],
70    // PressureLine
71    [[0.5, 0.442, 0.0], [0.333, 0.296, 0.0], [0.25, 0.221, 0.0]],
72    // PressureVessel
73    [
74        [0.333, 0.331, 0.0],
75        [0.222, 0.222, 0.0],
76        [0.167, 0.166, 0.0],
77    ],
78    // Level (intentionally identical to PressureVessel)
79    [
80        [0.333, 0.331, 0.0],
81        [0.222, 0.222, 0.0],
82        [0.167, 0.166, 0.0],
83    ],
84    // TemperatureMixing
85    [
86        [0.5, 0.47, 0.498],
87        [0.25, 0.235, 0.249],
88        [0.167, 0.155, 0.164],
89    ],
90    // TemperatureHeatExchange
91    [
92        [0.333, 0.325, 0.332],
93        [0.222, 0.218, 0.222],
94        [0.167, 0.163, 0.166],
95    ],
96];
97
98/// `C2[process_type][controller_type]` — the integral-time multiplier. Does not vary by
99/// response level in the source data (all three response-level rows are identical), so
100/// this crate models it as a 2D table.
101const C2: [[f32; 3]; 6] = [
102    [0.0, 0.331, 0.0],   // Flow
103    [0.0, 0.302, 0.0],   // PressureLine
104    [0.0, 1.216, 0.0],   // PressureVessel
105    [0.0, 1.216, 0.0],   // Level (intentionally identical to PressureVessel)
106    [0.0, 0.436, 0.162], // TemperatureMixing
107    [0.0, 0.704, 0.275], // TemperatureHeatExchange
108];
109
110/// `C3[process_type][controller_type]` — the derivative-time multiplier. Nonzero only for
111/// the PID column of the two temperature process types.
112const C3: [[f32; 3]; 6] = [
113    [0.0, 0.0, 0.0],  // Flow
114    [0.0, 0.0, 0.0],  // PressureLine
115    [0.0, 0.0, 0.0],  // PressureVessel
116    [0.0, 0.0, 0.0],  // Level
117    [0.0, 0.0, 0.14], // TemperatureMixing
118    [0.0, 0.0, 0.09], // TemperatureHeatExchange
119];
120
121/// `BETA[process_type][controller_type]` — the relay hysteresis multiplier. Does not vary
122/// by response level in the source data, so this crate models it as a 2D table.
123const BETA: [[f32; 3]; 6] = [
124    [0.0, 0.433, 0.0],   // Flow
125    [0.0, 0.466, 0.0],   // PressureLine
126    [0.0, 0.13, 0.0],    // PressureVessel
127    [0.0, 0.13, 0.0],    // Level (intentionally identical to PressureVessel)
128    [0.0, 0.343, 0.102], // TemperatureMixing
129    [0.0, 0.221, 0.013], // TemperatureHeatExchange
130];
131
132/// Looks up the tuning constants for one (process type, controller type, response level)
133/// combination. `response_level` only affects `c1`; `c2`/`c3`/`beta` are constant across
134/// response levels.
135pub fn lookup(
136    process_type: ProcessType,
137    controller_type: ControllerType,
138    response_level: ResponseLevel,
139) -> TuningConstants {
140    let p = process_type.index();
141    let c = controller_type.index();
142    TuningConstants {
143        c1: C1[p][response_level.index()][c],
144        c2: C2[p][c],
145        c3: C3[p][c],
146        beta: BETA[p][c],
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn flow_pi_aggressive_matches_known_constants() {
156        let tc = lookup(
157            ProcessType::Flow,
158            ControllerType::Pi,
159            ResponseLevel::Aggressive,
160        );
161        assert_eq!(tc.c1, 0.451);
162        assert_eq!(tc.c2, 0.331);
163        assert_eq!(tc.c3, 0.0);
164        assert_eq!(tc.beta, 0.433);
165    }
166
167    #[test]
168    fn flow_pi_moderate_and_sluggish_differ_only_in_c1() {
169        let moderate = lookup(
170            ProcessType::Flow,
171            ControllerType::Pi,
172            ResponseLevel::Moderate,
173        );
174        let sluggish = lookup(
175            ProcessType::Flow,
176            ControllerType::Pi,
177            ResponseLevel::Sluggish,
178        );
179        assert_eq!(moderate.c1, 0.302);
180        assert_eq!(sluggish.c1, 0.226);
181        assert_eq!(moderate.c2, sluggish.c2);
182        assert_eq!(moderate.c3, sluggish.c3);
183        assert_eq!(moderate.beta, sluggish.beta);
184    }
185
186    /// A P-only controller has no integral or derivative term, so `beta`/`c2`/`c3` are
187    /// always zero for it — but `c1` (the proportional-gain multiplier) is not, since P is
188    /// still a real controller structure with its own gain calibration.
189    #[test]
190    fn p_only_controller_has_zero_beta_c2_c3_but_nonzero_c1() {
191        for pt in ProcessType::ALL {
192            for response in ResponseLevel::ALL {
193                let tc = lookup(pt, ControllerType::P, response);
194                assert_eq!(tc.beta, 0.0, "{pt:?}/{response:?} beta");
195                assert_eq!(tc.c2, 0.0, "{pt:?}/{response:?} c2");
196                assert_eq!(tc.c3, 0.0, "{pt:?}/{response:?} c3");
197                assert!(tc.c1 > 0.0, "{pt:?}/{response:?} c1 should be nonzero");
198            }
199        }
200    }
201
202    /// Only the two temperature process types allow PID, and this is structurally encoded
203    /// in the constant tables themselves: the PID column is zero across the board for
204    /// every other process type.
205    #[test]
206    fn non_temperature_process_types_have_zero_pid_column() {
207        let non_temperature = [
208            ProcessType::Flow,
209            ProcessType::PressureLine,
210            ProcessType::PressureVessel,
211            ProcessType::Level,
212        ];
213        for pt in non_temperature {
214            for response in ResponseLevel::ALL {
215                let tc = lookup(pt, ControllerType::Pid, response);
216                assert_eq!(tc.c1, 0.0, "{pt:?}/{response:?} c1");
217                assert_eq!(tc.c2, 0.0, "{pt:?} c2");
218                assert_eq!(tc.c3, 0.0, "{pt:?} c3");
219                assert_eq!(tc.beta, 0.0, "{pt:?} beta");
220            }
221        }
222    }
223
224    #[test]
225    fn temperature_mixing_pid_is_nonzero() {
226        let tc = lookup(
227            ProcessType::TemperatureMixing,
228            ControllerType::Pid,
229            ResponseLevel::Moderate,
230        );
231        assert_eq!(tc.c1, 0.249);
232        assert_eq!(tc.c2, 0.162);
233        assert_eq!(tc.c3, 0.14);
234        assert_eq!(tc.beta, 0.102);
235    }
236
237    #[test]
238    fn temperature_heat_exchange_pid_is_nonzero() {
239        let tc = lookup(
240            ProcessType::TemperatureHeatExchange,
241            ControllerType::Pid,
242            ResponseLevel::Moderate,
243        );
244        assert_eq!(tc.c1, 0.222);
245        assert_eq!(tc.c2, 0.275);
246        assert_eq!(tc.c3, 0.09);
247        assert_eq!(tc.beta, 0.013);
248    }
249
250    /// The source calibration data for PressureVessel and Level is byte-for-byte
251    /// identical across every matrix; this pins that down as an intentional fact rather
252    /// than something a future edit might "fix" into a divergence.
253    #[test]
254    fn pressure_vessel_and_level_share_identical_constants() {
255        for controller in ControllerType::ALL {
256            for response in ResponseLevel::ALL {
257                let vessel = lookup(ProcessType::PressureVessel, controller, response);
258                let level = lookup(ProcessType::Level, controller, response);
259                assert_eq!(vessel, level, "{controller:?}/{response:?}");
260            }
261        }
262    }
263
264    #[test]
265    fn default_cycle_and_noise_tables_have_six_entries() {
266        assert_eq!(DEFAULT_CYCLES_SKIP.len(), 6);
267        assert_eq!(DEFAULT_CYCLES_TEST.len(), 6);
268        assert_eq!(DEFAULT_NOISE_PROTECTION_SECS.len(), 6);
269    }
270
271    #[test]
272    fn response_level_serde_round_trip() {
273        for rl in ResponseLevel::ALL {
274            let json = serde_json::to_string(&rl).unwrap();
275            let back: ResponseLevel = serde_json::from_str(&json).unwrap();
276            assert_eq!(rl, back);
277        }
278    }
279
280    #[test]
281    fn tuning_constants_serde_round_trip() {
282        let tc = lookup(
283            ProcessType::Flow,
284            ControllerType::Pi,
285            ResponseLevel::Aggressive,
286        );
287        let json = serde_json::to_string(&tc).unwrap();
288        let back: TuningConstants = serde_json::from_str(&json).unwrap();
289        assert_eq!(tc, back);
290    }
291}