From 36ede2dd19c8be4820eebe8b5ad8db286155ea54 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 30 Jun 2026 19:25:51 -0700 Subject: [PATCH 1/2] Chart: revive target cross, add nearby-DSO markers + zoom-scaled mag filter The chart screen's target cross was dead code (ui_state.set_target had no callers after the object_details/object_list refactor, so ui_state.target() was always None), nearby catalog DSOs were never plotted, and on a fresh session with no observing list loaded the chart drew no markers at all. This wires the target to selection and adds a nearby-catalog marker layer: - object_details: set_target(self.object) in update_object_info() -- the one chokepoint for open/scroll/eyepiece-cycle -- so the cross tracks the last-viewed object immediately (no push-to, no dwell lag). - chart.plot_markers(): restructured into deduped layers. The target cross is drawn in its own pass at full brightness, independent of chart_dso (so it survives chart_dso=0), with its designator label when on-screen. When chart_dso > 0, the observing-list layer (unchanged) and the new nearby layer render at chart_dso brightness. Deduped by object_id with precedence target -> observing-list -> nearby. - Nearby layer: objects from the active "All Filtered" set within fov*0.75 deg, magnitude-filtered by a zoom-scaled limit (dso_mag_limit: mag 11 @ 5deg -> mag 7 @ 60deg, unknown mags hidden), capped at the brightest 20. The BallTree is rebuilt only when the catalog filter's dirty_time changes or deferred catalogs finish loading -- never per frame; the radius query runs on the new-solve path only. - nearby: add ClosestObjectsFinder.get_objects_within_radius (query_radius, haversine), reusing the existing M/NGC dedup. - main: mark the filter dirty on catalogs_fully_loaded so the chart's index rebuilds to include newly loaded (e.g. WDS) objects. - docs: add the "Target" term to docs/ax/ui/CONTEXT.md resolving the target vs selected-object distinction. Mag-curve endpoints and the radius margin are starting values to tune on-device. Tested: dso_mag_limit endpoints/clamp, get_objects_within_radius correctness, nearby mag/cap/dedup selection (stubbed Starfield), and set_target tracking on open+scroll via the real UIObjectDetails; verified live via the headless app (on-screen cross+label for M57, off-screen pointer for NGC 1, nearby markers rendering). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ax/ui/CONTEXT.md | 4 + python/PiFinder/main.py | 5 + python/PiFinder/nearby.py | 20 +++ python/PiFinder/ui/chart.py | 196 +++++++++++++++++++++++---- python/PiFinder/ui/object_details.py | 7 + python/tests/test_chart_markers.py | 184 +++++++++++++++++++++++++ python/tests/test_nearby.py | 70 ++++++++++ python/tests/test_ui_modules.py | 48 +++++++ 8 files changed, 510 insertions(+), 24 deletions(-) create mode 100644 python/tests/test_chart_markers.py create mode 100644 python/tests/test_nearby.py diff --git a/docs/ax/ui/CONTEXT.md b/docs/ax/ui/CONTEXT.md index edb04fbb..1dafc846 100644 --- a/docs/ax/ui/CONTEXT.md +++ b/docs/ax/ui/CONTEXT.md @@ -160,6 +160,10 @@ _Avoid_: equipment list, generated menu. The small mutable object (`state.py`) holding UI-process state — observing list, recent objects, target, message/hint timeouts, FPS flag. Installed on `shared_state` via `set_ui_state`; every module reads `shared_state.ui_state()`. _Avoid_: ui config, session state. +**Target** (`ui_state.target()`): +The most-recently **selected object**, mirrored into `UIState` by `UIObjectDetails` (`update_object_info`) so cross-screen consumers can mark it — the **chart** draws it as a full-brightness cross (+ off-screen pointer, and its designator label when on-screen), and telemetry records it. Distinct from the Catalog *selected object* (the live `UIObjectDetails` cursor, see [Catalog](../catalog/CONTEXT.md)): the target is the **persisted last selection**, surviving after you leave details. Not a push-to concept — it follows selection automatically. +_Avoid_: push-to target, goto target. + **Published UI state** (`serialize_current_ui_state`): The dict `MenuManager` writes to `shared_state.set_current_ui_state(...)` each redraw — `ui_type`, `title`, marking-menu options, and the active module's own `serialize_ui_state()`. This is what `/api/current-selection` reflects. _Avoid_: api state, ui snapshot. diff --git a/python/PiFinder/main.py b/python/PiFinder/main.py index b8fe1d9f..6755897d 100644 --- a/python/PiFinder/main.py +++ b/python/PiFinder/main.py @@ -792,6 +792,11 @@ def main( logger.info( "All catalogs loaded - WDS and extended catalogs available" ) + # Mark the filter dirty so downstream consumers that cache + # off dirty_time (e.g. the chart's nearby-DSO spatial index) + # rebuild to include the newly available objects. + if catalogs.catalog_filter is not None: + catalogs.catalog_filter.mark_dirty() menu_manager.message(_("Catalogs\nFully Loaded"), 2) elif ui_command == "test_mode": dt = timez.utc(2025, 6, 28, 11, 0, 0) diff --git a/python/PiFinder/nearby.py b/python/PiFinder/nearby.py index 47a11785..ea7c36a9 100644 --- a/python/PiFinder/nearby.py +++ b/python/PiFinder/nearby.py @@ -108,6 +108,26 @@ def get_closest_objects(self, ra, dec, n: int = 0) -> List[CompositeObject]: # logger.debug("Found %i objects, from %i objects, n=%i", len(results), nr_objects, n) return results + def get_objects_within_radius( + self, ra, dec, radius_deg: float + ) -> List[CompositeObject]: + """ + Returns every object within ``radius_deg`` great-circle degrees of + ra/dec (unordered). Uses the haversine BallTree's ``query_radius``, + so the radius is converted to radians. Returns ``[]`` when the tree + is empty. Unlike ``get_closest_objects`` (k-NN), this bounds the + result by angular distance rather than count -- what the chart needs + to plot the objects that actually fall inside the current field. + """ + if self._objects_balltree is None or self._objects is None: + return [] + if len(self._objects) == 0: + return [] + + query = [[np.deg2rad(ra), np.deg2rad(dec)]] + obj_ind = self._objects_balltree.query_radius(query, r=np.deg2rad(radius_deg)) + return list(self._objects[obj_ind[0]]) + def deduplicate_objects( unfiltered_objects: list[CompositeObject], diff --git a/python/PiFinder/ui/chart.py b/python/PiFinder/ui/chart.py index 154cd8cb..1372fa8b 100644 --- a/python/PiFinder/ui/chart.py +++ b/python/PiFinder/ui/chart.py @@ -21,10 +21,44 @@ from PiFinder import plot from PiFinder.ui.base import UIModule from PiFinder import calc_utils +from PiFinder.composite_object import MagnitudeObject +from PiFinder.nearby import ClosestObjectsFinder logger = logging.getLogger("Chart") +# --- Nearby-DSO marker tuning ------------------------------------------------ +# Starting values; tune on-device (see the chart-markers handoff). The radius +# query fetches catalog objects within ``fov * NEARBY_RADIUS_FACTOR`` degrees of +# the pointing, then the mag/cap filters below decide which get drawn. +NEARBY_RADIUS_FACTOR = 0.75 +# When more than this many objects survive the mag filter, keep the brightest. +NEARBY_MARKER_CAP = 20 +# Linear magnitude-limit curve over the chart's full zoom range: more zoomed in +# (small FOV) -> show dimmer objects. Endpoints are (fov_deg, mag_limit) pairs. +_MAG_LIMIT_LO = (5.0, 11.0) +_MAG_LIMIT_HI = (60.0, 7.0) + + +def dso_mag_limit(fov: float) -> float: + """ + Magnitude limit for nearby DSO markers as a function of chart FOV. + + Linear between the two hard-coded endpoints and clamped outside the + chart's zoom range: 5deg -> mag 11 (zoomed in, show dimmer objects), + 60deg -> mag 7 (zoomed out, only the brightest). Kept deliberately + separate from ``plot.Starfield.set_fov``'s *star* mag limit -- different + curve, different purpose (DSO markers vs Hipparcos stars). + """ + fov_lo, mag_lo = _MAG_LIMIT_LO + fov_hi, mag_hi = _MAG_LIMIT_HI + if fov <= fov_lo: + return mag_lo + if fov >= fov_hi: + return mag_hi + perc = (fov - fov_lo) / (fov_hi - fov_lo) + return mag_lo + (mag_hi - mag_lo) * perc + class UIChart(UIModule): __title__ = "CHART" @@ -43,6 +77,14 @@ def __init__(self, *args, **kwargs): self.fov = self.desired_fov self.set_fov(self.desired_fov) + # Spatial index for the "nearby catalog DSOs" marker layer. Rebuilt + # from the active "All Filtered" set only when the catalog filter + # changes (tracked via dirty_time) or deferred catalogs finish loading + # -- never per frame. The radius query itself runs on the new-solve + # path in plot_markers(). + self._nearby_finder = ClosestObjectsFinder() + self._nearby_filter_dirty_time = None + # Marking menu definition self.marking_menu = MarkingMenu( left=MarkingMenuOption(), @@ -55,42 +97,40 @@ def __init__(self, *args, **kwargs): def plot_markers(self): """ - Plot the contents of the observing list - and target if there is one + Plot the chart's DSO markers, in three deduped layers: + + * The **target** cross -- the last-viewed object (``ui_state.target()``) + -- always drawn at full brightness and independent of ``chart_dso``, + with its designator label when it's on-screen. + * The **observing list** (loaded from a saved list) -- always on, no + mag limit, uncapped. + * **Nearby catalog DSOs** -- objects from the active "All Filtered" set + that fall inside the field, magnitude-filtered by zoom and capped. + + Only called on the new-solve path, so the radius query runs at most + once per solve (~1-2 Hz). ``chart_dso`` scales the two DSO layers but + never the target cross. """ if not self.solution: return - marker_list = [] - vertex_objects = [] + W, H = self.display_class.resolution - # is there a target? + # --- Target cross: always drawn, full brightness, chart_dso-independent target = self.ui_state.target() - if target: - marker_list.append( - (plot.Angle(degrees=target.ra)._hours, target.dec, "target") - ) - if target.size.is_vertices: - vertex_objects.append(target) + exclude_ids = set() + if target is not None and target.ra is not None and target.dec is not None: + exclude_ids.add(target.object_id) + self._draw_target(target, W, H) marker_brightness = self.config_object.get_option("chart_dso", 128) if marker_brightness == 0: return - for obs_target in self.ui_state.observing_list(): - if obs_target.size.is_vertices: - vertex_objects.append(obs_target) - marker = OBJ_TYPE_MARKERS.get(obs_target.obj_type) - if marker: - marker_list.append( - ( - plot.Angle(degrees=obs_target.ra)._hours, - obs_target.dec, - marker, - ) - ) + # --- DSO layers (observing list + nearby), deduped against the target + marker_list, vertex_objects = self._collect_dso_markers(exclude_ids) - if marker_list != []: + if marker_list: marker_image = self.starfield.plot_markers( marker_list, ) @@ -112,6 +152,114 @@ def plot_markers(self): if len(screen_pts) >= 2: self.draw.line(screen_pts, fill=line_color, width=1) + def _draw_target(self, target, W, H): + """ + Draw the target cross (+ off-screen pointer) at full brightness, and + its designator label when the target is on-screen. Rendered separately + from the ``chart_dso``-scaled DSO layers so it stays fully bright and + visible even when ``chart_dso`` is 0. + """ + target_image = self.starfield.plot_markers( + [(plot.Angle(degrees=target.ra)._hours, target.dec, "target")] + ) + target_image = ImageChops.multiply( + target_image, + Image.new("RGB", self.display_class.resolution, self.colors.get(255)), + ) + self.screen.paste(ImageChops.add(self.screen, target_image)) + + # Designator label only when the cross itself is on-screen; off-screen + # the pointer arrow (drawn above) already communicates direction. + tx, ty = self.starfield.radec_to_xy(target.ra, target.dec) + if 0 <= tx < W and 0 <= ty < H: + self.draw.text( + (int(tx) + 6, int(ty) - 4), + target.display_name, + font=self.fonts.base.font, + fill=self.colors.get(255), + ) + + def _collect_dso_markers(self, exclude_ids): + """ + Build the marker list for the observing-list and nearby-catalog layers, + deduped by ``object_id`` with precedence target -> observing-list -> + nearby (``exclude_ids`` seeds the target). Returns + ``(marker_list, vertex_objects)`` where marker_list holds + ``(ra_hours, dec_deg, symbol)`` tuples for ``Starfield.plot_markers`` + and vertex_objects holds asterism-polyline objects (observing list + only; nearby markers are symbols only). + """ + marker_list = [] + vertex_objects = [] + seen = set(exclude_ids) + + # Observing list: always on, uncapped, no mag limit. + for obj in self.ui_state.observing_list(): + if obj.object_id in seen: + continue + seen.add(obj.object_id) + if obj.size.is_vertices: + vertex_objects.append(obj) + symbol = OBJ_TYPE_MARKERS.get(obj.obj_type) + if symbol: + marker_list.append((plot.Angle(degrees=obj.ra)._hours, obj.dec, symbol)) + + # Nearby catalog DSOs: symbols only. + for obj in self._get_nearby_markers(): + if obj.object_id in seen: + continue + seen.add(obj.object_id) + symbol = OBJ_TYPE_MARKERS.get(obj.obj_type) + if symbol: + marker_list.append((plot.Angle(degrees=obj.ra)._hours, obj.dec, symbol)) + + return marker_list, vertex_objects + + def _get_nearby_markers(self): + """ + Catalog objects near the current pointing to draw as nearby markers: + drawn from the active "All Filtered" set, restricted to drawable object + types, magnitude-filtered for the current FOV (unknown mags hidden), + and capped at ``NEARBY_MARKER_CAP`` keeping the brightest. + + The BallTree is (re)built only when the catalog filter's ``dirty_time`` + changes (filter edits) or deferred catalogs finish loading (which marks + the filter dirty) -- otherwise the cached tree is reused. The radius + query runs each call, but plot_markers only calls this on a new solve. + """ + if self.catalogs is None: + return [] + + catalog_filter = getattr(self.catalogs, "catalog_filter", None) + dirty_time = getattr(catalog_filter, "dirty_time", None) + if dirty_time != self._nearby_filter_dirty_time: + objects = self.catalogs.get_objects(only_selected=True, filtered=True) + self._nearby_finder.calculate_objects_balltree(objects) + self._nearby_filter_dirty_time = dirty_time + + aligned = self.solution.pointing.aligned.estimate + radius = self.fov * NEARBY_RADIUS_FACTOR + candidates = self._nearby_finder.get_objects_within_radius( + aligned.RA, aligned.Dec, radius + ) + + mag_limit = dso_mag_limit(self.fov) + eligible = [] + for obj in candidates: + if OBJ_TYPE_MARKERS.get(obj.obj_type) is None: + continue + mag = obj.mag + if mag is None: + continue + filter_mag = mag.filter_mag + if filter_mag == MagnitudeObject.UNKNOWN_MAG or filter_mag > mag_limit: + continue + eligible.append((filter_mag, obj)) + + # Brightest first, then keep at most NEARBY_MARKER_CAP. + eligible.sort(key=lambda pair: pair[0]) + return [obj for _, obj in eligible[:NEARBY_MARKER_CAP]] + def _draw_orientation_indicator(self, orientation: "ChartOrientation"): """ Draw a small "up" indicator at the top-left of the chart. diff --git a/python/PiFinder/ui/object_details.py b/python/PiFinder/ui/object_details.py index c142519a..e81a71b3 100644 --- a/python/PiFinder/ui/object_details.py +++ b/python/PiFinder/ui/object_details.py @@ -200,6 +200,13 @@ def update_object_info(self): """ Generates object text and loads object images """ + # Mirror the just-selected object into UIState as the chart/telemetry + # "target" (see docs/ax/ui/CONTEXT.md). This is the single chokepoint + # where the displayed object is (re)set -- open, scroll, eyepiece cycle + # and display-mode cycle all route through here -- so the chart's target + # cross and the telemetry poller track the last-viewed object. + self.ui_state.set_target(self.object) + # Title... self.title = self.object.display_name diff --git a/python/tests/test_chart_markers.py b/python/tests/test_chart_markers.py new file mode 100644 index 00000000..8fb16ca6 --- /dev/null +++ b/python/tests/test_chart_markers.py @@ -0,0 +1,184 @@ +""" +Unit tests for the chart's DSO-marker logic: the zoom-scaled magnitude limit +(``dso_mag_limit``), the nearby-catalog marker selection (mag filter + cap + +drawable-type filter, with a rebuild-on-dirty spatial index), and the +dedup/precedence in ``_collect_dso_markers``. + +``UIChart`` is exercised via ``__new__`` with hand-injected collaborators so +the tests never construct ``plot.Starfield`` (which needs ``hip_main.dat``) -- +only the pure marker-selection paths are under test here. +""" + +from types import SimpleNamespace + +import pytest + +# Installs the ``_()`` gettext builtin that PiFinder.ui modules rely on. +import PiFinder.i18n # noqa: F401 + +from PiFinder.composite_object import CompositeObject, MagnitudeObject, SizeObject +from PiFinder.nearby import ClosestObjectsFinder +from PiFinder.ui.chart import ( + NEARBY_MARKER_CAP, + UIChart, + dso_mag_limit, +) + + +def _dso(object_id, ra, dec, mag=None, obj_type="Gx", catalog_code="NGC", size=None): + magobj = MagnitudeObject([str(mag)]) if mag is not None else MagnitudeObject([]) + return CompositeObject( + object_id=object_id, + ra=ra, + dec=dec, + obj_type=obj_type, + catalog_code=catalog_code, + mag=magobj, + size=size if size is not None else SizeObject([]), + ) + + +def _solution(ra, dec): + est = SimpleNamespace(RA=ra, Dec=dec) + return SimpleNamespace( + pointing=SimpleNamespace(aligned=SimpleNamespace(estimate=est)) + ) + + +class _StubCatalogs: + """Minimal stand-in exposing what _get_nearby_markers reads.""" + + def __init__(self, objects, dirty_time=1.0): + self._objects = objects + self.catalog_filter = SimpleNamespace(dirty_time=dirty_time) + + def get_objects(self, only_selected=True, filtered=True): + return list(self._objects) + + +def _chart(catalogs, solution, fov=5.0, observing_list=()): + chart = UIChart.__new__(UIChart) + chart.catalogs = catalogs + chart._nearby_finder = ClosestObjectsFinder() + chart._nearby_filter_dirty_time = None + chart.solution = solution + chart.fov = fov + chart.ui_state = SimpleNamespace(observing_list=lambda: list(observing_list)) + return chart + + +@pytest.mark.unit +class TestDsoMagLimit: + def test_endpoints(self): + assert dso_mag_limit(5.0) == 11.0 + assert dso_mag_limit(60.0) == 7.0 + + def test_clamps_outside_zoom_range(self): + assert dso_mag_limit(1.0) == 11.0 # below the 5deg endpoint + assert dso_mag_limit(120.0) == 7.0 # above the 60deg endpoint + + def test_linear_midpoint(self): + # Halfway through the FOV range -> halfway through the mag range. + assert dso_mag_limit(32.5) == pytest.approx(9.0, abs=1e-6) + + def test_monotonic_decreasing(self): + assert dso_mag_limit(10.0) > dso_mag_limit(30.0) > dso_mag_limit(50.0) + + +@pytest.mark.unit +class TestNearbyMarkerSelection: + def test_hides_unknown_faint_and_undrawable(self): + objs = [ + _dso(1, 0.0, 0.0, mag=8.0, obj_type="Gx"), # bright galaxy -> keep + _dso(2, 0.0, 0.2, mag=12.0, obj_type="Gx"), # fainter than limit -> drop + _dso(3, 0.0, 0.3, mag=None, obj_type="Gx"), # unknown mag -> drop + _dso(4, 0.0, 0.4, mag=6.0, obj_type="*"), # star: no marker -> drop + ] + chart = _chart(_StubCatalogs(objs), _solution(0.0, 0.0), fov=5.0) + result = chart._get_nearby_markers() + assert {o.object_id for o in result} == {1} + + def test_excludes_objects_outside_radius(self): + # At fov=5, radius = 5 * 0.75 = 3.75 deg. + objs = [ + _dso(1, 0.0, 0.0, mag=8.0), # centre + _dso(2, 0.0, 3.0, mag=8.0), # inside 3.75 deg + _dso(3, 0.0, 10.0, mag=8.0), # outside + ] + chart = _chart(_StubCatalogs(objs), _solution(0.0, 0.0), fov=5.0) + result = chart._get_nearby_markers() + assert {o.object_id for o in result} == {1, 2} + + def test_caps_and_keeps_brightest(self): + # 25 eligible galaxies; cap keeps the brightest NEARBY_MARKER_CAP. + objs = [ + _dso(i + 1, 0.0, i * 0.05, mag=5.0 + i * 0.1, obj_type="Gx") + for i in range(25) + ] + chart = _chart(_StubCatalogs(objs), _solution(0.0, 0.0), fov=5.0) + result = chart._get_nearby_markers() + assert len(result) == NEARBY_MARKER_CAP + # brightest 20 are object_ids 1..20 (mags 5.0..6.9). + assert {o.object_id for o in result} == set(range(1, NEARBY_MARKER_CAP + 1)) + + def test_index_rebuilds_only_when_filter_dirty(self): + cats = _StubCatalogs([_dso(1, 0.0, 0.0, mag=8.0)], dirty_time=1.0) + chart = _chart(cats, _solution(0.0, 0.0), fov=5.0) + + assert {o.object_id for o in chart._get_nearby_markers()} == {1} + + # Swap the underlying set but keep dirty_time -> cached tree, no rebuild. + cats._objects = [_dso(2, 0.0, 0.0, mag=8.0)] + assert {o.object_id for o in chart._get_nearby_markers()} == {1} + + # Bump dirty_time -> index rebuilds and reflects the new set. + cats.catalog_filter.dirty_time = 2.0 + assert {o.object_id for o in chart._get_nearby_markers()} == {2} + + def test_no_catalogs_returns_empty(self): + chart = _chart(None, _solution(0.0, 0.0), fov=5.0) + assert chart._get_nearby_markers() == [] + + +@pytest.mark.unit +class TestCollectDsoMarkers: + def test_dedupes_with_target_obslist_nearby_precedence(self): + obs = [_dso(2, 0.0, 0.1, mag=8.0, obj_type="Gx")] + nearby_objs = [ + _dso(1, 0.0, 0.0, mag=8.0, obj_type="Gx"), # == target -> excluded + _dso(2, 0.0, 0.1, mag=8.0, obj_type="Gx"), # dup of obs -> obs wins + _dso(3, 0.0, 0.2, mag=8.0, obj_type="Gx"), # nearby only -> kept + ] + chart = _chart( + _StubCatalogs(nearby_objs), + _solution(0.0, 0.0), + fov=5.0, + observing_list=obs, + ) + marker_list, vertex_objects = chart._collect_dso_markers({1}) + + # Exactly objects 2 and 3 (target 1 excluded, dup 2 not doubled). + assert len(marker_list) == 2 + decs = sorted(round(m[1], 3) for m in marker_list) + assert decs == [0.1, 0.2] + assert vertex_objects == [] + + def test_observing_list_vertices_collected_nearby_symbols_only(self): + asterism = _dso( + 5, + 0.0, + 0.1, + mag=8.0, + obj_type="Ast", + size=SizeObject([[0.0, 0.0], [1.0, 1.0]], geometry="polyline"), + ) + chart = _chart( + _StubCatalogs([]), # no nearby objects + _solution(0.0, 0.0), + fov=5.0, + observing_list=[asterism], + ) + marker_list, vertex_objects = chart._collect_dso_markers(set()) + + assert asterism in vertex_objects + assert len(marker_list) == 1 diff --git a/python/tests/test_nearby.py b/python/tests/test_nearby.py new file mode 100644 index 00000000..0316ab61 --- /dev/null +++ b/python/tests/test_nearby.py @@ -0,0 +1,70 @@ +""" +Unit tests for ``ClosestObjectsFinder.get_objects_within_radius`` -- the +radius (angular-distance) query the chart uses to find catalog objects that +fall inside the current field, distinct from the k-NN ``get_closest_objects`` +used by the object-list "Nearby" sort. + +The BallTree is built from ``[ra_rad, dec_rad]`` rows with the haversine +metric (the pre-existing convention). Along the ``ra=0`` meridian the haversine +distance reduces to exactly ``|dec|`` in radians, so these tests place objects +at ``ra=0`` and vary dec to assert an exact great-circle radius in degrees. +""" + +import pytest + +from PiFinder.composite_object import CompositeObject +from PiFinder.nearby import ClosestObjectsFinder + + +def _obj(object_id, ra, dec, catalog_code="NGC"): + return CompositeObject( + object_id=object_id, ra=ra, dec=dec, catalog_code=catalog_code + ) + + +@pytest.mark.unit +class TestGetObjectsWithinRadius: + def test_empty_finder_returns_empty(self): + finder = ClosestObjectsFinder() + assert finder.get_objects_within_radius(10.0, 20.0, 5.0) == [] + + def test_empty_object_set_returns_empty(self): + finder = ClosestObjectsFinder() + finder.calculate_objects_balltree([]) + assert finder.get_objects_within_radius(10.0, 20.0, 5.0) == [] + + def test_returns_only_objects_within_radius(self): + finder = ClosestObjectsFinder() + center = _obj(1, 0.0, 0.0) + near = _obj(2, 0.0, 2.0) # 2 deg away + far = _obj(3, 0.0, 20.0) # 20 deg away + finder.calculate_objects_balltree([center, near, far]) + + result = finder.get_objects_within_radius(0.0, 0.0, 5.0) + assert {o.object_id for o in result} == {1, 2} + + def test_radius_boundary_is_great_circle_degrees(self): + finder = ClosestObjectsFinder() + center = _obj(1, 0.0, 0.0) + five_north = _obj(2, 0.0, 5.0) # exactly 5 deg away + finder.calculate_objects_balltree([center, five_north]) + + # radius just under 5 deg excludes it, just over includes it + assert { + o.object_id for o in finder.get_objects_within_radius(0.0, 0.0, 4.0) + } == {1} + assert { + o.object_id for o in finder.get_objects_within_radius(0.0, 0.0, 6.0) + } == {1, 2} + + def test_deduplicates_by_object_id_with_catalog_precedence(self): + # Same object_id via M and NGC listings at the same coords; the M + # listing wins (deduplicate_objects precedence) and only one survives. + finder = ClosestObjectsFinder() + ngc = _obj(1, 0.0, 0.0, catalog_code="NGC") + messier = _obj(1, 0.0, 0.0, catalog_code="M") + finder.calculate_objects_balltree([ngc, messier]) + + result = finder.get_objects_within_radius(0.0, 0.0, 1.0) + assert len(result) == 1 + assert result[0].catalog_code == "M" diff --git a/python/tests/test_ui_modules.py b/python/tests/test_ui_modules.py index e94a5400..8fd784a6 100644 --- a/python/tests/test_ui_modules.py +++ b/python/tests/test_ui_modules.py @@ -644,6 +644,54 @@ def test_dynamic_ui_module( _build_and_exercise(item_definition, state, display, camera_image, catalogs) +@pytest.mark.integration +def test_object_details_tracks_target(display, camera_image, catalogs): + """UIObjectDetails mirrors the viewed object into ui_state.target(). + + The chart's target cross reads ui_state.target(); UIObjectDetails is the + single writer, setting it in update_object_info() so it tracks the + last-viewed object on both open and scroll (see the "Target" term in + docs/ax/ui/CONTEXT.md). + """ + cfg = Config() + shared_state = _make_shared_state("warm") + command_queues = _make_command_queues() + catalog_filter = CatalogFilter(shared_state=shared_state) + catalog_filter.load_from_config(cfg) + catalogs.set_catalog_filter(catalog_filter) + + # Two catalog objects with distinct object_ids (scroll_object indexes by + # equality, which is object_id-based). + objs = catalogs.get_objects(only_selected=False, filtered=False) + obj_a = objs[0] + obj_b = next((o for o in objs if o.object_id != obj_a.object_id), None) + if obj_b is None: + pytest.skip("need two distinct catalog objects for the scroll assertion") + + item_definition = { + "name": getattr(obj_a, "display_name", "Object"), + "class": UIObjectDetails, + "object": obj_a, + "object_list": [obj_a, obj_b], + "label": "object_details", + } + module = UIObjectDetails( + display, + camera_image, + shared_state, + command_queues, + cfg, + catalogs, + item_definition=item_definition, + ) + + # Set on open (update_object_info runs in __init__)... + assert shared_state.ui_state().target() is obj_a + # ...and updated on scroll. + module.scroll_object(1) + assert shared_state.ui_state().target() is obj_b + + @pytest.mark.integration def test_all_ui_modules_covered(): """Fail if a UIModule subclass is reached by neither discovery path. From 488be635f73eca6cb4d0b2516cae60469f298aff Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 1 Jul 2026 11:25:21 -0700 Subject: [PATCH 2/2] docs(user-guide): document chart object markers and target cross PR #513 revives the chart's target cross and makes nearby-DSO markers actually appear (magnitude-filtered by zoom). The Star Chart section claimed 'markers for nearby objects' but never explained them; add prose covering the object-type symbols, the filter/zoom behaviour and the DSO Display brightness tie-in, plus the last-viewed-object cross with its on-screen designation label and off-screen pointer. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/source/user_guide.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/source/user_guide.rst b/docs/source/user_guide.rst index 5cd82eec..974b2aab 100644 --- a/docs/source/user_guide.rst +++ b/docs/source/user_guide.rst @@ -346,6 +346,19 @@ where your telescope is pointing, with constellation lines and markers for nearb It redraws as you move the scope, so it's a quick way to see what's around you and confirm your aim. Zoom in and out with the **+/-** keys. +Deep-sky objects show as small symbols, one shape per object type. Near where you're +pointing the chart marks the objects your :ref:`filters ` allow, +revealing fainter ones as you zoom in and keeping only the brightest as you zoom out so the +field never crowds. Objects on a loaded observing list are marked too. Dim these markers, +or switch them off, with the DSO Display setting under Chart... in the +:ref:`user_guide:settings menu`. + +The object you last opened in :ref:`user_guide:object details` is marked with a brighter +cross wherever you steer, a quick way to see where it sits relative to your aim. On the +chart the cross is labelled with the object's designation — "M 57", say; once it drifts off +the edge an arrow at the rim points the way instead. The cross stays bright even with DSO +Display turned off. + How the chart is turned is up to you. Choose Coordinate Sys. under Chart... in the :ref:`user_guide:settings menu`: