use crate::astro::atmosphere::{ApArray, DEFAULT_AP};
use crate::astro::constants::time::SECONDS_PER_DAY_I64;
use crate::astro::forces::SpaceWeather;
use crate::astro::time::civil::{
civil_from_julian_day_number, days_in_month, j2000_seconds, J2000_JULIAN_DAY_NUMBER,
J2000_NOON_OFFSET_S,
};
use crate::astro::time::scales::julian_day_number;
use crate::format::columns;
pub use crate::format::{Diagnostics, Parsed, RecordRef, Skip, SkipReason, Warning, WarningKind};
use crate::validate;
use crate::validate::FieldError;
use std::fmt::Write as _;
const 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";
const TXT_HEADER_COMMENTS: &str = "\
# --------------------------------------------------------------------------------------------------------------------------------\n\
# SPACE WEATHER DATA\n\
# --------------------------------------------------------------------------------------------------------------------------------\n\
#\n\
# See https://celestrak.org/SpaceData/SpaceWx-format.php for format details.\n\
#\n\
# FORMAT(I4,I3,I3,I5,I3,8I3,I4,8I4,I4,F4.1,I2,I4,F6.1,I2,5F6.1)\n\
# --------------------------------------------------------------------------------------------------------------------------------\n\
# Adj Adj Adj Obs Obs Obs \n\
# 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\
# --------------------------------------------------------------------------------------------------------------------------------\n\
#";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ObservationClass {
Observed,
Interpolated,
DailyPredicted,
MonthlyPredicted,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpaceWeatherDay {
pub year: i32,
pub month: u8,
pub day: u8,
pub class: ObservationClass,
pub bsrn: Option<u16>,
pub nd: Option<u8>,
pub kp_10: [Option<u16>; 8],
pub kp_sum_10: Option<u16>,
pub ap: [Option<u16>; 8],
pub ap_avg: Option<u16>,
pub cp_10: Option<u8>,
pub c9: Option<u8>,
pub isn: Option<u16>,
pub flux_qualifier: Option<u8>,
pub f107_obs: Option<f64>,
pub f107_adj: Option<f64>,
pub f107_obs_center81: Option<f64>,
pub f107_obs_last81: Option<f64>,
pub f107_adj_center81: Option<f64>,
pub f107_adj_last81: Option<f64>,
}
impl SpaceWeatherDay {
pub fn kp(&self, bin: usize) -> Option<f64> {
self.kp_10
.get(bin)
.and_then(|v| v.map(|v| f64::from(v) / 10.0))
}
pub fn cp(&self) -> Option<f64> {
self.cp_10.map(|v| f64::from(v) / 10.0)
}
fn jdn(&self) -> i64 {
julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SpaceWeatherTable {
days: Vec<SpaceWeatherDay>,
monthly: Vec<SpaceWeatherDay>,
txt_updated: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpaceWeatherCoverage {
pub first_j2000_s: f64,
pub last_observed_j2000_s: Option<f64>,
pub last_daily_predicted_j2000_s: Option<f64>,
pub end_j2000_s: f64,
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SpaceWeatherError {
#[error("unrecognized space-weather format")]
UnrecognizedFormat,
#[error("malformed space-weather input at line {line}: {reason}")]
Malformed { line: usize, reason: String },
#[error("space-weather input is not valid UTF-8")]
NotText,
#[error("space-weather lookup before coverage")]
BeforeCoverage {
requested_j2000_s: f64,
first_j2000_s: f64,
},
#[error("space-weather lookup after coverage")]
AfterCoverage {
requested_j2000_s: f64,
end_j2000_s: f64,
},
#[error("space-weather data missing {field} on {year:04}-{month:02}-{day:02}")]
MissingData {
year: i32,
month: u8,
day: u8,
field: &'static str,
},
#[error("space-weather row class rejected by policy")]
RejectedByPolicy {
class: ObservationClass,
year: i32,
month: u8,
day: u8,
},
#[error("invalid space-weather epoch")]
InvalidEpoch {
epoch_j2000_s_bits: u64,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpaceWeatherSample {
pub space_weather: SpaceWeather,
pub class: ObservationClass,
pub ap_defaulted: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpaceWeatherPolicy {
pub allow_interpolated: bool,
pub allow_daily_predicted: bool,
pub allow_monthly_predicted: bool,
pub require_geomagnetic: bool,
}
impl Default for SpaceWeatherPolicy {
fn default() -> Self {
Self {
allow_interpolated: true,
allow_daily_predicted: true,
allow_monthly_predicted: true,
require_geomagnetic: false,
}
}
}
impl SpaceWeatherTable {
pub fn days(&self) -> &[SpaceWeatherDay] {
&self.days
}
pub fn monthly(&self) -> &[SpaceWeatherDay] {
&self.monthly
}
pub fn day(&self, year: i32, month: u8, day: u8) -> Option<&SpaceWeatherDay> {
let jdn = julian_day_number(year, i32::from(month), i32::from(day));
self.day_by_jdn(jdn)
}
pub fn coverage(&self) -> SpaceWeatherCoverage {
let first_jdn = self.first_jdn().expect("nonempty table");
let end_jdn = self.end_jdn().expect("nonempty table");
SpaceWeatherCoverage {
first_j2000_s: day_start_j2000_s(first_jdn),
last_observed_j2000_s: self
.days
.iter()
.rfind(|row| matches!(row.class, ObservationClass::Observed))
.map(|row| day_start_j2000_s(row.jdn())),
last_daily_predicted_j2000_s: self
.days
.iter()
.rfind(|row| matches!(row.class, ObservationClass::DailyPredicted))
.map(|row| day_start_j2000_s(row.jdn())),
end_j2000_s: day_start_j2000_s(end_jdn),
}
}
pub fn space_weather_at(&self, epoch_j2000_s: f64) -> Result<SpaceWeather, SpaceWeatherError> {
self.sample_at(epoch_j2000_s)
.map(|sample| sample.space_weather)
}
pub fn sample_at(&self, epoch_j2000_s: f64) -> Result<SpaceWeatherSample, SpaceWeatherError> {
self.sample_at_with_policy(epoch_j2000_s, SpaceWeatherPolicy::default())
}
pub fn sample_at_with_policy(
&self,
epoch_j2000_s: f64,
policy: SpaceWeatherPolicy,
) -> Result<SpaceWeatherSample, SpaceWeatherError> {
let jdn = epoch_day_jdn(epoch_j2000_s)?;
self.check_epoch_coverage(epoch_j2000_s, jdn, true)?;
let today = self.required_day(jdn, epoch_j2000_s)?;
let previous = self.required_day(jdn - 1, epoch_j2000_s)?;
enforce_policy(today, policy)?;
enforce_policy(previous, policy)?;
let f107 = previous
.f107_obs
.ok_or_else(|| missing(previous, "F10.7_OBS"))?;
let f107a = today
.f107_obs_center81
.ok_or_else(|| missing(today, "F10.7_OBS_CENTER81"))?;
let (ap, ap_defaulted) = daily_ap(today, policy)?;
Ok(SpaceWeatherSample {
space_weather: SpaceWeather { f107, f107a, ap },
class: today.class.max(previous.class),
ap_defaulted,
})
}
pub fn ap_array_at(&self, epoch_j2000_s: f64) -> Result<ApArray, SpaceWeatherError> {
let (jdn, bin) = epoch_day_and_ap_bin(epoch_j2000_s)?;
self.check_epoch_coverage(epoch_j2000_s, jdn, false)?;
let today = self.required_day(jdn, epoch_j2000_s)?;
let (daily, _) = daily_ap(today, SpaceWeatherPolicy::default())?;
let slot = jdn * 8 + i64::from(bin);
Ok([
daily,
self.ap_slot(slot, epoch_j2000_s)?,
self.ap_slot(slot - 1, epoch_j2000_s)?,
self.ap_slot(slot - 2, epoch_j2000_s)?,
self.ap_slot(slot - 3, epoch_j2000_s)?,
self.mean_ap_slots(slot - 11, slot - 4, epoch_j2000_s)?,
self.mean_ap_slots(slot - 19, slot - 12, epoch_j2000_s)?,
])
}
fn day_by_jdn(&self, jdn: i64) -> Option<&SpaceWeatherDay> {
if let Ok(index) = self.days.binary_search_by_key(&jdn, SpaceWeatherDay::jdn) {
return self.days.get(index);
}
let index = self
.monthly
.binary_search_by_key(&jdn, SpaceWeatherDay::jdn)
.unwrap_or_else(|index| index.saturating_sub(1));
let row = self.monthly.get(index)?;
let (year, month, _day) = civil_from_julian_day_number(jdn);
if row.year == year as i32 && row.month == month as u8 {
Some(row)
} else {
None
}
}
fn required_day(
&self,
jdn: i64,
requested_j2000_s: f64,
) -> Result<&SpaceWeatherDay, SpaceWeatherError> {
self.day_by_jdn(jdn).ok_or_else(|| {
if jdn < self.first_jdn().expect("nonempty table") {
SpaceWeatherError::BeforeCoverage {
requested_j2000_s,
first_j2000_s: self.coverage().first_j2000_s,
}
} else if jdn >= self.end_jdn().expect("nonempty table") {
SpaceWeatherError::AfterCoverage {
requested_j2000_s,
end_j2000_s: self.coverage().end_j2000_s,
}
} else {
let (year, month, day) = civil_from_julian_day_number(jdn);
SpaceWeatherError::MissingData {
year: year as i32,
month: month as u8,
day: day as u8,
field: "record",
}
}
})
}
fn check_epoch_coverage(
&self,
requested_j2000_s: f64,
jdn: i64,
needs_previous_day: bool,
) -> Result<(), SpaceWeatherError> {
let first_jdn = self.first_jdn().expect("nonempty table");
let end_jdn = self.end_jdn().expect("nonempty table");
let required_first = if needs_previous_day {
first_jdn + 1
} else {
first_jdn
};
if jdn < required_first {
return Err(SpaceWeatherError::BeforeCoverage {
requested_j2000_s,
first_j2000_s: self.coverage().first_j2000_s,
});
}
if jdn >= end_jdn {
return Err(SpaceWeatherError::AfterCoverage {
requested_j2000_s,
end_j2000_s: day_start_j2000_s(end_jdn),
});
}
Ok(())
}
fn ap_slot(&self, slot: i64, requested_j2000_s: f64) -> Result<f64, SpaceWeatherError> {
let jdn = slot.div_euclid(8);
let bin = slot.rem_euclid(8) as usize;
let row = self.required_day(jdn, requested_j2000_s)?;
if let Some(ap) = row.ap[bin] {
return Ok(f64::from(ap));
}
daily_ap(row, SpaceWeatherPolicy::default()).map(|(ap, _)| ap)
}
fn mean_ap_slots(
&self,
first_slot: i64,
last_slot: i64,
requested_j2000_s: f64,
) -> Result<f64, SpaceWeatherError> {
let mut sum = 0.0;
let mut count = 0.0;
for slot in first_slot..=last_slot {
sum += self.ap_slot(slot, requested_j2000_s)?;
count += 1.0;
}
Ok(sum / count)
}
fn first_jdn(&self) -> Option<i64> {
match (self.days.first(), self.monthly.first()) {
(Some(a), Some(b)) => Some(a.jdn().min(b.jdn())),
(Some(a), None) => Some(a.jdn()),
(None, Some(b)) => Some(b.jdn()),
(None, None) => None,
}
}
fn end_jdn(&self) -> Option<i64> {
let day_end = self.days.last().map(|row| row.jdn() + 1);
let monthly_end = self.monthly.last().map(|row| {
let next_month = if row.month == 12 {
(row.year + 1, 1)
} else {
(row.year, i32::from(row.month) + 1)
};
julian_day_number(next_month.0, next_month.1, 1)
});
match (day_end, monthly_end) {
(Some(a), Some(b)) => Some(a.max(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
}
pub fn parse_csv(text: &str) -> Result<Parsed<SpaceWeatherTable>, SpaceWeatherError> {
let mut lines = text.lines();
let header = lines.next().ok_or_else(|| SpaceWeatherError::Malformed {
line: 1,
reason: "missing CSV header".to_string(),
})?;
if header.trim_end_matches('\r') != CSV_HEADER {
return Err(SpaceWeatherError::Malformed {
line: 1,
reason: "unexpected CSV header".to_string(),
});
}
let mut records = Vec::new();
let mut diagnostics = Diagnostics::new();
let mut previous_jdn = None;
for (zero_index, raw_line) in lines.enumerate() {
let line_no = zero_index + 2;
let line = raw_line.trim_end_matches('\r');
if line.trim().is_empty() {
continue;
}
match parse_csv_record(line) {
Ok(row) => {
let jdn = row.jdn();
if previous_jdn.is_some_and(|previous| jdn < previous) {
diagnostics.push_skip(skip_line(
line_no,
SkipReason::InconsistentRecord("out-of-order date"),
));
continue;
}
previous_jdn = Some(jdn);
records.push((line_no, row));
}
Err(reason) => diagnostics.push_skip(skip_line(line_no, reason)),
}
}
build_table(records, diagnostics, None)
}
pub fn parse_txt(text: &str) -> Result<Parsed<SpaceWeatherTable>, SpaceWeatherError> {
let mut saw_datatype = false;
let mut section = None;
let mut txt_updated = None;
let mut records = Vec::new();
let mut diagnostics = Diagnostics::new();
let mut observed_count = None;
let mut daily_count = None;
let mut monthly_count = None;
let mut parsed_observed = 0usize;
let mut parsed_daily = 0usize;
let mut parsed_monthly = 0usize;
let mut previous_jdn = None;
for (zero_index, raw_line) in text.lines().enumerate() {
let line_no = zero_index + 1;
let line = raw_line.trim_end_matches('\r');
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed == "DATATYPE CssiSpaceWeather" {
saw_datatype = true;
continue;
}
if trimmed.starts_with("VERSION ") {
continue;
}
if let Some(updated) = trimmed.strip_prefix("UPDATED ") {
txt_updated = Some(updated.to_string());
continue;
}
if let Some(count) = trimmed.strip_prefix("NUM_OBSERVED_POINTS ") {
observed_count = parse_count(count).map(|count| (line_no, count));
continue;
}
if let Some(count) = trimmed.strip_prefix("NUM_DAILY_PREDICTED_POINTS ") {
daily_count = parse_count(count).map(|count| (line_no, count));
continue;
}
if let Some(count) = trimmed.strip_prefix("NUM_MONTHLY_PREDICTED_POINTS ") {
monthly_count = parse_count(count).map(|count| (line_no, count));
continue;
}
match trimmed {
"BEGIN OBSERVED" => {
if section.is_some() {
return Err(SpaceWeatherError::Malformed {
line: line_no,
reason: "nested fixed-width section".to_string(),
});
}
section = Some(TxtSection::Observed);
continue;
}
"END OBSERVED" => {
if section != Some(TxtSection::Observed) {
return Err(SpaceWeatherError::Malformed {
line: line_no,
reason: "END OBSERVED without matching BEGIN".to_string(),
});
}
section = None;
continue;
}
"BEGIN DAILY_PREDICTED" => {
if section.is_some() {
return Err(SpaceWeatherError::Malformed {
line: line_no,
reason: "nested fixed-width section".to_string(),
});
}
section = Some(TxtSection::DailyPredicted);
continue;
}
"END DAILY_PREDICTED" => {
if section != Some(TxtSection::DailyPredicted) {
return Err(SpaceWeatherError::Malformed {
line: line_no,
reason: "END DAILY_PREDICTED without matching BEGIN".to_string(),
});
}
section = None;
continue;
}
"BEGIN MONTHLY_PREDICTED" => {
if section.is_some() {
return Err(SpaceWeatherError::Malformed {
line: line_no,
reason: "nested fixed-width section".to_string(),
});
}
section = Some(TxtSection::MonthlyPredicted);
continue;
}
"END MONTHLY_PREDICTED" => {
if section != Some(TxtSection::MonthlyPredicted) {
return Err(SpaceWeatherError::Malformed {
line: line_no,
reason: "END MONTHLY_PREDICTED without matching BEGIN".to_string(),
});
}
section = None;
continue;
}
_ => {}
}
let Some(active_section) = section else {
continue;
};
match parse_txt_record(line, active_section) {
Ok(row) => {
let jdn = row.jdn();
if previous_jdn.is_some_and(|previous| jdn < previous) {
diagnostics.push_skip(skip_line(
line_no,
SkipReason::InconsistentRecord("out-of-order date"),
));
continue;
}
previous_jdn = Some(jdn);
match active_section {
TxtSection::Observed => parsed_observed += 1,
TxtSection::DailyPredicted => parsed_daily += 1,
TxtSection::MonthlyPredicted => parsed_monthly += 1,
}
records.push((line_no, row));
}
Err(error) => {
diagnostics.push_skip(skip_line(line_no, SkipReason::MalformedField(error)))
}
}
}
if !saw_datatype {
return Err(SpaceWeatherError::UnrecognizedFormat);
}
if section.is_some() {
return Err(SpaceWeatherError::Malformed {
line: text.lines().count(),
reason: "unterminated fixed-width section".to_string(),
});
}
warn_count_mismatch(observed_count, parsed_observed, &mut diagnostics);
warn_count_mismatch(daily_count, parsed_daily, &mut diagnostics);
warn_count_mismatch(monthly_count, parsed_monthly, &mut diagnostics);
build_table(records, diagnostics, txt_updated)
}
pub fn parse(data: &[u8]) -> Result<Parsed<SpaceWeatherTable>, SpaceWeatherError> {
let text = std::str::from_utf8(data).map_err(|_| SpaceWeatherError::NotText)?;
let first_content = text
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with('#'))
.ok_or(SpaceWeatherError::UnrecognizedFormat)?;
if first_content == CSV_HEADER {
parse_csv(text)
} else if first_content == "DATATYPE CssiSpaceWeather" {
parse_txt(text)
} else {
Err(SpaceWeatherError::UnrecognizedFormat)
}
}
fn parse_csv_record(line: &str) -> Result<SpaceWeatherDay, SkipReason> {
let fields: Vec<_> = line.split(',').collect();
if fields.len() != 31 {
return Err(if fields.len() < 31 {
SkipReason::Truncated
} else {
SkipReason::InconsistentRecord("CSV column count")
});
}
let (year, month, day) = parse_csv_date(fields[0]).map_err(SkipReason::MalformedField)?;
let class = parse_csv_class(fields[26]).map_err(SkipReason::MalformedField)?;
let mut kp_10 = [None; 8];
for (idx, slot) in kp_10.iter_mut().enumerate() {
*slot = opt_u16(fields[3 + idx], "KP").map_err(SkipReason::MalformedField)?;
}
let mut ap = [None; 8];
for (idx, slot) in ap.iter_mut().enumerate() {
*slot = opt_u16(fields[12 + idx], "AP").map_err(SkipReason::MalformedField)?;
}
Ok(SpaceWeatherDay {
year,
month,
day,
class,
bsrn: opt_u16(fields[1], "BSRN").map_err(SkipReason::MalformedField)?,
nd: opt_u8(fields[2], "ND").map_err(SkipReason::MalformedField)?,
kp_10,
kp_sum_10: opt_u16(fields[11], "KP_SUM").map_err(SkipReason::MalformedField)?,
ap,
ap_avg: opt_u16(fields[20], "AP_AVG").map_err(SkipReason::MalformedField)?,
cp_10: opt_cp_10(fields[21]).map_err(SkipReason::MalformedField)?,
c9: opt_u8(fields[22], "C9").map_err(SkipReason::MalformedField)?,
isn: opt_u16(fields[23], "ISN").map_err(SkipReason::MalformedField)?,
flux_qualifier: None,
f107_obs: opt_f64(fields[24], "F10.7_OBS").map_err(SkipReason::MalformedField)?,
f107_adj: opt_f64(fields[25], "F10.7_ADJ").map_err(SkipReason::MalformedField)?,
f107_obs_center81: opt_f64(fields[27], "F10.7_OBS_CENTER81")
.map_err(SkipReason::MalformedField)?,
f107_obs_last81: opt_f64(fields[28], "F10.7_OBS_LAST81")
.map_err(SkipReason::MalformedField)?,
f107_adj_center81: opt_f64(fields[29], "F10.7_ADJ_CENTER81")
.map_err(SkipReason::MalformedField)?,
f107_adj_last81: opt_f64(fields[30], "F10.7_ADJ_LAST81")
.map_err(SkipReason::MalformedField)?,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TxtSection {
Observed,
DailyPredicted,
MonthlyPredicted,
}
fn parse_txt_record(line: &str, section: TxtSection) -> Result<SpaceWeatherDay, FieldError> {
let year = req_i32_col(line, 0, 4, "year")?;
let month = req_u8_col(line, 4, 7, "month")?;
let day = req_u8_col(line, 7, 10, "day")?;
validate_date(year, month, day)?;
let mut pos = 10;
let bsrn = opt_u16_col(line, pos, pos + 5, "BSRN")?;
pos += 5;
let nd = opt_u8_col(line, pos, pos + 3, "ND")?;
pos += 3;
let mut kp_10 = [None; 8];
for slot in &mut kp_10 {
*slot = opt_u16_col(line, pos, pos + 3, "KP")?;
pos += 3;
}
let kp_sum_10 = opt_u16_col(line, pos, pos + 4, "KP_SUM")?;
pos += 4;
let mut ap = [None; 8];
for slot in &mut ap {
*slot = opt_u16_col(line, pos, pos + 4, "AP")?;
pos += 4;
}
let ap_avg = opt_u16_col(line, pos, pos + 4, "AP_AVG")?;
pos += 4;
let cp_10 = opt_cp_10_col(line, pos, pos + 4)?;
pos += 4;
let c9 = opt_u8_col(line, pos, pos + 2, "C9")?;
pos += 2;
let isn = opt_u16_col(line, pos, pos + 4, "ISN")?;
pos += 4;
let f107_adj = opt_f64_col(line, pos, pos + 6, "F10.7_ADJ")?;
pos += 6;
let flux_qualifier = opt_u8_col(line, pos, pos + 2, "Q")?;
pos += 2;
let f107_adj_center81 = opt_f64_col(line, pos, pos + 6, "F10.7_ADJ_CENTER81")?;
pos += 6;
let f107_adj_last81 = opt_f64_col(line, pos, pos + 6, "F10.7_ADJ_LAST81")?;
pos += 6;
let f107_obs = opt_f64_col(line, pos, pos + 6, "F10.7_OBS")?;
pos += 6;
let f107_obs_center81 = opt_f64_col(line, pos, pos + 6, "F10.7_OBS_CENTER81")?;
pos += 6;
let f107_obs_last81 = opt_f64_col(line, pos, pos + 6, "F10.7_OBS_LAST81")?;
let class = match section {
TxtSection::Observed if flux_qualifier == Some(4) => ObservationClass::Interpolated,
TxtSection::Observed => ObservationClass::Observed,
TxtSection::DailyPredicted => ObservationClass::DailyPredicted,
TxtSection::MonthlyPredicted => ObservationClass::MonthlyPredicted,
};
Ok(SpaceWeatherDay {
year,
month,
day,
class,
bsrn,
nd,
kp_10,
kp_sum_10,
ap,
ap_avg,
cp_10,
c9,
isn,
flux_qualifier,
f107_obs,
f107_adj,
f107_obs_center81,
f107_obs_last81,
f107_adj_center81,
f107_adj_last81,
})
}
fn build_table(
records: Vec<(usize, SpaceWeatherDay)>,
mut diagnostics: Diagnostics,
txt_updated: Option<String>,
) -> Result<Parsed<SpaceWeatherTable>, SpaceWeatherError> {
let mut days = Vec::new();
let mut monthly = Vec::new();
for (line, row) in records {
let target = if row.class == ObservationClass::MonthlyPredicted {
&mut monthly
} else {
&mut days
};
if target
.last()
.is_some_and(|existing: &SpaceWeatherDay| existing.jdn() == row.jdn())
{
diagnostics.push_skip(skip_line(
line,
SkipReason::InconsistentRecord("duplicate date"),
));
continue;
}
target.push(row);
}
days.sort_by_key(SpaceWeatherDay::jdn);
monthly.sort_by_key(SpaceWeatherDay::jdn);
if days.is_empty() && monthly.is_empty() {
return Err(SpaceWeatherError::Malformed {
line: 1,
reason: "no parseable space-weather rows".to_string(),
});
}
Ok(Parsed::new(
SpaceWeatherTable {
days,
monthly,
txt_updated,
},
diagnostics,
))
}
pub fn encode_csv(table: &SpaceWeatherTable) -> String {
let mut out = String::new();
out.push_str(CSV_HEADER);
out.push('\n');
for row in table.days.iter().chain(table.monthly.iter()) {
push_csv_row(&mut out, row);
}
out
}
pub fn encode_txt(table: &SpaceWeatherTable) -> String {
let mut out = String::new();
out.push_str("DATATYPE CssiSpaceWeather\n");
out.push_str("VERSION 1.2\n");
if let Some(updated) = &table.txt_updated {
let _ = writeln!(out, "UPDATED {updated}");
}
out.push_str(TXT_HEADER_COMMENTS);
out.push('\n');
let observed_count = table
.days
.iter()
.filter(|row| {
matches!(
row.class,
ObservationClass::Observed | ObservationClass::Interpolated
)
})
.count();
let daily_count = table
.days
.iter()
.filter(|row| row.class == ObservationClass::DailyPredicted)
.count();
write_txt_section(
&mut out,
"OBSERVED",
observed_count,
table.days.iter().filter(|row| {
matches!(
row.class,
ObservationClass::Observed | ObservationClass::Interpolated
)
}),
);
out.push('\n');
write_txt_section(
&mut out,
"DAILY_PREDICTED",
daily_count,
table
.days
.iter()
.filter(|row| row.class == ObservationClass::DailyPredicted),
);
out.push('\n');
write_txt_section(
&mut out,
"MONTHLY_PREDICTED",
table.monthly.len(),
table.monthly.iter(),
);
out
}
fn push_csv_row(out: &mut String, row: &SpaceWeatherDay) {
let class = match row.class {
ObservationClass::Observed => "OBS",
ObservationClass::Interpolated => "INT",
ObservationClass::DailyPredicted => "PRD",
ObservationClass::MonthlyPredicted => "PRM",
};
let _ = write!(out, "{:04}-{:02}-{:02}", row.year, row.month, row.day);
push_csv_opt_u16(out, row.bsrn);
push_csv_opt_u8(out, row.nd);
for value in row.kp_10 {
push_csv_opt_u16(out, value);
}
push_csv_opt_u16(out, row.kp_sum_10);
for value in row.ap {
push_csv_opt_u16(out, value);
}
push_csv_opt_u16(out, row.ap_avg);
push_csv_opt_cp(out, row.cp_10);
push_csv_opt_u8(out, row.c9);
push_csv_opt_u16(out, row.isn);
push_csv_opt_f64(out, row.f107_obs);
push_csv_opt_f64(out, row.f107_adj);
out.push(',');
out.push_str(class);
push_csv_opt_f64(out, row.f107_obs_center81);
push_csv_opt_f64(out, row.f107_obs_last81);
push_csv_opt_f64(out, row.f107_adj_center81);
push_csv_opt_f64(out, row.f107_adj_last81);
out.push('\n');
}
fn push_csv_opt_u16(out: &mut String, value: Option<u16>) {
out.push(',');
if let Some(value) = value {
let _ = write!(out, "{value}");
}
}
fn push_csv_opt_u8(out: &mut String, value: Option<u8>) {
out.push(',');
if let Some(value) = value {
let _ = write!(out, "{value}");
}
}
fn push_csv_opt_cp(out: &mut String, value: Option<u8>) {
out.push(',');
if let Some(value) = value {
let _ = write!(out, "{:.1}", f64::from(value) / 10.0);
}
}
fn push_csv_opt_f64(out: &mut String, value: Option<f64>) {
out.push(',');
if let Some(value) = value {
let _ = write!(out, "{value:.1}");
}
}
fn write_txt_section<'a, I>(out: &mut String, name: &str, count: usize, rows: I)
where
I: IntoIterator<Item = &'a SpaceWeatherDay>,
{
let _ = writeln!(out, "NUM_{name}_POINTS {count}");
let _ = writeln!(out, "BEGIN {name}");
for row in rows {
push_txt_row(out, row);
}
let _ = writeln!(out, "END {name}");
}
fn push_txt_row(out: &mut String, row: &SpaceWeatherDay) {
let _ = write!(out, "{:4} {:02} {:02}", row.year, row.month, row.day);
push_txt_opt_u16(out, row.bsrn, 5);
push_txt_opt_u8(out, row.nd, 3);
for value in row.kp_10 {
push_txt_opt_u16(out, value, 3);
}
push_txt_opt_u16(out, row.kp_sum_10, 4);
for value in row.ap {
push_txt_opt_u16(out, value, 4);
}
push_txt_opt_u16(out, row.ap_avg, 4);
push_txt_opt_cp(out, row.cp_10);
push_txt_opt_u8(out, row.c9, 2);
push_txt_opt_u16(out, row.isn, 4);
push_txt_opt_f64(out, row.f107_adj, 6);
push_txt_opt_u8(out, row.flux_qualifier, 2);
push_txt_opt_f64(out, row.f107_adj_center81, 6);
push_txt_opt_f64(out, row.f107_adj_last81, 6);
push_txt_opt_f64(out, row.f107_obs, 6);
push_txt_opt_f64(out, row.f107_obs_center81, 6);
push_txt_opt_f64(out, row.f107_obs_last81, 6);
out.push('\n');
}
fn push_txt_opt_u16(out: &mut String, value: Option<u16>, width: usize) {
if let Some(value) = value {
let _ = write!(out, "{value:width$}");
} else {
push_spaces(out, width);
}
}
fn push_txt_opt_u8(out: &mut String, value: Option<u8>, width: usize) {
if let Some(value) = value {
let _ = write!(out, "{value:width$}");
} else {
push_spaces(out, width);
}
}
fn push_txt_opt_cp(out: &mut String, value: Option<u8>) {
if let Some(value) = value {
let _ = write!(out, "{:4.1}", f64::from(value) / 10.0);
} else {
push_spaces(out, 4);
}
}
fn push_txt_opt_f64(out: &mut String, value: Option<f64>, width: usize) {
if let Some(value) = value {
let formatted = format!("{value:.1}");
let _ = write!(out, "{formatted:>width$}");
} else {
push_spaces(out, width);
}
}
fn push_spaces(out: &mut String, count: usize) {
for _ in 0..count {
out.push(' ');
}
}
fn parse_csv_date(text: &str) -> Result<(i32, u8, u8), FieldError> {
let mut parts = text.split('-');
let year = parse_required(parts.next(), "year")?;
let month = parse_required(parts.next(), "month")?;
let day = parse_required(parts.next(), "day")?;
if parts.next().is_some() {
return Err(FieldError::InvalidCivilDate {
field: "DATE",
year: i64::from(year),
month: i64::from(month),
day: i64::from(day),
});
}
validate_date(year, month, day)?;
Ok((year, month, day))
}
fn parse_csv_class(text: &str) -> Result<ObservationClass, FieldError> {
match text.trim() {
"OBS" => Ok(ObservationClass::Observed),
"INT" => Ok(ObservationClass::Interpolated),
"PRD" => Ok(ObservationClass::DailyPredicted),
"PRM" => Ok(ObservationClass::MonthlyPredicted),
value => Err(FieldError::IntParse {
field: "F10.7_DATA_TYPE",
value: value.to_string(),
}),
}
}
fn validate_date(year: i32, month: u8, day: u8) -> Result<(), FieldError> {
let days = days_in_month(i64::from(year), i64::from(month));
if days == 0 || day == 0 || i64::from(day) > days {
return Err(FieldError::InvalidCivilDate {
field: "DATE",
year: i64::from(year),
month: i64::from(month),
day: i64::from(day),
});
}
Ok(())
}
fn opt_u16(text: &str, field: &'static str) -> Result<Option<u16>, FieldError> {
opt_parse(text, field)
}
fn opt_u8(text: &str, field: &'static str) -> Result<Option<u8>, FieldError> {
opt_parse(text, field)
}
fn opt_f64(text: &str, field: &'static str) -> Result<Option<f64>, FieldError> {
let value = text.trim();
if value.is_empty() {
Ok(None)
} else {
validate::strict_f64(value, field).map(Some)
}
}
fn opt_cp_10(text: &str) -> Result<Option<u8>, FieldError> {
opt_f64(text, "CP").map(|value| value.map(|cp| (cp * 10.0).round() as u8))
}
fn opt_parse<T>(text: &str, field: &'static str) -> Result<Option<T>, FieldError>
where
T: std::str::FromStr,
{
let value = text.trim();
if value.is_empty() {
Ok(None)
} else {
validate::strict_int(value, field).map(Some)
}
}
fn parse_required<T>(value: Option<&str>, field: &'static str) -> Result<T, FieldError>
where
T: std::str::FromStr,
{
validate::strict_int(value.unwrap_or_default(), field)
}
fn req_i32_col(
line: &str,
start: usize,
end: usize,
field: &'static str,
) -> Result<i32, FieldError> {
validate::strict_int(columns::field(line, start, end).unwrap_or_default(), field)
}
fn req_u8_col(line: &str, start: usize, end: usize, field: &'static str) -> Result<u8, FieldError> {
validate::strict_int(columns::field(line, start, end).unwrap_or_default(), field)
}
fn opt_u16_col(
line: &str,
start: usize,
end: usize,
field: &'static str,
) -> Result<Option<u16>, FieldError> {
columns::field(line, start, end).map_or(Ok(None), |value| opt_u16(value, field))
}
fn opt_u8_col(
line: &str,
start: usize,
end: usize,
field: &'static str,
) -> Result<Option<u8>, FieldError> {
columns::field(line, start, end).map_or(Ok(None), |value| opt_u8(value, field))
}
fn opt_f64_col(
line: &str,
start: usize,
end: usize,
field: &'static str,
) -> Result<Option<f64>, FieldError> {
columns::field(line, start, end).map_or(Ok(None), |value| opt_f64(value, field))
}
fn opt_cp_10_col(line: &str, start: usize, end: usize) -> Result<Option<u8>, FieldError> {
columns::field(line, start, end).map_or(Ok(None), opt_cp_10)
}
fn parse_count(text: &str) -> Option<usize> {
text.trim().parse::<usize>().ok()
}
fn warn_count_mismatch(
declared: Option<(usize, usize)>,
actual: usize,
diagnostics: &mut Diagnostics,
) {
if let Some((line, _)) = declared.filter(|(_, declared)| *declared != actual) {
diagnostics.push_warning(Warning {
at: RecordRef::at_line(line),
kind: WarningKind::Mismatch,
});
}
}
fn skip_line(line: usize, reason: SkipReason) -> Skip {
Skip {
at: RecordRef::at_line(line),
reason,
}
}
fn enforce_policy(
row: &SpaceWeatherDay,
policy: SpaceWeatherPolicy,
) -> Result<(), SpaceWeatherError> {
let allowed = match row.class {
ObservationClass::Observed => true,
ObservationClass::Interpolated => policy.allow_interpolated,
ObservationClass::DailyPredicted => policy.allow_daily_predicted,
ObservationClass::MonthlyPredicted => policy.allow_monthly_predicted,
};
if allowed {
Ok(())
} else {
Err(SpaceWeatherError::RejectedByPolicy {
class: row.class,
year: row.year,
month: row.month,
day: row.day,
})
}
}
fn daily_ap(
row: &SpaceWeatherDay,
policy: SpaceWeatherPolicy,
) -> Result<(f64, bool), SpaceWeatherError> {
if let Some(ap) = row.ap_avg {
return Ok((f64::from(ap), false));
}
if row.class == ObservationClass::MonthlyPredicted {
if policy.require_geomagnetic {
return Err(SpaceWeatherError::RejectedByPolicy {
class: row.class,
year: row.year,
month: row.month,
day: row.day,
});
}
return Ok((DEFAULT_AP, true));
}
Err(missing(row, "AP_AVG"))
}
fn missing(row: &SpaceWeatherDay, field: &'static str) -> SpaceWeatherError {
SpaceWeatherError::MissingData {
year: row.year,
month: row.month,
day: row.day,
field,
}
}
fn epoch_day_jdn(epoch_j2000_s: f64) -> Result<i64, SpaceWeatherError> {
if !epoch_j2000_s.is_finite() {
return Err(SpaceWeatherError::InvalidEpoch {
epoch_j2000_s_bits: epoch_j2000_s.to_bits(),
});
}
let floor_second = epoch_j2000_s.floor();
if floor_second < i64::MIN as f64 || floor_second > i64::MAX as f64 {
return Err(SpaceWeatherError::InvalidEpoch {
epoch_j2000_s_bits: epoch_j2000_s.to_bits(),
});
}
let from_midnight = floor_second as i64 + J2000_NOON_OFFSET_S;
let day_index = from_midnight.div_euclid(SECONDS_PER_DAY_I64);
let jdn = day_index + J2000_JULIAN_DAY_NUMBER;
let min_jdn = julian_day_number(0, 1, 1);
let max_jdn = julian_day_number(9999, 12, 31);
if !(min_jdn..=max_jdn).contains(&jdn) {
return Err(SpaceWeatherError::InvalidEpoch {
epoch_j2000_s_bits: epoch_j2000_s.to_bits(),
});
}
Ok(jdn)
}
fn epoch_day_and_ap_bin(epoch_j2000_s: f64) -> Result<(i64, u8), SpaceWeatherError> {
let jdn = epoch_day_jdn(epoch_j2000_s)?;
let floor_second = epoch_j2000_s.floor() as i64;
let from_midnight = floor_second + J2000_NOON_OFFSET_S;
let second_of_day = from_midnight.rem_euclid(SECONDS_PER_DAY_I64);
Ok((jdn, (second_of_day / (3 * 3600)) as u8))
}
fn day_start_j2000_s(jdn: i64) -> f64 {
let (year, month, day) = civil_from_julian_day_number(jdn);
j2000_seconds(year as i32, month as i32, day as i32, 0, 0, 0.0)
}
#[cfg(test)]
mod tests {
use super::*;
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\
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\
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\
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\
2024-06-01,2557,24,,,,,,,,,,,,,,,,,,,,,118,171.0,168.0,PRM,153.0,152.0,150.0,149.0\n";
#[test]
fn parses_csv_and_serves_drag_weather() {
let parsed = parse_csv(CSV).expect("csv parses");
assert!(parsed.diagnostics.is_empty());
let table = parsed.value;
assert_eq!(table.days().len(), 3);
assert_eq!(table.monthly().len(), 1);
assert_eq!(
table.day(2024, 6, 15).unwrap().class,
ObservationClass::MonthlyPredicted
);
let epoch = j2000_seconds(2024, 5, 10, 12, 0, 0.0);
let sample = table.sample_at(epoch).expect("sample");
assert_eq!(
sample.space_weather,
SpaceWeather {
f107: 165.1,
f107a: 151.2,
ap: 66.0,
}
);
assert_eq!(sample.class, ObservationClass::Observed);
assert!(!sample.ap_defaulted);
}
#[test]
fn monthly_region_defaults_ap_and_can_be_rejected() {
let table = parse_csv(CSV).unwrap().value;
let epoch = j2000_seconds(2024, 6, 15, 0, 0, 0.0);
let sample = table.sample_at(epoch).expect("monthly sample");
assert_eq!(sample.space_weather.f107, 171.0);
assert_eq!(sample.space_weather.f107a, 153.0);
assert_eq!(sample.space_weather.ap, DEFAULT_AP);
assert!(sample.ap_defaulted);
let policy = SpaceWeatherPolicy {
require_geomagnetic: true,
..SpaceWeatherPolicy::default()
};
assert!(matches!(
table.sample_at_with_policy(epoch, policy),
Err(SpaceWeatherError::RejectedByPolicy {
class: ObservationClass::MonthlyPredicted,
..
})
));
}
#[test]
fn ap_array_crosses_day_boundaries() {
let table = parse_csv(CSV).unwrap().value;
let epoch = j2000_seconds(2024, 5, 11, 13, 0, 0.0);
let ap = table.ap_array_at(epoch).expect("ap array");
assert_eq!(ap[0], 10.0);
assert_eq!(ap[1], 7.0);
assert_eq!(ap[2], 9.0);
assert_eq!(ap[3], 12.0);
assert_eq!(ap[4], 15.0);
assert_eq!(
ap[5],
(18.0 + 22.0 + 39.0 + 67.0 + 111.0 + 132.0 + 80.0 + 48.0) / 8.0
);
assert_eq!(
ap[6],
(12.0 + 15.0 + 18.0 + 27.0 + 48.0 + 39.0 + 22.0 + 27.0) / 8.0
);
}
#[test]
fn parse_sniffs_utf8_and_format() {
assert!(matches!(parse(b"\xff"), Err(SpaceWeatherError::NotText)));
assert!(matches!(
parse(b"not cssi"),
Err(SpaceWeatherError::UnrecognizedFormat)
));
assert!(matches!(
parse(b"not a header\nCssiSpaceWeather"),
Err(SpaceWeatherError::UnrecognizedFormat)
));
assert_eq!(parse(CSV.as_bytes()).unwrap().value.days().len(), 3);
}
#[test]
fn parser_diagnostics_cover_bad_duplicate_and_ordered_rows() {
let truncated = format!("{CSV}2024-05-12,bad\n");
let parsed = parse_csv(&truncated).expect("forgiving parse");
assert_eq!(parsed.value.days().len(), 3);
assert!(matches!(
parsed.diagnostics.skips[0].reason,
SkipReason::Truncated
));
let bad_kp = format!(
"{CSV}{}",
"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"
);
let parsed = parse_csv(&bad_kp).expect("forgiving parse");
assert_eq!(parsed.value.days().len(), 3);
assert!(matches!(
parsed.diagnostics.skips[0].reason,
SkipReason::MalformedField(FieldError::IntParse { field: "KP", .. })
));
let duplicate = format!(
"{CSV_HEADER}\n{}{}",
"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",
"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"
);
let parsed = parse_csv(&duplicate).expect("forgiving duplicate");
assert_eq!(parsed.value.days().len(), 1);
assert!(matches!(
parsed.diagnostics.skips[0].reason,
SkipReason::InconsistentRecord("duplicate date")
));
let out_of_order = format!(
"{CSV_HEADER}\n{}{}",
"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",
"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"
);
let parsed = parse_csv(&out_of_order).expect("forgiving order check");
assert_eq!(parsed.value.days().len(), 1);
assert!(matches!(
parsed.diagnostics.skips[0].reason,
SkipReason::InconsistentRecord("out-of-order date")
));
}
#[test]
fn txt_count_mismatch_warns_without_rejecting_observed_only() {
let text = format!(
"DATATYPE CssiSpaceWeather\nVERSION 1.2\nNUM_OBSERVED_POINTS 2\nBEGIN OBSERVED\n{}\nEND OBSERVED\n",
txt_row_observed()
);
let parsed = parse_txt(&text).expect("txt parses with warning");
assert_eq!(parsed.value.days().len(), 1);
assert_eq!(parsed.value.monthly().len(), 0);
assert_eq!(parsed.diagnostics.warnings.len(), 1);
assert_eq!(parsed.value.coverage().last_daily_predicted_j2000_s, None);
}
#[test]
fn parses_fixed_width_sections() {
let text = format!(
"DATATYPE CssiSpaceWeather\nVERSION 1.2\nNUM_OBSERVED_POINTS 1\nBEGIN OBSERVED\n{}\nEND OBSERVED\n",
txt_row_observed()
);
let parsed = parse_txt(&text).expect("txt parses");
assert!(parsed.diagnostics.is_empty());
let row = parsed.value.day(2024, 5, 9).unwrap();
assert_eq!(row.class, ObservationClass::Observed);
assert_eq!(row.flux_qualifier, Some(0));
assert_eq!(row.f107_obs, Some(165.1));
assert_eq!(row.f107_obs_center81, Some(150.1));
assert_eq!(row.f107_adj, Some(162.0));
assert_eq!(row.f107_adj_center81, Some(147.0));
}
#[test]
fn observed_only_file_has_exclusive_end_no_holdover() {
let input = format!(
"{CSV_HEADER}\n{}{}",
"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",
"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"
);
let table = parse_csv(&input).unwrap().value;
assert_eq!(table.coverage().last_daily_predicted_j2000_s, None);
assert!(matches!(
table.sample_at(j2000_seconds(2024, 5, 11, 0, 0, 0.0)),
Err(SpaceWeatherError::AfterCoverage { .. })
));
}
#[test]
fn gap_days_and_boundaries_report_typed_errors() {
let input = format!(
"{CSV_HEADER}\n{}{}",
"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",
"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"
);
let table = parse_csv(&input).unwrap().value;
assert!(matches!(
table.sample_at(j2000_seconds(2024, 5, 10, 12, 0, 0.0)),
Err(SpaceWeatherError::MissingData {
year: 2024,
month: 5,
day: 10,
field: "record"
})
));
assert!(matches!(
table.sample_at(j2000_seconds(2024, 5, 11, 12, 0, 0.0)),
Err(SpaceWeatherError::MissingData {
year: 2024,
month: 5,
day: 10,
field: "record"
})
));
let table = parse_csv(CSV).unwrap().value;
let boundary = table
.sample_at(j2000_seconds(2024, 5, 10, 0, 0, 0.0))
.expect("day boundary belongs to starting day");
assert_eq!(boundary.space_weather.f107, 165.1);
assert_eq!(boundary.space_weather.ap, 66.0);
assert!(matches!(
table.sample_at(j2000_seconds(2024, 5, 9, 0, 0, 0.0)),
Err(SpaceWeatherError::BeforeCoverage { .. })
));
assert!(matches!(
table.sample_at(f64::NAN),
Err(SpaceWeatherError::InvalidEpoch { .. })
));
}
#[test]
fn policy_rejects_each_non_observed_class_and_geomagnetic_default() {
let input = format!(
"{CSV_HEADER}\n{}{}{}{}",
"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",
"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",
"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",
"2024-06-01,2557,24,,,,,,,,,,,,,,,,,,,,,118,171.0,168.0,PRM,153.0,152.0,150.0,149.0\n"
);
let table = parse_csv(&input).unwrap().value;
assert!(matches!(
table.sample_at_with_policy(
j2000_seconds(2024, 5, 10, 12, 0, 0.0),
SpaceWeatherPolicy {
allow_interpolated: false,
..SpaceWeatherPolicy::default()
}
),
Err(SpaceWeatherError::RejectedByPolicy {
class: ObservationClass::Interpolated,
..
})
));
assert!(matches!(
table.sample_at_with_policy(
j2000_seconds(2024, 5, 11, 12, 0, 0.0),
SpaceWeatherPolicy {
allow_daily_predicted: false,
..SpaceWeatherPolicy::default()
}
),
Err(SpaceWeatherError::RejectedByPolicy {
class: ObservationClass::DailyPredicted,
..
})
));
assert!(matches!(
table.sample_at_with_policy(
j2000_seconds(2024, 6, 15, 12, 0, 0.0),
SpaceWeatherPolicy {
allow_monthly_predicted: false,
..SpaceWeatherPolicy::default()
}
),
Err(SpaceWeatherError::RejectedByPolicy {
class: ObservationClass::MonthlyPredicted,
..
})
));
assert!(matches!(
table.sample_at_with_policy(
j2000_seconds(2024, 6, 15, 12, 0, 0.0),
SpaceWeatherPolicy {
require_geomagnetic: true,
..SpaceWeatherPolicy::default()
}
),
Err(SpaceWeatherError::RejectedByPolicy {
class: ObservationClass::MonthlyPredicted,
..
})
));
}
#[test]
fn monthly_prediction_is_piecewise_constant_and_ap_slots_fallback_to_daily() {
let monthly_input = format!(
"{CSV}{}",
"2024-07-01,2558,27,,,,,,,,,,,,,,,,,,,,,116,160.0,157.0,PRM,140.0,151.0,147.0,148.0\n"
);
let table = parse_csv(&monthly_input).unwrap().value;
let june_15 = table
.sample_at(j2000_seconds(2024, 6, 15, 12, 0, 0.0))
.expect("June monthly sample");
let june_20 = table
.sample_at(j2000_seconds(2024, 6, 20, 12, 0, 0.0))
.expect("same monthly sample");
let july_15 = table
.sample_at(j2000_seconds(2024, 7, 15, 12, 0, 0.0))
.expect("next monthly sample");
assert_eq!(june_15, june_20);
assert_ne!(june_15.space_weather.f107a, july_15.space_weather.f107a);
let input = format!(
"{CSV_HEADER}\n{}{}{}",
"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",
"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",
"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"
);
let table = parse_csv(&input).unwrap().value;
let ap = table
.ap_array_at(j2000_seconds(2024, 5, 11, 13, 0, 0.0))
.expect("AP fallback");
assert_eq!(ap[1], 10.0);
}
fn txt_row_observed() -> String {
let kp = [23, 27, 30, 33, 40, 50, 47, 37];
let ap = [9, 12, 15, 18, 27, 48, 39, 22];
let mut row = format!("{:4}{:3}{:3}{:5}{:3}", 2024, 5, 9, 2556, 1);
for value in kp {
row.push_str(&format!("{value:3}"));
}
row.push_str(&format!("{:4}", 287));
for value in ap {
row.push_str(&format!("{value:4}"));
}
row.push_str(&format!(
"{:4}{:4.1}{:2}{:4}{:6.1}{:2}{:6.1}{:6.1}{:6.1}{:6.1}{:6.1}",
24, 1.2, 5, 120, 162.0, 0, 147.0, 146.6, 165.1, 150.1, 149.8
));
row
}
}