pub mod builder;
pub(crate) mod index;
pub mod iter;
pub mod observation;
#[cfg(feature = "ades")]
pub mod ades;
#[cfg(feature = "mpc_80_col")]
pub mod mpc_80_col;
#[cfg(feature = "parallel")]
pub mod parallel;
#[cfg(feature = "polars")]
pub mod polars;
use std::fmt;
#[cfg(feature = "polars")]
use crate::io::polars::error::PolarsError;
#[cfg(feature = "datafusion")]
pub mod datafusion;
use ahash::AHashSet;
use thiserror::Error;
use crate::{
TrajId,
observation_dataset::{
index::{NightIndexMap, ObsDatasetIndex, ObsIndex, ObservationIndexMap, TrajIndexMap},
observation::{Observation, ObservationInput},
},
observer::{
Observer,
dataset::{ObserverDataset, ObserverId},
error_model::{ErrorModelParseError, ObsErrorModel},
mpc::MPCError,
},
};
pub type ObsId = u64;
#[derive(Debug, Error)]
pub enum ObsDatasetError {
#[error(transparent)]
MPCError(#[from] MPCError),
#[error(transparent)]
ErrorModelError(#[from] ErrorModelParseError),
#[error("The error model has not been initialised")]
ErrorModelNotFound,
#[cfg(feature = "polars")]
#[error(transparent)]
PolarIoError(#[from] PolarsError),
#[error("duplicate ObsId(s) detected during merge: {0:?}")]
DuplicateObsIds(Vec<ObsId>),
}
#[derive(Debug, Clone)]
pub struct ObsDataset {
pub(crate) observations: Vec<Observation>,
pub(crate) index: ObsDatasetIndex,
pub(crate) observer_dataset: ObserverDataset,
}
impl Default for ObsDataset {
fn default() -> Self {
Self::empty()
}
}
impl fmt::Display for ObsDataset {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let n_obs = self.observations.len();
let (mjd_min, mjd_max) = self
.observations
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), o| {
(lo.min(o.mjd_tt), hi.max(o.mjd_tt))
});
let (mjd_min, mjd_max) = if n_obs == 0 {
(0.0_f64, 0.0_f64)
} else {
(mjd_min, mjd_max)
};
let delta_days = mjd_max - mjd_min;
let n_custom = self.observer_dataset.custom_observers.len();
let n_mpc = self
.observations
.iter()
.filter_map(|o| o.observer.as_ref())
.filter(|id| matches!(id, ObserverId::MpcCode(_)))
.collect::<std::collections::HashSet<_>>()
.len();
let n_nights = self
.index
.obs_index_by_night
.as_ref()
.map(|m| m.len())
.unwrap_or(0);
let n_traj = self
.index
.obs_index_by_trajectory
.as_ref()
.map(|m| m.len())
.unwrap_or(0);
let n_aliases = self.index.traj_aliases.len();
let error_model = self
.observer_dataset
.mpc_error_model
.as_ref()
.map(|m| format!("{m}"))
.unwrap_or_else(|| "none".to_string());
if f.alternate() {
write!(
f,
"ObsDataset [{n_obs} obs | {mjd_min:.2}–{mjd_max:.2} MJD \
| {n_nights} nights | {n_traj} traj | {} observers]",
n_custom + n_mpc,
)
} else {
let nights_str = if self.index.obs_index_by_night.is_some() {
format!("{n_nights}")
} else {
"— (no night index)".to_string()
};
let traj_str = if self.index.obs_index_by_trajectory.is_some() {
if n_aliases > 0 {
format!("{n_traj} ({n_aliases} aliases)")
} else {
format!("{n_traj}")
}
} else {
"— (no trajectory index)".to_string()
};
writeln!(f, "ObsDataset — {n_obs} observations")?;
if n_obs > 0 {
writeln!(
f,
" Epoch range : {mjd_min:.6} – {mjd_max:.6} MJD (TT) [Δ = {delta_days:.2} days]"
)?;
} else {
writeln!(f, " Epoch range : — (empty dataset)")?;
}
writeln!(f, " Nights : {nights_str}")?;
writeln!(f, " Trajectories: {traj_str}")?;
writeln!(f, " Observers : {n_custom} custom | {n_mpc} MPC codes")?;
write!(f, " Error model : {error_model}")
}
}
}
impl ObsDataset {
pub fn empty() -> Self {
Self::new(vec![], vec![], None, None, None)
}
pub fn push_observation(
mut self,
new_obs: Vec<ObservationInput>,
) -> Result<(Self, Vec<ObsIndex>), ObsDatasetError> {
let mut obs_index_result = Vec::with_capacity(new_obs.len());
let duplicates = self.find_duplicate_obs_ids(&new_obs);
if !duplicates.is_empty() {
return Err(ObsDatasetError::DuplicateObsIds(duplicates));
}
let new_observer_dataset = ObserverDataset::new(
new_obs
.iter()
.filter_map(|o| o.observer)
.collect::<AHashSet<_>>()
.into_iter()
.filter_map(|id| match id {
ObserverId::MpcCode(_) => None,
ObserverId::IntId(_) => self.observer_dataset.get(&id).cloned(),
})
.collect(),
None,
);
let offset = self.observations.len();
let custom_offset = self
.observer_dataset
.merge_custom_observers(new_observer_dataset);
let placed: Vec<Observation> = new_obs
.into_iter()
.enumerate()
.map(|(local_idx, mut input)| {
let abs_idx = offset + local_idx;
obs_index_result.push(abs_idx);
self.index.obs_index_by_id.insert(input.id, abs_idx);
if let Some(ObserverId::IntId(ref mut i)) = input.observer {
*i += custom_offset;
}
Observation::place(input, abs_idx)
})
.collect();
self.observations.extend(placed);
Ok((self, obs_index_result))
}
pub fn push_observer(mut self, observer: Observer) -> (Self, ObserverId) {
let offset = self.observer_dataset.custom_observers.len();
self.observer_dataset.custom_observers.push(observer);
(self, ObserverId::IntId(offset))
}
pub fn get_observation(&self, id: ObsId) -> Option<&Observation> {
let idx = self.index.get_by_id(&id)?;
self.observations.get(idx)
}
pub fn get_obs_by_index(&self, idx: ObsIndex) -> Option<&Observation> {
self.observations.get(idx)
}
pub fn observation_count(&self) -> usize {
self.observations.len()
}
pub fn resolve_alias(&self, alias: &str) -> Option<&TrajId> {
self.index.resolve_alias(alias)
}
#[allow(dead_code)]
pub(crate) fn index_ref(&self) -> &ObsDatasetIndex {
&self.index
}
pub fn push_new_trajectory(
mut self,
traj_id: TrajId,
obs_indices: &[Observation],
) -> Result<Self, ObsDatasetError> {
let index_with_new_traj = self.index.push_trajectory(
traj_id,
&(obs_indices
.iter()
.map(|obs| obs.index())
.collect::<Vec<ObsIndex>>()),
);
self.index = index_with_new_traj;
Ok(self)
}
pub fn push_new_trajectory_by_index(
mut self,
traj_id: TrajId,
obs_indices: &[ObsIndex],
) -> Self {
let index_with_new_traj = self.index.push_trajectory(traj_id, obs_indices);
self.index = index_with_new_traj;
self
}
pub fn get_observer(&self, id: ObsId) -> Option<&Observer> {
let observer_id = self.get_observation(id)?.observer?;
self.observer_dataset.get(&observer_id)
}
#[cfg_attr(not(feature = "polars"), allow(dead_code))]
pub(crate) fn new(
observations: Vec<ObservationInput>,
custom_observers: Vec<Observer>,
error_model: Option<ObsErrorModel>,
obs_index_by_night: Option<NightIndexMap>,
obs_index_by_trajectory: Option<TrajIndexMap>,
) -> Self {
let mut obs_index_by_id = ObservationIndexMap::with_capacity(observations.len());
let placed: Vec<Observation> = observations
.into_iter()
.enumerate()
.map(|(idx, input)| {
obs_index_by_id.insert(input.id, idx);
Observation::place(input, idx)
})
.collect();
Self {
observations: placed,
index: ObsDatasetIndex::new(
obs_index_by_id,
obs_index_by_night,
obs_index_by_trajectory,
),
observer_dataset: ObserverDataset::new(custom_observers, error_model),
}
}
#[cfg(feature = "serde")]
pub(crate) fn new_from_parts(
observations: Vec<ObservationInput>,
observer_dataset: ObserverDataset,
obs_index_by_night: Option<NightIndexMap>,
obs_index_by_trajectory: Option<TrajIndexMap>,
traj_aliases: index::TrajAliasMap,
) -> Self {
let mut obs_index_by_id = ObservationIndexMap::with_capacity(observations.len());
let placed: Vec<Observation> = observations
.into_iter()
.enumerate()
.map(|(idx, input)| {
obs_index_by_id.insert(input.id, idx);
Observation::place(input, idx)
})
.collect();
let mut dataset_index =
ObsDatasetIndex::new(obs_index_by_id, obs_index_by_night, obs_index_by_trajectory);
dataset_index.set_aliases(traj_aliases);
Self {
observations: placed,
index: dataset_index,
observer_dataset,
}
}
pub fn merge_from(mut self, other: ObsDataset) -> Result<Self, ObsDatasetError> {
let duplicates: Vec<ObsId> = other
.observations
.iter()
.filter_map(|obs| {
if self.index.get_by_id(&obs.id).is_some() {
Some(obs.id)
} else {
None
}
})
.collect();
if !duplicates.is_empty() {
return Err(ObsDatasetError::DuplicateObsIds(duplicates));
}
let offset = self.observations.len();
let custom_offset = self
.observer_dataset
.merge_custom_observers(other.observer_dataset);
let mut merged = self.push_observations_from(other.observations, offset, custom_offset);
merged.index.merge_from(other.index, offset);
Ok(merged)
}
fn find_duplicate_obs_ids(&self, other: &[ObservationInput]) -> Vec<ObsId> {
other
.iter()
.filter_map(|obs| {
if self.index.get_by_id(&obs.id).is_some() {
Some(obs.id)
} else {
None
}
})
.collect()
}
fn push_observations_from(
mut self,
observations: Vec<Observation>,
offset: usize,
custom_offset: usize,
) -> Self {
self.observations.reserve(observations.len());
for (local_idx, mut obs) in observations.into_iter().enumerate() {
if let Some(ObserverId::IntId(ref mut i)) = obs.observer {
*i += custom_offset;
}
self.observations.push(obs.reindex(offset + local_idx));
}
self
}
}
#[cfg(test)]
mod observation_tests {
use super::*;
use crate::{
coordinates::equatorial::EquCoord,
observer::{Observer, dataset::ObserverId, error_model::ObsErrorModel},
photometry::{Filter, Photometry},
};
use std::collections::HashSet;
fn make_equ_coord() -> EquCoord {
EquCoord::new(0.5, 1e-5, 0.2, 1e-5)
}
fn make_photometry() -> Photometry {
Photometry {
magnitude: 15.0,
error: 0.1,
filter: Filter::String("G".to_string()),
}
}
fn make_observation(id: u64, observer: Option<ObserverId>) -> ObservationInput {
ObservationInput {
id,
equ_coord: make_equ_coord(),
photometry: make_photometry(),
mjd_tt: 60000.5,
observer,
}
}
fn make_custom_observer() -> Observer {
Observer::from_parallax(110.0, 0.836, 0.547, Some("Test".to_string()), None, None).unwrap()
}
fn make_dataset(obs: Vec<ObservationInput>, observers: Vec<Observer>) -> ObsDataset {
ObsDataset::new(obs, observers, Some(ObsErrorModel::FCCT14), None, None)
}
mod observer_id {
use super::*;
#[test]
fn observer_id_int_is_copy() {
let original = ObserverId::IntId(3);
let copy = original; assert_eq!(original, copy);
}
#[test]
fn observer_id_mpc_code_is_copy() {
let original = ObserverId::MpcCode(*b"G96");
let copy = original;
assert_eq!(original, copy);
}
#[test]
fn observer_id_int_same_index_is_eq() {
assert_eq!(ObserverId::IntId(0), ObserverId::IntId(0));
}
#[test]
fn observer_id_int_ordering_by_index() {
assert!(ObserverId::IntId(0) < ObserverId::IntId(1));
}
#[test]
fn observer_id_int_less_than_mpc_code() {
assert!(ObserverId::IntId(usize::MAX) < ObserverId::MpcCode(*b"000"));
}
#[test]
fn observer_id_int_debug_contains_index() {
let id = ObserverId::IntId(42);
let debug_str = format!("{id:?}");
assert!(
debug_str.contains("42"),
"Debug output should contain '42', got: {debug_str}"
);
}
#[test]
fn observer_id_mpc_code_debug_contains_code() {
let id = ObserverId::MpcCode(*b"G96");
let debug_str = format!("{id:?}");
assert!(
!debug_str.is_empty(),
"Debug output should not be empty for MpcCode variant"
);
}
#[test]
fn observer_id_can_be_inserted_into_hash_set() {
let mut set: HashSet<ObserverId> = HashSet::new();
set.insert(ObserverId::IntId(0));
set.insert(ObserverId::IntId(1));
set.insert(ObserverId::IntId(0)); assert_eq!(set.len(), 2);
}
}
mod obs_dataset_new {
use super::*;
#[test]
fn new_empty_with_none_cache_size_does_not_panic() {
let _ds = ObsDataset::new(vec![], vec![], Some(ObsErrorModel::FCCT14), None, None);
}
#[test]
fn new_empty_with_custom_cache_size_does_not_panic() {
let _ds = ObsDataset::new(vec![], vec![], Some(ObsErrorModel::FCCT14), None, None);
}
#[test]
fn new_empty_has_zero_observations() {
let ds = make_dataset(vec![], vec![]);
assert_eq!(ds.iter_observations().count(), 0);
}
#[test]
fn new_with_observations_has_correct_count() {
let obs = vec![
make_observation(1, None),
make_observation(2, None),
make_observation(3, None),
];
let ds = make_dataset(obs, vec![]);
assert_eq!(ds.iter_observations().count(), 3);
}
}
mod iter_observations {
use super::*;
#[test]
fn iter_on_empty_dataset_yields_nothing() {
let ds = make_dataset(vec![], vec![]);
assert_eq!(ds.iter_observations().count(), 0);
}
#[test]
fn iter_yields_observations_in_insertion_order() {
let obs = vec![
make_observation(10, None),
make_observation(20, None),
make_observation(30, None),
];
let ds = make_dataset(obs, vec![]);
let ids: Vec<ObsId> = ds.iter_observations().map(|o| o.id).collect();
assert_eq!(ids, vec![10, 20, 30]);
}
#[test]
fn iter_single_observation_yields_one_item() {
let ds = make_dataset(vec![make_observation(99, None)], vec![]);
assert_eq!(ds.iter_observations().count(), 1);
}
#[test]
fn iter_yields_correct_id() {
let ds = make_dataset(vec![make_observation(42, None)], vec![]);
let first = ds.iter_observations().next();
assert!(first.is_some(), "Expected at least one observation");
assert_eq!(first.unwrap().id, 42);
}
}
mod get_observation {
use super::*;
#[test]
fn get_observation_returns_some_for_existing_id() {
let ds = make_dataset(vec![make_observation(1, None)], vec![]);
assert!(ds.get_observation(1).is_some());
}
#[test]
fn get_observation_returns_none_for_missing_id() {
let ds = make_dataset(vec![make_observation(1, None)], vec![]);
assert!(ds.get_observation(9999).is_none());
}
#[test]
fn get_observation_repeated_calls_return_same_id() {
let ds = make_dataset(vec![make_observation(7, None)], vec![]);
let first_id = ds.get_observation(7).map(|o| o.id);
let second_id = ds.get_observation(7).map(|o| o.id);
assert_eq!(first_id, second_id);
}
#[test]
fn get_observation_returns_correct_one_among_multiple() {
let obs = vec![
make_observation(1, None),
make_observation(2, None),
make_observation(3, None),
];
let ds = make_dataset(obs, vec![]);
let found = ds.get_observation(2);
assert!(found.is_some(), "Expected Some for id=2");
assert_eq!(found.unwrap().id, 2);
}
#[test]
fn get_observation_repeated_calls_still_findable() {
let obs = vec![make_observation(1, None), make_observation(2, None)];
let ds = ObsDataset::new(obs, vec![], Some(ObsErrorModel::FCCT14), None, None);
assert!(ds.get_observation(1).is_some());
assert!(ds.get_observation(2).is_some());
assert!(
ds.get_observation(1).is_some(),
"id=1 should still be findable"
);
}
}
mod get_observer {
use super::*;
#[test]
fn get_observer_returns_none_for_missing_obs_id() {
let ds = make_dataset(vec![], vec![]);
assert!(ds.get_observer(9999).is_none());
}
#[test]
fn get_observer_returns_none_when_observer_is_none() {
let obs = vec![make_observation(1, None)];
let ds = make_dataset(obs, vec![]);
assert!(ds.get_observer(1).is_none());
}
#[test]
fn get_observer_returns_some_for_int_id_zero() {
let custom = make_custom_observer();
let obs = vec![make_observation(1, Some(ObserverId::IntId(0)))];
let ds = make_dataset(obs, vec![custom]);
assert!(
ds.get_observer(1).is_some(),
"Expected Some(observer) for ObserverId::IntId(0)"
);
}
#[test]
fn get_observer_returns_correct_observer_for_int_id() {
let custom = make_custom_observer();
let expected_name = custom.name.clone();
let obs = vec![make_observation(1, Some(ObserverId::IntId(0)))];
let ds = make_dataset(obs, vec![custom]);
let found = ds.get_observer(1).unwrap(); assert_eq!(
found.name, expected_name,
"Observer name should match the inserted observer"
);
}
#[test]
fn get_observer_returns_none_for_int_id_out_of_bounds() {
let obs = vec![make_observation(1, Some(ObserverId::IntId(5)))];
let custom = make_custom_observer();
let ds = make_dataset(obs, vec![custom]);
assert!(
ds.get_observer(1).is_none(),
"Expected None for ObserverId::IntId out of bounds"
);
}
#[test]
fn get_observer_returns_correct_observer_among_multiple() {
let obs1 =
Observer::from_parallax(10.0, 0.8, 0.5, Some("First".to_string()), None, None)
.unwrap(); let obs2 =
Observer::from_parallax(20.0, 0.9, 0.4, Some("Second".to_string()), None, None)
.unwrap();
let obs = vec![
make_observation(1, Some(ObserverId::IntId(0))),
make_observation(2, Some(ObserverId::IntId(1))),
];
let ds = make_dataset(obs, vec![obs1, obs2]);
let name_for_obs1 = ds.get_observer(1).and_then(|o| o.name.clone());
let name_for_obs2 = ds.get_observer(2).and_then(|o| o.name.clone());
assert_eq!(name_for_obs1.as_deref(), Some("First"));
assert_eq!(name_for_obs2.as_deref(), Some("Second"));
}
}
mod obs_dataset_error {
use super::*;
#[test]
fn obs_dataset_error_display_error_model_error_is_non_empty() {
use crate::observer::error_model::ErrorModelParseError;
let inner = ErrorModelParseError::NomParsingError("bad line".to_string());
let err = ObsDatasetError::ErrorModelError(inner);
let display = format!("{err}");
assert!(
!display.is_empty(),
"Display output for ErrorModelError should not be empty"
);
}
#[test]
fn obs_dataset_error_display_contains_meaningful_text() {
use crate::observer::error_model::ErrorModelParseError;
let inner = ErrorModelParseError::NomParsingError("bad line".to_string());
let err = ObsDatasetError::ErrorModelError(inner);
let display = format!("{err}");
assert!(
display.contains("bad line"),
"Display output should contain the inner error text, got: {display}"
);
}
#[test]
fn obs_dataset_error_debug_is_non_empty() {
use crate::observer::error_model::ErrorModelParseError;
let inner = ErrorModelParseError::NomParsingError("x".to_string());
let err = ObsDatasetError::ErrorModelError(inner);
let debug = format!("{err:?}");
assert!(!debug.is_empty(), "Debug output should not be empty");
}
#[test]
fn obs_dataset_error_from_mpc_error_display_is_non_empty() {
use crate::observer::error_model::ErrorModelParseError;
let inner = ErrorModelParseError::InvalidStationCode("TOOLONG".to_string());
let err = ObsDatasetError::ErrorModelError(inner);
let display = format!("{err}");
assert!(
!display.is_empty(),
"Display for ObsDatasetError wrapping ErrorModelError must be non-empty"
);
}
#[test]
fn obs_dataset_error_error_model_variant_display_is_non_empty() {
use crate::observer::error_model::ErrorModelParseError;
let inner = ErrorModelParseError::InvalidStationCode("BAD".to_string());
let err: ObsDatasetError = inner.into();
let s = format!("{err}");
assert!(!s.is_empty());
}
}
mod merge_from {
use super::*;
#[test]
fn merge_disjoint_datasets_succeeds() {
let ds1 = make_dataset(vec![make_observation(1, None)], vec![]);
let ds2 = make_dataset(vec![make_observation(2, None)], vec![]);
let merged = ds1.merge_from(ds2).unwrap();
assert_eq!(merged.observation_count(), 2);
}
#[test]
fn merge_does_not_modify_obs_id() {
let ds1 = make_dataset(vec![make_observation(10, None)], vec![]);
let ds2 = make_dataset(vec![make_observation(20, None)], vec![]);
let merged = ds1.merge_from(ds2).unwrap();
let ids: Vec<ObsId> = merged.iter_observations().map(|o| o.id).collect();
assert!(
ids.contains(&10),
"id 10 must be present unchanged after merge"
);
assert!(
ids.contains(&20),
"id 20 must be present unchanged after merge"
);
}
#[test]
fn merge_with_duplicate_obs_id_returns_err() {
let ds1 = make_dataset(
vec![make_observation(1, None), make_observation(2, None)],
vec![],
);
let ds2 = make_dataset(
vec![make_observation(2, None), make_observation(3, None)],
vec![],
);
let result = ds1.merge_from(ds2);
assert!(result.is_err(), "expected Err for duplicate ObsId");
match result.unwrap_err() {
ObsDatasetError::DuplicateObsIds(ids) => {
assert_eq!(ids, vec![2], "colliding id must be reported");
}
other => panic!("unexpected error variant: {other:?}"),
}
}
#[test]
fn merge_reports_all_duplicate_obs_ids() {
let ds1 = make_dataset(
vec![
make_observation(1, None),
make_observation(2, None),
make_observation(3, None),
],
vec![],
);
let ds2 = make_dataset(
vec![make_observation(2, None), make_observation(3, None)],
vec![],
);
let result = ds1.merge_from(ds2);
match result.unwrap_err() {
ObsDatasetError::DuplicateObsIds(mut ids) => {
ids.sort_unstable();
assert_eq!(ids, vec![2, 3]);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn merge_all_observations_reachable_by_id() {
let ds1 = make_dataset(vec![make_observation(1, None)], vec![]);
let ds2 = make_dataset(
vec![make_observation(2, None), make_observation(3, None)],
vec![],
);
let merged = ds1.merge_from(ds2).unwrap();
assert!(merged.get_observation(1).is_some());
assert!(merged.get_observation(2).is_some());
assert!(merged.get_observation(3).is_some());
}
#[test]
fn merge_custom_observer_remapped_correctly() {
let obs1 = make_custom_observer();
let obs2 =
Observer::from_parallax(50.0, 0.7, 0.6, Some("Second".to_string()), None, None)
.unwrap();
let ds1 = make_dataset(
vec![make_observation(1, Some(ObserverId::IntId(0)))],
vec![obs1],
);
let ds2 = make_dataset(
vec![make_observation(2, Some(ObserverId::IntId(0)))],
vec![obs2],
);
let merged = ds1.merge_from(ds2).unwrap();
let name = merged.get_observer(2).and_then(|o| o.name.clone());
assert_eq!(
name.as_deref(),
Some("Second"),
"observer for obs id=2 must resolve to the second observer"
);
}
}
mod index_consistency {
use super::*;
fn assert_index_consistency(dataset: &ObsDataset) {
for (idx, obs) in dataset.iter_observations().enumerate() {
assert_eq!(
idx,
obs.index(),
"index-consistency violated: enumeration position {idx} != obs.index() {}",
obs.index()
);
}
}
#[test]
fn index_consistency_empty_dataset() {
let ds = make_dataset(vec![], vec![]);
assert_index_consistency(&ds);
}
#[test]
fn index_consistency_single_observation() {
let ds = make_dataset(vec![make_observation(0, None)], vec![]);
assert_index_consistency(&ds);
}
#[test]
fn index_consistency_five_observations() {
let obs = (0u64..5).map(|i| make_observation(i, None)).collect();
let ds = make_dataset(obs, vec![]);
assert_index_consistency(&ds);
}
#[test]
fn index_consistency_fifty_observations() {
let obs = (0u64..50).map(|i| make_observation(i, None)).collect();
let ds = make_dataset(obs, vec![]);
assert_index_consistency(&ds);
}
#[test]
fn index_consistency_push_to_empty_dataset() {
let ds = make_dataset(vec![], vec![]);
let (ds, _) = ds
.push_observation(vec![make_observation(0, None)])
.expect("push_observation must succeed for unique ids");
assert_index_consistency(&ds);
}
#[test]
fn index_consistency_push_multiple_to_empty_dataset() {
let ds = make_dataset(vec![], vec![]);
let new_obs: Vec<ObservationInput> =
(0u64..5).map(|i| make_observation(i, None)).collect();
let (ds, _) = ds
.push_observation(new_obs)
.expect("push_observation must succeed for unique ids");
assert_index_consistency(&ds);
}
#[test]
fn index_consistency_push_to_non_empty_dataset() {
let initial: Vec<ObservationInput> =
(0u64..3).map(|i| make_observation(i, None)).collect();
let ds = make_dataset(initial, vec![]);
let extra: Vec<ObservationInput> =
(3u64..7).map(|i| make_observation(i, None)).collect();
let (ds, _) = ds
.push_observation(extra)
.expect("push_observation must succeed for unique ids");
assert_index_consistency(&ds);
}
#[test]
fn index_consistency_merge_from_disjoint_datasets() {
let obs_a: Vec<ObservationInput> =
(0u64..4).map(|i| make_observation(i, None)).collect();
let obs_b: Vec<ObservationInput> =
(4u64..9).map(|i| make_observation(i, None)).collect();
let ds_a = make_dataset(obs_a, vec![]);
let ds_b = make_dataset(obs_b, vec![]);
let merged = ds_a
.merge_from(ds_b)
.expect("disjoint datasets must merge without error");
assert_index_consistency(&merged);
}
}
mod index_consistency_proptest {
use super::*;
use proptest::prelude::*;
fn assert_index_consistency(dataset: &ObsDataset) {
for (idx, obs) in dataset.iter_observations().enumerate() {
assert_eq!(
idx,
obs.index(),
"index-consistency violated: enumeration position {idx} != obs.index() {}",
obs.index()
);
}
}
proptest! {
#[test]
fn prop_index_consistency_n_observations(n in 0usize..=200) {
let obs: Vec<ObservationInput> =
(0u64..n as u64).map(|i| make_observation(i, None)).collect();
let ds = make_dataset(obs, vec![]);
assert_index_consistency(&ds);
}
#[test]
fn prop_index_consistency_merge_from(a in 0usize..=100, b in 0usize..=100) {
let obs_a: Vec<ObservationInput> =
(0u64..a as u64).map(|i| make_observation(i, None)).collect();
let obs_b: Vec<ObservationInput> =
(a as u64..(a + b) as u64).map(|i| make_observation(i, None)).collect();
let ds_a = make_dataset(obs_a, vec![]);
let ds_b = make_dataset(obs_b, vec![]);
let merged = ds_a
.merge_from(ds_b)
.expect("disjoint datasets must merge without error");
assert_index_consistency(&merged);
}
#[test]
fn prop_index_consistency_push_observation(n in 0usize..=100, m in 0usize..=100) {
let initial: Vec<ObservationInput> =
(0u64..n as u64).map(|i| make_observation(i, None)).collect();
let ds = make_dataset(initial, vec![]);
let extra: Vec<ObservationInput> =
(n as u64..(n + m) as u64).map(|i| make_observation(i, None)).collect();
let (ds, _) = ds.push_observation(extra)
.expect("push_observation must succeed for unique ids");
assert_index_consistency(&ds);
}
}
}
mod error_model_parse_error_variants {
use crate::observer::error_model::ErrorModelParseError;
#[test]
fn nom_parsing_error_display_is_non_empty() {
let err = ErrorModelParseError::NomParsingError("broken line".to_string());
let s = format!("{err}");
assert!(!s.is_empty());
}
#[test]
fn invalid_station_code_display_contains_code() {
let err = ErrorModelParseError::InvalidStationCode("TOOLONG".to_string());
let s = format!("{err}");
assert!(
s.contains("TOOLONG"),
"Display should mention the bad code, got: {s}"
);
}
}
}