Skip to content

Asteroids! - #534

Open
mrosseel wants to merge 495 commits into
brickbots:mainfrom
mrosseel:feature/observable-asteroids
Open

Asteroids!#534
mrosseel wants to merge 495 commits into
brickbots:mainfrom
mrosseel:feature/observable-asteroids

Conversation

@mrosseel

Copy link
Copy Markdown
Collaborator

Adds an observable asteroids dynamic catalog, mirroring the existing comet/planet catalogs.

  • Source: MPC Soft00Bright.txt (bright numbered asteroids), fetched via download_utils.
  • Propagation via Skyfield; filtered to currently visible objects, sortable by magnitude/distance.
  • Opposition-date info surfaced in object descriptions.
  • Menu + marking-menu entry, marker glyph, config migration, and test coverage.

Note on scope/diff size: this branch is stacked on deepchartnixossqm, so the diff against main includes that underlying work (same as the deepchart/nixos PRs). The asteroid-specific change is ~35 files (asteroids.py, asteroid_catalog.py, catalog wiring, tests).

Opened to build a testable NixOS image and land the build in the unstable update channel.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VaxurSrCzxrsfJrSjfA5bo

mrosseel and others added 30 commits June 26, 2026 10:50
…crashing

The external observing-lists feature added `list_descriptions` to
CompositeObject, but CACHE_VERSION was not bumped. Devices upgrading from the
prior release keep their existing composite_objects.pkl, whose unpickled
objects lack the new field (dataclass defaults are not applied on unpickle),
so opening any object's details crashes the whole app:

    AttributeError: 'CompositeObject' object has no attribute 'list_descriptions'

The main process hosts the multiprocessing shared-state manager, so its death
cascades BrokenPipe/connection-reset into every worker — the symptom seen in
the logs; the real cause was masked because main()'s handler logs via the
multiprocess queue and then os._exit()s before the record is written.

- catalog_cache: bump CACHE_VERSION 1 -> 2 so pre-list_descriptions caches are
  rebuilt on upgrade (the real fix for deployed devices).
- composite_object: getattr guard in composed_sections so a stale-cached object
  degrades gracefully instead of taking down the process.
- main: print + flush the traceback before os._exit so a fatal exception in
  main() lands in the journal instead of being lost to the log queue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjeCZ17KqhKzhKikWGwBDo
Camera switching only changed the persisted choice and ran
switch-to-configuration boot, but the generic-extlinux builder always writes
DEFAULT=nixos-default (the base camera) and device-tree overlays load only at
boot. So a device set to imx477 kept booting the base imx462 DTB, the imx290
driver bound to absent hardware (Error writing reg 0x3038), and the camera
never worked.

Fix A keeps the one-image/no-rebuild specialisation design and just makes the
chosen specialisation the boot default:
- set-extlinux-default: fail-safe helper that repoints extlinux DEFAULT to the
  latest-generation nixos-<gen>-<camera> entry (base camera -> nixos-default);
  leaves a bootable DEFAULT untouched if the entry is missing.
- pifinder-switch-camera: repoint DEFAULT to the chosen camera, then reboot
  (DT overlays are boot-only).
- nixos_upgrade: re-apply the persisted camera's DEFAULT after activation,
  before the upgrade reboot (every rebuild resets DEFAULT to base).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjeCZ17KqhKzhKikWGwBDo
The ADR said device update downloads ship only the genuinely-new chunks
(~80 MB for a 1.5 GB closure). That conflates server-side storage / CI-upload
dedup with the device download. Attic serves whole NARs over the standard
binary-cache protocol: a device fetches the full compressed NAR of every
changed store path, with no chunk-delta against the previous version. The only
device-side saving is path-level (unchanged paths are not refetched). True
client-side chunk-delta needs a casync/desync client with a local chunk store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjeCZ17KqhKzhKikWGwBDo
pifinder-src is rebuilt (new store hash) on every code change, and Attic ships
whole NARs — so each change re-downloads ~43MB and rewrites ~70MB to the SD,
even for a one-line edit. 56MB of that is stable: fonts (~31MB) and the pinned
tetra3/cedar-solve solver (~25MB, ~15MB after trimming examples/tests/docs).

Move both into their own derivations (like astro-data) and symlink them into
pifinder-src. A routine code change now rewrites only the ~16MB code path;
fonts/tetra3 are distributed once and shared across changes. tetra3 keeps
cedar_detect_pb2 (ships in the repo) and is pre-compiled; it is symlinked after
compileall so bytecode isn't written into the read-only store path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjeCZ17KqhKzhKikWGwBDo
Adds a 'Rollback' channel next to stable/beta/unstable that lists the on-disk
system generations you can roll back to (all but the current one).

- sys_utils.list_rollback_targets(): reads ONLY immutable generation data (the
  /nix/var/nix/profiles symlinks + store-path labels) — no sidecar JSON state to
  evolve or corrupt across up/downgrades. Entry = label + generation + date.
