diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 2bde600c..36570d11 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -1,70 +1,157 @@ -name: Python lib +# This file is autogenerated by maturin v0.14.17 +# To update, run +# +# maturin generate-ci --pytest -o .github/workflows/python.yml github +# +name: Python CI on: push: branches: + - main - master tags: - - "*" + - '*' pull_request: workflow_dispatch: +permissions: + contents: read + jobs: linux: runs-on: ubuntu-latest + strategy: + matrix: + target: [x86_64, x86, aarch64, armv7, s390x, ppc64le] steps: - uses: actions/checkout@v3 - - uses: messense/maturin-action@v1 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Build wheels + uses: PyO3/maturin-action@v1 with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter -F python + sccache: 'true' manylinux: auto - command: build - args: --release --sdist -o dist --find-interpreter -F python - - name: Upload wheels uses: actions/upload-artifact@v3 with: name: wheels path: dist + - name: pytest + if: ${{ startsWith(matrix.target, 'x86_64') }} + shell: bash + run: | + set -e + pip install hifitime --find-links dist --force-reinstall + pip install pytest + pytest + - name: pytest + if: ${{ !startsWith(matrix.target, 'x86') && matrix.target != 'ppc64' }} + uses: uraimo/run-on-arch-action@v2.5.0 + with: + arch: ${{ matrix.target }} + distro: ubuntu22.04 + githubToken: ${{ github.token }} + install: | + apt-get update + apt-get install -y --no-install-recommends python3 python3-pip + pip3 install -U pip pytest + run: | + set -e + pip3 install hifitime --find-links dist --force-reinstall + pytest windows: runs-on: windows-latest + strategy: + matrix: + target: [x64, x86] steps: - uses: actions/checkout@v3 - - uses: messense/maturin-action@v1 + - uses: actions/setup-python@v4 with: - command: build - args: --release -o dist --find-interpreter -F python + python-version: '3.10' + architecture: ${{ matrix.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter -F python + sccache: 'true' - name: Upload wheels uses: actions/upload-artifact@v3 with: name: wheels path: dist + - name: pytest + if: ${{ !startsWith(matrix.target, 'aarch64') }} + shell: bash + run: | + set -e + pip install hifitime --find-links dist --force-reinstall + pip install pytest + pytest macos: runs-on: macos-latest + strategy: + matrix: + target: [x86_64, aarch64] steps: - uses: actions/checkout@v3 - - uses: messense/maturin-action@v1 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Build wheels + uses: PyO3/maturin-action@v1 with: - command: build - args: --release -o dist --universal2 --find-interpreter -F python + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter -F python + sccache: 'true' - name: Upload wheels uses: actions/upload-artifact@v3 with: name: wheels path: dist + - name: pytest + if: ${{ !startsWith(matrix.target, 'aarch64') }} + shell: bash + run: | + set -e + pip install hifitime --find-links dist --force-reinstall + pip install pytest + pytest + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v3 + with: + name: wheels + path: dist release: name: Release runs-on: ubuntu-latest - if: github.ref_type == 'tag' - needs: [macos, windows, linux] + if: "startsWith(github.ref, 'refs/tags/')" + needs: [linux, windows, macos, sdist] steps: - uses: actions/download-artifact@v3 with: name: wheels - name: Publish to PyPI - uses: messense/maturin-action@v1 + uses: PyO3/maturin-action@v1 env: MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} with: diff --git a/Cargo.toml b/Cargo.toml index fc331a37..69408b20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ name = "hifitime" serde = {version = "1.0.155", optional = true} serde_derive = {version = "1.0.155", optional = true} der = {version = "0.6.1", features = ["derive", "real"], optional = true} -pyo3 = { version = "0.18.1", features = ["extension-module"], optional = true} +pyo3 = { version = "0.18.1", features = ["extension-module"], optional = true } num-traits = {version = "0.2.15", default-features = false, features = ["libm"]} lexical-core = {version = "0.8.5", default-features = false, features = ["parse-integers", "parse-floats"]} reqwest = { version = "0.11", features = ["blocking", "json"], optional = true} diff --git a/examples/python/basic.py b/examples/python/basic.py index da9b1ad7..c2e48209 100644 --- a/examples/python/basic.py +++ b/examples/python/basic.py @@ -1,4 +1,4 @@ -''' +""" * Hifitime, part of the Nyx Space tools * Copyright (C) 2022 Christopher Rabotin et al. (cf. AUTHORS.md) * This Source Code Form is subject to the terms of the Apache @@ -6,7 +6,7 @@ * file, You can obtain one at https://www.apache.org/licenses/LICENSE-2.0. * * Documentation: https://nyxspace.com/ -''' +""" from hifitime import Duration, Epoch, TimeSeries, TimeScale, Unit @@ -30,7 +30,7 @@ print(f"min positive = {Duration.min_positive()}") # And more importantly, it does not suffer from rounding issues, even when the duration are very large. - print(f"Max duration: {Duration.max()}") # 1196851200 days + print(f"Max duration: {Duration.max()}") # 1196851200 days print(f"Nanosecond precision: {Duration.max() - Unit.Nanosecond * 1.0}") assert f"{Unit.Day * 1.2}" == "1 days 4 h 48 min" assert f"{Unit.Day * 1.200001598974}" == "1 days 4 h 48 min 138 ms 151 μs 353 ns" @@ -40,16 +40,21 @@ # You can also get all of the epochs between two different epochs at a specific step size. # This is like numpy's `linspace` with high fidelity durations - time_series = TimeSeries(Epoch.system_now(), - Epoch.system_now() + Unit.Day * 0.3, - Unit.Hour * 0.5, - inclusive=True) + time_series = TimeSeries( + Epoch.system_now(), + Epoch.system_now() + Unit.Day * 0.3, + Unit.Hour * 0.5, + inclusive=True, + ) print(time_series) - for (num, epoch) in enumerate(time_series): + for num, epoch in enumerate(time_series): print(f"#{num}:\t{epoch}") e1 = Epoch.system_now() e3 = e1 + Unit.Day * 1.5998 epoch_delta = e3.timedelta(e1) - assert epoch_delta == Unit.Day * 1 + Unit.Hour * 14 + Unit.Minute * 23 + Unit.Second * 42.720 - print(epoch_delta) \ No newline at end of file + assert ( + epoch_delta + == Unit.Day * 1 + Unit.Hour * 14 + Unit.Minute * 23 + Unit.Second * 42.720 + ) + print(epoch_delta) diff --git a/examples/python/timescales.py b/examples/python/timescales.py index b915e0f1..162e40a9 100644 --- a/examples/python/timescales.py +++ b/examples/python/timescales.py @@ -1,4 +1,4 @@ -''' +""" * Hifitime, part of the Nyx Space tools * Copyright (C) 2022 Christopher Rabotin et al. (cf. AUTHORS.md) * This Source Code Form is subject to the terms of the Apache @@ -6,35 +6,46 @@ * file, You can obtain one at https://www.apache.org/licenses/LICENSE-2.0. * * Documentation: https://nyxspace.com/ -''' +""" try: import plotly.express as px except ImportError: - print('\nThis script requires `plotly` (pip install plotly)\n') + print("\nThis script requires `plotly` (pip install plotly)\n") try: import pandas as pd except ImportError: - print('\nThis script requires `pandas` (pip install pandas)\n') + print("\nThis script requires `pandas` (pip install pandas)\n") from hifitime import Epoch, TimeSeries, Unit if __name__ == "__main__": - ''' + """ The purpose of this script is to plot the differences between time systems. It will plot the difference between TAI, UTC, and all of the other timescales supported by hifitime. Then, as a separate plot, it will remove the UTC line to make the difference between other timescales more evident. - ''' + """ # Start by building a time series from 1970 until 2023 with a step of 30 days. - ts = TimeSeries(Epoch('1970-01-01 00:00:00 UTC'), Epoch('2023-01-01 00:00:00 UTC'), - Unit.Day * 30.0, True) + ts = TimeSeries( + Epoch("1970-01-01 00:00:00 UTC"), + Epoch("2023-01-01 00:00:00 UTC"), + Unit.Day * 30.0, + True, + ) # Define the storage array data = [] # Define the columns - columns = ["UTC Epoch", "Δ TT (s)", "Δ ET (s)", "Δ TDB (s)", "Δ UTC (s)", "ET-TDB (s)"] + columns = [ + "UTC Epoch", + "Δ TT (s)", + "Δ ET (s)", + "Δ TDB (s)", + "Δ UTC (s)", + "ET-TDB (s)", + ] for epoch in ts: delta_utc = epoch.to_utc_duration() - epoch.to_tai_duration() @@ -45,34 +56,42 @@ # Convert the epoch into a pandas datetime pd_epoch = pd.to_datetime(str(epoch)) # Build the pandas series - data.append([ - pd_epoch, - delta_tt.to_seconds(), - delta_et.to_seconds(), - delta_tdb.to_seconds(), - delta_utc.to_seconds(), - delta_et_tdb.to_seconds(), - ]) + data.append( + [ + pd_epoch, + delta_tt.to_seconds(), + delta_et.to_seconds(), + delta_tdb.to_seconds(), + delta_utc.to_seconds(), + delta_et_tdb.to_seconds(), + ] + ) df = pd.DataFrame(data, columns=columns) - fig = px.line(df, - x='UTC Epoch', - y=columns[1:-1], - title="Time scale deviation with respect to TAI") + fig = px.line( + df, + x="UTC Epoch", + y=columns[1:-1], + title="Time scale deviation with respect to TAI", + ) fig.write_html("./target/time-scale-deviation.html") fig.show() - fig = px.line(df, - x='UTC Epoch', - y=columns[1:-2], - title="Time scale deviation with respect to TAI (excl. UTC)") + fig = px.line( + df, + x="UTC Epoch", + y=columns[1:-2], + title="Time scale deviation with respect to TAI (excl. UTC)", + ) fig.write_html("./target/time-scale-deviation-no-utc.html") fig.show() - fig = px.line(df, - x='UTC Epoch', - y=columns[-1], - title="Time scale deviation of TDB and ET with respect to TAI") + fig = px.line( + df, + x="UTC Epoch", + y=columns[-1], + title="Time scale deviation of TDB and ET with respect to TAI", + ) fig.write_html("./target/time-scale-deviation-tdb-et.html") - fig.show() \ No newline at end of file + fig.show() diff --git a/src/epoch.rs b/src/epoch.rs index ee22dbbb..25f9308e 100644 --- a/src/epoch.rs +++ b/src/epoch.rs @@ -34,6 +34,9 @@ use pyo3::prelude::*; #[cfg(feature = "python")] use pyo3::pyclass::CompareOp; +#[cfg(feature = "python")] +use pyo3::types::PyType; + #[cfg(feature = "python")] use crate::leap_seconds_file::LeapSecondsFile; @@ -1041,6 +1044,13 @@ impl Epoch { format.parse(s_in) } + /// Initializes an Epoch from the Format as a string. + pub fn from_format_str(s_in: &str, format_str: &str) -> Result { + Format::from_str(format_str) + .map_err(Errors::ParseError)? + .parse(s_in) + } + #[cfg(feature = "ut1")] #[must_use] /// Initialize an Epoch from the provided UT1 duration since 1900 January 01 at midnight @@ -1649,6 +1659,23 @@ impl Epoch { Self::from_gregorian_utc_hms(year, month, day, hour, minute, second) } + #[cfg(feature = "python")] + #[classmethod] + /// Equivalent to `datetime.strptime, refer to for format options + fn strptime(_cls: &PyType, epoch_str: String, format_str: String) -> PyResult { + Self::from_format_str(&epoch_str, &format_str).map_err(|e| PyErr::from(e)) + } + + #[cfg(feature = "python")] + /// Equivalent to `datetime.strftime, refer to for format options + fn strftime(&self, format_str: String) -> PyResult { + use crate::efmt::Formatter; + let fmt = Format::from_str(&format_str) + .map_err(Errors::ParseError) + .map_err(|e| PyErr::from(e))?; + Ok(format!("{}", Formatter::new(*self, fmt))) + } + /// Returns this epoch with respect to the time scale this epoch was created in. /// This is needed to correctly perform duration conversions in dynamical time scales (e.g. TDB). /// diff --git a/src/leap_seconds.rs b/src/leap_seconds.rs index e44b5cc1..54687c6b 100644 --- a/src/leap_seconds.rs +++ b/src/leap_seconds.rs @@ -147,3 +147,17 @@ impl Index for LatestLeapSeconds { } impl LeapSecondProvider for LatestLeapSeconds {} + +#[test] +fn leap_second_fetch() { + let leap_seconds = LatestLeapSeconds::default(); + + assert_eq!( + leap_seconds[0], + LeapSecond::new(1_893_369_600.0, 1.417818, false), + ); + assert_eq!( + leap_seconds[41], + LeapSecond::new(3_692_217_600.0, 37.0, true) + ); +} diff --git a/src/timeseries.rs b/src/timeseries.rs index c1f82722..bc53f8ae 100644 --- a/src/timeseries.rs +++ b/src/timeseries.rs @@ -28,9 +28,9 @@ NOTE: This is taken from itertools: https://docs.rs/itertools-num/0.1.3/src/iter #[cfg_attr(feature = "python", pyclass)] pub struct TimeSeries { start: Epoch, - end: Epoch, + duration: Duration, step: Duration, - cur: Epoch, + cur: i64, incl: bool, } @@ -54,9 +54,9 @@ impl TimeSeries { // Start one step prior to start because next() just moves forward Self { start, - end, + duration: end - start, step, - cur: start - step, + cur: 0, incl: false, } } @@ -80,9 +80,9 @@ impl TimeSeries { // Start one step prior to start because next() just moves forward Self { start, - end, + duration: end - start, step, - cur: start - step, + cur: 0, incl: true, } } @@ -96,9 +96,9 @@ impl fmt::Display for TimeSeries { "TimeSeries [{} : {} : {}]", self.start, if self.incl { - self.end + self.start + self.duration } else { - self.end - self.step + self.start + self.duration - self.step }, self.step ) @@ -113,9 +113,9 @@ impl fmt::LowerHex for TimeSeries { "TimeSeries [{:x} : {:x} : {}]", self.start, if self.incl { - self.end + self.start + self.duration } else { - self.end - self.step + self.start + self.duration - self.step }, self.step ) @@ -130,9 +130,9 @@ impl fmt::UpperHex for TimeSeries { "TimeSeries [{:X} : {:X} : {}]", self.start, if self.incl { - self.end + self.start + self.duration } else { - self.end - self.step + self.start + self.duration - self.step }, self.step ) @@ -147,9 +147,9 @@ impl fmt::LowerExp for TimeSeries { "TimeSeries [{:e} : {:e} : {}]", self.start, if self.incl { - self.end + self.start + self.duration } else { - self.end - self.step + self.start + self.duration - self.step }, self.step ) @@ -164,9 +164,9 @@ impl fmt::UpperExp for TimeSeries { "TimeSeries [{:E} : {:E} : {}]", self.start, if self.incl { - self.end + self.start + self.duration } else { - self.end - self.step + self.start + self.duration - self.step }, self.step ) @@ -181,9 +181,9 @@ impl fmt::Pointer for TimeSeries { "TimeSeries [{:p} : {:p} : {}]", self.start, if self.incl { - self.end + self.start + self.duration } else { - self.end - self.step + self.start + self.duration - self.step }, self.step ) @@ -198,9 +198,9 @@ impl fmt::Octal for TimeSeries { "TimeSeries [{:o} : {:o} : {}]", self.start, if self.incl { - self.end + self.start + self.duration } else { - self.end - self.step + self.start + self.duration - self.step }, self.step ) @@ -244,12 +244,14 @@ impl Iterator for TimeSeries { #[inline] fn next(&mut self) -> Option { - let next_item = self.cur + self.step; - if (!self.incl && next_item >= self.end) || (self.incl && next_item > self.end) { + let next_offset = self.cur * self.step; + if (!self.incl && next_offset >= self.duration) + || (self.incl && next_offset > self.duration) + { None } else { - self.cur = next_item; - Some(next_item) + self.cur += 1; + Some(self.start + next_offset) } } @@ -261,11 +263,16 @@ impl Iterator for TimeSeries { impl DoubleEndedIterator for TimeSeries { #[inline] fn next_back(&mut self) -> Option { - let next_item = self.cur - self.step; - if next_item < self.start { + // Offset from the end of the iterator + self.cur += 1; + let offset = self.cur * self.step; + // if offset < -self.duration - self.step { + if (!self.incl && offset > self.duration) + || (self.incl && offset > self.duration + self.step) + { None } else { - Some(next_item) + Some(self.start + self.duration - offset) } } } @@ -275,7 +282,7 @@ where TimeSeries: Iterator, { fn len(&self) -> usize { - let approx = ((self.end - self.start).to_seconds() / self.step.to_seconds()).abs(); + let approx = (self.duration.to_seconds() / self.step.to_seconds()).abs(); if self.incl { if approx.ceil() >= usize::MAX as f64 { usize::MAX @@ -353,4 +360,39 @@ mod tests { assert_eq!(times.len(), steps as usize); assert_eq!(times.len(), times.size_hint().0); } + + #[test] + fn ts_over_leap_second() { + let start = Epoch::from_gregorian_utc(2016, 12, 31, 23, 59, 59, 0); + let times = TimeSeries::exclusive(start, start + Unit::Second * 5, Unit::Second * 1); + let expect_end = start + Unit::Second * 4; + let mut cnt = 0; + let mut cur_epoch = start; + + for epoch in times { + cnt += 1; + cur_epoch = epoch; + } + + assert_eq!(cnt, 5); // Five because the first item is always inclusive + assert_eq!(cur_epoch, expect_end, "incorrect last item in iterator"); + } + + #[test] + fn ts_backward() { + let start = Epoch::from_gregorian_utc(2015, 1, 1, 12, 0, 0, 0); + let times = TimeSeries::exclusive(start, start + Unit::Second * 5, Unit::Second * 1); + let mut cnt = 0; + let mut cur_epoch = start; + + for epoch in times.rev() { + cnt += 1; + cur_epoch = epoch; + let expect = start + Unit::Second * (5 - cnt); + assert_eq!(expect, epoch, "incorrect item in iterator"); + } + + assert_eq!(cnt, 5); // Five because the first item is always inclusive + assert_eq!(cur_epoch, start, "incorrect last item in iterator"); + } } diff --git a/src/ut1.rs b/src/ut1.rs index a4484f48..e88afb46 100644 --- a/src/ut1.rs +++ b/src/ut1.rs @@ -99,11 +99,10 @@ impl Ut1Provider { return Err(Errors::ParseError(ParsingErrors::UnknownFormat)); } - let mjd_tai_days: f64; - match lexical_core::parse(data[0].trim().as_bytes()) { - Ok(val) => mjd_tai_days = val, + let mjd_tai_days: f64 = match lexical_core::parse(data[0].trim().as_bytes()) { + Ok(val) => val, Err(_) => return Err(Errors::ParseError(ParsingErrors::ValueError)), - } + }; let delta_ut1_ms: f64; match lexical_core::parse(data[3].trim().as_bytes()) { diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 00000000..ed8ebf58 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/tests/efmt.rs b/tests/efmt.rs index eab4fe6e..7086b76c 100644 --- a/tests/efmt.rs +++ b/tests/efmt.rs @@ -90,4 +90,14 @@ fn epoch_format_rfc2822() { format!("{}", Formatter::new(epoch - 2 * Unit::Microsecond, RFC2822)), "Sat, 07 Feb 2015 11:22:32" ); + + assert_eq!( + Epoch::from_format_str("Sat, 07 Feb 2015 11:22:33", "%a, %d %b %Y %H:%M:%S").unwrap(), + epoch + ); + + assert_eq!( + Epoch::from_str_with_format("Sat, 07 Feb 2015 11:22:33", RFC2822).unwrap(), + epoch + ); } diff --git a/tests/python/test_epoch.py b/tests/python/test_epoch.py new file mode 100644 index 00000000..0e2c7265 --- /dev/null +++ b/tests/python/test_epoch.py @@ -0,0 +1,46 @@ +from hifitime import Epoch, TimeSeries, Unit +from datetime import datetime + + +def test_strtime(): + """ + Tests both strp and strftime + """ + epoch = Epoch("2023-04-13 23:31:17 UTC") + dt = datetime(2023, 4, 13, 23, 31, 17) + + epoch_fmt = epoch.strftime("%A, %d %B %Y %H:%M:%S") + dt_fmt = dt.strftime("%A, %d %B %Y %H:%M:%S") + + assert epoch_fmt == dt_fmt + + assert Epoch.strptime(dt_fmt, "%A, %d %B %Y %H:%M:%S") == epoch + + +def test_utcnow(): + epoch = Epoch.system_now() + dt = datetime.utcnow() + + # Hifitime uses a different clock to Python and print down to the nanosecond + assert dt.isoformat()[:21] == f"{epoch}"[:21] + + +def test_time_series(): + """ + Time series are really cool way to iterate through a time without skipping a beat. + """ + + nye = Epoch("2022-12-31 23:59:00 UTC") + + time_series = TimeSeries( + nye, + nye + Unit.Second * 10, + Unit.Second * 1, + inclusive=True, + ) + print(time_series) + + for num, epoch in enumerate(time_series): + print(f"#{num}:\t{epoch}") + + assert num == 10