From 085c73c236532690f8a9b123c975aa3c73f5964a Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 9 Jul 2026 19:10:39 -0700 Subject: [PATCH 1/4] fix(catalogs): keep filtered lists fresh as objects are logged and the sky rotates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two kinds of object state change under an unchanged filter and were never picked up by the filter cache until a parameter changed: - Logging an observation: Catalogs.mark_logged(obj) now sets obj.logged and bumps dirty_time when an observed criterion is active, so an "Observed: No" list drops the object on its next refresh (and a "Yes" list gains it). With observed == "Any" no verdict can change, so the cached lists survive. Logged status stays per catalog listing, matching check_logged's (catalog_code, sequence) keying. - Altitude drift: with an altitude criterion active, verdicts age out as the sky rotates (<= 15 deg/hour). CatalogFilter.is_stale() reports staleness (TTL 600s, bounding drift to ~2.5 deg) and also fires once when an alt/az fix arrives after verdicts were computed without one — closing the boot-time gap where everything "passed" the skipped altitude test forever. Catalogs.filter_catalogs() promotes staleness to a dirty bump so both cache layers re-evaluate; is_dirty() surfaces it to the screen-activation refresh path, and UIObjectList.update() polls it so a list screen left open refreshes in place. Without an altitude criterion is_stale() short-circuits, preserving the O(catalogs) list-open fast path from #526. Freshness policy rationale in docs/adr/0020; glossary updated (docs/ax/catalog/CONTEXT.md: Dirty time, Stale). Co-Authored-By: Claude Fable 5 --- ...20-filter-freshness-staleness-promotion.md | 90 ++++++++++ docs/ax/catalog.md | 36 +++- docs/ax/catalog/CONTEXT.md | 10 +- python/PiFinder/catalogs.py | 68 +++++++- python/PiFinder/ui/log.py | 2 +- python/PiFinder/ui/object_list.py | 8 + python/tests/test_catalog_filter_cache.py | 159 +++++++++++++++++- 7 files changed, 353 insertions(+), 20 deletions(-) create mode 100644 docs/adr/0020-filter-freshness-staleness-promotion.md diff --git a/docs/adr/0020-filter-freshness-staleness-promotion.md b/docs/adr/0020-filter-freshness-staleness-promotion.md new file mode 100644 index 000000000..1e0462ece --- /dev/null +++ b/docs/adr/0020-filter-freshness-staleness-promotion.md @@ -0,0 +1,90 @@ +# Filter freshness: event-driven dirty bumps plus lazy staleness promotion, no timer thread + +The filter cache (per-object verdicts and, since PR #526, per-catalog filtered +lists) is keyed on `dirty_time`, which historically advanced **only** when a +filter parameter changed. Two kinds of object state change *under* an unchanged +filter: an object gets **logged** (the observed criterion's input), and object +**altitudes drift** as the sky rotates (≤ 15°/hour). Both left lists wrong until +the user happened to touch a filter parameter. + +The decision: keep `mark_dirty()` as the **single invalidation concept**, and +add two freshness triggers that feed it — + +1. **Logging is an event**: `Catalogs.mark_logged(obj)` sets `obj.logged` and + bumps `dirty_time` — but only when an observed criterion is active + (`observed != "Any"`), since otherwise no verdict can change and the ~0.58 s + full re-scan would be wasted. +2. **Altitude staleness is a lazily-evaluated condition**: + `CatalogFilter.is_stale()` reports verdicts outdated by time — altitude + criterion active *and* alt/az available *and* (verdicts older than + `ALTITUDE_STALE_SECONDS = 600` *or* the alt/az fix arrived after verdicts + were computed without one). Staleness never invalidates by itself; + `Catalogs.filter_catalogs()` — the single "filter everything" entry point — + **promotes** it to a `mark_dirty()` so *both* cache layers re-evaluate. + `is_dirty()` surfaces staleness too, so the non-forced UI refresh paths + (screen activation) notice it, and `UIObjectList.update()` checks + `is_stale()` each frame so a list screen left open all night refreshes in + place — the same pattern as `Nearby.should_refresh()`. + +## Sizing the TTL + +Altitude drifts at most 15°/hour, so a 600 s cadence bounds the error to +~2.5° — well inside the 10°-step granularity the altitude filter is set in. +No user-visible setting: a good fixed default beats a config knob nobody can +reason about. + +## Considered options + +- **Periodic `mark_dirty()` from the UI loop** (a timer that bumps the filter + every N minutes while an altitude criterion is active). Rejected: it re-scans + even when nothing will read the result (no list on screen, next list open is + hours away), and it spreads the freshness rule across the UI layer. Lazy + promotion pays the scan exactly when a consumer asks for filtered objects. +- **Background timer thread.** Rejected: thread-safety cost (the filter and + catalogs are touched from the UI thread and the catalog loader thread + already) for no UX gain over lazy promotion — a list nobody is looking at + doesn't need fresh verdicts. +- **TTL check inside `Catalog.filter_objects()` only.** Rejected: defeating the + per-catalog guard is not enough — the per-object layer would still return + cached verdicts (`obj.last_filtered_time > dirty_time`), so the re-scan would + recompute nothing. Any freshness trigger must advance `dirty_time` itself; + hence promotion lives in `filter_catalogs()`, above both layers. +- **Per-object invalidation on logging** (touch only the logged object's cached + verdict). Rejected as over-engineering: log events are rare (the user just + spent minutes observing), so one full re-scan per log is cheap; and the + catalog-layer lists would still need selective invalidation for every catalog + containing the object. +- **Propagating `logged` to sibling listings of the same sky object** + (M 31 ≡ NGC 224 share an `object_id`). Rejected — deliberately: logged + status is **per catalog listing**. `ObservationsDatabase.check_logged` keys + on `(catalog_code, sequence)`, so build-time flags after a restart mark only + the listing that was logged. In-session propagation would show sky-object + semantics that silently revert to per-listing semantics on reboot. If + sky-object-level observed status is ever wanted, it must start in the DB + layer (key the observed cache by `object_id`), not in the filter. + +## Consequences + +- The no-altitude-criterion case never pays: `is_stale()` short-circuits on + `altitude == -1`, preserving PR #526's O(catalogs) list-open fast path. +- With an altitude criterion but no GPS lock, `is_stale()` stays False (alt/az + can't improve the verdicts), so there is no futile re-scan loop before a fix; + the lock's *arrival* is what triggers the one re-filter. This also closes the + boot-time gap where pre-lock verdicts skipped the altitude test entirely and + everything "passed" forever. +- Planet positions mutated in place every ~5 min get re-judged on the same + cadence whenever an altitude criterion is active; without one, a planet's + drifting constellation assignment can still go stale against a constellation + criterion — a pre-existing, cosmetically small gap left open. +- A list screen left open takes the ~0.58 s re-scan hiccup once per TTL. Judged + acceptable against silently wrong lists. +- The freshness clock is wall-clock (`time.time()`), inherited from the + existing cache timestamps. A backward clock step (PiFinder sets time from + GPS; no RTC) can leave `last_filtered*` in the future so `mark_dirty()` never + wins until restart — a pre-existing hazard this ADR does not fix. The known + remedy is a monotonic **generation counter** (int incremented by + `mark_dirty`, compared for equality), which would also fix the + loader-thread/UI-thread stamp race and the pickle-cache carrying stale + verdict timestamps across sessions. Deferred as follow-up work. +- Companion glossary: [`docs/ax/catalog/CONTEXT.md`](../ax/catalog/CONTEXT.md) + (Dirty time, Stale). diff --git a/docs/ax/catalog.md b/docs/ax/catalog.md index 595afdb99..03921fd78 100644 --- a/docs/ax/catalog.md +++ b/docs/ax/catalog.md @@ -216,16 +216,33 @@ plus a selected-catalogs set: ### 4.1 Dirty tracking Filtering is the hot path — every menu redraw asks for filtered objects. -The filter therefore caches per-object decisions: +Caching happens in two layers, both keyed against `dirty_time`: - Every setter calls `mark_dirty()`, bumping `dirty_time = time.time()`. -- `CompositeObject.last_filtered_time` and `.last_filtered_result` cache - the most recent decision for that object. -- `apply_filter(obj)` short-circuits if `obj.last_filtered_time > +- Per object: `CompositeObject.last_filtered_time` and + `.last_filtered_result` cache the most recent decision; + `apply_filter(obj)` short-circuits if `obj.last_filtered_time > self.dirty_time` — i.e. the object was filtered after the last change. - -So if the filter has not changed since the last sweep, the second sweep -is O(n) cache reads with no real predicate work. +- Per catalog: `Catalog.filter_objects()` returns its cached + `filtered_objects` list outright while `catalog.last_filtered > + dirty_time`; any object-set mutation (`add_object`, `add_objects`, + `clear_objects`) resets `last_filtered = 0` for that catalog. + +So if the filter has not changed since the last sweep, a list open is +O(catalogs) cache reads with no real predicate work. + +Two freshness triggers advance `dirty_time` besides the setters +([ADR 0020](../adr/0020-filter-freshness-staleness-promotion.md)): + +- **Logging**: `Catalogs.mark_logged(obj)` sets `obj.logged` and marks + dirty when an observed criterion is active, so "Observed: No" lists + drop the object on their next refresh. +- **Staleness promotion**: with an altitude criterion active, verdicts + age out as the sky rotates. `CatalogFilter.is_stale()` reports it + (TTL `ALTITUDE_STALE_SECONDS = 600`, or alt/az becoming available — + see 4.2); `Catalogs.filter_catalogs()` promotes it to a dirty bump, + and `UIObjectList.update()` polls it so an open list refreshes in + place. ### 4.2 Altitude requires GPS @@ -233,7 +250,10 @@ is O(n) cache reads with no real predicate work. location/datetime, *if* `shared_state.altaz_ready()`. When the alt-az calculator is missing, altitude is skipped (not rejected). This is why the altitude filter "stops working" without GPS — the predicate is -simply not evaluated. +simply not evaluated. The filter records whether alt/az was available +at the last sweep (`_last_filtered_altaz_ready`); a fix arriving later +makes `is_stale()` true, so the altitude predicate is applied on the +next refresh instead of everything staying "passed" all session. ### 4.3 Surprising "empty list = reject" behaviour diff --git a/docs/ax/catalog/CONTEXT.md b/docs/ax/catalog/CONTEXT.md index ec25401b4..a82eb4274 100644 --- a/docs/ax/catalog/CONTEXT.md +++ b/docs/ax/catalog/CONTEXT.md @@ -94,10 +94,14 @@ _Avoid_: observed (that's the user-facing twin — see below). User-facing: "I've seen this object." Surfaces as the `CatalogFilter.observed` parameter (`"Yes" | "No" | "Any"`) and in UI copy. The predicate test reads `obj.logged`, so the two terms refer to the same underlying state — they're deliberately split into a technical term (`logged`) and a colloquial one (`observed`). _Avoid_: logged (when speaking to users or in UI copy), seen. -**Dirty time** (`dirty_time` / `last_filtered_time`): -Pair of epoch timestamps that drive the per-object filter cache. An object is re-evaluated only when `obj.last_filtered_time < filter.dirty_time`. +**Dirty time** (`dirty_time` / `last_filtered_time` / `last_filtered`): +Epoch timestamps driving the two filter cache layers. `mark_dirty()` advances `filter.dirty_time`; an object's verdict is re-evaluated only when `obj.last_filtered_time < filter.dirty_time` (per-object layer), and a whole catalog skips its scan while `catalog.last_filtered > filter.dirty_time` (per-catalog layer). Beyond the parameter setters, dirty time also advances when an object is logged with an observed criterion active (`Catalogs.mark_logged`) and when staleness is promoted (see **Stale**). _Avoid_: invalidation, cache key. +**Stale** (filter staleness): +Verdicts outdated by time passing rather than by a parameter change — only possible for time-sensitive criteria, today just altitude (the sky rotates ≤ 15°/hour). `CatalogFilter.is_stale()` reports it: altitude criterion active, alt/az available, and either verdicts older than `ALTITUDE_STALE_SECONDS` (600 s ≈ 2.5° of drift) or an alt/az fix arrived after verdicts were computed without one. Staleness never invalidates by itself; `Catalogs.filter_catalogs()` promotes it to a dirty bump. See [ADR 0020](../../adr/0020-filter-freshness-staleness-promotion.md). +_Avoid_: dirty (that's a parameter change), expired. + **Empty-list rejection**: For `object_types` and `constellations`, an empty list rejects every object; only `None` (or `"Any"` for `observed`) means "don't filter on this dimension." Flagged here because it surprises callers. _Avoid_: empty filter, no filter. @@ -219,7 +223,7 @@ _Avoid_: readonly list, immutable view. **`logged`** on `CompositeObject` is set at build time from `ObservationsDatabase.check_logged(obj)`; the observations DB is read-only from the Catalog's perspective. -**`altaz_ready` / `FastAltAz`** come from the Positioning context — Catalog uses them only to gate the altitude filter. +**`altaz_ready` / `FastAltAz`** come from the Positioning context — Catalog uses them only to gate the altitude filter and to detect its staleness (see **Stale**). ## Flagged ambiguities diff --git a/python/PiFinder/catalogs.py b/python/PiFinder/catalogs.py index c936ecf9d..19bd4fdeb 100644 --- a/python/PiFinder/catalogs.py +++ b/python/PiFinder/catalogs.py @@ -94,6 +94,11 @@ class CatalogFilter: """can be set on catalog to filter""" fast_aa = None + # With an altitude criterion active, object altitudes drift as the sky + # rotates (<= 15 deg/hour), so cached verdicts age out even though no + # filter parameter changed. 600s bounds the drift to ~2.5 deg — well + # inside the 10-degree steps the altitude filter is set in. + ALTITUDE_STALE_SECONDS = 600 def __init__( self, @@ -116,6 +121,10 @@ def __init__( self._constellations = constellations self._selected_catalogs = set(selected_catalogs) self.last_filtered_time = 0 + # Whether alt/az was available when verdicts were last computed. + # Verdicts computed without it skip the altitude test entirely, so + # they go stale the moment a fix arrives (see is_stale). + self._last_filtered_altaz_ready = False def load_from_config(self, config_object: Config): """ @@ -190,7 +199,8 @@ def selected_catalogs(self, catalog_codes: list[str]): def calc_fast_aa(self, shared_state): location = shared_state.location() dt = shared_state.datetime() - if shared_state.altaz_ready(): + self._last_filtered_altaz_ready = shared_state.altaz_ready() + if self._last_filtered_altaz_ready: self.fast_aa = calc_utils.FastAltAz( location.lat, location.lon, @@ -203,14 +213,38 @@ def calc_fast_aa(self, shared_state): def is_dirty(self) -> bool: """ - Returns true if the filter parameters have changed since - the last filter. False if not + Returns true if the filtered verdicts need recomputing: a filter + parameter changed since the last filter (dirty), or time-sensitive + criteria have aged out (stale — see is_stale). False if not """ if self.last_filtered_time > self.dirty_time: - return False + return self.is_stale() else: return True + def is_stale(self) -> bool: + """ + Returns true when altitude verdicts are outdated even though no + filter parameter changed: enough time has passed that the sky has + rotated appreciably, or an alt/az fix arrived (GPS lock) after + verdicts were computed without one. + + Always false without an altitude criterion, so the + no-altitude-filter case keeps its O(catalogs) cached fast path. + Staleness does not invalidate anything by itself — see + Catalogs.filter_catalogs, which promotes it to a dirty bump. + """ + if self._altitude == -1: + return False + if self.last_filtered_time == 0: + # never filtered yet — is_dirty already reports True + return False + if not self._last_filtered_altaz_ready: + return self.shared_state.altaz_ready() + if time.time() - self.dirty_time > self.ALTITUDE_STALE_SECONDS: + return self.shared_state.altaz_ready() + return False + def apply_filter(self, obj: CompositeObject): if obj.last_filtered_time > self.dirty_time: return obj.last_filtered_result @@ -381,10 +415,36 @@ def __init__(self, catalogs: List[Catalog]): def filter_catalogs(self): """ Applies filter to all catalogs + + Staleness (time-sensitive criteria outdated, see + CatalogFilter.is_stale) is promoted to a dirty bump here so both + cache layers — per-object verdicts and per-catalog filtered lists — + re-evaluate, not just the catalog that noticed. """ + if self.catalog_filter is not None and self.catalog_filter.is_stale(): + self.catalog_filter.mark_dirty() for catalog in self.__catalogs: catalog.filter_objects() + def mark_logged(self, obj: CompositeObject) -> None: + """ + Record that obj was just logged (observed) and invalidate the + filter, so lists with an observed criterion reflect it on their + next refresh. Without an observed criterion no verdict can + change, so the cached lists are kept. + + Note: logged status is per catalog listing (check_logged keys on + catalog_code + sequence), so sibling listings of the same sky + object (M 31 / NGC 224) are intentionally not touched — matching + what check_logged reports after a restart. + """ + obj.logged = True + if self.catalog_filter is not None and self.catalog_filter.observed not in ( + None, + "Any", + ): + self.catalog_filter.mark_dirty() + def set_catalog_filter(self, catalog_filter: CatalogFilter) -> None: """ Sets the catalog filter object for all the catalogs diff --git a/python/PiFinder/ui/log.py b/python/PiFinder/ui/log.py index 7c2359d9e..6c09c487b 100644 --- a/python/PiFinder/ui/log.py +++ b/python/PiFinder/ui/log.py @@ -280,7 +280,7 @@ def record_object(self): solution=self.shared_state.solution(), notes=notes, ) - self.object.logged = True + self.catalogs.mark_logged(self.object) self.reset_config() def key_number(self, number: int): diff --git a/python/PiFinder/ui/object_list.py b/python/PiFinder/ui/object_list.py index 79dd2381c..1b9f55e08 100644 --- a/python/PiFinder/ui/object_list.py +++ b/python/PiFinder/ui/object_list.py @@ -515,6 +515,14 @@ def update(self, force: bool = False) -> None: else: self._was_loading = is_loading + # Altitude verdicts age out while the screen sits open (the sky + # rotates); refresh the list when the filter reports staleness. + # Before the no-objects check so an emptied-by-altitude list can + # repopulate as objects rise. + catalog_filter = self.catalogs.catalog_filter + if catalog_filter is not None and catalog_filter.is_stale(): + self.refresh_object_list() + # no objects to display if self.get_nr_of_menu_items() == 0: # Get catalog-specific status message if available diff --git a/python/tests/test_catalog_filter_cache.py b/python/tests/test_catalog_filter_cache.py index c66ef54bf..b418f03ca 100644 --- a/python/tests/test_catalog_filter_cache.py +++ b/python/tests/test_catalog_filter_cache.py @@ -5,11 +5,21 @@ parameter change advances dirty_time, and any object-set mutation (add_object/add_objects/clear_objects) resets last_filtered — either one must trigger a real re-filter on the next call. + +Freshness triggers layered on top of that contract (ADR 0020): +logging an object (Catalogs.mark_logged) invalidates when an observed +criterion is active, and altitude verdicts go stale as the sky rotates +or when an alt/az fix first arrives (CatalogFilter.is_stale, promoted +to a dirty bump by Catalogs.filter_catalogs). """ +import datetime +from types import SimpleNamespace +from typing import Optional + import pytest -from PiFinder.catalogs import Catalog, CatalogFilter +from PiFinder.catalogs import Catalog, CatalogFilter, Catalogs from PiFinder.composite_object import CompositeObject, MagnitudeObject @@ -26,12 +36,35 @@ def altaz_ready(self): return False -def _make_obj(seq: int, mag: float = 10.0, logged: bool = False): +class FakeAltAzSharedState: + """Shared state with a switchable alt/az fix at lat 45N, lon 0. + + At that latitude an object at dec +89 is circumpolar (altitude always + >= 44 deg) and one at dec -89 never rises (altitude always <= -44 deg), + so altitude-filter verdicts are independent of RA and time of day. + """ + + def __init__(self, ready: bool = True): + self.ready = ready + + def location(self): + return SimpleNamespace(lat=45.0, lon=0.0) + + def datetime(self): + return datetime.datetime(2026, 7, 9, 3, 0, tzinfo=datetime.timezone.utc) + + def altaz_ready(self): + return self.ready + + +def _make_obj( + seq: int, mag: float = 10.0, logged: bool = False, dec: Optional[float] = None +): return CompositeObject( id=seq, object_id=seq, ra=10.0 * seq, - dec=1.0 * seq, + dec=1.0 * seq if dec is None else dec, catalog_code="TST", sequence=seq, description=f"obj {seq}", @@ -109,10 +142,128 @@ def test_emptied_catalog_never_serves_stale_list(catalog): @pytest.mark.unit def test_object_mutation_applied_after_mark_dirty(catalog): # Object attributes (like logged) changing does not itself invalidate; - # the next dirty_time advance must pick the new state up. + # the next dirty_time advance must pick the new state up. The sanctioned + # wrapper pairing the two for logging is Catalogs.mark_logged. catalog.catalog_filter.observed = "No" assert _sequences(catalog.filter_objects()) == [1, 2, 3] catalog.get_objects()[1].logged = True catalog.catalog_filter.mark_dirty() assert _sequences(catalog.filter_objects()) == [1, 3] + + +def _make_catalogs(cat: Catalog, shared_state, **filter_kwargs) -> Catalogs: + catalogs = Catalogs([cat]) + catalogs.set_catalog_filter( + CatalogFilter(shared_state=shared_state, **filter_kwargs) + ) + return catalogs + + +def _age_filter(catalog_filter: CatalogFilter, seconds: float) -> None: + """Simulate `seconds` of wall clock passing since the last dirty bump.""" + catalog_filter.dirty_time -= seconds + + +@pytest.mark.unit +def test_mark_logged_drops_object_from_observed_no_list(): + cat = Catalog("TST", "test catalog") + for seq in (1, 2, 3): + cat.add_object(_make_obj(seq)) + catalogs = _make_catalogs(cat, FakeSharedState(), observed="No") + catalogs.filter_catalogs() + assert _sequences(cat.get_filtered_objects()) == [1, 2, 3] + + catalogs.mark_logged(cat.get_objects()[1]) + catalogs.filter_catalogs() + assert _sequences(cat.get_filtered_objects()) == [1, 3] + + +@pytest.mark.unit +def test_mark_logged_appears_in_observed_yes_list(): + cat = Catalog("TST", "test catalog") + for seq in (1, 2, 3): + cat.add_object(_make_obj(seq)) + catalogs = _make_catalogs(cat, FakeSharedState(), observed="Yes") + catalogs.filter_catalogs() + assert _sequences(cat.get_filtered_objects()) == [] + + catalogs.mark_logged(cat.get_objects()[1]) + catalogs.filter_catalogs() + assert _sequences(cat.get_filtered_objects()) == [2] + + +@pytest.mark.unit +def test_mark_logged_without_observed_criterion_keeps_cache(): + # With observed == "Any" no verdict can change, so logging must not + # cost a re-scan — the cached filtered list survives. + cat = Catalog("TST", "test catalog") + for seq in (1, 2, 3): + cat.add_object(_make_obj(seq)) + catalogs = _make_catalogs(cat, FakeSharedState()) + catalogs.filter_catalogs() + first = cat.get_filtered_objects() + + catalogs.mark_logged(cat.get_objects()[1]) + assert cat.get_objects()[1].logged is True + catalogs.filter_catalogs() + assert cat.get_filtered_objects() is first + + +@pytest.mark.unit +def test_altitude_verdicts_refresh_after_ttl(): + cat = Catalog("TST", "test catalog") + cat.add_object(_make_obj(1, dec=89.0)) + cat.add_object(_make_obj(2, dec=-89.0)) + catalogs = _make_catalogs(cat, FakeAltAzSharedState(), altitude=10) + catalogs.filter_catalogs() + assert _sequences(cat.get_filtered_objects()) == [1] + + # Within the TTL the cached list is reused + first = cat.get_filtered_objects() + catalogs.filter_catalogs() + assert cat.get_filtered_objects() is first + + # Simulate the sky rotating: the below-horizon object "rises" while + # the TTL runs out — staleness must force fresh verdicts. + cat.get_objects()[1].dec = 89.0 + _age_filter(catalogs.catalog_filter, CatalogFilter.ALTITUDE_STALE_SECONDS + 1) + assert catalogs.catalog_filter.is_dirty() # refresh paths see staleness + catalogs.filter_catalogs() + assert _sequences(cat.get_filtered_objects()) == [1, 2] + + +@pytest.mark.unit +def test_no_ttl_refilter_without_altitude_criterion(): + # The TTL only applies with an altitude criterion; without one the + # cached list must survive any amount of elapsed time (PR #526 perf). + cat = Catalog("TST", "test catalog") + for seq in (1, 2, 3): + cat.add_object(_make_obj(seq)) + catalogs = _make_catalogs(cat, FakeAltAzSharedState()) + catalogs.filter_catalogs() + first = cat.get_filtered_objects() + + _age_filter(catalogs.catalog_filter, CatalogFilter.ALTITUDE_STALE_SECONDS + 1) + assert not catalogs.catalog_filter.is_stale() + assert not catalogs.catalog_filter.is_dirty() + catalogs.filter_catalogs() + assert cat.get_filtered_objects() is first + + +@pytest.mark.unit +def test_gps_lock_arrival_applies_altitude_filter(): + # Verdicts computed without an alt/az fix skip the altitude test, so + # everything passes; the fix arriving must trigger a real re-filter. + cat = Catalog("TST", "test catalog") + cat.add_object(_make_obj(1, dec=89.0)) + cat.add_object(_make_obj(2, dec=-89.0)) + shared_state = FakeAltAzSharedState(ready=False) + catalogs = _make_catalogs(cat, shared_state, altitude=10) + catalogs.filter_catalogs() + assert _sequences(cat.get_filtered_objects()) == [1, 2] + + shared_state.ready = True + assert catalogs.catalog_filter.is_stale() + catalogs.filter_catalogs() + assert _sequences(cat.get_filtered_objects()) == [1] From cf71236f231cda7d2e87649201feb3ce38a1ceb8 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 10 Jul 2026 12:11:34 -0700 Subject: [PATCH 2/4] feat(observations): derive observed status per sky object, not per listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log entries stay recorded per (catalog, sequence), but observed status now keys on object_id for DB-backed objects: the observed cache maps each logged listing to its object id at load, check_logged tests id membership, and Catalogs.mark_logged propagates to sibling composites in-session. Logging M 31 marks NGC 224 observed — durably across restarts and retroactively for historical logs. Virtual objects (negative, session-minted object_ids) keep the per-listing test: id-keyed status would cross-mark the shared -1 default or vanish on reboot. Supersedes ADR 0020's per-listing decision (amended in place — unpublished). Co-Authored-By: Claude Fable 5 --- ...20-filter-freshness-staleness-promotion.md | 62 ++++++++++--- docs/ax/catalog.md | 17 ++-- docs/ax/catalog/CONTEXT.md | 7 +- python/PiFinder/catalogs.py | 14 ++- python/PiFinder/db/observations_db.py | 71 +++++++++++++-- python/tests/test_catalog_filter_cache.py | 51 ++++++++++- python/tests/test_observed_identity.py | 86 +++++++++++++++++++ 7 files changed, 277 insertions(+), 31 deletions(-) create mode 100644 python/tests/test_observed_identity.py diff --git a/docs/adr/0020-filter-freshness-staleness-promotion.md b/docs/adr/0020-filter-freshness-staleness-promotion.md index 1e0462ece..729a7943d 100644 --- a/docs/adr/0020-filter-freshness-staleness-promotion.md +++ b/docs/adr/0020-filter-freshness-staleness-promotion.md @@ -1,4 +1,4 @@ -# Filter freshness: event-driven dirty bumps plus lazy staleness promotion, no timer thread +# Filter freshness and observed identity: event-driven dirty bumps, lazy staleness promotion, object_id-derived observed status The filter cache (per-object verdicts and, since PR #526, per-catalog filtered lists) is keyed on `dirty_time`, which historically advanced **only** when a @@ -10,8 +10,10 @@ the user happened to touch a filter parameter. The decision: keep `mark_dirty()` as the **single invalidation concept**, and add two freshness triggers that feed it — -1. **Logging is an event**: `Catalogs.mark_logged(obj)` sets `obj.logged` and - bumps `dirty_time` — but only when an observed criterion is active +1. **Logging is an event**: `Catalogs.mark_logged(obj)` sets `obj.logged` — + on the object and on every sibling composite sharing its non-negative + `object_id` (see *Observed status is a sky-object property* below) — and + bumps `dirty_time`, but only when an observed criterion is active (`observed != "Any"`), since otherwise no verdict can change and the ~0.58 s full re-scan would be wasted. 2. **Altitude staleness is a lazily-evaluated condition**: @@ -33,6 +35,41 @@ Altitude drifts at most 15°/hour, so a 600 s cadence bounds the error to No user-visible setting: a good fixed default beats a config knob nobody can reason about. +## Observed status is a sky-object property + +*(Amended 2026-07-10 — this supersedes the first version of this ADR, which +kept logged status per catalog listing.)* + +**Log entries** stay recorded per listing: `obs_objects` keys on +`(catalog, sequence)`, exactly as the user logged it — no schema change, no +migration. **Observed status** is derived per sky object on top of that: +when `ObservationsDatabase` loads its observed cache, each logged listing is +mapped to its `object_id` through the objects DB (a separate sqlite file; +the mapping is done Python-side — obs rows are few), and +`check_logged(obj)` tests a DB-backed object (`object_id >= 0`) by id +membership. Logging M 31 therefore marks NGC 224 observed — in the current +session (via `mark_logged` propagation), after a restart (re-derived at +cache load), and **retroactively** for every historical observation. + +The **virtual-id fallback is load-bearing**: `CompositeObject.object_id` +defaults to `-1`, and the `VirtualIDManager` mints session-only negative ids +for planets, comets, and coordinate objects — ids that are *not stable +across restarts*. Keying those by id would either cross-mark everything +sharing the default or silently lose status on reboot (logging Mars must +not mark Jupiter), so negative-id objects keep the `(catalog, sequence)` +test. Listings that no longer resolve to an object id (removed catalogs) +also stay listing-keyed. + +Durability options considered: + +- **In-memory-only propagation** (mark siblings in `mark_logged`, derive + nothing at load). Rejected: sky-object semantics that silently revert to + per-listing semantics on restart — the exact inconsistency the first + version of this ADR rejected propagation to avoid. +- **Schema migration adding `object_id` to `obs_objects`.** Rejected: it + stores derivable data, needs a backfill for every user's observations DB, + and would still need the listing fallback for virtual objects. + ## Considered options - **Periodic `mark_dirty()` from the UI loop** (a timer that bumps the filter @@ -54,14 +91,14 @@ reason about. spent minutes observing), so one full re-scan per log is cheap; and the catalog-layer lists would still need selective invalidation for every catalog containing the object. -- **Propagating `logged` to sibling listings of the same sky object** - (M 31 ≡ NGC 224 share an `object_id`). Rejected — deliberately: logged - status is **per catalog listing**. `ObservationsDatabase.check_logged` keys - on `(catalog_code, sequence)`, so build-time flags after a restart mark only - the listing that was logged. In-session propagation would show sky-object - semantics that silently revert to per-listing semantics on reboot. If - sky-object-level observed status is ever wanted, it must start in the DB - layer (key the observed cache by `object_id`), not in the filter. +- **Keeping logged status per catalog listing** (the first version of this + ADR). Superseded: the checkmark, the observed filter, and the details + screen all present observed-ness as a fact about the sky object, and + per-listing status made them contradict each other (M 31 observed, + NGC 224 "Not Logged"). The replacement starts in the DB layer — the + observed cache is keyed by `object_id` — with in-session `mark_logged` + propagation matching what the DB derives after a restart. See *Observed + status is a sky-object property* above. ## Consequences @@ -78,6 +115,9 @@ reason about. criterion — a pre-existing, cosmetically small gap left open. - A list screen left open takes the ~0.58 s re-scan hiccup once per TTL. Judged acceptable against silently wrong lists. +- Observed identity is **retroactive**: every historical observation marks its + sibling listings observed the moment this ships — an "Observed: No" NGC list + may visibly shrink after upgrade, and sibling rows gain the checkmark. - The freshness clock is wall-clock (`time.time()`), inherited from the existing cache timestamps. A backward clock step (PiFinder sets time from GPS; no RTC) can leave `last_filtered*` in the future so `mark_dirty()` never diff --git a/docs/ax/catalog.md b/docs/ax/catalog.md index 03921fd78..b545d728e 100644 --- a/docs/ax/catalog.md +++ b/docs/ax/catalog.md @@ -59,9 +59,12 @@ displays. It's a dataclass that merges three things: `object_id` — `ra`, `dec`, `obj_type`, `const`, `size`, `surface_brightness`, raw `mag` JSON. - Derived/auxiliary data — `names` (list of strings), `mag` - (`MagnitudeObject`), `mag_str` (display string), `logged` (looked up - in the observations DB), `last_filtered_time`/`last_filtered_result` - (used by the filter cache). + (`MagnitudeObject`), `mag_str` (display string), `logged` (derived + from the observations DB per sky object: any log entry under any of + the object's listings counts, keyed by `object_id`; virtual objects + key on their own listing — see ADR 0020), + `last_filtered_time`/`last_filtered_result` (used by the filter + cache). Two `CompositeObject`s are equal iff their `object_id`s match. That means the same underlying object referenced by multiple catalogs (e.g. @@ -234,9 +237,11 @@ O(catalogs) cache reads with no real predicate work. Two freshness triggers advance `dirty_time` besides the setters ([ADR 0020](../adr/0020-filter-freshness-staleness-promotion.md)): -- **Logging**: `Catalogs.mark_logged(obj)` sets `obj.logged` and marks - dirty when an observed criterion is active, so "Observed: No" lists - drop the object on their next refresh. +- **Logging**: `Catalogs.mark_logged(obj)` sets `obj.logged` — on the + object and its sibling composites sharing a non-negative `object_id` + (M 31 / NGC 224) — and marks dirty when an observed criterion is + active, so "Observed: No" lists drop the object on their next + refresh. - **Staleness promotion**: with an altitude criterion active, verdicts age out as the sky rotates. `CatalogFilter.is_stale()` reports it (TTL `ALTITUDE_STALE_SECONDS = 600`, or alt/az becoming available — diff --git a/docs/ax/catalog/CONTEXT.md b/docs/ax/catalog/CONTEXT.md index a82eb4274..4743a23ad 100644 --- a/docs/ax/catalog/CONTEXT.md +++ b/docs/ax/catalog/CONTEXT.md @@ -86,8 +86,11 @@ _Avoid_: matching objects, visible objects. The single `CompositeObject` currently being viewed in `UIObjectDetails` (info + push-to guidance). Not a property of a catalog — it's a UI cursor. _Avoid_: current object, active object, target object. +**Log entry**: +A row in the observations DB log table (`obs_objects`) — always recorded per catalog listing (`catalog`, `sequence`), exactly as the user logged it. Use this term for table rows / DB queries. + **Logged**: -Technical: the object has a row in the observations DB log table. The `CompositeObject.logged` bool is populated at build time from `ObservationsDatabase.check_logged(obj)`. Use this term when talking about table membership / DB queries / the field itself. +Technical: the underlying sky object has at least one log entry under any of its listings — derived by `object_id` for DB-backed objects, per (`catalog`, `sequence`) for virtual objects (their negative ids are session-minted, not stable across restarts). `CompositeObject.logged` caches this verdict: populated at build time from `ObservationsDatabase.check_logged(obj)` and kept consistent across sibling composites in-session by `Catalogs.mark_logged`. So logging M 31 marks NGC 224 logged too. Use this term for the field and the predicate. _Avoid_: observed (that's the user-facing twin — see below). **Observed**: @@ -221,7 +224,7 @@ _Avoid_: readonly list, immutable view. **`shared_state`** is referenced by Catalog but **owned by Positioning**. See [Positioning](../positioning/CONTEXT.md). -**`logged`** on `CompositeObject` is set at build time from `ObservationsDatabase.check_logged(obj)`; the observations DB is read-only from the Catalog's perspective. +**`logged`** on `CompositeObject` is set at build time from `ObservationsDatabase.check_logged(obj)`, which derives observed status per sky object (see **Logged**); the observations DB is read-only from the Catalog's perspective. **`altaz_ready` / `FastAltAz`** come from the Positioning context — Catalog uses them only to gate the altitude filter and to detect its staleness (see **Stale**). diff --git a/python/PiFinder/catalogs.py b/python/PiFinder/catalogs.py index 19bd4fdeb..bae42010b 100644 --- a/python/PiFinder/catalogs.py +++ b/python/PiFinder/catalogs.py @@ -433,12 +433,18 @@ def mark_logged(self, obj: CompositeObject) -> None: next refresh. Without an observed criterion no verdict can change, so the cached lists are kept. - Note: logged status is per catalog listing (check_logged keys on - catalog_code + sequence), so sibling listings of the same sky - object (M 31 / NGC 224) are intentionally not touched — matching - what check_logged reports after a restart. + Observed status is a sky-object property: sibling listings of + the same object (M 31 / NGC 224 share an object_id) are marked + too, matching what check_logged derives from the DB after a + restart. Virtual objects stay per listing — their negative + object_ids are minted per session, so id-keyed propagation + would cross-mark unrelated objects. """ obj.logged = True + if obj.object_id is not None and obj.object_id >= 0: + for sibling in self.get_objects(only_selected=False, filtered=False): + if sibling.object_id == obj.object_id: + sibling.logged = True if self.catalog_filter is not None and self.catalog_filter.observed not in ( None, "Any", diff --git a/python/PiFinder/db/observations_db.py b/python/PiFinder/db/observations_db.py index 7b532fd7c..06e3f908a 100644 --- a/python/PiFinder/db/observations_db.py +++ b/python/PiFinder/db/observations_db.py @@ -1,14 +1,18 @@ import json +import logging from pathlib import Path -from typing import Tuple +from typing import Optional, Tuple from sqlite3 import Connection, Cursor from PiFinder.db.db import Database import PiFinder.utils as utils from PiFinder.composite_object import CompositeObject +logger = logging.getLogger("Observations_DB") + class ObservationsDatabase(Database): def __init__(self, db_path: Path = utils.observations_db): + self._objects_db = None new_db = False if not db_path.exists(): new_db = True @@ -19,6 +23,37 @@ def __init__(self, db_path: Path = utils.observations_db): self.load_observed_objects_cache() + def _get_objects_db(self): + """ + The catalog objects DB — a separate sqlite file from this one. + Observed status is a property of the underlying sky object, so + listing keys (catalog, sequence) are mapped to object ids through + it. Opened lazily and kept for the life of this instance. + """ + if self._objects_db is None: + from PiFinder.db.objects_db import ObjectsDatabase + + self._objects_db = ObjectsDatabase() + return self._objects_db + + def _resolve_object_id(self, catalog: str, sequence: int) -> Optional[int]: + """ + Maps a listing to its objects-table id; None when the listing + doesn't resolve (virtual objects like planets, or log entries from + catalogs no longer installed). + """ + try: + row = self._get_objects_db().get_catalog_object_by_sequence( + catalog, sequence + ) + except Exception: + logger.warning( + "Objects DB unavailable; observed status stays per listing", + exc_info=True, + ) + return None + return None if row is None else row["object_id"] + def create_tables(self, force_delete: bool = False): """ Creates the base logging tables @@ -121,8 +156,11 @@ def log_object(self, session_uuid, obs_time, catalog, sequence, solution, notes) ) self.conn.commit() - # Update cache so filters reflect the new observation immediately + # Update caches so filters reflect the new observation immediately self.observed_objects_cache.add((catalog, sequence)) + object_id = self._resolve_object_id(catalog, sequence) + if object_id is not None and object_id >= 0: + self.observed_object_ids.add(object_id) observation_id = self.cursor.execute( "select last_insert_rowid() as id" @@ -143,15 +181,33 @@ def get_observed_objects(self): def load_observed_objects_cache(self) -> None: """ - (re)Loads the logged object cache + (re)Loads the logged object cache. + + Log entries are stored per listing (catalog, sequence), but + observed status is a property of the underlying sky object, so + each logged listing is also mapped to its object id — logging + M 31 marks NGC 224 observed too, retroactively for existing log + entries. Listings that don't resolve to an object id (virtual + objects, removed catalogs) stay listing-keyed only. """ self.observed_objects_cache: set[tuple[str, int]] = { (x["catalog"], x["sequence"]) for x in self.get_observed_objects() } + self.observed_object_ids: set[int] = set() + for catalog, sequence in self.observed_objects_cache: + object_id = self._resolve_object_id(catalog, sequence) + if object_id is not None and object_id >= 0: + self.observed_object_ids.add(object_id) def check_logged(self, obj_record: CompositeObject): """ - Returns true/false if this object has been observed + Returns true/false if this object has been observed. + + A DB-backed object (object_id >= 0) tests as logged when any of + its listings has a log entry. Virtual objects key on their own + (catalog, sequence) listing only: their negative object_ids are + minted per session, so id-keyed status would cross-mark + unrelated objects or vanish on restart. """ # safety check if self.observed_objects_cache is None: @@ -163,7 +219,12 @@ def check_logged(self, obj_record: CompositeObject): ) in self.observed_objects_cache: return True - return False + object_id = obj_record.object_id + return ( + object_id is not None + and object_id >= 0 + and object_id in self.observed_object_ids + ) def get_logs_for_object(self, obj_record: CompositeObject): """ diff --git a/python/tests/test_catalog_filter_cache.py b/python/tests/test_catalog_filter_cache.py index b418f03ca..8f2011e98 100644 --- a/python/tests/test_catalog_filter_cache.py +++ b/python/tests/test_catalog_filter_cache.py @@ -58,14 +58,19 @@ def altaz_ready(self): def _make_obj( - seq: int, mag: float = 10.0, logged: bool = False, dec: Optional[float] = None + seq: int, + mag: float = 10.0, + logged: bool = False, + dec: Optional[float] = None, + object_id: Optional[int] = None, + catalog_code: str = "TST", ): return CompositeObject( id=seq, - object_id=seq, + object_id=seq if object_id is None else object_id, ra=10.0 * seq, dec=1.0 * seq if dec is None else dec, - catalog_code="TST", + catalog_code=catalog_code, sequence=seq, description=f"obj {seq}", mag=MagnitudeObject([mag]), @@ -193,6 +198,46 @@ def test_mark_logged_appears_in_observed_yes_list(): assert _sequences(cat.get_filtered_objects()) == [2] +@pytest.mark.unit +def test_mark_logged_propagates_to_sibling_listings(): + # M 31 / NGC 224 pattern: one sky object listed in two catalogs. + # Observed status is a sky-object property, so logging either listing + # marks both — matching what check_logged derives after a restart. + cat_m = Catalog("M", "Messier") + cat_m.add_object(_make_obj(31, object_id=42, catalog_code="M")) + cat_ngc = Catalog("NGC", "New General Catalog") + cat_ngc.add_object(_make_obj(224, object_id=42, catalog_code="NGC")) + cat_ngc.add_object(_make_obj(225, object_id=43, catalog_code="NGC")) + catalogs = Catalogs([cat_m, cat_ngc]) + catalogs.set_catalog_filter( + CatalogFilter(shared_state=FakeSharedState(), observed="No") + ) + catalogs.filter_catalogs() + assert _sequences(cat_ngc.get_filtered_objects()) == [224, 225] + + catalogs.mark_logged(cat_m.get_objects()[0]) + assert cat_ngc.get_objects()[0].logged is True + catalogs.filter_catalogs() + assert _sequences(cat_m.get_filtered_objects()) == [] + assert _sequences(cat_ngc.get_filtered_objects()) == [225] + + +@pytest.mark.unit +def test_mark_logged_virtual_ids_stay_per_listing(): + # Virtual objects (planets, comets, coordinate objects) carry + # session-minted negative object_ids — and the CompositeObject + # default -1 is shared by many. Id-keyed propagation would + # cross-mark them: logging Mars must not mark Jupiter. + cat = Catalog("PL", "Planets") + cat.add_object(_make_obj(1, object_id=-1, catalog_code="PL")) + cat.add_object(_make_obj(2, object_id=-1, catalog_code="PL")) + catalogs = _make_catalogs(cat, FakeSharedState(), observed="No") + + catalogs.mark_logged(cat.get_objects()[0]) + assert cat.get_objects()[0].logged is True + assert cat.get_objects()[1].logged is False + + @pytest.mark.unit def test_mark_logged_without_observed_criterion_keeps_cache(): # With observed == "Any" no verdict can change, so logging must not diff --git a/python/tests/test_observed_identity.py b/python/tests/test_observed_identity.py new file mode 100644 index 000000000..2ac0cea12 --- /dev/null +++ b/python/tests/test_observed_identity.py @@ -0,0 +1,86 @@ +"""Tests for object_id-derived observed status (ADR 0020, amended). + +Log entries are recorded per catalog listing (catalog, sequence), but +observed status is a property of the underlying sky object: logging M 31 +must mark NGC 224 observed — in-session, after a restart, and +retroactively for historical log entries. Virtual objects (negative, +session-minted object_ids) stay keyed per listing, as do log entries +whose listing no longer resolves. +""" + +import pytest + +from PiFinder.composite_object import CompositeObject +from PiFinder.db.observations_db import ObservationsDatabase + +# M 31 and NGC 224 are the same sky object; NGC 7000 is unrelated. +LISTING_TO_OBJECT_ID = {("M", 31): 42, ("NGC", 224): 42, ("NGC", 7000): 77} + + +class MappedObservationsDatabase(ObservationsDatabase): + """ObservationsDatabase with the objects-DB lookups replaced by a + fixed listing<->object_id table (the real mapping lives in a separate + sqlite file not present in unit tests).""" + + def _resolve_object_id(self, catalog, sequence): + return LISTING_TO_OBJECT_ID.get((catalog, sequence)) + + def _resolve_listings(self, object_id): + return [ + listing for listing, oid in LISTING_TO_OBJECT_ID.items() if oid == object_id + ] + + +def _obj(catalog_code: str, sequence: int, object_id: int) -> CompositeObject: + return CompositeObject( + object_id=object_id, catalog_code=catalog_code, sequence=sequence + ) + + +def _log(db: ObservationsDatabase, catalog: str, sequence: int) -> None: + db.log_object("session-1", 1234567890, catalog, sequence, None, {}) + + +@pytest.fixture +def obs_db(tmp_path): + db = MappedObservationsDatabase(tmp_path / "observations.db") + yield db + db.close() + + +@pytest.mark.unit +def test_logging_marks_sibling_listing_in_session(obs_db): + _log(obs_db, "M", 31) + assert obs_db.check_logged(_obj("M", 31, 42)) is True + assert obs_db.check_logged(_obj("NGC", 224, 42)) is True + assert obs_db.check_logged(_obj("NGC", 7000, 77)) is False + + +@pytest.mark.unit +def test_observed_status_derives_by_object_id_after_restart(tmp_path): + db = MappedObservationsDatabase(tmp_path / "observations.db") + _log(db, "M", 31) + db.close() + + reopened = MappedObservationsDatabase(tmp_path / "observations.db") + assert reopened.check_logged(_obj("NGC", 224, 42)) is True + assert reopened.check_logged(_obj("NGC", 7000, 77)) is False + reopened.close() + + +@pytest.mark.unit +def test_virtual_objects_key_per_listing(obs_db): + # Virtual objects share the -1 default (and session-minted negative + # ids aren't stable across restarts): logging Mars must not mark + # Jupiter, only the exact listing counts. + _log(obs_db, "PL", 1) + assert obs_db.check_logged(_obj("PL", 1, -1)) is True + assert obs_db.check_logged(_obj("PL", 2, -1)) is False + + +@pytest.mark.unit +def test_unresolved_listing_stays_listing_keyed(obs_db): + # A log entry from a catalog that no longer resolves to an object id + # keeps marking its own listing observed. + _log(obs_db, "GONE", 5) + assert obs_db.check_logged(_obj("GONE", 5, -1)) is True From fbfe064d3683e658155ddc209446bc4d5ea9080d Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 10 Jul 2026 12:12:19 -0700 Subject: [PATCH 3/4] feat(observations): show log entries from all sibling listings on details get_logs_for_object resolves a DB-backed object's sibling listings and returns log entries recorded under any of them, so NGC 224's details show M 31's logs instead of "Not Logged" while the row carries an observed checkmark. Virtual objects keep the per-listing query. Co-Authored-By: Claude Fable 5 --- ...20-filter-freshness-staleness-promotion.md | 8 +++- python/PiFinder/db/observations_db.py | 39 +++++++++++++++---- python/tests/test_observed_identity.py | 16 ++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/docs/adr/0020-filter-freshness-staleness-promotion.md b/docs/adr/0020-filter-freshness-staleness-promotion.md index 729a7943d..0caa52ef3 100644 --- a/docs/adr/0020-filter-freshness-staleness-promotion.md +++ b/docs/adr/0020-filter-freshness-staleness-promotion.md @@ -60,6 +60,11 @@ not mark Jupiter), so negative-id objects keep the `(catalog, sequence)` test. Listings that no longer resolve to an object id (removed catalogs) also stay listing-keyed. +The details screen follows the same identity: `get_logs_for_object` +resolves a DB-backed object's sibling listings and returns log entries +recorded under any of them, so NGC 224's details show M 31's logs ("1 +Logs") instead of contradicting the checkmark. + Durability options considered: - **In-memory-only propagation** (mark siblings in `mark_logged`, derive @@ -117,7 +122,8 @@ Durability options considered: acceptable against silently wrong lists. - Observed identity is **retroactive**: every historical observation marks its sibling listings observed the moment this ships — an "Observed: No" NGC list - may visibly shrink after upgrade, and sibling rows gain the checkmark. + may visibly shrink after upgrade, sibling rows gain the checkmark, and a + sibling's details show the combined log count. - The freshness clock is wall-clock (`time.time()`), inherited from the existing cache timestamps. A backward clock step (PiFinder sets time from GPS; no RTC) can leave `last_filtered*` in the future so `mark_dirty()` never diff --git a/python/PiFinder/db/observations_db.py b/python/PiFinder/db/observations_db.py index 06e3f908a..7409c6d8a 100644 --- a/python/PiFinder/db/observations_db.py +++ b/python/PiFinder/db/observations_db.py @@ -1,7 +1,7 @@ import json import logging from pathlib import Path -from typing import Optional, Tuple +from typing import List, Optional, Tuple from sqlite3 import Connection, Cursor from PiFinder.db.db import Database import PiFinder.utils as utils @@ -54,6 +54,21 @@ def _resolve_object_id(self, catalog: str, sequence: int) -> Optional[int]: return None return None if row is None else row["object_id"] + def _resolve_listings(self, object_id: int) -> List[Tuple[str, int]]: + """ + Maps an objects-table id to all of its catalog listings (the + sibling designations of one sky object, e.g. M 31 / NGC 224). + """ + try: + rows = self._get_objects_db().get_catalog_objects_by_object_id(object_id) + except Exception: + logger.warning( + "Objects DB unavailable; log entries stay per listing", + exc_info=True, + ) + return [] + return [(row["catalog_code"], row["sequence"]) for row in rows] + def create_tables(self, force_delete: bool = False): """ Creates the base logging tables @@ -228,15 +243,23 @@ def check_logged(self, obj_record: CompositeObject): def get_logs_for_object(self, obj_record: CompositeObject): """ - Returns a list of observations for a particular object + Returns a list of log entries for the underlying sky object: for + a DB-backed object, entries recorded under any of its listings + (M 31's logs show on NGC 224's details too); virtual objects stay + per listing. """ + listings: List[Tuple[str, int]] = [] + object_id = obj_record.object_id + if object_id is not None and object_id >= 0: + listings = self._resolve_listings(object_id) + home = (obj_record.catalog_code, obj_record.sequence) + if home not in listings: + listings.append(home) + + predicate = " or ".join(["(catalog = ? and sequence = ?)"] * len(listings)) + params = [value for listing in listings for value in listing] logs = self.cursor.execute( - """ - select * from obs_objects - where catalog = :catalog - and sequence = :sequence - """, - {"catalog": obj_record.catalog_code, "sequence": obj_record.sequence}, + f"select * from obs_objects where {predicate}", params ).fetchall() return logs diff --git a/python/tests/test_observed_identity.py b/python/tests/test_observed_identity.py index 2ac0cea12..5a7d67997 100644 --- a/python/tests/test_observed_identity.py +++ b/python/tests/test_observed_identity.py @@ -84,3 +84,19 @@ def test_unresolved_listing_stays_listing_keyed(obs_db): # keeps marking its own listing observed. _log(obs_db, "GONE", 5) assert obs_db.check_logged(_obj("GONE", 5, -1)) is True + + +@pytest.mark.unit +def test_details_logs_combine_sibling_listings(obs_db): + _log(obs_db, "M", 31) + _log(obs_db, "NGC", 224) + assert len(obs_db.get_logs_for_object(_obj("NGC", 224, 42))) == 2 + assert len(obs_db.get_logs_for_object(_obj("M", 31, 42))) == 2 + assert len(obs_db.get_logs_for_object(_obj("NGC", 7000, 77))) == 0 + + +@pytest.mark.unit +def test_details_logs_stay_per_listing_for_virtual_objects(obs_db): + _log(obs_db, "PL", 1) + assert len(obs_db.get_logs_for_object(_obj("PL", 1, -1))) == 1 + assert len(obs_db.get_logs_for_object(_obj("PL", 2, -1))) == 0 From 889d837257aa22e9ba3fa5f378fa568c8702c5d3 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 10 Jul 2026 12:12:53 -0700 Subject: [PATCH 4/4] fix(ui): keep the cursor on target across object-list refreshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refresh_object_list() re-sorts and reset the cursor to the top, so logging an object (or the altitude-staleness refresh on a left-open list) dumped the user back at the start of the list. Now the cursor re-locates the previously selected object; if it was filtered out, it falls to the first surviving old successor — the natural next target — clamping as a last resort. The NEAREST auto re-sort follows the same rule, so the selection tracks the object through the distance ranking. Matching is by (catalog_code, sequence): CompositeObject.__eq__ compares object_id alone and would land the cursor on a sibling listing (M 31 == NGC 224). Co-Authored-By: Claude Fable 5 --- ...20-filter-freshness-staleness-promotion.md | 7 ++ docs/ax/catalog.md | 4 +- python/PiFinder/ui/object_list.py | 46 +++++++++++++ python/tests/test_object_list_cursor.py | 65 +++++++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 python/tests/test_object_list_cursor.py diff --git a/docs/adr/0020-filter-freshness-staleness-promotion.md b/docs/adr/0020-filter-freshness-staleness-promotion.md index 0caa52ef3..cbf94110f 100644 --- a/docs/adr/0020-filter-freshness-staleness-promotion.md +++ b/docs/adr/0020-filter-freshness-staleness-promotion.md @@ -120,6 +120,13 @@ Durability options considered: criterion — a pre-existing, cosmetically small gap left open. - A list screen left open takes the ~0.58 s re-scan hiccup once per TTL. Judged acceptable against silently wrong lists. +- List refreshes keep the cursor on the selected object; if it was filtered + out (just logged, or set below the altitude criterion), the cursor falls to + the object that *followed it in the old order* — the natural next target — + instead of resetting to the top (`_next_target_index` in + `ui/object_list.py`; matching is by `(catalog_code, sequence)` because + `CompositeObject.__eq__` compares `object_id` alone and would land on a + sibling listing). - Observed identity is **retroactive**: every historical observation marks its sibling listings observed the moment this ships — an "Observed: No" NGC list may visibly shrink after upgrade, sibling rows gain the checkmark, and a diff --git a/docs/ax/catalog.md b/docs/ax/catalog.md index b545d728e..8dc1779cd 100644 --- a/docs/ax/catalog.md +++ b/docs/ax/catalog.md @@ -241,7 +241,9 @@ Two freshness triggers advance `dirty_time` besides the setters object and its sibling composites sharing a non-negative `object_id` (M 31 / NGC 224) — and marks dirty when an observed criterion is active, so "Observed: No" lists drop the object on their next - refresh. + refresh. The refresh keeps the cursor on the selected object, or + moves it to the old successor when the selection itself dropped out + (`_next_target_index`). - **Staleness promotion**: with an altitude criterion active, verdicts age out as the sky rotates. `CatalogFilter.is_stale()` reports it (TTL `ALTITUDE_STALE_SECONDS = 600`, or alt/az becoming available — diff --git a/python/PiFinder/ui/object_list.py b/python/PiFinder/ui/object_list.py index 1b9f55e08..c3c052888 100644 --- a/python/PiFinder/ui/object_list.py +++ b/python/PiFinder/ui/object_list.py @@ -61,6 +61,36 @@ class SortOrder(Enum): RA = 3 # By RA +def _next_target_index( + new_order: list, + old_order: list, + old_index: int, +) -> int: + """ + Where the cursor lands after a list rebuild: on the previously + selected object if it survived, else on the first of its old + successors that did (the natural next target once the selection was + logged or dropped below the altitude filter), clamped as a last + resort. + + Matches listings by (catalog_code, sequence) — CompositeObject.__eq__ + compares object_id alone, which would land on a *sibling* listing + (M 31 == NGC 224). + """ + if not len(new_order) or not len(old_order): + return 0 + index_by_listing = {} + for index, obj in enumerate(new_order): + key = (obj.catalog_code, obj.sequence) + if key not in index_by_listing: + index_by_listing[key] = index + for candidate in old_order[old_index:]: + new_index = index_by_listing.get((candidate.catalog_code, candidate.sequence)) + if new_index is not None: + return new_index + return min(max(old_index, 0), len(new_order) - 1) + + class UIObjectList(UITextMenu): """ Displayes a list of objects @@ -87,6 +117,7 @@ def __init__(self, *args, **kwargs) -> None: self.mount_type = self.config_object.get_option("mount_type") self._menu_items: list[CompositeObject] = [] + self._menu_items_sorted: list[CompositeObject] = [] self.catalog_info_1: str = "" self.catalog_info_2: str = "" self._was_loading: bool = False # Track loading state to detect completion @@ -177,6 +208,11 @@ def refresh_object_list(self, force_update=False): if not self.catalogs.catalog_filter.is_dirty() and not force_update: return + # sort() resets the cursor to the top; keep it on the selected + # object (or its old successor) across the rebuild instead. + old_order = self._menu_items_sorted + old_index = self._current_item_index + self.catalogs.filter_catalogs() # The object list can display objects from various sources @@ -210,6 +246,9 @@ def refresh_object_list(self, force_update=False): self.catalog_info_1 = str(self.get_nr_of_menu_items()) self._menu_items_sorted = self._menu_items self.sort() + self._current_item_index = _next_target_index( + self._menu_items_sorted, old_order, old_index + ) def _get_catalog_status_message(self) -> Tuple[Optional[str], Optional[int]]: """ @@ -569,7 +608,14 @@ def update(self, force: bool = False) -> None: # should we refresh the nearby list? if self.current_sort == SortOrder.NEAREST and self.nearby.should_refresh(): + # keep the cursor on the selected object as it migrates + # through the distance ranking + old_order = self._menu_items_sorted + old_index = self._current_item_index self.nearby_refresh() + self._current_item_index = _next_target_index( + self._menu_items_sorted, old_order, old_index + ) # Draw sorting mode in the empty rows above the focus line if self._current_item_index < half: diff --git a/python/tests/test_object_list_cursor.py b/python/tests/test_object_list_cursor.py new file mode 100644 index 000000000..432a5998c --- /dev/null +++ b/python/tests/test_object_list_cursor.py @@ -0,0 +1,65 @@ +"""Tests for cursor preservation across object-list refreshes. + +refresh_object_list() rebuilds and re-sorts the list (logging with an +observed criterion, altitude staleness, NEAREST re-sorts); the cursor +follows the selected object instead of resetting to the top. If the +selection was filtered out, it falls to the first surviving old +successor — the natural next target — clamping as a last resort. +""" + +import pytest + +from PiFinder.composite_object import CompositeObject +from PiFinder.ui.object_list import _next_target_index + + +def _obj(sequence: int, object_id=None, catalog_code: str = "TST"): + return CompositeObject( + object_id=sequence if object_id is None else object_id, + catalog_code=catalog_code, + sequence=sequence, + ) + + +@pytest.mark.unit +def test_surviving_selection_stays_selected(): + old = [_obj(1), _obj(2), _obj(3)] + new = [_obj(2), _obj(3)] + assert _next_target_index(new, old, 1) == 0 + + +@pytest.mark.unit +def test_reordered_list_follows_object(): + old = [_obj(1), _obj(2), _obj(3)] + new = [_obj(3), _obj(1), _obj(2)] + assert _next_target_index(new, old, 1) == 2 + + +@pytest.mark.unit +def test_removed_selection_falls_to_old_successor(): + old = [_obj(1), _obj(2), _obj(3), _obj(4)] + new = [_obj(1), _obj(3), _obj(4)] + assert _next_target_index(new, old, 1) == 1 # lands on 3 + + +@pytest.mark.unit +def test_removed_last_item_clamps(): + old = [_obj(1), _obj(2), _obj(3)] + new = [_obj(1), _obj(2)] + assert _next_target_index(new, old, 2) == 1 # new last item + + +@pytest.mark.unit +def test_sibling_listing_is_not_a_match(): + # M 31 and NGC 224 share an object_id, so CompositeObject.__eq__ + # calls them equal — the cursor must not "find" the old selection in + # a sibling listing. + old = [_obj(31, object_id=42, catalog_code="M"), _obj(999)] + new = [_obj(224, object_id=42, catalog_code="NGC"), _obj(999)] + assert _next_target_index(new, old, 0) == 1 # falls to 999 + + +@pytest.mark.unit +def test_empty_lists_reset_to_top(): + assert _next_target_index([], [_obj(1)], 0) == 0 + assert _next_target_index([_obj(1)], [], 0) == 0