siderust 0.9.1

High-precision astronomy and satellite mechanics in Rust.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
//! # CRD SLR observation reader
//!
//! ## Scientific scope
//!
//! The Consolidated Laser Ranging Data (CRD) format is the primary interchange
//! format for Satellite Laser Ranging (SLR) observations published by the
//! International Laser Ranging Service (ILRS). This module supports CRD
//! versions 1 and 2 with both strict and permissive parse modes.
//!
//! The primary POD product is the **normal-point** record (type `11`), which
//! is a compressed, time-averaged two-way range observation. Station and target
//! metadata (H2/H3 header blocks) are captured in typed structs. Full-rate
//! records (type `10`) may appear in the input but are not exposed by the
//! typed public API.
//!
//! Station motion, atmospheric delay, range residual analysis, and any
//! corrections beyond what is in the file are outside this module.
//!
//! ## Technical scope
//!
//! Public entry points are [`read_crd`], [`parse_crd`], and
//! [`parse_crd_with_mode`]. All three return a [`CrdFile`] containing:
//!
//! - [`CrdStation`] / [`CrdTarget`] metadata from H2/H3.
//! - A [`Vec<NormalPoint>`] of typed normal-point records.
//!
//! The one-way slant range in [`NormalPoint::range_m`] is derived as
//! `c × TOF / 2` where `c = 299 792 458 m s⁻¹`.
//!
//! ## References
//!
//! - International Laser Ranging Service. (2022). Consolidated Laser
//!   Ranging Data Format Specification (v2.01).
//!   <https://ilrs.gsfc.nasa.gov/docs/2022/ILRS_CRD_Format_v2.01.pdf>
//! - Pearlman, M. R., Noll, C. E., et al. (2019). The ILRS: Current status
//!   and future prospects. Journal of Geodesy, 93, 2161–2180.
use super::{FileLocation, FormatError, ParseMode};
use chrono::{DateTime, NaiveDate, Utc as ChronoUtc};
use qtty::length::Meters;
use qtty::time::Seconds;
use std::fs;
use std::path::Path;
use tempoch::{Time, UTC};

/// Speed of light in m/s (IAU 2012 definition).
const SPEED_OF_LIGHT_M_S: f64 = 299_792_458.0;

// ── Station / Target metadata ─────────────────────────────────────────────────

/// Station metadata from the CRD H2 header record.
///
/// # Examples
///
/// ```
/// use siderust::formats::ilrs::crd::{CrdStation, parse_crd};
/// let txt = "H2 GRAZ 7839 1 1 0\nH8\n";
/// let f = parse_crd(txt).unwrap();
/// assert_eq!(f.station.name, "GRAZ");
/// assert_eq!(f.station.cdp_pad, 7839);
/// ```
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CrdStation {
    /// Station name (e.g. `"GRAZ"`).
    pub name: String,
    /// CDP Pad number.
    pub cdp_pad: i32,
    /// System number.
    pub sys_no: i32,
    /// Occupancy sequence number.
    pub occ_no: i32,
    /// Station time zone (hours from UTC, usually 0).
    pub time_zone: i32,
}

/// Target (satellite) metadata from the CRD H3 header record.
///
/// # Examples
///
/// ```
/// use siderust::formats::ilrs::crd::{CrdTarget, parse_crd};
/// let txt = "H3 lageos1 1155 7603901 0 1\nH8\n";
/// let f = parse_crd(txt).unwrap();
/// assert_eq!(f.target.name, "lageos1");
/// assert_eq!(f.target.sic, 1155);
/// ```
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CrdTarget {
    /// Target name (e.g. `"lageos1"`).
    pub name: String,
    /// Satellite Identification Code.
    pub sic: i32,
    /// COSPAR/NORAD-style identifier.
    pub norad: String,
    /// Spacecraft flag (0 = passive, 1 = active).
    pub sc_flag: i32,
    /// Epoch identifier.
    pub epoch_id: i32,
}

// ── Typed normal-point record ─────────────────────────────────────────────────