- software.py: the Rollback channel is built locally, so it's available even
  when the manifest fetch fails — i.e. exactly when you're stranded on a bad
  build. Select -> confirm -> reuses update_software() (the entry's ref is the
  generation's store path, so it activates + reboots with no download).
- nixos_upgrade.cleanup_old_generations: keep +3 (was +2) -> 2 rollback targets.

Labels currently come from the store-path name; a follow-up can set
system.nixos.label at build time for 'PR-379'-style names (and matching
bootloader entries).
Pin cedar-solve d8ff1d8 via uv2nix instead of the python/PiFinder/tetra3 git submodule. Removes the sys.path shims in solver/preview/utils, the pifinder-src tetra3 derivation, and .gitmodules. cedar-solve's stale numpy<2 / Pillow<9 caps are relaxed through uv dependency-metadata.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LMFdEnwncFyapQB38fiUHt
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LMFdEnwncFyapQB38fiUHt
… generations, capture evidence, announce rollback
…DR 0004), Type=notify readiness

- release.yml builds images.pifinder-migration and cuts the tarball from it
- SD image seeds /var/lib/pifinder/current-build.json with its own store path;
  version labels resolve via the update manifest; pifinder-build.json deleted
- pifinder.service Type=notify: app sends READY=1 once the UI is constructed
  and drawing (dev-safe no-op without NOTIFY_SOCKET) — the watchdog health
  check now means 'the screen is live'
…ery-hold hints on failure screens (ADR 0005/0006)
…6 dev boxes

python-libinput/rpi-gpio/rpi-hardware-pwm/python-prctl/dbus-python/picamera2/
spidev/pygobject/pyhotkey/pynput are aarch64-only: on dev machines they either
can't build (evdev kernel headers, imp removal, cairo/dbus C deps) or are
meaningless off-device (dev uses sys_utils_fake and the pygame keyboard).
The device env (uv2nix, aarch64) is unchanged. Dev flow: uv sync -> pytest
(CI marker set passes 735/735 under 3.13).
…rop the baked-target existence condition (ADR 0003)
mrosseel added 19 commits July 23, 2026 10:04
Telemetry menu under Settings > Telemetry:
- Record: inline On/Off toggle (no submenu), same pattern as Test Mode.
- Sections: one multi-select checklist with inline checkmarks (like the
  Catalogs filter) for what to record — IMU, SQM, Solves, Targets and
  Images. Toggling applies live to an in-progress recording.
- Max Size: session cap (250 MB / 500 MB / 1 GB / 2 GB / Unlimited).
- Load: unchanged.

Images ships OFF by default, preserving the old telemetry_images=false:
one 512x512 PNG is written per solve, so at ~1 solve/s it costs roughly
300 MB/hour against a few MB/hour for every other section combined.

Session size cap. Frames are written by the camera process, so the
recorder measures the session directory from its flush loop rather than
counting its own writes. On reaching the cap, frame capture is suspended
(the one unbounded consumer) while the event log keeps running, so a
capped session stays useful instead of going dark. Default 1 GB.

Also logs the SQM *ingredients*, not just the end products, so a session
stays recomputable if the SQM/airglow maths changes in code later:
- radio event gains the raw per-frame inputs — red/blue Bayer backgrounds,
  optical-black pedestal, photometry image size (green bg and exposure
  were already there).
- the session header snapshots the FULL camera profile constants plus the
  airglow calibration and camera_type — the constants those ingredients
  were produced under.
The derived published 'sqm' and applied 'floor' are still logged, but
explicitly as a what-the-device-showed audit trail, not ground truth.

The airglow import is optional: the model is part of the SQM stack only on
branches that carry it (deepchart has the radiometer but not airglow), and
there a session simply records no airglow constants.
Telemetry menu under Settings > Telemetry:
- Record: inline On/Off toggle (no submenu), same pattern as Test Mode.
- Sections: one multi-select checklist with inline checkmarks (like the
  Catalogs filter) for what to record — IMU, SQM, Solves, Targets and
  Images. Toggling applies live to an in-progress recording.
- Max Size: session cap (250 MB / 500 MB / 1 GB / 2 GB / Unlimited).
- Load: unchanged.

Images ships OFF by default, preserving the old telemetry_images=false:
one 512x512 PNG is written per solve, so at ~1 solve/s it costs roughly
300 MB/hour against a few MB/hour for every other section combined.

Session size cap. Frames are written by the camera process, so the
recorder measures the session directory from its flush loop rather than
counting its own writes. On reaching the cap, frame capture is suspended
(the one unbounded consumer) while the event log keeps running, so a
capped session stays useful instead of going dark. Default 1 GB.

