Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
220a648
Migrate solver/integrator/consumers from solved dict to PointingEstimate
brickbots May 23, 2026
9a2ee90
Collapse two-axis IDR into a single dual-axis instance
brickbots May 23, 2026
fb92dcd
Updating AX docs
brickbots May 24, 2026
913e5d0
Split solver→integrator message into SolveResult DTO
brickbots May 24, 2026
e406ef3
Remove stale session-handoff scratch doc
brickbots May 24, 2026
474cb27
Track the python/tetra3 symlink so submodule CI can import tetra3
brickbots May 24, 2026
de61183
Enable submodule checkout for nox action
brickbots May 24, 2026
8ecc3a5
Merge branch 'main' into refactor/pointing-estimate-dataclasses
brickbots May 24, 2026
a7ea2c1
Merge remote-tracking branch 'origin/main' into refactor/pointing-est…
brickbots May 25, 2026
d9f81f0
Adapt UI smoke harness to PointingEstimate
brickbots May 25, 2026
9ee4029
Make set_solution the single writer of solve_state
brickbots May 25, 2026
1f52b66
Annotate __solve_state as Optional[bool] for mypy
brickbots May 25, 2026
a55c5e9
Merge branch 'main' into refactor/pointing-estimate-dataclasses
brickbots May 25, 2026
192af54
Rename solve_time->estimate_time; migrate IMU dict to ImuSample
brickbots May 26, 2026
da482f7
Preserve the estimate on a failed solve; don't revert to "no solve"
brickbots May 26, 2026
a361810
Add Pointing.from_radecroll(); drop manual RaDecRoll->Pointing casts
brickbots May 26, 2026
864f75a
Fix api_extensions endpoints missed by the PointingEstimate migration
brickbots May 30, 2026
cedbe81
Merge remote-tracking branch 'origin/main' into refactor/pointing-est…
brickbots May 30, 2026
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
3 changes: 2 additions & 1 deletion .github/workflows/web-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ jobs:
run: |
git submodule sync
git submodule update --init --recursive
ln -s PiFinder/tetra3/tetra3 tetra3
# The python/tetra3 -> PiFinder/tetra3/tetra3 symlink is tracked in
# the repo, so it no longer needs to be created here.
- name: Use cache of hip_main.dat star catalog

