@neilberkman/sidereon 2.1.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -75,7 +75,10 @@ use crate::astro::math::vec3::norm3_ref as norm;
75
75
  use crate::validate;
76
76
  use std::fmt::Write as _;
77
77
 
78
- use super::{gregorian_to_two_part_julian_date, invalid_tide_input, BlqParseErrorKind, TideError};
78
+ use super::{
79
+ gregorian_to_two_part_julian_date, invalid_tide_input, BlqParseErrorKind, BlqWriteErrorKind,
80
+ TideError,
81
+ };
79
82
 
80
83
  /// Number of BLQ tidal constituents (M2 S2 N2 K2 K1 O1 P1 Q1 Mf Mm Ssa).
81
84
  pub const NUM_OCEAN_CONSTITUENTS: usize = 11;
@@ -86,16 +89,39 @@ const TWO_PI: f64 = 2.0 * std::f64::consts::PI;
86
89
  /// BLQ tidal constituents supported by the ARG2 evaluator.
87
90
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88
91
  pub enum OceanTideConstituent {
92
+ /// First BLQ/ARG2 slot (index 0), labeled `M2`; its ARG2 speed is
93
+ /// `1.40519e-4` rad/s with multipliers `(h0, s0, p0, 2pi) = (2, -2, 0, 0)`.
89
94
  M2,
95
+ /// Second BLQ/ARG2 slot (index 1), labeled `S2`; its ARG2 speed is
96
+ /// `1.45444e-4` rad/s and its multiplier row is all zero, so its argument
97
+ /// advances with the fractional hour alone.
90
98
  S2,
99
+ /// Third BLQ/ARG2 slot (index 2), labeled `N2`; its ARG2 speed is
100
+ /// `1.37880e-4` rad/s with multipliers `(h0, s0, p0, 2pi) = (2, -3, 1, 0)`.
91
101
  N2,
102
+ /// Fourth BLQ/ARG2 slot (index 3), labeled `K2`; its ARG2 speed is
103
+ /// `1.45842e-4` rad/s with multipliers `(h0, s0, p0, 2pi) = (2, 0, 0, 0)`.
92
104
  K2,
105
+ /// Fifth BLQ/ARG2 slot (index 4), labeled `K1`; its ARG2 speed is
106
+ /// `0.72921e-4` rad/s with multipliers `(h0, s0, p0, 2pi) = (1, 0, 0, 0.25)`.
93
107
  K1,
108
+ /// Sixth BLQ/ARG2 slot (index 5), labeled `O1`; its ARG2 speed is
109
+ /// `0.67598e-4` rad/s with multipliers `(h0, s0, p0, 2pi) = (1, -2, 0, -0.25)`.
94
110
  O1,
111
+ /// Seventh BLQ/ARG2 slot (index 6), labeled `P1`; its ARG2 speed is
112
+ /// `0.72523e-4` rad/s with multipliers `(h0, s0, p0, 2pi) = (-1, 0, 0, -0.25)`.
95
113
  P1,
114
+ /// Eighth BLQ/ARG2 slot (index 7), labeled `Q1`; its ARG2 speed is
115
+ /// `0.64959e-4` rad/s with multipliers `(h0, s0, p0, 2pi) = (1, -3, 1, -0.25)`.
96
116
  Q1,
117
+ /// Ninth BLQ/ARG2 slot (index 8), labeled `Mf`; its ARG2 speed is
118
+ /// `0.053234e-4` rad/s with multipliers `(h0, s0, p0, 2pi) = (0, 2, 0, 0)`.
97
119
  Mf,
120
+ /// Tenth BLQ/ARG2 slot (index 9), labeled `Mm`; its ARG2 speed is
121
+ /// `0.026392e-4` rad/s with multipliers `(h0, s0, p0, 2pi) = (0, 1, -1, 0)`.
98
122
  Mm,
123
+ /// Eleventh BLQ/ARG2 slot (index 10), labeled `Ssa`; its ARG2 speed is
124
+ /// `0.003982e-4` rad/s with multipliers `(h0, s0, p0, 2pi) = (2, 0, 0, 0)`.
99
125
  Ssa,
100
126
  }
