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}