1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
#![cfg(feature = "mpc_80_col")]
use camino::{Utf8Path, Utf8PathBuf};
use crate::{
ObsDatasetBuilder, TrajId,
io::mpc_80_col::{Mpc80ColError, parse_mpc_80_col_file},
observation_dataset::ObsDataset,
};
impl ObsDataset {
/// Register an alternate designation that resolves to `primary`.
///
/// Intended for use by ingestion backends; not part of the public API.
pub(crate) fn register_alias(&mut self, alias: String, primary: TrajId) {
self.index.register_alias(alias, primary);
}
/// Build an [`ObsDataset`] by reading an **MPC 80-column** observation file.
///
/// The format is the fixed-width ASCII format distributed by the Minor
/// Planet Center: each observation occupies exactly 80 columns. Both
/// numbered objects (columns 1–5) and provisionally designated objects
/// (columns 6–12) are supported, and a single file may contain
/// observations for **multiple trajectories**.
///
/// ## Errors
///
/// Returns [`Mpc80ColError::Io`] if the file cannot be read, or
/// [`Mpc80ColError::InvalidLine`] if a line cannot be parsed.
pub fn from_mpc_80_col(path: impl AsRef<Utf8Path>) -> Result<ObsDataset, Mpc80ColError> {
parse_mpc_80_col_file(path.as_ref(), 0)
}
/// Build an [`ObsDataset`] from **multiple** MPC 80-column files.
///
/// Files that cannot be parsed are skipped; their errors are collected in
/// the second element of the returned tuple.
///
/// # Arguments
///
/// - `paths` — slice of paths to MPC 80-column files to load.
pub fn from_mpc_80_col_files<P: AsRef<Utf8Path>>(
paths: &[P],
) -> (Self, Vec<(Utf8PathBuf, Mpc80ColError)>) {
let mut dataset: Option<ObsDataset> = None;
let mut errors: Vec<(Utf8PathBuf, Mpc80ColError)> = Vec::new();
for path in paths {
let start_id = dataset
.as_ref()
.map_or(0, |ds| ds.observation_count() as u64);
match parse_mpc_80_col_file(path.as_ref(), start_id) {
Ok(other) => {
if let Some(ds) = dataset.take() {
// IDs are globally unique: merge_from cannot fail here.
dataset = Some(
ds.merge_from(other)
.expect("IDs are globally unique: merge_from cannot fail"),
);
} else {
dataset = Some(other);
}
}
Err(e) => errors.push((path.as_ref().to_owned(), e)),
}
}
let ds = dataset.unwrap_or_else(ObsDataset::empty);
(ds, errors)
}
/// Merge observations from **multiple** MPC 80-column files into `self`.
///
/// Files that cannot be parsed are skipped; their errors are returned.
///
/// # Arguments
///
/// - `paths` — slice of paths to MPC 80-column files.
pub fn extend_from_mpc_80_col<P: AsRef<Utf8Path>>(
mut self,
paths: &[P],
) -> (Self, Vec<(Utf8PathBuf, Mpc80ColError)>) {
let mut errors: Vec<(Utf8PathBuf, Mpc80ColError)> = Vec::new();
for path in paths {
let start_id = self.observation_count() as u64;
match parse_mpc_80_col_file(path.as_ref(), start_id) {
// IDs are globally unique: merge_from cannot fail here.
Ok(other) => {
self = self
.merge_from(other)
.expect("IDs are globally unique: merge_from cannot fail");
}
Err(e) => errors.push((path.as_ref().to_owned(), e)),
}
}
(self, errors)
}
}
impl ObsDatasetBuilder {
/// Load one or more MPC 80-column files and merge their observations.
///
/// Files that raise a [`Mpc80ColError`]
/// are skipped and appended to the internal warning list.
///
/// # Arguments
///
/// - `paths` — slice of paths to MPC 80-column observation files to load.
pub fn add_mpc_80_col<P: AsRef<Utf8Path>>(mut self, paths: &[P]) -> Self {
for path in paths {
let start_id = self
.dataset
.as_ref()
.map_or(0, |ds| ds.observation_count() as u64);
match crate::io::mpc_80_col::parse_mpc_80_col_file(path.as_ref(), start_id) {
Ok(other) => {
if let Some(ds) = self.dataset.take() {
// IDs are globally unique: merge_from cannot fail here.
self.dataset = Some(
ds.merge_from(other)
.expect("IDs are globally unique: merge_from cannot fail"),
);
} else {
self.dataset = Some(other);
}
}
Err(error) => {
use crate::LoadWarning;
self.warnings.push(LoadWarning::MpcFile {
path: path.as_ref().to_owned(),
error,
});
}
}
}
self
}
}
#[cfg(test)]
mod mpc_80_col_index_consistency_tests {
use crate::observation_dataset::ObsDataset;
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()
);
}
}
fn fixture(name: &str) -> String {
format!("{}/tests/data/{}", env!("CARGO_MANIFEST_DIR"), name)
}
/// Loading a single MPC 80-column file satisfies the index-consistency invariant.
#[test]
fn index_consistency_from_mpc_80_col() {
let path_str = fixture("8467.obs");
let ds = ObsDataset::from_mpc_80_col(path_str).expect("8467.obs must parse without error");
assert_index_consistency(&ds);
}
/// Loading multiple MPC 80-column files satisfies the index-consistency invariant.
#[test]
fn index_consistency_from_mpc_80_col_files() {
let path1_str = fixture("8467.obs");
let path2_str = fixture("33803.obs");
let (ds, errors) = ObsDataset::from_mpc_80_col_files(&[path1_str, path2_str]);
assert!(
errors.is_empty(),
"no parse errors expected loading mpc fixtures, got: {errors:?}"
);
assert_index_consistency(&ds);
}
/// Extending a dataset with MPC 80-column files satisfies the index-consistency invariant.
#[test]
fn index_consistency_extend_from_mpc_80_col() {
let path1_str = fixture("8467.obs");
let path2_str = fixture("33803.obs");
let base =
ObsDataset::from_mpc_80_col(path1_str).expect("8467.obs must parse without error");
let (extended, errors) = base.extend_from_mpc_80_col(&[path2_str]);
assert!(
errors.is_empty(),
"no parse errors expected when extending with mpc fixture, got: {errors:?}"
);
assert_index_consistency(&extended);
}
}