diff --git a/.gitignore b/.gitignore index a8284f0..a6a4f44 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ data/weather_state.json *.fts .idea/ *.key +*.pem *.log logs/ # Logs @@ -61,9 +62,13 @@ __pycache__/ # Storage mounts — OS-level, not repo content *.swp tests/ +!dev/tests/ +!dev/tests/** # Tests & Dev Utilities — not part of public install *.token utils/ +!dev/utils/ +!dev/utils/raid_watchdog.py .venv/ venv/ .vscode/ diff --git a/README.md b/README.md index b76a35f..a6e6500 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ It is repeatable, honest measurement. Observation cadence follows AAVSO-style scientific needs rather than casual imaging habits. +When the photometric run is finished, SeeVar may optionally use remaining safe dark time for +secondary imaging catalogs such as Caldwell. These frames are filler products only: they are not +submitted as photometry and should be archived separately for later stacking/gallery tooling. + --- ## Current Status @@ -57,6 +61,7 @@ It is making the science chain more defensible and operationally safer: - deterministic AAVSO report staging - BAA-modified AAVSO Extended and richer CCD/CMOS test-file export - multi-scope execution without shared-file collisions +- optional post-photometry secondary imaging queue for Caldwell/Messier-style targets --- @@ -91,7 +96,11 @@ SeeVar is organized as a sovereign observing pipeline: Processes captured frames using the `P1-P8` science chain: ingest, calibration matching, calibration application, astrometric solve, source measurement, ensemble calibration, quality verdict, and commit/report. -4. **Oversight** +4. **Secondary Imaging** + Optional filler phase after photometry/reporting. It uses configured catalogs, respects weather, + horizon, daylight, and battery guards, and moves captured frames directly into secondary storage. + +5. **Oversight** Dashboard, logs, notifier, and ledger state remain available throughout the entire mission. --- @@ -109,6 +118,11 @@ Confirmed device access includes: - Focuser - Dew-heater switch +Developer SSH access is optional but useful for diagnostics. When enabled on owned scopes, +`dev/tools/telescope/seestar_ssh_probe.py` can collect OS version, network identity, +storage status, ZWO plan state, and onboard Astrometry.net index inventory without steering +the telescope. + This means: - no phone app required @@ -136,8 +150,9 @@ Current reporting direction: - science channel: `G` - reporting code: `TG` - morning triage artifact: `data/reports/postflight_summary_*.txt` and `.json` -- manual submission staging: `python3 dev/tools/stage_reports_from_summary.py` -- WebObs submit probe/upload: `python3 dev/tools/submit_aavso_webobs.py --probe-only` +- manual submission staging: `python3 dev/tools/reports/stage_reports_from_summary.py` +- WebObs submit probe/upload: `python3 dev/tools/reports/submit_aavso_webobs.py --probe-only` +- light-curve quicklooks: `python3 dev/tools/reports/lightcurve_plots.py` ### Astrometric and detector truth matter @@ -149,6 +164,21 @@ A magnitude is only trustworthy if the pipeline can justify: That is why postflight is now being hardened aggressively. +### Secondary imaging + +Secondary imaging is enabled in `[planner]`: + +```toml +secondary_catalogs = ["caldwell"] +secondary_after_photometry = true +secondary_duration_sec = 900 +secondary_output_dir = "" +``` + +If `secondary_output_dir` is empty, output goes to `[storage].primary_dir/secondary_catalogs`. +For AstroCat or similar gallery software, run the gallery on NAS/RAID and mount this directory +read-only. Do not index `data/local_buffer`. + --- ## Storage Philosophy @@ -252,7 +282,7 @@ For manual cleanup of `data/horizon_mask.json`, run the local Flask editor: ```bash cd ~/seevar -/home/ed/seevar/.venv/bin/python dev/tools/horizon_profile_editor.py \ +/home/ed/seevar/.venv/bin/python dev/tools/horizon/horizon_profile_editor.py \ --host 0.0.0.0 \ --port 5060 ``` diff --git a/bootstrap.sh b/bootstrap.sh index e81aa60..3b85e6f 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -647,6 +647,38 @@ TimeoutStopSec=10s [Install] WantedBy=default.target +SVCEOF + + cat > "$SYSTEMD_DIR/seevar-raid-watchdog.service" < "$SYSTEMD_DIR/seevar-raid-watchdog.timer" < None: } COMMAND_FILE.write_text(json.dumps(payload, indent=2)) + +# Mark dashboard state immediately after a manual abort. +def mark_abort_state(message: str = "Operator abort requested.") -> None: + now_utc = datetime.now(timezone.utc).isoformat() + paths = [STATE_FILE, *sorted(DATA_DIR.glob("system_state.scope*.json"))] + for path in paths: + try: + payload = load_json_file(path, {}) if path.exists() else {} + payload.update({ + "state": "ABORTED", + "sub": "OPERATOR ABORT", + "substate": "OPERATOR ABORT", + "msg": message, + "message": message, + "updated": now_utc, + "updated_utc": now_utc, + }) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + except Exception as exc: + log.warning("Could not mark abort state in %s: %s", path, exc) + + +def _configured_scope_hosts(config: dict) -> list[tuple[str, int]]: + hosts = [] + for entry in config.get("seestars", []): + ip = str(entry.get("ip", "")).strip() + if not ip or ip == "TBD": + continue + try: + port = int(entry.get("alpaca_port", 32323)) + except Exception: + port = 32323 + hosts.append((ip, port)) + return hosts + + +def abort_hardware_now(config: dict) -> list[str]: + results = [] + tx = int(time.time()) % 100000 + for ip, port in _configured_scope_hosts(config): + base = f"http://{ip}:{port}/api/v1" + for device, action in (("camera/0", "abortexposure"), ("camera/1", "abortexposure"), ("telescope/0", "abortslew")): + try: + response = http_requests.put( + f"{base}/{device}/{action}", + data={"ClientID": 42, "ClientTransactionID": tx}, + timeout=0.5, + ) + ok = response.status_code < 400 + results.append(f"{ip} {device}/{action}: {'ok' if ok else response.status_code}") + except Exception as exc: + results.append(f"{ip} {device}/{action}: {exc}") + tx += 1 + return results + ALPACA_TIMEOUT = 2.0 WIDE_PREVIEW_EXPOSURE_SEC = 0.5 @@ -106,8 +165,10 @@ def write_operator_command(command: str) -> None: }, "fleet": [], } -HW_CACHE_TTL = 5 -WEATHER_STALE_SEC = 1800 +HW_CACHE_TTL = 15 +# WeatherSentinel refreshes every 4 hours. Mark it unusable only after it +# misses the next cycle plus margin; the UI still shows the exact age. +WEATHER_STALE_SEC = 5 * 3600 TELEMETRY_STALE_SEC = 300 DASHBOARD_EVENT_STATE = { @@ -120,6 +181,12 @@ def write_operator_command(command: str) -> None: "weather_missing": None, } +SUBMISSION_CACHE = { + "timestamp": 0.0, + "data": {"aavso": 0, "baa": 0, "source": "local"}, +} +SUBMISSION_CACHE_TTL = 30 * 60 + def _set_event_flag(key: str, active: bool, message: str, *, level: int = logging.WARNING): previous = DASHBOARD_EVENT_STATE.get(key) if previous is active: @@ -239,6 +306,30 @@ def _payload_age_seconds(payload: dict) -> float | None: return None +def _load_orchestrator_state(config: dict) -> tuple[dict, Path]: + """Prefer the freshest scoped state in fleet/split mode.""" + planner = config.get("planner", {}) if isinstance(config, dict) else {} + fleet_mode = str(planner.get("fleet_mode", "single")).strip().lower() + + if fleet_mode in {"split", "auto"}: + candidates = [] + for path in sorted(DATA_DIR.glob("system_state.scope*.json")): + payload = load_json_file(path, {}) + if not payload: + continue + try: + mtime = path.stat().st_mtime + except OSError: + mtime = 0.0 + candidates.append((mtime, path, payload)) + + if candidates: + _, path, payload = max(candidates, key=lambda item: item[0]) + return payload, path + + return load_json_file(STATE_FILE, {}), STATE_FILE + + def refresh_hw_cache(): now = time.time() if now - HW_CACHE["timestamp"] < HW_CACHE_TTL: @@ -385,6 +476,110 @@ def load_json_file(path, default=None): except (json.JSONDecodeError, OSError): return default if default is not None else {} + +def _report_observation_count(report_path: Path) -> int: + if not report_path.exists(): + return 0 + try: + with report_path.open("r", encoding="utf-8", errors="replace") as handle: + return sum(1 for line in handle if line.strip() and not line.startswith("#")) + except OSError: + return 0 + + +def _local_aavso_submitted_count() -> int: + count = 0 + seen_reports = set() + for path in sorted(REPORT_DIR.glob("aavso_submit_result_*.json")): + result = load_json_file(path, {}) + if not result.get("accepted"): + continue + + report_path = Path(str(result.get("report_path", ""))) + if report_path and report_path not in seen_reports: + seen_reports.add(report_path) + count += _report_observation_count(report_path) + continue + + for line in result.get("success_lines", []): + match = re.search(r"(\d+)\s+observations?\s+(?:were\s+)?submitted", str(line), re.I) + if match: + count += int(match.group(1)) + break + return count + + +def _local_baa_submitted_count() -> int: + count = 0 + for path in sorted(REPORT_DIR.glob("baa_submit_result_*.json")): + result = load_json_file(path, {}) + if result.get("accepted"): + count += int(result.get("observations", 1) or 1) + return count + + +def _aavso_cookie_from_config(config: dict) -> str: + aavso = config.get("aavso", {}) if isinstance(config, dict) else {} + for key in ("webobs_session_cookie", "webobs_cookie", "session_cookie", "webobs_token"): + value = str(aavso.get(key, "")).strip() + if value: + return value + return "" + + +def _apply_aavso_cookie(session: http_requests.Session, cookie_string: str) -> None: + raw = str(cookie_string or "").strip() + if not raw: + return + if "=" not in raw: + session.cookies.set("app2_session", raw, domain="apps.aavso.org", path="/") + session.cookies.set("canary", "false", domain="apps.aavso.org", path="/") + return + for part in raw.split(";"): + if "=" not in part: + continue + name, value = part.split("=", 1) + session.cookies.set(name.strip(), value.strip(), domain="apps.aavso.org", path="/") + session.cookies.set("canary", "false", domain="apps.aavso.org", path="/") + + +def _fetch_aavso_analytics_count(config: dict) -> int | None: + cookie = _aavso_cookie_from_config(config) + if not cookie: + return None + try: + session = http_requests.Session() + _apply_aavso_cookie(session, cookie) + response = session.get(AAVSO_ANALYTICS_URL, timeout=8) + if "/accounts/auth0/login" in response.url: + return None + text = re.sub(r"<[^>]+>", " ", response.text) + text = re.sub(r"\s+", " ", text) + match = re.search(r"Photometry Submissions\s+(\d+)", text, re.I) + return int(match.group(1)) if match else None + except Exception as exc: + log.debug("AAVSO analytics count unavailable: %s", exc) + return None + + +def build_submission_counts(config: dict) -> dict: + now = time.time() + if now - SUBMISSION_CACHE["timestamp"] < SUBMISSION_CACHE_TTL: + return dict(SUBMISSION_CACHE["data"]) + + dashboard_cfg = config.get("dashboard", {}) if isinstance(config, dict) else {} + use_analytics = bool(dashboard_cfg.get("aavso_analytics", False)) + local_aavso = _local_aavso_submitted_count() + analytics_aavso = _fetch_aavso_analytics_count(config) if use_analytics else None + data = { + "aavso": analytics_aavso if analytics_aavso is not None else local_aavso, + "baa": _local_baa_submitted_count(), + "source": "aavso_analytics" if analytics_aavso is not None else "local", + } + SUBMISSION_CACHE["timestamp"] = now + SUBMISSION_CACHE["data"] = dict(data) + return data + def _primary_scope_ip() -> str | None: cfg = load_config("~/seevar/config.toml") seestars = cfg.get("seestars", []) @@ -457,7 +652,7 @@ def _alpaca_camera_request(base: str, method: str, endpoint: str, *, timeout: fl return data.get("Value") -def _download_alpaca_image(base: str, *, timeout: float = 12.0) -> np.ndarray: +def _download_alpaca_image(base: str, *, timeout: float = 12.0, allow_json: bool = True) -> np.ndarray: params = { "ClientID": 42, "ClientTransactionID": _next_preview_txid(), @@ -474,7 +669,10 @@ def _download_alpaca_image(base: str, *, timeout: float = 12.0) -> np.ndarray: except Exception as exc: log.debug("Wide preview imagebytes unavailable: %s", exc) - response = http_requests.get(f"{base}/imagearray", params=params, timeout=max(timeout, 30.0)) + if not allow_json: + raise RuntimeError("imagebytes unavailable") + + response = http_requests.get(f"{base}/imagearray", params=params, timeout=timeout) data = response.json() err = data.get("ErrorNumber", 0) if err: @@ -518,7 +716,7 @@ def _alpaca_wide_snapshot_jpeg() -> bytes | None: base = f"http://{ip}:{_primary_scope_port()}/api/v1/camera/1" try: try: - _alpaca_camera_request(base, "PUT", "connected", timeout=4.0, Connected="true") + _alpaca_camera_request(base, "PUT", "connected", timeout=1.0, Connected="true") except Exception as exc: log.debug("Wide preview connect attempt failed: %s", exc) @@ -527,19 +725,19 @@ def _alpaca_wide_snapshot_jpeg() -> bytes | None: base, "PUT", "startexposure", - timeout=4.0, + timeout=1.0, Duration=str(WIDE_PREVIEW_EXPOSURE_SEC), Light="true", ) - deadline = time.monotonic() + max(10.0, WIDE_PREVIEW_EXPOSURE_SEC + 6.0) + deadline = time.monotonic() + max(1.5, WIDE_PREVIEW_EXPOSURE_SEC + 1.0) while time.monotonic() < deadline: - if bool(_alpaca_camera_request(base, "GET", "imageready", timeout=3.0)): + if bool(_alpaca_camera_request(base, "GET", "imageready", timeout=0.5)): break time.sleep(0.25) except Exception as exc: log.debug("Wide preview fresh exposure unavailable, trying last image: %s", exc) - return _render_array_jpeg(_download_alpaca_image(base)) + return _render_array_jpeg(_download_alpaca_image(base, timeout=2.0, allow_json=False)) except Exception as exc: log.info("Alpaca wide snapshot unavailable: %s", exc) return None @@ -561,7 +759,7 @@ def _rtsp_snapshot_jpeg(kind: str) -> bytes | None: "-", ] try: - result = subprocess.run(cmd, capture_output=True, timeout=8, check=True) + result = subprocess.run(cmd, capture_output=True, timeout=2, check=True) return result.stdout or None except Exception as e: log.info("RTSP snapshot unavailable for %s: %s", kind, e) @@ -616,7 +814,7 @@ def build_target_funnel(): "compiled_count": compiled_count, } -FLIGHT_WINDOW_CACHE = {"date": None, "text": ""} +FLIGHT_WINDOW_CACHE = {"key": None, "text": ""} def get_dusk_utc(lat, lon, elev): try: @@ -705,9 +903,10 @@ def build_nightly_progress(plan_targets: list, ledger: dict, dusk_dt, now_utc: d } -def get_flight_window(lat: float, lon: float, elev: float) -> str: +def get_flight_window(lat: float, lon: float, elev: float, sun_limit_deg: float) -> str: today_str = datetime.now().strftime("%Y-%m-%d") - if FLIGHT_WINDOW_CACHE["date"] == today_str: + cache_key = f"{today_str}:{sun_limit_deg:.2f}" + if FLIGHT_WINDOW_CACHE["key"] == cache_key: return FLIGHT_WINDOW_CACHE["text"] try: loc = EarthLocation(lat=lat*u.deg, lon=lon*u.deg, height=elev*u.m) @@ -722,18 +921,18 @@ def get_flight_window(lat: float, lon: float, elev: float) -> str: t = Time(t_dt) frame = AltAz(obstime=t, location=loc) sun_alt = get_sun(t).transform_to(frame).alt.deg - if sun_alt <= -18.0 and not is_night: + if sun_alt <= sun_limit_deg and not is_night: is_night = True dusk_str = t_dt.astimezone().strftime("%H:%M") - elif sun_alt > -18.0 and is_night: + elif sun_alt > sun_limit_deg and is_night: is_night = False dawn_str = t_dt.astimezone().strftime("%H:%M") break if dusk_str and dawn_str: - res = f"{dusk_str} - {dawn_str}" + res = f"{dusk_str} - {dawn_str} (<{sun_limit_deg:.0f}°)" else: - res = "NO ASTRONOMICAL NIGHT" - FLIGHT_WINDOW_CACHE["date"] = today_str + res = f"NONE (<{sun_limit_deg:.0f}°)" + FLIGHT_WINDOW_CACHE["key"] = cache_key FLIGHT_WINDOW_CACHE["text"] = res return res except Exception as e: @@ -849,10 +1048,13 @@ def index(): target_data = load_plan() config = load_config("~/seevar/config.toml") loc = config.get('location', {}) + planner = config.get('planner', {}) + sun_limit = float(planner.get('sun_altitude_limit', -18.0)) fw_text = get_flight_window( loc.get('lat', 51.4769), loc.get('lon', 0.0), - loc.get('elevation', 0.0) + loc.get('elevation', 0.0), + sun_limit, ) return render_template('index.html', target_data=target_data, flight_window=fw_text) @@ -900,7 +1102,7 @@ def get_telemetry(): "remaining_count": 0, "planned_count": 0, } - state_data = load_json_file(STATE_FILE, {}) + state_data, state_path = _load_orchestrator_state(config) if state_data: orchestrator.update({ "state": state_data.get("state", orchestrator["state"]), @@ -912,6 +1114,9 @@ def get_telemetry(): "remaining_count": state_data.get("remaining_count", 0), "planned_count": state_data.get("planned_count", 0), }) + orchestrator["scope_name"] = state_data.get("scope_name") + orchestrator["scope_id"] = state_data.get("scope_id") + orchestrator["state_source"] = state_path.name ledger = load_json_file(LEDGER_FILE, {}) _check_dashboard_sources(env, state_data, weather_data) @@ -921,6 +1126,7 @@ def get_telemetry(): loc.get('lat', 51.4769), loc.get('lon', 0.0), loc.get('elevation', 0.0) ) postflight = build_postflight(ledger, dusk_dt) + postflight["submissions"] = build_submission_counts(config) plan_targets = load_plan() nightly_progress = build_nightly_progress(plan_targets, ledger, dusk_dt, datetime.now(timezone.utc)) @@ -955,8 +1161,31 @@ def get_telemetry(): @app.post('/command/') def dashboard_command(action: str): action = str(action).strip().lower() - if action not in {"abort", "reset"}: + if action not in {"abort", "reset", "fleet-sync"}: return jsonify({"ok": False, "error": "unsupported_command"}), 400 + if action == "fleet-sync": + script = PROJECT_ROOT / "dev/tools/telescope/sync_fleet_orchestrators.py" + result = subprocess.run( + [sys.executable, str(script), "--apply"], + cwd=str(PROJECT_ROOT), + text=True, + capture_output=True, + timeout=45, + check=False, + ) + return jsonify({ + "ok": result.returncode == 0, + "command": action, + "returncode": result.returncode, + "stdout": result.stdout.strip(), + "stderr": result.stderr.strip(), + }), 200 if result.returncode == 0 else 500 + if action == "abort": + config = load_config("~/seevar/config.toml") + mark_abort_state() + hardware_results = abort_hardware_now(config) + write_operator_command(action) + return jsonify({"ok": True, "command": action, "hardware": hardware_results}) write_operator_command(action) return jsonify({"ok": True, "command": action}) diff --git a/core/dashboard/templates/index.html b/core/dashboard/templates/index.html index 0866f60..8bf9ed5 100755 --- a/core/dashboard/templates/index.html +++ b/core/dashboard/templates/index.html @@ -71,6 +71,7 @@ .control-btn.reset { color: #88ccff; border-color: #234154; } .preview-card { width: 100%; max-width: 260px; margin-top: 18px; border-top: 1px dashed #333; padding-top: 12px; } .preview-title { display: flex; justify-content: space-between; color: var(--text-muted); font-size: 0.65em; letter-spacing: 1.5px; margin-bottom: 8px; } + .preview-status { color: var(--text-muted); } .preview-image { width: 100%; height: 120px; object-fit: cover; border: 1px solid #2a2a2a; background: #050505; } @keyframes pulse-green { 0% { box-shadow: 0 0 10px rgba(57,255,20,0.5); } 50% { box-shadow: 0 0 30px rgba(57,255,20,0.8); } 100% { box-shadow: 0 0 10px rgba(57,255,20,0.5); } } @keyframes pulse-blue { 0% { transform: scale(1); box-shadow: 0 0 10px rgba(93,173,226,0.4); } 50% { transform: scale(1.05); box-shadow: 0 0 25px rgba(93,173,226,0.8); } 100% { transform: scale(1); box-shadow: 0 0 10px rgba(93,173,226,0.4); } } @@ -142,14 +143,11 @@

SYSTEM VITALS

-
FLIGHT WINDOW {{ flight_window }}
-
SEESTAR (STATE) WAITING...
+
DARK WINDOW {{ flight_window }}
GPS (LOCK)
-
WEATHER (PRED) CLEAR
+
WEATHER FETCHING
FOG (VIS) DISCONNECTED
-
BATTERIES --
-
MOUNT POS --
FLEET --
@@ -176,13 +174,14 @@

SYSTEM VITALS

+
A7 VERIFY VIEWLATEST
Latest pointing verification frame
-
WIDE FIELD VIEWLIVE
+
WIDE FIELD VIEWLIVE
Wide-field live preview
@@ -218,7 +217,7 @@

POSTFLIGHT AUDIT

- AAVSO READY + REPORTS
--
@@ -280,7 +279,17 @@

POSTFLIGHT AUDIT

: t.period_days.toFixed(1) + "d") : "--"; document.getElementById('t-mag').innerText = `${maxM} – ${minM} · ${perD} (V)`; - tickerIndex = (tickerIndex + 1) % targetData.length; + } + + function formatAge(seconds) { + if(seconds === null || seconds === undefined || Number.isNaN(Number(seconds))) return 'age unknown'; + const s = Math.max(0, Number(seconds)); + if(s < 90) return 'updated now'; + const minutes = Math.floor(s / 60); + if(minutes < 90) return `${minutes}m old`; + const hours = Math.floor(minutes / 60); + const rem = minutes % 60; + return rem ? `${hours}h${String(rem).padStart(2, '0')}m old` : `${hours}h old`; } let lastWidePreviewRefresh = 0; @@ -295,6 +304,8 @@

POSTFLIGHT AUDIT

} const widePreview = document.getElementById('wide-preview'); if (widePreview && (Date.now() - lastWidePreviewRefresh > 15000)) { + const wideStatus = document.getElementById('wide-preview-status'); + if (wideStatus) wideStatus.innerText = 'LIVE'; widePreview.src = '/preview/wide.jpg?ts=' + Date.now(); lastWidePreviewRefresh = Date.now(); } @@ -333,16 +344,18 @@

POSTFLIGHT AUDIT

const wLed = document.getElementById('weather-icon-led'); const stale = !!data.weather.stale; const status = data.weather.status || 'N/A'; - const currentStatus = data.weather.current_status || status; + const age = formatAge(data.weather.age_s); if(wVal) { - const label = stale ? `STALE (${status})` : `${status} tonight / ${currentStatus} now`; + const label = stale + ? `NO RECENT WEATHER / ${age}` + : `${status} tonight / ${age}`; wVal.innerText = label; wVal.style.color = stale ? '#ff9900' : ''; wVal.style.fontStyle = stale ? 'italic' : 'normal'; } if(wLed) { - wLed.innerText = stale ? '⏳' : (data.weather.icon || '❓'); + wLed.innerText = stale ? '!' : (data.weather.icon || '❓'); wLed.style.color = '#fff'; } @@ -432,62 +445,6 @@