101
127
 
@@ -213,37 +239,238 @@ pub struct OceanLoadingBlq {
213
239
  pub phase_deg: [[f64; NUM_OCEAN_CONSTITUENTS]; 3],
214
240
  }
215
241
 
242
+ /// Position of a retained comment or header line within its station block.
243
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
244
+ pub enum OceanLoadingBlqCommentPlacement {
245
+ /// Before the station line. The parser gives a block every line after the
246
+ /// previous block's last coefficient row, so file header comments belong
247
+ /// to the first block.
248
+ BeforeStation,
249
+ /// Before the zero-based coefficient row, `0..=5`; `BeforeRow(0)` is
250
+ /// between the station line and the first row. A column-order header here
251
+ /// sets the order of this row and every later one.
252
+ BeforeRow(usize),
253
+ /// After the sixth coefficient row. The parser uses it only for lines
254
+ /// that follow the last block of the input, and the writer accepts it
255
+ /// only on the last block it writes.
256
+ AfterRows,
257
+ }
258
+
259
+ /// One comment or header line retained from a BLQ block, as read.
260
+ #[derive(Debug, Clone, PartialEq, Eq)]
261
+ pub struct OceanLoadingBlqComment {
262
+ /// Where the line sits in the block.
263
+ pub placement: OceanLoadingBlqCommentPlacement,
264
+ /// The line exactly as read, without its line terminator.
265
+ pub line: String,
266
+ }
267
+
216
268
  /// One parsed standard BLQ station block.
217
269
  #[derive(Debug, Clone, PartialEq)]
218
270
  pub struct OceanLoadingBlqBlock {
219
- /// Station identifier line from the BLQ block.
271
+ /// Station identifier line from the BLQ block, trimmed.
220
272
  pub station: String,
221
273
  /// Parsed and reordered BLQ coefficients.
222
274
  pub coefficients: OceanLoadingBlq,
275
+ /// Comment and column-order header lines of the block, in input order.
276
+ /// They carry the model, frame, unit and row-direction declarations the
277
+ /// provider writes as comments; the writer restates them unchanged.
278
+ pub comments: Vec<OceanLoadingBlqComment>,
223
279
  }
224
280
 
