automapper_validation/eval/
reading_dates.rs1use mig_types::segment::OwnedSegment;
24
25use super::timezone::{dst_transitions_between, parse_dtm303};
26use super::{ConditionResult, EvaluationContext};
27
28const SG10_PATH: [&str; 4] = ["SG5", "SG6", "SG9", "SG10"];
29
30pub fn condition_130(ctx: &EvaluationContext) -> ConditionResult {
32 evaluate(ctx, |gap| gap.holds_130())
33}
34
35pub fn condition_133(ctx: &EvaluationContext) -> ConditionResult {
37 evaluate(ctx, |gap| gap.holds_133())
38}
39
40fn evaluate(ctx: &EvaluationContext, scoped: impl Fn(&ReadingDates) -> bool) -> ConditionResult {
41 let in_sg10 = ctx
42 .scope
43 .is_some_and(|scope| scope.path.iter().any(|(id, _)| *id == "SG10"));
44 if in_sg10 && ctx.navigator.is_some() {
45 let dtms = ctx.scoped_find_segments_in("SG10", "DTM");
46 return ConditionResult::from(scoped(&ReadingDates::of(&dtms)));
47 }
48 let Some(nav) = ctx.navigator() else {
49 return ConditionResult::Unknown;
50 };
51 let count = nav.group_instance_count(&SG10_PATH);
52 if count == 0 {
53 return ConditionResult::Unknown;
54 }
55 let all_hold = (0..count).all(|i| {
56 let gap = ReadingDates::of(&nav.find_segments_in_group("DTM", &SG10_PATH, i));
57 gap.holds_130() || gap.holds_133()
58 });
59 ConditionResult::from(all_hold)
60}
61
62struct ReadingDates {
64 dtm7: Option<String>,
66 partners_303: Vec<String>,
68 dtm9_102: Option<String>,
70}
71
72impl ReadingDates {
73 fn of(dtms: &[OwnedSegment]) -> Self {
74 let mut dates = ReadingDates {
75 dtm7: None,
76 partners_303: Vec::new(),
77 dtm9_102: None,
78 };
79 for dtm in dtms {
80 let Some(c507) = dtm.elements.first() else {
81 continue;
82 };
83 let part = |i: usize| c507.get(i).map(String::as_str).unwrap_or("");
84 let (qualifier, value, format) = (part(0), part(1), part(2));
85 if value.is_empty() {
86 continue;
87 }
88 match (qualifier, format) {
89 ("7", _) => dates.dtm7 = dates.dtm7.take().or(Some(value.to_string())),
90 ("9", "102") => dates.dtm9_102 = Some(value.to_string()),
91 ("60" | "9", _) => dates.partners_303.push(value.to_string()),
92 _ => {}
93 }
94 }
95 dates
96 }
97
98 fn partners_303_hold(&self) -> bool {
101 let Some(dtm7) = self.dtm7.as_deref().and_then(parse_dtm303) else {
102 return true;
103 };
104 self.partners_303.iter().all(|partner| {
105 let Some(partner) = parse_dtm303(partner) else {
106 return true;
107 };
108 let bound = match dst_transitions_between(dtm7, partner).signum() {
109 -1 => 25 * 60,
111 1 => 23 * 60,
113 _ => 24 * 60,
114 };
115 (dtm7 - partner).abs() < bound
116 })
117 }
118
119 fn holds_130(&self) -> bool {
120 self.dtm9_102.is_none() && self.partners_303_hold()
121 }
122
123 fn holds_133(&self) -> bool {
124 let (Some(dtm7), Some(dtm9)) = (self.dtm7.as_deref(), self.dtm9_102.as_deref()) else {
125 return false;
126 };
127 let days_apart = match (day_number(dtm7), day_number(dtm9)) {
128 (Some(a), Some(b)) => (a - b).abs(),
129 _ => 0,
131 };
132 days_apart <= 1 && self.partners_303_hold()
133 }
134}
135
136fn day_number(value: &str) -> Option<i64> {
138 let date = value.get(..8)?;
139 if !date.bytes().all(|b| b.is_ascii_digit()) {
140 return None;
141 }
142 let (y, m, d): (i64, i64, i64) = (
143 date[..4].parse().ok()?,
144 date[4..6].parse().ok()?,
145 date[6..8].parse().ok()?,
146 );
147 if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
148 return None;
149 }
150 let a = (14 - m) / 12;
151 let (y, m) = (y + 4800 - a, m + 12 * a - 3);
152 Some(d + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32045)
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 fn dtm(qualifier: &str, value: &str, format: &str) -> OwnedSegment {
160 OwnedSegment {
161 id: "DTM".to_string(),
162 elements: vec![vec![qualifier.into(), value.into(), format.into()]],
163 segment_number: 0,
164 }
165 }
166
167 fn dates(dtms: &[OwnedSegment]) -> (bool, bool) {
168 let gap = ReadingDates::of(dtms);
169 (gap.holds_130(), gap.holds_133())
170 }
171
172 #[test]
173 fn a_lone_reading_holds_130() {
174 assert_eq!(dates(&[dtm("7", "202411142300", "303")]), (true, false));
175 }
176
177 #[test]
178 fn a_dtm60_two_days_off_holds_neither() {
179 let dtms = [
180 dtm("7", "202609302200", "303"),
181 dtm("60", "202609282200", "303"),
182 ];
183 assert_eq!(dates(&dtms), (false, false));
184 }
185
186 #[test]
187 fn a_dtm9_of_format_102_is_left_to_133() {
188 let next_day = [dtm("9", "20241115", "102"), dtm("7", "202411142300", "303")];
189 assert_eq!(dates(&next_day), (false, true));
190 let three_days = [dtm("9", "20241111", "102"), dtm("7", "202411142300", "303")];
191 assert_eq!(dates(&three_days), (false, false));
192 }
193
194 #[test]
195 fn the_bound_follows_the_dst_switch() {
196 let autumn = [
198 dtm("7", "202410272300", "303"),
199 dtm("60", "202410262230", "303"),
200 ];
201 assert!(dates(&autumn).0);
202 let spring = [
204 dtm("7", "202403312200", "303"),
205 dtm("60", "202403302230", "303"),
206 ];
207 assert!(!dates(&spring).0);
208 }
209}