Skip to content

fix(optics): no FOV gate for frames from an unknown optical train - #632

Merged
brickbots merged 2 commits into
mainfrom
docs/unknown-optical-train
Aug 20, 2026
Merged

fix(optics): no FOV gate for frames from an unknown optical train#632
brickbots merged 2 commits into
mainfrom
docs/unknown-optical-train

Conversation

@brickbots

@brickbots brickbots commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Grilling session output for "debug mode stopped solving after the 2.6.2/3 lens system". Two commits: the decision (CONTEXT.md + ADR 0029 amendment), then the code.

The bug

--camera debug derives its FOV gate from hq × whatever lens the config states. ADR 0027 fixed the sensor half (relabelled the debug camera imx296hq, PR #609) and left the lens half reading live from camera_lens, which resolve_lens honours whatever profile it is handed. Measured against the real solver and the shipped default_database.npz:

train derived FOV gate debug_01 / debug_02
hq + no stated lens 10.33° [8.78, 11.88] solves (fits 10.20°)
hq + stated 25mm 10.33° [8.78, 11.88] solves
hq + stated 16mm 17.12° [14.55, 19.69] no solve
hq + stated 12mm 20.43° [17.37, 23.49] no solve

So anyone who has opened the Lens menu — and every device whose lens 0029's self-heal has written — loses debug mode. tests/test_optics_solving.py missed it because every case called build_optical_train("hq") with no lens key, exercising only the assumed path.

Second bug, live since 0029, fixed here too. With no camera_lens in config, debug mode solves at 10.20°, identify_lens_from_fitted_fov matches that to the hq's 25mm within 1.3%, and the integrator states it. It is in Rich's own log firing on 2026-08-17:

Lens self-heal: 3 solves fitted 10.20 deg, which is the 25mm (10.33 deg derived).
Stating it in config; the FOV gate narrows from the assumed range to this lens...

Attach a real imx462 afterwards and the now-stated 25 mm derives 6.4°, nothing solves, and self-heal can't undo it — it only ever writes into an absence.

The decision

A new term, unknown optical train: a camera whose frames did not come through this device's optics. It extends 0029's ladder rather than special-casing the debug camera —

stated → ±15% · assumed → union over shipped lenses · unknown → no gate at all

— and one rule covers both bugs: nothing about the device may be asserted about the frames (no FOV hint), and nothing about the device may be inferred from them (no self-heal write).

Rejected, with reasons in the ADR: declaring a lens from the camera (needs a second writer for camera_lens, and gates out frames you drop in yourself — the main use of debug mode); emulating the configured train (right answer to a different question, wants captures we don't have); a database-range gate (no-hint with extra steps); rejecting non-shipped pairings in resolve_lens (fixes this and the sensor-swap deadlock, but makes a user's statement conditionally non-authoritative — a direct contradiction of 0027, and it deserves its own decision).

Cost of no-hint, measured: all three widths return identical RA/Dec, Matches and Prob on the shipped frames, within ~1 ms. What it gives up is the upper bound that rejected confident mis-solves in 0029's noise trials — a protection that matters to a device under the stars, and nothing is under the stars in debug mode.

What changed

  • CameraInterface.optical_train_known() defaults True, so a camera has to opt out — a new hardware backend inherits the gate rather than silently losing it. CameraDebug returns False.
  • Published beside camera_type in get_image_loop; SharedStateObj defaults it True so the boot window before the camera reports keeps its gate on real hardware.
  • solver.py omits fov_estimate/fov_max_error entirely, and says so in the one-shot train log line. _warn_if_outside_solver_database is skipped — it's only meaningful against a gate we're actually handing over.
  • LensSelfHeal._observe returns early, ahead of the stated-lens branch so a streak counted on real optics can't be spent on a recording.

Scope stops there. The frustum keeps deriving from the configured optics — it answers "what would my camera image", a question about the device, and propagating would delete a frustum that is correct on a dev laptop (10.33° vs 10.20° frames). SQM is unaffected: only camera_pi publishes radiometer samples.

Verification

End to end, not just unit tests. Launched headless with camera_lens: "16mm" stated — the exact config that produced zero solves:

"camera_solve": { "RA": 296.37, "Dec": -1.70, "Roll": 312.18 },
"constellation": "Aql", "FOV": 10.2016, "Matches": 21, "solve_source": "CAM"

with Optical train is unknown ... lens self-heal is off in the log and the config left unwritten. (A stated 16mm gates [14.55, 19.69], so a solve is only possible via the no-gate branch.)

  • 1313 passed, -m "unit or smoke"; 20 passed in test_optics_solving.py
  • ruff check + format clean; mypy clean on all five touched modules
  • Two -m integration failures (test_radec_entry, test_all_ui_modules_covered) reproduce on untouched origin/main — the second only when test_battery_titlebar_icon.py's _BareModule helper is loaded in the same session. Pre-existing, unrelated.

New tests: every lens a config can name, asserting both that the derived gate rejects these frames and that no-gate solves them; the self-heal bar plus a control showing the identical fit would have promoted on real optics; the opt-out default in both directions.

Not in scope

The sensor-swap deadlock — switch imx296 → hq from the Camera Type menu with a stated 16 mm → 17.12°, no solves, no way to measure out. Real hardware, real statement, different failure class. Rich says it's filed; the closest issue I found is #613.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CUbR3ryYDL7RizsNhPxGkX

brickbots and others added 2 commits August 19, 2026 15:00
`--camera debug` stopped solving for anyone with a stated lens. ADR 0027
relabelled the debug camera's sensor to `hq` so the archived 10.2 degree
frames would be covered, but the lens half still reads live from
`camera_lens`, and `resolve_lens` honours a statement whatever profile it
is handed. So the gate gets derived from hq x 16mm (17.12 deg) or hq x 12mm
(20.43 deg) -- trains that have never physically existed -- and every debug
frame falls outside the window. Measured against the real solver:

  hq + no stated lens  10.33 deg  [8.78, 11.88]   solves (fitted 10.20)
  hq + stated 25mm     10.33 deg  [8.78, 11.88]   solves
  hq + stated 16mm     17.12 deg  [14.55, 19.69]  no solve
  hq + stated 12mm     20.43 deg  [17.37, 23.49]  no solve

Same shape as the regression ADR 0029 exists to fix, mirrored: there an
assumption wore a statement's confidence, here a statement is made about
hardware that is not in the loop.

Names the state rather than special-casing the debug camera. An **unknown
optical train** is one whose frames did not come through this device's
optics, and it extends the confidence ladder 0029 already established:
stated -> +/-15%, assumed -> union over shipped lenses, unknown -> no gate
at all. Two consequences follow from one rule -- nothing about the device
may be asserted about the frames (no FOV hint), and nothing about the
device may be inferred from them (no lens self-heal).

The self-heal half is a live bug this records the fix for: with no
`camera_lens` in config, debug mode solves at 10.20 deg, self-heal matches
that to the hq's 25mm within 1.3%, and writes it into the developer's
config. Attach a real imx462 afterwards and the now-stated 25mm derives
6.4 deg, nothing solves, and self-heal cannot undo it -- it only ever
writes into an absence.

Docs only; the code change follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUbR3ryYDL7RizsNhPxGkX
`--camera debug` stopped solving for anyone with a stated lens. This is
the code half of the decision recorded in the previous commit.

The debug camera declares the sensor its archived frames were shot on
(`hq`), but the lens half of the train is read live from `camera_lens`, so
stating one pairs `hq` with glass that is not in the loop: 16mm implies
17.12 deg and 12mm 20.43, against frames that are 10.2. tetra3 prunes by
implied field of view before verification, so every frame is rejected --
no solves at all, and the symptom presents as an exposure problem.

Rather than special-case the debug camera, name the state and let the two
consumers that must not act on it read one flag:

* `CameraInterface.optical_train_known()` defaults True, so a camera has
  to opt *out* and a new hardware backend inherits the gate rather than
  silently losing it. `CameraDebug` returns False.
* The camera process publishes it beside `camera_type`; `SharedStateObj`
  defaults it True so the boot window before the camera reports keeps its
  gate on real hardware.
* The solver omits `fov_estimate`/`fov_max_error` entirely -- not a third
  window to keep consistent, and any frame from any train solves, which
  is what lets a developer drop their own captures into test_images/.
* Lens self-heal declines to write. This half fixes a bug that has been
  live since 0029: a debug run on a config with no lens fits 10.20 deg,
  matches that to the hq's 25mm within 1.3%, and states it. Rich's own
  log has it firing on 2026-08-17. Attach a real imx462 afterwards and
  the now-stated 25mm derives 6.4 deg, nothing solves, and self-heal
  cannot undo it -- it only ever writes into an absence.

Verified end to end, not just in unit tests: launched headless with
`camera_lens: "16mm"` stated -- the exact config that produced zero
solves -- and it solves RA 296.37 Dec -1.70 in Aql, fitted FOV 10.20,
21 matches, `solve_source: CAM`, with the integrator logging that
self-heal is off and the config left unwritten.

test_optics_solving.py now parametrises over every lens a config can
name, asserting both halves: the derived gate rejects these frames for
16mm and 12mm, and no-gate solves them whatever is stated. That gap --
every existing case passed no lens key at all -- is why this shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUbR3ryYDL7RizsNhPxGkX
@brickbots brickbots changed the title docs(optics): name the unknown optical train, the FOV gate's third rung fix(optics): no FOV gate for frames from an unknown optical train Aug 19, 2026
@brickbots
brickbots marked this pull request as ready for review August 19, 2026 22:38
@brickbots
brickbots merged commit 7c51745 into main Aug 20, 2026
4 checks passed
TakKanekoGit pushed a commit to TakKanekoGit/PiFinder that referenced this pull request Aug 21, 2026
…ickbots#632)

* docs(optics): name the unknown optical train, the FOV gate's third rung

`--camera debug` stopped solving for anyone with a stated lens. ADR 0027
relabelled the debug camera's sensor to `hq` so the archived 10.2 degree
frames would be covered, but the lens half still reads live from
`camera_lens`, and `resolve_lens` honours a statement whatever profile it
is handed. So the gate gets derived from hq x 16mm (17.12 deg) or hq x 12mm
(20.43 deg) -- trains that have never physically existed -- and every debug
frame falls outside the window. Measured against the real solver:

  hq + no stated lens  10.33 deg  [8.78, 11.88]   solves (fitted 10.20)
  hq + stated 25mm     10.33 deg  [8.78, 11.88]   solves
  hq + stated 16mm     17.12 deg  [14.55, 19.69]  no solve
  hq + stated 12mm     20.43 deg  [17.37, 23.49]  no solve

Same shape as the regression ADR 0029 exists to fix, mirrored: there an
assumption wore a statement's confidence, here a statement is made about
hardware that is not in the loop.

Names the state rather than special-casing the debug camera. An **unknown
optical train** is one whose frames did not come through this device's
optics, and it extends the confidence ladder 0029 already established:
stated -> +/-15%, assumed -> union over shipped lenses, unknown -> no gate
at all. Two consequences follow from one rule -- nothing about the device
may be asserted about the frames (no FOV hint), and nothing about the
device may be inferred from them (no lens self-heal).

The self-heal half is a live bug this records the fix for: with no
`camera_lens` in config, debug mode solves at 10.20 deg, self-heal matches
that to the hq's 25mm within 1.3%, and writes it into the developer's
config. Attach a real imx462 afterwards and the now-stated 25mm derives
6.4 deg, nothing solves, and self-heal cannot undo it -- it only ever
writes into an absence.

Docs only; the code change follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUbR3ryYDL7RizsNhPxGkX

* fix(solver): hand no FOV gate to frames from an unknown optical train

`--camera debug` stopped solving for anyone with a stated lens. This is
the code half of the decision recorded in the previous commit.

The debug camera declares the sensor its archived frames were shot on
(`hq`), but the lens half of the train is read live from `camera_lens`, so
stating one pairs `hq` with glass that is not in the loop: 16mm implies
17.12 deg and 12mm 20.43, against frames that are 10.2. tetra3 prunes by
implied field of view before verification, so every frame is rejected --
no solves at all, and the symptom presents as an exposure problem.

Rather than special-case the debug camera, name the state and let the two
consumers that must not act on it read one flag:

* `CameraInterface.optical_train_known()` defaults True, so a camera has
  to opt *out* and a new hardware backend inherits the gate rather than
  silently losing it. `CameraDebug` returns False.
* The camera process publishes it beside `camera_type`; `SharedStateObj`
  defaults it True so the boot window before the camera reports keeps its
  gate on real hardware.
* The solver omits `fov_estimate`/`fov_max_error` entirely -- not a third
  window to keep consistent, and any frame from any train solves, which
  is what lets a developer drop their own captures into test_images/.
* Lens self-heal declines to write. This half fixes a bug that has been
  live since 0029: a debug run on a config with no lens fits 10.20 deg,
  matches that to the hq's 25mm within 1.3%, and states it. Rich's own
  log has it firing on 2026-08-17. Attach a real imx462 afterwards and
  the now-stated 25mm derives 6.4 deg, nothing solves, and self-heal
  cannot undo it -- it only ever writes into an absence.

Verified end to end, not just in unit tests: launched headless with
`camera_lens: "16mm"` stated -- the exact config that produced zero
solves -- and it solves RA 296.37 Dec -1.70 in Aql, fitted FOV 10.20,
21 matches, `solve_source: CAM`, with the integrator logging that
self-heal is off and the config left unwritten.

test_optics_solving.py now parametrises over every lens a config can
name, asserting both halves: the derived gate rejects these frames for
16mm and 12mm, and no-gate solves them whatever is stated. That gap --
every existing case passed no lens key at all -- is why this shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUbR3ryYDL7RizsNhPxGkX

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant