fix(optics): FOV gate width follows lens confidence, plus lens self-heal (ADR 0029) - #624
Conversation
ac7f4ef to
adc3b44
Compare
|
Reviewed this branch. Verified the PR's own claims independently: 1,285 unit + smoke tests pass, One defect found and pushed as 5514a39 (now 1,286 passing): The Lens menu showed the wrong lens after self-heal, and confirming it was unrecoverable.
Not only cosmetic: The fix reads Filed #626 for the general form of the staleness — Smaller points, not pushed:
Nothing else blocking from me. |
Some rev4 units shipped with a 12mm lens and no camera_lens in config. resolve_lens falls back to the imx462's default_lens_key of 16mm, so the device derives 10.40 deg and gates [8.84, 11.96] against a true field of 13.51 deg -- every frame rejected before verification. Those units stopped solving on update having changed nothing. The pre-0027 window was the fixed 12.0 +/- 4.0, which spanned both lenses; that is why 2.6.1 worked. ADR 0027 treated the camera profile's fallback lens as knowledge. It isn't. Record the decision to make gate width proportional to what is actually known about the train, and to let the fitted FOV promote an assumption to a statement. Adds ADR 0029 and the vocabulary the change turns on: stated lens vs assumed lens (camera CONTEXT.md), and the gate-width and self-heal roles in positioning CONTEXT.md. Also corrects the camera glossary's claim that a wrong lens is "a configuration error the device cannot discover for itself" -- positioning CONTEXT.md already said the fitted FOV diagnoses exactly that, and the two entries contradicted each other. Docs and plan only; no behaviour change yet. Implementation checklist in lens-gate-confidence-plan.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012sYi5W1u5HsZCTKeUYHPxa
Measured against the real solver and real frames rather than assumed. On speed the narrow gate buys nothing: +-15%, +-30%, +-50% and the pre-0027 fixed window all solve in 1.1-1.4 ms, and even omitting the hint entirely costs only +0.3 ms, with Prob bit-identical across widths. It does buy mis-solve rejection, which is why the assumed gate is a union rather than no hint at all. With injected noise, wide and no-hint gates returned confident false solves at 20-23 deg that match_threshold did not reject; the gate's upper bound is what catches those, and [8.84, 15.53] keeps it. Also records the asymmetry that settles the trade: a good frame outside the gate burns the whole solve_timeout every frame, forever, which is three orders of magnitude worse than a wide gate's 0.3 ms. Adds an implementation note on tetra3's _pattern_cache, which is keyed on hash_index alone but stores the FOV-pruned result and is never cleared. Self-heal moves wide -> tight, the safe direction (cached entries are a superset and the post-fit rejection still applies). A Lens menu change moves tight -> different-tight and may not take effect until solver restart -- pre-existing in 2.6.2, not introduced here, but it will mislead anyone testing this change by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012sYi5W1u5HsZCTKeUYHPxa
The plan told the implementer to file it separately. It is filed and fixed: callbacks.set_camera_lens now restarts, as the neighbouring PiFinder Type and Camera Type settings do. The upstream tetra3 cache bug remains out of scope. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012sYi5W1u5HsZCTKeUYHPxa
Some rev4 units shipped with a 12mm lens and no camera_lens in config. resolve_lens fell back to the imx462's 16mm, so 2.6.2 derived 10.40 deg and gated [8.84, 11.96] against frames that are actually 13.51 deg wide. Those units stopped solving on update having changed nothing. The fallback was never a claim, so the gate should never have narrowed around it. Gate width now follows how the lens was obtained: * stated lens (config names a registered key) -> +/-15%, as 0027 had it * assumed lens -> the union of every lens that sensor shipped with CameraProfile gains shipped_lens_keys; default_lens_key keeps its value but a narrower job -- it is now specifically the lens to *assume*. LensSelfHeal (integrator) then closes the assumption: a successful solve reports the fitted FOV, the sensor is known, so what is left is the lens. Three consecutive identifications agreeing to within 5% promote the assumption to a statement and the gate narrows onto it. It never overwrites a stated lens, writes nothing when the measurement matches no shipped lens, skips replayed solves, and can never take the integrator down -- a failed promotion costs the promotion, not the pointing. The hq only ever shipped one lens, so it has no union to take. That case returns early rather than falling through the union arithmetic, which would re-derive the same window and land a float ULP off the value that has been shipping; its gate is asserted bit-identical. Also: the solver's "Optical train:" line now says stated or assumed, and api_extensions' frustum comment says "derived" -- the glossary reserves "fitted" for what tetra3 measures. Docs: release notes, troubleshooting.rst (#613) and BOM.rst's imx462-specific "10deg FOV" line (#613). Implements docs/adr/0029-fov-gate-width-follows-lens-confidence.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lens-gate-confidence-plan.md was a working checklist for this change and the work it describes is now done. The decisions it summarised live in ADR 0029, the vocabulary in the camera and positioning CONTEXT.md files, and the code and tests are on this branch. Keeping it would land a stale to-do list on main, which is also why the project's other planning docs stay untracked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nfig `get_camera_lens` resolved the sensor half from `shared_state` but the lens half from `config_object`. The UI process loads its `Config` at boot and reloads it only on an explicit `reload_config`, so once self-heal writes a lens from the integrator, this process still reports the *assumed* lens. The menu therefore showed 16mm on a rev4 that had just measured itself as a 12mm. That is not only a display fault: `text_menu` writes the highlighted entry on select, so confirming the stale value states a lens the device measured itself out of, tightens the FOV gate around it, and stops solving for good -- self-heal cannot undo it, because it only ever writes into an absence. `troubleshooting.rst` sends a user with no solves to exactly this screen. `shared_state` is seeded from config when it is built and republished by both `set_camera_lens` and self-heal, and nothing else writes `camera_lens`, so it is strictly the more current of the two views. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5514a39 to
6875eab
Compare
0029 was already taken by 0029-fov-gate-width-follows-lens-confidence.md, merged to main in brickbots#624 after this branch was cut. That ADR keeps the number: it is referenced from seven places in shipped code (optics.py, integrator.py, camera_profiles.py), against three here, all of them inside this branch. Updates the three references: two comments in nearby.py and the test_nearby.py module docstring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…622) * fix(nearby): rank against true angular distance, in a bounded window The BallTree behind both the object-list Nearby sort and the chart's nearby-DSO markers was built and queried as [ra, dec] against sklearn's haversine metric, which reads dimension 0 as latitude. Separations came out right only between objects sharing a meridian, and worse towards the poles. Index and query as [dec, ra]. Rank a bounded window (NEAREST_LIST_CAP = 200) rather than ordering the whole catalog to draw nine rows. This bounds the k-NN query and the cursor-tracking helper, which together were the per-frame cost during a slew. Count what the carousel actually navigates. UIObjectList draws, scrolls, opens and serialises _menu_items_sorted, so get_nr_of_menu_items() now measures that list rather than the source; otherwise long-DOWN parks the cursor past the end of the ranked window and opening that row raises IndexError. The catalog's own object count stays the source length, reported in catalog_info_1. Trigger the re-rank on great-circle separation rather than per-axis RA/Dec degrees, and raise MAX_TIME to 10 s -- that trigger is for catalog and filter changes, not pointing. In the Nearby sort, hold the cursor on the top row while the user has not scrolled, so the focused object tracks the pointing; pin it to the selected object once they scroll off the top. Also: implement SortOrder.RA and route both sort labels through one helper, drop the redundant pre-sort nearby_refresh() in mm_change_sort, make the unreachable 'No Solve Yet' message reachable, cache the spatial index on the filter's dirty_time as UIChart already does, skip the scrollbar when the list is empty, and hold off the in-frame re-rank until the index is built. Tests place objects off a shared meridian and at high declination -- a same-meridian check cannot observe the axis order at all. A UI regression test drives long-DOWN then RIGHT over a catalog larger than the cap. See docs/adr/0029-nearby-ranking-correctness-and-cost.md * docs(adr): renumber the nearby-ranking ADR 0029 -> 0030 0029 was already taken by 0029-fov-gate-width-follows-lens-confidence.md, merged to main in #624 after this branch was cut. That ADR keeps the number: it is referenced from seven places in shipped code (optics.py, integrator.py, camera_profiles.py), against three here, all of them inside this branch. Updates the three references: two comments in nearby.py and the test_nearby.py module docstring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(object details): serialise a Nearby-sorted object_list without raising serialize_ui_state tested self.object_list for truthiness to compute object_list_length. A Nearby-sorted list is the NumPy object array get_closest_objects returns, and bool() on a multi-element array raises "truth value of an array with more than one element is ambiguous". The raise landed in the method's own except clause, so it was never visible as a crash -- it just made every object opened from a Nearby-sorted list serialise as {"error": ...} instead of state, silently blanking the remote web interface's object-details view for that whole path. Tests the real path rather than a hand-built array: sorts the object list by NEAREST and hands the resulting _menu_items_sorted to UIObjectDetails the way show_object_details does. The test fails on the previous line with exactly the ambiguous-truth-value error. Pre-existing, but adjacent to this branch's work on which list the screen addresses, and a one-line fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Richard <rich@brickbots.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merging main in brought `0030-nearby-ranking-correctness-and-cost.md` alongside this branch's `0030-equipment-measurements-are-validated-floats.md`. Two different files, so git merged them without a conflict, and a bare "ADR 0030" in the tree now pointed at either one. Standing rule: the most-referenced file keeps the contested number. The nearby ADR has landed on main and is cited from nearby.py (x2), test_nearby.py, ADR 0031 and the 2.6.3 notes, against three references for this one — so it keeps 0030 and this moves. 0033 is the lowest slot free across every branch and open PR (0031 is main's chart readout, 0032 is claimed by #635). Three inbound references updated, plus the now-doubly-stale earmark in the 2.6.2 test plan, which had reserved 0029 for this ADR before #624 and #622 took 0029 and 0030. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ShwenqLLrgHTRsrM1UyfZa
#571) * fix(web): validate equipment and GPS form input at the API, not just the browser The equipment handlers parsed with bare float()/int() inside `except Exception: logger.error(...)` and then rendered the success template regardless, so an unreadable value reported "Eyepiece added" and saved nothing: POST /equipment/add_eyepiece/-1 focal_length_mm=7,5 -> HTTP 200 + "Eyepiece added, restart your PiFinder to use" -> eyepiece count unchanged /gps/update had no try/except at all, so the same input reached the user as an unhandled 500. A decimal comma is the easiest trigger — PiFinder ships de/es/fr/zh — but any unparseable value did it, including the blank instrument name from #569. Field rules now live in one table in equipment.py: the edit forms render them into their client-side check and the API re-checks them before anything reaches config. Measurements are floats throughout, so a 279.4mm aperture is enterable and a config carrying one is loadable (#291); whole millimetres still display as "1000", not "1000.0", via format_measurement. - equipment: measurements are validated floats; limits + name length live beside the dataclasses (ADR 0027) - server: parse_measurement/parse_name/*_from_form; failures re-render the edit form with the message and the values that were typed; route indexes are range-checked instead of raising IndexError as a 500; the DeepskyLog import skips records it can't read rather than writing them through - gps: parse everything before locking anything, so a bad clock entry can't half-apply a position; gps.html gets #536's normalizeDecimal, which never reached it, and locations.html's two decimal->DMS bypasses are fixed - config: an undecodable equipment section logs and falls back to the defaults instead of aborting main() before the UI comes up (#291) Covered at the request level (the Selenium suite runs en-US and structurally can't catch a decimal-comma bug): 68 new tests, and the equipment + locations web suites still pass against a live PiFinder. Fixes #569 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: renumber the equipment-validation ADR to 0030 0027 was already taken twice over. #608/#606 collided on it and were resolved by #616 (FOV gate keeps 0027, tracked black level moved to 0028); this branch, cut before that, carried a third 0027. Under the standing tiebreak the most-referenced ADR keeps the number, and the FOV gate wins by a wide margin -- CONTEXT-MAP.md, docs/ax/sqm.md, docs/ax/positioning.md, positioning/CONTEXT.md, ADR 0029 and two test modules all point at it, against three references here. The 2.6.2 test plan (P1.4) earmarked 0029 for this branch, but 0029 was taken by the lens-confidence ADR in the meantime, so this takes 0030 -- the lowest free number. Three inbound references updated. No content change; ADR titles in this repo carry no number, so the rename is the whole of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: renumber the equipment-validation ADR to 0033 Merging main in brought `0030-nearby-ranking-correctness-and-cost.md` alongside this branch's `0030-equipment-measurements-are-validated-floats.md`. Two different files, so git merged them without a conflict, and a bare "ADR 0030" in the tree now pointed at either one. Standing rule: the most-referenced file keeps the contested number. The nearby ADR has landed on main and is cited from nearby.py (x2), test_nearby.py, ADR 0031 and the 2.6.3 notes, against three references for this one — so it keeps 0030 and this moves. 0033 is the lowest slot free across every branch and open PR (0031 is main's chart readout, 0032 is claimed by #635). Three inbound references updated, plus the now-doubly-stale earmark in the 2.6.2 test plan, which had reserved 0029 for this ADR before #624 and #622 took 0029 and 0030. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ShwenqLLrgHTRsrM1UyfZa --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Some rev4 PiFinders shipped with a 12 mm lens and no
camera_lensin their config. 2.6.2's derived FOV gate falls back to the imx462'sdefault_lens_keyof16mm, derives 10.40°, and gates[8.84, 11.96]— against frames that are actually 13.51° wide. Those units stopped solving on update having changed nothing, and the symptom presents as an exposure problem rather than a configuration one.The pre-0027 window was the fixed
12.0 ± 4.0=[8.0, 16.0], which happened to span the imx462 at both 12 mm and 16 mm. That is the whole reason these units worked on 2.6.1 and not on 2.6.2.Note
#625 (
lens-menu-restart) has merged. This branch was stacked on it; it is now rebased ontomainwith #625's commit dropped, and no longer carries it. The rebase was clean and the resulting tree is bit-identical to the pre-rebase branch, so nothing in this PR's content changed.The fix
A fallback is not a claim, and it should never have bought a claim's confidence. Gate width now follows how the lens was obtained:
12.19 ± 3.35= [8.84, 15.53]16.05 ± 4.39= [11.65, 20.44]The imx462's assumed gate lands within a whisker of the pre-0027 constants it replaces, which is the behaviour these units are known to work under.
Then self-heal closes the assumption. A successful solve reports the fitted FOV and the sensor is already known, so what is left is the lens. Three consecutive identifications agreeing to within 5% promote the assumption to a statement, and the gate narrows onto it. It:
Why a union rather than dropping the hint
Measured against the real solver and real frames before committing to this: widening costs nothing on speed. ±15%, ±30%, ±50% and the pre-0027 fixed window all solve in 1.1–1.4 ms, and even no hint at all costs only +0.3 ms;
Probwas bit-identical across every width.But the gate does buy mis-solve rejection. With injected noise, wide and hintless gates returned confident false solves at 20–23° that
match_thresholddid not reject. The assumed gate still excludes everything above 15.53°, so it keeps that protection — omittingfov_estimatewould give it up. (Single-shot noise trials: direction established, magnitude not.)And the cost is wildly asymmetric. A good frame outside the gate burns the entire
solve_timeoutand returns nothing, every frame, forever. Against a wide gate's ~0.3 ms, an over-tight gate is the expensive failure by three orders of magnitude.Rejected
imx462and so do some v3 units, so the sensor is not the predicate. The board is probeable, but board revision is not the fact we need — which lens was in the box is, and not all rev4s shipped with the 12 mm. It would fix most units by breaking the rest, and break them in exactly the way this PR exists to fix.camera_lensindefault_config.json.Config.get_optionconsults the default file beforeresolve_lensever sees aNone, so hq would derive 17.12° instead of its true 10.33°.FOV_GATE_MARGINglobally. Spends 0027's entire benefit on every correctly-configured user to cover a case they are not in.Full reasoning and the numbers:
docs/adr/0029-fov-gate-width-follows-lens-confidence.md.The hq must not move
The hq only ever shipped one lens, so it has no union to take. That case returns early rather than falling through the union arithmetic — which re-derives the same window through different float operations and lands 1 ULP off the value that has been shipping.
test_the_hq_gate_is_bit_identical_to_what_shippedasserts the exact floats, notapprox.Verification
test_optics.py(63) and the newtest_lens_self_heal.py(19), plus the existing suites.-n -q), and the new prose carries no em-dashes or semicolons.--camera debug), which exercises the write path through the integrator wiring:Lens self-heal: 3 solves fitted 10.20 deg, which is the 25mm (10.33 deg derived).The dev config was backed up and restored to its original checksum afterwards.Warning
The debug camera declares
hq, which has one shipped lens, so its assumed gate is identical to its stated one. The debug run cannot exercise the widening at all — the imx462 12mm-vs-16mm case, which is the entire point of the change, is only reachable in unit tests or on real hardware. A green debug run is not evidence the fix works on an affected unit.Known limitation, not fixed here
After self-heal writes
camera_lens, the UI process'sConfigstill holds the dict it loaded at boot. Any laterset_optionfrom the menu rewritesconfig.jsonfrom that stale dict and drops the self-healed key. This is self-correcting — the device re-heals after three solves on the next boot, andshared_statecarries the correct lens for the whole session, so solving is never affected. It is also pre-existing:camera_interface.pyalready writes config from a non-UI process with the same exposure. Worth a follow-up issue rather than a wider change toConfigin this PR.Docs
release_notes/2.6.2.md— the fix, and two corrections it forces: the release no longer claims "no configuration file is rewritten", and the "device deliberately does not recover" sharp edge is restated (self-heal writes into an absence, never over a statement). A new sharp edge covers the post-self-heal physical lens swap.docs/source/troubleshooting.rst(Docs: "changed lens → no solves" troubleshooting, and BOM's FOV advice is sensor-specific #613) — the "changed lens → no solves" path, narrower now that self-heal covers the never-stated case.docs/source/BOM.rst(Docs: "changed lens → no solves" troubleshooting, and BOM's FOV advice is sensor-specific #613) — "something fast with a 10deg FOV" was imx462-specific and contradicted the parts list two lines above it.[11.66, …]→[11.65, …], a rounding slip. 11.65 is what the release notes already quoted.Closes #613. Related: #610, #611, #612.
🤖 Generated with Claude Code