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