/// A typed normal-point observation (CRD record type `11`).
///
/// A normal point compresses many raw returns within a time bin into a single
/// smoothed two-way range measurement. This is the primary POD input product
/// for SLR stations.
///
/// The one-way slant range [`range_m`](NormalPoint::range_m) is computed as
/// `c × time_of_flight / 2`.
///
/// # Examples
///
/// ```
/// use siderust::formats::ilrs::crd::parse_crd;
///
/// let txt = "\
/// H1 CRD 2 2024 01 01 00\n\
/// H2 GRAZ 7839 1 1 0\n\
/// H3 lageos1 1155 7603901 0 1\n\
/// H4 0 2024 01 01 08 00 00 12 00 00 0 0 0 0 1 0\n\
/// C0 0 std 1 5 0 0 1 0 0 0 0 0 0 0\n\
/// 11 28800.0 0.05123456789 std 0 0 1 0 5 8.0 0.1 0.0 0.0 90.0\n\
/// H8\n";
/// let f = parse_crd(txt).unwrap();
/// assert_eq!(f.normal_points.len(), 1);
/// let np = &f.normal_points[0];
/// assert!((np.time_of_flight.value() - 0.05123456789).abs() < 1e-12);
/// // One-way range ≈ 7 678 km
/// assert!(np.range_m.value() > 7_000_000.0);
/// ```
#[derive(Debug, Clone)]
pub struct NormalPoint {
    /// Seconds of day (UTC) at the observation epoch.
    pub seconds_of_day: Seconds,
    /// UTC epoch (session date midnight + SOD). `None` if H4 was absent.
    pub epoch: Option<Time<UTC>>,
    /// Two-way time-of-flight in seconds.
    pub time_of_flight: Seconds,
    /// One-way slant range in metres (`= c × TOF / 2`).
    pub range_m: Meters,
    /// System configuration ID from the preceding C0 record.
    pub system_config_id: String,
    /// Epoch event (0 = centre, 1 = start, 2 = end of bin).
    pub epoch_event: u8,
    /// Filter / calibration flag.
    pub filter_flag: u8,
    /// Data quality indicator.
    pub data_quality: u8,
    /// Format flag.
    pub format_flag: u8,
    /// Number of raw ranges contributing to this normal point.
    pub num_raws: Option<u32>,
    /// RMS of raw two-way ranges within the bin (m).
    pub bin_rms_m: Option<Meters>,
    /// Normal-point bin size (s).
    pub bin_size_s: Option<Seconds>,
    /// Return rate within the bin (%).
    pub return_rate: Option<f64>,
}

// ── CrdFile ───────────────────────────────────────────────────────────────────

/// Parsed CRD file (header + observations).
///
/// Contains the high-level typed normal-point view exposed by
/// [`normal_points`](CrdFile::normal_points).
///
/// # Examples
///
/// ```
/// use siderust::formats::ilrs::crd::parse_crd;
/// let txt = "H2 GRAZ 7839 1 1 0\nH3 lageos1 1155 7603901 0 1\nH8\n";
/// let f = parse_crd(txt).unwrap();
/// assert_eq!(f.station_name, "GRAZ");
/// assert_eq!(f.target.sic, 1155);
/// ```
#[derive(Debug, Clone, Default)]
pub struct CrdFile {
    /// Station name — mirrors `station.name` for backward compatibility.
    pub station_name: String,
    /// CDP/Pad number — mirrors `station.cdp_pad`.
    pub station_cdp_pad: i32,
    /// Satellite name — mirrors `target.name`.
    pub satellite_name: String,
    /// SIC — mirrors `target.sic`.
    pub satellite_sic: i32,
    /// NORAD/COSPAR ID — mirrors `target.norad`.
    pub satellite_norad: String,
    /// Session start (UTC midnight) from the H4 record.
    pub session_date: Option<Time<UTC>>,
    /// CRD format version string from H1 (e.g. `"2"`).
    pub format_version: String,
    /// Typed station metadata.
    pub station: CrdStation,
    /// Typed target (satellite) metadata.
    pub target: CrdTarget,
    /// Normal-point records (type 11) — primary POD product.
    pub normal_points: Vec<NormalPoint>,
    /// Parse mode used.
    pub parse_mode: ParseMode,
}

// ── Public entry points ───────────────────────────────────────────────────────

/// Read a CRD file from disk using [`ParseMode::Strict`].
///
/// # Examples
///
/// ```no_run
/// use siderust::formats::ilrs::crd::read_crd;
/// let f = read_crd("station.crd").unwrap();
/// println!("{} normal points", f.normal_points.len());
/// ```
pub fn read_crd<P: AsRef<Path>>(path: P) -> Result<CrdFile, FormatError> {
    let text = fs::read_to_string(path.as_ref())?;
    parse_crd_impl(&text, ParseMode::Strict, Some(path.as_ref().into()))
}

/// Parse a CRD string using [`ParseMode::Strict`].
///
/// # Examples
///
/// ```
/// use siderust::formats::ilrs::crd::parse_crd;
/// let f = parse_crd("H2 GRAZ 7839 1 1 0\nH8\n").unwrap();
/// assert_eq!(f.station_name, "GRAZ");
/// ```
pub fn parse_crd(text: &str) -> Result<CrdFile, FormatError> {
    parse_crd_impl(text, ParseMode::Strict, None)
}

/// Parse a CRD string with an explicit [`ParseMode`].
///
/// In `Strict` mode, a malformed range record (missing SOD or TOF) is a hard
/// error. In `Permissive` mode, such records are silently skipped.
///
/// # Examples
///
/// ```
/// use siderust::formats::{ParseMode, ilrs::crd::parse_crd_with_mode};
/// // Permissive: a broken "11" record is skipped rather than failing.
/// let txt = "H2 GRAZ 7839 1 1 0\nH3 lageos1 1155 7603901 0 1\n11 BROKEN\nH8\n";
/// let f = parse_crd_with_mode(txt, ParseMode::Permissive).unwrap();
/// assert_eq!(f.normal_points.len(), 0); // skipped
/// ```
pub fn parse_crd_with_mode(text: &str, mode: ParseMode) -> Result<CrdFile, FormatError> {
    parse_crd_impl(text, mode, None)
}

// ── Internal parser ───────────────────────────────────────────────────────────

fn parse_crd_impl(
    text: &str,
    mode: ParseMode,
    path: Option<std::path::PathBuf>,
) -> Result<CrdFile, FormatError> {
    let mut out = CrdFile {
        parse_mode: mode,
        ..Default::default()
    };
    let mut current_sys = String::from("std");
    let mut h4_date: Option<(i32, u32, u32)> = None;

    for (line_idx, raw) in text.lines().enumerate() {
        let line_no = line_idx + 1;
        let line = raw.trim_end();
        if line.is_empty() {
            continue;
        }
        let mut tokens = line.split_whitespace();
        let tag = match tokens.next() {
            Some(t) => t,
            None => continue,
        };

        match tag {
            "H1" => {
                // H1 CRD <version> <year> <month> <day> <hour>
                let _fmt = tokens.next(); // "CRD"
                if let Some(v) = tokens.next() {
                    out.format_version = v.to_string();
                }
            }
            "H2" => {
                // H2 <station_name> <cdp_pad> <sys_no> <occ_no> <time_zone>
                let name = tokens.next().unwrap_or("").to_string();
                let cdp_pad = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
                let sys_no = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
                let occ_no = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
                let time_zone = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
                out.station = CrdStation {
                    name: name.clone(),
                    cdp_pad,
                    sys_no,
                    occ_no,
                    time_zone,
                };
                out.station_name = name;
                out.station_cdp_pad = cdp_pad;
            }
            "H3" => {
                // H3 <target_name> <sic> <norad> <sc_flag> <epoch_id>
                let name = tokens.next().unwrap_or("").to_string();
                let sic: i32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
                let norad = tokens.next().unwrap_or("").to_string();
                let sc_flag: i32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
                let epoch_id: i32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
                out.target = CrdTarget {
                    name: name.clone(),
                    sic,
                    norad: norad.clone(),
                    sc_flag,
                    epoch_id,
                };
                out.satellite_name = name;
                out.satellite_sic = sic;
                out.satellite_norad = norad;
            }
            "H4" => {
                // H4 <type> <year> <month> <day> <hour> <min> <sec> ...
                let _kind = tokens.next();
                let year: i32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
                let month: u32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
                let day: u32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
                if let Some(naive) =
                    NaiveDate::from_ymd_opt(year, month, day).and_then(|d| d.and_hms_opt(0, 0, 0))
                {
                    let dt = DateTime::from_naive_utc_and_offset(naive, ChronoUtc);
                    if let Ok(t) = Time::<UTC>::try_from_chrono(dt) {
                        out.session_date = Some(t);
                        h4_date = Some((year, month, day));
                    }
                }
            }
            "H5" | "H8" | "H9" => { /* session/file end markers — ignored */ }
            "C0" => {
                // C0 <detail_type> <config_id> ...
                let _detail = tokens.next();
                if let Some(s) = tokens.next() {
                    current_sys = s.to_string();
                }
            }
            "C1" | "C2" | "C3" | "C4" => { /* other config records — skipped */ }
            "10" | "11" => {
                let record_type: u8 = if tag == "11" { 11 } else { 10 };

                let sod_str = tokens.next();
                let tof_str = tokens.next();

                let (sod, tof) = match (
                    sod_str.and_then(|s| s.parse::<f64>().ok()),
                    tof_str.and_then(|s| s.parse::<f64>().ok()),
                ) {
                    (Some(s), Some(t)) => (s, t),
                    (None, _) => {
                        let loc = FileLocation::new(path.clone(), Some(line_no), None);
                        let err = FormatError::located(
                            "CRD v2 §4.1",
                            loc,
                            format!("record {tag}: missing seconds-of-day"),
                        );
                        if mode == ParseMode::Strict {
                            return Err(err);
                        }
                        continue;
                    }
                    (_, None) => {
                        let loc = FileLocation::new(path.clone(), Some(line_no), None);
                        let err = FormatError::located(
                            "CRD v2 §4.1",
                            loc,
                            format!("record {tag}: missing time-of-flight"),
                        );
                        if mode == ParseMode::Strict {
                            return Err(err);
                        }
                        continue;
                    }
                };

                // Gather remaining optional fields for normal points.
                let rest: Vec<&str> = tokens.collect();

                if record_type == 11 {
                    // Build epoch directly from H4 date + SOD components to
                    // avoid floating-point precision loss in the JD round-trip.
                    let epoch = h4_date.and_then(|(y, mo, d)| {
                        let whole = sod as i64;
                        let subsec_nanos = ((sod - whole as f64) * 1_000_000_000.0).round() as u32;
                        let hh = (whole / 3600) as u32;
                        let mm = ((whole % 3600) / 60) as u32;
                        let ss = (whole % 60) as u32;
                        let naive = NaiveDate::from_ymd_opt(y, mo, d)
                            .and_then(|nd| nd.and_hms_nano_opt(hh, mm, ss, subsec_nanos))?;
                        let dt = DateTime::from_naive_utc_and_offset(naive, ChronoUtc);
                        Time::<UTC>::try_from_chrono(dt).ok()
                    });
                    let range_m = Meters::new(tof * SPEED_OF_LIGHT_M_S / 2.0);

                    // Parse optional fields from the "11" record.
                    // CRD v2 §4.1 record-11 field order (after TOF):
                    // sys_cfg_id epoch_event filter_flag data_quality
                    // format_flag num_raws bin_rms(ps) bin_skew bin_kurtosis
                    // bin_peak return_rate [detector_channel]
                    let sys_id = rest
                        .first()
                        .map(|s| s.to_string())
                        .unwrap_or_else(|| current_sys.clone());
                    let epoch_event: u8 = rest.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
                    let filter_flag: u8 = rest.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
                    let data_quality: u8 = rest.get(3).and_then(|s| s.parse().ok()).unwrap_or(0);
                    let format_flag: u8 = rest.get(4).and_then(|s| s.parse().ok()).unwrap_or(0);
                    let num_raws: Option<u32> = rest.get(5).and_then(|s| s.parse().ok());
                    // bin_rms is in picoseconds in the file; convert to metres.
                    let bin_rms_m: Option<Meters> = rest
                        .get(6)
                        .and_then(|s| s.parse::<f64>().ok())
                        .map(|ps| Meters::new(ps * 1e-12 * SPEED_OF_LIGHT_M_S / 2.0));
                    let return_rate: Option<f64> = rest.get(9).and_then(|s| s.parse().ok());

                    out.normal_points.push(NormalPoint {
                        seconds_of_day: Seconds::new(sod),
                        epoch,
                        time_of_flight: Seconds::new(tof),
                        range_m,
                        system_config_id: sys_id,
                        epoch_event,
                        filter_flag,
                        data_quality,
                        format_flag,
                        num_raws,
                        bin_rms_m,
                        bin_size_s: None,
                        return_rate,
                    });
                }
            }
            // Records 12–52: supplements/met/pointing/cal/stats — skip.
            "12" | "20" | "21" | "30" | "40" | "41" | "50" | "60" => {}
            _ => { /* unknown tag — silently skip in both modes */ }
        }
    }

    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::ParseMode;
    use super::*;

    #[test]
    fn parses_minimal_crd() {
        let txt = "\
H1 CRD 2 2024 01 01 00\n\
H2 7090 7090 1 01 0\n\
H3 lageos1 1155 7603901 8820 1\n\
H4 0 2024 01 01 00 00 00 00 00 00 0 0 0 0 1 0\n\
C0 0 std\n\
11 12345.000 0.05123456789 std 1 1 1 1 0 0\n\
11 12375.000 0.05123446712 std 1 1 1 1 0 0\n\
H8\n";
        let f = parse_crd(txt).expect("parse");
        assert_eq!(f.station_name, "7090");
        assert_eq!(f.satellite_name, "lageos1");
        use chrono::Datelike;
        let d = f.session_date.unwrap().try_to_chrono().unwrap();
        assert_eq!(d.year(), 2024);
        assert_eq!(d.month(), 1);
        assert_eq!(d.day(), 1);
        assert_eq!(f.normal_points.len(), 2);
        assert!((f.normal_points[0].time_of_flight.value() - 0.05123456789).abs() < 1e-15);
    }

    #[test]
    fn normal_points_populated() {
        let txt = "\
H1 CRD 2 2024 01 01 00\n\
H2 GRAZ 7839 1 1 0\n\
H3 lageos1 1155 7603901 0 1\n\
H4 0 2024 01 01 08 00 00 12 00 00 0 0 0 0 1 0\n\
C0 0 std 1 5 0 0 1 0 0 0 0 0 0 0\n\
11 28800.0 0.051234567890 std 0 0 1 0 5 8.0 0.0 0.0 0.0 90.0\n\
11 28830.0 0.051198765432 std 0 0 1 0 5 7.5 0.0 0.0 0.0 88.0\n\
H8\n";
        let f = parse_crd(txt).expect("parse");
        assert_eq!(f.normal_points.len(), 2);
        let np = &f.normal_points[0];
        assert!((np.time_of_flight.value() - 0.051234567890).abs() < 1e-12);
        // Range ≈ c * TOF / 2
        let expected_m = 0.051234567890 * SPEED_OF_LIGHT_M_S / 2.0;
        assert!((np.range_m.value() - expected_m).abs() < 1.0);
        assert_eq!(np.num_raws, Some(5));
    }

    #[test]
    fn epoch_computed_from_session_date() {
        let txt = "\
H1 CRD 2 2024 01 01 00\n\
H2 GRAZ 7839 1 1 0\n\
H3 lageos1 1155 7603901 0 1\n\
H4 0 2024 01 01 08 00 00 12 00 00 0 0 0 0 1 0\n\
11 3600.0 0.051 std 0 0 0 0\n\
H8\n";
        let f = parse_crd(txt).expect("parse");
        let np = &f.normal_points[0];
        assert!(np.epoch.is_some(), "epoch should be computed from H4 date");
        use chrono::Timelike;
        let dt = np.epoch.unwrap().try_to_chrono().unwrap();
        assert_eq!(dt.hour(), 1); // 3600 s after midnight = 01:00
    }

    #[test]
    fn station_and_target_populated() {
        let txt = "\
H2 GRAZ 7839 1 2 0\n\
H3 lageos1 1155 7603901 0 1\n\
H8\n";
        let f = parse_crd(txt).expect("parse");
        assert_eq!(f.station.name, "GRAZ");
        assert_eq!(f.station.cdp_pad, 7839);
        assert_eq!(f.station.sys_no, 1);
        assert_eq!(f.station.occ_no, 2);
        assert_eq!(f.target.name, "lageos1");
        assert_eq!(f.target.sic, 1155);
        assert_eq!(f.target.norad, "7603901");
    }

    #[test]
    fn format_version_parsed() {
        let txt = "H1 CRD 2 2024 01 01 00\nH8\n";
        let f = parse_crd(txt).expect("parse");
        assert_eq!(f.format_version, "2");
    }

    #[test]
    fn strict_missing_sod_fails() {
        let txt = "H2 S 0 0 0 0\n11\nH8\n";
        let err = parse_crd(txt).expect_err("strict: missing SOD");
        assert!(matches!(err, FormatError::Located { .. }));
    }

    #[test]
    fn permissive_missing_sod_skips() {
        let txt = "H2 S 0 0 0 0\n11\nH8\n";
        let f = parse_crd_with_mode(txt, ParseMode::Permissive).expect("permissive ok");
        assert_eq!(f.normal_points.len(), 0);
    }

    #[test]
    fn strict_missing_tof_fails() {
        let txt = "H2 S 0 0 0 0\n11 123.0\nH8\n";
        let err = parse_crd(txt).expect_err("strict: missing TOF");
        assert!(matches!(err, FormatError::Located { .. }));
    }

    #[test]
    fn permissive_missing_tof_skips() {
        let txt = "H2 S 0 0 0 0\n11 123.0\nH8\n";
        let f = parse_crd_with_mode(txt, ParseMode::Permissive).expect("permissive ok");
        assert_eq!(f.normal_points.len(), 0);
    }

    #[test]
    fn full_rate_records_are_not_exposed_as_normal_points() {
        let txt = "H2 S 0 0 0 0\n10 100.0 0.08 std\nH8\n";
        let f = parse_crd(txt).expect("parse full-rate");
        assert_eq!(f.normal_points.len(), 0);
    }
}