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. See `AGENTS.md`'s "Key architectural decisions" for why
3//! this must never read a clock, perform network I/O, or touch a 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/// Legacy-bug replication flags, for bug-for-bug replay validation against captured legacy
58/// traces (see `core-bug-register`). Every field defaults to `false`: the fixed, correct
59/// behavior. Set a field `true` only to intentionally reproduce that specific legacy defect,
60/// e.g. when asserting parity against a captured trace that has the bug baked in.
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
62pub struct MrftCompat {
63    /// Replicates `CheckMVboundaries`'s lower-clamp bug: clamps to `mv_range_low + mv_ini`
64    /// instead of the dimensionally-correct `mv_ini - mv_range_low`. Silently masked whenever
65    /// `mv_range_low == 0` (the common 0-100% case); only visibly wrong for cascaded loops
66    /// with a nonzero MV range floor.
67    pub replicate_lower_clamp_bug: bool,
68    /// Replicates the legacy extrema reset: after a switch, result measurement starts from
69    /// `pv_value_ini` instead of the switch sample. This can discard the shared switch endpoint
70    /// from the next half-cycle and produce a zero or biased PV amplitude when sampling is sparse.
71    pub replicate_extrema_reset_bug: bool,
72}
73
74/// Computes the raw engineering-unit relay amplitude from a percentage of the MV range, and
75/// clamps it so neither relay step would drive the MV outside `[mv_range_low,
76/// mv_range_high]`. Pure port of `CheckMVboundaries`.
77pub fn clamp_relay_amplitude(
78    relay_amp_percent: f32,
79    mv_ini: f32,
80    mv_range_low: f32,
81    mv_range_high: f32,
82    compat: MrftCompat,
83) -> f32 {
84    let mut relay_amp_raw = relay_amp_percent / 100.0 * (mv_range_high - mv_range_low);
85
86    if mv_ini + relay_amp_raw > mv_range_high {
87        relay_amp_raw = mv_range_high - mv_ini;
88    } else if mv_ini - relay_amp_raw < mv_range_low {
89        relay_amp_raw = if compat.replicate_lower_clamp_bug {
90            mv_range_low + mv_ini
91        } else {
92            mv_ini - mv_range_low
93        };
94    }
95
96    relay_amp_raw
97}
98
99/// A snapshot of the engine's observable state after a `step()` call — the fields a
100/// golden-master trace comparison checks against the legacy CSV log's per-tick columns
101/// (`Hysteresis`, `MvValueCurrent`, `MvSignNextStep`, `CounterAllSwitches`).
102#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
103#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
104pub struct MrftState {
105    pub hysteresis: f32,
106    pub mv_value_current: f32,
107    pub mv_sign_next_step: i8,
108    pub counter_all_switches: u32,
109    /// Whole relay cycles completed so far. Mirrors `GetCyclesCompleted`.
110    pub cycles_completed: i32,
111    /// Whole relay cycles remaining. Mirrors `GetCyclesRemaining`; reaches `0` on the tick of
112    /// the final switch, which is what triggers the snap-back to `mv_ini` instead of a full
113    /// relay step.
114    pub cycles_remaining: i32,
115}
116
117/// The MRFT relay-feedback state machine. Construct with [`MrftEngine::new`], then call
118/// [`MrftEngine::step`] once per PV sample.
119///
120/// Deliberately has no knowledge of `--mrftDelayTime` pre/post padding: the caller decides
121/// when to start calling `step()` at all, rather than the engine accepting an internal
122/// "delay active" no-op flag. That keeps this type's only job "given a PV sample, decide
123/// whether to switch" — nothing else.
124#[derive(Debug, Clone)]
125pub struct MrftEngine {
126    // Fixed for the life of the engine.
127    beta: f32,
128    action_multiplier: i8,
129    relay_amp_raw: f32,
130    pv_value_ini: f32,
131    mv_value_ini: f32,
132    num_switches_skip: u32,
133    num_cycles_count: u32,
134    noise_protection_secs: u32,
135
136    // Running state, updated by `step`.
137    mv_value_current: f32,
138    mv_value_next_step: f32,
139    max_pv_cycle: f32,
140    min_pv_cycle: f32,
141    max_pv_result: f32,
142    min_pv_result: f32,
143    hysteresis: f32,
144    mv_sign_next_step: i8,
145    mv_sign_init: i8,
146    time_previous_switch: DateTime<Utc>,
147    counter_all_switches: u32,
148    peaks: Vec<f32>,
149    troughs: Vec<f32>,
150    switch_times: Vec<DateTime<Utc>>,
151    completed: bool,
152    compat: MrftCompat,
153}
154
155impl MrftEngine {
156    /// Builds a new engine and performs the one-time setup `MRFTstart` does before the
157    /// relay-switching loop begins: resolves the action multiplier from `direction`, clamps
158    /// the relay amplitude to the MV range, and seeds the peak/trough trackers from the
159    /// initial PV reading.
160    ///
161    /// `start_time` seeds `time_previous_switch` (`TimePreviousSwitch = DateTime.Now` in the
162    /// legacy `MRFTinitializeVariables`) — pass the timestamp of the first [`Tick`] that will
163    /// be given to `step`, or whatever timestamp the caller considers "test start".
164    ///
165    /// `beta` is the hysteresis multiplier for this (process type, controller type)
166    /// combination — see [`crate::constants::lookup`]. It is response-level-invariant, so
167    /// any [`crate::constants::ResponseLevel`] passed to `lookup` yields the same `beta`.
168    pub fn new(
169        config: LoopConfig,
170        direction: ControllerDirection,
171        beta: f32,
172        initial: InitialReadings,
173        start_time: DateTime<Utc>,
174        compat: MrftCompat,
175    ) -> MrftEngine {
176        let action_multiplier = direction.action_multiplier();
177
178        let relay_amp_raw = clamp_relay_amplitude(
179            config.relay_amp_percent,
180            initial.mv_ini,
181            initial.mv_range_low,
182            initial.mv_range_high,
183            compat,
184        );
185
186        MrftEngine {
187            beta,
188            action_multiplier,
189            relay_amp_raw,
190            pv_value_ini: initial.pv_ini,
191            mv_value_ini: initial.mv_ini,
192            num_switches_skip: config.num_cycles_skip * 2 + 1,
193            num_cycles_count: config.num_cycles_count,
194            noise_protection_secs: config.noise_protection_secs,
195
196            mv_value_current: initial.mv_ini,
197            mv_value_next_step: initial.mv_ini,
198            max_pv_cycle: initial.pv_ini,
199            min_pv_cycle: initial.pv_ini,
200            max_pv_result: initial.pv_ini,
201            min_pv_result: initial.pv_ini,
202            hysteresis: 0.0,
203            mv_sign_next_step: 1,
204            mv_sign_init: 0,
205            time_previous_switch: start_time,
206            counter_all_switches: 0,
207            peaks: Vec::new(),
208            troughs: Vec::new(),
209            switch_times: Vec::new(),
210            completed: false,
211            compat,
212        }
213    }
214
215    /// Feeds one PV sample through the engine. Returns the actions the caller must perform:
216    /// zero or one [`Action::WriteMv`] (mirrors `MRFTswitchIsNeeded` + `MRFTperformSwitch`),
217    /// followed by [`Action::Complete`] at most once, on the tick that satisfies the
218    /// completion condition.
219    ///
220    /// Calling `step` again after `Complete` has been returned is a no-op that returns an
221    /// empty `Vec` — the engine has nothing left to do.
222    pub fn step(&mut self, tick: Tick) -> Vec<Action> {
223        if self.completed {
224            return Vec::new();
225        }
226
227        let mut actions = Vec::new();
228
229        if self.switch_is_needed(tick) {
230            actions.push(Action::WriteMv(self.perform_switch(tick)));
231        }
232
233        if self.is_complete() {
234            self.completed = true;
235            actions.push(Action::Complete {
236                peaks: self.peaks.clone(),
237                troughs: self.troughs.clone(),
238                switch_times: self.switch_times.clone(),
239                mv_sign_init: self.mv_sign_init,
240            });
241        }
242
243        actions
244    }
245
246    /// A snapshot of the fields a golden-master comparison checks per tick.
247    pub fn state(&self) -> MrftState {
248        MrftState {
249            hysteresis: self.hysteresis,
250            mv_value_current: self.mv_value_current,
251            mv_sign_next_step: self.mv_sign_next_step,
252            counter_all_switches: self.counter_all_switches,
253            cycles_completed: self.cycles_completed(),
254            cycles_remaining: self.cycles_remaining(),
255        }
256    }
257
258    /// Whether the completion condition has been reached. Pure port of `MRFTisComplete`.
259    pub fn is_complete(&self) -> bool {
260        self.counter_all_switches >= self.num_switches_skip + self.num_cycles_count * 2
261    }
262
263    /// Whole relay cycles completed so far. Pure port of `GetCyclesCompleted`. Integer
264    /// division truncates toward zero (matching C#'s `int` division), so this is `0` for
265    /// every tick before the first switch.
266    fn cycles_completed(&self) -> i32 {
267        (self.counter_all_switches as i32 - 1) / 2
268    }
269
270    /// Whole relay cycles remaining. Pure port of `GetCyclesRemaining`. Reaches exactly `0`
271    /// on the tick of the final switch — see `perform_switch`'s snap-back to `mv_value_ini`.
272    fn cycles_remaining(&self) -> i32 {
273        (self.num_switches_skip as i32 + self.num_cycles_count as i32 * 2
274            - self.counter_all_switches as i32
275            + 1)
276            / 2
277    }
278
279    /// Decides whether a switch is needed on this tick, updating peak/trough tracking,
280    /// hysteresis, and the next-step sign/value along the way. Pure port of
281    /// `MRFTswitchIsNeeded` — like the original, this both answers the question and mutates
282    /// state that `perform_switch` depends on; it is not a side-effect-free predicate.
283    fn switch_is_needed(&mut self, tick: Tick) -> bool {
284        let sp_pv_diff = self.pv_value_ini - tick.pv;
285
286        self.max_pv_cycle = self.max_pv_cycle.max(tick.pv);
287        self.min_pv_cycle = self.min_pv_cycle.min(tick.pv);
288        self.max_pv_result = self.max_pv_result.max(tick.pv);
289        self.min_pv_result = self.min_pv_result.min(tick.pv);
290
291        self.hysteresis = self.beta
292            * (self.max_pv_cycle - self.pv_value_ini).max(self.pv_value_ini - self.min_pv_cycle);
293
294        let mv_sign_previous: i8 = if self.mv_value_current >= self.mv_value_ini {
295            1
296        } else {
297            -1
298        };
299
300        let valve_switch: f32;
301        if self.action_multiplier == 1 {
302            valve_switch = sp_pv_diff + mv_sign_previous as f32 * self.hysteresis;
303            self.mv_sign_next_step = if valve_switch >= 0.0 { 1 } else { -1 };
304        } else {
305            valve_switch = sp_pv_diff - mv_sign_previous as f32 * self.hysteresis;
306            self.mv_sign_next_step = if valve_switch <= 0.0 { 1 } else { -1 };
307        }
308
309        // Freezes at whatever mv_sign_next_step evaluates to on the tick of the very first
310        // switch: this branch runs every tick while counter_all_switches is still 0, so it
311        // keeps being overwritten right up until perform_switch increments the counter past
312        // 0 on the tick the first switch actually happens.
313        if self.counter_all_switches == 0 {
314            self.mv_sign_init = self.mv_sign_next_step;
315        }
316
317        self.mv_value_next_step =
318            self.mv_value_ini + self.mv_sign_next_step as f32 * self.relay_amp_raw;
319
320        // Compared as f64, matching the legacy `Convert.ToDouble` widening before the
321        // subtraction, to keep rounding behavior identical for values near the threshold.
322        let mv_switch_required =
323            (self.mv_value_next_step as f64 - self.mv_value_current as f64).abs() >= 0.01;
324
325        let enable_mv_switch = self.time_previous_switch
326            + Duration::seconds(self.noise_protection_secs as i64)
327            <= tick.time
328            || self.counter_all_switches == 0;
329
330        mv_switch_required && enable_mv_switch
331    }
332
333    /// Performs a switch: advances the switch counters, records a peak or trough once the
334    /// skip period has elapsed, and computes the new MV (snapping back to `mv_value_ini`
335    /// instead of taking a full relay step on the final switch). Returns the new MV value to
336    /// write.
337    ///
338    /// Pure port of `MRFTperformSwitch`, with the wall-clock re-read bug structurally
339    /// impossible here: this reuses `tick.time`, the same timestamp `switch_is_needed` just
340    /// reasoned about, rather than reading a fresh clock value.
341    fn perform_switch(&mut self, tick: Tick) -> f32 {
342        self.time_previous_switch = tick.time;
343
344        self.counter_all_switches += 1;
345        if self.counter_all_switches >= self.num_switches_skip {
346            self.switch_times.push(tick.time);
347
348            if self.mv_sign_next_step as i32 * self.action_multiplier as i32 == 1 {
349                self.peaks.push(self.max_pv_result);
350            } else {
351                self.troughs.push(self.min_pv_result);
352            }
353        }
354
355        self.max_pv_cycle = self.pv_value_ini;
356        self.min_pv_cycle = self.pv_value_ini;
357        if self.compat.replicate_extrema_reset_bug {
358            self.max_pv_result = self.pv_value_ini;
359            self.min_pv_result = self.pv_value_ini;
360        } else {
361            self.max_pv_result = tick.pv;
362            self.min_pv_result = tick.pv;
363        }
364
365        self.mv_value_current = if self.cycles_remaining() == 0 {
366            self.mv_value_ini
367        } else {
368            self.mv_value_next_step
369        };
370
371        self.mv_value_current
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::{
379        constants::{ResponseLevel, lookup},
380        controller_type::ControllerType,
381        process_type::ProcessType,
382    };
383
384    fn t(secs: i64) -> DateTime<Utc> {
385        DateTime::UNIX_EPOCH + Duration::seconds(secs)
386    }
387
388    /// `relay_amp_percent=10` against a 0-100 MV range gives `relay_amp_raw=10`, unclamped.
389    /// `noise_protection_secs` and cycle skip/count are overridden per test via `LoopConfig`
390    /// spread syntax where a scenario needs something other than the `skip=0, count=1`
391    /// default (which completes after exactly 3 total switches).
392    fn config() -> LoopConfig {
393        LoopConfig {
394            process_type: ProcessType::Flow,
395            controller_type: ControllerType::Pi,
396            relay_amp_percent: 10.0,
397            num_cycles_skip: 0,
398            num_cycles_count: 1,
399            noise_protection_secs: 0,
400            mrft_delay_secs: 0,
401        }
402    }
403
404    fn initial() -> InitialReadings {
405        InitialReadings {
406            pv_ini: 50.0,
407            mv_ini: 50.0,
408            mv_range_low: 0.0,
409            mv_range_high: 100.0,
410        }
411    }
412
413    fn t_ms(offset_ms: i64) -> DateTime<Utc> {
414        DateTime::UNIX_EPOCH + Duration::milliseconds(offset_ms)
415    }
416
417    mod clamp_relay_amplitude_tests {
418        use super::*;
419
420        #[test]
421        fn no_clamp_needed_when_within_range() {
422            let amp = clamp_relay_amplitude(10.0, 50.0, 0.0, 100.0, MrftCompat::default());
423            assert_eq!(amp, 10.0); // 10% of (100-0)
424        }
425
426        #[test]
427        fn unclamped_amplitude_uses_the_mv_span() {
428            let amp = clamp_relay_amplitude(10.0, 50.0, 20.0, 100.0, MrftCompat::default());
429            assert_eq!(amp, 8.0); // 10% of (100-20)
430        }
431
432        #[test]
433        fn upper_clamp_engages_near_ceiling() {
434            let amp = clamp_relay_amplitude(10.0, 95.0, 0.0, 100.0, MrftCompat::default());
435            assert_eq!(amp, 5.0); // mv_range_high - mv_ini
436        }
437
438        #[test]
439        fn lower_clamp_uses_dimensionally_correct_formula_by_default() {
440            // mv_ini=10, mv_range_low=5: naive 10% of (100-5) = 9.5 would drive MV to 0.5,
441            // below the floor of 5, so it must clamp to mv_ini - mv_range_low = 5.
442            let amp = clamp_relay_amplitude(10.0, 10.0, 5.0, 100.0, MrftCompat::default());
443            assert_eq!(amp, 5.0);
444        }
445
446        #[test]
447        fn lower_clamp_replicates_legacy_bug_when_compat_flag_set() {
448            let compat = MrftCompat {
449                replicate_lower_clamp_bug: true,
450                ..MrftCompat::default()
451            };
452            let amp = clamp_relay_amplitude(10.0, 10.0, 5.0, 100.0, compat);
453            assert_eq!(amp, 15.0); // legacy: mv_range_low + mv_ini
454        }
455
456        #[test]
457        fn fixed_and_buggy_formulas_agree_when_mv_range_low_is_zero() {
458            // This is exactly why the legacy bug went unnoticed: with the common 0-100%
459            // range, `mv_ini - 0` and `0 + mv_ini` are the same value.
460            let fixed = clamp_relay_amplitude(50.0, 10.0, 0.0, 100.0, MrftCompat::default());
461            let buggy = clamp_relay_amplitude(
462                50.0,
463                10.0,
464                0.0,
465                100.0,
466                MrftCompat {
467                    replicate_lower_clamp_bug: true,
468                    ..MrftCompat::default()
469                },
470            );
471            assert_eq!(fixed, buggy);
472        }
473
474        #[test]
475        fn cascade_case_with_nonzero_floor() {
476            // mv_ini=10, mv_msl=5, mv_msh=100: naive 10% of (100-5)=9.5 drives MV to 0.5,
477            // below the floor, so the fixed clamp gives mv_ini - mv_range_low = 5.
478            let amp = clamp_relay_amplitude(10.0, 10.0, 5.0, 100.0, MrftCompat::default());
479            assert_eq!(amp, 5.0);
480        }
481
482        #[test]
483        fn lower_clamp_boundary_uses_subtraction() {
484            let relay_amp_percent = 100.0 * 1.1 / 91.0;
485            let amp =
486                clamp_relay_amplitude(relay_amp_percent, 10.0, 9.0, 100.0, MrftCompat::default());
487            assert_eq!(amp, 1.0);
488        }
489    }
490
491    #[test]
492    fn action_multiplier_is_negative_one_for_direct() {
493        let engine = MrftEngine::new(
494            config(),
495            ControllerDirection::Direct,
496            0.0,
497            initial(),
498            t(0),
499            MrftCompat::default(),
500        );
501        assert_eq!(engine.action_multiplier, -1);
502    }
503
504    #[test]
505    fn action_multiplier_is_positive_one_for_reverse() {
506        let engine = MrftEngine::new(
507            config(),
508            ControllerDirection::Reverse,
509            0.0,
510            initial(),
511            t(0),
512            MrftCompat::default(),
513        );
514        assert_eq!(engine.action_multiplier, 1);
515    }
516
517    /// Full 3-switch run to completion, beta=0.3 (a realistic hysteresis multiplier),
518    /// Reverse action. Expected values cross-checked against an independent Python
519    /// transcription of the same C# formulas (see the `core-mrft` task notes) rather than
520    /// hand-derived, since MRFT's peak/trough/hysteresis interaction is easy to get subtly
521    /// wrong by inspection alone.
522    #[test]
523    fn full_run_reverse_action_completes_with_expected_peaks_troughs_and_snap_back() {
524        let mut engine = MrftEngine::new(
525            config(),
526            ControllerDirection::Reverse,
527            0.3,
528            initial(),
529            t(0),
530            MrftCompat::default(),
531        );
532
533        let actions = engine.step(Tick {
534            time: t(1),
535            pv: 55.0,
536        });
537        assert_eq!(actions, vec![Action::WriteMv(40.0)]);
538        assert_eq!(
539            engine.state(),
540            MrftState {
541                hysteresis: 1.5,
542                mv_value_current: 40.0,
543                mv_sign_next_step: -1,
544                counter_all_switches: 1,
545                cycles_completed: 0,
546                cycles_remaining: 1,
547            }
548        );
549
550        let actions = engine.step(Tick {
551            time: t(2),
552            pv: 45.0,
553        });
554        assert_eq!(actions, vec![Action::WriteMv(60.0)]);
555        assert_eq!(
556            engine.state(),
557            MrftState {
558                hysteresis: 1.5,
559                mv_value_current: 60.0,
560                mv_sign_next_step: 1,
561                counter_all_switches: 2,
562                cycles_completed: 0,
563                cycles_remaining: 1,
564            }
565        );
566
567        // Final switch: snaps MV back to mv_value_ini (50) instead of taking a full relay
568        // step, and emits Complete in the same tick.
569        let actions = engine.step(Tick {
570            time: t(3),
571            pv: 55.0,
572        });
573        assert_eq!(
574            actions,
575            vec![
576                Action::WriteMv(50.0),
577                Action::Complete {
578                    peaks: vec![55.0],
579                    troughs: vec![50.0, 45.0],
580                    switch_times: vec![t(1), t(2), t(3)],
581                    mv_sign_init: -1,
582                },
583            ]
584        );
585        assert_eq!(
586            engine.state(),
587            MrftState {
588                hysteresis: 1.5,
589                mv_value_current: 50.0,
590                mv_sign_next_step: -1,
591                counter_all_switches: 3,
592                cycles_completed: 1,
593                cycles_remaining: 0,
594            }
595        );
596        assert!(engine.is_complete());
597    }
598
599    #[test]
600    fn extrema_compatibility_flag_reproduces_the_legacy_reset() {
601        let mut engine = MrftEngine::new(
602            config(),
603            ControllerDirection::Reverse,
604            0.3,
605            initial(),
606            t(0),
607            MrftCompat {
608                replicate_extrema_reset_bug: true,
609                ..MrftCompat::default()
610            },
611        );
612
613        let mut last_actions = Vec::new();
614        for (i, pv) in [55.0, 45.0, 55.0].into_iter().enumerate() {
615            last_actions = engine.step(Tick {
616                time: t(i as i64 + 1),
617                pv,
618            });
619        }
620
621        assert_eq!(
622            last_actions,
623            vec![
624                Action::WriteMv(50.0),
625                Action::Complete {
626                    peaks: vec![50.0],
627                    troughs: vec![50.0, 50.0],
628                    switch_times: vec![t(1), t(2), t(3)],
629                    mv_sign_init: -1,
630                },
631            ]
632        );
633    }
634
635    #[test]
636    fn direct_action_mirrors_reverse_with_peaks_and_troughs_swapped() {
637        let mut engine = MrftEngine::new(
638            config(),
639            ControllerDirection::Direct,
640            0.0,
641            initial(),
642            t(0),
643            MrftCompat::default(),
644        );
645
646        let mut last_actions = Vec::new();
647        for (i, pv) in [40.0, 60.0, 40.0].into_iter().enumerate() {
648            last_actions = engine.step(Tick {
649                time: t(i as i64 + 1),
650                pv,
651            });
652        }
653
654        assert_eq!(
655            last_actions,
656            vec![
657                Action::WriteMv(50.0),
658                Action::Complete {
659                    // Swapped vs. the Reverse scenario: Direct flips which sign counts as
660                    // a peak vs. a trough (`MvSignNextStep * ActionMultiplier`).
661                    peaks: vec![50.0, 60.0],
662                    troughs: vec![40.0],
663                    switch_times: vec![t(1), t(2), t(3)],
664                    mv_sign_init: -1,
665                },
666            ]
667        );
668    }
669
670    #[test]
671    fn run_7_like_trace_includes_switch_endpoints_in_recorded_extrema() {
672        let config = LoopConfig {
673            process_type: ProcessType::Flow,
674            controller_type: ControllerType::Pi,
675            relay_amp_percent: 10.0,
676            num_cycles_skip: 1,
677            num_cycles_count: 2,
678            noise_protection_secs: 3,
679            mrft_delay_secs: 0,
680        };
681        let initial = InitialReadings {
682            pv_ini: 2.25,
683            mv_ini: 50.0,
684            mv_range_low: 12.0,
685            mv_range_high: 100.0,
686        };
687        let beta = lookup(
688            ProcessType::Flow,
689            ControllerType::Pi,
690            ResponseLevel::Aggressive,
691        )
692        .beta;
693        let samples = [
694            (0, 2.25),
695            (2001, 2.49),
696            (2999, 2.67),
697            (3999, 2.72),
698            (5999, 2.25),
699            (6999, 1.89),
700            (8999, 2.26),
701            (10001, 2.61),
702            (12002, 2.24),
703            (12999, 1.89),
704            (13999, 1.80),
705            (15999, 2.25),
706            (17999, 2.61),
707            (19000, 2.24),
708            (20000, 1.89),
709            (21000, 1.80),
710        ];
711        let mut engine = MrftEngine::new(
712            config,
713            ControllerDirection::Reverse,
714            beta,
715            initial,
716            t_ms(0),
717            MrftCompat::default(),
718        );
719
720        let completion = samples.iter().find_map(|(offset_ms, pv)| {
721            engine
722                .step(Tick {
723                    time: t_ms(*offset_ms),
724                    pv: *pv,
725                })
726                .into_iter()
727                .find_map(|action| match action {
728                    Action::Complete {
729                        peaks,
730                        troughs,
731                        switch_times,
732                        mv_sign_init,
733                    } => Some((peaks, troughs, switch_times, mv_sign_init)),
734                    Action::WriteMv(_) => None,
735                })
736        });
737
738        let (peaks, troughs, switch_times, mv_sign_init) =
739            completion.expect("run 7-like trace must complete");
740        assert_eq!(mv_sign_init, 1);
741        assert_eq!(switch_times.len(), 5);
742        assert_eq!(peaks, vec![2.72, 2.61, 2.61]);
743        assert_eq!(troughs, vec![1.89, 1.80]);
744    }
745
746    #[test]
747    fn run_10_like_trace_keeps_the_final_switch_pv_as_a_trough() {
748        let config = LoopConfig {
749            process_type: ProcessType::PressureLine,
750            controller_type: ControllerType::Pi,
751            relay_amp_percent: 10.0,
752            num_cycles_skip: 1,
753            num_cycles_count: 2,
754            noise_protection_secs: 3,
755            mrft_delay_secs: 0,
756        };
757        let initial = InitialReadings {
758            pv_ini: 189.94,
759            mv_ini: 50.0,
760            mv_range_low: 0.0,
761            mv_range_high: 100.0,
762        };
763        let beta = lookup(
764            ProcessType::PressureLine,
765            ControllerType::Pi,
766            ResponseLevel::Aggressive,
767        )
768        .beta;
769        let samples = [
770            (0, 189.96),
771            (2001, 189.97),
772            (3003, 181.07),
773            (5989, 174.54),
774            (6989, 187.07),
775            (8989, 204.0),
776            (10990, 204.43),
777            (11988, 192.14),
778            (12990, 182.73),
779            (15990, 175.28),
780            (17989, 197.09),
781            (20003, 200.38),
782            (20988, 189.04),
783            (22991, 173.70),
784            (24991, 174.22),
785            (25990, 186.82),
786            (27989, 203.86),
787        ];
788        let mut engine = MrftEngine::new(
789            config,
790            ControllerDirection::Reverse,
791            beta,
792            initial,
793            t_ms(0),
794            MrftCompat::default(),
795        );
796
797        let completion = samples.iter().find_map(|(offset_ms, pv)| {
798            engine
799                .step(Tick {
800                    time: t_ms(*offset_ms),
801                    pv: *pv,
802                })
803                .into_iter()
804                .find_map(|action| match action {
805                    Action::Complete {
806                        peaks,
807                        troughs,
808                        switch_times,
809                        mv_sign_init,
810                    } => Some((peaks, troughs, switch_times, mv_sign_init)),
811                    Action::WriteMv(_) => None,
812                })
813        });
814
815        let (peaks, troughs, switch_times, mv_sign_init) =
816            completion.expect("run 10-like trace must complete");
817        assert_eq!(mv_sign_init, -1);
818        assert_eq!(switch_times.len(), 5);
819        assert_eq!(peaks, vec![204.43, 200.38]);
820        assert_eq!(troughs, vec![174.54, 175.28, 173.70]);
821    }
822
823    #[test]
824    fn direct_action_hysteresis_uses_previous_sign_and_subtracts() {
825        let mut engine = MrftEngine::new(
826            config(),
827            ControllerDirection::Direct,
828            0.3,
829            initial(),
830            t(0),
831            MrftCompat::default(),
832        );
833
834        assert_eq!(
835            engine.step(Tick {
836                time: t(1),
837                pv: 40.0,
838            }),
839            vec![Action::WriteMv(40.0)]
840        );
841        assert_eq!(engine.mv_sign_init, -1);
842
843        // Keep the previous sign at -1 while building a nonzero hysteresis from a
844        // second sample after the switch.
845        assert!(
846            engine
847                .step(Tick {
848                    time: t(2),
849                    pv: 40.0,
850                })
851                .is_empty()
852        );
853
854        // The previous MV is still below the initial MV, so its sign is -1. With
855        // the subtraction and multiplication intact, the next relay target
856        // remains 40 and no switch is needed.
857        assert!(
858            engine
859                .step(Tick {
860                    time: t(3),
861                    pv: 49.0,
862                })
863                .is_empty()
864        );
865    }
866
867    #[test]
868    fn direct_hysteresis_switch_sign_uses_multiplication() {
869        let mut engine = MrftEngine::new(
870            LoopConfig {
871                noise_protection_secs: 100,
872                ..config()
873            },
874            ControllerDirection::Direct,
875            0.3,
876            initial(),
877            t(0),
878            MrftCompat::default(),
879        );
880
881        assert_eq!(
882            engine.step(Tick {
883                time: t(1),
884                pv: 40.0,
885            }),
886            vec![Action::WriteMv(40.0)]
887        );
888        assert!(
889            engine
890                .step(Tick {
891                    time: t(2),
892                    pv: 60.0,
893                })
894                .is_empty()
895        );
896        assert!(
897            engine
898                .step(Tick {
899                    time: t(3),
900                    pv: 53.0,
901                })
902                .is_empty()
903        );
904
905        assert_eq!(engine.hysteresis, 3.0);
906        assert_eq!(engine.mv_sign_next_step, 1);
907        assert_eq!(engine.mv_value_next_step, 60.0);
908    }
909
910    /// With `num_cycles_skip=1`, the first `NumSwitchesSkip = 1*2+1 = 3` switches must not
911    /// be recorded as peaks/troughs — only the switches after the skip period count toward
912    /// the returned arrays, even though `cycles_completed`/`cycles_remaining` (a total
913    /// skip+test progress indicator) advance from switch 1 onward.
914    #[test]
915    fn skip_cycles_are_excluded_from_recorded_peaks_and_troughs() {
916        let config = LoopConfig {
917            num_cycles_skip: 1,
918            num_cycles_count: 1,
919            ..config()
920        };
921        let mut engine = MrftEngine::new(
922            config,
923            ControllerDirection::Reverse,
924            0.0,
925            initial(),
926            t(0),
927            MrftCompat::default(),
928        );
929
930        let mut last_actions = Vec::new();
931        for (i, pv) in [60.0, 40.0, 60.0, 40.0, 60.0].into_iter().enumerate() {
932            last_actions = engine.step(Tick {
933                time: t(i as i64 + 1),
934                pv,
935            });
936        }
937
938        assert_eq!(
939            last_actions,
940            vec![
941                Action::WriteMv(50.0),
942                Action::Complete {
943                    peaks: vec![60.0],
944                    troughs: vec![40.0, 40.0],
945                    switch_times: vec![t(3), t(4), t(5)],
946                    mv_sign_init: -1,
947                },
948            ]
949        );
950        assert_eq!(engine.state().counter_all_switches, 5);
951    }
952
953    /// A switch that becomes due too soon after the previous one must be suppressed until
954    /// `noise_protection_secs` has elapsed, then fire on the tick it finally allows it
955    /// (inclusive of the exact boundary).
956    #[test]
957    fn noise_protection_suppresses_and_then_allows_a_switch() {
958        let config = LoopConfig {
959            noise_protection_secs: 5,
960            ..config()
961        };
962        let mut engine = MrftEngine::new(
963            config,
964            ControllerDirection::Reverse,
965            0.0,
966            initial(),
967            t(0),
968            MrftCompat::default(),
969        );
970
971        let actions = engine.step(Tick {
972            time: t(1),
973            pv: 60.0,
974        });
975        assert_eq!(actions, vec![Action::WriteMv(40.0)]);
976
977        // Only 1s after the switch (needs 5s): must be suppressed even though a switch
978        // would otherwise be required (mv_sign_next_step flips to 1 here).
979        let actions = engine.step(Tick {
980            time: t(2),
981            pv: 40.0,
982        });
983        assert!(actions.is_empty());
984        assert_eq!(engine.state().mv_sign_next_step, 1);
985        assert_eq!(engine.state().counter_all_switches, 1);
986
987        // Exactly 5s after the switch (the inclusive boundary): now allowed.
988        let actions = engine.step(Tick {
989            time: t(6),
990            pv: 40.0,
991        });
992        assert_eq!(actions, vec![Action::WriteMv(60.0)]);
993        assert_eq!(engine.state().counter_all_switches, 2);
994    }
995
996    #[test]
997    fn step_after_completion_is_a_no_op() {
998        let mut engine = MrftEngine::new(
999            config(),
1000            ControllerDirection::Reverse,
1001            0.3,
1002            initial(),
1003            t(0),
1004            MrftCompat::default(),
1005        );
1006
1007        for (i, pv) in [55.0, 45.0, 55.0].into_iter().enumerate() {
1008            engine.step(Tick {
1009                time: t(i as i64 + 1),
1010                pv,
1011            });
1012        }
1013        assert!(engine.is_complete());
1014
1015        let actions = engine.step(Tick {
1016            time: t(100),
1017            pv: 0.0,
1018        });
1019        assert!(actions.is_empty());
1020    }
1021
1022    #[test]
1023    fn tick_serde_round_trip() {
1024        let tick = Tick {
1025            time: t(42),
1026            pv: 12.5,
1027        };
1028        let json = serde_json::to_string(&tick).unwrap();
1029        let back: Tick = serde_json::from_str(&json).unwrap();
1030        assert_eq!(tick, back);
1031    }
1032
1033    #[test]
1034    fn action_serde_round_trip() {
1035        for action in [
1036            Action::WriteMv(12.5),
1037            Action::Complete {
1038                peaks: vec![1.0, 2.0],
1039                troughs: vec![3.0],
1040                switch_times: vec![t(1), t(2)],
1041                mv_sign_init: 1,
1042            },
1043        ] {
1044            let json = serde_json::to_string(&action).unwrap();
1045            let back: Action = serde_json::from_str(&json).unwrap();
1046            assert_eq!(action, back);
1047        }
1048    }
1049}