Skip to main content

bhtune_core/
mrft.rs

1//! The MRFT (Modified Relay Feedback Test) engine: the pure, I/O-free relay-switching state
2//! machine at the heart of bhtune. It never reads a clock, performs network I/O, or touches a
3//! UI.
4//!
5//! Scope is deliberately narrow: this module decides *when to switch the MV and when the
6//! test is complete*. It does not read or write OPC tags (`bhtune-driver`'s job) and it does
7//! not calculate PID constants from the collected peaks/troughs (`core-tuning-math`'s job) —
8//! it only hands them off via [`Action::Complete`].
9
10use chrono::{DateTime, Duration, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::{direction::ControllerDirection, loop_config::LoopConfig};
14
15/// One PV sample fed into the engine. The engine has no clock of its own — every timestamp
16/// it ever reasons about arrives through a `Tick`.
17#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
18#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
19pub struct Tick {
20    pub time: DateTime<Utc>,
21    pub pv: f32,
22}
23
24/// A side effect the caller must perform in response to a [`MrftEngine::step`] call.
25///
26/// Uses adjacent tagging (`{"kind": "...", "data": ...}`), like
27/// [`crate::tags::TagOrValue`]: serde cannot internally tag a newtype variant holding a bare
28/// number, which `WriteMv(f32)` is.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
31pub enum Action {
32    /// Write this value to the MV tag.
33    WriteMv(f32),
34    /// The test is complete. Carries everything `core-tuning-math` needs to calculate PID
35    /// constants: the recorded peak/trough PV values, the timestamps of every switch after
36    /// the skip period, and which direction the very first switch went.
37    Complete {
38        peaks: Vec<f32>,
39        troughs: Vec<f32>,
40        switch_times: Vec<DateTime<Utc>>,
41        mv_sign_init: i8,
42    },
43}
44
45/// Initial readings the engine needs at construction time, taken once before the relay test
46/// starts (`ReadInitialOPCvalues` in the legacy app).
47#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
48pub struct InitialReadings {
49    pub pv_ini: f32,
50    pub mv_ini: f32,
51    /// MV range floor (`MvMSL`). Zero for an uncascaded, 0-100% loop.
52    pub mv_range_low: f32,
53    /// MV range ceiling (`MvMSH`).
54    pub mv_range_high: f32,
55}
56
57/// Computes the raw engineering-unit relay amplitude from a percentage of the MV range, and
58/// clamps it so neither relay step would drive the MV outside `[mv_range_low,
59/// mv_range_high]`. Pure port of `CheckMVboundaries`.
60pub fn clamp_relay_amplitude(
61    relay_amp_percent: f32,
62    mv_ini: f32,
63    mv_range_low: f32,
64    mv_range_high: f32,
65) -> f32 {
66    let mut relay_amp_raw = relay_amp_percent / 100.0 * (mv_range_high - mv_range_low);
67
68    if mv_ini + relay_amp_raw > mv_range_high {
69        relay_amp_raw = mv_range_high - mv_ini;
70    } else if mv_ini - relay_amp_raw < mv_range_low {
71        relay_amp_raw = mv_ini - mv_range_low;
72    }
73
74    relay_amp_raw
75}
76
77/// A snapshot of the engine's observable state after a `step()` call — the fields a
78/// golden-master trace comparison checks against the legacy CSV log's per-tick columns
79/// (`Hysteresis`, `MvValueCurrent`, `MvSignNextStep`, `CounterAllSwitches`).
80#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
81#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
82pub struct MrftState {
83    pub hysteresis: f32,
84    pub mv_value_current: f32,
85    pub mv_sign_next_step: i8,
86    pub counter_all_switches: u32,
87    /// Whole relay cycles completed so far. Mirrors `GetCyclesCompleted`.
88    pub cycles_completed: i32,
89    /// Whole relay cycles remaining. Mirrors `GetCyclesRemaining`; reaches `0` on the tick of
90    /// the final switch, which is what triggers the snap-back to `mv_ini` instead of a full
91    /// relay step.
92    pub cycles_remaining: i32,
93}
94
95/// The MRFT relay-feedback state machine. Construct with [`MrftEngine::new`], then call
96/// [`MrftEngine::step`] once per PV sample.
97///
98/// Deliberately has no knowledge of `--mrftDelayTime` pre/post padding: the caller decides
99/// when to start calling `step()` at all, rather than the engine accepting an internal
100/// "delay active" no-op flag. That keeps this type's only job "given a PV sample, decide
101/// whether to switch" — nothing else.
102#[derive(Debug, Clone)]
103pub struct MrftEngine {
104    // Fixed for the life of the engine.
105    beta: f32,
106    action_multiplier: i8,
107    relay_amp_raw: f32,
108    pv_value_ini: f32,
109    mv_value_ini: f32,
110    num_switches_skip: u32,
111    num_cycles_count: u32,
112    noise_protection_secs: u32,
113
114    // Running state, updated by `step`.
115    mv_value_current: f32,
116    mv_value_next_step: f32,
117    max_pv_cycle: f32,
118    min_pv_cycle: f32,
119    max_pv_result: f32,
120    min_pv_result: f32,
121    hysteresis: f32,
122    mv_sign_next_step: i8,
123    mv_sign_init: i8,
124    time_previous_switch: DateTime<Utc>,
125    counter_all_switches: u32,
126    peaks: Vec<f32>,
127    troughs: Vec<f32>,
128    switch_times: Vec<DateTime<Utc>>,
129    completed: bool,
130}
131
132impl MrftEngine {
133    /// Builds a new engine and performs the one-time setup `MRFTstart` does before the
134    /// relay-switching loop begins: resolves the action multiplier from `direction`, clamps
135    /// the relay amplitude to the MV range, and seeds the peak/trough trackers from the
136    /// initial PV reading.
137    ///
138    /// `start_time` seeds `time_previous_switch` (`TimePreviousSwitch = DateTime.Now` in the
139    /// legacy `MRFTinitializeVariables`) — pass the timestamp of the first [`Tick`] that will
140    /// be given to `step`, or whatever timestamp the caller considers "test start".
141    ///
142    /// `beta` is the hysteresis multiplier for this (process type, controller type)
143    /// combination — see [`crate::constants::lookup`]. It is response-level-invariant, so
144    /// any [`crate::constants::ResponseLevel`] passed to `lookup` yields the same `beta`.
145    pub fn new(
146        config: LoopConfig,
147        direction: ControllerDirection,
148        beta: f32,
149        initial: InitialReadings,
150        start_time: DateTime<Utc>,
151    ) -> MrftEngine {
152        let action_multiplier = direction.action_multiplier();
153
154        let relay_amp_raw = clamp_relay_amplitude(
155            config.relay_amp_percent,
156            initial.mv_ini,
157            initial.mv_range_low,
158            initial.mv_range_high,
159        );
160
161        MrftEngine {
162            beta,
163            action_multiplier,
164            relay_amp_raw,
165            pv_value_ini: initial.pv_ini,
166            mv_value_ini: initial.mv_ini,
167            num_switches_skip: config.num_cycles_skip * 2 + 1,
168            num_cycles_count: config.num_cycles_count,
169            noise_protection_secs: config.noise_protection_secs,
170
171            mv_value_current: initial.mv_ini,
172            mv_value_next_step: initial.mv_ini,
173            max_pv_cycle: initial.pv_ini,
174            min_pv_cycle: initial.pv_ini,
175            max_pv_result: initial.pv_ini,
176            min_pv_result: initial.pv_ini,
177            hysteresis: 0.0,
178            mv_sign_next_step: 1,
179            mv_sign_init: 0,
180            time_previous_switch: start_time,
181            counter_all_switches: 0,
182            peaks: Vec::new(),
183            troughs: Vec::new(),
184            switch_times: Vec::new(),
185            completed: false,
186        }
187    }
188
189    /// Feeds one PV sample through the engine. Returns the actions the caller must perform:
190    /// zero or one [`Action::WriteMv`] (mirrors `MRFTswitchIsNeeded` + `MRFTperformSwitch`),
191    /// followed by [`Action::Complete`] at most once, on the tick that satisfies the
192    /// completion condition.
193    ///
194    /// Calling `step` again after `Complete` has been returned is a no-op that returns an
195    /// empty `Vec` — the engine has nothing left to do.
196    pub fn step(&mut self, tick: Tick) -> Vec<Action> {
197        if self.completed {
198            return Vec::new();
199        }
200
201        let mut actions = Vec::new();
202
203        if self.switch_is_needed(tick) {
204            actions.push(Action::WriteMv(self.perform_switch(tick)));
205        }
206
207        if self.is_complete() {
208            self.completed = true;
209            actions.push(Action::Complete {
210                peaks: self.peaks.clone(),
211                troughs: self.troughs.clone(),
212                switch_times: self.switch_times.clone(),
213                mv_sign_init: self.mv_sign_init,
214            });
215        }
216
217        actions
218    }
219
220    /// A snapshot of the fields a golden-master comparison checks per tick.
221    pub fn state(&self) -> MrftState {
222        MrftState {
223            hysteresis: self.hysteresis,
224            mv_value_current: self.mv_value_current,
225            mv_sign_next_step: self.mv_sign_next_step,
226            counter_all_switches: self.counter_all_switches,
227            cycles_completed: self.cycles_completed(),
228            cycles_remaining: self.cycles_remaining(),
229        }
230    }
231
232    /// Whether the completion condition has been reached. Pure port of `MRFTisComplete`.
233    pub fn is_complete(&self) -> bool {
234        self.counter_all_switches >= self.num_switches_skip + self.num_cycles_count * 2
235    }
236
237    /// Whole relay cycles completed so far. Pure port of `GetCyclesCompleted`. Integer
238    /// division truncates toward zero (matching C#'s `int` division), so this is `0` for
239    /// every tick before the first switch.
240    fn cycles_completed(&self) -> i32 {
241        (self.counter_all_switches as i32 - 1) / 2
242    }
243
244    /// Whole relay cycles remaining. Pure port of `GetCyclesRemaining`. Reaches exactly `0`
245    /// on the tick of the final switch — see `perform_switch`'s snap-back to `mv_value_ini`.
246    fn cycles_remaining(&self) -> i32 {
247        (self.num_switches_skip as i32 + self.num_cycles_count as i32 * 2
248            - self.counter_all_switches as i32
249            + 1)
250            / 2
251    }
252
253    /// Decides whether a switch is needed on this tick, updating peak/trough tracking,
254    /// hysteresis, and the next-step sign/value along the way. Pure port of
255    /// `MRFTswitchIsNeeded` — like the original, this both answers the question and mutates
256    /// state that `perform_switch` depends on; it is not a side-effect-free predicate.
257    fn switch_is_needed(&mut self, tick: Tick) -> bool {
258        let sp_pv_diff = self.pv_value_ini - tick.pv;
259
260        self.max_pv_cycle = self.max_pv_cycle.max(tick.pv);
261        self.min_pv_cycle = self.min_pv_cycle.min(tick.pv);
262        self.max_pv_result = self.max_pv_result.max(tick.pv);
263        self.min_pv_result = self.min_pv_result.min(tick.pv);
264
265        self.hysteresis = self.beta
266            * (self.max_pv_cycle - self.pv_value_ini).max(self.pv_value_ini - self.min_pv_cycle);
267
268        let mv_sign_previous: i8 = if self.mv_value_current >= self.mv_value_ini {
269            1
270        } else {
271            -1
272        };
273
274        let valve_switch: f32;
275        if self.action_multiplier == 1 {
276            valve_switch = sp_pv_diff + mv_sign_previous as f32 * self.hysteresis;
277            self.mv_sign_next_step = if valve_switch >= 0.0 { 1 } else { -1 };
278        } else {
279            valve_switch = sp_pv_diff - mv_sign_previous as f32 * self.hysteresis;
280            self.mv_sign_next_step = if valve_switch <= 0.0 { 1 } else { -1 };
281        }
282
283        // Freezes at whatever mv_sign_next_step evaluates to on the tick of the very first
284        // switch: this branch runs every tick while counter_all_switches is still 0, so it
285        // keeps being overwritten right up until perform_switch increments the counter past
286        // 0 on the tick the first switch actually happens.
287        if self.counter_all_switches == 0 {
288            self.mv_sign_init = self.mv_sign_next_step;
289        }
290
291        self.mv_value_next_step =
292            self.mv_value_ini + self.mv_sign_next_step as f32 * self.relay_amp_raw;
293
294        // Compared as f64, matching the legacy `Convert.ToDouble` widening before the
295        // subtraction, to keep rounding behavior identical for values near the threshold.
296        let mv_switch_required =
297            (self.mv_value_next_step as f64 - self.mv_value_current as f64).abs() >= 0.01;
298
299        let enable_mv_switch = self.time_previous_switch
300            + Duration::seconds(self.noise_protection_secs as i64)
301            <= tick.time
302            || self.counter_all_switches == 0;
303
304        mv_switch_required && enable_mv_switch
305    }
306
307    /// Performs a switch: advances the switch counters, records a peak or trough once the
308    /// skip period has elapsed, and computes the new MV (snapping back to `mv_value_ini`
309    /// instead of taking a full relay step on the final switch). Returns the new MV value to
310    /// write.
311    ///
312    /// Reuses `tick.time`, the same timestamp `switch_is_needed` just reasoned about,
313    /// rather than reading a fresh clock value during the switch.
314    fn perform_switch(&mut self, tick: Tick) -> f32 {
315        self.time_previous_switch = tick.time;
316
317        self.counter_all_switches += 1;
318        if self.counter_all_switches >= self.num_switches_skip {
319            self.switch_times.push(tick.time);
320
321            if self.mv_sign_next_step as i32 * self.action_multiplier as i32 == 1 {
322                self.peaks.push(self.max_pv_result);
323            } else {
324                self.troughs.push(self.min_pv_result);
325            }
326        }
327
328        self.max_pv_cycle = self.pv_value_ini;
329        self.min_pv_cycle = self.pv_value_ini;
330        self.max_pv_result = tick.pv;
331        self.min_pv_result = tick.pv;
332
333        self.mv_value_current = if self.cycles_remaining() == 0 {
334            self.mv_value_ini
335        } else {
336            self.mv_value_next_step
337        };
338
339        self.mv_value_current
340    }
341}
342
343#[cfg(test)]
344mod tests;