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
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..08e2aec 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
@@ -116,6 +121,30 @@ 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.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/config.toml.example b/config.toml.example
index 80627eb..fcc8921 100644
--- a/config.toml.example
+++ b/config.toml.example
@@ -133,9 +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
@@ -157,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
# -----------------------------------------------------------------------------
@@ -199,6 +210,9 @@ plate_solve_timeout_sec = 90
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/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 }}
+
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/core/flight/fsm.py b/core/flight/fsm.py
index 2fbaa8b..1aff548 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
@@ -17,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")
@@ -24,11 +27,19 @@
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: _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:
return int(round(float(_flight_cfg().get(key, default))))
@@ -37,38 +48,122 @@ 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",
+ "[A6]": "SLEWING",
+ "[A7]": "EXPOSING",
+ "[A8]": "TRACKING",
+ "[A10]": "EXPOSING",
+ "[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__
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.sequence = DiamondSequence()
+ self.frame_retry_limit = FRAME_RETRY_LIMIT
+ self.pointing_reverify_interval_frames = POINTING_REVERIFY_INTERVAL_FRAMES
+ self.sequence = _build_sequence()
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 +180,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 +203,15 @@ 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,
+ _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]
@@ -115,11 +221,15 @@ 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:
status_cb(msg)
+ # Function: SovereignFSM.execute_target.abort_requested
def abort_requested() -> bool:
try:
return bool(abort_cb and abort_cb())
@@ -130,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)
@@ -148,12 +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
@@ -168,18 +286,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,
)
@@ -190,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))
@@ -199,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
@@ -212,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/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/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/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/core/postflight/accountant.py b/core/postflight/accountant.py
index 1a0a54b..feb5e49 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
@@ -39,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,
@@ -65,11 +68,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))))
@@ -77,12 +82,37 @@ 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()
+# 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 +144,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 +171,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 +272,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 +290,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 +302,7 @@ def load_ledger() -> dict:
return {}
+# Function: save_ledger
def save_ledger(entries: dict):
output = {
"#objective": "Master Observational Register and Status Ledger",
@@ -283,6 +318,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 +328,7 @@ def _temp_bin_for_requirement(temp_c):
return None
+# Function: save_missing_darks
def save_missing_darks(entries: dict):
requirements = {}
@@ -346,6 +383,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 +421,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 +470,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 +488,144 @@ 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: _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)
+ 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)
+ 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"
+ 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)
+ 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)
+ 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 +652,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 +683,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 +691,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 +712,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 +725,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)
@@ -583,7 +765,50 @@ 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:
ref_points = _source_centroids(reference)
mov_points = _source_centroids(moving)
@@ -610,6 +835,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 +865,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 +919,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
@@ -767,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)
@@ -774,11 +1004,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 +1028,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 +1047,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 +1056,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 +1075,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 +1084,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 +1092,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:
@@ -1039,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:
@@ -1101,7 +1385,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 +1474,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/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..37fa55c 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,14 +102,46 @@ 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
+## 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.
@@ -126,7 +159,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 +192,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 8012cdc..40335da 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)
@@ -104,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)
@@ -140,8 +151,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_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"))
@@ -150,24 +164,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_absent = not bool(entry.get("last_accepted_preview"))
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" 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 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("")
print("PASS: dark calibration + accountant closure path exercised successfully.")
+# Function: main
def main():
print("Preparing dark-postflight smoke test...")
reset_test_artifacts()
@@ -178,7 +198,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 +212,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 +235,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()
diff --git a/dev/tests/test_flight_step_proofs.py b/dev/tests/test_flight_step_proofs.py
new file mode 100644
index 0000000..6ae7a2f
--- /dev/null
+++ b/dev/tests/test_flight_step_proofs.py
@@ -0,0 +1,302 @@
+#!/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
+
+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_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/tests/test_star_quality.py b/dev/tests/test_star_quality.py
new file mode 100644
index 0000000..82297c4
--- /dev/null
+++ b/dev/tests/test_star_quality.py
@@ -0,0 +1,41 @@
+#!/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
+
+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
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..032735b 100644
--- a/dev/tools/README.md
+++ b/dev/tools/README.md
@@ -13,3 +13,13 @@ 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.
+
+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/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())
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())
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