birdcage/tui/tests/test_capture_triggers.py
Ryan Malloy 7035d814a1 Add Camera Capture overlay (F6) with multi-trigger capture pipeline
Camera backend abstraction wrapping fswebcam/ffmpeg/libcamera-still via
subprocess, with DemoCamera fallback generating valid JPEG from raw bytes.

Capture pipeline writes JPEG + JSON sidecar always, optional FITS (astropy)
and EXIF (Pillow) when available. Thread-safe orchestrator with session
tracking, sequence numbering, and date-based output directories.

Trigger system: manual capture, configurable interval timer, and pass event
detection (AOS/TCA/LOS) with 0.5-degree TCA hysteresis. PassEventDetector
runs in the Craft tracking loop, fires callbacks to the camera overlay.

F6 overlay follows the F5 ConsoleOverlay pattern — ModalScreen with
install_screen persistence. Status panel, scrollable capture log, interval
controls, and AOS/TCA/LOS toggle buttons.

Tagline: "a generic AZ/EL positioner that doesn't care about wavelength"
added to TUI header subtitle.

51 new tests (77 total passing).
2026-02-16 05:08:18 -07:00

284 lines
8.8 KiB
Python

"""Test capture triggers — interval timer and pass event detection.
IntervalTimer fires at configurable intervals.
PassEventDetector detects AOS/TCA/LOS transitions with hysteresis.
"""
import threading
import time
from birdcage_tui.capture_triggers import (
IntervalTimer,
PassEventDetector,
TriggerEvent,
TriggerType,
)
class _EventCollector:
"""Collects trigger events for test assertions."""
def __init__(self):
self.events: list[TriggerEvent] = []
self._lock = threading.Lock()
def __call__(self, event: TriggerEvent) -> None:
with self._lock:
self.events.append(event)
@property
def count(self) -> int:
with self._lock:
return len(self.events)
def types(self) -> list[TriggerType]:
with self._lock:
return [e.trigger_type for e in self.events]
# ------------------------------------------------------------------
# IntervalTimer tests
# ------------------------------------------------------------------
def test_interval_timer_fires():
"""Timer should fire at least once within 2x the interval."""
collector = _EventCollector()
timer = IntervalTimer(callback=collector, interval_seconds=0.2)
timer.start()
time.sleep(0.5)
timer.stop()
assert collector.count >= 1
assert all(e.trigger_type == TriggerType.INTERVAL for e in collector.events)
def test_interval_timer_stop():
"""Timer should stop cleanly and not fire after stop()."""
collector = _EventCollector()
timer = IntervalTimer(callback=collector, interval_seconds=0.1)
timer.start()
time.sleep(0.25)
timer.stop()
count_at_stop = collector.count
time.sleep(0.3)
assert collector.count == count_at_stop # No new events
def test_interval_timer_set_interval():
"""set_interval should update the interval property."""
collector = _EventCollector()
timer = IntervalTimer(callback=collector, interval_seconds=10.0)
assert timer.interval == 10.0
timer.set_interval(5.0)
assert timer.interval == 5.0
# Minimum clamp
timer.set_interval(0.5)
assert timer.interval >= 0.5
def test_interval_timer_double_start():
"""Starting twice should be safe (no duplicate threads)."""
collector = _EventCollector()
timer = IntervalTimer(callback=collector, interval_seconds=0.2)
timer.start()
timer.start() # Should be no-op
time.sleep(0.5)
timer.stop()
assert collector.count >= 1
def test_interval_timer_is_running():
"""is_running property should reflect timer state."""
collector = _EventCollector()
timer = IntervalTimer(callback=collector, interval_seconds=1.0)
assert not timer.is_running
timer.start()
assert timer.is_running
timer.stop()
assert not timer.is_running
# ------------------------------------------------------------------
# PassEventDetector tests
# ------------------------------------------------------------------
def test_pass_detector_aos():
"""WAITING -> TRACKING should fire AOS."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.AOS)
detector.update("WAITING", "ISS", 0.0)
detector.update("TRACKING", "ISS", 20.0)
assert collector.count == 1
assert collector.events[0].trigger_type == TriggerType.AOS
assert "ISS" in collector.events[0].detail
def test_pass_detector_los():
"""TRACKING -> WAITING should fire LOS."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.LOS)
detector.update("TRACKING", "ISS", 45.0)
detector.update("WAITING", "ISS", 5.0)
assert collector.count == 1
assert collector.events[0].trigger_type == TriggerType.LOS
def test_pass_detector_tca():
"""Elevation peak detection with hysteresis."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.TCA)
# Simulate ascending pass
detector.update("TRACKING", "ISS", 20.0)
detector.update("TRACKING", "ISS", 30.0)
detector.update("TRACKING", "ISS", 40.0)
detector.update("TRACKING", "ISS", 45.0) # Peak
detector.update("TRACKING", "ISS", 44.8) # Small jitter, no trigger
detector.update("TRACKING", "ISS", 44.4) # Below threshold
detector.update("TRACKING", "ISS", 43.0) # Well below peak
assert collector.count == 1
assert collector.events[0].trigger_type == TriggerType.TCA
assert "45.0" in collector.events[0].detail
def test_pass_detector_hysteresis():
"""Small jitter around peak should NOT trigger TCA."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.TCA)
# Simulate noisy plateau at peak
detector.update("TRACKING", "ISS", 44.8)
detector.update("TRACKING", "ISS", 45.0)
detector.update("TRACKING", "ISS", 44.9) # -0.1 from peak
detector.update("TRACKING", "ISS", 44.8) # -0.2 from peak
detector.update("TRACKING", "ISS", 44.7) # -0.3 from peak
# Still within hysteresis threshold (0.5 deg)
assert collector.count == 0
def test_pass_detector_tca_fires_once():
"""TCA should fire only once per pass."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.TCA)
detector.update("TRACKING", "ISS", 45.0) # Peak
detector.update("TRACKING", "ISS", 44.0) # -1.0, triggers TCA
detector.update("TRACKING", "ISS", 43.0) # Should NOT trigger again
detector.update("TRACKING", "ISS", 42.0)
assert collector.count == 1
def test_pass_detector_full_pass():
"""Full pass should generate AOS + TCA + LOS in order."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.AOS, TriggerType.TCA, TriggerType.LOS)
# Pre-pass
detector.update("WAITING", "ISS", 0.0)
# AOS
detector.update("TRACKING", "ISS", 5.0)
# Ascending
detector.update("TRACKING", "ISS", 20.0)
detector.update("TRACKING", "ISS", 35.0)
detector.update("TRACKING", "ISS", 45.0) # Peak
# Descending (triggers TCA)
detector.update("TRACKING", "ISS", 44.0)
detector.update("TRACKING", "ISS", 30.0)
detector.update("TRACKING", "ISS", 10.0)
# LOS
detector.update("WAITING", "ISS", 2.0)
types = collector.types()
assert types == [TriggerType.AOS, TriggerType.TCA, TriggerType.LOS]
def test_pass_detector_disabled_triggers():
"""Disabled triggers should not fire."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
# Enable only AOS, not TCA or LOS
detector.enable(TriggerType.AOS)
detector.update("WAITING", "ISS", 0.0)
detector.update("TRACKING", "ISS", 45.0)
detector.update("TRACKING", "ISS", 30.0)
detector.update("WAITING", "ISS", 0.0)
# Only AOS should fire
assert collector.count == 1
assert collector.events[0].trigger_type == TriggerType.AOS
def test_pass_detector_target_change_resets():
"""Changing target should reset peak tracking."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.TCA)
# First target peaks at 45
detector.update("TRACKING", "ISS", 45.0)
# Switch to new target — peak resets
detector.update("TRACKING", "NOAA 19", 30.0)
detector.update("TRACKING", "NOAA 19", 35.0) # New peak
detector.update("TRACKING", "NOAA 19", 34.0) # -1.0, triggers TCA
assert collector.count == 1
assert "NOAA 19" in collector.events[0].detail
def test_pass_detector_reset():
"""reset() should clear all internal state."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.AOS)
detector.update("TRACKING", "ISS", 45.0)
detector.reset()
# After reset, a new TRACKING should fire AOS again
detector.update("WAITING", "ISS", 0.0)
detector.update("TRACKING", "ISS", 20.0)
assert collector.count == 2 # Initial + after reset
def test_pass_detector_enable_disable():
"""enable/disable should toggle trigger states."""
collector = _EventCollector()
detector = PassEventDetector(on_event=collector)
detector.enable(TriggerType.AOS, TriggerType.LOS)
assert detector.is_enabled(TriggerType.AOS)
assert detector.is_enabled(TriggerType.LOS)
assert not detector.is_enabled(TriggerType.TCA)
detector.disable(TriggerType.AOS)
assert not detector.is_enabled(TriggerType.AOS)
assert detector.is_enabled(TriggerType.LOS)