Current section

Files

Jump to
sidereon lib sidereon gnss time.ex
Raw

lib/sidereon/gnss/time.ex

defmodule Sidereon.GNSS.Time do
@moduledoc """
Epoch conversions shared by the GNSS correction wrappers.
These helpers turn an Elixir `NaiveDateTime` or a
`{{year, month, day}, {hour, minute, second}}` tuple into the two
representations the `sidereon-core` crate consumes:
* a split Julian date `{jd_whole, fraction}` where `jd_whole` is the `*.5`
midnight boundary of the civil day and `fraction` is the within-day part
(the same convention the SP3 reader uses);
* integer or continuous seconds since the J2000 epoch (JD 2451545.0), used
by NIF calls that consume either exact product epoch axes or fractional
receive times.
No leap-second shifting is applied: the epoch stays in the time scale the
caller supplied it in (typically GPS time for these models).
"""
alias Sidereon.NIF
# Named time scales the core resolves, mapped to the abbreviations the NIF
# boundary expects. GLONASST and QZSST are the GNSS scales added with the
# multi-system catalog work; GLONASST (like UTC) is leap-second-based.
@time_scales %{
utc: "UTC",
tai: "TAI",
tt: "TT",
tdb: "TDB",
gpst: "GPST",
gst: "GST",
bdt: "BDT",
glonasst: "GLONASST",
qzsst: "QZSST"
}
@typedoc """
A time scale, named either by atom (`:gpst`, `:utc`, `:glonasst`, ...) or by
its uppercase abbreviation string (`"GPST"`, `"UTC"`, ...).
"""
@type time_scale :: atom() | String.t()
@type leap_second_table :: %{
source: String.t(),
first_mjd: integer(),
last_mjd: integer(),
entries: non_neg_integer()
}
@type ut1_coverage :: %{
source: String.t(),
first_mjd: integer(),
last_mjd: integer(),
first_jd_tt: float(),
last_jd_tt: float(),
entries: non_neg_integer()
}
@doc """
Fixed inter-system time offset `to - from`, in seconds.
Returns the value that, added to a reading in the `from` scale, yields the
`to`-scale reading of the same instant. Defined only for the atomic scales
(TAI/TT/GPST/GST/QZSST/BDT) whose mutual offset is a constant.
Returns `{:error, {:epoch_required, scale}}` for the UTC-based scales (UTC and
GLONASST) whose offset carries the leap-second count (use
`timescale_offset_at/3` with an epoch), and `{:error, {:unsupported, "TDB"}}`
for TDB (its offset from TT is an epoch-dependent periodic term).
iex> Sidereon.GNSS.Time.timescale_offset(:gpst, :tai)
{:ok, 19.0}
iex> Sidereon.GNSS.Time.timescale_offset(:gpst, :utc)
{:error, {:epoch_required, "UTC"}}
"""
@spec timescale_offset(time_scale(), time_scale()) :: {:ok, float()} | {:error, term()}
def timescale_offset(from, to) do
with {:ok, from} <- scale_abbrev(from),
{:ok, to} <- scale_abbrev(to) do
NIF.timescale_offset(from, to)
end
end
@doc """
Leap-aware inter-system time offset `to - from`, in seconds, at `utc_jd`.
`utc_jd` is the UTC Julian date of the instant; it only affects the result
when `from` or `to` is UTC-based (UTC/GLONASST), resolving the leap-second
count. For purely atomic pairs it is ignored and the result matches
`timescale_offset/2`.
iex> {:ok, off} = Sidereon.GNSS.Time.timescale_offset_at(:glonasst, :utc, 2_451_545.0)
iex> Float.round(off, 1)
-10800.0
"""
@spec timescale_offset_at(time_scale(), time_scale(), number()) ::
{:ok, float()} | {:error, term()}
def timescale_offset_at(from, to, utc_jd) when is_number(utc_jd) do
with {:ok, from} <- scale_abbrev(from),
{:ok, to} <- scale_abbrev(to) do
NIF.timescale_offset_at(from, to, utc_jd / 1.0)
end
end
@doc """
TAI minus UTC, in seconds, in effect at a UTC calendar date.
Delegates to `sidereon_core::astro::time::scales::julian_day_number` and
`sidereon_core::astro::time::scales::find_leap_seconds`.
"""
@spec leap_seconds(integer(), integer(), integer()) :: float()
def leap_seconds(year, month, day) when is_integer(year) and is_integer(month) and is_integer(day) do
NIF.leap_seconds(year, month, day)
end
@doc """
GPS minus UTC, in seconds, in effect at a UTC calendar date.
This is the IS-GPS-200 quantity broadcast in the navigation message (the leap
seconds a GPS receiver applies): 18 s from 2017. It is `leap_seconds/3` minus
the constant 19 s `TAI - GPST`, so it is the value to use whenever you mean
"GPS - UTC", not `leap_seconds/3` (which is `TAI - UTC`).
"""
@spec gps_utc_offset_s(integer(), integer(), integer()) :: float()
def gps_utc_offset_s(year, month, day) when is_integer(year) and is_integer(month) and is_integer(day) do
NIF.gps_utc_offset_s(year, month, day)
end
@doc """
TAI minus UTC, in seconds, in effect at a UTC calendar date.
The unambiguously named alias of `leap_seconds/3` (the IERS Bulletin C
quantity, 37 s from 2017); it returns the identical value. Use
`gps_utc_offset_s/3` for the GNSS "GPS - UTC" offset instead.
"""
@spec tai_utc_offset_s(integer(), integer(), integer()) :: float()
def tai_utc_offset_s(year, month, day) when is_integer(year) and is_integer(month) and is_integer(day) do
NIF.tai_utc_offset_s(year, month, day)
end
@doc """
TAI minus UTC for a list of UTC calendar dates.
Each date is `{year, month, day}` and each result delegates to the same core
functions as `leap_seconds/3`.
"""
@spec leap_seconds_batch([{integer(), integer(), integer()}]) :: [float()]
def leap_seconds_batch(dates) when is_list(dates) do
NIF.leap_seconds_batch(dates)
end
@doc """
Provenance and coverage of the embedded leap-second table.
"""
@spec leap_second_table_info() :: leap_second_table()
def leap_second_table_info do
{source, first_mjd, last_mjd, entries} = NIF.leap_second_table_info()
%{
source: source,
first_mjd: first_mjd,
last_mjd: last_mjd,
entries: entries
}
end
@doc """
Provenance and coverage of the embedded UT1/EOP table.
"""
@spec ut1_coverage_info() :: ut1_coverage()
def ut1_coverage_info do
{source, first_mjd, last_mjd, first_jd_tt, last_jd_tt, entries} =
NIF.ut1_coverage_info()
%{
source: source,
first_mjd: first_mjd,
last_mjd: last_mjd,
first_jd_tt: first_jd_tt,
last_jd_tt: last_jd_tt,
entries: entries
}
end
@doc "The supported time-scale atoms."
@spec time_scales() :: [atom()]
def time_scales, do: Map.keys(@time_scales)
defp scale_abbrev(scale) when is_atom(scale) do
case Map.fetch(@time_scales, scale) do
{:ok, abbrev} -> {:ok, abbrev}
:error -> {:error, {:unknown_time_scale, scale}}
end
end
defp scale_abbrev(scale) when is_binary(scale) do
upcased = String.upcase(scale)
if upcased in Map.values(@time_scales) do
{:ok, upcased}
else
{:error, {:unknown_time_scale, scale}}
end
end
defp scale_abbrev(scale), do: {:error, {:unknown_time_scale, scale}}
@doc """
Convert an epoch to the split Julian date `{jd_whole, fraction}`.
The calendar arithmetic lives in `sidereon-core`
(`sidereon_core::astro::time::civil::split_julian_date`); this module only
marshals the epoch into civil `(year, month, day, hour, minute, second)`
fields.
"""
@spec epoch_to_split_jd(NaiveDateTime.t() | tuple()) :: {float(), float()}
def epoch_to_split_jd(epoch) do
{year, month, day, hour, minute, second} = civil_fields(epoch)
NIF.civil_split_julian_date(year, month, day, hour, minute, second)
end
@doc """
Seconds-of-day in `[0, 86400)`, formed from the epoch's clock fields.
Used by the Klobuchar diurnal term, which takes the GPS second-of-day
directly. The arithmetic delegates to
`sidereon_core::astro::time::civil::second_of_day`.
"""
@spec second_of_day(NaiveDateTime.t() | tuple()) :: float()
def second_of_day(epoch) do
{_year, _month, _day, hour, minute, second} = civil_fields(epoch)
NIF.civil_second_of_day(hour, minute, second)
end
@doc """
Convert an epoch to integer seconds since the J2000 epoch (JD 2451545.0).
A whole-second epoch yields an exact integer (the core returns the exact
whole-second value, which is converted back to an integer here). Returns
`{:ok, seconds}` or `{:error, :non_integer_second_epoch}` if the epoch carries
a sub-second part. The continuous seconds come from
`sidereon_core::astro::time::civil::j2000_seconds`.
"""
@spec epoch_to_j2000_seconds(NaiveDateTime.t() | tuple()) ::
{:ok, integer()} | {:error, term()}
def epoch_to_j2000_seconds(%NaiveDateTime{} = ndt) do
{micro, _precision} = ndt.microsecond
if micro == 0 do
epoch_to_j2000_seconds({{ndt.year, ndt.month, ndt.day}, {ndt.hour, ndt.minute, ndt.second}})
else
{:error, :non_integer_second_epoch}
end
end
def epoch_to_j2000_seconds({{year, month, day}, {hour, minute, second}}) when is_integer(second) do
seconds = NIF.civil_j2000_seconds(year, month, day, hour, minute, second / 1.0)
{:ok, trunc(seconds)}
end
def epoch_to_j2000_seconds(_other), do: {:error, :non_integer_second_epoch}
@doc """
Convert an epoch to continuous floating-point seconds since J2000.
Unlike `epoch_to_j2000_seconds/1`, this accepts sub-second `NaiveDateTime`
values and tuple epochs with a floating-point seconds field. Delegates to
`sidereon_core::astro::time::civil::j2000_seconds`.
"""
@spec epoch_to_j2000_seconds_fractional(NaiveDateTime.t() | tuple()) ::
{:ok, float()} | {:error, term()}
def epoch_to_j2000_seconds_fractional(%NaiveDateTime{} = epoch) do
{year, month, day, hour, minute, second} = civil_fields(epoch)
{:ok, NIF.civil_j2000_seconds(year, month, day, hour, minute, second)}
end
def epoch_to_j2000_seconds_fractional({{_year, _month, _day}, {_hour, _minute, _second}} = epoch) do
{year, month, day, hour, minute, second} = civil_fields(epoch)
{:ok, NIF.civil_j2000_seconds(year, month, day, hour, minute, second)}
end
def epoch_to_j2000_seconds_fractional(_other), do: {:error, :non_integer_second_epoch}
@doc """
Fractional day-of-year of the epoch, as the `float` the Niell troposphere
seasonal term consumes.
January 1 00:00 is 1.0. The continuous day-of-year comes from
`sidereon_core::astro::time::civil::day_of_year`, matching the crate's
fractional `SolveInputs.day_of_year` convention, so the SPP troposphere and
`Sidereon.GNSS.Troposphere` agree for the same epoch.
"""
@spec day_of_year(NaiveDateTime.t() | tuple()) :: float()
def day_of_year(epoch) do
{year, month, day, hour, minute, second} = civil_fields(epoch)
NIF.civil_day_of_year(year, month, day, hour, minute, second)
end
@doc """
Validated UTC instant for an epoch, as the split Julian date `{jd_whole, fraction}`.
Delegates to `sidereon_core::astro::time::model::Instant::from_utc_civil`, the
entry the ionosphere/troposphere delay dispatchers build their `epoch` argument
from. Unlike `epoch_to_split_jd/1`, this runs the core's `JulianDateSplit`
guard, so an out-of-day clock field is rejected as `{:error, :invalid_instant}`
rather than producing an out-of-range fraction.
iex> {:ok, {jd_whole, _fraction}} =
...> Sidereon.GNSS.Time.utc_instant_split({{2020, 6, 25}, {12, 0, 0}})
iex> jd_whole
2_459_025.5
"""
@spec utc_instant_split(NaiveDateTime.t() | tuple()) ::
{:ok, {float(), float()}} | {:error, term()}
def utc_instant_split(epoch) do
{year, month, day, hour, minute, second} = civil_fields(epoch)
NIF.civil_utc_instant_split(year, month, day, hour, minute, second)
end
# Marshal an epoch into civil `(year, month, day, hour, minute, second)` fields
# with a floating-point seconds component (sub-second microseconds folded in).
defp civil_fields(%NaiveDateTime{} = ndt) do
{micro, _precision} = ndt.microsecond
{ndt.year, ndt.month, ndt.day, ndt.hour, ndt.minute, ndt.second + micro / 1_000_000.0}
end
defp civil_fields({{year, month, day}, {hour, minute, second}}) do
{year, month, day, hour, minute, second / 1.0}
end
end