Skip to main content

hydra_common/
variables.rs

1//! Result-variable contract: the per-element time-series variables a
2//! completed simulation carries (spec §6).
3//!
4//! Catalogs are static; which variables are *present* in a given run is
5//! resolved per-run by the engine (spec §6.2), the way block options are
6//! resolved against a model. Consumers address results by (element class,
7//! variable id, reporting period); wire encodings are the consumer's own
8//! concern but must be derived from the catalog rather than fixing a
9//! variable list (spec §6.3).
10
11use serde::{Deserialize, Serialize};
12
13/// How remarkable one categorical state is (spec §6.1).
14///
15/// A statement about the domain, not about presentation: whether a state is
16/// an abnormal condition is something only the engine knows. Applications
17/// decide what — if anything — each level looks like.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub enum CategorySeverity {
21    /// The ordinary condition; nothing to notice.
22    Nominal,
23    /// Worth attention, but not a fault.
24    Caution,
25    /// An abnormal condition.
26    Alarm,
27}
28
29/// One discrete state of a [`RampHint::Categorical`] variable.
30///
31/// `value` is the number the engine stores in the result series for this
32/// state; `label` is engine-authored display text.
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct CategoryItem {
36    /// The stored series value representing this state.
37    pub value: i64,
38    /// Human-facing label for this state.
39    pub label: String,
40    /// Whether this state is unremarkable, worth attention, or wrong.
41    ///
42    /// `None` where the states carry no such judgement — a partition like a
43    /// land-use class or a material has no abnormal member, and must not be
44    /// made to invent one.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub severity: Option<CategorySeverity>,
47}
48
49/// How a variable's values are meaningfully mapped to a colour scale
50/// (spec §6.1).
51///
52/// The only presentation vocabulary this layer contributes, and it is a
53/// shape statement, never a colour: an application chooses palettes, band
54/// edges, and legend styling; the engine says only which shape is truthful
55/// for the data.
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57#[serde(tag = "type", rename_all = "camelCase")]
58pub enum RampHint {
59    /// Magnitude on a continuous low→high scale.
60    Sequential,
61    /// Signed values around a meaningful zero (e.g. flow direction).
62    Diverging,
63    /// Values classed into user-configurable threshold bands.
64    Banded,
65    /// A closed set of discrete states, with engine-authored items.
66    Categorical { items: Vec<CategoryItem> },
67}
68
69/// Descriptor of one result variable in an engine's per-class catalog
70/// (spec §6.1).
71///
72/// `id` follows the block-id stability rule — application preferences and
73/// saved views may reference it.
74#[derive(Debug, Clone, PartialEq, Serialize)]
75#[serde(rename_all = "camelCase")]
76pub struct VariableDescriptor {
77    /// Stable variable identifier, opaque to this layer.
78    pub id: &'static str,
79    /// Human-facing name.
80    pub label: &'static str,
81    /// Compact engine-authored notation (≤3 chars) for space-starved
82    /// surfaces — ideally the domain's standard symbol (spec §6.1) — or
83    /// `None` for the application's own fallback.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub symbol: Option<&'static str>,
86    /// Key of the quantity the values carry (spec §5), or `None` for
87    /// dimensionless variables.
88    pub quantity: Option<&'static str>,
89    /// How values map to a colour scale.
90    pub ramp: RampHint,
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn ramp_hint_serialises_tagged() {
99        let ramp = RampHint::Categorical {
100            items: vec![CategoryItem {
101                value: 3,
102                label: "Open".into(),
103                severity: Some(CategorySeverity::Nominal),
104            }],
105        };
106        let json = serde_json::to_value(&ramp).unwrap();
107        assert_eq!(json["type"], "categorical");
108        assert_eq!(json["items"][0]["value"], 3);
109        assert_eq!(json["items"][0]["severity"], "nominal");
110    }
111
112    /// Severity is a real claim about the domain, so a state set that is
113    /// merely a partition must be able to decline it — and declining must
114    /// be distinguishable from claiming "nominal", not collapsed into it.
115    #[test]
116    fn a_state_without_a_judgement_omits_severity() {
117        let ramp = RampHint::Categorical {
118            items: vec![CategoryItem {
119                value: 1,
120                label: "Concrete".into(),
121                severity: None,
122            }],
123        };
124        let json = serde_json::to_value(&ramp).unwrap();
125        assert!(
126            json["items"][0].get("severity").is_none(),
127            "absent severity must not serialise, got {}",
128            json["items"][0]
129        );
130    }
131
132    /// Catalogs are persisted and exchanged; a severity that round-trips to
133    /// a different level would silently re-rank an engine's states.
134    #[test]
135    fn severity_round_trips() {
136        for level in [
137            CategorySeverity::Nominal,
138            CategorySeverity::Caution,
139            CategorySeverity::Alarm,
140        ] {
141            let item = CategoryItem {
142                value: 0,
143                label: "s".into(),
144                severity: Some(level),
145            };
146            let json = serde_json::to_string(&item).unwrap();
147            let back: CategoryItem = serde_json::from_str(&json).unwrap();
148            assert_eq!(back.severity, Some(level));
149        }
150    }
151}