Expand Down
2 changes: 1 addition & 1 deletion default_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"chart_reticle": 128,
"chart_constellations": 64,
"chart_coord_sys": "horiz",
"solve_pixel": [256, 256],
"target_pixel": [256, 256],
"gps_type": "ublox",
"gps_baud_rate": 9600,
"filter.selected_catalogs": [
Expand Down
31 changes: 31 additions & 0 deletions docs/adr/0003-solver-integrator-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Solver→integrator message is a `SolveResult`, not a `PointingEstimate`

The solver puts a dedicated `SolveResult` DTO on `solver_queue` — a union of `SuccessfulSolve` and `FailedSolve`, dispatched by `isinstance()`. The integrator alone owns the long-lived `PointingEstimate`, building it by applying a `SolveResult` onto its instance. This splits the *transport message* (what the solver produces from one tetra3 attempt) from the *published aggregate* (the canonical "where are we pointing?" record), which had been conflated into a single type.

## Context

[ADR-0001](./0001-positioning-vocabulary.md) introduced `PointingEstimate` and the 2 × 2 `pointing.<axis>.<state>` matrix, replacing the legacy `solved` dict. In that pass the solver also built the `PointingEstimate` directly and pushed it on `solver_queue`. That overloaded `PointingEstimate` with two roles:

- The solver produces **solve-truth only** — it has no `estimate` concept (no IMU progression happens there) — yet was forced to populate the `estimate` cells equal to `solve` and build a full `PointingMatrix`.
- A failed solve built a **hollow** `PointingEstimate` (empty matrix, no anchor) that wasn't an estimate of anything.
- The integrator never trusted the snapshot wholesale anyway — it cherry-picked ~11 fields on success and 3 on failure.

## Decision

- `SolveResult = Union[SuccessfulSolve, FailedSolve]`, defined in `PiFinder/types/positioning.py`. The integrator dispatches on `isinstance()`, mirroring the existing `SolverCommand` / `AlignResponse` unions in the same module.
- `SuccessfulSolve` carries **flat** `camera` / `aligned` `Pointing`s (no `solve`/`estimate` split), an `Optional` `imu_anchor`, a single `solve_time`, `SolveDiagnostics`, `AlignmentResult`, and the matched-star arrays.
- `FailedSolve` carries only `SolveDiagnostics` (`Matches=0`) and `last_solve_attempt` / `last_solve_success`.
- The integrator maps `SolveResult` → `PointingEstimate` in `_apply_successful_solve` / `_apply_failed_solve`. `PointingEstimate` has **no** knowledge of `SolveResult` (no `from_solve_result()` / `apply()` method) — symmetric with the rule that `ImuDeadReckoning` never imports `PointingEstimate`.
- `solve_source` stays an **aggregate-only** field. The message type is the success/failure discriminant; `CAMERA` / `CAMERA_FAILED` / `IMU` survive as published states on `PointingEstimate`.

## Considered options

- **Keep `PointingEstimate` as the queue message** (status quo) — rejected: preserves the dual-role overload and the hollow failed estimate.
- **Pass a raw dict** — rejected: regresses ADR-0001's removal of the `solved` dict and drops type-checking on the one hop that most needs it.
- **One `SolveResult` with a `succeeded: bool` discriminant and `Optional` fields** — rejected: re-creates the "everything's Optional, hope you checked" hollow object the refactor exists to remove. Two types make illegal states unrepresentable and match the module's existing `isinstance`-dispatch idiom.

## Consequences

- `solver_queue` no longer carries `PointingEstimate`; the glossary entry and `positioning.md` were updated to match.
- The fan-out from one `Pointing` to both `solve` and `estimate` cells, and the preserve-on-failure anchor policy, live solely in the integrator — the single owner of the aggregate.
- Tests that previously re-implemented the integrator loop body inline (e.g. the failed-solve branch) can call `_apply_failed_solve` directly, so they guard the production path.
31 changes: 31 additions & 0 deletions docs/adr/0004-pointing-estimate-timing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# `PointingEstimate` timing is the measurement epoch, not the publish time

The published pointing carries one timing field, `estimate_time`, defined as the **measurement epoch** of the data behind the current estimate — the camera frame's `exposure_end` for a plate-solve, the IMU sample's `timestamp` for an IMU-progressed estimate — both on the same `time.time()` wall clock. It replaces the old `solve_time`, which was a *publish* timestamp (`time.time()` at the moment the integrator produced the value) and was also misnamed: it updated on IMU advances, where no plate-solve occurred.

## Context

[ADR-0001](./0001-positioning-vocabulary.md) reserved **"solve"** for the plate-solve event (the `solve` state is "never IMU-touched") and **"estimate"** for the current, possibly IMU-progressed value. The timing fields didn't follow that split:

- `solve_time` was stamped `time.time()` at solve completion (`solver.py`) and again at `time.time()` on every IMU advance (`integrator.py`). On the IMU path it wasn't even the data's publish time — it was "when the integrator got around to reading the sample," which lags the actual sample by the IMU → `shared_state` → integrator latency.
- `cam_solve_time` held the last plate-solve's wall-clock completion, and consumers detected "is the live value still the raw solve?" via the fragile `solve_time == cam_solve_time` equality — the same parallel-fact pattern that caused the `solve_state` "no solve" regression on this branch.
- The `SuccessfulSolve` message carried a `solve_time` distinct from `last_solve_attempt` / `last_solve_success` (both already the frame's `exposure_end`).

## Decision

- Rename `solve_time` → **`estimate_time`** on `PointingEstimate`, redefined as the **measurement epoch** of the current estimate: camera frame `exposure_end`, or IMU sample `timestamp`. `time.time() - estimate_time` is therefore a true "age of the fix" regardless of source.
- **"Solve" means plate-solve, always.** The IMU never produces a "solve"; it advances the *estimate*. No `*_solve_*` name may carry an IMU-derived value.
- Remove `cam_solve_time`: under epoch semantics it is value-identical to `last_solve_success`. "Is the live value the raw plate-solve?" is answered by `solve_source` (`is_camera_solve()`); "time since last solve" reads `last_solve_success`.
- Remove `SuccessfulSolve.solve_time`: redundant with the message's `last_solve_success` (the solved frame's `exposure_end`). The integrator sets `estimate_time = result.last_solve_success` on the camera path and `estimate_time = imu.timestamp` on the IMU path.
- The IMU sample stamps **its own** capture `timestamp` in the IMU process. This required migrating the `shared_state.imu()` dict to the `ImuSample` dataclass (which gains a `timestamp` field and a `to_dict()` for the JSON API).

## Considered options

- **Keep `solve_time` as publish time** — rejected: it conflates "when computed" with "what the value represents," and on the IMU path it's neither (it's the integrator's read latency). Telemetry and any future inter-sample interpolation want the measurement epoch.
- **Take the IMU-path fix literally (IMU = sample time, camera = solve-completion wall clock)** — rejected: `estimate_time` would then mean two different things depending on `solve_source`. Making the camera path use `exposure_end` keeps one meaning across both sources.
- **Keep `cam_solve_time` for the `cam_active` check** — rejected: a value-identical duplicate that can drift; `is_camera_solve()` already expresses the intent from a single source of truth.

## Consequences

- `estimate_time` mixes a camera-stamped timestamp and an IMU-stamped timestamp; correctness depends on both being the same `time.time()` clock. They are (camera: `camera_interface.py`; IMU: `imu_pi.py`).
- The `imu` field of `/api/status` and `/api/imu` changes from a stringified quaternion (the old `default=str` fallback) to a structured `ImuSample.to_dict()` — a strict improvement; no structured consumer read the old form. This unblocks the telemetry work flagged on PR #429.
- `move_start` / `move_end` (long marked TODO-remove, never read) are dropped in the `ImuSample` migration.
26 changes: 26 additions & 0 deletions docs/adr/0005-failed-solve-preserves-estimate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# A failed plate-solve preserves the estimate; never reverts to "no solve" once anchored

On a `FailedSolve`, the integrator keeps the `estimate` cells (`pointing.camera.estimate`, `pointing.aligned.estimate`) intact rather than clearing them. Once the system has an anchor (has solved at least once), the last IMU-progressed pointing remains the published answer and `solve_state` stays true; the IMU advance keeps progressing it. "No solve" therefore only appears before the first successful solve.

## Context

The earlier design cleared the `estimate` cells on every failed solve "so consumers stop reading a stale camera pointing," relying on the IMU dead-reckoning step — which runs immediately after — to re-fill them. But the integrator publishes the failed-solve state *unconditionally* (to feed auto-exposure `Matches=0` on every attempt), and the IMU advance only fires when motion exceeds `IMU_MOVED_ANG_THRESHOLD` (the deadband). So whenever a solve failed while the PiFinder was held steady, the published estimate was the *cleared* one: `has_pointing()` → False → `solve_state` False → the UI flipped to "no solve" and stayed there until the next successful solve or a larger nudge. This was observed and reported on PR #429.

The clearing also contradicted the project's own glossary, whose example dialogue already said the integrator "keeps producing `pointing.aligned.estimate` from IMU dead-reckoning" across failures.

## Decision

- `_apply_failed_solve` preserves the `estimate` cells (and `estimate_time`); it only refreshes `diagnostics`, `last_solve_attempt` / `last_solve_success`, and sets `solve_source=CAMERA_FAILED`.
- The unconditional failed-solve publish stays (auto-exposure reads `solve_source`, `diagnostics.Matches`, and `last_solve_attempt` straight from `shared_state.solution()`), but it now carries the preserved pointing, so `solve_state` does not drop.
- "No solve" (`solve_state` False) is reserved for the genuinely-unanchored state: before the first successful solve.

## Considered options

- **Keep clearing, but always dead-reckon from the anchor on failure (ignore the deadband for the re-fill)** — rejected: more machinery for a result indistinguishable below the deadband (the anchor-dead-reckoned value differs from the preserved value by < 0.06°).
- **Debounce in the UI (show "no solve" only after N misses)** — rejected: masks the symptom in one consumer while the integrator keeps publishing no-pointing states to web, SkySafari, and the position server.

## Consequences

- A long run of failed solves while stationary keeps showing the last pointing with `solve_source=CAMERA_FAILED`. `RA`/`Dec` stay correct (the IMU is the authority on motion: deadband = not moved); `Alt`/`Az` are only recomputed on the IMU-advance / successful-solve publish, so they can lag during such a streak. Accepted as a minor, transient trade-off.
- Genuine "I'm lost" signalling on IMU failure (lost calibration, sensor dropout) is **not** handled here — it is an IMU-health concern, deliberately out of scope.
- Reverses the "clear the estimate cells on failure" detail documented alongside [ADR-0003](./0003-solver-integrator-message.md); the glossary and the `_apply_failed_solve` / `FailedSolve` docstrings were updated to match.
Loading
Loading