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::{
70    time::SECONDS_PER_HOUR,
71    units::{DEG_TO_RAD, KM_TO_M},
72};
73use crate::astro::frames::transforms::itrs_to_geodetic_compute;
74use crate::astro::math::vec3::norm3_ref as norm;
75use crate::validate;
76use std::fmt::Write as _;
77
78use super::{cal2jd, invalid_tide_input, BlqParseErrorKind, TideError};
79
80/// Number of BLQ tidal constituents (M2 S2 N2 K2 K1 O1 P1 Q1 Mf Mm Ssa).
81pub const NUM_OCEAN_CONSTITUENTS: usize = 11;
82
83/// Two pi (cycle of an astronomical argument).
84const TWO_PI: f64 = 2.0 * std::f64::consts::PI;
85
86/// BLQ tidal constituents supported by the ARG2 evaluator.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub enum OceanTideConstituent {
89    M2,
90    S2,
91    N2,
92    K2,
93    K1,
94    O1,
95    P1,
96    Q1,
97    Mf,
98    Mm,
99    Ssa,
100}
101
102impl OceanTideConstituent {
103    /// Standard BLQ constituent label.
104    pub const fn label(self) -> &'static str {
105        match self {
106            Self::M2 => "M2",
107            Self::S2 => "S2",
108            Self::N2 => "N2",
109            Self::K2 => "K2",
110            Self::K1 => "K1",
111            Self::O1 => "O1",
112            Self::P1 => "P1",
113            Self::Q1 => "Q1",
114            Self::Mf => "Mf",
115            Self::Mm => "Mm",
116            Self::Ssa => "Ssa",
117        }
118    }
119
120    const fn index(self) -> usize {
121        match self {
122            Self::M2 => 0,
123            Self::S2 => 1,
124            Self::N2 => 2,
125            Self::K2 => 3,
126            Self::K1 => 4,
127            Self::O1 => 5,
128            Self::P1 => 6,
129            Self::Q1 => 7,
130            Self::Mf => 8,
131            Self::Mm => 9,
132            Self::Ssa => 10,
133        }
134    }
135
136    fn from_label(label: &str) -> Option<Self> {
137        match label {
138            "M2" => Some(Self::M2),
139            "S2" => Some(Self::S2),
140            "N2" => Some(Self::N2),
141            "K2" => Some(Self::K2),
142            "K1" => Some(Self::K1),
143            "O1" => Some(Self::O1),
144            "P1" => Some(Self::P1),
145            "Q1" => Some(Self::Q1),
146            "MF" => Some(Self::Mf),
147            "MM" => Some(Self::Mm),
148            "SSA" => Some(Self::Ssa),
149            _ => None,
150        }
151    }
152}
153
154/// Standard BLQ column order.
155pub const OCEAN_LOADING_CONSTITUENTS: [OceanTideConstituent; NUM_OCEAN_CONSTITUENTS] = [
156    OceanTideConstituent::M2,
157    OceanTideConstituent::S2,
158    OceanTideConstituent::N2,
159    OceanTideConstituent::K2,
160    OceanTideConstituent::K1,
161    OceanTideConstituent::O1,
162    OceanTideConstituent::P1,
163    OceanTideConstituent::Q1,
164    OceanTideConstituent::Mf,
165    OceanTideConstituent::Mm,
166    OceanTideConstituent::Ssa,
167];
168
169/// IERS `ARG2.F` constituent angular speeds (rad/s), BLQ column order
170/// M2 S2 N2 K2 K1 O1 P1 Q1 Mf Mm Ssa.
171const SPEED_RAD_S: [f64; NUM_OCEAN_CONSTITUENTS] = [
172    1.405_19e-4,
173    1.454_44e-4,
174    1.378_80e-4,
175    1.458_42e-4,
176    0.729_21e-4,
177    0.675_98e-4,
178    0.725_23e-4,
179    0.649_59e-4,
180    0.053_234e-4,
181    0.026_392e-4,
182    0.003_982e-4,
183];
184
185/// IERS `ARG2.F` `ANGFAC` multipliers `(h0, s0, p0, 2pi)` per constituent. The
186/// fourth column is the quarter-cycle Schwiderski phase correction.
187#[rustfmt::skip]
188const ANGFAC: [[f64; 4]; NUM_OCEAN_CONSTITUENTS] = [
189    [ 2.0, -2.0,  0.0,  0.00], // M2
190    [ 0.0,  0.0,  0.0,  0.00], // S2
191    [ 2.0, -3.0,  1.0,  0.00], // N2
192    [ 2.0,  0.0,  0.0,  0.00], // K2
193    [ 1.0,  0.0,  0.0,  0.25], // K1
194    [ 1.0, -2.0,  0.0, -0.25], // O1
195    [-1.0,  0.0,  0.0, -0.25], // P1
196    [ 1.0, -3.0,  1.0, -0.25], // Q1
197    [ 0.0,  2.0,  0.0,  0.00], // Mf
198    [ 0.0,  1.0, -1.0,  0.00], // Mm
199    [ 2.0,  0.0,  0.0,  0.00], // Ssa
200];
201
202/// Per-station ocean-loading BLQ coefficients (Bos-Scherneck / HARDISP format).
203///
204/// Both arrays are indexed `[component][constituent]`. The component order is
205/// the BLQ row order: radial / up-positive (0), tangential EW / west-positive
206/// (1), tangential NS / south-positive (2). The constituent order is the BLQ
207/// column order M2 S2 N2 K2 K1 O1 P1 Q1 Mf Mm Ssa.
208#[derive(Debug, Clone, Copy, PartialEq)]
209pub struct OceanLoadingBlq {
210    /// Constituent amplitudes (m).
211    pub amplitude_m: [[f64; NUM_OCEAN_CONSTITUENTS]; 3],
212    /// Constituent Greenwich phase lags (degrees, positive lag).
213    pub phase_deg: [[f64; NUM_OCEAN_CONSTITUENTS]; 3],
214}
215
216/// One parsed standard BLQ station block.
217#[derive(Debug, Clone, PartialEq)]
218pub struct OceanLoadingBlqBlock {
219    /// Station identifier line from the BLQ block.
220    pub station: String,
221    /// Parsed and reordered BLQ coefficients.
222    pub coefficients: OceanLoadingBlq,
223}
224
225impl OceanLoadingBlqBlock {
226    /// Format as a standard six-row BLQ block in the supported constituent order.
227    #[must_use]
228    pub fn to_blq_block(&self) -> String {
229        let mut out = String::new();
230        let labels = OCEAN_LOADING_CONSTITUENTS
231            .iter()
232            .map(|constituent| constituent.label())
233            .collect::<Vec<_>>()
234            .join(" ");
235        let _ = writeln!(out, "$$ Column order: {labels}");
236        let _ = writeln!(out, "{}", self.station);
237        for row in self.coefficients.amplitude_m {
238            write_blq_row(&mut out, row);
239        }
240        for row in self.coefficients.phase_deg {
241            write_blq_row(&mut out, row);
242        }
243        out
244    }
245}
246
247impl OceanLoadingBlq {
248    /// Parse a single standard BLQ station block.
249    pub fn from_blq_block(text: &str) -> Result<OceanLoadingBlqBlock, TideError> {
250        parse_ocean_loading_blq_block(text)
251    }
252}
253
254/// Parse one standard Bos-Scherneck/HARDISP BLQ station block.
255pub fn parse_ocean_loading_blq_block(text: &str) -> Result<OceanLoadingBlqBlock, TideError> {
256    let mut blocks = parse_ocean_loading_blq_blocks(text)?;
257    match blocks.len() {
258        1 => Ok(blocks.remove(0)),
259        0 => Err(TideError::BlqParse {
260            line: 0,
261            kind: BlqParseErrorKind::Empty,
262        }),
263        _ => Err(TideError::BlqParse {
264            line: 0,
265            kind: BlqParseErrorKind::MultipleBlocks {
266                found: blocks.len(),
267            },
268        }),
269    }
270}
271
272/// Parse all standard station blocks in a BLQ file.
273pub fn parse_ocean_loading_blq_blocks(text: &str) -> Result<Vec<OceanLoadingBlqBlock>, TideError> {
274    let mut blocks = Vec::new();
275    let mut station: Option<(usize, String)> = None;
276    let mut rows: Vec<[f64; NUM_OCEAN_CONSTITUENTS]> = Vec::new();
277    let mut column_order = OCEAN_LOADING_CONSTITUENTS;
278    let mut saw_content = false;
279
280    for (idx, raw_line) in text.lines().enumerate() {
281        let line_no = idx + 1;
282        let trimmed = raw_line.trim();
283        if trimmed.is_empty() {
284            continue;
285        }
286        saw_content = true;
287
288        if let Some(order) = parse_constituent_header(trimmed, line_no)? {
289            column_order = order;
290            continue;
291        }
292        if is_blq_comment(trimmed) {
293            continue;
294        }
295
296        if station.is_none() {
297            if looks_like_numeric_row(trimmed) {
298                return Err(TideError::BlqParse {
299                    line: line_no,
300                    kind: BlqParseErrorKind::MissingStation,
301                });
302            }
303            station = Some((line_no, trimmed.to_string()));
304            rows.clear();
305            continue;
306        }
307
308        if !looks_like_numeric_row(trimmed) {
309            return Err(TideError::BlqParse {
310                line: line_no,
311                kind: BlqParseErrorKind::InvalidNumber {
312                    token: trimmed.to_string(),
313                },
314            });
315        }
316
317        let row = parse_blq_numeric_row(trimmed, line_no, column_order)?;
318        rows.push(row);
319        if rows.len() > 6 {
320            let station_name = station
321                .as_ref()
322                .map(|(_, name)| name.clone())
323                .unwrap_or_default();
324            return Err(TideError::BlqParse {
325                line: line_no,
326                kind: BlqParseErrorKind::TooManyCoefficientRows {
327                    station: station_name,
328                },
329            });
330        }
331        if rows.len() == 6 {
332            let (_, station_name) = station.take().expect("station present");
333            blocks.push(block_from_rows(station_name, &rows));
334            rows.clear();
335        }
336    }
337
338    if !saw_content {
339        return Err(TideError::BlqParse {
340            line: 0,
341            kind: BlqParseErrorKind::Empty,
342        });
343    }
344    if let Some((line, station_name)) = station {
345        return Err(TideError::BlqParse {
346            line,
347            kind: BlqParseErrorKind::MissingCoefficientRows {
348                station: station_name,
349                expected: 6,
350                found: rows.len(),
351            },
352        });
353    }
354
355    Ok(blocks)
356}
357
358fn block_from_rows(
359    station: String,
360    rows: &[[f64; NUM_OCEAN_CONSTITUENTS]],
361) -> OceanLoadingBlqBlock {
362    let mut amplitude_m = [[0.0_f64; NUM_OCEAN_CONSTITUENTS]; 3];
363    let mut phase_deg = [[0.0_f64; NUM_OCEAN_CONSTITUENTS]; 3];
364    amplitude_m.copy_from_slice(&rows[0..3]);
365    phase_deg.copy_from_slice(&rows[3..6]);
366    OceanLoadingBlqBlock {
367        station,
368        coefficients: OceanLoadingBlq {
369            amplitude_m,
370            phase_deg,
371        },
372    }
373}
374
375fn write_blq_row(out: &mut String, row: [f64; NUM_OCEAN_CONSTITUENTS]) {
376    for value in row {
377        let _ = write!(out, " {value:>16}");
378    }
379    out.push('\n');
380}
381
382fn is_blq_comment(line: &str) -> bool {
383    line.starts_with('$') || line.starts_with('#') || line.starts_with('!')
384}
385
386fn looks_like_numeric_row(line: &str) -> bool {
387    line.split_whitespace().next().is_some_and(|token| {
388        parse_blq_float_token(token).is_ok()
389            || token
390                .chars()
391                .next()
392                .is_some_and(|c| c == '+' || c == '-' || c == '.')
393    })
394}
395
396fn parse_blq_numeric_row(
397    line: &str,
398    line_no: usize,
399    column_order: [OceanTideConstituent; NUM_OCEAN_CONSTITUENTS],
400) -> Result<[f64; NUM_OCEAN_CONSTITUENTS], TideError> {
401    let tokens = line.split_whitespace().collect::<Vec<_>>();
402    if tokens.len() != NUM_OCEAN_CONSTITUENTS {
403        return Err(TideError::BlqParse {
404            line: line_no,
405            kind: BlqParseErrorKind::WrongColumnCount {
406                expected: NUM_OCEAN_CONSTITUENTS,
407                found: tokens.len(),
408            },
409        });
410    }
411
412    let mut row = [0.0_f64; NUM_OCEAN_CONSTITUENTS];
413    for (source_index, token) in tokens.iter().enumerate() {
414        let value = parse_blq_float_token(token).map_err(|kind| TideError::BlqParse {
415            line: line_no,
416            kind,
417        })?;
418        row[column_order[source_index].index()] = value;
419    }
420    Ok(row)
421}
422
423fn parse_blq_float_token(token: &str) -> Result<f64, BlqParseErrorKind> {
424    let normalized = token.replace('D', "E").replace('d', "e");
425    let value = normalized
426        .parse::<f64>()
427        .map_err(|_| BlqParseErrorKind::InvalidNumber {
428            token: token.to_string(),
429        })?;
430    if !value.is_finite() {
431        return Err(BlqParseErrorKind::NonFiniteNumber {
432            token: token.to_string(),
433        });
434    }
435    Ok(value)
436}
437
438fn parse_constituent_header(
439    line: &str,
440    line_no: usize,
441) -> Result<Option<[OceanTideConstituent; NUM_OCEAN_CONSTITUENTS]>, TideError> {
442    if line
443        .split_whitespace()
444        .all(|token| parse_blq_float_token(token).is_ok())
445    {
446        return Ok(None);
447    }
448
449    let upper = line.to_ascii_uppercase();
450    let header_hint = upper.contains("COLUMN") || upper.contains("CONSTITUENT");
451    let labels = line
452        .split_whitespace()
453        .map(normalize_constituent_token)
454        .filter(|token| is_constituent_like(token))
455        .collect::<Vec<_>>();
456    if labels.is_empty() {
457        return Ok(None);
458    }
459    if labels.len() != NUM_OCEAN_CONSTITUENTS && !header_hint {
460        return Ok(None);
461    }
462    if labels.len() != NUM_OCEAN_CONSTITUENTS {
463        return Err(TideError::BlqParse {
464            line: line_no,
465            kind: BlqParseErrorKind::WrongColumnCount {
466                expected: NUM_OCEAN_CONSTITUENTS,
467                found: labels.len(),
468            },
469        });
470    }
471
472    let mut order = [OceanTideConstituent::M2; NUM_OCEAN_CONSTITUENTS];
473    let mut seen = [false; NUM_OCEAN_CONSTITUENTS];
474    for (idx, label) in labels.iter().enumerate() {
475        let Some(constituent) = OceanTideConstituent::from_label(label) else {
476            return Err(TideError::BlqParse {
477                line: line_no,
478                kind: BlqParseErrorKind::UnsupportedConstituent {
479                    constituent: label.clone(),
480                },
481            });
482        };
483        let constituent_index = constituent.index();
484        if seen[constituent_index] {
485            return Err(TideError::BlqParse {
486                line: line_no,
487                kind: BlqParseErrorKind::DuplicateConstituent {
488                    constituent: constituent.label().to_string(),
489                },
490            });
491        }
492        seen[constituent_index] = true;
493        order[idx] = constituent;
494    }
495    Ok(Some(order))
496}
497
498fn normalize_constituent_token(token: &str) -> String {
499    token
500        .trim_matches(|c: char| {
501            c == '$'
502                || c == '#'
503                || c == '!'
504                || c == ':'
505                || c == ';'
506                || c == ','
507                || c == '('
508                || c == ')'
509                || c == '['
510                || c == ']'
511        })
512        .to_ascii_uppercase()
513}
514
515fn is_constituent_like(token: &str) -> bool {
516    if token.is_empty() {
517        return false;
518    }
519    OceanTideConstituent::from_label(token).is_some()
520        || matches!(
521            token,
522            "MSF" | "M4" | "MS4" | "MN4" | "SA" | "2N2" | "L2" | "T2"
523        )
524        || (token.len() <= 4
525            && token.chars().any(|c| c.is_ascii_digit())
526            && token.chars().all(|c| c.is_ascii_alphanumeric()))
527}
528
529/// Ocean tide loading displacement of an ITRF station, in metres (ECEF).
530///
531/// Arguments:
532/// * `xsta` - geocentric station position (m, ITRF).
533/// * `year`, `month`, `day` - UTC calendar date (selects the day of year).
534/// * `fhr` - UTC fractional hour of the day (`hour + min/60 + sec/3600`).
535/// * `blq` - the station's BLQ ocean-loading coefficients (a data dependency the
536///   caller supplies; the engine does not embed them).
537///
538/// Returns the displacement vector (m, geocentric ITRF), to be projected onto
539/// the line of sight identically to [`super::solid_earth_tide`].
540///
541/// Returns [`TideError`] when inputs are non-finite, the date/hour is invalid,
542/// the BLQ coefficients are non-finite, or the station vector is degenerate
543/// (zero radius).
544pub fn ocean_tide_loading(
545    xsta: &[f64; 3],
546    year: i32,
547    month: i32,
548    day: i32,
549    fhr: f64,
550    blq: &OceanLoadingBlq,
551) -> Result<[f64; 3], TideError> {
552    validate_ocean_loading_domain(xsta, year, month, day, fhr, blq)?;
553    Ok(ocean_tide_loading_unchecked(
554        xsta, year, month, day, fhr, blq,
555    ))
556}
557
558fn validate_ocean_loading_domain(
559    xsta: &[f64; 3],
560    year: i32,
561    month: i32,
562    day: i32,
563    fhr: f64,
564    blq: &OceanLoadingBlq,
565) -> Result<(), TideError> {
566    validate::finite_vec3(*xsta, "station position").map_err(invalid_tide_input)?;
567    validate::civil_datetime_with_second_policy(
568        i64::from(year),
569        i64::from(month),
570        i64::from(day),
571        0,
572        0,
573        0.0,
574        validate::CivilSecondPolicy::Continuous,
575    )
576    .map_err(invalid_tide_input)?;
577    validate::finite_in_range_exclusive_upper(fhr, 0.0, 24.0, "fractional hour")
578        .map_err(invalid_tide_input)?;
579
580    for component in &blq.amplitude_m {
581        for &amplitude in component {
582            validate::finite(amplitude, "ocean loading amplitude").map_err(invalid_tide_input)?;
583        }
584    }
585    for component in &blq.phase_deg {
586        for &phase in component {
587            validate::finite(phase, "ocean loading phase").map_err(invalid_tide_input)?;
588        }
589    }
590
591    validate::finite_positive(norm(xsta), "station radius").map_err(invalid_tide_input)?;
592
593    Ok(())
594}
595
596fn ocean_tide_loading_unchecked(
597    xsta: &[f64; 3],
598    year: i32,
599    month: i32,
600    day: i32,
601    fhr: f64,
602    blq: &OceanLoadingBlq,
603) -> [f64; 3] {
604    let arg = arg2_angles(year, month, day, fhr);
605
606    // BLQ component sums: 0 = radial (up), 1 = EW (west), 2 = NS (south).
607    let mut component = [0.0_f64; 3];
608    for (slot, (amplitudes, phases)) in component
609        .iter_mut()
610        .zip(blq.amplitude_m.iter().zip(blq.phase_deg.iter()))
611    {
612        let mut sum = 0.0;
613        for ((&amplitude, &phase_deg), &a) in amplitudes.iter().zip(phases).zip(&arg) {
614            sum += amplitude * (a - phase_deg * DEG_TO_RAD).cos();
615        }
616        *slot = sum;
617    }
618    let up = component[0];
619    let west = component[1];
620    let south = component[2];
621    let east = -west;
622    let north = -south;
623
624    // Geodetic (WGS84) ENU -> ECEF, matching RTKLIB ecef2pos/xyz2enu.
625    let (lat_deg, lon_deg, _height_km) =
626        itrs_to_geodetic_compute(xsta[0] / KM_TO_M, xsta[1] / KM_TO_M, xsta[2] / KM_TO_M)
627            .expect("validated station position yields geodetic coordinates");
628    let (sinlat, coslat) = (lat_deg * DEG_TO_RAD).sin_cos();
629    let (sinlon, coslon) = (lon_deg * DEG_TO_RAD).sin_cos();
630
631    // ENU basis vectors expressed in ECEF (geodetic topocentric frame):
632    //   e = [-sinlon, coslon, 0]
633    //   n = [-sinlat coslon, -sinlat sinlon, coslat]
634    //   u = [ coslat coslon,  coslat sinlon, sinlat]
635    [
636        east * (-sinlon) + north * (-sinlat * coslon) + up * (coslat * coslon),
637        east * coslon + north * (-sinlat * sinlon) + up * (coslat * sinlon),
638        north * coslat + up * sinlat,
639    ]
640}
641
642/// IERS `ARG2.F` astronomical arguments (radians) of the 11 BLQ constituents at
643/// the given UTC epoch.
644fn arg2_angles(year: i32, month: i32, day: i32, fhr: f64) -> [f64; NUM_OCEAN_CONSTITUENTS] {
645    let doy = day_of_year(year, month, day);
646    // `DAY` of ARG2 is the fractional day of year; `ID` its integer part and
647    // `FDAY` the seconds into the day, i.e. `(DAY - ID) * 86400 = fhr * 3600`.
648    let fday = fhr * SECONDS_PER_HOUR;
649
650    // ARG2.F day count and Julian centuries from the 1975 reference epoch.
651    // Fortran integer division (truncating toward zero, == floor for years
652    // >= 1973, the supported range) is reproduced by Rust's `/` on i32.
653    let icapd = doy + 365 * (year - 1975) + (year - 1973) / 4;
654    let capt = (27_392.500_528 + 1.000_000_035 * f64::from(icapd)) / 36_525.0;
655
656    // Mean longitudes (rad). ARG2.F uses a truncated DTR; the exact PI/180 used
657    // here is sub-femtometre different and is closer to the rigorous argument.
658    let h0 = (279.696_68 + (36_000.768_930_485 + 3.03e-4 * capt) * capt) * DEG_TO_RAD;
659    let s0 = (((1.9e-6 * capt - 0.001_133) * capt + 481_267.883_141_37) * capt + 270.434_358)
660        * DEG_TO_RAD;
661    let p0 = (((-1.2e-5 * capt - 0.010_325) * capt + 4_069.034_032_957_7) * capt + 334.329_653)
662        * DEG_TO_RAD;
663
664    let mut angle = [0.0_f64; NUM_OCEAN_CONSTITUENTS];
665    for (j, slot) in angle.iter_mut().enumerate() {
666        let a = SPEED_RAD_S[j] * fday
667            + ANGFAC[j][0] * h0
668            + ANGFAC[j][1] * s0
669            + ANGFAC[j][2] * p0
670            + ANGFAC[j][3] * TWO_PI;
671        *slot = a.rem_euclid(TWO_PI);
672    }
673    angle
674}
675
676/// 1-based UTC day of year (ARG2 `ID`), from the IERS/SOFA `CAL2JD` MJD diff
677/// (the `djm` return is in days, so the difference is the day-of-year minus 1).
678fn day_of_year(year: i32, month: i32, day: i32) -> i32 {
679    let (_, mjd) = cal2jd(year, month, day);
680    let (_, mjd_jan1) = cal2jd(year, 1, 1);
681    (mjd - mjd_jan1).round() as i32 + 1
682}