← older entries

Phase 213 — src/datamanagers sweep, continued: a real getcolumn crash on an undefined TiledCellStMan row, plus several confirmed-correct-but-untested paths closed with permanent tests

Continuing the coverage-instrumented + manual-reading sweep from Phase 211 (which covered standard.jl/incremental.jl/arrayfile.jl/ tiled.jl), this phase covers the remaining src/datamanagers/ files (dysco.jl, virtual.jl, container.jl, virtualtaql.jl, forwardcol.jl, datamanager.jl) plus a deeper pass over tiled.jl.

The real bug, found via coverage diff then live-reproduced: getcolumn's :cell (TiledCellStMan) branch (tiled.jl) iterated tsm.cubes[r] directly, skipping the isnull check getcell already has. A per-row cube can genuinely be undefined — real casacore's own TiledCellStMan::addRow64 (TiledCellStMan.cc:178-200) creates a null TSMCube (empty cubeshape, no file) for any row added before its cell shape is ever setShape'd — so a real casacore-authored table with more declared rows than actually-written cells for that column is a genuine, reachable state. Live-reproduced by hand-inserting a null cube into a real, on-disk-round-tripped TiledStMan instance: getcell already gave a clear "row N of this column has no stored data" error; getcolumn crashed instead with a raw, unhelpful MethodError: no method matching _tsmbytes(..., ::Nothing). Fixed to check isnull per row and raise the same clear error; new regression test in test/tsm_multicol_tests.jl.

Confirmed-correct-but-untested paths, closed with permanent tests (each live-verified first, several against real Casacore.jl/TaQL):

  • _cube_for_row's rownr > tsm.row[end] branch (TiledShapeStMan reading a row beyond its own row map's last defined interval — a column whose tile coverage never got extended to the table's full row count) — hand-truncated a real instance's row map to simulate it; getcell/getcolumn (both the astype-narrowed and plain fallback paths) all give the same clear error, and the still-defined rows are unaffected.
  • getcolumn's astype-narrowed per-cell fallback (used only when the whole-column bulk-read fast path doesn't apply, e.g. more than one real hypercube for the column) — every existing precision-narrowing test happened to hit the fast path instead.
  • read_plane's "leading axes tiled" branch (a tile shape that also chunks the cell's own non-row axes, not just the row axis) — our own writer never produces this layout (write_tiledshapestman always tiles only the row axis), so a real, TaQL-authored DEFAULTTILESHAPE=[1,2,2] fixture is both the only way to construct one and a genuine interop cross-check (_HAVE_TAQL-gated).
  • An unrecognized / not-yet-supported tiled wrapper name inside table.f<seq> (hand-patched a real file's private header to a bogus name and to "TiledDataStMan") — both give a clear, name-carrying error. Along the way, found "TiledDataStMan" was not registered in DATAMANAGERS at all, even though TiledDataStMan::dataManagerType() (TiledDataStMan.cc:79-80) literally returns that string — meaning a genuine TiledDataStMan-bound column would never even reach this file's own dedicated "TiledDataStMan not yet supported" error (_dm_instance, tables/column.jl, raises its generic "not yet supported (column data)" message first, since _dmtype returned nothing) — the dedicated branch was unreachable dead code for the exact real-world case it names. Fixed by registering it, routing to the same informative error.
  • _dmtype's own final fallback (datamanager.jl, return nothing for a totally unrecognized name) had no test at all.
  • MultiFile's _mf_pack_index (the write-side mirror of _mf_unpack_index) — only ever exercised with an already-contiguous block list (the only shape this package's own writer produces); its "close the current run, start a new one" branch (a genuinely different code path for a fragmented block list) had zero coverage anywhere. Confirmed correct via round-trip through _mf_unpack_index for several fragmented cases.
  • open_multifile's useCRC header-verification branch — no TaQL/StorageOption knob exists to make real casacore ever write a useCRC=true container (confirmed in the Phase 21 plan), so no fixture was available; hand-patched a real container this package wrote (with a correctly-computed CRC, and separately with a deliberately wrong one) to exercise both the accept- and reject-when-corrupted paths.
  • af_read/af_put!'s empty-string-element branches (an empty "" element within an otherwise-written ragged String array, distinct from Phase 211's "entire cell never filled" case) — every existing indirect-String-array test used only non-empty strings.
  • getcolumn's array-valued-ISM-column astype post-convert branch — every existing array-valued ISM test used Float64 data (narrowing only ever applies to Float32/ComplexF32), so no test combined a ragged ISM array column with precision narrowing.
  • Three VirtualTaQLColumn combinations: a quantity literal inside a CALC expression ("A > 1.4GHz", Unitful-gated), an array-typed CALC result, and _vtq_err's runtime-evaluation-error wrapping (distinct from the existing parse-time-error test — a per-row out-of-range array index is a genuine runtime-only failure, reachable via both getcell and the bulk getcolumn path).
  • _decode(::CompressComplex,...)'s im < -ENG_C_WRAP wrap-correction branch (needs a negative real part paired with a non-negative imaginary part — the existing cross-check fixtures never had that sign combination) and both of CompressComplexSD's wrap-correction branches (needs a genuinely mixed-sign complex value) — all confirmed correct via live round-trip and a real Casacore.jl cross-check. Constructing this also surfaced a real, confirmed-matching-upstream quirk, not a divergence to fix: CompressComplexSD::scaleOnPut (CompressComplex.cc:787-788) encodes a non-finite value the same way plain CompressComplex does (stored = -32768*65536) — but that value is even, and SD's own decode (scaleOnGet, .cc:750) branches on inval%2==0 before ever reaching the r==-32768 NaN sentinel check (which only lives in the odd branch) — so a NaN written through CompressComplexSD does not come back as NaN in real casacore either; it silently misdecodes as a large bogus real-only value. This package's port reproduces that upstream limitation exactly (live- verified byte-for-byte against real Casacore.jl) rather than "fixing" a divergence that doesn't actually exist. Documented in place, pinned by a permanent test.

