1#![allow(rustdoc::broken_intra_doc_links)]
2
3use std::collections::HashMap;
4use std::time::Duration;
5
6use bhtune_core::LoopConfig;
7use bhtune_core::mrft::clamp_relay_amplitude;
8use bhtune_db::SqlitePool;
9use bhtune_db::models::{
10 MvActuationKind, MvActuationStatus, NewTuneMvActuation, SampleQuality, TuneMvActuationRow,
11};
12use bhtune_driver::{Driver, TagValue};
13use chrono::{DateTime, Utc};
14use tokio::time::Instant;
15
16use super::request::{DriverKind, TuneRequest};
17use crate::cancel::CtrlC;
18use crate::timing::PollTimingAccumulator;
19
20use super::RequireInvariant;
21use super::config::EffectiveTiming;
22#[cfg(test)]
23use super::config::test_effective_timing;
24use super::outcome::AbortReason;
25use super::poll::{TickOperation, bounded_driver_call};
26use super::prepare::InitialState;
27use super::quality::{
28 check_quality, read_numeric_from_batch, read_numeric_sample, sample_quality_from_driver,
29};
30
31pub const MV_ACTUATION_CONFIRMATION_SECS: u64 = 4;
36pub(super) const MV_ACTUATION_RETRY_INTERVAL: Duration = Duration::from_millis(100);
37pub(super) const MV_ACTUATION_DEADLINE_READ_MAX: Duration = Duration::from_secs(1);
38pub(super) const MV_ACTUATION_FALLBACK_HEADROOM: Duration = Duration::from_secs(1);
39pub(super) const MV_RESTORE_HANDOFF_READ_MAX: Duration = Duration::from_secs(1);
40pub(super) const MV_SPAN_TOLERANCE_FRACTION: f32 = 0.001;
41pub(super) const RELAY_STEP_TOLERANCE_FRACTION: f32 = 0.25;
42pub(super) const MIN_RELAY_STEP: f32 = 0.01;
43#[derive(Debug)]
44pub(super) struct PendingMvActuation {
45 pub(super) id: Option<i64>,
46 pub(super) kind: MvActuationKind,
47 pub(super) target: f32,
48 pub(super) tolerance: f32,
49 pub(super) switch_tick: DateTime<Utc>,
50 pub(super) switch_instant: Instant,
51 pub(super) accepted_instant: Instant,
52 pub(super) first_check_at: Instant,
53 pub(super) deadline: Instant,
54 pub(super) last_readback: Option<f32>,
55}
56#[derive(Debug)]
57pub(super) struct MvActuationTracker {
58 pub(super) next_sequence: i64,
59 pub(super) previous_commanded_mv: f32,
60 pub(super) confirmed_mv: Option<f32>,
61 pub(super) pending: Option<PendingMvActuation>,
62 pub(super) mv_span: f32,
63}
64impl MvActuationTracker {
65 pub(super) fn for_run(args: &TuneRequest, initial: &InitialState) -> Option<Self> {
66 (args.driver == DriverKind::Opcda).then_some(Self {
67 next_sequence: 0,
68 previous_commanded_mv: initial.mv_ini,
69 confirmed_mv: None,
70 pending: None,
71 mv_span: initial.mv_range_high - initial.mv_range_low,
72 })
73 }
74
75 #[cfg(test)]
76 #[allow(clippy::too_many_arguments)]
77 pub(super) async fn record_accepted(
78 &mut self,
79 pool: &SqlitePool,
80 run_id: i64,
81 kind: MvActuationKind,
82 target: f32,
83 first_check_at: Instant,
84 accepted_at: DateTime<Utc>,
85 accepted_instant: Instant,
86 tolerance: f32,
87 ) -> anyhow::Result<()> {
88 self.record_accepted_at_switch(
89 pool,
90 run_id,
91 kind,
92 target,
93 accepted_at,
94 accepted_instant,
95 first_check_at,
96 accepted_at,
97 accepted_instant,
98 tolerance,
99 )
100 .await
101 }
102
103 #[allow(clippy::too_many_arguments)]
104 pub(super) async fn record_accepted_at_switch(
105 &mut self,
106 pool: &SqlitePool,
107 run_id: i64,
108 kind: MvActuationKind,
109 target: f32,
110 switch_tick: DateTime<Utc>,
111 switch_instant: Instant,
112 first_check_at: Instant,
113 accepted_at: DateTime<Utc>,
114 accepted_instant: Instant,
115 tolerance: f32,
116 ) -> anyhow::Result<()> {
117 let deadline = accepted_instant + Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS);
118 let confirmation_due_at =
119 accepted_at + chrono::Duration::seconds(MV_ACTUATION_CONFIRMATION_SECS as i64);
120 let previous_commanded_mv = Some(self.previous_commanded_mv);
121 let row = TuneMvActuationRow::insert_pending(
122 pool,
123 run_id,
124 NewTuneMvActuation {
125 sequence: self.next_sequence,
126 kind,
127 commanded_at: accepted_at,
128 target_mv: target,
129 previous_commanded_mv,
130 tolerance,
131 confirmation_due_at,
132 },
133 )
134 .await?;
135 self.accept_pending(PendingMvActuation {
136 id: Some(row.id),
137 kind,
138 target,
139 tolerance,
140 switch_tick,
141 switch_instant,
142 accepted_instant,
143 first_check_at: first_check_at.min(deadline),
144 deadline,
145 last_readback: None,
146 });
147 Ok(())
148 }
149
150 #[allow(clippy::too_many_arguments)]
151 pub(super) async fn record_restore_accepted_best_effort(
152 &mut self,
153 pool: &SqlitePool,
154 run_id: i64,
155 target: f32,
156 accepted_at: DateTime<Utc>,
157 accepted_instant: Instant,
158 tolerance: f32,
159 ) {
160 let deadline = accepted_instant + Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS);
161 let confirmation_due_at =
162 accepted_at + chrono::Duration::seconds(MV_ACTUATION_CONFIRMATION_SECS as i64);
163 let pending = PendingMvActuation {
164 id: None,
165 kind: MvActuationKind::Restore,
166 target,
167 tolerance,
168 switch_tick: accepted_at,
169 switch_instant: accepted_instant,
170 accepted_instant,
171 first_check_at: accepted_instant,
172 deadline,
173 last_readback: None,
174 };
175 let row = TuneMvActuationRow::insert_pending(
176 pool,
177 run_id,
178 NewTuneMvActuation {
179 sequence: self.next_sequence,
180 kind: MvActuationKind::Restore,
181 commanded_at: accepted_at,
182 target_mv: target,
183 previous_commanded_mv: Some(self.previous_commanded_mv),
184 tolerance,
185 confirmation_due_at,
186 },
187 )
188 .await;
189 let mut pending = pending;
190 match row {
191 Ok(row) => pending.id = Some(row.id),
192 Err(error) => {
193 tracing::error!(
194 run_id,
195 error = %error,
196 "failed to record accepted restore MV command; continuing physical restore"
197 );
198 }
199 }
200 self.accept_pending(pending);
201 }
202
203 pub(super) async fn record_restore_accepted_required(
204 &mut self,
205 pool: &SqlitePool,
206 run_id: i64,
207 target: f32,
208 accepted_at: DateTime<Utc>,
209 accepted_instant: Instant,
210 tolerance: f32,
211 ) -> anyhow::Result<()> {
212 let confirmation_due_at =
213 accepted_at + chrono::Duration::seconds(MV_ACTUATION_CONFIRMATION_SECS as i64);
214 let row = TuneMvActuationRow::insert_pending(
215 pool,
216 run_id,
217 NewTuneMvActuation {
218 sequence: self.next_sequence,
219 kind: MvActuationKind::Restore,
220 commanded_at: accepted_at,
221 target_mv: target,
222 previous_commanded_mv: Some(self.previous_commanded_mv),
223 tolerance,
224 confirmation_due_at,
225 },
226 )
227 .await?;
228 let deadline = accepted_instant + Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS);
229 self.accept_pending(PendingMvActuation {
230 id: Some(row.id),
231 kind: MvActuationKind::Restore,
232 target,
233 tolerance,
234 switch_tick: accepted_at,
235 switch_instant: accepted_instant,
236 accepted_instant,
237 first_check_at: accepted_instant,
238 deadline,
239 last_readback: None,
240 });
241 Ok(())
242 }
243
244 pub(super) fn accept_pending(&mut self, pending: PendingMvActuation) {
245 self.next_sequence += 1;
246 self.previous_commanded_mv = pending.target;
247 self.confirmed_mv = None;
248 self.pending = Some(pending);
249 }
250
251 pub(super) fn next_verification_wakeup(&self) -> Option<Instant> {
252 self.pending.as_ref().map(|pending| {
253 if pending.last_readback.is_some() {
254 pending.deadline
255 } else {
256 pending
257 .first_check_at
258 .max(pending.deadline - MV_ACTUATION_FALLBACK_HEADROOM)
259 }
260 })
261 }
262}
263#[allow(clippy::too_many_arguments)]
264pub(super) async fn record_relay_actuation(
265 tracker: &mut MvActuationTracker,
266 pool: &SqlitePool,
267 run_id: i64,
268 target: f32,
269 switch_tick: DateTime<Utc>,
270 switch_instant: Instant,
271 first_check_at: Instant,
272 elapsed_since_observation: Duration,
273 accepted_instant: Instant,
274 tolerance: f32,
275) -> anyhow::Result<()> {
276 let accepted_at = utc_after_elapsed(switch_tick, elapsed_since_observation)?;
277 tracker
278 .record_accepted_at_switch(
279 pool,
280 run_id,
281 MvActuationKind::Relay,
282 target,
283 switch_tick,
284 switch_instant,
285 first_check_at,
286 accepted_at,
287 accepted_instant,
288 tolerance,
289 )
290 .await
291}
292pub(super) fn f32_precision_floor(target: f32, previous: f32) -> f32 {
293 4.0 * f32::EPSILON * target.abs().max(previous.abs()).max(1.0)
294}
295pub(super) fn mv_actuation_tolerance(
296 kind: MvActuationKind,
297 target: f32,
298 previous: f32,
299 mv_span: f32,
300) -> anyhow::Result<f32> {
301 let uncapped = mv_actuation_uncapped_tolerance(target, previous, mv_span);
302 if kind == MvActuationKind::Restore {
303 return Ok(uncapped);
304 }
305
306 let step = (target - previous).abs();
307 let relay_cap = step * RELAY_STEP_TOLERANCE_FRACTION;
308 if !step.is_finite()
309 || step < MIN_RELAY_STEP
310 || relay_cap <= f32_precision_floor(target, previous)
311 {
312 let minimum_step = MIN_RELAY_STEP
313 .max(f32_precision_floor(target, previous) / RELAY_STEP_TOLERANCE_FRACTION);
314 anyhow::bail!(
315 "the effective relay step {step} is too small to verify safely (minimum {minimum_step})"
316 );
317 }
318 Ok(uncapped.min(relay_cap))
319}
320pub(super) fn mv_actuation_uncapped_tolerance(target: f32, previous: f32, mv_span: f32) -> f32 {
321 let precision_floor = f32_precision_floor(target, previous);
322 let span_tolerance = mv_span.abs() * MV_SPAN_TOLERANCE_FRACTION;
323 precision_floor + span_tolerance
324}
325pub(super) fn validate_relay_actuation_step(
326 args: &TuneRequest,
327 config: LoopConfig,
328 initial: &InitialState,
329) -> anyhow::Result<()> {
330 if args.driver != DriverKind::Opcda {
331 return Ok(());
332 }
333 let relay_step = clamp_relay_amplitude(
334 config.relay_amp_percent,
335 initial.mv_ini,
336 initial.mv_range_low,
337 initial.mv_range_high,
338 );
339 mv_actuation_tolerance(
340 MvActuationKind::Relay,
341 initial.mv_ini + relay_step,
342 initial.mv_ini,
343 initial.mv_range_high - initial.mv_range_low,
344 )
345 .map(|_| ())
346}
347pub(super) fn actuation_abort_reason(
348 tag: &str,
349 pending: &PendingMvActuation,
350 readback: Option<f32>,
351 now: Instant,
352) -> AbortReason {
353 let elapsed_ms = now
354 .saturating_duration_since(pending.accepted_instant)
355 .as_millis()
356 .min(u128::from(u64::MAX)) as u64;
357 AbortReason::MvActuationUnconfirmed {
358 tag: tag.to_string(),
359 target: pending.target,
360 readback,
361 tolerance: pending.tolerance,
362 elapsed_ms,
363 deadline_secs: MV_ACTUATION_CONFIRMATION_SECS,
364 }
365}
366pub(super) fn actuation_matches(target: f32, readback: f32, tolerance: f32) -> bool {
367 (target - readback).abs() <= tolerance
368}
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370pub(super) enum MvVerificationTrigger {
371 Scheduled,
372 Deadline,
373}
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375pub(super) enum ActuationAuditPolicy {
376 Required,
377 BestEffort,
378}
379#[derive(Debug, Clone, Copy)]
380pub(super) enum MvVerificationCallLimit {
381 None,
382 Restore(Instant),
383}
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385pub(super) enum MvVerificationLimitKind {
386 Confirmation,
387 Deadline,
388 Restore,
389}
390pub(super) async fn record_actuation_observation(
391 pool: &SqlitePool,
392 pending: &PendingMvActuation,
393 checked_at: DateTime<Utc>,
394 readback: Option<f32>,
395 quality: Option<SampleQuality>,
396 policy: ActuationAuditPolicy,
397) -> anyhow::Result<Option<i64>> {
398 let Some(id) = pending.id else {
399 return Ok(None);
400 };
401 match TuneMvActuationRow::record_observation(pool, id, checked_at, readback, quality).await {
402 Ok(row) => Ok(Some(row.attempt_count)),
403 Err(error) if policy == ActuationAuditPolicy::BestEffort => {
404 tracing::error!(
405 actuation_id = id,
406 error = %error,
407 "failed to record MV verification observation; continuing physical restore"
408 );
409 Ok(None)
410 }
411 Err(error) => Err(error.into()),
412 }
413}
414#[allow(clippy::too_many_arguments)]
415pub(super) async fn record_final_actuation_observation(
416 pool: &SqlitePool,
417 pending: &PendingMvActuation,
418 checked_at: DateTime<Utc>,
419 readback: Option<f32>,
420 quality: Option<SampleQuality>,
421 status: MvActuationStatus,
422 detail: &str,
423) -> Option<i64> {
424 let id = pending.id?;
425 match TuneMvActuationRow::record_final_observation(
426 pool,
427 id,
428 checked_at,
429 readback,
430 quality,
431 status,
432 (!detail.is_empty()).then_some(detail),
433 )
434 .await
435 {
436 Ok(row) => Some(row.attempt_count),
437 Err(error) => {
438 tracing::error!(
439 actuation_id = id,
440 error = %error,
441 "failed to record terminal MV verification observation"
442 );
443 None
444 }
445 }
446}
447pub(super) async fn finalize_actuation_best_effort(
448 pool: &SqlitePool,
449 pending: &PendingMvActuation,
450 status: MvActuationStatus,
451 detail: &str,
452) {
453 let Some(id) = pending.id else {
454 return;
455 };
456 if let Err(error) = TuneMvActuationRow::finalize(pool, id, status, Some(detail)).await {
457 tracing::error!(
458 actuation_id = id,
459 error = %error,
460 "failed to finalize MV actuation audit row"
461 );
462 }
463}
464pub(super) async fn reject_replacement_for_pending_actuation(
465 pool: &SqlitePool,
466 tag: &str,
467 tracker: &mut MvActuationTracker,
468) -> anyhow::Result<AbortReason> {
469 let pending = tracker
470 .pending
471 .take()
472 .require_invariant("called only when an MV actuation is pending")?;
473 let (status, detail) = if pending.last_readback.is_some() {
474 (
475 MvActuationStatus::Failed,
476 "a replacement relay command was requested while the prior MV readback remained outside tolerance",
477 )
478 } else {
479 (
480 MvActuationStatus::Unverified,
481 "a replacement relay command was requested before an acceptable prior MV readback was available",
482 )
483 };
484 finalize_actuation_best_effort(pool, &pending, status, detail).await;
485 Ok(actuation_abort_reason(
486 tag,
487 &pending,
488 pending.last_readback,
489 Instant::now(),
490 ))
491}
492pub(super) fn verification_trigger(
493 pending: &PendingMvActuation,
494 now: Instant,
495) -> Option<MvVerificationTrigger> {
496 if now >= pending.deadline {
497 Some(MvVerificationTrigger::Deadline)
498 } else if pending.last_readback.is_none() && now >= pending.first_check_at {
499 Some(MvVerificationTrigger::Scheduled)
500 } else {
501 None
502 }
503}
504pub(super) async fn wait_for_mv_verification(wakeup: Option<Instant>) {
505 match wakeup {
506 Some(wakeup) => tokio::time::sleep_until(wakeup).await,
507 None => std::future::pending::<()>().await,
508 }
509}
510pub(super) fn mv_verification_read_limit(
511 trigger: MvVerificationTrigger,
512 pending: &PendingMvActuation,
513 call_limit: MvVerificationCallLimit,
514) -> (Instant, MvVerificationLimitKind) {
515 let external = match call_limit {
516 MvVerificationCallLimit::None => None,
517 MvVerificationCallLimit::Restore(deadline) => {
518 Some((deadline, MvVerificationLimitKind::Restore))
519 }
520 };
521 if trigger == MvVerificationTrigger::Deadline {
522 let deadline_read_limit = (
523 Instant::now() + MV_ACTUATION_DEADLINE_READ_MAX,
524 MvVerificationLimitKind::Deadline,
525 );
526 return match external {
527 Some(external) if external.0 < deadline_read_limit.0 => external,
528 _ => deadline_read_limit,
529 };
530 }
531 match external {
532 Some(external @ (deadline, _)) if deadline < pending.deadline => external,
533 _ => (pending.deadline, MvVerificationLimitKind::Confirmation),
534 }
535}
536#[allow(clippy::too_many_arguments)]
543pub(super) async fn verify_pending_mv_actuation_with_timing(
544 pool: &SqlitePool,
545 _args: &TuneRequest,
546 effective_timing: EffectiveTiming,
547 tag: &str,
548 driver: &dyn Driver,
549 ctrl_c: &mut CtrlC,
550 allow_uncertain_quality: bool,
551 tracker: &mut MvActuationTracker,
552 trigger: MvVerificationTrigger,
553 call_limit: MvVerificationCallLimit,
554 audit_policy: ActuationAuditPolicy,
555 mut timing: Option<&mut PollTimingAccumulator>,
556) -> anyhow::Result<Option<AbortReason>> {
557 let Some(pending) = tracker.pending.as_ref() else {
558 return Ok(None);
559 };
560 let now = Instant::now();
561 if trigger == MvVerificationTrigger::Scheduled && now < pending.first_check_at {
562 return Ok(None);
563 }
564
565 match read_pending_mv_verification_with_timing(
566 effective_timing,
567 tag,
568 driver,
569 ctrl_c,
570 tracker,
571 trigger,
572 call_limit,
573 )
574 .await?
575 {
576 PendingMvVerificationRead::DeadlineTimedOut {
577 pending,
578 checked_at,
579 checked_instant,
580 } => {
581 record_final_actuation_observation(
582 pool,
583 &pending,
584 checked_at,
585 pending.last_readback,
586 None,
587 MvActuationStatus::Unverified,
588 "the fresh MV read at the confirmation deadline did not finish within its bounded verification window",
589 )
590 .await;
591 Ok(Some(actuation_abort_reason(
592 tag,
593 &pending,
594 pending.last_readback,
595 checked_instant,
596 )))
597 }
598 PendingMvVerificationRead::RestoreTimedOut {
599 pending,
600 checked_at,
601 checked_instant,
602 } => {
603 record_final_actuation_observation(
604 pool,
605 &pending,
606 checked_at,
607 pending.last_readback,
608 None,
609 MvActuationStatus::Unverified,
610 "the restore timeout elapsed before MV confirmation completed",
611 )
612 .await;
613 Ok(Some(actuation_abort_reason(
614 tag,
615 &pending,
616 pending.last_readback,
617 checked_instant,
618 )))
619 }
620 PendingMvVerificationRead::Ready {
621 operation,
622 checked_at,
623 checked_instant,
624 read_duration,
625 } => match resolve_pending_mv_read(
626 pool,
627 effective_timing,
628 tag,
629 tracker,
630 operation,
631 checked_at,
632 checked_instant,
633 allow_uncertain_quality,
634 )
635 .await?
636 {
637 PendingMvVerificationResult::Abort(reason) => Ok(Some(reason)),
638 PendingMvVerificationResult::Value(value) => {
639 if let Some(timing) = timing.as_mut() {
640 timing.observe_mv_verification(read_duration);
641 }
642 finalize_pending_mv_verification(pool, tag, tracker, value, trigger, audit_policy)
643 .await
644 }
645 },
646 }
647}
648#[cfg(test)]
649#[allow(clippy::too_many_arguments)]
650pub(super) async fn verify_pending_mv_actuation_with(
651 pool: &SqlitePool,
652 args: &TuneRequest,
653 tag: &str,
654 driver: &dyn Driver,
655 ctrl_c: &mut CtrlC,
656 allow_uncertain_quality: bool,
657 tracker: &mut MvActuationTracker,
658 trigger: MvVerificationTrigger,
659 call_limit: MvVerificationCallLimit,
660 audit_policy: ActuationAuditPolicy,
661) -> anyhow::Result<Option<AbortReason>> {
662 verify_pending_mv_actuation_with_timing(
663 pool,
664 args,
665 test_effective_timing(args),
666 tag,
667 driver,
668 ctrl_c,
669 allow_uncertain_quality,
670 tracker,
671 trigger,
672 call_limit,
673 audit_policy,
674 None,
675 )
676 .await
677}
678pub(super) enum PendingMvVerificationRead {
679 Ready {
680 operation: anyhow::Result<TickOperation<(f32, bhtune_driver::Quality)>>,
681 checked_at: DateTime<Utc>,
682 checked_instant: Instant,
683 read_duration: Duration,
684 },
685 DeadlineTimedOut {
686 pending: PendingMvActuation,
687 checked_at: DateTime<Utc>,
688 checked_instant: Instant,
689 },
690 RestoreTimedOut {
691 pending: PendingMvActuation,
692 checked_at: DateTime<Utc>,
693 checked_instant: Instant,
694 },
695}
696pub(super) struct PendingMvVerificationValue {
697 pub(super) readback: f32,
698 pub(super) sample_quality: SampleQuality,
699 pub(super) checked_at: DateTime<Utc>,
700 pub(super) checked_instant: Instant,
701}
702pub(super) enum PendingMvVerificationResult {
703 Value(PendingMvVerificationValue),
704 Abort(AbortReason),
705}
706pub(super) fn pending_verification_ready(
707 tracker: &MvActuationTracker,
708 operation: anyhow::Result<TickOperation<(f32, bhtune_driver::Quality)>>,
709 read_duration: Duration,
710) -> anyhow::Result<PendingMvVerificationRead> {
711 let checked_instant = Instant::now();
712 let pending = tracker
713 .pending
714 .as_ref()
715 .require_invariant("pending actuation existed after the bounded read")?;
716 Ok(PendingMvVerificationRead::Ready {
717 operation,
718 checked_at: checked_at_for_pending(pending, checked_instant)?,
719 checked_instant,
720 read_duration,
721 })
722}
723#[allow(clippy::too_many_arguments)]
724pub(super) async fn read_pending_mv_verification_with_timing(
725 effective_timing: EffectiveTiming,
726 tag: &str,
727 driver: &dyn Driver,
728 ctrl_c: &mut CtrlC,
729 tracker: &mut MvActuationTracker,
730 mut trigger: MvVerificationTrigger,
731 call_limit: MvVerificationCallLimit,
732) -> anyhow::Result<PendingMvVerificationRead> {
733 loop {
734 let limit = {
735 let pending = tracker
736 .pending
737 .as_ref()
738 .require_invariant("pending actuation existed before the bounded read")?;
739 mv_verification_read_limit(trigger, pending, call_limit)
740 };
741 let read_started = Instant::now();
742 let read = bounded_driver_call(
743 effective_timing.op_timeout_secs,
744 ctrl_c,
745 read_numeric_sample(driver, tag),
746 );
747 let (deadline, limit_kind) = limit;
748 match tokio::time::timeout_at(deadline, read).await {
749 Ok(operation) => {
750 return pending_verification_ready(tracker, operation, read_started.elapsed());
751 }
752 Err(_) => match limit_kind {
753 MvVerificationLimitKind::Confirmation => {
754 trigger = MvVerificationTrigger::Deadline;
755 }
756
757 MvVerificationLimitKind::Deadline => {
758 const MSG: &str = "pending actuation existed before the deadline read";
759 let pending = tracker.pending.take().require_invariant(MSG)?;
760 let checked_instant = Instant::now();
761 let checked_at = checked_at_for_pending(&pending, checked_instant)?;
762 return Ok(PendingMvVerificationRead::DeadlineTimedOut {
763 pending,
764 checked_at,
765 checked_instant,
766 });
767 }
768 MvVerificationLimitKind::Restore => {
769 const MSG: &str = "pending actuation existed before the bounded read";
770 let pending = tracker.pending.take().require_invariant(MSG)?;
771 let checked_instant = Instant::now();
772 let checked_at = checked_at_for_pending(&pending, checked_instant)?;
773 return Ok(PendingMvVerificationRead::RestoreTimedOut {
774 pending,
775 checked_at,
776 checked_instant,
777 });
778 }
779 },
780 }
781 }
782}
783#[allow(clippy::too_many_arguments)]
784pub(super) async fn resolve_pending_mv_read(
785 pool: &SqlitePool,
786 effective_timing: EffectiveTiming,
787 tag: &str,
788 tracker: &mut MvActuationTracker,
789 operation: anyhow::Result<TickOperation<(f32, bhtune_driver::Quality)>>,
790 checked_at: DateTime<Utc>,
791 checked_instant: Instant,
792 allow_uncertain_quality: bool,
793) -> anyhow::Result<PendingMvVerificationResult> {
794 let operation = match operation {
795 Ok(operation) => operation,
796 Err(error) => {
797 let pending = tracker
798 .pending
799 .take()
800 .require_invariant("pending actuation existed before the verification read")?;
801 let detail = format!("MV verification read failed: {error}");
802 record_final_actuation_observation(
803 pool,
804 &pending,
805 checked_at,
806 None,
807 None,
808 MvActuationStatus::Unverified,
809 &detail,
810 )
811 .await;
812 return Err(error);
813 }
814 };
815
816 let (readback, quality) = match operation {
817 TickOperation::Completed(value) => value,
818 TickOperation::Cancelled => {
819 let pending = tracker
820 .pending
821 .take()
822 .require_invariant("pending actuation existed before the verification read")?;
823 finalize_actuation_best_effort(
824 pool,
825 &pending,
826 MvActuationStatus::Unverified,
827 "MV verification was interrupted before confirmation completed",
828 )
829 .await;
830 return Ok(PendingMvVerificationResult::Abort(
831 AbortReason::UserInterrupt,
832 ));
833 }
834 TickOperation::TimedOut => {
835 let pending = tracker
836 .pending
837 .take()
838 .require_invariant("pending actuation existed before the verification read")?;
839 let detail = format!(
840 "MV verification read did not complete within {} seconds",
841 effective_timing.op_timeout_secs
842 );
843 record_final_actuation_observation(
844 pool,
845 &pending,
846 checked_at,
847 None,
848 None,
849 MvActuationStatus::Unverified,
850 &detail,
851 )
852 .await;
853 return Ok(PendingMvVerificationResult::Abort(
854 AbortReason::OperationTimedOut {
855 tag: tag.to_string(),
856 op_timeout_secs: effective_timing.op_timeout_secs,
857 },
858 ));
859 }
860 };
861 let sample_quality = sample_quality_from_driver(quality);
862 if check_quality(tag, quality, allow_uncertain_quality).is_err() {
863 let pending = tracker
864 .pending
865 .take()
866 .require_invariant("pending actuation existed before the verification read")?;
867 let detail = format!("MV verification read reported OPC quality {quality:?}");
868 record_final_actuation_observation(
869 pool,
870 &pending,
871 checked_at,
872 Some(readback),
873 Some(sample_quality),
874 MvActuationStatus::Unverified,
875 &detail,
876 )
877 .await;
878 return Ok(PendingMvVerificationResult::Abort(
879 AbortReason::PoorQuality {
880 tag: tag.to_string(),
881 quality,
882 },
883 ));
884 }
885
886 Ok(PendingMvVerificationResult::Value(
887 PendingMvVerificationValue {
888 readback,
889 sample_quality,
890 checked_at,
891 checked_instant,
892 },
893 ))
894}
895#[allow(clippy::too_many_arguments)]
896pub(super) async fn resolve_pending_mv_poll(
897 pool: &SqlitePool,
898 effective_timing: EffectiveTiming,
899 pv_mv_values: TickOperation<HashMap<String, TagValue>>,
900 mv_tag: &str,
901 checked_at: DateTime<Utc>,
902 checked_instant: Instant,
903 read_duration: Duration,
904 allow_uncertain_quality: bool,
905 tracker: &mut MvActuationTracker,
906 timing: &mut PollTimingAccumulator,
907) -> anyhow::Result<(Option<AbortReason>, bool)> {
908 let operation = match pv_mv_values {
909 TickOperation::Completed(values) => match read_numeric_from_batch(&values, mv_tag) {
910 Ok(value) => Ok(TickOperation::Completed(value)),
911 Err(error) => Err(error),
912 },
913 TickOperation::Cancelled => Ok(TickOperation::Cancelled),
914 TickOperation::TimedOut => Ok(TickOperation::TimedOut),
915 };
916
917 match resolve_pending_mv_read(
918 pool,
919 effective_timing,
920 mv_tag,
921 tracker,
922 operation,
923 checked_at,
924 checked_instant,
925 allow_uncertain_quality,
926 )
927 .await?
928 {
929 PendingMvVerificationResult::Abort(reason) => Ok((Some(reason), false)),
930 PendingMvVerificationResult::Value(value) => {
931 timing.observe_mv_verification(read_duration);
932 let outcome = finalize_pending_mv_verification(
933 pool,
934 mv_tag,
935 tracker,
936 value,
937 MvVerificationTrigger::Scheduled,
938 ActuationAuditPolicy::Required,
939 )
940 .await?;
941 Ok((outcome, true))
942 }
943 }
944}
945#[allow(clippy::too_many_arguments)]
946pub(super) async fn confirm_pending_mv_actuation(
947 pool: &SqlitePool,
948 tracker: &mut MvActuationTracker,
949 pending: PendingMvActuation,
950 checked_at: DateTime<Utc>,
951 readback: f32,
952 sample_quality: SampleQuality,
953 audit_policy: ActuationAuditPolicy,
954) -> anyhow::Result<()> {
955 let recorded_attempts = match pending.id {
956 Some(id) => {
957 let result = TuneMvActuationRow::record_final_observation(
958 pool,
959 id,
960 checked_at,
961 Some(readback),
962 Some(sample_quality),
963 MvActuationStatus::Confirmed,
964 None,
965 )
966 .await;
967 match result {
968 Ok(row) => Some(row.attempt_count),
969 Err(error) if audit_policy == ActuationAuditPolicy::BestEffort => {
970 tracing::error!(
971 actuation_id = id,
972 error = %error,
973 "failed to record confirmed MV restore observation"
974 );
975 None
976 }
977 Err(error) => {
978 tracker.pending = Some(pending);
979 return Err(error.into());
980 }
981 }
982 }
983 None => None,
984 };
985 if let Some(attempt_count) = recorded_attempts.filter(|attempt_count| *attempt_count > 1) {
986 tracing::warn!(
987 actuation_id = ?pending.id,
988 attempt_count,
989 target = pending.target,
990 readback,
991 tolerance = pending.tolerance,
992 "MV actuation confirmed after earlier unsuccessful observations"
993 );
994 }
995 tracker.confirmed_mv = Some(pending.target);
996 Ok(())
997}
998pub(super) async fn finalize_pending_mv_verification(
999 pool: &SqlitePool,
1000 tag: &str,
1001 tracker: &mut MvActuationTracker,
1002 value: PendingMvVerificationValue,
1003 trigger: MvVerificationTrigger,
1004 audit_policy: ActuationAuditPolicy,
1005) -> anyhow::Result<Option<AbortReason>> {
1006 let pending = tracker
1007 .pending
1008 .take()
1009 .require_invariant("pending actuation existed before the verification result")?;
1010 if value.checked_instant > pending.deadline {
1011 let detail = if actuation_matches(pending.target, value.readback, pending.tolerance) {
1012 "MV readback matched the target only after the confirmation deadline"
1013 } else {
1014 "MV readback remained outside tolerance after the confirmation deadline"
1015 };
1016 record_final_actuation_observation(
1017 pool,
1018 &pending,
1019 value.checked_at,
1020 Some(value.readback),
1021 Some(value.sample_quality),
1022 MvActuationStatus::Failed,
1023 detail,
1024 )
1025 .await;
1026 return Ok(Some(actuation_abort_reason(
1027 tag,
1028 &pending,
1029 Some(value.readback),
1030 value.checked_instant,
1031 )));
1032 }
1033 if actuation_matches(pending.target, value.readback, pending.tolerance) {
1034 confirm_pending_mv_actuation(
1035 pool,
1036 tracker,
1037 pending,
1038 value.checked_at,
1039 value.readback,
1040 value.sample_quality,
1041 audit_policy,
1042 )
1043 .await?;
1044 return Ok(None);
1045 }
1046
1047 let deadline_reached =
1048 trigger == MvVerificationTrigger::Deadline || value.checked_instant >= pending.deadline;
1049 if deadline_reached {
1050 record_final_actuation_observation(
1051 pool,
1052 &pending,
1053 value.checked_at,
1054 Some(value.readback),
1055 Some(value.sample_quality),
1056 MvActuationStatus::Failed,
1057 "MV readback remained outside tolerance at the confirmation deadline",
1058 )
1059 .await;
1060 return Ok(Some(actuation_abort_reason(
1061 tag,
1062 &pending,
1063 Some(value.readback),
1064 value.checked_instant,
1065 )));
1066 }
1067
1068 let attempt_count = record_actuation_observation(
1069 pool,
1070 &pending,
1071 value.checked_at,
1072 Some(value.readback),
1073 Some(value.sample_quality),
1074 audit_policy,
1075 )
1076 .await?;
1077 tracing::warn!(
1078 actuation_id = ?pending.id,
1079 attempt_count,
1080 target = pending.target,
1081 readback = value.readback,
1082 tolerance = pending.tolerance,
1083 "MV readback is outside tolerance; confirmation remains pending"
1084 );
1085 let mut pending = pending;
1086 pending.last_readback = Some(value.readback);
1087 tracker.pending = Some(pending);
1088 Ok(None)
1089}
1090#[cfg(test)]
1091#[allow(clippy::too_many_arguments)]
1092pub(super) async fn verify_pending_mv_actuation(
1093 pool: &SqlitePool,
1094 args: &TuneRequest,
1095 tag: &str,
1096 driver: &dyn Driver,
1097 ctrl_c: &mut CtrlC,
1098 allow_uncertain_quality: bool,
1099 tracker: &mut MvActuationTracker,
1100 call_deadline: Option<Instant>,
1101) -> anyhow::Result<Option<AbortReason>> {
1102 let trigger = tracker
1103 .pending
1104 .as_ref()
1105 .and_then(|pending| verification_trigger(pending, Instant::now()))
1106 .unwrap_or(MvVerificationTrigger::Scheduled);
1107 verify_pending_mv_actuation_with_timing(
1108 pool,
1109 args,
1110 test_effective_timing(args),
1111 tag,
1112 driver,
1113 ctrl_c,
1114 allow_uncertain_quality,
1115 tracker,
1116 trigger,
1117 call_deadline.map_or(
1118 MvVerificationCallLimit::None,
1119 MvVerificationCallLimit::Restore,
1120 ),
1121 ActuationAuditPolicy::Required,
1122 None,
1123 )
1124 .await
1125}
1126pub(super) async fn finalize_pending_for_run_best_effort(
1127 pool: &SqlitePool,
1128 run_id: i64,
1129 detail: &str,
1130) {
1131 if let Err(error) = TuneMvActuationRow::finalize_pending_for_run(
1132 pool,
1133 run_id,
1134 MvActuationStatus::Unverified,
1135 Some(detail),
1136 )
1137 .await
1138 {
1139 tracing::error!(
1140 run_id,
1141 error = %error,
1142 "failed to finalize pending MV actuation rows"
1143 );
1144 }
1145}
1146pub(super) async fn supersede_pending_actuation_best_effort(
1147 pool: &SqlitePool,
1148 tracker: &mut MvActuationTracker,
1149 detail: &str,
1150) {
1151 let Some(pending) = tracker.pending.take() else {
1152 return;
1153 };
1154 finalize_actuation_best_effort(pool, &pending, MvActuationStatus::Superseded, detail).await;
1155}
1156pub(super) async fn record_handoff_observation_best_effort(
1157 pool: &SqlitePool,
1158 pending: &PendingMvActuation,
1159 checked_at: DateTime<Utc>,
1160 readback: Option<f32>,
1161 quality: Option<SampleQuality>,
1162 detail: &str,
1163) {
1164 record_final_actuation_observation(
1165 pool,
1166 pending,
1167 checked_at,
1168 readback,
1169 quality,
1170 MvActuationStatus::Superseded,
1171 detail,
1172 )
1173 .await;
1174}
1175pub(super) fn checked_at_for_pending(
1176 pending: &PendingMvActuation,
1177 checked_instant: Instant,
1178) -> anyhow::Result<DateTime<Utc>> {
1179 Ok(pending.switch_tick
1180 + chrono::Duration::from_std(
1181 checked_instant.saturating_duration_since(pending.switch_instant),
1182 )
1183 .map_err(|_| anyhow::anyhow!("MV actuation observation time exceeded chrono's range"))?)
1184}
1185pub(super) fn utc_after_elapsed(
1186 now: DateTime<Utc>,
1187 elapsed: Duration,
1188) -> anyhow::Result<DateTime<Utc>> {
1189 Ok(now
1190 + chrono::Duration::from_std(elapsed)
1191 .map_err(|_| anyhow::anyhow!("MV command time exceeded chrono's range"))?)
1192}