POSTFLIGHT AUDIT

} if(data.hardware) { - const linkVal = document.getElementById('tel-link-val'); - const linkLed = document.getElementById('tel-link-led'); - const battTxt = document.getElementById('bb-batt'); - const mountTxt = document.getElementById('bb-mount'); - - if(["ACTIVE","ONLINE"].includes(data.hardware.link_status)) { - const statusLabels = {"ACTIVE": "NATIVE COMMS UP", "ONLINE": "ONLINE", "CONNECTING": "CONNECTING..."}; - linkVal.innerText = statusLabels[data.hardware.link_status] || data.hardware.link_status; - linkLed.className = "led green"; - } else if (data.hardware.link_status === "WAITING") { - linkVal.innerText = "WAITING"; - linkLed.className = "led orange"; - } else { - linkVal.innerText = "OFFLINE"; - linkLed.className = "led red"; - } - - if (data.fleet && data.fleet.length > 0) { - const battSummary = data.fleet.map(f => { - const raw = f.battery; - const value = raw !== undefined && raw !== null && raw !== "" && raw !== "N/A" - ? parseInt(raw, 10) - : null; - const suffix = f.charge_online ? " ⚡" : ""; - let cls = "batt-good"; - let text = "UNKNOWN"; - if (value !== null && !Number.isNaN(value)) { - text = `${value}%${suffix}`; - if (value <= 15) cls = "batt-crit-inline"; - else if (value <= 30) cls = "batt-warn-inline"; - } - return `·${f.name} ${text}`; - }).join(""); - battTxt.className = "val stat-val val-wrap"; - battTxt.innerHTML = `${battSummary}`; - } else { - const batt = data.hardware.battery; - if (batt !== "N/A" && batt !== undefined && batt !== null && batt !== "") { - battTxt.innerText = batt + "%"; - if (parseInt(batt) > 30) battTxt.className = "val stat-val"; - else if (parseInt(batt) > 15) battTxt.className = "val stat-val batt-warn"; - else battTxt.className = "val stat-val batt-crit"; - } else { - battTxt.innerText = "UNKNOWN"; - battTxt.className = "val"; - } - } - // New Live Mount Coordinate handling - if (data.hardware.ra !== undefined && data.hardware.dec !== undefined && data.hardware.ra !== "N/A") { - const ra = parseFloat(data.hardware.ra).toFixed(3); - const dec = parseFloat(data.hardware.dec).toFixed(3); - mountTxt.innerText = `${ra}h, ${dec}°`; - } else { - mountTxt.innerText = "--"; - } - // Fleet indicator if (data.fleet && data.fleet.length > 0) { const fleetTxt = document.getElementById("bb-fleet"); @@ -533,11 +490,15 @@

POSTFLIGHT AUDIT

photVal.className = 'pf-led-val' + (pf.phot_led === 'green' ? ' v-green' : pf.phot_led === 'orange' ? ' v-orange' : ''); photVal.innerText = observed > 0 ? observed + ' OBS' : '--'; - const aavsoLed = document.getElementById('led-aavso'); - const aavsoVal = document.getElementById('pf-aavso-val'); - aavsoLed.className = 'led ' + (pf.aavso_led || 'grey'); - aavsoVal.className = 'pf-led-val' + (pf.aavso_led === 'green' ? ' v-green' : pf.aavso_led === 'orange' ? ' v-orange' : ''); - aavsoVal.innerText = pf.aavso_led === 'green' ? 'SUBMITTED' : pf.aavso_led === 'orange' ? 'PENDING' : '--'; + const reportLed = document.getElementById('led-aavso'); + const reportVal = document.getElementById('pf-aavso-val'); + const submissions = pf.submissions || {}; + const aavsoCount = submissions.aavso || 0; + const baaCount = submissions.baa || 0; + const reportClass = (aavsoCount > 0 || baaCount > 0) ? 'green' : 'grey'; + reportLed.className = 'led ' + reportClass; + reportVal.className = 'pf-led-val' + (reportClass === 'green' ? ' v-green' : ''); + reportVal.innerText = `AAVSO ${aavsoCount} / BAA ${baaCount}`; const overallEl = document.getElementById('pf-overall'); if (pf.overall === 'green') { @@ -591,8 +552,14 @@

POSTFLIGHT AUDIT

document.getElementById('abort-btn').addEventListener('click', () => sendCommand('abort')); document.getElementById('reset-btn').addEventListener('click', () => sendCommand('reset')); + document.getElementById('fleet-sync-btn').addEventListener('click', () => sendCommand('fleet-sync')); + document.getElementById('wide-preview').addEventListener('load', () => { + document.getElementById('wide-preview-status').innerText = 'LIVE'; + }); + document.getElementById('wide-preview').addEventListener('error', () => { + document.getElementById('wide-preview-status').innerText = 'OFFLINE'; + }); - setInterval(updateTicker, 4000); setInterval(fetchTelemetry, 2000); setInterval(updateJDate, 1000); updateTicker(); fetchTelemetry(); updateJDate(); diff --git a/core/flight/fsm.py b/core/flight/fsm.py index 71f3e4e..2fbaa8b 100644 --- a/core/flight/fsm.py +++ b/core/flight/fsm.py @@ -44,6 +44,7 @@ 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() logger.info("🧠 FSM Initialized in state: %s", self.state) @@ -88,7 +89,13 @@ def _write_state_bridge(self, state: str | None, msg: str): except Exception as e: logger.debug("State bridge write skipped: %s", e) - def execute_target(self, target: AcquisitionTarget, status_cb: Optional[Callable[[str], None]] = None, telemetry: Optional[TelemetryBlock] = None) -> bool: + def execute_target( + self, + target: AcquisitionTarget, + status_cb: Optional[Callable[[str], None]] = None, + telemetry: Optional[TelemetryBlock] = None, + abort_cb: Optional[Callable[[], bool]] = None, + ) -> bool: """ Run the per-target sovereign sequence. @@ -97,6 +104,7 @@ def execute_target(self, target: AcquisitionTarget, status_cb: Optional[Callable """ self.update("WORKING") self.last_prepared_target = None + self.last_frame_paths = [] def bridge(*parts): if len(parts) == 1: @@ -112,7 +120,19 @@ def bridge(*parts): if status_cb: status_cb(msg) + def abort_requested() -> bool: + try: + return bool(abort_cb and abort_cb()) + except Exception as e: + logger.warning("Abort callback failed: %s", e) + return False + try: + if abort_requested(): + self._write_state_bridge("ABORTED", "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) @@ -139,6 +159,11 @@ def bridge(*parts): successful_frames = 0 failed_frames = 0 for i in range(target.n_frames): + if abort_requested(): + self._write_state_bridge("ABORTED", f"Operator abort during {target.name}") + self.update("ERROR") + return False + logger.info("[A10] Executing frame %d/%d", i + 1, target.n_frames) frame_ok = False last_error = "" @@ -155,11 +180,19 @@ def bridge(*parts): status_cb=bridge, telemetry=self.telemetry, skip_pointing=(successful_frames > 0), + abort_callback=abort_requested, ) + if result.error == "operator_abort": + self._write_state_bridge("ABORTED", f"Operator abort during {target.name}") + self.update("ERROR") + return False + if result.success: logger.info("[A11] Frame %d accepted: %s", i + 1, 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)) successful_frames += 1 frame_ok = True break diff --git a/core/flight/orchestrator.py b/core/flight/orchestrator.py index 823ff00..81daa3e 100755 --- a/core/flight/orchestrator.py +++ b/core/flight/orchestrator.py @@ -11,13 +11,14 @@ import json import logging import math +import shutil import subprocess import sys import time from datetime import datetime, timezone from logging.handlers import RotatingFileHandler from pathlib import Path -from typing import Optional +from typing import Any, Callable, Optional import numpy as np from astropy import units as u @@ -48,6 +49,12 @@ import core.ledger_manager as ledger_manager from core.hardware.live_battery import poll_battery_snapshot +try: + from core.preflight.horizon import required_altitude +except Exception: + def required_altitude(az: float, clearance_margin_deg: float = 0.0) -> float: + return 15.0 + max(0.0, float(clearance_margin_deg)) + LOG_DIR = PROJECT_ROOT / "logs" LOG_DIR.mkdir(parents=True, exist_ok=True) _LOG_SCOPE = selected_scope(load_config(), selected_scope_id()) @@ -80,19 +87,21 @@ MISSION_FILE = DATA_DIR / "tonights_plan.json" FLEET_PLAN_DIR = DATA_DIR / "fleet_plans" COMMAND_FILE = DATA_DIR / "operator_command.json" +CATALOG_DIR = PROJECT_ROOT / "catalogs" -def _safe_load_json(path: Path, default): +def _safe_load_json(path: Path, default: Any) -> Any: if not path.exists(): return default try: - with open(path, "r") as f: + with open(path, "r", encoding="utf-8") as f: return json.load(f) - except Exception: + except Exception as exc: + log.warning("Failed to load JSON from %s: %s", path, exc) return default -def _parse_plan_dt(value): +def _parse_plan_dt(value: Any) -> datetime | None: if not value: return None try: @@ -108,6 +117,7 @@ class PipelineState: IDLE, PREFLIGHT, PLANNING, FLIGHT, WAITING, POSTFLIGHT, ABORTED, PARKED = ( "IDLE", "PREFLIGHT", "PLANNING", "FLIGHT", "WAITING", "POSTFLIGHT", "ABORTED", "PARKED" ) + ALL = {IDLE, PREFLIGHT, PLANNING, FLIGHT, WAITING, POSTFLIGHT, ABORTED, PARKED} class MockDiamondSequence: @@ -215,12 +225,22 @@ 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) - def acquire(self, target: AcquisitionTarget, status_cb=None, telemetry: Optional[TelemetryBlock] = None, skip_pointing=False) -> FrameResult: + def acquire( + self, + target: AcquisitionTarget, + status_cb=None, + telemetry: Optional[TelemetryBlock] = None, + skip_pointing=False, + abort_callback=None, + ) -> FrameResult: def step(tag, msg): log.info(" [%s] SIM %s", tag, msg) if status_cb: status_cb(f"[{tag}] {msg}") + def abort_requested() -> bool: + return bool(abort_callback and abort_callback()) + width, height = 2160, 3840 array = np.random.normal(300.0, 12.0, (height, width)).astype(np.float64) @@ -234,15 +254,23 @@ def step(tag, msg): step("A4", f"Slew command to {target.name}") time.sleep(0.2) + if abort_requested(): + return FrameResult(success=False, error="operator_abort") step("A5", "Slew verify complete") time.sleep(0.2) + if abort_requested(): + return FrameResult(success=False, error="operator_abort") step("A6", "Settle complete") time.sleep(0.2) + if abort_requested(): + return FrameResult(success=False, error="operator_abort") step("A7", "Pointing verify placeholder") time.sleep(0.2) + if abort_requested(): + return FrameResult(success=False, error="operator_abort") ra_deg = target.ra_hours * 15.0 dec_deg = target.dec_deg @@ -287,6 +315,8 @@ def step(tag, msg): class Orchestrator: SUN_LIMIT_DEG = -18.0 LOOP_SLEEP_SEC = 30 + COMMAND_MAX_AGE_SEC = 300 + SUN_CACHE_TTL_SEC = 20.0 def __init__(self): cfg = load_config() @@ -339,6 +369,9 @@ def __init__(self): self._last_command_utc = "" self._battery_park_pct = int(self._cfg.get("power", {}).get("battery_park_pct", VETO_BATTERY)) self._sun_limit_deg = self._configured_sun_limit_deg() + self._sun_cache_alt = 0.0 + self._sun_cache_monotonic = 0.0 + self._prealign_done = False self.simulation_mode = "--simulate" in sys.argv @@ -350,6 +383,46 @@ def __init__(self): self.LOOP_SLEEP_SEC = 0 log.info("🚀 SIMULATION MODE ENGAGED - Hardware checks disabled.") + self._state_handlers: dict[str, Callable[[], None]] = { + PipelineState.IDLE: self._run_idle, + PipelineState.PREFLIGHT: self._run_preflight, + PipelineState.PLANNING: self._run_planning, + PipelineState.FLIGHT: self._run_flight, + PipelineState.WAITING: self._run_flight, + PipelineState.POSTFLIGHT: self._run_postflight, + PipelineState.PARKED: self._run_parked, + PipelineState.ABORTED: self._run_aborted, + } + + def _reload_runtime_config(self) -> None: + """Refresh config.toml-backed runtime settings before night gates.""" + cfg = load_config() + old_host = self._scope_host + self._cfg = cfg + self._fleet_mode = effective_fleet_mode(cfg) + self._scope = selected_scope(cfg, self._scope_id) + self._scope_name = self._scope.get("scope_name") or self._scope.get("name") or self._scope_id or "primary" + self._scope_host = str(self._scope.get("host") or self._scope.get("ip") or SEESTAR_HOST).strip() or SEESTAR_HOST + self._battery_park_pct = int(self._cfg.get("power", {}).get("battery_park_pct", VETO_BATTERY)) + self._sun_limit_deg = self._configured_sun_limit_deg() + + loc = self._cfg.get("location", {}) + self._obs.update({ + "lat": loc.get("lat", self._obs["lat"]), + "lon": loc.get("lon", self._obs["lon"]), + "elevation": loc.get("elevation", self._obs["elevation"]), + }) + self._location = EarthLocation( + lat=self._obs["lat"] * u.deg, + lon=self._obs["lon"] * u.deg, + height=self._obs["elevation"] * u.m, + ) + + if old_host != self._scope_host and not self.simulation_mode: + self._dark_library = DarkLibrary(host=self._scope_host) + self.fsm.sequence = DiamondSequence(host=self._scope_host) + self._log_flight(f"Runtime config reloaded: scope endpoint {old_host} -> {self._scope_host}") + def run(self): log.info( "🔭 Orchestrator starting — SeeVar Federation v2.0.0 (FSM-Governed) | scope=%s | mission=%s", @@ -384,22 +457,11 @@ def _tick(self): if self._enforce_battery_guard(): return - if self._state == PipelineState.IDLE: - self._run_idle() - elif self._state == PipelineState.PREFLIGHT: - self._run_preflight() - elif self._state == PipelineState.PLANNING: - self._run_planning() - elif self._state == PipelineState.FLIGHT: - self._run_flight() - elif self._state == PipelineState.WAITING: - self._run_flight() - elif self._state == PipelineState.POSTFLIGHT: - self._run_postflight() - elif self._state == PipelineState.PARKED: - self._run_parked() - elif self._state == PipelineState.ABORTED: - self._run_aborted() + handler = self._state_handlers.get(self._state) + if handler is None: + self._transition(PipelineState.ABORTED, msg=f"Invalid orchestrator state: {self._state}") + return + handler() def _check_weather_veto(self) -> tuple[bool, str]: hard_abort = {"RAIN", "FOGGY", "WINDY", "THUNDER"} @@ -414,15 +476,26 @@ def _check_weather_veto(self) -> tuple[bool, str]: status = w.get("status", "UNKNOWN") 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")) if age_s > 21600: log.warning("Weather data is %.0fh old — proceeding with caution", age_s / 3600) + if safe_to_open is False: + reason = ( + f"Weather veto: {status} {icon} — " + f"{w.get('current_reason') or 'conditions outside configured limits'} " + f"KNMI oktas:{w.get('knmi_oktas','?')} " + f"clouds:{w.get('clouds_pct','?')}% " + f"window:{w.get('imaging_window_start','none')}→{w.get('imaging_window_end','none')}" + ) + return False, reason + if status in hard_abort: reason = ( f"Weather veto: {status} {icon} — " f"KNMI oktas:{w.get('knmi_oktas','?')} " - f"CO_low:{w.get('low_cloud','?')}% " + f"clouds:{w.get('clouds_pct','?')}% " f"window:{w.get('dark_start','?')}→{w.get('dark_end','?')}" ) return False, reason @@ -435,6 +508,7 @@ def _check_weather_veto(self) -> tuple[bool, str]: return True, "WEATHER_CHECK_ERROR" def _run_idle(self): + self._reload_runtime_config() sun_alt = self._sun_altitude() msg = f"Sun at {sun_alt:.1f}°. Waiting for night (<{self._sun_limit_deg}°)." self._write_state(sub="Standing by", msg=msg) @@ -458,8 +532,9 @@ def _run_preflight(self): payload = _safe_load_json(self._mission_file, {}) if not payload or self._plan_is_stale(payload, now_utc): why = "missing" if not payload else "stale" - self._log_flight(f"♻️ Nightly plan {why} before hardware init — refreshing") - self._refresh_mission_plan() + self._log_flight(f"🛑 Nightly plan {why} before hardware init — planner timer must refresh it") + self._transition(PipelineState.ABORTED, msg=f"Nightly plan {why}; run seevar-planner.service") + return if not self.simulation_mode: self._log_flight("[A2] Safety gate — securing zero-state") @@ -484,8 +559,101 @@ def _run_preflight(self): self._transition(PipelineState.ABORTED, msg=f"Preflight veto: {reason}") return + if not self.simulation_mode and not self._run_prealign_if_configured(): + return + self._transition(PipelineState.PLANNING, msg="Preflight complete.") + 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)): + return True + if self._prealign_done: + return True + + points = int(flight_cfg.get("prealign_points", 3)) + exposure_sec = float(flight_cfg.get("prealign_exposure_sec", 5.0)) + timeout_sec = int(flight_cfg.get("prealign_timeout_sec", 600)) + required = bool(flight_cfg.get("prealign_required", True)) + allow_partial = bool(flight_cfg.get("prealign_allow_partial", False)) + wide_fallback = bool(flight_cfg.get("prealign_wide_fallback", True)) + + cmd = [ + sys.executable, + str(PROJECT_ROOT / "dev/tools/telescope/prealign_pointing.py"), + "--points", + str(points), + "--exposure-sec", + str(exposure_sec), + "--min-alt", + str(float(flight_cfg.get("prealign_min_alt", 35.0))), + "--max-alt", + str(float(flight_cfg.get("prealign_max_alt", 82.0))), + "--solve-radius-deg", + str(float(flight_cfg.get("prealign_solve_radius_deg", 20.0))), + "--solve-timeout-sec", + str(int(flight_cfg.get("prealign_solve_timeout_sec", 90))), + "--solve-downsample", + str(int(flight_cfg.get("prealign_solve_downsample", 2))), + "--wide-exposure-sec", + str(float(flight_cfg.get("prealign_wide_exposure_sec", exposure_sec))), + "--wide-gain", + str(int(flight_cfg.get("prealign_wide_gain", 0))), + "--wide-solve-radius-deg", + str(float(flight_cfg.get("prealign_wide_solve_radius_deg", 60.0))), + "--ip", + self._scope_host, + "--state-file", + str(self._state_file), + ] + if allow_partial: + cmd.append("--allow-partial") + if not wide_fallback: + cmd.append("--no-wide-fallback") + + wide_text = "wide fallback on" if wide_fallback else "wide fallback off" + self._log_flight(f"[A3] Pre-align start — {points} point(s), {exposure_sec:.1f}s, {wide_text}") + try: + result = subprocess.run( + cmd, + cwd=str(PROJECT_ROOT), + text=True, + capture_output=True, + timeout=timeout_sec, + check=False, + ) + except subprocess.TimeoutExpired: + msg = f"Pre-align timeout after {timeout_sec}s" + self._log_flight(f"[A3] ⚠️ {msg}") + if required: + self._transition( + PipelineState.ABORTED, + sub="ALIGNMENT FAILED", + msg=f"{msg}; science run blocked.", + ) + return False + return True + + output = "\n".join(part.strip() for part in (result.stdout, result.stderr) if part.strip()) + for line in output.splitlines()[-8:]: + self._log_flight(f"[A3] prealign: {line}") + + if result.returncode != 0: + msg = f"Pre-align failed rc={result.returncode}" + self._log_flight(f"[A3] ⚠️ {msg}") + if required: + self._transition( + PipelineState.ABORTED, + sub="ALIGNMENT FAILED", + msg=f"{msg}; science run blocked.", + ) + return False + return True + + self._prealign_done = True + self._log_flight("[A3] ✅ Pre-align model ready") + return True + def _run_planning(self): def _order_and_filter(mission, now_utc): if any("recommended_order" in t for t in mission): @@ -522,14 +690,12 @@ def _order_and_filter(mission, now_utc): self._log_flight("📋 Loading mission targets...") now_utc = datetime.now(timezone.utc) payload = _safe_load_json(self._mission_file, {}) - refreshed = False if not payload or self._plan_is_stale(payload, now_utc): why = "missing" if not payload else "stale" - self._log_flight(f"♻️ Nightly plan {why} — attempting refresh") - refreshed = self._refresh_mission_plan() - if refreshed: - payload = _safe_load_json(self._mission_file, {}) + self._log_flight(f"🛑 Nightly plan {why} — refusing in-session refresh") + self._transition(PipelineState.ABORTED, msg=f"Nightly plan {why}; run seevar-planner.service") + return mission = self._extract_targets(payload) if not mission: @@ -548,14 +714,6 @@ def _order_and_filter(mission, now_utc): self._log_flight(f"✂️ Mission cap active — limiting tonight to first {max_targets} target(s)") final = final[:max_targets] - if not final and not refreshed: - self._log_flight("♻️ All current target windows expired — refreshing nightly plan once") - if self._refresh_mission_plan(): - payload = _safe_load_json(self._mission_file, {}) - mission = self._extract_targets(payload) - now_utc = datetime.now(timezone.utc) - final, expired = _order_and_filter(mission, now_utc) - if not final: reason = "All planned target windows have expired." if expired else "No executable mission targets." self._transition(PipelineState.PARKED, msg=reason) @@ -672,7 +830,15 @@ def _run_flight(self): self._log_flight(f"Executing target via FSM: {name} RA={acq_target.ra_hours:.2f}h") ledger_manager.record_attempt(name) - success = self.fsm.execute_target(acq_target, telemetry=self._last_telemetry) + success = self.fsm.execute_target( + acq_target, + telemetry=self._last_telemetry, + abort_cb=self._operator_abort_pending, + ) + + if self._operator_abort_pending(): + self._handle_operator_command() + return if self.fsm.telemetry: self._last_telemetry = self.fsm.telemetry @@ -735,6 +901,161 @@ def _collect_buffer_dark_sequences(self) -> set[tuple[int, int]]: return sequences + 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", []) + if isinstance(value, str): + value = [part.strip() for part in value.split(",")] + if not isinstance(value, list): + return [] + return [str(item).strip().lower() for item in value if str(item).strip()] + + 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 {} + configured = str(planner_cfg.get("secondary_output_dir") or "").strip() + if configured: + return Path(configured).expanduser() + primary = Path(str(storage_cfg.get("primary_dir") or DATA_DIR / "archive")).expanduser() + return primary / "secondary_catalogs" + + 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) + default_duration = int(planner_cfg.get("secondary_duration_sec", 900) or 900) + targets: list[dict] = [] + + for catalog in self._enabled_secondary_catalogs(): + path = CATALOG_DIR / f"{catalog}.json" + if not path.exists(): + self._log_flight(f"🌌 Secondary catalog missing: {path.name}") + continue + try: + payload = json.loads(path.read_text(encoding="utf-8")) + rows = payload.get("targets", []) if isinstance(payload, dict) else payload + except Exception as e: + self._log_flight(f"🌌 Secondary catalog skipped {catalog}: {e}") + continue + + for row in rows: + if not isinstance(row, dict): + continue + item = dict(row) + item["catalog"] = catalog + item["secondary_target"] = True + item.setdefault("duration", default_duration) + targets.append(item) + if max_targets > 0 and len(targets) >= max_targets: + return targets + + return targets + + def _target_altaz_deg(self, ra_deg: float, dec_deg: float) -> tuple[float, float] | None: + try: + now = Time.now() + coord = SkyCoord(ra=ra_deg * u.deg, dec=dec_deg * u.deg, frame="icrs") + altaz = coord.transform_to(AltAz(obstime=now, location=self._location)) + return float(altaz.alt.deg), float(altaz.az.deg) + except Exception: + return None + + 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) + altaz = self._target_altaz_deg(ra_deg, dec_deg) + if not altaz: + return False + alt_deg, az_deg = altaz + return alt_deg >= required_altitude(az_deg, clearance_margin_deg=5.0) + 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("/", "-") + safe_name = str(name or "UNKNOWN").replace("/", "-").replace(" ", "_") + dest = self._secondary_output_dir() / safe_catalog / safe_name + dest.mkdir(parents=True, exist_ok=True) + copied = 0 + for src in paths: + try: + if not src.exists(): + continue + shutil.move(str(src), str(dest / src.name)) + copied += 1 + except Exception as e: + self._log_flight(f"🌌 Secondary frame custody failed for {src.name}: {e}") + return copied + + 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)): + return + + targets = self._load_secondary_imaging_targets() + if not targets: + return + + self._log_flight(f"🌌 Secondary imaging queue ready: {len(targets)} target(s)") + for target in targets: + if self._operator_abort_pending(): + self._handle_operator_command() + return + if self._sun_altitude() >= self._sun_limit_deg: + self._log_flight("🌌 Secondary imaging stopped: daylight limit reached") + return + go, reason = self._check_weather_veto() + if not go: + self._log_flight(f"🌌 Secondary imaging stopped: {reason}") + return + if self._enforce_battery_guard(): + return + if not self._secondary_target_visible(target): + continue + + 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) + 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)))) + except Exception as e: + self._log_flight(f"🌌 Secondary target skipped {name}: {e}") + continue + + acq_target = AcquisitionTarget( + name=name, + ra_hours=ra_deg / 15.0, + dec_deg=dec_deg, + exp_ms=exp_ms, + observer_code=self._obs["observer_id"], + n_frames=n_frames, + integration_sec=duration, + ) + self._current_target = {"name": name, "ra": round(ra_deg, 4), "dec": round(dec_deg, 4), "type": catalog} + 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 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. def _run_postflight(self): self._log_flight("📊 Flight operations concluded.") @@ -815,12 +1136,77 @@ def _run_postflight(self): else: self._log_flight(" [simulation] accountant skipped") + if not self.simulation_mode: + try: + from core.postflight.report_pipeline import run_postflight_report_pipeline + + report_result = run_postflight_report_pipeline() + outputs = report_result.get("outputs") or [] + mirrored = report_result.get("mirrored") or [] + if report_result.get("staged"): + self._log_flight(f"📨 Reports staged: {len(outputs)} file(s), mirrored={len(mirrored)}") + else: + self._log_flight(f"📨 Reports not staged: {report_result.get('skipped', 'already handled')}") + + submit = report_result.get("aavso_submit") or {} + if submit.get("accepted"): + self._log_flight(f"📨 AAVSO auto-submit accepted: {Path(report_result.get('aavso_report', '')).name}") + elif submit.get("skipped"): + self._log_flight(f"📨 AAVSO auto-submit skipped: {submit.get('skipped')}") + elif submit: + reason = submit.get("error") or submit.get("error_lines") or "not accepted" + self._log_flight(f"⚠️ AAVSO auto-submit not accepted: {reason}") + except Exception as e: + self._log_flight(f"⚠️ Report staging/submission error: {e}") + else: + self._log_flight(" [simulation] report staging/submission skipped") + + if not self.simulation_mode: + self._run_secondary_imaging() + else: + self._log_flight(" [simulation] secondary imaging skipped") + + postflight_cfg = self._cfg.get("postflight", {}) if isinstance(self._cfg, dict) else {} + auto_park = bool(postflight_cfg.get("auto_park", True)) + auto_shutdown = bool(postflight_cfg.get("auto_shutdown_scope", False)) + + hardware_park_msg = "Hardware park not requested by postflight." + if not self.simulation_mode and auto_park: + try: + self._log_flight("🅿️ Postflight requesting telescope park.") + self._call_with_retries("postflight park request", self.fsm.sequence.park) + deadline = time.monotonic() + 30.0 + while time.monotonic() < deadline: + try: + if self.fsm.sequence.at_park(): + break + except Exception: + break + time.sleep(2.0) + hardware_park_msg = "Hardware park requested after postflight." + self._log_flight("🅿️ Postflight telescope park requested.") + except Exception as e: + hardware_park_msg = f"Hardware park request failed: {e}" + self._log_flight(f"⚠️ Postflight park request failed: {e}") + elif not self.simulation_mode: + self._log_flight("🅿️ Postflight telescope park skipped by config.") + + if not self.simulation_mode and auto_shutdown: + try: + self._log_flight("🔌 Postflight requesting Seestar shutdown.") + self._call_with_retries("postflight shutdown request", self.fsm.sequence.shutdown_scope) + hardware_park_msg += " Scope shutdown requested." + self._log_flight("🔌 Postflight Seestar shutdown requested.") + except Exception as e: + hardware_park_msg += f" Scope shutdown request failed: {e}" + self._log_flight(f"⚠️ Postflight shutdown request failed: {e}") + if dark_ok > 0 and dark_fail == 0: - final_msg = "Mission complete. Hardware park not confirmed by this state alone." + final_msg = f"Mission complete. {hardware_park_msg}" elif dark_fail == 0 and dark_frames == 0: - final_msg = "Mission complete. No usable darks captured. Hardware park not confirmed by this state alone." + final_msg = f"Mission complete. No usable darks captured. {hardware_park_msg}" else: - final_msg = f"Mission complete with partial dark failures ({dark_fail}). Hardware park not confirmed by this state alone." + final_msg = f"Mission complete with partial dark failures ({dark_fail}). {hardware_park_msg}" self._transition(PipelineState.PARKED, msg=final_msg) def _run_parked(self): @@ -881,6 +1267,24 @@ def _read_operator_command(self) -> dict: payload = {} return payload if isinstance(payload, dict) else {} + def _operator_abort_pending(self) -> bool: + payload = self._read_operator_command() + command = str(payload.get("command", "")).strip().lower() + if command != "abort": + return False + + requested_utc = str(payload.get("requested_utc", "")).strip() + if requested_utc and requested_utc == self._last_command_utc: + return False + + requested_dt = _parse_plan_dt(requested_utc) if requested_utc else None + if requested_dt is not None: + age_s = (datetime.now(timezone.utc) - requested_dt).total_seconds() + if age_s > self.COMMAND_MAX_AGE_SEC: + return False + + return True + def _handle_operator_command(self) -> bool: payload = self._read_operator_command() command = str(payload.get("command", "")).strip().lower() @@ -893,7 +1297,7 @@ def _handle_operator_command(self) -> bool: return False if requested_dt is not None: age_s = (datetime.now(timezone.utc) - requested_dt).total_seconds() - if age_s > 300: + if age_s > self.COMMAND_MAX_AGE_SEC: self._last_command_utc = requested_utc return False if requested_utc: @@ -904,11 +1308,11 @@ def _handle_operator_command(self) -> bool: self._targets = [] self._current_target = None try: - self.fsm.sequence.park() + self._call_with_retries("abort park request", self.fsm.sequence.park) self._log_flight("🛑 Abort requested telescope park.") except Exception as e: self._log_flight(f"⚠️ Abort park request failed: {e}") - self._transition(PipelineState.ABORTED, msg="Operator abort requested.") + self._transition(PipelineState.ABORTED, sub="OPERATOR ABORT", msg="Operator abort requested.") return True if command == "reset": @@ -923,10 +1327,16 @@ def _handle_operator_command(self) -> bool: return False def _sun_altitude(self) -> float: + now_mono = time.monotonic() + if now_mono - self._sun_cache_monotonic <= self.SUN_CACHE_TTL_SEC: + return self._sun_cache_alt try: now = Time.now() sun = get_body("sun", now) - return float(sun.transform_to(AltAz(obstime=now, location=self._location)).alt.deg) + alt = float(sun.transform_to(AltAz(obstime=now, location=self._location)).alt.deg) + self._sun_cache_alt = alt + self._sun_cache_monotonic = now_mono + return alt except Exception: return 0.0 @@ -934,6 +1344,27 @@ def _load_mission_targets(self) -> list: data = _safe_load_json(self._mission_file, []) return data if isinstance(data, list) else data.get("targets", []) + def _call_with_retries( + self, + label: str, + action: Callable[[], Any], + *, + attempts: int = 2, + delay_sec: float = 2.0, + ) -> Any: + last_error: Exception | None = None + for attempt in range(1, max(1, attempts) + 1): + try: + return action() + except Exception as exc: + last_error = exc + self._log_flight(f"⚠️ {label} failed attempt {attempt}/{attempts}: {exc}") + if attempt < attempts and not self.simulation_mode: + time.sleep(delay_sec) + if last_error is not None: + raise last_error + return None + def _refresh_mission_plan(self) -> bool: planner = PROJECT_ROOT / "core/preflight/nightly_planner.py" compiler = PROJECT_ROOT / "core/preflight/schedule_compiler.py" @@ -989,7 +1420,7 @@ def _enforce_battery_guard(self) -> bool: charger_status = snapshot.get("charger_status") or ("Charging" if snapshot.get("charge_online") else "Discharging") self._log_flight(f"🔋 Battery guard triggered at {battery_pct}% ({charger_status})") try: - self.fsm.sequence.park() + self._call_with_retries("battery guard park request", self.fsm.sequence.park) self._log_flight("🔋 Battery guard requested telescope park") except Exception as e: self._log_flight(f"⚠️ Battery guard park request failed: {e}") @@ -1050,7 +1481,11 @@ def _write_plan(self, targets: list): }, "targets": targets, } - self._plan_file.write_text(json.dumps(payload, indent=2)) + self._write_json(self._plan_file, payload) + + 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") def _write_state(self, state=None, sub="", msg=""): now_utc = datetime.now(timezone.utc).isoformat() @@ -1073,9 +1508,11 @@ def _write_state(self, state=None, sub="", msg=""): "remaining_count": remaining, "planned_count": planned, }) - self._state_file.write_text(json.dumps(payload, indent=2)) + self._write_json(self._state_file, payload) - def _transition(self, new_state, sub="", msg=""): + 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}") self._state = new_state self._write_state(state=new_state, sub=sub, msg=msg) log.info("STATE -> %s | %s", new_state, msg) diff --git a/core/flight/pilot.py b/core/flight/pilot.py index 9bdd6eb..cddbed5 100755 --- a/core/flight/pilot.py +++ b/core/flight/pilot.py @@ -23,12 +23,13 @@ import json import logging import math +import socket import subprocess import time from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Optional +from typing import Any, Optional import numpy as np import requests @@ -46,26 +47,37 @@ # Dynamic IP Resolution # --------------------------------------------------------------------------- +_CONFIG_CACHE: dict[str, Any] | None = None + + +def _config() -> dict[str, Any]: + global _CONFIG_CACHE + if _CONFIG_CACHE is None: + cfg = load_config() + _CONFIG_CACHE = cfg if isinstance(cfg, dict) else {} + return _CONFIG_CACHE + + def _resolve_seestar_host() -> tuple[str, str]: - return selected_scope_host(load_config()) + return selected_scope_host(_config()) def _flight_cfg() -> dict: - cfg = load_config() + cfg = _config() return cfg.get("flight", {}) if isinstance(cfg, dict) else {} def _cfg_float(key: str, default: float) -> float: try: return float(_flight_cfg().get(key, default)) - except Exception: + except (TypeError, ValueError): return default def _cfg_int(key: str, default: int) -> int: try: return int(round(float(_flight_cfg().get(key, default)))) - except Exception: + except (TypeError, ValueError): return default @@ -109,7 +121,8 @@ def _verify_root_name(path: Path) -> str | None: # --------------------------------------------------------------------------- SEESTAR_HOST, SEESTAR_HOST_SOURCE = _resolve_seestar_host() -ALPACA_PORT = 32323 +SEESTAR_RPC_PORT = 4700 +ALPACA_PORT = max(1, _cfg_int("alpaca_port", 32323)) TELESCOPE_NUM = 0 CAMERA_NUM = 0 FILTERWHEEL_NUM = 0 @@ -122,7 +135,7 @@ def _verify_root_name(path: Path) -> str | None: TELESCOPE = "ZWO Seestar S30-Pro" FILTER_NAME = "TG" -GAIN = 80 +GAIN = max(0, _cfg_int("gain", 80)) FOCALLEN = 160 APERTURE = 30 PIXSCALE = 3.74 @@ -131,7 +144,7 @@ def _verify_root_name(path: Path) -> str | None: PEDESTAL = 0 SWCREATE = "SeeVar v3.1.0 (Alpaca)" -SETTLE_SECONDS = 8 +SETTLE_SECONDS = max(0, _cfg_int("settle_seconds", 8)) SLEW_TIMEOUT = 60 EXPOSE_TIMEOUT = 120 DOWNLOAD_TIMEOUT = 300 @@ -140,7 +153,7 @@ def _verify_root_name(path: Path) -> str | None: VETO_BATTERY = 10 VETO_TEMP = 55.0 -CLIENT_ID = 42 +CLIENT_ID = max(1, _cfg_int("alpaca_client_id", 42)) VERIFY_EXPOSURE_SEC = max(0.5, _cfg_float("pointing_verify_exposure_sec", 2.0)) VERIFY_EXPOSURE_RETRY_SEC = 2.0 @@ -163,7 +176,7 @@ def _verify_root_name(path: Path) -> str | None: LOCAL_BUFFER = DATA_DIR / "local_buffer" VERIFY_BUFFER = DATA_DIR / "verify_buffer" -ACTIVE_SCOPE = selected_scope(load_config()) +ACTIVE_SCOPE = selected_scope(_config()) ACTIVE_SCOPE_TAG = scope_file_tag(ACTIVE_SCOPE) logger = logging.getLogger("seevar.pilot") @@ -346,8 +359,14 @@ def _get(self, prop: str, timeout: float = 10.0): "ClientID": CLIENT_ID, "ClientTransactionID": self._next_tx(), } - r = requests.get(f"{self.base}/{prop}", params=params, timeout=timeout) - data = r.json() + try: + r = requests.get(f"{self.base}/{prop}", params=params, timeout=timeout) + r.raise_for_status() + data = r.json() + except requests.RequestException as e: + raise RuntimeError(f"Alpaca GET {prop}: request failed: {e}") from e + except ValueError as e: + raise RuntimeError(f"Alpaca GET {prop}: invalid JSON response") from e err = data.get("ErrorNumber", 0) if err: raise RuntimeError(f"Alpaca GET {prop}: error {err} — {data.get('ErrorMessage', '')}") @@ -359,26 +378,32 @@ def _put(self, method: str, timeout: float = 15.0, **kwargs): "ClientTransactionID": self._next_tx(), } payload.update(kwargs) - r = requests.put(f"{self.base}/{method}", data=payload, timeout=timeout) - data = r.json() + try: + r = requests.put(f"{self.base}/{method}", data=payload, timeout=timeout) + r.raise_for_status() + data = r.json() + except requests.RequestException as e: + raise RuntimeError(f"Alpaca PUT {method}: request failed: {e}") from e + except ValueError as e: + raise RuntimeError(f"Alpaca PUT {method}: invalid JSON response") from e err = data.get("ErrorNumber", 0) if err: raise RuntimeError(f"Alpaca PUT {method}: error {err} — {data.get('ErrorMessage', '')}") return data.get("Value") - def safe_get(self, prop: str, default=None): + def safe_get(self, prop: str, default: Any = None) -> Any: try: return self._get(prop) - except Exception: + except RuntimeError: return default - def connect(self): + def connect(self) -> None: self._put("connected", Connected="true") - def disconnect(self): + def disconnect(self) -> None: try: self._put("connected", Connected="false") - except Exception: + except RuntimeError: pass @property @@ -388,7 +413,7 @@ def connected(self) -> bool: class AlpacaTelescope(AlpacaClient): def __init__(self, ip: str | None = None, port: int = ALPACA_PORT, device_number: int = TELESCOPE_NUM): - host, _ = selected_scope_host(load_config()) if not ip else (ip, "explicit argument") + host, _ = selected_scope_host(_config()) if not ip else (ip, "explicit argument") super().__init__(host, port, "telescope", device_number) def unpark(self): @@ -408,9 +433,18 @@ def slew_to_coordinates_async(self, ra_hours: float, dec_deg: float): timeout=20.0, ) - def wait_for_slew(self, timeout: float = SLEW_TIMEOUT) -> bool: + def abort_slew(self): + self._put("abortslew") + + def wait_for_slew(self, timeout: float = SLEW_TIMEOUT, abort_callback=None) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: + if abort_callback and abort_callback(): + try: + self.abort_slew() + except Exception as e: + logger.warning("Abort slew request failed: %s", e) + return False if not self._get("slewing"): return True time.sleep(1.0) @@ -464,7 +498,7 @@ class AlpacaCamera(AlpacaClient): } def __init__(self, ip: str | None = None, port: int = ALPACA_PORT, device_number: int = CAMERA_NUM): - host, _ = selected_scope_host(load_config()) if not ip else (ip, "explicit argument") + host, _ = selected_scope_host(_config()) if not ip else (ip, "explicit argument") super().__init__(host, port, "camera", device_number) def set_gain(self, gain: int): @@ -476,9 +510,15 @@ def start_exposure(self, duration_sec: float, light: bool = True): def abort_exposure(self): self._put("abortexposure") - def wait_for_image(self, exposure_sec: float, timeout: float = EXPOSE_TIMEOUT) -> bool: + def wait_for_image(self, exposure_sec: float, timeout: float = EXPOSE_TIMEOUT, abort_callback=None) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: + if abort_callback and abort_callback(): + try: + self.abort_exposure() + except Exception as e: + logger.warning("Abort exposure request failed: %s", e) + return False try: if self._get("imageready"): return True @@ -492,8 +532,14 @@ def download_image(self) -> np.ndarray: "ClientID": CLIENT_ID, "ClientTransactionID": self._next_tx(), } - r = requests.get(f"{self.base}/imagearray", params=params, timeout=DOWNLOAD_TIMEOUT) - data = r.json() + try: + r = requests.get(f"{self.base}/imagearray", params=params, timeout=DOWNLOAD_TIMEOUT) + r.raise_for_status() + data = r.json() + except requests.RequestException as e: + raise RuntimeError(f"imagearray: request failed: {e}") from e + except ValueError as e: + raise RuntimeError("imagearray: invalid JSON response") from e err = data.get("ErrorNumber", 0) if err: raise RuntimeError(f"imagearray: error {err} — {data.get('ErrorMessage', '')}") @@ -529,13 +575,43 @@ def sensor_height(self) -> int: return self._get("cameraysize") +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: + payload["params"] = params + + wire = (json.dumps(payload) + "\r\n").encode("utf-8") + chunks: list[bytes] = [] + with socket.create_connection((host, port), timeout=timeout) as sock: + sock.settimeout(timeout) + sock.sendall(wire) + while True: + try: + block = sock.recv(65536) + except socket.timeout: + break + if not block: + break + chunks.append(block) + if b"\r\n" in block or b"\n" in block: + break + + if not chunks: + return {"result": None} + + data = json.loads(b"".join(chunks).splitlines()[0].decode("utf-8")) + if "error" in data: + raise RuntimeError(f"{method}: {data['error']}") + return data + + class AlpacaFilterWheel(AlpacaClient): DARK = 0 IR = 1 LP = 2 def __init__(self, ip: str | None = None, port: int = ALPACA_PORT, device_number: int = FILTERWHEEL_NUM): - host, _ = selected_scope_host(load_config()) if not ip else (ip, "explicit argument") + host, _ = selected_scope_host(_config()) if not ip else (ip, "explicit argument") super().__init__(host, port, "filterwheel", device_number) def set_position(self, pos: int): @@ -557,7 +633,7 @@ def _read_gps_ram() -> dict: lon = float(data.get("lon", 0.0)) elev = float(data.get("elevation", 0.0)) return {"lat": lat, "lon": lon, "elevation": elev} - except Exception: + except (OSError, TypeError, ValueError, json.JSONDecodeError): return {"lat": 0.0, "lon": 0.0, "elevation": 0.0} @@ -719,7 +795,7 @@ class DiamondSequence: """ def __init__(self, host: str | None = None, port: int = ALPACA_PORT): - resolved_host, resolved_source = selected_scope_host(load_config()) if not host else (host, "explicit argument") + resolved_host, resolved_source = selected_scope_host(_config()) if not host else (host, "explicit argument") self.host = resolved_host self.port = port self.host_source = resolved_source @@ -734,10 +810,9 @@ def __init__(self, host: str | None = None, port: int = ALPACA_PORT): def _management_status(self) -> tuple[bool, str]: try: r = requests.get(f"http://{self.host}:{self.port}/management/apiversions", timeout=5) - if r.status_code != 200: - return False, f"Alpaca management returned HTTP {r.status_code}" + r.raise_for_status() return True, "" - except Exception as e: + except requests.RequestException as e: return False, f"Alpaca management unreachable: {e}" def _is_reachable(self) -> bool: @@ -747,7 +822,7 @@ def _is_reachable(self) -> bool: def _require_connected(self, client: AlpacaClient, label: str): try: connected = bool(client.connected) - except Exception as e: + except RuntimeError as e: raise RuntimeError(f"{label} connection probe failed: {e}") if not connected: raise RuntimeError(f"{label} reports connected=false after connect()") @@ -770,7 +845,7 @@ def _session_health(self) -> tuple[bool, str]: self._telescope._get("rightascension") self._telescope._get("declination") return True, "" - except Exception as e: + except RuntimeError as e: return False, f"Alpaca backend reachable but telescope not operational: {e}" def _read_operational_telemetry(self, level_ok: bool) -> TelemetryBlock: @@ -786,17 +861,17 @@ def _site_latitude_deg(self) -> float | None: if lat not in (None, 0.0): return float(lat) try: - cfg = load_config() + cfg = _config() return float(cfg.get("location", {}).get("lat")) - except Exception: + except (TypeError, ValueError): return None def _mount_mode(self) -> str: try: - scope = selected_scope(load_config()) + scope = selected_scope(_config()) if scope: return str(scope.get("mount", "altaz")).strip().lower() - except Exception: + except (TypeError, ValueError): pass return "altaz" @@ -1129,7 +1204,14 @@ def init_session(self, level_ok: bool = True) -> TelemetryBlock: t.level_ok = level_ok return t - def acquire(self, target: AcquisitionTarget, status_cb=None, telemetry: Optional[TelemetryBlock] = None, skip_pointing: bool = False) -> FrameResult: + def acquire( + self, + target: AcquisitionTarget, + status_cb=None, + telemetry: Optional[TelemetryBlock] = None, + skip_pointing: bool = False, + abort_callback=None, + ) -> FrameResult: """Execute A4-A11 for one target, or science-only when pointing is already established.""" def notify(step, msg): @@ -1137,6 +1219,13 @@ def notify(step, msg): status_cb(f"[{step}] {msg}") logger.info("[%s] %s", step, msg) + def abort_requested() -> bool: + try: + return bool(abort_callback and abort_callback()) + except Exception as e: + logger.warning("Abort callback failed: %s", e) + return False + t_start = time.monotonic() ccd_temp = telemetry.temp_c if telemetry else None science_ra_hours = float(target.ra_hours) @@ -1146,6 +1235,9 @@ def notify(step, msg): exp_sec = target.exp_ms / 1000.0 try: + if abort_requested(): + return FrameResult(success=False, error="operator_abort") + if not skip_pointing: pointing_model = None if POINTING_MODEL_ENABLED: @@ -1176,15 +1268,27 @@ def notify(step, msg): ) for attempt in range(POINTING_MAX_RETRIES + 1): + if abort_requested(): + return FrameResult(success=False, error="operator_abort") + notify("A4", f"Slew command RA={command_ra_hours:.4f}h DEC={command_dec_deg:.4f}° ({target.name})") self._telescope.slew_to_coordinates_async(command_ra_hours, command_dec_deg) notify("A5", f"Waiting for slew completion (timeout={SLEW_TIMEOUT}s)") - if not self._telescope.wait_for_slew(SLEW_TIMEOUT): + if not self._telescope.wait_for_slew(SLEW_TIMEOUT, abort_callback=abort_requested): + if abort_requested(): + return FrameResult(success=False, error="operator_abort") return FrameResult(success=False, error=f"Slew timeout ({SLEW_TIMEOUT}s)") + if abort_requested(): + return FrameResult(success=False, error="operator_abort") + notify("A6", f"Settling {SETTLE_SECONDS}s after slew") - time.sleep(SETTLE_SECONDS) + settle_deadline = time.monotonic() + SETTLE_SECONDS + while time.monotonic() < settle_deadline: + if abort_requested(): + return FrameResult(success=False, error="operator_abort") + time.sleep(min(0.5, max(0.0, settle_deadline - time.monotonic()))) verify_target = AcquisitionTarget( name=target.name, @@ -1198,6 +1302,8 @@ def notify(step, msg): ) solve = self._pointing_verify(verify_target, notify, ccd_temp=ccd_temp) + if abort_requested(): + return FrameResult(success=False, error="operator_abort") if not solve.get("ok"): notify("A7", f"Pointing verify failed: {solve.get('error', 'unknown error')}") @@ -1277,6 +1383,8 @@ def notify(step, msg): logger.warning("Tracking verification before science exposure failed: %s", e) 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: @@ -1286,9 +1394,14 @@ def notify(step, msg): notify("A10", "Waiting for science exposure + readout") image_timeout = exp_sec + EXPOSE_TIMEOUT - if not self._camera.wait_for_image(exp_sec, timeout=image_timeout): + if not self._camera.wait_for_image(exp_sec, timeout=image_timeout, abort_callback=abort_requested): + if abort_requested(): + return FrameResult(success=False, error="operator_abort") return FrameResult(success=False, error=f"Image not ready after {image_timeout}s") + if abort_requested(): + return FrameResult(success=False, error="operator_abort") + try: ccd_temp = self._camera.temperature except Exception: @@ -1345,6 +1458,16 @@ def park(self): except Exception as e: logger.warning("Park failed: %s", e) + def at_park(self) -> bool: + return bool(self._telescope.at_park) + + def shutdown_scope(self): + try: + self._telescope.set_tracking(False) + except Exception as e: + logger.warning("Tracking stop before shutdown failed: %s", e) + return _seestar_rpc_call(self.host, "pi_shutdown") + def disconnect_all(self): self._camera.disconnect() self._filter.disconnect() diff --git a/core/flight/pointing_model.py b/core/flight/pointing_model.py index 896e2be..0bf5293 100644 --- a/core/flight/pointing_model.py +++ b/core/flight/pointing_model.py @@ -2,38 +2,85 @@ # -*- coding: utf-8 -*- """ Filename: core/flight/pointing_model.py -Version: 1.1.0 +Version: 1.2.0 Objective: Store and apply short-lived pointing corrections measured from solved pre-alignment fields. + +Coordinate convention +--------------------- +RA is stored and returned in decimal hours [0, 24). +Dec is stored and returned in decimal degrees [-90, 90]. +Internal degree arithmetic uses the half-open interval (-180, 180] via normalize_deg(). + +Model kinds +----------- +constant_prealignment - single (RA, Dec) offset derived from 1-2 solved fields. +affine_prealignment - 3x2 affine map from solved-sky to target-sky derived from >=3 fields. + The affine model is trained as solved -> target. At apply-time the desired target RA/Dec + is used as a first-order proxy for the solved position; this approximation is valid when + residuals are small, typically < 30 arcmin for pre-alignment fields. """ +from __future__ import annotations + import json +import logging +import re import statistics from datetime import datetime, timedelta, timezone from pathlib import Path +from typing import Sequence import numpy as np from core.utils.env_loader import DATA_DIR +__all__ = [ + "model_path", + "normalize_ra_hours", + "normalize_deg", + "circular_median_deg", + "apply_pointing_model", + "build_constant_model", + "build_affine_model", + "build_pointing_model", + "save_pointing_model", + "load_pointing_model", +] + +log = logging.getLogger(__name__) + +# Only allow simple alphanumeric + hyphen/underscore scope tags to prevent +# path traversal, e.g. a tag of "../etc/passwd" escaping DATA_DIR. +_SAFE_TAG_RE = re.compile(r"[^\w\-]") + -# Return the per-scope runtime JSON file used by the pilot. def model_path(scope_tag: str | None = None) -> Path: - tag = (scope_tag or "scope").strip() or "scope" + """Return the per-scope runtime JSON file used by the pilot.""" + raw = (scope_tag or "scope").strip() or "scope" + tag = _SAFE_TAG_RE.sub("_", raw) + if tag != raw: + log.warning("scope_tag %r contained unsafe characters; sanitised to %r", raw, tag) return DATA_DIR / f"pointing_model.{tag}.json" -# Keep right-ascension deltas in the shortest signed interval. +def _wrap_signed(value: float, half_period: float) -> float: + """Wrap a value into the half-open interval (-half_period, half_period].""" + period = half_period * 2.0 + return ((float(value) + half_period) % period) - half_period + + def normalize_ra_hours(delta_hours: float) -> float: - return ((float(delta_hours) + 12.0) % 24.0) - 12.0 + """Keep a right-ascension delta in the shortest signed interval (-12, 12].""" + return _wrap_signed(delta_hours, 12.0) -# Keep angular deltas in the shortest signed degree interval. def normalize_deg(delta_deg: float) -> float: - return ((float(delta_deg) + 180.0) % 360.0) - 180.0 + """Keep an angular delta in the shortest signed interval (-180, 180].""" + return _wrap_signed(delta_deg, 180.0) -# Compute a stable circular median for nearby right-ascension values. -def circular_median_deg(values: list[float]) -> float: +def circular_median_deg(values: Sequence[float]) -> float: + """Compute a wrap-safe median for a sequence of degree values.""" if not values: return 0.0 ref = float(values[0]) @@ -41,24 +88,65 @@ def circular_median_deg(values: list[float]) -> float: return (ref + statistics.median(deltas)) % 360.0 -# Apply a solved-field correction model to the next commanded slew. -def apply_pointing_model(ra_hours: float, dec_deg: float, model: dict) -> tuple[float, float]: - if model.get("kind") == "affine_prealignment": +def apply_pointing_model( + ra_hours: float, + dec_deg: float, + model: dict, +) -> tuple[float, float]: + """Return corrected command coordinates for a target RA/Dec.""" + kind = model.get("kind") + + if kind == "affine_prealignment": ref_ra_deg = float(model["ref_ra_deg"]) - x = np.array([normalize_deg(float(ra_hours) * 15.0 - ref_ra_deg), float(dec_deg), 1.0], dtype=float) + x = np.array( + [normalize_deg(float(ra_hours) * 15.0 - ref_ra_deg), float(dec_deg), 1.0], + dtype=float, + ) ra_coeff = np.array(model["command_ra_delta_coeff"], dtype=float) dec_coeff = np.array(model["command_dec_coeff"], dtype=float) command_ra_deg = (ref_ra_deg + float(ra_coeff @ x)) % 360.0 command_dec = float(dec_coeff @ x) return command_ra_deg / 15.0, max(-90.0, min(90.0, command_dec)) - corrected_ra = (float(ra_hours) + float(model.get("offset_ra_hours", 0.0))) % 24.0 - corrected_dec = float(dec_deg) + float(model.get("offset_dec_deg", 0.0)) - corrected_dec = max(-90.0, min(90.0, corrected_dec)) - return corrected_ra, corrected_dec + if kind == "constant_prealignment": + corrected_ra = (float(ra_hours) + float(model.get("offset_ra_hours", 0.0))) % 24.0 + corrected_dec = float(dec_deg) + float(model.get("offset_dec_deg", 0.0)) + return corrected_ra, max(-90.0, min(90.0, corrected_dec)) + + raise ValueError( + f"apply_pointing_model: unrecognised model kind {kind!r}. " + "Expected 'affine_prealignment' or 'constant_prealignment'." + ) + + +def _median_error(samples: Sequence[dict]) -> float | None: + """Return the median error in arcmin, if samples include errors.""" + errors = [float(sample["error_arcmin"]) for sample in samples if "error_arcmin" in sample] + return statistics.median(errors) if errors else None + + +def _model_header( + kind: str, + *, + scope_tag: str, + scope_name: str, + max_age_hours: float, + n_samples: int, + now: datetime, +) -> dict: + """Return fields common to every pointing model.""" + return { + "version": 1, + "kind": kind, + "scope_tag": scope_tag, + "scope_name": scope_name, + "created_utc": now.isoformat(), + "expires_utc": (now + timedelta(hours=float(max_age_hours))).isoformat(), + "max_age_hours": float(max_age_hours), + "n_samples": n_samples, + } -# Build a robust constant model from solved calibration samples. def build_constant_model( samples: list[dict], *, @@ -66,33 +154,34 @@ def build_constant_model( scope_name: str = "", max_age_hours: float = 12.0, ) -> dict: + """Build a robust constant-offset model from 1-2 solved calibration samples.""" if not samples: - raise ValueError("Cannot build pointing model without solved samples") + raise ValueError("build_constant_model: at least one solved sample is required") - ra_offsets = [normalize_ra_hours(float(sample["offset_ra_hours"])) for sample in samples] + ra_offsets_deg = [normalize_ra_hours(float(sample["offset_ra_hours"])) * 15.0 for sample in samples] + median_ra_hours = normalize_ra_hours(circular_median_deg(ra_offsets_deg) / 15.0) dec_offsets = [float(sample["offset_dec_deg"]) for sample in samples] - errors = [float(sample.get("error_arcmin", 0.0)) for sample in samples] - now = datetime.now(timezone.utc) + median_dec = statistics.median(dec_offsets) + now = datetime.now(timezone.utc) return { - "version": 1, - "kind": "constant_prealignment", - "scope_tag": scope_tag, - "scope_name": scope_name, - "created_utc": now.isoformat(), - "expires_utc": (now + timedelta(hours=float(max_age_hours))).isoformat(), - "max_age_hours": float(max_age_hours), - "n_samples": len(samples), - "offset_ra_hours": statistics.median(ra_offsets), - "offset_dec_deg": statistics.median(dec_offsets), - "offset_ra_arcmin": statistics.median(ra_offsets) * 15.0 * 60.0, - "offset_dec_arcmin": statistics.median(dec_offsets) * 60.0, - "median_error_arcmin": statistics.median(errors) if errors else None, + **_model_header( + "constant_prealignment", + scope_tag=scope_tag, + scope_name=scope_name, + max_age_hours=max_age_hours, + n_samples=len(samples), + now=now, + ), + "offset_ra_hours": median_ra_hours, + "offset_dec_deg": median_dec, + "offset_ra_arcmin": median_ra_hours * 15.0 * 60.0, + "offset_dec_arcmin": median_dec * 60.0, + "median_error_arcmin": _median_error(samples), "samples": samples, } -# Build an inverse affine model from desired sky coordinates to command coordinates. def build_affine_model( samples: list[dict], *, @@ -100,19 +189,24 @@ def build_affine_model( scope_name: str = "", max_age_hours: float = 12.0, ) -> dict: + """Build a least-squares affine model from 3 or more solved calibration samples.""" if len(samples) < 3: - raise ValueError("Affine pointing model requires at least three solved samples") + raise ValueError( + f"build_affine_model: at least 3 solved samples are required; got {len(samples)}" + ) actual_ra_deg = [float(sample["solved_ra_hours"]) * 15.0 for sample in samples] ref_ra_deg = circular_median_deg(actual_ra_deg) - matrix = [] - out_ra = [] - out_dec = [] + matrix: list[list[float]] = [] + out_ra: list[float] = [] + out_dec: list[float] = [] for sample in samples: solved_ra_deg = float(sample["solved_ra_hours"]) * 15.0 target_ra_deg = float(sample["target_ra_hours"]) * 15.0 - matrix.append([normalize_deg(solved_ra_deg - ref_ra_deg), float(sample["solved_dec_deg"]), 1.0]) + matrix.append( + [normalize_deg(solved_ra_deg - ref_ra_deg), float(sample["solved_dec_deg"]), 1.0] + ) out_ra.append(normalize_deg(target_ra_deg - ref_ra_deg)) out_dec.append(float(sample["target_dec_deg"])) @@ -120,27 +214,24 @@ def build_affine_model( ra_coeff, *_ = np.linalg.lstsq(x, np.array(out_ra, dtype=float), rcond=None) dec_coeff, *_ = np.linalg.lstsq(x, np.array(out_dec, dtype=float), rcond=None) - errors = [float(sample.get("error_arcmin", 0.0)) for sample in samples] now = datetime.now(timezone.utc) - return { - "version": 1, - "kind": "affine_prealignment", - "scope_tag": scope_tag, - "scope_name": scope_name, - "created_utc": now.isoformat(), - "expires_utc": (now + timedelta(hours=float(max_age_hours))).isoformat(), - "max_age_hours": float(max_age_hours), - "n_samples": len(samples), + **_model_header( + "affine_prealignment", + scope_tag=scope_tag, + scope_name=scope_name, + max_age_hours=max_age_hours, + n_samples=len(samples), + now=now, + ), "ref_ra_deg": ref_ra_deg, "command_ra_delta_coeff": [float(value) for value in ra_coeff], "command_dec_coeff": [float(value) for value in dec_coeff], - "median_error_arcmin": statistics.median(errors) if errors else None, + "median_error_arcmin": _median_error(samples), "samples": samples, } -# Choose the most appropriate pointing model for the available samples. def build_pointing_model( samples: list[dict], *, @@ -148,14 +239,9 @@ def build_pointing_model( scope_name: str = "", max_age_hours: float = 12.0, ) -> dict: - if len(samples) >= 3: - return build_affine_model( - samples, - scope_tag=scope_tag, - scope_name=scope_name, - max_age_hours=max_age_hours, - ) - return build_constant_model( + """Choose and build the most appropriate model for the available samples.""" + builder = build_affine_model if len(samples) >= 3 else build_constant_model + return builder( samples, scope_tag=scope_tag, scope_name=scope_name, @@ -163,29 +249,48 @@ def build_pointing_model( ) -# Persist a pointing model atomically enough for the pilot to read later. def save_pointing_model(model: dict, scope_tag: str | None = None) -> Path: + """Persist a model atomically enough for concurrent pilot reads.""" path = model_path(scope_tag or model.get("scope_tag")) path.parent.mkdir(parents=True, exist_ok=True) tmp_path = path.with_suffix(path.suffix + ".tmp") tmp_path.write_text(json.dumps(model, indent=2, sort_keys=True) + "\n", encoding="utf-8") tmp_path.replace(path) + log.debug("Saved pointing model (%s) to %s", model.get("kind"), path) return path -# Load a still-fresh pointing model; stale or malformed files are ignored. -def load_pointing_model(scope_tag: str | None = None, *, max_age_hours: float = 12.0) -> dict | None: +def load_pointing_model( + scope_tag: str | None = None, + *, + max_age_hours: float | None = None, +) -> dict | None: + """Load a still-fresh pointing model; stale or malformed files are ignored. + + max_age_hours is accepted for older callers. The model's own expires_utc is + authoritative, so callers cannot accidentally extend a model lifetime. + """ path = model_path(scope_tag) if not path.exists(): return None try: model = json.loads(path.read_text(encoding="utf-8")) - created = datetime.fromisoformat(str(model["created_utc"])) - if created.tzinfo is None: - created = created.replace(tzinfo=timezone.utc) - if datetime.now(timezone.utc) - created > timedelta(hours=float(max_age_hours)): - return None - return model - except Exception: + except (OSError, json.JSONDecodeError) as exc: + log.warning("Failed to read pointing model from %s: %s", path, exc) + return None + + try: + expires_str = model["expires_utc"] + expires = datetime.fromisoformat(str(expires_str)) + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + except (KeyError, ValueError) as exc: + log.warning("Pointing model at %s has invalid expires_utc (%s); discarding", path, exc) return None + + if datetime.now(timezone.utc) >= expires: + log.debug("Pointing model at %s has expired (expires_utc=%s)", path, expires_str) + return None + + return model diff --git a/core/hardware/live_battery.py b/core/hardware/live_battery.py index 1072395..af3c1cc 100644 --- a/core/hardware/live_battery.py +++ b/core/hardware/live_battery.py @@ -34,7 +34,7 @@ def _load_scope_ip() -> str | None: return None -def _rpc_call(ip: str, method: str, params: list[Any] | None = None, port: int = 4701, timeout: float = 3.0) -> dict: +def _rpc_call(ip: str, method: str, params: list[Any] | None = None, port: int = 4701, timeout: float = 0.15) -> dict: payload = {"id": 1, "method": method, "params": params or []} with socket.create_connection((ip, port), timeout=timeout) as sock: diff --git a/core/hardware/live_scope_status.py b/core/hardware/live_scope_status.py index 586fb7e..3bb8083 100644 --- a/core/hardware/live_scope_status.py +++ b/core/hardware/live_scope_status.py @@ -12,7 +12,7 @@ from core.hardware.live_battery import poll_battery_snapshot -ALPACA_TIMEOUT = 2.0 +ALPACA_TIMEOUT = 0.15 ALPACA_CLIENT_PARAMS = {"ClientID": 42, "ClientTransactionID": 1} CAMERA_STATE_NAMES = { diff --git a/core/postflight/aavso_submitter.py b/core/postflight/aavso_submitter.py index adf8a69..f850b78 100644 --- a/core/postflight/aavso_submitter.py +++ b/core/postflight/aavso_submitter.py @@ -136,7 +136,7 @@ def _classify_response_lines(lines: list[str]) -> tuple[list[str], list[str], li low = line.lower() if any(re.search(pattern, low) for pattern in success_patterns): success.append(line) - if any(token in low for token in ("warning", "out of limit", "out-of-limit", "outside limit", "outside limits", "outside range", "duplicate", "non-fatal")): + if any(token in low for token in ("warning", "out of limit", "out-of-limit", "outside limit", "outside limits", "outside range", "outside of expected range", "duplicate", "non-fatal")): warnings.append(line) if any(token in low for token in ("error", "failed", "invalid", "rejected", "must", "required", "unable")): errors.append(line) @@ -250,8 +250,12 @@ def submit(self, report_path: Path) -> dict: lines = _html_lines(response.text) success_lines, warning_lines, error_lines = _classify_response_lines(lines) - accepted = bool(success_lines) and not any("login session" in line.lower() for line in error_lines) out_of_limit = [line for line in warning_lines if "limit" in line.lower() or "range" in line.lower()] + accepted = ( + bool(success_lines) + and not out_of_limit + and not any("login session" in line.lower() for line in error_lines) + ) result = { "submitted_utc": datetime.now(timezone.utc).isoformat(), diff --git a/core/postflight/accountant.py b/core/postflight/accountant.py index 974f98e..1a0a54b 100755 --- a/core/postflight/accountant.py +++ b/core/postflight/accountant.py @@ -25,6 +25,11 @@ from scipy.spatial import cKDTree from skimage.registration import phase_cross_correlation +try: + import astroalign +except ImportError: # optional fallback; normal shift alignment remains available + astroalign = None + import sys PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) @@ -604,6 +609,89 @@ def _star_shift(reference: np.ndarray, moving: np.ndarray, max_shift_px: float = return shift_y, shift_x +# Use astroalign's asterism matching when translational shift alignment is not enough. +def _astroalign_frame(reference: np.ndarray, moving: np.ndarray) -> tuple[np.ndarray, dict] | None: + if astroalign is None: + return None + + try: + fill = float(np.median(moving[np.isfinite(moving)])) + registered, footprint = astroalign.register( + moving.astype(np.float32, copy=False), + reference.astype(np.float32, copy=False), + fill_value=fill, + ) + except Exception as exc: + log.debug(" astroalign fallback unavailable for frame: %s", exc) + return None + + invalid_fraction = 0.0 + if footprint is not None: + invalid_fraction = float(np.count_nonzero(footprint)) / float(footprint.size) + if invalid_fraction > 0.60: + log.warning(" astroalign fallback rejected: %.0f%% invalid footprint", invalid_fraction * 100.0) + return None + + return registered.astype(np.float32), { + "method": "ASTROALIGN", + "invalid_fraction": invalid_fraction, + } + + +# Prefer cheap shift alignment, but fall back to astroalign for rotated/doubled fields. +def _align_stack_frame( + reference: np.ndarray, + ref_work: np.ndarray, + moving: np.ndarray, + name: str, +) -> tuple[np.ndarray, tuple[float, float], str] | None: + work = moving - np.median(moving) + star_shift = _star_shift(reference, moving) + shift_y = shift_x = None + + try: + shift_yx, _, _ = phase_cross_correlation(ref_work, work, upsample_factor=10) + shift_y, shift_x = float(shift_yx[0]), float(shift_yx[1]) + except Exception as exc: + if star_shift is None: + astro = _astroalign_frame(reference, moving) + if astro: + return astro[0], (0.0, 0.0), astro[1]["method"] + log.warning(" stack align failed for %s: %s", name, exc) + return None + shift_y, shift_x = star_shift + + if star_shift is not None: + star_y, star_x = star_shift + if shift_y is not None and shift_x is not None and max(abs(star_y - shift_y), abs(star_x - shift_x)) > 5.0: + log.warning( + " stack phase shift overridden for %s: phase dy=%.1f dx=%.1f, stars dy=%.1f dx=%.1f", + name, + shift_y, + shift_x, + star_y, + star_x, + ) + shift_y, shift_x = star_y, star_x + + if abs(shift_y) > 250 or abs(shift_x) > 250: + astro = _astroalign_frame(reference, moving) + if astro: + log.info( + " astroalign fallback accepted for %s after excessive shift dy=%.1f dx=%.1f", + name, + shift_y, + shift_x, + ) + return astro[0], (0.0, 0.0), astro[1]["method"] + log.warning(" stack align rejected for %s: excessive shift dy=%.1f dx=%.1f", name, shift_y, shift_x) + return None + + fill = float(np.median(moving)) + aligned_arr = ndi_shift(moving, shift=(shift_y, shift_x), order=1, mode="constant", cval=fill) + return aligned_arr.astype(np.float32), (shift_y, shift_x), "SHIFT" + + def _median_stack(calibrated_paths: list[Path], target_name: str, obs_dt: datetime) -> Path | None: if len(calibrated_paths) < 2: return None @@ -647,40 +735,17 @@ def _median_stack(calibrated_paths: list[Path], target_name: str, obs_dt: dateti aligned = [reference] aligned_names = [kept[0][1]] shifts = [(0.0, 0.0)] + align_methods = Counter({"REF": 1}) for arr, name in kept[1:]: - work = arr - np.median(arr) - star_shift = _star_shift(reference, arr) - try: - shift_yx, _, _ = phase_cross_correlation(ref_work, work, upsample_factor=10) - shift_y, shift_x = float(shift_yx[0]), float(shift_yx[1]) - except Exception as e: - if star_shift is None: - log.warning(" stack align failed for %s: %s", name, e) - continue - shift_y, shift_x = star_shift - - if star_shift is not None: - star_y, star_x = star_shift - if max(abs(star_y - shift_y), abs(star_x - shift_x)) > 5.0: - log.warning( - " stack phase shift overridden for %s: phase dy=%.1f dx=%.1f, stars dy=%.1f dx=%.1f", - name, - shift_y, - shift_x, - star_y, - star_x, - ) - shift_y, shift_x = star_y, star_x - - if abs(shift_y) > 250 or abs(shift_x) > 250: - log.warning(" stack align rejected for %s: excessive shift dy=%.1f dx=%.1f", name, shift_y, shift_x) + aligned_result = _align_stack_frame(reference, ref_work, arr, name) + if aligned_result is None: continue - - aligned_arr = ndi_shift(arr, shift=(shift_y, shift_x), order=1, mode="constant", cval=float(np.median(arr))) - aligned.append(aligned_arr.astype(np.float32)) + aligned_arr, shift_yx, method = aligned_result + aligned.append(aligned_arr) aligned_names.append(name) - shifts.append((shift_y, shift_x)) + shifts.append(shift_yx) + align_methods[method] += 1 if len(aligned) < 2: log.warning(" stack alignment kept fewer than 2 usable frames for %s", target_name) @@ -694,7 +759,7 @@ def _median_stack(calibrated_paths: list[Path], target_name: str, obs_dt: dateti header["OBJECT"] = target_name header["NCOMBINE"] = len(aligned) header["STACKED"] = True - header["ALIGNMTH"] = "SHIFTMEAN" + header["ALIGNMTH"] = "+".join(f"{k}:{v}" for k, v in sorted(align_methods.items()))[:68] header["ALIGNSUC"] = len(aligned) header["ALIGNREF"] = aligned_names[0][:68] header["BUNIT"] = "ADU-DARKSUB" diff --git a/core/postflight/report_pipeline.py b/core/postflight/report_pipeline.py new file mode 100644 index 0000000..f8b9655 --- /dev/null +++ b/core/postflight/report_pipeline.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: core/postflight/report_pipeline.py +Objective: Stage postflight reports, mirror them to the NAS, and optionally + submit the AAVSO report without manual operator steering. +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from core.postflight.aavso_submitter import AAVSOWebObsSubmitter +from core.utils.env_loader import DATA_DIR, load_config + +log = logging.getLogger("PostflightReports") + +REPORT_DIR = DATA_DIR / "reports" +DEFAULT_MIRROR_DIR = Path("/mnt/astronas/reports") + + +# Locate the newest accountant summary for this completed postflight run. +def latest_summary(report_dir: Path = REPORT_DIR) -> Path: + candidates = sorted(report_dir.glob("postflight_summary_*.json")) + if not candidates: + raise FileNotFoundError(f"No postflight summary JSON found in {report_dir}") + return candidates[-1] + + +# Keep an accepted WebObs upload tied to one accountant summary. +def submit_marker_path(summary_path: Path) -> Path: + return summary_path.with_name(f"{summary_path.stem}.aavso_submit.json") + + +# Read the marker and return True only for already accepted submissions. +def already_accepted(summary_path: Path) -> bool: + marker = submit_marker_path(summary_path) + if not marker.exists(): + return False + try: + payload = json.loads(marker.read_text(encoding="utf-8")) + return bool((payload.get("aavso_submit") or {}).get("accepted")) + except Exception: + return False + + +# Read the marker and return a previous WebObs submission attempt if present. +def previous_submission(summary_path: Path) -> dict[str, Any] | None: + marker = submit_marker_path(summary_path) + if not marker.exists(): + return None + try: + payload = json.loads(marker.read_text(encoding="utf-8")) + submit = payload.get("aavso_submit") or {} + if submit.get("submitted_utc") or submit.get("accepted") is not None: + return payload + except Exception: + return None + return None + + +# Decide whether automatic WebObs submission is enabled for this install. +def auto_submit_enabled(cfg: dict[str, Any], override: bool | None) -> bool: + if override is not None: + return bool(override) + + aavso_cfg = cfg.get("aavso", {}) if isinstance(cfg, dict) else {} + explicit = aavso_cfg.get("auto_submit") + if explicit is not None: + return bool(explicit) + + cookie = ( + str(aavso_cfg.get("webobs_session_cookie", "")).strip() + or str(aavso_cfg.get("webobs_token", "")).strip() + ) + return bool(cookie) + + +# Pull the generated AAVSO Extended report out of the staged report set. +def aavso_report_from_outputs(outputs: list[Path]) -> Path | None: + candidates = [ + path for path in outputs + if path.name.startswith("AAVSO_") and path.suffix.lower() == ".txt" + ] + return candidates[-1] if candidates else None + + +# Write a durable marker with enough context to audit postflight publication. +def write_marker(summary_path: Path, payload: dict[str, Any]) -> Path: + marker = submit_marker_path(summary_path) + marker.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return marker + + +# Run the complete publication path: stage, mirror, and optionally submit. +def run_postflight_report_pipeline( + summary_path: Path | None = None, + *, + submit_aavso: bool | None = None, + mirror_dir: Path | None = DEFAULT_MIRROR_DIR, +) -> dict[str, Any]: + from dev.tools.reports.stage_reports_from_summary import _mirror_outputs, stage_reports + + cfg = load_config() + postflight_cfg = cfg.get("postflight", {}) if isinstance(cfg, dict) else {} + if postflight_cfg.get("auto_stage_reports") is False: + return { + "checked_utc": datetime.now(timezone.utc).isoformat(), + "staged": False, + "skipped": "postflight.auto_stage_reports=false", + } + + summary = Path(summary_path).expanduser().resolve() if summary_path else latest_summary() + previous = previous_submission(summary) + if previous is not None: + submit = previous.get("aavso_submit") or {} + reason = "already_accepted" if submit.get("accepted") else "already_attempted" + return { + "checked_utc": datetime.now(timezone.utc).isoformat(), + "summary_path": str(summary), + "staged": False, + "aavso_submit": {"skipped": reason}, + "aavso_submit_marker": str(submit_marker_path(summary)), + } + + configured_mirror = postflight_cfg.get("report_mirror_dir") + effective_mirror = Path(configured_mirror).expanduser() if configured_mirror else mirror_dir + + outputs = stage_reports(summary) + mirrored = _mirror_outputs(outputs, effective_mirror) + aavso_report = aavso_report_from_outputs(outputs) + + result: dict[str, Any] = { + "checked_utc": datetime.now(timezone.utc).isoformat(), + "summary_path": str(summary), + "staged": True, + "outputs": [str(path) for path in outputs], + "mirrored": [str(path) for path in mirrored], + "aavso_report": str(aavso_report) if aavso_report else None, + "auto_submit_enabled": auto_submit_enabled(cfg, submit_aavso), + } + + if not result["auto_submit_enabled"]: + result["aavso_submit"] = {"skipped": "auto_submit_disabled_or_no_cookie"} + write_marker(summary, result) + return result + + if aavso_report is None: + result["aavso_submit"] = {"accepted": False, "error": "No AAVSO report staged"} + write_marker(summary, result) + return result + + try: + submission = AAVSOWebObsSubmitter().submit(aavso_report) + except Exception as exc: + submission = {"accepted": False, "error": str(exc)} + + result["aavso_submit"] = submission + marker = write_marker(summary, result) + result["aavso_submit_marker"] = str(marker) + return result diff --git a/core/preflight/aavso_fetcher.py b/core/preflight/aavso_fetcher.py index bead575..3ad2572 100755 --- a/core/preflight/aavso_fetcher.py +++ b/core/preflight/aavso_fetcher.py @@ -2,18 +2,23 @@ # -*- coding: utf-8 -*- """ Filename: core/preflight/aavso_fetcher.py -Version: 1.6.8 -Objective: Haul AAVSO targets with nested dictionary support and strict error-message reporting. +Version: 1.7.0 +Objective: Haul AAVSO Target Tool campaign targets and write the SeeVar seed catalog. """ +from __future__ import annotations + +import argparse import json -import requests -import sys import logging +import os +import re +import sys import tomllib -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path -import re + +import requests logging.basicConfig(level=logging.INFO, format='%(asctime)s - [%(levelname)s] - %(message)s') logger = logging.getLogger("AAVSO_Step1") @@ -22,127 +27,289 @@ CONFIG_PATH = PROJECT_ROOT / "config.toml" CATALOG_DIR = PROJECT_ROOT / "catalogs" MASTER_HAUL_FILE = CATALOG_DIR / "campaign_targets.json" +RAW_HAUL_FILE = CATALOG_DIR / "aavso_targettool_raw.json" MAG_LIMIT = 15.0 MIN_DEC = -7.62 +TARGET_TOOL_URL = "https://targettool.aavso.org/TargetTool/api/v1/targets" +DEFAULT_SECTION = "ac" +PAGE_LIMIT = 1000 + + +# Resolve the Target Tool API key without ever echoing it into logs. +def get_aavso_key(explicit_key: str | None = None) -> str: + if explicit_key: + return explicit_key + + env_key = os.environ.get("AAVSO_TARGET_TOOL_API_KEY", "").strip() + if env_key: + return env_key -def get_aavso_key(): try: with open(CONFIG_PATH, "rb") as f: cfg = tomllib.load(f) - key = cfg.get("aavso", {}).get("target_key") + aavso_cfg = cfg.get("aavso", {}) + key = ( + aavso_cfg.get("target_tool_api_key") + or aavso_cfg.get("target_key") + or "" + ) if not key or key == "": - logger.error("❌ target_key is empty in config.toml") + logger.error("❌ AAVSO Target Tool key is empty in config.toml") + logger.error(" Add [aavso] target_tool_api_key = \"...\" or export AAVSO_TARGET_TOOL_API_KEY") sys.exit(1) return key except Exception: - logger.error("❌ Could not find [aavso] target_key in config.toml") + logger.error("❌ Could not find [aavso] Target Tool API key in config.toml") sys.exit(1) -def haul_and_filter(api_key): - endpoints = [ - "https://targettool.aavso.org/TargetTool/api/v1/targets", - "https://filtergraph.com/aavso/api/v1/targets" - ] - - raw_data = None - - for url in endpoints: - logger.info(f"📡 Attempting connection to: {url}") - try: - response = requests.get( - url, - auth=(api_key, "api_token"), - params={"obs_section": "all"}, - timeout=20 - ) - - if response.status_code == 200: - raw_data = response.json() - break - else: - logger.warning(f"⚠️ Server returned {response.status_code} at {url}") - - except Exception as e: - logger.warning(f"⚠️ Connection failed to {url}: {e}") - continue - if raw_data is None: - logger.error("❌ All AAVSO endpoints failed to return a valid 200 OK response.") - sys.exit(1) +# Return the list payload regardless of whether the API wraps it in a top-level key. +def _extract_targets(payload) -> list[dict]: + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + if isinstance(payload, dict): + for key in ("targets", "data", "results"): + items = payload.get(key) + if isinstance(items, list): + return [item for item in items if isinstance(item, dict)] + if "star_name" in payload or "name" in payload: + return [payload] + logger.error("❌ API returned an unexpected payload:\n%s", json.dumps(payload, indent=2)[:4000]) + sys.exit(1) - # Handle dictionary responses (either a single target, a nested list, or an error message) - if isinstance(raw_data, dict): - if 'star_name' in raw_data or 'name' in raw_data: - target_list = [raw_data] - elif 'targets' in raw_data and isinstance(raw_data['targets'], list): - target_list = raw_data['targets'] - else: - logger.error(f"❌ API returned an unexpected dictionary (likely an error message):\n{json.dumps(raw_data, indent=2)}") - sys.exit(1) - elif isinstance(raw_data, list): - target_list = raw_data + +# Read common coordinate forms from Target Tool and legacy seed files. +def _coerce_float(value, default=None): + try: + if value is None or value == "": + return default + return float(value) + except (TypeError, ValueError): + return default + + +# Pull RA/Dec from either direct fields or the Target Tool nested coordinates object. +def _coords_deg(target: dict) -> tuple[float | None, float | None]: + coords = target.get("coordinates") + if isinstance(coords, dict): + ra = ( + coords.get("ra") + or coords.get("raDeg") + or coords.get("rightAscension") + or coords.get("rightAscensionDeg") + ) + dec = ( + coords.get("dec") + or coords.get("decDeg") + or coords.get("declination") + or coords.get("declinationDeg") + ) else: - logger.error(f"❌ Unexpected data format received: {type(raw_data)}") + ra = target.get("ra") or target.get("raDeg") or target.get("rightAscension") + dec = target.get("dec") or target.get("decDeg") or target.get("declination") + return _coerce_float(ra), _coerce_float(dec) + + +# Pick the best available magnitude-like field for SeeVar's planning filters. +def _max_mag(target: dict) -> float | None: + for key in ("max_mag", "maxMag", "maximumMagnitude", "magnitude", "mag"): + mag = _coerce_float(target.get(key)) + if mag is not None: + return mag + return None + + +# Convert Target Tool cadence fields into the catalog cadence SeeVar already uses. +def _cadence_days(target: dict, var_type: str) -> float: + value = _coerce_float( + target.get("recommended_cadence_days") + or target.get("cadence") + or target.get("obs_cadence") + or target.get("recommendedCadence") + ) + unit = str(target.get("cadenceUnit") or target.get("recommendedCadenceUnit") or "day").lower() + if value is not None: + if unit.startswith("hour"): + return max(0.1, value / 24.0) + if unit.startswith("week"): + return value * 7.0 + return value + return 1.0 if any(x in var_type for x in ['CV', 'UG', 'RR', 'NA', 'ZAND', 'NL']) else 3.0 + + +# Convert friendly section names to the Target Tool obs_section codes. +def _section_param(section: str) -> list[str]: + aliases = { + "alerts": "ac", + "alerts & campaigns": "ac", + "alerts | campaigns": "ac", + "campaigns": "ac", + "all": "all", + } + parts = [part.strip() for part in str(section or DEFAULT_SECTION).split(",") if part.strip()] + return [aliases.get(part.lower(), part) for part in parts] or [DEFAULT_SECTION] + + +# Fetch AAVSO Target Tool rows using documented Basic Auth and obs_section codes. +def fetch_targettool_targets( + api_key: str, + *, + observing_section: str = DEFAULT_SECTION, + limit: int = 0, + timeout: float = 20.0, +) -> list[dict]: + params = {"obs_section": _section_param(observing_section)} + logger.info("📡 Fetching AAVSO Target Tool obs_section=%s", ",".join(params["obs_section"])) + response = requests.get( + TARGET_TOOL_URL, + auth=(api_key, "api_token"), + params=params, + timeout=timeout, + ) + if response.status_code != 200: + logger.error("❌ AAVSO Target Tool returned HTTP %s: %s", response.status_code, response.text[:1000]) sys.exit(1) - logger.info(f"📥 Processing {len(target_list)} raw entries...") + targets = _extract_targets(response.json()) + limit = int(limit or 0) + return targets[:limit] if limit > 0 else targets + + +# Normalize raw Target Tool rows into the legacy campaign_targets.json schema. +def normalize_targets(target_list: list[dict], *, source_label: str) -> list[dict]: + logger.info("📥 Processing %s raw entries...", len(target_list)) targets_dict = {} - + for t in target_list: if not isinstance(t, dict): continue - - target_name = t.get('star_name') or t.get('name') + + target_name = t.get('star_name') or t.get('name') or t.get("primaryName") if not target_name: continue try: - mag = float(t.get('max_mag', 0)) - if mag > MAG_LIMIT or float(t.get('dec', -90)) < MIN_DEC: + ra, dec = _coords_deg(t) + mag = _max_mag(t) + if mag is None or ra is None or dec is None: + continue + if mag > MAG_LIMIT or dec < MIN_DEC: continue - var_type = str(t.get('var_type', '')).upper() - - rec_cadence = 1 if any(x in var_type for x in ['CV', 'UG', 'RR', 'NA', 'ZAND', 'NL']) else 3 + var_type = str(t.get('var_type') or t.get("targetType") or t.get("type") or "").upper() + rec_cadence = _cadence_days(t, var_type) + raw_priority = t.get("priority") + priority = 1 if raw_priority is True or str(raw_priority).lower() == "true" else 2 canon_name = re.sub(r' V0+(\d)', r'V \1', str(target_name)) - + if canon_name not in targets_dict or mag < targets_dict[canon_name]['max_mag']: targets_dict[canon_name] = { "name": canon_name, - "ra": float(t.get('ra', 0)), - "dec": float(t.get('dec', 0)), + "ra": float(ra), + "dec": float(dec), "type": var_type, "max_mag": mag, "recommended_cadence_days": rec_cadence, - "priority": 2, - "duration": 600 + "priority": priority, + "duration": 600, + "source": source_label, + "target_class": "AAVSO_CAMPAIGN", } + min_mag = _coerce_float(t.get("min_mag") or t.get("minMag") or t.get("minimumMagnitude")) + period = _coerce_float(t.get("period") or t.get("period_days")) + if min_mag is not None: + targets_dict[canon_name]["min_mag"] = min_mag + if period is not None: + targets_dict[canon_name]["period_days"] = period + if t.get("auid"): + targets_dict[canon_name]["auid"] = t.get("auid") + recommended_filter = t.get("recommendedFilter") or t.get("filter") + if recommended_filter: + targets_dict[canon_name]["recommended_filter"] = recommended_filter + if t.get("observingPrograms"): + targets_dict[canon_name]["observing_programs"] = t.get("observingPrograms") + if t.get("other_info"): + targets_dict[canon_name]["campaign_notes"] = t.get("other_info") except (ValueError, TypeError): continue - final_targets = list(targets_dict.values()) + return list(targets_dict.values()) + + +# Write both raw audit data and the filtered catalog consumed by the existing pipeline. +def haul_and_filter( + api_key: str, + *, + observing_section: str = DEFAULT_SECTION, + limit: int = 0, + output_path: Path = MASTER_HAUL_FILE, + raw_output_path: Path = RAW_HAUL_FILE, +) -> list[dict]: + raw_targets = fetch_targettool_targets(api_key, observing_section=observing_section, limit=limit) + final_targets = normalize_targets(raw_targets, source_label=f"AAVSO Target Tool: {observing_section}") if not final_targets: logger.error("❌ No valid targets remained after filtering.") sys.exit(1) + output_path.parent.mkdir(parents=True, exist_ok=True) + raw_output_path.parent.mkdir(parents=True, exist_ok=True) + generated_utc = datetime.now(timezone.utc).isoformat() + + raw_output_data = { + "#objective": "Raw AAVSO Target Tool response retained for audit and future secondary-target curation.", + "metadata": { + "generated_utc": generated_utc, + "source": TARGET_TOOL_URL, + "observing_section": observing_section, + "raw_count": len(raw_targets), + }, + "targets": raw_targets, + } + with open(raw_output_path, "w") as f: + json.dump(raw_output_data, f, indent=4) + output_data = { + "#objective": "Filtered AAVSO campaign targets usable by SeeVar preflight tooling.", "metadata": { - "generated": datetime.now().isoformat(), - "source": "AAVSO Target Tool API", + "generated_utc": generated_utc, + "source": TARGET_TOOL_URL, + "observing_section": observing_section, + "raw_target_count": len(raw_targets), "target_count": len(final_targets) }, "targets": final_targets } - with open(MASTER_HAUL_FILE, "w") as f: + with open(output_path, "w") as f: json.dump(output_data, f, indent=4) - logger.info(f"✅ Success: {len(final_targets)} unique targets saved to {MASTER_HAUL_FILE}") + logger.info("✅ Success: %s unique targets saved to %s", len(final_targets), output_path) + logger.info("🧾 Raw haul retained at %s", raw_output_path) + return final_targets + + +# Keep the script usable from cron, bootstrap, or manual beta runs. +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Fetch AAVSO Target Tool campaign targets.") + parser.add_argument("--api-key", default=None, help="AAVSO Target Tool API key. Prefer config/env for normal use.") + parser.add_argument("--section", default=DEFAULT_SECTION, help="Target Tool obs_section code or alias. Default: ac (Alerts & Campaigns).") + parser.add_argument("--limit", type=int, default=0, help="Maximum raw targets to keep; 0 keeps the full API response.") + parser.add_argument("--output", type=Path, default=MASTER_HAUL_FILE, help="Filtered SeeVar catalog output path.") + parser.add_argument("--raw-output", type=Path, default=RAW_HAUL_FILE, help="Raw Target Tool audit output path.") + return parser.parse_args(argv) + if __name__ == "__main__": - key = get_aavso_key() - haul_and_filter(key) + args = parse_args() + key = get_aavso_key(args.api_key) + haul_and_filter( + key, + observing_section=args.section, + limit=args.limit, + output_path=args.output, + raw_output_path=args.raw_output, + ) diff --git a/core/preflight/horizon.py b/core/preflight/horizon.py index 4a50009..14b7818 100755 --- a/core/preflight/horizon.py +++ b/core/preflight/horizon.py @@ -15,17 +15,13 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2] CONFIG_PATH = PROJECT_ROOT / "config.toml" -MASK_PATH = PROJECT_ROOT / "data" / "horizon_mask.json" +DEFAULT_MASK_PATH = PROJECT_ROOT / "data" / "horizon_mask.json" DEFAULT_SCIENCE_FLOOR_DEG = 15.0 -DEFAULT_OBSTRUCTIONS = [ - {"az_start": 150, "az_end": 210, "min_alt": 45}, - {"az_start": 300, "az_end": 350, "min_alt": 55}, -] - _profile = {} _use_profile = False +_profile_source = None _config = None _obstructions = None @@ -74,23 +70,48 @@ def _profile_enabled() -> bool: return bool(True if value is None else value) +def _profile_required() -> bool: + cfg = _load_config() + value = cfg.get("horizon", {}).get("profile_required") + return bool(False if value is None else value) + + +def _profile_path() -> Path: + cfg = _load_config() + configured = cfg.get("horizon", {}).get("profile_path") + if not configured: + return DEFAULT_MASK_PATH + + path = Path(str(configured)).expanduser() + if not path.is_absolute(): + path = PROJECT_ROOT / path + return path + + def _load_profile() -> bool: - global _profile, _use_profile + global _profile, _use_profile, _profile_source if _profile: return _use_profile - if MASK_PATH.exists() and _profile_enabled(): + mask_path = _profile_path() + if mask_path.exists() and _profile_enabled(): try: - with open(MASK_PATH) as f: + with open(mask_path) as f: data = json.load(f) _profile = {int(k): float(v) for k, v in data["profile"].items()} _use_profile = True - logger.debug("Horizon profile loaded: %s (%d entries)", MASK_PATH, len(_profile)) + _profile_source = mask_path + logger.debug("Horizon profile loaded: %s (%d entries)", mask_path, len(_profile)) return True except Exception as e: - logger.warning("Failed to load horizon_mask.json: %s — using box fallback", e) + if _profile_required(): + raise RuntimeError(f"Required horizon profile failed to load: {mask_path}: {e}") from e + logger.warning("Failed to load horizon profile %s: %s — using configured safety floors", mask_path, e) + elif _profile_enabled() and _profile_required(): + raise FileNotFoundError(f"Required horizon profile does not exist: {mask_path}") _use_profile = False + _profile_source = None return False @@ -115,8 +136,8 @@ def _load_obstructions() -> list: _obstructions = horizon_obstructions return _obstructions - _obstructions = DEFAULT_OBSTRUCTIONS - return DEFAULT_OBSTRUCTIONS + _obstructions = [] + return _obstructions def _az_in_sector(az: float, start: float, end: float) -> bool: @@ -177,11 +198,25 @@ def is_obstructed(az: float, alt: float) -> bool: def horizon_summary() -> dict: _load_profile() obstructions = _load_obstructions() + values = list(_profile.values()) if _use_profile else [] return { "uses_profile": bool(_use_profile), - "profile_path": str(MASK_PATH) if _use_profile else None, + "profile_path": str(_profile_source) if _use_profile else str(_profile_path()), + "profile_required": _profile_required(), + "profile_points": len(_profile) if _use_profile else 0, + "profile_min_alt": round(min(values), 2) if values else None, + "profile_max_alt": round(max(values), 2) if values else None, "science_floor_deg": round(_science_floor_deg(), 2), "obstruction_count": len(obstructions), + "obstructions": [ + { + "label": str(obs.get("label", f"obstruction_{idx + 1}")), + "az_start": float(obs.get("az_start", 0.0)), + "az_end": float(obs.get("az_end", 0.0)), + "min_alt": float(obs.get("min_alt", 0.0)), + } + for idx, obs in enumerate(obstructions) + ], } @@ -225,9 +260,18 @@ def best_windows(step: int = 5) -> list: _load_profile() mode = "PROFILE + SAFETY BOXES" if _use_profile else "BOX MODEL FALLBACK" print(f"Horizon engine v2.1.1 — {mode}") - print(f"Mask: {MASK_PATH}") + summary = horizon_summary() + print(f"Mask: {summary['profile_path']}") + print(f"Profile points: {summary['profile_points']}") + if summary["profile_min_alt"] is not None: + print(f"Profile range: {summary['profile_min_alt']:.1f}° .. {summary['profile_max_alt']:.1f}°") print(f"Science floor: {_science_floor_deg():.1f}°") print(f"Manual obstructions: {len(_load_obstructions())}") + for obs in summary["obstructions"]: + print( + f" - {obs['label']}: az {obs['az_start']:.1f}°..{obs['az_end']:.1f}° " + f"min_alt {obs['min_alt']:.1f}°" + ) print() print("Az MinAlt ReqAlt(+5) Clear@25?") for az in range(0, 360, 10): diff --git a/core/preflight/nightly_planner.py b/core/preflight/nightly_planner.py index d0d40f6..bdf3286 100755 --- a/core/preflight/nightly_planner.py +++ b/core/preflight/nightly_planner.py @@ -118,6 +118,10 @@ def build_time_grid(now_utc): return [now_utc + timedelta(minutes=i * SAMPLE_MINUTES) for i in range(steps)] +def allow_partial_current_night() -> bool: + return "--allow-partial" in sys.argv + + def contiguous_windows(mask): windows = [] start = None @@ -586,8 +590,9 @@ def run_funnel(): data = json.load(f) targets = data.get("data", data.get("targets", [])) if isinstance(data, dict) else data primary_target_count = len(targets) + secondary_after_photometry = bool(planner_cfg.get("secondary_after_photometry", False)) secondary_targets, secondary_counts = _load_secondary_targets(planner_cfg) - if secondary_targets: + if secondary_targets and not secondary_after_photometry: targets = list(targets) + secondary_targets now_utc = datetime.now(timezone.utc) @@ -600,6 +605,17 @@ def run_funnel(): print("No astronomical dark found in the planning horizon.") return + skipped_partial_window = False + if dark_windows[0][0] == 0 and not allow_partial_current_night(): + dark_windows = dark_windows[1:] + skipped_partial_window = True + print("[=] Skipped active partial dark window; planning the next full night.") + print("[=] Use --allow-partial only for an intentional in-night recovery plan.") + + if not dark_windows: + print("No full upcoming dark window found in the planning horizon.") + return + planning_start_idx, planning_end_idx = dark_windows[0] planning_start_utc = times[planning_start_idx] planning_end_utc = times[planning_end_idx] @@ -717,6 +733,8 @@ def run_funnel(): "active_scope_count": len(active_scopes), "planning_start_utc": planning_start_utc.isoformat(), "planning_end_utc": planning_end_utc.isoformat(), + "allow_partial_current_night": allow_partial_current_night(), + "skipped_partial_current_night": skipped_partial_window, "sample_minutes": SAMPLE_MINUTES, "clearance_margin_deg": CLEARANCE_MARGIN_DEG, "sun_altitude_threshold_deg": sun_limit, @@ -725,6 +743,7 @@ def run_funnel(): "primary_target_count": primary_target_count, "secondary_target_count": len(secondary_targets), "secondary_catalog_counts": secondary_counts, + "secondary_after_photometry": secondary_after_photometry, "visible_target_count": len(ordered), "planned_target_count": len(ordered), "gate_counts": gate_counts, diff --git a/core/preflight/weather.py b/core/preflight/weather.py index 4799925..994a7b8 100755 --- a/core/preflight/weather.py +++ b/core/preflight/weather.py @@ -5,16 +5,17 @@ Version: 1.8.0 Objective: Tri-source weather consensus daemon providing dark-window timing and hard-abort imaging veto state for preflight and flight. conditions (rain, snow, fog, storm, wind) per hour within - tonight's astronomical dark window. Cloud cover at any level - is a warning only — never an abort. Reports best contiguous - imaging window within the dark period. Feeds status, + tonight's astronomical dark window. Cloud cover is not a safety + abort, but is a science no-go when configured limits are exceeded. + Reports best contiguous imaging window within the dark period. Feeds status, imaging_window_start, imaging_window_end, clouds_pct, humidity_pct to the Orchestrator via data/weather_state.json. Poll interval: 4 hours. - Source 1 — open-meteo : precipitation, wind, humidity (forecast) - Source 2 — Clear Outside: per-layer clouds, fog (forecast) - Source 3 — KNMI EDR : measured oktas, visibility, ww from - Schiphol (ground truth — hard aborts only) + Source 1 — open-meteo : precipitation, wind, humidity, total cloud (forecast) + Source 2 — MET Norway : total cloud forecast + Source 3 — Clear Outside: per-layer clouds, fog (forecast) + Source 4 — KNMI EDR : measured oktas, visibility, ww from + Schiphol (ground truth) """ import json @@ -24,8 +25,10 @@ import requests import sys import tomllib +import argparse from datetime import datetime, timezone, timedelta from pathlib import Path +from zoneinfo import ZoneInfo PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) @@ -37,7 +40,7 @@ format="%(asctime)s [%(levelname)s] %(message)s") log = logging.getLogger("WeatherSentinel") -POLL_INTERVAL_S = 14400 # 4 hours +POLL_INTERVAL_S = 1800 # 30 minutes; preflight must see fresh weather. LOCK_FILE = DATA_DIR / "locks" / "weather.lock" _LOCK_HANDLE = None @@ -98,12 +101,15 @@ def _ww_is_thunder(ww: float) -> bool: def _load_thresholds() -> dict: """Load hard-abort thresholds from config.toml [weather]. - Cloud cover thresholds are retained for display/warning only — - they do not trigger abort in v1.8+.""" + Cloud cover thresholds are science no-go gates.""" defaults = { "precip_limit": 0.5, # mm — open-meteo forecast abort "wind_limit": 30.0, # km/h "humidity_limit": 90.0, # % — warning/dew heater cue, not abort + "cloud_low_limit": 30.0, # % — science no-go + "cloud_mid_limit": 50.0, # % — science no-go + "cloud_high_limit":70.0, # % — science no-go + "cloud_total_limit": 70.0, # % — science no-go for total-cloud sources "fog_abort": True, "min_window_hours": 1, # minimum contiguous clear hours to report } @@ -115,6 +121,10 @@ def _load_thresholds() -> dict: "precip_limit": float(w.get("max_precip_mm", defaults["precip_limit"])), "wind_limit": float(w.get("max_wind_kmh", defaults["wind_limit"])), "humidity_limit": float(w.get("max_humidity_pct", defaults["humidity_limit"])), + "cloud_low_limit": float(w.get("max_cloud_low_pct", defaults["cloud_low_limit"])), + "cloud_mid_limit": float(w.get("max_cloud_mid_pct", defaults["cloud_mid_limit"])), + "cloud_high_limit": float(w.get("max_cloud_high_pct", defaults["cloud_high_limit"])), + "cloud_total_limit": float(w.get("max_cloud_total_pct", defaults["cloud_total_limit"])), "fog_abort": bool(w.get("fog_abort", defaults["fog_abort"])), "min_window_hours": int(w.get("min_window_hours", defaults["min_window_hours"])), } @@ -267,10 +277,39 @@ def _hour_has_hard_abort(hour_data: dict, t: dict, knmi_cfg: dict) -> tuple[bool return False, "" +def _hour_cloud_reason(hour_data: dict, t: dict, *, use_knmi: bool = False) -> str: + """Return a science no-go reason when cloud limits are exceeded.""" + low = float(hour_data.get("co_low", 0) or 0) + mid = float(hour_data.get("co_mid", 0) or 0) + high = float(hour_data.get("co_high", 0) or 0) + om_total = float(hour_data.get("om_clouds", 0) or 0) + met_total = hour_data.get("met_clouds") + total_limit = float(t.get("cloud_total_limit", max(t["cloud_high_limit"], t["cloud_mid_limit"]))) + reasons = [] + + if low > t["cloud_low_limit"]: + reasons.append(f"CO low {low:.0f}%>{t['cloud_low_limit']:.0f}%") + if mid > t["cloud_mid_limit"]: + reasons.append(f"CO mid {mid:.0f}%>{t['cloud_mid_limit']:.0f}%") + if high > t["cloud_high_limit"]: + reasons.append(f"CO high {high:.0f}%>{t['cloud_high_limit']:.0f}%") + if om_total > total_limit: + reasons.append(f"OM total {om_total:.0f}%>{total_limit:.0f}%") + if met_total is not None and float(met_total) > total_limit: + reasons.append(f"MET total {float(met_total):.0f}%>{total_limit:.0f}%") + + if use_knmi: + oktas = hour_data.get("knmi_oktas") + if oktas is not None and float(oktas) >= 5: + reasons.append(f"KNMI {float(oktas):.0f} oktas") + + return "; ".join(reasons) + + def find_best_imaging_window(hourly_evals: list, min_hours: int = 1) -> tuple | None: """ - Find the longest contiguous block of hours with no hard abort. - hourly_evals: list of (datetime, abort: bool, reason: str) + Find the longest contiguous block of hours usable for science. + hourly_evals: list of (datetime, blocked: bool, reason: str) Returns (window_start: datetime, window_end: datetime) or None. """ best_start = best_end = None @@ -313,10 +352,12 @@ def __init__(self): log.info( "Thresholds v1.8 — precip:%.1fmm wind:%.0fkm/h hum:%.0f%% " - "fog_abort:%s min_window:%dh | clouds: WARNING ONLY", + "fog_abort:%s min_window:%dh | clouds low/mid/high/total:%.0f/%.0f/%.0f/%.0f%%", self.t["precip_limit"], self.t["wind_limit"], self.t["humidity_limit"], self.t["fog_abort"], self.t["min_window_hours"], + self.t["cloud_low_limit"], self.t["cloud_mid_limit"], + self.t["cloud_high_limit"], self.t["cloud_total_limit"], ) if self.knmi_cfg: log.info("KNMI source: %s (%s) vv_limit:%dm", @@ -344,7 +385,7 @@ def fetch_open_meteo_hourly(self, lat: float, lon: float, f"https://api.open-meteo.com/v1/forecast" f"?latitude={lat}&longitude={lon}" f"&hourly=precipitation,cloud_cover,relative_humidity_2m," - f"wind_speed_10m&timezone=UTC" + f"wind_speed_10m&timezone=UTC&forecast_days=2" ) try: r = requests.get(url, timeout=10) @@ -377,15 +418,69 @@ def fetch_open_meteo_hourly(self, lat: float, lon: float, return [] # ------------------------------------------------------------------------- - # SOURCE 2 — Clear Outside (hourly fog only — clouds are display only) + # SOURCE 2 — MET Norway / Yr.no (hourly total cloud forecast) + # ------------------------------------------------------------------------- + + def fetch_met_no_hourly(self, lat: float, lon: float, + dark_window: tuple | None) -> dict: + """ + Fetch MET Norway total cloud forecast. + Returns dict keyed by UTC hour datetime → cloud_area_fraction. + """ + url = ( + "https://api.met.no/weatherapi/locationforecast/2.0/compact" + f"?lat={lat:.4f}&lon={lon:.4f}" + ) + headers = {"User-Agent": "SeeVar/1.8 weather sentinel; github.com/edjuh/seevar"} + try: + r = requests.get(url, headers=headers, timeout=10) + r.raise_for_status() + data = r.json() + + dark_start = dark_end = None + if dark_window: + dark_start, dark_end = dark_window + + hours = {} + for item in data.get("properties", {}).get("timeseries", []): + ts = item.get("time") + if not ts: + continue + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone(timezone.utc) + except ValueError: + continue + if dark_window and not (dark_start <= dt <= dark_end): + continue + cloud = ( + item.get("data", {}) + .get("instant", {}) + .get("details", {}) + .get("cloud_area_fraction") + ) + if cloud is None: + continue + hour_dt = dt.replace(minute=0, second=0, microsecond=0) + hours[hour_dt] = float(cloud) + + log.info("MET Norway — %d hours within dark window fetched", len(hours)) + return hours + + except Exception as e: + log.warning("MET Norway fetch failed: %s", e) + return {} + + # ------------------------------------------------------------------------- + # SOURCE 3 — Clear Outside (hourly fog and per-layer cloud forecast) # ------------------------------------------------------------------------- def fetch_clear_outside_hourly(self, lat: float, lon: float, dark_window: tuple | None) -> dict: """ Fetch per-hour fog flag and cloud layers from Clear Outside. - Returns dict keyed by hour-of-day (int) → {co_fog, co_low, co_mid, - co_high}. Clouds stored for display; fog used for abort evaluation. + Returns dict keyed by UTC hour datetime → {co_fog, co_low, co_mid, + co_high}. Clear Outside data is local-date/hour based; preserving + the date avoids overwriting tonight's hours with later forecast days. """ try: from clear_outside_apy import ClearOutsideAPY @@ -394,12 +489,25 @@ def fetch_clear_outside_hourly(self, lat: float, lon: float, data = api.pull() result = {} + local_tz = datetime.now().astimezone().tzinfo or ZoneInfo("UTC") + local_today = datetime.now(local_tz).date() for day_key in sorted(data.get("forecast", {}).keys()): day = data["forecast"][day_key] hours = day.get("hours", {}) + try: + day_offset = int(str(day_key).split("-")[-1]) + except (TypeError, ValueError): + continue + local_date = local_today + timedelta(days=day_offset) for hour_key in sorted(hours.keys(), key=int): h = hours[hour_key] - result[int(hour_key)] = { + local_dt = datetime.combine( + local_date, + datetime.min.time().replace(hour=int(hour_key)), + tzinfo=local_tz, + ) + utc_hour = local_dt.astimezone(timezone.utc).replace(minute=0, second=0, microsecond=0) + result[utc_hour] = { "co_fog": int(h.get("fog", 0)), "co_low": int(h.get("low-clouds", 0)), "co_mid": int(h.get("mid-clouds", 0)), @@ -417,7 +525,7 @@ def fetch_clear_outside_hourly(self, lat: float, lon: float, return {} # ------------------------------------------------------------------------- - # SOURCE 3 — KNMI EDR (ground truth — single latest measurement) + # SOURCE 4 — KNMI EDR (ground truth — single latest measurement) # ------------------------------------------------------------------------- def fetch_knmi(self) -> dict: @@ -503,7 +611,7 @@ def latest(key): def get_consensus(self): """ - Per-hour hard-abort evaluation across the dark window. + Per-hour safety and science-quality evaluation across the dark window. Hard abort conditions (telescope in): 1. KNMI ww >= 50 — measured precipitation (rain/snow/hail) @@ -514,17 +622,19 @@ def get_consensus(self): 6. open-meteo precip — forecast precipitation 7. open-meteo wind — forecast wind above limit - Warning only (log, never abort, telescope keeps imaging): - - Cloud cover at any level (low/mid/high) — all sources - - KNMI oktas — display only + Science no-go: + - Cloud cover above configured limits + + Warning only: - Humidity — dew heater cue only Outcome: - - status: CLEAR / CLOUDY / HAZY / HUMID / RAIN / FOG / WINDY / THUNDER - CLEAR/CLOUDY/HAZY/HUMID = imaging go + - status: CLEAR / MIXED / CLOUDY / HAZY / HUMID / RAIN / FOG / WINDY / THUNDER + CLEAR/HAZY/HUMID = imaging go + CLOUDY/MIXED = no science go RAIN/FOG/WINDY/THUNDER = hard abort - imaging_window_start / imaging_window_end: best contiguous - non-abort block within the dark window (UTC ISO strings) + non-abort and non-cloudy block within the dark window (UTC ISO strings) """ lat, lon = self.get_coordinates() if lat == 0.0 and lon == 0.0: @@ -535,6 +645,7 @@ def get_consensus(self): log.info("Fetching tri-source weather for %.4f, %.4f...", lat, lon) om_hours = self.fetch_open_meteo_hourly(lat, lon, dark_window) + met_hours = self.fetch_met_no_hourly(lat, lon, dark_window) co_hours = self.fetch_clear_outside_hourly(lat, lon, dark_window) knmi = self.fetch_knmi() @@ -553,38 +664,54 @@ def get_consensus(self): dt = om["dt"] hour = dt.hour - # Merge Clear Outside for this hour (keyed by hour-of-day) - co = co_hours.get(hour, {}) + # Merge Clear Outside for this exact UTC hour. + co = co_hours.get(dt.replace(minute=0, second=0, microsecond=0), {}) + met_clouds = met_hours.get(dt.replace(minute=0, second=0, microsecond=0)) # Build merged hour dict for abort evaluation h = { "om_precip": om["om_precip"], "om_wind": om["om_wind"], + "om_clouds": om["om_clouds"], + "met_clouds": met_clouds, "co_fog": co.get("co_fog", 0), + "co_low": co.get("co_low", 0), + "co_mid": co.get("co_mid", 0), + "co_high": co.get("co_high", 0), # KNMI ground truth only applied to current hour "knmi_ww": knmi.get("ww") if hour == current_hour else None, "knmi_vv": knmi.get("vv") if hour == current_hour else None, + "knmi_oktas": knmi.get("oktas") if hour == current_hour else None, } abort, reason = _hour_has_hard_abort(h, t, self.knmi_cfg) + cloud_reason = "" if abort else _hour_cloud_reason(h, t, use_knmi=(hour == current_hour)) hourly_evals.append((dt, abort, reason)) hourly_detail.append({ "hour_utc": dt.strftime("%H:%M"), "abort": abort, "reason": reason, + "cloud_block": bool(cloud_reason), + "cloud_reason": cloud_reason, "om_precip": om["om_precip"], "om_wind": om["om_wind"], "om_clouds": om["om_clouds"], + "met_clouds": met_clouds, "co_fog": co.get("co_fog", 0), "co_low": co.get("co_low", 0), "co_mid": co.get("co_mid", 0), "co_high": co.get("co_high", 0), }) - # Best contiguous imaging window + # Best contiguous science window: clouds block science, not just hard aborts. + science_evals = [ + (dt, bool(d.get("abort") or d.get("cloud_block")), + d.get("reason") or d.get("cloud_reason", "")) + for (dt, _abort, _reason), d in zip(hourly_evals, hourly_detail) + ] imaging_window = find_best_imaging_window( - hourly_evals, min_hours=t["min_window_hours"] + science_evals, min_hours=t["min_window_hours"] ) # Overall status — driven by NOW, not window_max @@ -592,10 +719,18 @@ def get_consensus(self): # Otherwise: warning statuses from current conditions now_abort = False now_reason = "" + now_cloudy = False + now_cloud_reason = "" for dt, abort, reason in hourly_evals: if dt.hour == current_hour: - now_abort = abort - now_reason = reason + current_hour_detail = next( + (d for d in hourly_detail if int(d["hour_utc"][:2]) == current_hour), + {}, + ) + now_abort = bool(current_hour_detail.get("abort")) + now_reason = current_hour_detail.get("reason", "") + now_cloudy = bool(current_hour_detail.get("cloud_block")) + now_cloud_reason = current_hour_detail.get("cloud_reason", "") break current_detail = next( @@ -617,6 +752,13 @@ def get_consensus(self): "knmi_vv": knmi.get("vv"), } now_abort, now_reason = _hour_has_hard_abort(h_now, t, self.knmi_cfg) + if not now_abort: + now_cloud_reason = _hour_cloud_reason( + {"knmi_oktas": knmi.get("oktas")}, + t, + use_knmi=True, + ) + now_cloudy = bool(now_cloud_reason) if now_abort: # Determine specific abort status from reason string @@ -641,7 +783,19 @@ def get_consensus(self): cur_high = current_detail.get("co_high", 0) knmi_oktas = knmi.get("oktas") - if cur_low > 50 or cur_mid > 50 or (knmi_oktas is not None and knmi_oktas >= 5): + current_cloud_reason = _hour_cloud_reason( + { + "co_low": cur_low, + "co_mid": cur_mid, + "co_high": cur_high, + "om_clouds": current_om.get("om_clouds", 0), + "met_clouds": current_detail.get("met_clouds"), + "knmi_oktas": knmi_oktas, + }, + t, + use_knmi=True, + ) + if current_cloud_reason: current_status, current_icon = "CLOUDY", "☁️" elif cur_high > 70: current_status, current_icon = "HAZY", "🌤️" @@ -651,7 +805,10 @@ def get_consensus(self): current_status, current_icon = "CLEAR", "✨" # Collect display values - clouds_pct = int(max((d["om_clouds"] for d in hourly_detail), default=0)) + clouds_pct = int(max( + max(float(d.get("om_clouds") or 0), float(d.get("met_clouds") or 0)) + for d in hourly_detail + ) if hourly_detail else 0) humidity_pct = int(knmi.get("rh") or max((om["om_humidity"] for om in om_hours), default=0)) knmi_oktas = knmi.get("oktas") @@ -665,26 +822,31 @@ def get_consensus(self): win_end_str = (imaging_window[1].strftime("%H:%M UTC") if imaging_window else None) - abort_hours = sum(1 for _, a, _ in hourly_evals if a) - clear_hours = len(hourly_evals) - abort_hours + abort_hours = sum(1 for d in hourly_detail if d.get("abort")) + cloudy_hours = sum(1 for d in hourly_detail if d.get("cloud_block")) + clear_hours = len(hourly_evals) - abort_hours - cloudy_hours if imaging_window: - if abort_hours == 0: + if abort_hours == 0 and cloudy_hours == 0: status, icon = "CLEAR", "✨" + elif abort_hours == 0 and cloudy_hours == len(hourly_detail): + status, icon = "CLOUDY", "☁️" else: status, icon = "MIXED", "🌤️" else: if abort_hours > 0: status, icon = "BLOCKED", "☁️" + elif cloudy_hours > 0: + status, icon = "CLOUDY", "☁️" else: status, icon = current_status, current_icon log.info( "Consensus: tonight=%s %s | now=%s %s | dark:%s→%s | imaging window:%s→%s " - "| clear:%dh abort:%dh | knmi:%.0f oktas ww:%.0f vv:%.0fm", + "| clear:%dh cloudy:%dh abort:%dh | knmi:%.0f oktas ww:%.0f vv:%.0fm", status, icon, current_status, current_icon, dark_start_str, dark_end_str, win_start_str or "none", win_end_str or "none", - clear_hours, abort_hours, + clear_hours, cloudy_hours, abort_hours, knmi_oktas or 0, knmi.get("ww") or 0, knmi.get("vv") or 0, @@ -694,19 +856,28 @@ def get_consensus(self): abort_reasons = list({r for _, a, r in hourly_evals if a and r}) log.info("Abort hours reasons: %s", "; ".join(abort_reasons)) + updated_utc = datetime.now(timezone.utc) state = { "_objective": ( - "Tri-source weather consensus v1.8. Hard aborts: rain/fog/thunder/wind. " - "Cloud cover is warning only. Per-hour evaluation within dark window." + "Multi-source weather consensus v1.8. Hard aborts: rain/fog/thunder/wind. " + "Cloud cover is a science no-go when configured limits are exceeded. " + "Per-hour evaluation within dark window." ), "status": status, "icon": icon, "current_status": current_status, "current_icon": current_icon, - "imaging_go": not now_abort, + "imaging_go": not now_abort and not now_cloudy, + "safe_to_open": not now_abort, + "science_go": bool(imaging_window), + "current_science_go": not now_cloudy, + "tonight_science_go": bool(imaging_window), + "current_reason": now_reason, + "cloud_reason": now_cloud_reason, "imaging_window_start": win_start_str, "imaging_window_end": win_end_str, "clear_hours": clear_hours, + "cloudy_hours": cloudy_hours, "abort_hours": abort_hours, "clouds_pct": clouds_pct, "humidity_pct": humidity_pct, @@ -720,7 +891,8 @@ def get_consensus(self): "dark_start": dark_start_str, "dark_end": dark_end_str, "hourly_detail": hourly_detail, - "last_update": time.time(), + "last_update": updated_utc.timestamp(), + "last_update_utc": updated_utc.isoformat(), } try: @@ -731,11 +903,54 @@ def get_consensus(self): log.error("Failed to write weather_state.json: %s", e) -if __name__ == "__main__": +def _print_manual_summary(state_path: Path) -> None: + """Print the fields an operator needs after a manual weather refresh.""" + try: + state = json.loads(state_path.read_text()) + except Exception as exc: + print(f"weather_state unavailable: {exc}") + return + + print(f"tonight : {state.get('status')} {state.get('icon', '')}") + print(f"now : {state.get('current_status')} {state.get('current_icon', '')}") + print(f"imaging_go : {state.get('imaging_go')}") + print(f"dark window : {state.get('dark_start')} -> {state.get('dark_end')}") + print(f"imaging window: {state.get('imaging_window_start')} -> {state.get('imaging_window_end')}") + print(f"clear/cloud/abort h : {state.get('clear_hours')} / {state.get('cloudy_hours')} / {state.get('abort_hours')}") + if state.get("cloud_reason"): + print(f"cloud reason : {state.get('cloud_reason')}") + print(f"clouds/humid : {state.get('clouds_pct')}% / {state.get('humidity_pct')}%") + print( + "KNMI : " + f"{state.get('knmi_station')} oktas={state.get('knmi_oktas')} " + f"ww={state.get('knmi_ww')} vv={state.get('knmi_vv_m')}m" + ) + + +def main() -> int: + """Run the weather sentinel as a daemon or one-shot manual check.""" + parser = argparse.ArgumentParser(description="SeeVar tri-source weather sentinel") + parser.add_argument( + "--once", + action="store_true", + help="fetch weather once, write data/weather_state.json, print a summary, and exit", + ) + args = parser.parse_args() + if not _acquire_singleton_lock(): - raise SystemExit(0) + return 0 log.info("WeatherSentinel v1.8.0 starting...") sentinel = WeatherSentinel() + + if args.once: + sentinel.get_consensus() + _print_manual_summary(sentinel.weather_state_file) + return 0 + while True: sentinel.get_consensus() time.sleep(POLL_INTERVAL_S) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/core/utils/env_loader.py b/core/utils/env_loader.py index a688258..59e0609 100644 --- a/core/utils/env_loader.py +++ b/core/utils/env_loader.py @@ -131,7 +131,7 @@ def effective_fleet_mode(cfg: dict | None = None) -> str: if requested == "auto": return "split" if active_count >= 2 else "single" if requested == "split": - return "split" if active_count >= 2 else "single" + return "split" return "single" diff --git a/dev/CONTRIBUTING.md b/dev/CONTRIBUTING.md index 756dc22..3e4c9d3 100644 --- a/dev/CONTRIBUTING.md +++ b/dev/CONTRIBUTING.md @@ -36,6 +36,5 @@ Do not write new code against the old “TCP 4700 as main control path” assump ## 5. Pull Request Protocol Before merging: 1. Verify the logic docs still reflect the real architecture. -2. Run the regression tests in `dev/test_*.py`. +2. Run the regression tests in `dev/tests/postflight/test_*.py`. 3. Keep changes scoped and scientifically honest. - diff --git a/dev/logic/AAVSO_LOGIC.MD b/dev/logic/AAVSO_LOGIC.MD index e0ee2c1..6e6ec25 100644 --- a/dev/logic/AAVSO_LOGIC.MD +++ b/dev/logic/AAVSO_LOGIC.MD @@ -69,8 +69,8 @@ Extraction: array[0::2, 0::2] and array[1::2, 1::2], averaged. - Output: `*_Green.fits` — single-channel float32, sky-subtracted **Acquisition** is via `DiamondSequence.acquire()` in `core/flight/pilot.py` -over direct TCP to port 4801. `start_exposure` is not a confirmed method -and is never used. See `API_PROTOCOL.MD`. +under the current Alpaca-era flight chain. See `API_PROTOCOL.MD` and +`ALPACA_BRIDGE.MD` for the active transport details. Sub-exposure range: 30s – 60s single RAW frames per target visit. diff --git a/dev/logic/ALPACA_BRIDGE.MD b/dev/logic/ALPACA_BRIDGE.MD index 469d50d..e980085 100644 --- a/dev/logic/ALPACA_BRIDGE.MD +++ b/dev/logic/ALPACA_BRIDGE.MD @@ -39,7 +39,10 @@ Port 4700 is retained read-only for battery_pct and charger_status, which are not exposed via Alpaca. WilhelminaMonitor listens passively. No commands are sent to port 4700. -**Ports 4801, 4800, 5432, 5555 — no longer used.** +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. ## Alpaca REST Conventions diff --git a/dev/logic/API_PROTOCOL.MD b/dev/logic/API_PROTOCOL.MD index 694d4d0..ae9b95c 100644 --- a/dev/logic/API_PROTOCOL.MD +++ b/dev/logic/API_PROTOCOL.MD @@ -22,11 +22,10 @@ Supporting paths: | `32323` | HTTP Alpaca (ASCOM) | Primary hardware control | | `32227` | UDP broadcast | Alpaca discovery | | `4700` | legacy / event-stream context only | Not the primary control doctrine | -| `4801` | binary stream | frame/event stream where applicable | +| `4801` | binary stream | historical frame/event diagnostics only | ## Contributor Rule New hardware-control code should target Alpaca-native behavior first. Do not reintroduce the old assumption that “SeeVar control = TCP 4700 JSON-RPC”. - diff --git a/dev/logic/BAA_LOGIC.MD b/dev/logic/BAA_LOGIC.MD index 7ac0ccf..42b9cc5 100644 --- a/dev/logic/BAA_LOGIC.MD +++ b/dev/logic/BAA_LOGIC.MD @@ -20,7 +20,7 @@ Implemented in: - `core/postflight/aavso_reporter.py` Test driver: -- `dev/tools/aavso_reporter_test.py` +- `dev/tools/reports/aavso_reporter_test.py` --- diff --git a/dev/logic/CADENCE.MD b/dev/logic/CADENCE.MD index 4ae5276..bbea487 100644 --- a/dev/logic/CADENCE.MD +++ b/dev/logic/CADENCE.MD @@ -49,20 +49,21 @@ See `AAVSO_LOGIC.MD` and `API_PROTOCOL.MD` for scoring detail. --- -## 🛠️ 3. Diamond Sequence Integration - -For every target cleared by cadence and altitude logic, the hardware -executes the **Diamond Sequence** via `core/flight/pilot.py`: - -1. **Clear** — `iscope_stop_view` on port 4700 — abort any active session. -2. **Slew** — `scope_sync [ra_hours, dec_deg]` on port 4700. -3. **Settle** — 8 second post-slew wait. -4. **Expose** — `iscope_start_view {"mode": "star"}` on port 4700. -5. **Capture** — read frame_id 21 from binary stream on port 4801 — - raw uint16 Bayer GRBG, 2160×3840. -6. **Stamp** — `sovereign_stamp()` writes AAVSO-compliant FITS header. -7. **Store** — `write_fits()` → `data/local_buffer/{TARGET}_{TS}_Raw.fits`. -8. **Stop** — `iscope_stop_view` to end session. +## 🛠️ 3. Flight Integration + +For every target cleared by cadence, horizon, weather, and timing logic, +`core/flight/orchestrator.py` executes the current `A1-A12` flight chain through +`core/flight/pilot.py` and `core/flight/fsm.py`. + +Flight captures raw FITS custody into: + +```text +data/local_buffer/{TARGET}_{TS}_Raw.fits +``` + +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. On success, `data/ledger.json` is updated with `last_success` timestamp, resetting the cadence clock for that target. diff --git a/dev/logic/COMMUNICATION.MD b/dev/logic/COMMUNICATION.MD index 8ec3f14..1bb707e 100644 --- a/dev/logic/COMMUNICATION.MD +++ b/dev/logic/COMMUNICATION.MD @@ -8,8 +8,8 @@ The TCP JSON-RPC protocol described below was the operational path during the Sovereign era (v1.0–v1.7). As of v3.0.0 (2026-03-30), all hardware control uses the official ZWO ASCOM Alpaca REST API. -Port 4700 is retained read-only for battery/charger telemetry via -WilhelminaMonitor. No commands are sent to port 4700. +Port 4700 may still be used by explicit diagnostics/RPC tools where documented, +but it is not the current production flight-control law. --- @@ -22,7 +22,7 @@ see `API_PROTOCOL.MD`. --- -## Three tiers — same interface +## Historical three-tier model The S30-Pro speaks JSON-RPC on port 4700 regardless of who is talking to it. Three tiers of control exist, all using the same wire format: @@ -30,20 +30,13 @@ to it. Three tiers of control exist, all using the same wire format: | Tier | Controller | Route | Status | |------|-----------|-------|--------| | 1 | ZWO phone app | Proprietary, closed | Not used by SeeVar | -| 2 | seestar_alp on Pi | JSON-RPC, Pi acts as server | Simulation only | -| 3 | SeeVar direct TCP | JSON-RPC direct to telescope | Production | +| 2 | seestar_alp on Pi | JSON-RPC/API bridge | Historical / diagnostic | +| 3 | SeeVar direct TCP | JSON-RPC direct to telescope | Historical | -**Simulation (now):** seestar_alp runs as a daemon on the Pi and -exposes the same JSON-RPC interface the real telescope will expose. -SeeVar connects to the Pi's own ethernet IP on port 4700. -The Pi is both client and server during simulation. +These notes describe the pre-Alpaca direct-control era and must not be used as +current production doctrine. -**Production (April 2026):** The S30-Pro joins the local network on -its own IP address. SeeVar connects directly to the telescope. -seestar_alp is no longer in the path. The wire format, the confirmed -methods, and the port numbers are identical. Only the IP changes. - -The telescope IP is set in `config.toml` under `[hardware]`. +The telescope IP is set in `config.toml` under `[[seestars]]`. It is never hardcoded in application logic or documentation. --- @@ -52,14 +45,15 @@ It is never hardcoded in application logic or documentation. | Port | Host | Protocol | Purpose | |------|------|----------|---------| -| `4700` | `` | JSON-RPC over TCP (`\r\n`) | All sovereign control | -| `4801` | `` | Binary frame stream | Science capture (preview frames) | -| `4800` | `` | Binary frame stream | ZIP stacks — not used for science | -| `5432` | `127.0.0.1` | HTTP (Alpaca) | Bridge health-check only | +| `4700` | `` | JSON-RPC over TCP (`\r\n`) | Historical / diagnostics | +| `4801` | `` | Binary frame stream | Historical frame diagnostics | +| `4800` | `` | Binary frame stream | Historical ZIP stacks | +| `5432` | `127.0.0.1` | HTTP | Historical bridge / current seestar_alp workbench | -`` is read from `config.toml [hardware] host` at runtime. +`` is read from `config.toml [[seestars]] ip` at runtime. -**Port 5555 does not exist. Port 4720 does not exist. Do not use either.** +Port `5555` exists only as a `seestar_alp` Alpaca action wrapper on the DEV RPI +when that service is running. It is not native Seestar firmware. --- @@ -72,9 +66,9 @@ Opened at session start by `ControlSocket`. Kept open for the duration of the session. All JSON-RPC commands go here. Closed after T6. **Frame connection (port 4801)** -Opened at T5 by `ImageSocket`. Opened only when a frame is expected. -Binary stream only — no JSON. Closed after one valid science frame -is received. +Opened at T5 by the old `ImageSocket`. Binary stream only — no JSON. +This is historical behavior; current science acquisition uses Alpaca camera +exposure/download paths. These are independent sockets. A failure on one does not close the other. @@ -125,7 +119,7 @@ a session lock. Send `iscope_stop_view` and retry. --- -## Wire format — port 4801 +## Historical wire format — port 4801 Binary stream. No JSON. No `\r\n` terminator. @@ -143,7 +137,7 @@ fields: _s1, _s2, _s3, size, _s5, _s6, code, frame_id, width, height **frame_id values:** | frame_id | Content | Action | |----------|---------|--------| -| 21 | Raw uint16 Bayer GRBG preview | Science target — read payload | +| 21 | Raw uint16 Bayer GRBG preview | Historical science frame | | 23 | ZIP stack | Not used for science — skip | | any | payload < 1000 bytes | Heartbeat packet — skip | diff --git a/dev/logic/DATA_MAPPING.MD b/dev/logic/DATA_MAPPING.MD index d315752..9269d34 100644 --- a/dev/logic/DATA_MAPPING.MD +++ b/dev/logic/DATA_MAPPING.MD @@ -17,7 +17,7 @@ Cadence and targeting logic: `AAVSO_LOGIC.MD`. | 3. Sequence | `core/preflight/chart_fetcher.py` | VSP API (`apps.aavso.org`) | `catalogs/reference_stars/*.json` | | 4. Audit | `core/preflight/audit.py` | `federation_catalog.json` + `ledger.json` | `federation_catalog.json` (annotated) | | 5. Plan | `core/preflight/nightly_planner.py` | `federation_catalog.json` + GPS | `data/tonights_plan.json` | -| 6. Acquire | `core/flight/pilot.py` `DiamondSequence` | `tonights_plan.json` + TCP port 4801 | `data/local_buffer/{TARGET}_{TS}_Raw.fits` | +| 6. Acquire | `core/flight/pilot.py` `DiamondSequence` | `tonights_plan.json` + Alpaca-era telescope control | `data/local_buffer/{TARGET}_{TS}_Raw.fits` | | 7. Extract | `core/postflight/science_processor.py` | `*_Raw.fits` | `*_Green.fits` (Johnson V proxy) | | 8. Ledger | `core/flight/orchestrator.py` | acquisition result | `data/ledger.json` (`last_success`, `attempts`) | | 9. Custody | NAS rsync | `data/local_buffer/` | `/mnt/astronas/{TARGET}/` | diff --git a/dev/logic/FILE_MANIFEST.md b/dev/logic/FILE_MANIFEST.md index 3b7f9b5..9ae27a8 100644 --- a/dev/logic/FILE_MANIFEST.md +++ b/dev/logic/FILE_MANIFEST.md @@ -112,20 +112,20 @@ | dev/logic/SIMULATORLOGIC.MD | 2.0.0 (Sovereign A1-A12) | Outlines networking and state logic required to synchronize the SeeStar ALP Bridge with the Raspberry Pi Simulator en... | | dev/logic/STATE_MACHINE.MD | 5.0.0 (Sovereign A1-A12) | Deterministic hardware transitions for AAVSO acquisition | | dev/logic/WORKFLOW.MD | 1.9.0 | Describe the full operational flow of SeeVar from preflight through flight, postflight, and parked state using the cu... | -| dev/test_calibration_assets.py | 1.0.0 | Smoke-test calibration asset requirement summaries without FITS dependencies. | -| dev/test_dark_postflight.py | 1.0.1 | Smoke-test the dark calibration + accountant closure path without hardware. | -| dev/test_postflight_low_snr.py | 1.0.0 | Verify postflight rejects a dark-calibrated frame when photometric SNR is too low. | -| dev/test_postflight_no_dark.py | 1.0.0 | Verify postflight fails honestly when no matching master dark exists. | -| dev/test_synthetic_imx585_field.py | 1.0.0 | End-to-end synthetic IMX585-style postflight rehearsal. | -| dev/test_tcrb_s30_s50_field.py | 1.0.0 | Rehearse postflight on T CrB-inspired synthetic S30 and S50 fields. | -| dev/tools/aavso_reporter_test.py | 1.0.0 | Generate a small dummy AAVSO Extended Format report for WebObs preview testing, or the BAA-modified AAVSO Extended va... | -| dev/tools/clean_postflight_remnants.py | N/A | Dry-run-first cleanup tool for transient astrometry solver products in SeeVar data directories. | -| dev/tools/horizon_audit.py | 1.0.1 | Audit tonights_plan.json against the real camera-scanned horizon mask. Shows how many targets are observable tonight... | -| dev/tools/install_horizon_mask.py | N/A | Install a candidate horizon_mask.json into the SeeVar runtime data dir with a timestamped backup of any existing mask. | -| dev/tools/package_sector_panorama.py | N/A | Package a pre-stitched panorama sector plus a SeeVar horizon mask into the conservative Stellarium spherical landscap... | -| dev/tools/prealign_pointing.py | 1.0.0 | Build a quick SeeVar software pointing model from 2-3 bright plate-solved alignment stars before starting a science s... | -| dev/tools/rpc_client.py | 2.0.1 | Interactive JSON-RPC client for Seestar port 4700 using pre-built sovereign payloads. | -| dev/tools/session_triage.py | N/A | Summarise the last SeeVar observing session from logs, ledger, plan, and data buffers without touching telescope state. | +| dev/tests/postflight/test_calibration_assets.py | 1.0.0 | Smoke-test calibration asset requirement summaries without FITS dependencies. | +| dev/tests/postflight/test_dark_postflight.py | 1.0.1 | Smoke-test the dark calibration + accountant closure path without hardware. | +| dev/tests/postflight/test_postflight_low_snr.py | 1.0.0 | Verify postflight rejects a dark-calibrated frame when photometric SNR is too low. | +| dev/tests/postflight/test_postflight_no_dark.py | 1.0.0 | Verify postflight fails honestly when no matching master dark exists. | +| dev/tests/postflight/test_synthetic_imx585_field.py | 1.0.0 | End-to-end synthetic IMX585-style postflight rehearsal. | +| dev/tests/postflight/test_tcrb_s30_s50_field.py | 1.0.0 | Rehearse postflight on T CrB-inspired synthetic S30 and S50 fields. | +| dev/tools/reports/aavso_reporter_test.py | 1.0.0 | Generate a small dummy AAVSO Extended Format report for WebObs preview testing, or the BAA-modified AAVSO Extended va... | +| dev/tools/ops/clean_postflight_remnants.py | N/A | Dry-run-first cleanup tool for transient astrometry solver products in SeeVar data directories. | +| dev/tools/horizon/horizon_audit.py | 1.0.1 | Audit tonights_plan.json against the real camera-scanned horizon mask. Shows how many targets are observable tonight... | +| dev/tools/horizon/install_horizon_mask.py | N/A | Install a candidate horizon_mask.json into the SeeVar runtime data dir with a timestamped backup of any existing mask. | +| dev/tools/horizon/package_sector_panorama.py | N/A | Package a pre-stitched panorama sector plus a SeeVar horizon mask into the conservative Stellarium spherical landscap... | +| dev/tools/telescope/prealign_pointing.py | 1.0.0 | Build a quick SeeVar software pointing model from 2-3 bright plate-solved alignment stars before starting a science s... | +| dev/tools/telescope/rpc_client.py | 2.0.1 | Interactive JSON-RPC client for Seestar port 4700 using pre-built sovereign payloads. | +| dev/tools/ops/session_triage.py | N/A | Summarise the last SeeVar observing session from logs, ledger, plan, and data buffers without touching telescope state. | | dev/utils/comp_purger.py | 1.1.1 | Prunes orphaned comparison star charts in the SeeVar catalog. | | dev/utils/generate_manifest.py | 1.6.2 | Generate FILE_MANIFEST.md for SeeVar and mirror it to NAS while excluding transient runtime data, generated science p... | | dev/utils/harvest_manager.py | 1.3.1 | SeeVar Harvester - Supports simulation data (.fit) and real FITS. | diff --git a/dev/logic/README.MD b/dev/logic/README.MD index 5b82286..4c6f8ad 100644 --- a/dev/logic/README.MD +++ b/dev/logic/README.MD @@ -39,6 +39,8 @@ postflight, oversight, and science rules. | `BAA_LOGIC.MD` | BAA VSS export conventions and Seestar-specific reporting defaults | | `API_PROTOCOL.MD` | Confirmed hardware/API behavior and protocol detail | | `ALPACA_BRIDGE.MD` | Alpaca role, device map, and transport notes | +| `SEESTAR_SSH_ACCESS.MD` | Optional owned-scope SSH diagnostics, file paths, and safety rules | +| `SEESTAR_ALP_API.MD` | seestar_alp Bruno API workbench, PEM warning, and tested endpoint notes | | `SIMULATORLOGIC.MD` | Simulation obligations and flight mirroring rules | | `WORKFLOW.MD` | Narrative system walkthrough — currently due for rewrite | | `FILE_MANIFEST.md` | Generated manifest — do not edit manually | diff --git a/dev/logic/SEESTAR_ALP_API.MD b/dev/logic/SEESTAR_ALP_API.MD new file mode 100644 index 0000000..94e6ecb --- /dev/null +++ b/dev/logic/SEESTAR_ALP_API.MD @@ -0,0 +1,62 @@ +# Seestar ALP API Notes + +Objective: record the confirmed `seestar_alp` API workbench path for diagnostics and future SeeVar integration. + +## Role + +`seestar_alp` talks to the native Seestar API and exposes it through an Alpaca-style action wrapper. It is not the same as using the limited firmware Alpaca implementation directly. + +Decision: `seestar_alp` is diagnostics/API workbench by default. It may become controlled tooling only when `[seestar_alp].enabled = true` and `[seestar_alp].mode = "controlled"` are set in `config.toml`. Normal photometry flight stays on firmware Alpaca. + +Use it as an API workbench and controlled integration candidate. Do not make it the primary flight path until the relevant calls have been tested under a real run. + +## Bruno Collection + +The DEV RPI has the Bruno collection here: + +```bash +/home/ed/seestar_alp/bruno/Seestar Alpaca API +``` + +Open it with Bruno or run it with the `bru` CLI. Use the collection to inspect request bodies and export known-good calls to `curl` or Python. + +The collection uses Alpaca action requests: + +```bash +curl -X PUT "http://127.0.0.1:5555/api/v1/telescope/1/action" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "Action=start_mosaic" \ + --data-urlencode 'Parameters={"target_name":"M 3","ra":"13h42m11.8s","dec":"+28d22m38s","is_j2000":true,"is_use_lp_filter":false,"gain":80}' \ + --data-urlencode "ClientID=1" \ + --data-urlencode "ClientTransactionID=999" +``` + +JSON request bodies may work through helper tools, but the Bruno ground truth is form-url-encoded Alpaca action wrapping. + +## Confirmed Useful Calls + +| Area | Action | +| --- | --- | +| Scheduler | `create_schedule`, `add_schedule_item`, `get_schedule`, `start_scheduler`, `stop_scheduler` | +| Imaging | `start_mosaic`, `capture_target`, exposure/gain setting actions | +| Pointing | `goto_target`, `start_solve`, `get_solve_result`, `get_last_solve_result`, `sync_scope` | +| Startup/shutdown | `start_up_sequence`, `pi_shutdown`, `pi_reboot` | +| Environment | dew heater via `pi_output_set2` with level value | +| Files/status | `get_last_image`, `get_device_state`, `get_setting`, disk volume and stack state calls | + +## Authentication + +Newer Seestar firmware uses challenge-response authentication for the native API. `seestar_alp` can use the extracted app PEM when configured for it. + +The PEM is a runtime secret and must never be committed: + +```bash +/home/ed/.config/seestar/seestar_3.1.2.pem +``` + +## Notes + +- `seestar_alp` can do features not exposed cleanly by pure Alpaca, including Seestar mosaics and heater level control. +- Dithering behavior may differ from native app imaging. +- Keep SeeVar production control on confirmed paths only. +- Prefer Bruno for discovery, then implement the smallest tested call in SeeVar. diff --git a/dev/logic/SEESTAR_SSH_ACCESS.MD b/dev/logic/SEESTAR_SSH_ACCESS.MD new file mode 100644 index 0000000..ca2ba72 --- /dev/null +++ b/dev/logic/SEESTAR_SSH_ACCESS.MD @@ -0,0 +1,90 @@ +# Seestar SSH Access + +Objective: define the safe SeeVar use of optional SSH access on owned Seestar scopes. + +## Scope + +SSH is for diagnostics and controlled file exchange only. SeeVar should keep normal +flight control on Alpaca/RPC paths unless a specific tool states otherwise. + +## Useful Paths + +| Path | Purpose | +| --- | --- | +| `/home/pi/.ZWO/view_plan.json` | App-side plan/session state. Candidate for app-compatible plan export. | +| `/home/pi/.ZWO/userData.db3` | ZWO app state database. Inspect read-only unless format is understood. | +| `/usr/local/astrometry/data/` | Onboard Astrometry.net Tycho index files. Useful for local solve inventory. | +| `/boot/Image/MyWorks/` | User media/FITS products exposed by the scope. | +| `/oem/zwo/iqfiles/` | Camera/image-quality profiles. Read-only reference. | + +## Tooling + +`dev/tools/telescope/seestar_ssh_probe.py` performs a read-only health and inventory probe: + +```bash +python3 dev/tools/telescope/seestar_ssh_probe.py --user pi +python3 dev/tools/telescope/seestar_ssh_probe.py --user ed --json +python3 dev/tools/telescope/seestar_ssh_probe.py --host 192.168.178.251 +``` + +The probe reports OS/kernel, MAC/IP state, storage, `.ZWO` files, `view_plan.json` +summary, and onboard astrometry indexes. + +## Seestar Proxy + +`seestar-proxy` is installed on the DEV RPI as `/home/ed/bin/seestar-proxy`. +It is configured as user-systemd services with discovery disabled and non-standard +ports, so it does not hijack normal Seestar app discovery. + +| Scope | Service | Dashboard | Control | Imaging | +| --- | --- | --- | --- | --- | +| Wilhelmina | `seestar-proxy@wilhelmina.service` | `http://192.168.178.57:14090` | `192.168.178.57:14700` | `192.168.178.57:14800` | +| Anna | `seestar-proxy@anna.service` | `http://192.168.178.57:24090` | `192.168.178.57:24700` | `192.168.178.57:24800` | + +```bash +systemctl --user start seestar-proxy@anna.service +systemctl --user stop seestar-proxy@anna.service +systemctl --user status seestar-proxy@anna.service +``` + +Use the proxy for diagnostics, protocol recording, and multi-client testing. Keep +SeeVar production flight paths on Alpaca/RPC until proxy behavior has been tested. + +## APK PEM + +The Seestar app `3.1.2` / firmware `7.32` PEM is stored on the DEV RPI only: + +```bash +/home/ed/.config/seestar/seestar_3.1.2.pem +``` + +Permissions must remain `0600`. Do not commit PEM files; `.gitignore` blocks +`*.pem`. + +`seestar-tool` is installed on the DEV RPI as `/usr/bin/seestar-tool`. It is +GUI/TUI-only, so diagnostics are interactive: + +```bash +seestar-tool --tui +``` + +Use the Diagnostics tab with scope IP and PEM path. Treat this as a manual +interoperability diagnostic path, not a SeeVar flight-control dependency. + +For non-interactive read-only diagnostics, use SeeVar's PEM probe: + +```bash +python3 dev/tools/telescope/seestar_pem_diagnostics.py +python3 dev/tools/telescope/seestar_pem_diagnostics.py --host 192.168.178.252 --json +``` + +The probe authenticates on port `4700`, then calls `get_device_state` and +`pi_get_info` only. + +## Rules + +- Prefer read-only SSH operations. +- Do not run package upgrades on the scope. +- Do not change ZWO system services unless recovery is understood. +- Remount root read-write only for deliberate account/key maintenance. +- Keep firmware payload changes small and reversible. diff --git a/dev/logic/SEEVAR_DICT.PSV b/dev/logic/SEEVAR_DICT.PSV index 4c28c4f..8ad09b8 100644 --- a/dev/logic/SEEVAR_DICT.PSV +++ b/dev/logic/SEEVAR_DICT.PSV @@ -2,21 +2,21 @@ # Version: 2026.03 (annotated) # Path: logic/SEEVAR_DICT.PSV # -# Empirical vocabulary dump from seestar_alp bridge testing (Kriel era). -# All entries were verified against the Alpaca /1/command wrapper on -# port 5432 (bridge path). Method names are still valid on sovereign -# TCP port 4700 — only the transport layer changed. +# Historical empirical vocabulary dump from seestar_alp bridge testing +# (Kriel era). Entries were verified against the old Alpaca /1/command +# wrapper on port 5432. Treat this as a diagnostics/protocol reference, +# not current production flight law. # # Reading guide: # Endpoint_Payload — the Alpaca wrapper form used during testing # Expected_Response — observed JSON response (truncated with ...) # -# CONFIRMED for sovereign TCP (port 4700, JSON-RPC): +# CONFIRMED historical/diagnostic JSON-RPC methods: # get_device_state, iscope_stop_view, iscope_start_view, # scope_sync, scope_goto, scope_park, set_control_value, # set_setting, start_solve, get_solve_result, start_auto_focuse # -# NOT CONFIRMED / DO NOT USE in sovereign path: +# NOT CONFIRMED / DO NOT USE in production flight: # start_exposure, get_last_image, get_stacked_img, # iscope_get_app_state, method_sync # diff --git a/dev/logic/SEEVAR_SKILL/SKILL.md b/dev/logic/SEEVAR_SKILL/SKILL.md index 8bed605..e22c64a 100644 --- a/dev/logic/SEEVAR_SKILL/SKILL.md +++ b/dev/logic/SEEVAR_SKILL/SKILL.md @@ -4,7 +4,7 @@ description: > Use this skill for ALL work on the SeeVar autonomous variable star observatory project. Trigger on any mention of: SeeVar, pilot.py, orchestrator.py, DiamondSequence, S30-Pro telescope, AAVSO photometry, - Seestar, port 4700, port 4801, scope_goto, iscope_start_view, Wilhelmina, + Seestar, Alpaca, seestar_alp, scope_goto, iscope_start_view, Wilhelmina, REDA observer code, or any request to write heredoc deploy scripts for the Pi. This skill contains the complete architectural law, confirmed hardware constants, wire protocol, and session rules. Never write a @@ -25,7 +25,7 @@ description: > 1. No vibe-coding — all logic maps to logic/ documents 2. Deploy via heredoc .sh scripts — idempotent, Garmt header standard 3. Garmt header: Filename, Version, Objective in every .py file -4. Sovereignty Principle: all hardware via direct TCP port 4700 (JSON-RPC) +4. Hardware control follows the current Alpaca-era SeeVar flight path unless a diagnostic tool explicitly states otherwise. 5. AAVSO API throttle: **188.4s** — Pi was blocked at 3.14s on 2026-03-13 6. Read logic/ documents BEFORE writing code — always request them first 7. Never invent key names, port numbers, method names, response shapes @@ -39,24 +39,23 @@ description: > - Verify anchor strings against actual file before writing patches - If anchor not found: show file content, fix anchor, never guess -## Port architecture -| Port | Host | Purpose | -|------|------|---------| -| 4700 | `` | JSON-RPC sovereign control — ALL hardware | -| 4801 | `` | Binary frame stream — science capture only | -| 5432 | 127.0.0.1 | Alpaca bridge health-check ONLY | +## Transport architecture +Current production flight control is Alpaca-era SeeVar control through +`core/flight/pilot.py`, `core/flight/fsm.py`, and `core/flight/orchestrator.py`. -`` from config.toml `[[seestars]] ip` — never hardcoded. -Port 5555 does not exist. Port 4720 does not exist. Never use either. +`seestar_alp` and direct JSON-RPC are diagnostic/integration paths unless a +specific SeeVar tool marks a call as production-safe. -## JSON-RPC wire format (port 4700) +`` comes from config.toml `[[seestars]] ip` — never hardcoded. + +## Historical/diagnostic JSON-RPC wire format (port 4700) ```python msg = {"id": , "method": "", "params": } wire = (json.dumps(msg) + "\r\n").encode("utf-8") ``` `id` starts at 10000, increments per session. `params` omitted if not needed. -## Confirmed methods — port 4700 +## Historical/diagnostic methods — port 4700 ### Session - `get_device_state` — health probe, parse TelemetryBlock - `iscope_stop_view` — abort all active operations (always first) @@ -94,7 +93,14 @@ S6: scope_set_track_state [true] — explicit unpark S7: scope_get_ra_dec — confirm mount live ``` -## Per-target Diamond Sequence T1–T7 +## Historical direct-TCP Diamond Sequence + +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`. + +## Historical per-target TCP sequence T1–T7 ``` T1: set_setting exp_ms — from exposure_planner T2: scope_goto [ra_h, dec_d] — slew + sleep 8s settle @@ -105,7 +111,7 @@ T6: iscope_stop_view — close + get_device_state veto check T7: write_fits sovereign_stamp — RAID1 local_buffer ``` -## Binary frame protocol (port 4801) +## Historical binary frame protocol (port 4801) ``` Header: 80 bytes, fmt ">HHHIHHBBHH", first 20 bytes used frame_id 21 = RAW uint16 Bayer GRBG (science) diff --git a/dev/logic/WORKFLOW.MD b/dev/logic/WORKFLOW.MD index 01a9206..3dd07e3 100644 --- a/dev/logic/WORKFLOW.MD +++ b/dev/logic/WORKFLOW.MD @@ -1,296 +1,173 @@ -# 🗺️ SEEVAR: MISSION WORKFLOW +# SeeVar Mission Workflow -> **Objective:** Describe the full operational flow of SeeVar from preflight through flight, postflight, and parked state using the current sovereign architecture. -> **Version:** 1.9.0 +> **Objective:** Current operational flow from preflight to parked state. +> **Version:** 2.0.0 > **Path:** `dev/logic/WORKFLOW.MD` -SeeVar runs as a deterministic observatory pipeline. +SeeVar is a deterministic observatory pipeline. -It no longer exists as a loose wrapper around Seestar tooling. -It is now a sovereign workflow with explicit authority boundaries: +The current production control layer is Alpaca-era hardware control with optional diagnostic/API workbench paths. Older direct-TCP JSON-RPC documents are historical unless explicitly marked as still used for a narrow diagnostic function. -- PREFLIGHT decides whether the mission may happen -- FLIGHT captures trustworthy raw science frames -- POSTFLIGHT decides whether those frames become trustworthy science -- PARKED and ABORTED preserve state and stop unsafe continuation +Authority boundaries: -This document is the narrative overview. -The detailed doctrinal documents remain: +- `PREFLIGHT` decides whether a mission may happen. +- `PLANNING` locks the executable target order from the preflight artifact. +- `FLIGHT` captures trustworthy raw frames. +- `POSTFLIGHT` decides whether frames become trustworthy science. +- `PARKED` and `ABORTED` stop unsafe or finished operations without hiding state. -- `PREFLIGHT.MD` -- `FLIGHT.MD` -- `POSTFLIGHT.MD` -- `STATE_MACHINE.MD` - ---- - -## State Overview +## State Chain ```text -IDLE → PREFLIGHT → PLANNING → FLIGHT → POSTFLIGHT → PARKED - ↓ - ABORTED -Each state has one purpose. -No state should silently take over another state's responsibilities. - -Phase 1 — PREFLIGHT -Entry condition: mission window approaching or simulation start -Exit condition: all hard gates pass -Abort condition: any hard veto fails - -Preflight answers three questions: - -is the system safe to operate? -is the observatory context valid? -what is tonight's canonical target list? -1.1 Environment and location truth -SeeVar first establishes observational context: - -site coordinates -system time -astronomical dark window -local horizon constraints -storage availability -Location and time truth matter because all planning, timestamping, and altitude logic depend on them. - -1.2 Catalog and cadence truth -The science catalog is filtered into an actionable mission set. - -This stage includes: - -target catalog readiness -comparison-star availability -cadence enforcement from the ledger -scientific due-ness for the current night -Targets that are not due should not enter the nightly plan. - -1.3 Weather and hardware gate -Before motion, the observatory must pass its hard vetoes. - -Typical checks include: - -weather imaging go/no-go -hardware reachability -thermal safety -power safety -storage readiness -zero-state or neutralizer readiness -A failed hard veto must stop the mission before flight begins. - -1.4 Nightly plan generation -Preflight then produces the authoritative mission artifact: - -data/tonights_plan.json -This file is the canonical handoff into flight. - -It represents the planner's truth after: - -cadence -astronomical dark -local horizon clearance -required observing window -ranking and ordering -Flight must consume this plan. -It must not silently invent its own competing planner. - -Phase 2 — PLANNING -Entry condition: preflight passed -Exit condition: executable target list locked -Abort condition: no viable targets remain - -Planning in the modern architecture is intentionally narrow. - -Its job is not to rebuild the night from scratch. -Its job is to prepare the already-approved nightly plan for execution. +IDLE -> PREFLIGHT -> PLANNING -> FLIGHT -> POSTFLIGHT -> PARKED + \ + -> ABORTED +``` -That means: +No state may silently replace another state's responsibility. -load tonights_plan.json -sort by canonical planner order if needed -reject expired windows -preserve the planner's authority -hand targets into flight cleanly -This phase exists to translate preflight products into executable mission order, not to replace them. +## Canonical Artifacts -Phase 3 — FLIGHT -Entry condition: executable targets exist -Exit condition: target list exhausted, dawn, or postflight handoff -Abort condition: hard safety veto during active mission +| Artifact | Owner | Meaning | +| --- | --- | --- | +| `config.toml` | Operator | Site, scope, planner, postflight, storage configuration | +| `data/weather_state.json` | Weather sentinel | Current and forecast safety context | +| `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/local_buffer/*.fits` | Flight | Raw frame custody | +| `data/calibrated_buffer/*` | Postflight | Working calibrated products | +| `data/archive/*` | Postflight | Durable observation custody | +| `data/ledger.json` | Postflight | Scientific outcome ledger | +| `data/reports/*` | Postflight/reporting | AAVSO/BAA/report artifacts | -Flight owns one thing: -capturing trustworthy raw science frames. +## Phase 1: IDLE -It does not own deep science reduction. -It does not own final photometric acceptance. -It does not own report publication. +`IDLE` waits for a valid observing opportunity. -Its canonical per-target chain is A1-A12. +It reports the dark window and current hold reason. It must re-read current configuration/weather before entering preflight so stale dashboard or planner state does not govern a live run. -The Sovereign Flight Sequence (A1-P12? No: A1-A12) -Each target executes the same deterministic chain. +## Phase 2: PREFLIGHT -A1 — Target Lock -Load the next target from tonights_plan.json. +Preflight answers one question: may the system attempt a mission now? -A2 — Safety Gate -Refresh hard veto state before movement. +Checks include: -A3 — Session Init -Establish live telescope, camera, and telemetry state. +- current config and selected fleet mode +- system time and site coordinates +- astronomical dark window +- weather hard vetoes +- horizon/profile constraints +- storage availability +- scope reachability and telemetry +- zero-state / neutralizer readiness +- optional pre-alignment / pointing model refresh -A4 — Slew Command -Command the telescope to the target coordinates. +Preflight produces or validates: -A5 — Slew Verify -Confirm that the mount has completed the commanded movement. - -A6 — Settle -Allow post-slew mechanical stability. - -A7 — Pointing Verify -Confirm actual pointing against intended pointing. - -A8 — Corrective Nudge -Re-slew or adjust if pointing error exceeds tolerance. - -A9 — Exposure Plan -Choose the actual science exposure parameters. - -A10 — Acquire -Capture the science frame and write raw FITS custody. +```text +data/tonights_plan.json +``` -A11 — Quality Gate -Perform immediate operational checks on the captured frame. +That file is the canonical handoff. Flight must not invent a competing target list. -A12 — Commit -Record the target attempt/result and hand the frame into postflight custody. +## Phase 3: PLANNING -Flight ends when a trustworthy raw frame has been captured and committed. +Planning is intentionally narrow. -Flight Outputs -Flight produces operational custody artifacts, not final science verdicts. +It loads `tonights_plan.json`, preserves planner order, removes expired windows, applies any mission cap, and locks the executable list. -Typical outputs include: +It does not redo cadence scoring or replace preflight filtering. -raw science FITS in data/local_buffer/ -live mission state in data/system_state.json -updated ledger attempt/success state -flight logs and telemetry traces -The key rule is: -a frame can be operationally successful in flight and still fail scientifically in postflight. +## Phase 4: FLIGHT -Phase 4 — POSTFLIGHT -Entry condition: flight complete or mission halted after capture phase -Exit condition: all eligible frames judged and committed -Failure mode: individual frames may fail without invalidating the whole night +Flight owns capture, not final science. -Postflight exists to establish scientific trust. +Each target follows the canonical `A1-A12` chain: -This is where SeeVar decides whether a raw frame is good enough to become an observation. +| Step | Name | Purpose | +| --- | --- | --- | +| `A1` | Target Lock | Select next approved target | +| `A2` | Safety Gate | Refresh hard veto state before movement | +| `A3` | Session Init | Establish live telemetry and session state | +| `A4` | Slew Command | Move to intended coordinates | +| `A5` | Slew Verify | Confirm movement completed | +| `A6` | Settle | Allow mechanical/optical settling | +| `A7` | Pointing Verify | Plate-solve or otherwise verify pointing | +| `A8` | Corrective Nudge | Re-slew/sync/nudge within configured tolerance | +| `A9` | Exposure Plan | Choose exposure, gain, frame count | +| `A10` | Acquire | Capture science frame(s) | +| `A11` | Quality Gate | Operational frame sanity checks | +| `A12` | Commit | Stamp custody and ledger attempt state | -Its canonical chain is P1-P8. +A target can pass flight and still fail postflight. -The Sovereign Postflight Sequence (P1-P8) -P1 — Ingest -Read and validate the raw FITS file and recover target identity. +Flight outputs raw custody: -P2 — Calibration Match -Find the best detector calibration assets for the frame. +- FITS files in `data/local_buffer/` +- live state in `data/system_state*.json` +- logs and telemetry +- ledger attempt/capture status -P3 — Calibration Apply -Apply dark subtraction and later flat correction on derived working products. +## Phase 5: POSTFLIGHT -P4 — Astrometric Solve -Produce a real solved WCS for the frame. +Postflight owns scientific truth. -P5 — Source Measurement -Measure target and candidate comp stars using Bayer-aware methods. +It follows `P1-P8`: -P6 — Ensemble Calibration -Compute the differential magnitude from the comparison-star ensemble. +| Step | Name | Purpose | +| --- | --- | --- | +| `P1` | Ingest | Read raw FITS and identify target group | +| `P2` | Calibration Match | Select dark/flat/bias assets | +| `P3` | Calibration Apply | Produce calibrated working frames | +| `P4` | Astrometric Solve | Attach real WCS truth | +| `P5` | Source Measurement | Measure target and comparison stars | +| `P6` | Ensemble Calibration | Compute calibrated magnitude | +| `P7` | Quality Verdict | Apply saturation, SNR, comp, fit, residual gates | +| `P8` | Commit And Report | Archive, ledger, report, and cleanup | -P7 — Quality Verdict -Apply scientific pass/fail gates. +Postflight must fail honestly. No report is produced from an unproven observation. -P8 — Commit And Report -Write durable ledger/archive/report products. +## Optional Secondary Imaging -Postflight ends only after the frame is either accepted honestly or failed honestly. +After photometric work, SeeVar may run a secondary imaging queue if enabled. -Postflight Outputs -Postflight produces scientific custody artifacts. +This is for Messier/Caldwell/Herschel-style filler targets. It must obey weather, horizon, daylight, storage, and battery guards. It is not AAVSO/BAA science unless separately reduced and proven. -Typical outputs include: +## PARKED -updated data/ledger.json -archived frame custody -Gaia cache products -dark-library usage records -staged AAVSO report artifacts -future QC artifacts and summary products -This is the phase that turns raw frames into scientific records. +`PARKED` is the normal completed state. -PARKED -Entry condition: postflight finished, mission complete, or safe stop required +The scope is parked or shutdown according to config. State remains visible for the dashboard and logs. -The observatory is no longer active. -Hardware is safe. -State is preserved. -The system waits for the next mission opportunity. +## ABORTED -PARKED is a normal end state. +`ABORTED` means control must stop. -ABORTED -Entry condition: hard veto or unrecoverable mission failure +It can be caused by: -ABORTED is not a scientific judgment on all data. -It is a control-state judgment that the mission must stop. +- weather hard veto +- battery/thermal/storage failure +- unrecoverable hardware or API failure +- no viable targets +- operator abort -Examples: +Abort does not automatically invalidate already captured frames. Postflight may still process frames with valid custody. -unsafe power state -unsafe thermal state -weather hard abort -irrecoverable hardware failure -no viable targets -If useful data was already captured before abort, that data may still proceed into postflight, depending on custody rules. +## Transport Law -Current Scientific Boundary -The most important architectural rule is this: +Current production control is Alpaca-era SeeVar control through `core/flight/pilot.py`, `core/flight/fsm.py`, and `core/flight/orchestrator.py`. -Flight proves: -a trustworthy raw frame was captured -Postflight proves: -the frame supports trustworthy science -That separation protects the observatory from confusing capture success with scientific success. +`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`. -Canonical Artifacts -Artifact Authority Meaning -data/tonights_plan.json PREFLIGHT canonical nightly mission list -data/system_state.json FLIGHT / orchestrator live mission state -data/local_buffer/*.fits FLIGHT raw frame custody -data/ledger.json POSTFLIGHT scientific outcome ledger -data/archive/* POSTFLIGHT archived observation custody -data/reports/* POSTFLIGHT publication-ready outputs -Current Version Story -1.7.x -Mission contract restoration. +Native Seestar SSH access is diagnostic and file-oriented only. It is documented in `SEESTAR_SSH_ACCESS.MD`. -1.8.x -Flight hardening and A1-A12 alignment. +Older direct-TCP JSON-RPC notes remain historical unless a document explicitly marks a specific method as still used for telemetry or diagnostics. -1.9.x -Postflight scientific hardening: +## Operating Principle -P1-P8 -astrometric truth -detector truth -robust ensemble photometry -deterministic reporting -Operating Principle SeeVar is not allowed to pretend. -If a frame is not proven, it is not accepted. -If a mission state is not authoritative, it must not silently overrule one that is. - -That principle applies to both code and science. +If a plan is stale, it must be refreshed or rejected. +If a frame is not proven, it must not be accepted. +If a transport path is experimental, it must not be described as production law. diff --git a/dev/tests/README.md b/dev/tests/README.md new file mode 100644 index 0000000..6d7847c --- /dev/null +++ b/dev/tests/README.md @@ -0,0 +1,3 @@ +# SeeVar Dev Tests + +- `postflight/`: Hardware-free smoke tests and synthetic rehearsals for calibration, dark handling, stacking, photometry rejection, and ledger closure. diff --git a/dev/test_calibration_assets.py b/dev/tests/postflight/test_calibration_assets.py similarity index 98% rename from dev/test_calibration_assets.py rename to dev/tests/postflight/test_calibration_assets.py index 84d7e11..4fb92fe 100644 --- a/dev/test_calibration_assets.py +++ b/dev/tests/postflight/test_calibration_assets.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/test_calibration_assets.py +Filename: dev/tests/postflight/test_calibration_assets.py Version: 1.0.0 Objective: Smoke-test calibration asset requirement summaries without FITS dependencies. """ diff --git a/dev/test_dark_postflight.py b/dev/tests/postflight/test_dark_postflight.py similarity index 99% rename from dev/test_dark_postflight.py rename to dev/tests/postflight/test_dark_postflight.py index ce1270e..8012cdc 100644 --- a/dev/test_dark_postflight.py +++ b/dev/tests/postflight/test_dark_postflight.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/test_dark_postflight.py +Filename: dev/tests/postflight/test_dark_postflight.py Version: 1.0.1 Objective: Smoke-test the dark calibration + accountant closure path without hardware. diff --git a/dev/test_postflight_low_snr.py b/dev/tests/postflight/test_postflight_low_snr.py similarity index 99% rename from dev/test_postflight_low_snr.py rename to dev/tests/postflight/test_postflight_low_snr.py index 30cdafe..19609b5 100644 --- a/dev/test_postflight_low_snr.py +++ b/dev/tests/postflight/test_postflight_low_snr.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/test_postflight_low_snr.py +Filename: dev/tests/postflight/test_postflight_low_snr.py Version: 1.0.0 Objective: Verify postflight rejects a dark-calibrated frame when photometric SNR is too low. """ diff --git a/dev/test_postflight_no_dark.py b/dev/tests/postflight/test_postflight_no_dark.py similarity index 98% rename from dev/test_postflight_no_dark.py rename to dev/tests/postflight/test_postflight_no_dark.py index 4fb85b0..651252e 100644 --- a/dev/test_postflight_no_dark.py +++ b/dev/tests/postflight/test_postflight_no_dark.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/test_postflight_no_dark.py +Filename: dev/tests/postflight/test_postflight_no_dark.py Version: 1.0.0 Objective: Verify postflight fails honestly when no matching master dark exists. """ diff --git a/dev/test_synthetic_imx585_field.py b/dev/tests/postflight/test_synthetic_imx585_field.py similarity index 99% rename from dev/test_synthetic_imx585_field.py rename to dev/tests/postflight/test_synthetic_imx585_field.py index 6abfaa3..4e4954f 100644 --- a/dev/test_synthetic_imx585_field.py +++ b/dev/tests/postflight/test_synthetic_imx585_field.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/test_synthetic_imx585_field.py +Filename: dev/tests/postflight/test_synthetic_imx585_field.py Version: 1.0.0 Objective: End-to-end synthetic IMX585-style postflight rehearsal. diff --git a/dev/test_tcrb_s30_s50_field.py b/dev/tests/postflight/test_tcrb_s30_s50_field.py similarity index 99% rename from dev/test_tcrb_s30_s50_field.py rename to dev/tests/postflight/test_tcrb_s30_s50_field.py index cf5feb5..1ba63fb 100644 --- a/dev/test_tcrb_s30_s50_field.py +++ b/dev/tests/postflight/test_tcrb_s30_s50_field.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/test_tcrb_s30_s50_field.py +Filename: dev/tests/postflight/test_tcrb_s30_s50_field.py Version: 1.0.0 Objective: Rehearse postflight on T CrB-inspired synthetic S30 and S50 fields. diff --git a/dev/tools/README.md b/dev/tools/README.md new file mode 100644 index 0000000..e5c9868 --- /dev/null +++ b/dev/tools/README.md @@ -0,0 +1,15 @@ +# SeeVar Dev Tools + +- `reports/`: AAVSO/BAA staging, submission probes, and campaign pulls. +- `horizon/`: Horizon-mask audits, editor, installers, and panorama packaging. +- `telescope/`: Live telescope diagnostics, PEM-auth probes, SSH probes, pre-alignment, RPC, SSC schedule injection, and widefield solve helpers. +- `ops/`: Cleanup and session triage tools that do not steer hardware. + +SSC schedule injection: +`python dev/tools/telescope/inject_ssc_schedule.py --device 1 --payload data/ssc_payload.json --dry-run` +Remove `--dry-run` to replace the target scope scheduler; add `--start` only when ready to run. + +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. diff --git a/dev/tools/horizon_audit.py b/dev/tools/horizon/horizon_audit.py similarity index 99% rename from dev/tools/horizon_audit.py rename to dev/tools/horizon/horizon_audit.py index 3ede7cd..a0009d0 100644 --- a/dev/tools/horizon_audit.py +++ b/dev/tools/horizon/horizon_audit.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/tools/horizon_audit.py +Filename: dev/tools/horizon/horizon_audit.py Version: 1.0.1 Objective: Audit tonights_plan.json against the real camera-scanned horizon mask. Shows how many targets are observable tonight and when. diff --git a/dev/tools/horizon_profile_editor.py b/dev/tools/horizon/horizon_profile_editor.py similarity index 99% rename from dev/tools/horizon_profile_editor.py rename to dev/tools/horizon/horizon_profile_editor.py index 7b2530a..6976468 100644 --- a/dev/tools/horizon_profile_editor.py +++ b/dev/tools/horizon/horizon_profile_editor.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/tools/horizon_profile_editor.py +Filename: dev/tools/horizon/horizon_profile_editor.py Version: 1.0.0 Objective: Run a local Flask editor for manual SeeVar horizon profile cleanup. """ @@ -83,7 +83,7 @@ def _blank_payload(mask_path: Path) -> dict: for az in range(360) } return { - "#objective": "Manual SeeVar horizon profile edited with dev/tools/horizon_profile_editor.py.", + "#objective": "Manual SeeVar horizon profile edited with dev/tools/horizon/horizon_profile_editor.py.", "source": "manual_editor", "method": "operator_canvas_edit", "timestamp": datetime.now(timezone.utc).isoformat(), diff --git a/dev/tools/horizon/import_horizon_profile.py b/dev/tools/horizon/import_horizon_profile.py new file mode 100755 index 0000000..ce9ec6c --- /dev/null +++ b/dev/tools/horizon/import_horizon_profile.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: dev/tools/horizon/import_horizon_profile.py +Objective: Convert external horizon profiles into SeeVar horizon_mask.json. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +DEFAULT_OUTPUT = PROJECT_ROOT / "data" / "horizon_mask.json" + + +def parse_args() -> argparse.Namespace: + """Define the command line for horizon profile conversion.""" + parser = argparse.ArgumentParser( + description="Import MIRA/NINA/Stellarium az-alt horizon data into SeeVar format." + ) + parser.add_argument("source", help="Input horizon file") + parser.add_argument("-o", "--output", default=str(DEFAULT_OUTPUT), help="Output horizon_mask.json") + parser.add_argument("--source-name", default="", help="Short label stored in the output metadata") + parser.add_argument("--floor", type=float, default=0.0, help="Minimum altitude clamp for imported points") + parser.add_argument("--round", type=int, default=2, help="Decimal places for per-degree altitudes") + return parser.parse_args() + + +def _clamp_alt(value: float, floor: float) -> float: + """Keep horizon altitudes inside a sane telescope-planning range.""" + return max(float(floor), min(90.0, float(value))) + + +def _point_from_mapping(item: dict) -> tuple[float, float] | None: + """Extract one azimuth/altitude pair from common mapping keys.""" + az = item.get("az", item.get("azimuth", item.get("Azimuth"))) + alt = item.get("alt", item.get("altitude", item.get("Altitude"))) + if az is None or alt is None: + return None + return float(az) % 360.0, float(alt) + + +def _load_json(path: Path) -> list[tuple[float, float]]: + """Read SeeVar JSON, MIRA-like JSON, or generic point arrays.""" + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict) and isinstance(data.get("profile"), dict): + return [(float(k) % 360.0, float(v)) for k, v in data["profile"].items()] + raw = data.get("points") if isinstance(data, dict) else data + points = [] + if isinstance(raw, list): + for item in raw: + if isinstance(item, dict): + point = _point_from_mapping(item) + if point: + points.append(point) + elif isinstance(item, (list, tuple)) and len(item) >= 2: + points.append((float(item[0]) % 360.0, float(item[1]))) + return points + + +def _load_csv(path: Path) -> list[tuple[float, float]]: + """Read CSV files with az/alt headers or first two numeric columns.""" + text = path.read_text(encoding="utf-8") + sample = "\n".join(text.splitlines()[:5]) + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t ") + rows = list(csv.reader(text.splitlines(), dialect)) + if not rows: + return [] + + header = [cell.strip().lower() for cell in rows[0]] + has_header = any(name in {"az", "azimuth"} for name in header) + points = [] + if has_header: + az_idx = next(i for i, name in enumerate(header) if name in {"az", "azimuth"}) + alt_idx = next(i for i, name in enumerate(header) if name in {"alt", "altitude", "horizon"}) + data_rows = rows[1:] + else: + az_idx, alt_idx, data_rows = 0, 1, rows + + for row in data_rows: + if len(row) <= max(az_idx, alt_idx): + continue + try: + points.append((float(row[az_idx]) % 360.0, float(row[alt_idx]))) + except ValueError: + continue + return points + + +def _load_mira_yaml(path: Path) -> list[tuple[float, float]]: + """Read MIRA's simple points: [{az: ..., alt: ...}] YAML profile.""" + points = [] + pattern = re.compile(r"az\s*:\s*([-+]?\d+(?:\.\d+)?)\s*,\s*alt\s*:\s*([-+]?\d+(?:\.\d+)?)") + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.split("#", 1)[0] + match = pattern.search(line) + if match: + points.append((float(match.group(1)) % 360.0, float(match.group(2)))) + return points + + +def _load_plain_pairs(path: Path) -> list[tuple[float, float]]: + """Read Stellarium/NINA-style plain azimuth altitude pairs.""" + points = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.split("#", 1)[0].strip() + if not line: + continue + numbers = re.findall(r"[-+]?\d+(?:\.\d+)?", line) + if len(numbers) >= 2: + points.append((float(numbers[0]) % 360.0, float(numbers[1]))) + return points + + +def load_points(path: Path) -> list[tuple[float, float]]: + """Load horizon points from JSON, YAML, CSV, or plain text.""" + suffix = path.suffix.lower() + if suffix == ".json": + points = _load_json(path) + elif suffix in {".yaml", ".yml"}: + points = _load_mira_yaml(path) + elif suffix == ".csv": + points = _load_csv(path) + else: + points = _load_plain_pairs(path) + + if len(points) < 2: + raise ValueError(f"{path} did not contain at least two azimuth/altitude points") + return sorted(points, key=lambda p: p[0]) + + +def interpolate_profile(points: Iterable[tuple[float, float]], floor: float, ndigits: int) -> dict[str, float]: + """Interpolate sparse azimuth points to SeeVar's per-degree 0..359 profile.""" + src = sorted(((az % 360.0, _clamp_alt(alt, floor)) for az, alt in points), key=lambda p: p[0]) + profile: dict[str, float] = {} + for az_i in range(360): + az = float(az_i) + upper_idx = next((idx for idx, (src_az, _) in enumerate(src) if src_az >= az), 0) + lower = src[upper_idx - 1] + upper = src[upper_idx] + lower_az, lower_alt = lower + upper_az, upper_alt = upper + target_az = az + if upper_az < lower_az: + upper_az += 360.0 + if target_az < lower_az: + target_az += 360.0 + span = upper_az - lower_az + alt = upper_alt if span <= 0 else lower_alt + ((target_az - lower_az) / span) * (upper_alt - lower_alt) + profile[str(az_i)] = round(_clamp_alt(alt, floor), ndigits) + return profile + + +def build_payload(source: Path, points: list[tuple[float, float]], profile: dict[str, float], source_name: str) -> dict: + """Build SeeVar horizon_mask.json payload with confidence metadata.""" + confidence = { + str(az): { + "mean": profile[str(az)], + "var": 0.0, + "n": 1, + "source": f"import:{source_name or source.name}", + } + for az in range(360) + } + values = list(profile.values()) + return { + "#objective": "Imported local horizon profile for SeeVar planning.", + "source": source_name or str(source), + "source_path": str(source), + "generated_utc": datetime.now(timezone.utc).isoformat(), + "input_points": len(points), + "n_points": 360, + "min_alt": round(min(values), 2), + "max_alt": round(max(values), 2), + "profile": profile, + "confidence": confidence, + } + + +def main() -> int: + """Convert the selected horizon source and write SeeVar JSON.""" + args = parse_args() + source = Path(args.source).expanduser().resolve() + output = Path(args.output).expanduser().resolve() + points = load_points(source) + profile = interpolate_profile(points, args.floor, args.round) + payload = build_payload(source, points, profile, args.source_name, ) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + print( + f"wrote {output} points=360 input={len(points)} " + f"min={payload['min_alt']:.1f} max={payload['max_alt']:.1f}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev/tools/install_horizon_mask.py b/dev/tools/horizon/install_horizon_mask.py similarity index 97% rename from dev/tools/install_horizon_mask.py rename to dev/tools/horizon/install_horizon_mask.py index 14db8f7..9fdc855 100644 --- a/dev/tools/install_horizon_mask.py +++ b/dev/tools/horizon/install_horizon_mask.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/tools/install_horizon_mask.py +Filename: dev/tools/horizon/install_horizon_mask.py Objective: Install a candidate horizon_mask.json into the SeeVar runtime data dir with a timestamped backup of any existing mask. """ diff --git a/dev/tools/package_sector_panorama.py b/dev/tools/horizon/package_sector_panorama.py similarity index 99% rename from dev/tools/package_sector_panorama.py rename to dev/tools/horizon/package_sector_panorama.py index 29c1d16..6671d27 100644 --- a/dev/tools/package_sector_panorama.py +++ b/dev/tools/horizon/package_sector_panorama.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/tools/package_sector_panorama.py +Filename: dev/tools/horizon/package_sector_panorama.py Objective: Package a pre-stitched panorama sector plus a SeeVar horizon mask into the conservative Stellarium spherical landscape zip format. """ diff --git a/dev/tools/clean_postflight_remnants.py b/dev/tools/ops/clean_postflight_remnants.py similarity index 98% rename from dev/tools/clean_postflight_remnants.py rename to dev/tools/ops/clean_postflight_remnants.py index cb53751..fe501fe 100644 --- a/dev/tools/clean_postflight_remnants.py +++ b/dev/tools/ops/clean_postflight_remnants.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/tools/clean_postflight_remnants.py +Filename: dev/tools/ops/clean_postflight_remnants.py Version: 1.0.0 Objective: Remove transient postflight solver sidecars from SeeVar data buffers. diff --git a/dev/tools/session_triage.py b/dev/tools/ops/session_triage.py similarity index 99% rename from dev/tools/session_triage.py rename to dev/tools/ops/session_triage.py index ae65a95..426cd24 100644 --- a/dev/tools/session_triage.py +++ b/dev/tools/ops/session_triage.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/tools/session_triage.py +Filename: dev/tools/ops/session_triage.py Objective: Summarise the last SeeVar observing session from logs, ledger, plan, and data buffers without touching telescope state. """ diff --git a/dev/tools/aavso_reporter_test.py b/dev/tools/reports/aavso_reporter_test.py similarity index 98% rename from dev/tools/aavso_reporter_test.py rename to dev/tools/reports/aavso_reporter_test.py index 0fe5cb9..e11f8cd 100644 --- a/dev/tools/aavso_reporter_test.py +++ b/dev/tools/reports/aavso_reporter_test.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/tools/aavso_reporter_test.py +Filename: dev/tools/reports/aavso_reporter_test.py Version: 1.0.0 Objective: Generate a small dummy AAVSO Extended Format report for WebObs preview testing, or the BAA-modified AAVSO Extended variant for diff --git a/dev/tools/reports/lightcurve_plots.py b/dev/tools/reports/lightcurve_plots.py new file mode 100644 index 0000000..9ff380f --- /dev/null +++ b/dev/tools/reports/lightcurve_plots.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: dev/tools/reports/lightcurve_plots.py +Objective: Build simple SeeVar light-curve PNGs from postflight summaries. +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(PROJECT_ROOT)) + +from astropy.time import Time + +REPORT_DIR = PROJECT_ROOT / "data" / "reports" +DEFAULT_OUTPUT_DIR = REPORT_DIR / "lightcurves" + + +def parse_utc(value: Any) -> datetime | None: + if not value: + return None + try: + text = str(value).strip().rstrip("Z") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + except Exception: + return None + + +def jd_from_utc(value: Any) -> float | None: + dt = parse_utc(value) + if not dt: + return None + return float(Time(dt).jd) + + +def float_or_none(value: Any) -> float | None: + try: + if value in (None, "", "na"): + return None + result = float(value) + if math.isnan(result): + return None + return result + except Exception: + return None + + +def safe_name(value: str) -> str: + return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in value).strip("_") + + +def rows_from_summaries(report_dir: Path) -> list[dict[str, Any]]: + rows = [] + for path in sorted(report_dir.glob("postflight_summary_*.json")): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception: + continue + for obs in payload.get("accepted_observations", []): + name = str(obs.get("target_name") or "").strip() + mag = float_or_none(obs.get("mag")) + err = float_or_none(obs.get("err")) + jd = jd_from_utc(obs.get("last_obs_utc") or obs.get("group_started_utc")) + if not name or mag is None or jd is None: + continue + rows.append( + { + "target": name, + "jd": jd, + "mag": mag, + "err": err, + "filter": obs.get("filter") or obs.get("photometric_system") or "TG", + "source": path.name, + } + ) + return rows + + +def rows_from_ledger(ledger_path: Path) -> list[dict[str, Any]]: + if not ledger_path.exists(): + return [] + try: + payload = json.loads(ledger_path.read_text(encoding="utf-8")) + except Exception: + return [] + entries = payload.get("entries", {}) if isinstance(payload, dict) else {} + rows = [] + for name, entry in entries.items(): + if not isinstance(entry, dict): + continue + mag = float_or_none(entry.get("last_mag")) + err = float_or_none(entry.get("last_err")) + jd = jd_from_utc(entry.get("last_obs_utc") or entry.get("last_success")) + if mag is None or jd is None: + continue + rows.append( + { + "target": str(name), + "jd": jd, + "mag": mag, + "err": err, + "filter": entry.get("last_filter") or "TG", + "source": ledger_path.name, + } + ) + return rows + + +def write_csv(rows: list[dict[str, Any]], output_dir: Path) -> Path: + output_dir.mkdir(parents=True, exist_ok=True) + path = output_dir / "lightcurve_points.csv" + lines = ["target,jd,mag,err,filter,source"] + for row in sorted(rows, key=lambda item: (item["target"], item["jd"])): + err = "" if row["err"] is None else f"{row['err']:.3f}" + lines.append( + f"{row['target']},{row['jd']:.5f},{row['mag']:.3f},{err},{row['filter']},{row['source']}" + ) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def plot_lightcurves(rows: list[dict[str, Any]], output_dir: Path, min_points: int) -> list[Path]: + try: + import matplotlib + except ImportError as exc: + raise SystemExit("matplotlib is required: pip install matplotlib") from exc + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in rows: + grouped[row["target"]].append(row) + + output_dir.mkdir(parents=True, exist_ok=True) + paths = [] + for target, points in sorted(grouped.items()): + points = sorted(points, key=lambda item: item["jd"]) + if len(points) < min_points: + continue + x = [point["jd"] for point in points] + y = [point["mag"] for point in points] + yerr = [point["err"] or 0.0 for point in points] + filt = sorted({str(point.get("filter") or "") for point in points if point.get("filter")}) + + fig, ax = plt.subplots(figsize=(8, 4.5), dpi=140) + ax.errorbar(x, y, yerr=yerr, fmt="o", color="#111111", ecolor="#777777", capsize=2) + ax.invert_yaxis() + ax.grid(True, alpha=0.25) + ax.set_title(f"{target} light curve ({', '.join(filt) or 'TG'})") + ax.set_xlabel("Julian Date") + ax.set_ylabel("Magnitude") + fig.tight_layout() + + path = output_dir / f"{safe_name(target)}.png" + fig.savefig(path) + plt.close(fig) + paths.append(path) + + return paths + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate simple SeeVar light-curve plots.") + parser.add_argument("--report-dir", type=Path, default=REPORT_DIR) + parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR) + parser.add_argument("--ledger", type=Path, default=PROJECT_ROOT / "data" / "ledger.json") + parser.add_argument("--min-points", type=int, default=1) + args = parser.parse_args() + + rows = rows_from_summaries(args.report_dir) + if not rows: + rows = rows_from_ledger(args.ledger) + csv_path = write_csv(rows, args.output_dir) + plots = plot_lightcurves(rows, args.output_dir, max(1, args.min_points)) + + print(f"points: {len(rows)}") + print(f"csv: {csv_path}") + for path in plots: + print(path) + + +if __name__ == "__main__": + main() diff --git a/dev/tools/reports/pull_aavso_campaign_targets.py b/dev/tools/reports/pull_aavso_campaign_targets.py new file mode 100644 index 0000000..09f414b --- /dev/null +++ b/dev/tools/reports/pull_aavso_campaign_targets.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: dev/tools/reports/pull_aavso_campaign_targets.py +Version: 1.0.0 +Objective: Pull AAVSO Target Tool campaign targets as secondary candidates without replacing the main SeeVar catalog. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from core.preflight.aavso_fetcher import DEFAULT_SECTION, get_aavso_key, haul_and_filter +from core.utils.env_loader import DATA_DIR + +SECONDARY_DIR = DATA_DIR / "secondary_targets" +DEFAULT_OUTPUT = SECONDARY_DIR / "aavso_campaign_targets.json" +DEFAULT_RAW_OUTPUT = SECONDARY_DIR / "aavso_targettool_raw.json" + + +# Parse a small operator-friendly CLI for beta/manual target harvesting. +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Pull AAVSO Target Tool campaign targets into data/secondary_targets/." + ) + parser.add_argument("--api-key", default=None, help="AAVSO Target Tool API key. Prefer config/env for normal use.") + parser.add_argument("--section", default=DEFAULT_SECTION, help="Target Tool obs_section code or alias. Default: ac (Alerts & Campaigns).") + parser.add_argument("--limit", type=int, default=0, help="Maximum raw targets to keep; 0 keeps the full API response.") + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help="Filtered secondary catalog output.") + parser.add_argument("--raw-output", type=Path, default=DEFAULT_RAW_OUTPUT, help="Raw Target Tool audit output.") + return parser.parse_args(argv) + + +# Fetch and annotate the secondary-target catalog so the planner can treat it cautiously later. +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + api_key = get_aavso_key(args.api_key) + targets = haul_and_filter( + api_key, + observing_section=args.section, + limit=args.limit, + output_path=args.output, + raw_output_path=args.raw_output, + ) + + with open(args.output, "r") as f: + payload = json.load(f) + + payload["#objective"] = ( + "AAVSO Target Tool campaign targets staged as secondary SeeVar candidates. " + "These are not automatically scheduled until planner policy enables them." + ) + payload.setdefault("metadata", {}) + payload["metadata"]["secondary_catalog"] = True + payload["metadata"]["staged_utc"] = datetime.now(timezone.utc).isoformat() + for target in payload.get("targets", []): + if isinstance(target, dict): + target["target_class"] = "SECONDARY_AAVSO_CAMPAIGN" + target["priority"] = min(int(target.get("priority", 2)), 3) + + with open(args.output, "w") as f: + json.dump(payload, f, indent=4) + + print(f"Secondary AAVSO campaign targets: {len(targets)}") + print(args.output) + print(args.raw_output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev/tools/stage_reports_from_summary.py b/dev/tools/reports/stage_reports_from_summary.py similarity index 72% rename from dev/tools/stage_reports_from_summary.py rename to dev/tools/reports/stage_reports_from_summary.py index b772fd6..67c4c09 100644 --- a/dev/tools/stage_reports_from_summary.py +++ b/dev/tools/reports/stage_reports_from_summary.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/tools/stage_reports_from_summary.py +Filename: dev/tools/reports/stage_reports_from_summary.py Objective: Stage AAVSO/BAA submission files from the latest real-night postflight summary JSON written by accountant.py. """ @@ -9,6 +9,8 @@ from __future__ import annotations import argparse +import csv +import io import json import math import shutil @@ -29,6 +31,13 @@ INSTRUMENTAL_MAG_ZEROPOINT = 25.0 DEFAULT_MIRROR_DIR = Path("/mnt/astronas/reports") AAVSO_MAX_MAG_ERROR = 0.5 +AAVSO_RANGE_MARGIN_MAG = 1.0 +AAVSO_PEER_MARGIN_MAG = 0.35 +AAVSO_PEER_WINDOW_DAYS = 120 +AAVSO_PEER_MIN_OBS = 5 +AAVSO_MAX_PEAK_ADU = 58000.0 +AAVSO_REJECTIONS: list[dict] = [] +AAVSO_PEER_CACHE: dict[tuple[str, int, int], tuple[float, float, int] | None] = {} import sys sys.path.insert(0, str(PROJECT_ROOT)) @@ -155,6 +164,108 @@ def _safe_float(value, default: float) -> float: return default +def _aavso_sanity_cfg() -> dict: + try: + cfg = load_config() + aavso = cfg.get("aavso", {}) if isinstance(cfg, dict) else {} + return aavso if isinstance(aavso, dict) else {} + except Exception: + return {} + + +def _vsx_range(target_name: str) -> tuple[float, float] | None: + path = PROJECT_ROOT / "data" / "vsx_catalog.json" + if not path.exists(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + stars = payload.get("stars") if isinstance(payload, dict) else None + if not isinstance(stars, dict): + return None + row = stars.get(str(target_name)) + if not isinstance(row, dict): + return None + bright = float(row.get("max_mag")) + faint = float(row.get("min_mag")) + if not math.isfinite(bright) or not math.isfinite(faint): + return None + return min(bright, faint), max(bright, faint) + except Exception: + return None + + +def _aavso_recent_range(target_name: str, jd: float, window_days: int) -> tuple[float, float, int] | None: + window = max(1, int(window_days)) + key = (str(target_name).strip().lower(), int(jd), window) + if key in AAVSO_PEER_CACHE: + return AAVSO_PEER_CACHE[key] + + try: + import requests + + response = requests.get( + "https://www.aavso.org/vsx/index.php", + params={ + "view": "api.delim", + "ident": target_name, + "fromjd": f"{jd - window:.5f}", + "tojd": f"{jd + window:.5f}", + "delimiter": ",", + }, + timeout=20, + ) + response.raise_for_status() + reader = csv.DictReader(io.StringIO(response.text)) + mags = [] + for row in reader: + band = str(row.get("band") or "").strip().upper() + if band not in {"V", "TG", "CV"}: + continue + if str(row.get("fainterThan") or "0").strip() not in {"", "0", "false", "False"}: + continue + try: + mag = float(row.get("mag")) + except (TypeError, ValueError): + continue + if math.isfinite(mag): + mags.append(mag) + + if not mags: + AAVSO_PEER_CACHE[key] = None + return None + + mags.sort() + lo_idx = max(0, min(len(mags) - 1, int(round((len(mags) - 1) * 0.05)))) + hi_idx = max(0, min(len(mags) - 1, int(round((len(mags) - 1) * 0.95)))) + result = (mags[lo_idx], mags[hi_idx], len(mags)) + AAVSO_PEER_CACHE[key] = result + return result + except Exception: + AAVSO_PEER_CACHE[key] = None + return None + + +def _reject_aavso(obs: dict, reason: str) -> None: + row = { + "target": obs.get("target", "UNKNOWN"), + "mag": obs.get("mag"), + "err": obs.get("err"), + "peak_adu": obs.get("peak_adu"), + "reason": reason, + } + AAVSO_REJECTIONS.append(row) + print(f"Warning: AAVSO skipped {row['target']}: {reason}") + + +def _write_aavso_quarantine() -> Path | None: + if not AAVSO_REJECTIONS: + return None + out = PROJECT_ROOT / "data" / "reports" / f"AAVSO_QUARANTINE_{datetime.now():%Y%m%d_%H%M}.json" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(AAVSO_REJECTIONS, indent=2), encoding="utf-8") + return out + + # Recover chart id and a plausible check-star row by crossmatching retained # Gaia comparison stars back onto the VSP chart sequence. def _chart_context(row: dict) -> dict: @@ -258,18 +369,70 @@ def _compute_check_mag(comp_rows: list[dict], source_id: str | None) -> str | fl def _aavso_ready_observations(observations: list[dict]) -> list[dict]: + sanity = _aavso_sanity_cfg() + enabled = bool(sanity.get("sanity_enabled", True)) + max_err = _safe_float(sanity.get("max_mag_error"), AAVSO_MAX_MAG_ERROR) + range_margin = _safe_float(sanity.get("sanity_margin_mag"), AAVSO_RANGE_MARGIN_MAG) + peer_enabled = bool(sanity.get("peer_sanity_enabled", True)) + peer_margin = _safe_float(sanity.get("peer_sanity_margin_mag"), AAVSO_PEER_MARGIN_MAG) + peer_window = int(_safe_float(sanity.get("peer_sanity_window_days"), AAVSO_PEER_WINDOW_DAYS)) + peer_min_obs = int(_safe_float(sanity.get("peer_sanity_min_obs"), AAVSO_PEER_MIN_OBS)) + max_peak = _safe_float(sanity.get("max_peak_adu"), AAVSO_MAX_PEAK_ADU) + ready = [] for obs in observations: target = obs.get("target", "UNKNOWN") try: merr = float(obs.get("err")) except (TypeError, ValueError): - print(f"Warning: AAVSO skipped {target}: MERR is missing or non-numeric") + _reject_aavso(obs, "MERR is missing or non-numeric") continue - if not math.isfinite(merr) or merr < 0.0 or merr > AAVSO_MAX_MAG_ERROR: - print(f"Warning: AAVSO skipped {target}: MERR {merr:.3f} outside 0.000-{AAVSO_MAX_MAG_ERROR:.3f}") + if not math.isfinite(merr) or merr < 0.0 or merr > max_err: + _reject_aavso(obs, f"MERR {merr:.3f} outside 0.000-{max_err:.3f}") continue + + if enabled: + peak = obs.get("peak_adu") + if peak not in (None, ""): + peak_value = _safe_float(peak, -1.0) + if peak_value >= max_peak: + _reject_aavso(obs, f"peak ADU {peak_value:.1f} exceeds sanity limit {max_peak:.1f}") + continue + + try: + mag = float(obs.get("mag")) + except (TypeError, ValueError): + _reject_aavso(obs, "magnitude is missing or non-numeric") + continue + if not math.isfinite(mag): + _reject_aavso(obs, "magnitude is not finite") + continue + + vsx = _vsx_range(str(target)) + if vsx is not None: + bright, faint = vsx + if mag < bright - range_margin or mag > faint + range_margin: + _reject_aavso( + obs, + f"mag {mag:.3f} outside VSX {bright:.2f}-{faint:.2f} +/- {range_margin:.2f}", + ) + continue + + if peer_enabled: + peer = _aavso_recent_range(str(target), float(obs.get("jd")), peer_window) + if peer is not None: + peer_bright, peer_faint, n_peer = peer + if n_peer >= peer_min_obs and (mag < peer_bright - peer_margin or mag > peer_faint + peer_margin): + _reject_aavso( + obs, + ( + f"mag {mag:.3f} outside recent AAVSO " + f"{peer_bright:.2f}-{peer_faint:.2f} +/- {peer_margin:.2f} " + f"({n_peer} peer obs)" + ), + ) + continue ready.append(obs) if not ready: @@ -384,6 +547,10 @@ def stage_reports( accepted = _accepted_from_ledger(PROJECT_ROOT / "data" / "ledger.json") ledger_fallback = True + global AAVSO_REJECTIONS + AAVSO_REJECTIONS = [] + AAVSO_PEER_CACHE.clear() + observations = [_observation_from_summary(row) for row in accepted] effective_baa_observer_code = baa_observer_code if effective_baa_observer_code is None and observer_code: @@ -400,6 +567,9 @@ def stage_reports( aavso.finalize_report(_aavso_ready_observations(observations)), baa_ext.finalize_report(observations), ] + quarantine = _write_aavso_quarantine() + if quarantine is not None: + outputs.append(quarantine) if include_baa_ccd and not ledger_fallback: for obs in observations: diff --git a/dev/tools/submit_aavso_webobs.py b/dev/tools/reports/submit_aavso_webobs.py similarity index 93% rename from dev/tools/submit_aavso_webobs.py rename to dev/tools/reports/submit_aavso_webobs.py index d39efb8..17fb1ae 100644 --- a/dev/tools/submit_aavso_webobs.py +++ b/dev/tools/reports/submit_aavso_webobs.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/tools/submit_aavso_webobs.py +Filename: dev/tools/reports/submit_aavso_webobs.py Objective: Probe or submit the newest staged AAVSO report through the authenticated apps.aavso.org WebObs photometry form. """ @@ -22,7 +22,10 @@ # Pick the newest staged AAVSO extended report by default. def _latest_aavso_report() -> Path: - candidates = sorted(REPORT_DIR.glob("AAVSO_*.txt")) + candidates = sorted( + path for path in REPORT_DIR.glob("AAVSO_*.txt") + if not path.name.startswith("AAVSO_TEST_") + ) if not candidates: raise FileNotFoundError(f"No staged AAVSO report found in {REPORT_DIR}") return candidates[-1] diff --git a/dev/tools/telescope/build_seestar_view_plan.py b/dev/tools/telescope/build_seestar_view_plan.py new file mode 100644 index 0000000..235dc66 --- /dev/null +++ b/dev/tools/telescope/build_seestar_view_plan.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: dev/tools/telescope/build_seestar_view_plan.py +Objective: Convert a SeeVar SSC payload into the Seestar app view_plan.json + shape used on the telescope under ~/.ZWO/view_plan.json. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +from datetime import datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +import astropy.units as u +from astropy.coordinates import FK5, SkyCoord +from astropy.time import Time + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +DEFAULT_PAYLOAD = PROJECT_ROOT / "data" / "ssc_payload.json" + + +def _load_payload(path: Path) -> dict[str, Any]: + 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 an SSC payload") + return payload + + +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 int(hashlib.sha1(raw).hexdigest()[:8], 16) + + +def _to_seestar_epoch(ra_deg: float, dec_deg: float, epoch: str) -> tuple[float, float]: + if epoch == "j2000": + return ra_deg / 15.0, dec_deg + if epoch != "jnow": + raise ValueError(f"unsupported coordinate epoch: {epoch}") + + coord = SkyCoord(ra=ra_deg * u.deg, dec=dec_deg * u.deg, frame="icrs") + jnow = coord.transform_to(FK5(equinox=Time.now())) + return float(jnow.ra.hour), float(jnow.dec.deg) + + +def _local_minute(value: str | None, timezone_name: str, fallback: int) -> int: + if not value: + return fallback + dt = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + local = dt.astimezone(ZoneInfo(timezone_name)) + return local.hour * 60 + local.minute + + +def _build_target( + item: dict[str, Any], + timezone_name: str, + fallback_start_min: int, + coordinate_epoch: str, +) -> tuple[dict[str, Any], int]: + 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") + ra_hours, dec_deg = _to_seestar_epoch(float(source["ra_deg"]), float(source["dec_deg"]), coordinate_epoch) + duration_min = max(1, int(math.ceil(float(params.get("panel_time_sec", 60)) / 60.0))) + window_start_min = _local_minute(notes.get("best_start_utc"), timezone_name, fallback_start_min) + start_min = max(window_start_min, fallback_start_min) + + target = { + "target_ra_dec": [round(ra_hours, 6), round(dec_deg, 6)], + "target_name": name, + "lp_filter": bool(params.get("is_use_lp_filter", False)), + "state": "waiting", + "lapse_ms": 0, + "target_id": _target_id(name, ra_hours, dec_deg), + "output_file": {"path": f"MyWorks/{name}", "files": []}, + "stack_total_sec": 0.0, + "start_min": start_min, + "duration_min": duration_min, + "skip": False, + "alias_name": name, + "coordinate_epoch": coordinate_epoch.upper(), + } + return target, start_min + duration_min + + +def build_view_plan(payload_path: Path, timezone_name: str, plan_name: str, coordinate_epoch: str) -> dict[str, Any]: + payload = _load_payload(payload_path) + targets = [] + fallback_start_min = 0 + + for item in payload["list"]: + if item.get("action") != "start_mosaic": + continue + target, fallback_start_min = _build_target(item, timezone_name, fallback_start_min, coordinate_epoch) + targets.append(target) + + return { + "state": "waiting", + "lapse_ms": 0, + "plan": { + "update_time_seestar": datetime.now(ZoneInfo(timezone_name)).strftime("%Y.%m.%d"), + "plan_name": plan_name, + "coordinate_epoch": coordinate_epoch.upper(), + "list": targets, + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--payload", type=Path, default=DEFAULT_PAYLOAD) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--timezone", default="Europe/Amsterdam") + parser.add_argument("--name", default="SeeVar") + parser.add_argument( + "--coordinate-epoch", + choices=("jnow", "j2000"), + default="jnow", + help="Seestar app plans appear to operate in JNOW; keep jnow unless validating firmware behavior.", + ) + args = parser.parse_args() + + plan = build_view_plan(args.payload.expanduser().resolve(), args.timezone, args.name, args.coordinate_epoch) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(plan, separators=(",", ":")), encoding="utf-8") + print(f"wrote {args.output} ({len(plan['plan']['list'])} target(s))") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev/tools/telescope/inject_ssc_schedule.py b/dev/tools/telescope/inject_ssc_schedule.py new file mode 100644 index 0000000..b6deb2e --- /dev/null +++ b/dev/tools/telescope/inject_ssc_schedule.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: dev/tools/telescope/inject_ssc_schedule.py +Objective: Inject a SeeVar SSC payload into a seestar_alp scheduler through + the Alpaca action wrapper. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +DEFAULT_PAYLOAD = PROJECT_ROOT / "data" / "ssc_payload.json" +DEFAULT_BASE_URL = "http://127.0.0.1:5555" + + +def _load_json(path: Path) -> dict[str, Any]: + 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 an SSC scheduler payload") + return payload + + +def _request_json(url: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]: + data = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json"}, + method="PUT", + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def _action(base_url: str, device: int, action: str, params: dict[str, Any], timeout: float) -> dict[str, Any]: + url = f"{base_url.rstrip('/')}/api/v1/telescope/{device}/action" + return _request_json( + url, + { + "Action": action, + "Parameters": json.dumps(params), + "ClientID": 1, + "ClientTransactionID": 999, + }, + timeout, + ) + + +def _ensure_ok(response: dict[str, Any], label: str) -> None: + if int(response.get("ErrorNumber", 0)) != 0: + raise RuntimeError(f"{label}: {response.get('ErrorMessage', response)}") + + +def _schedule_item_payload(item: dict[str, Any]) -> dict[str, Any]: + action = item.get("action") + if not action: + raise ValueError(f"schedule item without action: {item}") + params = item.get("params") or {} + return {"action": action, "params": params} + + +def inject_schedule( + *, + payload_path: Path, + base_url: str, + device: int, + clear: bool, + start: bool, + dry_run: bool, + timeout: float, +) -> int: + payload = _load_json(payload_path) + items = payload["list"] + + print(f"payload: {payload_path}") + print(f"device : {device}") + print(f"items : {len(items)}") + + for index, item in enumerate(items, start=1): + params = item.get("params") or {} + print(f"{index:02d}. {item.get('action')} {params.get('target_name', '')}".rstrip()) + + if dry_run: + print("dry-run: no scheduler changes sent") + return 0 + + try: + _ensure_ok(_action(base_url, device, "get_schedule", {}, timeout), "get_schedule") + if clear: + _ensure_ok(_action(base_url, device, "create_schedule", {}, timeout), "create_schedule") + + for index, item in enumerate(items, start=1): + response = _action(base_url, device, "add_schedule_item", _schedule_item_payload(item), timeout) + _ensure_ok(response, f"add_schedule_item #{index}") + + if start: + _ensure_ok(_action(base_url, device, "start_scheduler", {}, timeout), "start_scheduler") + + except urllib.error.URLError as exc: + raise RuntimeError(f"cannot reach seestar_alp at {base_url}: {exc}") from exc + + print("injected") + if start: + print("started") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--payload", type=Path, default=DEFAULT_PAYLOAD) + parser.add_argument("--base-url", default=DEFAULT_BASE_URL) + parser.add_argument("--device", type=int, required=True, help="seestar_alp device number") + parser.add_argument("--no-clear", action="store_true", help="append instead of replacing scheduler") + parser.add_argument("--start", action="store_true", help="start scheduler after injection") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--timeout", type=float, default=10.0) + args = parser.parse_args() + + return inject_schedule( + payload_path=args.payload.expanduser().resolve(), + base_url=args.base_url, + device=args.device, + clear=not args.no_clear, + start=args.start, + dry_run=args.dry_run, + timeout=args.timeout, + ) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/dev/tools/prealign_pointing.py b/dev/tools/telescope/prealign_pointing.py similarity index 59% rename from dev/tools/prealign_pointing.py rename to dev/tools/telescope/prealign_pointing.py index c6b5254..8d0fa78 100644 --- a/dev/tools/prealign_pointing.py +++ b/dev/tools/telescope/prealign_pointing.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/tools/prealign_pointing.py +Filename: dev/tools/telescope/prealign_pointing.py Version: 1.0.0 Objective: Build a quick SeeVar software pointing model from 2-3 bright plate-solved alignment stars before starting a science sequence. @@ -13,25 +13,35 @@ import json import logging import math +import subprocess import sys import time from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -PROJECT_ROOT = Path(__file__).resolve().parents[2] +PROJECT_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(PROJECT_ROOT)) +import numpy as np import astropy.units as u from astropy.coordinates import AltAz, EarthLocation, SkyCoord +from astropy.io import fits from astropy.time import Time +from alpaca.camera import Camera from core.flight.pilot import AcquisitionTarget, DiamondSequence from core.flight.pointing_model import build_pointing_model, normalize_ra_hours, save_pointing_model +import core.preflight.horizon_scanner_v2 as hv2 from core.preflight.horizon import required_altitude from core.utils.env_loader import load_config, selected_scope, scope_file_tag +WIDE_CAMERA_NUM = 1 +WIDE_CAMERA_SCALE_LOW = 35.0 +WIDE_CAMERA_SCALE_HIGH = 80.0 + + @dataclass(frozen=True) class AlignStar: name: str @@ -72,8 +82,16 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--solve-radius-deg", type=float, default=20.0, help="Search radius for alignment solves.") parser.add_argument("--solve-timeout-sec", type=int, default=90, help="Timeout for each alignment solve.") parser.add_argument("--solve-downsample", type=int, default=2, help="Downsample factor for alignment solves.") + parser.add_argument("--no-wide-fallback", action="store_true", help="Do not retry failed alignment solves with the wide camera.") + parser.add_argument("--wide-camera-num", type=int, default=WIDE_CAMERA_NUM, help="Wide-camera Alpaca device number.") + parser.add_argument("--wide-exposure-sec", type=float, default=5.0, help="Wide-camera fallback exposure in seconds.") + parser.add_argument("--wide-gain", type=int, default=0, help="Wide-camera fallback gain.") + parser.add_argument("--wide-solve-radius-deg", type=float, default=60.0, help="Wide-camera fallback solve radius.") + parser.add_argument("--port", type=int, default=32323, help="Alpaca port.") parser.add_argument("--ip", default="", help="Override selected scope IP address.") parser.add_argument("--scope-tag", default="", help="Override output model scope tag, e.g. scope01 or scope02.") + parser.add_argument("--state-file", type=Path, default=None, help="Scoped system_state JSON to update during manual alignment.") + parser.add_argument("--when", default="", help="UTC ISO time for candidate planning only, e.g. 2026-05-09T22:00:00Z.") parser.add_argument("--dry-run", action="store_true", help="Only list selected candidates; do not move the telescope.") parser.add_argument("--park-after", action="store_true", help="Park the telescope after pre-alignment.") parser.add_argument("--output", type=Path, default=None, help="Override pointing model output path.") @@ -124,7 +142,7 @@ def azimuth_far_enough(az: float, existing: list[dict], min_sep: float) -> bool: # Pick bright, visible, horizon-clear alignment stars for the current site and time. def choose_alignment_stars(args: argparse.Namespace) -> list[dict]: location = site_location() - obstime = Time(datetime.now(timezone.utc)) + obstime = Time(args.when) if str(args.when or "").strip() else Time(datetime.now(timezone.utc)) candidates = [] for star in BRIGHT_STARS: @@ -168,8 +186,155 @@ def notify(step: str, msg: str) -> None: print(f"[{step}] {msg}", flush=True) +# Write prealignment progress into the same state file the dashboard reads. +def write_state( + args: argparse.Namespace, + scope: dict, + sub: str, + msg: str, + target: str | None = None, + state: str = "PREFLIGHT", +) -> None: + if args.state_file is None: + return + try: + payload = json.loads(args.state_file.read_text(encoding="utf-8")) if args.state_file.exists() else {} + except Exception: + payload = {} + now_utc = datetime.now(timezone.utc).isoformat() + payload.update( + { + "state": state, + "scope_name": scope.get("scope_name") or scope.get("name"), + "scope_id": scope.get("scope_id"), + "sub": sub, + "substate": sub, + "msg": msg, + "message": msg, + "updated": now_utc, + "updated_utc": now_utc, + "current_target": target, + } + ) + args.state_file.parent.mkdir(parents=True, exist_ok=True) + args.state_file.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +# Store a wide-camera frame as FITS with the target coordinates as solve hints. +def write_wide_alignment_fits(data: np.ndarray, target: AcquisitionTarget, host: str, scope_tag: str) -> Path: + VERIFY_DIR = PROJECT_ROOT / "data" / "verify_buffer" + VERIFY_DIR.mkdir(parents=True, exist_ok=True) + utc_obs = datetime.now(timezone.utc) + safe_name = target.name.replace(" ", "_").replace("/", "-") + out_path = VERIFY_DIR / f"{safe_name}_{scope_tag}_{utc_obs.strftime('%Y%m%dT%H%M%S')}_WIDE_ALIGN.fits" + + header = fits.Header() + header["DATE-OBS"] = utc_obs.isoformat() + header["OBJECT"] = target.name[:68] + header["INSTRUME"] = "Seestar S30-Pro WIDE" + header["TELESCOP"] = f"Seestar {scope_tag}" + header["FILTER"] = "WIDE" + header["HOSTIP"] = host + header["OBJCTRA"] = float(target.ra_hours * 15.0) + header["OBJCTDEC"] = float(target.dec_deg) + header["CRVAL1"] = float(target.ra_hours * 15.0) + header["CRVAL2"] = float(target.dec_deg) + header["SCALE"] = 55.0 + fits.PrimaryHDU(data=data.astype(np.int32), header=header).writeto(out_path, overwrite=True) + return out_path + + +# Capture a fallback frame through the Seestar wide camera. +def capture_wide_alignment_frame(target: AcquisitionTarget, args: argparse.Namespace, host: str, scope_tag: str) -> Path: + camera = None + try: + camera = Camera(f"{host}:{args.port}", int(args.wide_camera_num)) + camera.Connected = True + hv2.ALPACA_CAMERA_BASE = f"http://{host}:{args.port}/api/v1/camera/{int(args.wide_camera_num)}" + hv2.GAIN_WIDE = int(args.wide_gain) + hv2.configure_camera(camera) + if not hv2.probe_wide_camera(camera, hv2.CLIENT_ID_DEFAULT): + raise RuntimeError("wide camera probe failed") + image = hv2.capture_image( + camera, + hv2.CLIENT_ID_DEFAULT, + float(args.wide_exposure_sec), + timeout=max(20.0, float(args.wide_exposure_sec) + 10.0), + download_timeout=max(20.0, float(args.wide_exposure_sec) + 10.0), + ) + if image.ndim == 3: + image = image[:, :, 1] + return write_wide_alignment_fits(image, target, host, scope_tag) + finally: + hv2.disconnect_safely(camera, None) + + +# Solve a wide-camera fallback frame using loose scale constraints. +def solve_wide_alignment_frame(fits_path: Path, target: AcquisitionTarget, args: argparse.Namespace) -> dict: + ra_deg = float(target.ra_hours * 15.0) + dec_deg = float(target.dec_deg) + cmd = [ + "solve-field", + str(fits_path), + "--dir", + str(fits_path.parent), + "--overwrite", + "--no-plots", + "--no-verify", + "--resort", + "--objs", + "1000", + "--downsample", + str(max(1, int(args.solve_downsample))), + "--ra", + str(ra_deg), + "--dec", + str(dec_deg), + "--radius", + str(max(1.0, float(args.wide_solve_radius_deg))), + "--scale-units", + "arcsecperpix", + "--scale-low", + str(WIDE_CAMERA_SCALE_LOW), + "--scale-high", + str(WIDE_CAMERA_SCALE_HIGH), + "--tweak-order", + "2", + "--cpulimit", + str(max(5, min(int(args.solve_timeout_sec), int(args.solve_timeout_sec) - 5))), + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=max(10, int(args.solve_timeout_sec))) + except subprocess.TimeoutExpired: + return {"ok": False, "error": f"wide solve-field timeout after {args.solve_timeout_sec}s"} + + wcs_path = fits_path.with_suffix(".wcs") + if not wcs_path.exists(): + return {"ok": False, "error": f"wide solve-field failed ({result.returncode})"} + + hdr = fits.getheader(wcs_path, 0) + solved_ra_deg = float(hdr.get("CRVAL1")) + solved_dec_deg = float(hdr.get("CRVAL2")) + target_coord = SkyCoord(ra=ra_deg * u.deg, dec=dec_deg * u.deg, frame="icrs") + solved_coord = SkyCoord(ra=solved_ra_deg * u.deg, dec=solved_dec_deg * u.deg, frame="icrs") + return { + "ok": True, + "wcs_path": str(wcs_path), + "solved_ra_deg": solved_ra_deg, + "solved_dec_deg": solved_dec_deg, + "error_arcmin": float(target_coord.separation(solved_coord).arcminute), + } + + # Slew, capture, plate-solve, and return one alignment sample. -def solve_alignment_star(sequence: DiamondSequence, item: dict, args: argparse.Namespace) -> dict: +def solve_alignment_star( + sequence: DiamondSequence, + item: dict, + args: argparse.Namespace, + host: str, + scope: dict, + scope_tag: str, +) -> dict: star: AlignStar = item["star"] target = AcquisitionTarget( name=f"ALIGN_{star.name}", @@ -180,11 +345,14 @@ def solve_alignment_star(sequence: DiamondSequence, item: dict, args: argparse.N ) print(f"Aligning {star.name}: alt={item['alt_deg']:.1f} az={item['az_deg']:.1f}", flush=True) + write_state(args, scope, "PREALIGN SLEW", f"Pre-align slewing to {star.name}", star.name) sequence._telescope.slew_to_coordinates_async(star.ra_hours, star.dec_deg) if not sequence._telescope.wait_for_slew(): return {"ok": False, "name": star.name, "error": "slew_timeout"} time.sleep(3.0) + write_state(args, scope, "PREALIGN SOLVE", f"Pre-align solving {star.name} with telephoto camera", star.name) + camera_source = "tele" fits_path = sequence._capture_temp_frame(target, args.exposure_sec, "ALIGN") solve = sequence._solve_verify_frame( fits_path, @@ -194,6 +362,12 @@ def solve_alignment_star(sequence: DiamondSequence, item: dict, args: argparse.N cpulimit_sec=max(5, min(args.solve_timeout_sec, args.solve_timeout_sec - 5)), downsample=args.solve_downsample, ) + if not solve.get("ok") and not args.no_wide_fallback: + print(f" Telephoto solve failed for {star.name}; trying wide camera fallback.", flush=True) + write_state(args, scope, "PREALIGN WIDE", f"Pre-align solving {star.name} with wide camera", star.name) + camera_source = "wide" + fits_path = capture_wide_alignment_frame(target, args, host, scope_tag) + solve = solve_wide_alignment_frame(fits_path, target, args) sample = { "ok": bool(solve.get("ok")), @@ -202,6 +376,7 @@ def solve_alignment_star(sequence: DiamondSequence, item: dict, args: argparse.N "target_dec_deg": round(star.dec_deg, 8), "alt_deg": item["alt_deg"], "az_deg": item["az_deg"], + "camera": camera_source, "fits_path": str(fits_path), } @@ -254,11 +429,11 @@ def main() -> int: for item in candidates: if sum(1 for sample in samples if sample.get("ok")) >= args.points: break - sample = solve_alignment_star(sequence, item, args) + sample = solve_alignment_star(sequence, item, args, sequence.host, scope, scope_tag) samples.append(sample) if sample.get("ok"): print( - f" OK {sample['name']}: dra={sample['offset_ra_hours'] * 15.0 * 60.0:.2f}' " + f" OK {sample['name']} ({sample['camera']}): dra={sample['offset_ra_hours'] * 15.0 * 60.0:.2f}' " f"ddec={sample['offset_dec_deg'] * 60.0:.2f}' solve_err={sample['error_arcmin']:.2f}'", flush=True, ) @@ -267,9 +442,23 @@ def main() -> int: successes = [sample for sample in samples if sample.get("ok")] if not successes: + write_state( + args, + scope, + "ALIGNMENT FAILED", + "No alignment solve succeeded; science run blocked.", + state="ABORTED", + ) print("No alignment solve succeeded; no pointing model written.", file=sys.stderr) return 4 if len(successes) < args.points and not args.allow_partial: + write_state( + args, + scope, + "ALIGNMENT FAILED", + f"Only {len(successes)}/{args.points} alignment solves succeeded; science run blocked.", + state="ABORTED", + ) print( f"Only {len(successes)}/{args.points} alignment solves succeeded; no pointing model written.", file=sys.stderr, @@ -296,6 +485,7 @@ def main() -> int: print(f" successes={len(successes)} ra_offset={model['offset_ra_arcmin']:.2f}' dec_offset={model['offset_dec_arcmin']:.2f}'") if len(successes) < args.points: print(f" warning: requested {args.points} points, got {len(successes)}") + write_state(args, scope, "PREALIGN OK", f"Pointing model ready from {len(successes)} solve(s).") if args.park_after: sequence._telescope.park() diff --git a/dev/tools/rpc_client.py b/dev/tools/telescope/rpc_client.py similarity index 98% rename from dev/tools/rpc_client.py rename to dev/tools/telescope/rpc_client.py index 1bff486..b28e444 100755 --- a/dev/tools/rpc_client.py +++ b/dev/tools/telescope/rpc_client.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/tools/rpc_client.py +Filename: dev/tools/telescope/rpc_client.py Version: 2.0.1 Objective: Interactive JSON-RPC client for Seestar port 4700 using pre-built sovereign payloads. """ diff --git a/dev/tools/telescope/seestar_pem_diagnostics.py b/dev/tools/telescope/seestar_pem_diagnostics.py new file mode 100644 index 0000000..79de390 --- /dev/null +++ b/dev/tools/telescope/seestar_pem_diagnostics.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: dev/tools/telescope/seestar_pem_diagnostics.py +Objective: Read-only authenticated Seestar JSON-RPC diagnostics using the APK PEM. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import socket +import subprocess +import sys +import tempfile +import tomllib +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +CONFIG_PATH = PROJECT_ROOT / "config.toml" +DEFAULT_PEM = Path.home() / ".config" / "seestar" / "seestar_3.1.2.pem" + + +# Load scope names and IP addresses from SeeVar config.toml. +def configured_hosts() -> list[dict[str, str]]: + if not CONFIG_PATH.exists(): + return [] + with CONFIG_PATH.open("rb") as handle: + cfg = tomllib.load(handle) + hosts = [] + for idx, entry in enumerate(cfg.get("seestars", []), start=1): + ip = str(entry.get("ip", "")).strip() + if not ip or ip == "TBD": + continue + hosts.append( + { + "scope_id": f"scope{idx:02d}", + "name": str(entry.get("name") or f"scope{idx:02d}"), + "ip": ip, + } + ) + return hosts + + +# Send one JSON-RPC line over the open authenticated socket. +def send_json(sock: socket.socket, payload: dict[str, Any]) -> None: + sock.sendall((json.dumps(payload, separators=(",", ":")) + "\r\n").encode("utf-8")) + + +# Read one CRLF-delimited JSON line from the scope. +def read_json_line(file_obj) -> dict[str, Any]: + line = file_obj.readline() + if not line: + raise RuntimeError("empty response from scope") + try: + return json.loads(line.decode("utf-8").strip()) + except json.JSONDecodeError as exc: + raise RuntimeError(f"invalid JSON from scope: {line!r}") from exc + + +# Skip asynchronous event pushes and return the requested response object. +def read_response(file_obj) -> dict[str, Any]: + while True: + payload = read_json_line(file_obj) + if "Event" not in payload: + return payload + + +# Sign the challenge with RSA/SHA1/PKCS1v1.5 via OpenSSL. +def sign_challenge(pem_path: Path, challenge: str) -> str: + with tempfile.NamedTemporaryFile("wb", delete=True) as handle: + handle.write(challenge.encode("utf-8")) + handle.flush() + result = subprocess.run( + ["openssl", "dgst", "-sha1", "-sign", str(pem_path), handle.name], + check=True, + capture_output=True, + ) + return base64.b64encode(result.stdout).decode("ascii") + + +# Authenticate to the scope's port-4700 API and return an open stream. +def authenticate(host: str, port: int, pem_path: Path, timeout: float) -> tuple[socket.socket, Any]: + sock = socket.create_connection((host, port), timeout=timeout) + sock.settimeout(timeout) + file_obj = sock.makefile("rb") + + send_json(sock, {"id": 1, "method": "get_verify_str", "params": "verify"}) + verify = read_json_line(file_obj) + result = verify.get("result") + challenge = result.get("str") if isinstance(result, dict) else result + if not isinstance(challenge, str) or not challenge: + raise RuntimeError(f"missing challenge string: {verify}") + + signature = sign_challenge(pem_path, challenge) + send_json(sock, {"id": 2, "method": "verify_client", "params": {"sign": signature, "data": challenge}}) + ack = read_json_line(file_obj) + if int(ack.get("code", -1)) != 0: + raise RuntimeError(f"authentication failed: {ack}") + + send_json(sock, {"id": 3, "method": "pi_is_verified", "params": "verify"}) + read_json_line(file_obj) + return sock, file_obj + + +# Run the read-only authenticated diagnostics calls. +def run_diagnostics(host: str, port: int, pem_path: Path, timeout: float) -> dict[str, Any]: + started = datetime.now(timezone.utc) + sock, file_obj = authenticate(host, port, pem_path, timeout) + try: + send_json(sock, {"id": 4, "method": "get_device_state", "params": []}) + device_state = read_response(file_obj) + send_json(sock, {"id": 5, "method": "pi_get_info", "params": []}) + pi_info = read_response(file_obj) + finally: + try: + file_obj.close() + finally: + sock.close() + + finished = datetime.now(timezone.utc) + return { + "checked_utc": finished.isoformat(), + "elapsed_ms": round((finished - started).total_seconds() * 1000.0, 1), + "host": host, + "port": port, + "device_state": device_state, + "pi_info": pi_info, + } + + +# Return a compact status object for dashboard-style operator checks. +def compact_summary(scope: dict[str, str], payload: dict[str, Any]) -> dict[str, Any]: + state = payload.get("device_state", {}).get("result", {}) + info = payload.get("pi_info", {}).get("result", {}) + device = state.get("device", {}) if isinstance(state, dict) else {} + pi_status = state.get("pi_status", {}) if isinstance(state, dict) else {} + return { + "scope_id": scope.get("scope_id"), + "name": scope.get("name"), + "ip": scope.get("ip"), + "ok": True, + "elapsed_ms": payload.get("elapsed_ms"), + "product_model": device.get("product_model"), + "firmware": device.get("firmware_ver_string"), + "battery": pi_status.get("battery_capacity") or info.get("battery_capacity"), + "charger": pi_status.get("charger_status") or info.get("charger_status"), + } + + +# Parse command line arguments. +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", action="append", help="Scope IP/host. May be repeated. Defaults to config.toml scopes.") + parser.add_argument("--port", type=int, default=4700, help="Seestar authenticated JSON-RPC port.") + parser.add_argument("--pem", type=Path, default=DEFAULT_PEM, help="Private PEM path.") + parser.add_argument("--timeout", type=float, default=5.0, help="Socket timeout in seconds.") + parser.add_argument("--json", action="store_true", help="Emit full JSON diagnostics.") + parser.add_argument("--output", type=Path, default=None, help="Write full diagnostics JSON to this path.") + return parser.parse_args() + + +# CLI entry point. +def main() -> int: + args = parse_args() + pem_path = args.pem.expanduser() + if not pem_path.exists(): + print(f"PEM missing: {pem_path}", file=sys.stderr) + return 2 + + scopes = [{"scope_id": None, "name": host, "ip": host} for host in args.host] if args.host else configured_hosts() + if not scopes: + print("No hosts supplied and no usable [[seestars]] entries found.", file=sys.stderr) + return 2 + + full_results = [] + summaries = [] + rc = 0 + for scope in scopes: + try: + payload = run_diagnostics(scope["ip"], args.port, pem_path, args.timeout) + full_results.append({**scope, **payload}) + summaries.append(compact_summary(scope, payload)) + except Exception as exc: + rc = 1 + item = {**scope, "ok": False, "error": str(exc)} + full_results.append(item) + summaries.append(item) + + if args.output: + args.output.expanduser().parent.mkdir(parents=True, exist_ok=True) + args.output.expanduser().write_text(json.dumps(full_results, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + if args.json: + print(json.dumps(full_results, indent=2, sort_keys=True)) + else: + for item in summaries: + if item.get("ok"): + print( + f"{item.get('name')} {item.get('ip')} OK " + f"{item.get('elapsed_ms')}ms bat={item.get('battery')} charger={item.get('charger')} " + f"{item.get('product_model')} fw={item.get('firmware')}" + ) + else: + print(f"{item.get('name')} {item.get('ip')} FAIL {item.get('error')}") + return rc + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev/tools/telescope/seestar_ssh_probe.py b/dev/tools/telescope/seestar_ssh_probe.py new file mode 100755 index 0000000..f22d2d8 --- /dev/null +++ b/dev/tools/telescope/seestar_ssh_probe.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: dev/tools/telescope/seestar_ssh_probe.py +Objective: Read-only SSH health and inventory probe for Seestar scopes. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tomllib +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +CONFIG_PATH = PROJECT_ROOT / "config.toml" + + +# Load configured Seestar hosts from config.toml. +def configured_hosts() -> list[dict[str, str]]: + if not CONFIG_PATH.exists(): + return [] + with CONFIG_PATH.open("rb") as handle: + cfg = tomllib.load(handle) + hosts = [] + for idx, entry in enumerate(cfg.get("seestars", []), start=1): + ip = str(entry.get("ip", "")).strip() + if not ip or ip == "TBD": + continue + hosts.append( + { + "scope_id": f"scope{idx:02d}", + "name": str(entry.get("name") or f"scope{idx:02d}"), + "ip": ip, + } + ) + return hosts + + +# Execute one SSH command and return text plus status. +def ssh_text(host: str, user: str, command: str, timeout: float) -> tuple[int, str, str]: + cmd = [ + "ssh", + "-o", + f"ConnectTimeout={int(timeout)}", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + f"{user}@{host}", + command, + ] + result = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout + 5, check=False) + return result.returncode, result.stdout.strip(), result.stderr.strip() + + +# Parse /etc/os-release style key/value output. +def parse_os_release(text: str) -> dict[str, str]: + parsed = {} + for line in text.splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + parsed[key] = value.strip().strip('"') + return parsed + + +# Run the standard read-only probe set against one host. +def probe_host(host: dict[str, str], user: str, timeout: float) -> dict[str, Any]: + ip = host["ip"] + out: dict[str, Any] = { + "scope_id": host.get("scope_id"), + "name": host.get("name"), + "ip": ip, + "checked_utc": datetime.now(timezone.utc).isoformat(), + "ssh_ok": False, + } + + rc, hostname, err = ssh_text(ip, user, "hostname", timeout) + if rc != 0: + out["error"] = err or hostname or f"ssh exited {rc}" + return out + + out["ssh_ok"] = True + out["hostname"] = hostname + + commands = { + "os_release_raw": "cat /etc/os-release 2>/dev/null || true", + "kernel": "uname -a", + "clock": "date -Iseconds 2>/dev/null || date", + "uptime": "uptime", + "network": "for i in /sys/class/net/*; do n=$(basename \"$i\"); echo \"$n $(cat \"$i/address\")\"; done; ip addr show", + "storage": "df -h / /boot /home/pi/.ZWO /usr/local/astrometry/data 2>/dev/null || df -h", + "zwo_dir": "ls -lah /home/pi/.ZWO 2>/dev/null || true", + "astrometry_indexes": "ls -lh /usr/local/astrometry/data/index-*.fits 2>/dev/null || true", + "view_plan": ( + "python3 - <<'PY'\n" + "import json, pathlib\n" + "p=pathlib.Path('/home/pi/.ZWO/view_plan.json')\n" + "if not p.exists():\n" + " print('missing')\n" + "else:\n" + " d=json.loads(p.read_text())\n" + " plan=d.get('plan', {})\n" + " items=plan.get('list', [])\n" + " print(json.dumps({\n" + " 'state': d.get('state'),\n" + " 'plan_name': plan.get('plan_name'),\n" + " 'update_time_seestar': plan.get('update_time_seestar'),\n" + " 'targets': len(items),\n" + " 'target_names': [x.get('target_name') for x in items[:10]],\n" + " }, sort_keys=True))\n" + "PY" + ), + } + + for key, command in commands.items(): + rc, stdout, stderr = ssh_text(ip, user, command, timeout) + out[key] = stdout + if rc != 0: + out[f"{key}_error"] = stderr or f"ssh exited {rc}" + + out["os_release"] = parse_os_release(str(out.get("os_release_raw", ""))) + try: + out["view_plan_summary"] = json.loads(str(out.get("view_plan", ""))) + except Exception: + out["view_plan_summary"] = {"raw": out.get("view_plan")} + return out + + +# Print a compact human-readable summary. +def print_text_report(results: list[dict[str, Any]]) -> None: + for result in results: + print(f"{result.get('name')} ({result.get('ip')})") + if not result.get("ssh_ok"): + print(f" SSH: FAIL {result.get('error')}") + continue + os_name = result.get("os_release", {}).get("PRETTY_NAME", "unknown") + print(f" SSH: OK as {result.get('hostname')}") + print(f" OS : {os_name}") + print(f" Plan: {result.get('view_plan_summary')}") + indexes = str(result.get("astrometry_indexes", "")).splitlines() + print(f" Astrometry indexes: {len(indexes)}") + print(" Storage:") + for line in str(result.get("storage", "")).splitlines()[:6]: + print(f" {line}") + + +# CLI entry point. +def main() -> int: + parser = argparse.ArgumentParser(description="Read-only SSH health probe for Seestar scopes.") + parser.add_argument("--user", default="pi", help="SSH user, usually pi or ed.") + parser.add_argument("--host", action="append", help="Host/IP to probe. May be repeated.") + parser.add_argument("--timeout", type=float, default=5.0, help="SSH timeout in seconds.") + parser.add_argument("--json", action="store_true", help="Emit JSON instead of text.") + args = parser.parse_args() + + hosts = [{"scope_id": None, "name": h, "ip": h} for h in args.host] if args.host else configured_hosts() + if not hosts: + print("No hosts supplied and no usable [[seestars]] entries found.", file=sys.stderr) + return 2 + + results = [probe_host(host, args.user, args.timeout) for host in hosts] + if args.json: + print(json.dumps(results, indent=2, sort_keys=True)) + else: + print_text_report(results) + return 1 if any(not item.get("ssh_ok") for item in results) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev/tools/telescope/sync_fleet_orchestrators.py b/dev/tools/telescope/sync_fleet_orchestrators.py new file mode 100644 index 0000000..79d9e1f --- /dev/null +++ b/dev/tools/telescope/sync_fleet_orchestrators.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Filename: dev/tools/telescope/sync_fleet_orchestrators.py +Objective: Start orchestrator instance units for online scopes and stop stale ones. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(PROJECT_ROOT)) + +from core.utils.env_loader import configured_scopes, live_available_scopes, load_config + + +def run_systemctl(args: list[str], *, apply: bool) -> int: + cmd = ["systemctl", "--user", *args] + print(" ".join(cmd)) + if not apply: + return 0 + return subprocess.run(cmd, check=False).returncode + + +def main() -> int: + parser = argparse.ArgumentParser(description="Synchronize SeeVar fleet orchestrator units.") + parser.add_argument("--apply", action="store_true", help="Actually run systemctl changes.") + args = parser.parse_args() + + cfg = load_config() + requested = str(cfg.get("planner", {}).get("fleet_mode", "single")).strip().lower() + scopes = configured_scopes(cfg, active_only=True) + online = {scope["scope_id"]: scope for scope in live_available_scopes(cfg, cache_ttl=0)} + + print(f"fleet_mode={requested}") + print("online=" + ",".join(sorted(online)) if online else "online=none") + + if requested not in {"split", "auto"}: + print("single fleet mode; leaving unscoped orchestrator policy unchanged") + return 0 + + rc = 0 + rc |= run_systemctl(["stop", "seevar-orchestrator.service"], apply=args.apply) + rc |= run_systemctl(["disable", "seevar-orchestrator.service"], apply=args.apply) + + for scope in scopes: + unit = f"seevar-orchestrator@{scope['scope_id']}.service" + if scope["scope_id"] in online: + rc |= run_systemctl(["enable", "--now", unit], apply=args.apply) + else: + rc |= run_systemctl(["stop", unit], apply=args.apply) + + rc |= run_systemctl(["reset-failed"], apply=args.apply) + return rc + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev/tools/widefield_plate_solve.py b/dev/tools/telescope/widefield_plate_solve.py similarity index 99% rename from dev/tools/widefield_plate_solve.py rename to dev/tools/telescope/widefield_plate_solve.py index 83a1ec5..562d06d 100644 --- a/dev/tools/widefield_plate_solve.py +++ b/dev/tools/telescope/widefield_plate_solve.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Filename: dev/tools/widefield_plate_solve.py +Filename: dev/tools/telescope/widefield_plate_solve.py Version: 1.0.0 Objective: Capture a wide-camera frame from a Seestar, solve it with the known wide-camera plate scale, and report the true field center versus the diff --git a/dev/utils/generate_manifest.py b/dev/utils/generate_manifest.py index bbaa423..e21f41f 100755 --- a/dev/utils/generate_manifest.py +++ b/dev/utils/generate_manifest.py @@ -269,7 +269,7 @@ def inferred_objective(filepath: Path) -> str | None: "dev/logic/PHOTOMETRICS.MD": "Scientific standards and roadmap for SeeVar differential photometry.", "dev/logic/PICKERING_PROTOCOL.MD": "Historical and cultural reference explaining SeeVar naming and observatory design inspiration.", "dev/logic/PREFLIGHT.MD": "Operational doctrine for preflight data preparation, planning, and go/no-go gates.", - "dev/tools/clean_postflight_remnants.py": "Dry-run-first cleanup tool for transient astrometry solver products in SeeVar data directories.", + "dev/tools/ops/clean_postflight_remnants.py": "Dry-run-first cleanup tool for transient astrometry solver products in SeeVar data directories.", "dev/logic/SEEVAR_DICT.PSV": "Pipe-separated data dictionary for SeeVar runtime files, fields, owners, and lifecycle notes.", } if rel in known: diff --git a/dev/utils/log_maintenance.py b/dev/utils/log_maintenance.py new file mode 100644 index 0000000..5567fe7 --- /dev/null +++ b/dev/utils/log_maintenance.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Rotate SeeVar application logs without relying on root logrotate.""" + +from __future__ import annotations + +import argparse +import gzip +import shutil +from datetime import datetime, timezone +from pathlib import Path + + +# Move old rotated files up one slot and discard files beyond retention. +def _shift_rotations(path: Path, keep: int) -> None: + oldest = path.with_name(f"{path.name}.{keep}.gz") + oldest.unlink(missing_ok=True) + + for idx in range(keep - 1, 0, -1): + src = path.with_name(f"{path.name}.{idx}.gz") + dest = path.with_name(f"{path.name}.{idx + 1}.gz") + if src.exists(): + src.replace(dest) + + +# Copy the live log into a compressed rotation and truncate the original. +def _rotate_log(path: Path, keep: int) -> dict[str, object]: + before = path.stat().st_size + _shift_rotations(path, keep) + rotated = path.with_name(f"{path.name}.1.gz") + with path.open("rb") as src, gzip.open(rotated, "wb") as dest: + shutil.copyfileobj(src, dest) + with path.open("w", encoding="utf-8"): + pass + return {"path": str(path), "bytes_before": before, "rotated_to": str(rotated)} + + +# Rotate logs exceeding the configured byte threshold. +def run(log_dir: Path, max_bytes: int, keep: int, force: bool = False) -> dict[str, object]: + log_dir = log_dir.expanduser() + rotated = [] + skipped = [] + for path in sorted(log_dir.glob("*.log")): + try: + size = path.stat().st_size + except OSError as exc: + skipped.append({"path": str(path), "error": str(exc)}) + continue + if force or size >= max_bytes: + rotated.append(_rotate_log(path, keep)) + else: + skipped.append({"path": str(path), "bytes": size}) + + return { + "checked_utc": datetime.now(timezone.utc).isoformat(), + "log_dir": str(log_dir), + "max_bytes": max_bytes, + "keep": keep, + "rotated": rotated, + "skipped_count": len(skipped), + } + + +# Parse CLI flags for manual and systemd timer use. +def main() -> int: + parser = argparse.ArgumentParser(description="Rotate SeeVar application logs.") + parser.add_argument("--log-dir", type=Path, default=Path.home() / "seevar" / "logs") + parser.add_argument("--max-mb", type=float, default=10.0) + parser.add_argument("--keep", type=int, default=14) + parser.add_argument("--force", action="store_true") + args = parser.parse_args() + + result = run( + args.log_dir, + max_bytes=max(1, int(args.max_mb * 1024 * 1024)), + keep=max(1, int(args.keep)), + force=args.force, + ) + print(result) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev/utils/raid_watchdog.py b/dev/utils/raid_watchdog.py new file mode 100644 index 0000000..9a16618 --- /dev/null +++ b/dev/utils/raid_watchdog.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Check mdadm RAID health and publish a small SeeVar state file.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + + +# Convert the md device path into the array name used by /proc/mdstat. +def _array_name(array_path: str) -> str: + return Path(array_path).name + + +# Read /proc/mdstat as the primary mdadm health source available to users. +def _read_mdstat(path: Path = Path("/proc/mdstat")) -> str: + return path.read_text(encoding="utf-8", errors="replace") + + +# Extract the block of mdstat text belonging to one md array. +def _array_block(mdstat: str, array_name: str) -> str: + lines = mdstat.splitlines() + for idx, line in enumerate(lines): + if line.startswith(f"{array_name} :"): + block = [line] + for extra in lines[idx + 1 :]: + if re.match(r"^md\d+\s+:", extra): + break + if extra.strip() == "unused devices: ": + break + block.append(extra) + return "\n".join(block) + return "" + + +# Parse mdstat into simple fields that are stable enough for watchdog use. +def _parse_mdstat(mdstat: str, array_name: str) -> dict[str, object]: + block = _array_block(mdstat, array_name) + if not block: + return { + "array": array_name, + "present": False, + "ok": False, + "severity": "CRITICAL", + "reasons": [f"{array_name} not present in /proc/mdstat"], + "mdstat_block": "", + } + + reasons: list[str] = [] + severity = "OK" + active_match = re.search(r"\[(U+_*)\]", block) + active_map = active_match.group(1) if active_match else None + failed = "(F)" in block or "faulty" in block.lower() or "failed" in block.lower() + degraded = bool(active_map and "_" in active_map) + recovering = "recovery =" in block or "resync =" in block or "reshape =" in block + + if failed: + severity = "CRITICAL" + reasons.append("RAID member marked failed/faulty") + if degraded: + severity = "CRITICAL" + reasons.append(f"RAID array degraded: [{active_map}]") + if recovering: + severity = "WARN" if severity == "OK" else severity + reasons.append("RAID array is rebuilding/resyncing") + if not active_map: + severity = "WARN" if severity == "OK" else severity + reasons.append("Could not read active mirror map from mdstat") + + return { + "array": array_name, + "present": True, + "ok": severity == "OK", + "severity": severity, + "reasons": reasons, + "active_map": active_map, + "failed": failed, + "degraded": degraded, + "recovering": recovering, + "mdstat_block": block, + } + + +# Check that the expected RAID mount is mounted and points to the md array. +def _check_mount(mountpoint: Path, array_path: str) -> dict[str, object]: + result: dict[str, object] = { + "mountpoint": str(mountpoint), + "mounted": False, + "source": None, + "ok": False, + "reasons": [], + } + try: + with Path("/proc/mounts").open("r", encoding="utf-8", errors="replace") as handle: + for line in handle: + parts = line.split() + if len(parts) >= 2 and parts[1] == str(mountpoint): + result["mounted"] = True + result["source"] = parts[0] + break + except OSError as exc: + result["reasons"] = [f"could not read /proc/mounts: {exc}"] + return result + + if not result["mounted"]: + result["reasons"] = [f"{mountpoint} is not mounted"] + return result + + if result["source"] != array_path: + result["reasons"] = [f"{mountpoint} source is {result['source']}, expected {array_path}"] + return result + + result["ok"] = True + return result + + +# Prove the mounted filesystem can accept a tiny fsync write. +def _write_probe(mountpoint: Path) -> dict[str, object]: + probe = mountpoint / ".seevar_raid_watchdog_probe" + result: dict[str, object] = {"enabled": True, "ok": False, "path": str(probe), "error": None} + try: + with probe.open("wb") as handle: + handle.write(b"seevar raid watchdog\n") + handle.flush() + os.fsync(handle.fileno()) + probe.unlink(missing_ok=True) + result["ok"] = True + except OSError as exc: + result["error"] = str(exc) + return result + + +# Stop services that may write into the damaged RAID-backed data tree. +def _stop_services(services: list[str]) -> dict[str, object]: + result: dict[str, object] = {"enabled": bool(services), "services": services, "ok": True, "error": None} + if not services: + return result + cmd = ["systemctl", "--user", "stop", *services] + try: + subprocess.run(cmd, check=True, timeout=20) + except Exception as exc: + result["ok"] = False + result["error"] = str(exc) + return result + + +# Store the watchdog result where the dashboard or operators can inspect it. +def _write_state(path: Path, payload: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + tmp.replace(path) + + +# Build a complete status payload and process exit code. +def run_check(args: argparse.Namespace) -> tuple[int, dict[str, object]]: + now = datetime.now(timezone.utc).isoformat() + array_name = _array_name(args.array) + mdstat = _read_mdstat() + raid = _parse_mdstat(mdstat, array_name) + mount = _check_mount(args.mountpoint, args.array) + write = {"enabled": False, "ok": True} + reasons = list(raid.get("reasons", [])) + severity = str(raid["severity"]) + + if not mount["ok"]: + severity = "CRITICAL" + reasons.extend(mount.get("reasons", [])) + elif args.write_probe and severity != "CRITICAL": + write = _write_probe(args.mountpoint) + if not write["ok"]: + severity = "CRITICAL" + reasons.append(f"write probe failed: {write.get('error')}") + elif args.write_probe and severity == "CRITICAL": + write = {"enabled": True, "ok": False, "skipped": True, "error": "skipped because RAID is already critical"} + + service_action = {"enabled": False, "ok": True} + if severity == "CRITICAL" and args.stop_services: + service_action = _stop_services(args.service) + + ok = severity == "OK" + payload: dict[str, object] = { + "checked_utc": now, + "last_update": time.time(), + "status": severity, + "ok": ok, + "array": args.array, + "mountpoint": str(args.mountpoint), + "reasons": reasons, + "raid": raid, + "mount": mount, + "write_probe": write, + "service_action": service_action, + } + return (0 if ok else 2), payload + + +# Parse CLI flags for systemd and manual operator checks. +def main() -> int: + parser = argparse.ArgumentParser(description="Check SeeVar mdadm RAID health.") + parser.add_argument("--array", default="/dev/md0", help="mdadm array device to check") + parser.add_argument("--mountpoint", type=Path, default=Path("/mnt/raid1"), help="RAID mountpoint") + parser.add_argument( + "--state", + type=Path, + default=Path.home() / "seevar" / "logs" / "raid_state.json", + help="JSON state output path", + ) + parser.add_argument("--no-write-probe", action="store_false", dest="write_probe", help="Skip fsync write probe") + parser.add_argument( + "--stop-services", + action="store_true", + help="Stop SeeVar writer services when RAID health is CRITICAL", + ) + parser.add_argument( + "--service", + action="append", + default=[ + "seevar-orchestrator.service", + "seevar-weather.service", + "seevar-dashboard.service", + "seevar-telescope.service", + "seevar-gps.service", + ], + help="User service to stop on CRITICAL; may be repeated", + ) + args = parser.parse_args() + + code, payload = run_check(args) + _write_state(args.state, payload) + print(json.dumps(payload, indent=2, sort_keys=True)) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt index dbdc78e..9859829 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,6 +23,8 @@ scipy==1.17.0 scikit-image==0.25.2 opencv-python==4.10.0.84 pillow==11.3.0 +astroalign>=2.6,<3.0 +matplotlib>=3.8,<4.0 # --- Data Handling ----------------------------------------------------------- pandas==2.3.1 diff --git a/systemd/seevar-dashboard.service b/systemd/seevar-dashboard.service index f5bfdba..35116b6 100644 --- a/systemd/seevar-dashboard.service +++ b/systemd/seevar-dashboard.service @@ -2,6 +2,7 @@ Description=SeeVar Dashboard After=network-online.target Wants=network-online.target +RequiresMountsFor=/mnt/raid1 StartLimitIntervalSec=300 StartLimitBurst=5 diff --git a/systemd/seevar-gps.service b/systemd/seevar-gps.service index f5cad90..b07924c 100644 --- a/systemd/seevar-gps.service +++ b/systemd/seevar-gps.service @@ -2,6 +2,7 @@ Description=SeeVar Continuous GPS Monitor After=network-online.target Wants=network-online.target +RequiresMountsFor=/mnt/raid1 StartLimitIntervalSec=300 StartLimitBurst=5 diff --git a/systemd/seevar-log-maintenance.service b/systemd/seevar-log-maintenance.service new file mode 100644 index 0000000..d0a7ccc --- /dev/null +++ b/systemd/seevar-log-maintenance.service @@ -0,0 +1,14 @@ +[Unit] +Description=SeeVar Log Maintenance + +[Service] +Type=oneshot +WorkingDirectory=%h/seevar +Environment=PYTHONPATH=%h/seevar +Environment=PYTHONUNBUFFERED=1 +ExecStart=%h/seevar/.venv/bin/python3 dev/utils/log_maintenance.py --log-dir %h/seevar/logs --max-mb 10 --keep 14 +StandardOutput=append:%h/seevar/logs/log_maintenance.log +StandardError=append:%h/seevar/logs/log_maintenance.log + +TimeoutStartSec=30s +TimeoutStopSec=5s diff --git a/systemd/seevar-log-maintenance.timer b/systemd/seevar-log-maintenance.timer new file mode 100644 index 0000000..f7ff206 --- /dev/null +++ b/systemd/seevar-log-maintenance.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run SeeVar Log Maintenance Daily + +[Timer] +OnCalendar=daily +Persistent=true +Unit=seevar-log-maintenance.service + +[Install] +WantedBy=timers.target diff --git a/systemd/seevar-orchestrator.service b/systemd/seevar-orchestrator.service index 8a89784..7680e22 100644 --- a/systemd/seevar-orchestrator.service +++ b/systemd/seevar-orchestrator.service @@ -2,6 +2,7 @@ Description=SeeVar Science Orchestrator After=network-online.target seevar-weather.service seevar-gps.service Wants=network-online.target seevar-weather.service seevar-gps.service +RequiresMountsFor=/mnt/raid1 StartLimitIntervalSec=300 StartLimitBurst=5 diff --git a/systemd/seevar-orchestrator@.service b/systemd/seevar-orchestrator@.service index e2cac62..8b650f1 100644 --- a/systemd/seevar-orchestrator@.service +++ b/systemd/seevar-orchestrator@.service @@ -2,6 +2,7 @@ Description=SeeVar Science Orchestrator (%i) After=network-online.target seevar-weather.service seevar-gps.service Wants=network-online.target seevar-weather.service seevar-gps.service +RequiresMountsFor=/mnt/raid1 StartLimitIntervalSec=300 StartLimitBurst=5 diff --git a/systemd/seevar-planner.service b/systemd/seevar-planner.service index f23af3b..7fe99ba 100644 --- a/systemd/seevar-planner.service +++ b/systemd/seevar-planner.service @@ -2,6 +2,7 @@ Description=SeeVar Nightly Planner After=network-online.target seevar-weather.service seevar-gps.service Wants=network-online.target seevar-weather.service seevar-gps.service +RequiresMountsFor=/mnt/raid1 [Service] Type=oneshot diff --git a/systemd/seevar-raid-watchdog.service b/systemd/seevar-raid-watchdog.service new file mode 100644 index 0000000..9cfa8c4 --- /dev/null +++ b/systemd/seevar-raid-watchdog.service @@ -0,0 +1,15 @@ +[Unit] +Description=SeeVar RAID Watchdog +After=local-fs.target + +[Service] +Type=oneshot +WorkingDirectory=%h/seevar +Environment=PYTHONPATH=%h/seevar +Environment=PYTHONUNBUFFERED=1 +ExecStart=%h/seevar/.venv/bin/python3 dev/utils/raid_watchdog.py --array /dev/md0 --mountpoint /mnt/raid1 --state %h/seevar/logs/raid_state.json --stop-services +StandardOutput=append:%h/seevar/logs/raid_watchdog.log +StandardError=append:%h/seevar/logs/raid_watchdog.log + +TimeoutStartSec=20s +TimeoutStopSec=5s diff --git a/systemd/seevar-raid-watchdog.timer b/systemd/seevar-raid-watchdog.timer new file mode 100644 index 0000000..f28e79a --- /dev/null +++ b/systemd/seevar-raid-watchdog.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run SeeVar RAID Watchdog Every 5 Minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min +Persistent=true +Unit=seevar-raid-watchdog.service + +[Install] +WantedBy=timers.target diff --git a/systemd/seevar-telescope.service b/systemd/seevar-telescope.service index a864eb0..3c9e914 100644 --- a/systemd/seevar-telescope.service +++ b/systemd/seevar-telescope.service @@ -2,6 +2,7 @@ Description=SeeVar Fleet Monitor After=network-online.target Wants=network-online.target +RequiresMountsFor=/mnt/raid1 StartLimitIntervalSec=300 StartLimitBurst=5 diff --git a/systemd/seevar-weather.service b/systemd/seevar-weather.service index c3072ff..8d4ff12 100644 --- a/systemd/seevar-weather.service +++ b/systemd/seevar-weather.service @@ -2,6 +2,7 @@ Description=SeeVar Weather Sentinel After=network-online.target Wants=network-online.target +RequiresMountsFor=/mnt/raid1 StartLimitIntervalSec=300 StartLimitBurst=5