Skip to main content

bhtune_core/
template.rs

1//! DCS/PLC template semantics: one instance per control-system convention (Yokogawa,
2//! Honeywell, etc.), describing how that DCS expresses PID parameters and the OPC
3//! item-name suffix convention used to derive a full tag set from a single PV tag (see
4//! [`crate::tags::derive_tag`]).
5//!
6//! The built-in templates are not hardcoded Rust -- they are parsed from an embedded TOML
7//! catalog (`templates/builtin.toml`), so adding support for a new DCS/PLC family is a data
8//! file change, not a Rust change. See AGENTS.md's "Community DCS/PLC template catalog"
9//! section for the full design and contribution rationale.
10
11use serde::{Deserialize, Serialize};
12
13use crate::pid_config::{DerivativeType, IntegralType, ProportionalType, TimeUnit};
14
15/// One DCS/PLC vendor's conventions.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
18#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
19pub struct DcsTemplate {
20    pub name: String,
21
22    /// If true, the controller mode is switched back to its original mode (e.g.
23    /// Auto/Cascade) after a completed MRFT test. Has no effect if the loop was already in
24    /// Manual at test start.
25    pub revert_mode: bool,
26
27    pub proportional_type: ProportionalType,
28    pub integral_type: IntegralType,
29    pub integral_unit: TimeUnit,
30    pub derivative_type: DerivativeType,
31    pub derivative_unit: TimeUnit,
32
33    /// OPC item-name suffixes, combined with a PV tag's path prefix by
34    /// [`crate::tags::derive_tag`] to fill in the rest of the tag set. An empty suffix
35    /// means the corresponding tag is not applicable for this DCS (e.g. some DCS families
36    /// have no mode-attribute concept).
37    pub process_variable_suffix: String,
38    pub manipulated_variable_suffix: String,
39    pub setpoint_variable_suffix: String,
40    pub controller_direction_suffix: String,
41    pub controller_mode_suffix: String,
42    pub mode_attribute_suffix: String,
43    pub upper_pv_range_suffix: String,
44    pub lower_pv_range_suffix: String,
45    pub upper_mv_range_suffix: String,
46    pub lower_mv_range_suffix: String,
47    pub proportional_constant_suffix: String,
48    pub integral_constant_suffix: String,
49    pub derivative_constant_suffix: String,
50
51    /// The DCS-specific raw values a Mode tag holds for Manual/Auto.
52    pub mode_manual_value: String,
53    pub mode_auto_value: String,
54    /// The raw value a Mode Attribute tag holds when in "Program" mode (permits external
55    /// OPC writes). `None` when the DCS has no mode-attribute concept.
56    pub mode_attribute_program_value: Option<String>,
57    /// The raw value a Controller Direction tag holds when the controller is Direct
58    /// acting; see [`crate::direction::ControllerDirection::from_raw_tag_value`].
59    pub controller_action_direct_value: String,
60
61    /// DCS/PLC releases this mapping is known to apply to (e.g. `["R5", "R6"]`), in each
62    /// vendor's own version-naming convention rather than a normalized scheme. A newer
63    /// release that changes tag conventions gets its *own* template entry with its own
64    /// `name` and `versions` list -- never an edit to this one in place, since sites still
65    /// running the older release depend on the existing mapping (see
66    /// `docs/dcs-templates.md`). May be empty for a contribution whose version coverage
67    /// isn't yet known; `name` is what makes a template unique, not `versions`.
68    #[serde(default)]
69    pub versions: Vec<String>,
70    /// Free-text description of the control system this template targets.
71    #[serde(default)]
72    pub description: Option<String>,
73    /// Citation for where this mapping came from (a manual, a field deployment).
74    /// Provenance, not a correctness guarantee -- there is deliberately no separate
75    /// "verified" trust field; everything accepted into the catalog is treated as verified,
76    /// and real mapping errors are fixed as bugs when they surface.
77    #[serde(default)]
78    pub source: Option<String>,
79}
80
81impl DcsTemplate {
82    /// Validates cross-field invariants a data file can't express on its own: a name, a PV
83    /// suffix, and an MV suffix are always required (without them tag derivation is
84    /// impossible); a mode suffix requires both a manual and an auto value; a
85    /// mode-attribute suffix requires its program value. Mirrors `LoopConfig::validate`'s
86    /// rationale (see AGENTS.md's "Live-plant safety hardening") -- a half-configured
87    /// template should fail loudly at parse/import time, not mid-tune against a live loop.
88    /// Called on every template parsed from the embedded catalog
89    /// ([`parse_catalog`]), an imported file, or the user catalog.
90    pub fn validate(&self) -> Result<(), TemplateError> {
91        if self.name.trim().is_empty() {
92            return Err(TemplateError::EmptyName);
93        }
94        if self.process_variable_suffix.is_empty() {
95            return Err(TemplateError::EmptyField {
96                name: self.name.clone(),
97                field: "process_variable_suffix",
98            });
99        }
100        if self.manipulated_variable_suffix.is_empty() {
101            return Err(TemplateError::EmptyField {
102                name: self.name.clone(),
103                field: "manipulated_variable_suffix",
104            });
105        }
106        if !self.controller_mode_suffix.is_empty() {
107            if self.mode_manual_value.is_empty() {
108                return Err(TemplateError::MissingModeValue {
109                    name: self.name.clone(),
110                    field: "mode_manual_value",
111                });
112            }
113            if self.mode_auto_value.is_empty() {
114                return Err(TemplateError::MissingModeValue {
115                    name: self.name.clone(),
116                    field: "mode_auto_value",
117                });
118            }
119        }
120        if !self.mode_attribute_suffix.is_empty() && self.mode_attribute_program_value.is_none() {
121            return Err(TemplateError::MissingModeAttributeProgramValue {
122                name: self.name.clone(),
123            });
124        }
125        Ok(())
126    }
127}
128
129/// Why [`DcsTemplate::validate`] or [`parse_catalog`] rejected a template.
130#[derive(Debug, Clone, PartialEq)]
131pub enum TemplateError {
132    /// The catalog's TOML could not be parsed at all (malformed syntax, wrong shape).
133    Toml(toml::de::Error),
134    /// `name` was empty or all whitespace.
135    EmptyName,
136    /// A required suffix field was empty.
137    EmptyField { name: String, field: &'static str },
138    /// `controller_mode_suffix` was set but the manual or auto value for it was empty.
139    MissingModeValue { name: String, field: &'static str },
140    /// `mode_attribute_suffix` was set but `mode_attribute_program_value` was `None`.
141    MissingModeAttributeProgramValue { name: String },
142}
143
144impl std::fmt::Display for TemplateError {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        match self {
147            TemplateError::Toml(e) => write!(f, "invalid template catalog: {e}"),
148            TemplateError::EmptyName => write!(f, "template name must not be empty"),
149            TemplateError::EmptyField { name, field } => {
150                write!(f, "template '{name}': {field} must not be empty")
151            }
152            TemplateError::MissingModeValue { name, field } => write!(
153                f,
154                "template '{name}': controller_mode_suffix is set but {field} is empty"
155            ),
156            TemplateError::MissingModeAttributeProgramValue { name } => write!(
157                f,
158                "template '{name}': mode_attribute_suffix is set but \
159                 mode_attribute_program_value is missing"
160            ),
161        }
162    }
163}
164
165impl std::error::Error for TemplateError {
166    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
167        match self {
168            TemplateError::Toml(e) => Some(e),
169            _ => None,
170        }
171    }
172}
173
174impl From<toml::de::Error> for TemplateError {
175    fn from(error: toml::de::Error) -> Self {
176        TemplateError::Toml(error)
177    }
178}
179
180/// The embedded/user catalog's top-level shape: a TOML `[[template]]` array of tables. Also
181/// used in reverse by [`to_catalog_toml`] (bhtune-cli's `template export --format toml`), so
182/// export and import always agree on the exact same wire shape with no separate format to
183/// keep in sync by hand.
184#[derive(Debug, Serialize, Deserialize)]
185struct Catalog {
186    #[serde(rename = "template")]
187    templates: Vec<DcsTemplate>,
188}
189
190/// Parses a TOML catalog (the `[[template]]` array-of-tables format used by
191/// `templates/builtin.toml` and the user catalog file bhtune-cli auto-loads) and validates
192/// every template it contains. Pure -- takes an in-memory string and does no I/O itself;
193/// all file reading is the caller's job (bhtune-cli's `template-user-catalog`/
194/// `template-cli`), keeping this crate's "no I/O" rule intact.
195pub fn parse_catalog(input: &str) -> Result<Vec<DcsTemplate>, TemplateError> {
196    let catalog: Catalog = toml::from_str(input)?;
197    for template in &catalog.templates {
198        template.validate()?;
199    }
200    Ok(catalog.templates)
201}
202
203/// Serializes `templates` as a TOML catalog in the exact `[[template]]` array-of-tables
204/// shape [`parse_catalog`] reads back -- the inverse operation. Used by bhtune-cli's
205/// `template export --format toml` (a single template exports as a one-entry catalog) so
206/// the contribution loop is export -> annotate -> PR with no hand-transcription step. Pure,
207/// like [`parse_catalog`]: writing the result to a file is the caller's job.
208pub fn to_catalog_toml(templates: Vec<DcsTemplate>) -> Result<String, toml::ser::Error> {
209    toml::to_string_pretty(&Catalog { templates })
210}
211
212/// The embedded catalog TOML, compiled into the binary so it can never go missing from a
213/// shipped install -- see `templates/builtin.toml` for the actual data and its contribution
214/// rules.
215const BUILTIN_CATALOG: &str = include_str!("../templates/builtin.toml");
216
217/// The DCS/PLC templates shipped by default, parsed from the embedded catalog.
218///
219/// # Panics
220///
221/// Panics if the embedded catalog fails to parse or validate. This can only happen from a
222/// bad edit to `templates/builtin.toml` itself; this module's
223/// `embedded_catalog_parses_and_validates` test proves it never does in practice, so a
224/// malformed contribution fails CI rather than shipping.
225pub fn built_in_templates() -> Vec<DcsTemplate> {
226    parse_catalog(BUILTIN_CATALOG).expect("embedded builtin.toml catalog must parse and validate")
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use proptest::prelude::*;
233
234    #[test]
235    fn ships_exactly_four_templates() {
236        let templates = built_in_templates();
237        assert_eq!(templates.len(), 4);
238        let names: Vec<&str> = templates.iter().map(|t| t.name.as_str()).collect();
239        assert_eq!(
240            names,
241            vec![
242                "Yokogawa CentumVP",
243                "Honeywell Experion",
244                "Schneider Modicon",
245                "Allen-Bradley PlantPAx"
246            ]
247        );
248    }
249
250    #[test]
251    fn yokogawa_has_no_mode_attribute_concept() {
252        let templates = built_in_templates();
253        let yokogawa = templates
254            .iter()
255            .find(|t| t.name == "Yokogawa CentumVP")
256            .unwrap();
257        assert!(yokogawa.mode_attribute_suffix.is_empty());
258        assert_eq!(yokogawa.mode_attribute_program_value, None);
259    }
260
261    #[test]
262    fn honeywell_has_a_mode_attribute_concept() {
263        let templates = built_in_templates();
264        let honeywell = templates
265            .iter()
266            .find(|t| t.name == "Honeywell Experion")
267            .unwrap();
268        assert_eq!(honeywell.mode_attribute_suffix, "MODEATTR");
269        assert_eq!(
270            honeywell.mode_attribute_program_value,
271            Some("2".to_string())
272        );
273    }
274
275    #[test]
276    fn yokogawa_uses_proportional_band_others_use_gain() {
277        let templates = built_in_templates();
278        for template in &templates {
279            let expected = if template.name == "Yokogawa CentumVP" {
280                ProportionalType::Band
281            } else {
282                ProportionalType::Gain
283            };
284            assert_eq!(template.proportional_type, expected, "{}", template.name);
285        }
286    }
287
288    #[test]
289    fn all_templates_use_reset_time_and_derivative_time() {
290        for template in built_in_templates() {
291            assert_eq!(template.integral_type, IntegralType::ResetTime);
292            assert_eq!(template.derivative_type, DerivativeType::DerivativeTime);
293        }
294    }
295
296    #[test]
297    fn serde_round_trip() {
298        for template in built_in_templates() {
299            let json = serde_json::to_string(&template).unwrap();
300            let back: DcsTemplate = serde_json::from_str(&json).unwrap();
301            assert_eq!(template, back);
302        }
303    }
304
305    #[test]
306    fn embedded_catalog_parses_and_validates() {
307        // `built_in_templates()` already calls `parse_catalog(...).expect(...)`
308        // internally, so simply calling it without panicking proves this -- but assert on
309        // the result explicitly too, so a future refactor that swallows the panic still
310        // gets caught here.
311        let templates = parse_catalog(BUILTIN_CATALOG).unwrap();
312        for template in &templates {
313            assert!(template.validate().is_ok(), "{}", template.name);
314        }
315    }
316
317    #[test]
318    fn built_in_templates_carry_their_researched_versions() {
319        let templates = built_in_templates();
320        let versions = |name: &str| -> Vec<String> {
321            templates
322                .iter()
323                .find(|t| t.name == name)
324                .unwrap()
325                .versions
326                .clone()
327        };
328        assert_eq!(versions("Yokogawa CentumVP"), vec!["R5", "R6"]);
329        assert_eq!(versions("Honeywell Experion"), vec!["R400", "R410", "R430"]);
330        assert_eq!(
331            versions("Schneider Modicon"),
332            vec!["Unity Pro V8.0", "Unity Pro V8.1", "Unity Pro V11.0"]
333        );
334        assert_eq!(
335            versions("Allen-Bradley PlantPAx"),
336            vec!["3.0", "3.5", "4.0"]
337        );
338    }
339
340    #[test]
341    fn built_in_templates_have_a_description_and_source() {
342        for template in built_in_templates() {
343            assert!(template.description.is_some(), "{}", template.name);
344            assert!(template.source.is_some(), "{}", template.name);
345        }
346    }
347
348    /// A minimal single-template TOML document that passes `validate()` as-is, used as a
349    /// base for the `parse_catalog` rejection tests below via targeted `str::replace`
350    /// edits. `controller_mode_suffix`/`mode_manual_value`/`mode_auto_value` are left blank
351    /// (no mode concept) and `mode_attribute_suffix` is blank too, so none of
352    /// `validate()`'s conditional checks fire unless a test deliberately arms them.
353    fn minimal_valid_toml() -> &'static str {
354        r#"
355[[template]]
356name = "Test DCS"
357revert_mode = false
358proportional_type = "gain"
359integral_type = "reset_time"
360integral_unit = "seconds"
361derivative_type = "derivative_time"
362derivative_unit = "seconds"
363process_variable_suffix = "PV"
364manipulated_variable_suffix = "MV"
365setpoint_variable_suffix = "SV"
366controller_direction_suffix = "DR"
367controller_mode_suffix = ""
368mode_attribute_suffix = ""
369upper_pv_range_suffix = "SH"
370lower_pv_range_suffix = "SL"
371upper_mv_range_suffix = "MSH"
372lower_mv_range_suffix = "MSL"
373proportional_constant_suffix = "P"
374integral_constant_suffix = "I"
375derivative_constant_suffix = "D"
376mode_manual_value = ""
377mode_auto_value = ""
378controller_action_direct_value = "0"
379"#
380    }
381
382    #[test]
383    fn parse_catalog_accepts_a_minimal_valid_template() {
384        let templates = parse_catalog(minimal_valid_toml()).unwrap();
385        assert_eq!(templates.len(), 1);
386        assert_eq!(templates[0].name, "Test DCS");
387        assert!(templates[0].versions.is_empty());
388        assert_eq!(templates[0].description, None);
389        assert_eq!(templates[0].source, None);
390    }
391
392    #[test]
393    fn parse_catalog_rejects_malformed_toml() {
394        let err = parse_catalog("this is not [[ valid toml").unwrap_err();
395        assert!(matches!(err, TemplateError::Toml(_)));
396    }
397
398    #[test]
399    fn parse_catalog_rejects_an_empty_pv_suffix() {
400        let toml = minimal_valid_toml().replace(
401            r#"process_variable_suffix = "PV""#,
402            r#"process_variable_suffix = """#,
403        );
404        let err = parse_catalog(&toml).unwrap_err();
405        assert!(matches!(
406            err,
407            TemplateError::EmptyField {
408                field: "process_variable_suffix",
409                ..
410            }
411        ));
412    }
413
414    #[test]
415    fn parse_catalog_rejects_an_empty_mv_suffix() {
416        let toml = minimal_valid_toml().replace(
417            r#"manipulated_variable_suffix = "MV""#,
418            r#"manipulated_variable_suffix = """#,
419        );
420        let err = parse_catalog(&toml).unwrap_err();
421        assert!(matches!(
422            err,
423            TemplateError::EmptyField {
424                field: "manipulated_variable_suffix",
425                ..
426            }
427        ));
428    }
429
430    #[test]
431    fn parse_catalog_rejects_a_mode_suffix_without_manual_value() {
432        let toml = minimal_valid_toml().replace(
433            r#"controller_mode_suffix = """#,
434            r#"controller_mode_suffix = "MODE""#,
435        );
436        // mode_manual_value/mode_auto_value are still "" in this variant.
437        let err = parse_catalog(&toml).unwrap_err();
438        assert!(matches!(
439            err,
440            TemplateError::MissingModeValue {
441                field: "mode_manual_value",
442                ..
443            }
444        ));
445    }
446
447    #[test]
448    fn parse_catalog_rejects_a_mode_suffix_without_auto_value() {
449        let toml = minimal_valid_toml()
450            .replace(
451                r#"controller_mode_suffix = """#,
452                r#"controller_mode_suffix = "MODE""#,
453            )
454            .replace(r#"mode_manual_value = """#, r#"mode_manual_value = "MAN""#);
455        // mode_auto_value stays "" in this variant.
456        let err = parse_catalog(&toml).unwrap_err();
457        assert!(matches!(
458            err,
459            TemplateError::MissingModeValue {
460                field: "mode_auto_value",
461                ..
462            }
463        ));
464    }
465
466    #[test]
467    fn parse_catalog_rejects_a_mode_attribute_suffix_without_program_value() {
468        let toml = minimal_valid_toml().replace(
469            r#"mode_attribute_suffix = """#,
470            r#"mode_attribute_suffix = "MODEATTR""#,
471        );
472        let err = parse_catalog(&toml).unwrap_err();
473        assert!(matches!(
474            err,
475            TemplateError::MissingModeAttributeProgramValue { .. }
476        ));
477    }
478
479    #[test]
480    fn to_catalog_toml_round_trips_the_built_in_templates() {
481        let original = built_in_templates();
482        let toml = to_catalog_toml(original.clone()).unwrap();
483        let parsed = parse_catalog(&toml).unwrap();
484        assert_eq!(parsed, original);
485    }
486
487    #[test]
488    fn to_catalog_toml_with_one_template_produces_a_single_template_block() {
489        let template = parse_catalog(minimal_valid_toml()).unwrap().remove(0);
490        let toml = to_catalog_toml(vec![template.clone()]).unwrap();
491        assert_eq!(toml.matches("[[template]]").count(), 1);
492        let parsed = parse_catalog(&toml).unwrap();
493        assert_eq!(parsed, vec![template]);
494    }
495
496    #[test]
497    fn to_catalog_toml_with_no_templates_produces_an_empty_catalog() {
498        let toml = to_catalog_toml(vec![]).unwrap();
499        assert_eq!(parse_catalog(&toml).unwrap(), Vec::new());
500    }
501
502    #[test]
503    fn validate_rejects_an_empty_name() {
504        let mut template = built_in_templates().remove(0);
505        template.name = "   ".to_string();
506        assert_eq!(template.validate(), Err(TemplateError::EmptyName));
507    }
508
509    #[test]
510    fn validate_accepts_every_built_in_template() {
511        for template in built_in_templates() {
512            assert!(template.validate().is_ok(), "{}", template.name);
513        }
514    }
515
516    #[test]
517    fn template_error_is_a_std_error() {
518        let err = TemplateError::EmptyName;
519        let _: Box<dyn std::error::Error> = Box::new(err);
520    }
521
522    #[test]
523    fn template_error_display_names_the_template_and_field() {
524        let err = TemplateError::EmptyField {
525            name: "My DCS".to_string(),
526            field: "process_variable_suffix",
527        };
528        let msg = err.to_string();
529        assert!(msg.contains("My DCS"));
530        assert!(msg.contains("process_variable_suffix"));
531    }
532
533    #[test]
534    fn template_error_toml_variant_has_a_source() {
535        use std::error::Error as _;
536        let err = parse_catalog("this is not [[ valid toml").unwrap_err();
537        assert!(err.source().is_some());
538    }
539
540    #[test]
541    fn template_error_display_covers_every_remaining_variant() {
542        let toml_err = parse_catalog("this is not [[ valid toml").unwrap_err();
543        assert!(toml_err.to_string().contains("invalid template catalog"));
544
545        assert_eq!(
546            TemplateError::EmptyName.to_string(),
547            "template name must not be empty"
548        );
549
550        let mode_value_err = TemplateError::MissingModeValue {
551            name: "My DCS".to_string(),
552            field: "mode_manual_value",
553        };
554        let msg = mode_value_err.to_string();
555        assert!(msg.contains("My DCS"));
556        assert!(msg.contains("mode_manual_value"));
557
558        let mode_attr_err = TemplateError::MissingModeAttributeProgramValue {
559            name: "My DCS".to_string(),
560        };
561        assert!(mode_attr_err.to_string().contains("My DCS"));
562    }
563
564    #[test]
565    fn template_error_non_toml_variants_have_no_source() {
566        use std::error::Error as _;
567        assert!(TemplateError::EmptyName.source().is_none());
568    }
569
570    proptest::proptest! {
571        #[test]
572        fn valid_templates_round_trip_through_catalog_toml(
573            name in "[A-Za-z][A-Za-z0-9 _-]{0,24}",
574            description in prop::option::of("[A-Za-z0-9 .,;:-]{0,40}"),
575            source in prop::option::of("[A-Za-z0-9 ./,:-]{0,40}"),
576            versions in prop::collection::vec("[A-Za-z0-9 ._-]{1,12}", 0..4),
577        ) {
578            let mut template = built_in_templates().remove(0);
579            template.name = name;
580            template.description = description;
581            template.source = source;
582            template.versions = versions;
583
584            let encoded = to_catalog_toml(vec![template.clone()]).unwrap();
585            prop_assert_eq!(parse_catalog(&encoded).unwrap(), vec![template]);
586        }
587
588        #[test]
589        fn arbitrary_catalog_text_never_panics(input in any::<String>()) {
590            let _ = parse_catalog(&input);
591        }
592
593        #[test]
594        fn arbitrary_json_text_never_panics(input in any::<String>()) {
595            let _ = serde_json::from_str::<DcsTemplate>(&input);
596        }
597    }
598}