Skip to main content

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}