fix(nearby): rank against true angular distance, in a bounded window - #622
Merged
Merged
Conversation
mrosseel
force-pushed
the
fix/nearby-ranking-upstream
branch
from
August 17, 2026 17:56
fa493dd to
e1ff718
Compare
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
mrosseel
force-pushed
the
fix/nearby-ranking-upstream
branch
from
August 17, 2026 19:34
e1ff718 to
b2b7ae6
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>
…ising
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>
brickbots
added a commit
that referenced
this pull request
Aug 19, 2026
) The 2.6.2 notes described the state of main as of #619. Five commits have landed since the branch point with release, and the notes covered only the lens ones. Adds a Fixes section for the two undocumented changes: - #622 (ADR 0030) nearby ranking. The BallTree behind the Nearby sort and the chart's nearby-DSO markers was indexed [ra, dec] against sklearn's haversine, which reads dimension 0 as latitude -- so separations were right only on a shared meridian and worse towards the poles. Plus the 200-object cap, the great-circle staleness trigger, the carousel counting the sorted list, and the serialisation fix that was blanking the remote web object-details view. - #623 observed-objects cache: one chunked, catalog-grouped query instead of a lookup per listing, and one shared ObservationsDatabase per process. Also corrects two figures the notes carried past #628, which measured the 12mm at 13.04mm: - the worked example still said a 12mm on an imx296 images 17.8 deg; it derives 16.38 deg, and the table in the same section already said so - Known Sharp Edges still claimed the 12mm's effective focal length was the nominal 12.0 with effective_focal_length_measured=False. It is measured. The zero point and f-number are what remain open on #612 And records what #628 means for self-heal -- at the nominal 12.0 a 12mm unit fitted 7.9% from derived, outside the 5% identification tolerance, so it could never promote -- plus the single-sample caveat, the solver's failed-solve log line, SortOrder.RA gaining an implementation while staying unreachable from the Quick Menu, and current test counts (1,305 unit+smoke, verified locally). No code changes. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The object list's "Nearby" sort has two user-visible faults: the focused row is
often not the closest object, and the list stalls the UI while the scope slews.
They have three independent causes, so fixing any one alone leaves the symptom.
Full reasoning, measurements and trade-offs:
docs/adr/0029-nearby-ranking-correctness-and-cost.md(added here).1. The BallTree was fed (RA, Dec); haversine means (lat, lon)
ClosestObjectsFinderbuilt rows as[ra_rad, dec_rad]and queried with thesame ordering against
metric="haversine". scikit-learn reads dimension 0 aslatitude, so RA was passed as latitude and Dec as longitude, and every
separation was computed on a swapped sphere. It is correct only between objects
sharing a meridian; the error grows with declination.
Pointing at RA 0°, Dec +60°:
The chart's nearby-DSO marker layer (
UIChart._get_nearby_markers→get_objects_within_radius) shares the tree, so it plotted the wrong set ofmarkers. Measured recall for a 5° radius query over a uniform 20 000-object sky:
Near the equator the query over-includes but never misses, which is why this
survived: the failure is invisible exactly where casual testing happens. This is
the more valuable half of the fix — a missing chart marker is silent.
Fixed: rows and queries are
[dec_rad, ra_rad].2. Ranking the whole catalog to draw nine rows
get_closest_objectswas always called with the defaultn=0, expanded ton = len(objects), so every refresh asked for a full k = N ordering andmaterialised an N-element object array — to draw about nine rows.
Replacing the BallTree with a vectorised haversine +
argsortmeasured0.9 ms / 4.0 ms — no real gain. The cost is inherent to producing a total
ordering; it can only be avoided, not optimised.
The larger cost sat one level up: each refresh also ran
_next_target_index,building a
(catalog_code, sequence)dict over the entire new ordering in purePython — 7.6 ms at N = 14 000, 22 ms at N = 40 000 on a fast dev machine,
against a 33 ms frame budget, on a Pi.
Fixed:
NEAREST_LIST_CAP = 200; the query isk = min(cap, N), whichbounds the cursor-tracking helper too.
Trade-off, stated plainly: a Nearby-sorted list no longer scrolls down to the
object on the far side of the sky. That ordering has no observing use, and
paying O(N) on every degree of slew to keep it available is the wrong bargain.
Catalog and RA sort still expose everything.
3. The refresh trigger used RA degrees, not sky degrees
should_refreshcomparedabs(ra - last_ra) > 1.0. One degree of RA spanscos(dec)degrees on the sky — 0.17° at Dec 80°, 0.017° at Dec 89° — so thelist re-ranked for movement the user cannot see, exactly where slewing is
slowest and the stall most noticeable. RA also wraps: crossing RA 0 gave
abs(359.5 - 0.5) = 359, leaving the trigger permanently true in a band aroundthe meridian. It never missed a refresh, so this was a cost bug, not a
correctness one — and an invisible one.
Fixed: trigger on great-circle separation (
great_circle_degrees), andraise
MAX_TIMEfrom 2 s to 10 s. The time trigger exists to pick upcatalog/filter changes and altitude drift, not pointing changes; at 15°/hour of
sky rotation a 2 s cadence buys nothing.
4. The cursor followed the old object while slewing
update()called_next_target_indexafter every Nearby refresh to hold thecursor on the previously selected object. For a filter-driven rebuild that
is right, and it is why the helper exists (the filter-freshness ADR). For a pointing-driven
re-rank it inverts intent: the user slews in order to change what is nearest,
and the cursor drifted down the list away from what they just pointed at. This
is the second half of "the focused thing is not the nearest thing" — a policy
fault, not a bug; the code did what it said.
Fixed: while the cursor sits on the top row it keeps following the pointing.
Once the user scrolls off the top they are browsing, and it pins to the selected
object exactly as before; scrolling back to the top re-arms the behaviour. No
new state — "has not scrolled" is
_current_item_index == 0.5. Smaller faults in the same call path
SortOrder.RAwas never implemented.sort()had branches forNEARESTand
CATALOG_SEQUENCEonly, so choosing RA left the list in whatever order italready had, and
update()'s two-way label rendered it as "Nearby". Nowsorted, with both labels routed through one
_sort_order_labelhelper.mm_change_sortcallednearby_refresh()before
sort(), ranking against a stale or empty tree thatsort()thenimmediately redid. Dropped.
Nearby.refresh()returns[]when there is nopointing, never
None, but the caller testedis Nonebefore showing "NoSolve Yet" — so the user saw "No objects match filter" instead. Now tested via
Nearby.has_pointing().sort()rebuilt the BallTree every call whileUIChartalready guards the identical rebuild oncatalog_filter.dirty_time.The object list now uses the same guard, invalidated explicitly by
refresh_object_list.get_closest_objectsreturns a NumPy object array, nota
List.Tests
tests/test_nearby.pyplaced every object atra = 0and its docstring calledthe
[ra, dec]ordering "the pre-existing convention" — that is the one line onwhich the bug cannot show, so the suite encoded the bug as intent.
Rewritten to place objects off a shared meridian and at high declination, and to
assert against an independently computed great-circle separation, so the axis
order is pinned rather than assumed. Added coverage for
get_closest_objectsordering, the
ncap, andgreat_circle_degrees(including the RA wrap andover-the-pole cases).
Checked against the old code: two of the new tests fail on it.
Verification
ruff@0.4.8check + format: passmypy PiFinder: pass (152 files)pytest -m "smoke or unit": 1148 passed. Two failures are pre-existing macOSpath-length limits (
AF_UNIX path too long,File name too long) intest_sd_notify.pyandtest_solver_shmem.py, unrelated to this change.pytest -m integration tests/test_ui_modules.py: 216 passed, 2 skippedBehaviour changes users may notice
from the list. Anyone who learned the old ordering will see it change — that
is the point.
🤖 Generated with Claude Code