@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,520 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 3.0.1 - 2026-10-04
6
+
7
+ ### Changed
8
+
9
+ - Updates the Rust engine dependencies to sidereon and sidereon-core 3.0.1. This coordinates the binding and crate versions; the engine patch does not change numerical algorithms or the JavaScript API.
10
+
11
+ ## 3.0.0 - 2026-10-04
12
+
13
+ Engine update: sidereon / sidereon-core 3.0.0. Every entry below
14
+ restates an engine change as it reaches the JavaScript API.
15
+
16
+ ### Labels
17
+
18
+ - **Breaking.** A variant a later engine adds to an enum this binding labels
19
+ crosses under its own name, the engine variant's name in the case of the
20
+ labels around it (`lowerCamelCase`, or `UPPER_SNAKE_CASE` for TDM input
21
+ error kinds), where 2.1.1 gave `"unknown"` or `"UNKNOWN"`. This covers the
22
+ almanac season, Moon phase, planet, planetary event, culmination and eclipse
23
+ kinds; the RINEX clock header and record readings; the TDM input error
24
+ kinds; and the new PPP unplaced reasons, Bias lookup statuses, RTCM and SBAS
25
+ departures and SBAS refused-line reasons. Where the declarations state these
26
+ as unions of literals, the unions end in
27
+ `| (string & {})`.
28
+
29
+ ### Positioning
30
+
31
+ - **Breaking.** SPP, static, DGNSS, PPP and tight-fusion solves place each
32
+ satellite at the transmission epoch of its measured pseudorange,
33
+ `t_tx = (t_rx - P / c) - dts`, as RTKLIB `satposs` places it. This moves
34
+ solutions by the receiver clock times each satellite's range rate: nothing
35
+ measurable on a steered receiver clock, decimetres of range on a receiver
36
+ clock of half a millisecond.
37
+ - **Breaking.** The SPP, DGNSS and tight-fusion code models apply to a precise
38
+ (SP3 or interpolated precise) satellite clock the relativistic term
39
+ `-2 r.v / c^2`, as RTKLIB `peph2pos` does; they applied none. Their
40
+ solutions on precise sources move by metres to tens of metres. PPP rows
41
+ already applied the term and do not change for it.
42
+ - **Breaking.** `buildRinexRtkArc` and `buildDualFrequencyRinexRtkArc` place
43
+ each receiver's satellites from that receiver's own pseudoranges, as RTKLIB
44
+ `satposs` places them, where they used the reception epoch less the
45
+ pseudorange over `c` rounded to whole microseconds with no satellite clock.
46
+ A satellite is skipped for an epoch where the source has no clock for it or
47
+ its pseudorange is not a positive distance. A satellite whose phase
48
+ observable has no carrier no longer fails the arc: its measurement is formed
49
+ from the first configured pair whose carriers resolve, and a satellite no
50
+ pair resolves for is left out of that epoch and listed in the new
51
+ `unresolvedCarriers` as `{ receiver, epochIndex, satelliteId,
52
+ observableCode }`.
53
+ - **Breaking.** A PPP observation whose code is zero or negative is no longer
54
+ solved at a meaningless transmission epoch. It is left out and reported in
55
+ the new `unplacedObservations` on `PppFloatSolution` and
56
+ `PppFixedSolution`, each `{ epochIndex, satelliteId, ambiguityId, reason }`
57
+ with `reason` `"codeNotPositive"`.
58
+ - **Breaking.** An ionosphere-corrected SPP or static solve no longer fails a
59
+ whole epoch because one satellite has no resolvable carrier (a GLONASS
60
+ observation without a valid `glonassChannels` entry). The satellite is left
61
+ out and reported in the new `rejectedSats` of `SppSolution` and
62
+ `StaticSolution`, each `{ satelliteId, reason }`, with `reason` one of
63
+ `"noEphemeris"`, `"lowElevation"`, `"sbasWithdrawn"`, `"sbasIonoUncovered"`
64
+ and `"ionosphereCarrierUnresolved"`.
65
+ - **Breaking.** A broadcast satellite clock no longer includes the
66
+ single-frequency group delay. SPP, FDE and DGNSS requests take
67
+ `pseudorangeCode`, `"singleFrequency"` (the default) or `"ionosphereFree"`;
68
+ the broadcast group delay (GPS and QZSS TGD, Galileo BGD, BeiDou TGD1) applies
69
+ to single-frequency code only, as RTKLIB `prange` applies it.
70
+ - PPP observations take `signals: { code1, code2, phase1, phase2 }`, the RINEX
71
+ 3 tracking codes the ionosphere-free code and phase were formed from. SSR and
72
+ HAS biases apply only to an observation that states the signals they are for.
73
+ - PPP residuals carry `ambiguityId`. `PppFloatSolution` gains
74
+ `solvedEpochIndices`, `residualScreen`, `residualScreenRemovals` and
75
+ `solveOptions` (the iteration cap and tolerances the solve ran with);
76
+ `PppFixedSolution` gains `solvedEpochIndices`.
77
+ - `solveStaticReferenceStationRinex` reports a reference or rover epoch outside
78
+ the UT1 table as a mode error with `kind` `"ut1OutsideCoverage"`, and the
79
+ code, carrier and station solutions carry `ut1Degraded`.
80
+ `sbasProtectionLevels` refuses such an epoch with the error label
81
+ `"Ut1OutsideCoverage"`, and DGNSS solves throw an `Error` naming it.
82
+
83
+ - **Breaking.** SPP and the static solve select, mask and weight the
84
+ satellites at every iterate, as RTKLIB `estpos` re-runs `rescode`, instead of
85
+ once at the initial guess. A pass whose selection repeats the previous one
86
+ takes RTKLIB's least-squares step, and the solve ends with the first such
87
+ step below 1e-4 m. Solutions move wherever the selection or the weights at
88
+ the solution differ from those at the initial guess; a coarse cold start that
89
+ failed now solves. A solve whose selection does not settle throws an `Error`
90
+ naming the passes it ran.
91
+ - **Breaking.** `StaticSolution.metadata.status` is `"SelectionSettled"` for a
92
+ converged solve and `"OuterBudgetExhausted"` for a robust solve that spent
93
+ its outer budget first, and `converged` describes how the whole solve ended:
94
+ a spent robust budget reports `converged: false`. The status is typed as the
95
+ new `SolveStatus` union, and the metadata as `StaticSolveMetadata`.
96
+ - `SppSolution.metadata` reports how the solve ran and ended, `{ iterations,
97
+ converged, status, outerIterations, finalRobustScaleM, ionosphereApplied,
98
+ troposphereApplied, usedCount }` (`SppSolveMetadata`).
99
+ - The SPP carrier check applies only when a frequency-dependent term is
100
+ applied, so a solve without the ionosphere correction no longer leaves out a
101
+ satellite for want of a carrier. The PPP auto-init SPP seed passes each
102
+ GLONASS observation's channel.
103
+
104
+ - **Breaking.** Every SPP-family failure throws an `Error` named
105
+ `PositioningError` whose `detail` is a typed `PositioningErrorDetail`: a
106
+ union on `kind` with one member per engine variant and the fields it
107
+ carries. This covers `solveSpp`, `solveSppWithDopplerVelocity`,
108
+ `solveSppBatch`, `solveSppFromRinexObs`, `solveBroadcast`,
109
+ `solveWithFallback`, `fde`, `solveSppSbas`, `solveStatic` and the DGNSS
110
+ solves, including the new `SELECTION_UNSETTLED { passes }` and
111
+ `UT1_OUTSIDE_COVERAGE { reason }`. A failure that wraps another nests it as
112
+ `cause` (`EPOCH_INPUT`, `PRECISE_SOLVE_FAILED`, `BROADCAST_SOLVE_FAILED`); a
113
+ rejected solution names its validation failure (`SOLUTION_REJECTED` with
114
+ `validation.kind` such as `DEGENERATE_GEOMETRY_PDOP { pdop }`); a RINEX SPP
115
+ variant a later engine adds crosses as `OTHER` with its own name in
116
+ `variant`. They were plain `Error`s carrying only a message, and a
117
+ fallback failure was named `PreciseSolveError` or `BroadcastSolveError`.
118
+ `SppBatchSolution.error(i)` and `RinexSppSolutionBatch.error(i)` return the
119
+ detail instead of a message string, and `broadcastReason.preciseError` is a
120
+ detail. A non-finite DGNSS base position throws a `PositioningError`
121
+ (`DGNSS_INVALID_INPUT`) instead of a `RangeError`.
122
+
123
+ ### UT1 coverage
124
+
125
+ - **Breaking.** Every function that reads UT1 (sidereal times, GCRS/ITRS
126
+ transforms, look angles, passes, ground tracks, visibility, observe, Sun and
127
+ Moon az/el and illumination, rise/set and meridian transits, coverage grids,
128
+ PPP corrections, SSR-corrected states) refuses an instant outside the UT1
129
+ table by default and throws. It previously held TT-UT1 at the table edge; a
130
+ pass search scored the failed look angle as -90 degrees and returned no
131
+ passes. Where an instant outside the table is accepted, the engine follows
132
+ the Skyfield 1.54 delta-T curve.
133
+ - `WithValidity` variants take an optional UT1 policy, `"strict"` (the
134
+ default) or `"permissive"`, and return `{ value, ut1Degraded }`, where
135
+ `ut1Degraded` is `"beforeCoverage"`, `"afterCoverage"` or `null`:
136
+ `gmstRadiansWithValidity`, `gastRadiansWithValidity`,
137
+ `gcrsToItrsWithValidity`, `itrsToGcrsWithValidity`, `sunAzElWithValidity`,
138
+ `moonAzElWithValidity`, `moonIlluminationWithValidity`,
139
+ `findMoonElevationCrossingsWithValidity`, `findMoonTransitsWithValidity`,
140
+ `meridianTransitsWithValidity`, `meridianTransitsSpkWithValidity`,
141
+ `observeWithValidity`, `observeSpkBodyWithValidity`,
142
+ `Tle.lookAnglesWithValidity`, `Tle.findPassesWithValidity`,
143
+ `Tle.groundTrackWithValidity` and `visibleFromSatellitesWithValidity`.
144
+ - `Instant.ut1Degraded`, `SppSolution.ut1Degraded` and
145
+ `CoverageGrid.ut1Degraded` report the departure a permissive policy
146
+ accepted. `coverageLookAngles` and `pppCorrections` (option `ut1Validity`)
147
+ take the policy; `pppCorrections` also returns `ut1Degraded` and `warnings`.
148
+ `ssrCorrectedState` takes an optional UT1 policy and returns `ut1Degraded`.
149
+
150
+ ### TLE and SGP4
151
+
152
+ - `Sgp4Satellite.fromOmm(omm)` initializes a reusable SGP4 propagator through
153
+ the engine's canonical OMM bridge. Its `propagate(epochs)` returns the same
154
+ TEME arrays as the matching TLE, while incompatible metadata and missing
155
+ `MEAN_MOTION` or `BSTAR` remain typed `OmmError` failures.
156
+ - **Breaking.** `new Tle(line1, line2, opsMode?, policy?)` and
157
+ `parseTleFile(text, opsMode?, policy?)` take a checksum policy. `"strict"`,
158
+ the default, refuses a column-69 digit that disagrees with the computed
159
+ checksum and a column 69 that is not a digit; 2.1.1 read both. `"lenient"`
160
+ reads them and reports each in `checksumWarnings`, now
161
+ `{ kind, expected?, found?, computed, message }`.
162
+ - **Breaking.** `Tle.revNumber`, `elementSetNumber` and `ephemerisType` are
163
+ `undefined` when the field is blank, instead of `0`.
164
+ - **Breaking.** `parseTleFile` returns `rejected`, one
165
+ `{ lineNumber, name, issue, message }` per record it refused, and `skipped`
166
+ is its length. Each `NamedTle` carries `lineNumber` and `checksumWarnings`.
167
+
168
+ - **Breaking.** `Tle.toLines` spells B\* and the second mean-motion derivative
169
+ as python-sgp4's `export_tle` does: the five significant digits of
170
+ `value * 10`, correctly rounded with ties to even (3.21675e-9 is now
171
+ `" 32168-8"`, where it was `" 32167-8"`). A zero B\* is `" 00000+0"`, a
172
+ negative zero `"-00000+0"`, and a zero second derivative `" 00000-0"`.
173
+ - **Breaking.** An OMM epoch of whole microseconds takes the SGP4 epoch
174
+ python-sgp4 2.22 gives it, bit for bit, and a satellite built from such an
175
+ OMM is initialised as python-sgp4's `sgp4.omm.initialize` does, with B\* and
176
+ the second derivative as stated. Epochs of the NAVSTAR 43 and GALAXY 15
177
+ fixtures move by about 0.3 microseconds. Other OMM element sets quantize B\*
178
+ and the second derivative onto the TLE grid at exponent -9, as the TLE writer
179
+ rounds them.
180
+ - **Breaking.** Passes and look angles propagate SGP4 at the split Julian date
181
+ Skyfield 1.54 uses for the same UTC instant, bit for bit.
182
+
183
+ ### SPK
184
+
185
+ - **Breaking.** SPK states always carry a velocity. Type 2 segments return
186
+ the derivative of the position polynomial, where the velocity was
187
+ `undefined`.
188
+ - `SpkKernels` holds several kernels and resolves a body chain across them
189
+ (`new SpkKernels()`, `push`, `pushBytes`, `length`, `state`,
190
+ `stateInFrame`). `Spk.stateInFrame`, `spkInertialFrameName` and
191
+ `spkInertialFrameRotation` expose the kernel's inertial frames.
192
+
193
+ ### Time
194
+
195
+ - **Breaking.** The embedded leap-second table dated the 1997 and 1999 leap
196
+ seconds to 1997-01-01 and 1998-01-01. TAI - UTC became 31 s on 1997-07-01
197
+ and 32 s on 1999-01-01, so every UTC conversion from 1997-01-01 to 1997-06-30
198
+ and from 1998-01-01 to 1998-12-31 moves by one second, and `23:59:60` is
199
+ accepted on 1997-06-30 and 1998-12-31 instead of 1996-12-31 and 1997-12-31.
200
+ - NMEA sentence seconds are read as the decimal stated, rounded once (`07.56`
201
+ was 7.5600000000000005).
202
+
203
+ ### Space weather
204
+
205
+ - **Breaking.** `sampleAt(epoch, policy?)` under the default policy refuses a
206
+ monthly predicted row, which states no Ap, instead of substituting the quiet
207
+ Ap of 4. `"lenient"`, or a policy object with `requireGeomagnetic: false`,
208
+ substitutes it and reports it. Rows whose flux qualifier states no
209
+ observation have the new class `"notObserved"`, refused unless the policy
210
+ sets `allowNotObserved`. Unknown policy keys and names throw a `TypeError`.
211
+ - `apHistoryAt(epoch, policy?)` returns the seven-element NRLMSISE-00 Ap
212
+ history `{ ap, class, apDefaulted, binsFromDailyAp }`. Decay requests take
213
+ `policy`.
214
+
215
+ ### CCSDS OMM, OPM, OEM and CDM
216
+
217
+ - **Breaking.** Reader and writer failures throw typed errors named
218
+ `OmmError`, `OpmError`, `OemError` and `CdmError`, whose `detail` is an
219
+ `NdmErrorDetail` (`kind`, `message` and the fields that variant carries).
220
+ Writers (`toKvnString`, `toXmlString`, `toJsonString` and the new CSV
221
+ writers) throw instead of writing text that would not read back.
222
+ - **Breaking.** A value a message does not carry is `undefined`, not a
223
+ fabricated default: an OMM read from JSON without `CCSDS_OMM_VERS` has no
224
+ version, and `new Omm(...)` takes `meanMotion` and `noradCatId` as optional.
225
+ - **Breaking.** OPM and OEM covariances are built from the 21-value lower
226
+ triangle (`lowerTriangle`) or a symmetric 36-value matrix; an asymmetric
227
+ matrix is refused. `matrix` returns the stored values unvalidated and
228
+ `toValidatedMatrix()` checks positive semidefiniteness.
229
+ - **Breaking.** `OemSegment(metadata, states, covariances, dataComments?,
230
+ covarianceComments?)` throws on invalid input. `skippedStates` is an array of
231
+ `{ line, segment, text, reason, itemCount, field, issue }`, with the count in
232
+ `skippedStateCount`.
233
+ - Every record the messages define is retained: OMM `classification`,
234
+ `messageId`, `refFrameEpoch`, `semiMajorAxisKm`, `gmKm3S2`, spacecraft
235
+ parameters, `btermM2Kg`, `agomM2Kg`, covariance, user-defined parameters and
236
+ comments; OPM and OEM `classification`, `messageId`, `refFrameEpoch`,
237
+ user-defined parameters and comments per block; CDM `ccsdsCdmVers`,
238
+ `messageFor`, relative state, screening volume and period, comments, drag,
239
+ SRP and thrust covariance rows, OD parameters and additional parameters,
240
+ with `toCovarianceRtn()`.
241
+ - `parseOmmCsv`, `parseOmmXmlAll`, `parseOmmJsonArray` and `parseOmmCsvArray`
242
+ read CSV and multi-record OMM, returning `{ omms, skipped }` for the array
243
+ readers. `Omm.toCsvString`, `toCsvStringDiscardingComments` and
244
+ `toJsonStringDiscardingComments` write them.
245
+
246
+ ### Bias-SINEX and CODE DCB
247
+
248
+ - **Breaking.** `loadBiasSinex(bytes, policy?)` and
249
+ `loadCodeDcb(bytes, options, policy?)` are strict by default: a file that
250
+ departs from Bias-SINEX 1.00, or a DCB title whose time-system label is
251
+ unknown when no options are given, is refused. `"lenient"` reads it and
252
+ reports each departure in `notices`.
253
+ - **Breaking.** Bias lookups (`codeOsbSeconds`, `phaseOsbCycles`,
254
+ `codeDsbSeconds`, `codeBiasModelM`) return a `BiasLookup`,
255
+ `{ status, value, records, overridden, productScale, queryScale,
256
+ observable }`, instead of a number or `undefined`, so an absent, ambiguous or
257
+ out-of-scale lookup is distinct from a value. `phaseOsbCycles` takes
258
+ `timeScale?` and `carrierHz?`.
259
+ - Bias records carry `family`, `unit`, `svn`, `validFrom`, `validUntil`,
260
+ `slopeSigma` and `line`. `BiasSet` gains `mode`, `timeScale`,
261
+ `timeSystemLabel`, `notices` and `lineCounts`, and writes with
262
+ `toBiasSinex()` and `toCodeDcb()`.
263
+
264
+ ### RINEX navigation
265
+
266
+ - **Breaking.** `BroadcastRecord.issue`, `issueMessage` and `svAccuracyM` are
267
+ `undefined` where the message states none (a CNAV record has no IODE).
268
+ `toRinexString` and `encodeRinexNav` throw instead of writing a record that
269
+ would not read back.
270
+ - **Breaking.** `issueMessage` gains `"navic_lnav"` and
271
+ `"galileo_unclassified"`. A Galileo record's message is read from its
272
+ data-source word by RINEX 3.05 Table A8; a word that names no single message
273
+ is `"galileo_unclassified"`, where it was read as I/NAV or F/NAV.
274
+ - Records expose the fields their message states: `iodc`, `l2Codes`,
275
+ `l2pDataFlag`, `galileoDataSources`, `beidouAodc`, `transmissionTimeSow`,
276
+ GLONASS `statedFreqChannel`, `ageDays`, `statusFlags`, `healthFlags`, group
277
+ delays and flag words, `isHealthy`, `singleFrequencyGroupDelayS` and
278
+ `clockBiasAtS`. Ionosphere corrections add QZSS, NavIC, Galileo NeQuick,
279
+ Galileo disturbance flags and BeiDou BDGIM, with
280
+ `BroadcastEphemeris.ionoCorrectionsAt`.
281
+ - `BroadcastEphemeris` and the NAV parse result report `skipped` blocks with
282
+ their `line`, `departures` and `other` blocks. `parseRinexNavFile` returns a
283
+ `RinexNavFile` that keeps every entry and writes it back.
284
+
285
+ ### RTCM
286
+
287
+ - **Breaking.** `decodeRtcm` reads a buffer in full or throws: a stray byte, a
288
+ CRC-24Q failure, a trailing partial frame, or a body that does not decode or
289
+ departs from RTCM 3. It returned the frames it could read. Use
290
+ `decodeRtcmStream` for noisy input.
291
+ - **Breaking.** `decodeRtcmStream(bytes, policy?)`, `decodeRtcmFrame(bytes,
292
+ policy?)`, `encodeRtcm(message, policy?)` and `encodeRtcmFrame(message,
293
+ policy?, reserved?)` take a policy, `"strict"` (the default) or
294
+ `"lenient"`. Messages carry `trailingBits`, MSM messages `signalMask` and
295
+ GLONASS fields `negativeZero`, so a lenient decode re-encodes byte for byte.
296
+ Stream diagnostics gain `crcFailures` and `departures`, and skipped frames
297
+ the reason `"departure"`. `decodeRtcmFrame` returns `reserved` and
298
+ `departures`; `FrameScanner` gains `resyncBytes`, `crcFailures` and
299
+ `reserved`.
300
+
301
+ - The decoded message IR is typed: `decodeRtcm`, `decodeRtcmMessage`,
302
+ `decodeRtcmStream`, `decodeRtcmFrame` and `FrameScanner.next()` return
303
+ `RtcmMessage` (a union on `type`), `RtcmStream`, `RtcmFrame` and
304
+ `RtcmScannedFrame`, with `RtcmDeparture` and `RtcmFrameSkip` for the
305
+ diagnostics; `encodeRtcm` and `encodeRtcmFrame` take `RtcmMessageInput`.
306
+ Raw 64-bit fields are `bigint`.
307
+ - `encodeRtcm` and `encodeRtcmFrame` encode SSR messages (`type: "ssr"`), so
308
+ every message type the decoder returns re-encodes.
309
+ - The encoder reads the GNSS system as the decoder writes it (`"GPS"`,
310
+ `"GLONASS"`, `"Galileo"`, `"BeiDou"`, `"QZSS"`, `"NavIC"`, `"SBAS"`) as well
311
+ as in lower case. A decoded MSM or SSR message was refused with
312
+ `invalid GNSS system label "GPS"`.
313
+
314
+ ### SBAS
315
+
316
+ - **Breaking.** `decodeSbasMessage(bytes, form?, policy?)` refuses by default
317
+ a preamble other than `0x53`, `0x9A` and `0xC6`. `"lenient"` reads it and
318
+ reports it in `departures`; the result carries `padBits`.
319
+ - `parseSbasEmsLog` and `parseSbasRtklibLog(text, { policy, referenceWeek })`
320
+ return an `SbasLog` with `blocks`, `skippedLines`, `refusedLines` and
321
+ `departures`. Log blocks carry `declaredMessageType` and `messageType` and
322
+ decode with `decode(policy?)`. The ionospheric grid reports
323
+ `unavailableIgps`.
324
+
325
+ ### SSR
326
+
327
+ - SSR orbit and clock corrections carry `navMessage` (`"rtcm"` or `"has"`),
328
+ `hasNavMessageIndex` and `transmittedEpochJ2000S`.
329
+
330
+ ### IONEX
331
+
332
+ - **Breaking.** A grid node the file gives as `9999` is now `null`. At the default exponent it used to read as 999.9 TECU, and `slantDelay` interpolated it. `Ionex.tecGridSamples()` returns `tecMaps` and `rmsMaps` as `(number | null)[][][]`, `tecSamples()` returns `vtecTecu` and `rmsTecu` as `number | null`, and `ionexFromSamples` / `ionexFromNodeSamples` accept `null` at those positions.
333
+ - **Breaking.** `tecGridSamples().rmsMaps` is `null` for a product that carries no RMS maps, where it was `[]`. A declared RMS stack whose nodes are all `null` is kept, and it stays distinct from an absent stack. The result also carries `heightMaps` (`null` when absent) and `header`, and each `tecSamples()` row carries `heightOffsetKm`.
334
+ - `START OF HEIGHT MAP` blocks are now read (a height map's `END OF HEIGHT MAP` record used to fail the parse). They are exposed as `Ionex.heightMaps`, in km added to `HGT1`. New getters: `Ionex.tecMaps`, `rmsMaps`, `heightMaps` (each `IonexMapCube`, or `null` when absent), `hasRms`, `hasHeight` and `skippedRecords`.
335
+ - **Breaking.** `toIonexString()` throws an `IonexWriterError` whose `detail` is `{ kind: "UNWRITABLE", message }` when its IONEX field cannot state a value, axis or header field exactly. It used to round values to the header exponent, write a 0.25 degree step as 0.2 and overrun columns. The writer now uses the IONEX 1 layouts (`2X,3F6.1` axes, `2X,5F6.1` band records, `16I5` values) and picks an exponent that states every value exactly. It writes back the header records the product carries, plus `EPOCH OF FIRST MAP`, `EPOCH OF LAST MAP`, `# OF MAPS IN FILE`, `MAP DIMENSION` and `END OF FILE`, so its text differs from 2.1.1.
336
+ - **Breaking.** `Ionex.slantDelay`, `IonexSelection.slantDelay` and the new top-level `ionexSlantDelay` now throw a named error with a typed `.detail` for an engine refusal, where they threw a plain `Error`. The names are `IonexCoverageError`, `IonexMissingNodesError`, `IonexVaryingHeightsError`, `IonexHeightNotAvailableError`, `IonexMappingFunctionError`, `IonexInvalidInputError`, and `IonexSlantError` for a variant the binding does not name. A query whose interpolation weights a `null` node is refused (`MISSING_NODES`). On a product with height maps, the delay uses `HGT1` plus the common height, or is refused with `VARYING_HEIGHTS` / `HEIGHT_NOT_AVAILABLE`.
337
+ - **Breaking.** Every J2000-second input (`epochJ2000S` on the slant entries and batch requests, `mapEpochsJ2000S` on `ionexFromSamples`, `epochJ2000S` on `ionexFromNodeSamples` rows) must be an integer within ±9007199254740991. Values between 2^53 and the `i64` range used to be accepted and could wrap.
338
+ - **Breaking.** `ionexFromSamples` and `ionexFromNodeSamples` throw a `TypeError` for an own string property outside the accepted names, enumerable or not (for example `heightMap` or `rmsTec`), where the property used to be ignored. `ionexFromNodeSamples` requires an array, walks it in order and names the index of the first malformed sample. A throwing accessor on `header`, on the header's mapping records or on a policy axis re-raises the caller's own value unchanged.
339
+ - `Ionex.header` returns the descriptive header records as an `IonexHeader`: `version`, `satelliteSystem`, `program`, `runBy`, `date`, `descriptions`, `comments`, `intervalS`, `mappingFunction`, `mappingDeclaration`, `elevationCutoffDeg`, `observablesUsed`, `stationCount`, `satelliteCount` and `mapsInFile`. `Ionex.mappingFunction` and `Ionex.mappingDeclaration` read the same two records. `ionexFromSamples` accepts `header`, and `ionexFromNodeSamples` takes an optional fifth `header` argument. A supplied header whose `mappingFunction` and `mappingDeclaration` contradict each other is refused, as is a known tag with another code (`{ kind: "COSZ", code: "MOD" }`).
340
+ - New `IonexSlantPolicy` with three axes: `coverage` (`"strict"` / `"hold"`), `missingNodes` (`"strict"` / `"renormalize"`) and `mapping` (`"declared"` / `"singleLayer"`). It has static constructors and `with*` modifiers, and a plain `{ coverage?, missingNodes?, mapping? }` object is accepted in its place. It is taken by `Ionex.slantDelayWithPolicy`, `Ionex.slantDelaysBatchResults`, `ionexSlantDelayWithPolicy` and `ionexSlantDelayResults`. The results are `IonexSlantDelayEvaluation` objects whose `status` carries `held`, `degraded`, `assumedMapping`, `isValid` and `isNominal`, and a batch returns one `{ index, isOk, evaluation, refusal }` row per request.
341
+ - A product declaring a `MAPPING FUNCTION` other than `COSZ` is still mapped with the single-layer `1/cos(z')`, and the value is now flagged in `status.assumedMapping`. It stays `isValid`. `mapping: "declared"` refuses such a product with `MAPPING_FUNCTION`.
342
+ - `loadIonexWithWarnings`, `Ionex.parseWithWarnings` and `Ionex.parseStrWithWarnings` return `{ ionex, value, warnings }` with seven warning kinds: `MISSING_RECORD`, `VERSION_RECORD_NOT_FIRST`, `EPOCH_MISMATCH`, `MAP_COUNT_MISMATCH`, `NOT_A_NUMBER_VALUE`, `INTERVAL_MISMATCH` and `EXPONENT_CARRIED_INTO_MAP`. `Ionex.parse` and `Ionex.parseStr` are new static constructors.
343
+ - The reader accepts files it used to refuse:
344
+ - an axis running in either direction, with `latNodesDeg` / `lonNodesDeg` now in file order (an ascending latitude axis was refused)
345
+ - an epoch at hour 24 with zero minute and second
346
+ - a `nan` value field, read as `null` with a `NOT_A_NUMBER_VALUE` warning
347
+ - a map inheriting an earlier map's `EXPONENT`, reported as `EXPONENT_CARRIED_INTO_MAP`
348
+
349
+ `slantDelay` also interpolates across the longitude seam of a grid that closes the circle, and returns a value along a line of sight through a pole.
350
+ - **Breaking.** The reader refuses what it used to misplace or pass over:
351
+ - a `MAP DIMENSION 3` product, or an `HGT1 / HGT2 / DHGT` giving several heights
352
+ - a band whose latitude, longitude or height is not a grid node
353
+ - a map that gives a node twice or leaves one without a value
354
+ - `START OF TEC MAP` numbers out of order
355
+ - an RMS or height map that does not name a TEC map
356
+ - a data record holding a non-ASCII character, or one outside a band
357
+ - a `16I5` record with an empty field
358
+ - an unclosed `AUX DATA` block
359
+ - a file type other than `I`
360
+ - two records of one header label that read as different values
361
+ - Values scale as `field * 10^EXPONENT` from an exact power of ten, and each band is placed by its own `LAT/LON1/LON2/DLON/H` record. An `EXPONENT` record inside a map applies to the data blocks after it. Previously, bands were placed in arrival order and an in-map `EXPONENT` before the first band was ignored.
362
+ - New standalone regular-grid surface: `TecGrid`, whose `values` are `(number | null)[]`, with `vtecAtPiercePoint` and `vtecAtPiercePointWithPolicy`. Alongside it are `TecGridEpoch` (exact `bigint` `unixNanos`), `TecGridShellGeometry` and `TecGridEvalOptions`, and the top-level `ionoDelayXyz`, `ionoDelayXyzWithPolicy`, `tecXyz` and `tecXyzWithPolicy` with an optional `ecefToLla` callback whose thrown value is re-raised unchanged. Failures throw a `TecGridError` whose `.detail` is a `TecGridErrorDetail`.
363
+
364
+ ### TDM
365
+
366
+ - **Breaking.** `Tdm.comments`, `TdmMetadata.comments` and `TdmDataSection.comments` return `TdmComment[]` (`{ text, beforeRecord }`) instead of `string[]`. `toKvnString()` writes each comment back at its position rather than gathering comments to the top of their block, and comment text keeps its leading indentation.
367
+ - **Breaking.** `parseTdmKvn` throws a `TdmParseError` and `Tdm.toKvnString()` a `TdmWriteError`, where both threw a plain `Error`. Each carries a `detail` that is a `TdmErrorDetail` union on `kind`, with a one-based `line` that is `null` for a failure raised with no input line.
368
+ - **Breaking.** `parseTdmKvn` reads under the strict CCSDS 503.0-B-2 rules and now refuses:
369
+ - a last line with no terminator (`UNTERMINATED_FINAL_LINE`)
370
+ - a tab, control or non-ASCII character (`NON_PRINTABLE_CHARACTER`)
371
+ - a line over 254 characters (`LINE_TOO_LONG`)
372
+ - a missing `CREATION_DATE`, `ORIGINATOR`, `TIME_SYSTEM` or `PARTICIPANT_n` (`MISSING_KEYWORD`)
373
+ - a data section with no records (`EMPTY_DATA_SECTION`), or `KEY =` with no value (`EMPTY_VALUE`)
374
+ - a version outside `x.y` (`INVALID_VERSION`)
375
+ - a timetag outside the 4.3.9 forms, one with `Z` under a time system other than UTC, one with second 60 other than at `23:59` UTC or `02:59` GLONASS, or one with more than 38 fraction digits (`MALFORMED_EPOCH`)
376
+ - records out of chronological order (`RECORDS_OUT_OF_ORDER`), or a repeated keyword and timetag pair (`DUPLICATE_RECORD`)
377
+ - keywords out of table order, or comments out of place (`KEYWORD_OUT_OF_ORDER`)
378
+ - a `PATH` naming an undefined participant (`UNDEFINED_PARTICIPANT`)
379
+ - a keyword outside its section's table (`UNDEFINED_KEYWORD`)
380
+ - `PARTICIPANT_0`, `PARTICIPANT_6` and up, padded indices such as `PARTICIPANT_01`, or `PATH_3` (`INVALID_FIELD`)
381
+ - a keyword repeated with another value (`CONFLICTING_KEYWORD`)
382
+ - `COMMENT=value` or `META_START = 1` (`MALFORMED_LINE`)
383
+ - The reader ends a line at CR, LF, CRLF or LFCR, so a file written with carriage returns reads. It used to read as one line and was refused.
384
+ - **Breaking.** `Tdm.toKvnString()` applies the reader's rules to what it writes. It refuses under the strict write policy each departure listed above. It also refuses a keyword written twice with one value (`REPEATED_KEYWORD`), and a field or comment the KVN form cannot carry, such as an empty or padded key, a key holding `=`, or a line break (`UNWRITABLE`, `KEYWORD_NOT_ASSIGNABLE`). It now terminates the last line, which it did not.
385
+ - `parseTdmKvnWithPolicy(text, policy?)` returns `{ tdm, value, warnings }`. `policy` is `"strict"`, `"lenient"`, or an object setting `nonPrintable`, `missingKeywords`, `longLines`, `emptyDataSections`, `recordOrder`, `duplicateRecords`, `keywordOrder` or `finalTerminator` to `"strict"` or `"forgive"`. A misspelled axis is a `TypeError`.
386
+ - `Tdm.toKvnStringWithPolicy(policy?)` returns `{ text, value, departures }`. It takes the reader's axes plus `repeatedKeywords`, and reports each departure it emitted as a `TdmDeparture`.
387
+ - `TdmMetadata.fromRaw(fields, comments?)`, `TdmMetadata.fromRawWithPolicy`, `metadata.replaceRaw`, `metadata.replaceRawWithPolicy`, `new TdmField(key, value)` and `tdm.setSegmentMetadata(segmentIndex, metadata)` build and replace a metadata block from ordered raw fields and positioned comments. `participants`, `paths`, `mode`, `timetagRef`, `timeSystem` and `rangeUnits` are derived from those fields. A refused replacement throws a `TdmValidationError` and leaves the block unchanged.
388
+
389
+ - `TdmMetadata.setField(key, value)` sets one keyword: a stated keyword takes
390
+ the value in place, an absent one is inserted where the keyword order of
391
+ tables 3-2 and 3-3 puts it, and every comment keeps the field it precedes.
392
+ `TdmMetadata.removeField(key)` removes every occurrence. Both validate as
393
+ `replaceRaw` does and are atomic.
394
+
395
+ ### RINEX observations
396
+
397
+ - **Breaking.** `RinexObs.toRinexString()` returns text only when reading it back gives the product. Otherwise it throws a `RinexObsWriteError` whose `detail` is a `RinexObsWriteErrorDetail` naming the first field that would change; it used to drop, round, wrap or truncate and return the text anyway. A version 2 product is written as a RINEX 2 file, where it used to be written with version 3 `>` epoch records under a version 2 header. A version 2 product holding `SYS / SCALE FACTOR` records is refused (`SCALE_FACTORS_IN_VERSION_TWO`), and so is picoseconds in a product below 4.02 (`EPOCH_PICOSECONDS_NOT_IN_VERSION`).
398
+ - **Breaking.** `RinexObsRepair.repairedText` now throws that same `RinexObsWriteError`, where it was a plain `string` getter, and `RinexObsRepair.toCrinexString()` throws it for a writer refusal. `repairRinexObs` returns the repair whether or not its product can be written.
399
+ - **Breaking.** `ObsEpoch.epoch` is `ObsEpochTime | undefined`. It is `undefined` for an event whose epoch fields are blank, which RINEX 2.11 and 3.05 allow and which was refused.
400
+ - **Breaking.** `ObsPhaseShift.code` is `string | undefined`: a record naming only its constellation (RINEX 3.05 section 5.2.12) is read, where it used to be refused. `ObsPhaseShift.correctionCycles` is `number | undefined` for a blank correction. New getters: `unrepresentableSatellites` (designators such as `R28`, kept and written back), `coversEverySatellite` and `satelliteCount`. Satellite lists continue past ten satellites.
401
+ - **Breaking.** `carrierPhaseRows(i, filter?)` reads epoch `i` with the header in effect at it. `phaseShiftCycles` holds `NaN` where no single correction applies, beside the new `phaseShiftAvailable`, `phaseShiftStatus` (`available` / `unknown` / `ambiguous`) and `phaseShiftCorrections`. From RINEX 4.00, every row is an available 0.
402
+ - **Breaking.** Header records an event epoch (flags 2 to 5) carries now take effect for the epochs after it; they used to be kept as text and never applied. `ObsHeader.obsCodes(system)` is the union of every list the file declares, and each epoch's `observations` and `cycleSlips` are index-aligned to it. The new `declaredObsCodes(system)` gives the list in effect. `RinexObs.headerAt(i)` and `RinexObs.headerTimeline()` (`ObsHeaderTimeline`, `ObsHeaderSegment`) give the header in effect at each epoch.
403
+ - **Breaking.** Every epoch-index argument (`epoch`, `headerAt`, `observationValues`, `carrierPhaseRows`, `pseudoranges`, `ObsHeaderTimeline.at`, `ObsHeaderTimeline.segmentIndex`) throws a `RangeError` for a negative, fractional, non-finite or out-of-range index. Previously a fractional index was truncated to another epoch.
404
+ - **Breaking.** One header block now refuses two records that read as different values for the same label. This applies to:
405
+ - `INTERVAL`, `MARKER NAME/NUMBER/TYPE`, `APPROX POSITION XYZ`, `ANTENNA: DELTA H/E/N`, `ANT # / TYPE`, `REC # / TYPE / VERS`, `OBSERVER / AGENCY` and `SIGNAL STRENGTH UNIT`
406
+ - in the file header only: `RINEX VERSION / TYPE`, `TIME OF FIRST OBS`, `TIME OF LAST OBS`, `LEAP SECONDS` and `# OF SATELLITES`
407
+
408
+ Two scale factors for one code and two channels for one GLONASS slot are refused too. The later record used to win. Conflicting phase shifts or GLONASS biases are kept and read as `ambiguous` instead of being refused.
409
+ - Flag 6 epochs are read as cycle slips into `ObsEpoch.cycleSlips` and `cycleSlipSatellites`, with `satellites` and `observations` empty, and `rinexObsCycleSlipFlag()` returns the flag. New `ObsEpoch` getters: `observations`, `rcvClockOffsetS`, `epochPicoseconds`, `declaredRecordCount` and `specialRecords` (the records an event carried, verbatim; they used to be discarded).
410
+ - New `ObsHeader` getters: `rinex2Types`, `rinex2System`, `programRunByDate`, `comments`, `markerNumber`, `markerType`, `observer`, `agency`, `receiver`, `antenna`, `timeOfLastObsEpoch`, `timeOfLastObsScale`, `declaredSatelliteCount`, `prnObsCounts`, `scaleFactors`, `signalStrengthUnit` and `unretainedHeaderLabels`. `leapSeconds` is an `ObsLeapSeconds` with `bigint` fields and a `timeSystem` that keeps blank and `GPS` distinct. `RinexObs.skippedRecords` is also new.
411
+ - `ObsHeader.glonassCodPhsBis` returns the record as written, with `biasM: null` for a blank bias. A header with a blank bias used to be refused. `glonassCodePhaseBias(code)` returns `available`, `none`, `unknown` or `ambiguous`.
412
+ - `RinexObs.downgradeToRinex2(version)` returns `{ obs, value, changes }` with every `ObsDowngradeChange`. It throws a `RinexObsWriteError` for what version 2 cannot state: `NOT_VERSION_TWO`, a carrier version 2 has no name for such as BeiDou B1C (`OBSERVABLE_NOT_REPRESENTABLE`), and `LEAP_SECONDS_TIME_SYSTEM_NOT_IN_VERSION`.
413
+ - **Breaking.** Version 2 observation codes are read as the signals they name. GPS `C2` below 2.12 reads as L2C rather than `C2C`, BeiDou digits name the band their frequency slot gives, and 2.12 lettered names read as their signals. Galileo and BeiDou are no longer given `P` codes, and a Galileo `C2` is no longer read as E5a.
414
+ - The reader takes fixed-column records from their columns. An epoch of 100 or more satellites, a position component of -10,000,000 m and a `PRN / # OF OBS` record laid out as `3X,A1,I2,9I6` now read. A file declaring version 2 while carrying `>` epoch records also reads.
415
+ - **Breaking.** The reader refuses values its own fields cannot write back:
416
+ - an `INTERVAL`, position, antenna delta or first/last-observation second below its field's resolution or too wide for its columns
417
+ - a version 2 value wider than `F14.3` or with more than three decimals
418
+ - a version 2 type code wider than two characters
419
+ - a `GLONASS COD/PHS/BIS` code longer than three characters
420
+ - a type-list continuation that continues no list
421
+ - blank epoch fields on an observation or cycle slip record
422
+ - `lintRinexObs` no longer reports `OBS-B11` and reports `OBS-B10` for an event whose header records do not read. `ObservationQcReport.notes` gains the `eventHeaderRecordsUnread` kind.
423
+
424
+ - **Breaking.** A lint finding's `detail` is a typed `RinexLintFindingDetail`,
425
+ a union on `kind` (`OBS_UNRETAINED_HEADER { label }`,
426
+ `OBS_EPOCH_GAP { gapS, intervalS }` and so on, one member per engine
427
+ variant), where it was the engine's debug text. `lintRinexObs`,
428
+ `lintRinexNav` and the repairs' `remaining` are typed `RinexLintReport`.
429
+ - `ObservationQcReport` gains `header` (marker, receiver, antenna,
430
+ approximate position, antenna delta, first and last observation times and
431
+ duration) and `systems` (per-system completeness), and `toJson()` includes
432
+ both.
433
+ - `simulateScenarioSet(scenario)` keeps the simulated observation set as a
434
+ `ScenarioSimulation`, with `arrays`, `determinismFingerprintHex`,
435
+ `toRinexObservationFile()`, `toRinexString()` and
436
+ `sppObservationsForEpoch(i)`.
437
+
438
+ ### RINEX clock
439
+
440
+ - **Breaking.** A parsed `RinexClock` keeps every line of its input. `toRinexString()` on an unedited product restates the input byte for byte, line terminators included. It used to write a three-line header, drop every other header record and every non-`AS` record, and write a `GLO` time system as `UTC`.
441
+ - **Breaking.** `parseRinexClock` / `loadRinexClock` read `AR`, `CR`, `DR` and `MS` records and refuse one with an unreadable epoch, bias or sigma or a blank name, where such records were skipped. The first line that does not read throws a `RinexClockParseError` whose `detail` is a `RinexClockErrorDetail`, where it threw a plain `Error`. A value beyond a record's declared count is kept in `surplusValues` instead of refused. `parseRinexClockLossy` keeps unread lines verbatim and reports each in `diagnostics`.
442
+ - **Breaking.** `TIME SYSTEM ID` reads `GPS`, `GLO`, `GAL`, `QZS`, `BDS`, `BDT`, `IRN`, `UTC` and `TAI`, with `GLO` read as UTC. A file without the record takes the RINEX clock 3.00 default and reports it in `notices`. An `IRN`, unrecognised or conflicting label leaves the product without a time scale, and `clockS` then throws a `RinexClockQueryError`.
443
+ - **Breaking.** `ClockEpoch` is a civil label read in the time scale of the product it is queried against. The constructor accepts `23:59:60` on a day that ends with a positive leap second, and `gpsSeconds` is `number | undefined`, `undefined` for such a label. The second is read as the shortest decimal of the number given with every digit kept, so `59.9999996` no longer rounds into the next minute. Record epochs keep every digit their seconds field states.
444
+ - **Breaking.** `series`, `seriesFor` and `sampleCount` include samples in every time scale. `ClockSeries.gpsSeconds` holds `NaN` for a sample off the GPS timeline, where such samples used to be dropped. New `ClockSeries` getters: `hasGpsSeconds`, `jdWhole`, `jdFraction`, `epochs` (`RinexClockInstant[]`), `additionalValues` and `timeScale`.
445
+ - `clockSAtGpsSeconds` answers QZSST series and throws a `RinexClockQueryError` outside civil years 1 through 9999. On a UTC product, interpolation across a leap second uses elapsed time.
446
+ - **Breaking.** `toRinexString()` refuses rather than rounds, throwing a `RinexClockWriteError`. It refuses a value no 19-column field states exactly, an epoch no microsecond seconds text states exactly, and a scale no RINEX clock time system names, GLONASS system time among them (`UNSUPPORTED_TIME_SCALE`). `toRinexStringWithPolicy(policy?)` returns `{ text, value, departures }`, and `{ nearestMicrosecondEpochs: "allow" }` writes an off-grid epoch at the nearest microsecond and reports it as `EPOCH_AT_NEAREST_MICROSECOND`.
447
+ - New views: `version`, `layout` (`"v300"` / `"v304"`), `satelliteSystem`, `timeSystem`, `timeSystemStatus`, `timeScale`, `headerRecords()`, `records()`, `recordCount`, `sourceLine(n)`, `skippedRecords`, `diagnostics` and `notices`.
448
+ - New edits: `setTimeSystem`, `setRecordValues`, `insertRecord`, `removeRecord`, `retainRecords` and `editRecords`. Each validates the whole change first; a refused edit throws a `RinexClockEditError` and leaves the product unchanged.
449
+ - New builders: `RinexClock.fromClockPoints(timeScale, rows)` and `RinexClock.fromSeriesRows(rows)`, which throw a `RinexClockBuildError`.
450
+
451
+ - **Breaking.** `ClockEpoch.gpsSeconds` and each sample's `gpsSeconds` are the
452
+ double nearest the GPS second count the civil tag states, rounded once. They
453
+ were rounded at each step, so 2026-05-13 00:00:59.9999996 came out one unit
454
+ in the last place high.
455
+
456
+ ### ANTEX
457
+
458
+ - **Breaking.** `Antenna.daziDeg`, `zenithStartDeg`, `zenithEndDeg` and `zenithStepDeg` are `number | undefined`, `undefined` when the block has no such record. A blank or non-numeric `DAZI` or `ZEN1 / ZEN2 / DZEN` value is refused as `INVALID_FIELD`, where it used to read as `0`.
459
+ - **Breaking.** `Antenna.frequencies` lists labels in file order, once per section. 2.1.1 listed each label once, sorted, with a repeated label's last section winning. `pco`, `pcv` and the new `frequency(label)` throw an `AntexLookupError` with `AMBIGUOUS_FREQUENCY` for a label whose sections differ, and `UNKNOWN_FREQUENCY` for an absent label. `pcv` also refuses a zenith outside the block's grid and an empty grid.
460
+ - **Breaking.** `loadAntex` throws an `AntexParseError` whose `detail` is an `AntexErrorDetail`, where it threw a plain `Error`.
461
+ - **Breaking.** `VALID FROM` / `VALID UNTIL` are read by their `5I6,F13.7` columns with an exact fraction of the second, so `59.9999999` no longer becomes 59. The following are refused as `INVALID_FIELD`: a blank, short or malformed field, and a second of 60, which GPS time does not have. `new AntexDateTime(...)` throws a `RangeError` for a second of 60 and takes an optional seventh `fractionDigits` argument. New getters `fractionDigits` and `nanosecond`; `validAt` compares every digit.
462
+ - **Breaking.** Millimetre fields convert to metres as `mm * 1e-3`, the arithmetic of RTKLIB `readantex`, so about one PCO or PCV value in seven moves by one unit in the last place.
463
+ - **Breaking.** A PCV row that would put two values on one zenith is refused with `DEGENERATE_GRID`, and a once-per-block record repeated with different content with `REPEATED_RECORD`. Lines outside any record, a `# OF FREQUENCIES` count that disagrees with the sections, and unclosed blocks or sections are counted in the new `Antex.skippedRecords`. Blank PCV cells are kept at their declared zenith positions.
464
+ - **Breaking.** `Antex.toAntexString()` throws an `AntexWriteError` whose `detail` is `{ kind: "UNWRITABLE", field, reason, message }`. It throws for field overflow, precision loss, validity seconds no 13-column form with a decimal point states, a malformed frequency label, sample coordinates the reader could not rebuild, and antenna fields that disagree with the retained validity intervals. It writes every record from a retained value and adds none the source did not carry, apart from block and section start and end records.
465
+ - New records and lookups:
466
+ - `Antex.header` (`version`, and `pcvType` with `referenceAntenna`), `outerComments`, `antennaBlocks()`, `blockCount`, `antennaIntervals(id)` and `antennaAt(id, epoch)`
467
+ - `Antenna.leadingComments`, `comments`, `calibrations`, `hasFrequencyCount`, `frequencySections()` and `frequency(label)`
468
+
469
+ `Antex.antenna(id)` returns the id's latest block.
470
+
471
+ ### SP3
472
+
473
+ - **Breaking.** `Sp3.toSp3String()` throws an `Sp3WriteError` whose `detail` is an `Sp3WriteErrorDetail` union on `kind`, where it always returned text. It writes a field only when reading its columns back gives the stored value bit for bit. It writes an epoch record only when that record restates the stored instant.
474
+ - A value that would read back as an absence sentinel is refused (`RECORD_READS_AS_ABSENT`).
475
+ - An integer-nanosecond epoch is refused (`EPOCH_REPRESENTATION_UNSUPPORTED`).
476
+ - A satellite with no `01`..`99` token is refused (`SATELLITE_NOT_REPRESENTABLE`), where a PRN of 0 used to be written as `G00`.
477
+ - `Sp3WriteErrorDetail` carries each engine `u64` / `i64` as an exact decimal string beside a `number` that is `null` when not exactly representable: `value` / `valueNumber`, `year` / `yearNumber`, `declared` / `declaredNumber`. The writer restates the data-used and file-type descriptors and `%f` bases the product was read with.
478
+ - **Breaking.** `loadSp3` now fails on a non-finite value in the first `%f` line's base fields. A `V` record with no matching `P` record is counted in `skippedRecords` rather than dropped silently. Clock-only records survive reading, writing and merging without creating orbit samples.
479
+ - **Breaking.** `Sp3AgreementMetric.positionRmsM` and `positionMaxM` are `number | undefined`, `undefined` for a merged cell that carries a clock and no position. `positionMembers` is 0 for such a cell.
480
+ - `Sp3MergeReport` states what the merge did not write: `droppedInputEpochs` (input epochs that took no part, each `Sp3DroppedInputEpoch` with `source`, `epochIndex`, `epochJ2000Seconds` and `reason` `"off_target_grid"` or `"not_on_tick_axis"`), `omittedEpochsJ2000Seconds` (union-grid epochs with no accepted cell, which are not written), `arcWithheld` (cells whose preferred source carried no position, as `Sp3MergeFlag`s) and `clockOmissions` (each source clock left out, each `Sp3ClockOmission` with `epochJ2000Seconds`, `satellite`, `source`, `reason` `"datum_not_observable"`, `"preferred_source_without_clock"` or `"no_consensus"`, `preferred` and `cellHasClock`), with `droppedInputEpochCount`, `arcWithheldCount` and `clockOmissionCount`. The epochs of these lists and of every other merge report entry, of `Sp3.predictionSummary()` and of `Sp3ClockReferenceOffset` are reduced to seconds as the engine reduces an SP3 instant. The binding reduced a Julian date with one more rounding, which lands a whole-second epoch of many days a unit in the last place off its second, and gave `NaN` for an integer-nanosecond epoch.
481
+ - `Sp3MergeReport.continuity` returns the merge continuity report, `null` when `verifyContinuity` was not requested: `{ attested, defects, pairsChecked, residualsChecked, residualsSkipped, violations, splices }`. Only window verdicts reached it before, and they carried the splices, not the violations inside one contributor's arc.
482
+ - A merge continuity violation carries `sources` and `cells`, each `{ epochJ2000S, role, selection }` with `role` `"held_out"`, `"interpolation_node"`, `"pair_end"` or `"repeated_epoch"` and `selection` a `CellSelection` (`{ kind: "single_source", source }`, `{ kind: "precedence", source, members }` or `{ kind: "combined", rule, members }`) or `undefined`. A `ContinuityDefect` carries every field of its kind under the engine's name beside the summary fields: `epochJ2000S` and `occurrences`; `intervalS`, `displacementM`, `impliedSpeedMS` and `boundMS`; or `epochJ2000S`, `precedingJ2000S`, `residualM`, `toleranceM` and `nodeEpochsJ2000S`.
483
+ - `Sp3.selectedNodes(satellite, fromJ2000S, throughJ2000S)` and `Sp3MergeReport.continuitySelectedNodes(satellite, fromJ2000S, throughJ2000S)` return the position nodes the interpolations of a satellite in a window select, for a product and for the merged product a merge continuity report holds (`undefined` when `verifyContinuity` was not requested): the nodes window verdicts read.
484
+ - `Sp3MergeReport` carries the engine's whole-product and per-epoch agreement: `positionAgreementRmsM`, `positionAgreementMaxM`, `clockAgreementRmsS`, `clockAgreementMaxS`, `singleSourceFraction` and `perEpochAgreement` (`Sp3EpochAgreement` with `epochJ2000Seconds`, `satellites`, `positionRmsM`, `positionMaxM`, `clockRmsS` and `clockMaxS`).
485
+ - `mergeSp3` takes `provenance: "summary" | "full"` and `Sp3MergeReport.provenance` returns the per-epoch provenance the engine recorded, `{ mode, cells, transitions, coverage }`, or `null` when it was not requested.
486
+ - **Breaking.** `mergeSp3` refuses an option field it does not know with a `TypeError`, as `sp3MergeInputIdentity` does, where it ignored one.
487
+ - **Breaking.** `mergeSp3` takes any `targetEpochIntervalS` that is a whole number of the 10-nanosecond ticks an SP3 interval states, as the engine's merge does, where it threw a `RangeError` for an interval that was not a whole number of seconds. The engine refuses any other interval, and the binding throws its refusal as an `Error`. `sp3MergeInputIdentity` still binds whole seconds only; its refusal of any other interval is now the engine's `Error` ("invalid merged-SP3 policy: target epoch interval") where it was a `RangeError`. A zero, negative or non-finite interval is still a `RangeError`.
488
+ - **Breaking.** `Sp3MergeReport.continuityVerdict(fromJ2000S, throughJ2000S)` takes the window alone, as the engine's merge verdict does: the report holds the merged product's interpolation nodes, and a violation influences a window when the nodes its interpolations select include the violation's held-out, repeated or pair-end record or straddle a handover between its records. It took the merged `Sp3` first and read its stencil extent; in a merge whose source takes over twelve epochs before its run ends, single-epoch windows were refused from epoch 51 where the selected nodes accept them through epoch 67.
489
+
490
+ ### Terrain and tides
491
+
492
+ - **Breaking.** A DTED posting holding the null value is now an unknown elevation. Before, a terrain store returned it as a -32767 m height. A `DtedTerrain` or `MmapTerrain` lookup that gives such a posting nonzero weight throws a `TerrainLookupError` whose `detail` is `UNKNOWN_TERRAIN_ELEVATION` with the tile and zero-based posting indices. A query exactly on a known posting next to a null returns that posting, and a neighbouring tile answers on a shared edge.
493
+ - **Breaking.** `DtedTerrain` and `MmapTerrain` lookups (`heightM`, `heightMWithOptions`, `orthometricHeightM*`) throw a `TerrainLookupError` whose `detail` is a `TerrainLookupErrorDetail`, where they threw a plain `Error`. The kinds are `UNKNOWN_TERRAIN_ELEVATION`, `NON_WGS84_TERRAIN_TILE`, `MISSING_TERRAIN_TILE`, `INVALID_INPUT`, `PARSE` and `UNKNOWN`. Failed `heightBatch` / `orthometricHeightBatch` entries gain `detail`. An ellipsoidal lookup whose terrain step fails carries the refusal in `detail.terrain`.
494
+ - **Breaking.** `DtedTerrain` refuses a tile whose DSI states a horizontal datum other than WGS84 (`NON_WGS84_TERRAIN_TILE`, with `datum` as a `DtedHorizontalDatum`). `dtedTreeToMmapStore` and `writeDtedTreeToMmapStore` refuse such a tile with `NonWgs84Tile`. A blank datum still reads as WGS84.
495
+ - **Breaking.** DTED tiles are checked against their UHL metadata, and a tile whose origin disagrees with its file name is refused; it used to read as sea level. A posting below -16000 m other than the null is read as two's complement, as GDAL reads it. Terrain stores built before this release keep the value decoded at conversion.
496
+ - **Breaking.** `MmapTerrain.fromBytes`, `fromVec` and `fromPath` refuse an index record whose tile id lies outside the coordinate domain (`TileIdOutOfRange`). They also refuse one whose bounds are not its one-degree cell's edges (`TileBoundsMismatch`, with `field`).
497
+ - Terrain-store bilinear lookup agrees bit for bit with raw DTED lookup west of the prime meridian and south of the equator.
498
+ - New `parseOceanLoadingBlqBlock(text)`, `parseOceanLoadingBlqBlocks(text)`, `writeOceanLoadingBlqBlock(block)` and `writeOceanLoadingBlqBlocks(blocks)`. They read and write `{ station, amplitudeM, phaseDeg, comments }` blocks in the standard constituent order. Each retained comment and column-order header line is kept with its `placement` (`beforeStation`, `beforeRow` with `row`, or `afterRows`).
499
+ - A BLQ column-order header is one of:
500
+ - a `COLUMN ORDER` declaration
501
+ - a line of labels only
502
+ - a comment in which a word `ORDER` is followed by labels to the end of the line
503
+
504
+ A header naming a label outside the eleven constituents, repeating one, or holding another count is refused with a `BlqParseError`.
505
+ - The BLQ writer throws a `BlqWriteError` with the zero-based `block` index for a block that would not read back unchanged:
506
+ - a station that is empty, holds a line break or surrounding whitespace, or reads as a comment, header or row
507
+ - a non-finite coefficient
508
+ - a comment that is misplaced or would not read as a comment
509
+ - a comment after the rows of any block but the last
510
+
511
+ ### Fixed
512
+
513
+ - The 2.1.1 documentation of the `MmapTerrain` lookups said a missing tile
514
+ evaluates to `0.0`. A lookup on a missing tile threw in 2.1.1 and throws now,
515
+ as a `TerrainLookupError` with `MISSING_TERRAIN_TILE`; the documentation says
516
+ so.
517
+
3
518
  ## 2.1.1 - 2026-09-22
4
519
 
5
520
  ### Fixed