Add DemoCraftClient for complete offline demo mode
- DemoCraftClient in demo.py: duck-typed CraftClient replacement with 8 canned satellites, synthetic pass predictions, and time-varying LEO arcs that drive real AOS/TCA/LOS pass events to the camera overlay - Fix sky map EL signal fidelity: snap _el to _target_el at sweep start so 2D scans compute correct per-row RSSI (was using stale elevation) - Branch on demo_mode in app.py _setup_craft_client() to inject DemoCraftClient instead of the HTTP CraftClient - Add test_demo_craft.py: 5 tests exercising search, passes, tracking, and WAITING state through the full TUI without mocks - Update take_screenshots.py to cover all 8 screens (dashboard, control, craft search, craft tracking, signal, system, console, camera)
This commit is contained in:
parent
7035d814a1
commit
3013eeee4c
4 changed files with 518 additions and 13 deletions
|
|
@ -141,9 +141,14 @@ class BirdcageApp(App):
|
|||
|
||||
def _setup_craft_client(self) -> None:
|
||||
"""Create a Craft API client and hand it to the control screen."""
|
||||
from birdcage_tui.craft_client import CraftClient
|
||||
if self.demo_mode:
|
||||
from birdcage_tui.demo import DemoCraftClient
|
||||
|
||||
client = CraftClient(base_url=self.craft_url)
|
||||
client = DemoCraftClient()
|
||||
else:
|
||||
from birdcage_tui.craft_client import CraftClient
|
||||
|
||||
client = CraftClient(base_url=self.craft_url)
|
||||
try:
|
||||
control = self.query_one("#control")
|
||||
if hasattr(control, "set_craft_client"):
|
||||
|
|
|
|||
|
|
@ -3,14 +3,21 @@
|
|||
Drop-in replacement for SerialBridge that simulates a Winegard Carryout G2
|
||||
dish with motor movement, RSSI signal modeling, and canned firmware responses.
|
||||
No serial hardware required.
|
||||
|
||||
Also provides DemoCraftClient — a duck-typed replacement for CraftClient that
|
||||
returns synthetic satellite data with zero HTTP calls. Supports time-varying
|
||||
LEO arcs so the tracking loop drives real pass events to the camera overlay.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import datetime as _dt
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from enum import Enum, auto
|
||||
|
||||
from birdcage_tui.craft_client import PassPrediction, SearchResult, TargetPosition
|
||||
|
||||
|
||||
class _DemoMenu(Enum):
|
||||
"""Simulated firmware submenu states."""
|
||||
|
|
@ -375,6 +382,10 @@ class DemoDevice:
|
|||
timeout: float = 120,
|
||||
) -> list[dict[str, float]]:
|
||||
"""Simulate a firmware azscanwxp sweep with Gaussian signal peak."""
|
||||
# Snap EL to target so 2D scans compute correct per-row signal.
|
||||
# Without this, move_motor(1, el) only sets _target_el — _el stays
|
||||
# stale because the position interpolator never runs mid-sweep.
|
||||
self._el = self._target_el
|
||||
step_deg = step_cdeg / 100.0
|
||||
if step_deg <= 0:
|
||||
step_deg = 1.0
|
||||
|
|
@ -724,3 +735,216 @@ class DemoDevice:
|
|||
if cmd == "reboot":
|
||||
return "Rebooting...\nApplication Starting Kinetis PCB...\nTRK>"
|
||||
return f"Unknown command: {cmd}\nOS>"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DemoCraftClient — offline Craft API replacement
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Canned satellite catalog for search results.
|
||||
_DEMO_CATALOG: list[dict] = [
|
||||
{
|
||||
"name": "ISS (ZARYA)",
|
||||
"type": "satellite",
|
||||
"id": "25544",
|
||||
"groups": ["stations"],
|
||||
},
|
||||
{
|
||||
"name": "NOAA 19",
|
||||
"type": "satellite",
|
||||
"id": "33591",
|
||||
"groups": ["weather"],
|
||||
},
|
||||
{
|
||||
"name": "SO-50 (SAUDISAT 1C)",
|
||||
"type": "satellite",
|
||||
"id": "27607",
|
||||
"groups": ["amateur"],
|
||||
},
|
||||
{
|
||||
"name": "TEVEL-2",
|
||||
"type": "satellite",
|
||||
"id": "50988",
|
||||
"groups": ["amateur"],
|
||||
},
|
||||
{
|
||||
"name": "AO-91 (FOX-1B)",
|
||||
"type": "satellite",
|
||||
"id": "43017",
|
||||
"groups": ["amateur"],
|
||||
},
|
||||
{
|
||||
"name": "Moon",
|
||||
"type": "celestial",
|
||||
"id": "moon",
|
||||
"groups": ["solar-system"],
|
||||
},
|
||||
{
|
||||
"name": "Sun",
|
||||
"type": "celestial",
|
||||
"id": "sun",
|
||||
"groups": ["solar-system"],
|
||||
},
|
||||
{
|
||||
"name": "Jupiter",
|
||||
"type": "celestial",
|
||||
"id": "jupiter",
|
||||
"groups": ["solar-system"],
|
||||
},
|
||||
]
|
||||
|
||||
# LEO arc parameters: (phase_offset_minutes, period_minutes, max_el)
|
||||
_LEO_ARCS: dict[str, tuple[float, float, float]] = {
|
||||
"25544": (0.0, 10.0, 55.0), # ISS — primary demo target
|
||||
"33591": (3.0, 9.0, 42.0), # NOAA 19
|
||||
"27607": (5.0, 8.5, 38.0), # SO-50
|
||||
"50988": (7.0, 11.0, 48.0), # TEVEL-2
|
||||
"43017": (2.0, 9.5, 35.0), # AO-91
|
||||
}
|
||||
|
||||
|
||||
def _leo_position(target_id: str, t: float) -> tuple[float, float]:
|
||||
"""Compute time-varying AZ/EL for a simulated LEO satellite.
|
||||
|
||||
The arc traces: rise east (AZ ~90, EL 0) -> TCA (~180, max_el)
|
||||
-> set west (AZ ~270, EL 0) over one period, then resets.
|
||||
Returns (az, el) where el < 0 means below horizon.
|
||||
"""
|
||||
offset, period, max_el = _LEO_ARCS.get(target_id, (0.0, 10.0, 40.0))
|
||||
period_sec = period * 60.0
|
||||
phase = ((t + offset * 60.0) % period_sec) / period_sec # 0.0 → 1.0
|
||||
|
||||
# Visible window: phase 0.0-0.5 = above horizon, 0.5-1.0 = below
|
||||
if phase > 0.5:
|
||||
return 0.0, -10.0 # Below horizon
|
||||
|
||||
# Map 0→0.5 to AZ 90→270, EL 0→max→0 (sine arc)
|
||||
arc_phase = phase / 0.5 # 0.0 → 1.0 through the visible pass
|
||||
az = 90.0 + 180.0 * arc_phase
|
||||
el = max_el * math.sin(math.pi * arc_phase)
|
||||
return az, el
|
||||
|
||||
|
||||
def _celestial_position(target_id: str, t: float) -> tuple[float, float]:
|
||||
"""Slow-drift positions for celestial bodies."""
|
||||
if target_id == "moon":
|
||||
az = 145.0 + 0.5 * math.sin(t / 600.0) * (t / 60.0 % 10)
|
||||
el = 32.0 + 3.0 * math.sin(t / 900.0)
|
||||
return az, max(el, 5.0)
|
||||
if target_id == "sun":
|
||||
az = 210.0 + 0.3 * (t / 60.0 % 15)
|
||||
el = 45.0 + 5.0 * math.sin(t / 1200.0)
|
||||
return az, max(el, 10.0)
|
||||
# Jupiter — nearly fixed
|
||||
return 255.0, 28.0
|
||||
|
||||
|
||||
class DemoCraftClient:
|
||||
"""Offline replacement for CraftClient returning synthetic orbital data.
|
||||
|
||||
Duck-typed to match CraftClient's interface. No HTTP calls are made.
|
||||
LEO satellites trace realistic arcs using time.monotonic() so the
|
||||
tracking loop sees genuine AOS/TCA/LOS transitions.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._t0 = time.monotonic()
|
||||
|
||||
def health(self) -> bool:
|
||||
return True
|
||||
|
||||
def search(self, query: str, limit: int = 20) -> list[SearchResult]:
|
||||
q_lower = query.lower()
|
||||
results = []
|
||||
for entry in _DEMO_CATALOG:
|
||||
if q_lower in entry["name"].lower():
|
||||
results.append(
|
||||
SearchResult(
|
||||
name=entry["name"],
|
||||
target_type=entry["type"],
|
||||
target_id=entry["id"],
|
||||
score=1.0,
|
||||
groups=entry["groups"],
|
||||
)
|
||||
)
|
||||
if len(results) >= limit:
|
||||
break
|
||||
return results
|
||||
|
||||
def get_passes(self, norad_id: int, hours: int = 24) -> list[PassPrediction]:
|
||||
now = _dt.datetime.now(tz=_dt.UTC)
|
||||
name = "Unknown"
|
||||
for entry in _DEMO_CATALOG:
|
||||
if entry["id"] == str(norad_id):
|
||||
name = entry["name"]
|
||||
break
|
||||
|
||||
arc_params = _LEO_ARCS.get(str(norad_id), (0.0, 10.0, 40.0))
|
||||
_, period, max_el = arc_params
|
||||
passes = []
|
||||
for i in range(4):
|
||||
aos = now + _dt.timedelta(minutes=30 * (i + 1))
|
||||
tca = aos + _dt.timedelta(minutes=period / 2)
|
||||
los = aos + _dt.timedelta(minutes=period)
|
||||
duration = int(period * 60)
|
||||
passes.append(
|
||||
PassPrediction(
|
||||
satellite_name=name,
|
||||
norad_id=norad_id,
|
||||
aos_time=aos.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
aos_az=90.0 + random.uniform(-10, 10),
|
||||
tca_time=tca.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
tca_alt=max_el,
|
||||
tca_az=180.0 + random.uniform(-15, 15),
|
||||
los_time=los.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
los_az=270.0 + random.uniform(-10, 10),
|
||||
max_elevation=max_el,
|
||||
duration_seconds=duration,
|
||||
is_visible=i < 2,
|
||||
)
|
||||
)
|
||||
return passes
|
||||
|
||||
def get_next_pass(self, norad_id: int) -> PassPrediction | None:
|
||||
passes = self.get_passes(norad_id)
|
||||
return passes[0] if passes else None
|
||||
|
||||
def get_visible_targets(self, min_alt: float = 0.0) -> list[TargetPosition]:
|
||||
t = time.monotonic() - self._t0
|
||||
targets = []
|
||||
|
||||
for entry in _DEMO_CATALOG:
|
||||
tid = entry["id"]
|
||||
ttype = entry["type"]
|
||||
|
||||
if ttype == "satellite" and tid in _LEO_ARCS:
|
||||
az, el = _leo_position(tid, t)
|
||||
elif ttype == "celestial":
|
||||
az, el = _celestial_position(tid, t)
|
||||
else:
|
||||
continue
|
||||
|
||||
if el < min_alt:
|
||||
continue
|
||||
|
||||
# Synthetic distance/range-rate for LEO targets
|
||||
if ttype == "satellite":
|
||||
dist = 400.0 + 200.0 * math.cos(math.pi * el / 90.0)
|
||||
rr = -2.0 + 4.0 * math.sin(t / 120.0)
|
||||
else:
|
||||
dist = 384400.0 if tid == "moon" else 0.0
|
||||
rr = 0.0
|
||||
|
||||
targets.append(
|
||||
TargetPosition(
|
||||
name=entry["name"],
|
||||
target_type=ttype,
|
||||
target_id=tid,
|
||||
azimuth=round(az, 2),
|
||||
altitude=round(el, 2),
|
||||
distance_km=round(dist, 1),
|
||||
range_rate=round(rr, 3),
|
||||
)
|
||||
)
|
||||
|
||||
return targets
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue