Skip to main content

sidereon_core/astro/
space_weather.rs

1//! CelesTrak CSSI space-weather parsing and NRLMSISE-00 lookup.
2//!
3//! The parser is sans-IO: callers supply bytes or text from the CelesTrak
4//! CSV or fixed-width product. Lookups return the existing drag input type used
5//! by the NRLMSISE-00 drag path.
6//!
7//! References:
8//! - Picone, Hedin, Drob, and Aikin, "NRLMSISE-00 empirical model of the
9//!   atmosphere", JGR 107(A12), 2002.
10//! - CelesTrak CSSI space-weather format:
11//!   <https://celestrak.org/SpaceData/SpaceWx-format.php>.
12
13use crate::astro::atmosphere::{ApArray, DEFAULT_AP};
14use crate::astro::constants::time::SECONDS_PER_DAY_I64;
15use crate::astro::forces::SpaceWeather;
16use crate::astro::time::civil::{
17    civil_from_julian_day_number, days_in_month, j2000_seconds, J2000_JULIAN_DAY_NUMBER,
18    J2000_NOON_OFFSET_S,
19};
20use crate::astro::time::scales::julian_day_number;
21use crate::format::columns;
22pub use crate::format::{Diagnostics, Parsed, RecordRef, Skip, SkipReason, Warning, WarningKind};
23use crate::validate;
24use crate::validate::FieldError;
25use std::fmt::Write as _;
26
27const CSV_HEADER: &str = "DATE,BSRN,ND,KP1,KP2,KP3,KP4,KP5,KP6,KP7,KP8,KP_SUM,AP1,AP2,AP3,AP4,AP5,AP6,AP7,AP8,AP_AVG,CP,C9,ISN,F10.7_OBS,F10.7_ADJ,F10.7_DATA_TYPE,F10.7_OBS_CENTER81,F10.7_OBS_LAST81,F10.7_ADJ_CENTER81,F10.7_ADJ_LAST81";
28const TXT_HEADER_COMMENTS: &str = "\
29# --------------------------------------------------------------------------------------------------------------------------------\n\
30#                              SPACE WEATHER DATA\n\
31# --------------------------------------------------------------------------------------------------------------------------------\n\
32#\n\
33# See https://celestrak.org/SpaceData/SpaceWx-format.php for format details.\n\
34#\n\
35# FORMAT(I4,I3,I3,I5,I3,8I3,I4,8I4,I4,F4.1,I2,I4,F6.1,I2,5F6.1)\n\
36# --------------------------------------------------------------------------------------------------------------------------------\n\
37#                                                                                             Adj     Adj   Adj   Obs   Obs   Obs \n\
38# yy mm dd BSRN ND Kp Kp Kp Kp Kp Kp Kp Kp Sum Ap  Ap  Ap  Ap  Ap  Ap  Ap  Ap  Avg Cp C9 ISN F10.7 Q Ctr81 Lst81 F10.7 Ctr81 Lst81\n\
39# --------------------------------------------------------------------------------------------------------------------------------\n\
40#";
41
42/// Provenance class of one daily CelesTrak space-weather row.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
44pub enum ObservationClass {
45    /// Observed row (`OBS` in CSV, `OBSERVED` txt section with qualifier other than 4).
46    Observed,
47    /// Source-interpolated observed row (`INT` in CSV, `OBSERVED` txt with qualifier 4).
48    Interpolated,
49    /// Daily predicted row (`PRD` in CSV, `DAILY_PREDICTED` txt section).
50    DailyPredicted,
51    /// Monthly predicted row (`PRM` in CSV, `MONTHLY_PREDICTED` txt section).
52    MonthlyPredicted,
53}
54
55/// One CelesTrak CSSI daily space-weather record.
56///
57/// Fields are stored at source quantization so serializers can reproduce the
58/// product widths and decimal precision. Missing source fields are `None`.
59#[derive(Debug, Clone, Copy, PartialEq)]
60pub struct SpaceWeatherDay {
61    /// UTC civil year.
62    pub year: i32,
63    /// UTC civil month in `1..=12`.
64    pub month: u8,
65    /// UTC civil day of month.
66    pub day: u8,
67    /// Row provenance and prediction class.
68    pub class: ObservationClass,
69    /// Bartels solar rotation number.
70    pub bsrn: Option<u16>,
71    /// Day within the 27-day Bartels rotation.
72    pub nd: Option<u8>,
73    /// Kp per 3-hour UT bin, stored as source Kp times 10.
74    pub kp_10: [Option<u16>; 8],
75    /// Daily sum of the eight stored Kp values.
76    pub kp_sum_10: Option<u16>,
77    /// Planetary equivalent amplitude `ap` per 3-hour UT bin.
78    pub ap: [Option<u16>; 8],
79    /// Daily Ap index (`AP_AVG`).
80    pub ap_avg: Option<u16>,
81    /// Planetary daily character figure Cp, stored times 10.
82    pub cp_10: Option<u8>,
83    /// C9 index.
84    pub c9: Option<u8>,
85    /// International sunspot number.
86    pub isn: Option<u16>,
87    /// Fixed-width text flux qualifier; absent in CSV.
88    pub flux_qualifier: Option<u8>,
89    /// Observed F10.7 flux at actual Earth-Sun distance, sfu.
90    pub f107_obs: Option<f64>,
91    /// F10.7 flux adjusted to 1 AU, sfu.
92    pub f107_adj: Option<f64>,
93    /// Centered 81-day average of observed F10.7, sfu.
94    pub f107_obs_center81: Option<f64>,
95    /// Trailing 81-day average of observed F10.7, sfu.
96    pub f107_obs_last81: Option<f64>,
97    /// Centered 81-day average of adjusted F10.7, sfu.
98    pub f107_adj_center81: Option<f64>,
99    /// Trailing 81-day average of adjusted F10.7, sfu.
100    pub f107_adj_last81: Option<f64>,
101}
102
103impl SpaceWeatherDay {
104    /// Kp for one 3-hour bin as the physical Kp value, not times 10.
105    pub fn kp(&self, bin: usize) -> Option<f64> {
106        self.kp_10
107            .get(bin)
108            .and_then(|v| v.map(|v| f64::from(v) / 10.0))
109    }
110
111    /// Cp as the physical one-decimal value.
112    pub fn cp(&self) -> Option<f64> {
113        self.cp_10.map(|v| f64::from(v) / 10.0)
114    }
115
116    fn jdn(&self) -> i64 {
117        julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
118    }
119}
120
121/// Time-indexed CelesTrak CSSI space-weather table.
122#[derive(Debug, Clone, PartialEq)]
123pub struct SpaceWeatherTable {
124    days: Vec<SpaceWeatherDay>,
125    monthly: Vec<SpaceWeatherDay>,
126    txt_updated: Option<String>,
127}
128
129/// Coverage summary for a parsed space-weather table.
130#[derive(Debug, Clone, Copy, PartialEq)]
131pub struct SpaceWeatherCoverage {
132    /// Start of the first covered UTC day, as J2000 seconds.
133    pub first_j2000_s: f64,
134    /// Start of the last observed daily row, as J2000 seconds.
135    pub last_observed_j2000_s: Option<f64>,
136    /// Start of the last daily-predicted row, as J2000 seconds.
137    pub last_daily_predicted_j2000_s: Option<f64>,
138    /// End of the last covered day overall, as an exclusive J2000-second bound.
139    pub end_j2000_s: f64,
140}
141
142/// Errors from parsing CelesTrak space-weather products or looking up samples.
143#[derive(Debug, Clone, PartialEq, thiserror::Error)]
144pub enum SpaceWeatherError {
145    /// Input is neither the CSV schema nor the CSSI fixed-width text schema.
146    #[error("unrecognized space-weather format")]
147    UnrecognizedFormat,
148    /// Structural failure that prevents a table from being built.
149    #[error("malformed space-weather input at line {line}: {reason}")]
150    Malformed { line: usize, reason: String },
151    /// Input bytes are not UTF-8 text.
152    #[error("space-weather input is not valid UTF-8")]
153    NotText,
154    /// Lookup before the table can supply required rows.
155    #[error("space-weather lookup before coverage")]
156    BeforeCoverage {
157        /// Requested epoch as J2000 seconds.
158        requested_j2000_s: f64,
159        /// Start of table coverage as J2000 seconds.
160        first_j2000_s: f64,
161    },
162    /// Lookup at or beyond the table's exclusive coverage end.
163    #[error("space-weather lookup after coverage")]
164    AfterCoverage {
165        /// Requested epoch as J2000 seconds.
166        requested_j2000_s: f64,
167        /// End of table coverage as J2000 seconds.
168        end_j2000_s: f64,
169    },
170    /// A covered day or required field is missing.
171    #[error("space-weather data missing {field} on {year:04}-{month:02}-{day:02}")]
172    MissingData {
173        /// UTC civil year.
174        year: i32,
175        /// UTC civil month.
176        month: u8,
177        /// UTC civil day.
178        day: u8,
179        /// Missing field name.
180        field: &'static str,
181    },
182    /// Lookup policy rejected a row class.
183    #[error("space-weather row class rejected by policy")]
184    RejectedByPolicy {
185        /// Rejected row class.
186        class: ObservationClass,
187        /// UTC civil year.
188        year: i32,
189        /// UTC civil month.
190        month: u8,
191        /// UTC civil day.
192        day: u8,
193    },
194    /// Lookup epoch was non-finite or outside the representable civil range.
195    #[error("invalid space-weather epoch")]
196    InvalidEpoch {
197        /// `f64::to_bits()` of the rejected epoch.
198        epoch_j2000_s_bits: u64,
199    },
200}
201
202/// NRLMSISE-00 drag input plus metadata about the consulted rows.
203#[derive(Debug, Clone, Copy, PartialEq)]
204pub struct SpaceWeatherSample {
205    /// Space-weather values to feed the drag path.
206    pub space_weather: SpaceWeather,
207    /// Least-trusted class among the rows consulted.
208    pub class: ObservationClass,
209    /// True when monthly-predicted geomagnetic data defaulted to quiet Ap.
210    pub ap_defaulted: bool,
211}
212
213/// Lookup policy for rejecting lower-trust rows or monthly Ap defaults.
214#[derive(Debug, Clone, Copy, PartialEq)]
215pub struct SpaceWeatherPolicy {
216    /// Permit source-interpolated observed rows.
217    pub allow_interpolated: bool,
218    /// Permit daily predicted rows.
219    pub allow_daily_predicted: bool,
220    /// Permit monthly predicted rows.
221    pub allow_monthly_predicted: bool,
222    /// Reject monthly rows instead of using the quiet geomagnetic default.
223    pub require_geomagnetic: bool,
224}
225
226impl Default for SpaceWeatherPolicy {
227    fn default() -> Self {
228        Self {
229            allow_interpolated: true,
230            allow_daily_predicted: true,
231            allow_monthly_predicted: true,
232            require_geomagnetic: false,
233        }
234    }
235}
236
237impl SpaceWeatherTable {
238    /// All daily-resolution rows, sorted by date.
239    pub fn days(&self) -> &[SpaceWeatherDay] {
240        &self.days
241    }
242
243    /// All monthly-predicted rows, sorted by date.
244    pub fn monthly(&self) -> &[SpaceWeatherDay] {
245        &self.monthly
246    }
247
248    /// Row for a UTC civil date, including monthly holdover when applicable.
249    pub fn day(&self, year: i32, month: u8, day: u8) -> Option<&SpaceWeatherDay> {
250        let jdn = julian_day_number(year, i32::from(month), i32::from(day));
251        self.day_by_jdn(jdn)
252    }
253
254    /// Summary of the dates covered by this table.
255    pub fn coverage(&self) -> SpaceWeatherCoverage {
256        let first_jdn = self.first_jdn().expect("nonempty table");
257        let end_jdn = self.end_jdn().expect("nonempty table");
258        SpaceWeatherCoverage {
259            first_j2000_s: day_start_j2000_s(first_jdn),
260            last_observed_j2000_s: self
261                .days
262                .iter()
263                .rfind(|row| matches!(row.class, ObservationClass::Observed))
264                .map(|row| day_start_j2000_s(row.jdn())),
265            last_daily_predicted_j2000_s: self
266                .days
267                .iter()
268                .rfind(|row| matches!(row.class, ObservationClass::DailyPredicted))
269                .map(|row| day_start_j2000_s(row.jdn())),
270            end_j2000_s: day_start_j2000_s(end_jdn),
271        }
272    }
273
274    /// NRLMSISE-00 conventional space-weather inputs at an epoch.
275    ///
276    /// `epoch_j2000_s` is treated as UT seconds on the same convention used by
277    /// the drag module for `CartesianState::epoch_tdb_seconds`; the TDB-UT
278    /// difference is below one minute against daily and 3-hour source data.
279    pub fn space_weather_at(&self, epoch_j2000_s: f64) -> Result<SpaceWeather, SpaceWeatherError> {
280        self.sample_at(epoch_j2000_s)
281            .map(|sample| sample.space_weather)
282    }
283
284    /// Lookup a sample with default policy, returning values plus row metadata.
285    pub fn sample_at(&self, epoch_j2000_s: f64) -> Result<SpaceWeatherSample, SpaceWeatherError> {
286        self.sample_at_with_policy(epoch_j2000_s, SpaceWeatherPolicy::default())
287    }
288
289    /// Lookup a sample with explicit row-class and geomagnetic-default policy.
290    pub fn sample_at_with_policy(
291        &self,
292        epoch_j2000_s: f64,
293        policy: SpaceWeatherPolicy,
294    ) -> Result<SpaceWeatherSample, SpaceWeatherError> {
295        let jdn = epoch_day_jdn(epoch_j2000_s)?;
296        self.check_epoch_coverage(epoch_j2000_s, jdn, true)?;
297
298        let today = self.required_day(jdn, epoch_j2000_s)?;
299        let previous = self.required_day(jdn - 1, epoch_j2000_s)?;
300        enforce_policy(today, policy)?;
301        enforce_policy(previous, policy)?;
302
303        let f107 = previous
304            .f107_obs
305            .ok_or_else(|| missing(previous, "F10.7_OBS"))?;
306        let f107a = today
307            .f107_obs_center81
308            .ok_or_else(|| missing(today, "F10.7_OBS_CENTER81"))?;
309        let (ap, ap_defaulted) = daily_ap(today, policy)?;
310
311        Ok(SpaceWeatherSample {
312            space_weather: SpaceWeather { f107, f107a, ap },
313            class: today.class.max(previous.class),
314            ap_defaulted,
315        })
316    }
317
318    /// Build the NRLMSISE-00 seven-element Ap history array at an epoch.
319    pub fn ap_array_at(&self, epoch_j2000_s: f64) -> Result<ApArray, SpaceWeatherError> {
320        let (jdn, bin) = epoch_day_and_ap_bin(epoch_j2000_s)?;
321        self.check_epoch_coverage(epoch_j2000_s, jdn, false)?;
322        let today = self.required_day(jdn, epoch_j2000_s)?;
323        let (daily, _) = daily_ap(today, SpaceWeatherPolicy::default())?;
324        let slot = jdn * 8 + i64::from(bin);
325
326        Ok([
327            daily,
328            self.ap_slot(slot, epoch_j2000_s)?,
329            self.ap_slot(slot - 1, epoch_j2000_s)?,
330            self.ap_slot(slot - 2, epoch_j2000_s)?,
331            self.ap_slot(slot - 3, epoch_j2000_s)?,
332            self.mean_ap_slots(slot - 11, slot - 4, epoch_j2000_s)?,
333            self.mean_ap_slots(slot - 19, slot - 12, epoch_j2000_s)?,
334        ])
335    }
336
337    fn day_by_jdn(&self, jdn: i64) -> Option<&SpaceWeatherDay> {
338        if let Ok(index) = self.days.binary_search_by_key(&jdn, SpaceWeatherDay::jdn) {
339            return self.days.get(index);
340        }
341        let index = self
342            .monthly
343            .binary_search_by_key(&jdn, SpaceWeatherDay::jdn)
344            .unwrap_or_else(|index| index.saturating_sub(1));
345        let row = self.monthly.get(index)?;
346        let (year, month, _day) = civil_from_julian_day_number(jdn);
347        if row.year == year as i32 && row.month == month as u8 {
348            Some(row)
349        } else {
350            None
351        }
352    }
353
354    fn required_day(
355        &self,
356        jdn: i64,
357        requested_j2000_s: f64,
358    ) -> Result<&SpaceWeatherDay, SpaceWeatherError> {
359        self.day_by_jdn(jdn).ok_or_else(|| {
360            if jdn < self.first_jdn().expect("nonempty table") {
361                SpaceWeatherError::BeforeCoverage {
362                    requested_j2000_s,
363                    first_j2000_s: self.coverage().first_j2000_s,
364                }
365            } else if jdn >= self.end_jdn().expect("nonempty table") {
366                SpaceWeatherError::AfterCoverage {
367                    requested_j2000_s,
368                    end_j2000_s: self.coverage().end_j2000_s,
369                }
370            } else {
371                let (year, month, day) = civil_from_julian_day_number(jdn);
372                SpaceWeatherError::MissingData {
373                    year: year as i32,
374                    month: month as u8,
375                    day: day as u8,
376                    field: "record",
377                }
378            }
379        })
380    }
381
382    fn check_epoch_coverage(
383        &self,
384        requested_j2000_s: f64,
385        jdn: i64,
386        needs_previous_day: bool,
387    ) -> Result<(), SpaceWeatherError> {
388        let first_jdn = self.first_jdn().expect("nonempty table");
389        let end_jdn = self.end_jdn().expect("nonempty table");
390        let required_first = if needs_previous_day {
391            first_jdn + 1
392        } else {
393            first_jdn
394        };
395        if jdn < required_first {
396            return Err(SpaceWeatherError::BeforeCoverage {
397                requested_j2000_s,
398                first_j2000_s: self.coverage().first_j2000_s,
399            });
400        }
401        if jdn >= end_jdn {
402            return Err(SpaceWeatherError::AfterCoverage {
403                requested_j2000_s,
404                end_j2000_s: day_start_j2000_s(end_jdn),
405            });
406        }
407        Ok(())
408    }
409
410    fn ap_slot(&self, slot: i64, requested_j2000_s: f64) -> Result<f64, SpaceWeatherError> {
411        let jdn = slot.div_euclid(8);
412        let bin = slot.rem_euclid(8) as usize;
413        let row = self.required_day(jdn, requested_j2000_s)?;
414        if let Some(ap) = row.ap[bin] {
415            return Ok(f64::from(ap));
416        }
417        daily_ap(row, SpaceWeatherPolicy::default()).map(|(ap, _)| ap)
418    }
419
420    fn mean_ap_slots(
421        &self,
422        first_slot: i64,
423        last_slot: i64,
424        requested_j2000_s: f64,
425    ) -> Result<f64, SpaceWeatherError> {
426        let mut sum = 0.0;
427        let mut count = 0.0;
428        for slot in first_slot..=last_slot {
429            sum += self.ap_slot(slot, requested_j2000_s)?;
430            count += 1.0;
431        }
432        Ok(sum / count)
433    }
434
435    fn first_jdn(&self) -> Option<i64> {
436        match (self.days.first(), self.monthly.first()) {
437            (Some(a), Some(b)) => Some(a.jdn().min(b.jdn())),
438            (Some(a), None) => Some(a.jdn()),
439            (None, Some(b)) => Some(b.jdn()),
440            (None, None) => None,
441        }
442    }
443
444    fn end_jdn(&self) -> Option<i64> {
445        let day_end = self.days.last().map(|row| row.jdn() + 1);
446        let monthly_end = self.monthly.last().map(|row| {
447            let next_month = if row.month == 12 {
448                (row.year + 1, 1)
449            } else {
450                (row.year, i32::from(row.month) + 1)
451            };
452            julian_day_number(next_month.0, next_month.1, 1)
453        });
454        match (day_end, monthly_end) {
455            (Some(a), Some(b)) => Some(a.max(b)),
456            (Some(a), None) => Some(a),
457            (None, Some(b)) => Some(b),
458            (None, None) => None,
459        }
460    }
461}
462
463/// Parse the CelesTrak CSV encoding.
464pub fn parse_csv(text: &str) -> Result<Parsed<SpaceWeatherTable>, SpaceWeatherError> {
465    let mut lines = text.lines();
466    let header = lines.next().ok_or_else(|| SpaceWeatherError::Malformed {
467        line: 1,
468        reason: "missing CSV header".to_string(),
469    })?;
470    if header.trim_end_matches('\r') != CSV_HEADER {
471        return Err(SpaceWeatherError::Malformed {
472            line: 1,
473            reason: "unexpected CSV header".to_string(),
474        });
475    }
476
477    let mut records = Vec::new();
478    let mut diagnostics = Diagnostics::new();
479    let mut previous_jdn = None;
480    for (zero_index, raw_line) in lines.enumerate() {
481        let line_no = zero_index + 2;
482        let line = raw_line.trim_end_matches('\r');
483        if line.trim().is_empty() {
484            continue;
485        }
486        match parse_csv_record(line) {
487            Ok(row) => {
488                let jdn = row.jdn();
489                if previous_jdn.is_some_and(|previous| jdn < previous) {
490                    diagnostics.push_skip(skip_line(
491                        line_no,
492                        SkipReason::InconsistentRecord("out-of-order date"),
493                    ));
494                    continue;
495                }
496                previous_jdn = Some(jdn);
497                records.push((line_no, row));
498            }
499            Err(reason) => diagnostics.push_skip(skip_line(line_no, reason)),
500        }
501    }
502    build_table(records, diagnostics, None)
503}
504
505/// Parse the CelesTrak CSSI fixed-width text encoding.
506pub fn parse_txt(text: &str) -> Result<Parsed<SpaceWeatherTable>, SpaceWeatherError> {
507    let mut saw_datatype = false;
508    let mut section = None;
509    let mut txt_updated = None;
510    let mut records = Vec::new();
511    let mut diagnostics = Diagnostics::new();
512    let mut observed_count = None;
513    let mut daily_count = None;
514    let mut monthly_count = None;
515    let mut parsed_observed = 0usize;
516    let mut parsed_daily = 0usize;
517    let mut parsed_monthly = 0usize;
518    let mut previous_jdn = None;
519
520    for (zero_index, raw_line) in text.lines().enumerate() {
521        let line_no = zero_index + 1;
522        let line = raw_line.trim_end_matches('\r');
523        let trimmed = line.trim();
524        if trimmed.is_empty() || trimmed.starts_with('#') {
525            continue;
526        }
527        if trimmed == "DATATYPE CssiSpaceWeather" {
528            saw_datatype = true;
529            continue;
530        }
531        if trimmed.starts_with("VERSION ") {
532            continue;
533        }
534        if let Some(updated) = trimmed.strip_prefix("UPDATED ") {
535            txt_updated = Some(updated.to_string());
536            continue;
537        }
538        if let Some(count) = trimmed.strip_prefix("NUM_OBSERVED_POINTS ") {
539            observed_count = parse_count(count).map(|count| (line_no, count));
540            continue;
541        }
542        if let Some(count) = trimmed.strip_prefix("NUM_DAILY_PREDICTED_POINTS ") {
543            daily_count = parse_count(count).map(|count| (line_no, count));
544            continue;
545        }
546        if let Some(count) = trimmed.strip_prefix("NUM_MONTHLY_PREDICTED_POINTS ") {
547            monthly_count = parse_count(count).map(|count| (line_no, count));
548            continue;
549        }
550        match trimmed {
551            "BEGIN OBSERVED" => {
552                if section.is_some() {
553                    return Err(SpaceWeatherError::Malformed {
554                        line: line_no,
555                        reason: "nested fixed-width section".to_string(),
556                    });
557                }
558                section = Some(TxtSection::Observed);
559                continue;
560            }
561            "END OBSERVED" => {
562                if section != Some(TxtSection::Observed) {
563                    return Err(SpaceWeatherError::Malformed {
564                        line: line_no,
565                        reason: "END OBSERVED without matching BEGIN".to_string(),
566                    });
567                }
568                section = None;
569                continue;
570            }
571            "BEGIN DAILY_PREDICTED" => {
572                if section.is_some() {
573                    return Err(SpaceWeatherError::Malformed {
574                        line: line_no,
575                        reason: "nested fixed-width section".to_string(),
576                    });
577                }
578                section = Some(TxtSection::DailyPredicted);
579                continue;
580            }
581            "END DAILY_PREDICTED" => {
582                if section != Some(TxtSection::DailyPredicted) {
583                    return Err(SpaceWeatherError::Malformed {
584                        line: line_no,
585                        reason: "END DAILY_PREDICTED without matching BEGIN".to_string(),
586                    });
587                }
588                section = None;
589                continue;
590            }
591            "BEGIN MONTHLY_PREDICTED" => {
592                if section.is_some() {
593                    return Err(SpaceWeatherError::Malformed {
594                        line: line_no,
595                        reason: "nested fixed-width section".to_string(),
596                    });
597                }
598                section = Some(TxtSection::MonthlyPredicted);
599                continue;
600            }
601            "END MONTHLY_PREDICTED" => {
602                if section != Some(TxtSection::MonthlyPredicted) {
603                    return Err(SpaceWeatherError::Malformed {
604                        line: line_no,
605                        reason: "END MONTHLY_PREDICTED without matching BEGIN".to_string(),
606                    });
607                }
608                section = None;
609                continue;
610            }
611            _ => {}
612        }
613
614        let Some(active_section) = section else {
615            continue;
616        };
617        match parse_txt_record(line, active_section) {
618            Ok(row) => {
619                let jdn = row.jdn();
620                if previous_jdn.is_some_and(|previous| jdn < previous) {
621                    diagnostics.push_skip(skip_line(
622                        line_no,
623                        SkipReason::InconsistentRecord("out-of-order date"),
624                    ));
625                    continue;
626                }
627                previous_jdn = Some(jdn);
628                match active_section {
629                    TxtSection::Observed => parsed_observed += 1,
630                    TxtSection::DailyPredicted => parsed_daily += 1,
631                    TxtSection::MonthlyPredicted => parsed_monthly += 1,
632                }
633                records.push((line_no, row));
634            }
635            Err(error) => {
636                diagnostics.push_skip(skip_line(line_no, SkipReason::MalformedField(error)))
637            }
638        }
639    }
640
641    if !saw_datatype {
642        return Err(SpaceWeatherError::UnrecognizedFormat);
643    }
644    if section.is_some() {
645        return Err(SpaceWeatherError::Malformed {
646            line: text.lines().count(),
647            reason: "unterminated fixed-width section".to_string(),
648        });
649    }
650    warn_count_mismatch(observed_count, parsed_observed, &mut diagnostics);
651    warn_count_mismatch(daily_count, parsed_daily, &mut diagnostics);
652    warn_count_mismatch(monthly_count, parsed_monthly, &mut diagnostics);
653    build_table(records, diagnostics, txt_updated)
654}
655
656/// Sniff and parse either CelesTrak space-weather encoding from UTF-8 bytes.
657pub fn parse(data: &[u8]) -> Result<Parsed<SpaceWeatherTable>, SpaceWeatherError> {
658    let text = std::str::from_utf8(data).map_err(|_| SpaceWeatherError::NotText)?;
659    let first_content = text
660        .lines()
661        .map(str::trim)
662        .find(|line| !line.is_empty() && !line.starts_with('#'))
663        .ok_or(SpaceWeatherError::UnrecognizedFormat)?;
664    if first_content == CSV_HEADER {
665        parse_csv(text)
666    } else if first_content == "DATATYPE CssiSpaceWeather" {
667        parse_txt(text)
668    } else {
669        Err(SpaceWeatherError::UnrecognizedFormat)
670    }
671}
672
673fn parse_csv_record(line: &str) -> Result<SpaceWeatherDay, SkipReason> {
674    let fields: Vec<_> = line.split(',').collect();
675    if fields.len() != 31 {
676        return Err(if fields.len() < 31 {
677            SkipReason::Truncated
678        } else {
679            SkipReason::InconsistentRecord("CSV column count")
680        });
681    }
682    let (year, month, day) = parse_csv_date(fields[0]).map_err(SkipReason::MalformedField)?;
683    let class = parse_csv_class(fields[26]).map_err(SkipReason::MalformedField)?;
684    let mut kp_10 = [None; 8];
685    for (idx, slot) in kp_10.iter_mut().enumerate() {
686        *slot = opt_u16(fields[3 + idx], "KP").map_err(SkipReason::MalformedField)?;
687    }
688    let mut ap = [None; 8];
689    for (idx, slot) in ap.iter_mut().enumerate() {
690        *slot = opt_u16(fields[12 + idx], "AP").map_err(SkipReason::MalformedField)?;
691    }
692    Ok(SpaceWeatherDay {
693        year,
694        month,
695        day,
696        class,
697        bsrn: opt_u16(fields[1], "BSRN").map_err(SkipReason::MalformedField)?,
698        nd: opt_u8(fields[2], "ND").map_err(SkipReason::MalformedField)?,
699        kp_10,
700        kp_sum_10: opt_u16(fields[11], "KP_SUM").map_err(SkipReason::MalformedField)?,
701        ap,
702        ap_avg: opt_u16(fields[20], "AP_AVG").map_err(SkipReason::MalformedField)?,
703        cp_10: opt_cp_10(fields[21]).map_err(SkipReason::MalformedField)?,
704        c9: opt_u8(fields[22], "C9").map_err(SkipReason::MalformedField)?,
705        isn: opt_u16(fields[23], "ISN").map_err(SkipReason::MalformedField)?,
706        flux_qualifier: None,
707        f107_obs: opt_f64(fields[24], "F10.7_OBS").map_err(SkipReason::MalformedField)?,
708        f107_adj: opt_f64(fields[25], "F10.7_ADJ").map_err(SkipReason::MalformedField)?,
709        f107_obs_center81: opt_f64(fields[27], "F10.7_OBS_CENTER81")
710            .map_err(SkipReason::MalformedField)?,
711        f107_obs_last81: opt_f64(fields[28], "F10.7_OBS_LAST81")
712            .map_err(SkipReason::MalformedField)?,
713        f107_adj_center81: opt_f64(fields[29], "F10.7_ADJ_CENTER81")
714            .map_err(SkipReason::MalformedField)?,
715        f107_adj_last81: opt_f64(fields[30], "F10.7_ADJ_LAST81")
716            .map_err(SkipReason::MalformedField)?,
717    })
718}
719
720#[derive(Debug, Clone, Copy, PartialEq, Eq)]
721enum TxtSection {
722    Observed,
723    DailyPredicted,
724    MonthlyPredicted,
725}
726
727fn parse_txt_record(line: &str, section: TxtSection) -> Result<SpaceWeatherDay, FieldError> {
728    let year = req_i32_col(line, 0, 4, "year")?;
729    let month = req_u8_col(line, 4, 7, "month")?;
730    let day = req_u8_col(line, 7, 10, "day")?;
731    validate_date(year, month, day)?;
732    let mut pos = 10;
733    let bsrn = opt_u16_col(line, pos, pos + 5, "BSRN")?;
734    pos += 5;
735    let nd = opt_u8_col(line, pos, pos + 3, "ND")?;
736    pos += 3;
737    let mut kp_10 = [None; 8];
738    for slot in &mut kp_10 {
739        *slot = opt_u16_col(line, pos, pos + 3, "KP")?;
740        pos += 3;
741    }
742    let kp_sum_10 = opt_u16_col(line, pos, pos + 4, "KP_SUM")?;
743    pos += 4;
744    let mut ap = [None; 8];
745    for slot in &mut ap {
746        *slot = opt_u16_col(line, pos, pos + 4, "AP")?;
747        pos += 4;
748    }
749    let ap_avg = opt_u16_col(line, pos, pos + 4, "AP_AVG")?;
750    pos += 4;
751    let cp_10 = opt_cp_10_col(line, pos, pos + 4)?;
752    pos += 4;
753    let c9 = opt_u8_col(line, pos, pos + 2, "C9")?;
754    pos += 2;
755    let isn = opt_u16_col(line, pos, pos + 4, "ISN")?;
756    pos += 4;
757    let f107_adj = opt_f64_col(line, pos, pos + 6, "F10.7_ADJ")?;
758    pos += 6;
759    let flux_qualifier = opt_u8_col(line, pos, pos + 2, "Q")?;
760    pos += 2;
761    let f107_adj_center81 = opt_f64_col(line, pos, pos + 6, "F10.7_ADJ_CENTER81")?;
762    pos += 6;
763    let f107_adj_last81 = opt_f64_col(line, pos, pos + 6, "F10.7_ADJ_LAST81")?;
764    pos += 6;
765    let f107_obs = opt_f64_col(line, pos, pos + 6, "F10.7_OBS")?;
766    pos += 6;
767    let f107_obs_center81 = opt_f64_col(line, pos, pos + 6, "F10.7_OBS_CENTER81")?;
768    pos += 6;
769    let f107_obs_last81 = opt_f64_col(line, pos, pos + 6, "F10.7_OBS_LAST81")?;
770
771    let class = match section {
772        TxtSection::Observed if flux_qualifier == Some(4) => ObservationClass::Interpolated,
773        TxtSection::Observed => ObservationClass::Observed,
774        TxtSection::DailyPredicted => ObservationClass::DailyPredicted,
775        TxtSection::MonthlyPredicted => ObservationClass::MonthlyPredicted,
776    };
777
778    Ok(SpaceWeatherDay {
779        year,
780        month,
781        day,
782        class,
783        bsrn,
784        nd,
785        kp_10,
786        kp_sum_10,
787        ap,
788        ap_avg,
789        cp_10,
790        c9,
791        isn,
792        flux_qualifier,
793        f107_obs,
794        f107_adj,
795        f107_obs_center81,
796        f107_obs_last81,
797        f107_adj_center81,
798        f107_adj_last81,
799    })
800}
801
802fn build_table(
803    records: Vec<(usize, SpaceWeatherDay)>,
804    mut diagnostics: Diagnostics,
805    txt_updated: Option<String>,
806) -> Result<Parsed<SpaceWeatherTable>, SpaceWeatherError> {
807    let mut days = Vec::new();
808    let mut monthly = Vec::new();
809    for (line, row) in records {
810        let target = if row.class == ObservationClass::MonthlyPredicted {
811            &mut monthly
812        } else {
813            &mut days
814        };
815        if target
816            .last()
817            .is_some_and(|existing: &SpaceWeatherDay| existing.jdn() == row.jdn())
818        {
819            diagnostics.push_skip(skip_line(
820                line,
821                SkipReason::InconsistentRecord("duplicate date"),
822            ));
823            continue;
824        }
825        target.push(row);
826    }
827    days.sort_by_key(SpaceWeatherDay::jdn);
828    monthly.sort_by_key(SpaceWeatherDay::jdn);
829    if days.is_empty() && monthly.is_empty() {
830        return Err(SpaceWeatherError::Malformed {
831            line: 1,
832            reason: "no parseable space-weather rows".to_string(),
833        });
834    }
835    Ok(Parsed::new(
836        SpaceWeatherTable {
837            days,
838            monthly,
839            txt_updated,
840        },
841        diagnostics,
842    ))
843}
844
845/// Serialize a table to the CelesTrak CSV encoding.
846pub fn encode_csv(table: &SpaceWeatherTable) -> String {
847    let mut out = String::new();
848    out.push_str(CSV_HEADER);
849    out.push('\n');
850    for row in table.days.iter().chain(table.monthly.iter()) {
851        push_csv_row(&mut out, row);
852    }
853    out
854}
855
856/// Serialize a table to the CelesTrak CSSI fixed-width text encoding.
857///
858/// When the table came from CSV, the text-only flux qualifier field is blank.
859/// When the table came from txt and had an `UPDATED` header, that header is
860/// reproduced.
861pub fn encode_txt(table: &SpaceWeatherTable) -> String {
862    let mut out = String::new();
863    out.push_str("DATATYPE CssiSpaceWeather\n");
864    out.push_str("VERSION 1.2\n");
865    if let Some(updated) = &table.txt_updated {
866        let _ = writeln!(out, "UPDATED {updated}");
867    }
868    out.push_str(TXT_HEADER_COMMENTS);
869    out.push('\n');
870
871    let observed_count = table
872        .days
873        .iter()
874        .filter(|row| {
875            matches!(
876                row.class,
877                ObservationClass::Observed | ObservationClass::Interpolated
878            )
879        })
880        .count();
881    let daily_count = table
882        .days
883        .iter()
884        .filter(|row| row.class == ObservationClass::DailyPredicted)
885        .count();
886    write_txt_section(
887        &mut out,
888        "OBSERVED",
889        observed_count,
890        table.days.iter().filter(|row| {
891            matches!(
892                row.class,
893                ObservationClass::Observed | ObservationClass::Interpolated
894            )
895        }),
896    );
897    out.push('\n');
898    write_txt_section(
899        &mut out,
900        "DAILY_PREDICTED",
901        daily_count,
902        table
903            .days
904            .iter()
905            .filter(|row| row.class == ObservationClass::DailyPredicted),
906    );
907    out.push('\n');
908    write_txt_section(
909        &mut out,
910        "MONTHLY_PREDICTED",
911        table.monthly.len(),
912        table.monthly.iter(),
913    );
914    out
915}
916
917fn push_csv_row(out: &mut String, row: &SpaceWeatherDay) {
918    let class = match row.class {
919        ObservationClass::Observed => "OBS",
920        ObservationClass::Interpolated => "INT",
921        ObservationClass::DailyPredicted => "PRD",
922        ObservationClass::MonthlyPredicted => "PRM",
923    };
924    let _ = write!(out, "{:04}-{:02}-{:02}", row.year, row.month, row.day);
925    push_csv_opt_u16(out, row.bsrn);
926    push_csv_opt_u8(out, row.nd);
927    for value in row.kp_10 {
928        push_csv_opt_u16(out, value);
929    }
930    push_csv_opt_u16(out, row.kp_sum_10);
931    for value in row.ap {
932        push_csv_opt_u16(out, value);
933    }
934    push_csv_opt_u16(out, row.ap_avg);
935    push_csv_opt_cp(out, row.cp_10);
936    push_csv_opt_u8(out, row.c9);
937    push_csv_opt_u16(out, row.isn);
938    push_csv_opt_f64(out, row.f107_obs);
939    push_csv_opt_f64(out, row.f107_adj);
940    out.push(',');
941    out.push_str(class);
942    push_csv_opt_f64(out, row.f107_obs_center81);
943    push_csv_opt_f64(out, row.f107_obs_last81);
944    push_csv_opt_f64(out, row.f107_adj_center81);
945    push_csv_opt_f64(out, row.f107_adj_last81);
946    out.push('\n');
947}
948
949fn push_csv_opt_u16(out: &mut String, value: Option<u16>) {
950    out.push(',');
951    if let Some(value) = value {
952        let _ = write!(out, "{value}");
953    }
954}
955
956fn push_csv_opt_u8(out: &mut String, value: Option<u8>) {
957    out.push(',');
958    if let Some(value) = value {
959        let _ = write!(out, "{value}");
960    }
961}
962
963fn push_csv_opt_cp(out: &mut String, value: Option<u8>) {
964    out.push(',');
965    if let Some(value) = value {
966        let _ = write!(out, "{:.1}", f64::from(value) / 10.0);
967    }
968}
969
970fn push_csv_opt_f64(out: &mut String, value: Option<f64>) {
971    out.push(',');
972    if let Some(value) = value {
973        let _ = write!(out, "{value:.1}");
974    }
975}
976
977fn write_txt_section<'a, I>(out: &mut String, name: &str, count: usize, rows: I)
978where
979    I: IntoIterator<Item = &'a SpaceWeatherDay>,
980{
981    let _ = writeln!(out, "NUM_{name}_POINTS {count}");
982    let _ = writeln!(out, "BEGIN {name}");
983    for row in rows {
984        push_txt_row(out, row);
985    }
986    let _ = writeln!(out, "END {name}");
987}
988
989fn push_txt_row(out: &mut String, row: &SpaceWeatherDay) {
990    let _ = write!(out, "{:4} {:02} {:02}", row.year, row.month, row.day);
991    push_txt_opt_u16(out, row.bsrn, 5);
992    push_txt_opt_u8(out, row.nd, 3);
993    for value in row.kp_10 {
994        push_txt_opt_u16(out, value, 3);
995    }
996    push_txt_opt_u16(out, row.kp_sum_10, 4);
997    for value in row.ap {
998        push_txt_opt_u16(out, value, 4);
999    }
1000    push_txt_opt_u16(out, row.ap_avg, 4);
1001    push_txt_opt_cp(out, row.cp_10);
1002    push_txt_opt_u8(out, row.c9, 2);
1003    push_txt_opt_u16(out, row.isn, 4);
1004    push_txt_opt_f64(out, row.f107_adj, 6);
1005    push_txt_opt_u8(out, row.flux_qualifier, 2);
1006    push_txt_opt_f64(out, row.f107_adj_center81, 6);
1007    push_txt_opt_f64(out, row.f107_adj_last81, 6);
1008    push_txt_opt_f64(out, row.f107_obs, 6);
1009    push_txt_opt_f64(out, row.f107_obs_center81, 6);
1010    push_txt_opt_f64(out, row.f107_obs_last81, 6);
1011    out.push('\n');
1012}
1013
1014fn push_txt_opt_u16(out: &mut String, value: Option<u16>, width: usize) {
1015    if let Some(value) = value {
1016        let _ = write!(out, "{value:width$}");
1017    } else {
1018        push_spaces(out, width);
1019    }
1020}
1021
1022fn push_txt_opt_u8(out: &mut String, value: Option<u8>, width: usize) {
1023    if let Some(value) = value {
1024        let _ = write!(out, "{value:width$}");
1025    } else {
1026        push_spaces(out, width);
1027    }
1028}
1029
1030fn push_txt_opt_cp(out: &mut String, value: Option<u8>) {
1031    if let Some(value) = value {
1032        let _ = write!(out, "{:4.1}", f64::from(value) / 10.0);
1033    } else {
1034        push_spaces(out, 4);
1035    }
1036}
1037
1038fn push_txt_opt_f64(out: &mut String, value: Option<f64>, width: usize) {
1039    if let Some(value) = value {
1040        let formatted = format!("{value:.1}");
1041        let _ = write!(out, "{formatted:>width$}");
1042    } else {
1043        push_spaces(out, width);
1044    }
1045}
1046
1047fn push_spaces(out: &mut String, count: usize) {
1048    for _ in 0..count {
1049        out.push(' ');
1050    }
1051}
1052
1053fn parse_csv_date(text: &str) -> Result<(i32, u8, u8), FieldError> {
1054    let mut parts = text.split('-');
1055    let year = parse_required(parts.next(), "year")?;
1056    let month = parse_required(parts.next(), "month")?;
1057    let day = parse_required(parts.next(), "day")?;
1058    if parts.next().is_some() {
1059        return Err(FieldError::InvalidCivilDate {
1060            field: "DATE",
1061            year: i64::from(year),
1062            month: i64::from(month),
1063            day: i64::from(day),
1064        });
1065    }
1066    validate_date(year, month, day)?;
1067    Ok((year, month, day))
1068}
1069
1070fn parse_csv_class(text: &str) -> Result<ObservationClass, FieldError> {
1071    match text.trim() {
1072        "OBS" => Ok(ObservationClass::Observed),
1073        "INT" => Ok(ObservationClass::Interpolated),
1074        "PRD" => Ok(ObservationClass::DailyPredicted),
1075        "PRM" => Ok(ObservationClass::MonthlyPredicted),
1076        value => Err(FieldError::IntParse {
1077            field: "F10.7_DATA_TYPE",
1078            value: value.to_string(),
1079        }),
1080    }
1081}
1082
1083fn validate_date(year: i32, month: u8, day: u8) -> Result<(), FieldError> {
1084    let days = days_in_month(i64::from(year), i64::from(month));
1085    if days == 0 || day == 0 || i64::from(day) > days {
1086        return Err(FieldError::InvalidCivilDate {
1087            field: "DATE",
1088            year: i64::from(year),
1089            month: i64::from(month),
1090            day: i64::from(day),
1091        });
1092    }
1093    Ok(())
1094}
1095
1096fn opt_u16(text: &str, field: &'static str) -> Result<Option<u16>, FieldError> {
1097    opt_parse(text, field)
1098}
1099
1100fn opt_u8(text: &str, field: &'static str) -> Result<Option<u8>, FieldError> {
1101    opt_parse(text, field)
1102}
1103
1104fn opt_f64(text: &str, field: &'static str) -> Result<Option<f64>, FieldError> {
1105    let value = text.trim();
1106    if value.is_empty() {
1107        Ok(None)
1108    } else {
1109        validate::strict_f64(value, field).map(Some)
1110    }
1111}
1112
1113fn opt_cp_10(text: &str) -> Result<Option<u8>, FieldError> {
1114    opt_f64(text, "CP").map(|value| value.map(|cp| (cp * 10.0).round() as u8))
1115}
1116
1117fn opt_parse<T>(text: &str, field: &'static str) -> Result<Option<T>, FieldError>
1118where
1119    T: std::str::FromStr,
1120{
1121    let value = text.trim();
1122    if value.is_empty() {
1123        Ok(None)
1124    } else {
1125        validate::strict_int(value, field).map(Some)
1126    }
1127}
1128
1129fn parse_required<T>(value: Option<&str>, field: &'static str) -> Result<T, FieldError>
1130where
1131    T: std::str::FromStr,
1132{
1133    validate::strict_int(value.unwrap_or_default(), field)
1134}
1135
1136fn req_i32_col(
1137    line: &str,
1138    start: usize,
1139    end: usize,
1140    field: &'static str,
1141) -> Result<i32, FieldError> {
1142    validate::strict_int(columns::field(line, start, end).unwrap_or_default(), field)
1143}
1144
1145fn req_u8_col(line: &str, start: usize, end: usize, field: &'static str) -> Result<u8, FieldError> {
1146    validate::strict_int(columns::field(line, start, end).unwrap_or_default(), field)
1147}
1148
1149fn opt_u16_col(
1150    line: &str,
1151    start: usize,
1152    end: usize,
1153    field: &'static str,
1154) -> Result<Option<u16>, FieldError> {
1155    columns::field(line, start, end).map_or(Ok(None), |value| opt_u16(value, field))
1156}
1157
1158fn opt_u8_col(
1159    line: &str,
1160    start: usize,
1161    end: usize,
1162    field: &'static str,
1163) -> Result<Option<u8>, FieldError> {
1164    columns::field(line, start, end).map_or(Ok(None), |value| opt_u8(value, field))
1165}
1166
1167fn opt_f64_col(
1168    line: &str,
1169    start: usize,
1170    end: usize,
1171    field: &'static str,
1172) -> Result<Option<f64>, FieldError> {
1173    columns::field(line, start, end).map_or(Ok(None), |value| opt_f64(value, field))
1174}
1175
1176fn opt_cp_10_col(line: &str, start: usize, end: usize) -> Result<Option<u8>, FieldError> {
1177    columns::field(line, start, end).map_or(Ok(None), opt_cp_10)
1178}
1179
1180fn parse_count(text: &str) -> Option<usize> {
1181    text.trim().parse::<usize>().ok()
1182}
1183
1184fn warn_count_mismatch(
1185    declared: Option<(usize, usize)>,
1186    actual: usize,
1187    diagnostics: &mut Diagnostics,
1188) {
1189    if let Some((line, _)) = declared.filter(|(_, declared)| *declared != actual) {
1190        diagnostics.push_warning(Warning {
1191            at: RecordRef::at_line(line),
1192            kind: WarningKind::Mismatch,
1193        });
1194    }
1195}
1196
1197fn skip_line(line: usize, reason: SkipReason) -> Skip {
1198    Skip {
1199        at: RecordRef::at_line(line),
1200        reason,
1201    }
1202}
1203
1204fn enforce_policy(
1205    row: &SpaceWeatherDay,
1206    policy: SpaceWeatherPolicy,
1207) -> Result<(), SpaceWeatherError> {
1208    let allowed = match row.class {
1209        ObservationClass::Observed => true,
1210        ObservationClass::Interpolated => policy.allow_interpolated,
1211        ObservationClass::DailyPredicted => policy.allow_daily_predicted,
1212        ObservationClass::MonthlyPredicted => policy.allow_monthly_predicted,
1213    };
1214    if allowed {
1215        Ok(())
1216    } else {
1217        Err(SpaceWeatherError::RejectedByPolicy {
1218            class: row.class,
1219            year: row.year,
1220            month: row.month,
1221            day: row.day,
1222        })
1223    }
1224}
1225
1226fn daily_ap(
1227    row: &SpaceWeatherDay,
1228    policy: SpaceWeatherPolicy,
1229) -> Result<(f64, bool), SpaceWeatherError> {
1230    if let Some(ap) = row.ap_avg {
1231        return Ok((f64::from(ap), false));
1232    }
1233    if row.class == ObservationClass::MonthlyPredicted {
1234        if policy.require_geomagnetic {
1235            return Err(SpaceWeatherError::RejectedByPolicy {
1236                class: row.class,
1237                year: row.year,
1238                month: row.month,
1239                day: row.day,
1240            });
1241        }
1242        return Ok((DEFAULT_AP, true));
1243    }
1244    Err(missing(row, "AP_AVG"))
1245}
1246
1247fn missing(row: &SpaceWeatherDay, field: &'static str) -> SpaceWeatherError {
1248    SpaceWeatherError::MissingData {
1249        year: row.year,
1250        month: row.month,
1251        day: row.day,
1252        field,
1253    }
1254}
1255
1256fn epoch_day_jdn(epoch_j2000_s: f64) -> Result<i64, SpaceWeatherError> {
1257    if !epoch_j2000_s.is_finite() {
1258        return Err(SpaceWeatherError::InvalidEpoch {
1259            epoch_j2000_s_bits: epoch_j2000_s.to_bits(),
1260        });
1261    }
1262    let floor_second = epoch_j2000_s.floor();
1263    if floor_second < i64::MIN as f64 || floor_second > i64::MAX as f64 {
1264        return Err(SpaceWeatherError::InvalidEpoch {
1265            epoch_j2000_s_bits: epoch_j2000_s.to_bits(),
1266        });
1267    }
1268    let from_midnight = floor_second as i64 + J2000_NOON_OFFSET_S;
1269    let day_index = from_midnight.div_euclid(SECONDS_PER_DAY_I64);
1270    let jdn = day_index + J2000_JULIAN_DAY_NUMBER;
1271    let min_jdn = julian_day_number(0, 1, 1);
1272    let max_jdn = julian_day_number(9999, 12, 31);
1273    if !(min_jdn..=max_jdn).contains(&jdn) {
1274        return Err(SpaceWeatherError::InvalidEpoch {
1275            epoch_j2000_s_bits: epoch_j2000_s.to_bits(),
1276        });
1277    }
1278    Ok(jdn)
1279}
1280
1281fn epoch_day_and_ap_bin(epoch_j2000_s: f64) -> Result<(i64, u8), SpaceWeatherError> {
1282    let jdn = epoch_day_jdn(epoch_j2000_s)?;
1283    let floor_second = epoch_j2000_s.floor() as i64;
1284    let from_midnight = floor_second + J2000_NOON_OFFSET_S;
1285    let second_of_day = from_midnight.rem_euclid(SECONDS_PER_DAY_I64);
1286    Ok((jdn, (second_of_day / (3 * 3600)) as u8))
1287}
1288
1289fn day_start_j2000_s(jdn: i64) -> f64 {
1290    let (year, month, day) = civil_from_julian_day_number(jdn);
1291    j2000_seconds(year as i32, month as i32, day as i32, 0, 0, 0.0)
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296    use super::*;
1297
1298    const CSV: &str = "DATE,BSRN,ND,KP1,KP2,KP3,KP4,KP5,KP6,KP7,KP8,KP_SUM,AP1,AP2,AP3,AP4,AP5,AP6,AP7,AP8,AP_AVG,CP,C9,ISN,F10.7_OBS,F10.7_ADJ,F10.7_DATA_TYPE,F10.7_OBS_CENTER81,F10.7_OBS_LAST81,F10.7_ADJ_CENTER81,F10.7_ADJ_LAST81\n\
12992024-05-09,2556,1,23,27,30,33,40,50,47,37,287,9,12,15,18,27,48,39,22,24,1.2,5,120,165.1,162.0,OBS,150.1,149.8,147.0,146.6\n\
13002024-05-10,2556,2,40,50,60,70,67,57,47,37,428,27,48,80,132,111,67,39,22,66,1.8,7,121,190.2,187.1,OBS,151.2,150.9,148.0,147.6\n\
13012024-05-11,2556,3,33,30,27,23,20,17,13,10,173,18,15,12,9,7,6,5,4,10,0.8,3,119,176.3,173.0,OBS,152.3,151.1,149.0,148.2\n\
13022024-06-01,2557,24,,,,,,,,,,,,,,,,,,,,,118,171.0,168.0,PRM,153.0,152.0,150.0,149.0\n";
1303
1304    #[test]
1305    fn parses_csv_and_serves_drag_weather() {
1306        let parsed = parse_csv(CSV).expect("csv parses");
1307        assert!(parsed.diagnostics.is_empty());
1308        let table = parsed.value;
1309        assert_eq!(table.days().len(), 3);
1310        assert_eq!(table.monthly().len(), 1);
1311        assert_eq!(
1312            table.day(2024, 6, 15).unwrap().class,
1313            ObservationClass::MonthlyPredicted
1314        );
1315
1316        let epoch = j2000_seconds(2024, 5, 10, 12, 0, 0.0);
1317        let sample = table.sample_at(epoch).expect("sample");
1318        assert_eq!(
1319            sample.space_weather,
1320            SpaceWeather {
1321                f107: 165.1,
1322                f107a: 151.2,
1323                ap: 66.0,
1324            }
1325        );
1326        assert_eq!(sample.class, ObservationClass::Observed);
1327        assert!(!sample.ap_defaulted);
1328    }
1329
1330    #[test]
1331    fn monthly_region_defaults_ap_and_can_be_rejected() {
1332        let table = parse_csv(CSV).unwrap().value;
1333        let epoch = j2000_seconds(2024, 6, 15, 0, 0, 0.0);
1334        let sample = table.sample_at(epoch).expect("monthly sample");
1335        assert_eq!(sample.space_weather.f107, 171.0);
1336        assert_eq!(sample.space_weather.f107a, 153.0);
1337        assert_eq!(sample.space_weather.ap, DEFAULT_AP);
1338        assert!(sample.ap_defaulted);
1339
1340        let policy = SpaceWeatherPolicy {
1341            require_geomagnetic: true,
1342            ..SpaceWeatherPolicy::default()
1343        };
1344        assert!(matches!(
1345            table.sample_at_with_policy(epoch, policy),
1346            Err(SpaceWeatherError::RejectedByPolicy {
1347                class: ObservationClass::MonthlyPredicted,
1348                ..
1349            })
1350        ));
1351    }
1352
1353    #[test]
1354    fn ap_array_crosses_day_boundaries() {
1355        let table = parse_csv(CSV).unwrap().value;
1356        let epoch = j2000_seconds(2024, 5, 11, 13, 0, 0.0);
1357        let ap = table.ap_array_at(epoch).expect("ap array");
1358        assert_eq!(ap[0], 10.0);
1359        assert_eq!(ap[1], 7.0);
1360        assert_eq!(ap[2], 9.0);
1361        assert_eq!(ap[3], 12.0);
1362        assert_eq!(ap[4], 15.0);
1363        assert_eq!(
1364            ap[5],
1365            (18.0 + 22.0 + 39.0 + 67.0 + 111.0 + 132.0 + 80.0 + 48.0) / 8.0
1366        );
1367        assert_eq!(
1368            ap[6],
1369            (12.0 + 15.0 + 18.0 + 27.0 + 48.0 + 39.0 + 22.0 + 27.0) / 8.0
1370        );
1371    }
1372
1373    #[test]
1374    fn parse_sniffs_utf8_and_format() {
1375        assert!(matches!(parse(b"\xff"), Err(SpaceWeatherError::NotText)));
1376        assert!(matches!(
1377            parse(b"not cssi"),
1378            Err(SpaceWeatherError::UnrecognizedFormat)
1379        ));
1380        assert!(matches!(
1381            parse(b"not a header\nCssiSpaceWeather"),
1382            Err(SpaceWeatherError::UnrecognizedFormat)
1383        ));
1384        assert_eq!(parse(CSV.as_bytes()).unwrap().value.days().len(), 3);
1385    }
1386
1387    #[test]
1388    fn parser_diagnostics_cover_bad_duplicate_and_ordered_rows() {
1389        let truncated = format!("{CSV}2024-05-12,bad\n");
1390        let parsed = parse_csv(&truncated).expect("forgiving parse");
1391        assert_eq!(parsed.value.days().len(), 3);
1392        assert!(matches!(
1393            parsed.diagnostics.skips[0].reason,
1394            SkipReason::Truncated
1395        ));
1396
1397        let bad_kp = format!(
1398            "{CSV}{}",
1399            "2024-05-12,2556,4,bad,27,30,33,40,50,47,37,287,9,12,15,18,27,48,39,22,24,1.2,5,120,165.1,162.0,OBS,150.1,149.8,147.0,146.6\n"
1400        );
1401        let parsed = parse_csv(&bad_kp).expect("forgiving parse");
1402        assert_eq!(parsed.value.days().len(), 3);
1403        assert!(matches!(
1404            parsed.diagnostics.skips[0].reason,
1405            SkipReason::MalformedField(FieldError::IntParse { field: "KP", .. })
1406        ));
1407
1408        let duplicate = format!(
1409            "{CSV_HEADER}\n{}{}",
1410            "2024-05-09,2556,1,23,27,30,33,40,50,47,37,287,9,12,15,18,27,48,39,22,24,1.2,5,120,165.1,162.0,OBS,150.1,149.8,147.0,146.6\n",
1411            "2024-05-09,2556,1,23,27,30,33,40,50,47,37,287,9,12,15,18,27,48,39,22,24,1.2,5,120,165.1,162.0,OBS,150.1,149.8,147.0,146.6\n"
1412        );
1413        let parsed = parse_csv(&duplicate).expect("forgiving duplicate");
1414        assert_eq!(parsed.value.days().len(), 1);
1415        assert!(matches!(
1416            parsed.diagnostics.skips[0].reason,
1417            SkipReason::InconsistentRecord("duplicate date")
1418        ));
1419
1420        let out_of_order = format!(
1421            "{CSV_HEADER}\n{}{}",
1422            "2024-05-10,2556,2,40,50,60,70,67,57,47,37,428,27,48,80,132,111,67,39,22,66,1.8,7,121,190.2,187.1,OBS,151.2,150.9,148.0,147.6\n",
1423            "2024-05-09,2556,1,23,27,30,33,40,50,47,37,287,9,12,15,18,27,48,39,22,24,1.2,5,120,165.1,162.0,OBS,150.1,149.8,147.0,146.6\n"
1424        );
1425        let parsed = parse_csv(&out_of_order).expect("forgiving order check");
1426        assert_eq!(parsed.value.days().len(), 1);
1427        assert!(matches!(
1428            parsed.diagnostics.skips[0].reason,
1429            SkipReason::InconsistentRecord("out-of-order date")
1430        ));
1431    }
1432
1433    #[test]
1434    fn txt_count_mismatch_warns_without_rejecting_observed_only() {
1435        let text = format!(
1436            "DATATYPE CssiSpaceWeather\nVERSION 1.2\nNUM_OBSERVED_POINTS 2\nBEGIN OBSERVED\n{}\nEND OBSERVED\n",
1437            txt_row_observed()
1438        );
1439        let parsed = parse_txt(&text).expect("txt parses with warning");
1440        assert_eq!(parsed.value.days().len(), 1);
1441        assert_eq!(parsed.value.monthly().len(), 0);
1442        assert_eq!(parsed.diagnostics.warnings.len(), 1);
1443        assert_eq!(parsed.value.coverage().last_daily_predicted_j2000_s, None);
1444    }
1445
1446    #[test]
1447    fn parses_fixed_width_sections() {
1448        let text = format!(
1449            "DATATYPE CssiSpaceWeather\nVERSION 1.2\nNUM_OBSERVED_POINTS 1\nBEGIN OBSERVED\n{}\nEND OBSERVED\n",
1450            txt_row_observed()
1451        );
1452        let parsed = parse_txt(&text).expect("txt parses");
1453        assert!(parsed.diagnostics.is_empty());
1454        let row = parsed.value.day(2024, 5, 9).unwrap();
1455        assert_eq!(row.class, ObservationClass::Observed);
1456        assert_eq!(row.flux_qualifier, Some(0));
1457        assert_eq!(row.f107_obs, Some(165.1));
1458        assert_eq!(row.f107_obs_center81, Some(150.1));
1459        assert_eq!(row.f107_adj, Some(162.0));
1460        assert_eq!(row.f107_adj_center81, Some(147.0));
1461    }
1462
1463    #[test]
1464    fn observed_only_file_has_exclusive_end_no_holdover() {
1465        let input = format!(
1466            "{CSV_HEADER}\n{}{}",
1467            "2024-05-09,2556,1,23,27,30,33,40,50,47,37,287,9,12,15,18,27,48,39,22,24,1.2,5,120,165.1,162.0,OBS,150.1,149.8,147.0,146.6\n",
1468            "2024-05-10,2556,2,40,50,60,70,67,57,47,37,428,27,48,80,132,111,67,39,22,66,1.8,7,121,190.2,187.1,OBS,151.2,150.9,148.0,147.6\n"
1469        );
1470        let table = parse_csv(&input).unwrap().value;
1471        assert_eq!(table.coverage().last_daily_predicted_j2000_s, None);
1472        assert!(matches!(
1473            table.sample_at(j2000_seconds(2024, 5, 11, 0, 0, 0.0)),
1474            Err(SpaceWeatherError::AfterCoverage { .. })
1475        ));
1476    }
1477
1478    #[test]
1479    fn gap_days_and_boundaries_report_typed_errors() {
1480        let input = format!(
1481            "{CSV_HEADER}\n{}{}",
1482            "2024-05-09,2556,1,23,27,30,33,40,50,47,37,287,9,12,15,18,27,48,39,22,24,1.2,5,120,165.1,162.0,OBS,150.1,149.8,147.0,146.6\n",
1483            "2024-05-11,2556,3,33,30,27,23,20,17,13,10,173,18,15,12,9,7,6,5,4,10,0.8,3,119,176.3,173.0,OBS,152.3,151.1,149.0,148.2\n"
1484        );
1485        let table = parse_csv(&input).unwrap().value;
1486        assert!(matches!(
1487            table.sample_at(j2000_seconds(2024, 5, 10, 12, 0, 0.0)),
1488            Err(SpaceWeatherError::MissingData {
1489                year: 2024,
1490                month: 5,
1491                day: 10,
1492                field: "record"
1493            })
1494        ));
1495        assert!(matches!(
1496            table.sample_at(j2000_seconds(2024, 5, 11, 12, 0, 0.0)),
1497            Err(SpaceWeatherError::MissingData {
1498                year: 2024,
1499                month: 5,
1500                day: 10,
1501                field: "record"
1502            })
1503        ));
1504
1505        let table = parse_csv(CSV).unwrap().value;
1506        let boundary = table
1507            .sample_at(j2000_seconds(2024, 5, 10, 0, 0, 0.0))
1508            .expect("day boundary belongs to starting day");
1509        assert_eq!(boundary.space_weather.f107, 165.1);
1510        assert_eq!(boundary.space_weather.ap, 66.0);
1511        assert!(matches!(
1512            table.sample_at(j2000_seconds(2024, 5, 9, 0, 0, 0.0)),
1513            Err(SpaceWeatherError::BeforeCoverage { .. })
1514        ));
1515        assert!(matches!(
1516            table.sample_at(f64::NAN),
1517            Err(SpaceWeatherError::InvalidEpoch { .. })
1518        ));
1519    }
1520
1521    #[test]
1522    fn policy_rejects_each_non_observed_class_and_geomagnetic_default() {
1523        let input = format!(
1524            "{CSV_HEADER}\n{}{}{}{}",
1525            "2024-05-09,2556,1,23,27,30,33,40,50,47,37,287,9,12,15,18,27,48,39,22,24,1.2,5,120,165.1,162.0,OBS,150.1,149.8,147.0,146.6\n",
1526            "2024-05-10,2556,2,40,50,60,70,67,57,47,37,428,27,48,80,132,111,67,39,22,66,1.8,7,121,190.2,187.1,INT,151.2,150.9,148.0,147.6\n",
1527            "2024-05-11,2556,3,33,30,27,23,20,17,13,10,173,18,15,12,9,7,6,5,4,10,0.8,3,119,176.3,173.0,PRD,152.3,151.1,149.0,148.2\n",
1528            "2024-06-01,2557,24,,,,,,,,,,,,,,,,,,,,,118,171.0,168.0,PRM,153.0,152.0,150.0,149.0\n"
1529        );
1530        let table = parse_csv(&input).unwrap().value;
1531        assert!(matches!(
1532            table.sample_at_with_policy(
1533                j2000_seconds(2024, 5, 10, 12, 0, 0.0),
1534                SpaceWeatherPolicy {
1535                    allow_interpolated: false,
1536                    ..SpaceWeatherPolicy::default()
1537                }
1538            ),
1539            Err(SpaceWeatherError::RejectedByPolicy {
1540                class: ObservationClass::Interpolated,
1541                ..
1542            })
1543        ));
1544        assert!(matches!(
1545            table.sample_at_with_policy(
1546                j2000_seconds(2024, 5, 11, 12, 0, 0.0),
1547                SpaceWeatherPolicy {
1548                    allow_daily_predicted: false,
1549                    ..SpaceWeatherPolicy::default()
1550                }
1551            ),
1552            Err(SpaceWeatherError::RejectedByPolicy {
1553                class: ObservationClass::DailyPredicted,
1554                ..
1555            })
1556        ));
1557        assert!(matches!(
1558            table.sample_at_with_policy(
1559                j2000_seconds(2024, 6, 15, 12, 0, 0.0),
1560                SpaceWeatherPolicy {
1561                    allow_monthly_predicted: false,
1562                    ..SpaceWeatherPolicy::default()
1563                }
1564            ),
1565            Err(SpaceWeatherError::RejectedByPolicy {
1566                class: ObservationClass::MonthlyPredicted,
1567                ..
1568            })
1569        ));
1570        assert!(matches!(
1571            table.sample_at_with_policy(
1572                j2000_seconds(2024, 6, 15, 12, 0, 0.0),
1573                SpaceWeatherPolicy {
1574                    require_geomagnetic: true,
1575                    ..SpaceWeatherPolicy::default()
1576                }
1577            ),
1578            Err(SpaceWeatherError::RejectedByPolicy {
1579                class: ObservationClass::MonthlyPredicted,
1580                ..
1581            })
1582        ));
1583    }
1584
1585    #[test]
1586    fn monthly_prediction_is_piecewise_constant_and_ap_slots_fallback_to_daily() {
1587        let monthly_input = format!(
1588            "{CSV}{}",
1589            "2024-07-01,2558,27,,,,,,,,,,,,,,,,,,,,,116,160.0,157.0,PRM,140.0,151.0,147.0,148.0\n"
1590        );
1591        let table = parse_csv(&monthly_input).unwrap().value;
1592        let june_15 = table
1593            .sample_at(j2000_seconds(2024, 6, 15, 12, 0, 0.0))
1594            .expect("June monthly sample");
1595        let june_20 = table
1596            .sample_at(j2000_seconds(2024, 6, 20, 12, 0, 0.0))
1597            .expect("same monthly sample");
1598        let july_15 = table
1599            .sample_at(j2000_seconds(2024, 7, 15, 12, 0, 0.0))
1600            .expect("next monthly sample");
1601        assert_eq!(june_15, june_20);
1602        assert_ne!(june_15.space_weather.f107a, july_15.space_weather.f107a);
1603
1604        let input = format!(
1605            "{CSV_HEADER}\n{}{}{}",
1606            "2024-05-09,2556,1,23,27,30,33,40,50,47,37,287,9,12,15,18,27,48,39,22,24,1.2,5,120,165.1,162.0,OBS,150.1,149.8,147.0,146.6\n",
1607            "2024-05-10,2556,2,40,50,60,70,67,57,47,37,428,27,48,80,132,111,67,39,22,66,1.8,7,121,190.2,187.1,OBS,151.2,150.9,148.0,147.6\n",
1608            "2024-05-11,2556,3,33,30,27,23,20,17,13,10,173,18,15,12,9,,6,5,4,10,0.8,3,119,176.3,173.0,OBS,152.3,151.1,149.0,148.2\n"
1609        );
1610        let table = parse_csv(&input).unwrap().value;
1611        let ap = table
1612            .ap_array_at(j2000_seconds(2024, 5, 11, 13, 0, 0.0))
1613            .expect("AP fallback");
1614        assert_eq!(ap[1], 10.0);
1615    }
1616
1617    fn txt_row_observed() -> String {
1618        let kp = [23, 27, 30, 33, 40, 50, 47, 37];
1619        let ap = [9, 12, 15, 18, 27, 48, 39, 22];
1620        let mut row = format!("{:4}{:3}{:3}{:5}{:3}", 2024, 5, 9, 2556, 1);
1621        for value in kp {
1622            row.push_str(&format!("{value:3}"));
1623        }
1624        row.push_str(&format!("{:4}", 287));
1625        for value in ap {
1626            row.push_str(&format!("{value:4}"));
1627        }
1628        row.push_str(&format!(
1629            "{:4}{:4.1}{:2}{:4}{:6.1}{:2}{:6.1}{:6.1}{:6.1}{:6.1}{:6.1}",
1630            24, 1.2, 5, 120, 162.0, 0, 147.0, 146.6, 165.1, 150.1, 149.8
1631        ));
1632        row
1633    }
1634}