225
281
  impl 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 {
282
+ /// Format as a standard six-row BLQ block.
283
+ ///
284
+ /// The retained comments are written back at their placements, the station
285
+ /// line starts in the third column (two leading spaces, as in the
286
+ /// provider's files and where RTKLIB `readblq` reads the name), and each
287
+ /// row is written in the column order its retained header declares, or the
288
+ /// standard order when none does. Output reads back to an equal block.
289
+ ///
290
+ /// Returns [`TideError::BlqWrite`] for a station, comment or coefficient
291
+ /// that the parser would not read back unchanged, including comments not
292
+ /// grouped by placement in file order. To write several blocks as one
293
+ /// file, use [`write_ocean_loading_blq_blocks`], which carries a
294
+ /// column-order header across blocks the way the parser does.
295
+ pub fn to_blq_block(&self) -> Result<String, TideError> {
229
296
  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);
297
+ let mut column_order = OCEAN_LOADING_CONSTITUENTS;
298
+ self.write_blq(&mut out, &mut column_order, 0, true)?;
299
+ Ok(out)
300
+ }
301
+
302
+ fn write_blq(
303
+ &self,
304
+ out: &mut String,
305
+ column_order: &mut [OceanTideConstituent; NUM_OCEAN_CONSTITUENTS],
306
+ block: usize,
307
+ is_last: bool,
308
+ ) -> Result<(), TideError> {
309
+ let fail = |kind| TideError::BlqWrite { block, kind };
310
+ validate_blq_station(&self.station).map_err(fail)?;
311
+ let rows = self.coefficient_rows();
312
+ for (row_index, row) in rows.iter().enumerate() {
313
+ for constituent in OCEAN_LOADING_CONSTITUENTS {
314
+ if !row[constituent.index()].is_finite() {
315
+ return Err(fail(BlqWriteErrorKind::NonFiniteCoefficient {
316
+ row: row_index,
317
+ constituent,
318
+ }));
319
+ }
320
+ }
239
321
  }
240
- for row in self.coefficients.phase_deg {
241
- write_blq_row(&mut out, row);
322
+ let mut headers = Vec::with_capacity(self.comments.len());
323
+ let mut previous_rank = 0;
324
+ for (index, comment) in self.comments.iter().enumerate() {
325
+ headers.push(validate_blq_comment(index, comment).map_err(fail)?);
326
+ let rank = placement_rank(comment.placement);
327
+ if rank < previous_rank {
328
+ return Err(fail(BlqWriteErrorKind::CommentsOutOfPlacementOrder {
329
+ index,
330
+ }));
331
+ }
332
+ previous_rank = rank;
333
+ if !is_last && comment.placement == OceanLoadingBlqCommentPlacement::AfterRows {
334
+ return Err(fail(BlqWriteErrorKind::AfterRowsBeforeAnotherBlock {
335
+ index,
336
+ }));
337
+ }
338
+ }
339
+
340
+ let placements = std::iter::once(OceanLoadingBlqCommentPlacement::BeforeStation);
341
+ self.write_comments(out, column_order, &headers, placements);
342
+ let _ = writeln!(out, " {}", self.station);
343
+ for (row_index, row) in rows.iter().enumerate() {
344
+ let placement = OceanLoadingBlqCommentPlacement::BeforeRow(row_index);
345
+ self.write_comments(out, column_order, &headers, std::iter::once(placement));
346
+ write_blq_row(out, row, column_order);
347
+ }
348
+ let placements = std::iter::once(OceanLoadingBlqCommentPlacement::AfterRows);
349
+ self.write_comments(out, column_order, &headers, placements);
350
+ Ok(())
351
+ }
352
+
353
+ /// Write the retained lines at `placements`, in retained order, applying
354
+ /// each header's column order as it is passed.
355
+ fn write_comments(
356
+ &self,
357
+ out: &mut String,
358
+ column_order: &mut [OceanTideConstituent; NUM_OCEAN_CONSTITUENTS],
359
+ headers: &[Option<[OceanTideConstituent; NUM_OCEAN_CONSTITUENTS]>],
360
+ placements: impl Iterator<Item = OceanLoadingBlqCommentPlacement>,
361
+ ) {
362
+ for placement in placements {
363
+ for (comment, header) in self.comments.iter().zip(headers) {
364
+ if comment.placement == placement {
365
+ if let Some(order) = header {
366
+ *column_order = *order;
367
+ }
368
+ out.push_str(&comment.line);
369
+ out.push('\n');
370
+ }
371
+ }
242
372
  }
243
- out
373
+ }
374
+
375
+ fn coefficient_rows(&self) -> [[f64; NUM_OCEAN_CONSTITUENTS]; 6] {
376
+ let amplitude = self.coefficients.amplitude_m;
377
+ let phase = self.coefficients.phase_deg;
378
+ [
379
+ amplitude[0],
380
+ amplitude[1],
381
+ amplitude[2],
382
+ phase[0],
383
+ phase[1],
384
+ phase[2],
385
+ ]
244
386
  }
245
387
  }
246
388
 