Investigated, deprioritized (real, defensive, or too costly to construct a fixture for relative to the payoff — not chased further): SSMIndex/ISMIndex's internal "row out of range" guards (only reachable from a bug in this package's own calling code, never from real data); DyscoStMan's rowsPerBlock == 0 guard (only reachable for a genuinely 0-row table, which this package's own writer already rejects before it can be produced); _af_calculate_antenna_rms's dead-antenna snap-to-zero branch and _af_fit_to_maximum!'s two early-termination branches (deep inside the already CASA-cross-checked AF-normalization hill-climb — correct by construction and at the macro (round-trip) level, just not independently traced at this micro-branch level); SSMStringHandler's filled==0 "shape declared, never actually written" case (standard.jl) — a real casacore state (mirrors the TiledCellStMan null-cube case), but constructing a fixture needs a setShape-without-put sequence this package's own writer never produces and TaQL has no clean way to trigger either.

Full suite green (baseline 5011, +54 new = 5065/5065). README/memory updated, merge on the user's word.

Phase 214 — src/datamanagers sweep, continued: a mathematically-unreachable branch confirmed (not a bug), a real casacore clamp asymmetry documented, and the last few coverage gaps closed

Continuing Phase 213's sweep with fresh eyes: a manual line-by-line re-verification of ScaledArrayEngine/ScaledComplexData/ MappedArrayEngine against ScaledArrayEngine.tcc/ ScaledComplexData.tcc/MappedArrayEngine.tcc (never independently re-checked since their original implementation) found no divergence — both confirmed to match casacore's scaleOnGet/scaleOnPut formulas exactly, element layout included.

A genuine, previously-undocumented casacore asymmetry, found while re-reading CompressFloat.cc: CompressComplex::scaleOnPut explicitly clamps each part to ±32767 before casting to short (CompressComplex.cc:274-320); CompressFloat::scaleOnPut (CompressFloat.cc:274-296) has no clamp at all — a raw short(...) cast, undefined behaviour in C++ for an out-of-range float (no single "correct" value across platforms/compilers to replicate). This package's own _encode(::CompressFloat,...) clamps anyway, reusing CompressComplex's own clamp constant — a deliberate, safe, bounded divergence (never crashes, never silently produces an arbitrary platform-specific value) rather than a bug to "fix" by removing the clamp. Documented in place with a citation; new permanent regression test confirms an out-of-range value clamps to exactly ±32767 and never crashes (deliberately not cross-checked against real Casacore.jl — feeding an out-of-range value through the real C++ library would itself be exercising UB, not something to rely on as ground truth).

A mathematically-provable dead branch, not merely untested: _decode(::CompressComplexSD,...)'s r == -ENG_C_WRAP → NaN check (inside the odd branch) can never actually fire — the only integer v for which div(v, 65536) == -32768 is v == ENG_NAN_C itself, which is even and is therefore always caught by the iseven(v) dispatch before ever reaching this branch (confirmed computationally: any v one step less negative than ENG_NAN_C already truncates to -32767, not -32768). Real casacore's own CompressComplexSD::scaleOnGet (CompressComplex.cc:750-756) has the identical structure — this package's branch faithfully mirrors an equally-dead branch in upstream casacore itself, not a bug, and no test can exercise it (there is no reachable input). Documented in place; nothing to test.

Coverage gaps closed with permanent tests (each live-verified first):

  • TiledCellStMan's shared-group per-row shape-mismatch validation (write_tiledcellstman) — a real, easily-reachable user-input validation error with zero prior test coverage.
  • VirtualTaQLColumn's non-default-TaQL-style warning (_vtq_prepare!, real casacore can write e.g. "python" for 0-based array indexing, which TaQL-lite has no concept of) — hand-set on a real, opened instance (this package's own writer always writes "").
  • Dysco's AF-normalization dead-antenna snap-to-zero rule (_af_calculate_antenna_rms, rmsPerAntenna[i] < maxVal*1e-5 -> 0) — every existing AF test fixture gives every antenna real signal; a genuinely dead antenna (zero amplitude on every baseline it participates in — a realistic broken/flagged-antenna scenario) is the reachable, well-defined case, exercised via a direct unit call.

Investigated, still deprioritized (same reasoning as Phase 213, revisited but not chased further): _af_fit_to_maximum!'s two early-termination branches (deep inside the already CASA-cross-checked AF hill-climb; engineering a precise numeric scenario for these specific branches has a poor effort/payoff ratio relative to everything else found this sweep); SSMIndex/ISMIndex's internal bounds guards; SSMStringHandler's filled==0 case; tiled.jl's >2 GiB file-record branch (would need a multi-gigabyte fixture); Dysco's rowsPerBlock==0 guard (this package's own writer already rejects a 0-row write before it can be produced).