Also logs the SQM *ingredients*, not just the end products, so a session
stays recomputable if the SQM/airglow maths changes in code later:
- radio event gains the raw per-frame inputs — red/blue Bayer backgrounds,
  optical-black pedestal, photometry image size (green bg and exposure
  were already there).
- the session header snapshots the FULL camera profile constants plus the
  airglow calibration and camera_type — the constants those ingredients
  were produced under.
The derived published 'sqm' and applied 'floor' are still logged, but
explicitly as a what-the-device-showed audit trail, not ground truth.

The airglow import is optional: the model is part of the SQM stack only on
branches that carry it (deepchart has the radiometer but not airglow), and
there a session simply records no airglow constants.
Telemetry menu under Settings > Telemetry:
- Record: inline On/Off toggle (no submenu), same pattern as Test Mode.
- Sections: one multi-select checklist with inline checkmarks (like the
  Catalogs filter) for what to record — IMU, SQM, Solves, Targets and
  Images. Toggling applies live to an in-progress recording.
- Max Size: session cap (250 MB / 500 MB / 1 GB / 2 GB / Unlimited).
- Load: unchanged.

Images ships OFF by default, preserving the old telemetry_images=false:
one 512x512 PNG is written per solve, so at ~1 solve/s it costs roughly
300 MB/hour against a few MB/hour for every other section combined.

Session size cap. Frames are written by the camera process, so the
recorder measures the session directory from its flush loop rather than
counting its own writes. On reaching the cap, frame capture is suspended
(the one unbounded consumer) while the event log keeps running, so a
capped session stays useful instead of going dark. Default 1 GB.

Also logs the SQM *ingredients*, not just the end products, so a session
stays recomputable if the SQM/airglow maths changes in code later:
- radio event gains the raw per-frame inputs — red/blue Bayer backgrounds,
  optical-black pedestal, photometry image size (green bg and exposure
  were already there).
- the session header snapshots the FULL camera profile constants plus the
  airglow calibration and camera_type — the constants those ingredients
  were produced under.
The derived published 'sqm' and applied 'floor' are still logged, but
explicitly as a what-the-device-showed audit trail, not ground truth.

The airglow import is optional: the model is part of the SQM stack only on
branches that carry it (deepchart has the radiometer but not airglow), and
there a session simply records no airglow constants.
pull_request_target defaults actions/checkout to the repository's default
branch. That branch carries this workflow so the event fires, but not
necessarily .github/scripts/, so update-manifest died with

    bash: .github/scripts/publish_manifest.sh: No such file or directory

after a successful 19-minute build -- the image was pushed to Attic but no
device could ever see it, because the manifest was never written.

Check out the PR's base branch instead: it is the branch this workflow file
itself came from, so the scripts are always beside it, and it stays
maintainer-controlled. Checking out the PR head here is not an option -- this
job holds contents:write and the Attic token.
The preview label did nothing testable did not. nixos-pr-build.yml checked
for either in both job conditions, and nothing downstream inspected the
label: update_manifest.py never receives it and writes every PR build to
channels["unstable"]. Upstream never had a preview label at all, so this
was fork-only. Removed from the workflow; the GitHub label is deleted and
testable's description now says what it actually does.

The channel docs claimed beta closures live in the retained,
GC-disabled pifinder-release cache. release.yml sends beta to the
short-retention pifinder cache instead -- "so prereleases stay
transient" -- so beta's rollback is durable only while its closure
survives GC. Corrected in ADR 0001, ADR 0002 and the nixos CONTEXT,
including the rollback-guarantee claims that followed from it. ADR 0002's
rejected "beta = live main head" option keeps its original reasoning with
an amendment banner noting the cache argument no longer distinguishes
beta; the curation symmetry with stable still does.

Also corrected: ADR 0003 said migration resolves the unstable trunk
because stable and beta are empty -- beta now holds v3.0.0-beta, so it
resolves beta. The "render the trunk row more prominently" delta in
ADR 0002 and the CONTEXT is implemented (software.py). The ADR README
index was missing 0004 through 0007.
chore(nixos): retire the preview label and correct stale channel docs
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
fix(nearby): rank against true angular distance, in a bounded window
@mrosseel
mrosseel force-pushed the feature/observable-asteroids branch 4 times, most recently from 96330f7 to 2d168b1 Compare August 18, 2026 18:35
mrosseel and others added 4 commits August 18, 2026 20:42
When cycling from Gaia chart (DM_IMAGE) to description (DM_DESC),
update_object_info() was unnecessarily regenerating the chart via a
generator, setting object_image=None. This caused a black screen
because the generator could exhaust before being consumed.

Gate get_display_image() behind object_display_mode == DM_IMAGE so
text-only modes preserve the existing object_image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mrosseel
mrosseel force-pushed the feature/observable-asteroids branch from 2d168b1 to c7ad04f Compare August 18, 2026 18:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

testable Ready for testing via PiFinder software update

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants