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;