Skip to content

Commit aa4e5ae

Browse files
Merge pull request #419 from FusRoman/ut1-borrowed
Perf: Borrow `Ut1Provider`, `O(log n)` UT1 offset lookup, and faster EOP parsing
2 parents c0c0688 + 9140b71 commit aa4e5ae

4 files changed

Lines changed: 125 additions & 50 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "hifitime"
3-
version = "4.1.3"
3+
version = "4.2.0"
44
authors = ["Christopher Rabotin <christopher.rabotin@gmail.com>"]
55
description = "Ultra-precise date and time handling in Rust for scientific applications with leap second support"
66
homepage = "https://nyxspace.com/"

src/epoch/ops.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -495,10 +495,7 @@ impl PartialEq for Epoch {
495495

496496
impl PartialOrd for Epoch {
497497
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
498-
Some(
499-
self.duration
500-
.cmp(&other.to_time_scale(self.time_scale).duration),
501-
)
498+
Some(self.cmp(other))
502499
}
503500
}
504501

src/epoch/ut1.rs

Lines changed: 121 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ impl Epoch {
3131
/// # Warning
3232
/// The time scale of this Epoch will be set to TAI! This is to ensure that no additional computations will change the duration since it's stored in TAI.
3333
/// However, this also means that calling `to_duration()` on this Epoch will return the TAI duration and not the UT1 duration!
34-
pub fn from_ut1_duration(duration: Duration, provider: Ut1Provider) -> Self {
34+
pub fn from_ut1_duration(duration: Duration, provider: &Ut1Provider) -> Self {
3535
let mut e = Self::from_tai_duration(duration);
3636
// Compute the TAI to UT1 offset at this time.
3737
// We have the time in TAI. But we were given UT1.
@@ -41,26 +41,44 @@ impl Epoch {
4141
e
4242
}
4343

44-
/// Get the accumulated offset between this epoch and UT1, assuming that the provider includes all data.
45-
pub fn ut1_offset(&self, provider: Ut1Provider) -> Option<Duration> {
46-
for delta_tai_ut1 in provider.rev() {
47-
if self > &delta_tai_ut1.epoch {
48-
return Some(delta_tai_ut1.delta_tai_minus_ut1);
44+
/// Get the accumulated offset between this epoch and UT1.
45+
/// Assumes the provider's records are sorted by ascending epoch (enforced in `from_eop_data`).
46+
///
47+
/// Arguments
48+
/// -----------------
49+
/// * `provider`: Borrowed UT1 data source.
50+
///
51+
/// Return
52+
/// ----------
53+
/// * `Some(Duration)` for the last record with `record.epoch <= self`, otherwise `None`.
54+
pub fn ut1_offset(&self, provider: &Ut1Provider) -> Option<Duration> {
55+
let s = provider.as_slice();
56+
57+
// Fast-path: very common case — query is after the latest record.
58+
if let Some(last) = s.last() {
59+
if *self >= last.epoch {
60+
return Some(last.delta_tai_minus_ut1);
4961
}
5062
}
51-
None
63+
64+
// Find the index of the first element with epoch > self (monotonic predicate)
65+
let idx = s.partition_point(|r| r.epoch <= *self);
66+
67+
// Candidate is the previous element if any exists
68+
let rec = s.get(idx.checked_sub(1)?)?;
69+
Some(rec.delta_tai_minus_ut1)
5270
}
5371

5472
#[must_use]
5573
/// Returns this time in a Duration past J1900 counted in UT1
56-
pub fn to_ut1_duration(&self, provider: Ut1Provider) -> Duration {
74+
pub fn to_ut1_duration(&self, provider: &Ut1Provider) -> Duration {
5775
// TAI = UT1 + offset <=> UTC = TAI - offset
5876
self.to_tai_duration() - self.ut1_offset(provider).unwrap_or(Duration::ZERO)
5977
}
6078

6179
#[must_use]
6280
/// Returns this time in a Duration past J1900 counted in UT1
63-
pub fn to_ut1(&self, provider: Ut1Provider) -> Self {
81+
pub fn to_ut1(&self, provider: &Ut1Provider) -> Self {
6482
Self::from_tai_duration(self.to_ut1_duration(provider))
6583
}
6684
}
@@ -82,6 +100,19 @@ pub struct Ut1Provider {
82100
}
83101

84102
impl Ut1Provider {
103+
/// Read-only view of the underlying UT1 records.
104+
///
105+
/// Arguments
106+
/// -----------------
107+
/// * None.
108+
///
109+
/// Return
110+
/// ----------
111+
/// * A slice `&[DeltaTaiUt1]` over all records.
112+
pub fn as_slice(&self) -> &[DeltaTaiUt1] {
113+
&self.data
114+
}
115+
85116
/// Builds a UT1 provided by downloading the data from <https://eop2-external.jpl.nasa.gov/eop2/latest_eop2.short> (short time scale UT1 data) and parsing it.
86117
pub fn download_short_from_jpl() -> Result<Self, HifitimeError> {
87118
Self::download_from_jpl("latest_eop2.short")
@@ -136,60 +167,98 @@ impl Ut1Provider {
136167
Self::from_eop_data(contents)
137168
}
138169

139-
/// Builds a UT1 provider from the provided EOP data
170+
/// Builds a UT1 provider from the provided EOP data.
171+
/// Single-pass, no per-line allocation:
172+
/// - Use `split(',')` and take exactly columns 0 and 3 (no `collect()`).
173+
/// - Track sortedness and only sort at the end if needed.
174+
/// - Trim CR/LF and ignore empty lines.
175+
///
176+
/// Arguments
177+
/// -----------------
178+
/// * `contents`: The full EOP2 text payload from JPL.
179+
///
180+
/// Return
181+
/// ----------
182+
/// * `Ok(Self)` with records sorted by ascending epoch.
183+
/// * `Err(HifitimeError)` on malformed lines.
184+
///
185+
/// See also
186+
/// ------------
187+
/// * [`Ut1Provider::from_eop_file`] – File-based variant calling this parser.
140188
pub fn from_eop_data(contents: String) -> Result<Self, HifitimeError> {
141189
let mut me = Self::default();
142-
143-
let mut ignore = true;
144-
for line in contents.lines() {
145-
if line == " EOP2=" {
146-
// Data will start after this line
147-
ignore = false;
190+
// Heuristic to reduce Vec reallocations
191+
me.data.reserve(contents.len() / 48);
192+
193+
let mut in_data = false;
194+
let mut prev_epoch: Option<Epoch> = None;
195+
let mut already_sorted = true;
196+
197+
for raw in contents.lines() {
198+
// Header section control
199+
if !in_data {
200+
if raw == " EOP2=" || raw == "EOP2=" {
201+
in_data = true;
202+
}
148203
continue;
149-
} else if line == " $END" {
150-
// We've reached the end of the EOP data file.
204+
}
205+
if raw == " $END" || raw == "$END" {
151206
break;
152207
}
153-
if ignore {
208+
if raw.is_empty() {
154209
continue;
155210
}
156211

157-
// We have data of interest!
158-
let data: Vec<&str> = line.split(',').collect();
159-
if data.len() < 4 {
160-
return Err(HifitimeError::Parse {
161-
source: ParsingError::UnknownFormat,
162-
details: "expected EOP line to contain 4 comma-separated columns",
163-
});
164-
}
212+
// Extract exactly columns 0 and 3 (others ignored)
213+
let mut cols = raw.split(',');
214+
let mjd_col = cols.next().ok_or_else(|| HifitimeError::Parse {
215+
source: ParsingError::UnknownFormat,
216+
details: "missing MJD column (0)",
217+
})?;
218+
let delta_col = cols.nth(2).ok_or_else(|| HifitimeError::Parse {
219+
source: ParsingError::UnknownFormat,
220+
details: "missing ΔUT1 column (3)",
221+
})?;
165222

166-
let mjd_tai_days: f64 = match lexical_core::parse(data[0].trim().as_bytes()) {
167-
Ok(val) => val,
168-
Err(err) => {
169-
return Err(HifitimeError::Parse {
223+
// Parse numeric fields
224+
let mjd_tai_days: f64 =
225+
lexical_core::parse(mjd_col.trim().as_bytes()).map_err(|err| {
226+
HifitimeError::Parse {
170227
source: ParsingError::Lexical { err },
171-
details: "when parsing MJD TAI days (zeroth column)",
172-
})
173-
}
174-
};
228+
details: "when parsing MJD TAI days (column 0)",
229+
}
230+
})?;
175231

176-
let delta_ut1_ms: f64;
177-
match lexical_core::parse(data[3].trim().as_bytes()) {
178-
Ok(val) => delta_ut1_ms = val,
179-
Err(err) => {
180-
return Err(HifitimeError::Parse {
232+
let delta_ut1_ms: f64 =
233+
lexical_core::parse(delta_col.trim().as_bytes()).map_err(|err| {
234+
HifitimeError::Parse {
181235
source: ParsingError::Lexical { err },
182-
details: "when parsing ΔUT1 in ms (last column)",
183-
})
236+
details: "when parsing ΔUT1 in ms (column 3)",
237+
}
238+
})?;
239+
240+
let epoch = Epoch::from_mjd_tai(mjd_tai_days);
241+
if let Some(prev) = prev_epoch {
242+
if epoch < prev {
243+
already_sorted = false;
184244
}
185245
}
246+
prev_epoch = Some(epoch);
186247

187248
me.data.push(DeltaTaiUt1 {
188-
epoch: Epoch::from_mjd_tai(mjd_tai_days),
249+
epoch,
189250
delta_tai_minus_ut1: delta_ut1_ms * Unit::Millisecond,
190251
});
191252
}
192253

254+
if !already_sorted {
255+
me.data.sort_unstable_by(|a, b| {
256+
a.epoch
257+
.partial_cmp(&b.epoch)
258+
.expect("Epoch must be orderable (no NaN)")
259+
});
260+
}
261+
193262
Ok(me)
194263
}
195264
}
@@ -243,6 +312,15 @@ impl Index<usize> for Ut1Provider {
243312
}
244313
}
245314

315+
impl<'a> IntoIterator for &'a Ut1Provider {
316+
type Item = &'a DeltaTaiUt1;
317+
type IntoIter = std::slice::Iter<'a, DeltaTaiUt1>;
318+
319+
fn into_iter(self) -> Self::IntoIter {
320+
self.data.iter()
321+
}
322+
}
323+
246324
#[cfg(kani)]
247325
mod kani_harnesses {
248326
use super::*;

tests/ut1.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ fn test_ut1_from_file() {
1818
//
1919
let epoch = Epoch::from_str("2022-01-03 03:05:06.7891").unwrap();
2020
assert_eq!(
21-
format!("{:x}", epoch.to_ut1(provider)),
21+
format!("{:x}", epoch.to_ut1(&provider)),
2222
"2022-01-03T03:05:06.679020600 TAI"
2323
);
2424
}
@@ -43,7 +43,7 @@ fn test_ut1_from_jpl() {
4343
// >>>
4444
//
4545
let epoch = Epoch::from_str("2022-01-03 03:05:06.7891 UTC").unwrap();
46-
let ut1_epoch = epoch.to_ut1(provider);
46+
let ut1_epoch = epoch.to_ut1(&provider);
4747
assert_eq!(
4848
format!("{:x}", ut1_epoch),
4949
"2022-01-03T03:05:06.679020600 TAI",

0 commit comments

Comments
 (0)