389
+ /// Write station blocks as one BLQ file.
390
+ ///
391
+ /// Blocks are written in order with [`OceanLoadingBlqBlock::to_blq_block`]'s
392
+ /// layout. A column-order header retained on one block stays in force for the
393
+ /// blocks after it, as it does when the parser reads the file, so parsing the
394
+ /// output of this function gives back equal blocks. A comment placed after the
395
+ /// rows of any block but the last is refused, because the parser reads it as
396
+ /// part of the next block.
397
+ pub fn write_ocean_loading_blq_blocks(
398
+ blocks: &[OceanLoadingBlqBlock],
399
+ ) -> Result<String, TideError> {
400
+ let mut out = String::new();
401
+ let mut column_order = OCEAN_LOADING_CONSTITUENTS;
402
+ for (index, block) in blocks.iter().enumerate() {
403
+ block.write_blq(
404
+ &mut out,
405
+ &mut column_order,
406
+ index,
407
+ index + 1 == blocks.len(),
408
+ )?;
409
+ }
410
+ Ok(out)
411
+ }
412
+
413
+ /// Position of a placement in file order, which is the order the parser
414
+ /// retains comments in.
415
+ fn placement_rank(placement: OceanLoadingBlqCommentPlacement) -> usize {
416
+ match placement {
417
+ OceanLoadingBlqCommentPlacement::BeforeStation => 0,
418
+ OceanLoadingBlqCommentPlacement::BeforeRow(row) => 1 + row,
419
+ OceanLoadingBlqCommentPlacement::AfterRows => 7,
420
+ }
421
+ }
422
+
423
+ /// Refuse a station name the parser would not read back as the same station.
424
+ fn validate_blq_station(station: &str) -> Result<(), BlqWriteErrorKind> {
425
+ if station.is_empty() {
426
+ return Err(BlqWriteErrorKind::EmptyStation);
427
+ }
428
+ if station.contains('\n') {
429
+ return Err(BlqWriteErrorKind::StationLineBreak);
430
+ }
431
+ if station.trim() != station {
432
+ return Err(BlqWriteErrorKind::StationSurroundingWhitespace);
433
+ }
434
+ if is_blq_comment(station) {
435
+ return Err(BlqWriteErrorKind::StationReadsAsComment);
436
+ }
437
+ if !matches!(parse_constituent_header(station, 0), Ok(None)) {
438
+ return Err(BlqWriteErrorKind::StationReadsAsHeader);
439
+ }
440
+ if looks_like_numeric_row(station) {
441
+ return Err(BlqWriteErrorKind::StationReadsAsCoefficientRow);
442
+ }
443
+ Ok(())
444
+ }
445
+
446
+ /// Refuse a retained line the parser would not read back as the same comment
447
+ /// or header at the same placement; return the column order a header declares.
448
+ fn validate_blq_comment(
449
+ index: usize,
450
+ comment: &OceanLoadingBlqComment,
451
+ ) -> Result<Option<[OceanTideConstituent; NUM_OCEAN_CONSTITUENTS]>, BlqWriteErrorKind> {
452
+ if comment.line.contains('\n') || comment.line.ends_with('\r') {
453
+ return Err(BlqWriteErrorKind::CommentLineBreak { index });
454
+ }
455
+ if let OceanLoadingBlqCommentPlacement::BeforeRow(row) = comment.placement {
456
+ if row > 5 {
457
+ return Err(BlqWriteErrorKind::CommentPlacementOutOfRange { index });
458
+ }
459
+ }
460
+ let trimmed = comment.line.trim();
461
+ if trimmed.is_empty() {
462
+ return Err(BlqWriteErrorKind::NotACommentLine { index });
463
+ }
464
+ let header = parse_constituent_header(trimmed, 0).map_err(|error| match error {
465
+ TideError::BlqParse { kind, .. } => BlqWriteErrorKind::InvalidHeader { index, kind },
466
+ _ => BlqWriteErrorKind::NotACommentLine { index },
467
+ })?;
468
+ if header.is_none() && !is_blq_comment(trimmed) {
469
+ return Err(BlqWriteErrorKind::NotACommentLine { index });
470
+ }
471
+ Ok(header)
472
+ }
473
+
247
474
  impl OceanLoadingBlq {
248
475
  /// Parse a single standard BLQ station block.
249
476
  pub fn from_blq_block(text: &str) -> Result<OceanLoadingBlqBlock, TideError> {
@@ -270,10 +497,23 @@ pub fn parse_ocean_loading_blq_block(text: &str) -> Result<OceanLoadingBlqBlock,
270
497
  }
271
498
 
272
499
  /// Parse all standard station blocks in a BLQ file.
500
+ ///
501
+ /// Lines starting with `$`, `#` or `!` are comments. A column-order header is
502
+ /// a line whose text after any comment markers is `COLUMN ORDER` (any case,
503
+ /// optional colon) followed by the constituent labels, or consists only of
504
+ /// two or more constituent labels, or is a comment in which a word `ORDER` is
505
+ /// followed to the end of the line by two or more constituent labels, such as
506
+ /// `$$ Constituent order: S2 M2 ...`. Other comments are prose, even when they
507
+ /// list constituents. A header sets the column order for every later row until
508
+ /// the next header, including the remaining rows of a block it appears inside.
509
+ /// Every label of a header must be one of the eleven supported constituents,
510
+ /// each once; anything else is refused by name rather than dropped. Comment
511
+ /// and header lines are retained on the block they belong to.
273
512
  pub fn parse_ocean_loading_blq_blocks(text: &str) -> Result<Vec<OceanLoadingBlqBlock>, TideError> {
274
- let mut blocks = Vec::new();
513
+ let mut blocks: Vec<OceanLoadingBlqBlock> = Vec::new();
275
514
  let mut station: Option<(usize, String)> = None;
276
515
  let mut rows: Vec<[f64; NUM_OCEAN_CONSTITUENTS]> = Vec::new();
516
+ let mut comments: Vec<OceanLoadingBlqComment> = Vec::new();
277
517
  let mut column_order = OCEAN_LOADING_CONSTITUENTS;
278
518
  let mut saw_content = false;
279
519
 
@@ -285,11 +525,20 @@ pub fn parse_ocean_loading_blq_blocks(text: &str) -> Result<Vec<OceanLoadingBlqB
285
525
  }
286
526
  saw_content = true;
287
527
 
288
- if let Some(order) = parse_constituent_header(trimmed, line_no)? {
289
- column_order = order;
290
- continue;
291
- }
292
- if is_blq_comment(trimmed) {
528
+ let header = parse_constituent_header(trimmed, line_no)?;
529
+ if header.is_some() || is_blq_comment(trimmed) {
530
+ let placement = if station.is_some() {
531
+ OceanLoadingBlqCommentPlacement::BeforeRow(rows.len())
532
+ } else {
533
+ OceanLoadingBlqCommentPlacement::BeforeStation
534
+ };
535
+ if let Some(order) = header {
536
+ column_order = order;
537
+ }
538
+ comments.push(OceanLoadingBlqComment {
539
+ placement,
540
+ line: raw_line.to_string(),
541
+ });
293
542
  continue;
294
543
  }
295
544
 
@@ -316,21 +565,15 @@ pub fn parse_ocean_loading_blq_blocks(text: &str) -> Result<Vec<OceanLoadingBlqB
316
565
 
317
566
  let row = parse_blq_numeric_row(trimmed, line_no, column_order)?;
318
567
  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
568
  if rows.len() == 6 {
332
- let (_, station_name) = station.take().expect("station present");
333
- blocks.push(block_from_rows(station_name, &rows));
569
+ let Some((_, station_name)) = station.take() else {
570
+ unreachable!("coefficient rows are only collected after a station line");
571
+ };
572
+ blocks.push(block_from_rows(
573
+ station_name,
574
+ &rows,
575
+ std::mem::take(&mut comments),
576
+ ));
334
577
  rows.clear();
335
578
  }
336
579
  }
@@ -351,6 +594,13 @@ pub fn parse_ocean_loading_blq_blocks(text: &str) -> Result<Vec<OceanLoadingBlqB
351
594
  },
352
595
  });
353
596
  }
597
+ if let Some(last) = blocks.last_mut() {
598
+ last.comments
599
+ .extend(comments.into_iter().map(|comment| OceanLoadingBlqComment {
600
+ placement: OceanLoadingBlqCommentPlacement::AfterRows,
601
+ line: comment.line,
602
+ }));
603
+ }
354
604
 
355
605
  Ok(blocks)
356
606
  }
@@ -358,6 +608,7 @@ pub fn parse_ocean_loading_blq_blocks(text: &str) -> Result<Vec<OceanLoadingBlqB
358
608
  fn block_from_rows(
359
609
  station: String,
360
610
  rows: &[[f64; NUM_OCEAN_CONSTITUENTS]],
611
+ comments: Vec<OceanLoadingBlqComment>,
361
612
  ) -> OceanLoadingBlqBlock {
362
613
  let mut amplitude_m = [[0.0_f64; NUM_OCEAN_CONSTITUENTS]; 3];
363
614
  let mut phase_deg = [[0.0_f64; NUM_OCEAN_CONSTITUENTS]; 3];
@@ -369,11 +620,17 @@ fn block_from_rows(
369
620
  amplitude_m,
370
621
  phase_deg,
371
622
  },
623
+ comments,
372
624
  }
373
625
  }
