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, "ElectricityMeter"),
185            DeviceType::WaterMeter => write!(f, "WaterMeter"),
186            DeviceType::GasMeter => write!(f, "GasMeter"),
187            DeviceType::TemperatureSensor => write!(f, "TemperatureSensor"),
188            DeviceType::HumiditySensor => write!(f, "HumiditySensor"),
189            DeviceType::PowerMeter => write!(f, "PowerMeter"),
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" => Ok(DeviceType::ElectricityMeter),
199            "WaterMeter" => Ok(DeviceType::WaterMeter),
200            "GasMeter" => Ok(DeviceType::GasMeter),
201            "TemperatureSensor" => Ok(DeviceType::TemperatureSensor),
202            "HumiditySensor" => Ok(DeviceType::HumiditySensor),
203            "PowerMeter" => 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, "ElectricityConsumption"),
226            MetricType::WaterConsumption => write!(f, "WaterConsumption"),
227            MetricType::GasConsumption => write!(f, "GasConsumption"),
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" => Ok(MetricType::ElectricityConsumption),
241            "WaterConsumption" => Ok(MetricType::WaterConsumption),
242            "GasConsumption" => Ok(MetricType::GasConsumption),
243            "Temperature" => Ok(MetricType::Temperature),
244            "Humidity" => Ok(MetricType::Humidity),
245            "Power" => Ok(MetricType::Power),
246            "Voltage" => Ok(MetricType::Voltage),
247            _ => Err(format!("Invalid MetricType: {}", s)),
248        }
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn sample_building_id() -> Uuid {
257        Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
258    }
259
260    #[test]
261    fn test_create_iot_reading_success() {
262        let reading = IoTReading::new(
263            sample_building_id(),
264            DeviceType::ElectricityMeter,
265            MetricType::ElectricityConsumption,
266            123.45,
267            "kWh".to_string(),
268            Utc::now() - chrono::Duration::hours(1),
269            "linky_ores".to_string(),
270        );
271
272        assert!(reading.is_ok());
273        let r = reading.unwrap();
274        assert_eq!(r.building_id, sample_building_id());
275        assert_eq!(r.device_type, DeviceType::ElectricityMeter);
276        assert_eq!(r.metric_type, MetricType::ElectricityConsumption);
277        assert_eq!(r.value, 123.45);
278        assert_eq!(r.unit, "kWh");
279        assert_eq!(r.source, "linky_ores");
280    }
281
282    #[test]
283    fn test_validate_electricity_consumption_negative() {
284        let reading = IoTReading::new(
285            sample_building_id(),
286            DeviceType::ElectricityMeter,
287            MetricType::ElectricityConsumption,
288            -10.0,
289            "kWh".to_string(),
290            Utc::now(),
291            "linky_ores".to_string(),
292        );
293
294        assert!(reading.is_err());
295        assert!(reading.unwrap_err().contains("cannot be negative"));
296    }
297
298    #[test]
299    fn test_validate_temperature_out_of_range() {
300        let reading = IoTReading::new(
301            sample_building_id(),
302            DeviceType::TemperatureSensor,
303            MetricType::Temperature,
304            -50.0,
305            "°C".to_string(),
306            Utc::now(),
307            "netatmo".to_string(),
308        );
309
310        assert!(reading.is_err());
311        assert!(reading.unwrap_err().contains("out of range"));
312
313        let reading2 = IoTReading::new(
314            sample_building_id(),
315            DeviceType::TemperatureSensor,
316            MetricType::Temperature,
317            100.0,
318            "°C".to_string(),
319            Utc::now(),
320            "netatmo".to_string(),
321        );
322
323        assert!(reading2.is_err());
324    }
325
326    #[test]
327    fn test_validate_humidity_range() {
328        let reading = IoTReading::new(
329            sample_building_id(),
330            DeviceType::HumiditySensor,
331            MetricType::Humidity,
332            50.0,
333            "%".to_string(),
334            Utc::now(),
335            "netatmo".to_string(),
336        );
337
338        assert!(reading.is_ok());
339
340        let reading2 = IoTReading::new(
341            sample_building_id(),
342            DeviceType::HumiditySensor,
343            MetricType::Humidity,
344            150.0,
345            "%".to_string(),
346            Utc::now(),
347            "netatmo".to_string(),
348        );
349
350        assert!(reading2.is_err());
351    }
352
353    #[test]
354    fn test_validate_invalid_unit() {
355        let reading = IoTReading::new(
356            sample_building_id(),
357            DeviceType::ElectricityMeter,
358            MetricType::ElectricityConsumption,
359            100.0,
360            "gallons".to_string(), // Invalid unit for electricity
361            Utc::now(),
362            "linky_ores".to_string(),
363        );
364
365        assert!(reading.is_err());
366        assert!(reading.unwrap_err().contains("Invalid unit"));
367    }
368
369    #[test]
370    fn test_validate_future_timestamp() {
371        let reading = IoTReading::new(
372            sample_building_id(),
373            DeviceType::ElectricityMeter,
374            MetricType::ElectricityConsumption,
375            100.0,
376            "kWh".to_string(),
377            Utc::now() + chrono::Duration::hours(1),
378            "linky_ores".to_string(),
379        );
380
381        assert!(reading.is_err());
382        assert!(reading.unwrap_err().contains("cannot be in the future"));
383    }
384
385    #[test]
386    fn test_validate_empty_source() {
387        let reading = IoTReading::new(
388            sample_building_id(),
389            DeviceType::ElectricityMeter,
390            MetricType::ElectricityConsumption,
391            100.0,
392            "kWh".to_string(),
393            Utc::now(),
394            "".to_string(),
395        );
396
397        assert!(reading.is_err());
398        assert!(reading.unwrap_err().contains("Source cannot be empty"));
399    }
400
401    #[test]
402    fn test_with_metadata() {
403        let reading = IoTReading::new(
404            sample_building_id(),
405            DeviceType::ElectricityMeter,
406            MetricType::ElectricityConsumption,
407            100.0,
408            "kWh".to_string(),
409            Utc::now(),
410            "linky_ores".to_string(),
411        )
412        .unwrap()
413        .with_metadata(serde_json::json!({"granularity": "30min", "quality": "good"}));
414
415        assert!(reading.metadata.is_some());
416        assert_eq!(reading.metadata.unwrap()["granularity"], "30min");
417    }
418
419    #[test]
420    fn test_is_anomalous() {
421        let reading = IoTReading::new(
422            sample_building_id(),
423            DeviceType::ElectricityMeter,
424            MetricType::ElectricityConsumption,
425            150.0,
426            "kWh".to_string(),
427            Utc::now(),
428            "linky_ores".to_string(),
429        )
430        .unwrap();
431
432        // Average: 100 kWh, reading: 150 kWh = 50% deviation
433        assert!(reading.is_anomalous(100.0, 40.0)); // Threshold 40% -> anomalous
434        assert!(!reading.is_anomalous(100.0, 60.0)); // Threshold 60% -> not anomalous
435    }
436
437    #[test]
438    fn test_normalized_value_electricity() {
439        let reading_wh = IoTReading::new(
440            sample_building_id(),
441            DeviceType::ElectricityMeter,
442            MetricType::ElectricityConsumption,
443            5000.0,
444            "Wh".to_string(),
445            Utc::now(),
446            "linky_ores".to_string(),
447        )
448        .unwrap();
449
450        assert_eq!(reading_wh.normalized_value(), 5.0); // 5000 Wh = 5 kWh
451
452        let reading_mwh = IoTReading::new(
453            sample_building_id(),
454            DeviceType::ElectricityMeter,
455            MetricType::ElectricityConsumption,
456            0.5,
457            "MWh".to_string(),
458            Utc::now(),
459            "linky_ores".to_string(),
460        )
461        .unwrap();
462
463        assert_eq!(reading_mwh.normalized_value(), 500.0); // 0.5 MWh = 500 kWh
464    }
465
466    #[test]
467    fn test_normalized_value_temperature() {
468        let reading_f = IoTReading::new(
469            sample_building_id(),
470            DeviceType::TemperatureSensor,
471            MetricType::Temperature,
472            32.0,
473            "°F".to_string(),
474            Utc::now(),
475            "netatmo".to_string(),
476        )
477        .unwrap();
478
479        // 32°F = 0°C
480        assert!((reading_f.normalized_value() - 0.0).abs() < 0.01);
481
482        let reading_k = IoTReading::new(
483            sample_building_id(),
484            DeviceType::TemperatureSensor,
485            MetricType::Temperature,
486            273.15,
487            "K".to_string(),
488            Utc::now(),
489            "sensor".to_string(),
490        )
491        .unwrap();
492
493        // 273.15 K = 0°C
494        assert!((reading_k.normalized_value() - 0.0).abs() < 0.01);
495    }
496}