#[cfg(test)]
mod tests;
use crate::astro::constants::{
time::SECONDS_PER_HOUR,
units::{DEG_TO_RAD, KM_TO_M},
};
use crate::astro::frames::transforms::itrs_to_geodetic_compute;
use crate::astro::math::vec3::norm3_ref as norm;
use crate::validate;
use std::fmt::Write as _;
use super::{cal2jd, invalid_tide_input, BlqParseErrorKind, TideError};
pub const NUM_OCEAN_CONSTITUENTS: usize = 11;
const TWO_PI: f64 = 2.0 * std::f64::consts::PI;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum OceanTideConstituent {
M2,
S2,
N2,
K2,
K1,
O1,
P1,
Q1,
Mf,
Mm,
Ssa,
}
impl OceanTideConstituent {
pub const fn label(self) -> &'static str {
match self {
Self::M2 => "M2",
Self::S2 => "S2",
Self::N2 => "N2",
Self::K2 => "K2",
Self::K1 => "K1",
Self::O1 => "O1",
Self::P1 => "P1",
Self::Q1 => "Q1",
Self::Mf => "Mf",
Self::Mm => "Mm",
Self::Ssa => "Ssa",
}
}
const fn index(self) -> usize {
match self {
Self::M2 => 0,
Self::S2 => 1,
Self::N2 => 2,
Self::K2 => 3,
Self::K1 => 4,
Self::O1 => 5,
Self::P1 => 6,
Self::Q1 => 7,
Self::Mf => 8,
Self::Mm => 9,
Self::Ssa => 10,
}
}
fn from_label(label: &str) -> Option<Self> {
match label {
"M2" => Some(Self::M2),
"S2" => Some(Self::S2),
"N2" => Some(Self::N2),
"K2" => Some(Self::K2),
"K1" => Some(Self::K1),
"O1" => Some(Self::O1),
"P1" => Some(Self::P1),
"Q1" => Some(Self::Q1),
"MF" => Some(Self::Mf),
"MM" => Some(Self::Mm),
"SSA" => Some(Self::Ssa),
_ => None,
}
}
}
pub const OCEAN_LOADING_CONSTITUENTS: [OceanTideConstituent; NUM_OCEAN_CONSTITUENTS] = [
OceanTideConstituent::M2,
OceanTideConstituent::S2,
OceanTideConstituent::N2,
OceanTideConstituent::K2,
OceanTideConstituent::K1,
OceanTideConstituent::O1,
OceanTideConstituent::P1,
OceanTideConstituent::Q1,
OceanTideConstituent::Mf,
OceanTideConstituent::Mm,
OceanTideConstituent::Ssa,
];
const SPEED_RAD_S: [f64; NUM_OCEAN_CONSTITUENTS] = [
1.405_19e-4,
1.454_44e-4,
1.378_80e-4,
1.458_42e-4,
0.729_21e-4,
0.675_98e-4,
0.725_23e-4,
0.649_59e-4,
0.053_234e-4,
0.026_392e-4,
0.003_982e-4,
];
#[rustfmt::skip]
const ANGFAC: [[f64; 4]; NUM_OCEAN_CONSTITUENTS] = [
[ 2.0, -2.0, 0.0, 0.00], [ 0.0, 0.0, 0.0, 0.00], [ 2.0, -3.0, 1.0, 0.00], [ 2.0, 0.0, 0.0, 0.00], [ 1.0, 0.0, 0.0, 0.25], [ 1.0, -2.0, 0.0, -0.25], [-1.0, 0.0, 0.0, -0.25], [ 1.0, -3.0, 1.0, -0.25], [ 0.0, 2.0, 0.0, 0.00], [ 0.0, 1.0, -1.0, 0.00], [ 2.0, 0.0, 0.0, 0.00], ];
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct OceanLoadingBlq {
pub amplitude_m: [[f64; NUM_OCEAN_CONSTITUENTS]; 3],
pub phase_deg: [[f64; NUM_OCEAN_CONSTITUENTS]; 3],
}
#[derive(Debug, Clone, PartialEq)]
pub struct OceanLoadingBlqBlock {
pub station: String,
pub coefficients: OceanLoadingBlq,
}
impl OceanLoadingBlqBlock {
#[must_use]
pub fn to_blq_block(&self) -> String {
let mut out = String::new();
let labels = OCEAN_LOADING_CONSTITUENTS
.iter()
.map(|constituent| constituent.label())
.collect::<Vec<_>>()
.join(" ");
let _ = writeln!(out, "$$ Column order: {labels}");
let _ = writeln!(out, "{}", self.station);
for row in self.coefficients.amplitude_m {
write_blq_row(&mut out, row);
}
for row in self.coefficients.phase_deg {
write_blq_row(&mut out, row);
}
out
}
}
impl OceanLoadingBlq {
pub fn from_blq_block(text: &str) -> Result<OceanLoadingBlqBlock, TideError> {
parse_ocean_loading_blq_block(text)
}
}
pub fn parse_ocean_loading_blq_block(text: &str) -> Result<OceanLoadingBlqBlock, TideError> {
let mut blocks = parse_ocean_loading_blq_blocks(text)?;
match blocks.len() {
1 => Ok(blocks.remove(0)),
0 => Err(TideError::BlqParse {
line: 0,
kind: BlqParseErrorKind::Empty,
}),
_ => Err(TideError::BlqParse {
line: 0,
kind: BlqParseErrorKind::MultipleBlocks {
found: blocks.len(),
},
}),
}
}
pub fn parse_ocean_loading_blq_blocks(text: &str) -> Result<Vec<OceanLoadingBlqBlock>, TideError> {
let mut blocks = Vec::new();
let mut station: Option<(usize, String)> = None;
let mut rows: Vec<[f64; NUM_OCEAN_CONSTITUENTS]> = Vec::new();
let mut column_order = OCEAN_LOADING_CONSTITUENTS;
let mut saw_content = false;
for (idx, raw_line) in text.lines().enumerate() {
let line_no = idx + 1;
let trimmed = raw_line.trim();
if trimmed.is_empty() {
continue;
}
saw_content = true;
if let Some(order) = parse_constituent_header(trimmed, line_no)? {
column_order = order;
continue;
}
if is_blq_comment(trimmed) {
continue;
}
if station.is_none() {
if looks_like_numeric_row(trimmed) {
return Err(TideError::BlqParse {
line: line_no,
kind: BlqParseErrorKind::MissingStation,
});
}
station = Some((line_no, trimmed.to_string()));
rows.clear();
continue;
}
if !looks_like_numeric_row(trimmed) {
return Err(TideError::BlqParse {
line: line_no,
kind: BlqParseErrorKind::InvalidNumber {
token: trimmed.to_string(),
},
});
}
let row = parse_blq_numeric_row(trimmed, line_no, column_order)?;
rows.push(row);
if rows.len() > 6 {
let station_name = station
.as_ref()
.map(|(_, name)| name.clone())
.unwrap_or_default();
return Err(TideError::BlqParse {
line: line_no,
kind: BlqParseErrorKind::TooManyCoefficientRows {
station: station_name,
},
});
}
if rows.len() == 6 {
let (_, station_name) = station.take().expect("station present");
blocks.push(block_from_rows(station_name, &rows));
rows.clear();
}
}
if !saw_content {
return Err(TideError::BlqParse {
line: 0,
kind: BlqParseErrorKind::Empty,
});
}
if let Some((line, station_name)) = station {
return Err(TideError::BlqParse {
line,
kind: BlqParseErrorKind::MissingCoefficientRows {
station: station_name,
expected: 6,
found: rows.len(),
},
});
}
Ok(blocks)
}
fn block_from_rows(
station: String,
rows: &[[f64; NUM_OCEAN_CONSTITUENTS]],
) -> OceanLoadingBlqBlock {
let mut amplitude_m = [[0.0_f64; NUM_OCEAN_CONSTITUENTS]; 3];
let mut phase_deg = [[0.0_f64; NUM_OCEAN_CONSTITUENTS]; 3];
amplitude_m.copy_from_slice(&rows[0..3]);
phase_deg.copy_from_slice(&rows[3..6]);
OceanLoadingBlqBlock {
station,
coefficients: OceanLoadingBlq {
amplitude_m,
phase_deg,
},
}
}
fn write_blq_row(out: &mut String, row: [f64; NUM_OCEAN_CONSTITUENTS]) {
for value in row {
let _ = write!(out, " {value:>16}");
}
out.push('\n');
}
fn is_blq_comment(line: &str) -> bool {
line.starts_with('$') || line.starts_with('#') || line.starts_with('!')
}
fn looks_like_numeric_row(line: &str) -> bool {
line.split_whitespace().next().is_some_and(|token| {
parse_blq_float_token(token).is_ok()
|| token
.chars()
.next()
.is_some_and(|c| c == '+' || c == '-' || c == '.')
})
}
fn parse_blq_numeric_row(
line: &str,
line_no: usize,
column_order: [OceanTideConstituent; NUM_OCEAN_CONSTITUENTS],
) -> Result<[f64; NUM_OCEAN_CONSTITUENTS], TideError> {
let tokens = line.split_whitespace().collect::<Vec<_>>();
if tokens.len() != NUM_OCEAN_CONSTITUENTS {
return Err(TideError::BlqParse {
line: line_no,
kind: BlqParseErrorKind::WrongColumnCount {
expected: NUM_OCEAN_CONSTITUENTS,
found: tokens.len(),
},
});
}
let mut row = [0.0_f64; NUM_OCEAN_CONSTITUENTS];
for (source_index, token) in tokens.iter().enumerate() {
let value = parse_blq_float_token(token).map_err(|kind| TideError::BlqParse {
line: line_no,
kind,
})?;
row[column_order[source_index].index()] = value;
}
Ok(row)
}
fn parse_blq_float_token(token: &str) -> Result<f64, BlqParseErrorKind> {
let normalized = token.replace('D', "E").replace('d', "e");
let value = normalized
.parse::<f64>()
.map_err(|_| BlqParseErrorKind::InvalidNumber {
token: token.to_string(),
})?;
if !value.is_finite() {
return Err(BlqParseErrorKind::NonFiniteNumber {
token: token.to_string(),
});
}
Ok(value)
}
fn parse_constituent_header(
line: &str,
line_no: usize,
) -> Result<Option<[OceanTideConstituent; NUM_OCEAN_CONSTITUENTS]>, TideError> {
if line
.split_whitespace()
.all(|token| parse_blq_float_token(token).is_ok())
{
return Ok(None);
}
let upper = line.to_ascii_uppercase();
let header_hint = upper.contains("COLUMN") || upper.contains("CONSTITUENT");
let labels = line
.split_whitespace()
.map(normalize_constituent_token)
.filter(|token| is_constituent_like(token))
.collect::<Vec<_>>();
if labels.is_empty() {
return Ok(None);
}
if labels.len() != NUM_OCEAN_CONSTITUENTS && !header_hint {
return Ok(None);
}
if labels.len() != NUM_OCEAN_CONSTITUENTS {
return Err(TideError::BlqParse {
line: line_no,
kind: BlqParseErrorKind::WrongColumnCount {
expected: NUM_OCEAN_CONSTITUENTS,
found: labels.len(),
},
});
}
let mut order = [OceanTideConstituent::M2; NUM_OCEAN_CONSTITUENTS];
let mut seen = [false; NUM_OCEAN_CONSTITUENTS];
for (idx, label) in labels.iter().enumerate() {
let Some(constituent) = OceanTideConstituent::from_label(label) else {
return Err(TideError::BlqParse {
line: line_no,
kind: BlqParseErrorKind::UnsupportedConstituent {
constituent: label.clone(),
},
});
};
let constituent_index = constituent.index();
if seen[constituent_index] {
return Err(TideError::BlqParse {
line: line_no,
kind: BlqParseErrorKind::DuplicateConstituent {
constituent: constituent.label().to_string(),
},
});
}
seen[constituent_index] = true;
order[idx] = constituent;
}
Ok(Some(order))
}
fn normalize_constituent_token(token: &str) -> String {
token
.trim_matches(|c: char| {
c == '$'
|| c == '#'
|| c == '!'
|| c == ':'
|| c == ';'
|| c == ','
|| c == '('
|| c == ')'
|| c == '['
|| c == ']'
})
.to_ascii_uppercase()
}
fn is_constituent_like(token: &str) -> bool {
if token.is_empty() {
return false;
}
OceanTideConstituent::from_label(token).is_some()
|| matches!(
token,
"MSF" | "M4" | "MS4" | "MN4" | "SA" | "2N2" | "L2" | "T2"
)
|| (token.len() <= 4
&& token.chars().any(|c| c.is_ascii_digit())
&& token.chars().all(|c| c.is_ascii_alphanumeric()))
}
pub fn ocean_tide_loading(
xsta: &[f64; 3],
year: i32,
month: i32,
day: i32,
fhr: f64,
blq: &OceanLoadingBlq,
) -> Result<[f64; 3], TideError> {
validate_ocean_loading_domain(xsta, year, month, day, fhr, blq)?;
Ok(ocean_tide_loading_unchecked(
xsta, year, month, day, fhr, blq,
))
}
fn validate_ocean_loading_domain(
xsta: &[f64; 3],
year: i32,
month: i32,
day: i32,
fhr: f64,
blq: &OceanLoadingBlq,
) -> Result<(), TideError> {
validate::finite_vec3(*xsta, "station position").map_err(invalid_tide_input)?;
validate::civil_datetime_with_second_policy(
i64::from(year),
i64::from(month),
i64::from(day),
0,
0,
0.0,
validate::CivilSecondPolicy::Continuous,
)
.map_err(invalid_tide_input)?;
validate::finite_in_range_exclusive_upper(fhr, 0.0, 24.0, "fractional hour")
.map_err(invalid_tide_input)?;
for component in &blq.amplitude_m {
for &litude in component {
validate::finite(amplitude, "ocean loading amplitude").map_err(invalid_tide_input)?;
}
}
for component in &blq.phase_deg {
for &phase in component {
validate::finite(phase, "ocean loading phase").map_err(invalid_tide_input)?;
}
}
validate::finite_positive(norm(xsta), "station radius").map_err(invalid_tide_input)?;
Ok(())
}
fn ocean_tide_loading_unchecked(
xsta: &[f64; 3],
year: i32,
month: i32,
day: i32,
fhr: f64,
blq: &OceanLoadingBlq,
) -> [f64; 3] {
let arg = arg2_angles(year, month, day, fhr);
let mut component = [0.0_f64; 3];
for (slot, (amplitudes, phases)) in component
.iter_mut()
.zip(blq.amplitude_m.iter().zip(blq.phase_deg.iter()))
{
let mut sum = 0.0;
for ((&litude, &phase_deg), &a) in amplitudes.iter().zip(phases).zip(&arg) {
sum += amplitude * (a - phase_deg * DEG_TO_RAD).cos();
}
*slot = sum;
}
let up = component[0];
let west = component[1];
let south = component[2];
let east = -west;
let north = -south;
let (lat_deg, lon_deg, _height_km) =
itrs_to_geodetic_compute(xsta[0] / KM_TO_M, xsta[1] / KM_TO_M, xsta[2] / KM_TO_M)
.expect("validated station position yields geodetic coordinates");
let (sinlat, coslat) = (lat_deg * DEG_TO_RAD).sin_cos();
let (sinlon, coslon) = (lon_deg * DEG_TO_RAD).sin_cos();
[
east * (-sinlon) + north * (-sinlat * coslon) + up * (coslat * coslon),
east * coslon + north * (-sinlat * sinlon) + up * (coslat * sinlon),
north * coslat + up * sinlat,
]
}
fn arg2_angles(year: i32, month: i32, day: i32, fhr: f64) -> [f64; NUM_OCEAN_CONSTITUENTS] {
let doy = day_of_year(year, month, day);
let fday = fhr * SECONDS_PER_HOUR;
let icapd = doy + 365 * (year - 1975) + (year - 1973) / 4;
let capt = (27_392.500_528 + 1.000_000_035 * f64::from(icapd)) / 36_525.0;
let h0 = (279.696_68 + (36_000.768_930_485 + 3.03e-4 * capt) * capt) * DEG_TO_RAD;
let s0 = (((1.9e-6 * capt - 0.001_133) * capt + 481_267.883_141_37) * capt + 270.434_358)
* DEG_TO_RAD;
let p0 = (((-1.2e-5 * capt - 0.010_325) * capt + 4_069.034_032_957_7) * capt + 334.329_653)
* DEG_TO_RAD;
let mut angle = [0.0_f64; NUM_OCEAN_CONSTITUENTS];
for (j, slot) in angle.iter_mut().enumerate() {
let a = SPEED_RAD_S[j] * fday
+ ANGFAC[j][0] * h0
+ ANGFAC[j][1] * s0
+ ANGFAC[j][2] * p0
+ ANGFAC[j][3] * TWO_PI;
*slot = a.rem_euclid(TWO_PI);
}
angle
}
fn day_of_year(year: i32, month: i32, day: i32) -> i32 {
let (_, mjd) = cal2jd(year, month, day);
let (_, mjd_jan1) = cal2jd(year, 1, 1);
(mjd - mjd_jan1).round() as i32 + 1
}