Skip to main content

haystack_server/
demo.rs

1// Demo dataset — a realistic small building automation system for testing.
2
3use haystack_core::data::HDict;
4use haystack_core::kinds::{HRef, Kind, Number};
5
6/// Generate a complete demo dataset representing a small building automation system.
7///
8/// Returns 36 entities:
9/// - 1 site
10/// - 3 floors
11/// - 2 AHUs (on Floor 1)
12/// - 6 VAVs (3 per AHU, spread across floors)
13/// - 24 points (4 per VAV)
14pub fn demo_entities() -> Vec<HDict> {
15    let mut entities = Vec::new();
16
17    // ── Site ──
18    let site_id = "demo-site";
19    entities.push(make_site(site_id));
20
21    // ── Floors ──
22    let floor_ids: Vec<String> = (1..=3).map(|n| format!("demo-floor-{n}")).collect();
23    for (i, floor_id) in floor_ids.iter().enumerate() {
24        entities.push(make_floor(floor_id, i + 1, site_id));
25    }
26
27    // ── AHUs (both on Floor 1) ──
28    let ahu_ids: Vec<String> = (1..=2).map(|n| format!("demo-ahu-{n}")).collect();
29    for (i, ahu_id) in ahu_ids.iter().enumerate() {
30        entities.push(make_ahu(ahu_id, i + 1, site_id, &floor_ids[0]));
31    }
32
33    // ── VAVs (3 per AHU, spread across floors) ──
34    // AHU-1: VAV-1-01 (Floor 1), VAV-1-02 (Floor 2), VAV-1-03 (Floor 3)
35    // AHU-2: VAV-2-01 (Floor 1), VAV-2-02 (Floor 2), VAV-2-03 (Floor 3)
36    for (ahu_idx, ahu_id) in ahu_ids.iter().enumerate() {
37        let ahu_num = ahu_idx + 1;
38        for vav_num in 1..=3 {
39            let vav_id = format!("demo-vav-{ahu_num}-{vav_num:02}");
40            let floor_id = &floor_ids[vav_num - 1];
41            entities.push(make_vav(
42                &vav_id, ahu_num, vav_num, site_id, floor_id, ahu_id,
43            ));
44
45            // ── Points (4 per VAV) ──
46            entities.push(make_zone_air_temp_sensor(
47                &vav_id, site_id, floor_id, ahu_id,
48            ));
49            entities.push(make_zone_air_temp_sp(&vav_id, site_id, floor_id, ahu_id));
50            entities.push(make_damper_cmd(&vav_id, site_id, floor_id, ahu_id));
51            entities.push(make_occ_sensor(&vav_id, site_id, floor_id, ahu_id));
52        }
53    }
54
55    entities
56}
57
58fn make_site(id: &str) -> HDict {
59    let mut d = HDict::new();
60    d.set("id", Kind::Ref(HRef::new(id, Some("Demo Building".into()))));
61    d.set("site", Kind::Marker);
62    d.set("dis", Kind::Str("Demo Building".into()));
63    d.set(
64        "area",
65        Kind::Number(Number::new(50000.0, Some("ft\u{00b2}".into()))),
66    );
67    d.set("geoCity", Kind::Str("Richmond".into()));
68    d.set("geoState", Kind::Str("VA".into()));
69    d.set("tz", Kind::Str("New_York".into()));
70    d
71}
72
73fn make_floor(id: &str, num: usize, site_id: &str) -> HDict {
74    let mut d = HDict::new();
75    let dis = format!("Floor {num}");
76    d.set("id", Kind::Ref(HRef::new(id, Some(dis.clone()))));
77    d.set("floor", Kind::Marker);
78    d.set("dis", Kind::Str(dis));
79    d.set("siteRef", Kind::Ref(HRef::from_val(site_id)));
80    d
81}
82
83fn make_ahu(id: &str, num: usize, site_id: &str, floor_id: &str) -> HDict {
84    let mut d = HDict::new();
85    let dis = format!("AHU-{num}");
86    d.set("id", Kind::Ref(HRef::new(id, Some(dis.clone()))));
87    d.set("ahu", Kind::Marker);
88    d.set("equip", Kind::Marker);
89    d.set("dis", Kind::Str(dis));
90    d.set("siteRef", Kind::Ref(HRef::from_val(site_id)));
91    d.set("floorRef", Kind::Ref(HRef::from_val(floor_id)));
92    d
93}
94
95fn make_vav(
96    id: &str,
97    ahu_num: usize,
98    vav_num: usize,
99    site_id: &str,
100    floor_id: &str,
101    ahu_id: &str,
102) -> HDict {
103    let mut d = HDict::new();
104    let dis = format!("VAV-{ahu_num}-{vav_num:02}");
105    d.set("id", Kind::Ref(HRef::new(id, Some(dis.clone()))));
106    d.set("vav", Kind::Marker);
107    d.set("equip", Kind::Marker);
108    d.set("dis", Kind::Str(dis));
109    d.set("siteRef", Kind::Ref(HRef::from_val(site_id)));
110    d.set("floorRef", Kind::Ref(HRef::from_val(floor_id)));
111    d.set("equipRef", Kind::Ref(HRef::from_val(ahu_id)));
112    d
113}
114
115fn make_zone_air_temp_sensor(vav_id: &str, site_id: &str, floor_id: &str, _ahu_id: &str) -> HDict {
116    let mut d = HDict::new();
117    let pt_id = format!("{vav_id}-zat");
118    let dis = format!("{} Zone Air Temp", vav_dis(vav_id));
119    d.set("id", Kind::Ref(HRef::new(&pt_id, Some(dis.clone()))));
120    d.set("point", Kind::Marker);
121    d.set("sensor", Kind::Marker);
122    d.set("zone", Kind::Marker);
123    d.set("air", Kind::Marker);
124    d.set("temp", Kind::Marker);
125    d.set("his", Kind::Marker);
126    d.set("kind", Kind::Str("Number".into()));
127    d.set("unit", Kind::Str("\u{00b0}F".into()));
128    d.set(
129        "curVal",
130        Kind::Number(Number::new(72.0, Some("\u{00b0}F".into()))),
131    );
132    d.set("dis", Kind::Str(dis));
133    d.set("siteRef", Kind::Ref(HRef::from_val(site_id)));
134    d.set("floorRef", Kind::Ref(HRef::from_val(floor_id)));
135    d.set("equipRef", Kind::Ref(HRef::from_val(vav_id)));
136    d
137}
138
139fn make_zone_air_temp_sp(vav_id: &str, site_id: &str, floor_id: &str, _ahu_id: &str) -> HDict {
140    let mut d = HDict::new();
141    let pt_id = format!("{vav_id}-zatsp");
142    let dis = format!("{} Zone Air Temp SP", vav_dis(vav_id));
143    d.set("id", Kind::Ref(HRef::new(&pt_id, Some(dis.clone()))));
144    d.set("point", Kind::Marker);
145    d.set("sp", Kind::Marker);
146    d.set("zone", Kind::Marker);
147    d.set("air", Kind::Marker);
148    d.set("temp", Kind::Marker);
149    d.set("kind", Kind::Str("Number".into()));
150    d.set("unit", Kind::Str("\u{00b0}F".into()));
151    d.set(
152        "curVal",
153        Kind::Number(Number::new(72.0, Some("\u{00b0}F".into()))),
154    );
155    d.set("dis", Kind::Str(dis));
156    d.set("siteRef", Kind::Ref(HRef::from_val(site_id)));
157    d.set("floorRef", Kind::Ref(HRef::from_val(floor_id)));
158    d.set("equipRef", Kind::Ref(HRef::from_val(vav_id)));
159    d
160}
161
162fn make_damper_cmd(vav_id: &str, site_id: &str, floor_id: &str, _ahu_id: &str) -> HDict {
163    let mut d = HDict::new();
164    let pt_id = format!("{vav_id}-dmpr");
165    let dis = format!("{} Damper Cmd", vav_dis(vav_id));
166    d.set("id", Kind::Ref(HRef::new(&pt_id, Some(dis.clone()))));
167    d.set("point", Kind::Marker);
168    d.set("cmd", Kind::Marker);
169    d.set("damper", Kind::Marker);
170    d.set("writable", Kind::Marker);
171    d.set("kind", Kind::Str("Number".into()));
172    d.set("unit", Kind::Str("%".into()));
173    d.set("curVal", Kind::Number(Number::new(85.0, Some("%".into()))));
174    d.set("dis", Kind::Str(dis));
175    d.set("siteRef", Kind::Ref(HRef::from_val(site_id)));
176    d.set("floorRef", Kind::Ref(HRef::from_val(floor_id)));
177    d.set("equipRef", Kind::Ref(HRef::from_val(vav_id)));
178    d
179}
180
181fn make_occ_sensor(vav_id: &str, site_id: &str, floor_id: &str, _ahu_id: &str) -> HDict {
182    let mut d = HDict::new();
183    let pt_id = format!("{vav_id}-occ");
184    let dis = format!("{} Occ", vav_dis(vav_id));
185    d.set("id", Kind::Ref(HRef::new(&pt_id, Some(dis.clone()))));
186    d.set("point", Kind::Marker);
187    d.set("sensor", Kind::Marker);
188    d.set("occ", Kind::Marker);
189    d.set("kind", Kind::Str("Bool".into()));
190    d.set("curVal", Kind::Bool(true));
191    d.set("dis", Kind::Str(dis));
192    d.set("siteRef", Kind::Ref(HRef::from_val(site_id)));
193    d.set("floorRef", Kind::Ref(HRef::from_val(floor_id)));
194    d.set("equipRef", Kind::Ref(HRef::from_val(vav_id)));
195    d
196}
197
198/// Extract a human-readable display name from a VAV entity ID.
199fn vav_dis(vav_id: &str) -> String {
200    // "demo-vav-1-01" -> "VAV-1-01"
201    vav_id
202        .strip_prefix("demo-")
203        .unwrap_or(vav_id)
204        .to_uppercase()
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use std::collections::HashSet;
211
212    #[test]
213    fn demo_has_expected_count() {
214        let entities = demo_entities();
215        // 1 site + 3 floors + 2 AHUs + 6 VAVs + 24 points = 36
216        assert_eq!(entities.len(), 36);
217    }
218
219    #[test]
220    fn demo_site_has_required_tags() {
221        let entities = demo_entities();
222        let site = entities
223            .iter()
224            .find(|e| e.has("site"))
225            .expect("should have a site entity");
226        assert!(site.has("site"));
227        assert!(site.has("dis"));
228        assert!(site.has("area"));
229        assert_eq!(site.get("dis"), Some(&Kind::Str("Demo Building".into())));
230        assert_eq!(
231            site.get("area"),
232            Some(&Kind::Number(Number::new(
233                50000.0,
234                Some("ft\u{00b2}".into())
235            )))
236        );
237    }
238
239    #[test]
240    fn demo_refs_are_valid() {
241        let entities = demo_entities();
242
243        // Collect all entity IDs.
244        let ids: HashSet<String> = entities
245            .iter()
246            .filter_map(|e| e.id().map(|r| r.val.clone()))
247            .collect();
248
249        // Check every siteRef, floorRef, equipRef points to a valid ID.
250        for entity in &entities {
251            for ref_tag in &["siteRef", "floorRef", "equipRef"] {
252                if let Some(Kind::Ref(r)) = entity.get(ref_tag) {
253                    assert!(
254                        ids.contains(&r.val),
255                        "entity {:?} has {} = @{} which is not a valid entity ID",
256                        entity.id().map(|r| &r.val),
257                        ref_tag,
258                        r.val
259                    );
260                }
261            }
262        }
263    }
264
265    #[test]
266    fn demo_points_have_kind() {
267        let entities = demo_entities();
268        let points: Vec<&HDict> = entities.iter().filter(|e| e.has("point")).collect();
269
270        // Should have 24 points (4 per VAV * 6 VAVs)
271        assert_eq!(points.len(), 24);
272
273        for pt in &points {
274            assert!(
275                pt.has("kind"),
276                "point {:?} is missing 'kind' tag",
277                pt.id().map(|r| &r.val)
278            );
279            match pt.get("kind") {
280                Some(Kind::Str(s)) => {
281                    assert!(s == "Number" || s == "Bool", "unexpected kind value: {s}");
282                }
283                other => panic!("expected kind to be a Str, got {:?}", other),
284            }
285        }
286    }
287}