koprogo_api/domain/entities/
iot_reading.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// IoT sensor reading entity
6/// Stores time-series data from various IoT devices (Linky smart meters, temperature sensors, etc.)
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub struct IoTReading {
9    pub id: Uuid,
10    pub building_id: Uuid,
11    pub device_type: DeviceType,
12    pub metric_type: MetricType,
13    pub value: f64,
14    pub unit: String,
15    pub timestamp: DateTime<Utc>,
16    pub source: String, // e.g., "linky_ores", "linky_enedis", "netatmo"
17    pub metadata: Option<serde_json::Value>, // Additional context (granularity, quality, etc.)
18    pub created_at: DateTime<Utc>,
19}
20
21impl IoTReading {
22    /// Create a new IoT reading with validation
23    pub fn new(
24        building_id: Uuid,
25        device_type: DeviceType,
26        metric_type: MetricType,
27        value: f64,
28        unit: String,
29        timestamp: DateTime<Utc>,
30        source: String,
31    ) -> Result<Self, String> {
32        // Validate unit matches metric type (must be done first)
33        Self::validate_unit(&metric_type, &unit)?;
34
35        // Validate value based on metric type (unit needed for temperature conversion)
36        Self::validate_value(&metric_type, value, &unit)?;
37
38        // Validate source is non-empty
39        if source.trim().is_empty() {
40            return Err("Source cannot be empty".to_string());
41        }
42
43        // Validate timestamp is not in future
44        if timestamp > Utc::now() {
45            return Err("Timestamp cannot be in the future".to_string());
46        }
47
48        Ok(Self {
49            id: Uuid::new_v4(),
50            building_id,
51            device_type,
52            metric_type,
53            value,
54            unit,
55            timestamp,
56            source,
57            metadata: None,
58            created_at: Utc::now(),
59        })
60    }
61
62    /// Set metadata (optional context)
63    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
64        self.metadata = Some(metadata);
65        self
66    }
67
68    /// Validate value based on metric type
69    fn validate_value(metric_type: &MetricType, value: f64, unit: &str) -> Result<(), String> {
70        match metric_type {
71            MetricType::ElectricityConsumption
72            | MetricType::WaterConsumption
73            | MetricType::GasConsumption => {
74                if value < 0.0 {
75                    return Err(format!("Consumption value cannot be negative: {}", value));
76                }
77                if value > 1_000_000.0 {
78                    return Err(format!("Consumption value too large (max 1M): {}", value));
79                }
80            }
81            MetricType::Temperature => {
82                // Convert to Celsius for validation
83                let normalized = match unit {
84                    "F" | "°F" => (value - 32.0) * 5.0 / 9.0,
85                    "K" => value - 273.15,
86                    _ => value, // Already Celsius
87                };
88
89                if normalized < -40.0 || normalized > 80.0 {
90                    return Err(format!(
91                        "Temperature value out of range (-40 to +80°C): {}",
92                        value
93                    ));
94                }
95            }
96            MetricType::Humidity => {
97                if value < 0.0 || value > 100.0 {
98                    return Err(format!("Humidity value out of range (0-100%): {}", value));
99                }
100            }
101            MetricType::Power => {
102                if value < 0.0 {
103                    return Err(format!("Power value cannot be negative: {}", value));
104                }
105                if value > 100_000.0 {
106                    return Err(format!("Power value too large (max 100kW): {}", value));
107                }
108            }
109            MetricType::Voltage => {
110                if value < 0.0 || value > 500.0 {
111                    return Err(format!("Voltage value out of range (0-500V): {}", value));
112                }
113            }
114        }
115        Ok(())
116    }
117
118    /// Validate unit matches metric type
119    fn validate_unit(metric_type: &MetricType, unit: &str) -> Result<(), String> {
120        let valid_units = match metric_type {
121            MetricType::ElectricityConsumption => vec!["kWh", "Wh", "MWh"],
122            MetricType::WaterConsumption => vec!["m3", "L", "gal"],
123            MetricType::GasConsumption => vec!["m3", "kWh"],
124            MetricType::Temperature => vec!["C", "°C", "F", "°F", "K"],
125            MetricType::Humidity => vec!["%", "percent"],
126            MetricType::Power => vec!["W", "kW", "MW"],
127            MetricType::Voltage => vec!["V", "kV"],
128        };
129
130        if !valid_units.contains(&unit) {
131            return Err(format!(
132                "Invalid unit '{}' for metric type '{:?}'. Valid units: {:?}",
133                unit, metric_type, valid_units
134            ));
135        }
136
137        Ok(())
138    }
139
140    /// Check if reading is anomalous compared to average
141    /// Returns true if value exceeds average by more than threshold percentage
142    pub fn is_anomalous(&self, average_value: f64, threshold_percentage: f64) -> bool {
143        if average_value == 0.0 {
144            return false;
145        }
146
147        let deviation_percentage = ((self.value - average_value) / average_value).abs() * 100.0;
148        deviation_percentage > threshold_percentage
149    }
150
151    /// Convert value to standard unit (kWh for electricity, m3 for water/gas, °C for temperature)
152    pub fn normalized_value(&self) -> f64 {
153        match &self.metric_type {
154            MetricType::ElectricityConsumption => match self.unit.as_str() {
155                "Wh" => self.value / 1000.0,
156                "MWh" => self.value * 1000.0,
157                _ => self.value, // Already kWh
158            },
159            MetricType::Temperature => match self.unit.as_str() {
160                "F" | "°F" => (self.value - 32.0) * 5.0 / 9.0, // Convert to °C
161                "K" => self.value - 273.15,                    // Convert to °C
162                _ => self.value,                               // Already °C
163            },
164            _ => self.value, // No conversion needed
165        }
166    }
167}
168
169/// Type of IoT device
170#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
171#[serde(rename_all = "snake_case")]
172pub enum DeviceType {
173    ElectricityMeter,  // Linky smart meter
174    WaterMeter,        // Water consumption meter
175    GasMeter,          // Gas consumption meter
176    TemperatureSensor, // Temperature sensor (Netatmo, etc.)
177    HumiditySensor,    // Humidity sensor
178    PowerMeter,        // Real-time power meter
179}
180
181impl std::fmt::Display for DeviceType {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        match self {
184            DeviceType::ElectricityMeter => write!(f, "electricity_meter"),
185            DeviceType::WaterMeter => write!(f, "water_meter"),
186            DeviceType::GasMeter => write!(f, "gas_meter"),
187            DeviceType::TemperatureSensor => write!(f, "temperature_sensor"),
188            DeviceType::HumiditySensor => write!(f, "humidity_sensor"),
189            DeviceType::PowerMeter => write!(f, "power_meter"),
190        }
191    }
192}
193
194impl std::str::FromStr for DeviceType {
195    type Err = String;
196    fn from_str(s: &str) -> Result<Self, Self::Err> {
197        match s {
198            "ElectricityMeter" | "electricity_meter" => Ok(DeviceType::ElectricityMeter),
199            "WaterMeter" | "water_meter" => Ok(DeviceType::WaterMeter),
200            "GasMeter" | "gas_meter" => Ok(DeviceType::GasMeter),
201            "TemperatureSensor" | "temperature_sensor" => Ok(DeviceType::TemperatureSensor),
202            "HumiditySensor" | "humidity_sensor" => Ok(DeviceType::HumiditySensor),
203            "PowerMeter" | "power_meter" => Ok(DeviceType::PowerMeter),
204            _ => Err(format!("Invalid DeviceType: {}", s)),
205        }
206    }
207}
208
209/// Type of metric being measured
210#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
211#[serde(rename_all = "snake_case")]
212pub enum MetricType {
213    ElectricityConsumption, // kWh
214    WaterConsumption,       // m3
215    GasConsumption,         // m3
216    Temperature,            // °C
217    Humidity,               // %
218    Power,                  // W (real-time power draw)
219    Voltage,                // V
220}
221
222impl std::fmt::Display for MetricType {
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        match self {
225            MetricType::ElectricityConsumption => write!(f, "electricity_consumption"),
226            MetricType::WaterConsumption => write!(f, "water_consumption"),
227            MetricType::GasConsumption => write!(f, "gas_consumption"),
228            MetricType::Temperature => write!(f, "temperature"),
229            MetricType::Humidity => write!(f, "humidity"),
230            MetricType::Power => write!(f, "power"),
231            MetricType::Voltage => write!(f, "voltage"),
232        }
233    }
234}
235
236impl std::str::FromStr for MetricType {
237    type Err = String;
238    fn from_str(s: &str) -> Result<Self, Self::Err> {
239        match s {
240            "ElectricityConsumption" | "electricity_consumption" => {
241                Ok(MetricType::ElectricityConsumption)
242            }
243            "WaterConsumption" | "water_consumption" => Ok(MetricType::WaterConsumption),
244            "GasConsumption" | "gas_consumption" => Ok(MetricType::GasConsumption),
245            "Temperature" | "temperature" => Ok(MetricType::Temperature),
246            "Humidity" | "humidity" => Ok(MetricType::Humidity),
247            "Power" | "power" => Ok(MetricType::Power),
248            "Voltage" | "voltage" => Ok(MetricType::Voltage),
249            _ => Err(format!("Invalid MetricType: {}", s)),
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn sample_building_id() -> Uuid {
259        Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
260    }
261
262    #[test]
263    fn test_create_iot_reading_success() {
264        let reading = IoTReading::new(
265            sample_building_id(),
266            DeviceType::ElectricityMeter,
267            MetricType::ElectricityConsumption,
268            123.45,
269            "kWh".to_string(),
270            Utc::now() - chrono::Duration::hours(1),
271            "linky_ores".to_string(),
272        );
273
274        assert!(reading.is_ok());
275        let r = reading.unwrap();
276        assert_eq!(r.building_id, sample_building_id());
277        assert_eq!(r.device_type, DeviceType::ElectricityMeter);
278        assert_eq!(r.metric_type, MetricType::ElectricityConsumption);
279        assert_eq!(r.value, 123.45);
280        assert_eq!(r.unit, "kWh");
281        assert_eq!(r.source, "linky_ores");
282    }
283
284    #[test]
285    fn test_validate_electricity_consumption_negative() {
286        let reading = IoTReading::new(
287            sample_building_id(),
288            DeviceType::ElectricityMeter,
289            MetricType::ElectricityConsumption,
290            -10.0,
291            "kWh".to_string(),
292            Utc::now(),
293            "linky_ores".to_string(),
294        );
295
296        assert!(reading.is_err());
297        assert!(reading.unwrap_err().contains("cannot be negative"));
298    }
299
300    #[test]
301    fn test_validate_temperature_out_of_range() {
302        let reading = IoTReading::new(
303            sample_building_id(),
304            DeviceType::TemperatureSensor,
305            MetricType::Temperature,
306            -50.0,
307            "°C".to_string(),
308            Utc::now(),
309            "netatmo".to_string(),
310        );
311
312        assert!(reading.is_err());
313        assert!(reading.unwrap_err().contains("out of range"));
314
315        let reading2 = IoTReading::new(
316            sample_building_id(),
317            DeviceType::TemperatureSensor,
318            MetricType::Temperature,
319            100.0,
320            "°C".to_string(),
321            Utc::now(),
322            "netatmo".to_string(),
323        );
324
325        assert!(reading2.is_err());
326    }
327
328    #[test]
329    fn test_validate_humidity_range() {
330        let reading = IoTReading::new(
331            sample_building_id(),
332            DeviceType::HumiditySensor,
333            MetricType::Humidity,
334            50.0,
335            "%".to_string(),
336            Utc::now(),
337            "netatmo".to_string(),
338        );
339
340        assert!(reading.is_ok());
341
342        let reading2 = IoTReading::new(
343            sample_building_id(),
344            DeviceType::HumiditySensor,
345            MetricType::Humidity,
346            150.0,
347            "%".to_string(),
348            Utc::now(),
349            "netatmo".to_string(),
350        );
351
352        assert!(reading2.is_err());
353    }
354
355    #[test]
356    fn test_validate_invalid_unit() {
357        let reading = IoTReading::new(
358            sample_building_id(),
359            DeviceType::ElectricityMeter,
360            MetricType::ElectricityConsumption,
361            100.0,
362            "gallons".to_string(), // Invalid unit for electricity
363            Utc::now(),
364            "linky_ores".to_string(),
365        );
366
367        assert!(reading.is_err());
368        assert!(reading.unwrap_err().contains("Invalid unit"));
369    }
370
371    #[test]
372    fn test_validate_future_timestamp() {
373        let reading = IoTReading::new(
374            sample_building_id(),
375            DeviceType::ElectricityMeter,
376            MetricType::ElectricityConsumption,
377            100.0,
378            "kWh".to_string(),
379            Utc::now() + chrono::Duration::hours(1),
380            "linky_ores".to_string(),
381        );
382
383        assert!(reading.is_err());
384        assert!(reading.unwrap_err().contains("cannot be in the future"));
385    }
386
387    #[test]
388    fn test_validate_empty_source() {
389        let reading = IoTReading::new(
390            sample_building_id(),
391            DeviceType::ElectricityMeter,
392            MetricType::ElectricityConsumption,
393            100.0,
394            "kWh".to_string(),
395            Utc::now(),
396            "".to_string(),
397        );
398
399        assert!(reading.is_err());
400        assert!(reading.unwrap_err().contains("Source cannot be empty"));
401    }
402
403    #[test]
404    fn test_with_metadata() {
405        let reading = IoTReading::new(
406            sample_building_id(),
407            DeviceType::ElectricityMeter,
408            MetricType::ElectricityConsumption,
409            100.0,
410            "kWh".to_string(),
411            Utc::now(),
412            "linky_ores".to_string(),
413        )
414        .unwrap()
415        .with_metadata(serde_json::json!({"granularity": "30min", "quality": "good"}));
416
417        assert!(reading.metadata.is_some());
418        assert_eq!(reading.metadata.unwrap()["granularity"], "30min");
419    }
420
421    #[test]
422    fn test_is_anomalous() {
423        let reading = IoTReading::new(
424            sample_building_id(),
425            DeviceType::ElectricityMeter,
426            MetricType::ElectricityConsumption,
427            150.0,
428            "kWh".to_string(),
429            Utc::now(),
430            "linky_ores".to_string(),
431        )
432        .unwrap();
433
434        // Average: 100 kWh, reading: 150 kWh = 50% deviation
435        assert!(reading.is_anomalous(100.0, 40.0)); // Threshold 40% -> anomalous
436        assert!(!reading.is_anomalous(100.0, 60.0)); // Threshold 60% -> not anomalous
437    }
438
439    #[test]
440    fn test_normalized_value_electricity() {
441        let reading_wh = IoTReading::new(
442            sample_building_id(),
443            DeviceType::ElectricityMeter,
444            MetricType::ElectricityConsumption,
445            5000.0,
446            "Wh".to_string(),
447            Utc::now(),
448            "linky_ores".to_string(),
449        )
450        .unwrap();
451
452        assert_eq!(reading_wh.normalized_value(), 5.0); // 5000 Wh = 5 kWh
453
454        let reading_mwh = IoTReading::new(
455            sample_building_id(),
456            DeviceType::ElectricityMeter,
457            MetricType::ElectricityConsumption,
458            0.5,
459            "MWh".to_string(),
460            Utc::now(),
461            "linky_ores".to_string(),
462        )
463        .unwrap();
464
465        assert_eq!(reading_mwh.normalized_value(), 500.0); // 0.5 MWh = 500 kWh
466    }
467
468    #[test]
469    fn test_normalized_value_temperature() {
470        let reading_f = IoTReading::new(
471            sample_building_id(),
472            DeviceType::TemperatureSensor,
473            MetricType::Temperature,
474            32.0,
475            "°F".to_string(),
476            Utc::now(),
477            "netatmo".to_string(),
478        )
479        .unwrap();
480
481        // 32°F = 0°C
482        assert!((reading_f.normalized_value() - 0.0).abs() < 0.01);
483
484        let reading_k = IoTReading::new(
485            sample_building_id(),
486            DeviceType::TemperatureSensor,
487            MetricType::Temperature,
488            273.15,
489            "K".to_string(),
490            Utc::now(),
491            "sensor".to_string(),
492        )
493        .unwrap();
494
495        // 273.15 K = 0°C
496        assert!((reading_k.normalized_value() - 0.0).abs() < 0.01);
497    }
498}