374
626
 
375
- fn write_blq_row(out: &mut String, row: [f64; NUM_OCEAN_CONSTITUENTS]) {
376
- for value in row {
627
+ fn write_blq_row(
628
+ out: &mut String,
629
+ row: &[f64; NUM_OCEAN_CONSTITUENTS],
630
+ column_order: &[OceanTideConstituent; NUM_OCEAN_CONSTITUENTS],
631
+ ) {
632
+ for constituent in column_order {
633
+ let value = row[constituent.index()];
377
634
  let _ = write!(out, " {value:>16}");
378
635
  }
379
636
  out.push('\n');
@@ -435,40 +692,93 @@ fn parse_blq_float_token(token: &str) -> Result<f64, BlqParseErrorKind> {
435
692
  Ok(value)
436
693
  }
437
694
 
695
+ /// Recognize a column-order header and return the order it declares.
696
+ ///
697
+ /// Three forms are headers:
698
+ ///
699
+ /// 1. The declared form: `COLUMN ORDER` (any case, optionally followed by a
700
+ /// colon) after any comment markers, as in the provider's
701
+ /// `$$ COLUMN ORDER: M2 S2 ...`. Everything after it is the label list.
702
+ /// 2. The bare form: a line of two or more tokens that all look like tidal
703
+ /// constituent labels, at least one of them a supported one.
704
+ /// 3. The ordered comment: a comment line in which a word `ORDER` (any case,
705
+ /// optionally followed by a colon) is followed, to the end of the line, by
706
+ /// two or more tokens that all look like constituent labels, at least one
707
+ /// of them a supported one, such as
708
+ /// `$$ Constituent order: S2 M2 ...`. Surrounding punctuation `: ; ( ) [ ] .`
709
+ /// is not part of a token.
710
+ ///
711
+ /// Any other line is not a header, including prose that lists constituents
712
+ /// without declaring an order, such as
713
+ /// `$$ diurnal K1 O1 P1 Q1, semidiurnal M2 S2 N2 K2, long-period MF MM SSA`.
714
+ /// The labels of every header form must be exactly the eleven supported
715
+ /// constituents, each once, or the header is refused by name.
438
716
  fn parse_constituent_header(
439
717
  line: &str,
440
718
  line_no: usize,
441
719
  ) -> 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);
720
+ let body = line.trim_start_matches(['$', '#', '!']).trim_start();
721
+ let split_labels = |text: &str| {
722
+ text.split(|c: char| c.is_whitespace() || c == ',')
723
+ .filter(|token| !token.is_empty())
724
+ .map(str::to_ascii_uppercase)
725
+ .collect::<Vec<_>>()
726
+ };
727
+
728
+ const DECLARATION: &str = "COLUMN ORDER";
729
+ let declared = body
730
+ .get(..DECLARATION.len())
731
+ .filter(|prefix| prefix.eq_ignore_ascii_case(DECLARATION))
732
+ .map(|_| &body[DECLARATION.len()..])
733
+ .filter(|rest| {
734
+ rest.is_empty() || rest.starts_with(':') || rest.starts_with(char::is_whitespace)
735
+ });
736
+ if let Some(rest) = declared {
737
+ let rest = rest.trim_start();
738
+ let rest = rest.strip_prefix(':').unwrap_or(rest);
739
+ return constituent_order(&split_labels(rest), line_no).map(Some);
447
740
  }
448
741
 
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);
742
+ let labels = split_labels(body);
743
+ let is_bare_header = labels.len() >= 2
744
+ && labels.iter().all(|label| is_constituent_like(label))
745
+ && labels
746
+ .iter()
747
+ .any(|label| OceanTideConstituent::from_label(label).is_some());
748
+ if is_bare_header {
749
+ return constituent_order(&labels, line_no).map(Some);
461
750
  }
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
- });
751
+
752
+ if is_blq_comment(line) {
753
+ let tokens = labels
754
+ .iter()
755
+ .map(|token| {
756
+ token
757
+ .trim_matches(|c: char| matches!(c, ':' | ';' | '(' | ')' | '[' | ']' | '.'))
758
+ .to_string()
759
+ })
760
+ .filter(|token| !token.is_empty())
761
+ .collect::<Vec<_>>();
762
+ for (index, token) in tokens.iter().enumerate() {
763
+ let list = &tokens[index + 1..];
764
+ if token == "ORDER"
765
+ && list.len() >= 2
766
+ && list.iter().all(|label| is_constituent_like(label))
767
+ && list
768
+ .iter()
769
+ .any(|label| OceanTideConstituent::from_label(label).is_some())
770
+ {
771
+ return constituent_order(list, line_no).map(Some);
772
+ }
773
+ }
470
774
  }
