Skip to main content

oxirs_physics/digital_twin/
mod.rs

1//! Digital Twin Management
2//!
3//! Full implementation of Digital Twin synchronization, sensor/actuator management,
4//! and DTDL (Digital Twin Definition Language) v2 support.
5//!
6//! ## State model
7//!
8//! Each `DigitalTwin` maintains two complementary state maps:
9//!
10//! - **`model_state`**: the digital model's computed / predicted values.
11//!   These are set by actuator commands, simulation outputs, or explicit
12//!   initialisation, but are *not* overwritten by raw sensor data.
13//! - **`state`**: the merged view used externally.  After a sync it
14//!   reflects the sensor-reported physical reality.
15//!
16//! Deviation analysis in [`DigitalTwinManager::synchronize`] always compares
17//! the *physical* sensor reading against the *digital* `model_state`, so a
18//! deviation is reported even when both values have been observed.
19
20use crate::error::{PhysicsError, PhysicsResult};
21use crate::simulation::SimulationParameters;
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use std::time::{SystemTime, UNIX_EPOCH};
25use uuid::Uuid;
26
27// ─────────────────────────────────────────────────────────────────────────────
28// Core data types
29// ─────────────────────────────────────────────────────────────────────────────
30
31/// Opaque identifier for a registered digital twin.
32#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
33pub struct TwinId(String);
34
35impl TwinId {
36    /// Create a new random TwinId.
37    pub fn new() -> Self {
38        Self(Uuid::new_v4().to_string())
39    }
40
41    /// Return the inner string representation.
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45}
46
47impl Default for TwinId {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl std::fmt::Display for TwinId {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        write!(f, "{}", self.0)
56    }
57}
58
59// ─────────────────────────────────────────────────────────────────────────────
60// Sensor / Actuator primitives
61// ─────────────────────────────────────────────────────────────────────────────
62
63/// A single sensor measurement.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct SensorReading {
66    /// Unique sensor identifier.
67    pub id: String,
68    /// Physical quantity measured (e.g. "temperature", "pressure").
69    pub quantity: String,
70    /// Numeric value.
71    pub value: f64,
72    /// SI unit string (e.g. "K", "Pa").
73    pub unit: String,
74    /// Unix-epoch timestamp in milliseconds.
75    pub timestamp_ms: u64,
76    /// Measurement uncertainty (1-sigma), if known.
77    pub uncertainty: Option<f64>,
78}
79
80impl SensorReading {
81    /// Create a new sensor reading with the current time.
82    pub fn new(
83        id: impl Into<String>,
84        quantity: impl Into<String>,
85        value: f64,
86        unit: impl Into<String>,
87    ) -> Self {
88        let timestamp_ms = SystemTime::now()
89            .duration_since(UNIX_EPOCH)
90            .map(|d| d.as_millis() as u64)
91            .unwrap_or(0);
92        Self {
93            id: id.into(),
94            quantity: quantity.into(),
95            value,
96            unit: unit.into(),
97            timestamp_ms,
98            uncertainty: None,
99        }
100    }
101
102    /// Builder: set uncertainty.
103    pub fn with_uncertainty(mut self, sigma: f64) -> Self {
104        self.uncertainty = Some(sigma);
105        self
106    }
107}
108
109/// A command sent to an actuator.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ActuatorCommand {
112    /// Unique actuator identifier.
113    pub id: String,
114    /// Target physical quantity (e.g. "set_temperature").
115    pub target_quantity: String,
116    /// Desired numeric value.
117    pub target_value: f64,
118    /// Whether the command has been applied.
119    pub applied: bool,
120}
121
122impl ActuatorCommand {
123    /// Create a new unapplied command.
124    pub fn new(
125        id: impl Into<String>,
126        target_quantity: impl Into<String>,
127        target_value: f64,
128    ) -> Self {
129        Self {
130            id: id.into(),
131            target_quantity: target_quantity.into(),
132            target_value,
133            applied: false,
134        }
135    }
136}
137
138// ─────────────────────────────────────────────────────────────────────────────
139// Synchronisation report
140// ─────────────────────────────────────────────────────────────────────────────
141
142/// Deviation of a single quantity after sync.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct QuantityDeviation {
145    /// Quantity name.
146    pub quantity: String,
147    /// Physical-world value (from latest sensor).
148    pub physical_value: f64,
149    /// Digital model value *before* this sync.
150    pub digital_value: f64,
151    /// Absolute deviation.
152    pub deviation: f64,
153}
154
155/// Anomaly detected during synchronisation.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct SyncAnomaly {
158    /// Quantity that triggered the anomaly.
159    pub quantity: String,
160    /// Human-readable description.
161    pub description: String,
162    /// Severity: 0.0 (info) … 1.0 (critical).
163    pub severity: f64,
164}
165
166/// Result of a bidirectional sync operation.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct SyncReport {
169    /// Names of quantities that were successfully synchronised.
170    pub synced_quantities: Vec<String>,
171    /// Per-quantity deviations.
172    pub deviations: Vec<QuantityDeviation>,
173    /// Detected anomalies.
174    pub anomalies: Vec<SyncAnomaly>,
175    /// Unix-epoch time of the sync in milliseconds.
176    pub sync_time_ms: u64,
177}
178
179impl SyncReport {
180    fn new_now() -> Self {
181        let sync_time_ms = SystemTime::now()
182            .duration_since(UNIX_EPOCH)
183            .map(|d| d.as_millis() as u64)
184            .unwrap_or(0);
185        Self {
186            synced_quantities: Vec::new(),
187            deviations: Vec::new(),
188            anomalies: Vec::new(),
189            sync_time_ms,
190        }
191    }
192
193    /// Returns `true` when no anomalies with severity ≥ `threshold` were found.
194    pub fn is_healthy(&self, threshold: f64) -> bool {
195        self.anomalies.iter().all(|a| a.severity < threshold)
196    }
197}
198
199// ─────────────────────────────────────────────────────────────────────────────
200// DigitalTwin
201// ─────────────────────────────────────────────────────────────────────────────
202
203/// Anomaly detection threshold: alert when deviation exceeds this fraction of
204/// the model value (or this absolute amount when the model value is ~0).
205const ANOMALY_DEVIATION_THRESHOLD: f64 = 0.15;
206
207/// Core digital twin linking a physical asset to its simulation state.
208///
209/// Two state maps are maintained:
210/// - `model_state` — the digital model's predicted / set values.
211/// - `state` — the externally-visible merged view; updated by sync.
212pub struct DigitalTwin {
213    /// IRI of the physical entity this twin represents.
214    pub entity_iri: String,
215    /// Human-readable type label.
216    pub twin_type: String,
217    /// Current model state: quantity-name → digital-model value.
218    /// Updated by actuator commands and explicit `set_model_value` calls.
219    pub model_state: HashMap<String, f64>,
220    /// Merged state: updated to reflect sensor values after each sync.
221    pub state: HashMap<String, f64>,
222    /// Buffered sensor readings (most-recent per sensor id).
223    pub sensors: Vec<SensorReading>,
224    /// Pending actuator commands.
225    pub actuators: Vec<ActuatorCommand>,
226    /// Wall-clock time of the last successful sync.
227    pub last_sync: SystemTime,
228}
229
230impl DigitalTwin {
231    /// Construct a new, empty digital twin.
232    pub fn new(entity_iri: impl Into<String>, twin_type: impl Into<String>) -> Self {
233        Self {
234            entity_iri: entity_iri.into(),
235            twin_type: twin_type.into(),
236            model_state: HashMap::new(),
237            state: HashMap::new(),
238            sensors: Vec::new(),
239            actuators: Vec::new(),
240            last_sync: UNIX_EPOCH,
241        }
242    }
243
244    /// Convenience accessor.
245    pub fn entity_iri(&self) -> &str {
246        &self.entity_iri
247    }
248
249    /// Explicitly set a value in the digital model (without a sensor reading).
250    pub fn set_model_value(&mut self, quantity: impl Into<String>, value: f64) {
251        let qty = quantity.into();
252        self.model_state.insert(qty.clone(), value);
253        self.state.insert(qty, value);
254    }
255
256    /// Register a sensor reading.
257    ///
258    /// The reading is stored (replacing any previous reading from the same
259    /// sensor id).  The `state` map is **not** updated here; call
260    /// [`DigitalTwin::apply_sensor_to_state`] or run
261    /// [`DigitalTwinManager::synchronize`] to merge sensor data into `state`.
262    pub fn buffer_sensor(&mut self, reading: SensorReading) {
263        self.sensors.retain(|s| s.id != reading.id);
264        self.sensors.push(reading);
265    }
266
267    /// Apply all buffered sensor readings into `state` (merges physical reality).
268    /// This does **not** update `model_state`.
269    pub fn apply_sensor_to_state(&mut self) {
270        for sensor in &self.sensors {
271            self.state.insert(sensor.quantity.clone(), sensor.value);
272        }
273    }
274
275    /// Queue an actuator command (replaces existing command for the same id).
276    /// Also updates `model_state` immediately so sync can detect deviations.
277    pub fn queue_actuator(&mut self, cmd: ActuatorCommand) {
278        self.model_state
279            .insert(cmd.target_quantity.clone(), cmd.target_value);
280        self.actuators.retain(|a| a.id != cmd.id);
281        self.actuators.push(cmd);
282    }
283
284    /// Flush all pending actuator commands: apply their target values to both
285    /// `model_state` and `state`.
286    pub fn apply_pending_actuators(&mut self) {
287        for cmd in &mut self.actuators {
288            if !cmd.applied {
289                self.model_state
290                    .insert(cmd.target_quantity.clone(), cmd.target_value);
291                self.state
292                    .insert(cmd.target_quantity.clone(), cmd.target_value);
293                cmd.applied = true;
294            }
295        }
296    }
297
298    /// Build `SimulationParameters` from current model state.
299    pub async fn extract_simulation_params(&self) -> PhysicsResult<SimulationParameters> {
300        let initial_conditions = self
301            .model_state
302            .iter()
303            .map(|(k, v)| {
304                (
305                    k.clone(),
306                    crate::simulation::parameter_extraction::PhysicalQuantity {
307                        value: *v,
308                        unit: String::from("SI"),
309                        uncertainty: None,
310                    },
311                )
312            })
313            .collect();
314
315        Ok(SimulationParameters {
316            entity_iri: self.entity_iri.clone(),
317            simulation_type: self.twin_type.clone(),
318            initial_conditions,
319            boundary_conditions: Vec::new(),
320            time_span: (0.0, 100.0),
321            time_steps: 100,
322            material_properties: HashMap::new(),
323            constraints: Vec::new(),
324        })
325    }
326}
327
328// ─────────────────────────────────────────────────────────────────────────────
329// DigitalTwinManager
330// ─────────────────────────────────────────────────────────────────────────────
331
332/// Manages a collection of digital twins.
333pub struct DigitalTwinManager {
334    twins: HashMap<TwinId, DigitalTwin>,
335    /// Anomaly threshold (fraction) used when building sync reports.
336    anomaly_threshold: f64,
337}
338
339impl DigitalTwinManager {
340    /// Create a new manager.
341    pub fn new() -> Self {
342        Self {
343            twins: HashMap::new(),
344            anomaly_threshold: ANOMALY_DEVIATION_THRESHOLD,
345        }
346    }
347
348    /// Set the fraction above which deviations are flagged as anomalies.
349    pub fn with_anomaly_threshold(mut self, threshold: f64) -> Self {
350        self.anomaly_threshold = threshold;
351        self
352    }
353
354    /// Register a new digital twin.
355    ///
356    /// `name` is a human-readable label; `model_uri` is the IRI of the physical
357    /// entity. Returns the new twin's [`TwinId`].
358    pub fn register(
359        &mut self,
360        name: impl Into<String>,
361        model_uri: impl Into<String>,
362    ) -> PhysicsResult<TwinId> {
363        let id = TwinId::new();
364        let twin = DigitalTwin::new(model_uri, name);
365        self.twins.insert(id.clone(), twin);
366        Ok(id)
367    }
368
369    /// Buffer a sensor reading on a twin.
370    ///
371    /// The reading is stored but **not** immediately applied to the twin's
372    /// `state`.  Call `synchronize` to perform the full bidirectional sync.
373    pub fn update_from_sensor(
374        &mut self,
375        twin_id: &TwinId,
376        reading: SensorReading,
377    ) -> PhysicsResult<()> {
378        let twin = self
379            .twins
380            .get_mut(twin_id)
381            .ok_or_else(|| PhysicsError::Internal(format!("twin not found: {twin_id}")))?;
382        twin.buffer_sensor(reading);
383        Ok(())
384    }
385
386    /// Return a snapshot of the current (merged) twin state.
387    pub fn get_state(&self, twin_id: &TwinId) -> PhysicsResult<HashMap<String, f64>> {
388        let twin = self
389            .twins
390            .get(twin_id)
391            .ok_or_else(|| PhysicsError::Internal(format!("twin not found: {twin_id}")))?;
392        Ok(twin.state.clone())
393    }
394
395    /// Perform a bidirectional synchronisation of the named twin.
396    ///
397    /// Steps:
398    /// 1. Apply pending actuator commands to `model_state` and `state`.
399    /// 2. Compute deviations: sensor value vs. `model_state`.
400    /// 3. Flag deviations that exceed `anomaly_threshold` as anomalies.
401    /// 4. Merge sensor readings into `state` (so `state` reflects reality).
402    /// 5. Update `last_sync`.
403    pub fn synchronize(&mut self, twin_id: &TwinId) -> PhysicsResult<SyncReport> {
404        let threshold = self.anomaly_threshold;
405        let twin = self
406            .twins
407            .get_mut(twin_id)
408            .ok_or_else(|| PhysicsError::Internal(format!("twin not found: {twin_id}")))?;
409
410        // Step 1: apply actuators.
411        twin.apply_pending_actuators();
412
413        // Step 2 & 3: compute deviations, flag anomalies.
414        let mut report = SyncReport::new_now();
415
416        for sensor in &twin.sensors {
417            // Compare sensor (physical) against the *digital model* value.
418            let digital_value = *twin.model_state.get(&sensor.quantity).unwrap_or(&0.0);
419            let deviation = (sensor.value - digital_value).abs();
420            let relative = if digital_value.abs() > 1e-12 {
421                deviation / digital_value.abs()
422            } else {
423                // When model has no prediction (0.0), use absolute deviation.
424                deviation
425            };
426
427            report.synced_quantities.push(sensor.quantity.clone());
428            report.deviations.push(QuantityDeviation {
429                quantity: sensor.quantity.clone(),
430                physical_value: sensor.value,
431                digital_value,
432                deviation,
433            });
434
435            if relative > threshold {
436                let severity = (relative / (threshold + f64::EPSILON)).clamp(0.0, 1.0);
437                report.anomalies.push(SyncAnomaly {
438                    quantity: sensor.quantity.clone(),
439                    description: format!(
440                        "Deviation {deviation:.4} ({:.1}%) exceeds threshold {:.1}%",
441                        relative * 100.0,
442                        threshold * 100.0
443                    ),
444                    severity,
445                });
446            }
447        }
448
449        // Step 4: merge sensor readings into state.
450        twin.apply_sensor_to_state();
451
452        // Step 5: update last_sync.
453        twin.last_sync = SystemTime::now();
454
455        Ok(report)
456    }
457
458    /// Immutable access to a twin (for inspection / testing).
459    pub fn get_twin(&self, twin_id: &TwinId) -> Option<&DigitalTwin> {
460        self.twins.get(twin_id)
461    }
462
463    /// Mutable access to a twin.
464    pub fn get_twin_mut(&mut self, twin_id: &TwinId) -> Option<&mut DigitalTwin> {
465        self.twins.get_mut(twin_id)
466    }
467
468    /// Number of registered twins.
469    pub fn len(&self) -> usize {
470        self.twins.len()
471    }
472
473    /// Returns `true` when no twins are registered.
474    pub fn is_empty(&self) -> bool {
475        self.twins.is_empty()
476    }
477}
478
479impl Default for DigitalTwinManager {
480    fn default() -> Self {
481        Self::new()
482    }
483}
484
485// ─────────────────────────────────────────────────────────────────────────────
486// DTDL – Digital Twin Definition Language v2 support
487// ─────────────────────────────────────────────────────────────────────────────
488
489/// A DTDL v2 telemetry entry (time-series data reported by the twin).
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct DtdlTelemetry {
492    /// DTDL `@id` or display name.
493    pub name: String,
494    /// Schema / data type hint (e.g. "double", "float", "integer").
495    pub schema: String,
496    /// Optional unit annotation.
497    pub unit: Option<String>,
498}
499
500/// A DTDL v2 property (writable configuration / state).
501#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct DtdlProperty {
503    pub name: String,
504    pub schema: String,
505    pub writable: bool,
506}
507
508/// A DTDL v2 component reference.
509#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct DtdlComponent {
511    pub name: String,
512    pub schema: String,
513}
514
515/// A DTDL v2 relationship.
516#[derive(Debug, Clone, Serialize, Deserialize)]
517pub struct DtdlRelationship {
518    pub name: String,
519    pub target: Option<String>,
520    pub max_multiplicity: Option<u32>,
521}
522
523/// Simplified representation of a DTDL v2 Interface.
524#[derive(Debug, Clone, Serialize, Deserialize)]
525pub struct DtdlModel {
526    /// `@id` of the interface (e.g. `dtmi:com:example:Thermostat;1`).
527    pub id: String,
528    /// Display name.
529    pub display_name: String,
530    /// Telemetry channels.
531    pub telemetry: Vec<DtdlTelemetry>,
532    /// Properties (writable and read-only).
533    pub properties: Vec<DtdlProperty>,
534    /// Components.
535    pub components: Vec<DtdlComponent>,
536    /// Relationships.
537    pub relationships: Vec<DtdlRelationship>,
538}
539
540impl DtdlModel {
541    /// Create a minimal empty model.
542    pub fn new(id: impl Into<String>, display_name: impl Into<String>) -> Self {
543        Self {
544            id: id.into(),
545            display_name: display_name.into(),
546            telemetry: Vec::new(),
547            properties: Vec::new(),
548            components: Vec::new(),
549            relationships: Vec::new(),
550        }
551    }
552}
553
554/// Parse a DTDL v2 JSON payload into a [`DtdlModel`].
555///
556/// Supports the minimal structure produced by Azure Digital Twins tooling:
557/// ```json
558/// {
559///   "@context": "dtmi:dtdl:context;2",
560///   "@type": "Interface",
561///   "@id": "dtmi:com:example:Thermostat;1",
562///   "displayName": "Thermostat",
563///   "contents": [
564///     { "@type": "Telemetry", "name": "temperature", "schema": "double", "unit": "degreeCelsius" },
565///     { "@type": "Property",  "name": "targetTemperature", "schema": "double", "writable": true }
566///   ]
567/// }
568/// ```
569pub fn parse_dtdl_json(json: &str) -> PhysicsResult<DtdlModel> {
570    let root: serde_json::Value = serde_json::from_str(json)
571        .map_err(|e| PhysicsError::Internal(format!("DTDL JSON parse error: {e}")))?;
572
573    let id = root
574        .get("@id")
575        .and_then(|v| v.as_str())
576        .unwrap_or("")
577        .to_string();
578
579    let display_name = root
580        .get("displayName")
581        .and_then(|v| v.as_str())
582        .unwrap_or(&id)
583        .to_string();
584
585    let mut model = DtdlModel::new(id, display_name);
586
587    if let Some(contents) = root.get("contents").and_then(|v| v.as_array()) {
588        for item in contents {
589            let type_tag = item
590                .get("@type")
591                .and_then(|v| v.as_str())
592                .unwrap_or("")
593                .to_lowercase();
594            let name = item
595                .get("name")
596                .and_then(|v| v.as_str())
597                .unwrap_or("")
598                .to_string();
599            let schema = item
600                .get("schema")
601                .and_then(|v| v.as_str())
602                .unwrap_or("double")
603                .to_string();
604
605            match type_tag.as_str() {
606                "telemetry" => {
607                    let unit = item.get("unit").and_then(|v| v.as_str()).map(String::from);
608                    model.telemetry.push(DtdlTelemetry { name, schema, unit });
609                }
610                "property" => {
611                    let writable = item
612                        .get("writable")
613                        .and_then(|v| v.as_bool())
614                        .unwrap_or(false);
615                    model.properties.push(DtdlProperty {
616                        name,
617                        schema,
618                        writable,
619                    });
620                }
621                "component" => {
622                    model.components.push(DtdlComponent { name, schema });
623                }
624                "relationship" => {
625                    let target = item
626                        .get("target")
627                        .and_then(|v| v.as_str())
628                        .map(String::from);
629                    let max_multiplicity = item
630                        .get("maxMultiplicity")
631                        .and_then(|v| v.as_u64())
632                        .map(|n| n as u32);
633                    model.relationships.push(DtdlRelationship {
634                        name,
635                        target,
636                        max_multiplicity,
637                    });
638                }
639                _ => {
640                    // unknown content type — skip gracefully
641                }
642            }
643        }
644    }
645
646    Ok(model)
647}
648
649/// Convert a parsed [`DtdlModel`] into a [`DigitalTwin`].
650///
651/// Telemetry and properties are registered with a default model value of `0.0`;
652/// the caller is expected to populate them via sensor updates and sync.
653pub fn model_to_digital_twin(model: &DtdlModel) -> DigitalTwin {
654    let mut twin = DigitalTwin::new(model.id.clone(), model.display_name.clone());
655
656    for tel in &model.telemetry {
657        twin.set_model_value(tel.name.clone(), 0.0);
658    }
659    for prop in &model.properties {
660        twin.set_model_value(prop.name.clone(), 0.0);
661    }
662
663    twin
664}
665
666// ─────────────────────────────────────────────────────────────────────────────
667// Tests
668// ─────────────────────────────────────────────────────────────────────────────
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    fn make_reading(id: &str, qty: &str, value: f64) -> SensorReading {
675        SensorReading::new(id, qty, value, "SI")
676    }
677
678    // ── TwinId ────────────────────────────────────────────────────────────────
679
680    #[test]
681    fn twin_id_uniqueness() {
682        let a = TwinId::new();
683        let b = TwinId::new();
684        assert_ne!(a, b, "each TwinId must be unique");
685    }
686
687    #[test]
688    fn twin_id_display() {
689        let id = TwinId::new();
690        assert!(!id.to_string().is_empty());
691    }
692
693    // ── DigitalTwin ───────────────────────────────────────────────────────────
694
695    #[test]
696    fn digital_twin_creation() {
697        let twin = DigitalTwin::new("urn:example:motor:1", "ElectricMotor");
698        assert_eq!(twin.entity_iri(), "urn:example:motor:1");
699        assert_eq!(twin.twin_type, "ElectricMotor");
700        assert!(twin.state.is_empty());
701    }
702
703    #[test]
704    fn digital_twin_sensor_buffered() {
705        let mut twin = DigitalTwin::new("urn:example:motor:1", "ElectricMotor");
706        twin.buffer_sensor(make_reading("s1", "temperature", 350.0));
707
708        // Buffered only — state not yet updated.
709        assert!(!twin.state.contains_key("temperature"));
710        assert_eq!(twin.sensors.len(), 1);
711    }
712
713    #[test]
714    fn digital_twin_sensor_apply_to_state() {
715        let mut twin = DigitalTwin::new("urn:example:motor:1", "ElectricMotor");
716        twin.buffer_sensor(make_reading("s1", "temperature", 350.0));
717        twin.apply_sensor_to_state();
718
719        assert_eq!(twin.state["temperature"], 350.0);
720    }
721
722    #[test]
723    fn digital_twin_sensor_deduplication() {
724        let mut twin = DigitalTwin::new("urn:example:motor:1", "ElectricMotor");
725        twin.buffer_sensor(make_reading("s1", "temperature", 350.0));
726        twin.buffer_sensor(make_reading("s1", "temperature", 370.0));
727
728        // Only the latest reading for sensor s1 should be kept.
729        assert_eq!(twin.sensors.len(), 1);
730        assert_eq!(twin.sensors[0].value, 370.0);
731    }
732
733    #[test]
734    fn digital_twin_actuator_command() {
735        let mut twin = DigitalTwin::new("urn:example:motor:1", "ElectricMotor");
736        twin.set_model_value("set_temperature", 300.0);
737        twin.queue_actuator(ActuatorCommand::new("a1", "set_temperature", 400.0));
738        twin.apply_pending_actuators();
739
740        assert_eq!(twin.model_state["set_temperature"], 400.0);
741        assert_eq!(twin.state["set_temperature"], 400.0);
742        assert!(twin.actuators[0].applied);
743    }
744
745    // ── DigitalTwinManager ────────────────────────────────────────────────────
746
747    #[test]
748    fn manager_register_and_get_state() {
749        let mut mgr = DigitalTwinManager::new();
750        let id = mgr
751            .register("Thermostat", "urn:example:thermostat:1")
752            .expect("register failed");
753
754        assert_eq!(mgr.len(), 1);
755
756        // Set model prediction first.
757        mgr.get_twin_mut(&id)
758            .expect("should succeed")
759            .set_model_value("temperature", 298.15);
760
761        // Add sensor matching prediction.
762        mgr.update_from_sensor(&id, SensorReading::new("t1", "temperature", 298.15, "K"))
763            .expect("sensor update failed");
764
765        // Sync to merge sensor into state.
766        mgr.synchronize(&id).expect("sync failed");
767
768        let state = mgr.get_state(&id).expect("get_state failed");
769        assert!((state["temperature"] - 298.15).abs() < 1e-9);
770    }
771
772    #[test]
773    fn manager_synchronise_no_anomaly() {
774        let mut mgr = DigitalTwinManager::new();
775        let id = mgr
776            .register("Motor", "urn:example:motor:1")
777            .expect("should succeed");
778
779        // Set model prediction.
780        mgr.get_twin_mut(&id)
781            .expect("should succeed")
782            .set_model_value("speed", 1500.0);
783
784        // Sensor matches model prediction exactly.
785        mgr.update_from_sensor(&id, SensorReading::new("s1", "speed", 1500.0, "rpm"))
786            .expect("should succeed");
787
788        let report = mgr.synchronize(&id).expect("sync failed");
789        assert_eq!(report.synced_quantities.len(), 1);
790        assert!(
791            report.anomalies.is_empty(),
792            "no anomaly expected when sensor matches model"
793        );
794    }
795
796    #[test]
797    fn manager_synchronise_with_anomaly() {
798        let mut mgr = DigitalTwinManager::new().with_anomaly_threshold(0.05);
799        let id = mgr
800            .register("Sensor", "urn:example:s:1")
801            .expect("should succeed");
802
803        // Set digital model state to 300 K.
804        mgr.get_twin_mut(&id)
805            .expect("should succeed")
806            .set_model_value("temperature", 300.0);
807
808        // Push sensor with large deviation from model (>5%).
809        mgr.update_from_sensor(&id, SensorReading::new("s1", "temperature", 400.0, "K"))
810            .expect("should succeed");
811
812        let report = mgr.synchronize(&id).expect("should succeed");
813        assert!(
814            !report.anomalies.is_empty(),
815            "expected anomaly to be detected (model=300, sensor=400)"
816        );
817        assert_eq!(report.deviations[0].digital_value, 300.0);
818        assert_eq!(report.deviations[0].physical_value, 400.0);
819    }
820
821    // ── DTDL ──────────────────────────────────────────────────────────────────
822
823    #[test]
824    fn dtdl_parse_minimal() {
825        let json = r#"{
826            "@context": "dtmi:dtdl:context;2",
827            "@type": "Interface",
828            "@id": "dtmi:com:example:Thermostat;1",
829            "displayName": "Thermostat",
830            "contents": [
831                { "@type": "Telemetry", "name": "temperature", "schema": "double", "unit": "degreeCelsius" },
832                { "@type": "Property", "name": "targetTemperature", "schema": "double", "writable": true }
833            ]
834        }"#;
835
836        let model = parse_dtdl_json(json).expect("parse failed");
837        assert_eq!(model.id, "dtmi:com:example:Thermostat;1");
838        assert_eq!(model.display_name, "Thermostat");
839        assert_eq!(model.telemetry.len(), 1);
840        assert_eq!(model.telemetry[0].name, "temperature");
841        assert_eq!(model.telemetry[0].unit.as_deref(), Some("degreeCelsius"));
842        assert_eq!(model.properties.len(), 1);
843        assert!(model.properties[0].writable);
844    }
845
846    #[test]
847    fn dtdl_parse_with_relationship() {
848        let json = r#"{
849            "@id": "dtmi:com:example:Room;1",
850            "displayName": "Room",
851            "contents": [
852                { "@type": "Relationship", "name": "contains", "target": "dtmi:com:example:Device;1", "maxMultiplicity": 10 }
853            ]
854        }"#;
855
856        let model = parse_dtdl_json(json).expect("parse failed");
857        assert_eq!(model.relationships.len(), 1);
858        assert_eq!(model.relationships[0].name, "contains");
859        assert_eq!(model.relationships[0].max_multiplicity, Some(10));
860    }
861
862    #[test]
863    fn dtdl_model_to_twin_state_initialised() {
864        let json = r#"{
865            "@id": "dtmi:com:example:Device;1",
866            "displayName": "Device",
867            "contents": [
868                { "@type": "Telemetry", "name": "voltage", "schema": "double" },
869                { "@type": "Property",  "name": "setPoint", "schema": "double", "writable": false }
870            ]
871        }"#;
872
873        let model = parse_dtdl_json(json).expect("should succeed");
874        let twin = model_to_digital_twin(&model);
875
876        assert!(twin.model_state.contains_key("voltage"));
877        assert!(twin.model_state.contains_key("setPoint"));
878        assert!(twin.state.contains_key("voltage"));
879        assert_eq!(twin.entity_iri(), "dtmi:com:example:Device;1");
880    }
881
882    #[test]
883    fn dtdl_parse_invalid_json_returns_error() {
884        let result = parse_dtdl_json("{ not valid json }");
885        assert!(result.is_err());
886    }
887}