Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/constitution.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ jobs:
with:
python-version: "3.12"

- run: pip install pytest
# numpy + astropy added by M3/E5/#17: the SoLEXS products are FITS, and the parsers'
# unit tests build real FITS fixtures to fire each fail-loud rule against. Without
# these the whole parser suite would error at import rather than run.
- run: pip install pytest numpy astropy

# `domain` added by M2/E4/#12 and `contexts` by M3/E5/#15, each placing its unit tests
# beside the code they test. Without the path here those tests would exist and never
Expand Down Expand Up @@ -134,7 +137,9 @@ jobs:
# domain types against the normative contracts (ADR-0019), which cannot be done
# without a validator. The test skips rather than fails if the library is absent, so
# omitting this would have turned a cross-component check into a silent skip.
- run: pip install pytest jsonschema
# astropy + numpy added by M3/E5/#17 for the same reason as the unit job; jsonschema
# was added by #12 to validate the domain against the normative contracts.
- run: pip install pytest jsonschema numpy astropy

- name: Integration tests
run: pytest tests/integration -q -rs
8 changes: 8 additions & 0 deletions contexts/ingest/parsers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Instrument parsers — canonicalise an acquired product into domain objects.

`parsers/solexs` is delivered by M3/E5/#17. `parsers/hel1os` is #18 and does not exist yet.

Acquisition and canonicalisation are one context with two internal module groups (ADR-0026),
with the seam preserved as directory boundaries because it is free under ADR-0025. There is
no parser registry and no dispatch: a caller selects the module for the product it holds.
"""
17 changes: 17 additions & 0 deletions contexts/ingest/parsers/solexs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""SoLEXS parsers — `.lc`, `.pi` and `.gti` (M3/E5/#17).

Each implements its section of `SPEC-parsers@r6`, which is a **contract**: a deviation
requires a logged amendment, not a code change.

lc §2.1 total-band counts per second. NaN is data, not an error.
pi §2.2 340-channel PI spectra per second. Ordinal channels; keV is prohibited.
gti §2.3 inclusive second-marks; Σ(STOP−START+1) == EXPOSURE, exactly.

`.hk` (§2.4, v1.1 only, 5 of 436 days) is NOT implemented here: TIS E5 §5 lists this epic's
SoLEXS modules as `parsers/solexs/{lc,pi,gti}` and assigns no `.hk` module to any issue. The
gap is reported in the Issue #17 completion record rather than filled by guessing at scope.
"""

from contexts.ingest.parsers.solexs import gti, lc, pi

__all__ = ["gti", "lc", "pi"]
96 changes: 96 additions & 0 deletions contexts/ingest/parsers/solexs/_fits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Strict FITS access. Every lookup fails loud; none has a default.

`SPEC-parsers@r6` §5 opens with the rule this module exists to make unbreakable:

> **No default-on-missing-key** (`header.get(K, default)` is banned for any physically
> meaningful key — that idiom is the direct cause of v1's thirty-sprint failure).

The concrete failure was F-05: v1 defaulted `MJDREF` to 58484 where the archive declares
40587, producing a **~49-year** timestamp error that nothing detected. A default is not a
convenience — it is a fabricated measurement wearing the shape of a real one.

So there is no `get` here. `keyword()` raises. `column()` raises. A caller who wants a
tolerated absence must say so at the call site, in one place, with a reason — as `.hk`
absence is tolerated for v1.0 archives (§2.4) and an empty GTI is tolerated for SDD1 (F-12).

Three parsers use this (`lc`, `pi`, `gti`), which satisfies STD-11's two-instance rule; it
exists to centralise the ban rather than to anticipate a fourth.
"""

from __future__ import annotations

from typing import Any

from domain.errors import ContractViolation


def fail(rule: str, pointer: str, message: str) -> None:
"""Terminate with a diagnostic naming file, HDU and expectation (§5).

`ContractViolation` is the TIS §0.2 class for *"input fails its schema or an invariant"*.
The F-rule identifier is carried in the message rather than in a new exception type,
because §0.2 permits exactly five failure classes and no others.
"""
raise ContractViolation(pointer, f"{rule}: {message}")


def hdu(hdul: Any, name: str, *, source: str) -> Any:
"""Fetch an HDU **by name**. F-02: HDU order is not a contract.

v1 read HDUs positionally. The archive's ordering happens to be stable, which is exactly
what makes a positional read dangerous: it works until it does not, and then it reads a
different HDU rather than failing.
"""
try:
return hdul[name]
except KeyError:
present = [x.name for x in hdul]
fail("F-02", f"/{source}", f"HDU {name!r} absent; present by name: {present}")


def keyword(header: Any, key: str, *, source: str, hdu_name: str) -> Any:
"""Fetch a header keyword. Absent is F-04/F-05 territory, never a default."""
if key not in header:
fail(
"F-05",
f"/{source}#{hdu_name}/{key}",
f"keyword {key!r} absent from HDU {hdu_name!r}. A default here is how v1 "
f"produced a ~49-year timestamp error (§5).",
)
return header[key]


def expect(
header: Any, key: str, expected: Any, *, source: str, hdu_name: str, rule: str
) -> Any:
"""Fetch a keyword and require an exact value."""
actual = keyword(header, key, source=source, hdu_name=hdu_name)
if actual != expected:
fail(
rule,
f"/{source}#{hdu_name}/{key}",
f"{key} is {actual!r}, expected {expected!r}",
)
return actual


def column(table: Any, name: str, *, source: str, hdu_name: str) -> Any:
"""Fetch a column by name, case-insensitively. F-04.

Case-insensitive because FITS column names are not case-sensitive by the standard, and a
case difference is a spelling of the same column rather than a different one. Absence is
still fatal: *"v1 sought `RATE`; real is `COUNTS`"* is F-04's stated origin, and a parser
that silently produced no column would have reproduced it.
"""
wanted = name.upper()
for candidate in table.columns.names:
if candidate.upper() == wanted:
return table.data[candidate]
fail(
"F-04",
f"/{source}#{hdu_name}/{name}",
f"column {name!r} absent; present: {list(table.columns.names)}",
)


__all__ = ["column", "expect", "fail", "hdu", "keyword"]
Loading
Loading