Skip to main content

bhtune_driver/
simulator.rs

1//! `SimulatorDriver`: an in-process FOPDT (first-order-plus-dead-time) process model,
2//! served through the [`Driver`] trait, for fully automated E2E tests (no Windows, no
3//! Kepware, no external process) and demo mode.
4//!
5//! Ported from `Model/ProcessModelOPC.py`, the process model used by the legacy application's
6//! `OPCClass.Python` test path. This module splits into three independent pieces:
7//!
8//! - [`FopdtProcess`]: the process itself -- pure state advanced one tick at a time, with no
9//!   `Driver`/async awareness at all.
10//! - [`VirtualPid`]: a standalone position-form PID controller, for *closed-loop* validation
11//!   (e.g. "do the constants a completed MRFT run just calculated actually control this
12//!   process well?") and demos. Not wired into [`SimulatorDriver`] -- the open-loop MRFT
13//!   relay test drives the MV itself, so nothing here needs to close the loop automatically.
14//! - [`SimulatorDriver`]: the thin [`Driver`] shell wrapping one [`FopdtProcess`].
15
16use std::{collections::VecDeque, sync::Mutex};
17
18use async_trait::async_trait;
19use rand::{RngExt, SeedableRng, rngs::StdRng};
20
21use crate::{
22    driver::Driver,
23    error::{DriverError, DriverResult, poisoned_lock},
24    types::{
25        BrowsePage, BrowsePageRequest, DriverCapabilities, Quality, SearchEvent, SearchRequest,
26        TagId, TagValue, TagWrite, WriteOutcome,
27    },
28};
29
30/// Configuration for a [`FopdtProcess`]: the classic three parameters process control
31/// literature uses to characterize a first-order-plus-dead-time (FOPDT) process, plus the
32/// tick cadence and measurement noise needed to turn the continuous model into a discrete
33/// one a [`Driver`] can serve one sample at a time.
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct FopdtConfig {
36    /// Process gain (`Kp`): steady-state change in PV per unit change in MV.
37    pub gain: f32,
38    /// Time constant (`tau`), in seconds: how quickly PV approaches its new steady state
39    /// after an MV change, absent dead time. `0.0` means an instantaneous response (PV
40    /// reaches its new steady state within a single tick).
41    pub time_constant_s: f32,
42    /// Dead time (`theta`), in seconds: how long PV takes to begin responding to an MV
43    /// change at all. Modeled as a whole number of ticks
44    /// (`ceil(dead_time_s / tick_interval_s)`), matching `Model/ProcessModelOPC.py`'s own
45    /// `ndelay` calculation. `0.0` disables the delay line entirely.
46    pub dead_time_s: f32,
47    /// Simulated seconds advanced per [`FopdtProcess::step`] call. Deliberately unrelated to
48    /// real wall-clock time -- a caller can call `step` (via [`SimulatorDriver::read`]) as
49    /// fast as the CPU allows, and the simulated clock still advances at this rate, which is
50    /// what lets an E2E test run an entire tuning cycle in milliseconds instead of the
51    /// minutes a real 800ms-tick MRFT test takes.
52    pub tick_interval_s: f32,
53    /// Amplitude of uniform noise (`+/- noise_amplitude`, in PV engineering units) added to
54    /// each computed sample, mirroring `ProcessModelOPC.py`'s `noise_amp`. `0.0` (the
55    /// default from [`FopdtConfig::new`]) disables noise entirely and skips drawing from
56    /// the RNG altogether, keeping every sample exactly reproducible run to run.
57    pub noise_amplitude: f32,
58}
59
60impl FopdtConfig {
61    /// A process configuration with no measurement noise. Chain
62    /// [`FopdtConfig::with_noise_amplitude`] to add some, or set the field directly.
63    pub fn new(
64        gain: f32,
65        time_constant_s: f32,
66        dead_time_s: f32,
67        tick_interval_s: f32,
68    ) -> FopdtConfig {
69        FopdtConfig {
70            gain,
71            time_constant_s,
72            dead_time_s,
73            tick_interval_s,
74            noise_amplitude: 0.0,
75        }
76    }
77
78    /// Returns `self` with `noise_amplitude` set, for a fluent construction style.
79    pub fn with_noise_amplitude(mut self, noise_amplitude: f32) -> FopdtConfig {
80        self.noise_amplitude = noise_amplitude;
81        self
82    }
83}
84
85/// A first-order-plus-dead-time process model, advanced one tick at a time.
86///
87/// Uses the exact analytical solution for a first-order lag driven by a piecewise-constant
88/// (zero-order-hold) input over each tick --
89/// `pv_new = pv*decay + (1-decay)*(bias + gain*mv_effective)`,
90/// `decay = exp(-tick_interval_s / time_constant_s)` -- rather than numerically integrating
91/// an ODE every tick, as `Model/ProcessModelOPC.py`'s `scipy.integrate.odeint` call does.
92/// This is not an approximation: it is the closed-form solution of
93/// `tau * d(pv)/dt = -(pv - pv0) + gain*(mv - mv0)` for an input held constant over
94/// `[t, t+dt]`, which is exactly what "the controller wrote this MV and it stays there until
95/// the next write" means physically. Cross-checked numerically (in a disposable scratch
96/// script, not part of this repo) against the reference script's own `odeint`-based
97/// integration across a range of gain/tau/dt combinations before relying on it -- the two
98/// agree to within `odeint`'s own numerical tolerance (~1e-5).
99#[derive(Debug)]
100pub struct FopdtProcess {
101    config: FopdtConfig,
102    /// `pv0 - gain*mv0`, fixed at construction from the initial operating point. Folds the
103    /// reference script's separate `Bias`/deviation-variable bookkeeping into one constant:
104    /// expanding the ODE solution above shows any choice of anchor point (`pv0`, `mv0`)
105    /// yields the same absolute-PV trajectory as long as this one value is held fixed, so
106    /// there is nothing else worth tracking separately.
107    bias: f32,
108    /// `exp(-tick_interval_s / time_constant_s)` (or `0.0` for an instant-response
109    /// process), precomputed once since it never changes.
110    decay: f32,
111    pv: f32,
112    /// The most recently recorded MV (via [`FopdtProcess::set_mv`]) -- not yet delayed.
113    current_mv: f32,
114    /// Transport-delay line: holds the last `ceil(dead_time_s / tick_interval_s)` MV values,
115    /// oldest first. Seeded with that many copies of the initial MV so the process starts
116    /// at rest rather than assuming a fictitious pre-history.
117    mv_delay_line: VecDeque<f32>,
118    rng: StdRng,
119}
120
121impl FopdtProcess {
122    /// Builds a process starting at rest: `initial_pv` is assumed to already be the correct
123    /// steady-state PV for `initial_mv` (i.e. nothing changes until a [`FopdtProcess::set_mv`]
124    /// call moves the MV away from `initial_mv`). `seed` makes the noise sequence
125    /// reproducible -- the same config/seed/write sequence always produces the same PV
126    /// sequence (on a given platform and `rand` version; see [`rand::rngs::StdRng`]'s own
127    /// caveat that its algorithm is not a portability guarantee across those).
128    pub fn new(config: FopdtConfig, initial_pv: f32, initial_mv: f32, seed: u64) -> FopdtProcess {
129        let bias = initial_pv - config.gain * initial_mv;
130        let decay = if config.time_constant_s > 0.0 {
131            (-config.tick_interval_s / config.time_constant_s).exp()
132        } else {
133            0.0
134        };
135        let delay_ticks = if config.dead_time_s > 0.0 && config.tick_interval_s > 0.0 {
136            (config.dead_time_s / config.tick_interval_s).ceil() as usize
137        } else {
138            0
139        };
140        FopdtProcess {
141            config,
142            bias,
143            decay,
144            pv: initial_pv,
145            current_mv: initial_mv,
146            mv_delay_line: std::iter::repeat_n(initial_mv, delay_ticks).collect(),
147            rng: StdRng::seed_from_u64(seed),
148        }
149    }
150
151    /// The current PV, as of the last [`FopdtProcess::step`] call (or `initial_pv`, if
152    /// `step` has never been called).
153    pub fn pv(&self) -> f32 {
154        self.pv
155    }
156
157    /// The most recently recorded MV (see [`FopdtProcess::set_mv`]) -- not delayed; this is
158    /// what a real DCS's own MV readback tag would report immediately. Only the PV's
159    /// response to it is delayed.
160    pub fn mv(&self) -> f32 {
161        self.current_mv
162    }
163
164    /// Records a new controller output, effective from the next [`FopdtProcess::step`] call
165    /// onward (after passing through the dead-time delay line, if configured).
166    pub fn set_mv(&mut self, mv: f32) {
167        self.current_mv = mv;
168    }
169
170    /// Advances the process by one `tick_interval_s`, using whichever MV was most recently
171    /// recorded via [`FopdtProcess::set_mv`] -- delayed by `dead_time_s`, if configured -- as
172    /// the input, and returns the resulting PV (including noise, if configured).
173    pub fn step(&mut self) -> f32 {
174        self.mv_delay_line.push_back(self.current_mv);
175        let effective_mv = self.mv_delay_line.pop_front().unwrap_or(self.current_mv);
176
177        self.pv = self.pv * self.decay
178            + (1.0 - self.decay) * (self.bias + self.config.gain * effective_mv);
179
180        if self.config.noise_amplitude > 0.0 {
181            self.pv += self
182                .rng
183                .random_range(-self.config.noise_amplitude..=self.config.noise_amplitude);
184        }
185
186        self.pv
187    }
188}
189
190/// Configuration for a [`VirtualPid`]: a standard textbook position-form PID controller --
191/// proportional and integral on error, derivative on the measurement rather than the error
192/// (to avoid "derivative kick" on a setpoint change) -- with output clamping and
193/// anti-reset-windup.
194#[derive(Debug, Clone, Copy, PartialEq)]
195pub struct VirtualPidConfig {
196    /// Controller gain (`Kc`).
197    pub kc: f32,
198    /// Integral time in seconds (`Ti`). `None` disables integral action entirely (a P-only
199    /// or PD controller) rather than requiring some sentinel "infinite" value.
200    pub ti_s: Option<f32>,
201    /// Derivative time in seconds (`Td`). `None` disables derivative action (a P-only or PI
202    /// controller).
203    pub td_s: Option<f32>,
204    pub output_min: f32,
205    pub output_max: f32,
206    /// The output value corresponding to zero accumulated error -- i.e. the operating point
207    /// the controller starts from (matches `op[0]` in `Model/ProcessModelOPC.py`'s reference
208    /// controller, which biases every computed output by this same constant).
209    pub output_bias: f32,
210}
211
212/// A standalone position-form PID controller, for *closed-loop* validation and demos (e.g.
213/// "do the constants a completed MRFT run just calculated actually control this process
214/// well?"). Deliberately not wired into [`SimulatorDriver`]/[`Driver`] at all: the
215/// `Driver` trait models open-loop tag I/O, and during an actual MRFT relay test the engine
216/// itself (`bhtune_core::mrft::MrftEngine`) drives the MV -- nothing needs to close the loop
217/// automatically for that. This exists for whatever, later, wants to run this process in
218/// automatic mode instead (e.g. simulating a completed tune's results before trusting them
219/// against a real DCS).
220#[derive(Debug, Clone, Copy, PartialEq)]
221pub struct VirtualPid {
222    config: VirtualPidConfig,
223    integral: f32,
224    prev_pv: Option<f32>,
225}
226
227impl VirtualPid {
228    pub fn new(config: VirtualPidConfig) -> VirtualPid {
229        VirtualPid {
230            config,
231            integral: 0.0,
232            prev_pv: None,
233        }
234    }
235
236    /// Computes one control step given `setpoint`/`pv` and the elapsed time `dt` (seconds)
237    /// since the previous call, returning the clamped controller output.
238    ///
239    /// Derivative acts on `pv`, not on the error, so a setpoint change alone never spikes the
240    /// output -- mirrors `Model/ProcessModelOPC.py`'s own
241    /// `D = -Kc*tauD*(pv[i]-pv[i-1])/dt` (mathematically equivalent to derivative-on-error
242    /// only while the setpoint itself is unchanging). The very first call has no previous
243    /// `pv` to compare against, so derivative action is skipped for that call only.
244    pub fn step(&mut self, setpoint: f32, pv: f32, dt: f32) -> f32 {
245        let error = setpoint - pv;
246
247        let integral_gain = self
248            .config
249            .ti_s
250            .filter(|ti| *ti > 0.0)
251            .map(|ti| self.config.kc / ti);
252        let candidate_integral = self.integral + error * dt;
253
254        let derivative_term = match (self.config.td_s, self.prev_pv) {
255            (Some(td), Some(prev_pv)) if td > 0.0 && dt > 0.0 => {
256                -self.config.kc * td * (pv - prev_pv) / dt
257            }
258            _ => 0.0,
259        };
260        self.prev_pv = Some(pv);
261
262        let proportional_term = self.config.kc * error;
263        let integral_term = integral_gain.map_or(0.0, |ki| ki * candidate_integral);
264        let raw_output =
265            self.config.output_bias + proportional_term + integral_term + derivative_term;
266        let clamped_output = raw_output.clamp(self.config.output_min, self.config.output_max);
267
268        // Anti-reset-windup: only commit this tick's integral contribution if doing so
269        // didn't require clamping the output. Otherwise the accumulator would keep growing
270        // while already saturated, delaying recovery once the error eventually reverses --
271        // mirrors the reference script's `ie[i] -= e[i]*delta_t` undo-on-saturation, just
272        // phrased as "don't commit" instead of "commit then undo".
273        if clamped_output == raw_output {
274            self.integral = candidate_integral;
275        }
276
277        clamped_output
278    }
279}
280
281/// The [`Driver`] implementation for CI E2E tests and demo mode: an in-process
282/// [`FopdtProcess`] served through exactly two tags -- a PV tag (reading it advances the
283/// simulated clock by one tick) and an MV tag (reading it reports the last-written value
284/// without advancing anything; writing it records a new controller output). Any other tag
285/// is [`DriverError::InvalidTagValue`]; [`Driver::browse`] is always
286/// [`DriverError::Unsupported`], per that method's own documented convention for drivers
287/// with no real tag tree.
288///
289/// Uses a plain `std::sync::Mutex`, not `tokio::sync::Mutex` like [`crate::OpcDaDriver`]:
290/// every operation here is synchronous, in-memory math with no `.await` point anywhere in
291/// the critical section, so there is nothing that could hold the guard across a suspension
292/// point -- the concern `tokio::sync::Mutex` exists for -- in the first place.
293#[derive(Debug)]
294pub struct SimulatorDriver {
295    pv_tag: TagId,
296    mv_tag: TagId,
297    process: Mutex<FopdtProcess>,
298}
299
300impl SimulatorDriver {
301    /// `pv_tag`/`mv_tag` are the only two tags this driver recognizes -- pick names that
302    /// match whatever tag configuration the rest of a test or demo run uses, so the same
303    /// tag names work whether the caller is pointed at this driver or a real
304    /// [`crate::OpcDaDriver`].
305    pub fn new(
306        pv_tag: impl Into<TagId>,
307        mv_tag: impl Into<TagId>,
308        config: FopdtConfig,
309        initial_pv: f32,
310        initial_mv: f32,
311        seed: u64,
312    ) -> SimulatorDriver {
313        SimulatorDriver {
314            pv_tag: pv_tag.into(),
315            mv_tag: mv_tag.into(),
316            process: Mutex::new(FopdtProcess::new(config, initial_pv, initial_mv, seed)),
317        }
318    }
319}
320
321#[async_trait]
322impl Driver for SimulatorDriver {
323    async fn read(&self, tags: &[TagId]) -> DriverResult<Vec<TagValue>> {
324        let mut process = poisoned_lock(self.process.lock())?;
325        tags.iter()
326            .map(|tag| {
327                let value = if *tag == self.pv_tag {
328                    process.step()
329                } else if *tag == self.mv_tag {
330                    process.mv()
331                } else {
332                    return Err(DriverError::InvalidTagValue {
333                        tag: tag.clone(),
334                        message: "SimulatorDriver only knows its configured PV/MV tags".to_string(),
335                    });
336                };
337                Ok(TagValue {
338                    tag: tag.clone(),
339                    value: value.to_string(),
340                    quality: Quality::Good,
341                    timestamp: None,
342                })
343            })
344            .collect()
345    }
346
347    async fn write(&self, tag: &TagId, value: TagWrite) -> DriverResult<WriteOutcome> {
348        if *tag != self.mv_tag {
349            return Err(DriverError::InvalidTagValue {
350                tag: tag.clone(),
351                message: "SimulatorDriver only accepts writes to its configured MV tag".to_string(),
352            });
353        }
354        let mv = match value {
355            TagWrite::Float(f) => f,
356            TagWrite::Raw(s) => match s.parse::<f32>() {
357                Ok(f) => f,
358                Err(_) => {
359                    return Ok(WriteOutcome::failure(format!(
360                        "'{s}' is not a valid numeric MV value"
361                    )));
362                }
363            },
364        };
365        poisoned_lock(self.process.lock())?.set_mv(mv);
366        Ok(WriteOutcome::success())
367    }
368
369    async fn capabilities(&self) -> DriverResult<DriverCapabilities> {
370        Err(DriverError::Unsupported {
371            operation: "capabilities",
372        })
373    }
374
375    async fn browse(&self, _request: BrowsePageRequest) -> DriverResult<BrowsePage> {
376        Err(DriverError::Unsupported {
377            operation: "browse",
378        })
379    }
380
381    async fn close_browse_session(&self, _session_id: &str) -> DriverResult<()> {
382        Err(DriverError::Unsupported {
383            operation: "browse-session close",
384        })
385    }
386
387    async fn search(&self, _request: SearchRequest) -> DriverResult<Vec<SearchEvent>> {
388        Err(DriverError::Unsupported {
389            operation: "search",
390        })
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    // --- FopdtProcess --------------------------------------------------------------------
399
400    #[test]
401    fn step_response_settles_at_gain_times_mv_at_steady_state() {
402        let config = FopdtConfig::new(2.0, 5.0, 0.0, 1.0);
403        let mut process = FopdtProcess::new(config, 50.0, 25.0, 0);
404        process.set_mv(30.0);
405
406        let mut pv = 50.0;
407        for _ in 0..200 {
408            pv = process.step();
409        }
410
411        // bias = 50 - 2*25 = 0; target = bias + gain*mv = 0 + 2*30 = 60.
412        assert!((pv - 60.0).abs() < 1e-3, "expected pv near 60.0, got {pv}");
413    }
414
415    #[test]
416    fn matches_the_multi_tick_closed_form_solution() {
417        // A second, independently-coded computation path (a one-shot closed-form formula
418        // using `decay.powi(n)`) cross-checked against the per-tick iterative
419        // implementation -- not merely restating the same recurrence.
420        let config = FopdtConfig::new(1.0, 4.0, 0.0, 1.0);
421        let mut process = FopdtProcess::new(config, 10.0, 10.0, 0);
422        process.set_mv(20.0);
423
424        let decay: f32 = (-1.0_f32 / 4.0).exp();
425        let target = 20.0; // bias(0) + gain(1)*mv(20)
426        for n in 1..=6 {
427            let pv = process.step();
428            let expected = 10.0 * decay.powi(n) + target * (1.0 - decay.powi(n));
429            assert!(
430                (pv - expected).abs() < 1e-4,
431                "tick {n}: pv={pv} expected={expected}"
432            );
433        }
434    }
435
436    #[test]
437    fn dead_time_delays_the_response_by_the_configured_number_of_ticks() {
438        let config = FopdtConfig::new(1.0, 4.0, 3.0, 1.0); // 3s dead time / 1s ticks = 3 ticks
439        let mut process = FopdtProcess::new(config, 10.0, 10.0, 0);
440        process.set_mv(20.0);
441
442        // For the first 3 ticks the delay line still reports the initial MV (10.0), so PV
443        // must not have moved from steady state at all.
444        for tick in 0..3 {
445            let pv = process.step();
446            assert!(
447                (pv - 10.0).abs() < 1e-4,
448                "tick {tick}: expected pv to stay at 10.0 during dead time, got {pv}"
449            );
450        }
451        // From the 4th tick onward the new MV has propagated through the delay line and PV
452        // must start moving toward the new target.
453        let pv_after_delay = process.step();
454        assert!(
455            pv_after_delay > 10.1,
456            "expected pv to start moving after dead time elapsed, got {pv_after_delay}"
457        );
458    }
459
460    #[test]
461    fn zero_dead_time_responds_on_the_very_first_tick() {
462        let config = FopdtConfig::new(1.0, 4.0, 0.0, 1.0);
463        let mut process = FopdtProcess::new(config, 10.0, 10.0, 0);
464        process.set_mv(20.0);
465        assert!(process.step() > 10.1);
466    }
467
468    #[tokio::test]
469    async fn unsupported_namespace_operations_are_reported() {
470        let driver = SimulatorDriver::new(
471            "PV",
472            "MV",
473            FopdtConfig::new(1.0, 2.0, 0.0, 1.0),
474            0.0,
475            0.0,
476            1,
477        );
478        assert!(matches!(
479            driver.capabilities().await,
480            Err(DriverError::Unsupported {
481                operation: "capabilities"
482            })
483        ));
484        assert!(matches!(
485            driver.browse(BrowsePageRequest::root(1)).await,
486            Err(DriverError::Unsupported {
487                operation: "browse"
488            })
489        ));
490        assert!(matches!(
491            driver.close_browse_session("s").await,
492            Err(DriverError::Unsupported {
493                operation: "browse-session close"
494            })
495        ));
496        assert!(matches!(
497            driver
498                .search(SearchRequest {
499                    query: "PV".into(),
500                    match_mode: crate::types::SearchMatchMode::Exact,
501                    session_id: None,
502                    scope_node_key: None,
503                    max_results: 1,
504                    include_branches: false,
505                    refresh: false,
506                })
507                .await,
508            Err(DriverError::Unsupported {
509                operation: "search"
510            })
511        ));
512    }
513
514    #[test]
515    fn same_seed_produces_identical_pv_sequences() {
516        let config = FopdtConfig::new(1.0, 4.0, 0.0, 1.0).with_noise_amplitude(0.5);
517        let mut a = FopdtProcess::new(config, 10.0, 10.0, 42);
518        let mut b = FopdtProcess::new(config, 10.0, 10.0, 42);
519        a.set_mv(15.0);
520        b.set_mv(15.0);
521        let seq_a: Vec<f32> = (0..20).map(|_| a.step()).collect();
522        let seq_b: Vec<f32> = (0..20).map(|_| b.step()).collect();
523        assert_eq!(seq_a, seq_b);
524    }
525
526    #[test]
527    fn different_seeds_produce_different_pv_sequences() {
528        let config = FopdtConfig::new(1.0, 4.0, 0.0, 1.0).with_noise_amplitude(0.5);
529        let mut a = FopdtProcess::new(config, 10.0, 10.0, 1);
530        let mut b = FopdtProcess::new(config, 10.0, 10.0, 2);
531        a.set_mv(15.0);
532        b.set_mv(15.0);
533        let seq_a: Vec<f32> = (0..20).map(|_| a.step()).collect();
534        let seq_b: Vec<f32> = (0..20).map(|_| b.step()).collect();
535        assert_ne!(seq_a, seq_b);
536    }
537
538    #[test]
539    fn noise_never_exceeds_the_configured_amplitude() {
540        // gain=0.0 and time_constant_s=0.0 mean the deterministic component of `pv` is
541        // exactly 0.0 on every single tick, regardless of history (decay=0.0 discards the
542        // previous, noise-carrying pv entirely) -- so each returned sample is purely that
543        // tick's noise draw, cleanly isolated for this assertion.
544        let config = FopdtConfig::new(0.0, 0.0, 0.0, 1.0).with_noise_amplitude(0.3);
545        let mut process = FopdtProcess::new(config, 0.0, 0.0, 7);
546        for _ in 0..500 {
547            let pv = process.step();
548            assert!(pv.abs() <= 0.3, "noise sample {pv} exceeded amplitude 0.3");
549        }
550    }
551
552    #[test]
553    fn noise_is_added_to_the_computed_process_value() {
554        let seed = 123;
555        let noise_amplitude = 0.5;
556        let config = FopdtConfig::new(0.0, 0.0, 0.0, 1.0).with_noise_amplitude(noise_amplitude);
557        let mut expected_rng = StdRng::seed_from_u64(seed);
558        let expected_noise = expected_rng.random_range(-noise_amplitude..=noise_amplitude);
559
560        let mut process = FopdtProcess::new(config, 0.0, 0.0, seed);
561        assert_eq!(process.step(), expected_noise);
562    }
563
564    #[test]
565    fn mv_reports_the_last_written_value_without_advancing_pv() {
566        let config = FopdtConfig::new(1.0, 4.0, 0.0, 1.0);
567        let mut process = FopdtProcess::new(config, 10.0, 10.0, 0);
568        assert_eq!(process.mv(), 10.0);
569        process.set_mv(25.0);
570        assert_eq!(process.mv(), 25.0);
571        assert_eq!(process.pv(), 10.0); // step() never called -- pv must be untouched
572    }
573
574    // --- VirtualPid ------------------------------------------------------------------------
575
576    #[test]
577    fn proportional_only_output_matches_kc_times_error_plus_bias() {
578        let config = VirtualPidConfig {
579            kc: 2.0,
580            ti_s: None,
581            td_s: None,
582            output_min: -1000.0,
583            output_max: 1000.0,
584            output_bias: 5.0,
585        };
586        let mut pid = VirtualPid::new(config);
587        let output = pid.step(60.0, 50.0, 1.0);
588        // error = 10.0, P = 2.0*10.0 = 20.0, output = bias(5) + 20 = 25.0.
589        assert!((output - 25.0).abs() < 1e-4);
590    }
591
592    #[test]
593    fn zero_integral_time_disables_integral_action() {
594        let config = VirtualPidConfig {
595            kc: 1.0,
596            ti_s: Some(0.0),
597            td_s: None,
598            output_min: -1000.0,
599            output_max: 1000.0,
600            output_bias: 0.0,
601        };
602        let mut pid = VirtualPid::new(config);
603
604        assert_eq!(pid.step(10.0, 0.0, 1.0), 10.0);
605    }
606
607    #[test]
608    fn integral_action_scales_the_error_by_elapsed_time() {
609        let config = VirtualPidConfig {
610            kc: 1.0,
611            ti_s: Some(2.0),
612            td_s: None,
613            output_min: -1000.0,
614            output_max: 1000.0,
615            output_bias: 0.0,
616        };
617        let mut pid = VirtualPid::new(config);
618
619        // P = 10 and I = (10 * 2) * (1 / 2) = 10.
620        assert_eq!(pid.step(10.0, 0.0, 2.0), 20.0);
621    }
622
623    #[test]
624    fn anti_windup_prevents_the_integral_from_growing_while_saturated() {
625        let config = VirtualPidConfig {
626            kc: 1.0,
627            ti_s: Some(2.0),
628            td_s: None,
629            output_min: 0.0,
630            output_max: 10.0,
631            output_bias: 0.0,
632        };
633        let mut pid = VirtualPid::new(config);
634
635        // A huge, sustained positive error saturates the output high for many ticks -- if
636        // the integral term were allowed to keep accumulating while saturated, it would
637        // grow far beyond what's needed to reach output_max.
638        for _ in 0..100 {
639            assert_eq!(pid.step(1000.0, 0.0, 1.0), 10.0);
640        }
641
642        // A *small* negative error (a slight overshoot past the setpoint) should
643        // immediately pull the output below saturation -- proving the integral accumulator
644        // did not keep growing unboundedly during the 100 saturated ticks above (if it had,
645        // an error of this modest size couldn't possibly overcome it, and output would stay
646        // pinned at 10.0).
647        let output = pid.step(-1.0, 0.0, 1.0);
648        assert!(
649            output < 10.0,
650            "expected output to leave saturation, got {output}"
651        );
652    }
653
654    #[test]
655    fn derivative_acts_on_measurement_so_a_setpoint_step_causes_no_kick() {
656        let config = VirtualPidConfig {
657            kc: 1.0,
658            ti_s: None,
659            td_s: Some(5.0),
660            output_min: -1000.0,
661            output_max: 1000.0,
662            output_bias: 0.0,
663        };
664        let mut pid = VirtualPid::new(config);
665
666        // Prime `prev_pv` with an initial call.
667        pid.step(50.0, 50.0, 1.0);
668
669        // The setpoint now jumps by 40 (a typical operator setpoint change), but PV itself
670        // hasn't moved -- a derivative-on-error implementation would compute a huge
671        // spurious derivative kick here (`d(error)/dt` includes the setpoint's own jump);
672        // derivative-on-pv must not.
673        let output = pid.step(90.0, 50.0, 1.0);
674        // error=40, P=1*40=40, I=0 (disabled), D=0 (pv unchanged) => output=40.
675        assert!((output - 40.0).abs() < 1e-4);
676    }
677
678    #[test]
679    fn derivative_action_uses_measurement_delta_and_elapsed_time() {
680        let config = VirtualPidConfig {
681            kc: 2.0,
682            ti_s: None,
683            td_s: Some(3.0),
684            output_min: -1000.0,
685            output_max: 1000.0,
686            output_bias: 0.0,
687        };
688        let mut pid = VirtualPid::new(config);
689
690        pid.step(0.0, 0.0, 1.0);
691        // P = -2 and D = -2 * 3 * (1 - 0) / 2 = -3.
692        assert_eq!(pid.step(0.0, 1.0, 2.0), -5.0);
693    }
694
695    #[test]
696    fn non_positive_derivative_inputs_disable_derivative_action() {
697        let config = VirtualPidConfig {
698            kc: 1.0,
699            ti_s: None,
700            td_s: Some(-1.0),
701            output_min: -1000.0,
702            output_max: 1000.0,
703            output_bias: 0.0,
704        };
705        let mut negative_td = VirtualPid::new(config);
706        negative_td.step(0.0, 0.0, 1.0);
707        assert_eq!(negative_td.step(0.0, 1.0, 1.0), -1.0);
708
709        let mut negative_dt = VirtualPid::new(VirtualPidConfig {
710            td_s: Some(1.0),
711            ..config
712        });
713        negative_dt.step(0.0, 0.0, 1.0);
714        assert_eq!(negative_dt.step(0.0, 1.0, -1.0), -1.0);
715    }
716
717    #[test]
718    fn zero_derivative_time_and_elapsed_time_are_safe() {
719        let config = VirtualPidConfig {
720            kc: 1.0,
721            ti_s: None,
722            td_s: Some(0.0),
723            output_min: -1000.0,
724            output_max: 1000.0,
725            output_bias: 0.0,
726        };
727        let mut pid = VirtualPid::new(config);
728
729        pid.step(0.0, 0.0, 1.0);
730        let output = pid.step(0.0, 1.0, 0.0);
731        assert_eq!(output, -1.0);
732    }
733
734    #[test]
735    fn zero_elapsed_time_skips_derivative_action() {
736        let config = VirtualPidConfig {
737            kc: 1.0,
738            ti_s: None,
739            td_s: Some(1.0),
740            output_min: -1000.0,
741            output_max: 1000.0,
742            output_bias: 0.0,
743        };
744        let mut pid = VirtualPid::new(config);
745
746        pid.step(0.0, 0.0, 1.0);
747        assert_eq!(pid.step(0.0, 1.0, 0.0), -1.0);
748    }
749
750    #[test]
751    fn pid_and_fopdt_process_together_converge_to_the_setpoint() {
752        // Gains numerically pre-verified (in a disposable scratch script, not part of this
753        // repo) to converge cleanly with no oscillation for this specific process.
754        let process_config = FopdtConfig::new(2.0, 5.0, 1.0, 1.0);
755        let mut process = FopdtProcess::new(process_config, 20.0, 10.0, 0);
756
757        let pid_config = VirtualPidConfig {
758            kc: 0.8,
759            ti_s: Some(6.0),
760            td_s: None,
761            output_min: 0.0,
762            output_max: 100.0,
763            output_bias: 10.0, // matches the process's initial_mv operating point
764        };
765        let mut pid = VirtualPid::new(pid_config);
766
767        let setpoint = 45.0;
768        let mut pv = process.pv();
769        for _ in 0..500 {
770            let mv = pid.step(setpoint, pv, 1.0);
771            process.set_mv(mv);
772            pv = process.step();
773        }
774
775        assert!(
776            (pv - setpoint).abs() < 0.5,
777            "expected convergence near {setpoint}, got {pv}"
778        );
779    }
780
781    // --- SimulatorDriver ------------------------------------------------------------------
782
783    fn driver() -> SimulatorDriver {
784        SimulatorDriver::new(
785            "Loop.PV",
786            "Loop.MV",
787            FopdtConfig::new(1.0, 4.0, 0.0, 1.0),
788            50.0,
789            50.0,
790            0,
791        )
792    }
793
794    #[tokio::test]
795    async fn read_mv_tag_reports_current_mv_without_advancing_pv() {
796        let driver = driver();
797        let first = driver.read(&["Loop.MV".to_string()]).await.unwrap();
798        let second = driver.read(&["Loop.MV".to_string()]).await.unwrap();
799        assert_eq!(first[0].value, "50");
800        assert_eq!(second[0].value, "50");
801        assert_eq!(first[0].quality, Quality::Good);
802    }
803
804    #[tokio::test]
805    async fn read_pv_tag_advances_the_simulated_process_each_call() {
806        let driver = driver();
807        driver
808            .write(&"Loop.MV".to_string(), TagWrite::Float(80.0))
809            .await
810            .unwrap();
811
812        let mut values = Vec::new();
813        for _ in 0..5 {
814            let read = driver.read(&["Loop.PV".to_string()]).await.unwrap();
815            values.push(read[0].value.parse::<f32>().unwrap());
816        }
817        // Each successive read should move further toward the new MV-driven target (80.0),
818        // proving each `read` call genuinely advances the simulated clock.
819        for pair in values.windows(2) {
820            assert!(pair[1] > pair[0], "expected monotonic approach: {values:?}");
821        }
822    }
823
824    #[tokio::test]
825    async fn write_accepts_a_raw_string_that_parses_as_a_number() {
826        let driver = driver();
827        let outcome = driver
828            .write(&"Loop.MV".to_string(), TagWrite::Raw("65.5".to_string()))
829            .await
830            .unwrap();
831        assert!(outcome.success);
832        let read = driver.read(&["Loop.MV".to_string()]).await.unwrap();
833        assert_eq!(read[0].value, "65.5");
834    }
835
836    #[tokio::test]
837    async fn write_rejects_a_raw_string_that_does_not_parse_as_a_number() {
838        let driver = driver();
839        let outcome = driver
840            .write(
841                &"Loop.MV".to_string(),
842                TagWrite::Raw("not-a-number".to_string()),
843            )
844            .await
845            .unwrap();
846        assert!(!outcome.success);
847        assert!(outcome.error_message.is_some());
848    }
849
850    #[tokio::test]
851    async fn read_unknown_tag_is_invalid_tag_value_not_a_panic() {
852        let driver = driver();
853        let err = driver
854            .read(&["Nonexistent.Tag".to_string()])
855            .await
856            .unwrap_err();
857        assert!(matches!(err, DriverError::InvalidTagValue { .. }));
858    }
859
860    #[tokio::test]
861    async fn write_unknown_tag_is_invalid_tag_value_not_a_panic() {
862        let driver = driver();
863        let err = driver
864            .write(&"Nonexistent.Tag".to_string(), TagWrite::Float(1.0))
865            .await
866            .unwrap_err();
867        assert!(matches!(err, DriverError::InvalidTagValue { .. }));
868    }
869
870    #[tokio::test]
871    async fn browse_is_unsupported() {
872        let driver = driver();
873        let err = driver
874            .browse(BrowsePageRequest::root(20))
875            .await
876            .unwrap_err();
877        assert!(matches!(
878            err,
879            DriverError::Unsupported {
880                operation: "browse"
881            }
882        ));
883    }
884
885    /// End-to-end: a real `MrftEngine` (from `bhtune-core`) drives `SimulatorDriver`
886    /// through the actual `Driver` trait -- not a hand-rolled process simulation local to
887    /// the test, as `bhtune-core`'s own equivalent test necessarily uses (it cannot depend
888    /// on `bhtune-driver`, which depends on it) -- and completes with plausible peaks,
889    /// troughs, and switch counts. Proves `SimulatorDriver` is actually fit for its
890    /// intended purpose: driving synthetic MRFT runs for integration coverage, entirely
891    /// without wall-clock sleeps.
892    #[tokio::test]
893    async fn mrft_engine_completes_a_realistic_relay_test_against_the_simulator_driver() {
894        use bhtune_core::{
895            Action, ControllerDirection, ControllerType, InitialReadings, LoopConfig, MrftEngine,
896            ProcessType, ResponseLevel, Tick, lookup,
897        };
898        use chrono::{TimeZone, Utc};
899
900        let pv_tag = "Loop.PV".to_string();
901        let mv_tag = "Loop.MV".to_string();
902
903        let initial = InitialReadings {
904            pv_ini: 50.0,
905            mv_ini: 50.0,
906            mv_range_low: 0.0,
907            mv_range_high: 100.0,
908        };
909
910        // 5 ticks of dead time plus a mild lag: relay feedback auto-tuning fundamentally
911        // needs process phase lag to sustain oscillation (a memoryless process makes the
912        // relay chatter every tick instead) -- matching the reasoning already established
913        // for `bhtune-core`'s own equivalent test.
914        let driver = SimulatorDriver::new(
915            pv_tag.clone(),
916            mv_tag.clone(),
917            FopdtConfig::new(1.0, 2.0, 5.0, 1.0),
918            initial.pv_ini,
919            initial.mv_ini,
920            0,
921        );
922
923        let config = LoopConfig {
924            process_type: ProcessType::Flow,
925            controller_type: ControllerType::Pi,
926            relay_amp_percent: 10.0,
927            num_cycles_skip: 1,
928            num_cycles_count: 2,
929            noise_protection_secs: 0,
930            mrft_delay_secs: 0,
931        };
932        let tc = lookup(
933            config.process_type,
934            config.controller_type,
935            ResponseLevel::Aggressive,
936        );
937        let start_time = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
938        let mut engine = MrftEngine::new(
939            config,
940            ControllerDirection::Reverse,
941            tc.beta,
942            initial,
943            start_time,
944        );
945
946        let mut completion = None;
947        for i in 1..=500 {
948            let read = driver.read(std::slice::from_ref(&pv_tag)).await.unwrap();
949            let pv: f32 = read[0].value.parse().unwrap();
950            let time = start_time + chrono::Duration::seconds(i);
951
952            for action in engine.step(Tick { time, pv }) {
953                match action {
954                    Action::WriteMv(mv) => {
955                        driver.write(&mv_tag, TagWrite::Float(mv)).await.unwrap();
956                    }
957                    Action::Complete {
958                        peaks,
959                        troughs,
960                        switch_times,
961                        mv_sign_init,
962                    } => {
963                        completion = Some((peaks, troughs, switch_times, mv_sign_init));
964                    }
965                }
966            }
967            if completion.is_some() {
968                break;
969            }
970        }
971
972        let (peaks, troughs, switch_times, mv_sign_init) =
973            completion.expect("engine should complete within 500 ticks");
974
975        assert!(!peaks.is_empty(), "expected at least one recorded peak");
976        assert!(!troughs.is_empty(), "expected at least one recorded trough");
977        assert!(switch_times.len() >= 2, "expected multiple relay switches");
978        assert!(mv_sign_init == 1 || mv_sign_init == -1);
979        for pv in peaks.iter().chain(troughs.iter()) {
980            assert!(pv.is_finite());
981        }
982    }
983}