775
+ Ok(None)
776
+ }
471
777
 
778
+ fn constituent_order(
779
+ labels: &[String],
780
+ line_no: usize,
781
+ ) -> Result<[OceanTideConstituent; NUM_OCEAN_CONSTITUENTS], TideError> {
472
782
  let mut order = [OceanTideConstituent::M2; NUM_OCEAN_CONSTITUENTS];
473
783
  let mut seen = [false; NUM_OCEAN_CONSTITUENTS];
474
784
  for (idx, label) in labels.iter().enumerate() {
@@ -490,26 +800,20 @@ fn parse_constituent_header(
490
800
  });
491
801
  }
492
802
  seen[constituent_index] = true;
493
- order[idx] = constituent;
803
+ if idx < NUM_OCEAN_CONSTITUENTS {
804
+ order[idx] = constituent;
805
+ }
494
806
  }
495
- Ok(Some(order))
496
- }
497
-
498
- fn 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()
807
+ if labels.len() != NUM_OCEAN_CONSTITUENTS {
808
+ return Err(TideError::BlqParse {
809
+ line: line_no,
810
+ kind: BlqParseErrorKind::WrongColumnCount {
811
+ expected: NUM_OCEAN_CONSTITUENTS,
812
+ found: labels.len(),
813
+ },
814
+ });
815
+ }
816
+ Ok(order)
513
817
  }
514
818
 
515
819
  fn is_constituent_like(token: &str) -> bool {
@@ -523,6 +827,7 @@ fn is_constituent_like(token: &str) -> bool {
523
827
  )
524
828
  || (token.len() <= 4
525
829
  && token.chars().any(|c| c.is_ascii_digit())
830
+ && token.chars().any(|c| c.is_ascii_alphabetic())
526
831
  && token.chars().all(|c| c.is_ascii_alphanumeric()))
527
832
  }
528
833
 
@@ -611,7 +916,7 @@ fn ocean_tide_loading_unchecked(
611
916
  {
612
917
  let mut sum = 0.0;
613
918
  for ((&amplitude, &phase_deg), &a) in amplitudes.iter().zip(phases).zip(&arg) {
614
- sum += amplitude * (a - phase_deg * DEG_TO_RAD).cos();
919
+ sum += amplitude * libm::cos(a - phase_deg * DEG_TO_RAD);
615
920
  }
616
921
  *slot = sum;
617
922
  }
@@ -625,8 +930,8 @@ fn ocean_tide_loading_unchecked(
625
930
  let (lat_deg, lon_deg, _height_km) =
626
931
  itrs_to_geodetic_compute(xsta[0] / KM_TO_M, xsta[1] / KM_TO_M, xsta[2] / KM_TO_M)
627
932
  .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();
933
+ let (sinlat, coslat) = libm::sincos(lat_deg * DEG_TO_RAD);
934
+ let (sinlon, coslon) = libm::sincos(lon_deg * DEG_TO_RAD);
630
935
 
631
936
  // ENU basis vectors expressed in ECEF (geodetic topocentric frame):
632
937
  // e = [-sinlon, coslon, 0]