Skip to main content

bhtune_core/
tuning_math.rs

1//! Tuning-constant math: turns a completed MRFT run's peaks/troughs/switch-times into Kp/Ti/Td
2//! for all three [`ResponseLevel`]s, then into the PID parameters in whatever
3//! representation/units a DCS/PLC template expects.
4//!
5//! Pure port of `TuningConstantsCalc` ([`measure_oscillation`] + [`calculate_tuning_result`])
6//! and `CalculatePIDparameters` ([`calculate_pid_parameters`]), split the same way the legacy
7//! app split them. Like `core-mrft`, this module does no I/O and reads no clock — every
8//! timestamp it reasons about is already inside `switch_times`, taken from a completed
9//! [`crate::mrft::Action::Complete`].
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14use crate::{
15    constants::{ResponseLevel, lookup},
16    controller_type::ControllerType,
17    direction::ControllerDirection,
18    loop_config::LoopConfig,
19    pid_config::{DerivativeType, IntegralType, ProportionalType, TimeUnit},
20    range::PvRange,
21    template::DcsTemplate,
22};
23
24/// Legacy-bug replication flags for this module, mirroring [`crate::mrft::MrftCompat`]'s
25/// pattern (see `core-bug-register`). Every field defaults to `false`: the fixed, correct
26/// behavior.
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28pub struct TuningMathCompat {
29    /// Replicate `TuningConstantsCalc`'s period calculation, which reconstructs elapsed time
30    /// from a `TimeSpan`'s `.Hours`/`.Minutes`/`.Seconds` *component* properties instead of
31    /// its total duration. That has two effects, both reproduced together when this is
32    /// `true`: sub-second precision is silently dropped (`.Seconds` is an integer), and whole
33    /// days are silently dropped for any run lasting 24 hours or longer (`.Hours` wraps at
34    /// 24). The default (`false`) keeps millisecond precision and never wraps; set this
35    /// `true` only to reproduce the legacy behavior bit-for-bit against a captured legacy
36    /// trace.
37    pub replicate_period_truncation_bug: bool,
38}
39
40/// Oscillation measurements derived from a completed MRFT run, before applying any
41/// response-level-specific Kp multiplier. Pure port of the period/frequency/amplitude portion
42/// of `TuningConstantsCalc`.
43#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
44pub struct Oscillation {
45    pub period_minutes: f32,
46    pub frequency: f32,
47    pub pv_amp_raw: f32,
48    pub pv_amp_percent: f32,
49}
50
51/// Calculated Kp/Ti/Td for one [`ResponseLevel`], before DCS-specific unit conversion. Pure
52/// port of the Kp/Ti/Td portion of `TuningConstantsCalc`. `ti_minutes`/`td_minutes` are
53/// identical across all three response levels for a given run — only `kp` varies — since
54/// `C2`/`C3` don't vary by response level (see [`crate::constants::lookup`]).
55#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
56pub struct TuningResult {
57    pub response_level: ResponseLevel,
58    pub kp: f32,
59    pub ti_minutes: f32,
60    pub td_minutes: f32,
61}
62
63/// The final PID parameters in a DCS/PLC template's own representation (e.g. proportional
64/// band instead of gain, reset rate instead of reset time, seconds instead of minutes) — pure
65/// port of `CalculatePIDparameters`, applied to one [`TuningResult`].
66#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
67pub struct PidParameters {
68    pub response_level: ResponseLevel,
69    pub proportional: f32,
70    pub integral: f32,
71    pub derivative: f32,
72}
73
74/// The literal values to write back to the DCS/PLC for one [`PidParameters`] — see
75/// [`opc_write_values`]. Distinct from [`PidParameters`] because integral/derivative may
76/// differ from the calculated values for controller types that don't use one or both terms.
77#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
78pub struct OpcWriteValues {
79    pub response_level: ResponseLevel,
80    pub proportional: f32,
81    pub integral: f32,
82    pub derivative: f32,
83}
84
85/// Whether a calculated response-level result contains values that are safe to use.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
88#[serde(rename_all = "snake_case")]
89pub enum TuningResultStatus {
90    Valid,
91    Invalid,
92}
93
94/// Why a calculated response-level result was marked invalid.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
97#[serde(rename_all = "snake_case")]
98pub enum TuningResultInvalidReason {
99    NonFinitePvAmplitude,
100    NonPositivePvAmplitude,
101    NonFinitePeriod,
102    NonPositivePeriod,
103    NonFiniteFrequency,
104    NonPositiveFrequency,
105    NonFiniteKp,
106    NonFiniteTiMinutes,
107    NonFiniteTdMinutes,
108    NonFiniteProportional,
109    NonFiniteIntegral,
110    NonFiniteDerivative,
111}
112
113impl std::fmt::Display for TuningResultInvalidReason {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        let message = match self {
116            Self::NonFinitePvAmplitude => "PV amplitude is not finite",
117            Self::NonPositivePvAmplitude => "PV amplitude is not positive",
118            Self::NonFinitePeriod => "oscillation period is not finite",
119            Self::NonPositivePeriod => "oscillation period is not positive",
120            Self::NonFiniteFrequency => "oscillation frequency is not finite",
121            Self::NonPositiveFrequency => "oscillation frequency is not positive",
122            Self::NonFiniteKp => "Kp is not finite",
123            Self::NonFiniteTiMinutes => "Ti is not finite",
124            Self::NonFiniteTdMinutes => "Td is not finite",
125            Self::NonFiniteProportional => "proportional value is not finite",
126            Self::NonFiniteIntegral => "integral value is not finite",
127            Self::NonFiniteDerivative => "derivative value is not finite",
128        };
129        f.write_str(message)
130    }
131}
132
133/// A checked result for one response level.
134///
135/// Invalid results retain their response level and an explicit diagnostic reason, but never
136/// expose numeric tuning/PID values for callers to accidentally write or display as usable
137/// constants.
138#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
139pub struct CheckedTuningResult {
140    pub response_level: ResponseLevel,
141    pub status: TuningResultStatus,
142    pub invalid_reason: Option<TuningResultInvalidReason>,
143    pub tuning: Option<TuningResult>,
144    pub pid: Option<PidParameters>,
145}
146
147impl CheckedTuningResult {
148    fn invalid(response_level: ResponseLevel, reason: TuningResultInvalidReason) -> Self {
149        Self {
150            response_level,
151            status: TuningResultStatus::Invalid,
152            invalid_reason: Some(reason),
153            tuning: None,
154            pid: None,
155        }
156    }
157
158    fn valid(tuning: TuningResult, pid: PidParameters) -> Self {
159        Self {
160            response_level: tuning.response_level,
161            status: TuningResultStatus::Valid,
162            invalid_reason: None,
163            tuning: Some(tuning),
164            pid: Some(pid),
165        }
166    }
167
168    /// Returns the calculated values only when this result is safe to use.
169    pub fn usable_values(&self) -> Option<(TuningResult, PidParameters)> {
170        match (self.status, self.invalid_reason, self.tuning, self.pid) {
171            (TuningResultStatus::Valid, None, Some(tuning), Some(pid)) => Some((tuning, pid)),
172            _ => None,
173        }
174    }
175}
176
177/// Computes oscillation measurements (period, frequency, PV amplitude) from a completed MRFT
178/// run's recorded peaks/troughs/switch-times. Pure port of `TuningConstantsCalc`'s
179/// period/frequency/amplitude math — everything before the per-response-level Kp/Ti/Td split,
180/// which [`calculate_tuning_result`] handles.
181///
182/// `peaks`/`troughs`/`switch_times`/`mv_sign_init` come directly from a completed
183/// [`crate::mrft::Action::Complete`]; `direction` and `config.num_cycles_count` must be the
184/// same values the [`crate::mrft::MrftEngine`] that produced them was built with.
185///
186/// # Panics
187/// Panics if `switch_times` has fewer than 2 entries, or if `peaks`/`troughs` don't have the
188/// lengths a real `Action::Complete` for `config.num_cycles_count` cycles always has (one of
189/// them `num_cycles_count`, the other `num_cycles_count + 1`, alternating from the first
190/// recorded switch's direction). Both are impossible outputs from [`crate::mrft::MrftEngine`]
191/// and indicate a caller bug, not a runtime data problem.
192#[allow(clippy::too_many_arguments)]
193pub fn measure_oscillation(
194    peaks: &[f32],
195    troughs: &[f32],
196    switch_times: &[DateTime<Utc>],
197    mv_sign_init: i8,
198    direction: ControllerDirection,
199    config: LoopConfig,
200    pv_range: PvRange,
201    compat: TuningMathCompat,
202) -> Oscillation {
203    let num_cycles_count = config.num_cycles_count;
204    assert!(
205        switch_times.len() >= 2,
206        "switch_times must have at least 2 entries to measure a period, got {}",
207        switch_times.len()
208    );
209    assert_eq!(
210        switch_times.len(),
211        num_cycles_count as usize * 2 + 1,
212        "switch_times.len() must equal 2 * num_cycles_count + 1"
213    );
214
215    // Whichever of peaks/troughs holds the first recorded switch's value has one extra
216    // (excluded-from-the-average) entry at index 0 — see the comment below.
217    let first_switch_is_peak = mv_sign_init as i32 * direction.action_multiplier() as i32 == 1;
218    let (peaks_sum, troughs_sum) = if first_switch_is_peak {
219        assert_eq!(
220            peaks.len(),
221            num_cycles_count as usize + 1,
222            "peaks.len() must be num_cycles_count + 1 when the first recorded switch is a peak"
223        );
224        assert_eq!(
225            troughs.len(),
226            num_cycles_count as usize,
227            "troughs.len() must equal num_cycles_count when the first recorded switch is a peak"
228        );
229        // The very first recorded peak is excluded from the average — the same choice
230        // `TuningConstantsCalc` makes (its summation loop starts at index 1, not 0), on the
231        // theory that the first post-skip oscillation may not yet be a "clean" full-amplitude
232        // cycle.
233        (peaks[1..].iter().sum::<f32>(), troughs.iter().sum::<f32>())
234    } else {
235        assert_eq!(
236            troughs.len(),
237            num_cycles_count as usize + 1,
238            "troughs.len() must be num_cycles_count + 1 when the first recorded switch is a trough"
239        );
240        assert_eq!(
241            peaks.len(),
242            num_cycles_count as usize,
243            "peaks.len() must equal num_cycles_count when the first recorded switch is a trough"
244        );
245        (peaks.iter().sum::<f32>(), troughs[1..].iter().sum::<f32>())
246    };
247
248    // `TuningConstantsCalc` reconstructs elapsed time from a `TimeSpan`'s `.Hours`/`.Minutes`/
249    // `.Seconds` *component* properties -- each an integer, so the sub-second remainder is
250    // silently dropped -- and `.Hours` additionally wraps at 24, dropping whole days for a
251    // run lasting that long. Reproducing the bug therefore needs *both* effects: whole-second
252    // precision and a 24-hour wrap (`elapsed_ms / 1000 % 86_400`). The fixed default instead
253    // keeps millisecond precision (ample for any real polling cadence) and never wraps, using
254    // the duration's true elapsed time -- this matters in practice, not just past 24 hours:
255    // an oscillation period under one second (a fast loop with a short poll interval) used to
256    // collapse to exactly zero here even by default, silently zeroing `ti_minutes`/`td_minutes`
257    // for a real, measured oscillation (caught by `e2e-simulator`'s real-timing coverage,
258    // which -- unlike this module's other tests -- drives a real polling loop with genuine,
259    // sub-second switch-time deltas rather than hand-picked whole-second ones).
260    let elapsed_ms = (*switch_times.last().unwrap() - switch_times[0]).num_milliseconds();
261    let secs_for_period = if compat.replicate_period_truncation_bug {
262        ((elapsed_ms / 1000) % 86_400) as f32
263    } else {
264        elapsed_ms as f32 / 1000.0
265    };
266    let period_minutes = (secs_for_period / 60.0) / num_cycles_count as f32;
267    let frequency = 2.0 * std::f32::consts::PI / period_minutes;
268
269    let pv_amp_raw = (peaks_sum - troughs_sum) / (2.0 * num_cycles_count as f32);
270    let pv_amp_percent = pv_amp_raw / (pv_range.high - pv_range.low) * 100.0;
271
272    Oscillation {
273        period_minutes,
274        frequency,
275        pv_amp_raw,
276        pv_amp_percent,
277    }
278}
279
280/// Applies one [`ResponseLevel`]'s tuning constants to an [`Oscillation`], producing Kp/Ti/Td.
281/// Pure port of the per-response-level portion of `TuningConstantsCalc`.
282pub fn calculate_tuning_result(
283    oscillation: Oscillation,
284    config: LoopConfig,
285    response_level: ResponseLevel,
286) -> TuningResult {
287    let tc = lookup(config.process_type, config.controller_type, response_level);
288
289    // Matches `Convert.ToSingle(Math.PI * PvAmpPercent)`: the product is computed in `double`
290    // (like `Math.PI`) and only truncated to `f32` afterward, so this widens explicitly rather
291    // than multiplying two `f32`s directly, to keep rounding identical to the legacy app.
292    let kp_denom = (std::f64::consts::PI * oscillation.pv_amp_percent as f64) as f32;
293    let kp = tc.c1 * 4.0 * config.relay_amp_percent / kp_denom;
294
295    // Unlike the Kp denominator above, `Convert.ToSingle(Math.PI)` here converts only the
296    // constant itself (not a product) before it's used in `f32` arithmetic — equivalent to
297    // using `f32::consts::PI` directly, with no `f64` intermediate needed.
298    let ti_minutes = tc.c2 * 2.0 * std::f32::consts::PI / oscillation.frequency;
299    let td_minutes = tc.c3 * 2.0 * std::f32::consts::PI / oscillation.frequency;
300
301    TuningResult {
302        response_level,
303        kp,
304        ti_minutes,
305        td_minutes,
306    }
307}
308
309/// Converts a [`TuningResult`] into the PID parameters a specific DCS/PLC template expects.
310/// Pure port of `CalculatePIDparameters`.
311///
312/// Order matters and matches the legacy app exactly: the integral/derivative unit conversion
313/// (minutes -> seconds) happens *before* the reset-rate/reset-gain/derivative-gain type
314/// conversion, so e.g. `Ki` ends up computed from `Ti` already expressed in seconds whenever
315/// the template's `integral_unit` is [`TimeUnit::Seconds`] — not from `Ti` in minutes.
316pub fn calculate_pid_parameters(result: TuningResult, template: &DcsTemplate) -> PidParameters {
317    let proportional = match template.proportional_type {
318        ProportionalType::Gain => result.kp,
319        ProportionalType::Band => 100.0 / result.kp,
320    };
321
322    let integral_in_template_unit = match template.integral_unit {
323        TimeUnit::Seconds => result.ti_minutes * 60.0,
324        TimeUnit::Minutes => result.ti_minutes,
325    };
326    let integral = match template.integral_type {
327        IntegralType::ResetTime => integral_in_template_unit,
328        IntegralType::ResetRate => 1.0 / integral_in_template_unit,
329        IntegralType::ResetGain => result.kp / integral_in_template_unit,
330    };
331
332    let derivative_in_template_unit = match template.derivative_unit {
333        TimeUnit::Seconds => result.td_minutes * 60.0,
334        TimeUnit::Minutes => result.td_minutes,
335    };
336    let derivative = match template.derivative_type {
337        DerivativeType::DerivativeTime => derivative_in_template_unit,
338        DerivativeType::DerivativeGain => result.kp * derivative_in_template_unit,
339    };
340
341    PidParameters {
342        response_level: result.response_level,
343        proportional,
344        integral,
345        derivative,
346    }
347}
348
349/// The literal integral/derivative values to write back to the DCS/PLC for one
350/// [`PidParameters`], given the controller type the run was configured for. Pure port of the
351/// controller-type-conditional part of `WritePIDparametersToOPCtags` — proportional is always
352/// `pid.proportional`, so this only exists to decide integral/derivative, which
353/// [`ControllerType::P`]/[`ControllerType::Pi`] algorithms don't use:
354///
355/// - Integral: a [`ControllerType::P`]-only run never writes `pid.integral` (it was computed
356///   but is meaningless for an algorithm with no integral term). Instead it writes a sentinel
357///   that disables integral action in whatever representation the template uses — `9999` for
358///   [`IntegralType::ResetTime`] (an effectively-infinite reset time), or `0` for
359///   [`IntegralType::ResetRate`]/[`IntegralType::ResetGain`] (a zero rate/gain has the same
360///   disabling effect). [`ControllerType::Pi`]/[`ControllerType::Pid`] always write the real
361///   calculated value.
362/// - Derivative: only a [`ControllerType::Pid`] run writes `pid.derivative`; `P`/`Pi` always
363///   write `0`.
364pub fn opc_write_values(
365    pid: PidParameters,
366    controller_type: ControllerType,
367    integral_type: IntegralType,
368) -> OpcWriteValues {
369    let integral = match controller_type {
370        ControllerType::P => match integral_type {
371            IntegralType::ResetTime => 9999.0,
372            IntegralType::ResetRate | IntegralType::ResetGain => 0.0,
373        },
374        ControllerType::Pi | ControllerType::Pid => pid.integral,
375    };
376    let derivative = match controller_type {
377        ControllerType::Pid => pid.derivative,
378        ControllerType::P | ControllerType::Pi => 0.0,
379    };
380
381    OpcWriteValues {
382        response_level: pid.response_level,
383        proportional: pid.proportional,
384        integral,
385        derivative,
386    }
387}
388
389/// The top-level entry point: computes the PID parameters for all three response levels from
390/// a completed MRFT run, in one call. Composes [`measure_oscillation`] (once) with
391/// [`calculate_tuning_result`] and [`calculate_pid_parameters`] (once per [`ResponseLevel`]).
392/// Pure port of `MRFTcompletionActions`'s call into `TuningConstantsCalc` +
393/// `CalculatePIDparameters`.
394///
395/// Returns both the intermediate [`TuningResult`] (Kp/Ti/Td, DCS-unit-independent) and the
396/// final [`PidParameters`] (in the template's own units) for each response level, since
397/// callers that persist a run (e.g. `bhtune-cli`'s `tune` command, via
398/// `bhtune_db::TuneResultRow::from_calculated`) need both — the schema records the
399/// control-theory result alongside the exact values it derived for the connected DCS.
400#[allow(clippy::too_many_arguments)]
401pub fn calculate_all(
402    peaks: &[f32],
403    troughs: &[f32],
404    switch_times: &[DateTime<Utc>],
405    mv_sign_init: i8,
406    direction: ControllerDirection,
407    config: LoopConfig,
408    pv_range: PvRange,
409    template: &DcsTemplate,
410    compat: TuningMathCompat,
411) -> [(TuningResult, PidParameters); 3] {
412    let osc = measure_oscillation(
413        peaks,
414        troughs,
415        switch_times,
416        mv_sign_init,
417        direction,
418        config,
419        pv_range,
420        compat,
421    );
422    ResponseLevel::ALL.map(|level| {
423        let result = calculate_tuning_result(osc, config, level);
424        let pid = calculate_pid_parameters(result, template);
425        (result, pid)
426    })
427}
428
429/// Checked counterpart to [`calculate_all`].
430///
431/// The legacy numeric functions remain available for parity/replay callers. Production
432/// persistence should use this entry point so a degenerate PV amplitude or any non-finite
433/// intermediate/final value becomes an explicit invalid result instead of reaching SQLite or
434/// PID write-back as `NaN`/infinity.
435#[allow(clippy::too_many_arguments)]
436pub fn calculate_all_checked(
437    peaks: &[f32],
438    troughs: &[f32],
439    switch_times: &[DateTime<Utc>],
440    mv_sign_init: i8,
441    direction: ControllerDirection,
442    config: LoopConfig,
443    pv_range: PvRange,
444    template: &DcsTemplate,
445    compat: TuningMathCompat,
446) -> [CheckedTuningResult; 3] {
447    let oscillation = measure_oscillation(
448        peaks,
449        troughs,
450        switch_times,
451        mv_sign_init,
452        direction,
453        config,
454        pv_range,
455        compat,
456    );
457
458    let oscillation_reason = invalid_oscillation_reason(oscillation);
459
460    ResponseLevel::ALL.map(|level| {
461        if let Some(reason) = oscillation_reason {
462            return CheckedTuningResult::invalid(level, reason);
463        }
464
465        let tuning = calculate_tuning_result(oscillation, config, level);
466        checked_result_from_tuning(tuning, template)
467    })
468}
469
470fn invalid_oscillation_reason(oscillation: Oscillation) -> Option<TuningResultInvalidReason> {
471    if !oscillation.pv_amp_raw.is_finite() || !oscillation.pv_amp_percent.is_finite() {
472        Some(TuningResultInvalidReason::NonFinitePvAmplitude)
473    } else if oscillation.pv_amp_raw <= 0.0 || oscillation.pv_amp_percent <= 0.0 {
474        Some(TuningResultInvalidReason::NonPositivePvAmplitude)
475    } else if !oscillation.period_minutes.is_finite() {
476        Some(TuningResultInvalidReason::NonFinitePeriod)
477    } else if oscillation.period_minutes <= 0.0 {
478        Some(TuningResultInvalidReason::NonPositivePeriod)
479    } else if !oscillation.frequency.is_finite() {
480        Some(TuningResultInvalidReason::NonFiniteFrequency)
481    } else if oscillation.frequency <= 0.0 {
482        Some(TuningResultInvalidReason::NonPositiveFrequency)
483    } else {
484        None
485    }
486}
487
488fn checked_result_from_tuning(tuning: TuningResult, template: &DcsTemplate) -> CheckedTuningResult {
489    if !tuning.kp.is_finite() {
490        return CheckedTuningResult::invalid(
491            tuning.response_level,
492            TuningResultInvalidReason::NonFiniteKp,
493        );
494    }
495    if !tuning.ti_minutes.is_finite() {
496        return CheckedTuningResult::invalid(
497            tuning.response_level,
498            TuningResultInvalidReason::NonFiniteTiMinutes,
499        );
500    }
501    if !tuning.td_minutes.is_finite() {
502        return CheckedTuningResult::invalid(
503            tuning.response_level,
504            TuningResultInvalidReason::NonFiniteTdMinutes,
505        );
506    }
507
508    let pid = calculate_pid_parameters(tuning, template);
509    if !pid.proportional.is_finite() {
510        return CheckedTuningResult::invalid(
511            tuning.response_level,
512            TuningResultInvalidReason::NonFiniteProportional,
513        );
514    }
515    if !pid.integral.is_finite() {
516        return CheckedTuningResult::invalid(
517            tuning.response_level,
518            TuningResultInvalidReason::NonFiniteIntegral,
519        );
520    }
521    if !pid.derivative.is_finite() {
522        return CheckedTuningResult::invalid(
523            tuning.response_level,
524            TuningResultInvalidReason::NonFiniteDerivative,
525        );
526    }
527
528    CheckedTuningResult::valid(tuning, pid)
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use crate::{process_type::ProcessType, template};
535
536    fn t(offset_secs: i64) -> DateTime<Utc> {
537        DateTime::<Utc>::UNIX_EPOCH + chrono::Duration::seconds(offset_secs)
538    }
539
540    fn flow_pi_config() -> LoopConfig {
541        LoopConfig {
542            process_type: ProcessType::Flow,
543            controller_type: ControllerType::Pi,
544            relay_amp_percent: 5.0,
545            num_cycles_skip: 1,
546            num_cycles_count: 2,
547            noise_protection_secs: 3,
548            mrft_delay_secs: 0,
549        }
550    }
551
552    fn assert_approx(actual: f32, expected: f32, epsilon: f32) {
553        let diff = (actual - expected).abs();
554        let message = format!("expected {expected}, got {actual} (diff {diff})");
555        assert!(diff <= epsilon, "{message}");
556    }
557
558    // --- measure_oscillation ---------------------------------------------------------------
559
560    /// Verified against an independent Python re-implementation of `TuningConstantsCalc`'s
561    /// period/frequency/amplitude math (`peaks=[52,49], troughs=[48], mv_sign_init=1,
562    /// direction=Reverse` => first_switch_is_peak, matching `perform_switch`'s convention).
563    #[test]
564    fn measure_oscillation_reverse_first_switch_is_peak() {
565        let osc = measure_oscillation(
566            &[999.0, 52.0, 48.0],
567            &[50.0, 46.0],
568            &[t(0), t(30), t(60), t(90), t(120)],
569            1,
570            ControllerDirection::Reverse,
571            flow_pi_config(),
572            PvRange {
573                high: 100.0,
574                low: 0.0,
575            },
576            TuningMathCompat::default(),
577        );
578        // period = (120s / 60) / 2 cycles = 1.0 minute
579        assert_approx(osc.period_minutes, 1.0, 1e-6);
580        // frequency = 2*pi / 1.0
581        assert_approx(osc.frequency, 2.0 * std::f32::consts::PI, 1e-5);
582        // peaks_sum excludes index 0 (999.0, a deliberately distinct junk value proving
583        // exclusion): 52.0 + 48.0 = 100.0. troughs_sum = 50.0 + 46.0 = 96.0 (all of it).
584        // pv_amp_raw = (100.0 - 96.0) / (2*2) = 1.0
585        assert_approx(osc.pv_amp_raw, 1.0, 1e-5);
586        // pv_amp_percent = 1.0 / (100-0) * 100 = 1.0
587        assert_approx(osc.pv_amp_percent, 1.0, 1e-5);
588    }
589
590    /// Same shape but with the discriminant flipped to select the "troughs is long" branch:
591    /// `mv_sign_init=-1, direction=Reverse` => `mv_sign_init * action_multiplier == -1`, so the
592    /// first recorded switch is a trough.
593    #[test]
594    fn measure_oscillation_first_switch_is_trough() {
595        let osc = measure_oscillation(
596            &[52.0, 48.0],
597            &[999.0, 50.0, 46.0],
598            &[t(0), t(30), t(60), t(90), t(120)],
599            -1,
600            ControllerDirection::Reverse,
601            flow_pi_config(),
602            PvRange {
603                high: 100.0,
604                low: 0.0,
605            },
606            TuningMathCompat::default(),
607        );
608        // peaks_sum = 52.0 + 48.0 = 100.0 (all of it, short array). troughs_sum excludes
609        // index 0 (999.0, junk): 50.0 + 46.0 = 96.0.
610        // pv_amp_raw = (100.0 - 96.0) / 4 = 1.0
611        assert_approx(osc.pv_amp_raw, 1.0, 1e-5);
612    }
613
614    /// Direct action flips which sign of `mv_sign_init` selects the "peak" branch, since
615    /// `action_multiplier` itself flips sign — matching `perform_switch`'s
616    /// `mv_sign_next_step * action_multiplier == 1` peak/trough discriminant.
617    #[test]
618    fn measure_oscillation_direct_action_flips_discriminant() {
619        let osc = measure_oscillation(
620            &[999.0, 52.0, 48.0],
621            &[50.0, 46.0],
622            &[t(0), t(30), t(60), t(90), t(120)],
623            -1, // Direct's action_multiplier is -1, so mv_sign_init=-1 gives the same
624            // product (+1) that mv_sign_init=1 gave with Reverse above.
625            ControllerDirection::Direct,
626            flow_pi_config(),
627            PvRange {
628                high: 100.0,
629                low: 0.0,
630            },
631            TuningMathCompat::default(),
632        );
633        assert_approx(osc.pv_amp_raw, 1.0, 1e-5);
634    }
635
636    #[test]
637    #[should_panic(expected = "switch_times must have at least 2 entries")]
638    fn measure_oscillation_panics_on_too_few_switch_times() {
639        measure_oscillation(
640            &[52.0],
641            &[48.0],
642            &[t(0)], // only 1 entry, can't measure a period
643            1,
644            ControllerDirection::Reverse,
645            flow_pi_config(),
646            PvRange {
647                high: 100.0,
648                low: 0.0,
649            },
650            TuningMathCompat::default(),
651        );
652    }
653
654    #[test]
655    #[should_panic(expected = "switch_times.len() must equal 2 * num_cycles_count + 1")]
656    fn measure_oscillation_panics_on_mismatched_switch_times_length() {
657        measure_oscillation(
658            &[52.0, 49.0],
659            &[48.0],
660            &[t(0), t(30), t(60), t(90)], // 4, not 5
661            1,
662            ControllerDirection::Reverse,
663            flow_pi_config(),
664            PvRange {
665                high: 100.0,
666                low: 0.0,
667            },
668            TuningMathCompat::default(),
669        );
670    }
671
672    #[test]
673    #[should_panic(expected = "peaks.len() must be num_cycles_count + 1")]
674    fn measure_oscillation_panics_on_mismatched_peaks_length() {
675        measure_oscillation(
676            &[52.0], // should be length 3 (num_cycles_count + 1 = 2 + 1)
677            &[48.0, 47.0],
678            &[t(0), t(30), t(60), t(90), t(120)],
679            1,
680            ControllerDirection::Reverse,
681            flow_pi_config(),
682            PvRange {
683                high: 100.0,
684                low: 0.0,
685            },
686            TuningMathCompat::default(),
687        );
688    }
689
690    /// A run "lasting" >= 24 hours: the fixed (default) period calculation uses the true
691    /// total elapsed time, while the compat flag reproduces the legacy bug that silently
692    /// drops whole days (`TimeSpan.Hours` wraps at 24). Verified against the independent
693    /// Python oracle: 90000s total / 2 cycles => 750.0 minutes fixed, vs (90000 % 86400) =
694    /// 3600s => 30.0 minutes with the bug.
695    #[test]
696    fn measure_oscillation_period_truncation_bug_vs_fixed() {
697        let switch_times = [t(0), t(20_000), t(40_000), t(60_000), t(90_000)];
698
699        let fixed = measure_oscillation(
700            &[999.0, 52.0, 48.0],
701            &[50.0, 46.0],
702            &switch_times,
703            1,
704            ControllerDirection::Reverse,
705            flow_pi_config(),
706            PvRange {
707                high: 100.0,
708                low: 0.0,
709            },
710            TuningMathCompat::default(),
711        );
712        assert_approx(fixed.period_minutes, 750.0, 1e-3);
713
714        let buggy = measure_oscillation(
715            &[999.0, 52.0, 48.0],
716            &[50.0, 46.0],
717            &switch_times,
718            1,
719            ControllerDirection::Reverse,
720            flow_pi_config(),
721            PvRange {
722                high: 100.0,
723                low: 0.0,
724            },
725            TuningMathCompat {
726                replicate_period_truncation_bug: true,
727            },
728        );
729        assert_approx(buggy.period_minutes, 30.0, 1e-3);
730    }
731
732    /// A run lasting well under one second total: proves the *default* (non-compat) path
733    /// keeps millisecond precision rather than truncating to whole seconds the way
734    /// `TimeSpan.Seconds` does. Before this was fixed, elapsed time was computed via
735    /// `Duration::num_seconds()` *unconditionally* -- the compat flag only gated the extra
736    /// `% 86_400` day-wrap on top of it -- so any run completing in under a second (entirely
737    /// plausible for a fast loop with a short poll interval, and exactly the shape
738    /// `e2e-simulator`'s real-timing coverage exercises) silently collapsed `period_minutes`,
739    /// and therefore `ti_minutes`/`td_minutes`, to exactly zero even with
740    /// `TuningMathCompat::default()`.
741    #[test]
742    fn measure_oscillation_keeps_sub_second_precision_by_default() {
743        let t_ms = |offset_ms: i64| {
744            DateTime::<Utc>::UNIX_EPOCH + chrono::Duration::milliseconds(offset_ms)
745        };
746        // Same shape as `measure_oscillation_reverse_first_switch_is_peak` (2 cycles, first
747        // switch is a peak), but 300ms total elapsed instead of 120s.
748        let switch_times = [t_ms(0), t_ms(75), t_ms(150), t_ms(225), t_ms(300)];
749
750        let osc = measure_oscillation(
751            &[999.0, 52.0, 48.0],
752            &[50.0, 46.0],
753            &switch_times,
754            1,
755            ControllerDirection::Reverse,
756            flow_pi_config(),
757            PvRange {
758                high: 100.0,
759                low: 0.0,
760            },
761            TuningMathCompat::default(),
762        );
763
764        // period = (0.3s / 60) / 2 cycles = 0.0025 minutes -- small, but the bug this guards
765        // against collapsed it all the way to exactly 0.0, not just imprecisely small.
766        assert_approx(osc.period_minutes, 0.3 / 60.0 / 2.0, 1e-8);
767        assert!(
768            osc.period_minutes > 0.0,
769            "a sub-second run's period must not collapse to exactly zero"
770        );
771    }
772
773    #[test]
774    fn measure_oscillation_scales_amplitude_by_cycle_count() {
775        let config = LoopConfig {
776            num_cycles_count: 3,
777            ..flow_pi_config()
778        };
779        let osc = measure_oscillation(
780            &[999.0, 52.0, 52.0, 52.0],
781            &[50.0, 50.0, 50.0],
782            &[t(0), t(10), t(20), t(30), t(40), t(50), t(60)],
783            1,
784            ControllerDirection::Reverse,
785            config,
786            PvRange {
787                high: 100.0,
788                low: 0.0,
789            },
790            TuningMathCompat::default(),
791        );
792        assert_approx(osc.pv_amp_raw, 1.0, 1e-6);
793    }
794
795    #[test]
796    fn measure_oscillation_uses_nonzero_range_floor_in_amplitude_percent() {
797        let osc = measure_oscillation(
798            &[999.0, 52.0, 48.0],
799            &[50.0, 46.0],
800            &[t(0), t(30), t(60), t(90), t(120)],
801            1,
802            ControllerDirection::Reverse,
803            flow_pi_config(),
804            PvRange {
805                high: 100.0,
806                low: 20.0,
807            },
808            TuningMathCompat::default(),
809        );
810        assert_approx(osc.pv_amp_raw, 1.0, 1e-6);
811        assert_approx(osc.pv_amp_percent, 1.25, 1e-6);
812    }
813
814    // --- calculate_tuning_result -------------------------------------------------------------
815
816    /// Verified against the independent Python oracle for Flow/PI/Aggressive
817    /// (c1=0.451, c2=0.331, c3=0.0, from `constants::lookup`).
818    #[test]
819    fn calculate_tuning_result_flow_pi_aggressive() {
820        let osc = Oscillation {
821            period_minutes: 1.0,
822            frequency: 2.0 * std::f32::consts::PI,
823            pv_amp_raw: 0.25,
824            pv_amp_percent: 0.25,
825        };
826        let result = calculate_tuning_result(osc, flow_pi_config(), ResponseLevel::Aggressive);
827        // kp = 0.451 * 4 * 5.0 / (pi * 0.25) = 9.02 / 0.7853981... = 11.4854...
828        assert_approx(result.kp, 11.4854, 1e-2);
829        // ti_minutes = 0.331 * 2*pi / (2*pi) = 0.331
830        assert_approx(result.ti_minutes, 0.331, 1e-5);
831        // td_minutes = 0.0 (c3 is 0 for PI)
832        assert_approx(result.td_minutes, 0.0, 1e-6);
833    }
834
835    /// `c2`/`c3` don't vary by response level, so `ti_minutes`/`td_minutes` must be identical
836    /// across all three levels for the same `Oscillation` — only `kp` (driven by `c1`) varies.
837    #[test]
838    fn calculate_tuning_result_ti_td_invariant_across_response_levels() {
839        let osc = Oscillation {
840            period_minutes: 1.0,
841            frequency: 2.0 * std::f32::consts::PI,
842            pv_amp_raw: 0.25,
843            pv_amp_percent: 0.25,
844        };
845        let config = flow_pi_config();
846        let aggressive = calculate_tuning_result(osc, config, ResponseLevel::Aggressive);
847        let moderate = calculate_tuning_result(osc, config, ResponseLevel::Moderate);
848        let sluggish = calculate_tuning_result(osc, config, ResponseLevel::Sluggish);
849
850        assert_eq!(aggressive.ti_minutes, moderate.ti_minutes);
851        assert_eq!(moderate.ti_minutes, sluggish.ti_minutes);
852        assert_eq!(aggressive.td_minutes, moderate.td_minutes);
853        assert!(aggressive.kp > moderate.kp);
854        assert!(moderate.kp > sluggish.kp);
855    }
856
857    #[test]
858    fn calculate_tuning_result_pid_derivative_uses_frequency_division() {
859        let osc = Oscillation {
860            period_minutes: 1.0,
861            frequency: 3.0,
862            pv_amp_raw: 1.0,
863            pv_amp_percent: 1.0,
864        };
865        let config = LoopConfig {
866            process_type: ProcessType::TemperatureMixing,
867            controller_type: ControllerType::Pid,
868            ..flow_pi_config()
869        };
870        let result = calculate_tuning_result(osc, config, ResponseLevel::Moderate);
871        let expected = 0.14 * 2.0 * std::f32::consts::PI / 3.0;
872        assert_approx(result.td_minutes, expected, 1e-6);
873    }
874
875    // --- calculate_pid_parameters -------------------------------------------------------------
876
877    fn sample_result() -> TuningResult {
878        TuningResult {
879            response_level: ResponseLevel::Moderate,
880            kp: 2.0,
881            ti_minutes: 4.0,
882            td_minutes: 0.5,
883        }
884    }
885
886    #[test]
887    fn proportional_gain_passes_through_kp_unchanged() {
888        let mut template = template::built_in_templates().remove(1); // Honeywell: Kp
889        template.proportional_type = ProportionalType::Gain;
890        let pid = calculate_pid_parameters(sample_result(), &template);
891        assert_approx(pid.proportional, 2.0, 1e-6);
892    }
893
894    #[test]
895    fn proportional_band_is_100_over_kp() {
896        let mut template = template::built_in_templates().remove(0); // Yokogawa: PB
897        template.proportional_type = ProportionalType::Band;
898        let pid = calculate_pid_parameters(sample_result(), &template);
899        assert_approx(pid.proportional, 50.0, 1e-6); // 100 / 2.0
900    }
901
902    #[test]
903    fn reset_time_minutes_passes_through_unchanged() {
904        let mut template = template::built_in_templates().remove(1);
905        template.integral_type = IntegralType::ResetTime;
906        template.integral_unit = TimeUnit::Minutes;
907        let pid = calculate_pid_parameters(sample_result(), &template);
908        assert_approx(pid.integral, 4.0, 1e-6);
909    }
910
911    #[test]
912    fn reset_time_seconds_converts_minutes_to_seconds() {
913        let mut template = template::built_in_templates().remove(1);
914        template.integral_type = IntegralType::ResetTime;
915        template.integral_unit = TimeUnit::Seconds;
916        let pid = calculate_pid_parameters(sample_result(), &template);
917        assert_approx(pid.integral, 240.0, 1e-4); // 4.0 * 60
918    }
919
920    /// The order-of-operations subtlety: unit conversion happens *before* the reset-gain
921    /// transform, so with `integral_unit = Seconds`, Ki is computed from Ti-in-seconds
922    /// (`kp / (ti_minutes * 60)`), not from Ti-in-minutes (`kp / ti_minutes`) — these give
923    /// very different numeric results (2.0/240.0 = 0.008333... vs 2.0/4.0 = 0.5), so this
924    /// test would fail if the conversion order were swapped.
925    #[test]
926    fn reset_gain_uses_the_already_unit_converted_integral_time() {
927        let mut template = template::built_in_templates().remove(1);
928        template.integral_type = IntegralType::ResetGain;
929        template.integral_unit = TimeUnit::Seconds;
930        let pid = calculate_pid_parameters(sample_result(), &template);
931        assert_approx(pid.integral, 2.0 / 240.0, 1e-6);
932    }
933
934    #[test]
935    fn reset_rate_is_reciprocal_of_unit_converted_integral_time() {
936        let mut template = template::built_in_templates().remove(1);
937        template.integral_type = IntegralType::ResetRate;
938        template.integral_unit = TimeUnit::Minutes;
939        let pid = calculate_pid_parameters(sample_result(), &template);
940        assert_approx(pid.integral, 1.0 / 4.0, 1e-6);
941    }
942
943    #[test]
944    fn derivative_time_seconds_converts_minutes_to_seconds() {
945        let mut template = template::built_in_templates().remove(1);
946        template.derivative_type = DerivativeType::DerivativeTime;
947        template.derivative_unit = TimeUnit::Seconds;
948        let pid = calculate_pid_parameters(sample_result(), &template);
949        assert_approx(pid.derivative, 30.0, 1e-4); // 0.5 * 60
950    }
951
952    #[test]
953    fn derivative_gain_uses_the_already_unit_converted_derivative_time() {
954        let mut template = template::built_in_templates().remove(1);
955        template.derivative_type = DerivativeType::DerivativeGain;
956        template.derivative_unit = TimeUnit::Seconds;
957        let pid = calculate_pid_parameters(sample_result(), &template);
958        assert_approx(pid.derivative, 2.0 * 30.0, 1e-4); // kp * (0.5*60)
959    }
960
961    // --- opc_write_values ----------------------------------------------------------------
962
963    fn sample_pid() -> PidParameters {
964        PidParameters {
965            response_level: ResponseLevel::Moderate,
966            proportional: 2.0,
967            integral: 0.25,
968            derivative: 1.5,
969        }
970    }
971
972    #[test]
973    fn pi_and_pid_write_the_real_integral_value() {
974        for controller_type in [ControllerType::Pi, ControllerType::Pid] {
975            let values = opc_write_values(sample_pid(), controller_type, IntegralType::ResetTime);
976            assert_approx(values.integral, 0.25, 1e-6);
977        }
978    }
979
980    #[test]
981    fn p_only_reset_time_writes_9999_sentinel_for_integral() {
982        let values = opc_write_values(sample_pid(), ControllerType::P, IntegralType::ResetTime);
983        assert_approx(values.integral, 9999.0, 1e-6);
984    }
985
986    #[test]
987    fn p_only_reset_rate_or_gain_writes_zero_for_integral() {
988        for integral_type in [IntegralType::ResetRate, IntegralType::ResetGain] {
989            let values = opc_write_values(sample_pid(), ControllerType::P, integral_type);
990            assert_approx(values.integral, 0.0, 1e-6);
991        }
992    }
993
994    #[test]
995    fn only_pid_writes_the_real_derivative_value() {
996        let values = opc_write_values(sample_pid(), ControllerType::Pid, IntegralType::ResetTime);
997        assert_approx(values.derivative, 1.5, 1e-6);
998    }
999
1000    #[test]
1001    fn p_and_pi_write_zero_for_derivative() {
1002        for controller_type in [ControllerType::P, ControllerType::Pi] {
1003            let values = opc_write_values(sample_pid(), controller_type, IntegralType::ResetTime);
1004            assert_approx(values.derivative, 0.0, 1e-6);
1005        }
1006    }
1007
1008    #[test]
1009    fn opc_write_values_always_passes_through_proportional_and_response_level() {
1010        let values = opc_write_values(sample_pid(), ControllerType::P, IntegralType::ResetTime);
1011        assert_approx(values.proportional, 2.0, 1e-6);
1012        assert_eq!(values.response_level, ResponseLevel::Moderate);
1013    }
1014
1015    // --- calculate_all + integration with a real MrftEngine run -----------------------------
1016
1017    #[test]
1018    fn calculate_all_produces_three_distinct_kp_with_shared_ti_td() {
1019        let template = template::built_in_templates().remove(1); // Honeywell: Kp, Ti/Td minutes
1020        let results = calculate_all(
1021            &[999.0, 52.0, 48.0],
1022            &[50.0, 46.0],
1023            &[t(0), t(30), t(60), t(90), t(120)],
1024            1,
1025            ControllerDirection::Reverse,
1026            flow_pi_config(),
1027            PvRange {
1028                high: 100.0,
1029                low: 0.0,
1030            },
1031            &template,
1032            TuningMathCompat::default(),
1033        );
1034
1035        assert_eq!(results[0].1.response_level, ResponseLevel::Aggressive);
1036        assert_eq!(results[1].1.response_level, ResponseLevel::Moderate);
1037        assert_eq!(results[2].1.response_level, ResponseLevel::Sluggish);
1038        assert!(results[0].1.proportional > results[1].1.proportional);
1039        assert!(results[1].1.proportional > results[2].1.proportional);
1040        // Ti (here: `integral`, since Honeywell uses ResetTime/Minutes) is shared.
1041        assert_eq!(results[0].1.integral, results[1].1.integral);
1042        assert_eq!(results[1].1.integral, results[2].1.integral);
1043        // The intermediate TuningResult (Kp/Ti/Td) is also present alongside PidParameters.
1044        assert_eq!(results[0].0.response_level, ResponseLevel::Aggressive);
1045        assert!(results[0].0.kp > 0.0);
1046    }
1047
1048    fn checked_fixture(
1049        peaks: &[f32],
1050        troughs: &[f32],
1051        switch_times: &[DateTime<Utc>],
1052        config: LoopConfig,
1053        template: &DcsTemplate,
1054    ) -> [CheckedTuningResult; 3] {
1055        calculate_all_checked(
1056            peaks,
1057            troughs,
1058            switch_times,
1059            1,
1060            ControllerDirection::Reverse,
1061            config,
1062            PvRange {
1063                high: 100.0,
1064                low: 0.0,
1065            },
1066            template,
1067            TuningMathCompat::default(),
1068        )
1069    }
1070
1071    #[test]
1072    fn checked_calculation_rejects_zero_pv_amplitude_for_every_response_level() {
1073        let config = flow_pi_config();
1074        let template = template::built_in_templates().remove(0);
1075        let results = checked_fixture(
1076            &[2.25, 2.25, 2.25],
1077            &[2.25, 2.25],
1078            &[t(0), t(30), t(60), t(90), t(120)],
1079            config,
1080            &template,
1081        );
1082
1083        for result in results {
1084            assert_eq!(result.status, TuningResultStatus::Invalid);
1085            assert_eq!(
1086                result.invalid_reason,
1087                Some(TuningResultInvalidReason::NonPositivePvAmplitude)
1088            );
1089            assert!(result.tuning.is_none());
1090            assert!(result.pid.is_none());
1091            assert!(result.usable_values().is_none());
1092        }
1093    }
1094
1095    #[test]
1096    fn checked_calculation_rejects_negative_pv_amplitude() {
1097        let config = flow_pi_config();
1098        let template = template::built_in_templates().remove(0);
1099        let results = checked_fixture(
1100            &[1.0, 1.0, 1.0],
1101            &[2.0, 2.0],
1102            &[t(0), t(30), t(60), t(90), t(120)],
1103            config,
1104            &template,
1105        );
1106
1107        assert!(results.iter().all(|result| {
1108            result.status == TuningResultStatus::Invalid
1109                && result.invalid_reason == Some(TuningResultInvalidReason::NonPositivePvAmplitude)
1110        }));
1111    }
1112
1113    #[test]
1114    fn checked_calculation_rejects_non_finite_pv_amplitude() {
1115        let config = flow_pi_config();
1116        let template = template::built_in_templates().remove(0);
1117        let results = checked_fixture(
1118            &[1.0, f32::NAN, 1.0],
1119            &[0.0, 0.0],
1120            &[t(0), t(30), t(60), t(90), t(120)],
1121            config,
1122            &template,
1123        );
1124
1125        assert!(results.iter().all(|result| {
1126            result.status == TuningResultStatus::Invalid
1127                && result.invalid_reason == Some(TuningResultInvalidReason::NonFinitePvAmplitude)
1128        }));
1129    }
1130
1131    #[test]
1132    fn checked_calculation_rejects_a_zero_period_before_non_finite_frequency() {
1133        let config = flow_pi_config();
1134        let template = template::built_in_templates().remove(0);
1135        let results = checked_fixture(
1136            &[1.0, 2.0, 1.5],
1137            &[0.0, 0.0],
1138            &[t(0), t(0), t(0), t(0), t(0)],
1139            config,
1140            &template,
1141        );
1142
1143        assert!(results.iter().all(|result| {
1144            result.status == TuningResultStatus::Invalid
1145                && result.invalid_reason == Some(TuningResultInvalidReason::NonPositivePeriod)
1146        }));
1147    }
1148
1149    #[test]
1150    fn checked_calculation_accepts_finite_zero_terms_for_a_p_only_result() {
1151        let config = LoopConfig {
1152            controller_type: ControllerType::P,
1153            ..flow_pi_config()
1154        };
1155        let mut template = template::built_in_templates().remove(0);
1156        template.integral_type = IntegralType::ResetTime;
1157        let results = checked_fixture(
1158            &[60.0, 52.0, 48.0],
1159            &[50.0, 46.0],
1160            &[t(0), t(30), t(60), t(90), t(120)],
1161            config,
1162            &template,
1163        );
1164
1165        for result in results {
1166            assert_eq!(result.status, TuningResultStatus::Valid);
1167            assert!(result.invalid_reason.is_none());
1168            let (tuning, pid) = result.usable_values().expect("result should be usable");
1169            assert!(tuning.kp.is_finite() && tuning.kp > 0.0);
1170            assert!(pid.proportional.is_finite() && pid.proportional > 0.0);
1171            assert_eq!(pid.integral, 0.0);
1172            assert_eq!(pid.derivative, 0.0);
1173        }
1174    }
1175
1176    #[test]
1177    fn checked_calculation_rejects_non_finite_kp() {
1178        let mut config = flow_pi_config();
1179        config.relay_amp_percent = f32::INFINITY;
1180        let template = template::built_in_templates().remove(0);
1181        let results = checked_fixture(
1182            &[60.0, 52.0, 48.0],
1183            &[50.0, 46.0],
1184            &[t(0), t(30), t(60), t(90), t(120)],
1185            config,
1186            &template,
1187        );
1188
1189        assert!(results.iter().all(|result| {
1190            result.status == TuningResultStatus::Invalid
1191                && result.invalid_reason == Some(TuningResultInvalidReason::NonFiniteKp)
1192        }));
1193    }
1194
1195    #[test]
1196    fn invalid_result_reasons_have_stable_operator_messages() {
1197        let cases = [
1198            (
1199                TuningResultInvalidReason::NonFinitePvAmplitude,
1200                "PV amplitude is not finite",
1201            ),
1202            (
1203                TuningResultInvalidReason::NonPositivePvAmplitude,
1204                "PV amplitude is not positive",
1205            ),
1206            (
1207                TuningResultInvalidReason::NonFinitePeriod,
1208                "oscillation period is not finite",
1209            ),
1210            (
1211                TuningResultInvalidReason::NonPositivePeriod,
1212                "oscillation period is not positive",
1213            ),
1214            (
1215                TuningResultInvalidReason::NonFiniteFrequency,
1216                "oscillation frequency is not finite",
1217            ),
1218            (
1219                TuningResultInvalidReason::NonPositiveFrequency,
1220                "oscillation frequency is not positive",
1221            ),
1222            (TuningResultInvalidReason::NonFiniteKp, "Kp is not finite"),
1223            (
1224                TuningResultInvalidReason::NonFiniteTiMinutes,
1225                "Ti is not finite",
1226            ),
1227            (
1228                TuningResultInvalidReason::NonFiniteTdMinutes,
1229                "Td is not finite",
1230            ),
1231            (
1232                TuningResultInvalidReason::NonFiniteProportional,
1233                "proportional value is not finite",
1234            ),
1235            (
1236                TuningResultInvalidReason::NonFiniteIntegral,
1237                "integral value is not finite",
1238            ),
1239            (
1240                TuningResultInvalidReason::NonFiniteDerivative,
1241                "derivative value is not finite",
1242            ),
1243        ];
1244
1245        for (reason, expected) in cases {
1246            assert_eq!(reason.to_string(), expected);
1247        }
1248    }
1249
1250    #[test]
1251    fn checked_calculation_classifies_every_oscillation_guard() {
1252        let valid = Oscillation {
1253            period_minutes: 1.0,
1254            frequency: 1.0,
1255            pv_amp_raw: 1.0,
1256            pv_amp_percent: 1.0,
1257        };
1258        let cases = [
1259            (
1260                Oscillation {
1261                    pv_amp_raw: f32::NAN,
1262                    ..valid
1263                },
1264                Some(TuningResultInvalidReason::NonFinitePvAmplitude),
1265            ),
1266            (
1267                Oscillation {
1268                    pv_amp_raw: 0.0,
1269                    ..valid
1270                },
1271                Some(TuningResultInvalidReason::NonPositivePvAmplitude),
1272            ),
1273            (
1274                Oscillation {
1275                    period_minutes: f32::NAN,
1276                    ..valid
1277                },
1278                Some(TuningResultInvalidReason::NonFinitePeriod),
1279            ),
1280            (
1281                Oscillation {
1282                    period_minutes: 0.0,
1283                    ..valid
1284                },
1285                Some(TuningResultInvalidReason::NonPositivePeriod),
1286            ),
1287            (
1288                Oscillation {
1289                    frequency: f32::NAN,
1290                    ..valid
1291                },
1292                Some(TuningResultInvalidReason::NonFiniteFrequency),
1293            ),
1294            (
1295                Oscillation {
1296                    frequency: 0.0,
1297                    ..valid
1298                },
1299                Some(TuningResultInvalidReason::NonPositiveFrequency),
1300            ),
1301            (valid, None),
1302        ];
1303
1304        for (oscillation, expected) in cases {
1305            assert_eq!(invalid_oscillation_reason(oscillation), expected);
1306        }
1307    }
1308
1309    fn sample_tuning() -> TuningResult {
1310        TuningResult {
1311            response_level: ResponseLevel::Moderate,
1312            kp: 2.0,
1313            ti_minutes: 4.0,
1314            td_minutes: 0.5,
1315        }
1316    }
1317
1318    fn assert_invalid_checked_result(
1319        tuning: TuningResult,
1320        template: &DcsTemplate,
1321        reason: TuningResultInvalidReason,
1322    ) {
1323        let result = checked_result_from_tuning(tuning, template);
1324        assert_eq!(result.status, TuningResultStatus::Invalid);
1325        assert_eq!(result.invalid_reason, Some(reason));
1326        assert!(result.tuning.is_none());
1327        assert!(result.pid.is_none());
1328    }
1329
1330    #[test]
1331    fn checked_calculation_classifies_every_calculated_value_guard() {
1332        let gain_template = template::built_in_templates().remove(1);
1333        assert_invalid_checked_result(
1334            TuningResult {
1335                kp: f32::INFINITY,
1336                ..sample_tuning()
1337            },
1338            &gain_template,
1339            TuningResultInvalidReason::NonFiniteKp,
1340        );
1341        assert_invalid_checked_result(
1342            TuningResult {
1343                ti_minutes: f32::INFINITY,
1344                ..sample_tuning()
1345            },
1346            &gain_template,
1347            TuningResultInvalidReason::NonFiniteTiMinutes,
1348        );
1349        assert_invalid_checked_result(
1350            TuningResult {
1351                td_minutes: f32::INFINITY,
1352                ..sample_tuning()
1353            },
1354            &gain_template,
1355            TuningResultInvalidReason::NonFiniteTdMinutes,
1356        );
1357
1358        let band_template = template::built_in_templates().remove(0);
1359        assert_invalid_checked_result(
1360            TuningResult {
1361                kp: 0.0,
1362                ..sample_tuning()
1363            },
1364            &band_template,
1365            TuningResultInvalidReason::NonFiniteProportional,
1366        );
1367
1368        let mut reset_rate_template = gain_template.clone();
1369        reset_rate_template.integral_type = IntegralType::ResetRate;
1370        assert_invalid_checked_result(
1371            TuningResult {
1372                ti_minutes: 0.0,
1373                ..sample_tuning()
1374            },
1375            &reset_rate_template,
1376            TuningResultInvalidReason::NonFiniteIntegral,
1377        );
1378
1379        let mut derivative_gain_template = gain_template;
1380        derivative_gain_template.derivative_type = DerivativeType::DerivativeGain;
1381        assert_invalid_checked_result(
1382            TuningResult {
1383                kp: f32::MAX,
1384                td_minutes: f32::MAX,
1385                ..sample_tuning()
1386            },
1387            &derivative_gain_template,
1388            TuningResultInvalidReason::NonFiniteDerivative,
1389        );
1390
1391        let valid = checked_result_from_tuning(sample_tuning(), &template::built_in_templates()[1]);
1392        assert_eq!(valid.status, TuningResultStatus::Valid);
1393        assert!(valid.usable_values().is_some());
1394    }
1395
1396    /// Fields unpacked from a real engine's `Action::Complete`, named here purely to keep
1397    /// clippy's `type_complexity` lint happy for the one test that needs them.
1398    type CompletionFields = (Vec<f32>, Vec<f32>, Vec<DateTime<Utc>>, i8);
1399
1400    /// End-to-end: run a real `MrftEngine` to completion and feed its `Action::Complete`
1401    /// straight into `calculate_all`, proving the two modules compose without needing any
1402    /// glue beyond what `Action::Complete` already carries.
1403    #[test]
1404    fn calculate_all_consumes_a_real_mrft_engine_completion() {
1405        use crate::mrft::{Action, InitialReadings, MrftCompat, MrftEngine, Tick};
1406        use std::collections::VecDeque;
1407
1408        let config = flow_pi_config();
1409        let tc = lookup(
1410            config.process_type,
1411            config.controller_type,
1412            ResponseLevel::Aggressive,
1413        );
1414        let initial = InitialReadings {
1415            pv_ini: 50.0,
1416            mv_ini: 50.0,
1417            mv_range_low: 0.0,
1418            mv_range_high: 100.0,
1419        };
1420        let mut engine = MrftEngine::new(
1421            config,
1422            ControllerDirection::Reverse,
1423            tc.beta,
1424            initial,
1425            t(0),
1426            MrftCompat::default(),
1427        );
1428
1429        // A minimal first-order-plus-dead-time process reacting to the engine's own
1430        // relay-driven MV: pv exponentially approaches a target set by a *delayed* view of
1431        // whatever MV the engine last wrote (gain 1.0, i.e. mv_ini +/- relay_amp_raw maps
1432        // directly to the pv target). The dead time is essential: without it, the relay's
1433        // own hysteresis is computed from the same tick's pv that decides the switch, so a
1434        // memoryless (zero-delay) process causes it to flip every single tick (chattering),
1435        // and every recorded "peak"/"trough" degenerates to exactly `pv_ini` (never having
1436        // had more than one, wrongly-signed sample to update the tracked extremum before the
1437        // next reset). Five ticks of dead time plus a mild lag reproduces genuine relay
1438        // feedback behavior — a real, sustained square-wave-driven oscillation with distinct
1439        // peaks/troughs — matching the classic Åström-Hägglund relay auto-tuning method,
1440        // which fundamentally relies on process phase lag to sustain oscillation.
1441        const DELAY_TICKS: usize = 5;
1442        const LAG: f32 = 0.2;
1443        let mut pv = initial.pv_ini;
1444        let mut mv_value_current = initial.mv_ini;
1445        let mut mv_history: VecDeque<f32> =
1446            std::iter::repeat_n(initial.mv_ini, DELAY_TICKS).collect();
1447        let mut completion: Option<CompletionFields> = None;
1448        for i in 1..=200 {
1449            let delayed_mv = *mv_history.front().expect("fixed-size, never empty");
1450            let target = initial.pv_ini + (delayed_mv - initial.mv_ini);
1451            pv += (target - pv) * LAG;
1452            mv_history.pop_front();
1453            mv_history.push_back(mv_value_current);
1454
1455            let actions = engine.step(Tick { time: t(i), pv });
1456            for action in actions {
1457                match action {
1458                    Action::WriteMv(new_mv) => mv_value_current = new_mv,
1459                    Action::Complete {
1460                        peaks,
1461                        troughs,
1462                        switch_times,
1463                        mv_sign_init,
1464                    } => completion = Some((peaks, troughs, switch_times, mv_sign_init)),
1465                }
1466            }
1467            if completion.is_some() {
1468                break;
1469            }
1470        }
1471
1472        let (peaks, troughs, switch_times, mv_sign_init) =
1473            completion.expect("engine should complete within 200 ticks");
1474
1475        let template = template::built_in_templates().remove(1);
1476        let results = calculate_all(
1477            &peaks,
1478            &troughs,
1479            &switch_times,
1480            mv_sign_init,
1481            ControllerDirection::Reverse,
1482            config,
1483            PvRange {
1484                high: 100.0,
1485                low: 0.0,
1486            },
1487            &template,
1488            TuningMathCompat::default(),
1489        );
1490
1491        // Just confirm the pipeline produced finite, sane-signed output — the precise
1492        // numeric values are already covered by the synthetic-array tests above.
1493        for (tuning, pid) in &results {
1494            assert!(tuning.kp.is_finite() && tuning.kp > 0.0);
1495            assert!(pid.proportional.is_finite() && pid.proportional > 0.0);
1496            assert!(pid.integral.is_finite() && pid.integral > 0.0);
1497        }
1498    }
1499
1500    // --- serde round trips -------------------------------------------------------------------
1501
1502    #[test]
1503    fn pv_range_serde_round_trip() {
1504        let range = PvRange {
1505            high: 100.0,
1506            low: 0.0,
1507        };
1508        let json = serde_json::to_string(&range).unwrap();
1509        let back: PvRange = serde_json::from_str(&json).unwrap();
1510        assert_eq!(range, back);
1511    }
1512
1513    #[test]
1514    fn oscillation_serde_round_trip() {
1515        let osc = Oscillation {
1516            period_minutes: 1.0,
1517            frequency: 7.5,
1518            pv_amp_raw: 0.25,
1519            pv_amp_percent: 0.25,
1520        };
1521        let json = serde_json::to_string(&osc).unwrap();
1522        let back: Oscillation = serde_json::from_str(&json).unwrap();
1523        assert_eq!(osc, back);
1524    }
1525
1526    #[test]
1527    fn tuning_result_serde_round_trip() {
1528        let result = sample_result();
1529        let json = serde_json::to_string(&result).unwrap();
1530        let back: TuningResult = serde_json::from_str(&json).unwrap();
1531        assert_eq!(result, back);
1532    }
1533
1534    #[test]
1535    fn pid_parameters_serde_round_trip() {
1536        let pid = PidParameters {
1537            response_level: ResponseLevel::Aggressive,
1538            proportional: 2.0,
1539            integral: 4.0,
1540            derivative: 0.5,
1541        };
1542        let json = serde_json::to_string(&pid).unwrap();
1543        let back: PidParameters = serde_json::from_str(&json).unwrap();
1544        assert_eq!(pid, back);
1545    }
1546
1547    #[test]
1548    fn opc_write_values_serde_round_trip() {
1549        let values = OpcWriteValues {
1550            response_level: ResponseLevel::Aggressive,
1551            proportional: 2.0,
1552            integral: 9999.0,
1553            derivative: 0.0,
1554        };
1555        let json = serde_json::to_string(&values).unwrap();
1556        let back: OpcWriteValues = serde_json::from_str(&json).unwrap();
1557        assert_eq!(values, back);
1558    }
1559}