Skip to main content

automapper_validation/eval/
werktag.rs

1//! German Werktag (working day) arithmetic for BDEW AHB conditions.
2//!
3//! "Werktage" under GPKE/GeLi-Gas are calendar days that are **not**
4//! Saturdays, Sundays, or a holiday per the BDEW Feiertagskalender
5//! (see `Feiertagskalender_GPKE-GeLiGas_<FV>_V_x_y_<yyyymmdd>.pdf`).
6//!
7//! BDEW rule: "*Wenn in einem Bundesland ein Tag als Feiertag ausgewiesen
8//! ist, dann gilt dieser Feiertag bundesweit. Generell gelten der 24.12.
9//! und der 31.12. als Feiertage.*" — the union of every Land's public
10//! holidays plus 24.12. and 31.12. as BDEW-specific non-working days.
11//!
12//! | # | Name | Date |
13//! |---|------|------|
14//! | 1 | Neujahr | 1. Januar |
15//! | 2 | Hl. Drei Könige | 6. Januar |
16//! | 3 | Internationaler Frauentag | 8. März |
17//! | 4 | Karfreitag | Ostersonntag − 2 |
18//! | 5 | Ostermontag | Ostersonntag + 1 |
19//! | 6 | Tag der Arbeit | 1. Mai |
20//! | 7 | Christi Himmelfahrt | Ostersonntag + 39 |
21//! | 8 | Pfingstmontag | Ostersonntag + 50 |
22//! | 9 | Fronleichnam | Ostersonntag + 60 |
23//! | 10 | Mariä Himmelfahrt | 15. August |
24//! | 11 | Weltkindertag | 20. September |
25//! | 12 | Tag der Deutschen Einheit | 3. Oktober |
26//! | 13 | Reformationstag | 31. Oktober |
27//! | 14 | Allerheiligen | 1. November |
28//! | 15 | Buß- und Bettag | Mittwoch vor dem 23. November |
29//! | 16 | Heiligabend | 24. Dezember |
30//! | 17 | 1. Weihnachtstag | 25. Dezember |
31//! | 18 | 2. Weihnachtstag | 26. Dezember |
32//! | 19 | Silvester | 31. Dezember |
33//!
34//! Easter (Ostersonntag) is computed via the Anonymous Gregorian algorithm
35//! (Gauss 1816 / Meeus 1991).
36//!
37//! **Note on historical dates:** Frauentag (Berlin ab 2019, MV ab 2023) and
38//! Weltkindertag (Thüringen ab 2019) are applied unconditionally; the
39//! validator is intended for current-era energy-market traffic, not
40//! pre-2019 archival data.
41
42use chrono::{Datelike, Duration, NaiveDate, Weekday};
43
44/// Compute Gregorian Easter Sunday for the given year.
45///
46/// Anonymous Gregorian algorithm. Valid for years ≥ 1583.
47pub fn compute_easter(year: i32) -> NaiveDate {
48    let a = year % 19;
49    let b = year / 100;
50    let c = year % 100;
51    let d = b / 4;
52    let e = b % 4;
53    let f = (b + 8) / 25;
54    let g = (b - f + 1) / 3;
55    let h = (19 * a + b - d - g + 15) % 30;
56    let i = c / 4;
57    let k = c % 4;
58    let l = (32 + 2 * e + 2 * i - h - k) % 7;
59    let m = (a + 11 * h + 22 * l) / 451;
60    let month = ((h + l - 7 * m + 114) / 31) as u32;
61    let day = (((h + l - 7 * m + 114) % 31) + 1) as u32;
62    NaiveDate::from_ymd_opt(year, month, day).expect("easter date is always valid")
63}
64
65/// Compute Buß- und Bettag (Wednesday before 23. November) for the given year.
66pub fn buss_und_bettag(year: i32) -> NaiveDate {
67    let nov_23 = NaiveDate::from_ymd_opt(year, 11, 23).expect("23.11. is always valid");
68    // Days to subtract to reach the Wednesday strictly before Nov 23. If Nov 23
69    // is itself a Wednesday, go back a full week.
70    let days_back = match nov_23.weekday() {
71        Weekday::Wed => 7,
72        Weekday::Thu => 1,
73        Weekday::Fri => 2,
74        Weekday::Sat => 3,
75        Weekday::Sun => 4,
76        Weekday::Mon => 5,
77        Weekday::Tue => 6,
78    };
79    nov_23 - Duration::days(days_back)
80}
81
82/// Is `date` a holiday per the BDEW GPKE/GeLi-Gas Feiertagskalender?
83pub fn is_german_holiday(date: NaiveDate) -> bool {
84    let (year, month, day) = (date.year(), date.month(), date.day());
85    // Fixed-date holidays (union of all Länder + BDEW 24.12. / 31.12.).
86    if matches!(
87        (month, day),
88        (1, 1)     // Neujahr
89            | (1, 6)   // Hl. Drei Könige (BW, BY, ST)
90            | (3, 8)   // Internationaler Frauentag (BE, MV)
91            | (5, 1)   // Tag der Arbeit
92            | (8, 15)  // Mariä Himmelfahrt (BY, SL)
93            | (9, 20)  // Weltkindertag (TH)
94            | (10, 3)  // Tag der Deutschen Einheit
95            | (10, 31) // Reformationstag (BB, MV, SN, ST, TH, HB, NDS, SH, HH)
96            | (11, 1)  // Allerheiligen (BW, BY, NRW, RP, SL)
97            | (12, 24) // Heiligabend (BDEW)
98            | (12, 25) // 1. Weihnachtstag
99            | (12, 26) // 2. Weihnachtstag
100            | (12, 31) // Silvester (BDEW)
101    ) {
102        return true;
103    }
104    // Easter-relative holidays.
105    let easter = compute_easter(year);
106    if date == easter - Duration::days(2)
107        || date == easter + Duration::days(1)
108        || date == easter + Duration::days(39)
109        || date == easter + Duration::days(50)
110        || date == easter + Duration::days(60)
111    {
112        return true;
113    }
114    // Buß- und Bettag (SN) — Wednesday before 23. November.
115    if month == 11 && date == buss_und_bettag(year) {
116        return true;
117    }
118    false
119}
120
121/// Is `date` a Werktag (Mon–Fri and not a federal holiday)?
122pub fn is_werktag(date: NaiveDate) -> bool {
123    !matches!(date.weekday(), Weekday::Sat | Weekday::Sun) && !is_german_holiday(date)
124}
125
126/// Count Werktage strictly after `from` up to and including `to`.
127///
128/// Interpretation follows the BDEW convention "N Werktage nach X":
129/// `werktage_between(X, X + N WT) == N`. The starting day `from` itself is
130/// not counted.
131///
132/// Sign reflects direction: positive when `to > from`, negative when
133/// `to < from`, zero when equal.
134pub fn werktage_between(from: NaiveDate, to: NaiveDate) -> i32 {
135    if from == to {
136        return 0;
137    }
138    let (start, end, sign) = if from < to {
139        (from, to, 1)
140    } else {
141        (to, from, -1)
142    };
143    let mut count = 0;
144    let mut cur = start;
145    while cur < end {
146        cur = cur
147            .succ_opt()
148            .expect("succ_opt within validated date range");
149        if is_werktag(cur) {
150            count += 1;
151        }
152    }
153    count * sign
154}
155
156/// Parse a CCYYMMDD date prefix (ignoring anything after the 8th character).
157/// Used for DTM values in formats 102 / 203 / 303.
158pub fn parse_ccyymmdd_prefix(value: &str) -> Option<NaiveDate> {
159    let s = value.trim();
160    if s.len() < 8 {
161        return None;
162    }
163    let year: i32 = s.get(0..4).and_then(|v| v.parse().ok())?;
164    let month: u32 = s.get(4..6).and_then(|v| v.parse().ok())?;
165    let day: u32 = s.get(6..8).and_then(|v| v.parse().ok())?;
166    NaiveDate::from_ymd_opt(year, month, day)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_easter_2024() {
175        // 2024-03-31
176        assert_eq!(
177            compute_easter(2024),
178            NaiveDate::from_ymd_opt(2024, 3, 31).unwrap()
179        );
180    }
181
182    #[test]
183    fn test_easter_2025() {
184        // 2025-04-20
185        assert_eq!(
186            compute_easter(2025),
187            NaiveDate::from_ymd_opt(2025, 4, 20).unwrap()
188        );
189    }
190
191    #[test]
192    fn test_easter_2026() {
193        // 2026-04-05
194        assert_eq!(
195            compute_easter(2026),
196            NaiveDate::from_ymd_opt(2026, 4, 5).unwrap()
197        );
198    }
199
200    #[test]
201    fn test_fixed_federal_holidays_2026() {
202        assert!(is_german_holiday(
203            NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()
204        ));
205        assert!(is_german_holiday(
206            NaiveDate::from_ymd_opt(2026, 5, 1).unwrap()
207        ));
208        assert!(is_german_holiday(
209            NaiveDate::from_ymd_opt(2026, 10, 3).unwrap()
210        ));
211        assert!(is_german_holiday(
212            NaiveDate::from_ymd_opt(2026, 12, 25).unwrap()
213        ));
214        assert!(is_german_holiday(
215            NaiveDate::from_ymd_opt(2026, 12, 26).unwrap()
216        ));
217    }
218
219    #[test]
220    fn test_state_level_holidays_included_bundesweit_2026() {
221        // BDEW rule: Feiertag in any Bundesland → bundesweit.
222        assert!(is_german_holiday(
223            NaiveDate::from_ymd_opt(2026, 1, 6).unwrap()
224        )); // Hl. Drei Könige
225        assert!(is_german_holiday(
226            NaiveDate::from_ymd_opt(2026, 3, 8).unwrap()
227        )); // Frauentag
228        assert!(is_german_holiday(
229            NaiveDate::from_ymd_opt(2026, 8, 15).unwrap()
230        )); // Mariä Himmelfahrt
231        assert!(is_german_holiday(
232            NaiveDate::from_ymd_opt(2026, 9, 20).unwrap()
233        )); // Weltkindertag
234        assert!(is_german_holiday(
235            NaiveDate::from_ymd_opt(2026, 10, 31).unwrap()
236        )); // Reformationstag
237        assert!(is_german_holiday(
238            NaiveDate::from_ymd_opt(2026, 11, 1).unwrap()
239        )); // Allerheiligen
240    }
241
242    #[test]
243    fn test_bdew_specific_holidays_2026() {
244        // Heiligabend and Silvester are BDEW-specific non-working days.
245        assert!(is_german_holiday(
246            NaiveDate::from_ymd_opt(2026, 12, 24).unwrap()
247        ));
248        assert!(is_german_holiday(
249            NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()
250        ));
251    }
252
253    #[test]
254    fn test_fronleichnam_2026() {
255        // Easter 2026-04-05 + 60 days = 2026-06-04 (Thursday).
256        assert!(is_german_holiday(
257            NaiveDate::from_ymd_opt(2026, 6, 4).unwrap()
258        ));
259    }
260
261    #[test]
262    fn test_buss_und_bettag() {
263        // Nov 23 weekday → expected Buß- und Bettag per BDEW calendar.
264        // 2024: Nov 23 is Sat → Mi 2024-11-20
265        assert_eq!(
266            buss_und_bettag(2024),
267            NaiveDate::from_ymd_opt(2024, 11, 20).unwrap()
268        );
269        // 2025: Nov 23 is Sun → Mi 2025-11-19
270        assert_eq!(
271            buss_und_bettag(2025),
272            NaiveDate::from_ymd_opt(2025, 11, 19).unwrap()
273        );
274        // 2026: Nov 23 is Mon → Mi 2026-11-18
275        assert_eq!(
276            buss_und_bettag(2026),
277            NaiveDate::from_ymd_opt(2026, 11, 18).unwrap()
278        );
279        // 2022: Nov 23 is Wed → Mi 2022-11-16 (go back a full week)
280        assert_eq!(
281            buss_und_bettag(2022),
282            NaiveDate::from_ymd_opt(2022, 11, 16).unwrap()
283        );
284    }
285
286    #[test]
287    fn test_buss_und_bettag_is_holiday() {
288        assert!(is_german_holiday(
289            NaiveDate::from_ymd_opt(2026, 11, 18).unwrap()
290        ));
291        // The surrounding Wednesday is NOT a holiday.
292        assert!(!is_german_holiday(
293            NaiveDate::from_ymd_opt(2026, 11, 11).unwrap()
294        ));
295        assert!(!is_german_holiday(
296            NaiveDate::from_ymd_opt(2026, 11, 25).unwrap()
297        ));
298    }
299
300    #[test]
301    fn test_easter_holidays_2026() {
302        // Easter 2026 = 2026-04-05
303        // Karfreitag = 2026-04-03
304        // Ostermontag = 2026-04-06
305        // Christi Himmelfahrt = 2026-05-14
306        // Pfingstmontag = 2026-05-25
307        assert!(is_german_holiday(
308            NaiveDate::from_ymd_opt(2026, 4, 3).unwrap()
309        ));
310        assert!(is_german_holiday(
311            NaiveDate::from_ymd_opt(2026, 4, 6).unwrap()
312        ));
313        assert!(is_german_holiday(
314            NaiveDate::from_ymd_opt(2026, 5, 14).unwrap()
315        ));
316        assert!(is_german_holiday(
317            NaiveDate::from_ymd_opt(2026, 5, 25).unwrap()
318        ));
319    }
320
321    #[test]
322    fn test_non_holidays() {
323        assert!(!is_german_holiday(
324            NaiveDate::from_ymd_opt(2026, 1, 2).unwrap()
325        ));
326        assert!(!is_german_holiday(
327            NaiveDate::from_ymd_opt(2026, 7, 15).unwrap()
328        ));
329    }
330
331    #[test]
332    fn test_werktag_skips_weekend() {
333        // 2026-04-04 is a Saturday, 2026-04-05 a Sunday (also Ostersonntag).
334        assert!(!is_werktag(NaiveDate::from_ymd_opt(2026, 4, 4).unwrap()));
335        assert!(!is_werktag(NaiveDate::from_ymd_opt(2026, 4, 5).unwrap()));
336    }
337
338    #[test]
339    fn test_werktag_skips_holiday() {
340        // 2026-04-03 Karfreitag is a Friday but a holiday.
341        assert!(!is_werktag(NaiveDate::from_ymd_opt(2026, 4, 3).unwrap()));
342    }
343
344    #[test]
345    fn test_werktage_between_simple() {
346        // Mon 2026-01-12 → Fri 2026-01-16: 4 Werktage (no holidays in range).
347        // (2026-01-05..09 avoided because Hl. Drei Könige = Tue 2026-01-06.)
348        let from = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
349        let to = NaiveDate::from_ymd_opt(2026, 1, 16).unwrap();
350        assert_eq!(werktage_between(from, to), 4);
351    }
352
353    #[test]
354    fn test_werktage_between_skips_hl_drei_koenige() {
355        // Mon 2026-01-05 → Fri 2026-01-09: Tue 01-06 is Hl. Drei Könige.
356        // Counted: Wed, Thu, Fri = 3.
357        let from = NaiveDate::from_ymd_opt(2026, 1, 5).unwrap();
358        let to = NaiveDate::from_ymd_opt(2026, 1, 9).unwrap();
359        assert_eq!(werktage_between(from, to), 3);
360    }
361
362    #[test]
363    fn test_werktage_between_across_weekend() {
364        // Fri 2026-01-09 → Mon 2026-01-12 = 1 Werktag (Mon).
365        let from = NaiveDate::from_ymd_opt(2026, 1, 9).unwrap();
366        let to = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
367        assert_eq!(werktage_between(from, to), 1);
368    }
369
370    #[test]
371    fn test_werktage_between_skips_easter_holidays() {
372        // Wed 2026-04-01 → Tue 2026-04-07.
373        // Skipped: Fri 04-03 Karfreitag, Sat 04-04, Sun 04-05, Mon 04-06 Ostermontag.
374        // Counted: Thu 04-02, Tue 04-07 = 2.
375        let from = NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
376        let to = NaiveDate::from_ymd_opt(2026, 4, 7).unwrap();
377        assert_eq!(werktage_between(from, to), 2);
378    }
379
380    #[test]
381    fn test_werktage_between_10_wt_after_monday() {
382        // Mon 2026-01-12 + 10 WT = Mon 2026-01-26 (no holidays in this range).
383        let from = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
384        let to = NaiveDate::from_ymd_opt(2026, 1, 26).unwrap();
385        assert_eq!(werktage_between(from, to), 10);
386    }
387
388    #[test]
389    fn test_werktage_between_negative() {
390        let from = NaiveDate::from_ymd_opt(2026, 1, 26).unwrap();
391        let to = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
392        assert_eq!(werktage_between(from, to), -10);
393    }
394
395    #[test]
396    fn test_werktage_between_same_day() {
397        let d = NaiveDate::from_ymd_opt(2026, 1, 5).unwrap();
398        assert_eq!(werktage_between(d, d), 0);
399    }
400
401    #[test]
402    fn test_parse_ccyymmdd_prefix_from_format_102() {
403        assert_eq!(
404            parse_ccyymmdd_prefix("20260115"),
405            Some(NaiveDate::from_ymd_opt(2026, 1, 15).unwrap())
406        );
407    }
408
409    #[test]
410    fn test_parse_ccyymmdd_prefix_from_format_303() {
411        // CCYYMMDDHHMM + TZ suffix (e.g., "202601151200+00") → date part only.
412        assert_eq!(
413            parse_ccyymmdd_prefix("202601151200+00"),
414            Some(NaiveDate::from_ymd_opt(2026, 1, 15).unwrap())
415        );
416    }
417
418    #[test]
419    fn test_parse_ccyymmdd_prefix_rejects_short() {
420        assert!(parse_ccyymmdd_prefix("2026").is_none());
421        assert!(parse_ccyymmdd_prefix("").is_none());
422    }
423
424    #[test]
425    fn test_parse_ccyymmdd_prefix_rejects_invalid() {
426        assert!(parse_ccyymmdd_prefix("20261315").is_none());
427    }
428}