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 the MRFT engine, 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::{
20        DerivativeType, IntegralType, PidRounding, PidRoundingError, ProportionalType,
21        RoundedPidValue, TimeUnit,
22    },
23    range::PvRange,
24    template::DcsTemplate,
25};
26
27/// Oscillation measurements derived from a completed MRFT run, before applying any
28/// response-level-specific Kp multiplier. Pure port of the period/frequency/amplitude portion
29/// of `TuningConstantsCalc`.
30#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
31pub struct Oscillation {
32    pub period_minutes: f32,
33    pub frequency: f32,
34    pub pv_amp_raw: f32,
35    pub pv_amp_percent: f32,
36}
37
38/// Calculated Kp/Ti/Td for one [`ResponseLevel`], before DCS-specific unit conversion. Pure
39/// port of the Kp/Ti/Td portion of `TuningConstantsCalc`. `ti_minutes`/`td_minutes` are
40/// identical across all three response levels for a given run — only `kp` varies — since
41/// `C2`/`C3` don't vary by response level (see [`crate::constants::lookup`]).
42#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
43pub struct TuningResult {
44    pub response_level: ResponseLevel,
45    pub kp: f32,
46    pub ti_minutes: f32,
47    pub td_minutes: f32,
48}
49
50/// The final PID parameters in a DCS/PLC template's own representation (e.g. proportional
51/// band instead of gain, reset rate instead of reset time, seconds instead of minutes) — pure
52/// port of `CalculatePIDparameters`, applied to one [`TuningResult`].
53#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
54pub struct PidParameters {
55    pub response_level: ResponseLevel,
56    pub proportional: f32,
57    pub integral: f32,
58    pub derivative: f32,
59}
60
61/// The literal values to write back to the DCS/PLC for one [`PidParameters`] — see
62/// [`opc_write_values`]. Distinct from [`PidParameters`] because integral/derivative may
63/// differ from the calculated values for controller types that don't use one or both terms.
64#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
65pub struct OpcWriteValues {
66    pub response_level: ResponseLevel,
67    pub proportional: f32,
68    pub integral: f32,
69    pub derivative: f32,
70}
71
72/// Whether a calculated response-level result contains values that are safe to use.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
75#[serde(rename_all = "snake_case")]
76pub enum TuningResultStatus {
77    Valid,
78    Invalid,
79}
80
81/// Why a calculated response-level result was marked invalid.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
84#[serde(rename_all = "snake_case")]
85pub enum TuningResultInvalidReason {
86    NonFinitePvAmplitude,
87    NonPositivePvAmplitude,
88    NonFinitePeriod,
89    NonPositivePeriod,
90    NonFiniteFrequency,
91    NonPositiveFrequency,
92    NonFiniteKp,
93    NonFiniteTiMinutes,
94    NonFiniteTdMinutes,
95    NonFiniteProportional,
96    NonFiniteIntegral,
97    NonFiniteDerivative,
98}
99
100impl std::fmt::Display for TuningResultInvalidReason {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        let message = match self {
103            Self::NonFinitePvAmplitude => "PV amplitude is not finite",
104            Self::NonPositivePvAmplitude => "PV amplitude is not positive",
105            Self::NonFinitePeriod => "oscillation period is not finite",
106            Self::NonPositivePeriod => "oscillation period is not positive",
107            Self::NonFiniteFrequency => "oscillation frequency is not finite",
108            Self::NonPositiveFrequency => "oscillation frequency is not positive",
109            Self::NonFiniteKp => "Kp is not finite",
110            Self::NonFiniteTiMinutes => "Ti is not finite",
111            Self::NonFiniteTdMinutes => "Td is not finite",
112            Self::NonFiniteProportional => "proportional value is not finite",
113            Self::NonFiniteIntegral => "integral value is not finite",
114            Self::NonFiniteDerivative => "derivative value is not finite",
115        };
116        f.write_str(message)
117    }
118}
119
120/// A checked result for one response level.
121///
122/// Invalid results retain their response level and an explicit diagnostic reason, but never
123/// expose numeric tuning/PID values for callers to accidentally write or display as usable
124/// constants.
125#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
126pub struct CheckedTuningResult {
127    pub response_level: ResponseLevel,
128    pub status: TuningResultStatus,
129    pub invalid_reason: Option<TuningResultInvalidReason>,
130    pub tuning: Option<TuningResult>,
131    pub pid: Option<PidParameters>,
132}
133
134impl CheckedTuningResult {
135    fn invalid(response_level: ResponseLevel, reason: TuningResultInvalidReason) -> Self {
136        Self {
137            response_level,
138            status: TuningResultStatus::Invalid,
139            invalid_reason: Some(reason),
140            tuning: None,
141            pid: None,
142        }
143    }
144
145    fn valid(tuning: TuningResult, pid: PidParameters) -> Self {
146        Self {
147            response_level: tuning.response_level,
148            status: TuningResultStatus::Valid,
149            invalid_reason: None,
150            tuning: Some(tuning),
151            pid: Some(pid),
152        }
153    }
154
155    /// Returns the calculated values only when this result is safe to use.
156    pub fn usable_values(&self) -> Option<(TuningResult, PidParameters)> {
157        match (self.status, self.invalid_reason, self.tuning, self.pid) {
158            (TuningResultStatus::Valid, None, Some(tuning), Some(pid)) => Some((tuning, pid)),
159            _ => None,
160        }
161    }
162}
163
164/// Computes oscillation measurements (period, frequency, PV amplitude) from a completed MRFT
165/// run's recorded peaks/troughs/switch-times. Pure port of `TuningConstantsCalc`'s
166/// period/frequency/amplitude math — everything before the per-response-level Kp/Ti/Td split,
167/// which [`calculate_tuning_result`] handles.
168///
169/// `peaks`/`troughs`/`switch_times`/`mv_sign_init` come directly from a completed
170/// [`crate::mrft::Action::Complete`]; `direction` and `config.num_cycles_count` must be the
171/// same values the [`crate::mrft::MrftEngine`] that produced them was built with.
172///
173/// # Panics
174/// Panics if `switch_times` has fewer than 2 entries, or if `peaks`/`troughs` don't have the
175/// lengths a real `Action::Complete` for `config.num_cycles_count` cycles always has (one of
176/// them `num_cycles_count`, the other `num_cycles_count + 1`, alternating from the first
177/// recorded switch's direction). Both are impossible outputs from [`crate::mrft::MrftEngine`]
178/// and indicate a caller bug, not a runtime data problem.
179pub fn measure_oscillation(
180    peaks: &[f32],
181    troughs: &[f32],
182    switch_times: &[DateTime<Utc>],
183    mv_sign_init: i8,
184    direction: ControllerDirection,
185    config: LoopConfig,
186    pv_range: PvRange,
187) -> Oscillation {
188    let num_cycles_count = config.num_cycles_count;
189    assert!(
190        switch_times.len() >= 2,
191        "switch_times must have at least 2 entries to measure a period, got {}",
192        switch_times.len()
193    );
194    assert_eq!(
195        switch_times.len(),
196        num_cycles_count as usize * 2 + 1,
197        "switch_times.len() must equal 2 * num_cycles_count + 1"
198    );
199
200    // Whichever of peaks/troughs holds the first recorded switch's value has one extra
201    // (excluded-from-the-average) entry at index 0 — see the comment below.
202    let first_switch_is_peak = mv_sign_init as i32 * direction.action_multiplier() as i32 == 1;
203    let (peaks_sum, troughs_sum) = if first_switch_is_peak {
204        assert_eq!(
205            peaks.len(),
206            num_cycles_count as usize + 1,
207            "peaks.len() must be num_cycles_count + 1 when the first recorded switch is a peak"
208        );
209        assert_eq!(
210            troughs.len(),
211            num_cycles_count as usize,
212            "troughs.len() must equal num_cycles_count when the first recorded switch is a peak"
213        );
214        // The very first recorded peak is excluded from the average — the same choice
215        // `TuningConstantsCalc` makes (its summation loop starts at index 1, not 0), on the
216        // theory that the first post-skip oscillation may not yet be a "clean" full-amplitude
217        // cycle.
218        (peaks[1..].iter().sum::<f32>(), troughs.iter().sum::<f32>())
219    } else {
220        assert_eq!(
221            troughs.len(),
222            num_cycles_count as usize + 1,
223            "troughs.len() must be num_cycles_count + 1 when the first recorded switch is a trough"
224        );
225        assert_eq!(
226            peaks.len(),
227            num_cycles_count as usize,
228            "peaks.len() must equal num_cycles_count when the first recorded switch is a trough"
229        );
230        (peaks.iter().sum::<f32>(), troughs[1..].iter().sum::<f32>())
231    };
232
233    // Keep millisecond precision and total elapsed time: sub-second periods must stay nonzero,
234    // and long runs must not wrap after 24 hours.
235    let elapsed_ms = (switch_times[switch_times.len() - 1] - switch_times[0]).num_milliseconds();
236    let secs_for_period = elapsed_ms as f32 / 1000.0;
237    let period_minutes = (secs_for_period / 60.0) / num_cycles_count as f32;
238    let frequency = 2.0 * std::f32::consts::PI / period_minutes;
239
240    let pv_amp_raw = (peaks_sum - troughs_sum) / (2.0 * num_cycles_count as f32);
241    let pv_amp_percent = pv_amp_raw / (pv_range.high - pv_range.low) * 100.0;
242
243    Oscillation {
244        period_minutes,
245        frequency,
246        pv_amp_raw,
247        pv_amp_percent,
248    }
249}
250
251/// Applies one [`ResponseLevel`]'s tuning constants to an [`Oscillation`], producing Kp/Ti/Td.
252/// Pure port of the per-response-level portion of `TuningConstantsCalc`.
253pub fn calculate_tuning_result(
254    oscillation: Oscillation,
255    config: LoopConfig,
256    response_level: ResponseLevel,
257) -> TuningResult {
258    let tc = lookup(config.process_type, config.controller_type, response_level);
259
260    // Matches `Convert.ToSingle(Math.PI * PvAmpPercent)`: the product is computed in `double`
261    // (like `Math.PI`) and only truncated to `f32` afterward, so this widens explicitly rather
262    // than multiplying two `f32`s directly, to keep rounding identical to the legacy app.
263    let kp_denom = (std::f64::consts::PI * oscillation.pv_amp_percent as f64) as f32;
264    let kp = tc.c1 * 4.0 * config.relay_amp_percent / kp_denom;
265
266    // Unlike the Kp denominator above, `Convert.ToSingle(Math.PI)` here converts only the
267    // constant itself (not a product) before it's used in `f32` arithmetic — equivalent to
268    // using `f32::consts::PI` directly, with no `f64` intermediate needed.
269    let ti_minutes = tc.c2 * 2.0 * std::f32::consts::PI / oscillation.frequency;
270    let td_minutes = tc.c3 * 2.0 * std::f32::consts::PI / oscillation.frequency;
271
272    TuningResult {
273        response_level,
274        kp,
275        ti_minutes,
276        td_minutes,
277    }
278}
279
280/// Converts a [`TuningResult`] into the PID parameters a specific DCS/PLC template expects.
281/// Pure port of `CalculatePIDparameters`.
282///
283/// Order matters and matches the legacy app exactly: the integral/derivative unit conversion
284/// (minutes -> seconds) happens *before* the reset-rate/reset-gain/derivative-gain type
285/// conversion, so e.g. `Ki` ends up computed from `Ti` already expressed in seconds whenever
286/// the template's `integral_unit` is [`TimeUnit::Seconds`] — not from `Ti` in minutes.
287pub fn calculate_pid_parameters(result: TuningResult, template: &DcsTemplate) -> PidParameters {
288    let proportional = match template.proportional_type {
289        ProportionalType::Gain => result.kp,
290        ProportionalType::Band => 100.0 / result.kp,
291    };
292
293    let integral_in_template_unit = match template.integral_unit {
294        TimeUnit::Seconds => result.ti_minutes * 60.0,
295        TimeUnit::Minutes => result.ti_minutes,
296    };
297    let integral = match template.integral_type {
298        IntegralType::ResetTime => integral_in_template_unit,
299        IntegralType::ResetRate => 1.0 / integral_in_template_unit,
300        IntegralType::ResetGain => result.kp / integral_in_template_unit,
301    };
302
303    let derivative_in_template_unit = match template.derivative_unit {
304        TimeUnit::Seconds => result.td_minutes * 60.0,
305        TimeUnit::Minutes => result.td_minutes,
306    };
307    let derivative = match template.derivative_type {
308        DerivativeType::DerivativeTime => derivative_in_template_unit,
309        DerivativeType::DerivativeGain => result.kp * derivative_in_template_unit,
310    };
311
312    PidParameters {
313        response_level: result.response_level,
314        proportional,
315        integral,
316        derivative,
317    }
318}
319
320/// The literal integral/derivative values to write back to the DCS/PLC for one
321/// [`PidParameters`], given the controller type the run was configured for. Pure port of the
322/// controller-type-conditional part of `WritePIDparametersToOPCtags` — proportional is always
323/// `pid.proportional`, so this only exists to decide integral/derivative, which
324/// [`ControllerType::P`]/[`ControllerType::Pi`] algorithms don't use:
325///
326/// - Integral: a [`ControllerType::P`]-only run never writes `pid.integral` (it was computed
327///   but is meaningless for an algorithm with no integral term). Instead it writes a sentinel
328///   that disables integral action in whatever representation the template uses — `9999` for
329///   [`IntegralType::ResetTime`] (an effectively-infinite reset time), or `0` for
330///   [`IntegralType::ResetRate`]/[`IntegralType::ResetGain`] (a zero rate/gain has the same
331///   disabling effect). [`ControllerType::Pi`]/[`ControllerType::Pid`] always write the real
332///   calculated value.
333/// - Derivative: only a [`ControllerType::Pid`] run writes `pid.derivative`; `P`/`Pi` always
334///   write `0`.
335pub fn opc_write_values(
336    pid: PidParameters,
337    controller_type: ControllerType,
338    integral_type: IntegralType,
339) -> OpcWriteValues {
340    let integral = match controller_type {
341        ControllerType::P => match integral_type {
342            IntegralType::ResetTime => 9999.0,
343            IntegralType::ResetRate | IntegralType::ResetGain => 0.0,
344        },
345        ControllerType::Pi | ControllerType::Pid => pid.integral,
346    };
347    let derivative = match controller_type {
348        ControllerType::Pid => pid.derivative,
349        ControllerType::P | ControllerType::Pi => 0.0,
350    };
351
352    OpcWriteValues {
353        response_level: pid.response_level,
354        proportional: pid.proportional,
355        integral,
356        derivative,
357    }
358}
359
360/// Controller-ready targets, separate from the full-precision calculated PID parameters.
361#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
362#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
363pub struct ControllerPidValues {
364    pub response_level: ResponseLevel,
365    pub proportional: RoundedPidValue,
366    pub integral: RoundedPidValue,
367    pub derivative: RoundedPidValue,
368}
369
370#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct ControllerPidError {
372    pub term: &'static str,
373    pub reason: PidRoundingError,
374}
375
376impl std::fmt::Display for ControllerPidError {
377    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378        write!(f, "{}: {}", self.term, self.reason)
379    }
380}
381
382impl std::error::Error for ControllerPidError {}
383
384fn rounded_active_term(
385    value: f32,
386    term: &'static str,
387    policy: PidRounding,
388) -> Result<RoundedPidValue, ControllerPidError> {
389    let rounded = policy
390        .round_value(value)
391        .map_err(|reason| ControllerPidError { term, reason })?;
392    let reason = if value <= 0.0 {
393        Some(PidRoundingError::NonPositive)
394    } else if rounded.value == 0.0 {
395        Some(PidRoundingError::RoundedToZero)
396    } else {
397        None
398    };
399    match reason {
400        Some(reason) => Err(ControllerPidError { term, reason }),
401        None => Ok(rounded),
402    }
403}
404
405/// Applies template precision only to active, already-converted PID terms. Disabled
406/// integral/derivative targets retain the exact values chosen by [`opc_write_values`].
407pub fn controller_pid_values(
408    pid: PidParameters,
409    controller_type: ControllerType,
410    template: &DcsTemplate,
411) -> Result<ControllerPidValues, ControllerPidError> {
412    let raw = opc_write_values(pid, controller_type, template.integral_type);
413    let policy = template.pid_rounding;
414    let proportional = rounded_active_term(raw.proportional, "proportional", policy)?;
415    let integral = match controller_type {
416        ControllerType::P => policy.disabled_value(raw.integral),
417        ControllerType::Pi | ControllerType::Pid => {
418            rounded_active_term(raw.integral, "integral", policy)?
419        }
420    };
421    let derivative = match controller_type {
422        ControllerType::Pid => rounded_active_term(raw.derivative, "derivative", policy)?,
423        ControllerType::P | ControllerType::Pi => policy.disabled_value(raw.derivative),
424    };
425    Ok(ControllerPidValues {
426        response_level: raw.response_level,
427        proportional,
428        integral,
429        derivative,
430    })
431}
432
433/// The top-level entry point: computes the PID parameters for all three response levels from
434/// a completed MRFT run, in one call. Composes [`measure_oscillation`] (once) with
435/// [`calculate_tuning_result`] and [`calculate_pid_parameters`] (once per [`ResponseLevel`]).
436/// Pure port of `MRFTcompletionActions`'s call into `TuningConstantsCalc` +
437/// `CalculatePIDparameters`.
438///
439/// Returns both the intermediate [`TuningResult`] (Kp/Ti/Td, DCS-unit-independent) and the
440/// final [`PidParameters`] (in the template's own units) for each response level, since
441/// callers that persist a run (e.g. the `bhtune` CLI's `tune` command, via
442/// `bhtune_db::TuneResultRow::from_calculated`) need both — the schema records the
443/// control-theory result alongside the exact values it derived for the connected DCS.
444#[allow(clippy::too_many_arguments)]
445pub fn calculate_all(
446    peaks: &[f32],
447    troughs: &[f32],
448    switch_times: &[DateTime<Utc>],
449    mv_sign_init: i8,
450    direction: ControllerDirection,
451    config: LoopConfig,
452    pv_range: PvRange,
453    template: &DcsTemplate,
454) -> [(TuningResult, PidParameters); 3] {
455    let osc = measure_oscillation(
456        peaks,
457        troughs,
458        switch_times,
459        mv_sign_init,
460        direction,
461        config,
462        pv_range,
463    );
464    ResponseLevel::ALL.map(|level| {
465        let result = calculate_tuning_result(osc, config, level);
466        let pid = calculate_pid_parameters(result, template);
467        (result, pid)
468    })
469}
470
471/// Checked counterpart to [`calculate_all`].
472///
473/// The legacy numeric functions remain available for parity/replay callers. Production
474/// persistence should use this entry point so a degenerate PV amplitude or any non-finite
475/// intermediate/final value becomes an explicit invalid result instead of reaching SQLite or
476/// PID write-back as `NaN`/infinity.
477#[allow(clippy::too_many_arguments)]
478pub fn calculate_all_checked(
479    peaks: &[f32],
480    troughs: &[f32],
481    switch_times: &[DateTime<Utc>],
482    mv_sign_init: i8,
483    direction: ControllerDirection,
484    config: LoopConfig,
485    pv_range: PvRange,
486    template: &DcsTemplate,
487) -> [CheckedTuningResult; 3] {
488    let oscillation = measure_oscillation(
489        peaks,
490        troughs,
491        switch_times,
492        mv_sign_init,
493        direction,
494        config,
495        pv_range,
496    );
497
498    let oscillation_reason = invalid_oscillation_reason(oscillation);
499
500    ResponseLevel::ALL.map(|level| {
501        if let Some(reason) = oscillation_reason {
502            return CheckedTuningResult::invalid(level, reason);
503        }
504
505        let tuning = calculate_tuning_result(oscillation, config, level);
506        checked_result_from_tuning(tuning, template)
507    })
508}
509
510fn invalid_oscillation_reason(oscillation: Oscillation) -> Option<TuningResultInvalidReason> {
511    if !oscillation.pv_amp_raw.is_finite() || !oscillation.pv_amp_percent.is_finite() {
512        Some(TuningResultInvalidReason::NonFinitePvAmplitude)
513    } else if oscillation.pv_amp_raw <= 0.0 || oscillation.pv_amp_percent <= 0.0 {
514        Some(TuningResultInvalidReason::NonPositivePvAmplitude)
515    } else if !oscillation.period_minutes.is_finite() {
516        Some(TuningResultInvalidReason::NonFinitePeriod)
517    } else if oscillation.period_minutes <= 0.0 {
518        Some(TuningResultInvalidReason::NonPositivePeriod)
519    } else if !oscillation.frequency.is_finite() {
520        Some(TuningResultInvalidReason::NonFiniteFrequency)
521    } else if oscillation.frequency <= 0.0 {
522        Some(TuningResultInvalidReason::NonPositiveFrequency)
523    } else {
524        None
525    }
526}
527
528fn checked_result_from_tuning(tuning: TuningResult, template: &DcsTemplate) -> CheckedTuningResult {
529    if !tuning.kp.is_finite() {
530        return CheckedTuningResult::invalid(
531            tuning.response_level,
532            TuningResultInvalidReason::NonFiniteKp,
533        );
534    }
535    if !tuning.ti_minutes.is_finite() {
536        return CheckedTuningResult::invalid(
537            tuning.response_level,
538            TuningResultInvalidReason::NonFiniteTiMinutes,
539        );
540    }
541    if !tuning.td_minutes.is_finite() {
542        return CheckedTuningResult::invalid(
543            tuning.response_level,
544            TuningResultInvalidReason::NonFiniteTdMinutes,
545        );
546    }
547
548    let pid = calculate_pid_parameters(tuning, template);
549    if !pid.proportional.is_finite() {
550        return CheckedTuningResult::invalid(
551            tuning.response_level,
552            TuningResultInvalidReason::NonFiniteProportional,
553        );
554    }
555    if !pid.integral.is_finite() {
556        return CheckedTuningResult::invalid(
557            tuning.response_level,
558            TuningResultInvalidReason::NonFiniteIntegral,
559        );
560    }
561    if !pid.derivative.is_finite() {
562        return CheckedTuningResult::invalid(
563            tuning.response_level,
564            TuningResultInvalidReason::NonFiniteDerivative,
565        );
566    }
567
568    CheckedTuningResult::valid(tuning, pid)
569}
570
571#[cfg(test)]
572mod tests;