Skip to content

fix(nearby): rank against true angular distance, in a bounded window - #622

Merged
brickbots merged 3 commits into
brickbots:mainfrom
mrosseel:fix/nearby-ranking-upstream
Aug 19, 2026
Merged

fix(nearby): rank against true angular distance, in a bounded window#622
brickbots merged 3 commits into
brickbots:mainfrom
mrosseel:fix/nearby-ranking-upstream

Conversation

@mrosseel

@mrosseel mrosseel commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

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)

ClosestObjectsFinder built rows as [ra_rad, dec_rad] and queried with the
same ordering against metric="haversine". scikit-learn reads dimension 0 as
latitude, 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°:

object RA Dec true separation ranked distance before rank before
A 10° +60° 5.00° 10.00° 2nd
B +50° 10.00° 10.00° 1st
C 90° +60° 41.41° 90.00° 3rd
D 180° +62° 58.00° 178.00° 4th

The chart's nearby-DSO marker layer (UIChart._get_nearby_markers
get_objects_within_radius) shares the tree, so it plotted the wrong set of
markers. Measured recall for a 5° radius query over a uniform 20 000-object sky:

pointing Dec truly in radius returned correct recall
36 59 36 100% (23 false positives)
40° 34 58 28 82%
60° 46 41 32 70%
85° 41 12 7 17%

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_objects was always called with the default n=0, expanded to
n = len(objects), so every refresh asked for a full k = N ordering and
materialised an N-element object array — to draw about nine rows.

N k = N query k = 200 query
14 000 1.5 ms 0.09 ms
40 000 4.4 ms 0.13 ms

Replacing the BallTree with a vectorised haversine + argsort measured
0.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 pure
Python — 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 is k = min(cap, N), which
bounds 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_refresh compared abs(ra - last_ra) > 1.0. One degree of RA spans
cos(dec) degrees on the sky — 0.17° at Dec 80°, 0.017° at Dec 89° — so the
list 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 around
the 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), and
raise MAX_TIME from 2 s to 10 s. The time trigger exists to pick up
catalog/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_index after every Nearby refresh to hold the
cursor 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.RA was never implemented. sort() had branches for NEAREST
    and CATALOG_SEQUENCE only, so choosing RA left the list in whatever order it
    already had, and update()'s two-way label rendered it as "Nearby". Now
    sorted, with both labels routed through one _sort_order_label helper.
  • Redundant full-catalog query. mm_change_sort called nearby_refresh()
    before sort(), ranking against a stale or empty tree that sort() then
    immediately redid. Dropped.
  • Unreachable message. Nearby.refresh() returns [] when there is no
    pointing, never None, but the caller tested is None before showing "No
    Solve Yet" — so the user saw "No objects match filter" instead. Now tested via
    Nearby.has_pointing().
  • Uncached index rebuild. sort() rebuilt the BallTree every call while
    UIChart already guards the identical rebuild on catalog_filter.dirty_time.
    The object list now uses the same guard, invalidated explicitly by
    refresh_object_list.
  • Honest type hint. get_closest_objects returns a NumPy object array, not
    a List.

Tests

tests/test_nearby.py placed every object at ra = 0 and its docstring called
the [ra, dec] ordering "the pre-existing convention" — that is the one line on
which 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_objects
ordering, the n cap, and great_circle_degrees (including the RA wrap and
over-the-pole cases).

Checked against the old code: two of the new tests fail on it.

Verification

  • ruff@0.4.8 check + format: pass
  • mypy PiFinder: pass (152 files)
  • pytest -m "smoke or unit": 1148 passed. Two failures are pre-existing macOS
    path-length limits (AF_UNIX path too long, File name too long) in
    test_sd_notify.py and test_solver_shmem.py, unrelated to this change.
  • pytest -m integration tests/test_ui_modules.py: 216 passed, 2 skipped

Behaviour changes users may notice

  • Nearby lists are truncated to 200 objects.
  • Objects ranked "near" only because of the swapped metric will move or vanish
    from the list. Anyone who learned the old ordering will see it change — that
    is the point.

🤖 Generated with Claude Code

@mrosseel
mrosseel force-pushed the fix/nearby-ranking-upstream branch from fa493dd to e1ff718 Compare August 17, 2026 17:56
@mrosseel mrosseel changed the title fix(nearby): correct haversine axis order, bound the ranking, hold the cursor on the nearest fix(nearby): rank against true angular distance, in a bounded window Aug 17, 2026
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
brickbots and others added 2 commits August 18, 2026 12:45
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
brickbots merged commit 50c5575 into brickbots:main Aug 19, 2026
4 checks passed
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>
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.

2 participants