022a(core): almanac loader — Yuma/SEM parsers, visibility, cached fetch #38

Merged
ykp merged 1 commit from feat/022a-almanac-core into main 2026-09-29 21:45:40 +02:00
Owner

Implements the almanac core (step 022a) so satellite selection can use "what's actually in the sky" instead of raw ICD ranges. Per the owner's direction, the 022 items are not tracked in the backlog index — the work order (with outcome) lives directly in docs/done/022a-almanac-core.md.

Scope (verified live 2026-09-29)

  • GPS: CelesTrak Yuma and SEM almanacs — fetched and inspected live (week 390). The only machine-readable public GNSS almanacs (PRN, health, orbital elements). Field conventions verified across both formats: SEM angles in semicircles, inclination as an offset from 0.30 semicircles ((0.30 + 4.4117e-3)·π = 0.956338 matches Yuma's radians exactly); SEM record = 14 tokens (PRN, SVN, URA, ecc, incl, RA-rate, sqrtA, Ω0, argP, M0, af0, af1, health, config).
  • GLONASS: RSA IAC status page is JS-rendered behind an authenticated API — no public machine-readable almanac. Handled via the JSON interchange format + the nominal 24-slot registry table as fallback; scrapers explicitly out of scope.
  • Galileo/BeiDou: no public open-service almanac with PRN↔SV mappings — the 021 ICD ranges remain their source of truth.

New code

src/gengnss/core/almanac.py (headless, stdlib only):

  • parse_yuma / parse_sem → AlmanacData (tolerant: garbled blocks/tokens skipped, 3-per-line and 1-per-line SEM layouts, ValueError only when nothing parses)
  • parse_almanac_text — content-sniffing dispatch (JSON { / Yuma / SEM)
  • AlmanacData.to_json/from_json — round-trip-safe interchange format for GLONASS/hand-curated data
  • nominal_almanac(system) — full ICD constellation from the registry (documented "ICD ranges, not live status")
  • fetch_almanac(url, cache_dir, max_age_h, opener=…) — injectable opener, 4 attempts with backoff, atomic staleness-checked JSON cache (GENGNSS_CACHE_DIR override)
  • gps_week_epoch / almanac_reference_epoch — mod-1024 week-rollover resolution

src/gengnss/core/visibility.py:

  • ICD-GPS-240 propagation: Kepler solve (Newton), argument of latitude, Earth-fixed node longitude Ω(t) = Ω0 + (Ω̇−ωe)·tk − ωe·ToA with tk = t−ToA (Yuma/gpstk convention — the first draft used the week-epoch reference and was corrected by the geometry tests)
  • subpoint, look_angles (spherical-observer ENU), `visible_satellites(lat, lon, when, min_elevation)

No new runtime dependencies.

Tests (tests/test_almanac.py, network-free — fixtures mirror the live formats)

14 tests: Yuma/SEM parse + cross-format element agreement (±1e-6 relative on the same week); truncated/binary input tolerance; JSON round trip incl. GLONASS channels; closed-form subpoint geometry at ToA (±0.5°); observer-at-subpoint → >80° elevation; synthetic 31-sat walker visibility bounds (5–13 visible at mid-latitudes, unhealthy never visible, |lat| ≤ 55.5°); week-rollover resolution; cache reuse/staleness/offline-error behavior.

ruff clean; pytest: 223 passed, 8 skipped. No backlog-index changes (022 untracked by owner decision).

Implements the **almanac core** (step 022a) so satellite selection can use "what's actually in the sky" instead of raw ICD ranges. Per the owner's direction, the 022 items are **not tracked in the backlog index** — the work order (with outcome) lives directly in `docs/done/022a-almanac-core.md`. ## Scope (verified live 2026-09-29) - **GPS**: CelesTrak **Yuma** and **SEM** almanacs — fetched and inspected live (week 390). The only machine-readable public GNSS almanacs (PRN, health, orbital elements). Field conventions verified across both formats: SEM angles in semicircles, inclination as an offset from 0.30 semicircles (`(0.30 + 4.4117e-3)·π = 0.956338` matches Yuma's radians exactly); SEM record = 14 tokens (PRN, SVN, URA, ecc, incl, RA-rate, sqrtA, Ω0, argP, M0, af0, af1, health, config). - **GLONASS**: RSA IAC status page is JS-rendered behind an authenticated API — no public machine-readable almanac. Handled via the **JSON interchange format** + the nominal 24-slot registry table as fallback; scrapers explicitly out of scope. - **Galileo/BeiDou**: no public open-service almanac with PRN↔SV mappings — the 021 ICD ranges remain their source of truth. ## New code **`src/gengnss/core/almanac.py`** (headless, stdlib only): - `parse_yuma` / `parse_sem` → `AlmanacData` (tolerant: garbled blocks/tokens skipped, 3-per-line and 1-per-line SEM layouts, `ValueError` only when nothing parses) - `parse_almanac_text` — content-sniffing dispatch (JSON `{` / Yuma / SEM) - `AlmanacData.to_json`/`from_json` — round-trip-safe interchange format for GLONASS/hand-curated data - `nominal_almanac(system)` — full ICD constellation from the registry (documented "ICD ranges, not live status") - `fetch_almanac(url, cache_dir, max_age_h, opener=…)` — injectable opener, 4 attempts with backoff, atomic staleness-checked JSON cache (`GENGNSS_CACHE_DIR` override) - `gps_week_epoch` / `almanac_reference_epoch` — mod-1024 week-rollover resolution **`src/gengnss/core/visibility.py`**: - ICD-GPS-240 propagation: Kepler solve (Newton), argument of latitude, Earth-fixed node longitude `Ω(t) = Ω0 + (Ω̇−ωe)·tk − ωe·ToA` with `tk = t−ToA` (Yuma/gpstk convention — the first draft used the week-epoch reference and was corrected by the geometry tests) - `subpoint`, `look_angles` (spherical-observer ENU), `visible_satellites(lat, lon, when, min_elevation) No new runtime dependencies. ## Tests (`tests/test_almanac.py`, network-free — fixtures mirror the live formats) 14 tests: Yuma/SEM parse + **cross-format element agreement** (±1e-6 relative on the same week); truncated/binary input tolerance; JSON round trip incl. GLONASS channels; closed-form subpoint geometry at ToA (±0.5°); observer-at-subpoint → >80° elevation; synthetic 31-sat walker visibility bounds (5–13 visible at mid-latitudes, unhealthy never visible, |lat| ≤ 55.5°); week-rollover resolution; cache reuse/staleness/offline-error behavior. `ruff` clean; `pytest`: **223 passed, 8 skipped**. No backlog-index changes (022 untracked by owner decision).
022a(core): almanac loader — Yuma/SEM parsers, visibility, cached fetch
All checks were successful
ci / test (3.14) (push) Successful in 1m11s
ci / test (3.12) (push) Successful in 1m18s
ci / test (3.12) (pull_request) Successful in 2m18s
ci / test (3.9) (push) Successful in 2m30s
ci / test (3.14) (pull_request) Successful in 2m59s
ci / test (3.9) (pull_request) Successful in 3m3s
e66a04db85
Per the owner-approved plan (PR #37 planning was rejected; work orders
are untracked from the backlog and land in docs/done when complete):

- core/almanac.py: Yuma + SEM parsers (angles verified against live
  CelesTrak week-390 data; SEM = semicircles, inclination offset from
  0.30 semicircles per ICD-GPS-240D; Yuma = radians), content-sniffing
  dispatch, AlmanacData/AlmanacSatellite with round-trip-safe to_json/
  from_json (the interchange format for GLONASS/hand-curated data),
  nominal_almanac from the registry (GPS PRN1-32, GLONASS R1-R24 +
  slot->channel table), fetch_almanac with injectable opener, 4-attempt
  backoff, atomic staleness-checked cache (GENGNSS_CACHE_DIR override),
  GPS-week rollover resolution (mod 1024, closest representation)
- core/visibility.py: ICD-GPS-240 almanac propagation (Kepler solve,
  argument of latitude, Earth-fixed node longitude Omega = Omega0 +
  (OmegaDot-omegaE)*tk - omegaE*ToA with tk = t-ToA per gpstk convention),
  spherical-observer ENU look angles, visible_satellites filter
- tests/test_almanac.py: 14 tests with fixtures mirroring the live
  formats (network-free); Yuma<->SEM cross-check; closed-form subpoint
  geometry checks; visibility sanity bounds on a synthetic walker;
  cache/offline behavior. ruff clean; pytest 223 passed, 8 skipped.

Scope (verified live 2026-09-29): GPS Yuma/SEM are the only
machine-readable public almanacs; GLONASS status page is JS-rendered
behind an authenticated API -> JSON interchange + nominal fallback;
Galileo/BeiDou have no public open-service almanac with PRN<->SV maps
-> ICD ranges (021) remain their source of truth. No new dependencies.
ykp merged commit d5d0cbd984 into main 2026-09-29 21:45:40 +02:00
ykp deleted branch feat/022a-almanac-core 2026-09-29 21:45:40 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
ykp/gengnss!38
No description provided.