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::{
20 DerivativeType, IntegralType, PidRounding, PidRoundingError, ProportionalType,
21 RoundedPidValue, TimeUnit,
22 },
23 range::PvRange,
24 template::DcsTemplate,
25};
26
27#[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#[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#[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#[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#[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#[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#[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 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
164pub 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 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 (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 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
251pub 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 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 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
280pub 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
320pub 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#[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
405pub 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#[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#[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;