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}