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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
// SPDX-License-Identifier: AGPL-3.0-only
//! A reader for DSN Orbit Data Files (ODF), format TRK-2-18 (JPL/DSN document 820-13, module
//! TRK-2-18; the revision archived with the MRO radio-science data as
//! `NAV023-ODF-2-18_Rev3.htm`).
//!
//! An ODF is a sequence of 36-byte logical records: nine big-endian 32-bit words. They fall
//! into groups, and each group starts with a header record whose first word is the group's
//! primary key:
//!
//! | Key | Group |
//! |---|---|
//! | 101 | file label |
//! | 107 | identifier |
//! | 109 | orbit data |
//! | 2030 | ramp, one group per station (the station number is the secondary key) |
//! | 2040 | clock offsets |
//! | 105 | data summary |
//! | −1 | end of file |
//!
//! Words 5 and 6 of a header record are zero, and those of a data record are not, which is how
//! the reader tells them apart.
//!
//! An orbit-data record holds:
//!
//! - the time tag: integer seconds, and milliseconds in the first 10 bits of word 2;
//! - the receiving station's downlink delay (22 bits, ns);
//! - the observable: an integer part and a fractional part in units of 1e-9;
//! - a packed word: format, receiving and transmitting station, network, data type, band
//! identifiers and data validity;
//! - channel, spacecraft and receiver/exciter flag;
//! - the reference frequency, a 46-bit field split 22/24 in mHz;
//! - the compression time (0.01 s);
//! - the transmitting station's uplink delay.
//!
//! A ramp record holds start and end times, the ramp rate and the ramp start frequency. Times
//! are seconds since 1950-01-01T00:00:00 UTC.
/// Seconds since 1950-01-01T00:00:00 UTC.
pub type Seconds1950 = f64;
/// One orbit-data record.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OdfObservation {
/// Time tag (s since 1950, UTC).
pub time_s: Seconds1950,
/// Primary receiving station downlink delay (ns).
pub downlink_delay_ns: u32,
/// The observable in the units of its data type (Hz for Doppler, range units for range).
pub observable: f64,
/// Format identifier (2 for this revision).
pub format_id: u8,
/// Receiving station.
pub rx_station: u8,
/// Transmitting station (0 for one-way data).
pub tx_station: u8,
/// Network of the transmitting station.
pub network_id: u8,
/// Data type (11, 12, 13 one-, two-, three-way Doppler; 36, 37, 41 range; ...).
pub data_type: u8,
/// Downlink band (1 S, 2 X, 3 Ka; 0 Ku or not applicable).
pub downlink_band: u8,
/// Uplink band.
pub uplink_band: u8,
/// Exciter band.
pub exciter_band: u8,
/// True when the record is flagged bad.
pub invalid: bool,
/// Channel number (Doppler) or second station or lowest range component.
pub channel: u8,
/// Spacecraft identifier.
pub spacecraft_id: u16,
/// Receiver/exciter independent flag.
pub rx_ex_independent: bool,
/// Reference frequency (Hz).
pub reference_frequency_hz: f64,
/// Item 20 (data-type dependent).
pub item20: i32,
/// Compression time (s) for Doppler; data-type dependent otherwise.
pub compression_time_s: f64,
/// Transmitting station uplink delay (ns) for Doppler or range.
pub uplink_delay_ns: u32,
}
/// One ramp record.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OdfRamp {
/// Station.
pub station: u16,
/// Ramp start time (s since 1950).
pub start_s: Seconds1950,
/// Ramp end time (s since 1950).
pub end_s: Seconds1950,
/// Ramp rate (Hz/s).
pub rate_hz_s: f64,
/// Ramp start frequency (Hz).
pub start_frequency_hz: f64,
}
/// Where a group's header record sits (1-based record number, as a PDS label counts).
#[derive(Clone, Debug, PartialEq)]
pub struct OdfGroup {
/// Primary key.
pub primary_key: i32,
/// Secondary key (the station for ramp groups).
pub secondary_key: i32,
/// 1-based record number of the header.
pub header_record: usize,
/// Data records that follow it.
pub rows: usize,
}
/// A parsed ODF.
#[derive(Clone, Debug, Default)]
pub struct Odf {
/// Total 36-byte records.
pub n_records: usize,
/// The groups in file order.
pub groups: Vec<OdfGroup>,
/// Orbit-data records.
pub observations: Vec<OdfObservation>,
/// Ramp records of every station.
pub ramps: Vec<OdfRamp>,
/// Spacecraft name from the file label group, when present.
pub spacecraft_name: Option<String>,
}
const HEADER_KEYS: [i32; 7] = [101, 105, 107, 109, 2030, 2040, -1];
fn word(rec: &[u8], i: usize) -> u32 {
u32::from_be_bytes([rec[4 * i], rec[4 * i + 1], rec[4 * i + 2], rec[4 * i + 3]])
}
fn bits(w: u32, from_msb: u32, len: u32) -> u32 {
(w >> (32 - from_msb - len)) & ((1u64 << len) - 1) as u32
}
fn is_header(rec: &[u8]) -> bool {
let key = word(rec, 0) as i32;
HEADER_KEYS.contains(&key) && word(rec, 4) == 0 && word(rec, 5) == 0
}
fn observation(rec: &[u8]) -> OdfObservation {
let w = |i| word(rec, i);
let w4 = w(4);
// Words 6-7: items 15 (I7), 16 (I10), 17 (I1), 18 (I22, reference frequency high part)
// and 19 (I24, low part), 64 bits.
let w67 = ((w(5) as u64) << 32) | w(6) as u64;
let ref_mhz = ((w67 >> 24) & 0x3F_FFFF) << 24 | (w67 & 0xFF_FFFF);
// Words 8-9: items 20 (S20), 21 (I22) and 22 (I22), 64 bits.
let w89 = ((w(7) as u64) << 32) | w(8) as u64;
let item20 = ((w89 >> 44) & 0xF_FFFF) as i32;
let item20 = if item20 & (1 << 19) != 0 {
item20 - (1 << 20)
} else {
item20
};
OdfObservation {
time_s: w(0) as f64 + bits(w(1), 0, 10) as f64 * 1e-3,
downlink_delay_ns: bits(w(1), 10, 22),
observable: w(2) as i32 as f64 + w(3) as i32 as f64 * 1e-9,
format_id: bits(w4, 0, 3) as u8,
rx_station: bits(w4, 3, 7) as u8,
tx_station: bits(w4, 10, 7) as u8,
network_id: bits(w4, 17, 2) as u8,
data_type: bits(w4, 19, 6) as u8,
downlink_band: bits(w4, 25, 2) as u8,
uplink_band: bits(w4, 27, 2) as u8,
exciter_band: bits(w4, 29, 2) as u8,
invalid: bits(w4, 31, 1) == 1,
channel: ((w67 >> 57) & 0x7F) as u8,
spacecraft_id: ((w67 >> 47) & 0x3FF) as u16,
rx_ex_independent: (w67 >> 46) & 1 == 1,
reference_frequency_hz: ref_mhz as f64 * 1e-3,
item20,
compression_time_s: ((w89 >> 22) & 0x3F_FFFF) as f64 * 0.01,
uplink_delay_ns: (w89 & 0x3F_FFFF) as u32,
}
}
fn ramp(rec: &[u8]) -> OdfRamp {
let w = |i| word(rec, i);
let w4 = w(4);
OdfRamp {
station: bits(w4, 22, 10) as u16,
start_s: w(0) as f64 + w(1) as f64 * 1e-9,
rate_hz_s: w(2) as i32 as f64 + w(3) as i32 as f64 * 1e-9,
start_frequency_hz: bits(w4, 0, 22) as f64 * 1e9 + w(5) as f64 + w(6) as f64 * 1e-9,
end_s: w(7) as f64 + w(8) as f64 * 1e-9,
}
}
/// Parse an ODF from its bytes. Errors when the length is not a whole number of 36-byte
/// records or no orbit-data group is found.
pub fn parse_odf(bytes: &[u8]) -> Result<Odf, String> {
if bytes.is_empty() || bytes.len() % 36 != 0 {
return Err(format!(
"{} bytes is not a whole number of 36-byte records",
bytes.len()
));
}
let n = bytes.len() / 36;
let mut out = Odf {
n_records: n,
..Default::default()
};
let mut current: Option<i32> = None;
let mut ramp_station: u16 = 0;
for i in 0..n {
let rec = &bytes[36 * i..36 * (i + 1)];
if current != Some(-1) && is_header(rec) {
let key = word(rec, 0) as i32;
out.groups.push(OdfGroup {
primary_key: key,
secondary_key: word(rec, 1) as i32,
header_record: i + 1,
rows: 0,
});
current = Some(key);
ramp_station = word(rec, 1) as u16;
continue;
}
let Some(g) = out.groups.last_mut() else {
return Err(format!("record {} precedes any group header", i + 1));
};
g.rows += 1;
match current {
Some(101) => {
// Items: system ID (A8), program ID (A8), spacecraft ID, file creation date
// and time, and so on. The first 8 characters name the system.
let s: String = rec[..8].iter().map(|&b| b as char).collect();
out.spacecraft_name.get_or_insert(s.trim().to_string());
}
Some(109) => out.observations.push(observation(rec)),
Some(2030) => {
let mut r = ramp(rec);
if r.station == 0 {
r.station = ramp_station;
}
out.ramps.push(r);
}
_ => {}
}
}
if !out.groups.iter().any(|g| g.primary_key == 109) {
return Err("no orbit data group".into());
}
Ok(out)
}
/// Calendar UTC `(year, day_of_year, hour, minute, second)` → seconds since 1950-01-01 UTC,
/// counting no leap seconds (the ODF time scale counts UTC seconds of day; differences across a
/// leap second are off by that second).
pub fn seconds_1950(year: i32, doy: u32, h: u32, m: u32, s: f64) -> f64 {
let mut days = 0i64;
for y in 1950..year {
days += if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 {
366
} else {
365
};
}
days += doy as i64 - 1;
days as f64 * 86_400.0 + (h * 3600 + m * 60) as f64 + s
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(words: [u32; 9]) -> Vec<u8> {
words.iter().flat_map(|w| w.to_be_bytes()).collect()
}
#[test]
fn orbit_data_bit_fields_round_trip() {
// Build one orbit-data record by hand from the TRK-2-18 layout.
let t = 1_825_000_000u32;
let w1 = (512u32 << 22) | 1234; // 512 ms, downlink delay 1234 ns
let w4 =
(2u32 << 29) | (63 << 22) | (14 << 15) | (12 << 7) | (2 << 5) | (2 << 3) | (2 << 1);
let ref_mhz: u64 = 7_183_000_000_123; // 7.183 GHz + 0.123 Hz in mHz
let hp = (ref_mhz >> 24) as u32; // 22 bits
let lp = (ref_mhz & 0xFF_FFFF) as u32;
let w5 = (5u32 << 25) | (74 << 15) | (1 << 14) | (hp >> 8);
let w6 = ((hp & 0xFF) << 24) | lp;
let comp = 1000u64; // 10 s
let up = 4321u64;
let both: u64 = ((0x0_0003u64) << 44) | (comp << 22) | up;
let r = rec([
t,
w1,
(-123_456i32) as u32,
(789_000_000i32) as u32,
w4,
w5,
w6,
(both >> 32) as u32,
both as u32,
]);
let o = observation(&r);
assert_eq!(o.time_s, t as f64 + 0.512);
assert_eq!(o.downlink_delay_ns, 1234);
assert!((o.observable - (-123_456.0 + 0.789)).abs() < 1e-9);
assert_eq!(
(o.format_id, o.rx_station, o.tx_station, o.network_id),
(2, 63, 14, 0)
);
assert_eq!(
(o.data_type, o.downlink_band, o.uplink_band, o.exciter_band),
(12, 2, 2, 2)
);
assert!(!o.invalid);
assert_eq!(
(o.channel, o.spacecraft_id, o.rx_ex_independent),
(5, 74, true)
);
assert!((o.reference_frequency_hz - 7_183_000_000.123).abs() < 1e-6);
assert_eq!(o.item20, 3);
assert!((o.compression_time_s - 10.0).abs() < 1e-12);
assert_eq!(o.uplink_delay_ns, 4321);
}
#[test]
fn epoch_1950_counts_days_and_leap_years() {
assert_eq!(seconds_1950(1950, 1, 0, 0, 0.0), 0.0);
assert_eq!(seconds_1950(1951, 1, 0, 0, 0.0), 365.0 * 86_400.0);
// 1952 is a leap year.
assert_eq!(
seconds_1950(1953, 1, 0, 0, 0.0),
(365.0 * 2.0 + 366.0) * 86_400.0
);
}
#[test]
fn bad_lengths_are_refused() {
assert!(parse_odf(&[0u8; 35]).is_err());
assert!(parse_odf(&[]).is_err());
}
}