1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
44pub enum ObservationClass {
45 Observed,
47 Interpolated,
49 DailyPredicted,
51 MonthlyPredicted,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq)]
60pub struct SpaceWeatherDay {
61 pub year: i32,
63 pub month: u8,
65 pub day: u8,
67 pub class: ObservationClass,
69 pub bsrn: Option<u16>,
71 pub nd: Option<u8>,
73 pub kp_10: [Option<u16>; 8],
75 pub kp_sum_10: Option<u16>,
77 pub ap: [Option<u16>; 8],
79 pub ap_avg: Option<u16>,
81 pub cp_10: Option<u8>,
83 pub c9: Option<u8>,
85 pub isn: Option<u16>,
87 pub flux_qualifier: Option<u8>,
89 pub f107_obs: Option<f64>,
91 pub f107_adj: Option<f64>,
93 pub f107_obs_center81: Option<f64>,
95 pub f107_obs_last81: Option<f64>,
97 pub f107_adj_center81: Option<f64>,
99 pub f107_adj_last81: Option<f64>,
101}
102
103impl SpaceWeatherDay {
104 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 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#[derive(Debug, Clone, PartialEq)]
123pub struct SpaceWeatherTable {
124 days: Vec<SpaceWeatherDay>,
125 monthly: Vec<SpaceWeatherDay>,
126 txt_updated: Option<String>,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq)]
131pub struct SpaceWeatherCoverage {
132 pub first_j2000_s: f64,
134 pub last_observed_j2000_s: Option<f64>,
136 pub last_daily_predicted_j2000_s: Option<f64>,
138 pub end_j2000_s: f64,
140}
141
142#[derive(Debug, Clone, PartialEq, thiserror::Error)]
144pub enum SpaceWeatherError {
145 #[error("unrecognized space-weather format")]
147 UnrecognizedFormat,
148 #[error("malformed space-weather input at line {line}: {reason}")]
150 Malformed { line: usize, reason: String },
151 #[error("space-weather input is not valid UTF-8")]
153 NotText,
154 #[error("space-weather lookup before coverage")]
156 BeforeCoverage {
157 requested_j2000_s: f64,
159 first_j2000_s: f64,
161 },
162 #[error("space-weather lookup after coverage")]
164 AfterCoverage {
165 requested_j2000_s: f64,
167 end_j2000_s: f64,
169 },
170 #[error("space-weather data missing {field} on {year:04}-{month:02}-{day:02}")]
172 MissingData {
173 year: i32,
175 month: u8,
177 day: u8,
179 field: &'static str,
181 },
182 #[error("space-weather row class rejected by policy")]
184 RejectedByPolicy {
185 class: ObservationClass,
187 year: i32,
189 month: u8,
191 day: u8,
193 },
194 #[error("invalid space-weather epoch")]
196 InvalidEpoch {
197 epoch_j2000_s_bits: u64,
199 },
200}
201
202#[derive(Debug, Clone, Copy, PartialEq)]
204pub struct SpaceWeatherSample {
205 pub space_weather: SpaceWeather,
207 pub class: ObservationClass,
209 pub ap_defaulted: bool,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq)]
215pub struct SpaceWeatherPolicy {
216 pub allow_interpolated: bool,
218 pub allow_daily_predicted: bool,
220 pub allow_monthly_predicted: bool,
222 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 pub fn days(&self) -> &[SpaceWeatherDay] {
240 &self.days
241 }
242
243 pub fn monthly(&self) -> &[SpaceWeatherDay] {
245 &self.monthly
246 }
247
248 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 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 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 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 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 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
463pub 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
505pub 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
656pub 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
845pub 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
856pub 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}