Skip to main content

edifact_randomize/
generators.rs

1//! Deterministic ID generators for German energy market identifiers.
2//!
3//! Each generator takes a `seed: u64` and produces a valid identifier of the
4//! appropriate format. Same seed always produces the same output.
5
6/// Generate a Marktlokation ID: 11 digits, the last one the BDEW check digit.
7pub fn generate_malo_id(seed: u64) -> String {
8    // Use seed to derive 10 base digits
9    let base = seed % 10_000_000_000;
10    let base_str = format!("{base:010}");
11    let check = malo_check_digit(&base_str);
12    format!("{base_str}{check}")
13}
14
15/// Check digit of a Marktlokations-ID over its first ten digits (BDEW): the
16/// digits at odd positions (1-based, from the left) plus twice those at even
17/// positions, and the check digit completes that sum to a multiple of ten.
18/// Unlike the Luhn algorithm, doubled digits are not reduced to one digit.
19pub fn malo_check_digit(digits: &str) -> u8 {
20    let sum: u32 = digits
21        .chars()
22        .enumerate()
23        .map(|(i, c)| {
24            let d = c.to_digit(10).expect("input must be all digits");
25            if (i + 1) % 2 == 0 {
26                2 * d
27            } else {
28                d
29            }
30        })
31        .sum();
32    ((10 - (sum % 10)) % 10) as u8
33}
34
35/// Generate a Messlokation ID: "DE" + 31 digits = 33 chars total.
36pub fn generate_melo_id(seed: u64) -> String {
37    // Split the seed into parts to fill 31 digits
38    let part1 = seed % 10_000_000_000; // 10 digits
39    let part2 = (seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1)) % 10_000_000_000; // 10 digits
40    let part3 = (seed.wrapping_mul(2_862_933_555_777_941_757).wrapping_add(3)) % 100_000_000_000; // 11 digits
41    format!("DE{part1:010}{part2:010}{part3:011}")
42}
43
44/// Generate a Netzlokation ID: "E" + 10 digits = 11 chars.
45pub fn generate_nelo_id(seed: u64) -> String {
46    let digits = seed % 10_000_000_000;
47    format!("E{digits:010}")
48}
49
50/// Generate a Steuerbare Ressource ID: "C" + 10 digits = 11 chars.
51pub fn generate_steuress_id(seed: u64) -> String {
52    let digits = seed % 10_000_000_000;
53    format!("C{digits:010}")
54}
55
56/// Generate a Technische Ressource ID: "D" + 10 digits = 11 chars.
57pub fn generate_techress_id(seed: u64) -> String {
58    let digits = seed % 10_000_000_000;
59    format!("D{digits:010}")
60}
61
62/// Generate a Global Location Number (GLN): 13 digits with valid GS1 check digit.
63///
64/// The check digit uses the GS1 algorithm: alternating weights 1 and 3 from
65/// the rightmost position, sum mod 10, then (10 - sum % 10) % 10.
66pub fn generate_gln(seed: u64) -> String {
67    let base = seed % 1_000_000_000_000;
68    let base_str = format!("{base:012}");
69    let check = gs1_check_digit(&base_str);
70    format!("{base_str}{check}")
71}
72
73/// Generate an alphanumeric business reference ID safe for EDIFACT.
74///
75/// Uses only characters that don't conflict with EDIFACT delimiters
76/// (no `+`, `:`, `'`, or `?`).
77pub fn generate_reference_id(seed: u64) -> String {
78    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
79    let len = 12;
80    let mut result = Vec::with_capacity(len);
81    let mut state = seed;
82    for _ in 0..len {
83        // Simple LCG for deterministic pseudo-random selection
84        state = state
85            .wrapping_mul(6_364_136_223_846_793_005)
86            .wrapping_add(1);
87        let idx = (state >> 33) as usize % CHARSET.len();
88        result.push(CHARSET[idx]);
89    }
90    String::from_utf8(result).expect("charset is ASCII")
91}
92
93/// Compute a GS1 check digit for a string of ASCII digits.
94///
95/// Alternating weights 1 and 3 from the rightmost digit of the input,
96/// then check = (10 - sum % 10) % 10.
97fn gs1_check_digit(digits: &str) -> u8 {
98    let mut sum: u32 = 0;
99    for (i, ch) in digits.chars().rev().enumerate() {
100        let d = ch.to_digit(10).expect("input must be all digits");
101        let weight = if i % 2 == 0 { 3 } else { 1 };
102        sum += d * weight;
103    }
104    ((10 - (sum % 10)) % 10) as u8
105}
106
107/// Generate an interchange datum in YYMMDD format (used in UNB segment).
108pub fn generate_datum(seed: u64) -> String {
109    let (year, month, day) = compute_ymd(seed, seed % 730);
110    let year2 = year % 100;
111    format!("{year2:02}{month:02}{day:02}")
112}
113
114/// Generate an interchange time in HHMM format (used in UNB segment).
115pub fn generate_zeit(seed: u64) -> String {
116    let hour = seed % 24;
117    let s2 = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
118    let minute = (s2 >> 33) % 60;
119    format!("{hour:02}{minute:02}")
120}
121
122/// Generate a date in YYYY-MM-DD format (HTML5 date input format).
123pub fn generate_date_ymd(seed: u64) -> String {
124    let (year, month, day) = compute_ymd(seed, seed % 730);
125    format!("{year:04}-{month:02}-{day:02}")
126}
127
128/// Generate a date in CCYYMMDDHHMMSS+00 format (EDIFACT 303).
129pub fn generate_date_edifact303(seed: u64) -> String {
130    let (year, month, day) = compute_ymd(seed, seed % 730);
131    format!("{year:04}{month:02}{day:02}120000+00")
132}
133
134/// Generate a date in CCYYMMDD format (EDIFACT 102).
135pub fn generate_date_edifact102(seed: u64) -> String {
136    let (year, month, day) = compute_ymd(seed, seed % 730);
137    format!("{year:04}{month:02}{day:02}")
138}
139
140/// Compute (year, month, day) from 2024-01-01 + day_offset days.
141pub fn compute_ymd(seed: u64, day_offset: u64) -> (u64, u64, u64) {
142    let _ = seed;
143    let base_year = 2024u64;
144    let mut year = base_year;
145    let mut remaining = day_offset;
146    loop {
147        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
148        if remaining < days_in_year {
149            break;
150        }
151        remaining -= days_in_year;
152        year += 1;
153    }
154    let month_days = days_per_month(year);
155    let mut month = 1u64;
156    for &md in &month_days {
157        if remaining < md {
158            break;
159        }
160        remaining -= md;
161        month += 1;
162    }
163    let day = remaining + 1;
164    (year, month, day)
165}
166
167/// Return true if `year` is a leap year.
168pub fn is_leap_year(year: u64) -> bool {
169    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
170}
171
172/// Days per month for the given year (January = index 0).
173pub fn days_per_month(year: u64) -> [u64; 12] {
174    let feb = if is_leap_year(year) { 29 } else { 28 };
175    [31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_malo_id_format() {
184        let id = generate_malo_id(12345);
185        assert_eq!(id.len(), 11, "MaLo ID must be 11 chars, got: {id}");
186        assert!(
187            id.chars().all(|c| c.is_ascii_digit()),
188            "MaLo ID must be all digits, got: {id}"
189        );
190    }
191
192    #[test]
193    fn test_malo_id_deterministic() {
194        let a = generate_malo_id(42);
195        let b = generate_malo_id(42);
196        assert_eq!(a, b, "Same seed must produce same MaLo ID");
197    }
198
199    /// The BDEW rule, checked against Marktlokations-IDs from real messages
200    /// (`samples/55218_test.txt` carries `LOC+Z16+10023447486`). The standard
201    /// Luhn algorithm, which this generator used, gives other check digits:
202    /// most generated IDs failed the AHB's `[950] Format: Marktlokations-ID`.
203    #[test]
204    fn malo_check_digit_follows_the_bdew_rule() {
205        for real in ["51238696781", "10023447486"] {
206            assert_eq!(
207                malo_check_digit(&real[..10]),
208                real[10..].parse::<u8>().unwrap(),
209                "{real}"
210            );
211        }
212        for seed in [1, 42, 7_919, 12_345, 99_999, 134_623] {
213            let id = generate_malo_id(seed);
214            assert_eq!(malo_check_digit(&id[..10]).to_string(), &id[10..], "{id}");
215        }
216    }
217
218    #[test]
219    fn test_melo_id_format() {
220        let id = generate_melo_id(67890);
221        assert!(
222            id.starts_with("DE"),
223            "MeLo ID must start with DE, got: {id}"
224        );
225        assert_eq!(id.len(), 33, "MeLo ID must be 33 chars, got: {id}");
226        assert!(
227            id[2..].chars().all(|c| c.is_ascii_digit()),
228            "MeLo ID digits portion must be all digits, got: {id}"
229        );
230    }
231
232    #[test]
233    fn test_melo_id_deterministic() {
234        let a = generate_melo_id(42);
235        let b = generate_melo_id(42);
236        assert_eq!(a, b, "Same seed must produce same MeLo ID");
237    }
238
239    #[test]
240    fn test_nelo_id_format() {
241        let id = generate_nelo_id(11111);
242        assert_eq!(
243            id.chars().next().unwrap(),
244            'E',
245            "NeLo ID must start with E, got: {id}"
246        );
247        assert_eq!(id.len(), 11, "NeLo ID must be 11 chars, got: {id}");
248        assert!(
249            id[1..].chars().all(|c| c.is_ascii_digit()),
250            "NeLo ID digits portion must be all digits, got: {id}"
251        );
252    }
253
254    #[test]
255    fn test_steuress_id_format() {
256        let id = generate_steuress_id(22222);
257        assert_eq!(
258            id.chars().next().unwrap(),
259            'C',
260            "SteuRess ID must start with C, got: {id}"
261        );
262        assert_eq!(id.len(), 11, "SteuRess ID must be 11 chars, got: {id}");
263        assert!(
264            id[1..].chars().all(|c| c.is_ascii_digit()),
265            "SteuRess ID digits portion must be all digits, got: {id}"
266        );
267    }
268
269    #[test]
270    fn test_techress_id_format() {
271        let id = generate_techress_id(33333);
272        assert_eq!(
273            id.chars().next().unwrap(),
274            'D',
275            "TechRess ID must start with D, got: {id}"
276        );
277        assert_eq!(id.len(), 11, "TechRess ID must be 11 chars, got: {id}");
278        assert!(
279            id[1..].chars().all(|c| c.is_ascii_digit()),
280            "TechRess ID digits portion must be all digits, got: {id}"
281        );
282    }
283
284    #[test]
285    fn test_gln_format() {
286        let id = generate_gln(44444);
287        assert_eq!(id.len(), 13, "GLN must be 13 chars, got: {id}");
288        assert!(
289            id.chars().all(|c| c.is_ascii_digit()),
290            "GLN must be all digits, got: {id}"
291        );
292    }
293
294    #[test]
295    fn test_gln_valid_check_digit() {
296        for seed in [0, 1, 42, 999, 123456789, u64::MAX] {
297            let id = generate_gln(seed);
298            // Verify GS1 check: sum of (digit * weight) for all 13 digits should be divisible by 10
299            let mut sum: u32 = 0;
300            for (i, ch) in id.chars().rev().enumerate() {
301                let d = ch.to_digit(10).unwrap();
302                let weight = if i % 2 == 0 { 1 } else { 3 };
303                sum += d * weight;
304            }
305            assert_eq!(
306                sum % 10,
307                0,
308                "GLN {id} (seed={seed}) fails GS1 check digit validation: sum={sum}"
309            );
310        }
311    }
312
313    #[test]
314    fn test_gln_deterministic() {
315        let a = generate_gln(42);
316        let b = generate_gln(42);
317        assert_eq!(a, b, "Same seed must produce same GLN");
318    }
319
320    #[test]
321    fn test_reference_id_format() {
322        let id = generate_reference_id(55555);
323        assert!(!id.is_empty(), "Reference ID must not be empty");
324        assert!(
325            !id.contains('+'),
326            "Reference ID must not contain +, got: {id}"
327        );
328        assert!(
329            !id.contains(':'),
330            "Reference ID must not contain :, got: {id}"
331        );
332        assert!(
333            !id.contains('\''),
334            "Reference ID must not contain ', got: {id}"
335        );
336        assert!(
337            !id.contains('?'),
338            "Reference ID must not contain ?, got: {id}"
339        );
340    }
341
342    #[test]
343    fn test_reference_id_deterministic() {
344        let a = generate_reference_id(42);
345        let b = generate_reference_id(42);
346        assert_eq!(a, b, "Same seed must produce same reference ID");
347    }
348
349    #[test]
350    fn test_different_seeds_produce_different_ids() {
351        // Sanity check: different seeds should generally produce different output
352        let a = generate_malo_id(1);
353        let b = generate_malo_id(2);
354        assert_ne!(a, b, "Different seeds should produce different MaLo IDs");
355
356        let a = generate_gln(1);
357        let b = generate_gln(2);
358        assert_ne!(a, b, "Different seeds should produce different GLNs");
359
360        let a = generate_reference_id(1);
361        let b = generate_reference_id(2);
362        assert_ne!(
363            a, b,
364            "Different seeds should produce different reference IDs"
365        );
366    }
367
368    #[test]
369    fn test_generate_datum_format() {
370        let d = generate_datum(42);
371        assert_eq!(d.len(), 6, "datum must be 6 chars YYMMDD, got: {d}");
372        assert!(
373            d.chars().all(|c| c.is_ascii_digit()),
374            "datum must be digits"
375        );
376    }
377
378    #[test]
379    fn test_generate_zeit_format() {
380        let z = generate_zeit(42);
381        assert_eq!(z.len(), 4, "zeit must be 4 chars HHMM, got: {z}");
382        let hour: u64 = z[..2].parse().unwrap();
383        let min: u64 = z[2..].parse().unwrap();
384        assert!(hour < 24, "hour out of range: {hour}");
385        assert!(min < 60, "minute out of range: {min}");
386    }
387
388    #[test]
389    fn test_generate_date_ymd_format() {
390        let d = generate_date_ymd(42);
391        assert_eq!(d.len(), 10, "date_ymd must be YYYY-MM-DD, got: {d}");
392        assert!(d.contains('-'), "date_ymd must contain dashes");
393    }
394
395    #[test]
396    fn test_generate_date_edifact303_format() {
397        let d = generate_date_edifact303(42);
398        assert!(d.ends_with("+00"), "edifact303 must end with +00, got: {d}");
399        assert!(
400            d.len() >= 15,
401            "edifact303 must be at least 15 chars, got: {d}"
402        );
403    }
404
405    #[test]
406    fn test_generate_date_edifact102_format() {
407        let d = generate_date_edifact102(42);
408        assert_eq!(d.len(), 8, "edifact102 must be 8 chars CCYYMMDD, got: {d}");
409        assert!(
410            d.chars().all(|c| c.is_ascii_digit()),
411            "edifact102 must be digits"
412        );
413    }
414}