ecad_processor/models/
weather.rs

1use chrono::NaiveDate;
2use serde::{Deserialize, Serialize};
3use validator::Validate;
4
5use crate::error::{ProcessingError, Result};
6
7#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
8pub enum PhysicalValidity {
9    Valid,   // Within normal physical limits
10    Suspect, // Unusual but physically possible
11    Invalid, // Physically impossible
12}
13
14impl PhysicalValidity {
15    pub fn parse(s: &str) -> Option<Self> {
16        match s {
17            "Valid" => Some(PhysicalValidity::Valid),
18            "Suspect" => Some(PhysicalValidity::Suspect),
19            "Invalid" => Some(PhysicalValidity::Invalid),
20            _ => None,
21        }
22    }
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
26pub enum DataQuality {
27    Valid,           // ECAD=0 AND physically valid
28    SuspectOriginal, // ECAD=1, physically valid
29    SuspectRange,    // ECAD=0, physically suspect
30    SuspectBoth,     // ECAD=1 AND physically suspect
31    Invalid,         // Physically impossible (regardless of ECAD flag)
32    Missing,         // ECAD=9
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
36pub struct WeatherRecord {
37    // Core station info (always present)
38    pub station_id: u32,
39    pub station_name: String,
40    pub date: NaiveDate,
41
42    #[validate(range(min = -90.0, max = 90.0))]
43    pub latitude: f64,
44
45    #[validate(range(min = -180.0, max = 180.0))]
46    pub longitude: f64,
47
48    // Optional temperature metrics (0.1°C units)
49    #[validate(range(min = -50.0, max = 50.0))]
50    pub temp_min: Option<f32>,
51
52    #[validate(range(min = -50.0, max = 50.0))]
53    pub temp_max: Option<f32>,
54
55    #[validate(range(min = -50.0, max = 50.0))]
56    pub temp_avg: Option<f32>,
57
58    // Optional precipitation (0.1mm units)
59    #[validate(range(min = 0.0, max = 1000.0))]
60    pub precipitation: Option<f32>,
61
62    // Optional wind speed (0.1 m/s units)
63    #[validate(range(min = 0.0, max = 100.0))]
64    pub wind_speed: Option<f32>,
65
66    // Quality flags per metric type (original ECAD flags)
67    pub temp_quality: Option<String>, // "000", "001", etc.
68    pub precip_quality: Option<String>,
69    pub wind_quality: Option<String>,
70
71    // Physical validation assessments (our validation layer)
72    pub temp_validation: Option<PhysicalValidity>,
73    pub precip_validation: Option<PhysicalValidity>,
74    pub wind_validation: Option<PhysicalValidity>,
75}
76
77impl WeatherRecord {
78    #[allow(clippy::too_many_arguments)]
79    pub fn new(
80        station_id: u32,
81        station_name: String,
82        date: NaiveDate,
83        latitude: f64,
84        longitude: f64,
85        temp_min: Option<f32>,
86        temp_max: Option<f32>,
87        temp_avg: Option<f32>,
88        precipitation: Option<f32>,
89        wind_speed: Option<f32>,
90        temp_quality: Option<String>,
91        precip_quality: Option<String>,
92        wind_quality: Option<String>,
93    ) -> Self {
94        let mut record = Self {
95            station_id,
96            station_name,
97            date,
98            latitude,
99            longitude,
100            temp_min,
101            temp_max,
102            temp_avg,
103            precipitation,
104            wind_speed,
105            temp_quality,
106            precip_quality,
107            wind_quality,
108            temp_validation: None,
109            precip_validation: None,
110            wind_validation: None,
111        };
112
113        // Automatically perform physical validation
114        record.perform_physical_validation();
115        record
116    }
117
118    /// Create a WeatherRecord without automatic validation (for reading from file)
119    #[allow(clippy::too_many_arguments)]
120    pub fn new_raw(
121        station_id: u32,
122        station_name: String,
123        date: NaiveDate,
124        latitude: f64,
125        longitude: f64,
126        temp_min: Option<f32>,
127        temp_max: Option<f32>,
128        temp_avg: Option<f32>,
129        precipitation: Option<f32>,
130        wind_speed: Option<f32>,
131        temp_quality: Option<String>,
132        precip_quality: Option<String>,
133        wind_quality: Option<String>,
134        temp_validation: Option<PhysicalValidity>,
135        precip_validation: Option<PhysicalValidity>,
136        wind_validation: Option<PhysicalValidity>,
137    ) -> Self {
138        Self {
139            station_id,
140            station_name,
141            date,
142            latitude,
143            longitude,
144            temp_min,
145            temp_max,
146            temp_avg,
147            precipitation,
148            wind_speed,
149            temp_quality,
150            precip_quality,
151            wind_quality,
152            temp_validation,
153            precip_validation,
154            wind_validation,
155        }
156    }
157
158    pub fn builder() -> WeatherRecordBuilder {
159        WeatherRecordBuilder::new()
160    }
161
162    pub fn validate_relationships(&self) -> Result<()> {
163        // Validate temperature relationships if all three are present
164        if let (Some(min), Some(avg), Some(max)) = (self.temp_min, self.temp_avg, self.temp_max) {
165            let tolerance = 1.0; // Increased tolerance for real-world data
166
167            if min > avg + tolerance {
168                return Err(ProcessingError::TemperatureValidation {
169                    message: format!(
170                        "Min temperature {} > Avg temperature {} (tolerance={})",
171                        min, avg, tolerance
172                    ),
173                });
174            }
175
176            if avg > max + tolerance {
177                return Err(ProcessingError::TemperatureValidation {
178                    message: format!(
179                        "Avg temperature {} > Max temperature {} (tolerance={})",
180                        avg, max, tolerance
181                    ),
182                });
183            }
184        }
185
186        self.validate()?;
187        Ok(())
188    }
189
190    pub fn has_temperature_data(&self) -> bool {
191        self.temp_min.is_some() || self.temp_max.is_some() || self.temp_avg.is_some()
192    }
193
194    pub fn has_complete_temperature(&self) -> bool {
195        self.temp_min.is_some() && self.temp_max.is_some() && self.temp_avg.is_some()
196    }
197
198    pub fn has_precipitation(&self) -> bool {
199        self.precipitation.is_some()
200    }
201
202    pub fn has_wind_speed(&self) -> bool {
203        self.wind_speed.is_some()
204    }
205
206    pub fn available_metrics(&self) -> Vec<&str> {
207        let mut metrics = Vec::new();
208        if self.has_temperature_data() {
209            metrics.push("temperature");
210        }
211        if self.has_precipitation() {
212            metrics.push("precipitation");
213        }
214        if self.has_wind_speed() {
215            metrics.push("wind_speed");
216        }
217        metrics
218    }
219
220    pub fn metric_coverage_score(&self) -> f32 {
221        let total_metrics = 3.0; // temp, precip, wind
222        let available = self.available_metrics().len() as f32;
223        available / total_metrics
224    }
225
226    pub fn temperature_range(&self) -> Option<f32> {
227        match (self.temp_min, self.temp_max) {
228            (Some(min), Some(max)) => Some(max - min),
229            _ => None,
230        }
231    }
232
233    pub fn has_valid_temperature_data(&self) -> bool {
234        self.temp_quality.as_ref().is_some_and(|q| q == "000")
235    }
236
237    pub fn has_valid_precipitation_data(&self) -> bool {
238        self.precip_quality.as_ref().is_some_and(|q| q == "0")
239    }
240
241    pub fn has_valid_wind_data(&self) -> bool {
242        self.wind_quality.as_ref().is_some_and(|q| q == "0")
243    }
244
245    pub fn has_suspect_data(&self) -> bool {
246        self.temp_quality.as_ref().is_some_and(|q| q.contains('1'))
247            || self
248                .precip_quality
249                .as_ref()
250                .is_some_and(|q| q.contains('1'))
251            || self.wind_quality.as_ref().is_some_and(|q| q.contains('1'))
252    }
253
254    pub fn has_missing_data(&self) -> bool {
255        self.temp_quality.as_ref().is_some_and(|q| q.contains('9'))
256            || self
257                .precip_quality
258                .as_ref()
259                .is_some_and(|q| q.contains('9'))
260            || self.wind_quality.as_ref().is_some_and(|q| q.contains('9'))
261    }
262
263    /// Perform physical validation on all metrics
264    pub fn perform_physical_validation(&mut self) {
265        self.temp_validation = self.validate_temperature_physics();
266        self.precip_validation = self.validate_precipitation_physics();
267        self.wind_validation = self.validate_wind_physics();
268    }
269
270    /// Validate temperature values against physical limits
271    fn validate_temperature_physics(&self) -> Option<PhysicalValidity> {
272        let temps = [self.temp_min, self.temp_max, self.temp_avg];
273        let existing_temps: Vec<f32> = temps.into_iter().flatten().collect();
274
275        if existing_temps.is_empty() {
276            return None;
277        }
278
279        for &temp in &existing_temps {
280            // Physical impossibility (below absolute zero or above physically possible)
281            if !(-90.0..=60.0).contains(&temp) {
282                return Some(PhysicalValidity::Invalid);
283            }
284
285            // Suspect but possible for UK/Ireland climate
286            if !(-35.0..=45.0).contains(&temp) {
287                return Some(PhysicalValidity::Suspect);
288            }
289        }
290
291        Some(PhysicalValidity::Valid)
292    }
293
294    /// Validate precipitation values against physical limits
295    fn validate_precipitation_physics(&self) -> Option<PhysicalValidity> {
296        if let Some(precip) = self.precipitation {
297            // Physical impossibility
298            if !(0.0..=2000.0).contains(&precip) {
299                return Some(PhysicalValidity::Invalid);
300            }
301
302            // Suspect but possible (extreme rainfall events)
303            if precip > 500.0 {
304                return Some(PhysicalValidity::Suspect);
305            }
306
307            Some(PhysicalValidity::Valid)
308        } else {
309            None
310        }
311    }
312
313    /// Validate wind speed values against physical limits
314    fn validate_wind_physics(&self) -> Option<PhysicalValidity> {
315        if let Some(wind) = self.wind_speed {
316            // Physical impossibility
317            if !(0.0..=120.0).contains(&wind) {
318                return Some(PhysicalValidity::Invalid);
319            }
320
321            // Suspect but possible (hurricane-force winds)
322            if wind > 50.0 {
323                return Some(PhysicalValidity::Suspect);
324            }
325
326            Some(PhysicalValidity::Valid)
327        } else {
328            None
329        }
330    }
331
332    /// Assess overall temperature data quality combining ECAD flags and physical validation
333    pub fn assess_temperature_quality(&self) -> DataQuality {
334        match (self.temp_quality.as_deref(), self.temp_validation) {
335            (Some(q), _) if q.contains('9') => DataQuality::Missing,
336            (_, Some(PhysicalValidity::Invalid)) => DataQuality::Invalid,
337            (Some(q), Some(PhysicalValidity::Suspect)) if q.contains('1') => {
338                DataQuality::SuspectBoth
339            }
340            (Some(q), _) if q.contains('1') => DataQuality::SuspectOriginal,
341            (_, Some(PhysicalValidity::Suspect)) => DataQuality::SuspectRange,
342            _ => DataQuality::Valid,
343        }
344    }
345
346    /// Assess overall precipitation data quality
347    pub fn assess_precipitation_quality(&self) -> DataQuality {
348        match (self.precip_quality.as_deref(), self.precip_validation) {
349            (Some("9"), _) => DataQuality::Missing,
350            (_, Some(PhysicalValidity::Invalid)) => DataQuality::Invalid,
351            (Some("1"), Some(PhysicalValidity::Suspect)) => DataQuality::SuspectBoth,
352            (Some("1"), _) => DataQuality::SuspectOriginal,
353            (_, Some(PhysicalValidity::Suspect)) => DataQuality::SuspectRange,
354            _ => DataQuality::Valid,
355        }
356    }
357
358    /// Assess overall wind data quality
359    pub fn assess_wind_quality(&self) -> DataQuality {
360        match (self.wind_quality.as_deref(), self.wind_validation) {
361            (Some("9"), _) => DataQuality::Missing,
362            (_, Some(PhysicalValidity::Invalid)) => DataQuality::Invalid,
363            (Some("1"), Some(PhysicalValidity::Suspect)) => DataQuality::SuspectBoth,
364            (Some("1"), _) => DataQuality::SuspectOriginal,
365            (_, Some(PhysicalValidity::Suspect)) => DataQuality::SuspectRange,
366            _ => DataQuality::Valid,
367        }
368    }
369
370    /// Check if record has any invalid data (physically impossible)
371    pub fn has_invalid_data(&self) -> bool {
372        matches!(self.assess_temperature_quality(), DataQuality::Invalid)
373            || matches!(self.assess_precipitation_quality(), DataQuality::Invalid)
374            || matches!(self.assess_wind_quality(), DataQuality::Invalid)
375    }
376
377    /// Check if record has high-quality data (valid with no flags)
378    pub fn has_high_quality_data(&self) -> bool {
379        let temp_ok = self.temp_validation.is_none()
380            || matches!(self.assess_temperature_quality(), DataQuality::Valid);
381        let precip_ok = self.precip_validation.is_none()
382            || matches!(self.assess_precipitation_quality(), DataQuality::Valid);
383        let wind_ok = self.wind_validation.is_none()
384            || matches!(self.assess_wind_quality(), DataQuality::Valid);
385
386        temp_ok && precip_ok && wind_ok
387    }
388}
389
390pub struct WeatherRecordBuilder {
391    station_id: Option<u32>,
392    station_name: Option<String>,
393    date: Option<NaiveDate>,
394    latitude: Option<f64>,
395    longitude: Option<f64>,
396    temp_min: Option<f32>,
397    temp_max: Option<f32>,
398    temp_avg: Option<f32>,
399    precipitation: Option<f32>,
400    wind_speed: Option<f32>,
401    temp_quality: Option<String>,
402    precip_quality: Option<String>,
403    wind_quality: Option<String>,
404    temp_validation: Option<PhysicalValidity>,
405    precip_validation: Option<PhysicalValidity>,
406    wind_validation: Option<PhysicalValidity>,
407}
408
409impl Default for WeatherRecordBuilder {
410    fn default() -> Self {
411        Self::new()
412    }
413}
414
415impl WeatherRecordBuilder {
416    pub fn new() -> Self {
417        Self {
418            station_id: None,
419            station_name: None,
420            date: None,
421            latitude: None,
422            longitude: None,
423            temp_min: None,
424            temp_max: None,
425            temp_avg: None,
426            precipitation: None,
427            wind_speed: None,
428            temp_quality: None,
429            precip_quality: None,
430            wind_quality: None,
431            temp_validation: None,
432            precip_validation: None,
433            wind_validation: None,
434        }
435    }
436
437    pub fn station_id(mut self, id: u32) -> Self {
438        self.station_id = Some(id);
439        self
440    }
441
442    pub fn station_name(mut self, name: String) -> Self {
443        self.station_name = Some(name);
444        self
445    }
446
447    pub fn date(mut self, date: NaiveDate) -> Self {
448        self.date = Some(date);
449        self
450    }
451
452    pub fn coordinates(mut self, latitude: f64, longitude: f64) -> Self {
453        self.latitude = Some(latitude);
454        self.longitude = Some(longitude);
455        self
456    }
457
458    pub fn temp_min(mut self, temp: f32) -> Self {
459        self.temp_min = Some(temp);
460        self
461    }
462
463    pub fn temp_max(mut self, temp: f32) -> Self {
464        self.temp_max = Some(temp);
465        self
466    }
467
468    pub fn temp_avg(mut self, temp: f32) -> Self {
469        self.temp_avg = Some(temp);
470        self
471    }
472
473    pub fn temperatures(mut self, min: f32, avg: f32, max: f32) -> Self {
474        self.temp_min = Some(min);
475        self.temp_avg = Some(avg);
476        self.temp_max = Some(max);
477        self
478    }
479
480    pub fn precipitation(mut self, precip: f32) -> Self {
481        self.precipitation = Some(precip);
482        self
483    }
484
485    pub fn wind_speed(mut self, speed: f32) -> Self {
486        self.wind_speed = Some(speed);
487        self
488    }
489
490    pub fn temp_quality(mut self, quality: String) -> Self {
491        self.temp_quality = Some(quality);
492        self
493    }
494
495    pub fn precip_quality(mut self, quality: String) -> Self {
496        self.precip_quality = Some(quality);
497        self
498    }
499
500    pub fn wind_quality(mut self, quality: String) -> Self {
501        self.wind_quality = Some(quality);
502        self
503    }
504
505    pub fn build(self) -> Result<WeatherRecord> {
506        let mut record = WeatherRecord {
507            station_id: self
508                .station_id
509                .ok_or_else(|| ProcessingError::MissingData("station_id".to_string()))?,
510            station_name: self
511                .station_name
512                .ok_or_else(|| ProcessingError::MissingData("station_name".to_string()))?,
513            date: self
514                .date
515                .ok_or_else(|| ProcessingError::MissingData("date".to_string()))?,
516            latitude: self
517                .latitude
518                .ok_or_else(|| ProcessingError::MissingData("latitude".to_string()))?,
519            longitude: self
520                .longitude
521                .ok_or_else(|| ProcessingError::MissingData("longitude".to_string()))?,
522            temp_min: self.temp_min,
523            temp_max: self.temp_max,
524            temp_avg: self.temp_avg,
525            precipitation: self.precipitation,
526            wind_speed: self.wind_speed,
527            temp_quality: self.temp_quality,
528            precip_quality: self.precip_quality,
529            wind_quality: self.wind_quality,
530            temp_validation: self.temp_validation,
531            precip_validation: self.precip_validation,
532            wind_validation: self.wind_validation,
533        };
534
535        // Perform physical validation if not already set
536        if record.temp_validation.is_none()
537            || record.precip_validation.is_none()
538            || record.wind_validation.is_none()
539        {
540            record.perform_physical_validation();
541        }
542
543        record.validate_relationships()?;
544        Ok(record)
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn test_weather_record_creation() {
554        let date = NaiveDate::from_ymd_opt(2023, 7, 15).unwrap();
555
556        let record = WeatherRecord::new(
557            12345,
558            "London Station".to_string(),
559            date,
560            51.5074,
561            -0.1278,
562            Some(10.0),
563            Some(20.0),
564            Some(15.0),
565            Some(5.0),
566            Some(3.2),
567            Some("000".to_string()),
568            Some("0".to_string()),
569            Some("0".to_string()),
570        );
571
572        assert_eq!(record.station_id, 12345);
573        assert_eq!(record.station_name, "London Station");
574        assert!(record.has_complete_temperature());
575        assert!(record.has_precipitation());
576        assert!(record.has_wind_speed());
577        assert_eq!(record.available_metrics().len(), 3);
578        assert_eq!(record.metric_coverage_score(), 1.0);
579    }
580
581    #[test]
582    fn test_temperature_validation() {
583        let date = NaiveDate::from_ymd_opt(2023, 7, 15).unwrap();
584
585        let record = WeatherRecord::new(
586            12345,
587            "Test Station".to_string(),
588            date,
589            51.5074,
590            -0.1278,
591            Some(10.0),
592            Some(20.0),
593            Some(15.0),
594            None,
595            None,
596            Some("000".to_string()),
597            None,
598            None,
599        );
600
601        assert!(record.validate_relationships().is_ok());
602        assert_eq!(record.temperature_range(), Some(10.0));
603    }
604
605    #[test]
606    fn test_invalid_temperature_relationship() {
607        let date = NaiveDate::from_ymd_opt(2023, 7, 15).unwrap();
608
609        let record = WeatherRecord::new(
610            12345,
611            "Test Station".to_string(),
612            date,
613            51.5074,
614            -0.1278,
615            Some(20.0), // min > avg
616            Some(10.0), // max < avg
617            Some(15.0),
618            None,
619            None,
620            Some("000".to_string()),
621            None,
622            None,
623        );
624
625        assert!(record.validate_relationships().is_err());
626    }
627
628    #[test]
629    fn test_builder_pattern() {
630        let date = NaiveDate::from_ymd_opt(2023, 7, 15).unwrap();
631
632        let record = WeatherRecord::builder()
633            .station_id(12345)
634            .station_name("Test Station".to_string())
635            .date(date)
636            .coordinates(51.5074, -0.1278)
637            .temperatures(10.0, 15.0, 20.0)
638            .precipitation(5.5)
639            .wind_speed(3.2)
640            .temp_quality("000".to_string())
641            .build()
642            .unwrap();
643
644        assert_eq!(record.station_id, 12345);
645        assert_eq!(record.station_name, "Test Station");
646        assert!(record.validate_relationships().is_ok());
647        assert!(record.has_complete_temperature());
648        assert!(record.has_precipitation());
649        assert!(record.has_wind_speed());
650    }
651
652    #[test]
653    fn test_partial_data() {
654        let date = NaiveDate::from_ymd_opt(2023, 7, 15).unwrap();
655
656        let record = WeatherRecord::builder()
657            .station_id(12345)
658            .station_name("Test Station".to_string())
659            .date(date)
660            .coordinates(51.5074, -0.1278)
661            .temp_min(10.0)
662            .precipitation(5.5)
663            .build()
664            .unwrap();
665
666        assert!(record.has_temperature_data());
667        assert!(!record.has_complete_temperature());
668        assert!(record.has_precipitation());
669        assert!(!record.has_wind_speed());
670        assert_eq!(record.available_metrics().len(), 2);
671        assert!((record.metric_coverage_score() - 0.666).abs() < 0.01);
672    }
673
674    #[test]
675    fn test_quality_flags() {
676        let date = NaiveDate::from_ymd_opt(2023, 7, 15).unwrap();
677
678        let record = WeatherRecord::builder()
679            .station_id(12345)
680            .station_name("Test Station".to_string())
681            .date(date)
682            .coordinates(51.5074, -0.1278)
683            .temp_min(10.0)
684            .temp_quality("001".to_string()) // suspect data
685            .build()
686            .unwrap();
687
688        assert!(!record.has_valid_temperature_data());
689        assert!(record.has_suspect_data());
690        assert!(!record.has_missing_data());
691    }
692}