1use std::{
4 collections::{HashMap, HashSet},
5 future::Future,
6 time::Duration,
7};
8
9use bhtune_core::{DcsTemplate, LoopTags, TagOrValue, built_in_templates};
10use bhtune_db::{
11 SqlitePool,
12 models::{DcsTemplateRow, TemplateOrigin},
13};
14use bhtune_driver::{
15 Driver, DriverCapabilities, OpcDaCompatibilityStatus, OpcDaGatewayCompatibility,
16 OpcDaGatewayInfo, Quality, ReadOnlyDriver, TagValue, check_gateway_compatibility,
17 get_opcda_gateway_info, list_opcda_servers,
18};
19use serde::Serialize;
20
21use crate::{
22 config::{BhtuneConfig, resolve_and_validate_tuning_config},
23 driver::build_with_poll_interval,
24};
25
26use super::{
27 config::{EffectiveTiming, build_loop_config_with_timing, build_loop_tags},
28 prepare::{InitialState, validate_initial_state},
29 quality::{
30 check_quality, parse_f32_value, read_batch_f32, read_batch_raw,
31 resolve_direction_from_batch, resolve_f32_from_batch,
32 },
33 request::{DriverKind, TuneRequest, ValidatedTuneRequest},
34};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
38#[serde(rename_all = "snake_case")]
39pub enum PreflightCheckStatus {
40 Pass,
41 Warn,
42 Fail,
43}
44
45impl PreflightCheckStatus {
46 #[must_use]
48 pub const fn as_str(self) -> &'static str {
49 match self {
50 Self::Pass => "pass",
51 Self::Warn => "warn",
52 Self::Fail => "fail",
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
59pub struct PreflightCheck {
60 pub name: String,
61 pub status: PreflightCheckStatus,
62 pub detail: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67pub struct PreflightTagRead {
68 pub tag: String,
69 pub roles: Vec<String>,
70 pub value: Option<String>,
71 pub quality: Option<String>,
72 pub status: PreflightCheckStatus,
73 pub detail: String,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78pub struct PreflightReport {
79 pub checks: Vec<PreflightCheck>,
80 pub tag_reads: Vec<PreflightTagRead>,
81}
82
83impl PreflightReport {
84 fn new() -> Self {
85 Self {
86 checks: Vec::new(),
87 tag_reads: Vec::new(),
88 }
89 }
90
91 fn push_check(
92 &mut self,
93 name: impl Into<String>,
94 status: PreflightCheckStatus,
95 detail: impl Into<String>,
96 ) {
97 self.checks.push(PreflightCheck {
98 name: name.into(),
99 status,
100 detail: detail.into(),
101 });
102 }
103
104 #[must_use]
106 pub fn status(&self) -> PreflightCheckStatus {
107 if self.has_failures() {
108 PreflightCheckStatus::Fail
109 } else if self.has_warnings() {
110 PreflightCheckStatus::Warn
111 } else {
112 PreflightCheckStatus::Pass
113 }
114 }
115
116 #[must_use]
118 pub fn has_failures(&self) -> bool {
119 self.checks
120 .iter()
121 .any(|check| check.status == PreflightCheckStatus::Fail)
122 || self
123 .tag_reads
124 .iter()
125 .any(|tag| tag.status == PreflightCheckStatus::Fail)
126 }
127
128 #[must_use]
130 pub fn has_warnings(&self) -> bool {
131 self.checks
132 .iter()
133 .any(|check| check.status == PreflightCheckStatus::Warn)
134 || self
135 .tag_reads
136 .iter()
137 .any(|tag| tag.status == PreflightCheckStatus::Warn)
138 }
139
140 #[must_use]
142 pub fn passes(&self, strict: bool) -> bool {
143 !self.has_failures() && (!strict || !self.has_warnings())
144 }
145}
146
147struct ResolvedTemplate {
148 template: DcsTemplate,
149 source: &'static str,
150}
151
152#[derive(Debug, Clone)]
153struct DerivedTag {
154 tag: String,
155 roles: Vec<String>,
156}
157
158pub async fn preflight(
162 request: ValidatedTuneRequest,
163 app_config: &BhtuneConfig,
164 database: Option<&SqlitePool>,
165 user_templates: Option<&[DcsTemplate]>,
166) -> anyhow::Result<PreflightReport> {
167 let args = request.into_request();
168 let mut report = PreflightReport::new();
169
170 let effective_timing = match resolve_and_validate_tuning_config(
171 &app_config.tuning,
172 args.driver == DriverKind::Opcda,
173 ) {
174 Ok(timing) => {
175 report.push_check(
176 "Configuration and tuning",
177 PreflightCheckStatus::Pass,
178 format!(
179 "[tuning] is valid: poll={}ms timeout={}s operation={}s restore={}s delay={}s",
180 timing.poll_interval_ms,
181 timing.timeout_secs,
182 timing.op_timeout_secs,
183 timing.restore_timeout_secs,
184 timing.mrft_delay_secs
185 ),
186 );
187 EffectiveTiming::from(timing)
188 }
189 Err(error) => {
190 report.push_check(
191 "Configuration and tuning",
192 PreflightCheckStatus::Fail,
193 error.to_string(),
194 );
195 return Ok(report);
196 }
197 };
198
199 let Some(resolved_template) =
200 resolve_template(&args.template, database, user_templates).await?
201 else {
202 report.push_check(
203 "Template and tag derivation",
204 PreflightCheckStatus::Fail,
205 format!("no template named '{}'", args.template),
206 );
207 return Ok(report);
208 };
209
210 let loop_config = match build_loop_config_with_timing(&args, effective_timing) {
211 Ok(config) => config,
212 Err(error) => {
213 report.push_check(
214 "Loop configuration",
215 PreflightCheckStatus::Fail,
216 error.to_string(),
217 );
218 return Ok(report);
219 }
220 };
221
222 let tags = match build_loop_tags(&args, &resolved_template.template) {
223 Ok(tags) => tags,
224 Err(error) => {
225 report.push_check(
226 "Template and tag derivation",
227 PreflightCheckStatus::Fail,
228 error.to_string(),
229 );
230 return Ok(report);
231 }
232 };
233 report.push_check(
234 "Template and tag derivation",
235 PreflightCheckStatus::Pass,
236 format!(
237 "resolved '{}' from {}; derived {} tag(s)",
238 resolved_template.template.name,
239 resolved_template.source,
240 derived_tags(&tags).len()
241 ),
242 );
243 report.push_check(
244 "Loop configuration",
245 PreflightCheckStatus::Pass,
246 format!(
247 "LoopConfig::validate() passed for {:?}/{:?}",
248 loop_config.process_type, loop_config.controller_type
249 ),
250 );
251
252 let mut args = args;
253 args.bridge_host = Some(crate::config::resolve_bridge_host(
254 args.bridge_host.take(),
255 app_config,
256 ));
257 let bridge_host = args
258 .bridge_host
259 .as_deref()
260 .unwrap_or(crate::config::DEFAULT_BRIDGE_HOST)
261 .to_string();
262
263 if args.driver == DriverKind::Opcda {
264 let server = crate::config::resolve_server(args.server.take(), app_config)?;
265 args.server = Some(server.clone());
266 if !check_gateway_readiness(
267 &bridge_host,
268 &server,
269 effective_timing.op_timeout_secs,
270 &mut report,
271 )
272 .await?
273 {
274 return Ok(report);
275 }
276 } else {
277 report.push_check(
278 "Gateway info",
279 PreflightCheckStatus::Pass,
280 "not applicable to the in-process simulator",
281 );
282 report.push_check(
283 "Gateway compatibility",
284 PreflightCheckStatus::Pass,
285 "not applicable to the in-process simulator",
286 );
287 report.push_check(
288 "OPC DA ProgID",
289 PreflightCheckStatus::Pass,
290 "not applicable to the in-process simulator",
291 );
292 }
293
294 let driver = within_timeout(
295 effective_timing.op_timeout_secs,
296 "driver connection",
297 async { build_with_poll_interval(&args, effective_timing.poll_interval_ms).await },
298 )
299 .await?;
300 let driver = ReadOnlyDriver::new(driver);
301
302 if args.driver == DriverKind::Opcda {
303 match within_timeout(
304 effective_timing.op_timeout_secs,
305 "server capabilities",
306 async { Ok(driver.capabilities().await?) },
307 )
308 .await
309 {
310 Ok(capabilities) => report.push_check(
311 "Server capabilities",
312 PreflightCheckStatus::Pass,
313 capabilities_detail(&capabilities),
314 ),
315 Err(error) => report.push_check(
316 "Server capabilities",
317 PreflightCheckStatus::Warn,
318 format!("server capability discovery is unavailable: {error:#}"),
319 ),
320 }
321 } else {
322 report.push_check(
323 "Server capabilities",
324 PreflightCheckStatus::Pass,
325 "not applicable to the in-process simulator",
326 );
327 }
328
329 let derived = derived_tags(&tags);
330 let requested_tags: Vec<String> = derived.iter().map(|tag| tag.tag.clone()).collect();
331 let values = match within_timeout(effective_timing.op_timeout_secs, "tag read", async {
332 Ok(driver.read(&requested_tags).await?)
333 })
334 .await
335 {
336 Ok(values) => values,
337 Err(error) => {
338 report.push_check(
339 "Derived tag reads",
340 PreflightCheckStatus::Fail,
341 format!("could not read all derived tags: {error:#}"),
342 );
343 return Ok(report);
344 }
345 };
346
347 let requested_set: HashSet<&str> = derived.iter().map(|tag| tag.tag.as_str()).collect();
348 let mut values_by_tag = HashMap::new();
349 let mut duplicate_tags = HashSet::new();
350 let mut unexpected_tags = Vec::new();
351 for value in values {
352 if !requested_set.contains(value.tag.as_str()) {
353 unexpected_tags.push(value.tag);
354 } else if values_by_tag.contains_key(&value.tag) {
355 duplicate_tags.insert(value.tag);
356 } else {
357 values_by_tag.insert(value.tag.clone(), value);
358 }
359 }
360
361 report.tag_reads = derived
362 .iter()
363 .map(|tag| {
364 assess_tag_read(
365 tag,
366 values_by_tag.get(&tag.tag),
367 duplicate_tags.contains(&tag.tag),
368 app_config.allow_uncertain_quality,
369 )
370 })
371 .collect();
372
373 let tag_read_status = if report
374 .tag_reads
375 .iter()
376 .any(|tag| tag.status == PreflightCheckStatus::Fail)
377 || !unexpected_tags.is_empty()
378 {
379 PreflightCheckStatus::Fail
380 } else if report
381 .tag_reads
382 .iter()
383 .any(|tag| tag.status == PreflightCheckStatus::Warn)
384 {
385 PreflightCheckStatus::Warn
386 } else {
387 PreflightCheckStatus::Pass
388 };
389 let mut tag_read_detail = format!(
390 "read {} derived tag(s), including value and OPC quality",
391 report.tag_reads.len()
392 );
393 if !unexpected_tags.is_empty() {
394 tag_read_detail.push_str(&format!(
395 "; driver returned unexpected tag(s): {}",
396 unexpected_tags.join(", ")
397 ));
398 }
399 report.push_check("Derived tag reads", tag_read_status, tag_read_detail);
400
401 let (mode_status, mode_detail) = mode_status(
402 &tags,
403 &resolved_template.template,
404 &values_by_tag,
405 &report.tag_reads,
406 );
407 report.push_check("Mode values", mode_status, mode_detail);
408
409 let (mode_attribute_status, mode_attribute_detail) = mode_attribute_status(
410 &tags,
411 &resolved_template.template,
412 &values_by_tag,
413 &report.tag_reads,
414 );
415 report.push_check(
416 "Mode attribute",
417 mode_attribute_status,
418 mode_attribute_detail,
419 );
420
421 match initial_state_from_batch(
422 &driver,
423 &tags,
424 &resolved_template.template,
425 &values_by_tag,
426 app_config.allow_uncertain_quality,
427 )
428 .await
429 {
430 Ok(initial) => match validate_initial_state(&initial) {
431 Ok(()) => report.push_check(
432 "Initial state",
433 PreflightCheckStatus::Pass,
434 "PV/MV values are numeric and the PV/MV ranges are valid; initial MV is in range",
435 ),
436 Err(error) => report.push_check(
437 "Initial state",
438 PreflightCheckStatus::Fail,
439 error.to_string(),
440 ),
441 },
442 Err(error) => report.push_check(
443 "Initial state",
444 PreflightCheckStatus::Fail,
445 error.to_string(),
446 ),
447 }
448
449 report.push_check(
450 "Write-back readiness",
451 write_back_status(&args, &tags, &report.tag_reads, mode_attribute_status),
452 write_back_detail(&args, &tags, &report.tag_reads, mode_attribute_status),
453 );
454
455 Ok(report)
456}
457
458async fn check_gateway_readiness(
459 bridge_host: &str,
460 server: &str,
461 op_timeout_secs: u64,
462 report: &mut PreflightReport,
463) -> anyhow::Result<bool> {
464 let gateway_info = within_timeout(op_timeout_secs, "gateway info", async {
465 Ok(get_opcda_gateway_info(bridge_host).await?)
466 })
467 .await;
468 match &gateway_info {
469 Ok(info) => report.push_check(
470 "Gateway info",
471 PreflightCheckStatus::Pass,
472 gateway_info_detail(info),
473 ),
474 Err(error) => report.push_check(
475 "Gateway info",
476 PreflightCheckStatus::Warn,
477 format!("gateway-wide metadata is unavailable: {error:#}"),
478 ),
479 }
480
481 let compatibility = within_timeout(op_timeout_secs, "gateway compatibility", async {
482 Ok(check_gateway_compatibility(bridge_host, Some(server)).await?)
483 })
484 .await?;
485 report.push_check(
486 "Gateway compatibility",
487 compatibility_status(&compatibility),
488 compatibility_detail(&compatibility),
489 );
490
491 let servers = within_timeout(op_timeout_secs, "OPC DA server discovery", async {
492 Ok(list_opcda_servers(bridge_host).await?)
493 })
494 .await?;
495 let server_found = servers
496 .iter()
497 .any(|registered| registered.eq_ignore_ascii_case(server));
498 report.push_check(
499 "OPC DA ProgID",
500 if server_found {
501 PreflightCheckStatus::Pass
502 } else {
503 PreflightCheckStatus::Fail
504 },
505 if server_found {
506 format!("'{server}' is registered on the gateway")
507 } else if servers.is_empty() {
508 format!("'{server}' is not registered; the gateway reported no servers")
509 } else {
510 format!(
511 "'{server}' is not registered; available servers: {}",
512 servers.join(", ")
513 )
514 },
515 );
516
517 Ok(server_found && !compatibility.is_incompatible())
518}
519
520async fn resolve_template(
521 name: &str,
522 database: Option<&SqlitePool>,
523 user_templates: Option<&[DcsTemplate]>,
524) -> anyhow::Result<Option<ResolvedTemplate>> {
525 let builtins = built_in_templates();
526 let builtin = builtins.iter().find(|template| template.name == name);
527 let catalog = user_templates
528 .and_then(|templates| templates.iter().find(|template| template.name == name));
529
530 if let Some(row) = match database {
531 Some(pool) => DcsTemplateRow::get_by_name(pool, name).await?,
532 None => None,
533 } {
534 let (template, source) = match row.origin {
535 TemplateOrigin::Builtin => (
536 builtin.cloned().unwrap_or(row.template),
537 "built-in template",
538 ),
539 TemplateOrigin::Catalog => (
540 catalog.cloned().unwrap_or(row.template),
541 "user catalog template",
542 ),
543 TemplateOrigin::User => (row.template, "database user template"),
544 };
545 return Ok(Some(ResolvedTemplate { template, source }));
546 }
547
548 Ok(builtin
549 .cloned()
550 .map(|template| ResolvedTemplate {
551 template,
552 source: "built-in template",
553 })
554 .or_else(|| {
555 catalog.cloned().map(|template| ResolvedTemplate {
556 template,
557 source: "user catalog template",
558 })
559 }))
560}
561
562fn derived_tags(tags: &LoopTags) -> Vec<DerivedTag> {
563 let mut derived: Vec<DerivedTag> = Vec::new();
564 let mut indices: HashMap<String, usize> = HashMap::new();
565
566 let mut add = |tag: &str, role: &str| {
567 if let Some(index) = indices.get(tag).copied() {
568 let roles = &mut derived[index].roles;
569 if !roles.iter().any(|existing| existing == role) {
570 roles.push(role.to_string());
571 }
572 } else {
573 indices.insert(tag.to_string(), derived.len());
574 derived.push(DerivedTag {
575 tag: tag.to_string(),
576 roles: vec![role.to_string()],
577 });
578 }
579 };
580
581 add(&tags.process_variable, "process_variable");
582 add(&tags.manipulated_variable, "manipulated_variable");
583 if let Some(tag) = &tags.setpoint_variable {
584 add(tag, "setpoint_variable");
585 }
586 if let Some(tag) = &tags.controller_mode {
587 add(tag, "controller_mode");
588 }
589 if let Some(tag) = &tags.mode_attribute {
590 add(tag, "mode_attribute");
591 }
592 if let TagOrValue::Tag(tag) = &tags.upper_pv_range {
593 add(tag, "upper_pv_range");
594 }
595 if let TagOrValue::Tag(tag) = &tags.lower_pv_range {
596 add(tag, "lower_pv_range");
597 }
598 if let TagOrValue::Tag(tag) = &tags.upper_mv_range {
599 add(tag, "upper_mv_range");
600 }
601 if let TagOrValue::Tag(tag) = &tags.lower_mv_range {
602 add(tag, "lower_mv_range");
603 }
604 if let TagOrValue::Tag(tag) = &tags.controller_direction {
605 add(tag, "controller_direction");
606 }
607 if let Some(tag) = &tags.proportional_constant {
608 add(tag, "proportional_constant");
609 }
610 if let Some(tag) = &tags.integral_constant {
611 add(tag, "integral_constant");
612 }
613 if let Some(tag) = &tags.derivative_constant {
614 add(tag, "derivative_constant");
615 }
616 derived
617}
618
619fn assess_tag_read(
620 derived: &DerivedTag,
621 value: Option<&TagValue>,
622 duplicate: bool,
623 allow_uncertain: bool,
624) -> PreflightTagRead {
625 let Some(value) = value else {
626 return PreflightTagRead {
627 tag: derived.tag.clone(),
628 roles: derived.roles.clone(),
629 value: None,
630 quality: None,
631 status: PreflightCheckStatus::Fail,
632 detail: "driver returned no value for this tag".to_string(),
633 };
634 };
635
636 let mut status = if duplicate {
637 PreflightCheckStatus::Fail
638 } else {
639 PreflightCheckStatus::Pass
640 };
641 let mut details = vec![format!("quality {:?}", value.quality)];
642 if duplicate {
643 details.push("driver returned this tag more than once".to_string());
644 }
645
646 match check_quality(&derived.tag, value.quality, allow_uncertain) {
647 Ok(()) if value.quality == Quality::Uncertain => {
648 status = highest_status(status, PreflightCheckStatus::Warn);
649 details.push("Uncertain quality is accepted by configuration".to_string());
650 }
651 Ok(()) => {}
652 Err(error) => {
653 status = PreflightCheckStatus::Fail;
654 details.push(error.to_string());
655 }
656 }
657
658 if status != PreflightCheckStatus::Fail || value.quality == Quality::Uncertain {
659 for role in &derived.roles {
660 if is_numeric_role(role)
661 && let Err(error) = parse_f32_value(&derived.tag, &value.value)
662 {
663 status = PreflightCheckStatus::Fail;
664 details.push(error.to_string());
665 break;
666 }
667 }
668 }
669
670 PreflightTagRead {
671 tag: derived.tag.clone(),
672 roles: derived.roles.clone(),
673 value: Some(value.value.clone()),
674 quality: Some(format!("{:?}", value.quality)),
675 status,
676 detail: details.join("; "),
677 }
678}
679
680fn is_numeric_role(role: &str) -> bool {
681 matches!(
682 role,
683 "process_variable"
684 | "manipulated_variable"
685 | "setpoint_variable"
686 | "upper_pv_range"
687 | "lower_pv_range"
688 | "upper_mv_range"
689 | "lower_mv_range"
690 | "proportional_constant"
691 | "integral_constant"
692 | "derivative_constant"
693 )
694}
695
696fn mode_status(
697 tags: &LoopTags,
698 template: &DcsTemplate,
699 values: &HashMap<String, TagValue>,
700 tag_reads: &[PreflightTagRead],
701) -> (PreflightCheckStatus, String) {
702 let Some(mode_tag) = &tags.controller_mode else {
703 return (
704 PreflightCheckStatus::Pass,
705 "template does not define a controller mode tag".to_string(),
706 );
707 };
708
709 let Some(read) = tag_reads.iter().find(|read| read.tag == *mode_tag) else {
710 return (
711 PreflightCheckStatus::Fail,
712 format!("mode tag '{mode_tag}' was not included in the read results"),
713 );
714 };
715 if read.status == PreflightCheckStatus::Fail {
716 return (
717 PreflightCheckStatus::Fail,
718 format!("mode tag '{mode_tag}' did not have a usable value and quality"),
719 );
720 }
721
722 let Some(value) = values.get(mode_tag) else {
723 return (
724 PreflightCheckStatus::Fail,
725 format!("driver returned no value for mode tag '{mode_tag}'"),
726 );
727 };
728 if value.value == template.mode_manual_value || value.value == template.mode_auto_value {
729 (
730 read.status,
731 format!(
732 "mode tag '{mode_tag}' value '{}' matches the template's Manual or Auto value",
733 value.value
734 ),
735 )
736 } else {
737 (
738 PreflightCheckStatus::Fail,
739 format!(
740 "mode tag '{mode_tag}' value '{}' matches neither template value ('{}' or '{}')",
741 value.value, template.mode_manual_value, template.mode_auto_value
742 ),
743 )
744 }
745}
746
747fn mode_attribute_status(
748 tags: &LoopTags,
749 template: &DcsTemplate,
750 values: &HashMap<String, TagValue>,
751 tag_reads: &[PreflightTagRead],
752) -> (PreflightCheckStatus, String) {
753 let Some(attribute_tag) = &tags.mode_attribute else {
754 return (
755 PreflightCheckStatus::Pass,
756 "template does not define a mode-attribute tag".to_string(),
757 );
758 };
759 let Some(program_value) = &template.mode_attribute_program_value else {
760 return (
761 PreflightCheckStatus::Fail,
762 format!("mode-attribute tag '{attribute_tag}' has no template program value"),
763 );
764 };
765 let Some(read) = tag_reads.iter().find(|read| read.tag == *attribute_tag) else {
766 return (
767 PreflightCheckStatus::Fail,
768 format!("mode-attribute tag '{attribute_tag}' was not included in the read results"),
769 );
770 };
771 if read.status == PreflightCheckStatus::Fail {
772 return (
773 PreflightCheckStatus::Fail,
774 format!("mode-attribute tag '{attribute_tag}' did not have a usable value and quality"),
775 );
776 }
777 let Some(value) = values.get(attribute_tag) else {
778 return (
779 PreflightCheckStatus::Fail,
780 format!("driver returned no value for mode-attribute tag '{attribute_tag}'"),
781 );
782 };
783 if value.value == *program_value {
784 (
785 read.status,
786 format!(
787 "mode-attribute tag '{attribute_tag}' value matches the template's program value"
788 ),
789 )
790 } else {
791 (
792 PreflightCheckStatus::Warn,
793 format!(
794 "mode-attribute tag '{attribute_tag}' value '{}' differs from the template's \
795 program value '{program_value}'",
796 value.value
797 ),
798 )
799 }
800}
801
802async fn initial_state_from_batch(
803 driver: &dyn Driver,
804 tags: &LoopTags,
805 template: &DcsTemplate,
806 values: &HashMap<String, TagValue>,
807 allow_uncertain: bool,
808) -> anyhow::Result<InitialState> {
809 let pv_ini = read_batch_f32(values, &tags.process_variable, allow_uncertain)?;
810 let mv_ini = read_batch_f32(values, &tags.manipulated_variable, allow_uncertain)?;
811 let mode_raw = match &tags.controller_mode {
812 Some(tag) => Some(read_batch_raw(values, tag, allow_uncertain)?),
813 None => None,
814 };
815 let mode_attribute_raw = match &tags.mode_attribute {
816 Some(tag) => Some(read_batch_raw(values, tag, allow_uncertain)?),
817 None => None,
818 };
819 let setpoint_ini = match (&tags.setpoint_variable, &mode_raw) {
820 (Some(tag), Some(mode)) if mode == &template.mode_auto_value => {
821 Some(read_batch_f32(values, tag, allow_uncertain)?)
822 }
823 _ => None,
824 };
825 let direction = resolve_direction_from_batch(
826 driver,
827 values,
828 &tags.controller_direction,
829 template,
830 allow_uncertain,
831 )
832 .await?;
833 let pv_range_high =
834 resolve_f32_from_batch(driver, values, &tags.upper_pv_range, allow_uncertain).await?;
835 let pv_range_low =
836 resolve_f32_from_batch(driver, values, &tags.lower_pv_range, allow_uncertain).await?;
837 let mv_range_high =
838 resolve_f32_from_batch(driver, values, &tags.upper_mv_range, allow_uncertain).await?;
839 let mv_range_low =
840 resolve_f32_from_batch(driver, values, &tags.lower_mv_range, allow_uncertain).await?;
841
842 Ok(InitialState {
843 pv_ini,
844 mv_ini,
845 pv_range_high,
846 pv_range_low,
847 mv_range_high,
848 mv_range_low,
849 direction,
850 mode_raw,
851 mode_attribute_raw,
852 setpoint_ini,
853 })
854}
855
856fn pid_constant_tags(tags: &LoopTags) -> [(&'static str, Option<&str>); 3] {
857 [
858 ("Proportional", tags.proportional_constant.as_deref()),
859 ("Integral", tags.integral_constant.as_deref()),
860 ("Derivative", tags.derivative_constant.as_deref()),
861 ]
862}
863
864fn write_back_status(
865 args: &TuneRequest,
866 tags: &LoopTags,
867 reads: &[PreflightTagRead],
868 mode_attribute_status: PreflightCheckStatus,
869) -> PreflightCheckStatus {
870 if args.driver == DriverKind::Simulator {
871 return if args.write_pid.is_some() {
872 PreflightCheckStatus::Fail
873 } else {
874 PreflightCheckStatus::Pass
875 };
876 }
877
878 let required = pid_constant_tags(tags);
879 let missing: Vec<&str> = required
880 .iter()
881 .filter_map(|(label, tag)| tag.is_none().then_some(*label))
882 .collect();
883 if !missing.is_empty() {
884 return if args.write_pid.is_some() {
885 PreflightCheckStatus::Fail
886 } else {
887 PreflightCheckStatus::Warn
888 };
889 }
890
891 if mode_attribute_status == PreflightCheckStatus::Fail
892 || (args.write_pid.is_some() && mode_attribute_status == PreflightCheckStatus::Warn)
893 {
894 return PreflightCheckStatus::Fail;
895 }
896
897 let statuses: Vec<PreflightCheckStatus> = required
898 .iter()
899 .filter_map(|(_, tag)| *tag)
900 .filter_map(|tag| reads.iter().find(|read| read.tag == tag))
901 .map(|read| read.status)
902 .collect();
903 if statuses.len() != required.len() || statuses.contains(&PreflightCheckStatus::Fail) {
904 PreflightCheckStatus::Fail
905 } else if statuses.contains(&PreflightCheckStatus::Warn)
906 || mode_attribute_status == PreflightCheckStatus::Warn
907 {
908 PreflightCheckStatus::Warn
909 } else {
910 PreflightCheckStatus::Pass
911 }
912}
913
914fn write_back_detail(
915 args: &TuneRequest,
916 tags: &LoopTags,
917 reads: &[PreflightTagRead],
918 mode_attribute_status: PreflightCheckStatus,
919) -> String {
920 if args.driver == DriverKind::Simulator {
921 return if args.write_pid.is_some() {
922 "PID write-back is not available for the simulator".to_string()
923 } else {
924 "not applicable: the simulator has no PID constant tags".to_string()
925 };
926 }
927
928 let required = pid_constant_tags(tags);
929 let missing: Vec<&str> = required
930 .iter()
931 .filter_map(|(label, tag)| tag.is_none().then_some(*label))
932 .collect();
933 if !missing.is_empty() {
934 return format!(
935 "{} PID constant tag(s) are not configured{}",
936 missing.join(", "),
937 if args.write_pid.is_some() {
938 "; the requested write-back cannot be prepared"
939 } else {
940 "; write-back would be skipped"
941 }
942 );
943 }
944
945 if mode_attribute_status == PreflightCheckStatus::Fail
946 || (args.write_pid.is_some() && mode_attribute_status == PreflightCheckStatus::Warn)
947 {
948 return if args.write_pid.is_some() {
949 "the mode attribute is not confirmed at the template's program value; the \
950 requested write-back is not ready"
951 .to_string()
952 } else {
953 "mode-attribute readiness could not be confirmed".to_string()
954 };
955 }
956
957 let unreadable: Vec<&str> = required
958 .iter()
959 .filter_map(|(label, tag)| {
960 let tag = (*tag)?;
961 reads
962 .iter()
963 .find(|read| read.tag == tag)
964 .filter(|read| read.status != PreflightCheckStatus::Pass)
965 .map(|_| *label)
966 })
967 .collect();
968 if !unreadable.is_empty() {
969 return format!(
970 "{} PID constant tag(s) have failed or warning-level reads{}",
971 unreadable.join(", "),
972 if args.write_pid.is_some() {
973 "; the requested write-back is not ready"
974 } else {
975 "; write-back readiness is incomplete"
976 }
977 );
978 }
979
980 let mut detail = "P/I/D tags are derived and readable as numeric values; this read-only \
981 check does not probe controller write permissions"
982 .to_string();
983 if mode_attribute_status == PreflightCheckStatus::Warn {
984 detail.push_str("; current mode attribute differs from the template's program value");
985 }
986 detail
987}
988
989fn compatibility_status(compatibility: &OpcDaGatewayCompatibility) -> PreflightCheckStatus {
990 match compatibility.status {
991 OpcDaCompatibilityStatus::Full => PreflightCheckStatus::Pass,
992 OpcDaCompatibilityStatus::Partial | OpcDaCompatibilityStatus::Unknown => {
993 PreflightCheckStatus::Warn
994 }
995 OpcDaCompatibilityStatus::Incompatible => PreflightCheckStatus::Fail,
996 }
997}
998
999fn compatibility_detail(compatibility: &OpcDaGatewayCompatibility) -> String {
1000 match compatibility.status {
1001 OpcDaCompatibilityStatus::Full => {
1002 format!(
1003 "gateway protocol is fully compatible ({})",
1004 compatibility.client_version
1005 )
1006 }
1007 OpcDaCompatibilityStatus::Partial | OpcDaCompatibilityStatus::Unknown => {
1008 compatibility_warning_detail(compatibility, compatibility.warning_message())
1009 }
1010 OpcDaCompatibilityStatus::Incompatible => compatibility.incompatibility_message(),
1011 }
1012}
1013
1014fn compatibility_warning_detail(
1015 compatibility: &OpcDaGatewayCompatibility,
1016 warning: Option<String>,
1017) -> String {
1018 warning.unwrap_or_else(|| format!("compatibility status is {}", compatibility.status.as_str()))
1019}
1020
1021fn gateway_info_detail(info: &OpcDaGatewayInfo) -> String {
1022 let features = info
1023 .features
1024 .iter()
1025 .map(|feature| {
1026 format!(
1027 "{} {}-{}",
1028 feature.feature.as_str(),
1029 feature.min_version,
1030 feature.max_version
1031 )
1032 })
1033 .collect::<Vec<_>>()
1034 .join(", ");
1035 format!(
1036 "gateway {} (compatibility schema {}); features: {}",
1037 info.application_version, info.compatibility_schema_version, features
1038 )
1039}
1040
1041fn capabilities_detail(capabilities: &DriverCapabilities) -> String {
1042 format!(
1043 "server {} protocol {}; browse sessions={}, live search={}, indexed search={}",
1044 capabilities.application_version,
1045 capabilities.protocol_version,
1046 capabilities.supports_browse_sessions,
1047 capabilities.supports_search,
1048 capabilities.supports_indexed_search
1049 )
1050}
1051
1052fn highest_status(
1053 current: PreflightCheckStatus,
1054 candidate: PreflightCheckStatus,
1055) -> PreflightCheckStatus {
1056 match (current, candidate) {
1057 (PreflightCheckStatus::Fail, _) | (_, PreflightCheckStatus::Fail) => {
1058 PreflightCheckStatus::Fail
1059 }
1060 (PreflightCheckStatus::Warn, _) | (_, PreflightCheckStatus::Warn) => {
1061 PreflightCheckStatus::Warn
1062 }
1063 _ => PreflightCheckStatus::Pass,
1064 }
1065}
1066
1067async fn within_timeout<T>(
1068 timeout_secs: u64,
1069 operation: &str,
1070 future: impl Future<Output = anyhow::Result<T>>,
1071) -> anyhow::Result<T> {
1072 tokio::time::timeout(Duration::from_secs(timeout_secs), future)
1073 .await
1074 .map_err(|_| anyhow::anyhow!("{operation} timed out after {timeout_secs}s"))?
1075}
1076
1077#[cfg(test)]
1078mod tests;