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 against a criterion's threshold bands (spec §7).
64    ///
65    /// The criterion is named rather than left to the application to
66    /// work out: a valuation holds several criteria, and matching them to
67    /// variables by quantity is a guess — two criteria can share one, and
68    /// two engines can publish a variable of the same name meaning
69    /// different things. Guessing produced a drainage map offered a
70    /// threshold scale annotated with water-distribution numbers.
71    Banded {
72        /// Key of the criterion (spec §7.1) whose valuation supplies the
73        /// thresholds. Must exist in the same engine's criteria catalog
74        /// and carry severities.
75        criterion: &'static str,
76    },
77    /// A closed set of discrete states, with engine-authored items.
78    Categorical { items: Vec<CategoryItem> },
79}
80
81/// Descriptor of one result variable in an engine's per-class catalog
82/// (spec §6.1).
83///
84/// `id` follows the block-id stability rule — application preferences and
85/// saved views may reference it.
86#[derive(Debug, Clone, PartialEq, Serialize)]
87#[serde(rename_all = "camelCase")]
88pub struct VariableDescriptor {
89    /// Stable variable identifier, opaque to this layer.
90    pub id: &'static str,
91    /// Human-facing name.
92    pub label: &'static str,
93    /// Compact engine-authored notation (≤3 chars) for space-starved
94    /// surfaces — ideally the domain's standard symbol (spec §6.1) — or
95    /// `None` for the application's own fallback.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub symbol: Option<&'static str>,
98    /// Key of the quantity the values carry (spec §5), or `None` for
99    /// dimensionless variables.
100    pub quantity: Option<&'static str>,
101    /// How values map to a colour scale.
102    pub ramp: RampHint,
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn ramp_hint_serialises_tagged() {
111        let ramp = RampHint::Categorical {
112            items: vec![CategoryItem {
113                value: 3,
114                label: "Open".into(),
115                severity: Some(CategorySeverity::Nominal),
116            }],
117        };
118        let json = serde_json::to_value(&ramp).unwrap();
119        assert_eq!(json["type"], "categorical");
120        assert_eq!(json["items"][0]["value"], 3);
121        assert_eq!(json["items"][0]["severity"], "nominal");
122    }
123
124    /// Severity is a real claim about the domain, so a state set that is
125    /// merely a partition must be able to decline it — and declining must
126    /// be distinguishable from claiming "nominal", not collapsed into it.
127    #[test]
128    fn a_state_without_a_judgement_omits_severity() {
129        let ramp = RampHint::Categorical {
130            items: vec![CategoryItem {
131                value: 1,
132                label: "Concrete".into(),
133                severity: None,
134            }],
135        };
136        let json = serde_json::to_value(&ramp).unwrap();
137        assert!(
138            json["items"][0].get("severity").is_none(),
139            "absent severity must not serialise, got {}",
140            json["items"][0]
141        );
142    }
143
144    /// Catalogs are persisted and exchanged; a severity that round-trips to
145    /// a different level would silently re-rank an engine's states.
146    #[test]
147    fn severity_round_trips() {
148        for level in [
149            CategorySeverity::Nominal,
150            CategorySeverity::Caution,
151            CategorySeverity::Alarm,
152        ] {
153            let item = CategoryItem {
154                value: 0,
155                label: "s".into(),
156                severity: Some(level),
157            };
158            let json = serde_json::to_string(&item).unwrap();
159            let back: CategoryItem = serde_json::from_str(&json).unwrap();
160            assert_eq!(back.severity, Some(level));
161        }
162    }
163}