1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28pub struct TuningMathCompat {
29 pub replicate_period_truncation_bug: bool,
38}
39
40#[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#[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#[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#[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#[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#[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#[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 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#[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 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 (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 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
280pub 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 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 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
309pub 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
349pub 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#[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#[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 #[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 assert_approx(osc.period_minutes, 1.0, 1e-6);
580 assert_approx(osc.frequency, 2.0 * std::f32::consts::PI, 1e-5);
582 assert_approx(osc.pv_amp_raw, 1.0, 1e-5);
586 assert_approx(osc.pv_amp_percent, 1.0, 1e-5);
588 }
589
590 #[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 assert_approx(osc.pv_amp_raw, 1.0, 1e-5);
612 }
613
614 #[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, 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)], 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)], 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], &[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 #[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 #[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 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 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 #[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 assert_approx(result.kp, 11.4854, 1e-2);
829 assert_approx(result.ti_minutes, 0.331, 1e-5);
831 assert_approx(result.td_minutes, 0.0, 1e-6);
833 }
834
835 #[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 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); 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); template.proportional_type = ProportionalType::Band;
898 let pid = calculate_pid_parameters(sample_result(), &template);
899 assert_approx(pid.proportional, 50.0, 1e-6); }
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); }
919
920 #[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); }
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); }
960
961 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 #[test]
1018 fn calculate_all_produces_three_distinct_kp_with_shared_ti_td() {
1019 let template = template::built_in_templates().remove(1); 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 assert_eq!(results[0].1.integral, results[1].1.integral);
1042 assert_eq!(results[1].1.integral, results[2].1.integral);
1043 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 type CompletionFields = (Vec<f32>, Vec<f32>, Vec<DateTime<Utc>>, i8);
1399
1400 #[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 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 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 #[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}