Skip to main content

automapper_validation/eval/
reading_dates.rs

1//! MSCONS SG10 DTM+7 (Nutzungszeitpunkt) beside DTM+60 / DTM+9: conditions
2//! [130] and [133], identical in every MSCONS AHB from 3.1d to 3.2.
3//!
4//! Both only ever appear as `X [UB1] ∧ [495] ∧ ([130] ∨ [133])` on the
5//! DTM+7 value, and each reads "if DTM+… stands beside it, the gap must be …".
6//! Taken one by one, both hold wherever their partner is absent — and then the
7//! alternative always holds, since a reading never has every partner: a DTM+7
8//! two days from its DTM+60 passed because [133] (DTM+9 of format 102) was
9//! `True` without a DTM+9 (mako-twin-64, 2026-11-02). They are therefore split
10//! so that the alternative says "every partner present keeps its gap":
11//!
12//! - [130]: no DTM+9 of format 102 is present, and every DTM+60 and every
13//!   DTM+9 of format 303 lies less than 24 hours away (23 hours across the
14//!   switch to summer time, 25 across the switch to winter time). A DTM+7
15//!   with no partner at all holds here.
16//! - [133]: a DTM+9 of format 102 is present, its day is the DTM+7's day or
17//!   one day apart, and every format-303 partner beside it keeps [130]'s gap.
18//!
19//! "Innerhalb desselben LIN-Segments … neben diesem Segment" is the DTM+7's
20//! own SG10. With a scope that holds an SG10 only that one is read; without
21//! one, every SG10 of the message must hold the alternative.
22
23use 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
30/// [130] — see the module docs.
31pub fn condition_130(ctx: &EvaluationContext) -> ConditionResult {
32    evaluate(ctx, |gap| gap.holds_130())
33}
34
35/// [133] — see the module docs.
36pub 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
62/// The dates of one SG10.
63struct ReadingDates {
64    /// DTM+7, format 303.
65    dtm7: Option<String>,
66    /// DTM+60 and DTM+9 of format 303.
67    partners_303: Vec<String>,
68    /// DTM+9 of format 102.
69    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    /// Every format-303 partner lies within the hour bound of the DTM+7. A
99    /// value that is no format-303 date is the format check's business.
100    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                // Net summer→winter crossing: the day has 25 hours.
110                -1 => 25 * 60,
111                // Net winter→summer crossing: the day has 23 hours.
112                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            // Not a date: the format check reports it.
130            _ => 0,
131        };
132        days_apart <= 1 && self.partners_303_hold()
133    }
134}
135
136/// The day number of the `CCYYMMDD` a value starts with.
137fn 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        // 2024-10-27: the day has 25 hours, 24.5 h hold.
197        let autumn = [
198            dtm("7", "202410272300", "303"),
199            dtm("60", "202410262230", "303"),
200        ];
201        assert!(dates(&autumn).0);
202        // 2024-03-31: the day has 23 hours, 23.5 h do not.
203        let spring = [
204            dtm("7", "202403312200", "303"),
205            dtm("60", "202403302230", "303"),
206        ];
207        assert!(!dates(&spring).0);
208    }
209}