@neilberkman/sidereon 2.1.0 → 3.0.0

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,535 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ Engine update: sidereon / sidereon-core 3.0.0 (pending release). Every entry below
6
+ restates an engine change as it reaches the JavaScript API.
7
+
8
+ ### Labels
9
+
10
+ - **Breaking.** A variant a later engine adds to an enum this binding labels
11
+ crosses under its own name, the engine variant's name in the case of the
12
+ labels around it (`lowerCamelCase`, or `UPPER_SNAKE_CASE` for TDM input
13
+ error kinds), where 2.1.1 gave `"unknown"` or `"UNKNOWN"`. This covers the
14
+ almanac season, Moon phase, planet, planetary event, culmination and eclipse
15
+ kinds; the RINEX clock header and record readings; the TDM input error
16
+ kinds; and the new PPP unplaced reasons, Bias lookup statuses, RTCM and SBAS
17
+ departures and SBAS refused-line reasons. Where the declarations state these
18
+ as unions of literals, the unions end in
19
+ `| (string & {})`.
20
+
21
+ ### Positioning
22
+
23
+ - **Breaking.** SPP, static, DGNSS, PPP and tight-fusion solves place each
24
+ satellite at the transmission epoch of its measured pseudorange,
25
+ `t_tx = (t_rx - P / c) - dts`, as RTKLIB `satposs` places it. This moves
26
+ solutions by the receiver clock times each satellite's range rate: nothing
27
+ measurable on a steered receiver clock, decimetres of range on a receiver
28
+ clock of half a millisecond.
29
+ - **Breaking.** The SPP, DGNSS and tight-fusion code models apply to a precise
30
+ (SP3 or interpolated precise) satellite clock the relativistic term
31
+ `-2 r.v / c^2`, as RTKLIB `peph2pos` does; they applied none. Their
32
+ solutions on precise sources move by metres to tens of metres. PPP rows
33
+ already applied the term and do not change for it.
34
+ - **Breaking.** `buildRinexRtkArc` and `buildDualFrequencyRinexRtkArc` place
35
+ each receiver's satellites from that receiver's own pseudoranges, as RTKLIB
36
+ `satposs` places them, where they used the reception epoch less the
37
+ pseudorange over `c` rounded to whole microseconds with no satellite clock.
38
+ A satellite is skipped for an epoch where the source has no clock for it or
39
+ its pseudorange is not a positive distance. A satellite whose phase
40
+ observable has no carrier no longer fails the arc: its measurement is formed
41
+ from the first configured pair whose carriers resolve, and a satellite no
42
+ pair resolves for is left out of that epoch and listed in the new
43
+ `unresolvedCarriers` as `{ receiver, epochIndex, satelliteId,
44
+ observableCode }`.
45
+ - **Breaking.** A PPP observation whose code is zero or negative is no longer
46
+ solved at a meaningless transmission epoch. It is left out and reported in
47
+ the new `unplacedObservations` on `PppFloatSolution` and
48
+ `PppFixedSolution`, each `{ epochIndex, satelliteId, ambiguityId, reason }`
49
+ with `reason` `"codeNotPositive"`.
50
+ - **Breaking.** An ionosphere-corrected SPP or static solve no longer fails a
51
+ whole epoch because one satellite has no resolvable carrier (a GLONASS
52
+ observation without a valid `glonassChannels` entry). The satellite is left
53
+ out and reported in the new `rejectedSats` of `SppSolution` and
54
+ `StaticSolution`, each `{ satelliteId, reason }`, with `reason` one of
55
+ `"noEphemeris"`, `"lowElevation"`, `"sbasWithdrawn"`, `"sbasIonoUncovered"`
56
+ and `"ionosphereCarrierUnresolved"`.
57
+ - **Breaking.** A broadcast satellite clock no longer includes the
58
+ single-frequency group delay. SPP, FDE and DGNSS requests take
59
+ `pseudorangeCode`, `"singleFrequency"` (the default) or `"ionosphereFree"`;
60
+ the broadcast group delay (GPS and QZSS TGD, Galileo BGD, BeiDou TGD1) applies
61
+ to single-frequency code only, as RTKLIB `prange` applies it.
62
+ - PPP observations take `signals: { code1, code2, phase1, phase2 }`, the RINEX
63
+ 3 tracking codes the ionosphere-free code and phase were formed from. SSR and
64
+ HAS biases apply only to an observation that states the signals they are for.
65
+ - PPP residuals carry `ambiguityId`. `PppFloatSolution` gains
66
+ `solvedEpochIndices`, `residualScreen`, `residualScreenRemovals` and
67
+ `solveOptions` (the iteration cap and tolerances the solve ran with);
68
+ `PppFixedSolution` gains `solvedEpochIndices`.
69
+ - `solveStaticReferenceStationRinex` reports a reference or rover epoch outside
70
+ the UT1 table as a mode error with `kind` `"ut1OutsideCoverage"`, and the
71
+ code, carrier and station solutions carry `ut1Degraded`.
72
+ `sbasProtectionLevels` refuses such an epoch with the error label
73
+ `"Ut1OutsideCoverage"`, and DGNSS solves throw an `Error` naming it.
74
+
75
+ - **Breaking.** SPP and the static solve select, mask and weight the
76
+ satellites at every iterate, as RTKLIB `estpos` re-runs `rescode`, instead of
77
+ once at the initial guess. A pass whose selection repeats the previous one
78
+ takes RTKLIB's least-squares step, and the solve ends with the first such
79
+ step below 1e-4 m. Solutions move wherever the selection or the weights at
80
+ the solution differ from those at the initial guess; a coarse cold start that
81
+ failed now solves. A solve whose selection does not settle throws an `Error`
82
+ naming the passes it ran.
83
+ - **Breaking.** `StaticSolution.metadata.status` is `"SelectionSettled"` for a
84
+ converged solve and `"OuterBudgetExhausted"` for a robust solve that spent
85
+ its outer budget first, and `converged` describes how the whole solve ended:
86
+ a spent robust budget reports `converged: false`. The status is typed as the
87
+ new `SolveStatus` union, and the metadata as `StaticSolveMetadata`.
88
+ - `SppSolution.metadata` reports how the solve ran and ended, `{ iterations,
89
+ converged, status, outerIterations, finalRobustScaleM, ionosphereApplied,
90
+ troposphereApplied, usedCount }` (`SppSolveMetadata`).
91
+ - The SPP carrier check applies only when a frequency-dependent term is
92
+ applied, so a solve without the ionosphere correction no longer leaves out a
93
+ satellite for want of a carrier. The PPP auto-init SPP seed passes each
94
+ GLONASS observation's channel.
95
+
96
+ - **Breaking.** Every SPP-family failure throws an `Error` named
97
+ `PositioningError` whose `detail` is a typed `PositioningErrorDetail`: a
98
+ union on `kind` with one member per engine variant and the fields it
99
+ carries. This covers `solveSpp`, `solveSppWithDopplerVelocity`,
100
+ `solveSppBatch`, `solveSppFromRinexObs`, `solveBroadcast`,
101
+ `solveWithFallback`, `fde`, `solveSppSbas`, `solveStatic` and the DGNSS
102
+ solves, including the new `SELECTION_UNSETTLED { passes }` and
103
+ `UT1_OUTSIDE_COVERAGE { reason }`. A failure that wraps another nests it as
104
+ `cause` (`EPOCH_INPUT`, `PRECISE_SOLVE_FAILED`, `BROADCAST_SOLVE_FAILED`); a
105
+ rejected solution names its validation failure (`SOLUTION_REJECTED` with
106
+ `validation.kind` such as `DEGENERATE_GEOMETRY_PDOP { pdop }`); a RINEX SPP
107
+ variant a later engine adds crosses as `OTHER` with its own name in
108
+ `variant`. They were plain `Error`s carrying only a message, and a
109
+ fallback failure was named `PreciseSolveError` or `BroadcastSolveError`.
110
+ `SppBatchSolution.error(i)` and `RinexSppSolutionBatch.error(i)` return the
111
+ detail instead of a message string, and `broadcastReason.preciseError` is a
112
+ detail. A non-finite DGNSS base position throws a `PositioningError`
113
+ (`DGNSS_INVALID_INPUT`) instead of a `RangeError`.
114
+
115
+ ### UT1 coverage
116
+
117
+ - **Breaking.** Every function that reads UT1 (sidereal times, GCRS/ITRS
118
+ transforms, look angles, passes, ground tracks, visibility, observe, Sun and
119
+ Moon az/el and illumination, rise/set and meridian transits, coverage grids,
120
+ PPP corrections, SSR-corrected states) refuses an instant outside the UT1
121
+ table by default and throws. It previously held TT-UT1 at the table edge; a
122
+ pass search scored the failed look angle as -90 degrees and returned no
123
+ passes. Where an instant outside the table is accepted, the engine follows
124
+ the Skyfield 1.54 delta-T curve.
125
+ - `WithValidity` variants take an optional UT1 policy, `"strict"` (the
126
+ default) or `"permissive"`, and return `{ value, ut1Degraded }`, where
127
+ `ut1Degraded` is `"beforeCoverage"`, `"afterCoverage"` or `null`:
128
+ `gmstRadiansWithValidity`, `gastRadiansWithValidity`,
129
+ `gcrsToItrsWithValidity`, `itrsToGcrsWithValidity`, `sunAzElWithValidity`,
130
+ `moonAzElWithValidity`, `moonIlluminationWithValidity`,
131
+ `findMoonElevationCrossingsWithValidity`, `findMoonTransitsWithValidity`,
132
+ `meridianTransitsWithValidity`, `meridianTransitsSpkWithValidity`,
133
+ `observeWithValidity`, `observeSpkBodyWithValidity`,
134
+ `Tle.lookAnglesWithValidity`, `Tle.findPassesWithValidity`,
135
+ `Tle.groundTrackWithValidity` and `visibleFromSatellitesWithValidity`.
136
+ - `Instant.ut1Degraded`, `SppSolution.ut1Degraded` and
137
+ `CoverageGrid.ut1Degraded` report the departure a permissive policy
138
+ accepted. `coverageLookAngles` and `pppCorrections` (option `ut1Validity`)
139
+ take the policy; `pppCorrections` also returns `ut1Degraded` and `warnings`.
140
+ `ssrCorrectedState` takes an optional UT1 policy and returns `ut1Degraded`.
141
+
142
+ ### TLE and SGP4
143
+
144
+ - `Sgp4Satellite.fromOmm(omm)` initializes a reusable SGP4 propagator through
145
+ the engine's canonical OMM bridge. Its `propagate(epochs)` returns the same
146
+ TEME arrays as the matching TLE, while incompatible metadata and missing
147
+ `MEAN_MOTION` or `BSTAR` remain typed `OmmError` failures.
148
+ - **Breaking.** `new Tle(line1, line2, opsMode?, policy?)` and
149
+ `parseTleFile(text, opsMode?, policy?)` take a checksum policy. `"strict"`,
150
+ the default, refuses a column-69 digit that disagrees with the computed
151
+ checksum and a column 69 that is not a digit; 2.1.1 read both. `"lenient"`
152
+ reads them and reports each in `checksumWarnings`, now
153
+ `{ kind, expected?, found?, computed, message }`.
154
+ - **Breaking.** `Tle.revNumber`, `elementSetNumber` and `ephemerisType` are
155
+ `undefined` when the field is blank, instead of `0`.
156
+ - **Breaking.** `parseTleFile` returns `rejected`, one
157
+ `{ lineNumber, name, issue, message }` per record it refused, and `skipped`
158
+ is its length. Each `NamedTle` carries `lineNumber` and `checksumWarnings`.
159
+
160
+ - **Breaking.** `Tle.toLines` spells B\* and the second mean-motion derivative
161
+ as python-sgp4's `export_tle` does: the five significant digits of
162
+ `value * 10`, correctly rounded with ties to even (3.21675e-9 is now
163
+ `" 32168-8"`, where it was `" 32167-8"`). A zero B\* is `" 00000+0"`, a
164
+ negative zero `"-00000+0"`, and a zero second derivative `" 00000-0"`.
165
+ - **Breaking.** An OMM epoch of whole microseconds takes the SGP4 epoch
166
+ python-sgp4 2.22 gives it, bit for bit, and a satellite built from such an
167
+ OMM is initialised as python-sgp4's `sgp4.omm.initialize` does, with B\* and
168
+ the second derivative as stated. Epochs of the NAVSTAR 43 and GALAXY 15
169
+ fixtures move by about 0.3 microseconds. Other OMM element sets quantize B\*
170
+ and the second derivative onto the TLE grid at exponent -9, as the TLE writer
171
+ rounds them.
172
+ - **Breaking.** Passes and look angles propagate SGP4 at the split Julian date
173
+ Skyfield 1.54 uses for the same UTC instant, bit for bit.
174
+
175
+ ### SPK
176
+
177
+ - **Breaking.** SPK states always carry a velocity. Type 2 segments return
178
+ the derivative of the position polynomial, where the velocity was
179
+ `undefined`.
180
+ - `SpkKernels` holds several kernels and resolves a body chain across them
181
+ (`new SpkKernels()`, `push`, `pushBytes`, `length`, `state`,
182
+ `stateInFrame`). `Spk.stateInFrame`, `spkInertialFrameName` and
183
+ `spkInertialFrameRotation` expose the kernel's inertial frames.
184
+
185
+ ### Time
186
+
187
+ - **Breaking.** The embedded leap-second table dated the 1997 and 1999 leap
188
+ seconds to 1997-01-01 and 1998-01-01. TAI - UTC became 31 s on 1997-07-01
189
+ and 32 s on 1999-01-01, so every UTC conversion from 1997-01-01 to 1997-06-30
190
+ and from 1998-01-01 to 1998-12-31 moves by one second, and `23:59:60` is
191
+ accepted on 1997-06-30 and 1998-12-31 instead of 1996-12-31 and 1997-12-31.
192
+ - NMEA sentence seconds are read as the decimal stated, rounded once (`07.56`
193
+ was 7.5600000000000005).
194
+
195
+ ### Space weather
196
+
197
+ - **Breaking.** `sampleAt(epoch, policy?)` under the default policy refuses a
198
+ monthly predicted row, which states no Ap, instead of substituting the quiet
199
+ Ap of 4. `"lenient"`, or a policy object with `requireGeomagnetic: false`,
200
+ substitutes it and reports it. Rows whose flux qualifier states no
201
+ observation have the new class `"notObserved"`, refused unless the policy
202
+ sets `allowNotObserved`. Unknown policy keys and names throw a `TypeError`.
203
+ - `apHistoryAt(epoch, policy?)` returns the seven-element NRLMSISE-00 Ap
204
+ history `{ ap, class, apDefaulted, binsFromDailyAp }`. Decay requests take
205
+ `policy`.
206
+
207
+ ### CCSDS OMM, OPM, OEM and CDM
208
+
209
+ - **Breaking.** Reader and writer failures throw typed errors named
210
+ `OmmError`, `OpmError`, `OemError` and `CdmError`, whose `detail` is an
211
+ `NdmErrorDetail` (`kind`, `message` and the fields that variant carries).
212
+ Writers (`toKvnString`, `toXmlString`, `toJsonString` and the new CSV
213
+ writers) throw instead of writing text that would not read back.
214
+ - **Breaking.** A value a message does not carry is `undefined`, not a
215
+ fabricated default: an OMM read from JSON without `CCSDS_OMM_VERS` has no
216
+ version, and `new Omm(...)` takes `meanMotion` and `noradCatId` as optional.
217
+ - **Breaking.** OPM and OEM covariances are built from the 21-value lower
218
+ triangle (`lowerTriangle`) or a symmetric 36-value matrix; an asymmetric
219
+ matrix is refused. `matrix` returns the stored values unvalidated and
220
+ `toValidatedMatrix()` checks positive semidefiniteness.
221
+ - **Breaking.** `OemSegment(metadata, states, covariances, dataComments?,
222
+ covarianceComments?)` throws on invalid input. `skippedStates` is an array of
223
+ `{ line, segment, text, reason, itemCount, field, issue }`, with the count in
224
+ `skippedStateCount`.
225
+ - Every record the messages define is retained: OMM `classification`,
226
+ `messageId`, `refFrameEpoch`, `semiMajorAxisKm`, `gmKm3S2`, spacecraft
227
+ parameters, `btermM2Kg`, `agomM2Kg`, covariance, user-defined parameters and
228
+ comments; OPM and OEM `classification`, `messageId`, `refFrameEpoch`,
229
+ user-defined parameters and comments per block; CDM `ccsdsCdmVers`,
230
+ `messageFor`, relative state, screening volume and period, comments, drag,
231
+ SRP and thrust covariance rows, OD parameters and additional parameters,
232
+ with `toCovarianceRtn()`.
233
+ - `parseOmmCsv`, `parseOmmXmlAll`, `parseOmmJsonArray` and `parseOmmCsvArray`
234
+ read CSV and multi-record OMM, returning `{ omms, skipped }` for the array
235
+ readers. `Omm.toCsvString`, `toCsvStringDiscardingComments` and
236
+ `toJsonStringDiscardingComments` write them.
237
+
238
+ ### Bias-SINEX and CODE DCB
239
+
240
+ - **Breaking.** `loadBiasSinex(bytes, policy?)` and
241
+ `loadCodeDcb(bytes, options, policy?)` are strict by default: a file that
242
+ departs from Bias-SINEX 1.00, or a DCB title whose time-system label is
243
+ unknown when no options are given, is refused. `"lenient"` reads it and
244
+ reports each departure in `notices`.
245
+ - **Breaking.** Bias lookups (`codeOsbSeconds`, `phaseOsbCycles`,
246
+ `codeDsbSeconds`, `codeBiasModelM`) return a `BiasLookup`,
247
+ `{ status, value, records, overridden, productScale, queryScale,
248
+ observable }`, instead of a number or `undefined`, so an absent, ambiguous or
249
+ out-of-scale lookup is distinct from a value. `phaseOsbCycles` takes
250
+ `timeScale?` and `carrierHz?`.
251
+ - Bias records carry `family`, `unit`, `svn`, `validFrom`, `validUntil`,
252
+ `slopeSigma` and `line`. `BiasSet` gains `mode`, `timeScale`,
253
+ `timeSystemLabel`, `notices` and `lineCounts`, and writes with
254
+ `toBiasSinex()` and `toCodeDcb()`.
255
+
256
+ ### RINEX navigation
257
+
258
+ - **Breaking.** `BroadcastRecord.issue`, `issueMessage` and `svAccuracyM` are
259
+ `undefined` where the message states none (a CNAV record has no IODE).
260
+ `toRinexString` and `encodeRinexNav` throw instead of writing a record that
261
+ would not read back.
262
+ - **Breaking.** `issueMessage` gains `"navic_lnav"` and
263
+ `"galileo_unclassified"`. A Galileo record's message is read from its
264
+ data-source word by RINEX 3.05 Table A8; a word that names no single message
265
+ is `"galileo_unclassified"`, where it was read as I/NAV or F/NAV.
266
+ - Records expose the fields their message states: `iodc`, `l2Codes`,
267
+ `l2pDataFlag`, `galileoDataSources`, `beidouAodc`, `transmissionTimeSow`,
268
+ GLONASS `statedFreqChannel`, `ageDays`, `statusFlags`, `healthFlags`, group
269
+ delays and flag words, `isHealthy`, `singleFrequencyGroupDelayS` and
270
+ `clockBiasAtS`. Ionosphere corrections add QZSS, NavIC, Galileo NeQuick,
271
+ Galileo disturbance flags and BeiDou BDGIM, with
272
+ `BroadcastEphemeris.ionoCorrectionsAt`.
273
+ - `BroadcastEphemeris` and the NAV parse result report `skipped` blocks with
274
+ their `line`, `departures` and `other` blocks. `parseRinexNavFile` returns a
275
+ `RinexNavFile` that keeps every entry and writes it back.
276
+
277
+ ### RTCM
278
+
279
+ - **Breaking.** `decodeRtcm` reads a buffer in full or throws: a stray byte, a
280
+ CRC-24Q failure, a trailing partial frame, or a body that does not decode or
281
+ departs from RTCM 3. It returned the frames it could read. Use
282
+ `decodeRtcmStream` for noisy input.
283
+ - **Breaking.** `decodeRtcmStream(bytes, policy?)`, `decodeRtcmFrame(bytes,
284
+ policy?)`, `encodeRtcm(message, policy?)` and `encodeRtcmFrame(message,
285
+ policy?, reserved?)` take a policy, `"strict"` (the default) or
286
+ `"lenient"`. Messages carry `trailingBits`, MSM messages `signalMask` and
287
+ GLONASS fields `negativeZero`, so a lenient decode re-encodes byte for byte.
288
+ Stream diagnostics gain `crcFailures` and `departures`, and skipped frames
289
+ the reason `"departure"`. `decodeRtcmFrame` returns `reserved` and
290
+ `departures`; `FrameScanner` gains `resyncBytes`, `crcFailures` and
291
+ `reserved`.
292
+
293
+ - The decoded message IR is typed: `decodeRtcm`, `decodeRtcmMessage`,
294
+ `decodeRtcmStream`, `decodeRtcmFrame` and `FrameScanner.next()` return
295
+ `RtcmMessage` (a union on `type`), `RtcmStream`, `RtcmFrame` and
296
+ `RtcmScannedFrame`, with `RtcmDeparture` and `RtcmFrameSkip` for the
297
+ diagnostics; `encodeRtcm` and `encodeRtcmFrame` take `RtcmMessageInput`.
298
+ Raw 64-bit fields are `bigint`.
299
+ - `encodeRtcm` and `encodeRtcmFrame` encode SSR messages (`type: "ssr"`), so
300
+ every message type the decoder returns re-encodes.
301
+ - The encoder reads the GNSS system as the decoder writes it (`"GPS"`,
302
+ `"GLONASS"`, `"Galileo"`, `"BeiDou"`, `"QZSS"`, `"NavIC"`, `"SBAS"`) as well
303
+ as in lower case. A decoded MSM or SSR message was refused with
304
+ `invalid GNSS system label "GPS"`.
305
+
306
+ ### SBAS
307
+
308
+ - **Breaking.** `decodeSbasMessage(bytes, form?, policy?)` refuses by default
309
+ a preamble other than `0x53`, `0x9A` and `0xC6`. `"lenient"` reads it and
310
+ reports it in `departures`; the result carries `padBits`.
311
+ - `parseSbasEmsLog` and `parseSbasRtklibLog(text, { policy, referenceWeek })`
312
+ return an `SbasLog` with `blocks`, `skippedLines`, `refusedLines` and
313
+ `departures`. Log blocks carry `declaredMessageType` and `messageType` and
314
+ decode with `decode(policy?)`. The ionospheric grid reports
315
+ `unavailableIgps`.
316
+
317
+ ### SSR
318
+
319
+ - SSR orbit and clock corrections carry `navMessage` (`"rtcm"` or `"has"`),
320
+ `hasNavMessageIndex` and `transmittedEpochJ2000S`.
321
+
322
+ ### IONEX
323
+
324
+ - **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.
325
+ - **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`.
326
+ - `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`.
327
+ - **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.
328
+ - **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`.
329
+ - **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.
330
+ - **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.
331
+ - `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" }`).
332
+ - 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.
333
+ - 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`.
334
+ - `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.
335
+ - The reader accepts files it used to refuse:
336
+ - an axis running in either direction, with `latNodesDeg` / `lonNodesDeg` now in file order (an ascending latitude axis was refused)
337
+ - an epoch at hour 24 with zero minute and second
338
+ - a `nan` value field, read as `null` with a `NOT_A_NUMBER_VALUE` warning
339
+ - a map inheriting an earlier map's `EXPONENT`, reported as `EXPONENT_CARRIED_INTO_MAP`
340
+
341
+ `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.
342
+ - **Breaking.** The reader refuses what it used to misplace or pass over:
343
+ - a `MAP DIMENSION 3` product, or an `HGT1 / HGT2 / DHGT` giving several heights
344
+ - a band whose latitude, longitude or height is not a grid node
345
+ - a map that gives a node twice or leaves one without a value
346
+ - `START OF TEC MAP` numbers out of order
347
+ - an RMS or height map that does not name a TEC map
348
+ - a data record holding a non-ASCII character, or one outside a band
349
+ - a `16I5` record with an empty field
350
+ - an unclosed `AUX DATA` block
351
+ - a file type other than `I`
352
+ - two records of one header label that read as different values
353
+ - 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.
354
+ - 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`.
355
+
356
+ ### TDM
357
+
358
+ - **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.
359
+ - **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.
360
+ - **Breaking.** `parseTdmKvn` reads under the strict CCSDS 503.0-B-2 rules and now refuses:
361
+ - a last line with no terminator (`UNTERMINATED_FINAL_LINE`)
362
+ - a tab, control or non-ASCII character (`NON_PRINTABLE_CHARACTER`)
363
+ - a line over 254 characters (`LINE_TOO_LONG`)
364
+ - a missing `CREATION_DATE`, `ORIGINATOR`, `TIME_SYSTEM` or `PARTICIPANT_n` (`MISSING_KEYWORD`)
365
+ - a data section with no records (`EMPTY_DATA_SECTION`), or `KEY =` with no value (`EMPTY_VALUE`)
366
+ - a version outside `x.y` (`INVALID_VERSION`)
367
+ - 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`)
368
+ - records out of chronological order (`RECORDS_OUT_OF_ORDER`), or a repeated keyword and timetag pair (`DUPLICATE_RECORD`)
369
+ - keywords out of table order, or comments out of place (`KEYWORD_OUT_OF_ORDER`)
370
+ - a `PATH` naming an undefined participant (`UNDEFINED_PARTICIPANT`)
371
+ - a keyword outside its section's table (`UNDEFINED_KEYWORD`)
372
+ - `PARTICIPANT_0`, `PARTICIPANT_6` and up, padded indices such as `PARTICIPANT_01`, or `PATH_3` (`INVALID_FIELD`)
373
+ - a keyword repeated with another value (`CONFLICTING_KEYWORD`)
374
+ - `COMMENT=value` or `META_START = 1` (`MALFORMED_LINE`)
375
+ - 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.
376
+ - **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.
377
+ - `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`.
378
+ - `Tdm.toKvnStringWithPolicy(policy?)` returns `{ text, value, departures }`. It takes the reader's axes plus `repeatedKeywords`, and reports each departure it emitted as a `TdmDeparture`.
379
+ - `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.
380
+
381
+ - `TdmMetadata.setField(key, value)` sets one keyword: a stated keyword takes
382
+ the value in place, an absent one is inserted where the keyword order of
383
+ tables 3-2 and 3-3 puts it, and every comment keeps the field it precedes.
384
+ `TdmMetadata.removeField(key)` removes every occurrence. Both validate as
385
+ `replaceRaw` does and are atomic.
386
+
387
+ ### RINEX observations
388
+
389
+ - **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`).
390
+ - **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.
391
+ - **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.
392
+ - **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.
393
+ - **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.
394
+ - **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.
395
+ - **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.
396
+ - **Breaking.** One header block now refuses two records that read as different values for the same label. This applies to:
397
+ - `INTERVAL`, `MARKER NAME/NUMBER/TYPE`, `APPROX POSITION XYZ`, `ANTENNA: DELTA H/E/N`, `ANT # / TYPE`, `REC # / TYPE / VERS`, `OBSERVER / AGENCY` and `SIGNAL STRENGTH UNIT`
398
+ - in the file header only: `RINEX VERSION / TYPE`, `TIME OF FIRST OBS`, `TIME OF LAST OBS`, `LEAP SECONDS` and `# OF SATELLITES`
399
+
400
+ 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.
401
+ - 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).
402
+ - 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.
403
+ - `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`.
404
+ - `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`.
405
+ - **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.
406
+ - 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.
407
+ - **Breaking.** The reader refuses values its own fields cannot write back:
408
+ - an `INTERVAL`, position, antenna delta or first/last-observation second below its field's resolution or too wide for its columns
409
+ - a version 2 value wider than `F14.3` or with more than three decimals
410
+ - a version 2 type code wider than two characters
411
+ - a `GLONASS COD/PHS/BIS` code longer than three characters
412
+ - a type-list continuation that continues no list
413
+ - blank epoch fields on an observation or cycle slip record
414
+ - `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.
415
+
416
+ - **Breaking.** A lint finding's `detail` is a typed `RinexLintFindingDetail`,
417
+ a union on `kind` (`OBS_UNRETAINED_HEADER { label }`,
418
+ `OBS_EPOCH_GAP { gapS, intervalS }` and so on, one member per engine
419
+ variant), where it was the engine's debug text. `lintRinexObs`,
420
+ `lintRinexNav` and the repairs' `remaining` are typed `RinexLintReport`.
421
+ - `ObservationQcReport` gains `header` (marker, receiver, antenna,
422
+ approximate position, antenna delta, first and last observation times and
423
+ duration) and `systems` (per-system completeness), and `toJson()` includes
424
+ both.
425
+ - `simulateScenarioSet(scenario)` keeps the simulated observation set as a
426
+ `ScenarioSimulation`, with `arrays`, `determinismFingerprintHex`,
427
+ `toRinexObservationFile()`, `toRinexString()` and
428
+ `sppObservationsForEpoch(i)`.
429
+
430
+ ### RINEX clock
431
+
432
+ - **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`.
433
+ - **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`.
434
+ - **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`.
435
+ - **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.
436
+ - **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`.
437
+ - `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.
438
+ - **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`.
439
+ - New views: `version`, `layout` (`"v300"` / `"v304"`), `satelliteSystem`, `timeSystem`, `timeSystemStatus`, `timeScale`, `headerRecords()`, `records()`, `recordCount`, `sourceLine(n)`, `skippedRecords`, `diagnostics` and `notices`.
440
+ - 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.
441
+ - New builders: `RinexClock.fromClockPoints(timeScale, rows)` and `RinexClock.fromSeriesRows(rows)`, which throw a `RinexClockBuildError`.
442
+
443
+ - **Breaking.** `ClockEpoch.gpsSeconds` and each sample's `gpsSeconds` are the
444
+ double nearest the GPS second count the civil tag states, rounded once. They
445
+ were rounded at each step, so 2026-05-13 00:00:59.9999996 came out one unit
446
+ in the last place high.
447
+
448
+ ### ANTEX
449
+
450
+ - **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`.
451
+ - **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.
452
+ - **Breaking.** `loadAntex` throws an `AntexParseError` whose `detail` is an `AntexErrorDetail`, where it threw a plain `Error`.
453
+ - **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.
454
+ - **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.
455
+ - **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.
456
+ - **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.
457
+ - New records and lookups:
458
+ - `Antex.header` (`version`, and `pcvType` with `referenceAntenna`), `outerComments`, `antennaBlocks()`, `blockCount`, `antennaIntervals(id)` and `antennaAt(id, epoch)`
459
+ - `Antenna.leadingComments`, `comments`, `calibrations`, `hasFrequencyCount`, `frequencySections()` and `frequency(label)`
460
+
461
+ `Antex.antenna(id)` returns the id's latest block.
462
+
463
+ ### SP3
464
+
465
+ - **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.
466
+ - A value that would read back as an absence sentinel is refused (`RECORD_READS_AS_ABSENT`).
467
+ - An integer-nanosecond epoch is refused (`EPOCH_REPRESENTATION_UNSUPPORTED`).
468
+ - A satellite with no `01`..`99` token is refused (`SATELLITE_NOT_REPRESENTABLE`), where a PRN of 0 used to be written as `G00`.
469
+ - `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.
470
+ - **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.
471
+ - **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.
472
+ - `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.
473
+ - `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.
474
+ - 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`.
475
+ - `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.
476
+ - `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`).
477
+ - `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.
478
+ - **Breaking.** `mergeSp3` refuses an option field it does not know with a `TypeError`, as `sp3MergeInputIdentity` does, where it ignored one.
479
+ - **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`.
480
+ - **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.
481
+
482
+ ### Terrain and tides
483
+
484
+ - **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.
485
+ - **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`.
486
+ - **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.
487
+ - **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.
488
+ - **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`).
489
+ - Terrain-store bilinear lookup agrees bit for bit with raw DTED lookup west of the prime meridian and south of the equator.
490
+ - 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`).
491
+ - A BLQ column-order header is one of:
492
+ - a `COLUMN ORDER` declaration
493
+ - a line of labels only
494
+ - a comment in which a word `ORDER` is followed by labels to the end of the line
495
+
496
+ A header naming a label outside the eleven constituents, repeating one, or holding another count is refused with a `BlqParseError`.
497
+ - The BLQ writer throws a `BlqWriteError` with the zero-based `block` index for a block that would not read back unchanged:
498
+ - a station that is empty, holds a line break or surrounding whitespace, or reads as a comment, header or row
499
+ - a non-finite coefficient
500
+ - a comment that is misplaced or would not read as a comment
501
+ - a comment after the rows of any block but the last
502
+
503
+ ### Fixed
504
+
505
+ - The 2.1.1 documentation of the `MmapTerrain` lookups said a missing tile
506
+ evaluates to `0.0`. A lookup on a missing tile threw in 2.1.1 and throws now,
507
+ as a `TerrainLookupError` with `MISSING_TERRAIN_TILE`; the documentation says
508
+ so.
509
+
510
+ ## 2.1.1 - 2026-09-22
511
+
512
+ ### Fixed
513
+
514
+ - Engine update: sidereon 2.1.1 / sidereon-core 2.1.1. CODE predicted
515
+ ionosphere maps resolve to the archive AIUB now serves them from: `cod_prd1`
516
+ is `CODE/IONO/PRD/COD0OPSP0D_<date>0000_01D_01H_GIM.INX.gz` and `cod_prd2`
517
+ is `CODE/IONO/PRD/COD0OPSP1D_<date>0000_01D_01H_GIM.INX.gz`. The
518
+ `CODE/IONO/P1/<year>` and `CODE/IONO/P2/<year>` `COD0OPSPRD` trees they were
519
+ read from stopped receiving issues after 2026-09-21 and are now empty, so
520
+ every predicted-IONEX URL `distributionLocation` and
521
+ `predictedIonexLineCandidates` returned pointed at an object that no longer
522
+ exists. For the dates both layouts carried the objects decompress to the
523
+ same bytes.
524
+ - The two predicted lines now carry distinct official filenames, so
525
+ `productIdentity("cod_prd1", ...)` and `productIdentity("cod_prd2", ...)`
526
+ for one map date differ in `officialFilename` as well as in
527
+ `predictionHorizonDays` and `cacheKey`.
528
+ - `newestPublishedProduct` and `resolveFirstPublishedPredictedIonex` attribute
529
+ only objects under `CODE/IONO/PRD/` to a predicted line, not the rolling
530
+ copies CODE keeps at the tree root.
531
+ - The JavaScript API is unchanged.
532
+
3
533
  ## 2.1.0 - 2026-09-05
4
534
 
5
535
  ### Added