Skip to main content

sidereon_core/tides/
ocean.rs

1//! Ocean tide loading station displacement (IERS Conventions 2010, §6.2; the
2//! Bos-Scherneck BLQ convention), via the IERS `ARG2` 11-constituent
3//! astronomical-argument method.
4//!
5//! Scope: this is the ARG2 main-constituent method (the 11 BLQ constituents
6//! below), **not** the full HARDISP admittance scheme. It does not apply the
7//! 18.6-yr nodal modulation or interpolate the minor side constituents that
8//! HARDISP (e.g. RTKLIB's 342-constituent spline) carries - see the
9//! "Astronomical arguments" note. For inland stations the difference is sub-mm
10//! (validated against RTKLIB below), but this is deliberately the ARG2
11//! approximation, not a HARDISP reimplementation.
12//!
13//! [`ocean_tide_loading`] computes the displacement of an Earth-fixed (ITRF)
14//! station caused by the elastic deformation of the solid Earth under the
15//! periodic load of the ocean tide. It is the sibling of
16//! [`super::solid_earth_tide`] and [`super::solid_earth_pole_tide`] and is wired
17//! into the PPP correction stack in the identical way: a per-epoch station
18//! displacement vector projected onto the line of sight in
19//! `precise_positioning/model.rs`.
20//!
21//! Physics (IERS Conventions 2010, §6.2; the HARDISP / BLQ convention). The
22//! site displacement in each of the three BLQ components is the sum over 11
23//! tidal constituents (in BLQ column order M2, S2, N2, K2, K1, O1, P1, Q1, Mf,
24//! Mm, Ssa) of
25//!
26//! ```text
27//! dc(t) = sum_j  A_cj * cos( arg_j(t) - phi_cj )          (per component c)
28//! ```
29//!
30//! where `A_cj` (m) and `phi_cj` (rad) are the per-station BLQ amplitude and
31//! Greenwich phase lag for component `c` and constituent `j`, and `arg_j(t)` is
32//! the astronomical (equilibrium) argument of constituent `j` at the epoch.
33//! This is the displacement formula the Bos-Scherneck BLQ tables are designed
34//! for; RTKLIB's `tide_oload`/`hardisp` is used as the validation oracle, and
35//! agreement holds to sub-mm for inland stations (it is not claimed to be
36//! bit-identical to HARDISP - the constituent sets differ, see below).
37//!
38//! Astronomical arguments. `arg_j(t)` is the IERS `ARG2` argument (IERS
39//! Conventions 2010 Chapter 7 reference software `ARG2.F`):
40//!
41//! ```text
42//! arg_j = SPEED_j * FDAY + n1_j*h0 + n2_j*s0 + n3_j*p0 + n4_j*2pi   (mod 2pi)
43//! ```
44//!
45//! with `FDAY` the UT seconds of the day, `(h0, s0, p0)` the mean longitudes of
46//! the Sun, the Moon, and the lunar perigee at 0h of the day (`ARG2.F` cubic
47//! polynomials in `CAPT`, Julian centuries from the 1975 reference epoch),
48//! `SPEED_j` the constituent angular speed (rad/s), and `(n1..n4)_j` the
49//! `ANGFAC` multipliers. The quarter-cycle `n4_j` entries (`+/-0.25`) are the
50//! Schwiderski phase corrections the `cos(arg - phi)` convention requires for
51//! the diurnal band. `ARG2` deliberately omits the 18.6-yr nodal modulation and
52//! the minor side constituents that the full HARDISP admittance method (e.g.
53//! RTKLIB's 342-constituent spline) interpolates; for an inland station the
54//! resulting difference is well below the millimetre (verified against RTKLIB in
55//! `tests/ocean_loading_oracle.rs`).
56//!
57//! BLQ components are radial (positive up), tangential EW (positive west), and
58//! tangential NS (positive south); the returned vector is the geodetic ENU
59//! displacement (east = -west, north = -south, up = radial) rotated to ECEF on
60//! the WGS84 ellipsoid, matching RTKLIB's `ecef2pos`/`xyz2enu`.
61//!
62//! The per-station BLQ coefficients are a data dependency the caller supplies
63//! from an ocean-loading provider (Bos-Scherneck / OSO Chalmers, or equivalent);
64//! the engine does not embed them and they must not be fabricated.
65
66#[cfg(test)]
67mod tests;
68
69use crate::astro::constants::units::{DEG_TO_RAD, KM_TO_M};
70use crate::astro::frames::transforms::itrs_to_geodetic_compute;
71use crate::astro::math::vec3::norm3_ref as norm;
72use crate::validate;
73
74use super::{cal2jd, invalid_tide_input, TideError};
75
76/// Number of BLQ tidal constituents (M2 S2 N2 K2 K1 O1 P1 Q1 Mf Mm Ssa).
77pub const NUM_OCEAN_CONSTITUENTS: usize = 11;
78
79/// Two pi (cycle of an astronomical argument).
80const TWO_PI: f64 = 2.0 * std::f64::consts::PI;
81
82/// IERS `ARG2.F` constituent angular speeds (rad/s), BLQ column order
83/// M2 S2 N2 K2 K1 O1 P1 Q1 Mf Mm Ssa.
84const SPEED_RAD_S: [f64; NUM_OCEAN_CONSTITUENTS] = [
85    1.405_19e-4,
86    1.454_44e-4,
87    1.378_80e-4,
88    1.458_42e-4,
89    0.729_21e-4,
90    0.675_98e-4,
91    0.725_23e-4,
92    0.649_59e-4,
93    0.053_234e-4,
94    0.026_392e-4,
95    0.003_982e-4,
96];
97
98/// IERS `ARG2.F` `ANGFAC` multipliers `(h0, s0, p0, 2pi)` per constituent. The
99/// fourth column is the quarter-cycle Schwiderski phase correction.
100#[rustfmt::skip]
101const ANGFAC: [[f64; 4]; NUM_OCEAN_CONSTITUENTS] = [
102    [ 2.0, -2.0,  0.0,  0.00], // M2
103    [ 0.0,  0.0,  0.0,  0.00], // S2
104    [ 2.0, -3.0,  1.0,  0.00], // N2
105    [ 2.0,  0.0,  0.0,  0.00], // K2
106    [ 1.0,  0.0,  0.0,  0.25], // K1
107    [ 1.0, -2.0,  0.0, -0.25], // O1
108    [-1.0,  0.0,  0.0, -0.25], // P1
109    [ 1.0, -3.0,  1.0, -0.25], // Q1
110    [ 0.0,  2.0,  0.0,  0.00], // Mf
111    [ 0.0,  1.0, -1.0,  0.00], // Mm
112    [ 2.0,  0.0,  0.0,  0.00], // Ssa
113];
114
115/// Per-station ocean-loading BLQ coefficients (Bos-Scherneck / HARDISP format).
116///
117/// Both arrays are indexed `[component][constituent]`. The component order is
118/// the BLQ row order: radial / up-positive (0), tangential EW / west-positive
119/// (1), tangential NS / south-positive (2). The constituent order is the BLQ
120/// column order M2 S2 N2 K2 K1 O1 P1 Q1 Mf Mm Ssa.
121#[derive(Debug, Clone, Copy, PartialEq)]
122pub struct OceanLoadingBlq {
123    /// Constituent amplitudes (m).
124    pub amplitude_m: [[f64; NUM_OCEAN_CONSTITUENTS]; 3],
125    /// Constituent Greenwich phase lags (degrees, positive lag).
126    pub phase_deg: [[f64; NUM_OCEAN_CONSTITUENTS]; 3],
127}
128
129/// Ocean tide loading displacement of an ITRF station, in metres (ECEF).
130///
131/// Arguments:
132/// * `xsta` - geocentric station position (m, ITRF).
133/// * `year`, `month`, `day` - UTC calendar date (selects the day of year).
134/// * `fhr` - UTC fractional hour of the day (`hour + min/60 + sec/3600`).
135/// * `blq` - the station's BLQ ocean-loading coefficients (a data dependency the
136///   caller supplies; the engine does not embed them).
137///
138/// Returns the displacement vector (m, geocentric ITRF), to be projected onto
139/// the line of sight identically to [`super::solid_earth_tide`].
140///
141/// Returns [`TideError`] when inputs are non-finite, the date/hour is invalid,
142/// the BLQ coefficients are non-finite, or the station vector is degenerate
143/// (zero radius).
144pub fn ocean_tide_loading(
145    xsta: &[f64; 3],
146    year: i32,
147    month: i32,
148    day: i32,
149    fhr: f64,
150    blq: &OceanLoadingBlq,
151) -> Result<[f64; 3], TideError> {
152    validate_ocean_loading_domain(xsta, year, month, day, fhr, blq)?;
153    Ok(ocean_tide_loading_unchecked(
154        xsta, year, month, day, fhr, blq,
155    ))
156}
157
158fn validate_ocean_loading_domain(
159    xsta: &[f64; 3],
160    year: i32,
161    month: i32,
162    day: i32,
163    fhr: f64,
164    blq: &OceanLoadingBlq,
165) -> Result<(), TideError> {
166    validate::finite_vec3(*xsta, "station position").map_err(invalid_tide_input)?;
167    validate::civil_datetime_with_second_policy(
168        i64::from(year),
169        i64::from(month),
170        i64::from(day),
171        0,
172        0,
173        0.0,
174        validate::CivilSecondPolicy::Continuous,
175    )
176    .map_err(invalid_tide_input)?;
177    validate::finite_in_range_exclusive_upper(fhr, 0.0, 24.0, "fractional hour")
178        .map_err(invalid_tide_input)?;
179
180    for component in &blq.amplitude_m {
181        for &amplitude in component {
182            validate::finite(amplitude, "ocean loading amplitude").map_err(invalid_tide_input)?;
183        }
184    }
185    for component in &blq.phase_deg {
186        for &phase in component {
187            validate::finite(phase, "ocean loading phase").map_err(invalid_tide_input)?;
188        }
189    }
190
191    validate::finite_positive(norm(xsta), "station radius").map_err(invalid_tide_input)?;
192
193    Ok(())
194}
195
196fn ocean_tide_loading_unchecked(
197    xsta: &[f64; 3],
198    year: i32,
199    month: i32,
200    day: i32,
201    fhr: f64,
202    blq: &OceanLoadingBlq,
203) -> [f64; 3] {
204    let arg = arg2_angles(year, month, day, fhr);
205
206    // BLQ component sums: 0 = radial (up), 1 = EW (west), 2 = NS (south).
207    let mut component = [0.0_f64; 3];
208    for (slot, (amplitudes, phases)) in component
209        .iter_mut()
210        .zip(blq.amplitude_m.iter().zip(blq.phase_deg.iter()))
211    {
212        let mut sum = 0.0;
213        for ((&amplitude, &phase_deg), &a) in amplitudes.iter().zip(phases).zip(&arg) {
214            sum += amplitude * (a - phase_deg * DEG_TO_RAD).cos();
215        }
216        *slot = sum;
217    }
218    let up = component[0];
219    let west = component[1];
220    let south = component[2];
221    let east = -west;
222    let north = -south;
223
224    // Geodetic (WGS84) ENU -> ECEF, matching RTKLIB ecef2pos/xyz2enu.
225    let (lat_deg, lon_deg, _height_km) =
226        itrs_to_geodetic_compute(xsta[0] / KM_TO_M, xsta[1] / KM_TO_M, xsta[2] / KM_TO_M)
227            .expect("validated station position yields geodetic coordinates");
228    let (sinlat, coslat) = (lat_deg * DEG_TO_RAD).sin_cos();
229    let (sinlon, coslon) = (lon_deg * DEG_TO_RAD).sin_cos();
230
231    // ENU basis vectors expressed in ECEF (geodetic topocentric frame):
232    //   e = [-sinlon, coslon, 0]
233    //   n = [-sinlat coslon, -sinlat sinlon, coslat]
234    //   u = [ coslat coslon,  coslat sinlon, sinlat]
235    [
236        east * (-sinlon) + north * (-sinlat * coslon) + up * (coslat * coslon),
237        east * coslon + north * (-sinlat * sinlon) + up * (coslat * sinlon),
238        north * coslat + up * sinlat,
239    ]
240}
241
242/// IERS `ARG2.F` astronomical arguments (radians) of the 11 BLQ constituents at
243/// the given UTC epoch.
244fn arg2_angles(year: i32, month: i32, day: i32, fhr: f64) -> [f64; NUM_OCEAN_CONSTITUENTS] {
245    let doy = day_of_year(year, month, day);
246    // `DAY` of ARG2 is the fractional day of year; `ID` its integer part and
247    // `FDAY` the seconds into the day, i.e. `(DAY - ID) * 86400 = fhr * 3600`.
248    let fday = fhr * 3600.0;
249
250    // ARG2.F day count and Julian centuries from the 1975 reference epoch.
251    // Fortran integer division (truncating toward zero, == floor for years
252    // >= 1973, the supported range) is reproduced by Rust's `/` on i32.
253    let icapd = doy + 365 * (year - 1975) + (year - 1973) / 4;
254    let capt = (27_392.500_528 + 1.000_000_035 * f64::from(icapd)) / 36_525.0;
255
256    // Mean longitudes (rad). ARG2.F uses a truncated DTR; the exact PI/180 used
257    // here is sub-femtometre different and is closer to the rigorous argument.
258    let h0 = (279.696_68 + (36_000.768_930_485 + 3.03e-4 * capt) * capt) * DEG_TO_RAD;
259    let s0 = (((1.9e-6 * capt - 0.001_133) * capt + 481_267.883_141_37) * capt + 270.434_358)
260        * DEG_TO_RAD;
261    let p0 = (((-1.2e-5 * capt - 0.010_325) * capt + 4_069.034_032_957_7) * capt + 334.329_653)
262        * DEG_TO_RAD;
263
264    let mut angle = [0.0_f64; NUM_OCEAN_CONSTITUENTS];
265    for (j, slot) in angle.iter_mut().enumerate() {
266        let a = SPEED_RAD_S[j] * fday
267            + ANGFAC[j][0] * h0
268            + ANGFAC[j][1] * s0
269            + ANGFAC[j][2] * p0
270            + ANGFAC[j][3] * TWO_PI;
271        *slot = a.rem_euclid(TWO_PI);
272    }
273    angle
274}
275
276/// 1-based UTC day of year (ARG2 `ID`), from the IERS/SOFA `CAL2JD` MJD diff
277/// (the `djm` return is in days, so the difference is the day-of-year minus 1).
278fn day_of_year(year: i32, month: i32, day: i32) -> i32 {
279    let (_, mjd) = cal2jd(year, month, day);
280    let (_, mjd_jan1) = cal2jd(year, 1, 1);
281    (mjd - mjd_jan1).round() as i32 + 1
282}