src/datamanagers/{forwardcol,datamanager,arrayfile}.jl are now fully covered (closed in Phase 213); virtual.jl is down to exactly the one provably-unreachable line above.

Full suite green (baseline 5065, +9 new = 5074/5074). README/memory updated, merge on the user's word.

Phase 215 — src/datamanagers sweep, continued: a dead/fabricated

fallback removed, plus a handful of confirmed-correct-but-untested paths closed

Another coverage-instrumented full-suite run + manual re-reading of every src/datamanagers/*.jl file, continuing where Phase 214 left off. arrayfile.jl, datamanager.jl, forwardcol.jl, virtualtaql.jl, container.jl (barring the expected HDF5-fallback stubs, overridden whenever import HDF5 loads the extension) all came back fully covered or down to already-documented, deliberately deprioritized lines; every remaining "0 never executed" line across standard.jl / incremental.jl / tiled.jl / virtual.jl / dysco.jl matched — line for line — something Phase 213 or 214 had already investigated and explicitly deprioritized (the SSMIndex/ISMIndex bounds guards, SSMStringHandler's filled==0 case, tiled.jl's >2 GiB file-record branch, _af_fit_to_maximum!'s two early- termination branches, Dysco's rowsPerBlock==0 guard, and — confirmed once more, still genuinely unreachable — CompressComplexSD's mathematically-dead r == -ENG_C_WRAP branch). src/datamanagers/ is, at this point, about as thoroughly line-covered as it is going to get without chasing fixtures that need multi-gigabyte files or platform- specific undefined behaviour to reproduce.

A real, if not crashing, finding: a fabricated fallback in ForwardColumnEngine's reader that corresponds to no real casacore convention. open(::Type{ForwardColumnEngine}, ...) has carried, unchanged since its original Phase 40 implementation, a fallback that looked up _ForwardColumn_TableName_$(dm.sequ) on the table's private keyword set whenever the primary lookup (vdesc.keywords ["_ForwardColumn_TableName"]) came back empty — with no source citation for it anywhere in the comments. Reading ForwardCol.cc::fillTableName/basePrepare directly (tables/DataMan/ForwardCol.cc:270-300,311-313) shows real casacore always defines this keyword on the column's own keyword set, under the literal name "_ForwardColumn_TableName" + enginePtr_p->suffix() — never on a table-level private slot, and never keyed by a data-manager sequence number. suffix() (ForwardCol.h:531-532,554-558) is only ever set to a non-empty value by ForwardColumnIndexedRowEngine:: setSuffix("_Row") (ForwardColRow.cc:46,60,70) — the sibling engine this package deliberately doesn't support (Phase 124) — so for the plain ForwardColumnEngine this file implements, the keyword name is always exactly "_ForwardColumn_TableName", unsuffixed, and never on t.desc.private. Confirmed via git log the fallback was speculative from the start (present in the very first commit, no citation, never touched since) and via grep that neither this package's own writer (create.jl, which only ever populates vdesc.keywords) nor any test ever puts anything there — a dm.sequ-suffixed private-keyword form was never a real convention to begin with. Removed the fallback and replaced the comment with the actual source citation; the primary lookup + error path are unchanged (and were already correctly tested).

Confirmed-correct-but-untested paths closed with permanent tests (each live-verified first, several against real Casacore.jl):

  • An SSM- or ISM-indirect (variable-shape) array cell that was never put — or is explicitly written as an empty array — decodes via the foff == 0 branch in standard.jl's getcell and incremental.jl's _ism_decode (a real casacore state, the same category as the TiledCellStMan null-cube state fixed in Phase 213), and is directly reachable through this package's own writer (isempty(v) ? Int64(0) : af_put!(...)). The SSM numeric-array case turned out to already be exercised abundantly by the real sample-MS fixture reads (the coverage counters showed the early-return firing over 100,000 times) — just never with an explicit, hand-built, Casacore.jl-cross-checked assertion pinning the exact behaviour. The ISM case, and both indirect-string-array variants (_read_string_array's total <= 0 branch), had zero prior coverage at all. New regression tests in test/indirect_tests.jl and test/ism_writer_tests.jl cover all four combinations.
  • encode_engine's kind isa ScaledKind && autoscale rejection (virtual.jl) — per-row auto-scale/offset only makes sense for the Compress* engines (a ScaledArrayEngine/ScaledComplexData column has one fixed scale/offset per column, by casacore's own design — there is no such thing as an autoscaled one); the guard exists and is correct, but nothing had ever actually passed the combination it rejects.
  • A fixed scale=0 for CompressFloat/CompressComplex (virtual.jl's _encode — distinct from the autoscale-all-NaN-row case the existing "autoScale" testset already covers) hits the same sc == 0 → NaN sentinel guard from a different direction and was likewise never tried. Live-verified it encodes straight to the NaN sentinel with no division-by-zero, for both engines.

Full suite green (baseline 5074, +35 new = 5109/5109). README/memory updated, merge on the user's word.

Phase 216 — src/beam/ sweep: a real, reachable PolynomialBeam

symmetry bug found live-verifying a documented-but-untested code path

Coverage-instrumented full-suite run + a fresh, skeptical re-derivation of every formula in src/beam/beam.jl (analytic primary-beam models — Phases 99/100/117) against textbook optics: GaussianBeam's HPBW/ frequency scaling, AiryBeam's annular-aperture closed form (_airy_voltage, re-checked against the quoted [2J₁(x)/x − ε²·2J₁(εx)/(εx)]/(1−ε²) formula term by term), _elliptical_gaussian_ power's position-angle projection (re-derived the major/minor-axis unit-vector dot products from scratch, including the pa=0/pa=π/2 boundary cases the existing tests already pin), and SquintBeam's centre-shift sign convention — all confirmed correct, no divergence found in any of them.

A real, confirmed bug: PolynomialBeam's power_response evaluates θ as x² (x = (freq/1e9)·rad2deg(θ)·60, only even powers of x ever appear in the fit) — an even function of θ, symmetric like every other beam model in the file — but its maxrad domain cutoff was one-sided: θ > b.maxrad && return 0.0. A negative θ beyond -maxrad skipped the cutoff entirely and let the raw polynomial run unclamped outside its fitted domain. Live-reproduced: for a beam with maxrad = 1°, power_response(p, -5°) returned 20235.9 — a "power" value wildly outside the type's own documented [0,1] range (the trailing max(p, 0.0) only floors negative results at 0, it never caps a positive runaway at 1), while the symmetric positive value (power_response(p, +5°)) correctly gave 0.0. Fixed by checking abs(θ) > b.maxrad instead — confirmed the fix restores the expected power_response(p,θ) == power_response(p,-θ) symmetry and a [0,1]-bounded result at every offset tried. Not reachable through mscal.pbresponse's own beam mini-language (Phase 101/103) — it has no "polynomial:..." spec form at all — so this was purely a direct-API usage-path bug, still a real one for any caller passing a signed angular offset (a foreseeable usage, since nothing in the API requires θ to be non-negative — GaussianBeam/AiryBeam handle a negative θ correctly by construction, so PolynomialBeam silently diverging from that pattern was a genuine, surprising asymmetry).

Confirmed-correct-but-untested path closed: the generic power_response(b::PrimaryBeam, offset::NTuple{2,Real}, freq) fallback (beam.jl's own mechanism letting any circular beam accept a 2-D (dlon, dlat) tangent-plane offset interchangeably with a scalar θ, via hypot(offset...)) is exercised end-to-end through the mscal.pbresponse/pbcorr/pbatten integration tests (Phases 101-103), but had no direct beam_tests.jl assertion pinning its exact behaviour against GaussianBeam/AiryBeam/PolynomialBeam themselves. New regression tests confirm it agrees with the scalar form bit-for-bit and composes correctly with voltage_response/attenuate/ correct_flux.

Full suite green (baseline 5109, +12 new = 5121/5121). README/memory updated, merge on the user's word.

Phase 217 — src/beam/ sweep, continued: confirmed fully covered,

no further issue found

A second coverage-instrumented full-suite run over src/beam/beam.jl (after Phase 216's fix + new tests), plus another independent, fresh- eyes re-derivation of the file's remaining math (the annular-aperture Airy formula re-checked term by term once more against the quoted closed form; every SquintBeam/EllipticalGaussianBeam composition path — squint-of-a-squint, squinting an ellipse, frequency scaling threaded through a squint wrapper — traced by hand; a systematic grep for every other θ-vs-bound comparison in the file, to check for any sibling of Phase 216's one-sided PolynomialBeam cutoff bug — found none: abs(θ) > b.maxrad is now the file's only such comparison).

Result: src/beam/beam.jl is now down to zero never-executed lines (354/354, up from the handful of untested branches the Phase 216 fix + tests closed) — genuinely fully covered, not merely "down to a provably-unreachable line" the way src/datamanagers/ settled. No new bug found; this phase is a confirming pass, not a fix, matching this project's own precedent for investigation-only sweeps (e.g. Phases 116, 118, 124, 134) where "thoroughly checked, nothing further to find" is itself the useful, documented outcome.

Full suite green: 5121/5121 (unchanged — no code or test changes this phase). README/memory updated, merge on the user's word.

Phase 218 — src/measures/ sweep: a real silent-truncation bug

found, a misleading (but upstream-matching) error message fixed, and a live cross-check of the bundled Observatories table

The project's first dedicated sweep of src/measures/ (types.jl, measinfo.jl, read.jl, write.jl, doppler.jl, ephemeris.jl, observatories.jl, earthfield.jl/igrf14_data.jl, emmachine.jl — Phases 66/71/72/74-76/82/88/90-93/96) — a coverage-instrumented full-suite run plus a fresh-eyes read of every file, prioritising the parts that had had the least independent re-verification since their original implementation (the write side, the MeasInfo/VarRefCol plumbing, the ephemeris table reader, the bundled static data), rather than re-deriving ext/SOFAExt.jl's core conversion math yet again (already independently re-checked across roughly 20 prior phases — 66 through 217 — with no further divergence expected from another blind re-read).

A real bug, fixed: read.jl's _scalar (the shared scalar-cell reader behind :epoch/:frequency/:radialvelocity/:doppler measure decoding) was _scalar(v::AbstractArray) = length(v) == 1 ? first(v) : first(v) — a dead ternary with identical branches, meaning it always just returned first(v) regardless of the array's actual length, with no validation at all. For :frequency/:radialvelocity/:doppler this never mattered (_wrap_measure already branches on length(v) != 1 before ever reaching _scalar), but :epoch calls it unconditionally. Live-reproduced: measure(t, "T", row) on an :epoch-MEASINFO column whose cell was a length-3 array (reachable through this package's own write_table(...; measures=Dict(...))) silently returned an MEpoch built from only the first of three elements, discarding the rest with no warning whatsoever — precisely the "a wrong-but-plausible answer is worse than an error" failure shape measconvert's own _all_finite guard (Phase 195) already states as a design principle for this exact file, just via a different mechanism. Fixed to validate length(v) == 1 and throw a clear ArgumentError otherwise; confirmed both the genuine scalar-epoch path and the array-valued frequency/ radial-velocity path (which never reaches the changed branch) are unaffected.

A misleading-but-upstream-matching message fixed, not the behaviour: ephemeris.jl's _ephem_bracket throws when a query MJD falls outside the table's sampled range — but live-reproduced that querying exactly the table's last sampled MJD always throws, even though the error message itself claimed that exact value was within the covered range ("table covers 60000.0 .. 60004.0" when 60004.0 — the very value in the message — is what triggers the error). Read casacore's own MeasComet::fillMeas (measures/Measures/MeasComet.cc:405-427) directly: it has the identical formula and the identical ut >= nrow-1 bound — this is a faithful match to a real, structural limitation in upstream itself (interpolation always needs a bracketing pair of rows, and the last row has none after it), not a divergence to "fix" by changing the behaviour. Only the message was wrong — it described the valid range as closed when it is actually half-open. Fixed the wording; added a permanent regression test pinning the exact boundary (last sample throws, one epsilon before it doesn't, the first sample is fine).

A live cross-check, no bug found: independently spot-checked 14 entries of the bundled _OBSERVATORIES table (observatories.jl, Phase 90) against a fresh casatools.measures().observatory(name) query on this machine's real CASA install — including the two suspicious-looking near-duplicate pairs ("IRAM PDB" vs "IRAM_PDB", "GB" vs "GBT" vs "NRAO_GBT") that looked at first glance like they might be transcription errors. All 14 matched to sub-millimetre precision once the check correctly distinguished CASA's two different position representations (refer: "ITRF" returns geocentric spherical (lon, lat, radius); refer: "WGS84" returns geodetic (lon, lat, height-above-ellipsoid), needing the real WGS84 ellipsoid formula, not a sphere) — an initial naive spherical-only conversion gave wildly wrong values for the WGS84 entries and looked like a real bug for a few minutes, until re-doing the check with the correct per-entry formula resolved it cleanly. The near-duplicate pairs are genuine, independently real casacore Observatories-table entries (both distinct sites/reference points, not typos).

Full suite green: 5121 baseline + 8 new = 5129/5129. README/memory updated, merge on the user's word.

Phase 219 — src/measures/ sweep, continued: a real bug in the

ephemeris pointing-offset formula, and a coverage-gap closure across measinfo.jl/read.jl

Continuing the Phase 218 sweep with fresh eyes over the rest of src/measures/ (types.jl, measinfo.jl, write.jl, doppler.jl, observatories.jl, earthfield.jl, emmachine.jl) and the measures- relevant parts of ext/SOFAExt.jl.

A real bug, fixed: ephemeris.jl's _ephem_shift — the function that applies a moving-target FIELD's static PHASE_DIR pointing offset to the ephemeris-derived body direction — carried a comment claiming it was casacore's MVDirection::shift(offset, True), but the code was actually only a small-angle tangent-plane approximation of it (lon + dlon/cos(lat+dlat), lat+dlat). Traced the real call site (ms/MeasurementSets/MSFieldColumns.cc:480, mvxdir.shift(offsetDir.getAngle(), True)) down through MVDirection::shift(const MVDirection&, Bool) → shift(Double lng, Double lat, Bool trueAngle) (casa/Quanta/MVDirection.cc:308-343): the real "true angle" shift is a 3-rotation composition — with R = Rz(-dlon)·Ry(lat+dlat)·Rz(-lon), the result is the first row of R applied to the unit pole (1,0,0) (per MVPosition::operator*=(RotMatrix), casa/Quanta/MVPosition.cc:278-285, inherited by MVDirection). Independently re-derived this from the quoted C++ operator*/operator*= definitions in a from-scratch throwaway script and confirmed numerically: the two formulas agree to < 1 mas for a realistic pointing-offset magnitude (arcsec-to-arcmin — the ephemeris table's own UTC~TDB coarseness already swamps that), but diverge by ~0.1-1″ within about a degree of the celestial pole. Fixed _ephem_shift to the exact rotation formula (three small pure-arithmetic helpers, no new dependency — this file stays SOFA-free); a coverage check afterward confirmed the existing test suite had only ever exercised this function with an all-zero offset (every fixture used PHASE_DIR => [[0.0, 0.0]]), so the actual shift logic had zero prior test coverage at all. Added a dedicated testset pinning the fix against the independent from-scratch reference (both a realistic small offset and the near-pole divergent case) plus an end-to-end measure() round-trip through a genuinely nonzero-offset moving-target FIELD.

A coverage-gap closure: a coverage-instrumented run of the full suite turned up several measinfo.jl/read.jl branches with zero prior test coverage — every existing fixture in this suite happened to always take the opposite path:

  • measinfo.jl's _ref_from_code fixed-casacore-enum-order fallback (a VarRefCol column with no TabRefCodes/TabRefTypes map at all — every other VarRefCol fixture in this suite supplies one explicitly).
  • measinfo.jl's _measinfo_record "give ref or varrefcol" error.
  • read.jl's whole-column measure(t, col) VarRefCol path (every other whole-column measure() call in this suite is on a fixed-Ref column).
  • read.jl's entire :radialvelocity branch of _wrap_measure — both the scalar and array-cell forms — meaning measure() had never been called on a genuine on-disk :radialvelocity-kind MEASINFO column anywhere in this suite; every existing radial-velocity test constructs MRadialVelocity directly.
  • read.jl's _wrap_measure fallback for an unrecognised MEASINFO type (confirmed no write-side validation rejects an arbitrary kind — it only ever surfaces on read).
  • read.jl's _scalar's legitimate length-1-array return — distinct from Phase 218's length-3 error case, which was the only one this suite exercised until now.
  • read.jl's _lonlat's 3-element unit-direction-vector cell form (distinct from the [lon,lat]-pair and (2,npoly)-matrix forms every other direction test uses) and its fallback error for an unsupported cell length.

None of these turned out to hide a further bug — each was live-verified correct before being pinned with a permanent test (the _ref_from_code enum-order fallback and the _lonlat 3-vector unit-vector decode were both checked against hand-derived expected values). 20 new assertions.

Full suite green: 5129 baseline + 30 new (10 + 20) = 5159/5159. README/memory updated, merge on the user's word.

Phase 220 — src/measures/ sweep, continued: _eop_lookup's

documented "falls back to zeros" fallback had never actually fired

Continued the sweep into ext/EarthOrientationExt.jl (the IERS Earth-orientation feed for ext/SOFAExt.jl's ΔUT1 / polar-motion lookups), the one measures-adjacent file the Phase 218/219 sweeps hadn't yet examined line by line.

A real bug, fixed: _eop_lookup's own docstring promises it "falls back to zeros (with one warning) if the table has no coverage for the date." The three EarthOrientation.jl calls it makes (getΔUT1/getxp/getyp) all passed outside_range=:nothing, which — live-verified by reading EarthOrientation.jl's interpolate function directly, then confirming with a live probe — does not mean "return nothing." It means "skip the warn/error and keep going," i.e. silently return an Akima-spline extrapolation past the table's actual covered range, with zero indication anything was off. Live-reproduced: a UTC MJD corresponding to a date past the IERS finals2000A table's current forward bound (~2027-09-25 at investigation time — and creeping forward every day, so this will soon start silently affecting ordinary near-future/simulated- observation epochs, not just deliberately-contrived test dates) returned a real, never-warned, silently-extrapolated xp/yp/dut1 triple instead of ever reaching the documented zero-fallback — the catch block below had, until now, only ever been reached by the Phase 192-195 non-finite-input case, never by a genuinely out-of-coverage (but otherwise well-formed) date. Fixed by switching to outside_range=:error, which — confirmed directly against interpolate's own :error branch, and live-verified with a probe — genuinely raises EarthOrientation.OutOfRangeError for an out-of-coverage date, caught by the existing try/catch exactly as the docstring always claimed. Confirmed numerically that an in-range date (a 2024 epoch, well within real IERS coverage) is completely unaffected — same values before and after.

New testset covering all three cases: an in-range date returns real, physically-plausible-magnitude values (not the zero fallback); a far-future MJD (~year 4500, robust against the table's forward bound creeping forward over time) falls back to exactly (0.0, 0.0, 0.0); a far-past MJD (before the IERS series even starts, ~1962) does too. 6 new assertions.

Full suite green: 5159 baseline + 6 new = 5165/5165. README/memory updated, merge on the user's word.

Phase 221 — src/measures/ sweep, continued: addcolumn! and

write_table disagreed on whether to stamp an empty QuantumUnits keyword

Continued the sweep, this time tracing the src/measures/write.jl (_measure_column_spec) integration seam into both of its two call sites — src/tables/create.jl's write_table and src/tables/ edit.jl's addcolumn! — rather than re-reading src/measures/ itself again (the bundled igrf14_data.jl table's structural shape was also spot-checked: 26 five-year epochs × 195 Schmidt coefficients each, matching the documented degree-13 spherical-harmonic count, both _IGRF_COEF and _IGRF_DCOEF).

A real bug, fixed: edit.jl's _addcol_desc (the shared helper behind both addcolumn!(::EditTable, ...) and addcolumn!(::RefEditTable, ...), Phase 126) unconditionally stamped a QuantumUnits keyword whenever the added column was Measure-typed — even when the measure kind is dimensionless (MDoppler, whose _measure_column_spec reports units = String[]). create.jl's write_table path (_stamp_measinfo) already has the correct guard (if !isempty(units)) for exactly this case, so the two entry points diverged for identical input: live-reproduced, write_table(dir,"T",["D"=>[MDoppler{RADIO}(...)]];nrow=...) correctly wrote no QuantumUnits keyword at all, while edit(dir) do t; addcolumn!(t,"D",[MDoppler{RADIO}(...)]); end unconditionally wrote an empty QuantumUnits = String[]. Fixed by replicating _stamp_measinfo's !isempty guard in _addcol_desc (the _quantity_column_spec/Unitful branch didn't need the same fix — its units vector is never empty, always at least [""]/["<unit>"]). New regression testset confirms: the Doppler case now correctly omits the keyword through addcolumn!; a non-dimensionless kind (MEpoch) is unaffected; and write_table's own (already-correct) behaviour for the identical data is unchanged, so the two entry points now genuinely agree. 5 new assertions.

Full suite green: 5165 baseline + 5 new = 5170/5170. README/memory updated, merge on the user's word.

Phase 222 — src/measures/ sweep, continued: an out-of-range

Doppler value crashed with a raw DomainError instead of a clear message

Continued the sweep by re-deriving the measconvert-adjacent numeric paths that hadn't yet been probed with genuinely out-of-physical-range inputs (_all_finite's NaN/Inf guard in measconvert was already heavily tested; the untested gap was a finite-but-unphysical value).

A real bug, fixed: doppler.jl's _dop_ratio(BETA, D) = sqrt((1-D)/(1+D)) and _dop_ratio(GAMMA, D) = D*(1-sqrt(1-1/(D*D))) both go negative under the radical for an out-of-physical-range input — |D| > 1 for BETA (faster than light) or |D| < 1 for GAMMA (a Lorentz factor below its physical minimum of 1, at rest). Live- reproduced: measconvert(MDoppler{BETA}(1.5), GAMMA) crashed with a raw, unhelpful DomainError from deep inside sqrt; even a merely noisy near-rest value, MDoppler{GAMMA}(0.9999) — entirely plausible after a chain of floating-point conversions, not a deliberately malformed input — crashed identically. Real casacore's C++ std::sqrt of a negative double quietly returns NaN rather than throwing, but this package's own measures/ subsystem already has an established, stronger convention for exactly this shape of problem (measconvert's own _all_finite guard, Phase 195: a physically- meaningless result is worse than a clear early error) — so a raw, unexplained crash gets the same treatment, not silently downgraded to a NaN either. Fixed both functions to validate their domain and throw a clear ArgumentError naming the actual out-of-range value; the physical boundary itself (|D| == 1 for either convention — an infinite/zero Doppler shift at exactly the speed of light for BETA, exactly at rest for GAMMA) is not an error, only strictly beyond it is — confirmed both boundary values and every in-domain value are completely unaffected by the new guard. 10 new assertions.

Full suite green: 5170 baseline + 10 new = 5180/5180. README/memory updated, merge on the user's word.

Phase 223 — src/measures/ sweep, continued: Phase 222's own fix

had a gap — the Doppler crash was still reachable through the frequency/velocity bridge functions

Continued the sweep by systematically re-checking every sqrt/acos/ asin/log call across src/measures/*.jl, ext/SOFAExt.jl and ext/EarthOrientationExt.jl for a domain-violation risk like Phase 222's — most were already clamp-guarded (asin(clamp(·,-1,1)) appears throughout the direction/position code) or mathematically safe given their inputs (ext/SOFAExt.jl's frequency/radial-velocity relativistic-aberration sqrts only ever see a β built from bounded physical velocity constants divided by c, never raw user input). That systematic check turned up one genuine remaining gap, directly adjacent to what Phase 222 had just fixed.

A real bug, fixed: Phase 222 added a domain guard to _dop_ratio for the BETA/GAMMA Doppler conventions — but _beta_factor (the shared helper behind shiftfreq/frequency/restfrequency) and radialvelocity(d::MDoppler) extracted d's value via measconvert(d, BETA).d, and measconvert(m::MDoppler{C}, ::Type{D}) has its own C === D ? m : ... short-circuit — a legitimate no-op passthrough everywhere else in the codebase, but one that means d's own value never reaches _dop_ratio's Phase 222 check at all when d already happens to be stored in BETA convention. Live- reproduced: shiftfreq(MDoppler{BETA}(1.5), 1.4e9) — a public, exported, documented function — still crashed with the exact same raw DomainError Phase 222 was meant to close. Fixed by adding _beta_value(d::MDoppler{C}) where {C} = _ratio_dop(BETA, _dop_ratio(C, d.d)), which routes through _dop_ratio unconditionally regardless of C, and using it in both _beta_factor and radialvelocity. Proven mathematically safe beyond just the tested cases: _ratio_dop(BETA, F) = (1-F²)/(1+F²) is bounded in (-1, 1] for any real F (its denominator 1+F² is never zero), so once _beta_value hasn't thrown, the subsequent sqrt((1-β)/(1+β)) in _beta_factor is unconditionally safe — not just for the specific values this phase's tests happen to exercise. Confirmed all four bridge functions (shiftfreq/frequency/restfrequency/ radialvelocity) now throw the same clear error for an out-of-domain MDoppler{BETA}, and that in-domain values (including one reached via a different source convention, GAMMA, to prove the fix doesn't special-case the failing scenario) give identical, correct results through all of them. 6 new assertions.

Full suite green: 5180 baseline + 6 new = 5186/5186. README/memory updated, merge on the user's word.

Phase 224 — src/measures/ sweep, continued: doppler(v::MRadialVelocity)

had no domain check at all, and MDoppler's measconvert bypassed the package's own NaN/Inf guard entirely

Continued the sweep with a fresh re-read of every file in src/measures/ not yet re-checked this cycle. _FRAME_STRING was independently re-verified complete against every named RefFrame/ DopplerType singleton in types.jl (42 entries, exact match — no gap); ephemeris.jl, emmachine.jl, earthfield.jl, measinfo.jl and observatories.jl were all re-read and found consistent with their own prior, extensively-verified fixes (Phases 82/91-93/134-135/ 144/218-219). One already-investigated lead (emm_lineofsight's sqrt(an*an+subl) for a pathological negative shell height) was confirmed still correctly deprioritized, not re-chased.

Two real bugs, fixed, both in doppler.jl:

  1. doppler(v::MRadialVelocity) used to construct MDoppler{BETA}(v.mps / C_LIGHT) directly, with no validation at all — unlike its sibling doppler(f::MFrequency, restfreq), whose t = (f/restfreq)^2 ≥ 0 provably keeps (1-t)/(1+t) in (-1, 1] for any finite input and so needs none. v.mps has no such bound. Live-reproduced: doppler(MRadialVelocity{LSRK}(4e8)) (superluminal, > c) silently succeeded, returning an MDoppler{BETA} with |d.d| > 1 — a physically-meaningless value that then only crashed (with the Phase 222 message) the next time anyone tried to measconvert/radialvelocity/frequency/ shiftfreq it, not at the point the bad input was actually given. Fixed by factoring _dop_ratio(::Type{BETA}, ·)'s own domain check out into a shared _check_beta_domain, reused by doppler before constructing the value — the physical boundary itself (|v| == c) is still not an error, only strictly beyond it is.

  2. MDoppler's own measconvert dispatches on DopplerType, not RefFrame — a completely separate method from the generic Measure -> RefFrame one in types.jl that validates _all_finite on its input before converting (Phase 195). It never went through that guard at all. Live-reproduced: measconvert(MDoppler{RADIO} (NaN), OPTICAL) silently returned MDoppler{OPTICAL}(NaN), while the identical NaN input to e.g. measconvert(MEpoch{UTC}(NaN), TAI) correctly throws a clear ArgumentError — the one measure type with a domain-sensitive conversion (Phase 222/223's own BETA/GAMMA sqrt) was also the one measure type where a non-finite input could slip through silently instead of erroring. Fixed with the same isfinite check every other measure conversion already has, raising the identical message style; the C === D short-circuit still skips it, matching the generic version's own identical reftype(m) === R && return m early return — a genuine no-op needs no validation either way.

Both fixes verified live (boundary values |v| == c / same-convention NaN passthrough unaffected; ordinary round-trips unaffected) before being pinned with permanent tests. 9 new assertions.

Full suite green: 5186 baseline + 9 new = 5195/5195. README/memory updated, merge on the user's word.

Phase 225 — src/measures/ sweep, continued: a permanently uncovered

"SOFA loaded, EarthOrientation not loaded" fallback path, finally pinned with a real cross-process test

A comprehensive fresh re-read of every remaining unswept corner — ext/EarthOrientationExt.jl, the epoch/direction/_frame_site/body- resolution sections of ext/SOFAExt.jl not covered by Phase 224's own Doppler-focused pass, and igrf14_data.jl's bundled coefficient table — found no further bug. Two candidate leads were investigated and ruled out as already-settled or non-reachable: _riseset's acos/division at an exact celestial-pole declination (live-tested at dec = ±90° — real precession perturbs the converted apparent position just enough that the exact singularity never actually manifests; the same class of double-degenerate coincidence this project has already declined to chase elsewhere), and _measure_column_spec's "mixed-convention Vector{MDoppler}" case (confirmed to be the same, already-documented, already-accepted "stores every row under the first row's frame/ convention" limitation Phase 70 established for every other measure kind, not a new gap). igrf14_data.jl was independently re-verified structurally sound (26 epochs × 195 coefficients; the first 25 _IGRF_DCOEF rows exactly equal (COEF[i+1]-COEF[i])/5, confirming they really are generated per-year interpolation rates and not a transcription error, with the 26th correctly holding the distinct published 2025–2030 secular-variation values).

A genuine, permanent coverage gap, closed: a coverage-instrumented run showed src/measures/ itself was now 100% line-covered, but turned up 12 never-executed lines in ext/SOFAExt.jl — 8 are "frame … is not supported" fallbacks for the enumerated dispatch chains (unreachable by construction — every RefFrame this package defines already has a handled branch) and were left alone, but the remaining 4 are _eop's ext === nothing branch: the fallback for "SOFA is loaded but EarthOrientation is not" (ΔUT1 = 0, no polar motion, a one-time @warn). This had never been exercised by any test in this suite's history — test/runtests.jl's harness always imports both SOFA and EarthOrientation together (asserted explicitly at the top of measures_tests.jl), and once EarthOrientationExt loads for a Julia process it stays loaded for that process's entire lifetime, so the gap genuinely could not be closed by adding a testset to the existing file. Live-verified the fallback is correctly implemented (exactly one warning on first use, none on a repeat call, ΔUT1 = 0 gives UT1.mjd == UTC.mjd bit-for-bit, and the AZEL result agrees with the EOP-accurate value to within the documented ~1″ tolerance) via a real child process with only SOFA imported — reusing lock_tests.jl's _JULIA/_PROJ cross-process machinery (already in scope, included earlier in runtests.jl) rather than inventing a new mechanism. 3 new assertions.

Full suite green: 5195 baseline + 3 new = 5198/5198. README/memory updated, merge on the user's word.

newer entries →