Skip to main content

mabi_scenario/
schema.rs

1//! Scenario schema definitions.
2
3use mabi_core::tags::Tags;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Scenario definition.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Scenario {
10    /// Scenario name.
11    pub name: String,
12
13    /// Description.
14    #[serde(default)]
15    pub description: String,
16
17    /// Duration in seconds (0 = infinite).
18    #[serde(default)]
19    pub duration_secs: u64,
20
21    /// Time scale factor (1.0 = real-time).
22    #[serde(default = "default_time_scale")]
23    pub time_scale: f64,
24
25    /// Device definitions for the scenario.
26    #[serde(default)]
27    pub devices: Vec<ScenarioDevice>,
28
29    /// Data points.
30    #[serde(default)]
31    pub points: Vec<ScenarioPoint>,
32
33    /// Events.
34    #[serde(default)]
35    pub events: Vec<ScenarioEvent>,
36
37    /// Variables.
38    #[serde(default)]
39    pub variables: HashMap<String, f64>,
40}
41
42fn default_time_scale() -> f64 {
43    1.0
44}
45
46impl Default for Scenario {
47    fn default() -> Self {
48        Self {
49            name: "Unnamed Scenario".to_string(),
50            description: String::new(),
51            duration_secs: 0,
52            time_scale: 1.0,
53            devices: Vec::new(),
54            points: Vec::new(),
55            events: Vec::new(),
56            variables: HashMap::new(),
57        }
58    }
59}
60
61/// Device definition in a scenario.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ScenarioDevice {
64    /// Device ID.
65    pub id: String,
66
67    /// Device name.
68    #[serde(default)]
69    pub name: String,
70
71    /// Protocol type.
72    pub protocol: String,
73
74    /// Protocol-specific configuration.
75    #[serde(default)]
76    pub config: HashMap<String, serde_yaml::Value>,
77
78    /// Device tags for organization and filtering.
79    #[serde(default, skip_serializing_if = "Tags::is_empty")]
80    pub tags: Tags,
81}
82
83impl ScenarioDevice {
84    /// Create a new scenario device.
85    pub fn new(id: impl Into<String>, protocol: impl Into<String>) -> Self {
86        Self {
87            id: id.into(),
88            name: String::new(),
89            protocol: protocol.into(),
90            config: HashMap::new(),
91            tags: Tags::new(),
92        }
93    }
94
95    /// Set the device name.
96    pub fn with_name(mut self, name: impl Into<String>) -> Self {
97        self.name = name.into();
98        self
99    }
100
101    /// Set device tags.
102    pub fn with_tags(mut self, tags: Tags) -> Self {
103        self.tags = tags;
104        self
105    }
106
107    /// Add a single tag.
108    pub fn with_tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
109        self.tags.insert(key.into(), value.into());
110        self
111    }
112
113    /// Add a label.
114    pub fn with_label(mut self, label: impl Into<String>) -> Self {
115        self.tags.add_label(label.into());
116        self
117    }
118}
119
120/// Scenario data point.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ScenarioPoint {
123    /// Point ID.
124    pub id: String,
125
126    /// Device ID (optional if using device_tags).
127    #[serde(default)]
128    pub device_id: String,
129
130    /// Point ID within device.
131    pub point_id: String,
132
133    /// Pattern configuration.
134    pub pattern: PatternConfig,
135
136    /// Update interval in milliseconds.
137    #[serde(default = "default_interval")]
138    pub interval_ms: u64,
139
140    /// Filter by device tags (applies pattern to all matching devices).
141    /// If specified, device_id can be empty and the pattern applies to
142    /// all devices that match these tags.
143    #[serde(default, skip_serializing_if = "Tags::is_empty")]
144    pub device_tags: Tags,
145}
146
147fn default_interval() -> u64 {
148    1000
149}
150
151/// Pattern configuration.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(tag = "type", rename_all = "lowercase")]
154pub enum PatternConfig {
155    /// Constant value.
156    Constant { value: f64 },
157
158    /// Sine wave.
159    Sine {
160        amplitude: f64,
161        offset: f64,
162        period_secs: f64,
163        #[serde(default)]
164        phase: f64,
165    },
166
167    /// Cosine wave.
168    Cosine {
169        amplitude: f64,
170        offset: f64,
171        period_secs: f64,
172        #[serde(default)]
173        phase: f64,
174    },
175
176    /// Linear ramp.
177    Ramp {
178        start: f64,
179        end: f64,
180        duration_secs: f64,
181        #[serde(default)]
182        repeat: bool,
183    },
184
185    /// Step function.
186    Step {
187        levels: Vec<f64>,
188        step_duration_secs: f64,
189    },
190
191    /// Random values.
192    Random {
193        min: f64,
194        max: f64,
195        #[serde(default = "default_distribution")]
196        distribution: String,
197    },
198
199    /// Gaussian noise.
200    Noise { mean: f64, std_dev: f64 },
201
202    /// Follow another point.
203    Follow {
204        source: String,
205        #[serde(default)]
206        offset: f64,
207        #[serde(default = "default_gain")]
208        gain: f64,
209        #[serde(default)]
210        delay_ms: u64,
211    },
212
213    /// Replay from CSV/JSON.
214    Replay {
215        file: String,
216        #[serde(default)]
217        loop_replay: bool,
218    },
219}
220
221fn default_distribution() -> String {
222    "uniform".to_string()
223}
224
225fn default_gain() -> f64 {
226    1.0
227}
228
229/// Scenario event.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ScenarioEvent {
232    /// Event name.
233    pub name: String,
234
235    /// Trigger condition.
236    pub trigger: EventTrigger,
237
238    /// Actions to perform.
239    pub actions: Vec<EventAction>,
240}
241
242/// Event trigger.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(tag = "type", rename_all = "lowercase")]
245pub enum EventTrigger {
246    /// Time-based trigger.
247    Time { at_secs: f64 },
248
249    /// Periodic trigger.
250    Periodic {
251        interval_secs: f64,
252        #[serde(default)]
253        start_secs: f64,
254    },
255
256    /// Condition-based trigger.
257    Condition {
258        point: String,
259        operator: String,
260        value: f64,
261    },
262}
263
264/// Event action.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266#[serde(tag = "type", rename_all = "lowercase")]
267pub enum EventAction {
268    /// Set a point value.
269    SetValue { point: String, value: f64 },
270
271    /// Change pattern.
272    ChangePattern {
273        point: String,
274        pattern: PatternConfig,
275    },
276
277    /// Log a message.
278    Log {
279        message: String,
280        #[serde(default = "default_log_level")]
281        level: String,
282    },
283
284    /// Pause scenario.
285    Pause,
286
287    /// Stop scenario.
288    Stop,
289}
290
291fn default_log_level() -> String {
292    "info".to_string()
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn test_scenario_deserialization() {
301        let yaml = r#"
302name: Test Scenario
303description: A test scenario
304duration_secs: 3600
305time_scale: 1.0
306points:
307  - id: temp
308    device_id: device-001
309    point_id: temperature
310    pattern:
311      type: sine
312      amplitude: 5.0
313      offset: 22.0
314      period_secs: 3600
315    interval_ms: 1000
316"#;
317
318        let scenario: Scenario = serde_yaml::from_str(yaml).unwrap();
319        assert_eq!(scenario.name, "Test Scenario");
320        assert_eq!(scenario.points.len(), 1);
321    }
322
323    #[test]
324    fn test_scenario_with_devices_and_tags() {
325        let yaml = r#"
326name: Tagged Devices Scenario
327description: A scenario with tagged devices
328duration_secs: 3600
329
330devices:
331  - id: hvac-001
332    name: HVAC Unit 1
333    protocol: modbus
334    config:
335      unit_id: 1
336      port: 5020
337    tags:
338      tags:
339        location: building-a
340        floor: "3"
341      labels:
342        - hvac
343        - critical
344
345  - id: hvac-002
346    name: HVAC Unit 2
347    protocol: modbus
348    config:
349      unit_id: 2
350    tags:
351      tags:
352        location: building-a
353        floor: "4"
354      labels:
355        - hvac
356
357points:
358  - id: temp_all_hvac
359    point_id: temperature
360    pattern:
361      type: sine
362      amplitude: 2.0
363      offset: 22.0
364      period_secs: 3600
365    device_tags:
366      labels:
367        - hvac
368"#;
369
370        let scenario: Scenario = serde_yaml::from_str(yaml).unwrap();
371        assert_eq!(scenario.name, "Tagged Devices Scenario");
372        assert_eq!(scenario.devices.len(), 2);
373
374        let device1 = &scenario.devices[0];
375        assert_eq!(device1.id, "hvac-001");
376        assert_eq!(device1.tags.get("location"), Some("building-a"));
377        assert!(device1.tags.has_label("hvac"));
378        assert!(device1.tags.has_label("critical"));
379
380        let device2 = &scenario.devices[1];
381        assert_eq!(device2.tags.get("floor"), Some("4"));
382        assert!(device2.tags.has_label("hvac"));
383        assert!(!device2.tags.has_label("critical"));
384
385        // Point with device_tags filter
386        let point = &scenario.points[0];
387        assert!(point.device_tags.has_label("hvac"));
388    }
389
390    #[test]
391    fn test_scenario_device_builder() {
392        let device = ScenarioDevice::new("sensor-001", "modbus")
393            .with_name("Temperature Sensor")
394            .with_tag("zone", "hvac")
395            .with_tag("floor", "1")
396            .with_label("monitored")
397            .with_label("critical");
398
399        assert_eq!(device.id, "sensor-001");
400        assert_eq!(device.protocol, "modbus");
401        assert_eq!(device.tags.get("zone"), Some("hvac"));
402        assert!(device.tags.has_label("monitored"));
403        assert!(device.tags.has_label("critical"));
404    }
405}