1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[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, pub metadata: Option<serde_json::Value>, pub created_at: DateTime<Utc>,
19}
20
21impl IoTReading {
22 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 Self::validate_unit(&metric_type, &unit)?;
34
35 Self::validate_value(&metric_type, value, &unit)?;
37
38 if source.trim().is_empty() {
40 return Err("Source cannot be empty".to_string());
41 }
42
43 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 pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
64 self.metadata = Some(metadata);
65 self
66 }
67
68 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 let normalized = match unit {
84 "F" | "°F" => (value - 32.0) * 5.0 / 9.0,
85 "K" => value - 273.15,
86 _ => value, };
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 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 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 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, },
159 MetricType::Temperature => match self.unit.as_str() {
160 "F" | "°F" => (self.value - 32.0) * 5.0 / 9.0, "K" => self.value - 273.15, _ => self.value, },
164 _ => self.value, }
166 }
167}
168
169#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
171#[serde(rename_all = "snake_case")]
172pub enum DeviceType {
173 ElectricityMeter, WaterMeter, GasMeter, TemperatureSensor, HumiditySensor, PowerMeter, }
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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
211#[serde(rename_all = "snake_case")]
212pub enum MetricType {
213 ElectricityConsumption, WaterConsumption, GasConsumption, Temperature, Humidity, Power, Voltage, }
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(), 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 assert!(reading.is_anomalous(100.0, 40.0)); assert!(!reading.is_anomalous(100.0, 60.0)); }
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); 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); }
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 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 assert!((reading_k.normalized_value() - 0.0).abs() < 0.01);
497 }
498}