Skip to main content

bhtune_runtime/tune/
preflight.rs

1//! Read-only validation of a tune request before the tune execution path is prepared.
2
3use 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/// Severity assigned to an individual preflight result.
37#[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    /// Returns the stable JSON and table representation.
47    #[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/// One named preflight check and its operator-facing detail.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
59pub struct PreflightCheck {
60    pub name: String,
61    pub status: PreflightCheckStatus,
62    pub detail: String,
63}
64
65/// The value, quality, and validation result for one derived OPC tag.
66#[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/// Read-only results for a tune request.
77#[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    /// Returns the highest severity present in the report.
105    #[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    /// Returns whether at least one check or tag read failed.
117    #[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    /// Returns whether at least one check or tag read produced a warning.
129    #[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    /// Returns whether this report passes, including warning policy.
141    #[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
158/// Checks a request without invoking tune preparation, the polling loop, or any database
159/// mutation. An existing database is queried only through a read-only pool; when it is
160/// absent, the current built-in and configured catalog templates provide the lookup data.
161pub 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;