1#[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
80pub const NUM_OCEAN_CONSTITUENTS: usize = 11;
82
83const TWO_PI: f64 = 2.0 * std::f64::consts::PI;
85
86#[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 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
154pub 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
169const 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#[rustfmt::skip]
188const ANGFAC: [[f64; 4]; NUM_OCEAN_CONSTITUENTS] = [
189 [ 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], ];
201
202#[derive(Debug, Clone, Copy, PartialEq)]
209pub struct OceanLoadingBlq {
210 pub amplitude_m: [[f64; NUM_OCEAN_CONSTITUENTS]; 3],
212 pub phase_deg: [[f64; NUM_OCEAN_CONSTITUENTS]; 3],
214}
215
216#[derive(Debug, Clone, PartialEq)]
218pub struct OceanLoadingBlqBlock {
219 pub station: String,
221 pub coefficients: OceanLoadingBlq,
223}
224
225impl OceanLoadingBlqBlock {
226 #[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 pub fn from_blq_block(text: &str) -> Result<OceanLoadingBlqBlock, TideError> {
250 parse_ocean_loading_blq_block(text)
251 }
252}
253
254pub 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
272pub 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
529pub 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 &litude 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 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 ((&litude, &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 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 [
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
642fn arg2_angles(year: i32, month: i32, day: i32, fhr: f64) -> [f64; NUM_OCEAN_CONSTITUENTS] {
645 let doy = day_of_year(year, month, day);
646 let fday = fhr * SECONDS_PER_HOUR;
649
650 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 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
676fn 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}