1#![allow(rustdoc::broken_intra_doc_links)]
2
3use bhtune_core::{
4 ControllerPidValues, ControllerType, DcsTemplate, LoopConfig, LoopTags, PidParameters,
5 ResponseLevel, TuningResultStatus, controller_pid_values,
6};
7use bhtune_db::SqlitePool;
8use bhtune_db::models::{
9 LiveOwnershipRow, NewTuneWrite, RollbackState, TuneResultRow, TuneRunRow, TuneWriteRow,
10 WriteKind, WriteReadback,
11};
12use bhtune_driver::Driver;
13use chrono::Utc;
14
15use crate::live_ownership::{AuditedDriver, LiveOperationGuard};
16
17use super::outcome::WriteBackOutcome;
18use super::quality::{read_f32, write_value};
19
20pub(crate) async fn read_previous_pid_values(
26 driver: &dyn Driver,
27 p_tag: &str,
28 i_tag: &str,
29 d_tag: &str,
30 allow_uncertain: bool,
31) -> anyhow::Result<WriteReadback> {
32 let proportional = read_f32(driver, p_tag, allow_uncertain)
33 .await
34 .map_err(|e| anyhow::anyhow!("pre-read of Proportional tag '{p_tag}' failed: {e}"))?;
35 let integral = read_f32(driver, i_tag, allow_uncertain)
36 .await
37 .map_err(|e| anyhow::anyhow!("pre-read of Integral tag '{i_tag}' failed: {e}"))?;
38 let derivative = read_f32(driver, d_tag, allow_uncertain)
39 .await
40 .map_err(|e| anyhow::anyhow!("pre-read of Derivative tag '{d_tag}' failed: {e}"))?;
41 Ok(WriteReadback {
42 proportional,
43 integral,
44 derivative,
45 })
46}
47
48async fn persist_pid_restore_intent(
49 pool: &SqlitePool,
50 ownership: &LiveOperationGuard,
51 run_id: i64,
52 kind: WriteKind,
53 response_level: ResponseLevel,
54 previous: WriteReadback,
55 target: WriteReadback,
56) -> anyhow::Result<()> {
57 let owner = LiveOwnershipRow::get(pool, ownership.owner().id)
58 .await?
59 .ok_or_else(|| anyhow::anyhow!("live owner {} disappeared", ownership.owner().id))?;
60 let mut intent = match owner.restore_intent_json {
61 Some(json) => serde_json::from_str::<serde_json::Value>(&json)?,
62 None => serde_json::json!({}),
63 };
64 let Some(root) = intent.as_object_mut() else {
65 anyhow::bail!("persisted live restore intent is not a JSON object");
66 };
67 root.insert(
68 "pid_restore".to_string(),
69 serde_json::json!({
70 "run_id": run_id,
71 "write_kind": kind,
72 "response_level": response_level,
73 "previous": {
74 "proportional": previous.proportional,
75 "integral": previous.integral,
76 "derivative": previous.derivative,
77 },
78 "target": {
79 "proportional": target.proportional,
80 "integral": target.integral,
81 "derivative": target.derivative,
82 },
83 }),
84 );
85 ownership
86 .persist_restore_intent(&serde_json::to_string(&intent)?)
87 .await
88}
89
90pub(super) fn pid_value_within_tolerance(requested: f32, actual: f32) -> bool {
97 let tolerance = (1e-3_f32).max(0.01 * requested.abs());
98 (actual - requested).abs() <= tolerance
99}
100pub(crate) async fn write_and_verify_pid_value(
108 driver: &dyn Driver,
109 label: &str,
110 tag: &str,
111 value: f32,
112 allow_uncertain: bool,
113) -> Result<f32, String> {
114 write_value(driver, tag, value)
115 .await
116 .map_err(|e| format!("{label} write to '{tag}' failed: {e}"))?;
117 let readback = read_f32(driver, tag, allow_uncertain)
118 .await
119 .map_err(|e| format!("{label} readback from '{tag}' failed: {e}"))?;
120 if pid_value_within_tolerance(value, readback) {
121 Ok(readback)
122 } else {
123 Err(format!(
124 "{label} readback {readback} from '{tag}' is outside tolerance of requested {value}"
125 ))
126 }
127}
128pub(super) async fn rollback_pid_writes(
134 driver: &dyn Driver,
135 targets: &[(&str, &str, f32, f32)],
136 ownership: &LiveOperationGuard,
137) -> Result<(), String> {
138 let mut failures = Vec::new();
139 for (label, tag, value_written, previous_value) in targets {
140 let Some(previous_json) = serde_json::Number::from_f64(f64::from(*value_written))
141 .map(|number| number.to_string())
142 else {
143 failures.push(format!(
144 "{label} rollback audit intent for '{tag}' has a non-finite write value"
145 ));
146 continue;
147 };
148 ownership
149 .queue_previous_write_value(tag, previous_json)
150 .await;
151 if let Err(e) = write_value(driver, tag, *previous_value).await {
152 failures.push(format!("{label} rollback write to '{tag}' failed: {e}"));
153 }
154 }
155 if failures.is_empty() {
156 Ok(())
157 } else {
158 Err(failures.join("; "))
159 }
160}
161#[derive(Debug, Clone, PartialEq)]
169pub enum PidWriteOutcome {
170 Written,
172 Failed { detail: String },
178}
179
180#[derive(Debug, Clone, PartialEq)]
181pub enum WriteBackSelection {
182 Selected(ResponseLevel),
183 Skipped(String),
184 Failed(String),
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum WriteBackSkipReason {
189 NoPidTags,
190 NoResults,
191}
192
193#[derive(Debug, Clone, PartialEq, serde::Serialize)]
196pub struct PidWritePreview {
197 pub response_level: ResponseLevel,
198 pub values: Option<ControllerPidValues>,
199 pub unavailable_reason: Option<String>,
200}
201
202pub trait WriteBackHandler: Send {
208 fn pid_results_ready(&mut self, _previews: &[PidWritePreview]) {}
210
211 fn select_response_level(&mut self, previews: &[PidWritePreview]) -> WriteBackSelection;
212
213 fn write_back_selected(&mut self, _values: &ControllerPidValues, _requested: bool) {}
214
215 fn write_back_skipped(&mut self, _reason: WriteBackSkipReason) {}
216
217 fn write_back_failed(&mut self, _detail: &str) {}
218
219 fn write_back_finished(&mut self, _response_level: ResponseLevel, _outcome: &PidWriteOutcome) {}
220}
221#[allow(clippy::too_many_arguments)]
245pub async fn write_pid_values(
246 pool: &SqlitePool,
247 run_id: i64,
248 driver: &dyn Driver,
249 p_tag: &str,
250 i_tag: &str,
251 d_tag: &str,
252 response_level: ResponseLevel,
253 target: WriteReadback,
254 kind: WriteKind,
255 allow_uncertain: bool,
256) -> anyhow::Result<PidWriteOutcome> {
257 let run = TuneRunRow::get(pool, run_id)
258 .await?
259 .ok_or_else(|| anyhow::anyhow!("no run with id {run_id}"))?;
260 let operation_kind = match kind {
261 WriteKind::Write => bhtune_db::models::LiveOperationKind::PidWrite,
262 WriteKind::Revert => bhtune_db::models::LiveOperationKind::PidRevert,
263 };
264 let restore_intent = serde_json::to_string(&serde_json::json!({
265 "version": 1,
266 "kind": "pid_restore",
267 "run_id": run_id,
268 "write_kind": kind,
269 "state": "awaiting_prewrite_readings",
270 }))?;
271 let ownership =
272 LiveOperationGuard::acquire_for_recorded_run(pool, &run, operation_kind, restore_intent)
273 .await?;
274 let audited = AuditedDriver::new(driver, pool, &ownership, Some(run_id));
275 let result = write_pid_values_inner(
276 pool,
277 run_id,
278 &audited,
279 p_tag,
280 i_tag,
281 d_tag,
282 response_level,
283 target,
284 kind,
285 allow_uncertain,
286 &ownership,
287 )
288 .await;
289 let release_result = ownership.release().await;
290 match (result, release_result) {
291 (Ok(outcome), Ok(())) => Ok(outcome),
292 (Err(error), Ok(())) => Err(error),
293 (Ok(_), Err(error)) => Err(error),
294 (Err(error), Err(release_error)) => Err(anyhow::anyhow!(
295 "{error}; additionally failed to persist live ownership release: {release_error}"
296 )),
297 }
298}
299
300#[allow(clippy::too_many_arguments)]
303pub async fn write_pid_values_with_owner(
304 pool: &SqlitePool,
305 run_id: i64,
306 driver: &AuditedDriver<'_>,
307 p_tag: &str,
308 i_tag: &str,
309 d_tag: &str,
310 response_level: ResponseLevel,
311 target: WriteReadback,
312 kind: WriteKind,
313 allow_uncertain: bool,
314) -> anyhow::Result<PidWriteOutcome> {
315 if driver.run_id() != Some(run_id) || driver.owner().owner().run_id != Some(run_id) {
316 anyhow::bail!("live ownership does not match PID write run {run_id}");
317 }
318 let valid_operation =
319 operation_kind_authorizes_pid_write(kind, driver.owner().owner().operation_kind);
320 if !valid_operation {
321 anyhow::bail!("live ownership kind does not authorize the requested PID operation");
322 }
323 write_pid_values_inner(
324 pool,
325 run_id,
326 driver,
327 p_tag,
328 i_tag,
329 d_tag,
330 response_level,
331 target,
332 kind,
333 allow_uncertain,
334 driver.owner(),
335 )
336 .await
337}
338
339fn operation_kind_authorizes_pid_write(
340 kind: WriteKind,
341 operation_kind: bhtune_db::models::LiveOperationKind,
342) -> bool {
343 if kind == WriteKind::Write {
344 operation_kind == bhtune_db::models::LiveOperationKind::Tune
345 || operation_kind == bhtune_db::models::LiveOperationKind::PidWrite
346 } else {
347 operation_kind == bhtune_db::models::LiveOperationKind::PidRevert
348 }
349}
350
351#[allow(clippy::too_many_arguments)]
352async fn write_pid_values_inner(
353 pool: &SqlitePool,
354 run_id: i64,
355 driver: &dyn Driver,
356 p_tag: &str,
357 i_tag: &str,
358 d_tag: &str,
359 response_level: ResponseLevel,
360 target: WriteReadback,
361 kind: WriteKind,
362 allow_uncertain: bool,
363 ownership: &LiveOperationGuard,
364) -> anyhow::Result<PidWriteOutcome> {
365 ownership.ensure_healthy()?;
366 if ownership.owner().run_id != Some(run_id) {
367 anyhow::bail!("live ownership does not match PID write run {run_id}");
368 }
369 let written_at = Utc::now();
370 let mut new_write = NewTuneWrite::new(response_level, written_at);
371 new_write.kind = kind;
372 new_write.allow_uncertain_quality = allow_uncertain;
373
374 let previous =
375 match read_previous_pid_values(driver, p_tag, i_tag, d_tag, allow_uncertain).await {
376 Ok(previous) => previous,
377 Err(e) => {
378 let error_message = e.to_string();
379 new_write.error_message = Some(error_message.clone());
380 TuneWriteRow::insert(pool, run_id, new_write).await?;
381 tracing::error!(
382 run_id,
383 ?response_level,
384 ?kind,
385 %error_message,
386 "PID pre-read failed"
387 );
388 return Ok(PidWriteOutcome::Failed {
389 detail: format!("pre-read failed: {error_message}"),
390 });
391 }
392 };
393 new_write.previous = Some(previous);
394 persist_pid_restore_intent(
395 pool,
396 ownership,
397 run_id,
398 kind,
399 response_level,
400 previous,
401 target,
402 )
403 .await?;
404
405 let steps: [(&str, &str, f32, f32); 3] = [
410 (
411 "Proportional",
412 p_tag,
413 target.proportional,
414 previous.proportional,
415 ),
416 ("Integral", i_tag, target.integral, previous.integral),
417 ("Derivative", d_tag, target.derivative, previous.derivative),
418 ];
419 let mut written_vals: [Option<f32>; 3] = [None; 3];
420 let mut readback_vals: [Option<f32>; 3] = [None; 3];
421 let mut rollback_targets: Vec<(&str, &str, f32, f32)> = Vec::new();
422 let mut failure: Option<String> = None;
423
424 for (i, (label, tag, value, previous_value)) in steps.into_iter().enumerate() {
425 written_vals[i] = Some(value);
426 let previous_json = serde_json::to_string(&previous_value)?;
427 ownership
428 .queue_previous_write_value(tag, previous_json)
429 .await;
430 match write_and_verify_pid_value(driver, label, tag, value, allow_uncertain).await {
431 Ok(readback) => {
432 readback_vals[i] = Some(readback);
433 rollback_targets.push((label, tag, value, previous_value));
434 }
435 Err(e) => {
436 failure = Some(e);
437 break;
438 }
439 }
440 }
441
442 new_write.proportional_written = written_vals[0];
443 new_write.integral_written = written_vals[1];
444 new_write.derivative_written = written_vals[2];
445 new_write.proportional_readback = readback_vals[0];
446 new_write.integral_readback = readback_vals[1];
447 new_write.derivative_readback = readback_vals[2];
448
449 let Some(error_message) = failure else {
450 new_write.success = true;
451 TuneWriteRow::insert(pool, run_id, new_write).await?;
452 tracing::info!(run_id, ?response_level, ?kind, "PID write succeeded");
453 return Ok(PidWriteOutcome::Written);
454 };
455
456 new_write.success = false;
457 new_write.error_message = Some(error_message.clone());
458
459 if kind != WriteKind::Write || rollback_targets.is_empty() {
463 TuneWriteRow::insert(pool, run_id, new_write).await?;
464 tracing::error!(run_id, ?response_level, ?kind, %error_message, "PID write failed");
465 return Ok(PidWriteOutcome::Failed {
466 detail: error_message,
467 });
468 }
469
470 match rollback_pid_writes(driver, &rollback_targets, ownership).await {
471 Ok(()) => {
472 new_write.rollback_state = Some(RollbackState::Succeeded);
473 TuneWriteRow::insert(pool, run_id, new_write).await?;
474 tracing::error!(run_id, ?response_level, %error_message, "PID write failed partway through; rollback succeeded");
475 Ok(PidWriteOutcome::Failed {
476 detail: format!("{error_message} (rolled back)"),
477 })
478 }
479 Err(rollback_error) => {
480 new_write.rollback_state = Some(RollbackState::Failed);
481 new_write.rollback_error = Some(rollback_error.clone());
482 TuneWriteRow::insert(pool, run_id, new_write).await?;
483 tracing::error!(
484 run_id,
485 ?response_level,
486 %error_message,
487 %rollback_error,
488 "PID write failed partway through; rollback also failed"
489 );
490 Ok(PidWriteOutcome::Failed {
491 detail: format!(
492 "{error_message}; rollback also failed: {rollback_error} -- the loop may \
493 hold a mismatched set of PID constants, see \
494 `bhtune history revert {run_id}`"
495 ),
496 })
497 }
498 }
499}
500pub fn pid_parameters_for_result(result: &TuneResultRow) -> anyhow::Result<PidParameters> {
506 if result.status != TuningResultStatus::Valid {
507 let reason = result
508 .invalid_reason
509 .map(|reason| reason.to_string())
510 .unwrap_or_else(|| "no invalid reason was recorded".to_string());
511 anyhow::bail!(
512 "{:?} calculated result is invalid: {reason}",
513 result.response_level
514 );
515 }
516 if result.invalid_reason.is_some() {
517 anyhow::bail!(
518 "{:?} calculated result has an invalid reason despite being marked valid",
519 result.response_level
520 );
521 }
522
523 let proportional = result.proportional.ok_or_else(|| {
524 anyhow::anyhow!(
525 "{:?} calculated result is missing its proportional value",
526 result.response_level
527 )
528 })?;
529 let integral = result.integral.ok_or_else(|| {
530 anyhow::anyhow!(
531 "{:?} calculated result is missing its integral value",
532 result.response_level
533 )
534 })?;
535 let derivative = result.derivative.ok_or_else(|| {
536 anyhow::anyhow!(
537 "{:?} calculated result is missing its derivative value",
538 result.response_level
539 )
540 })?;
541 if !proportional.is_finite() || !integral.is_finite() || !derivative.is_finite() {
542 anyhow::bail!(
543 "{:?} calculated result contains a non-finite PID value",
544 result.response_level
545 );
546 }
547
548 Ok(PidParameters {
549 response_level: result.response_level,
550 proportional,
551 integral,
552 derivative,
553 })
554}
555
556pub fn controller_pid_for_result(
559 result: &TuneResultRow,
560 controller_type: ControllerType,
561 template: &DcsTemplate,
562) -> anyhow::Result<ControllerPidValues> {
563 let pid = pid_parameters_for_result(result)?;
564 controller_pid_values(pid, controller_type, template).map_err(|error| {
565 anyhow::anyhow!(
566 "{:?} controller PID target is unavailable: {error}",
567 result.response_level
568 )
569 })
570}
571
572pub fn pid_write_preview(
573 result: &TuneResultRow,
574 controller_type: ControllerType,
575 template: &DcsTemplate,
576) -> PidWritePreview {
577 let (values, unavailable_reason) =
578 match controller_pid_for_result(result, controller_type, template) {
579 Ok(values) => (Some(values), None),
580 Err(error) => (None, Some(error.to_string())),
581 };
582 PidWritePreview {
583 response_level: result.response_level,
584 values,
585 unavailable_reason,
586 }
587}
588#[allow(clippy::too_many_arguments)]
592#[cfg(test)]
593pub(super) async fn maybe_write_back(
594 pool: &SqlitePool,
595 run_id: i64,
596 tags: &LoopTags,
597 template: &DcsTemplate,
598 driver: &dyn Driver,
599 config: LoopConfig,
600 write_pid: Option<ResponseLevel>,
601 allow_uncertain: bool,
602 handler: Option<&mut dyn WriteBackHandler>,
603) -> anyhow::Result<(WriteBackOutcome, Option<String>)> {
604 maybe_write_back_with_owner(
605 pool,
606 run_id,
607 tags,
608 template,
609 driver,
610 config,
611 write_pid,
612 allow_uncertain,
613 handler,
614 None,
615 )
616 .await
617}
618
619#[allow(clippy::too_many_arguments)]
620pub(super) async fn maybe_write_back_with_owner(
621 pool: &SqlitePool,
622 run_id: i64,
623 tags: &LoopTags,
624 template: &DcsTemplate,
625 driver: &dyn Driver,
626 config: LoopConfig,
627 write_pid: Option<ResponseLevel>,
628 allow_uncertain: bool,
629 mut handler: Option<&mut dyn WriteBackHandler>,
630 ownership: Option<&LiveOperationGuard>,
631) -> anyhow::Result<(WriteBackOutcome, Option<String>)> {
632 let (Some(p_tag), Some(i_tag), Some(d_tag)) = (
633 &tags.proportional_constant,
634 &tags.integral_constant,
635 &tags.derivative_constant,
636 ) else {
637 if let Some(handler) = handler.as_deref_mut() {
638 handler.write_back_skipped(WriteBackSkipReason::NoPidTags);
639 }
640 return Ok((
641 WriteBackOutcome::Skipped,
642 Some("no PID constant tags configured for this run's driver/template".to_string()),
643 ));
644 };
645
646 let results = TuneResultRow::list_for_run(pool, run_id).await?;
647 if results.is_empty() {
648 if let Some(handler) = handler.as_deref_mut() {
649 handler.write_back_skipped(WriteBackSkipReason::NoResults);
650 }
651 return Ok((
652 WriteBackOutcome::Skipped,
653 Some("no calculated results were recorded for this run".to_string()),
654 ));
655 }
656
657 let selection = match write_pid {
658 Some(level) => WriteBackSelection::Selected(level),
659 None => match handler.as_deref_mut() {
660 Some(handler) => handler.select_response_level(
661 &results
662 .iter()
663 .map(|result| pid_write_preview(result, config.controller_type, template))
664 .collect::<Vec<_>>(),
665 ),
666 None => WriteBackSelection::Skipped(
667 "no response level was selected by the caller".to_string(),
668 ),
669 },
670 };
671 let response_level = match selection {
672 WriteBackSelection::Selected(level) => level,
673 WriteBackSelection::Skipped(detail) => {
674 return Ok((WriteBackOutcome::Skipped, Some(detail)));
675 }
676 WriteBackSelection::Failed(detail) => {
677 if let Some(handler) = handler.as_deref_mut() {
678 handler.write_back_failed(&detail);
679 }
680 return Ok((WriteBackOutcome::Failed, Some(detail)));
681 }
682 };
683
684 let Some(selected) = results
685 .iter()
686 .find(|result| result.response_level == response_level)
687 else {
688 let detail = format!("no calculated result recorded for response level {response_level:?}");
689 if let Some(handler) = handler.as_deref_mut() {
690 handler.write_back_failed(&detail);
691 }
692 return Ok((WriteBackOutcome::Failed, Some(detail)));
693 };
694 let written = match controller_pid_for_result(selected, config.controller_type, template) {
695 Ok(written) => written,
696 Err(error) => {
697 let detail = error.to_string();
698 if let Some(handler) = handler.as_deref_mut() {
699 handler.write_back_failed(&detail);
700 }
701 return Ok((WriteBackOutcome::Failed, Some(detail)));
702 }
703 };
704 let response_level = written.response_level;
705 if let Some(handler) = handler.as_deref_mut() {
706 handler.write_back_selected(&written, write_pid.is_some());
707 }
708 let target = WriteReadback {
709 proportional: written.proportional.value,
710 integral: written.integral.value,
711 derivative: written.derivative.value,
712 };
713
714 let outcome = match ownership {
715 Some(ownership) => {
716 write_pid_values_inner(
717 pool,
718 run_id,
719 driver,
720 p_tag,
721 i_tag,
722 d_tag,
723 response_level,
724 target,
725 WriteKind::Write,
726 allow_uncertain,
727 ownership,
728 )
729 .await?
730 }
731 None => anyhow::bail!(
732 "PID write-back requires a live ownership guard before controller mutation"
733 ),
734 };
735
736 if let Some(handler) = handler {
737 handler.write_back_finished(response_level, &outcome);
738 }
739
740 let report = match outcome {
741 PidWriteOutcome::Written => (WriteBackOutcome::Written { response_level }, None),
742 PidWriteOutcome::Failed { detail } => (WriteBackOutcome::Failed, Some(detail)),
743 };
744 Ok(report)
745}
746
747#[cfg(test)]
748mod tests;