hydra_common/elements.rs
1//! Element taxonomy contract: element classes, kind descriptors, and
2//! attribute schemas (spec §4).
3//!
4//! Everything here is *data*. The only structural vocabulary this layer
5//! owns is [`ElementClass`] — the geometric and referential nature of an
6//! element, which an application must know to render it. Everything else
7//! (what a junction or a subcatchment *is*) travels as opaque ids and
8//! engine-authored text, exactly as the recognition and reportable-output
9//! contracts do.
10
11use serde::{Deserialize, Serialize};
12
13use crate::OptionKind;
14
15/// The geometric and referential nature of an element kind (spec §4.1).
16///
17/// The class list is closed in this revision; extending it is an additive
18/// spec change in this layer, never an engine decision. A subcatchment is
19/// the proof case for [`ElementClass::Region`]: it is neither a node nor a
20/// link, and a taxonomy offering only those two classes would have baked
21/// one engine family's shape into the foundation.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum ElementClass {
25 /// A located element: one coordinate. May anchor `Polyline` ends and
26 /// `Region` discharge references.
27 Point,
28 /// A connecting element: references a from-point and a to-point, with
29 /// optional intermediate vertices.
30 Polyline,
31 /// An areal element: a polygon boundary, with an optional reference to
32 /// a `Point` element it discharges to.
33 Region,
34 /// A non-spatial named object (a curve, a pattern, a time series, a
35 /// control). Enumerable and countable; presentation is
36 /// application-defined and may be engine-specific.
37 Collection,
38}
39
40/// What an element kind does in the network, as distinct from what it is
41/// geometrically (spec §4.3).
42///
43/// This exists because it is the distinction an application must draw to
44/// present an *unsimulated* model at all: before any results exist there is
45/// nothing to colour by, and a network drawn in one uniform tone tells a
46/// reader nothing. `ElementClass` cannot answer it — a pump and a pipe are
47/// both `Polyline`, a reservoir and a junction both `Point` — and kind
48/// cannot either without the application naming kinds it should not know.
49///
50/// Carries no presentation. An application decides what a boundary looks
51/// like; this layer decides only which kinds are boundaries.
52///
53/// Optional on `ElementKind`: some kinds have no role in the flow network,
54/// and the absence is information rather than a gap to be defaulted away.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum ElementRole {
58 /// Carries flow without imposing a boundary or a control on it — a
59 /// junction, a pipe, a conduit. The bulk of any model.
60 Conveyance,
61 /// Where the model meets what it does not simulate: a fixed head or
62 /// stage, a storage volume, an outfall. Flow enters or leaves here.
63 Boundary,
64 /// Acts on the flow rather than merely passing it — a pump, a valve, a
65 /// weir, an orifice, a flow divider.
66 Control,
67}
68
69/// Descriptor of one element kind in an engine's catalog (spec §4.2).
70///
71/// The catalog is static and model-free, like the block catalog: an
72/// application must be able to build its chrome — tables, filters, layer
73/// toggles, legends — before any model is loaded. `id` follows the block-id
74/// stability rule: removing one, or changing the *meaning* of one, is a
75/// break on the order of a file-format break.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "camelCase")]
78pub struct ElementKind {
79 /// Stable kind identifier, opaque to this layer.
80 pub id: &'static str,
81 /// Human-facing singular name.
82 pub label: &'static str,
83 /// Human-facing plural name.
84 pub label_plural: &'static str,
85 /// The kind's element class.
86 pub class: ElementClass,
87 /// What the kind does in the network (spec §4.3), or `None` for a kind
88 /// that is not in the flow network at all — a rain gage conveys
89 /// nothing, and a curve or a control rule is not in it to begin with.
90 pub role: Option<ElementRole>,
91 /// One- or two-character glyph for dense UI (markers, chips).
92 pub badge: &'static str,
93 /// The heading this kind is listed under (spec §4.2.1), or `None`
94 /// for one the engine places under no heading.
95 ///
96 /// The engine's own word rather than a class: §4.1's classes say how
97 /// an application must *draw* a thing, and that is not what a
98 /// modeller calls it. A drainage engineer says "nodes" and "links",
99 /// not "points" and "polylines".
100 ///
101 /// Presentation only. Nothing may depend on it, and an application
102 /// that ignores it renders a flat list and is correct.
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub group: Option<&'static str>,
105 /// Whether elements of this kind may be created (spec §4.5.3).
106 ///
107 /// Advisory, like [`AttributeDescriptor::editable`]: creation is the
108 /// authority. `false` is the default a kind gets by saying nothing,
109 /// so a catalog written before the editing contract offers nothing
110 /// rather than everything.
111 pub creatable: bool,
112 /// What a new element of this kind would need that cannot be
113 /// defaulted — a relation curve, a rating, an opening geometry.
114 ///
115 /// Present only when `creatable` is false, and required then: a
116 /// refusal without a reason is a dead end, and the application shows
117 /// this rather than inventing its own explanation. Plain text,
118 /// engine-authored.
119 pub not_creatable_because: Option<&'static str>,
120}
121
122/// Description of one attribute an application may display for elements of
123/// a kind (spec §4.4).
124///
125/// Reuses the option-descriptor value vocabulary ([`OptionKind`], spec
126/// §3.2.1) and is advisory in exactly that sense: it tells a generic UI
127/// what to show; it is not the validation authority, and an engine remains
128/// free to hold data no schema advertises.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct AttributeDescriptor {
132 /// Field name in the element's attribute data. Stable per kind;
133 /// renaming one is a break, like a block id.
134 pub key: String,
135 /// Human-facing name.
136 pub label: String,
137 /// Value shape and bounds.
138 pub kind: OptionKind,
139 /// Key of the physical quantity the value carries (spec §5), or `None`
140 /// for dimensionless or textual attributes.
141 pub quantity: Option<String>,
142 /// Whether a write to this attribute may be offered (spec §4.5.1).
143 ///
144 /// Advisory: the write is the authority, and an engine refuses one it
145 /// will not accept whether or not this said so. It exists so a
146 /// surface can offer an input rather than offer one and be refused,
147 /// which teaches the user the same thing one interaction later.
148 ///
149 /// This says the *attribute* can be written, not that a particular
150 /// element can be: an element that carries no value for the key has
151 /// nothing to change, and offering an input there would invite
152 /// creating a value the model never held.
153 ///
154 /// Defaulted on the wire so a schema written before the editing
155 /// contract deserialises, offering nothing rather than everything.
156 #[serde(default)]
157 pub editable: bool,
158 /// The kind ids whose elements this attribute may name (spec
159 /// §4.5.1.1), empty for a value that is not a reference.
160 ///
161 /// Without them a reference is indistinguishable from free text, and
162 /// an application can only offer a box to type a name into — where
163 /// the names are the model's own and a typo produces a reference to
164 /// nothing.
165 ///
166 /// A list because a reference is not always to one kind: a drainage
167 /// subcatchment discharges to a conveyance node or to another
168 /// subcatchment. It must be the complete set an application may
169 /// offer — a subset is worse than none, since a list that looks
170 /// complete is read as complete.
171 ///
172 /// Not a foreign key: this layer defines no referential integrity
173 /// and does not require the named element to exist. What happens to
174 /// a reference when its target is removed is the engine's rule,
175 /// expressed through its removal (§4.5.4).
176 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 pub references: Vec<String>,
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn element_class_serialises_lowercase() {
186 assert_eq!(
187 serde_json::to_string(&ElementClass::Region).unwrap(),
188 "\"region\""
189 );
190 assert_eq!(
191 serde_json::to_string(&ElementClass::Collection).unwrap(),
192 "\"collection\""
193 );
194 }
195
196 /// A group is optional, and absent means the application draws no
197 /// heading — not an empty one. The field leaves the wire entirely so
198 /// a consumer cannot tell "no group" from "a group called nothing".
199 #[test]
200 fn a_kind_in_no_group_carries_no_group_field() {
201 let kind = ElementKind {
202 id: "k",
203 label: "Kind",
204 label_plural: "Kinds",
205 class: ElementClass::Collection,
206 role: None,
207 badge: "K",
208 group: None,
209 creatable: false,
210 not_creatable_because: None,
211 };
212 let json = serde_json::to_value(kind).unwrap();
213 assert!(json.get("group").is_none(), "{json}");
214 }
215
216 #[test]
217 fn kind_descriptor_serialises_camel_case() {
218 let kind = ElementKind {
219 id: "k",
220 label: "Kind",
221 label_plural: "Kinds",
222 class: ElementClass::Point,
223 role: Some(ElementRole::Boundary),
224 badge: "K",
225 group: Some("Kinds"),
226 creatable: false,
227 not_creatable_because: Some("a kind needs something only its engine knows"),
228 };
229 let json = serde_json::to_value(kind).unwrap();
230 assert_eq!(json["labelPlural"], "Kinds");
231 assert_eq!(json["class"], "point");
232 assert_eq!(json["role"], "boundary");
233 assert_eq!(json["creatable"], false);
234 assert_eq!(json["group"], "Kinds");
235 assert_eq!(
236 json["notCreatableBecause"],
237 "a kind needs something only its engine knows"
238 );
239 }
240
241 /// A schema written before the editing contract has no `editable`
242 /// field, and must deserialise as offering nothing rather than
243 /// failing outright (spec §4.5.1).
244 #[test]
245 fn an_attribute_without_the_flag_is_not_editable() {
246 let json = serde_json::json!({
247 "key": "elevation",
248 "label": "Elevation",
249 "kind": { "type": "number" },
250 "quantity": "elevation",
251 });
252 let attr: AttributeDescriptor =
253 serde_json::from_value(json).expect("a pre-contract schema still reads");
254 assert!(!attr.editable);
255 }
256}