From 2148efc50e5f12b9ebad34c89c998ed51364375a Mon Sep 17 00:00:00 2001 From: Ed de la Rie Date: Wed, 20 May 2026 15:23:42 +0200 Subject: [PATCH 1/8] Fix CI headers and add compact weather forecast --- core/dashboard/dashboard.py | 10 +++++++- core/dashboard/templates/index.html | 38 +++++++++++++++++++++++++++++ dev/utils/log_maintenance.py | 7 +++++- dev/utils/raid_watchdog.py | 7 +++++- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/core/dashboard/dashboard.py b/core/dashboard/dashboard.py index 5da2f80..7e9d303 100755 --- a/core/dashboard/dashboard.py +++ b/core/dashboard/dashboard.py @@ -1056,7 +1056,15 @@ def index(): loc.get('elevation', 0.0), sun_limit, ) - return render_template('index.html', target_data=target_data, flight_window=fw_text) + co_lat = f"{float(loc.get('lat', 51.4769)):.2f}" + co_lon = f"{float(loc.get('lon', 0.0)):.2f}" + return render_template( + 'index.html', + target_data=target_data, + flight_window=fw_text, + clearoutside_url=f"https://clearoutside.com/forecast/{co_lat}/{co_lon}", + clearoutside_image=f"https://clearoutside.com/forecast_image_small/{co_lat}/{co_lon}/forecast.png", + ) @app.route('/telemetry') def get_telemetry(): diff --git a/core/dashboard/templates/index.html b/core/dashboard/templates/index.html index 8bf9ed5..be5e3a0 100755 --- a/core/dashboard/templates/index.html +++ b/core/dashboard/templates/index.html @@ -52,6 +52,18 @@ .grey { background-color: var(--led-grey); } .blue-grey { background-color: var(--led-bluegrey); box-shadow: inset 0 2px 4px rgba(0,0,0,0.5); border: 1px solid #333; color: transparent; } .weather-icon { background: none; box-shadow: none; font-size: 18px; color: transparent; } + .weather-forecast-card { margin: -2px 0 14px 0; padding: 8px 0 12px 0; border-bottom: 1px dashed #333; } + .weather-forecast-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 0.65em; letter-spacing: 1.5px; color: var(--text-muted); } + .weather-forecast-head a { color: #88ccff; text-decoration: none; } + .weather-forecast-img-wrap { display: block; max-width: 100%; height: 42px; overflow: hidden; border: 1px solid #252525; background: #050505; margin-bottom: 6px; } + .weather-forecast-img { width: 100%; max-width: 520px; transform: translateY(-18px); opacity: 0.82; filter: saturate(0.85) brightness(0.8); } + .weather-hours { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 3px; } + .weather-hour { border: 1px solid #252525; padding: 3px 2px; min-height: 30px; text-align: center; font-size: 0.62em; color: #777; overflow: hidden; } + .weather-hour.clear { color: var(--led-green); border-color: #1a3a0a; } + .weather-hour.cloudy { color: var(--led-orange); border-color: #3a2a00; } + .weather-hour.abort { color: var(--alert-red); border-color: #4f1d1d; } + .weather-hour-time { display: block; color: #555; } + .weather-hour-val { display: block; font-weight: bold; } .center-content { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 80%; text-align: center; } .icon-p { background: linear-gradient(135deg, #1e90ff, #005cbf); color: white; font-size: 3em; font-weight: bold; font-family: sans-serif; width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; border-radius: 16px; margin-bottom: 20px; box-shadow: 0 8px 16px rgba(0,0,0,0.5); transition: all 0.5s ease; } @@ -146,6 +158,16 @@

SYSTEM VITALS

DARK WINDOW {{ flight_window }}
GPS (LOCK)
WEATHER FETCHING
+
+
+ NIGHT FORECAST + CLEAROUTSIDE +
+ + ClearOutside forecast + +
+
FOG (VIS) DISCONNECTED
FLEET --
@@ -363,6 +385,22 @@

POSTFLIGHT AUDIT

if (stale && gpsLed) { // no-op placeholder to keep weather handling grouped } + + const weatherHours = document.getElementById('weather-hours'); + if (weatherHours) { + const hours = (data.weather.hourly_detail || []).slice(0, 6); + weatherHours.innerHTML = hours.map(h => { + const cls = h.abort ? 'abort' : (h.cloud_block ? 'cloudy' : 'clear'); + const clouds = Math.max( + Number(h.om_clouds || 0), + Number(h.met_clouds || 0), + Number(h.co_low || 0), + Number(h.co_mid || 0), + Number(h.co_high || 0) + ); + return `
${h.hour_utc || '--'}${Math.round(clouds)}%
`; + }).join(''); + } } if(data.orchestrator) { diff --git a/dev/utils/log_maintenance.py b/dev/utils/log_maintenance.py index 5567fe7..fb715a5 100644 --- a/dev/utils/log_maintenance.py +++ b/dev/utils/log_maintenance.py @@ -1,5 +1,10 @@ #!/usr/bin/env python3 -"""Rotate SeeVar application logs without relying on root logrotate.""" +# -*- coding: utf-8 -*- +""" +Filename: dev/utils/log_maintenance.py +Version: 1.0.0 +Objective: Rotate SeeVar application logs without relying on root logrotate. +""" from __future__ import annotations diff --git a/dev/utils/raid_watchdog.py b/dev/utils/raid_watchdog.py index 9a16618..50b49db 100644 --- a/dev/utils/raid_watchdog.py +++ b/dev/utils/raid_watchdog.py @@ -1,5 +1,10 @@ #!/usr/bin/env python3 -"""Check mdadm RAID health and publish a small SeeVar state file.""" +# -*- coding: utf-8 -*- +""" +Filename: dev/utils/raid_watchdog.py +Version: 1.0.0 +Objective: Check mdadm RAID health and publish a small SeeVar state file. +""" from __future__ import annotations From 728d3ea6efe920006baa7875189de1201963be28 Mon Sep 17 00:00:00 2001 From: Ed de la Rie Date: Mon, 25 May 2026 19:47:52 +0200 Subject: [PATCH 2/8] Harden flight pointing and accepted products --- config.toml.example | 2 + core/flight/fsm.py | 60 ++- core/flight/orchestrator.py | 490 +++++++++++++++++-- core/flight/pilot.py | 184 ++++++- core/postflight/accountant.py | 173 +++++++ dev/tests/postflight/test_dark_postflight.py | 30 +- 6 files changed, 856 insertions(+), 83 deletions(-) diff --git a/config.toml.example b/config.toml.example index 80627eb..3ab01b1 100644 --- a/config.toml.example +++ b/config.toml.example @@ -135,6 +135,7 @@ pointing_edge_margin_px = 250 verify_retention_sets = 40 pointing_model_enabled = true pointing_model_max_age_hours = 12.0 +pointing_reverify_interval_frames = 5 frame_retry_limit = 0 prealign_before_flight = false prealign_points = 3 @@ -199,6 +200,7 @@ plate_solve_timeout_sec = 90 plate_solve_cpulimit_sec = 75 auto_stage_reports = true report_mirror_dir = "/mnt/astronas/reports" +accepted_products_dir = "" auto_park = true auto_shutdown_scope = false diff --git a/core/flight/fsm.py b/core/flight/fsm.py index 2fbaa8b..86fa58e 100644 --- a/core/flight/fsm.py +++ b/core/flight/fsm.py @@ -6,6 +6,8 @@ Objective: Finite State Machine governing A1-A12 target execution and failure handling for Sovereign flight operations, with live bridge-state updates back into system_state.json. """ +from __future__ import annotations + import json import logging import sys @@ -24,11 +26,13 @@ STATE_FILE = DATA_DIR / "system_state.json" +# Function: _flight_cfg def _flight_cfg() -> dict: cfg = load_config() return cfg.get("flight", {}) if isinstance(cfg, dict) else {} +# Function: _cfg_int def _cfg_int(key: str, default: int) -> int: try: return int(round(float(_flight_cfg().get(key, default)))) @@ -37,38 +41,52 @@ def _cfg_int(key: str, default: int) -> int: FRAME_RETRY_LIMIT = max(0, _cfg_int("frame_retry_limit", 0)) +POINTING_REVERIFY_INTERVAL_FRAMES = max(1, _cfg_int("pointing_reverify_interval_frames", 5)) +TAG_STATE = { + "[A4]": "SLEWING", + "[A5]": "SLEWING", + "[A6]": "SLEWING", + "[A7]": "EXPOSING", + "[A8]": "TRACKING", + "[A10]": "EXPOSING", + "[A11]": "TRACKING", +} class SovereignFSM: + # Function: SovereignFSM.__init__ def __init__(self): self.state = "IDLE" self.telemetry: Optional[TelemetryBlock] = None self.last_prepared_target: Optional[AcquisitionTarget] = None self.last_frame_paths: list[Path] = [] + self.frame_retry_limit = FRAME_RETRY_LIMIT + self.pointing_reverify_interval_frames = POINTING_REVERIFY_INTERVAL_FRAMES self.sequence = DiamondSequence() logger.info("🧠 FSM Initialized in state: %s", self.state) + # Function: SovereignFSM.update def update(self, new_state: str): self.state = new_state logger.info("🔄 FSM State updated to: %s", self.state) + # Function: SovereignFSM.get_status def get_status(self) -> str: return self.state + # Function: SovereignFSM._bridge_ui_state def _bridge_ui_state(self, msg: str) -> str | None: - if msg.startswith("[A4]") or msg.startswith("[A5]") or msg.startswith("[A6]") or msg.startswith("[A7]") or msg.startswith("[A8]"): - return "SLEWING" - if msg.startswith("[A10]"): - return "EXPOSING" - if msg.startswith("[A11]"): - return "TRACKING" + for tag, state in TAG_STATE.items(): + if msg.startswith(tag): + return state return None + # Function: SovereignFSM._write_state_bridge def _write_state_bridge(self, state: str | None, msg: str): try: payload = {} if STATE_FILE.exists(): - payload = json.loads(STATE_FILE.read_text()) + payload = json.loads(STATE_FILE.read_text(encoding="utf-8")) if not isinstance(payload, dict): payload = {} @@ -85,10 +103,13 @@ def _write_state_bridge(self, state: str | None, msg: str): payload["updated"] = now_utc payload["updated_utc"] = now_utc - STATE_FILE.write_text(json.dumps(payload, indent=2)) + tmp_path = STATE_FILE.with_suffix(f"{STATE_FILE.suffix}.tmp") + tmp_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + tmp_path.replace(STATE_FILE) except Exception as e: logger.debug("State bridge write skipped: %s", e) + # Function: SovereignFSM.execute_target def execute_target( self, target: AcquisitionTarget, @@ -105,7 +126,13 @@ def execute_target( self.update("WORKING") self.last_prepared_target = None self.last_frame_paths = [] + self.frame_retry_limit = max(0, _cfg_int("frame_retry_limit", self.frame_retry_limit)) + self.pointing_reverify_interval_frames = max( + 1, + _cfg_int("pointing_reverify_interval_frames", self.pointing_reverify_interval_frames), + ) + # Function: SovereignFSM.execute_target.bridge def bridge(*parts): if len(parts) == 1: msg = parts[0] @@ -120,6 +147,7 @@ def bridge(*parts): if status_cb: status_cb(msg) + # Function: SovereignFSM.execute_target.abort_requested def abort_requested() -> bool: try: return bool(abort_cb and abort_cb()) @@ -153,6 +181,8 @@ def abort_requested() -> bool: return False target = self.sequence.prepare_target(target, telemetry=self.telemetry, notify=bridge) + if target is None: + raise RuntimeError("prepare_target returned None") self.last_prepared_target = target logger.info("[A10] Acquire %d frame(s) for %s", target.n_frames, target.name) @@ -168,18 +198,22 @@ def abort_requested() -> bool: frame_ok = False last_error = "" - for attempt in range(FRAME_RETRY_LIMIT + 1): + for attempt in range(self.frame_retry_limit + 1): if attempt == 0: - self._write_state_bridge("SLEWING", f"[A4] Executing frame {i + 1}/{target.n_frames} for {target.name}") + self._write_state_bridge("EXPOSING", f"[A10] Executing frame {i + 1}/{target.n_frames} for {target.name}") else: - logger.warning("[A10] Retrying frame %d/%d for %s (%d/%d)", i + 1, target.n_frames, target.name, attempt, FRAME_RETRY_LIMIT) - self._write_state_bridge("SLEWING", f"[A10] Retrying frame {i + 1}/{target.n_frames} for {target.name} ({attempt}/{FRAME_RETRY_LIMIT})") + logger.warning("[A10] Retrying frame %d/%d for %s (%d/%d)", i + 1, target.n_frames, target.name, attempt, self.frame_retry_limit) + self._write_state_bridge("EXPOSING", f"[A10] Retrying frame {i + 1}/{target.n_frames} for {target.name} ({attempt}/{self.frame_retry_limit})") + reverify_due = ( + successful_frames == 0 + or successful_frames % self.pointing_reverify_interval_frames == 0 + ) result = self.sequence.acquire( target=target, status_cb=bridge, telemetry=self.telemetry, - skip_pointing=(successful_frames > 0), + skip_pointing=not reverify_due, abort_callback=abort_requested, ) diff --git a/core/flight/orchestrator.py b/core/flight/orchestrator.py index 81daa3e..beb13fc 100755 --- a/core/flight/orchestrator.py +++ b/core/flight/orchestrator.py @@ -8,9 +8,12 @@ dark acquisition followed by postflight accounting. """ +from __future__ import annotations + import json import logging import math +import os import shutil import subprocess import sys @@ -25,6 +28,7 @@ from astropy.coordinates import AltAz, EarthLocation, SkyCoord, get_body from astropy.io import fits from astropy.time import Time +from PIL import Image PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) @@ -35,15 +39,22 @@ DiamondSequence, SEESTAR_HOST, GAIN, + ACTIVE_SCOPE_TAG, + POINTING_MODEL_ENABLED, + POINTING_MODEL_MAX_AGE_HOURS, + SETTLE_SECONDS, + SLEW_TIMEOUT, TelemetryBlock, VETO_BATTERY, FrameResult, + clear_config_cache, write_fits, sovereign_stamp, ) from core.flight.exposure_planner import plan_exposure from core.flight.dark_library import DarkLibrary from core.flight.neutralizer import enforce_zero_state +from core.flight.pointing_model import apply_pointing_model, load_pointing_model from core.preflight.vsx_catalog import get_target_mag from core.flight.fsm import SovereignFSM import core.ledger_manager as ledger_manager @@ -52,6 +63,7 @@ try: from core.preflight.horizon import required_altitude except Exception: + # Function: required_altitude def required_altitude(az: float, clearance_margin_deg: float = 0.0) -> float: return 15.0 + max(0.0, float(clearance_margin_deg)) @@ -63,33 +75,60 @@ def required_altitude(az: float, clearance_margin_deg: float = 0.0) -> float: _LOG_FILE = LOG_DIR / (f"orchestrator.{_LOG_SCOPE_ID}.log" if _LOG_SCOPE_ID else "orchestrator.log") _LOG_MAX_BYTES = 5 * 1024 * 1024 _LOG_BACKUP_COUNT = 5 +_LOG_HANDLERS = [ + RotatingFileHandler( + _LOG_FILE, + mode="a", + maxBytes=_LOG_MAX_BYTES, + backupCount=_LOG_BACKUP_COUNT, + ) +] + +if not os.environ.get("INVOCATION_ID"): + _LOG_HANDLERS.insert(0, logging.StreamHandler()) logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s — %(message)s", datefmt="%H:%M:%S", - handlers=[ - logging.StreamHandler(), - RotatingFileHandler( - _LOG_FILE, - mode="a", - maxBytes=_LOG_MAX_BYTES, - backupCount=_LOG_BACKUP_COUNT, - ), - ], + handlers=_LOG_HANDLERS, force=True, ) log = logging.getLogger("seevar.orchestrator") -PLAN_FILE = DATA_DIR / "tonights_plan.json" +PLAN_FILE = DATA_DIR / "flight_plan.json" STATE_FILE = DATA_DIR / "system_state.json" WEATHER_FILE = DATA_DIR / "weather_state.json" MISSION_FILE = DATA_DIR / "tonights_plan.json" FLEET_PLAN_DIR = DATA_DIR / "fleet_plans" COMMAND_FILE = DATA_DIR / "operator_command.json" +OVERRIDE_FILE = DATA_DIR / "operator_override.json" CATALOG_DIR = PROJECT_ROOT / "catalogs" - - +SECONDARY_REFERENCE_STARS = [ + ("Polaris", 2.530301, 89.2641), + ("Caph", 0.152887, 59.1502), + ("Schedar", 0.675122, 56.5373), + ("Mirfak", 3.405375, 49.8612), + ("Capella", 5.278155, 45.9980), + ("Aldebaran", 4.598677, 16.5093), + ("Betelgeuse", 5.919529, 7.4071), + ("Rigel", 5.242298, -8.2016), + ("Sirius", 6.752481, -16.7161), + ("Procyon", 7.655033, 5.2250), + ("Regulus", 10.139531, 11.9672), + ("Dubhe", 11.062130, 61.7510), + ("Mizar", 13.398750, 54.9254), + ("Alkaid", 13.792354, 49.3133), + ("Arcturus", 14.261021, 19.1825), + ("Kochab", 14.845109, 74.1555), + ("Spica", 13.419883, -11.1613), + ("Vega", 18.615649, 38.7837), + ("Altair", 19.846389, 8.8683), + ("Deneb", 20.690532, 45.2803), +] + + +# Function: _safe_load_json def _safe_load_json(path: Path, default: Any) -> Any: if not path.exists(): return default @@ -101,6 +140,7 @@ def _safe_load_json(path: Path, default: Any) -> Any: return default +# Function: _parse_plan_dt def _parse_plan_dt(value: Any) -> datetime | None: if not value: return None @@ -113,6 +153,22 @@ def _parse_plan_dt(value: Any) -> datetime | None: return None +# Function: _parse_ra_dec_deg +def _parse_ra_dec_deg(ra_raw: Any, dec_raw: Any) -> tuple[float, float]: + if isinstance(ra_raw, (int, float)) and isinstance(dec_raw, (int, float)): + return float(ra_raw), float(dec_raw) + coord = SkyCoord(ra=ra_raw, dec=dec_raw, unit=(u.hourangle, u.deg)) + return float(coord.ra.deg), float(coord.dec.deg) + + +# Function: _safe_positive_int +def _safe_positive_int(value: Any, default: int = 1) -> int: + try: + return max(1, int(value)) + except (TypeError, ValueError): + return max(1, int(default)) + + class PipelineState: IDLE, PREFLIGHT, PLANNING, FLIGHT, WAITING, POSTFLIGHT, ABORTED, PARKED = ( "IDLE", "PREFLIGHT", "PLANNING", "FLIGHT", "WAITING", "POSTFLIGHT", "ABORTED", "PARKED" @@ -123,11 +179,13 @@ class PipelineState: class MockDiamondSequence: """Mock hardware sequence for the Full Mission Simulator.""" + # Function: MockDiamondSequence.prepare_target def prepare_target(self, target, telemetry=None, notify=None): if notify: notify("A9", f"Simulation prepare target - exp_ms={target.exp_ms} n_frames={target.n_frames}") return target + # Function: MockDiamondSequence.init_session def init_session(self, level_ok: bool = True) -> TelemetryBlock: t = TelemetryBlock( battery_pct=95, @@ -141,6 +199,7 @@ def init_session(self, level_ok: bool = True) -> TelemetryBlock: log.info("[SIM][A3] Session init — mock telemetry generated") return t + # Function: MockDiamondSequence._pixel_from_world def _pixel_from_world(self, header: dict, ra_deg: float, dec_deg: float) -> tuple[float, float]: crval1 = float(header["CRVAL1"]) crval2 = float(header["CRVAL2"]) @@ -153,6 +212,7 @@ def _pixel_from_world(self, header: dict, ra_deg: float, dec_deg: float) -> tupl py = crpix2 + (dec_deg - crval2) / abs(cdelt2) return px, py + # Function: MockDiamondSequence._draw_star def _draw_star(self, array: np.ndarray, x: float, y: float, amplitude: float, sigma: float = 2.0): h, w = array.shape x0 = int(round(x)) @@ -168,6 +228,7 @@ def _draw_star(self, array: np.ndarray, x: float, y: float, amplitude: float, si spot = amplitude * np.exp(-(((xx - x) ** 2 + (yy - y) ** 2) / (2.0 * sigma ** 2))) array[np.ix_(ys, xs)] += spot + # Function: MockDiamondSequence._build_sim_comp_stars def _build_sim_comp_stars(self, target: AcquisitionTarget) -> list[dict]: ra_deg = target.ra_hours * 15.0 dec_deg = target.dec_deg @@ -197,6 +258,7 @@ def _build_sim_comp_stars(self, target: AcquisitionTarget) -> list[dict]: }) return stars + # Function: MockDiamondSequence._write_wcs_sidecar def _write_wcs_sidecar(self, out_path: Path, header: dict): wcs_header = fits.Header() for key in ( @@ -208,6 +270,7 @@ def _write_wcs_sidecar(self, out_path: Path, header: dict): hdu = fits.PrimaryHDU(data=np.zeros((2, 2), dtype=np.uint16), header=wcs_header) hdu.writeto(out_path.with_suffix(".wcs"), overwrite=True) + # Function: MockDiamondSequence._write_sim_gaia_cache def _write_sim_gaia_cache(self, target: AcquisitionTarget, comp_stars: list[dict]): from core.postflight.gaia_resolver import _cache_path @@ -225,6 +288,7 @@ def _write_sim_gaia_cache(self, target: AcquisitionTarget, comp_stars: list[dict with open(cache_path, "w") as f: json.dump(payload, f, indent=2) + # Function: MockDiamondSequence.acquire def acquire( self, target: AcquisitionTarget, @@ -233,11 +297,13 @@ def acquire( skip_pointing=False, abort_callback=None, ) -> FrameResult: + # Function: MockDiamondSequence.acquire.step def step(tag, msg): log.info(" [%s] SIM %s", tag, msg) if status_cb: status_cb(f"[{tag}] {msg}") + # Function: MockDiamondSequence.acquire.abort_requested def abort_requested() -> bool: return bool(abort_callback and abort_callback()) @@ -318,6 +384,7 @@ class Orchestrator: COMMAND_MAX_AGE_SEC = 300 SUN_CACHE_TTL_SEC = 20.0 + # Function: Orchestrator.__init__ def __init__(self): cfg = load_config() loc = cfg.get("location", {}) @@ -394,8 +461,10 @@ def __init__(self): PipelineState.ABORTED: self._run_aborted, } + # Function: Orchestrator._reload_runtime_config def _reload_runtime_config(self) -> None: """Refresh config.toml-backed runtime settings before night gates.""" + clear_config_cache() cfg = load_config() old_host = self._scope_host self._cfg = cfg @@ -423,6 +492,7 @@ def _reload_runtime_config(self) -> None: self.fsm.sequence = DiamondSequence(host=self._scope_host) self._log_flight(f"Runtime config reloaded: scope endpoint {old_host} -> {self._scope_host}") + # Function: Orchestrator.run def run(self): log.info( "🔭 Orchestrator starting — SeeVar Federation v2.0.0 (FSM-Governed) | scope=%s | mission=%s", @@ -444,6 +514,7 @@ def run(self): self._transition(PipelineState.ABORTED, msg=f"Error: {e}") time.sleep(max(1, self.LOOP_SLEEP_SEC * 4)) + # Function: Orchestrator._tick def _tick(self): if self._handle_operator_command(): return @@ -463,6 +534,7 @@ def _tick(self): return handler() + # Function: Orchestrator._check_weather_veto def _check_weather_veto(self) -> tuple[bool, str]: hard_abort = {"RAIN", "FOGGY", "WINDY", "THUNDER"} try: @@ -477,6 +549,7 @@ def _check_weather_veto(self) -> tuple[bool, str]: icon = w.get("icon", "") age_s = time.time() - w.get("last_update", 0) safe_to_open = w.get("safe_to_open", w.get("imaging_go")) + override = self._blocking_override_active() if age_s > 21600: log.warning("Weather data is %.0fh old — proceeding with caution", age_s / 3600) @@ -489,6 +562,9 @@ def _check_weather_veto(self) -> tuple[bool, str]: f"clouds:{w.get('clouds_pct','?')}% " f"window:{w.get('imaging_window_start','none')}→{w.get('imaging_window_end','none')}" ) + if override: + log.warning("Weather veto overridden: %s", reason) + return True, f"OVERRIDE:{status}" return False, reason if status in hard_abort: @@ -498,6 +574,9 @@ def _check_weather_veto(self) -> tuple[bool, str]: f"clouds:{w.get('clouds_pct','?')}% " f"window:{w.get('dark_start','?')}→{w.get('dark_end','?')}" ) + if override: + log.warning("Weather veto overridden: %s", reason) + return True, f"OVERRIDE:{status}" return False, reason log.info("Weather GO: %s %s (age: %.0fmin)", status, icon, age_s / 60) @@ -507,6 +586,7 @@ def _check_weather_veto(self) -> tuple[bool, str]: log.warning("Weather veto check failed: %s — proceeding", e) return True, "WEATHER_CHECK_ERROR" + # Function: Orchestrator._run_idle def _run_idle(self): self._reload_runtime_config() sun_alt = self._sun_altitude() @@ -525,6 +605,7 @@ def _run_idle(self): else: time.sleep(max(1, self.LOOP_SLEEP_SEC)) + # Function: Orchestrator._run_preflight def _run_preflight(self): self._log_flight("🛫 PREFLIGHT sequence initiated.") @@ -553,8 +634,11 @@ def _run_preflight(self): except Exception: pass - if not self._last_telemetry.is_safe(): - reason = self._last_telemetry.parse_error or self._last_telemetry.veto_reason() + if not self._last_telemetry or not self._last_telemetry.is_safe(): + if self._last_telemetry: + reason = self._last_telemetry.parse_error or self._last_telemetry.veto_reason() + else: + reason = "Telemetry unavailable" self._log_flight(f"[A3] 🛑 VETO at preflight: {reason}") self._transition(PipelineState.ABORTED, msg=f"Preflight veto: {reason}") return @@ -564,6 +648,7 @@ def _run_preflight(self): self._transition(PipelineState.PLANNING, msg="Preflight complete.") + # Function: Orchestrator._run_prealign_if_configured def _run_prealign_if_configured(self) -> bool: flight_cfg = self._cfg.get("flight", {}) if isinstance(self._cfg, dict) else {} if not bool(flight_cfg.get("prealign_before_flight", False)): @@ -654,7 +739,9 @@ def _run_prealign_if_configured(self) -> bool: self._log_flight("[A3] ✅ Pre-align model ready") return True + # Function: Orchestrator._run_planning def _run_planning(self): + # Function: Orchestrator._run_planning._order_and_filter def _order_and_filter(mission, now_utc): if any("recommended_order" in t for t in mission): ordered = sorted( @@ -727,21 +814,21 @@ def _order_and_filter(mission, now_utc): self._log_flight(f"⏭️ Skipped {expired} expired target window(s)") self._transition(PipelineState.FLIGHT, sub=final[0].get("name", "UNKNOWN"), msg="Flight plan locked.") + # Function: Orchestrator._run_flight def _run_flight(self): if not self._targets: self._transition(PipelineState.POSTFLIGHT, msg="Target list exhausted.") return - target = self._targets[0] - name = target.get("name", "UNKNOWN") + peeked = self._targets[0] + name = peeked.get("name", "UNKNOWN") now_utc = datetime.now(timezone.utc) - start_dt = _parse_plan_dt(target.get("best_start_utc")) - end_dt = _parse_plan_dt(target.get("best_end_utc")) + start_dt = _parse_plan_dt(peeked.get("best_start_utc")) + end_dt = _parse_plan_dt(peeked.get("best_end_utc")) - ra_str = target.get("ra") - dec_str = target.get("dec") - ra_deg_val = float(ra_str) if isinstance(ra_str, (int, float)) else float(SkyCoord(ra=ra_str, dec=dec_str, unit=(u.hourangle, u.deg)).ra.hour * 15) - dec_deg_val = float(dec_str) if isinstance(dec_str, (int, float)) else float(SkyCoord(ra=ra_str, dec=dec_str, unit=(u.hourangle, u.deg)).dec.deg) + ra_raw = peeked.get("ra") + dec_raw = peeked.get("dec") + ra_deg_val, dec_deg_val = _parse_ra_dec_deg(ra_raw, dec_raw) ra_hours_val = ra_deg_val / 15.0 if not self.simulation_mode: @@ -780,7 +867,7 @@ def _run_flight(self): if target.get("exp_ms") is not None: exp_ms = int(target.get("exp_ms")) - n_frames = max(1, int(planned_n_frames or 1)) + n_frames = _safe_positive_int(planned_n_frames, 1) else: try: exp_plan = plan_exposure( @@ -789,10 +876,10 @@ def _run_flight(self): mount_mode=self._mount_mode(), ) exp_ms = int(exp_plan.exp_ms) - n_frames = max(1, int(planned_n_frames or getattr(exp_plan, "n_frames", 1))) + n_frames = _safe_positive_int(planned_n_frames, getattr(exp_plan, "n_frames", 1)) except Exception: exp_ms = 5000 - n_frames = max(1, int(planned_n_frames or 1)) + n_frames = _safe_positive_int(planned_n_frames, 1) self._log_flight(f"[A9] Exposure plan — exp_ms={exp_ms} n_frames={n_frames}") @@ -856,6 +943,7 @@ def _run_flight(self): self._log_flight(f"❌ FSM Sequence failed for {name}") # Count dark acquisition results for the postflight state message. + # Function: Orchestrator._summarize_dark_results def _summarize_dark_results(self, dark_results: dict) -> tuple[int, int, int]: ok = 0 fail = 0 @@ -874,6 +962,7 @@ def _summarize_dark_results(self, dark_results: dict) -> tuple[int, int, int]: return ok, fail, frames # Inspect staged science FITS and derive the dark sequences postflight must cover. + # Function: Orchestrator._collect_buffer_dark_sequences def _collect_buffer_dark_sequences(self) -> set[tuple[int, int]]: sequences: set[tuple[int, int]] = set() for path in DATA_DIR.joinpath("local_buffer").glob("*_Raw.fits"): @@ -901,6 +990,7 @@ def _collect_buffer_dark_sequences(self) -> set[tuple[int, int]]: return sequences + # Function: Orchestrator._enabled_secondary_catalogs def _enabled_secondary_catalogs(self) -> list[str]: planner_cfg = self._cfg.get("planner", {}) if isinstance(self._cfg, dict) else {} value = planner_cfg.get("secondary_catalogs", []) @@ -910,6 +1000,7 @@ def _enabled_secondary_catalogs(self) -> list[str]: return [] return [str(item).strip().lower() for item in value if str(item).strip()] + # Function: Orchestrator._secondary_output_dir def _secondary_output_dir(self) -> Path: planner_cfg = self._cfg.get("planner", {}) if isinstance(self._cfg, dict) else {} storage_cfg = self._cfg.get("storage", {}) if isinstance(self._cfg, dict) else {} @@ -919,6 +1010,7 @@ def _secondary_output_dir(self) -> Path: primary = Path(str(storage_cfg.get("primary_dir") or DATA_DIR / "archive")).expanduser() return primary / "secondary_catalogs" + # Function: Orchestrator._load_secondary_imaging_targets def _load_secondary_imaging_targets(self) -> list[dict]: planner_cfg = self._cfg.get("planner", {}) if isinstance(self._cfg, dict) else {} max_targets = int(planner_cfg.get("secondary_max_targets", 0) or 0) @@ -943,13 +1035,14 @@ def _load_secondary_imaging_targets(self) -> list[dict]: item = dict(row) item["catalog"] = catalog item["secondary_target"] = True - item.setdefault("duration", default_duration) + item["duration"] = default_duration targets.append(item) if max_targets > 0 and len(targets) >= max_targets: return targets return targets + # Function: Orchestrator._target_altaz_deg def _target_altaz_deg(self, ra_deg: float, dec_deg: float) -> tuple[float, float] | None: try: now = Time.now() @@ -959,12 +1052,10 @@ def _target_altaz_deg(self, ra_deg: float, dec_deg: float) -> tuple[float, float except Exception: return None + # Function: Orchestrator._secondary_target_visible def _secondary_target_visible(self, target: dict) -> bool: try: - ra = target.get("ra") - dec = target.get("dec") - ra_deg = float(ra) if isinstance(ra, (int, float)) else float(SkyCoord(ra=ra, dec=dec, unit=(u.hourangle, u.deg)).ra.deg) - dec_deg = float(dec) if isinstance(dec, (int, float)) else float(SkyCoord(ra=ra, dec=dec, unit=(u.hourangle, u.deg)).dec.deg) + ra_deg, dec_deg = _parse_ra_dec_deg(target.get("ra"), target.get("dec")) altaz = self._target_altaz_deg(ra_deg, dec_deg) if not altaz: return False @@ -973,24 +1064,280 @@ def _secondary_target_visible(self, target: dict) -> bool: except Exception: return False - def _mirror_secondary_frames(self, catalog: str, name: str, paths: list[Path]) -> int: - if not paths: - return 0 - safe_catalog = str(catalog or "secondary").replace("/", "-") + # Function: Orchestrator._secondary_catalog_dir_name + def _secondary_catalog_dir_name(self, catalog: str) -> str: + names = { + "caldwell": "Caldwell", + "messier": "Messier", + } + raw = str(catalog or "secondary").strip().lower() + return names.get(raw, raw.replace("/", "-") or "secondary") + + # Function: Orchestrator._secondary_object_dir_name + def _secondary_object_dir_name(self, name: str) -> str: safe_name = str(name or "UNKNOWN").replace("/", "-").replace(" ", "_") + if safe_name.endswith("_sub"): + return safe_name + return f"{safe_name}_sub" + + # Function: Orchestrator._planner_bool + def _planner_bool(self, key: str, default: bool = False) -> bool: + planner_cfg = self._cfg.get("planner", {}) if isinstance(self._cfg, dict) else {} + value = planner_cfg.get(key, default) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + # Function: Orchestrator._move_secondary_frames + def _move_secondary_frames(self, catalog: str, name: str, paths: list[Path]) -> list[Path]: + if not paths: + return [] + safe_catalog = self._secondary_catalog_dir_name(catalog) + safe_name = self._secondary_object_dir_name(name) dest = self._secondary_output_dir() / safe_catalog / safe_name dest.mkdir(parents=True, exist_ok=True) - copied = 0 + moved: list[Path] = [] for src in paths: try: + src = Path(src) if not src.exists(): continue - shutil.move(str(src), str(dest / src.name)) - copied += 1 + out = dest / src.name + if out.exists(): + stem = out.stem + suffix = out.suffix + idx = 1 + while out.exists(): + out = dest / f"{stem}_{idx}{suffix}" + idx += 1 + shutil.move(str(src), str(out)) + moved.append(out) except Exception as e: - self._log_flight(f"🌌 Secondary frame custody failed for {src.name}: {e}") - return copied + self._log_flight(f"🌌 Secondary frame custody failed for {Path(src).name}: {e}") + return moved + + # Function: Orchestrator._mirror_secondary_frames + def _mirror_secondary_frames(self, catalog: str, name: str, paths: list[Path]) -> int: + return len(self._move_secondary_frames(catalog, name, paths)) + + # Function: Orchestrator._write_secondary_stack_products + def _write_secondary_stack_products(self, catalog: str, name: str, paths: list[Path]) -> tuple[Path | None, Path | None]: + if not paths: + return None, None + first = Path(paths[0]) + dest = first.parent + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + safe_name = str(name or "UNKNOWN").replace("/", "-").replace(" ", "_") + fits_out = dest / f"{safe_name}_stack_{stamp}_{len(paths)}x.fits" + jpg_out = dest / f"{safe_name}_stack_{stamp}_{len(paths)}x.jpg" + + try: + total = None + count = 0 + header = fits.getheader(first) + for path in paths: + data = np.asarray(fits.getdata(path), dtype=np.float32) + if data.ndim > 2: + data = np.squeeze(data) + if data.ndim != 2: + continue + if total is None: + total = np.zeros_like(data, dtype=np.float64) + if total.shape != data.shape: + self._log_flight(f"🌌 Stack skipped frame with mismatched shape: {Path(path).name}") + continue + total += data + count += 1 + + if total is None or count == 0: + return None, None + + stack = (total / float(count)).astype(np.float32) + header["STACKN"] = count + header["IMAGETYP"] = "LIGHT_STACK" + header["OBJECT"] = str(name or header.get("OBJECT", "SECONDARY")) + header.add_history("SeeVar secondary mean stack.") + fits.writeto(fits_out, stack, header=header, overwrite=True) + + finite = stack[np.isfinite(stack)] + if finite.size: + lo, hi = np.nanpercentile(finite, [1.0, 99.7]) + else: + lo, hi = 0.0, 1.0 + if not math.isfinite(float(hi - lo)) or hi <= lo: + lo, hi = float(np.nanmin(stack)), float(np.nanmax(stack)) + scaled = np.clip((stack - lo) / max(1e-6, hi - lo), 0.0, 1.0) + scaled = np.power(scaled, 1.0 / 2.2) + Image.fromarray((scaled * 255.0).astype(np.uint8), mode="L").save(jpg_out, quality=92) + return fits_out, jpg_out + except Exception as e: + self._log_flight(f"🌌 Secondary stack/JPEG failed for {name}: {e}") + return None, None + + # Function: Orchestrator._secondary_command_coordinates + def _secondary_command_coordinates(self, target: AcquisitionTarget) -> tuple[float, float]: + if not POINTING_MODEL_ENABLED: + return target.ra_hours, target.dec_deg + model = load_pointing_model(ACTIVE_SCOPE_TAG, max_age_hours=POINTING_MODEL_MAX_AGE_HOURS) + if not model: + return target.ra_hours, target.dec_deg + command_ra, command_dec = apply_pointing_model(target.ra_hours, target.dec_deg, model) + self._log_flight(f"🌌 Secondary prealignment model applied — {target.name}") + return command_ra, command_dec + + # Function: Orchestrator._secondary_reference_star + def _secondary_reference_star(self, target: AcquisitionTarget) -> AcquisitionTarget | None: + planner_cfg = self._cfg.get("planner", {}) if isinstance(self._cfg, dict) else {} + max_sep = float(planner_cfg.get("secondary_reference_max_sep_deg", 45.0) or 45.0) + target_coord = SkyCoord(ra=float(target.ra_hours) * 15.0 * u.deg, dec=float(target.dec_deg) * u.deg, frame="icrs") + candidates: list[tuple[float, str, float, float]] = [] + for name, ra_hours, dec_deg in SECONDARY_REFERENCE_STARS: + altaz = self._target_altaz_deg(float(ra_hours) * 15.0, float(dec_deg)) + if not altaz: + continue + alt_deg, az_deg = altaz + if alt_deg < required_altitude(az_deg, clearance_margin_deg=5.0): + continue + star_coord = SkyCoord(ra=float(ra_hours) * 15.0 * u.deg, dec=float(dec_deg) * u.deg, frame="icrs") + sep = float(target_coord.separation(star_coord).deg) + candidates.append((sep, name, float(ra_hours), float(dec_deg))) + if not candidates: + return None + sep, name, ra_hours, dec_deg = sorted(candidates, key=lambda item: item[0])[0] + if sep > max_sep: + return None + self._log_flight(f"🌌 Secondary reference — {name} ({sep:.1f}° from {target.name})") + return AcquisitionTarget( + name=f"REF_{name}", + ra_hours=ra_hours, + dec_deg=dec_deg, + exp_ms=target.exp_ms, + observer_code=target.observer_code, + n_frames=1, + integration_sec=target.integration_sec, + ) + + # Function: Orchestrator._secondary_slew_to + def _secondary_slew_to(self, ra_hours: float, dec_deg: float) -> bool: + self.fsm.sequence._telescope.slew_to_coordinates_async(ra_hours, dec_deg) + if not self.fsm.sequence._telescope.wait_for_slew(SLEW_TIMEOUT, abort_callback=self._operator_abort_pending): + return False + settle_deadline = time.monotonic() + SETTLE_SECONDS + while time.monotonic() < settle_deadline: + if self._operator_abort_pending(): + return False + time.sleep(min(0.5, max(0.0, settle_deadline - time.monotonic()))) + return True + + # Function: Orchestrator._secondary_reference_corrected_coordinates + def _secondary_reference_corrected_coordinates( + self, + target: AcquisitionTarget, + command_ra: float, + command_dec: float, + ) -> tuple[float, float]: + if not self._planner_bool("secondary_reference_solve", True): + return command_ra, command_dec + + ref_target = self._secondary_reference_star(target) + if ref_target is None: + self._log_flight(f"🌌 Secondary reference skipped — no nearby bright star for {target.name}") + return command_ra, command_dec + + planner_cfg = self._cfg.get("planner", {}) if isinstance(self._cfg, dict) else {} + radius = float(planner_cfg.get("secondary_reference_solve_radius_deg", 8.0) or 8.0) + timeout = int(planner_cfg.get("secondary_reference_solve_timeout_sec", 60) or 60) + cpulimit = max(5, min(timeout, int(planner_cfg.get("secondary_reference_solve_cpulimit_sec", timeout - 5) or timeout - 5))) + + try: + ref_command_ra, ref_command_dec = self._secondary_command_coordinates(ref_target) + if not self._secondary_slew_to(ref_command_ra, ref_command_dec): + return command_ra, command_dec + notify = lambda step, msg: self._log_flight(f"🌌 REF {step}: {msg}") + ccd_temp = getattr(self._last_telemetry, "temp_c", None) + verify_fits = self.fsm.sequence._capture_temp_frame( + ref_target, + 2.0, + "REF_VERIFY", + ccd_temp=ccd_temp, + abort_callback=self._operator_abort_pending, + ) + solve = self.fsm.sequence._solve_verify_frame( + verify_fits, + ref_target, + radius_deg=radius, + timeout_sec=timeout, + cpulimit_sec=cpulimit, + ) + if not solve.get("ok"): + notify("A7", f"reference solve failed: {solve.get('error', 'unknown error')}") + return command_ra, command_dec + corrected_ra, corrected_dec = self.fsm.sequence._corrective_nudge( + command_ra, + command_dec, + ref_target.ra_hours, + ref_target.dec_deg, + solve, + ) + notify("A7", f"reference accepted err={float(solve['error_arcmin']):.2f} arcmin") + return corrected_ra, corrected_dec + except Exception as e: + self._log_flight(f"🌌 Secondary reference failed — {target.name}: {e}") + return command_ra, command_dec + + # Function: Orchestrator._execute_secondary_without_target_solve + def _execute_secondary_without_target_solve(self, target: AcquisitionTarget) -> tuple[bool, list[Path]]: + self.fsm.last_frame_paths = [] + self.fsm.last_prepared_target = None + + telemetry = self._last_telemetry or getattr(self.fsm, "telemetry", None) + if not telemetry or not telemetry.is_safe(): + telemetry = self.fsm.sequence.init_session() + self._last_telemetry = telemetry + if not telemetry or not telemetry.is_safe(): + reason = telemetry.veto_reason() if telemetry else "Telemetry unavailable" + self._log_flight(f"🌌 Secondary hardware veto: {reason}") + return False, [] + + target = self.fsm.sequence.prepare_target(target, telemetry=telemetry) + self.fsm.last_prepared_target = target + command_ra, command_dec = self._secondary_command_coordinates(target) + command_ra, command_dec = self._secondary_reference_corrected_coordinates(target, command_ra, command_dec) + + try: + self._log_flight(f"🌌 Secondary direct slew — {target.name}; target solve skipped") + if not self._secondary_slew_to(command_ra, command_dec): + return False, [] + except Exception as e: + self._log_flight(f"🌌 Secondary direct slew failed — {target.name}: {e}") + return False, [] + + for i in range(target.n_frames): + if self._operator_abort_pending(): + return False, self.fsm.last_frame_paths + result = self.fsm.sequence.acquire( + target=target, + telemetry=telemetry, + skip_pointing=True, + abort_callback=self._operator_abort_pending, + ) + if result.error == "operator_abort": + return False, self.fsm.last_frame_paths + if result.success and result.path: + self.fsm.last_frame_paths.append(Path(result.path)) + else: + self._log_flight(f"🌌 Secondary frame {i + 1}/{target.n_frames} failed: {result.error}") + err = str(result.error or "").lower() + if "camera" in err and ("not connected" in err or "reconnect" in err or "error 1031" in err): + self._log_flight("🌌 Secondary stopped: camera connection lost") + return False, list(self.fsm.last_frame_paths) + + return bool(self.fsm.last_frame_paths), list(self.fsm.last_frame_paths) + + # Function: Orchestrator._run_secondary_imaging def _run_secondary_imaging(self) -> None: planner_cfg = self._cfg.get("planner", {}) if isinstance(self._cfg, dict) else {} if not bool(planner_cfg.get("secondary_after_photometry", False)): @@ -1020,10 +1367,7 @@ def _run_secondary_imaging(self) -> None: name = target.get("name", "UNKNOWN") catalog = target.get("catalog", "secondary") try: - ra = target.get("ra") - dec = target.get("dec") - ra_deg = float(ra) if isinstance(ra, (int, float)) else float(SkyCoord(ra=ra, dec=dec, unit=(u.hourangle, u.deg)).ra.deg) - dec_deg = float(dec) if isinstance(dec, (int, float)) else float(SkyCoord(ra=ra, dec=dec, unit=(u.hourangle, u.deg)).dec.deg) + ra_deg, dec_deg = _parse_ra_dec_deg(target.get("ra"), target.get("dec")) duration = max(1, int(float(target.get("duration", planner_cfg.get("secondary_duration_sec", 900))))) exp_ms = max(1000, int(target.get("exp_ms", 30000))) n_frames = max(1, int(round(duration / (exp_ms / 1000.0)))) @@ -1044,19 +1388,29 @@ def _run_secondary_imaging(self) -> None: self._write_state(state="SECONDARY", sub=name, msg=f"Secondary imaging: {catalog}") self._log_flight(f"🌌 Secondary target — {catalog}:{name} exp_ms={exp_ms} n={n_frames}") - ok = self.fsm.execute_target( - acq_target, - telemetry=self._last_telemetry, - abort_cb=self._operator_abort_pending, - ) - paths = list(getattr(self.fsm, "last_frame_paths", [])) - copied = self._mirror_secondary_frames(catalog, name, paths) + if self._planner_bool("secondary_skip_target_plate_solve", False): + ok, paths = self._execute_secondary_without_target_solve(acq_target) + else: + ok = self.fsm.execute_target( + acq_target, + telemetry=self._last_telemetry, + abort_cb=self._operator_abort_pending, + ) + paths = list(getattr(self.fsm, "last_frame_paths", [])) + + moved = self._move_secondary_frames(catalog, name, paths) + copied = len(moved) + if self._planner_bool("secondary_write_stack_products", True): + stack_fits, stack_jpg = self._write_secondary_stack_products(catalog, name, moved) + if stack_fits or stack_jpg: + self._log_flight(f"🌌 Secondary products — {name}: {stack_fits.name if stack_fits else '-'} / {stack_jpg.name if stack_jpg else '-'}") if ok: self._log_flight(f"🌌 Secondary complete — {name}, moved={copied}") else: self._log_flight(f"🌌 Secondary failed — {name}, moved={copied}") # Close the flight by acquiring matching darks and handing frames to the accountant. + # Function: Orchestrator._run_postflight def _run_postflight(self): self._log_flight("📊 Flight operations concluded.") @@ -1209,6 +1563,7 @@ def _run_postflight(self): final_msg = f"Mission complete with partial dark failures ({dark_fail}). {hardware_park_msg}" self._transition(PipelineState.PARKED, msg=final_msg) + # Function: Orchestrator._run_parked def _run_parked(self): self._current_target = None sun_alt = self._sun_altitude() @@ -1236,13 +1591,16 @@ def _run_parked(self): ) time.sleep(max(1, self.LOOP_SLEEP_SEC)) + # Function: Orchestrator._run_aborted def _run_aborted(self): self._current_target = None time.sleep(max(1, self.LOOP_SLEEP_SEC)) + # Function: Orchestrator._extract_targets def _extract_targets(self, payload): return payload if isinstance(payload, list) else payload.get("targets", []) + # Function: Orchestrator._plan_is_stale def _plan_is_stale(self, payload, now_utc): if not isinstance(payload, dict): return False @@ -1258,6 +1616,7 @@ def _plan_is_stale(self, payload, now_utc): return False + # Function: Orchestrator._read_operator_command def _read_operator_command(self) -> dict: if not COMMAND_FILE.exists(): return {} @@ -1267,6 +1626,20 @@ def _read_operator_command(self) -> dict: payload = {} return payload if isinstance(payload, dict) else {} + # Function: Orchestrator._blocking_override_active + def _blocking_override_active(self) -> bool: + if not OVERRIDE_FILE.exists(): + return False + try: + payload = json.loads(OVERRIDE_FILE.read_text()) + except Exception: + return False + if not isinstance(payload, dict) or not bool(payload.get("blocking_override")): + return False + expires = _parse_plan_dt(payload.get("expires_utc")) + return bool(expires and expires > datetime.now(timezone.utc)) + + # Function: Orchestrator._operator_abort_pending def _operator_abort_pending(self) -> bool: payload = self._read_operator_command() command = str(payload.get("command", "")).strip().lower() @@ -1285,6 +1658,7 @@ def _operator_abort_pending(self) -> bool: return True + # Function: Orchestrator._handle_operator_command def _handle_operator_command(self) -> bool: payload = self._read_operator_command() command = str(payload.get("command", "")).strip().lower() @@ -1326,6 +1700,7 @@ def _handle_operator_command(self) -> bool: self._log_flight(f"⚠️ Ignoring unknown operator command: {command}") return False + # Function: Orchestrator._sun_altitude def _sun_altitude(self) -> float: now_mono = time.monotonic() if now_mono - self._sun_cache_monotonic <= self.SUN_CACHE_TTL_SEC: @@ -1340,10 +1715,12 @@ def _sun_altitude(self) -> float: except Exception: return 0.0 + # Function: Orchestrator._load_mission_targets def _load_mission_targets(self) -> list: data = _safe_load_json(self._mission_file, []) return data if isinstance(data, list) else data.get("targets", []) + # Function: Orchestrator._call_with_retries def _call_with_retries( self, label: str, @@ -1365,6 +1742,7 @@ def _call_with_retries( raise last_error return None + # Function: Orchestrator._refresh_mission_plan def _refresh_mission_plan(self) -> bool: planner = PROJECT_ROOT / "core/preflight/nightly_planner.py" compiler = PROJECT_ROOT / "core/preflight/schedule_compiler.py" @@ -1382,6 +1760,7 @@ def _refresh_mission_plan(self) -> bool: self._log_flight(f"⚠️ Nightly plan refresh failed: {e}") return False + # Function: Orchestrator._current_battery_snapshot def _current_battery_snapshot(self) -> dict: if self._scope: snapshot = poll_battery_snapshot(self._scope.get("ip")) @@ -1403,6 +1782,7 @@ def _current_battery_snapshot(self) -> dict: return {} + # Function: Orchestrator._enforce_battery_guard def _enforce_battery_guard(self) -> bool: snapshot = self._current_battery_snapshot() battery_pct = snapshot.get("battery_pct", snapshot.get("battery_capacity")) @@ -1429,6 +1809,7 @@ def _enforce_battery_guard(self) -> bool: self._transition(PipelineState.PARKED, msg=f"Battery guard parked telescope at {battery_pct}%.") return True + # Function: Orchestrator._progress_counts def _progress_counts(self) -> tuple[int, int, int]: done = int(self._session_stats.get("targets_completed", 0)) current = 1 if self._current_target else 0 @@ -1436,6 +1817,7 @@ def _progress_counts(self) -> tuple[int, int, int]: planned = max(self._planned_target_count, done + remaining) return done, remaining, planned + # Function: Orchestrator._target_altitude_deg def _target_altitude_deg(self, ra_deg: float, dec_deg: float) -> float | None: try: now = Time.now() @@ -1445,24 +1827,28 @@ def _target_altitude_deg(self, ra_deg: float, dec_deg: float) -> float | None: except Exception: return None + # Function: Orchestrator._sky_bortle def _sky_bortle(self) -> float: try: return float(self._cfg.get("location", {}).get("bortle", 6.0)) except Exception: return 6.0 + # Function: Orchestrator._configured_sun_limit_deg def _configured_sun_limit_deg(self) -> float: try: return float(self._cfg.get("planner", {}).get("sun_altitude_limit", self.SUN_LIMIT_DEG)) except Exception: return float(self.SUN_LIMIT_DEG) + # Function: Orchestrator._mount_mode def _mount_mode(self) -> str: try: return str(self._scope.get("mount", "altaz")).strip().lower() except Exception: return "altaz" + # Function: Orchestrator._log_flight def _log_flight(self, message: str): stamp = datetime.now(timezone.utc).strftime("%H:%M:%S") line = f"{stamp} {message}" @@ -1470,6 +1856,7 @@ def _log_flight(self, message: str): self._flight_log = self._flight_log[-100:] log.info(message) + # Function: Orchestrator._write_plan def _write_plan(self, targets: list): payload = { "#objective": "Tactical flight plan as locked by orchestrator.", @@ -1483,10 +1870,12 @@ def _write_plan(self, targets: list): } self._write_json(self._plan_file, payload) + # Function: Orchestrator._write_json def _write_json(self, path: Path, payload: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + # Function: Orchestrator._write_state def _write_state(self, state=None, sub="", msg=""): now_utc = datetime.now(timezone.utc).isoformat() done, remaining, planned = self._progress_counts() @@ -1510,6 +1899,7 @@ def _write_state(self, state=None, sub="", msg=""): }) self._write_json(self._state_file, payload) + # Function: Orchestrator._transition def _transition(self, new_state: str, sub: str = "", msg: str = ""): if new_state not in PipelineState.ALL: raise ValueError(f"Invalid pipeline state: {new_state}") diff --git a/core/flight/pilot.py b/core/flight/pilot.py index cddbed5..7abb2f8 100755 --- a/core/flight/pilot.py +++ b/core/flight/pilot.py @@ -20,6 +20,8 @@ - sovereign_stamp(), write_fits() utility functions """ +from __future__ import annotations + import json import logging import math @@ -50,6 +52,7 @@ _CONFIG_CACHE: dict[str, Any] | None = None +# Function: _config def _config() -> dict[str, Any]: global _CONFIG_CACHE if _CONFIG_CACHE is None: @@ -58,15 +61,24 @@ def _config() -> dict[str, Any]: return _CONFIG_CACHE +# Function: clear_config_cache +def clear_config_cache() -> None: + global _CONFIG_CACHE + _CONFIG_CACHE = None + + +# Function: _resolve_seestar_host def _resolve_seestar_host() -> tuple[str, str]: return selected_scope_host(_config()) +# Function: _flight_cfg def _flight_cfg() -> dict: cfg = _config() return cfg.get("flight", {}) if isinstance(cfg, dict) else {} +# Function: _cfg_float def _cfg_float(key: str, default: float) -> float: try: return float(_flight_cfg().get(key, default)) @@ -74,6 +86,7 @@ def _cfg_float(key: str, default: float) -> float: return default +# Function: _cfg_int def _cfg_int(key: str, default: int) -> int: try: return int(round(float(_flight_cfg().get(key, default)))) @@ -82,6 +95,7 @@ def _cfg_int(key: str, default: int) -> int: # Read boolean flight settings without trusting TOML/string spelling. +# Function: _cfg_bool def _cfg_bool(key: str, default: bool) -> bool: value = _flight_cfg().get(key, default) if isinstance(value, bool): @@ -107,6 +121,7 @@ def _cfg_bool(key: str, default: bool) -> bool: # Map a verify artifact back to the shared stem that ties the FITS frame to # all astrometry sidecars generated from that solve attempt. +# Function: _verify_root_name def _verify_root_name(path: Path) -> str | None: name = path.name for suffix in VERIFY_SUFFIXES: @@ -183,6 +198,7 @@ def _verify_root_name(path: Path) -> str | None: # Keep only the newest verification bundles so dashboard previews remain # available without letting solve-field sidecars accumulate indefinitely. +# Function: _prune_verify_buffer def _prune_verify_buffer(keep_sets: int = VERIFY_RETENTION_SETS) -> None: if keep_sets < 1 or not VERIFY_BUFFER.exists(): return @@ -262,6 +278,7 @@ class TelemetryBlock: alpaca_version: Optional[str] = None @classmethod + # Function: TelemetryBlock.from_alpaca def from_alpaca(cls, telescope: "AlpacaTelescope", camera: "AlpacaCamera") -> "TelemetryBlock": try: temp = camera.safe_get("ccdtemperature") @@ -289,6 +306,7 @@ def from_alpaca(cls, telescope: "AlpacaTelescope", camera: "AlpacaCamera") -> "T return cls(parse_error=f"Alpaca telemetry read failed: {e}") @classmethod + # Function: TelemetryBlock.from_response def from_response(cls, response: Optional[dict]) -> "TelemetryBlock": if response is None: return cls(parse_error="No response received") @@ -308,6 +326,7 @@ def from_response(cls, response: Optional[dict]) -> "TelemetryBlock": except Exception as e: return cls(parse_error=str(e), raw=response) + # Function: TelemetryBlock.veto_reason def veto_reason(self) -> Optional[str]: if self.parse_error: return f"Telemetry unavailable: {self.parse_error}" @@ -319,9 +338,11 @@ def veto_reason(self) -> Optional[str]: return "Level veto: device not level (preflight check failed)" return None + # Function: TelemetryBlock.is_safe def is_safe(self) -> bool: return self.veto_reason() is None + # Function: TelemetryBlock.summary def summary(self) -> str: if self.parse_error: return f"TelemetryBlock parse error: {self.parse_error}" @@ -346,14 +367,17 @@ def summary(self) -> str: # --------------------------------------------------------------------------- class AlpacaClient: + # Function: AlpacaClient.__init__ def __init__(self, ip: str, port: int, device_type: str, device_number: int): self.base = f"http://{ip}:{port}/api/v1/{device_type}/{device_number}" self._txid = 0 + # Function: AlpacaClient._next_tx def _next_tx(self) -> int: self._txid += 1 return self._txid + # Function: AlpacaClient._get def _get(self, prop: str, timeout: float = 10.0): params = { "ClientID": CLIENT_ID, @@ -372,6 +396,7 @@ def _get(self, prop: str, timeout: float = 10.0): raise RuntimeError(f"Alpaca GET {prop}: error {err} — {data.get('ErrorMessage', '')}") return data.get("Value") + # Function: AlpacaClient._put def _put(self, method: str, timeout: float = 15.0, **kwargs): payload = { "ClientID": CLIENT_ID, @@ -391,15 +416,18 @@ def _put(self, method: str, timeout: float = 15.0, **kwargs): raise RuntimeError(f"Alpaca PUT {method}: error {err} — {data.get('ErrorMessage', '')}") return data.get("Value") + # Function: AlpacaClient.safe_get def safe_get(self, prop: str, default: Any = None) -> Any: try: return self._get(prop) except RuntimeError: return default + # Function: AlpacaClient.connect def connect(self) -> None: self._put("connected", Connected="true") + # Function: AlpacaClient.disconnect def disconnect(self) -> None: try: self._put("connected", Connected="false") @@ -407,24 +435,30 @@ def disconnect(self) -> None: pass @property + # Function: AlpacaClient.connected def connected(self) -> bool: return self._get("connected") class AlpacaTelescope(AlpacaClient): + # Function: AlpacaTelescope.__init__ def __init__(self, ip: str | None = None, port: int = ALPACA_PORT, device_number: int = TELESCOPE_NUM): host, _ = selected_scope_host(_config()) if not ip else (ip, "explicit argument") super().__init__(host, port, "telescope", device_number) + # Function: AlpacaTelescope.unpark def unpark(self): self._put("unpark") + # Function: AlpacaTelescope.park def park(self): self._put("park") + # Function: AlpacaTelescope.set_tracking def set_tracking(self, on: bool): self._put("tracking", Tracking=str(on).lower()) + # Function: AlpacaTelescope.slew_to_coordinates_async def slew_to_coordinates_async(self, ra_hours: float, dec_deg: float): self._put( "slewtocoordinatesasync", @@ -433,9 +467,11 @@ def slew_to_coordinates_async(self, ra_hours: float, dec_deg: float): timeout=20.0, ) + # Function: AlpacaTelescope.abort_slew def abort_slew(self): self._put("abortslew") + # Function: AlpacaTelescope.wait_for_slew def wait_for_slew(self, timeout: float = SLEW_TIMEOUT, abort_callback=None) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: @@ -452,30 +488,37 @@ def wait_for_slew(self, timeout: float = SLEW_TIMEOUT, abort_callback=None) -> b return False @property + # Function: AlpacaTelescope.tracking def tracking(self) -> bool: return self._get("tracking") @property + # Function: AlpacaTelescope.at_park def at_park(self) -> bool: return self._get("atpark") @property + # Function: AlpacaTelescope.ra def ra(self) -> float: return self._get("rightascension") @property + # Function: AlpacaTelescope.dec def dec(self) -> float: return self._get("declination") @property + # Function: AlpacaTelescope.altitude def altitude(self) -> float: return self._get("altitude") @property + # Function: AlpacaTelescope.azimuth def azimuth(self) -> float: return self._get("azimuth") @property + # Function: AlpacaTelescope.sidereal_time def sidereal_time(self) -> float: return self._get("siderealtime") @@ -497,19 +540,24 @@ class AlpacaCamera(AlpacaClient): 5: "Error", } + # Function: AlpacaCamera.__init__ def __init__(self, ip: str | None = None, port: int = ALPACA_PORT, device_number: int = CAMERA_NUM): host, _ = selected_scope_host(_config()) if not ip else (ip, "explicit argument") super().__init__(host, port, "camera", device_number) + # Function: AlpacaCamera.set_gain def set_gain(self, gain: int): self._put("gain", Gain=str(gain)) + # Function: AlpacaCamera.start_exposure def start_exposure(self, duration_sec: float, light: bool = True): self._put("startexposure", Duration=str(duration_sec), Light=str(light).lower()) + # Function: AlpacaCamera.abort_exposure def abort_exposure(self): self._put("abortexposure") + # Function: AlpacaCamera.wait_for_image def wait_for_image(self, exposure_sec: float, timeout: float = EXPOSE_TIMEOUT, abort_callback=None) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: @@ -527,6 +575,7 @@ def wait_for_image(self, exposure_sec: float, timeout: float = EXPOSE_TIMEOUT, a time.sleep(1.0) return False + # Function: AlpacaCamera.download_image def download_image(self) -> np.ndarray: params = { "ClientID": CLIENT_ID, @@ -551,30 +600,37 @@ def download_image(self) -> np.ndarray: return np.array(value, dtype=np.int32) @property + # Function: AlpacaCamera.camera_state def camera_state(self) -> int: return self._get("camerastate") @property + # Function: AlpacaCamera.image_ready def image_ready(self) -> bool: return self._get("imageready") @property + # Function: AlpacaCamera.gain def gain(self) -> int: return self._get("gain") @property + # Function: AlpacaCamera.temperature def temperature(self) -> Optional[float]: return self.safe_get("ccdtemperature") @property + # Function: AlpacaCamera.sensor_width def sensor_width(self) -> int: return self._get("cameraxsize") @property + # Function: AlpacaCamera.sensor_height def sensor_height(self) -> int: return self._get("cameraysize") +# Function: _seestar_rpc_call def _seestar_rpc_call(host: str, method: str, params=None, port: int = SEESTAR_RPC_PORT, timeout: float = 6.0) -> dict: payload = {"id": int(time.time() * 1000) % 1_000_000, "method": method} if params is not None: @@ -610,14 +666,17 @@ class AlpacaFilterWheel(AlpacaClient): IR = 1 LP = 2 + # Function: AlpacaFilterWheel.__init__ def __init__(self, ip: str | None = None, port: int = ALPACA_PORT, device_number: int = FILTERWHEEL_NUM): host, _ = selected_scope_host(_config()) if not ip else (ip, "explicit argument") super().__init__(host, port, "filterwheel", device_number) + # Function: AlpacaFilterWheel.set_position def set_position(self, pos: int): self._put("position", Position=str(pos)) @property + # Function: AlpacaFilterWheel.position def position(self) -> int: return self._get("position") @@ -626,6 +685,7 @@ def position(self) -> int: # FITS construction # --------------------------------------------------------------------------- +# Function: _read_gps_ram def _read_gps_ram() -> dict: try: data = json.loads(ENV_STATUS.read_text()) @@ -637,6 +697,7 @@ def _read_gps_ram() -> dict: return {"lat": 0.0, "lon": 0.0, "elevation": 0.0} +# Function: sovereign_stamp def sovereign_stamp( target: AcquisitionTarget, utc_obs: datetime, @@ -692,13 +753,18 @@ def sovereign_stamp( "CTYPE1": "RA---TAN", "CTYPE2": "DEC--TAN", "DATE-OBS": utc_obs.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3], + "IMAGETYP": "LIGHT", + "FRAME": "LIGHT", "EXPTIME": target.exp_ms / 1000.0, "EXPMS": int(target.exp_ms), - "INSTRUME": INSTRUMENT, + "INSTRUME": "Seestar S30 IMX585", "TELESCOP": TELESCOPE, "FILTER": FILTER_NAME, "BAYERPAT": BAYER_PATTERN, "GAIN": GAIN, + "OFFSET": PEDESTAL, + "XBINNING": 1, + "YBINNING": 1, "FOCALLEN": FOCALLEN, "APERTURE": APERTURE, "PIXSCALE": PIXSCALE, @@ -711,7 +777,8 @@ def sovereign_stamp( "SWCREATE": SWCREATE, } - h["CCD-TEMP"] = ccd_temp if ccd_temp is not None else "UNKNOWN" + if ccd_temp is not None: + h["CCD-TEMP"] = float(ccd_temp) h["SCOPEID"] = str(ACTIVE_SCOPE.get("scope_id", ACTIVE_SCOPE_TAG))[:68] h["SCOPENAM"] = str(ACTIVE_SCOPE.get("scope_name", ACTIVE_SCOPE_TAG))[:68] if ACTIVE_SCOPE.get("ip"): @@ -729,10 +796,11 @@ def sovereign_stamp( return h -def write_fits(array: np.ndarray, header_dict: dict, output_path: Path) -> bool: +# Function: write_fits +def write_fits(array: np.ndarray, header_dict: dict | fits.Header, output_path: Path) -> bool: # Backward compatibility: older simulation code passed # write_fits(output_path, array, header_dict). - if isinstance(array, Path) and isinstance(header_dict, np.ndarray) and isinstance(output_path, fits.Header): + if isinstance(array, Path) and isinstance(header_dict, np.ndarray) and isinstance(output_path, (dict, fits.Header)): array, header_dict, output_path = header_dict, output_path, array output_path.parent.mkdir(parents=True, exist_ok=True) @@ -743,6 +811,7 @@ def write_fits(array: np.ndarray, header_dict: dict, output_path: Path) -> bool: if array_signed.dtype.byteorder not in (">",): array_signed = array_signed.byteswap().view(array_signed.dtype.newbyteorder(">")) + # Function: write_fits.card def card(key: str, value, comment: str = "") -> str: key = key.upper()[:8].ljust(8) if isinstance(value, bool): @@ -794,6 +863,7 @@ class DiamondSequence: acquire(target, status_cb, telemetry) -> FrameResult """ + # Function: DiamondSequence.__init__ def __init__(self, host: str | None = None, port: int = ALPACA_PORT): resolved_host, resolved_source = selected_scope_host(_config()) if not host else (host, "explicit argument") self.host = resolved_host @@ -807,6 +877,7 @@ def __init__(self, host: str | None = None, port: int = ALPACA_PORT): self._session_connects = 0 self._last_session_error = "" + # Function: DiamondSequence._management_status def _management_status(self) -> tuple[bool, str]: try: r = requests.get(f"http://{self.host}:{self.port}/management/apiversions", timeout=5) @@ -815,10 +886,12 @@ def _management_status(self) -> tuple[bool, str]: except requests.RequestException as e: return False, f"Alpaca management unreachable: {e}" + # Function: DiamondSequence._is_reachable def _is_reachable(self) -> bool: ok, _ = self._management_status() return ok + # Function: DiamondSequence._require_connected def _require_connected(self, client: AlpacaClient, label: str): try: connected = bool(client.connected) @@ -827,6 +900,7 @@ def _require_connected(self, client: AlpacaClient, label: str): if not connected: raise RuntimeError(f"{label} reports connected=false after connect()") + # Function: DiamondSequence._session_health def _session_health(self) -> tuple[bool, str]: ok, err = self._management_status() if not ok: @@ -848,6 +922,7 @@ def _session_health(self) -> tuple[bool, str]: except RuntimeError as e: return False, f"Alpaca backend reachable but telescope not operational: {e}" + # Function: DiamondSequence._read_operational_telemetry def _read_operational_telemetry(self, level_ok: bool) -> TelemetryBlock: telemetry = TelemetryBlock.from_alpaca(self._telescope, self._camera) telemetry.level_ok = level_ok @@ -855,6 +930,51 @@ def _read_operational_telemetry(self, level_ok: bool) -> TelemetryBlock: telemetry.parse_error = f"Alpaca backend reachable but telemetry unavailable: {telemetry.parse_error}" return telemetry + # Function: DiamondSequence._camera_not_connected_error + def _camera_not_connected_error(self, error: Exception) -> bool: + text = str(error).lower() + return "error 1031" in text or "not connected" in text or "camera disconnected" in text + + # Function: DiamondSequence._ensure_camera_connected + def _ensure_camera_connected(self, notify=None) -> None: + try: + if self._camera.connected: + return + except Exception as e: + logger.warning("Camera connection probe failed: %s", e) + + self._session_ready = False + if notify: + notify("A10", "Camera disconnected; reconnecting") + self._camera.connect() + self._require_connected(self._camera, "Camera") + time.sleep(1.0) + + # Function: DiamondSequence._start_camera_exposure + def _start_camera_exposure(self, exposure_sec: float, light: bool = True, notify=None) -> None: + last_error: Exception | None = None + for attempt in range(2): + try: + self._ensure_camera_connected(notify=notify) + try: + self._camera.set_gain(GAIN) + except Exception as e: + if self._camera_not_connected_error(e): + raise + logger.warning("Gain set during acquire: %s", e) + self._camera.start_exposure(exposure_sec, light=light) + return + except Exception as e: + last_error = e + if not self._camera_not_connected_error(e) or attempt: + break + self._session_ready = False + if notify: + notify("A10", "Camera reconnect retry before exposure") + time.sleep(2.0) + raise RuntimeError(f"Camera exposure start failed after reconnect: {last_error}") + + # Function: DiamondSequence._site_latitude_deg def _site_latitude_deg(self) -> float | None: gps = _read_gps_ram() lat = gps.get("lat") @@ -866,6 +986,7 @@ def _site_latitude_deg(self) -> float | None: except (TypeError, ValueError): return None + # Function: DiamondSequence._mount_mode def _mount_mode(self) -> str: try: scope = selected_scope(_config()) @@ -875,7 +996,9 @@ def _mount_mode(self) -> str: pass return "altaz" + # Function: DiamondSequence.prepare_target def prepare_target(self, target: AcquisitionTarget, telemetry: Optional[TelemetryBlock] = None, notify=None) -> AcquisitionTarget: + # Function: DiamondSequence.prepare_target.emit def emit(msg: str): if notify: notify("A9", msg) @@ -927,10 +1050,18 @@ def emit(msg: str): integration_sec=planned_total_sec, ) - def _capture_temp_frame(self, target: AcquisitionTarget, exposure_sec: float, suffix: str, ccd_temp=None) -> Path: - self._camera.start_exposure(exposure_sec, light=True) + # Function: DiamondSequence._capture_temp_frame + def _capture_temp_frame( + self, + target: AcquisitionTarget, + exposure_sec: float, + suffix: str, + ccd_temp=None, + abort_callback=None, + ) -> Path: + self._start_camera_exposure(exposure_sec, light=True) image_timeout = exposure_sec + EXPOSE_TIMEOUT - if not self._camera.wait_for_image(exposure_sec, timeout=image_timeout): + if not self._camera.wait_for_image(exposure_sec, timeout=image_timeout, abort_callback=abort_callback): raise RuntimeError(f"Verification image not ready after {image_timeout}s") img = self._camera.download_image() @@ -949,6 +1080,7 @@ def _capture_temp_frame(self, target: AcquisitionTarget, exposure_sec: float, su return out_path + # Function: DiamondSequence._solve_verify_frame def _solve_verify_frame( self, fits_path: Path, @@ -1041,7 +1173,7 @@ def _solve_verify_frame( frame_width = int(image_hdr.get("NAXIS1", 0)) frame_height = int(image_hdr.get("NAXIS2", 0)) if frame_width > 0 and frame_height > 0: - wcs = WCS(str(wcs_path)) + wcs = WCS(hdr) target_x, target_y = [float(v) for v in wcs.all_world2pix([[ra_deg, dec_deg]], 0)[0]] max_margin = max(0, min(frame_width, frame_height) // 2 - 1) margin_px = min(POINTING_EDGE_MARGIN_PX, max_margin) @@ -1075,10 +1207,17 @@ def _solve_verify_frame( "edge_margin_px": margin_px, } - def _pointing_verify(self, target: AcquisitionTarget, notify, ccd_temp=None) -> dict: + # Function: DiamondSequence._pointing_verify + def _pointing_verify(self, target: AcquisitionTarget, notify, ccd_temp=None, abort_callback=None) -> dict: notify("A7", f"Pointing verify frame {VERIFY_EXPOSURE_SEC:.1f}s") try: - verify_fits = self._capture_temp_frame(target, VERIFY_EXPOSURE_SEC, "VERIFY", ccd_temp=ccd_temp) + verify_fits = self._capture_temp_frame( + target, + VERIFY_EXPOSURE_SEC, + "VERIFY", + ccd_temp=ccd_temp, + abort_callback=abort_callback, + ) except Exception as e: notify("A7", f"Verify capture failed: {e}") return { @@ -1113,6 +1252,7 @@ def _pointing_verify(self, target: AcquisitionTarget, notify, ccd_temp=None) -> _prune_verify_buffer() # Correct the command point from a solved frame while preserving the true target coordinates. + # Function: DiamondSequence._corrective_nudge def _corrective_nudge( self, command_ra_hours: float, @@ -1134,6 +1274,7 @@ def _corrective_nudge( corrected_dec_deg = max(-90.0, min(90.0, corrected_dec_deg)) return corrected_ra_hours, corrected_dec_deg + # Function: DiamondSequence.init_session def init_session(self, level_ok: bool = True) -> TelemetryBlock: mgmt_ok, mgmt_error = self._management_status() if not mgmt_ok: @@ -1204,6 +1345,7 @@ def init_session(self, level_ok: bool = True) -> TelemetryBlock: t.level_ok = level_ok return t + # Function: DiamondSequence.acquire def acquire( self, target: AcquisitionTarget, @@ -1214,11 +1356,13 @@ def acquire( ) -> FrameResult: """Execute A4-A11 for one target, or science-only when pointing is already established.""" + # Function: DiamondSequence.acquire.notify def notify(step, msg): if status_cb: status_cb(f"[{step}] {msg}") logger.info("[%s] %s", step, msg) + # Function: DiamondSequence.acquire.abort_requested def abort_requested() -> bool: try: return bool(abort_callback and abort_callback()) @@ -1301,7 +1445,12 @@ def abort_requested() -> bool: integration_sec=target.integration_sec, ) - solve = self._pointing_verify(verify_target, notify, ccd_temp=ccd_temp) + solve = self._pointing_verify( + verify_target, + notify, + ccd_temp=ccd_temp, + abort_callback=abort_requested, + ) if abort_requested(): return FrameResult(success=False, error="operator_abort") @@ -1385,12 +1534,7 @@ def abort_requested() -> bool: notify("A10", f"Set gain={GAIN} and start science exposure {exp_sec:.1f}s") if abort_requested(): return FrameResult(success=False, error="operator_abort") - try: - self._camera.set_gain(GAIN) - except Exception as e: - logger.warning("Gain set during acquire: %s", e) - - self._camera.start_exposure(exp_sec, light=True) + self._start_camera_exposure(exp_sec, light=True, notify=notify) notify("A10", "Waiting for science exposure + readout") image_timeout = exp_sec + EXPOSE_TIMEOUT @@ -1452,15 +1596,18 @@ def abort_requested() -> bool: logger.exception("acquire() failed: %s", e) return FrameResult(success=False, error=f"Acquire exception: {e}", elapsed_s=elapsed) + # Function: DiamondSequence.park def park(self): try: self._telescope.park() except Exception as e: logger.warning("Park failed: %s", e) + # Function: DiamondSequence.at_park def at_park(self) -> bool: return bool(self._telescope.at_park) + # Function: DiamondSequence.shutdown_scope def shutdown_scope(self): try: self._telescope.set_tracking(False) @@ -1468,6 +1615,7 @@ def shutdown_scope(self): logger.warning("Tracking stop before shutdown failed: %s", e) return _seestar_rpc_call(self.host, "pi_shutdown") + # Function: DiamondSequence.disconnect_all def disconnect_all(self): self._camera.disconnect() self._filter.disconnect() @@ -1478,6 +1626,7 @@ def disconnect_all(self): # Coordinate helpers # --------------------------------------------------------------------------- +# Function: _hours_to_hms def _hours_to_hms(hours: float) -> str: h = int(hours) m = int((hours - h) * 60) @@ -1485,6 +1634,7 @@ def _hours_to_hms(hours: float) -> str: return f"{h:02d}:{m:02d}:{s:05.2f}" +# Function: _deg_to_dms def _deg_to_dms(deg: float) -> str: sign = "+" if deg >= 0 else "-" deg = abs(deg) diff --git a/core/postflight/accountant.py b/core/postflight/accountant.py index 1a0a54b..b3af3a1 100755 --- a/core/postflight/accountant.py +++ b/core/postflight/accountant.py @@ -19,7 +19,9 @@ import astropy.units as u from astropy.coordinates import SkyCoord from astropy.io import fits +from astropy.wcs import WCS import numpy as np +from PIL import Image, ImageDraw from scipy import ndimage from scipy.ndimage import shift as ndi_shift from scipy.spatial import cKDTree @@ -65,11 +67,13 @@ MAX_STACK_FRAMES = 24 +# Function: _postflight_cfg def _postflight_cfg() -> dict: cfg = load_config() return cfg.get("postflight", {}) if isinstance(cfg, dict) else {} +# Function: _cfg_int def _cfg_int(key: str, default: int) -> int: try: return int(round(float(_postflight_cfg().get(key, default)))) @@ -83,6 +87,7 @@ def _cfg_int(key: str, default: int) -> int: _analyst = MasterAnalyst() +# Function: _install_accountant_log_handler def _install_accountant_log_handler(): LOG_DIR.mkdir(parents=True, exist_ok=True) log_path = LOG_DIR / "accountant.log" @@ -114,6 +119,7 @@ def _install_accountant_log_handler(): # Collapse low-level calibration errors into stable morning-triage categories. +# Function: _classify_failure def _classify_failure(error: str) -> str: err_l = str(error or "").lower() if "snr_too_low" in err_l: @@ -140,6 +146,7 @@ def _classify_failure(error: str) -> str: # Render a compact text report and matching JSON artifact for morning triage. +# Function: _write_postflight_report def _write_postflight_report( session_started_utc: str, processed: int, @@ -240,6 +247,7 @@ def _write_postflight_report( @contextmanager +# Function: _process_lock def _process_lock(): ACCOUNTANT_LOCK.parent.mkdir(parents=True, exist_ok=True) with open(ACCOUNTANT_LOCK, "w") as lock_handle: @@ -257,6 +265,7 @@ def _process_lock(): fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN) +# Function: load_ledger def load_ledger() -> dict: if LEDGER_FILE.exists(): try: @@ -268,6 +277,7 @@ def load_ledger() -> dict: return {} +# Function: save_ledger def save_ledger(entries: dict): output = { "#objective": "Master Observational Register and Status Ledger", @@ -283,6 +293,7 @@ def save_ledger(entries: dict): +# Function: _temp_bin_for_requirement def _temp_bin_for_requirement(temp_c): if temp_c in (None, "", "UNKNOWN"): return None @@ -292,6 +303,7 @@ def _temp_bin_for_requirement(temp_c): return None +# Function: save_missing_darks def save_missing_darks(entries: dict): requirements = {} @@ -346,6 +358,7 @@ def save_missing_darks(entries: dict): json.dump(payload, f, indent=4) +# Function: _blank_entry def _blank_entry() -> dict: return { "status": "PENDING", @@ -383,9 +396,12 @@ def _blank_entry() -> dict: "last_scope_id": None, "last_scope_name": None, "last_calibration_state": None, + "last_accepted_product": None, + "last_accepted_preview": None, } +# Function: _parse_header def _parse_header(fpath: Path, header: dict) -> tuple: target_name = header.get("OBJECT", "") if not str(target_name).strip(): @@ -429,10 +445,12 @@ def _parse_header(fpath: Path, header: dict) -> tuple: return target_name, date_obs, ra_deg, dec_deg +# Function: _archive_frame def _archive_frame(fpath: Path): _archive_paths([fpath]) +# Function: _parse_iso_utc def _parse_iso_utc(value: str | None): if not value: return None @@ -445,10 +463,128 @@ def _parse_iso_utc(value: str | None): return None +# Function: _safe_name def _safe_name(name: str) -> str: return str(name or "UNKNOWN").replace(" ", "_").replace("/", "-") +# Function: _accepted_products_dir +def _accepted_products_dir(session_started_utc: str) -> Path: + cfg = load_config() + postflight_cfg = cfg.get("postflight", {}) if isinstance(cfg, dict) else {} + configured = str(postflight_cfg.get("accepted_products_dir") or "").strip() + if configured: + return Path(configured).expanduser() + + storage_cfg = cfg.get("storage", {}) if isinstance(cfg, dict) else {} + primary = str(storage_cfg.get("primary_dir") or "").strip() + if primary: + root = Path(primary).expanduser() + return root / "Astrophoto" / "Variables" / f"SeeVar_{session_started_utc[:10].replace('-', '')}_accepted_solved" + + return ARCHIVE_DIR / "accepted_solved" + + +# Function: _copy_wcs_sidecar +def _copy_wcs_sidecar(source_wcs: Path | None, product_path: Path) -> Path | None: + if not source_wcs or not Path(source_wcs).exists(): + return None + dest_wcs = product_path.with_suffix(".wcs") + if Path(source_wcs) != dest_wcs: + shutil.copy2(source_wcs, dest_wcs) + return dest_wcs + + +# Function: _copy_accepted_fits +def _copy_accepted_fits(source: Path, dest: Path, result: dict, row: dict) -> Path: + dest.parent.mkdir(parents=True, exist_ok=True) + with fits.open(source) as hdul: + header = hdul[0].header + header["ACCEPTED"] = (True, "SeeVar postflight accepted") + header["ACCMAG"] = (float(result.get("mag", 0.0)), "Accepted TG magnitude") + header["ACCERR"] = (float(result.get("err", 0.0)), "Accepted TG uncertainty") + header["ACCSNR"] = (float(result.get("target_snr", 0.0)), "Accepted target SNR") + header["ACCSTATE"] = (str(row.get("calibration_state", ""))[:68], "Accepted product state") + header["ACCUTC"] = (datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "Acceptance UTC") + hdul.writeto(dest, overwrite=True) + return dest + + +# Function: _scale_preview +def _scale_preview(data: np.ndarray) -> np.ndarray: + arr = np.asarray(data, dtype=np.float32) + if arr.ndim > 2: + arr = np.squeeze(arr) + if arr.ndim != 2: + raise ValueError("preview data is not 2-D") + finite = arr[np.isfinite(arr)] + if finite.size == 0: + raise ValueError("preview data has no finite pixels") + lo, hi = np.nanpercentile(finite, [5.0, 99.85]) + if not np.isfinite(hi - lo) or hi <= lo: + lo, hi = float(np.nanmin(finite)), float(np.nanmax(finite)) + scaled = np.clip((arr - lo) / max(1e-6, hi - lo), 0.0, 1.0) + scaled = np.arcsinh(scaled * 8.0) / np.arcsinh(8.0) + return (scaled * 255.0).astype(np.uint8) + + +# Function: _write_accepted_preview +def _write_accepted_preview(fits_path: Path, jpg_path: Path, wcs_path: Path | None, ra_deg: float, dec_deg: float, label: str) -> Path: + data = fits.getdata(fits_path) + gray = _scale_preview(data) + image = Image.fromarray(gray).convert("RGB") + draw = ImageDraw.Draw(image) + + try: + if wcs_path and Path(wcs_path).exists(): + wcs = WCS(fits.getheader(wcs_path, 0)) + x, y = [float(v) for v in wcs.all_world2pix([[ra_deg, dec_deg]], 0)[0]] + if 0 <= x < image.width and 0 <= y < image.height: + r = 22 + draw.line((x - r, y, x + r, y), fill=(255, 210, 80), width=2) + draw.line((x, y - r, x, y + r), fill=(255, 210, 80), width=2) + draw.ellipse((x - 5, y - 5, x + 5, y + 5), outline=(255, 210, 80), width=2) + except Exception as e: + log.debug(" accepted preview WCS overlay skipped for %s: %s", fits_path.name, e) + + draw.text((18, 18), label, fill=(255, 255, 255)) + jpg_path.parent.mkdir(parents=True, exist_ok=True) + image.save(jpg_path, quality=92) + return jpg_path + + +# Function: _publish_accepted_products +def _publish_accepted_products( + product_path: Path, + wcs_path: Path | None, + target_name: str, + ra_deg: float, + dec_deg: float, + result: dict, + row: dict, + session_started_utc: str, +) -> tuple[Path | None, Path | None]: + dest_dir = _accepted_products_dir(session_started_utc) + safe = _safe_name(target_name) + suffix = "stack" if "STACK" in product_path.name.upper() else "single" + stamp = _parse_iso_utc(row.get("last_obs_utc")) or datetime.now(timezone.utc) + base = f"{safe}_{stamp.strftime('%Y%m%dT%H%M%S')}_accepted_solved_{suffix}" + dest_fits = dest_dir / f"{base}.fits" + dest_jpg = dest_dir / f"{base}.jpg" + + try: + copied_fits = _copy_accepted_fits(product_path, dest_fits, result, row) + copied_wcs = _copy_wcs_sidecar(wcs_path, copied_fits) + label = f"{target_name} TG={float(result.get('mag', 0.0)):.3f} SNR={float(result.get('target_snr', 0.0)):.1f}" + copied_jpg = _write_accepted_preview(copied_fits, dest_jpg, copied_wcs, ra_deg, dec_deg, label) + log.info(" accepted products written for %s: %s / %s", target_name, copied_fits.name, copied_jpg.name) + return copied_fits, copied_jpg + except Exception as e: + log.warning(" accepted product publish failed for %s: %s", target_name, e) + return None, None + + +# Function: _load_frame_meta def _load_frame_meta(fpath: Path) -> dict | None: try: header = fits.getheader(fpath, 0) @@ -475,6 +611,7 @@ def _load_frame_meta(fpath: Path) -> dict | None: } +# Function: _build_target_groups def _build_target_groups(fits_files: list[Path]) -> list[dict]: metas = [] for fpath in sorted(fits_files): @@ -505,6 +642,7 @@ def _build_target_groups(fits_files: list[Path]) -> list[dict]: return groups +# Function: _stack_output_path def _stack_output_path(target_name: str, obs_dt: datetime, n_frames: int) -> Path: PROCESS_DIR.mkdir(parents=True, exist_ok=True) stamp = obs_dt.strftime("%Y%m%dT%H%M%S") @@ -512,6 +650,7 @@ def _stack_output_path(target_name: str, obs_dt: datetime, n_frames: int) -> Pat return PROCESS_DIR / f"{safe_name}_{stamp}_STACK_{n_frames}x.fits" +# Function: _stack_subset def _stack_subset(paths: list[Path], limit: int = MAX_STACK_FRAMES) -> list[Path]: if len(paths) <= limit: return list(paths) @@ -532,6 +671,7 @@ def _stack_subset(paths: list[Path], limit: int = MAX_STACK_FRAMES) -> list[Path # Estimate a robust background level and sigma from finite image pixels. +# Function: _background_stats def _background_stats(data: np.ndarray) -> tuple[float, float]: finite = np.isfinite(data) vals = data[finite] @@ -544,6 +684,7 @@ def _background_stats(data: np.ndarray) -> tuple[float, float]: # Detect compact bright-source centroids for stack alignment. +# Function: _source_centroids def _source_centroids(data: np.ndarray, max_sources: int = 200) -> np.ndarray: med, sigma = _background_stats(data) finite = np.isfinite(data) @@ -584,6 +725,7 @@ def _source_centroids(data: np.ndarray, max_sources: int = 200) -> np.ndarray: # Estimate image shift from matched star centroids and reject noisy matches. +# Function: _star_shift def _star_shift(reference: np.ndarray, moving: np.ndarray, max_shift_px: float = 250.0) -> tuple[float, float] | None: ref_points = _source_centroids(reference) mov_points = _source_centroids(moving) @@ -610,6 +752,7 @@ def _star_shift(reference: np.ndarray, moving: np.ndarray, max_shift_px: float = # Use astroalign's asterism matching when translational shift alignment is not enough. +# Function: _astroalign_frame def _astroalign_frame(reference: np.ndarray, moving: np.ndarray) -> tuple[np.ndarray, dict] | None: if astroalign is None: return None @@ -639,6 +782,7 @@ def _astroalign_frame(reference: np.ndarray, moving: np.ndarray) -> tuple[np.nda # Prefer cheap shift alignment, but fall back to astroalign for rotated/doubled fields. +# Function: _align_stack_frame def _align_stack_frame( reference: np.ndarray, ref_work: np.ndarray, @@ -692,6 +836,7 @@ def _align_stack_frame( return aligned_arr.astype(np.float32), (shift_y, shift_x), "SHIFT" +# Function: _median_stack def _median_stack(calibrated_paths: list[Path], target_name: str, obs_dt: datetime) -> Path | None: if len(calibrated_paths) < 2: return None @@ -774,11 +919,13 @@ def _median_stack(calibrated_paths: list[Path], target_name: str, obs_dt: dateti median_abs_shift = float(np.median([max(abs(dy), abs(dx)) for dy, dx in shifts])) if shifts else 0.0 log.info(" aligned stack for %s: %d/%d frame(s) kept, median |shift|=%.2f px -> %s", target_name, len(aligned), len(kept), median_abs_shift, out_path.name) return out_path +# Function: _archive_group def _archive_group(group_items: list[dict]): for item in group_items: _archive_frame(item["path"]) +# Function: _related_products def _related_products(path: Path) -> list[Path]: path = Path(path) stem = path.stem @@ -796,6 +943,7 @@ def _related_products(path: Path) -> list[Path]: return products +# Function: _archive_paths def _archive_paths(paths: list[Path]): ARCHIVE_DIR.mkdir(parents=True, exist_ok=True) seen = set() @@ -814,6 +962,7 @@ def _archive_paths(paths: list[Path]): log.warning(" Archive failed for %s: %s", candidate.name, e) +# Function: _archive_failed_group def _archive_failed_group(group_items: list[dict], calibrated_paths: list[Path], stack_path: Path | None): raw_paths = [item["path"] for item in group_items] transient_paths = raw_paths + list(calibrated_paths) @@ -822,6 +971,7 @@ def _archive_failed_group(group_items: list[dict], calibrated_paths: list[Path], _archive_paths(transient_paths) +# Function: _purge_paths def _purge_paths(paths: list[Path]): seen = set() for path in paths: @@ -840,6 +990,7 @@ def _purge_paths(paths: list[Path]): log.warning(" Cleanup failed for %s: %s", candidate.name, e) +# Function: _cleanup_completed_group def _cleanup_completed_group(group_items: list[dict], calibrated_paths: list[Path], stack_path: Path | None): raw_paths = [item["path"] for item in group_items] transient_paths = raw_paths + list(calibrated_paths) @@ -848,6 +999,7 @@ def _cleanup_completed_group(group_items: list[dict], calibrated_paths: list[Pat _purge_paths(transient_paths) +# Function: _cleanup_intermediates def _cleanup_intermediates(calibrated_paths: list[Path], stack_path: Path | None): transient_paths = list(calibrated_paths) if stack_path: @@ -855,11 +1007,13 @@ def _cleanup_intermediates(calibrated_paths: list[Path], stack_path: Path | None _purge_paths(transient_paths) +# Function: _clear_unclosed_success def _clear_unclosed_success(entry: dict): if not entry.get("last_obs_utc"): entry["last_success"] = None +# Function: process_buffer def process_buffer(): with _process_lock() as lock_acquired: if not lock_acquired: @@ -1101,7 +1255,10 @@ def process_buffer(): solve_path.name, stack_path.name, ) + copied_wcs = _copy_wcs_sidecar(solve_wcs_path, stack_path) solve_path = stack_path + if copied_wcs: + solve_wcs_path = copied_wcs cal_state = f"{cal_state}+STACK_WCS_FALLBACK" target_mag = mag_lookup.get(key) @@ -1187,6 +1344,22 @@ def process_buffer(): "solved_ra_deg": result.get("solved_ra_deg"), "solved_dec_deg": result.get("solved_dec_deg"), }) + accepted_fits, accepted_jpg = _publish_accepted_products( + Path(solve_path), + solve_wcs_path, + key, + ra_deg, + dec_deg, + result, + row, + session_started_utc, + ) + if accepted_fits: + ledger[key]["last_accepted_product"] = str(accepted_fits) + row["accepted_product"] = str(accepted_fits) + if accepted_jpg: + ledger[key]["last_accepted_preview"] = str(accepted_jpg) + row["accepted_preview"] = str(accepted_jpg) else: log.warning(" %s poor SNR=%.1f (min %.1f)", key, snr, MIN_SNR) if ledger[key].get("status") != "OBSERVED": diff --git a/dev/tests/postflight/test_dark_postflight.py b/dev/tests/postflight/test_dark_postflight.py index 8012cdc..3159f52 100644 --- a/dev/tests/postflight/test_dark_postflight.py +++ b/dev/tests/postflight/test_dark_postflight.py @@ -11,6 +11,7 @@ """ import json +import shutil from datetime import datetime, timezone from pathlib import Path @@ -18,7 +19,7 @@ from astropy.io import fits import sys -PROJECT_ROOT = Path(__file__).resolve().parents[1] +PROJECT_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(PROJECT_ROOT)) from core.utils.env_loader import DATA_DIR @@ -32,6 +33,7 @@ LEDGER_FILE = DATA_DIR / "ledger.json" +# Function: reset_test_artifacts def reset_test_artifacts(): for path in (LOCAL_BUFFER, ARCHIVE_DIR, DARK_DIR, CAL_DIR, PROCESS_DIR): path.mkdir(parents=True, exist_ok=True) @@ -40,11 +42,14 @@ def reset_test_artifacts(): for p in directory.iterdir(): if p.is_file(): p.unlink() + elif p.is_dir(): + shutil.rmtree(p) if LEDGER_FILE.exists(): LEDGER_FILE.unlink() +# Function: make_master_dark def make_master_dark(exp_ms=5000, gain=80, temp_bin=20): dark_key = f"dark_tb{temp_bin:+d}_e{exp_ms}_g{gain}" dark_path = DARK_DIR / f"{dark_key}_master.fits" @@ -79,6 +84,7 @@ def make_master_dark(exp_ms=5000, gain=80, temp_bin=20): return dark_path +# Function: draw_star def draw_star(img, x, y, amp=8000.0, sigma=2.5): h, w = img.shape x0 = int(round(x)) @@ -93,6 +99,7 @@ def draw_star(img, x, y, amp=8000.0, sigma=2.5): img[np.ix_(ys, xs)] += spot +# Function: make_science_frame def make_science_frame(name="TEST_VAR", exp_ms=5000, gain=80, ccd_temp=21.3): h, w = 3840, 2160 img = np.random.normal(500.0, 18.0, (h, w)).astype(np.float32) @@ -140,8 +147,11 @@ def make_science_frame(name="TEST_VAR", exp_ms=5000, gain=80, ccd_temp=21.3): return fits_path +# Function: inspect_results def inspect_results(): ledger_ok = False + accepted_product_ok = False + accepted_preview_ok = False cal_files = sorted(CAL_DIR.glob("*_cal.fit")) + sorted(CAL_DIR.glob("*_cal.fits")) archived = sorted(ARCHIVE_DIR.glob("*.fit")) + sorted(ARCHIVE_DIR.glob("*.fits")) stacks = sorted(PROCESS_DIR.glob("*.fit")) + sorted(PROCESS_DIR.glob("*.fits")) @@ -150,24 +160,30 @@ def inspect_results(): if LEDGER_FILE.exists(): data = json.loads(LEDGER_FILE.read_text()) entries = data.get("entries", {}) - if entries.get("TEST_VAR", {}).get("status") == "OBSERVED": + entry = entries.get("TEST_VAR", {}) + if entry.get("status") == "OBSERVED": ledger_ok = True + accepted_product_ok = bool(entry.get("last_accepted_product") and Path(entry["last_accepted_product"]).exists()) + accepted_preview_ok = bool(entry.get("last_accepted_preview") and Path(entry["last_accepted_preview"]).exists()) print("") print("Smoke test results") print(f" Ledger stamped : {'YES' if ledger_ok else 'NO'}") print(f" Calibrated FITS: {len(cal_files)}") print(f" Archived raw : {len(archived)}") + print(f" Accepted FITS : {'YES' if accepted_product_ok else 'NO'}") + print(f" Accepted JPEG : {'YES' if accepted_preview_ok else 'NO'}") print(f" Stack FITS : {len(stacks)}") print(f" Raw remaining : {len(remaining_raw)}") - if not ledger_ok or cal_files or archived or stacks or remaining_raw: + if not ledger_ok or cal_files or archived or not accepted_product_ok or not accepted_preview_ok or stacks or remaining_raw: raise SystemExit(1) print("") print("PASS: dark calibration + accountant closure path exercised successfully.") +# Function: main def main(): print("Preparing dark-postflight smoke test...") reset_test_artifacts() @@ -178,7 +194,9 @@ def main(): from core.postflight.dark_calibrator import DarkCalibrator accountant.dark_calibrator = DarkCalibrator() + accountant._accepted_products_dir = lambda session_started_utc: ARCHIVE_DIR / "accepted_solved" + # Function: main.fake_solve_frame def fake_solve_frame(path): p = Path(path) return { @@ -190,6 +208,7 @@ def fake_solve_frame(path): "fov_deg": 0.9, } + # Function: main.fake_calibrate def fake_calibrate(fits_path, ra_deg, dec_deg, target_name, target_mag=None, wcs_path=None, solve_result=None): return { "status": "ok", @@ -212,5 +231,10 @@ def fake_calibrate(fits_path, ra_deg, dec_deg, target_name, target_mag=None, wcs inspect_results() +# Function: test_dark_postflight_smoke +def test_dark_postflight_smoke(): + main() + + if __name__ == "__main__": main() From b9f1f1927bd29a358d1baf7ceaf127baf6e5474d Mon Sep 17 00:00:00 2001 From: Ed de la Rie Date: Mon, 25 May 2026 20:05:59 +0200 Subject: [PATCH 3/8] Fix basic checks empty regression glob --- .github/workflows/basic-checks.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/basic-checks.yml b/.github/workflows/basic-checks.yml index 15bdaa6..bce3a6a 100644 --- a/.github/workflows/basic-checks.yml +++ b/.github/workflows/basic-checks.yml @@ -52,7 +52,13 @@ jobs: - name: Run regression tests run: | - for t in dev/test_*.py; do + shopt -s nullglob + tests=(dev/test_*.py) + if [ "${#tests[@]}" -eq 0 ]; then + echo "No legacy dev/test_*.py regression tests found." + exit 0 + fi + for t in "${tests[@]}"; do echo "Running $t" python "$t" done From dcaa4af3af8b9e670f74b466b79c5416f54c5ac2 Mon Sep 17 00:00:00 2001 From: Ed de la Rie Date: Wed, 3 Jun 2026 15:13:46 +0200 Subject: [PATCH 4/8] Harden flight continuity proof chain --- README.md | 16 +- ROADMAP.md | 5 + config.toml.example | 12 + core/flight/fsm.py | 111 ++++++- core/flight/proof.py | 58 ++++ core/flight/seestar_alp_adapter.py | 201 +++++++++++++ core/flight/star_quality.py | 146 +++++++++ core/postflight/accountant.py | 138 ++++++++- dev/CONTRIBUTING.md | 7 +- dev/logic/ALPACA_BRIDGE.MD | 11 +- dev/logic/ARCHITECTURE_OVERVIEW.MD | 11 +- dev/logic/CADENCE.MD | 9 + dev/logic/CORE.MD | 9 +- dev/logic/FILE_MANIFEST.md | 4 +- dev/logic/PHOTOMETRICS.MD | 3 + dev/logic/POSTFLIGHT.MD | 14 +- dev/logic/README.MD | 6 +- dev/logic/SEEVAR_SKILL/SKILL.md | 8 +- dev/logic/SIMULATORLOGIC.MD | 5 +- dev/logic/STATE_MACHINE.MD | 8 +- dev/logic/WORKFLOW.MD | 13 +- dev/tests/README.md | 1 + dev/tests/postflight/test_dark_postflight.py | 12 +- dev/tests/test_flight_step_proofs.py | 297 +++++++++++++++++++ dev/tests/test_star_quality.py | 36 +++ 25 files changed, 1099 insertions(+), 42 deletions(-) create mode 100644 core/flight/proof.py create mode 100644 core/flight/seestar_alp_adapter.py create mode 100644 core/flight/star_quality.py create mode 100644 dev/tests/test_flight_step_proofs.py create mode 100644 dev/tests/test_star_quality.py diff --git a/README.md b/README.md index a6e6500..b3ad6f4 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,9 @@ SeeVar is organized as a sovereign observing pipeline: 2. **Flight** Executes one canonical target sequence per object using the `A1-A12` flight chain: target lock, safety gate, session init, slew, verify, settle, exposure planning, acquire, quality gate, and commit. + Each target writes a proof ledger in `data/flight_runs/`; success requires + the full connect, prepare, solve, track, expose, accept, and complete chain. + Partial target success is disabled by default. 3. **Postflight** Processes captured frames using the `P1-P8` science chain: @@ -108,6 +111,9 @@ SeeVar is organized as a sovereign observing pipeline: ## Hardware Interface SeeVar communicates with the Seestar through the official Alpaca REST interface exposed by the telescope firmware. +The default control path remains firmware Alpaca. A dormant `seestar_alp` +adapter can be enabled with `[seestar_alp].enabled = true` and +`mode = "controlled"`; otherwise it is diagnostics-only. Confirmed device access includes: @@ -300,9 +306,11 @@ plate_solve_timeout_sec = 90 plate_solve_cpulimit_sec = 75 ``` -The accountant tries the aligned stack first, then the newest calibrated single -frames. When the cap is reached, the target fails honestly and processing moves -on. +For multi-frame targets, the accountant now requires a stacked product by +default. Singles are not promoted to successful object previews unless +`require_stacked_products = false` or the target only has one calibrated frame. +Successful postflight should publish one accepted stacked FITS/JPEG/report per +object. A7 in-flight pointing verification is separate from postflight. It is governed by `[flight]` and should fail fast: @@ -318,6 +326,8 @@ pointing_accept_target_in_frame = true pointing_edge_margin_px = 250 verify_retention_sets = 40 frame_retry_limit = 0 +allow_partial_target_success = false +strict_target_proof_chain = true ``` When `pointing_accept_target_in_frame` is enabled, A7 accepts a solved frame if diff --git a/ROADMAP.md b/ROADMAP.md index 036ff8a..0f698d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,6 +80,10 @@ It is making the science chain defensible end-to-end. - postflight doctrine frozen around `P1-P8` - photometry doctrine rewritten around Bayer-aware measurement rather than naive debayering - docs now reflect that flight and postflight have separate responsibilities +- per-target proof ledger started in `data/flight_runs/` +- partial target success disabled by default +- multi-frame postflight requires one stacked accepted product by default +- dormant `seestar_alp` controlled adapter added behind the pilot interface #### 1.9.0 — Doctrine Freeze - freeze sovereign `A1-A12` @@ -107,6 +111,7 @@ Next: - wire accepted postflight TG results into AAVSO report staging - make report generation a true `P8` output - stop treating the reporter as a side utility +- enforce no AAVSO upload without stack, WCS, photometry, and report proof #### 1.9.5 — Astropy Review Pass - replace custom code with `astropy` components where that improves correctness and maintainability diff --git a/config.toml.example b/config.toml.example index 3ab01b1..fcc8921 100644 --- a/config.toml.example +++ b/config.toml.example @@ -133,10 +133,19 @@ pointing_gross_max_retries = 1 pointing_accept_target_in_frame = true pointing_edge_margin_px = 250 verify_retention_sets = 40 +strict_tracking_required = true +strict_camera_idle_required = true +mount_idle_verify_timeout_sec = 5.0 +tracking_verify_timeout_sec = 10.0 +tracking_verify_interval_sec = 0.5 +camera_idle_timeout_sec = 8.0 +pointing_proof_max_age_sec = 900.0 pointing_model_enabled = true pointing_model_max_age_hours = 12.0 pointing_reverify_interval_frames = 5 frame_retry_limit = 0 +allow_partial_target_success = false +strict_target_proof_chain = true prealign_before_flight = false prealign_points = 3 prealign_exposure_sec = 5.0 @@ -158,6 +167,7 @@ prealign_wide_solve_radius_deg = 60.0 [seestar_alp] enabled = false base_url = "http://127.0.0.1:5555" +port = 5555 mode = "diagnostic" # diagnostic | controlled # ----------------------------------------------------------------------------- @@ -201,6 +211,8 @@ plate_solve_cpulimit_sec = 75 auto_stage_reports = true report_mirror_dir = "/mnt/astronas/reports" accepted_products_dir = "" +publish_single_frame_previews = false +require_stacked_products = true auto_park = true auto_shutdown_scope = false diff --git a/core/flight/fsm.py b/core/flight/fsm.py index 86fa58e..1aff548 100644 --- a/core/flight/fsm.py +++ b/core/flight/fsm.py @@ -19,6 +19,7 @@ sys.path.insert(0, str(PROJECT_ROOT)) from core.flight.pilot import AcquisitionTarget, DiamondSequence, TelemetryBlock +from core.flight.proof import FlightProofRecorder from core.utils.env_loader import DATA_DIR, load_config logger = logging.getLogger("seevar.fsm") @@ -32,6 +33,12 @@ def _flight_cfg() -> dict: return cfg.get("flight", {}) if isinstance(cfg, dict) else {} +# Function: _seestar_alp_cfg +def _seestar_alp_cfg() -> dict: + cfg = load_config() + return cfg.get("seestar_alp", {}) if isinstance(cfg, dict) else {} + + # Function: _cfg_int def _cfg_int(key: str, default: int) -> int: try: @@ -42,6 +49,46 @@ def _cfg_int(key: str, default: int) -> int: FRAME_RETRY_LIMIT = max(0, _cfg_int("frame_retry_limit", 0)) POINTING_REVERIFY_INTERVAL_FRAMES = max(1, _cfg_int("pointing_reverify_interval_frames", 5)) + + +# Function: _cfg_bool +def _cfg_bool(key: str, default: bool) -> bool: + value = _flight_cfg().get(key, default) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +# Function: _truthy +def _truthy(value, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on", "controlled"} + if value is None: + return default + return bool(value) + + +# Function: _seestar_alp_control_enabled +def _seestar_alp_control_enabled() -> bool: + cfg = _seestar_alp_cfg() + return _truthy(cfg.get("enabled"), False) and str(cfg.get("mode", "diagnostic")).strip().lower() == "controlled" + + +# Function: _build_sequence +def _build_sequence(): + if _seestar_alp_control_enabled(): + from core.flight.seestar_alp_adapter import SeestarAlpSequence + + return SeestarAlpSequence() + return DiamondSequence() + + +ALLOW_PARTIAL_TARGET_SUCCESS = _cfg_bool("allow_partial_target_success", False) +STRICT_TARGET_PROOF_CHAIN = _cfg_bool("strict_target_proof_chain", True) TAG_STATE = { "[A4]": "SLEWING", "[A5]": "SLEWING", @@ -52,6 +99,36 @@ def _cfg_int(key: str, default: int) -> int: "[A11]": "TRACKING", } +PROOF_PASS_PATTERNS = { + "solve": ("Pointing accepted", "Solve success"), + "track": ("Tracking proof accepted",), + "accept": ("Frame accepted",), +} +PROOF_FAIL_PATTERNS = { + "slew": ("Slew timeout", "Slew completion proof failed"), + "solve": ("Pointing verify failed", "Verify solve failed", "Pointing error", "Gross pointing error"), + "track": ("Tracking not ready", "Tracking unavailable", "Telescope is parked"), + "expose": ("Image not ready", "Camera not idle", "exposure start failed"), + "accept": ("Frame rejected", "Invalid image", "Flat image", "FITS write failed"), +} + + +# Function: _proof_step_from_message +def _proof_step_from_message(msg: str) -> tuple[str | None, str | None]: + for step, patterns in PROOF_PASS_PATTERNS.items(): + if any(pattern in msg for pattern in patterns): + return step, "pass" + for step, patterns in PROOF_FAIL_PATTERNS.items(): + if any(pattern in msg for pattern in patterns): + return step, "fail" + if msg.startswith("[A4]"): + return "slew", "progress" + if msg.startswith("[A7]"): + return "solve", "progress" + if msg.startswith("[A10] Set gain") or "start science exposure" in msg: + return "expose", "progress" + return None, None + class SovereignFSM: # Function: SovereignFSM.__init__ @@ -62,7 +139,7 @@ def __init__(self): self.last_frame_paths: list[Path] = [] self.frame_retry_limit = FRAME_RETRY_LIMIT self.pointing_reverify_interval_frames = POINTING_REVERIFY_INTERVAL_FRAMES - self.sequence = DiamondSequence() + self.sequence = _build_sequence() logger.info("🧠 FSM Initialized in state: %s", self.state) # Function: SovereignFSM.update @@ -126,6 +203,8 @@ def execute_target( self.update("WORKING") self.last_prepared_target = None self.last_frame_paths = [] + proof = FlightProofRecorder(target.name) + proof.record("target", "start", detail=f"n_frames={target.n_frames}") self.frame_retry_limit = max(0, _cfg_int("frame_retry_limit", self.frame_retry_limit)) self.pointing_reverify_interval_frames = max( 1, @@ -142,6 +221,9 @@ def bridge(*parts): msg = " ".join(str(p) for p in parts) logger.info("Bridge: %s", msg) + proof_step, proof_status = _proof_step_from_message(msg) + if proof_step and proof_status: + proof.record(proof_step, proof_status, detail=msg) ui_state = self._bridge_ui_state(msg) self._write_state_bridge(ui_state, msg) if status_cb: @@ -158,15 +240,18 @@ def abort_requested() -> bool: try: if abort_requested(): self._write_state_bridge("ABORTED", "Operator abort before target execution") + proof.record("target", "fail", detail="operator_abort before target execution") self.update("ERROR") return False if telemetry and telemetry.is_safe(): self.telemetry = telemetry logger.info("[A3] Reusing validated session telemetry for %s", target.name) + proof.record("connect", "pass", detail="validated session telemetry reused") self._write_state_bridge("PREFLIGHT", f"[A3] Reusing validated session telemetry for {target.name}") elif self.telemetry and self.telemetry.is_safe(): logger.info("[A3] Reusing cached session telemetry for %s", target.name) + proof.record("connect", "pass", detail="cached session telemetry reused") self._write_state_bridge("PREFLIGHT", f"[A3] Reusing cached session telemetry for {target.name}") else: logger.info("[A3] Session init for %s", target.name) @@ -176,14 +261,17 @@ def abort_requested() -> bool: if not self.telemetry or not self.telemetry.is_safe(): reason = self.telemetry.veto_reason() if self.telemetry else "Telemetry unavailable" logger.error("[A3] Hardware veto: %s", reason) + proof.record("connect", "fail", detail=reason) self._write_state_bridge("ABORTED", f"[A3] Hardware veto: {reason}") self.update("ERROR") return False + proof.record("connect", "pass", detail=self.telemetry.summary()) target = self.sequence.prepare_target(target, telemetry=self.telemetry, notify=bridge) if target is None: raise RuntimeError("prepare_target returned None") self.last_prepared_target = target + proof.record("target_prepare", "pass", detail=f"exp_ms={target.exp_ms} n_frames={target.n_frames}") logger.info("[A10] Acquire %d frame(s) for %s", target.n_frames, target.name) successful_frames = 0 @@ -224,6 +312,7 @@ def abort_requested() -> bool: if result.success: logger.info("[A11] Frame %d accepted: %s", i + 1, result.path) + proof.record("accept", "pass", detail=f"frame {i + 1}/{target.n_frames}", evidence_path=result.path) self._write_state_bridge("TRACKING", f"[A11] Frame {i + 1} accepted: {result.path}") if result.path: self.last_frame_paths.append(Path(result.path)) @@ -233,10 +322,12 @@ def abort_requested() -> bool: last_error = result.error logger.error("[A11] Frame %d failed: %s", i + 1, result.error) + proof.record("accept", "fail", detail=f"frame {i + 1}/{target.n_frames}: {result.error}") if not frame_ok: failed_frames += 1 - if target.n_frames == 1: + if STRICT_TARGET_PROOF_CHAIN or target.n_frames == 1: + proof.record("target", "fail", detail=f"first failed proof stopped target: {last_error}") self._write_state_bridge("ABORTED", f"[A11] Frame {i + 1} failed after retries: {last_error}") self.update("ERROR") return False @@ -246,22 +337,32 @@ def abort_requested() -> bool: if successful_frames == target.n_frames: logger.info("[A11] Acquisition complete for %s", target.name) + proof.record("target", "pass", detail=f"{successful_frames}/{target.n_frames} frame(s) accepted") self._write_state_bridge("TRACKING", f"[A11] Acquisition complete for {target.name}") self.update("SUCCESS") return True if successful_frames > 0: logger.warning("[A11] Acquisition partial for %s: %d/%d frame(s) accepted, %d failed", target.name, successful_frames, target.n_frames, failed_frames) - self._write_state_bridge("TRACKING", f"[A11] Acquisition partial for {target.name}: {successful_frames}/{target.n_frames} frame(s) accepted") - self.update("SUCCESS") - return True + if ALLOW_PARTIAL_TARGET_SUCCESS: + proof.record("target", "pass", detail=f"partial accepted by config: {successful_frames}/{target.n_frames}") + self._write_state_bridge("TRACKING", f"[A11] Acquisition partial for {target.name}: {successful_frames}/{target.n_frames} frame(s) accepted") + self.update("SUCCESS") + return True + + proof.record("target", "fail", detail=f"incomplete: {successful_frames}/{target.n_frames} frame(s) accepted") + self._write_state_bridge("ABORTED", f"[A11] Acquisition incomplete for {target.name}: {successful_frames}/{target.n_frames} frame(s) accepted") + self.update("ERROR") + return False + proof.record("target", "fail", detail=f"0/{target.n_frames} frame(s) accepted") self._write_state_bridge("ABORTED", f"[A11] Acquisition failed for {target.name}: 0/{target.n_frames} frame(s) accepted") self.update("ERROR") return False except Exception as e: logger.exception("💥 FSM Critical Failure: %s", e) + proof.record("target", "fail", detail=f"FSM critical failure: {e}") self._write_state_bridge("ABORTED", f"FSM critical failure: {e}") self.update("ERROR") return False diff --git a/core/flight/proof.py b/core/flight/proof.py new file mode 100644 index 0000000..b2cdaaf --- /dev/null +++ b/core/flight/proof.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: core/flight/proof.py +Objective: Append-only per-target proof records for SeeVar flight continuity. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from core.utils.env_loader import DATA_DIR + +RUNS_DIR = DATA_DIR / "flight_runs" + + +# Function: _safe_name +def _safe_name(value: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in str(value)) + return cleaned.strip("_") or "target" + + +class FlightProofRecorder: + # Function: FlightProofRecorder.__init__ + def __init__(self, target_name: str, run_id: str | None = None): + self.target_name = target_name + self.run_id = run_id or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + RUNS_DIR.mkdir(parents=True, exist_ok=True) + self.path = RUNS_DIR / f"{self.run_id}_{_safe_name(target_name)}.jsonl" + + # Function: FlightProofRecorder.record + def record( + self, + step: str, + status: str, + *, + detail: str = "", + evidence_path: str | Path | None = None, + extra: dict[str, Any] | None = None, + ) -> None: + row: dict[str, Any] = { + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "run_id": self.run_id, + "target": self.target_name, + "step": step, + "status": status, + } + if detail: + row["detail"] = detail + if evidence_path: + row["evidence_path"] = str(evidence_path) + if extra: + row.update(extra) + with self.path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(row, sort_keys=True) + "\n") diff --git a/core/flight/seestar_alp_adapter.py b/core/flight/seestar_alp_adapter.py new file mode 100644 index 0000000..fb589c0 --- /dev/null +++ b/core/flight/seestar_alp_adapter.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: core/flight/seestar_alp_adapter.py +Objective: Optional seestar_alp-backed flight control adapter behind the SeeVar pilot interface. +""" + +from __future__ import annotations + +import logging +import time +from pathlib import Path +from typing import Any, Protocol + +from core.flight.pilot import AcquisitionTarget, FrameResult, GAIN, TelemetryBlock +from core.utils.env_loader import load_config, selected_scope, selected_scope_host + +logger = logging.getLogger("seevar.seestar_alp_adapter") + + +class SSalpClientProtocol(Protocol): + # Function: SSalpClientProtocol.test_connection_sync + def test_connection_sync(self) -> dict: ... + + # Function: SSalpClientProtocol.goto_target_sync + def goto_target_sync(self, target_name: str, ra: float, dec: float, is_j2000: bool = True) -> dict: ... + + # Function: SSalpClientProtocol.scope_get_track_state_sync + def scope_get_track_state_sync(self) -> dict: ... + + # Function: SSalpClientProtocol.scope_set_track_state_sync + def scope_set_track_state_sync(self, enabled: bool) -> dict: ... + + # Function: SSalpClientProtocol.start_solve_sync + def start_solve_sync(self) -> dict: ... + + # Function: SSalpClientProtocol.get_solve_result_sync + def get_solve_result_sync(self) -> dict: ... + + # Function: SSalpClientProtocol.start_stack_sync + def start_stack_sync(self, gain: int, restart: bool = True) -> dict: ... + + # Function: SSalpClientProtocol.get_last_image_sync + def get_last_image_sync(self, is_subframe: bool = True, is_thumb: bool = False) -> dict: ... + + +# Function: _seestar_alp_cfg +def _seestar_alp_cfg() -> dict: + cfg = load_config() + return cfg.get("seestar_alp", {}) if isinstance(cfg, dict) else {} + + +# Function: _truthy +def _truthy(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, dict): + for key in ("ok", "success", "solved", "tracking", "is_tracking", "state", "enabled", "Value"): + if key in value: + return _truthy(value[key]) + if isinstance(value, str): + return value.strip().lower() in {"true", "on", "tracking", "solved", "ok", "success", "1"} + return bool(value) + + +# Function: _extract_path +def _extract_path(value: Any) -> Path | None: + if isinstance(value, (str, Path)): + text = str(value).strip() + return Path(text) if text else None + if isinstance(value, dict): + for key in ("path", "file", "file_path", "fits", "fits_path", "filename", "local_path"): + if key in value: + found = _extract_path(value[key]) + if found: + return found + for child in value.values(): + found = _extract_path(child) + if found: + return found + if isinstance(value, (list, tuple)): + for child in value: + found = _extract_path(child) + if found: + return found + return None + + +class SeestarAlpSequence: + # Function: SeestarAlpSequence.__init__ + def __init__(self, host: str | None = None, port: int | None = None, client: SSalpClientProtocol | None = None): + cfg = load_config() + scope = selected_scope(cfg if isinstance(cfg, dict) else {}) + resolved_host, source = selected_scope_host(cfg if isinstance(cfg, dict) else {}) if not host else (host, "explicit") + alp_cfg = _seestar_alp_cfg() + self.host = resolved_host + self.port = int(port or alp_cfg.get("port") or alp_cfg.get("alpaca_port") or scope.get("alpaca_port") or 5555) + self.base_url = str(alp_cfg.get("base_url") or f"http://{self.host}:{self.port}") + self.host_source = source + self.client = client or self._build_client() + self._session_ready = False + logger.info("SeestarAlpSequence endpoint: %s (%s)", self.base_url, self.host_source) + + # Function: SeestarAlpSequence._build_client + def _build_client(self) -> SSalpClientProtocol: + try: + from ssalp_api_client import SSAlpApiClient + except Exception as e: + raise RuntimeError(f"ssalp_api_client unavailable: {e}") from e + return SSAlpApiClient(base_url=self.base_url) + + # Function: SeestarAlpSequence.init_session + def init_session(self, level_ok: bool = True) -> TelemetryBlock: + try: + response = self.client.test_connection_sync() + tracking_state = self.client.scope_get_track_state_sync() + telemetry = TelemetryBlock( + raw={"connection": response, "tracking": tracking_state}, + tracking=_truthy(tracking_state), + level_ok=level_ok, + ) + self._session_ready = True + return telemetry + except Exception as e: + self._session_ready = False + return TelemetryBlock(parse_error=f"seestar_alp init_session failed: {e}", level_ok=level_ok) + + # Function: SeestarAlpSequence.prepare_target + def prepare_target(self, target: AcquisitionTarget, telemetry: TelemetryBlock | None = None, notify=None) -> AcquisitionTarget: + return target + + # Function: SeestarAlpSequence._notify + def _notify(self, status_cb, step: str, msg: str) -> None: + if status_cb: + status_cb(f"[{step}] {msg}") + logger.info("[%s] %s", step, msg) + + # Function: SeestarAlpSequence._ensure_tracking + def _ensure_tracking(self) -> dict: + state = self.client.scope_get_track_state_sync() + if not _truthy(state): + self.client.scope_set_track_state_sync(True) + state = self.client.scope_get_track_state_sync() + if not _truthy(state): + raise RuntimeError("seestar_alp tracking did not enable") + return state + + # Function: SeestarAlpSequence.acquire + def acquire( + self, + target: AcquisitionTarget, + status_cb=None, + telemetry: TelemetryBlock | None = None, + skip_pointing: bool = False, + abort_callback=None, + ) -> FrameResult: + start = time.monotonic() + try: + if abort_callback and abort_callback(): + return FrameResult(success=False, error="operator_abort") + + if not skip_pointing: + self._notify(status_cb, "A4", f"seestar_alp slew to {target.name}") + self.client.goto_target_sync(target.name, float(target.ra_hours), float(target.dec_deg), True) + self._notify(status_cb, "A7", "seestar_alp solve") + self.client.start_solve_sync() + solve = self.client.get_solve_result_sync() + if not _truthy(solve): + return FrameResult(success=False, error=f"seestar_alp solve failed: {solve}") + self._notify(status_cb, "A7", "Solve success") + + self._ensure_tracking() + self._notify(status_cb, "A8", "Tracking proof accepted") + + self._notify(status_cb, "A10", f"seestar_alp stack exposure for {target.name}") + self.client.start_stack_sync(gain=GAIN, restart=False) + image = self.client.get_last_image_sync(is_subframe=False, is_thumb=False) + path = _extract_path(image) + if not path: + return FrameResult(success=False, error=f"seestar_alp returned no image path: {image}") + self._notify(status_cb, "A11", f"Frame accepted: {path}") + return FrameResult(success=True, path=path, elapsed_s=time.monotonic() - start) + except Exception as e: + logger.exception("seestar_alp acquire failed: %s", e) + return FrameResult(success=False, error=f"seestar_alp acquire failed: {e}", elapsed_s=time.monotonic() - start) + + # Function: SeestarAlpSequence.park + def park(self): + logger.info("seestar_alp park not implemented by adapter") + + # Function: SeestarAlpSequence.at_park + def at_park(self) -> bool: + return False + + # Function: SeestarAlpSequence.shutdown_scope + def shutdown_scope(self): + raise RuntimeError("seestar_alp shutdown not implemented by adapter") + + # Function: SeestarAlpSequence.disconnect_all + def disconnect_all(self): + return None diff --git a/core/flight/star_quality.py b/core/flight/star_quality.py new file mode 100644 index 0000000..cdd79e7 --- /dev/null +++ b/core/flight/star_quality.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: core/flight/star_quality.py +Objective: Lightweight star-shape quality checks for rejecting trailed science frames. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass +class StarShapeMetrics: + sources: int = 0 + median_elongation: float | None = None + median_major_axis_px: float | None = None + p90_major_axis_px: float | None = None + error: str = "" + + # Function: StarShapeMetrics.acceptance_error + def acceptance_error(self, *, max_elongation: float, max_major_axis_px: float, min_sources: int) -> str | None: + if self.error: + return self.error + if self.sources < min_sources: + return f"star_shape_sources_low:{self.sources}<{min_sources}" + if self.median_elongation is not None and self.median_elongation > max_elongation: + return f"star_elongation_high:{self.median_elongation:.2f}>{max_elongation:.2f}" + if ( + self.p90_major_axis_px is not None + and self.p90_major_axis_px > max_major_axis_px + and self.median_elongation is not None + and self.median_elongation > 2.0 + ): + return f"star_trail_px_high:{self.p90_major_axis_px:.1f}>{max_major_axis_px:.1f}" + return None + + # Function: StarShapeMetrics.write_header + def write_header(self, header) -> None: + header["STARSRC"] = (int(self.sources), "Star-shape QC sources") + if self.median_elongation is not None: + header["STARELON"] = (round(float(self.median_elongation), 3), "Median star elongation") + if self.median_major_axis_px is not None: + header["STARMED"] = (round(float(self.median_major_axis_px), 3), "Median major-axis length px") + if self.p90_major_axis_px is not None: + header["STARLEN"] = (round(float(self.p90_major_axis_px), 3), "P90 major-axis length px") + if self.error: + header["STARQERR"] = (self.error[:68], "Star-shape QC error") + + +# Function: _background_stats +def _background_stats(arr: np.ndarray) -> tuple[float, float]: + vals = arr[np.isfinite(arr)] + if vals.size == 0: + return 0.0, 1.0 + med = float(np.median(vals)) + mad = float(np.median(np.abs(vals - med))) + sigma = 1.4826 * mad if mad > 0 else float(np.std(vals)) + return med, max(sigma, 1e-6) + + +# Function: measure_star_shape +def measure_star_shape( + data: np.ndarray, + *, + max_sources: int = 60, + half_size: int = 32, + min_separation_px: int = 24, +) -> StarShapeMetrics: + arr = np.asarray(data, dtype=np.float32) + if arr.ndim > 2: + arr = np.squeeze(arr) + if arr.ndim != 2: + return StarShapeMetrics(error="star_shape_not_2d") + + finite = np.isfinite(arr) + vals = arr[finite] + if vals.size < 1000: + return StarShapeMetrics(error="star_shape_no_finite_data") + + med, sigma = _background_stats(arr) + threshold = max(med + 8.0 * sigma, float(np.percentile(vals, 99.92))) + yx = np.argwhere(finite & (arr > threshold)) + if yx.size == 0: + return StarShapeMetrics(sources=0) + + values = arr[yx[:, 0], yx[:, 1]] + top_n = min(values.size, max(500, max_sources * 80)) + top_idx = np.argpartition(values, -top_n)[-top_n:] + top_idx = top_idx[np.argsort(values[top_idx])[::-1]] + + height, width = arr.shape + used: list[tuple[int, int]] = [] + elongations: list[float] = [] + major_axes: list[float] = [] + yy, xx = np.indices((half_size * 2 + 1, half_size * 2 + 1), dtype=np.float32) + + for idx in top_idx: + y, x = int(yx[idx, 0]), int(yx[idx, 1]) + if y < half_size or x < half_size or y >= height - half_size or x >= width - half_size: + continue + if any((y - uy) ** 2 + (x - ux) ** 2 < min_separation_px ** 2 for uy, ux in used): + continue + + cutout = arr[y - half_size : y + half_size + 1, x - half_size : x + half_size + 1] + weights = np.clip(np.where(np.isfinite(cutout), cutout, med) - (med + 3.0 * sigma), 0.0, None) + if int(np.count_nonzero(weights)) < 6: + continue + flux = float(weights.sum()) + if flux <= 0: + continue + + cx = float((xx * weights).sum() / flux) + cy = float((yy * weights).sum() / flux) + dx = xx - cx + dy = yy - cy + var_x = float((dx * dx * weights).sum() / flux) + var_y = float((dy * dy * weights).sum() / flux) + cov = float((dx * dy * weights).sum() / flux) + disc = max((var_x - var_y) ** 2 + 4.0 * cov * cov, 0.0) + major_var = max(0.5 * (var_x + var_y + float(np.sqrt(disc))), 1e-6) + minor_var = max(0.5 * (var_x + var_y - float(np.sqrt(disc))), 1e-6) + elongation = float(np.sqrt(major_var / minor_var)) + major_axis = float(4.0 * np.sqrt(major_var)) + if not np.isfinite(elongation) or not np.isfinite(major_axis): + continue + if major_axis < 1.0 or major_axis > float(half_size * 2): + continue + + used.append((y, x)) + elongations.append(elongation) + major_axes.append(major_axis) + if len(elongations) >= max_sources: + break + + if not elongations: + return StarShapeMetrics(sources=0) + + return StarShapeMetrics( + sources=len(elongations), + median_elongation=float(np.median(elongations)), + median_major_axis_px=float(np.median(major_axes)), + p90_major_axis_px=float(np.percentile(major_axes, 90.0)), + ) diff --git a/core/postflight/accountant.py b/core/postflight/accountant.py index b3af3a1..feb5e49 100755 --- a/core/postflight/accountant.py +++ b/core/postflight/accountant.py @@ -41,6 +41,7 @@ from core.postflight.dark_calibrator import dark_calibrator from core.postflight.calibration_assets import ensure_calibration_dirs, save_missing_calibrations from core.utils.env_loader import load_config +from core.flight.star_quality import StarShapeMetrics, measure_star_shape logging.basicConfig( level=logging.INFO, @@ -81,7 +82,31 @@ def _cfg_int(key: str, default: int) -> int: return default +# Function: _cfg_float +def _cfg_float(key: str, default: float) -> float: + try: + return float(_postflight_cfg().get(key, default)) + except Exception: + return default + + +# Function: _cfg_bool +def _cfg_bool(key: str, default: bool) -> bool: + value = _postflight_cfg().get(key, default) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + MAX_PLATE_SOLVE_CANDIDATES = max(1, _cfg_int("max_plate_solve_candidates", 3)) +STACK_SHAPE_QC_ENABLED = _cfg_bool("stack_shape_qc_enabled", True) +PUBLISH_SINGLE_FRAME_PREVIEWS = _cfg_bool("publish_single_frame_previews", False) +REQUIRE_STACKED_PRODUCTS = _cfg_bool("require_stacked_products", True) +MIN_STAR_SHAPE_SOURCES = max(3, _cfg_int("min_star_shape_sources", 8)) +MAX_STAR_ELONGATION = max(1.5, _cfg_float("max_star_elongation", 6.0)) +MAX_STAR_TRAIL_PX = max(4.0, _cfg_float("max_star_trail_px", 18.0)) _engine = CalibrationEngine() _analyst = MasterAnalyst() @@ -510,6 +535,18 @@ def _copy_accepted_fits(source: Path, dest: Path, result: dict, row: dict) -> Pa return dest +# Function: _is_stacked_product +def _is_stacked_product(product_path: Path, row: dict) -> bool: + state = str(row.get("calibration_state") or "").upper() + if "STACK" in state or "STACK" in product_path.name.upper(): + return True + try: + header = fits.getheader(product_path, 0) + return bool(header.get("STACKED") or int(header.get("NCOMBINE", 1)) > 1) + except Exception: + return False + + # Function: _scale_preview def _scale_preview(data: np.ndarray) -> np.ndarray: arr = np.asarray(data, dtype=np.float32) @@ -566,7 +603,8 @@ def _publish_accepted_products( ) -> tuple[Path | None, Path | None]: dest_dir = _accepted_products_dir(session_started_utc) safe = _safe_name(target_name) - suffix = "stack" if "STACK" in product_path.name.upper() else "single" + is_stack = _is_stacked_product(product_path, row) + suffix = "stack" if is_stack else "single" stamp = _parse_iso_utc(row.get("last_obs_utc")) or datetime.now(timezone.utc) base = f"{safe}_{stamp.strftime('%Y%m%dT%H%M%S')}_accepted_solved_{suffix}" dest_fits = dest_dir / f"{base}.fits" @@ -575,6 +613,9 @@ def _publish_accepted_products( try: copied_fits = _copy_accepted_fits(product_path, dest_fits, result, row) copied_wcs = _copy_wcs_sidecar(wcs_path, copied_fits) + if not is_stack and not PUBLISH_SINGLE_FRAME_PREVIEWS: + log.info(" accepted single-frame FITS written for %s; JPEG preview skipped", target_name) + return copied_fits, None label = f"{target_name} TG={float(result.get('mag', 0.0)):.3f} SNR={float(result.get('target_snr', 0.0)):.1f}" copied_jpg = _write_accepted_preview(copied_fits, dest_jpg, copied_wcs, ra_deg, dec_deg, label) log.info(" accepted products written for %s: %s / %s", target_name, copied_fits.name, copied_jpg.name) @@ -724,6 +765,48 @@ def _source_centroids(data: np.ndarray, max_sources: int = 200) -> np.ndarray: return np.array(centroids, dtype=np.float32) +# Function: _shape_metrics_from_header +def _shape_metrics_from_header(header) -> StarShapeMetrics | None: + try: + sources = int(header.get("STARSRC")) + except Exception: + return None + try: + median_elongation = header.get("STARELON") + median_major = header.get("STARMED") + p90_major = header.get("STARLEN") + return StarShapeMetrics( + sources=sources, + median_elongation=float(median_elongation) if median_elongation is not None else None, + median_major_axis_px=float(median_major) if median_major is not None else None, + p90_major_axis_px=float(p90_major) if p90_major is not None else None, + error=str(header.get("STARQERR", "")).strip(), + ) + except Exception: + return None + + +# Function: _shape_qc_failure +def _shape_qc_failure(path: Path) -> str | None: + if not STACK_SHAPE_QC_ENABLED: + return None + try: + with fits.open(path, mode="update") as hdul: + header = hdul[0].header + metrics = _shape_metrics_from_header(header) + if metrics is None: + metrics = measure_star_shape(hdul[0].data) + metrics.write_header(header) + hdul.flush() + return metrics.acceptance_error( + max_elongation=MAX_STAR_ELONGATION, + max_major_axis_px=MAX_STAR_TRAIL_PX, + min_sources=MIN_STAR_SHAPE_SOURCES, + ) + except Exception as exc: + return f"star_shape_qc_error:{exc}" + + # Estimate image shift from matched star centroids and reject noisy matches. # Function: _star_shift def _star_shift(reference: np.ndarray, moving: np.ndarray, max_shift_px: float = 250.0) -> tuple[float, float] | None: @@ -912,6 +995,8 @@ def _median_stack(calibrated_paths: list[Path], target_name: str, obs_dt: dateti usable_exptimes = exptimes[:len(aligned)] header["TOTEXP"] = round(sum(usable_exptimes), 3) header["EXPTIME"] = round(sum(usable_exptimes), 3) + if STACK_SHAPE_QC_ENABLED: + measure_star_shape(stacked).write_header(header) out_path = _stack_output_path(target_name, obs_dt, len(aligned)) fits.PrimaryHDU(data=stacked, header=header).writeto(out_path, overwrite=True) @@ -1193,11 +1278,56 @@ def process_buffer(): processed += raw_count continue - candidate_paths = [] + raw_candidate_paths = [] stack_path = _median_stack(calibrated_paths, key, ref["obs_dt"]) + if REQUIRE_STACKED_PRODUCTS and len(calibrated_paths) >= 2 and not stack_path: + log.error(" %s rejected: stacked product required but stack creation failed", key) + if ledger[key].get("status") != "OBSERVED": + ledger[key]["status"] = "FAILED_STACK_REQUIRED" + ledger[key]["last_calibration_state"] = "STACK_REQUIRED_FAILED" + _clear_unclosed_success(ledger[key]) + row["failure_category"] = "STACK_REQUIRED" + row["failure_detail"] = "stacked product required but stack creation failed" + row["ledger_status"] = ledger[key].get("status") + row["calibration_state"] = ledger[key].get("last_calibration_state") + session_rows.append(row) + _archive_failed_group(items, calibrated_paths, stack_path) + save_ledger(ledger) + save_missing_calibrations(ledger) + processed += raw_count + continue if stack_path: - candidate_paths.append((stack_path, f"DARKSUB_STACK_{len(calibrated_paths)}")) - candidate_paths.extend((path, "DARKSUB_SINGLE") for path in reversed(calibrated_paths)) + raw_candidate_paths.append((stack_path, f"DARKSUB_STACK_{len(calibrated_paths)}")) + if not REQUIRE_STACKED_PRODUCTS or len(calibrated_paths) < 2: + raw_candidate_paths.extend((path, "DARKSUB_SINGLE") for path in reversed(calibrated_paths)) + + candidate_paths = [] + shape_rejects = [] + for candidate_path, candidate_state in raw_candidate_paths: + shape_error = _shape_qc_failure(candidate_path) + if shape_error: + shape_rejects.append(f"{candidate_path.name}:{shape_error}") + log.warning(" %s shape QC rejected %s: %s", key, candidate_path.name, shape_error) + continue + candidate_paths.append((candidate_path, candidate_state)) + + if not candidate_paths: + detail = "; ".join(shape_rejects[:4]) or "no shape-usable candidates" + log.error(" %s rejected: no untrailed stack/single candidates", key) + if ledger[key].get("status") != "OBSERVED": + ledger[key]["status"] = "FAILED_QC_TRAILED" + ledger[key]["last_calibration_state"] = "FAILED_STAR_SHAPE_QC" + _clear_unclosed_success(ledger[key]) + row["failure_category"] = "TRAILING" + row["failure_detail"] = detail + row["ledger_status"] = ledger[key].get("status") + row["calibration_state"] = ledger[key].get("last_calibration_state") + session_rows.append(row) + _archive_failed_group(items, calibrated_paths, stack_path) + save_ledger(ledger) + save_missing_calibrations(ledger) + processed += raw_count + continue total_solve_candidates = len(candidate_paths) if total_solve_candidates > MAX_PLATE_SOLVE_CANDIDATES: diff --git a/dev/CONTRIBUTING.md b/dev/CONTRIBUTING.md index 3e4c9d3..7024ab4 100644 --- a/dev/CONTRIBUTING.md +++ b/dev/CONTRIBUTING.md @@ -15,13 +15,14 @@ Required fields: ## 2. Architectural Pillars All new logic must fall into one of these pillars: 1. PREFLIGHT: data harvesting, vetting, horizon logic, scheduling -2. FLIGHT: hardware orchestration and acquisition via Alpaca-native control +2. FLIGHT: hardware orchestration, proof ledger, and acquisition via pilot adapters 3. POSTFLIGHT: solved astrometry, dark-calibrated photometry, and reporting ## 3. Protocol Reality SeeVar’s current hardware control path is: - primary control: Alpaca HTTP on port `32323` +- optional controlled adapter: `seestar_alp`, only when explicitly configured - discovery: Alpaca UDP beacon on port `32227` - legacy/event paths may still exist in old notes, but are not the primary control doctrine @@ -32,9 +33,11 @@ Do not write new code against the old “TCP 4700 as main control path” assump - Current production photometry is raw Bayer-green, untransformed `TG`. - Production photometry should not depend on naive debayering. - If a frame is not proven by calibration/WCS/QC, it must not be accepted. +- If a target proof fails during flight, the target must stop with a clear reason. +- Multi-frame science targets must publish one stacked accepted product by default. ## 5. Pull Request Protocol Before merging: 1. Verify the logic docs still reflect the real architecture. -2. Run the regression tests in `dev/tests/postflight/test_*.py`. +2. Run focused flight and postflight regression tests. 3. Keep changes scoped and scientifically honest. diff --git a/dev/logic/ALPACA_BRIDGE.MD b/dev/logic/ALPACA_BRIDGE.MD index e980085..d2ee295 100644 --- a/dev/logic/ALPACA_BRIDGE.MD +++ b/dev/logic/ALPACA_BRIDGE.MD @@ -5,12 +5,12 @@ ## Role (Post-Alpaca Breakthrough, 2026-03-30) -The ZWO official ASCOM Alpaca driver is the **primary and sole** control -interface for all science acquisition. It runs inside the Seestar firmware +The ZWO official ASCOM Alpaca driver is the **default primary** control +interface for science acquisition. It runs inside the Seestar firmware on port 32323, exposed via standard HTTP REST. No phone app required. No session master lock. No JSON-RPC encryption. -No seestar_alp middleware. +No `seestar_alp` middleware is required for the default path. Confirmed working: slew, track, park, unpark, expose, gain control, filter wheel, image download — all without any ZWO cooperation or @@ -40,9 +40,8 @@ which are not exposed via Alpaca. WilhelminaMonitor listens passively. No commands are sent to port 4700. Ports `4801` and `4800` are not production science-control paths. -Ports `5432` and `5555` may exist on the DEV RPI when `seestar_alp` is -running as a diagnostics/API workbench, but they are not the normal flight -control path. +Ports `5432` and `5555` may exist when `seestar_alp` is running. That path is +diagnostic unless `[seestar_alp].enabled = true` and `mode = "controlled"`. ## Alpaca REST Conventions diff --git a/dev/logic/ARCHITECTURE_OVERVIEW.MD b/dev/logic/ARCHITECTURE_OVERVIEW.MD index 93bd57c..11843d2 100644 --- a/dev/logic/ARCHITECTURE_OVERVIEW.MD +++ b/dev/logic/ARCHITECTURE_OVERVIEW.MD @@ -12,14 +12,18 @@ For wire protocol detail see `API_PROTOCOL.MD`. ## 🛰️ 1. THE FLIGHT HANGAR (`core/flight/`) *The cockpit. Hardware meets command here.* -- **`pilot.py`** — The sovereign acquisition engine over ASCOM Alpaca REST on port `32323`. +- **`pilot.py`** — The default acquisition engine over ASCOM Alpaca REST on port `32323`. Owns target execution steps `A3-A10`: session init, slew, settle, exposure, image download, and FITS write. - **`orchestrator.py`** — The autonomous night daemon. Owns mission states `IDLE -> PREFLIGHT -> PLANNING -> FLIGHT -> POSTFLIGHT -> PARKED`. Consumes `data/tonights_plan.json` as the canonical nightly plan and must not re-score the sky independently. -- **`fsm.py`** — State transition authority for per-target execution and failure handling. +- **`fsm.py`** — State transition authority for per-target execution, proof rows, and failure handling. + +- **`proof.py`** — Append-only target proof ledger writer for `data/flight_runs/`. + +- **`seestar_alp_adapter.py`** — Optional controlled adapter behind the same pilot interface. - **`camera_control.py`** — Thin Alpaca reachability and safe-stop wrapper for hardware gating, not the science acquisition path. @@ -34,6 +38,7 @@ For wire protocol detail see `API_PROTOCOL.MD`. - **`mission_chronicle.py`** — Preflight funnel launcher. It prepares the night but is not the per-target flight engine. The canonical per-target science chain is now the Sovereign Flight Sequence `A1-A12`. +Each required proof must pass; partial target success is disabled by default. --- @@ -58,6 +63,7 @@ Flight only needs an immediate operational quality gate (`A11`). | Location | Storage | Purpose | |----------|---------|---------| | `data/local_buffer/` | RAID1 USB | Transient raw + processed FITS | +| `data/flight_runs/` | RAID1 USB | Per-target proof rows | | `data/ledger.json` | RAID1 USB | Persistent per-target observation history | | `data/system_state.json` | SD Card / runtime state | Volatile live pipeline telemetry | | `data/tonights_plan.json` | RAID1 USB | Canonical nightly target list | @@ -94,3 +100,4 @@ Flight only needs an immediate operational quality gate (`A11`). - `tonights_plan.json` is the authoritative handoff. - Flight must not silently replace planner logic with local ad-hoc scoring. - The canonical per-target execution chain is `A1-A12`. +- Multi-frame successful objects require one stacked postflight product by default. diff --git a/dev/logic/CADENCE.MD b/dev/logic/CADENCE.MD index bbea487..6814c75 100644 --- a/dev/logic/CADENCE.MD +++ b/dev/logic/CADENCE.MD @@ -61,6 +61,15 @@ Flight captures raw FITS custody into: data/local_buffer/{TARGET}_{TS}_Raw.fits ``` +Flight also writes target proof rows into: + +```text +data/flight_runs/*.jsonl +``` + +Only a fully proven target can reset cadence. Partial target success is disabled +by default. + The active transport is described in `ALPACA_BRIDGE.MD` and `API_PROTOCOL.MD`. Older direct-TCP examples are historical and must not override current flight law. diff --git a/dev/logic/CORE.MD b/dev/logic/CORE.MD index 8dabee3..8f939f9 100644 --- a/dev/logic/CORE.MD +++ b/dev/logic/CORE.MD @@ -23,10 +23,11 @@ Fleet naming and founding principles: `PICKERING_PROTOCOL.MD`. 5. **The Gate** — `core/flight/orchestrator.py` runs Go/No-Go checks: storage, hardware (Alpaca management API on port 32323), GPS clock, weather, fog. 6. **The Scoring** — Orchestrator scores observable targets by meridian-aware priority. -7. **The Acquisition** — `core/flight/pilot.py` v3.0.0 `DiamondSequence.acquire()` executes - per target via Alpaca REST: +7. **The Acquisition** — `core/flight/fsm.py` drives a proof-required target + chain through `core/flight/pilot.py` by default: - SlewToCoordinatesAsync → settle → SetGain → StartExposure → poll imageready - Download imagearray (2160×3840 int32) → sovereign_stamp() → write_fits() + - Reject failed connect/solve/track/expose/accept proofs immediately - Output: `data/local_buffer/{TARGET}_{TS}_Raw.fits` 8. **The Handoff** — On success, orchestrator updates ledger and triggers postflight. @@ -39,7 +40,9 @@ Fleet naming and founding principles: `PICKERING_PROTOCOL.MD`. *Do not live in the moment. Plan for the astronomical night.* -*The Alpaca REST API on port 32323 is the sole control path for all science acquisition.* +*Firmware Alpaca on port 32323 is the default control path for science acquisition.* + +*`seestar_alp` may be used only when explicitly enabled as a controlled adapter.* *Port 4700 is retained read-only for battery/charger telemetry via WilhelminaMonitor.* diff --git a/dev/logic/FILE_MANIFEST.md b/dev/logic/FILE_MANIFEST.md index 9ae27a8..f8fbd27 100644 --- a/dev/logic/FILE_MANIFEST.md +++ b/dev/logic/FILE_MANIFEST.md @@ -19,12 +19,14 @@ | core/flight/exposure_planner.py | 1.2.0 | Estimate safe science exposure parameters for a target using brightness, sky quality, and flight constraints. its mag... | | core/flight/field_rotation.py | 1.2.0 | Field rotation for Alt-Az telescopes (ZWO Seestar S30/S50). Now includes accurate integrated smear via numerical inte... | | core/flight/flat_library.py | 1.0.0 | Capture normalized master flat assets for a scope/filter pair and mark whether they are ready for science use. | -| core/flight/fsm.py | 1.3.0 | Finite State Machine governing A1-A12 target execution and failure handling for Sovereign flight operations, with liv... | +| core/flight/fsm.py | 1.3.0 | Finite State Machine governing A1-A12 target execution, proof recording, strict target failure, and live bridge updates. | | core/flight/mission_chronicle.py | 4.2.0 | Orchestrates the Preflight Funnel (Janitor -> Librarian -> Auditor -> Planner). | | core/flight/neutralizer.py | 2.1.0 | Hardware reset via Alpaca REST — parks telescope and verifies idle state before handing control to the pilot. | | core/flight/orchestrator.py | 1.8.3 | Autonomous night daemon consuming tonights_plan.json as the canonical mission order, logging A1-A12, executing target... | | core/flight/pilot.py | 3.1.0 | Sovereign Alpaca acquisition engine for the Seestar S30-Pro, owning A4-A11 including slew, pointing verification, cor... | | core/flight/pointing_model.py | 1.0.0 | Store and apply a short-lived constant pointing correction measured from solved pre-alignment fields. | +| core/flight/proof.py | N/A | Append-only per-target proof ledger writer for data/flight_runs. | +| core/flight/seestar_alp_adapter.py | N/A | Optional seestar_alp-backed control adapter behind the SeeVar pilot interface. | | core/flight/sim_runner.py | 1.0.0 | Execute a full realtime nightly simulation against tonights_plan.json with structured CLI output and live system_stat... | | core/flight/vault_manager.py | 1.4.1 | Secure metadata access with actual bi-directional tomli_w syncing. | | core/hardware/fleet_mapper.py | 2.0.0 | Read [[seestars]] from config.toml, load hardware constants from core/hardware/models/.json, and produce data/... | diff --git a/dev/logic/PHOTOMETRICS.MD b/dev/logic/PHOTOMETRICS.MD index 78bf650..de33480 100644 --- a/dev/logic/PHOTOMETRICS.MD +++ b/dev/logic/PHOTOMETRICS.MD @@ -303,6 +303,9 @@ The ensemble should later support: ### 1.9.4 — Deterministic reporting - accept only `P7`-passed frames into report staging +- require stacked accepted products for multi-frame targets by default +- publish one accepted FITS/JPEG/report per successful object +- block AAVSO upload unless stack, WCS, photometry, and report all pass - wire accepted results into AAVSO output products ### Later diff --git a/dev/logic/POSTFLIGHT.MD b/dev/logic/POSTFLIGHT.MD index 632d6d7..77338cb 100644 --- a/dev/logic/POSTFLIGHT.MD +++ b/dev/logic/POSTFLIGHT.MD @@ -23,8 +23,8 @@ Postflight decides whether the frame is scientifically usable. ## The Sovereign Postflight Sequence (P1-P8) -Each science frame must pass one deterministic postflight chain. -A frame may fail without invalidating the rest of the night. +Each target group must pass one deterministic postflight chain. +A target may fail without invalidating the rest of the night. ### P1 — Ingest @@ -95,7 +95,7 @@ This is where detector truth begins. ### P4 — Astrometric Solve -Produce a real solved WCS for the frame. +Produce a real solved WCS for the stacked product when multiple frames exist. This must be an actual astrometric solution. Header guesses such as `CRVAL`, `CRPIX`, and `CDELT` are not by themselves sufficient scientific truth. @@ -174,6 +174,8 @@ Apply scientific acceptance gates and assign the final frame verdict. Minimum gates: - readable FITS - calibration state known +- stacked product present for multi-frame targets +- star-shape/trail QC pass - real solved WCS present - target not saturated - target flux positive @@ -202,6 +204,7 @@ This includes: - move or copy final custody products into archive - write QC/report artifacts - stage AAVSO output products +- publish exactly one accepted stacked FITS/JPEG/report per successful object Only at this step is a frame considered postflight-complete. @@ -232,6 +235,8 @@ The following are not enough on their own: - unsolved header coordinates - raw CRVAL-based pixel estimates - uncalibrated detector data +- single-frame fallback for multi-frame targets +- trailed stacks or accepted previews - weighted comp-star ensembles without outlier rejection --- @@ -246,6 +251,8 @@ The following are not enough on their own: - weighted zero-point ensemble - ledger stamping and archive movement - AAVSO report formatter +- stack-first target products with required stack for multi-frame targets +- star-shape/trail QC on stack candidates ### Under scientific upgrade - real solved WCS as a hard postflight dependency @@ -295,6 +302,7 @@ Future: | `data/ledger.json` | authoritative observation ledger | | `data/archive/*.fits` | archived processed custody | | `data/reports/*.txt` | AAVSO-ready report outputs | +| `data/accepted_products/` | one accepted FITS/JPEG per successful object | --- diff --git a/dev/logic/README.MD b/dev/logic/README.MD index 4c6f8ad..8750383 100644 --- a/dev/logic/README.MD +++ b/dev/logic/README.MD @@ -88,12 +88,14 @@ Postflight now follows the sovereign `P1-P8` chain: ### Scientific boundary Flight proves: -- a trustworthy raw frame was captured +- the target chain reached a trustworthy raw capture Postflight proves: -- that the frame supports trustworthy science +- that the target stack supports trustworthy science That boundary must remain clean. +Target proof rows are written to `data/flight_runs/`. Multi-frame targets must +produce one stacked accepted product unless explicitly configured otherwise. --- diff --git a/dev/logic/SEEVAR_SKILL/SKILL.md b/dev/logic/SEEVAR_SKILL/SKILL.md index e22c64a..8985bec 100644 --- a/dev/logic/SEEVAR_SKILL/SKILL.md +++ b/dev/logic/SEEVAR_SKILL/SKILL.md @@ -43,8 +43,11 @@ description: > Current production flight control is Alpaca-era SeeVar control through `core/flight/pilot.py`, `core/flight/fsm.py`, and `core/flight/orchestrator.py`. -`seestar_alp` and direct JSON-RPC are diagnostic/integration paths unless a -specific SeeVar tool marks a call as production-safe. +`seestar_alp` is diagnostic unless `[seestar_alp].enabled = true` and +`mode = "controlled"`. Direct JSON-RPC remains diagnostic/historical. + +Every science target must prove the chain in `data/flight_runs/`. Do not accept +partial target success unless config explicitly allows it. `` comes from config.toml `[[seestars]] ip` — never hardcoded. @@ -99,6 +102,7 @@ This section is retained only to interpret old logs and early design notes. Current production flight uses Alpaca-era control through `core/flight/orchestrator.py`, `core/flight/fsm.py`, and `core/flight/pilot.py`. +Optional controlled `seestar_alp` flight uses `core/flight/seestar_alp_adapter.py`. ## Historical per-target TCP sequence T1–T7 ``` diff --git a/dev/logic/SIMULATORLOGIC.MD b/dev/logic/SIMULATORLOGIC.MD index 8c162b1..cf1fc68 100644 --- a/dev/logic/SIMULATORLOGIC.MD +++ b/dev/logic/SIMULATORLOGIC.MD @@ -62,11 +62,14 @@ At minimum, the simulator must model: - Simulation must not collapse the target sequence into a single fake exposure event if the goal is workflow verification. - Simulation logs must resemble real flight logs closely enough that step-by-step validation is possible. - The simulator should prove orchestration logic, not just FITS file creation. +- Fake hardware should emit enough evidence to populate `data/flight_runs/`. +- Strict mode should fail a simulated target on the first failed required proof. ## Minimum Acceptable Output For each simulated target, the log should show the A1-A12 chain explicitly, -even if some steps are mocked. +even if some steps are mocked. The dry-run proof ledger should show pass/fail +rows for connect, prepare, solve, track, acquire, accept, and target completion. This ensures that: - the dashboard reflects real mission progress diff --git a/dev/logic/STATE_MACHINE.MD b/dev/logic/STATE_MACHINE.MD index 5dbc61a..daddc6a 100644 --- a/dev/logic/STATE_MACHINE.MD +++ b/dev/logic/STATE_MACHINE.MD @@ -7,8 +7,9 @@ Implementation: - `core/flight/orchestrator.py` — mission order, vetoes, commit -- `core/flight/fsm.py` — state transitions and per-target control +- `core/flight/fsm.py` — state transitions, per-target control, proof ledger - `core/flight/pilot.py` — hardware execution +- `core/flight/seestar_alp_adapter.py` — optional controlled adapter - `core/flight/exposure_planner.py` — exposure logic Alpaca device map: `ALPACA_BRIDGE.MD`. @@ -21,6 +22,8 @@ The telescope executes one deterministic per-target sequence. The orchestrator owns mission order and veto logic. The pilot owns hardware execution. The FSM owns state transitions and failure handling. +Every target writes proof rows to `data/flight_runs/`. If any required proof +fails, the target stops and records the reason. ### A1 — Target Lock **Owner:** `core/flight/orchestrator.py` @@ -122,7 +125,8 @@ Minimum checks: - array shape valid - pixel stats sane - no gross transport failure -- optional fast solve or centroid sanity +- star-shape/trail sanity when enabled +- no partial target success unless explicitly configured ### A12 — Commit **Owner:** `core/flight/orchestrator.py` diff --git a/dev/logic/WORKFLOW.MD b/dev/logic/WORKFLOW.MD index 3dd07e3..8a42f18 100644 --- a/dev/logic/WORKFLOW.MD +++ b/dev/logic/WORKFLOW.MD @@ -35,6 +35,7 @@ No state may silently replace another state's responsibility. | `data/tonights_plan.json` | Preflight | Canonical nightly mission plan | | `data/ssc_payload.json` | Schedule compiler | Executable Seestar/SSC-style payload | | `data/system_state*.json` | Orchestrator/dashboard | Live mission state | +| `data/flight_runs/*.jsonl` | FSM | Per-target proof chain | | `data/local_buffer/*.fits` | Flight | Raw frame custody | | `data/calibrated_buffer/*` | Postflight | Working calibrated products | | `data/archive/*` | Postflight | Durable observation custody | @@ -101,10 +102,15 @@ Each target follows the canonical `A1-A12` chain: | `A12` | Commit | Stamp custody and ledger attempt state | A target can pass flight and still fail postflight. +A target cannot pass flight partially by default: connect, prepare, pointing, +tracking, exposure, frame acceptance, and target completion must all be proven. +Fresh pointing proof is required before the first science frame and then at the +configured frame interval. Flight outputs raw custody: - FITS files in `data/local_buffer/` +- proof rows in `data/flight_runs/` - live state in `data/system_state*.json` - logs and telemetry - ledger attempt/capture status @@ -126,7 +132,8 @@ It follows `P1-P8`: | `P7` | Quality Verdict | Apply saturation, SNR, comp, fit, residual gates | | `P8` | Commit And Report | Archive, ledger, report, and cleanup | -Postflight must fail honestly. No report is produced from an unproven observation. +Postflight must fail honestly. No report is produced from an unproven +observation. Multi-frame targets require one stacked accepted product by default. ## Optional Secondary Imaging @@ -158,7 +165,9 @@ Abort does not automatically invalidate already captured frames. Postflight may Current production control is Alpaca-era SeeVar control through `core/flight/pilot.py`, `core/flight/fsm.py`, and `core/flight/orchestrator.py`. -`seestar_alp` is an API workbench/integration candidate, especially for scheduler, mosaic, solve, heater level, and diagnostics. It is documented in `SEESTAR_ALP_API.MD`. +`seestar_alp` is diagnostics by default and can be enabled as a controlled +adapter only with `[seestar_alp].enabled = true` and `mode = "controlled"`. +It is documented in `SEESTAR_ALP_API.MD`. Native Seestar SSH access is diagnostic and file-oriented only. It is documented in `SEESTAR_SSH_ACCESS.MD`. diff --git a/dev/tests/README.md b/dev/tests/README.md index 6d7847c..6c014bc 100644 --- a/dev/tests/README.md +++ b/dev/tests/README.md @@ -1,3 +1,4 @@ # SeeVar Dev Tests - `postflight/`: Hardware-free smoke tests and synthetic rehearsals for calibration, dark handling, stacking, photometry rejection, and ledger closure. +- `test_flight_step_proofs.py`: Flight proof-chain tests for connect/solve/track/acquire failures, strict target rejection, and the dormant `seestar_alp` adapter. diff --git a/dev/tests/postflight/test_dark_postflight.py b/dev/tests/postflight/test_dark_postflight.py index 3159f52..40335da 100644 --- a/dev/tests/postflight/test_dark_postflight.py +++ b/dev/tests/postflight/test_dark_postflight.py @@ -111,6 +111,10 @@ def make_science_frame(name="TEST_VAR", exp_ms=5000, gain=80, ccd_temp=21.3): (w / 2 + 240, h / 2 - 160, 8500.0), (w / 2 - 320, h / 2 - 260, 7000.0), (w / 2 + 300, h / 2 + 280, 6500.0), + (w / 2 - 520, h / 2 + 340, 6200.0), + (w / 2 + 510, h / 2 - 430, 5900.0), + (w / 2 - 720, h / 2 - 480, 5600.0), + (w / 2 + 700, h / 2 + 460, 5300.0), ] for x, y, amp in stars: draw_star(img, x, y, amp=amp, sigma=2.4) @@ -151,7 +155,7 @@ def make_science_frame(name="TEST_VAR", exp_ms=5000, gain=80, ccd_temp=21.3): def inspect_results(): ledger_ok = False accepted_product_ok = False - accepted_preview_ok = False + accepted_preview_absent = True cal_files = sorted(CAL_DIR.glob("*_cal.fit")) + sorted(CAL_DIR.glob("*_cal.fits")) archived = sorted(ARCHIVE_DIR.glob("*.fit")) + sorted(ARCHIVE_DIR.glob("*.fits")) stacks = sorted(PROCESS_DIR.glob("*.fit")) + sorted(PROCESS_DIR.glob("*.fits")) @@ -164,7 +168,7 @@ def inspect_results(): if entry.get("status") == "OBSERVED": ledger_ok = True accepted_product_ok = bool(entry.get("last_accepted_product") and Path(entry["last_accepted_product"]).exists()) - accepted_preview_ok = bool(entry.get("last_accepted_preview") and Path(entry["last_accepted_preview"]).exists()) + accepted_preview_absent = not bool(entry.get("last_accepted_preview")) print("") print("Smoke test results") @@ -172,11 +176,11 @@ def inspect_results(): print(f" Calibrated FITS: {len(cal_files)}") print(f" Archived raw : {len(archived)}") print(f" Accepted FITS : {'YES' if accepted_product_ok else 'NO'}") - print(f" Accepted JPEG : {'YES' if accepted_preview_ok else 'NO'}") + print(f" Single JPEG : {'NO' if accepted_preview_absent else 'YES'}") print(f" Stack FITS : {len(stacks)}") print(f" Raw remaining : {len(remaining_raw)}") - if not ledger_ok or cal_files or archived or not accepted_product_ok or not accepted_preview_ok or stacks or remaining_raw: + if not ledger_ok or cal_files or archived or not accepted_product_ok or not accepted_preview_absent or stacks or remaining_raw: raise SystemExit(1) print("") diff --git a/dev/tests/test_flight_step_proofs.py b/dev/tests/test_flight_step_proofs.py new file mode 100644 index 0000000..dbe78c9 --- /dev/null +++ b/dev/tests/test_flight_step_proofs.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from core.flight import pilot +from core.flight.pilot import AcquisitionTarget, DiamondSequence, PointingProof +from core.flight import fsm as fsm_module +from core.flight.fsm import SovereignFSM +from core.flight.pilot import FrameResult, TelemetryBlock + + +class FakeTelescope: + # Function: FakeTelescope.__init__ + def __init__(self, *, tracking=False, slewing=False, atpark=False): + self.tracking = tracking + self.slewing = slewing + self.atpark = atpark + self.set_tracking_calls = 0 + + # Function: FakeTelescope.safe_get + def safe_get(self, prop, default=None): + return { + "tracking": self.tracking, + "slewing": self.slewing, + "atpark": self.atpark, + }.get(prop, default) + + # Function: FakeTelescope.set_tracking + def set_tracking(self, on): + self.set_tracking_calls += 1 + self.tracking = bool(on) + + +class FakeCamera: + # Function: FakeCamera.__init__ + def __init__(self, state): + self._state = state + + @property + # Function: FakeCamera.camera_state + def camera_state(self): + return self._state + + +# Function: _sequence +def _sequence(telescope=None, camera=None): + seq = object.__new__(DiamondSequence) + seq._telescope = telescope or FakeTelescope() + seq._camera = camera or FakeCamera(pilot.AlpacaCamera.IDLE) + seq._last_pointing_proof = None + return seq + + +# Function: test_tracking_gate_enables_and_verifies_tracking +def test_tracking_gate_enables_and_verifies_tracking(monkeypatch): + monkeypatch.setattr(pilot, "TRACKING_VERIFY_TIMEOUT_SEC", 0.2) + monkeypatch.setattr(pilot, "TRACKING_VERIFY_INTERVAL_SEC", 0.01) + telescope = FakeTelescope(tracking=False, slewing=False, atpark=False) + seq = _sequence(telescope=telescope) + + seq._ensure_science_tracking() + + assert telescope.tracking is True + assert telescope.set_tracking_calls == 1 + + +# Function: test_camera_idle_gate_rejects_busy_camera +def test_camera_idle_gate_rejects_busy_camera(monkeypatch): + monkeypatch.setattr(pilot, "CAMERA_IDLE_TIMEOUT_SEC", 0.01) + seq = _sequence(camera=FakeCamera(pilot.AlpacaCamera.EXPOSING)) + + with pytest.raises(RuntimeError, match="Camera not idle"): + seq._ensure_camera_idle() + + +# Function: test_pointing_proof_must_match_target +def test_pointing_proof_must_match_target(): + seq = _sequence() + target = AcquisitionTarget("ST Boo", 15.0, 35.0) + seq._last_pointing_proof = PointingProof( + target_name="ST Boo", + ra_hours=15.0, + dec_deg=35.0, + verified_at_monotonic=pilot.time.monotonic(), + error_arcmin=2.0, + ) + + assert seq._pointing_proof_matches(target) + assert not seq._pointing_proof_matches(AcquisitionTarget("TT Boo", 15.0, 35.0)) + + +class PartialSequence: + # Function: PartialSequence.__init__ + def __init__(self): + self.results = [ + FrameResult(success=True, path=Path("/tmp/ok.fits")), + FrameResult(success=False, error="tracking proof failed"), + ] + + # Function: PartialSequence.init_session + def init_session(self): + return TelemetryBlock() + + # Function: PartialSequence.prepare_target + def prepare_target(self, target, telemetry=None, notify=None): + return target + + # Function: PartialSequence.acquire + def acquire(self, **_kwargs): + return self.results.pop(0) + + +class FailFirstSequence: + # Function: FailFirstSequence.__init__ + def __init__(self): + self.calls = 0 + + # Function: FailFirstSequence.init_session + def init_session(self): + return TelemetryBlock() + + # Function: FailFirstSequence.prepare_target + def prepare_target(self, target, telemetry=None, notify=None): + return target + + # Function: FailFirstSequence.acquire + def acquire(self, **_kwargs): + self.calls += 1 + if self.calls == 1: + return FrameResult(success=False, error="trailed frame") + return FrameResult(success=True, path=Path("/tmp/should_not_happen.fits")) + + +class SuccessSequence: + # Function: SuccessSequence.init_session + def init_session(self): + return TelemetryBlock() + + # Function: SuccessSequence.prepare_target + def prepare_target(self, target, telemetry=None, notify=None): + return target + + # Function: SuccessSequence.acquire + def acquire(self, **_kwargs): + return FrameResult(success=True, path=Path("/tmp/ok.fits")) + + +class FakeProofRecorder: + records = [] + + # Function: FakeProofRecorder.__init__ + def __init__(self, target_name): + self.target_name = target_name + + # Function: FakeProofRecorder.record + def record(self, step, status, *, detail="", evidence_path=None, extra=None): + self.records.append( + { + "target": self.target_name, + "step": step, + "status": status, + "detail": detail, + "evidence_path": str(evidence_path) if evidence_path else None, + } + ) + + +# Function: test_fsm_rejects_partial_target_by_default +def test_fsm_rejects_partial_target_by_default(monkeypatch, tmp_path): + monkeypatch.setattr(fsm_module, "STATE_FILE", tmp_path / "system_state.json") + fsm = SovereignFSM() + fsm.sequence = PartialSequence() + + ok = fsm.execute_target(AcquisitionTarget("ST Boo", 15.0, 35.0, n_frames=2), telemetry=TelemetryBlock()) + + assert ok is False + assert fsm.state == "ERROR" + + +# Function: test_strict_chain_stops_after_first_failed_frame +def test_strict_chain_stops_after_first_failed_frame(monkeypatch, tmp_path): + monkeypatch.setattr(fsm_module, "STATE_FILE", tmp_path / "system_state.json") + monkeypatch.setattr(fsm_module, "STRICT_TARGET_PROOF_CHAIN", True) + sequence = FailFirstSequence() + fsm = SovereignFSM() + fsm.sequence = sequence + + ok = fsm.execute_target(AcquisitionTarget("ST Boo", 15.0, 35.0, n_frames=2), telemetry=TelemetryBlock()) + + assert ok is False + assert sequence.calls == 1 + + +# Function: test_fsm_writes_proof_records +def test_fsm_writes_proof_records(monkeypatch, tmp_path): + monkeypatch.setattr(fsm_module, "STATE_FILE", tmp_path / "system_state.json") + FakeProofRecorder.records = [] + monkeypatch.setattr(fsm_module, "FlightProofRecorder", FakeProofRecorder) + fsm = SovereignFSM() + fsm.sequence = SuccessSequence() + + ok = fsm.execute_target(AcquisitionTarget("ST Boo", 15.0, 35.0, n_frames=1), telemetry=TelemetryBlock()) + + assert ok is True + assert {"target": "ST Boo", "step": "target", "status": "start", "detail": "n_frames=1", "evidence_path": None} in FakeProofRecorder.records + assert any(row["step"] == "connect" and row["status"] == "pass" for row in FakeProofRecorder.records) + assert any(row["step"] == "target_prepare" and row["status"] == "pass" for row in FakeProofRecorder.records) + assert any(row["step"] == "accept" and row["status"] == "pass" and row["evidence_path"] == "/tmp/ok.fits" for row in FakeProofRecorder.records) + assert any(row["step"] == "target" and row["status"] == "pass" for row in FakeProofRecorder.records) + + +# Function: test_fsm_builds_seestar_alp_adapter_only_when_controlled +def test_fsm_builds_seestar_alp_adapter_only_when_controlled(monkeypatch): + monkeypatch.setattr(fsm_module, "_seestar_alp_control_enabled", lambda: False) + assert isinstance(fsm_module._build_sequence(), DiamondSequence) + + +class FakeSSalpClient: + # Function: FakeSSalpClient.__init__ + def __init__(self, *, image_path="last.fit"): + self.tracking = False + self.image_path = image_path + self.slew = None + + # Function: FakeSSalpClient.test_connection_sync + def test_connection_sync(self): + return {"ok": True} + + # Function: FakeSSalpClient.goto_target_sync + def goto_target_sync(self, target_name, ra, dec, is_j2000=True): + self.slew = (target_name, ra, dec, is_j2000) + return {"ok": True} + + # Function: FakeSSalpClient.scope_get_track_state_sync + def scope_get_track_state_sync(self): + return {"tracking": self.tracking} + + # Function: FakeSSalpClient.scope_set_track_state_sync + def scope_set_track_state_sync(self, enabled): + self.tracking = bool(enabled) + return {"tracking": self.tracking} + + # Function: FakeSSalpClient.start_solve_sync + def start_solve_sync(self): + return {"started": True} + + # Function: FakeSSalpClient.get_solve_result_sync + def get_solve_result_sync(self): + return {"solved": True} + + # Function: FakeSSalpClient.start_stack_sync + def start_stack_sync(self, gain, restart=True): + return {"gain": gain, "restart": restart} + + # Function: FakeSSalpClient.get_last_image_sync + def get_last_image_sync(self, is_subframe=True, is_thumb=False): + return {"path": self.image_path} + + +# Function: test_seestar_alp_adapter_proves_slew_solve_track_capture +def test_seestar_alp_adapter_proves_slew_solve_track_capture(monkeypatch): + from core.flight.seestar_alp_adapter import SeestarAlpSequence + + monkeypatch.setattr( + "core.flight.seestar_alp_adapter.load_config", + lambda: {"seestar_alp": {"base_url": "http://127.0.0.1:5555"}, "seestars": []}, + ) + client = FakeSSalpClient() + sequence = SeestarAlpSequence(client=client) + result = sequence.acquire(AcquisitionTarget("ST Boo", 15.0, 35.0)) + + assert result.success is True + assert result.path == Path("last.fit") + assert client.slew == ("ST Boo", 15.0, 35.0, True) + assert client.tracking is True + + +# Function: test_seestar_alp_adapter_fails_without_image_path +def test_seestar_alp_adapter_fails_without_image_path(monkeypatch): + from core.flight.seestar_alp_adapter import SeestarAlpSequence + + monkeypatch.setattr( + "core.flight.seestar_alp_adapter.load_config", + lambda: {"seestar_alp": {"base_url": "http://127.0.0.1:5555"}, "seestars": []}, + ) + sequence = SeestarAlpSequence(client=FakeSSalpClient(image_path="")) + result = sequence.acquire(AcquisitionTarget("ST Boo", 15.0, 35.0)) + + assert result.success is False + assert "no image path" in result.error diff --git a/dev/tests/test_star_quality.py b/dev/tests/test_star_quality.py new file mode 100644 index 0000000..61ffee7 --- /dev/null +++ b/dev/tests/test_star_quality.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import sys +from pathlib import Path + +import numpy as np + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from core.flight.star_quality import measure_star_shape + + +# Function: _synthetic_field +def _synthetic_field(*, trailed: bool) -> np.ndarray: + rng = np.random.default_rng(42) + image = rng.normal(1000.0, 6.0, size=(512, 512)).astype(np.float32) + yy, xx = np.indices(image.shape, dtype=np.float32) + for y, x in [(80, 80), (110, 210), (150, 350), (220, 120), (260, 260), (300, 410), (380, 170), (430, 340)]: + sigma_x = 1.5 if not trailed else 1.2 + sigma_y = 1.5 if not trailed else 13.0 + image += 2500.0 * np.exp(-0.5 * (((xx - x) / sigma_x) ** 2 + ((yy - y) / sigma_y) ** 2)) + return image + + +# Function: test_round_star_field_passes_shape_qc +def test_round_star_field_passes_shape_qc(): + metrics = measure_star_shape(_synthetic_field(trailed=False), max_sources=20) + assert metrics.acceptance_error(max_elongation=6.0, max_major_axis_px=18.0, min_sources=6) is None + + +# Function: test_trailed_star_field_fails_shape_qc +def test_trailed_star_field_fails_shape_qc(): + metrics = measure_star_shape(_synthetic_field(trailed=True), max_sources=20) + assert metrics.acceptance_error(max_elongation=6.0, max_major_axis_px=18.0, min_sources=6) is not None From 1ed8bd9d276bc6a464e41e84bc19346bf202384b Mon Sep 17 00:00:00 2001 From: Ed de la Rie Date: Wed, 3 Jun 2026 16:25:13 +0200 Subject: [PATCH 5/8] Fix CI test headers --- dev/tests/test_flight_step_proofs.py | 5 +++++ dev/tests/test_star_quality.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/dev/tests/test_flight_step_proofs.py b/dev/tests/test_flight_step_proofs.py index dbe78c9..6ae7a2f 100644 --- a/dev/tests/test_flight_step_proofs.py +++ b/dev/tests/test_flight_step_proofs.py @@ -1,5 +1,10 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +""" +Filename: dev/tests/test_flight_step_proofs.py +Version: 1.0.0 +Objective: Verify flight proof-chain gates, strict target failure, and seestar_alp adapter behavior. +""" import sys from pathlib import Path diff --git a/dev/tests/test_star_quality.py b/dev/tests/test_star_quality.py index 61ffee7..82297c4 100644 --- a/dev/tests/test_star_quality.py +++ b/dev/tests/test_star_quality.py @@ -1,5 +1,10 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +""" +Filename: dev/tests/test_star_quality.py +Version: 1.0.0 +Objective: Verify lightweight star-shape QC accepts round fields and rejects trailed fields. +""" import sys from pathlib import Path From 896a04be1f48f8f263c4b8c9f8dc6372fd8354ca Mon Sep 17 00:00:00 2001 From: Ed de la Rie Date: Wed, 3 Jun 2026 22:03:44 +0200 Subject: [PATCH 6/8] Document seestarpy continuity adapter plan --- ROADMAP.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 0f698d8..e69ea9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -121,6 +121,22 @@ Next: - custody/state workflow - perform a helicopter-view audit of places where Astropy is the better answer +#### 1.9.6 — seestarpy Continuity Adapter +Future: +- test `seestarpy` as the preferred telescope execution adapter +- keep SeeVar responsible for target accounting, proof ledger, photometry, and AAVSO reporting +- map SeeVar proof steps onto `seestarpy` primitives: + - connect/open + - goto target + - plate-solve result + - tracking state + - start/stop stack + - stack info + - file download +- use `seestarpy` multi-Seestar support for Wilhelmina and Anna when stable +- handle firmware `7.18+` authentication explicitly before making this production +- prove one strict object chain before replacing the current Alpaca capture path + #### 1.9.x also includes - rewrite `WORKFLOW.MD` to match current Alpaca-era reality - remove stale TCP-era doctrine from remaining docs From 40a824d1c610ed1f1f5dcf70525f2fa44c0ae0be Mon Sep 17 00:00:00 2001 From: Ed de la Rie Date: Wed, 3 Jun 2026 22:47:53 +0200 Subject: [PATCH 7/8] Add seestarpy plan export bridge --- dev/tests/tools/test_build_seestarpy_plan.py | 106 +++++++++ dev/tools/README.md | 5 + dev/tools/telescope/build_seestarpy_plan.py | 221 +++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 dev/tests/tools/test_build_seestarpy_plan.py create mode 100644 dev/tools/telescope/build_seestarpy_plan.py diff --git a/dev/tests/tools/test_build_seestarpy_plan.py b/dev/tests/tools/test_build_seestarpy_plan.py new file mode 100644 index 0000000..0b46185 --- /dev/null +++ b/dev/tests/tools/test_build_seestarpy_plan.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: dev/tests/tools/test_build_seestarpy_plan.py +Version: 1.0.0 +Objective: Verify SeeVar plans convert into seestarpy observation-plan dictionaries. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(PROJECT_ROOT)) + +from dev.tools.telescope import build_seestarpy_plan + + +# Function: _write_json +def _write_json(path: Path, payload: dict) -> Path: + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +# Function: test_tonights_plan_converts_with_midnight_wrap +def test_tonights_plan_converts_with_midnight_wrap(tmp_path): + plan_path = _write_json( + tmp_path / "tonights_plan.json", + { + "targets": [ + { + "name": "ST Boo", + "ra": 224.44, + "dec": 40.73, + "integration_sec": 600, + "best_start_utc": "2026-05-25T19:00:00Z", + "recommended_order": 2, + }, + { + "name": "TT Boo", + "ra": 224.60, + "dec": 40.90, + "integration_sec": 900, + "best_start_utc": "2026-05-25T22:30:00Z", + "recommended_order": 3, + }, + ], + }, + ) + + output = build_seestarpy_plan.build_seestarpy_plan( + plan_path, + "Europe/Amsterdam", + "SeeVar Test", + plan_date="2026-05-25", + ) + + assert output["plan_name"] == "SeeVar Test" + assert output["update_time_seestar"] == "2026.05.25" + assert output["list"][0]["target_name"] == "ST Boo" + assert output["list"][0]["target_ra_dec"] == [14.962667, 40.73] + assert output["list"][0]["start_min"] == 1260 + assert output["list"][0]["duration_min"] == 10 + assert output["list"][1]["start_min"] == 1470 + assert output["list"][1]["duration_min"] == 15 + + +# Function: test_ssc_payload_converts_start_mosaic_items_only +def test_ssc_payload_converts_start_mosaic_items_only(tmp_path): + plan_path = _write_json( + tmp_path / "ssc_payload.json", + { + "list": [ + {"action": "start_up_sequence", "params": {}}, + { + "action": "start_mosaic", + "params": { + "target_name": "M 51", + "panel_time_sec": 3600, + "is_use_lp_filter": True, + }, + "compiler_notes": {"best_start_utc": "2026-05-25T20:15:00Z"}, + "source_target": {"name": "M 51", "ra_deg": 202.4696, "dec_deg": 47.1952}, + }, + {"action": "scope_park", "params": {}}, + ], + }, + ) + + output = build_seestarpy_plan.build_seestarpy_plan( + plan_path, + "Europe/Amsterdam", + "SSC Test", + plan_date="2026-05-25", + ) + + assert len(output["list"]) == 1 + target = output["list"][0] + assert target["target_name"] == "M 51" + assert target["target_ra_dec"] == [13.497973, 47.1952] + assert target["lp_filter"] is True + assert target["start_min"] == 1335 + assert target["duration_min"] == 60 diff --git a/dev/tools/README.md b/dev/tools/README.md index e5c9868..26bf80f 100644 --- a/dev/tools/README.md +++ b/dev/tools/README.md @@ -13,3 +13,8 @@ Seestar app view-plan export: `python dev/tools/telescope/build_seestar_view_plan.py --payload data/ssc_payload.json --output /tmp/view_plan.json` The confirmed firmware file is `/home/pi/.ZWO/view_plan.json`; it is current/history state for Seestar app Plan mode. Firmware logs also reference `/home/pi/.ZWO/plan.json`, but no ground-truth sample has been captured yet. The exporter defaults to JNOW coordinates because ASIAIR/Seestar plan execution appears to operate in current epoch, while SeeVar/AAVSO inputs are J2000. + +seestarpy plan export: +`python dev/tools/telescope/build_seestarpy_plan.py --input data/tonights_plan.json --output data/seestarpy_plan.json --name SeeVar` + +This produces the documented `seestarpy.plan.set_view_plan()` dictionary. Add `--submit` only from an environment where `seestarpy` is configured and connected. diff --git a/dev/tools/telescope/build_seestarpy_plan.py b/dev/tools/telescope/build_seestarpy_plan.py new file mode 100644 index 0000000..1a44a36 --- /dev/null +++ b/dev/tools/telescope/build_seestarpy_plan.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: dev/tools/telescope/build_seestarpy_plan.py +Version: 1.0.0 +Objective: Convert SeeVar nightly or SSC payloads into seestarpy observation plans. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +from datetime import date, datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +DEFAULT_INPUT = PROJECT_ROOT / "data" / "tonights_plan.json" + + +# Function: _load_json +def _load_json(path: Path) -> Any: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +# Function: _parse_plan_date +def _parse_plan_date(value: str | None) -> date | None: + if not value: + return None + return date.fromisoformat(value) + + +# Function: _parse_iso_dt +def _parse_iso_dt(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + + +# Function: _parse_clock_minute +def _parse_clock_minute(value: str) -> int: + hour_text, minute_text = str(value).split(":", 1) + return int(hour_text) * 60 + int(minute_text) + + +# Function: _target_id +def _target_id(name: str, ra_hours: float, dec_deg: float) -> int: + raw = f"{name}|{ra_hours:.7f}|{dec_deg:.7f}".encode("utf-8") + return 100_000_000 + (int(hashlib.sha1(raw).hexdigest()[:8], 16) % 900_000_000) + + +# Function: _duration_min +def _duration_min(target: dict[str, Any]) -> int: + for key in ("duration_min", "block_minutes", "window_minutes"): + if target.get(key) is not None: + return max(1, int(math.ceil(float(target[key])))) + for key in ("integration_sec", "duration", "panel_time_sec"): + if target.get(key) is not None: + return max(1, int(math.ceil(float(target[key]) / 60.0))) + return 10 + + +# Function: _start_min +def _start_min(target: dict[str, Any], timezone_name: str, plan_date: date, fallback: int) -> int: + if target.get("start_min") is not None: + return int(target["start_min"]) + if target.get("start_time"): + return _parse_clock_minute(str(target["start_time"])) + + dt = _parse_iso_dt(target.get("best_start_utc")) + if dt is None: + return fallback + + local = dt.astimezone(ZoneInfo(timezone_name)) + day_offset = max(0, (local.date() - plan_date).days) + return day_offset * 1440 + local.hour * 60 + local.minute + + +# Function: _plan_date +def _plan_date(targets: list[dict[str, Any]], timezone_name: str, explicit: str | None) -> date: + parsed = _parse_plan_date(explicit) + if parsed is not None: + return parsed + for target in targets: + dt = _parse_iso_dt(target.get("best_start_utc")) + if dt is not None: + return dt.astimezone(ZoneInfo(timezone_name)).date() + return datetime.now(ZoneInfo(timezone_name)).date() + + +# Function: _ra_hours_dec_deg +def _ra_hours_dec_deg(target: dict[str, Any]) -> tuple[float, float]: + if target.get("ra_hours") is not None: + return float(target["ra_hours"]), float(target["dec_deg"]) + if target.get("ra_deg") is not None: + return float(target["ra_deg"]) / 15.0, float(target["dec_deg"]) + return float(target["ra"]) / 15.0, float(target["dec"]) + + +# Function: _from_ssc_item +def _from_ssc_item(item: dict[str, Any]) -> dict[str, Any] | None: + if item.get("action") != "start_mosaic": + return None + params = item.get("params") or {} + source = item.get("source_target") or {} + notes = item.get("compiler_notes") or {} + name = str(params.get("target_name") or source.get("name") or "SeeVar Target") + return { + "name": name, + "alias_name": source.get("alias_name") or name, + "ra_deg": source.get("ra_deg"), + "dec_deg": source.get("dec_deg"), + "panel_time_sec": params.get("panel_time_sec"), + "best_start_utc": notes.get("best_start_utc"), + "lp_filter": bool(params.get("is_use_lp_filter", False)), + } + + +# Function: _source_targets +def _source_targets(payload: Any) -> list[dict[str, Any]]: + if isinstance(payload, dict) and isinstance(payload.get("list"), list): + items = [_from_ssc_item(item) for item in payload["list"]] + return [item for item in items if item is not None] + if isinstance(payload, dict): + targets = payload.get("targets", payload.get("data", [])) + else: + targets = payload + if not isinstance(targets, list): + raise ValueError("input does not contain a target list") + return [dict(target) for target in targets] + + +# Function: _sorted_targets +def _sorted_targets(targets: list[dict[str, Any]]) -> list[dict[str, Any]]: + if any("recommended_order" in target for target in targets): + return sorted(targets, key=lambda target: int(target.get("recommended_order", 999999))) + return targets + + +# Function: build_seestarpy_plan +def build_seestarpy_plan( + input_path: Path, + timezone_name: str, + plan_name: str, + plan_date: str | None = None, + default_start: str = "21:00", +) -> dict[str, Any]: + targets = _sorted_targets(_source_targets(_load_json(input_path))) + local_plan_date = _plan_date(targets, timezone_name, plan_date) + fallback_start = _parse_clock_minute(default_start) + output_targets = [] + + for target in targets: + name = str(target.get("name") or target.get("target_name") or "SeeVar Target") + ra_hours, dec_deg = _ra_hours_dec_deg(target) + duration = _duration_min(target) + start = _start_min(target, timezone_name, local_plan_date, fallback_start) + fallback_start = start + duration + output_targets.append( + { + "target_id": _target_id(name, ra_hours, dec_deg), + "target_name": name, + "alias_name": str(target.get("alias_name") or name), + "target_ra_dec": [round(ra_hours % 24.0, 6), round(dec_deg, 6)], + "lp_filter": bool(target.get("lp_filter", False)), + "start_min": start, + "duration_min": duration, + } + ) + + return { + "plan_name": plan_name, + "update_time_seestar": local_plan_date.strftime("%Y.%m.%d"), + "list": output_targets, + } + + +# Function: submit_seestarpy_plan +def submit_seestarpy_plan(plan_payload: dict[str, Any]) -> None: + from seestarpy import plan + + plan.set_view_plan(plan_payload) + + +# Function: main +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", type=Path, default=DEFAULT_INPUT) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--timezone", default="Europe/Amsterdam") + parser.add_argument("--name", default="SeeVar") + parser.add_argument("--plan-date", help="Local observing date, YYYY-MM-DD.") + parser.add_argument("--default-start", default="21:00") + parser.add_argument("--submit", action="store_true", help="Submit with seestarpy.plan.set_view_plan after writing.") + args = parser.parse_args() + + payload = build_seestarpy_plan( + args.input.expanduser().resolve(), + args.timezone, + args.name, + args.plan_date, + args.default_start, + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, indent=2), encoding="utf-8") + if args.submit: + submit_seestarpy_plan(payload) + print(f"wrote {args.output} ({len(payload['list'])} target(s))") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From da4fff6e0ccfd7e23c2b6ddcb9ebade9889a49a5 Mon Sep 17 00:00:00 2001 From: Ed de la Rie Date: Thu, 4 Jun 2026 11:53:33 +0200 Subject: [PATCH 8/8] Add SeeVar Lite plan monitor --- ROADMAP.md | 8 +++ core/lite/__init__.py | 8 +++ core/lite/executor.py | 91 ++++++++++++++++++++++++ core/lite/monitor.py | 84 +++++++++++++++++++++++ dev/logic/WORKFLOW.MD | 27 ++++++++ dev/tests/test_lite_executor.py | 95 ++++++++++++++++++++++++++ dev/tools/README.md | 5 ++ dev/tools/telescope/run_seevar_lite.py | 72 +++++++++++++++++++ 8 files changed, 390 insertions(+) create mode 100644 core/lite/__init__.py create mode 100644 core/lite/executor.py create mode 100644 core/lite/monitor.py create mode 100644 dev/tests/test_lite_executor.py create mode 100644 dev/tools/telescope/run_seevar_lite.py diff --git a/ROADMAP.md b/ROADMAP.md index e69ea9a..08e2aec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,14 @@ Future: - handle firmware `7.18+` authentication explicitly before making this production - prove one strict object chain before replacing the current Alpaca capture path +#### 1.9.7 — SeeVar Lite Reducer +Future: +- make Lite the preferred development path +- submit a generated seestarpy/seestar_alp plan instead of steering frames directly +- monitor plan state into `system_state_lite.json` and `flight_runs/lite_*.jsonl` +- download one accepted stack per target +- keep the old Alpaca loop as fallback until Lite proves one full science target + #### 1.9.x also includes - rewrite `WORKFLOW.MD` to match current Alpaca-era reality - remove stale TCP-era doctrine from remaining docs diff --git a/core/lite/__init__.py b/core/lite/__init__.py new file mode 100644 index 0000000..8da25e9 --- /dev/null +++ b/core/lite/__init__.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: core/lite/__init__.py +Version: 0.1.0 +Objective: SeeVar Lite plan-execution and monitoring primitives. +""" + diff --git a/core/lite/executor.py b/core/lite/executor.py new file mode 100644 index 0000000..0daeaac --- /dev/null +++ b/core/lite/executor.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: core/lite/executor.py +Version: 0.1.0 +Objective: Provide the small SeeVar Lite plan-executor interface. +""" + +from __future__ import annotations + +from typing import Any, Protocol + + +ACTIVE_STATES = {"working", "waiting", "pending", "running"} + + +class PlanExecutor(Protocol): + # Function: PlanExecutor.submit_plan + def submit_plan(self, plan_payload: dict[str, Any]) -> dict[str, Any]: ... + + # Function: PlanExecutor.poll_plan + def poll_plan(self) -> dict[str, Any]: ... + + # Function: PlanExecutor.stop_plan + def stop_plan(self) -> dict[str, Any]: ... + + +# Function: _target_summary +def _target_summary(target: dict[str, Any]) -> dict[str, Any]: + return { + "target_id": target.get("target_id"), + "target_name": target.get("target_name"), + "state": target.get("state", "pending"), + "skip": bool(target.get("skip", False)), + "lapse_ms": int(target.get("lapse_ms", 0) or 0), + "start_min": target.get("start_min"), + "duration_min": target.get("duration_min"), + } + + +# Function: normalize_running_plan +def normalize_running_plan(raw: dict[str, Any] | None) -> dict[str, Any]: + if raw is None: + return { + "success": True, + "state": "idle", + "active": False, + "plan_name": None, + "targets": [], + "raw": None, + } + + plan = raw.get("plan", raw) + state = str(raw.get("state") or plan.get("state") or "unknown").lower() + targets = [_target_summary(target) for target in plan.get("list", [])] + return { + "success": True, + "state": state, + "active": state in ACTIVE_STATES, + "plan_name": plan.get("plan_name"), + "targets": targets, + "raw": raw, + } + + +class SeestarPyPlanExecutor: + # Function: SeestarPyPlanExecutor.__init__ + def __init__(self, plan_module: Any | None = None): + if plan_module is None: + from seestarpy import plan as plan_module + + self.plan_module = plan_module + + # Function: SeestarPyPlanExecutor.submit_plan + def submit_plan(self, plan_payload: dict[str, Any]) -> dict[str, Any]: + self.plan_module.set_view_plan(plan_payload) + return { + "success": True, + "action": "set_view_plan", + "plan_name": plan_payload.get("plan_name"), + "target_count": len(plan_payload.get("list", [])), + } + + # Function: SeestarPyPlanExecutor.poll_plan + def poll_plan(self) -> dict[str, Any]: + return normalize_running_plan(self.plan_module.get_running_plan()) + + # Function: SeestarPyPlanExecutor.stop_plan + def stop_plan(self) -> dict[str, Any]: + self.plan_module.stop_view_plan() + return {"success": True, "action": "stop_view_plan"} diff --git a/core/lite/monitor.py b/core/lite/monitor.py new file mode 100644 index 0000000..ba5166c --- /dev/null +++ b/core/lite/monitor.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: core/lite/monitor.py +Version: 0.1.0 +Objective: Monitor a SeeVar Lite plan run and write proof/status artifacts. +""" + +from __future__ import annotations + +import json +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from core.lite.executor import PlanExecutor + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_STATUS_PATH = PROJECT_ROOT / "data" / "system_state_lite.json" +DEFAULT_PROOF_DIR = PROJECT_ROOT / "data" / "flight_runs" + + +# Function: utc_timestamp +def utc_timestamp() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +# Function: target_state_counts +def target_state_counts(status: dict[str, Any]) -> dict[str, int]: + counts: dict[str, int] = {} + for target in status.get("targets", []): + state = str(target.get("state") or "unknown") + counts[state] = counts.get(state, 0) + 1 + return counts + + +class LitePlanMonitor: + # Function: LitePlanMonitor.__init__ + def __init__( + self, + executor: PlanExecutor, + status_path: Path = DEFAULT_STATUS_PATH, + proof_path: Path | None = None, + ): + self.executor = executor + self.status_path = status_path + self.proof_path = proof_path or DEFAULT_PROOF_DIR / f"lite_{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}.jsonl" + + # Function: LitePlanMonitor._write_json + def _write_json(self, path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + # Function: LitePlanMonitor._append_jsonl + def _append_jsonl(self, path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, sort_keys=True) + "\n") + + # Function: LitePlanMonitor.sample_once + def sample_once(self) -> dict[str, Any]: + status = dict(self.executor.poll_plan()) + status["timestamp_utc"] = utc_timestamp() + status["target_state_counts"] = target_state_counts(status) + status["proof_path"] = str(self.proof_path) + self._write_json(self.status_path, status) + self._append_jsonl(self.proof_path, status) + return status + + # Function: LitePlanMonitor.monitor_until_inactive + def monitor_until_inactive(self, poll_sec: float = 30.0, timeout_sec: float | None = None) -> dict[str, Any]: + started = time.monotonic() + latest = self.sample_once() + while latest.get("active"): + if timeout_sec is not None and time.monotonic() - started >= timeout_sec: + latest["timeout"] = True + self._write_json(self.status_path, latest) + self._append_jsonl(self.proof_path, latest) + return latest + time.sleep(max(0.1, float(poll_sec))) + latest = self.sample_once() + return latest diff --git a/dev/logic/WORKFLOW.MD b/dev/logic/WORKFLOW.MD index 8a42f18..37fa55c 100644 --- a/dev/logic/WORKFLOW.MD +++ b/dev/logic/WORKFLOW.MD @@ -115,6 +115,33 @@ Flight outputs raw custody: - logs and telemetry - ledger attempt/capture status +## SeeVar Lite Flight Path + +SeeVar Lite is the reducer path for current development. + +It keeps SeeVar responsible for planning, monitoring, proof artifacts, +postflight, photometry, and AAVSO reporting. It delegates telescope execution +to `seestarpy` or `seestar_alp` plan execution. + +Lite chain: + +```text +tonights_plan.json +-> seestarpy plan dictionary +-> plan submit +-> plan monitor +-> stack/product download +-> postflight +``` + +The default Lite monitor writes: + +- `data/system_state_lite.json` +- `data/flight_runs/lite_*.jsonl` + +The old Alpaca frame loop remains a fallback until one Lite target proves +`plan -> solve -> track -> stack -> download -> photometry -> report`. + ## Phase 5: POSTFLIGHT Postflight owns scientific truth. diff --git a/dev/tests/test_lite_executor.py b/dev/tests/test_lite_executor.py new file mode 100644 index 0000000..0f1c236 --- /dev/null +++ b/dev/tests/test_lite_executor.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: dev/tests/test_lite_executor.py +Version: 0.1.0 +Objective: Verify SeeVar Lite plan executor and monitor proof output. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from core.lite.executor import SeestarPyPlanExecutor, normalize_running_plan +from core.lite.monitor import LitePlanMonitor + + +class FakePlanModule: + # Function: FakePlanModule.__init__ + def __init__(self): + self.submitted = None + self.stopped = False + self.running = { + "state": "working", + "plan": { + "plan_name": "Lite Test", + "list": [ + {"target_id": 1, "target_name": "ST Boo", "state": "working", "lapse_ms": 1000}, + {"target_id": 2, "target_name": "TT Boo", "state": "pending", "skip": False}, + ], + }, + } + + # Function: FakePlanModule.set_view_plan + def set_view_plan(self, payload): + self.submitted = payload + + # Function: FakePlanModule.get_running_plan + def get_running_plan(self): + return self.running + + # Function: FakePlanModule.stop_view_plan + def stop_view_plan(self): + self.stopped = True + + +# Function: test_normalize_running_plan_handles_idle +def test_normalize_running_plan_handles_idle(): + status = normalize_running_plan(None) + + assert status["state"] == "idle" + assert status["active"] is False + assert status["targets"] == [] + + +# Function: test_seestarpy_executor_wraps_plan_module +def test_seestarpy_executor_wraps_plan_module(): + fake = FakePlanModule() + executor = SeestarPyPlanExecutor(fake) + payload = {"plan_name": "Lite Test", "list": [{"target_name": "ST Boo"}]} + + submit = executor.submit_plan(payload) + poll = executor.poll_plan() + stop = executor.stop_plan() + + assert fake.submitted == payload + assert submit["target_count"] == 1 + assert poll["state"] == "working" + assert poll["active"] is True + assert poll["targets"][0]["target_name"] == "ST Boo" + assert stop["success"] is True + assert fake.stopped is True + + +# Function: test_lite_monitor_writes_status_and_proof +def test_lite_monitor_writes_status_and_proof(tmp_path): + executor = SeestarPyPlanExecutor(FakePlanModule()) + status_path = tmp_path / "status.json" + proof_path = tmp_path / "proof.jsonl" + monitor = LitePlanMonitor(executor, status_path, proof_path) + + status = monitor.sample_once() + + assert status["target_state_counts"] == {"working": 1, "pending": 1} + assert status_path.exists() + assert proof_path.exists() + saved = json.loads(status_path.read_text(encoding="utf-8")) + rows = [json.loads(line) for line in proof_path.read_text(encoding="utf-8").splitlines()] + assert saved["plan_name"] == "Lite Test" + assert rows[0]["proof_path"] == str(proof_path) diff --git a/dev/tools/README.md b/dev/tools/README.md index 26bf80f..032735b 100644 --- a/dev/tools/README.md +++ b/dev/tools/README.md @@ -18,3 +18,8 @@ seestarpy plan export: `python dev/tools/telescope/build_seestarpy_plan.py --input data/tonights_plan.json --output data/seestarpy_plan.json --name SeeVar` This produces the documented `seestarpy.plan.set_view_plan()` dictionary. Add `--submit` only from an environment where `seestarpy` is configured and connected. + +SeeVar Lite run/monitor: +`python dev/tools/telescope/run_seevar_lite.py --plan data/seestarpy_plan.json --submit --monitor` + +The Lite monitor writes `data/system_state_lite.json` plus a JSONL proof file in `data/flight_runs/`. diff --git a/dev/tools/telescope/run_seevar_lite.py b/dev/tools/telescope/run_seevar_lite.py new file mode 100644 index 0000000..7ee695b --- /dev/null +++ b/dev/tools/telescope/run_seevar_lite.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: dev/tools/telescope/run_seevar_lite.py +Version: 0.1.0 +Objective: Submit and monitor a seestarpy plan through the SeeVar Lite path. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(PROJECT_ROOT)) + +from core.lite.executor import SeestarPyPlanExecutor +from core.lite.monitor import DEFAULT_STATUS_PATH, LitePlanMonitor + + +# Function: _load_plan +def _load_plan(path: Path) -> dict: + with path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, dict) or not isinstance(payload.get("list"), list): + raise ValueError(f"{path} is not a seestarpy plan dictionary") + return payload + + +# Function: _print_status +def _print_status(status: dict) -> None: + counts = status.get("target_state_counts", {}) + print( + "state={state} active={active} plan={plan} targets={targets} proof={proof}".format( + state=status.get("state"), + active=status.get("active"), + plan=status.get("plan_name"), + targets=counts, + proof=status.get("proof_path"), + ) + ) + + +# Function: main +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plan", type=Path, required=True) + parser.add_argument("--submit", action="store_true") + parser.add_argument("--monitor", action="store_true") + parser.add_argument("--poll-sec", type=float, default=30.0) + parser.add_argument("--timeout-sec", type=float) + parser.add_argument("--status", type=Path, default=DEFAULT_STATUS_PATH) + parser.add_argument("--proof", type=Path) + args = parser.parse_args() + + plan_payload = _load_plan(args.plan.expanduser().resolve()) + executor = SeestarPyPlanExecutor() + + if args.submit: + print(json.dumps(executor.submit_plan(plan_payload), sort_keys=True)) + + monitor = LitePlanMonitor(executor, args.status.expanduser(), args.proof.expanduser() if args.proof else None) + status = monitor.monitor_until_inactive(args.poll_sec, args.timeout_sec) if args.monitor else monitor.sample_once() + _print_status(status) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())