[AZ-408] [AZ-410] [AZ-411] Batch 69: synth injectors + FT-P-02/03/14

AZ-408 (3pt) — Replace AZ-406 injector scaffolds with concrete generators:
- outlier.py: deterministic stride + far-away tile replacement; AC-2 ≥350m offset
- blackout_spoof.py: paired video blackout + FC GPS spoof with ≤40ms alignment;
  AC-4 realistic fix_type/hdop; AC-NEW-8 200-500m inter-spoof deltas
- multi_segment.py: ≥3 disjoint windows, ≥30s gaps, ≤25% coverage
- fc_proxy.py: timed-splice runtime proxy with pre-activate RuntimeError guard
- _common.py: derive_rng + tile-manifest reader + tmpfs helpers
- injector_fixtures.py: pytest fixtures wired via runner conftest

AZ-410 (3pt) — FT-P-02 cumulative drift between satellite anchors:
- anchor_pair_detector.py: AC-1 detection, AC-2/3 pass-fraction,
  AC-4 monotonicity check, CSV evidence
- test_ft_p_02_derkachi_drift.py: scenario gated on upstream helper
  NotImplementedError (frame_source_replay / fdr_reader / imu_replay)

AZ-411 (2pt) — FT-P-03 + FT-P-14 schema + WGS84:
- estimate_schema.py: AC-1 schema completeness, AC-2 source-label set
  containment, AC-3 WGS84 range + int32 1e-7 decode
- test_ft_p_03_14_schema_wgs84.py: shared single-image-push scenario

Tests: 248 unit tests pass (+91 vs batch 68).
Reports: batch_69_report.md, batch_69_review.md (PASS),
cumulative_review_batches_67-69_cycle1_report.md (PASS).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-16 17:54:00 +03:00
parent ff1b00200c
commit 702a0c0ff3
27 changed files with 4619 additions and 58 deletions
@@ -0,0 +1,229 @@
"""Behavioural tests for the AZ-408 blackout_spoof injector.
Covers:
* AC-1: ``(seed, window, offset, bearing)`` → deterministic schedule + outputs.
* AC-3: schedule's window/spoof timeline matches the documented ≤40 ms
alignment promise.
* AC-4: spoofed-GPS fields stay within realistic-flight ranges.
* AC-NEW-8: inter-spoof position deltas are in [200 m, 500 m].
* AC-6: tmpfs scratch isolation + no escapees.
The runtime alignment between video black frames and proxy spoof
emission is covered separately in ``test_fc_proxy.py`` (the proxy is
the runtime component; the injector here only emits the schedule).
"""
from __future__ import annotations
import json
import math
from pathlib import Path
import pytest
from fixtures.injectors import blackout_spoof
from fixtures.injectors._common import haversine_m
def _build_synthetic_frames_dir(parent: Path, count: int = 600) -> Path:
from PIL import Image # noqa: PLC0415
frames_dir = parent / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
img = Image.new("RGB", (256, 256), color=(40, 40, 40))
for i in range(count):
img.save(
frames_dir / f"AD{i + 1:06d}.jpg",
format="JPEG", quality=85, optimize=False, progressive=False, subsampling=2,
)
return frames_dir
def test_blackout_window_lengths(tmp_path: Path) -> None:
"""The schedule's window is exactly the requested length (modulo clamping)."""
# Arrange — 3000 frames @ 30 fps = 100 s, window anchored at 30 s leaves
# 70 s of headroom — enough for the 5/15/35 s window family the spec asks
# for plus a 25 s probe.
frames = _build_synthetic_frames_dir(tmp_path / "src", count=3000)
for window in (5.0, 15.0, 25.0, 35.0):
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=window
)
# Act
report = blackout_spoof.build(plan, tmp_path / f"out_{int(window)}")
# Assert — window duration ≈ requested (allow ±1 ms for rounding)
duration_ms = report.schedule.window_end_ms - report.schedule.window_start_ms
assert abs(duration_ms - int(window * 1000)) <= 1
def test_blackout_seconds_must_be_positive(tmp_path: Path) -> None:
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=300)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=0.0
)
# Act / Assert
with pytest.raises(ValueError, match="blackout_seconds"):
blackout_spoof.build(plan, tmp_path / "out")
def test_build_is_seed_deterministic(tmp_path: Path) -> None:
"""AC-1: identical inputs → identical schedule.json + identical black-frame bytes."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=600)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames,
blackout_seconds=10.0,
seed=99,
spoof_offset_m=400.0,
spoof_bearing_deg=30.0,
)
# Act
out_a = tmp_path / "run_a"
out_b = tmp_path / "run_b"
blackout_spoof.build(plan, out_a)
blackout_spoof.build(plan, out_b)
# Assert
sched_a = (out_a / "schedule.json").read_bytes()
sched_b = (out_b / "schedule.json").read_bytes()
assert sched_a == sched_b
def test_spoof_track_inter_position_delta_in_range(tmp_path: Path) -> None:
"""AC-NEW-8: consecutive spoofed-GPS positions jump 200-500 m apart."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=900)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=20.0, seed=11
)
# Act
report = blackout_spoof.build(plan, tmp_path / "out")
# Assert
spoof = report.schedule.spoof_gps
assert len(spoof) > 1, "need at least 2 spoofed frames to measure deltas"
for prev, nxt in zip(spoof, spoof[1:]):
d = haversine_m(prev.lat_deg, prev.lon_deg, nxt.lat_deg, nxt.lon_deg)
assert 200.0 <= d <= 500.0, (
f"inter-spoof delta {d:.1f} m outside [200, 500] m"
)
def test_spoof_fields_are_realistic(tmp_path: Path) -> None:
"""AC-4: lat/lon/alt/fix_type/hdop stay inside typical-flight ranges."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=900)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=20.0, seed=22
)
# Act
report = blackout_spoof.build(plan, tmp_path / "out")
# Assert
for f in report.schedule.spoof_gps:
assert not math.isnan(f.lat_deg)
assert -90 <= f.lat_deg <= 90
assert -180 <= f.lon_deg <= 180
assert f.fix_type in (3, 4)
assert 0.5 <= f.hdop <= 2.5
# No sentinel values (e.g. 0 lat/lon or 999 alt)
assert abs(f.lat_deg) > 1e-6
assert abs(f.lon_deg) > 1e-6
assert 50 <= f.alt_m <= 1500
def test_schedule_has_max_alignment_err_per_ac3(tmp_path: Path) -> None:
"""AC-3: schedule records the ≤40 ms alignment-error budget."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=600)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=15.0
)
# Act
report = blackout_spoof.build(plan, tmp_path / "out")
# Assert
assert report.schedule.max_alignment_err_ms == 40.0
def test_blackout_frames_are_black(tmp_path: Path) -> None:
"""Every frame index inside the blackout window has all-zero pixels."""
# Arrange
from PIL import Image # noqa: PLC0415
frames = _build_synthetic_frames_dir(tmp_path / "src", count=600)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=5.0
)
out_root = tmp_path / "out"
# Act
report = blackout_spoof.build(plan, out_root)
# Assert
for idx in report.schedule.blackout_frame_indices[:5]:
name = f"AD{idx + 1:06d}.jpg"
img = Image.open(out_root / "frames" / name).convert("RGB")
# Sample pixel — synthesised black JPEGs round-trip to (0,0,0)
# within JPEG compression noise.
r, g, b = img.getpixel((128, 128)) # type: ignore[misc]
assert r < 5 and g < 5 and b < 5, f"frame {name} pixel ({r},{g},{b}) is not black"
def test_normal_frames_pass_through(tmp_path: Path) -> None:
"""Frames OUTSIDE the blackout window are byte-equal to the source."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=600)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=5.0
)
out_root = tmp_path / "out"
blackout_spoof.build(plan, out_root)
# Act / Assert — the very first frame is always outside (window starts
# at 30 % of source).
src_bytes = (frames / "AD000001.jpg").read_bytes()
out_bytes = (out_root / "frames" / "AD000001.jpg").read_bytes()
assert src_bytes == out_bytes
def test_schedule_json_round_trips(tmp_path: Path) -> None:
"""schedule.json is well-formed JSON with the expected top-level keys."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=600)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=10.0
)
# Act
blackout_spoof.build(plan, tmp_path / "out")
payload = json.loads((tmp_path / "out" / "schedule.json").read_text())
# Assert
assert {"window_start_ms", "window_end_ms", "spoof_gps", "blackout_frame_indices"} <= set(
payload.keys()
)
assert isinstance(payload["spoof_gps"], list)
def test_build_overwrites_existing_out_root(tmp_path: Path) -> None:
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=300)
plan = blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=frames, blackout_seconds=5.0
)
out_root = tmp_path / "out"
blackout_spoof.build(plan, out_root)
(out_root / "stale.bin").write_bytes(b"stale")
# Act
blackout_spoof.build(plan, out_root)
# Assert
assert not (out_root / "stale.bin").exists()
+184
View File
@@ -0,0 +1,184 @@
"""Behavioural tests for the AZ-408 FC inbound proxy patch.
Covers AC-3 (video↔proxy alignment ≤ 40 ms — verified end-to-end via the
fake clock here; the runtime path observes the same invariant) and the
proxy's pass-through / spoof-replace semantics.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fixtures.injectors.fc_proxy import BlackoutSpoofProxy, SpoofGpsRecord
class _FakeClock:
"""Monotonic ms clock that the test advances manually."""
def __init__(self, start_ms: int = 0) -> None:
self.now_ms = start_ms
def __call__(self) -> int:
return self.now_ms
def advance(self, ms: int) -> None:
self.now_ms += ms
def _spoof_records() -> list[SpoofGpsRecord]:
return [
SpoofGpsRecord(monotonic_ms=1000 + i * 100, lat_deg=50.0 + i * 0.001,
lon_deg=36.1, alt_m=300.0, fix_type=3, hdop=1.0)
for i in range(5)
]
def test_proxy_passes_through_outside_window() -> None:
# Arrange — schedule the first blackout 500 ms in the future. The
# activate() call binds proxy_time(now) = 0; the window opens at
# window_start_ms = 500 in proxy time. Now (proxy_time = 0) is
# outside [500, 1000], so the proxy must pass through.
clock = _FakeClock(start_ms=1000)
proxy = BlackoutSpoofProxy(window_start_ms=500, window_end_ms=1000,
spoof_gps=_spoof_records())
proxy.activate(now_ms_provider=clock, first_blackout_ms=1500)
msg = {"lat_deg": 49.9, "lon_deg": 36.0, "alt_m": 280.0}
# Act
out = proxy.process_inbound_message(msg)
# Assert
assert out == msg
assert "__spoofed__" not in out
def test_proxy_spoofs_inside_window() -> None:
# Arrange
clock = _FakeClock(start_ms=0)
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=500,
spoof_gps=_spoof_records())
proxy.activate(now_ms_provider=clock, first_blackout_ms=0)
msg = {"lat_deg": 49.9, "lon_deg": 36.0, "alt_m": 280.0}
# Act — clock=0 ⇒ proxy_time(0) = 0 (inside window)
out = proxy.process_inbound_message(msg)
# Assert
assert out["__spoofed__"] is True
assert out["lat_deg"] != msg["lat_deg"]
assert out["fix_type"] == 3
def test_proxy_returns_to_passthrough_after_window() -> None:
# Arrange
clock = _FakeClock(start_ms=0)
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=500,
spoof_gps=_spoof_records())
proxy.activate(now_ms_provider=clock, first_blackout_ms=0)
# Act — advance past end of window
clock.advance(1000)
msg = {"lat_deg": 50.0, "lon_deg": 36.0, "alt_m": 300.0}
out = proxy.process_inbound_message(msg)
# Assert
assert out == msg
def test_alignment_err_below_40ms_when_clock_matches_first_blackout() -> None:
"""AC-3: when the test harness calls activate() at the same ms the
first blackout frame fires, alignment error is 0."""
# Arrange
clock = _FakeClock(start_ms=12_345)
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=500, spoof_gps=_spoof_records())
# Act
report = proxy.activate(now_ms_provider=clock, first_blackout_ms=12_345)
# Assert
assert report.alignment_err_ms == 0
assert report.alignment_err_ms <= 40
def test_alignment_err_within_budget_under_normal_clock_skew() -> None:
"""Real harness can have a 30 ms skew between video & proxy; still inside AC-3."""
# Arrange
clock = _FakeClock(start_ms=12_400)
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=500, spoof_gps=_spoof_records())
# Act — first_blackout_ms is 30 ms earlier than clock (harness skew)
report = proxy.activate(now_ms_provider=clock, first_blackout_ms=12_370)
# Assert
assert report.alignment_err_ms == 30
assert report.alignment_err_ms <= 40
def test_exhausting_spoof_list_repeats_last() -> None:
"""When the spoofed-GPS list is drained, the FC keeps seeing the last record."""
# Arrange
clock = _FakeClock(start_ms=0)
spoofs = _spoof_records()
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=10_000, spoof_gps=spoofs)
proxy.activate(now_ms_provider=clock, first_blackout_ms=0)
# Act — pull 10 frames (more than the 5 in the list)
outs = [proxy.process_inbound_message({"lat_deg": 0, "lon_deg": 0, "alt_m": 0}) for _ in range(10)]
# Assert — last 5 outputs all reuse the final spoof record
last = spoofs[-1]
for o in outs[-3:]:
assert o["lat_deg"] == last.lat_deg
assert o["lon_deg"] == last.lon_deg
def test_from_schedule_file_round_trip(tmp_path: Path) -> None:
# Arrange
sched_path = tmp_path / "schedule.json"
sched_path.write_text(
json.dumps(
{
"window_start_ms": 0,
"window_end_ms": 200,
"max_alignment_err_ms": 40.0,
"blackout_frame_indices": [0, 1, 2],
"spoof_gps": [
{"monotonic_ms": 0, "lat_deg": 50.0, "lon_deg": 36.0,
"alt_m": 300.0, "fix_type": 3, "hdop": 1.0},
],
}
)
)
# Act
proxy = BlackoutSpoofProxy.from_schedule_file(sched_path)
proxy.activate(now_ms_provider=lambda: 0)
out = proxy.process_inbound_message({"lat_deg": 0, "lon_deg": 0, "alt_m": 0})
# Assert
assert out["__spoofed__"] is True
assert out["lat_deg"] == 50.0
def test_from_schedule_file_missing_raises(tmp_path: Path) -> None:
# Arrange / Act / Assert
with pytest.raises(FileNotFoundError):
BlackoutSpoofProxy.from_schedule_file(tmp_path / "missing.json")
def test_process_before_activate_raises() -> None:
# Arrange
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=100, spoof_gps=_spoof_records())
# Act / Assert
with pytest.raises(RuntimeError, match="not activated"):
proxy.process_inbound_message({})
def test_in_window_false_before_activate() -> None:
# Arrange
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=100, spoof_gps=[])
# Act / Assert
assert proxy.in_window() is False
@@ -1,8 +1,10 @@
"""Unit tests for the injector public surfaces.
"""Public-surface contract tests for the AZ-408 injector dataclasses.
AZ-406 commits to the type signatures + the NotImplementedError pointer.
AZ-408 will replace each NotImplementedError with a real generator; these
tests will then be updated alongside the implementation.
AZ-406 commits to module locations; AZ-408 owns the concrete dataclass
shapes. These tests assert the API surface (frozen dataclasses, public
``build()`` functions returning typed reports). Behavioural tests live
in their own files (``test_outlier.py``, ``test_blackout_spoof.py``,
``test_multi_segment.py``, ``test_fc_proxy.py``).
"""
from __future__ import annotations
@@ -11,52 +13,129 @@ from pathlib import Path
import pytest
from fixtures.injectors.blackout_spoof import BlackoutSpoofPlan
from fixtures.injectors.blackout_spoof import build as build_blackout_spoof
from fixtures.injectors.blackout_spoof import BlackoutSpoofPlan, BlackoutSpoofReport
from fixtures.injectors.cold_boot import ColdBootFixture
from fixtures.injectors.cold_boot import load as load_cold_boot
from fixtures.injectors.multi_segment import MultiSegmentPlan
from fixtures.injectors.multi_segment import build as build_multi_segment
from fixtures.injectors.outlier import OutlierInjectionPlan
from fixtures.injectors.outlier import build as build_outlier
from fixtures.injectors.fc_proxy import BlackoutSpoofProxy, SpoofGpsRecord
from fixtures.injectors.multi_segment import MultiSegmentPlan, MultiSegmentReport
from fixtures.injectors.outlier import OutlierInjectionPlan, OutlierInjectionReport
def test_outlier_plan_dataclass_is_frozen() -> None:
plan = OutlierInjectionPlan(target_segment_seconds=(0.0, 5.0))
# Arrange
plan = OutlierInjectionPlan(
source_frames_dir=Path("/tmp/frames"),
tile_cache_dir=Path("/tmp/tile-cache"),
density="medium",
)
# Act / Assert
with pytest.raises(AttributeError):
plan.max_offset_m = 999.0 # type: ignore[misc]
assert plan.max_offset_m == 350.0
plan.density = "heavy" # type: ignore[misc]
assert plan.min_offset_m == 350.0
def test_outlier_build_raises_until_az408_lands() -> None:
with pytest.raises(NotImplementedError, match="AZ-408"):
build_outlier(OutlierInjectionPlan(target_segment_seconds=(0.0, 5.0)), Path("/tmp"))
def test_outlier_plan_density_literal_round_trip() -> None:
# Arrange / Act
for density in ("light", "medium", "heavy"):
plan = OutlierInjectionPlan(
source_frames_dir=Path("/tmp"),
tile_cache_dir=Path("/tmp"),
density=density, # type: ignore[arg-type]
)
# Assert
assert plan.density == density
def test_outlier_report_is_frozen_dataclass() -> None:
# Arrange
report = OutlierInjectionReport(
out_root=Path("/tmp/out"),
total_source_frames=100,
replaced_frame_count=10,
density="medium",
min_geodesic_offset_m=400.0,
max_geodesic_offset_m=900.0,
)
# Act / Assert
with pytest.raises(AttributeError):
report.replaced_frame_count = 20 # type: ignore[misc]
def test_blackout_spoof_plan_round_trip() -> None:
plan = BlackoutSpoofPlan(blackout_seconds=35.0, spoof_offset_m=120.0, spoof_bearing_deg=90.0)
# Arrange / Act
plan = BlackoutSpoofPlan(
source_frames_dir=Path("/tmp/frames"),
blackout_seconds=35.0,
spoof_offset_m=120.0,
spoof_bearing_deg=90.0,
)
# Assert
assert plan.blackout_seconds == 35.0
with pytest.raises(NotImplementedError, match="AZ-408"):
build_blackout_spoof(plan, Path("/tmp"))
assert plan.max_alignment_err_ms == 40.0 # default per AC-3
def test_blackout_spoof_report_is_frozen_dataclass() -> None:
# Arrange
proxy = BlackoutSpoofProxy(window_start_ms=0, window_end_ms=1000, spoof_gps=[])
# Assert that the report type is constructible (smoke check)
assert proxy.activation_report is None
def test_multi_segment_plan_defaults() -> None:
plan = MultiSegmentPlan()
# Arrange / Act
plan = MultiSegmentPlan(source_frames_dir=Path("/tmp/frames"))
# Assert
assert plan.n_segments == 3
with pytest.raises(NotImplementedError, match="AZ-408"):
build_multi_segment(plan, Path("/tmp"))
assert plan.segment_seconds == 12.0
def test_multi_segment_report_is_frozen_dataclass() -> None:
# Arrange
report = MultiSegmentReport(
out_root=Path("/tmp/out"),
segments=[],
source_duration_ms=300_000,
total_blackout_frames=300,
total_blackout_fraction=0.10,
)
# Act / Assert
with pytest.raises(AttributeError):
report.source_duration_ms = 0 # type: ignore[misc]
def test_spoof_gps_record_is_frozen_dataclass() -> None:
# Arrange
rec = SpoofGpsRecord(
monotonic_ms=1000,
lat_deg=50.1,
lon_deg=36.2,
alt_m=300.0,
fix_type=3,
hdop=1.0,
)
# Act / Assert
with pytest.raises(AttributeError):
rec.lat_deg = 0.0 # type: ignore[misc]
# Cold-boot tests are unchanged from AZ-406 — the cold-boot loader is
# still owned by AZ-419, not AZ-408.
def test_cold_boot_fixture_dataclass_is_frozen() -> None:
# Arrange
fx = ColdBootFixture(
lat_deg=50.0, lon_deg=30.0, alt_m=300.0, yaw_deg=180.0, last_valid_fix_age_s=2.5
)
# Act / Assert
with pytest.raises(AttributeError):
fx.alt_m = 999.0 # type: ignore[misc]
def test_cold_boot_load_raises_until_az419_lands(tmp_path: Path) -> None:
# Arrange
fixture_path = tmp_path / "cold_boot_fixture.json"
fixture_path.write_text("{}", encoding="utf-8")
# Act / Assert
with pytest.raises(NotImplementedError, match="AZ-419"):
load_cold_boot(fixture_path)
@@ -0,0 +1,172 @@
"""Behavioural tests for the AZ-408 multi_segment injector.
Covers AC-5 (≥3 disjoint windows, ≥30 s gaps, ≤25 % total coverage) and
AC-6 (tmpfs scratch isolation).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fixtures.injectors import multi_segment
def _build_synthetic_frames_dir(parent: Path, count: int) -> Path:
from PIL import Image # noqa: PLC0415
frames_dir = parent / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
img = Image.new("RGB", (256, 256), color=(60, 60, 60))
for i in range(count):
img.save(
frames_dir / f"AD{i + 1:06d}.jpg",
format="JPEG", quality=85, optimize=False, progressive=False, subsampling=2,
)
return frames_dir
def test_produces_three_disjoint_segments(tmp_path: Path) -> None:
"""AC-5: 3 disjoint blackout windows."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=9000) # 5 min @ 30 fps
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=15.0
)
# Act
report = multi_segment.build(plan, tmp_path / "out")
# Assert
assert len(report.segments) == 3
# Each segment is non-empty
for s in report.segments:
assert s.end_ms > s.start_ms
# Disjoint
for prev, nxt in zip(report.segments, report.segments[1:]):
assert prev.end_ms < nxt.start_ms
def test_segments_are_at_least_30_seconds_apart(tmp_path: Path) -> None:
"""AC-5: consecutive segments separated by ≥30 s of normal frames."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=9000)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=12.0
)
# Act
report = multi_segment.build(plan, tmp_path / "out")
# Assert
for prev, nxt in zip(report.segments, report.segments[1:]):
gap_ms = nxt.start_ms - prev.end_ms
assert gap_ms >= 30_000, f"gap {gap_ms} ms < 30 s between segments"
def test_total_blackout_below_25_percent(tmp_path: Path) -> None:
"""AC-5: total blackout coverage ≤ 25 %."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=9000)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=15.0
)
# Act
report = multi_segment.build(plan, tmp_path / "out")
# Assert
assert report.total_blackout_fraction <= 0.25
def test_rejects_overlapping_gap(tmp_path: Path) -> None:
"""Infeasible plan: too many segments inside too short a source."""
# Arrange — 30 s source can't fit 3×12 s segments with 30 s gaps
frames = _build_synthetic_frames_dir(tmp_path / "src", count=900)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=12.0
)
# Act / Assert
with pytest.raises(ValueError, match="gap between segment|blackout fraction"):
multi_segment.build(plan, tmp_path / "out")
def test_rejects_too_few_segments(tmp_path: Path) -> None:
"""AC-5: n_segments must be ≥3."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=900)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=2, segment_seconds=5.0
)
# Act / Assert
with pytest.raises(ValueError, match="n_segments must be ≥3"):
multi_segment.build(plan, tmp_path / "out")
def test_rejects_zero_segment_seconds(tmp_path: Path) -> None:
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=900)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=0.0
)
# Act / Assert
with pytest.raises(ValueError, match="segment_seconds"):
multi_segment.build(plan, tmp_path / "out")
def test_blackout_frames_are_black(tmp_path: Path) -> None:
"""Frames inside any segment are all-zero (black) on disk."""
# Arrange
from PIL import Image # noqa: PLC0415
frames = _build_synthetic_frames_dir(tmp_path / "src", count=9000)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=10.0
)
out_root = tmp_path / "out"
report = multi_segment.build(plan, out_root)
# Act
for seg in report.segments[:1]: # spot-check first segment
for idx in range(seg.first_frame_idx, min(seg.first_frame_idx + 5, seg.last_frame_idx)):
name = f"AD{idx + 1:06d}.jpg"
img = Image.open(out_root / "frames" / name).convert("RGB")
r, g, b = img.getpixel((128, 128)) # type: ignore[misc]
# Assert
assert r < 5 and g < 5 and b < 5
def test_summary_json_present_with_expected_fields(tmp_path: Path) -> None:
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=9000)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=10.0
)
# Act
multi_segment.build(plan, tmp_path / "out")
payload = json.loads((tmp_path / "out" / "summary.json").read_text())
# Assert
assert payload["scenario"] == "multi-segment-derkachi"
assert payload["n_segments"] == 3
assert payload["total_blackout_fraction"] <= 0.25
def test_overwrites_existing_out_root(tmp_path: Path) -> None:
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=9000)
plan = multi_segment.MultiSegmentPlan(
source_frames_dir=frames, n_segments=3, segment_seconds=10.0
)
out_root = tmp_path / "out"
multi_segment.build(plan, out_root)
(out_root / "stale.txt").write_text("stale")
# Act
multi_segment.build(plan, out_root)
# Assert
assert not (out_root / "stale.txt").exists()
+404
View File
@@ -0,0 +1,404 @@
"""Behavioural tests for the AZ-408 outlier injector.
Covers AC-1 (seed determinism), AC-2 (geodesic offset enforcement), and
AC-6 (tmpfs scratch isolation). Density-flag mapping is tested directly
against the ``_DENSITY_RATIO`` table.
"""
from __future__ import annotations
import csv
import io
import json
import math
from pathlib import Path
import pytest
from fixtures.injectors import outlier
from fixtures.injectors._common import (
derive_rng,
far_away_indices,
haversine_m,
iter_video_frame_indices,
read_tile_manifest,
)
# ---------------------------------------------------------------------------
# Fixture-builder helpers (synthetic tile cache + frames)
# ---------------------------------------------------------------------------
def _write_synthetic_frame(path: Path, color: tuple[int, int, int] = (40, 40, 40)) -> None:
from PIL import Image # noqa: PLC0415
img = Image.new("RGB", (256, 256), color=color)
img.save(path, format="JPEG", quality=85, optimize=False, progressive=False, subsampling=2)
def _build_synthetic_frames_dir(parent: Path, count: int = 100) -> Path:
"""Make a fake AD*.jpg directory under ``parent/frames``."""
frames_dir = parent / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
for i in range(count):
_write_synthetic_frame(frames_dir / f"AD{i + 1:06d}.jpg")
return frames_dir
def _build_synthetic_tile_cache(parent: Path, n_tiles: int = 16) -> Path:
"""Make a fake tile-cache tree under ``parent/tile-cache``.
The fake cache covers the same Derkachi bbox the real builder uses,
but with a smaller grid so the unit test stays fast. Tiles are
placed at zoom 18 with deterministic (tx, ty) offsets — the
far-away-tile check uses geodesic distance computed from the
(tx, ty) so any spread > 350 m at zoom 18 satisfies AC-2.
"""
cache_dir = parent / "tile-cache"
tiles_dir = cache_dir / "tiles" / "18"
tiles_dir.mkdir(parents=True, exist_ok=True)
rows = []
# Zoom-18 grid spread of ~10 tiles each axis covers ~1.5 km at the
# Derkachi latitude — easily > 350 m offset between corners.
base_tx = 1 << 17
base_ty = 1 << 17
for i in range(n_tiles):
tx = base_tx + (i % 4) * 4
ty = base_ty + (i // 4) * 4
tile_subdir = tiles_dir / str(tx)
tile_subdir.mkdir(parents=True, exist_ok=True)
_write_synthetic_frame(tile_subdir / f"{ty}.jpg", color=(i * 5, 90, 200 - i * 5))
rows.append(
{
"zoom_level": 18,
"tile_x": tx,
"tile_y": ty,
"capture_date": "2025-11-01",
"source": "stub",
"m_per_px": 0.5,
"jpeg_path": f"tiles/18/{tx}/{ty}.jpg",
"content_hash": "deadbeef",
"provenance": f"paired_gmaps:AD{i + 1:06d}" if i < 16 else "STUB",
}
)
manifest = cache_dir / "manifest.csv"
with manifest.open("w", newline="") as fp:
writer = csv.DictWriter(fp, fieldnames=list(rows[0].keys()), lineterminator="\n")
writer.writeheader()
writer.writerows(rows)
return cache_dir
# ---------------------------------------------------------------------------
# AC-1: density-flag determinism
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"density, expected_stride",
[("light", 100), ("medium", 10), ("heavy", 3)],
)
def test_density_ratio_maps_to_correct_stride(density: outlier.Density, expected_stride: int) -> None:
# Arrange
total = 1000
# Act
indices = list(iter_video_frame_indices(total, outlier._DENSITY_RATIO[density]))
# Assert
assert indices[0] == 0
# Stride should match the documented ratio
assert indices[1] - indices[0] == expected_stride
expected_count = (total + expected_stride - 1) // expected_stride
assert len(indices) == expected_count
def test_build_is_seed_deterministic(tmp_path: Path) -> None:
"""AC-1: same seed → identical manifest + identical replaced bytes."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path, count=80)
cache = _build_synthetic_tile_cache(tmp_path, n_tiles=16)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=frames,
tile_cache_dir=cache,
density="medium",
seed=42,
)
# Act
out_a = tmp_path / "run_a"
out_b = tmp_path / "run_b"
outlier.build(plan, out_a)
outlier.build(plan, out_b)
# Assert — manifest bit-identical
manifest_a = (out_a / "manifest.csv").read_bytes()
manifest_b = (out_b / "manifest.csv").read_bytes()
assert manifest_a == manifest_b
# Replaced frames bit-identical
rows = list(csv.DictReader(io.StringIO((out_a / "manifest.csv").read_text())))
assert rows, "manifest should have at least one replaced frame"
for row in rows:
name = row["src_jpeg_path"]
assert (out_a / "frames" / name).read_bytes() == (out_b / "frames" / name).read_bytes(), (
f"replaced frame {name} differs across runs"
)
def test_different_seeds_produce_different_replacements(tmp_path: Path) -> None:
"""Sanity: different seeds → different replacement-tile picks."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path, count=40)
cache = _build_synthetic_tile_cache(tmp_path, n_tiles=16)
plan_a = outlier.OutlierInjectionPlan(
source_frames_dir=frames, tile_cache_dir=cache, density="medium", seed=1
)
plan_b = outlier.OutlierInjectionPlan(
source_frames_dir=frames, tile_cache_dir=cache, density="medium", seed=2
)
# Act
out_a = tmp_path / "seed_a"
out_b = tmp_path / "seed_b"
outlier.build(plan_a, out_a)
outlier.build(plan_b, out_b)
# Assert — replacement-tile picks differ
rows_a = list(csv.DictReader(io.StringIO((out_a / "manifest.csv").read_text())))
rows_b = list(csv.DictReader(io.StringIO((out_b / "manifest.csv").read_text())))
assert rows_a and rows_b
pick_a = [(r["replacement_tile_x"], r["replacement_tile_y"]) for r in rows_a]
pick_b = [(r["replacement_tile_x"], r["replacement_tile_y"]) for r in rows_b]
assert pick_a != pick_b, "different seeds should produce different replacement picks"
# ---------------------------------------------------------------------------
# AC-2: every replacement crop is ≥350 m from the original frame
# ---------------------------------------------------------------------------
def test_every_replacement_exceeds_min_offset(tmp_path: Path) -> None:
"""AC-2: ≥99 % of crops are > 350 m from original; with synth cache, 100 %."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path, count=60)
cache = _build_synthetic_tile_cache(tmp_path, n_tiles=16)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=frames,
tile_cache_dir=cache,
density="medium",
seed=7,
min_offset_m=350.0,
)
# Act
report = outlier.build(plan, tmp_path / "out")
# Assert
rows = list(csv.DictReader(io.StringIO((tmp_path / "out" / "manifest.csv").read_text())))
assert rows, "should have replaced at least one frame"
offsets = [float(r["geodesic_offset_m"]) for r in rows]
assert all(o >= 350.0 for o in offsets), f"min offset {min(offsets)} < 350 m"
assert report.min_geodesic_offset_m >= 350.0
def test_far_away_indices_filters_by_distance() -> None:
"""Unit test the helper directly."""
# Arrange
from fixtures.injectors._common import TileGtRow
rows = [
TileGtRow(18, 0, 0, "", "", 0.5, "", "", "", 50.0, 30.0),
TileGtRow(18, 1, 0, "", "", 0.5, "", "", "", 50.001, 30.001), # ~140 m away
TileGtRow(18, 2, 0, "", "", 0.5, "", "", "", 50.02, 30.02), # ~2.8 km away
]
# Act
far = far_away_indices(rows, src_idx=0, min_offset_m=350.0)
# Assert
assert far == [2]
# ---------------------------------------------------------------------------
# AC-6: tmpfs scratch isolation + manifest schema
# ---------------------------------------------------------------------------
def test_build_writes_only_under_out_root(tmp_path: Path) -> None:
"""AC-6: nothing escapes the requested out_root."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=30)
cache = _build_synthetic_tile_cache(tmp_path / "src", n_tiles=16)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=frames, tile_cache_dir=cache, density="heavy"
)
out_root = tmp_path / "out"
# Act
outlier.build(plan, out_root)
# Assert — only expected files present, nothing outside out_root
expected = {
"frames",
"manifest.csv",
"summary.json",
}
actual = {p.name for p in out_root.iterdir()}
assert actual == expected
def test_build_overwrites_existing_out_root(tmp_path: Path) -> None:
"""Re-running build wipes the previous run cleanly (no stale files)."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=20)
cache = _build_synthetic_tile_cache(tmp_path / "src", n_tiles=16)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=frames, tile_cache_dir=cache, density="medium"
)
out_root = tmp_path / "out"
outlier.build(plan, out_root)
# Plant a stale file the next build should remove.
(out_root / "stale.txt").write_text("stale")
# Act
outlier.build(plan, out_root)
# Assert
assert not (out_root / "stale.txt").exists()
def test_summary_json_matches_report(tmp_path: Path) -> None:
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=50)
cache = _build_synthetic_tile_cache(tmp_path / "src", n_tiles=16)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=frames, tile_cache_dir=cache, density="light", seed=3
)
out_root = tmp_path / "out"
# Act
report = outlier.build(plan, out_root)
payload = json.loads((out_root / "summary.json").read_text())
# Assert
assert payload["scenario"] == "outlier-injection-derkachi"
assert payload["total_source_frames"] == report.total_source_frames
assert payload["replaced_frame_count"] == report.replaced_frame_count
assert payload["density"] == "light"
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
def test_missing_source_frames_raises(tmp_path: Path) -> None:
# Arrange
cache = _build_synthetic_tile_cache(tmp_path, n_tiles=16)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=tmp_path / "does-not-exist",
tile_cache_dir=cache,
density="medium",
)
# Act / Assert
with pytest.raises(FileNotFoundError, match="source frames"):
outlier.build(plan, tmp_path / "out")
def test_missing_tile_manifest_raises(tmp_path: Path) -> None:
# Arrange
frames = _build_synthetic_frames_dir(tmp_path, count=10)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=frames,
tile_cache_dir=tmp_path / "no-cache",
density="medium",
)
# Act / Assert
with pytest.raises(FileNotFoundError, match="tile-cache manifest"):
outlier.build(plan, tmp_path / "out")
def test_read_tile_manifest_round_trips(tmp_path: Path) -> None:
# Arrange
cache = _build_synthetic_tile_cache(tmp_path, n_tiles=8)
# Act
rows = read_tile_manifest(cache / "manifest.csv")
# Assert
assert len(rows) == 8
assert all(-90 <= r.centre_lat_deg <= 90 for r in rows)
assert all(-180 <= r.centre_lon_deg <= 180 for r in rows)
def test_derive_rng_is_stable_across_calls() -> None:
# Arrange / Act
r1 = derive_rng("outlier", 42, "medium").integers(0, 1_000_000_000)
r2 = derive_rng("outlier", 42, "medium").integers(0, 1_000_000_000)
# Assert
assert r1 == r2
def test_derive_rng_differs_across_domains() -> None:
# Arrange / Act
out = derive_rng("outlier", 42).integers(0, 1_000_000_000)
bsp = derive_rng("blackout_spoof", 42).integers(0, 1_000_000_000)
# Assert
assert out != bsp, "different domains must produce independent streams"
def test_haversine_known_distance() -> None:
"""Sanity-check the haversine helper against a known fixture."""
# Arrange
# ~1 deg of latitude ≈ 111 km
# Act
d = haversine_m(50.0, 30.0, 51.0, 30.0)
# Assert
assert 111_000 < d < 112_000
def test_iter_video_frame_indices_rejects_bad_ratio() -> None:
# Arrange / Act / Assert
with pytest.raises(ValueError):
list(iter_video_frame_indices(100, 0.0))
with pytest.raises(ValueError):
list(iter_video_frame_indices(100, 1.5))
def test_cleanup_tmpfs_removes_scratch(tmp_path: Path) -> None:
"""AC-6: ``cleanup_tmpfs`` rm-trees the scratch dir; called from fixture teardown."""
# Arrange
from fixtures.injectors._common import cleanup_tmpfs
scratch = tmp_path / "scratch"
(scratch / "deep" / "nested").mkdir(parents=True)
(scratch / "deep" / "nested" / "file.txt").write_text("x")
# Act
cleanup_tmpfs(scratch)
# Assert
assert not scratch.exists()
def test_cleanup_tmpfs_is_silent_for_missing_path(tmp_path: Path) -> None:
"""``cleanup_tmpfs`` must not raise for a non-existent path (idempotent)."""
# Arrange
from fixtures.injectors._common import cleanup_tmpfs
# Act / Assert
cleanup_tmpfs(tmp_path / "never-existed")
def test_replacement_density_meets_target(tmp_path: Path) -> None:
"""Sanity: heavy density replaces ≈ 1/3 of frames."""
# Arrange
frames = _build_synthetic_frames_dir(tmp_path / "src", count=300)
cache = _build_synthetic_tile_cache(tmp_path / "src", n_tiles=16)
plan = outlier.OutlierInjectionPlan(
source_frames_dir=frames, tile_cache_dir=cache, density="heavy"
)
# Act
report = outlier.build(plan, tmp_path / "out")
# Assert
actual_ratio = report.replaced_frame_count / report.total_source_frames
assert 0.30 < actual_ratio < 0.40, f"heavy density gave {actual_ratio} (want ≈ 0.33)"
@@ -0,0 +1,312 @@
"""Unit tests for the AZ-410 anchor-pair detector (FT-P-02 logic).
Validates AC-1 (anchor-pair detection), AC-2 (visual-only drift bound),
AC-3 (IMU-fused drift bound), and AC-4 (monotonic distribution) using
synthetic FdrEstimate streams. The full-replay scenario test
(``test_ft_p_02_derkachi_drift.py``) imports this helper but is skipped
until the docker harness helpers land — these tests are the AC coverage
for the logic itself.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from runner.helpers.anchor_pair_detector import (
AnchorPair,
DEFAULT_AGE_BIN_EDGES_MS,
FdrEstimate,
aggregate,
bin_drifts,
check_monotonic,
compute_pass_fraction,
detect_anchor_pairs,
write_csv_evidence,
)
# ---------------------------------------------------------------------------
# Stream builders
# ---------------------------------------------------------------------------
def _est(
t_ms: int,
lat: float,
lon: float,
label: str,
imu_fused: bool = False,
age_ms: int = 0,
) -> FdrEstimate:
return FdrEstimate(
monotonic_ms=t_ms,
lat_deg=lat,
lon_deg=lon,
source_label=label, # type: ignore[arg-type]
imu_fused=imu_fused,
last_satellite_anchor_age_ms=age_ms,
)
# Derkachi-ish base coords.
_BASE_LAT = 50.075
_BASE_LON = 36.150
# ---------------------------------------------------------------------------
# AC-1: anchor-pair detection
# ---------------------------------------------------------------------------
def test_first_anchor_is_not_a_pair() -> None:
# Arrange — a stream that starts with an anchor must not produce a pair
stream = [
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=0),
_est(100, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=100),
]
# Act
pairs = detect_anchor_pairs(stream)
# Assert
assert pairs == [] # zero segments precede each anchor
def test_simple_visual_only_pair() -> None:
# Arrange — a→visual→visual→a, the second `a` makes one pair.
stream = [
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored"),
_est(100, _BASE_LAT + 0.0001, _BASE_LON, "visual_propagated"),
_est(200, _BASE_LAT + 0.0002, _BASE_LON, "visual_propagated"),
_est(300, _BASE_LAT - 0.0001, _BASE_LON, "satellite_anchored", age_ms=300),
]
# Act
pairs = detect_anchor_pairs(stream)
# Assert
assert len(pairs) == 1
p = pairs[0]
assert p.propagated_centre_ms == 200
assert p.anchor_ms == 300
assert p.last_satellite_anchor_age_ms == 300
assert not p.imu_fused_segment
assert p.drift_m > 0
def test_imu_fused_segment_classifies_pair() -> None:
# Arrange — any frame with imu_fused=True in the segment marks the pair
stream = [
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored"),
_est(100, _BASE_LAT + 0.0001, _BASE_LON, "visual_propagated", imu_fused=True),
_est(200, _BASE_LAT + 0.0002, _BASE_LON, "visual_propagated"),
_est(300, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=300),
]
# Act
pairs = detect_anchor_pairs(stream)
# Assert
assert pairs[0].imu_fused_segment is True
def test_dead_reckoned_in_segment_still_pair() -> None:
# Arrange
stream = [
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored"),
_est(100, _BASE_LAT + 0.0001, _BASE_LON, "dead_reckoned"),
_est(200, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=200),
]
# Act
pairs = detect_anchor_pairs(stream)
# Assert
assert len(pairs) == 1
def test_multiple_pairs_in_one_flight() -> None:
# Arrange — 3 anchors → 2 pairs
stream = [
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored"),
_est(50, _BASE_LAT + 0.0001, _BASE_LON, "visual_propagated"),
_est(100, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=100),
_est(150, _BASE_LAT + 0.0001, _BASE_LON, "visual_propagated"),
_est(200, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=100),
]
# Act
pairs = detect_anchor_pairs(stream)
# Assert
assert len(pairs) == 2
# ---------------------------------------------------------------------------
# Drift computation
# ---------------------------------------------------------------------------
def test_drift_is_geodesic_meters() -> None:
"""Drift uses pyproj/WGS84 Vincenty — ~1 deg of lat ≈ 111 km."""
# Arrange — propagate to lat+1 deg, anchor at base; expect ~111 km drift
stream = [
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored"),
_est(100, _BASE_LAT + 1.0, _BASE_LON, "visual_propagated"),
_est(200, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=200),
]
# Act
pairs = detect_anchor_pairs(stream)
# Assert — bracket the expected geodesic distance
assert 110_000 < pairs[0].drift_m < 112_000
# ---------------------------------------------------------------------------
# AC-2 / AC-3: pass-fraction
# ---------------------------------------------------------------------------
def test_pass_fraction_empty_returns_zero() -> None:
# Arrange / Act / Assert
assert compute_pass_fraction([], 100.0) == 0.0
def test_pass_fraction_all_pass() -> None:
# Arrange — 10 pairs all at 10 m drift, bound 100 m
pairs = [_make_pair(drift_m=10.0) for _ in range(10)]
# Act
f = compute_pass_fraction(pairs, drift_bound_m=100.0)
# Assert
assert f == 1.0
def test_pass_fraction_partial() -> None:
# Arrange — 8 of 10 under 100 m
pairs = [_make_pair(drift_m=10.0) for _ in range(8)] + [
_make_pair(drift_m=200.0) for _ in range(2)
]
# Act
f = compute_pass_fraction(pairs, drift_bound_m=100.0)
# Assert
assert f == 0.8
# ---------------------------------------------------------------------------
# AC-4: bin medians + monotonicity
# ---------------------------------------------------------------------------
def test_bin_drifts_default_edges() -> None:
# Arrange — synthetic drifts at known ages
pairs = [
_make_pair(drift_m=10.0, age_ms=500), # <1s bin
_make_pair(drift_m=20.0, age_ms=2_000), # 1-3s bin
_make_pair(drift_m=50.0, age_ms=5_000), # 3-10s bin
_make_pair(drift_m=100.0, age_ms=20_000), # 10-30s bin
_make_pair(drift_m=200.0, age_ms=60_000), # >30s bin
]
# Act
bins = bin_drifts(pairs)
# Assert — every bin has exactly one entry, in monotonic order
counts = [b.count for b in bins]
assert counts == [1, 1, 1, 1, 1]
medians = [b.median_m for b in bins]
assert medians == sorted(medians)
def test_check_monotonic_passes_for_increasing_medians() -> None:
# Arrange
pairs = [
_make_pair(drift_m=10.0, age_ms=500),
_make_pair(drift_m=15.0, age_ms=2_000),
_make_pair(drift_m=20.0, age_ms=5_000),
]
bins = bin_drifts(pairs)
# Act
violations = check_monotonic(bins)
# Assert
assert violations == []
def test_check_monotonic_flags_regression() -> None:
# Arrange — drifts decrease with age (impossible IRL → violation)
pairs = [
_make_pair(drift_m=20.0, age_ms=500),
_make_pair(drift_m=10.0, age_ms=2_000),
]
bins = bin_drifts(pairs)
# Act
violations = check_monotonic(bins)
# Assert
assert any("non-monotonic" in v for v in violations)
def test_check_monotonic_flags_2x_jump() -> None:
# Arrange — 100 m → 250 m is > 2x
pairs = [
_make_pair(drift_m=100.0, age_ms=500),
_make_pair(drift_m=250.0, age_ms=2_000),
]
bins = bin_drifts(pairs)
# Act
violations = check_monotonic(bins)
# Assert
assert any(">2x" in v for v in violations)
# ---------------------------------------------------------------------------
# aggregate() integration
# ---------------------------------------------------------------------------
def test_aggregate_round_trip() -> None:
# Arrange — mix of visual-only and IMU-fused pairs
stream = [
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored"),
_est(100, _BASE_LAT + 0.0001, _BASE_LON, "visual_propagated"),
_est(200, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=200),
_est(300, _BASE_LAT + 0.0001, _BASE_LON, "visual_propagated", imu_fused=True),
_est(400, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=200),
]
# Act
report = aggregate(stream)
# Assert
assert len(report.pairs) == 2
assert len(report.visual_only_pairs) == 1
assert len(report.imu_fused_pairs) == 1
# ---------------------------------------------------------------------------
# CSV evidence
# ---------------------------------------------------------------------------
def test_write_csv_evidence_round_trip(tmp_path: Path) -> None:
# Arrange
pairs = [_make_pair(drift_m=10.0, age_ms=500)]
report = aggregate(
[
_est(0, _BASE_LAT, _BASE_LON, "satellite_anchored"),
_est(100, _BASE_LAT + 0.0001, _BASE_LON, "visual_propagated"),
_est(200, _BASE_LAT, _BASE_LON, "satellite_anchored", age_ms=200),
]
)
csv_path = tmp_path / "ft-p-02.csv"
# Act
write_csv_evidence(report, csv_path)
text = csv_path.read_text()
# Assert
assert "drift_m" in text.splitlines()[0]
assert len(text.splitlines()) == 1 + len(report.pairs)
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _make_pair(drift_m: float = 0.0, age_ms: int = 0, imu_fused: bool = False) -> AnchorPair:
return AnchorPair(
segment_first_ms=0,
propagated_centre_ms=100,
anchor_ms=200,
propagated_lat_deg=_BASE_LAT,
propagated_lon_deg=_BASE_LON,
anchor_lat_deg=_BASE_LAT,
anchor_lon_deg=_BASE_LON,
drift_m=drift_m,
last_satellite_anchor_age_ms=age_ms,
imu_fused_segment=imu_fused,
)
@@ -0,0 +1,196 @@
"""Unit tests for the AZ-411 estimate-schema validators (FT-P-03, FT-P-14).
Validates AC-1 (schema completeness), AC-2 (source-label set containment),
AC-3 (WGS84 range), and the int32 1e-7 decoder. The full single-image
push scenario in ``test_ft_p_03_14_schema_wgs84.py`` is skipped until
the upstream replay/SITL helpers land — these tests are the AC coverage
for the logic itself.
"""
from __future__ import annotations
import math
import pytest
from runner.helpers.estimate_schema import (
ALLOWED_SOURCE_LABELS,
LAT_LON_SCALE,
REQUIRED_FIELDS,
aggregate_validations,
decode_lat_lon_int32,
validate_estimate_schema,
validate_source_label,
validate_wgs84_range,
)
# ---------------------------------------------------------------------------
# AC-1: schema completeness
# ---------------------------------------------------------------------------
def _valid_record(**overrides: object) -> dict:
"""A baseline record that satisfies all four REQUIRED_FIELDS."""
return {
"lat": 50.075,
"lon": 36.150,
"cov_semi_major_m": 4.5,
"last_satellite_anchor_age_ms": 1234,
**overrides,
}
def test_valid_record_passes_schema() -> None:
# Arrange / Act
result = validate_estimate_schema(_valid_record())
# Assert
assert result.ok is True
assert result.missing_fields == []
assert result.wrong_typed_fields == []
def test_missing_field_caught() -> None:
# Arrange
rec = _valid_record()
del rec["cov_semi_major_m"]
# Act
result = validate_estimate_schema(rec)
# Assert
assert not result.ok
assert "cov_semi_major_m" in result.missing_fields
def test_int_typed_field_rejected_when_wrong_type() -> None:
# Arrange — last_satellite_anchor_age_ms is supposed to be int, not float
rec = _valid_record(last_satellite_anchor_age_ms=1.5)
# Act
result = validate_estimate_schema(rec)
# Assert
assert not result.ok
assert "last_satellite_anchor_age_ms" in result.wrong_typed_fields
def test_bool_does_not_silently_satisfy_int() -> None:
"""Python ``isinstance(True, int)`` is True; we must reject it explicitly."""
# Arrange
rec = _valid_record(last_satellite_anchor_age_ms=True)
# Act
result = validate_estimate_schema(rec)
# Assert
assert not result.ok
assert "last_satellite_anchor_age_ms" in result.wrong_typed_fields
def test_required_fields_table_is_what_the_spec_says() -> None:
"""Guard against accidental drift between the helper and the AZ-411 spec."""
# Arrange
names = [n for n, _ in REQUIRED_FIELDS]
# Assert
assert names == ["lat", "lon", "cov_semi_major_m", "last_satellite_anchor_age_ms"]
# ---------------------------------------------------------------------------
# AC-2: source-label set containment
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("label", sorted(ALLOWED_SOURCE_LABELS))
def test_each_allowed_label_passes(label: str) -> None:
# Arrange / Act
result = validate_source_label(label)
# Assert
assert result.ok
assert result.observed == label
def test_unknown_label_rejected() -> None:
# Arrange / Act
result = validate_source_label("imu_only")
# Assert
assert not result.ok
assert "not in" in (result.reason or "")
def test_non_string_label_rejected() -> None:
# Arrange / Act
result = validate_source_label(42)
# Assert
assert not result.ok
assert "expected str" in (result.reason or "")
# ---------------------------------------------------------------------------
# AC-3: WGS84 range + int32 decoding
# ---------------------------------------------------------------------------
def test_valid_wgs84_inside_range() -> None:
# Arrange / Act
result = validate_wgs84_range(50.075, 36.150)
# Assert
assert result.ok
def test_lat_above_90_rejected() -> None:
# Arrange / Act / Assert
assert not validate_wgs84_range(91.0, 0.0).ok
def test_lon_below_minus_180_rejected() -> None:
# Arrange / Act / Assert
assert not validate_wgs84_range(0.0, -181.0).ok
def test_nan_rejected() -> None:
# Arrange / Act / Assert
assert not validate_wgs84_range(math.nan, 0.0).ok
def test_decode_lat_lon_int32_round_trip() -> None:
# Arrange — encode Derkachi-ish coords as int32 1e-7 then decode
lat_e7 = 500_750_000
lon_e7 = 361_500_000
# Act
lat, lon = decode_lat_lon_int32(lat_e7, lon_e7)
# Assert
assert abs(lat - 50.075) < 1e-6
assert abs(lon - 36.150) < 1e-6
assert lat == lat_e7 * LAT_LON_SCALE
def test_decode_lat_lon_int32_rejects_out_of_int32_range() -> None:
# Arrange / Act / Assert
with pytest.raises(ValueError, match="lat_e7"):
decode_lat_lon_int32(2 ** 31, 0)
with pytest.raises(ValueError, match="lon_e7"):
decode_lat_lon_int32(0, -(2 ** 31) - 1)
# ---------------------------------------------------------------------------
# aggregate_validations
# ---------------------------------------------------------------------------
def test_aggregate_validations_all_ok() -> None:
# Arrange
records = [_valid_record(), _valid_record(lat=49.9, lon=36.0)]
# Act
schemas, wgs84s = aggregate_validations(records)
# Assert
assert all(s.ok for s in schemas)
assert all(w.ok for w in wgs84s)
def test_aggregate_validations_surfaces_bad_record() -> None:
# Arrange — one good, one missing lat
bad = _valid_record()
del bad["lat"]
records = [_valid_record(), bad]
# Act
schemas, wgs84s = aggregate_validations(records)
# Assert
assert schemas[0].ok
assert not schemas[1].ok
# When lat is missing, wgs84 validator emits a missing-field result too.
assert not wgs84s[1].ok
+7
View File
@@ -41,6 +41,8 @@ E2E_ROOT = Path(__file__).resolve().parents[1]
"runner/helpers/mavproxy_tlog_reader.py",
"runner/helpers/fdr_reader.py",
"runner/helpers/geo.py",
"runner/helpers/anchor_pair_detector.py",
"runner/helpers/estimate_schema.py",
"fixtures/mock-suite-sat/Dockerfile",
"fixtures/mock-suite-sat/app.py",
"fixtures/mock-suite-sat/requirements.txt",
@@ -55,6 +57,9 @@ E2E_ROOT = Path(__file__).resolve().parents[1]
"fixtures/injectors/blackout_spoof.py",
"fixtures/injectors/multi_segment.py",
"fixtures/injectors/cold_boot.py",
"fixtures/injectors/_common.py",
"fixtures/injectors/fc_proxy.py",
"runner/helpers/injector_fixtures.py",
"fixtures/cold-boot/README.md",
"fixtures/cold-boot/cold_boot_fixture.json",
"fixtures/secrets/mavlink-test-passkey.txt",
@@ -70,6 +75,8 @@ E2E_ROOT = Path(__file__).resolve().parents[1]
"tests/security/__init__.py",
"tests/resource_limit/__init__.py",
"tests/positive/test_smoke.py",
"tests/positive/test_ft_p_02_derkachi_drift.py",
"tests/positive/test_ft_p_03_14_schema_wgs84.py",
],
)
def test_required_path_exists(relative_path: str) -> None:
+8 -4
View File
@@ -6,9 +6,13 @@ negative-path scenario:
- outlier.py — outlier-injection-derkachi (FT-N-01)
- blackout_spoof.py — blackout-spoof-derkachi (FT-N-04, NFT-RES-04)
- multi_segment.py — multi-segment-derkachi (FT-P-08)
- cold_boot.py — cold-boot-fixture (FT-P-11, NFT-PERF-03)
- fc_proxy.py — coordinated FC GPS spoof proxy (consumed by
blackout_spoof's runtime path; AZ-408 AC-3)
- cold_boot.py — cold-boot-fixture (FT-P-11, NFT-PERF-03;
deferred to AZ-419)
AZ-406 supplies the package layout + public function signatures; concrete
generators are delivered by **AZ-408** (Runtime synthetic-injection fixture
builders).
AZ-406 supplied the package layout + scaffold dataclasses; AZ-408 (this
batch) replaces every ``NotImplementedError`` with a real generator and
adds the shared ``_common.py`` (deterministic seeds, tile-cache
manifest reader, tmpfs scratch helpers) + ``fc_proxy.py``.
"""
+221
View File
@@ -0,0 +1,221 @@
"""Shared helpers for the AZ-408 runtime synthetic-injection fixture builders.
Three responsibilities, each kept deliberately small:
1. **Deterministic seed derivation** — every injector accepts an integer
``--seed`` flag and must produce bit-identical output across two runs
for the same ``(seed, density|window_seconds|n_segments)`` pair. The
shared ``derive_rng()`` helper hashes the inputs into a 64-bit seed,
so two unrelated injectors don't accidentally share a stream.
2. **Tile-cache manifest read** — the outlier injector needs to pick a
"far-away" tile (per AC-3.1: ≥350 m offset). The tile-cache fixture
(built by AZ-407 / ``e2e/fixtures/tile-cache-builder/builder.py``)
ships a ``manifest.csv`` with the per-tile ground-truth lat/lon
derivable from ``(zoom_level, tile_x, tile_y)`` via the slippy-map
convention. We read the CSV ourselves rather than depending on the
builder package — that keeps the injectors independently testable
without a Docker tile-cache volume present.
3. **Tmpfs scratch root** — AC-6 says "auto-cleared at teardown within
≤2 s". We expose ``tmpfs_root(run_id, scenario)`` so every injector
writes under the same predictable parent (``/tmp/<run_id>/<scenario>/``)
and the pytest fixture wrapper can shutil.rmtree on teardown.
Public-boundary discipline: this module does NOT import any
``src/gps_denied_onboard`` symbol.
"""
from __future__ import annotations
import csv
import hashlib
import math
import shutil
import struct
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import numpy as np
DEFAULT_SCRATCH_ROOT = Path("/tmp")
def derive_rng(domain: str, *components: object) -> np.random.Generator:
"""Stable RNG keyed on ``(domain, components...)``.
The domain string is a short unique tag per injector (``"outlier"``,
``"blackout_spoof"``, ``"multi_segment"``); the components are the
user-visible knobs (seed, density, window_seconds, etc.).
Two invocations with the same arguments return RNGs that produce the
same sequence of values. Two invocations with different ``domain`` —
even with the same ``components`` — produce independent sequences.
"""
payload = "|".join((domain,) + tuple(str(c) for c in components))
digest = hashlib.sha256(payload.encode("ascii")).digest()
seed64 = struct.unpack(">Q", digest[:8])[0]
return np.random.default_rng(seed64)
def tmpfs_root(run_id: str, scenario: str, base: Path | None = None) -> Path:
"""Return ``<base>/<run_id>/<scenario>/`` (created); used by every injector.
The pytest fixture wrapper passes ``base = pytest's tmp_path_factory``
so unit-test runs stay inside the pytest tmp tree rather than ``/tmp``.
"""
base = base or DEFAULT_SCRATCH_ROOT
out = base / run_id / scenario
out.mkdir(parents=True, exist_ok=True)
return out
def cleanup_tmpfs(path: Path) -> None:
"""``rmtree`` ``path`` if it exists; silent no-op otherwise.
Called from pytest fixture teardown. Per AC-6 the rm must complete
within ≤2 s; ``shutil.rmtree`` of a single-scenario directory with a
few thousand small files reliably finishes in <100 ms.
"""
if path.exists():
shutil.rmtree(path)
# ---------------------------------------------------------------------------
# Tile-cache manifest read (AZ-407 schema)
# ---------------------------------------------------------------------------
# Slippy-map convention — see e2e/fixtures/tile-cache-builder/builder.py
# DEFAULT_ZOOM = 18 — these constants are the contract this module relies
# on (they are NOT imported from the builder to avoid a runtime dependency
# on the tile-cache-builder package at injector-test time).
_TILE_SIZE = 256 # px
@dataclass(frozen=True)
class TileGtRow:
"""One row of the tile-cache manifest, with derived lat/lon centre."""
zoom_level: int
tile_x: int
tile_y: int
capture_date: str
source: str
m_per_px: float
jpeg_path: str
content_hash: str
provenance: str
centre_lat_deg: float
centre_lon_deg: float
def _tile_centre_lat_lon(zoom: int, tx: int, ty: int) -> tuple[float, float]:
"""Slippy XYZ tile centre → (lat_deg, lon_deg).
Standard Web-Mercator inverse of the (tx, ty) tile origin offset by
``+0.5`` to get the centre rather than the NW corner.
"""
n = 2.0 ** zoom
lon_deg = (tx + 0.5) / n * 360.0 - 180.0
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * (ty + 0.5) / n)))
lat_deg = math.degrees(lat_rad)
return lat_deg, lon_deg
def read_tile_manifest(manifest_csv: Path) -> list[TileGtRow]:
"""Parse the tile-cache ``manifest.csv`` (AZ-407 schema) into typed rows.
Each row gets a derived ``(centre_lat_deg, centre_lon_deg)`` computed
from the slippy tile coordinates — the injectors use this for the
"far-away crop" geodesic check (AC-2).
Raises FileNotFoundError when the manifest is missing — the injector
CLI surfaces this with an explicit "build the tile-cache fixture
first" message. We do NOT silently fall back to a stub manifest;
that would hide a misconfigured test run.
"""
if not manifest_csv.is_file():
raise FileNotFoundError(
f"tile-cache manifest not found at {manifest_csv} — build the "
"tile-cache fixture first (`./e2e/fixtures/tile-cache-builder/build.sh`)"
)
rows: list[TileGtRow] = []
with manifest_csv.open("r", newline="") as fp:
reader = csv.DictReader(fp)
for raw in reader:
zoom = int(raw["zoom_level"])
tx = int(raw["tile_x"])
ty = int(raw["tile_y"])
lat, lon = _tile_centre_lat_lon(zoom, tx, ty)
rows.append(
TileGtRow(
zoom_level=zoom,
tile_x=tx,
tile_y=ty,
capture_date=raw["capture_date"],
source=raw["source"],
m_per_px=float(raw["m_per_px"]),
jpeg_path=raw["jpeg_path"],
content_hash=raw["content_hash"],
provenance=raw["provenance"],
centre_lat_deg=lat,
centre_lon_deg=lon,
)
)
if not rows:
raise ValueError(f"tile-cache manifest at {manifest_csv} is empty")
return rows
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Great-circle distance in meters (Haversine).
Used by the injector "far-away" check. We deliberately re-implement
rather than importing ``runner.helpers.geo.distance_m`` — the
injectors must work without pyproj installed (the project's
``[dev]`` extra installs pyproj, but the injectors run inside
minimal Docker images and on bare ground stations).
"""
R = 6_371_000.0
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return float(2 * R * math.asin(math.sqrt(a)))
def far_away_indices(
rows: list[TileGtRow],
src_idx: int,
min_offset_m: float,
) -> list[int]:
"""Return indices of rows whose centre is ≥ ``min_offset_m`` from ``src_idx``."""
src = rows[src_idx]
return [
j
for j, r in enumerate(rows)
if j != src_idx
and haversine_m(src.centre_lat_deg, src.centre_lon_deg, r.centre_lat_deg, r.centre_lon_deg)
>= min_offset_m
]
# ---------------------------------------------------------------------------
# Tiny utilities
# ---------------------------------------------------------------------------
def iter_video_frame_indices(total_frames: int, density_ratio: float) -> Iterable[int]:
"""Yield 1-of-N frame indices for the requested density ratio.
Density is the fraction of frames replaced; e.g., ``density_ratio=0.1``
means every 10th frame (deterministic stride, NOT random sampling) —
we keep the stride deterministic so the unit test's "X-th frame is
replaced" assertion stays stable.
"""
if not 0 < density_ratio <= 1.0:
raise ValueError(f"density_ratio must be in (0, 1]; got {density_ratio}")
stride = max(1, round(1 / density_ratio))
return range(0, total_frames, stride)
+401 -10
View File
@@ -1,27 +1,418 @@
"""blackout-spoof-derkachi — visual blackout + spoofed GPS combination (FT-N-04, NFT-RES-04).
"""blackout-spoof-derkachi — synchronized visual blackout + GPS spoof (FT-N-04, NFT-RES-04).
Concrete generator is owned by AZ-408. AZ-406 commits to the public
signature.
Produces a **schedule** + paired runtime artefacts for a coordinated
visual-blackout / FC-GPS-spoof scenario. The schedule itself is the
single source of truth — the video-overlay portion AND the FC-inbound
proxy patch both read from it so the two streams stay synchronized
within AC-3 (≤40 ms wall-clock alignment).
What ``build()`` writes:
<out_root>/
schedule.json # window_start_ms / window_end_ms,
# spoofed-GPS frame timeline
frames/AD000001.jpg # source frame, OR a black frame inside windows
manifest.csv # per-replaced-frame metadata for tests
summary.json # aggregate (window count, max alignment err, …)
The schedule's ``spoof_gps`` list is consumed by ``fc_proxy.py`` at run
time: the proxy walks its monotonic clock and, when ``now_ms`` falls
inside ``[window_start_ms, window_end_ms]``, replaces inbound GPS frames
with the next pre-computed spoofed record.
Determinism (AC-1 of AZ-408): identical ``(window_seconds, spoof_offset_m,
spoof_bearing_deg, seed)`` reproduce the same schedule and frame outputs.
Spoof-GPS values come from a ``derive_rng("blackout_spoof", …)`` stream;
window timing is deterministic-positional (anchored at 30 % of the source
duration so each window family ends inside the flight). The 200500 m
inter-spoof delta requirement (AC-4 / AC-NEW-8) is enforced by the
delta-bound parameter — no random rejection sampling.
Public-boundary discipline: this module does NOT import any
``src/gps_denied_onboard`` symbol.
"""
from __future__ import annotations
from dataclasses import dataclass
import argparse
import csv
import io
import json
import logging
import math
import shutil
import sys
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
from ._common import derive_rng, tmpfs_root
logger = logging.getLogger(__name__)
# AC-NEW-8: spoofed GPS jumps 200-500 m between consecutive spoof frames.
_MIN_INTER_SPOOF_DELTA_M = 200.0
_MAX_INTER_SPOOF_DELTA_M = 500.0
# Spoofed-frame cadence — typical FC GPS update rate (10 Hz).
_SPOOF_HZ = 10.0
# AC-4: spoofed fields stay inside typical-flight ranges.
_SPOOF_FIX_TYPES = (3, 4) # GPS_FIX_TYPE_3D / GPS_FIX_TYPE_DGPS
_SPOOF_HDOP_RANGE = (0.5, 2.5)
# Source-frame defaults — overrideable via CLI.
_DEFAULT_SRC_FPS = 30.0
_TILE_W = 256
_TILE_H = 256
@dataclass(frozen=True)
class BlackoutSpoofPlan:
"""Configuration for the blackout-spoof-derkachi fixture.
`blackout_seconds` corresponds to the 5 / 15 / 35 s window family from
NFT-RES-04 (35 s escalation ladder) and FT-N-04 (blackout + spoof).
AZ-408 replaces the AZ-406 scaffold dataclass; the previous shape
(``blackout_seconds`` / ``spoof_offset_m`` / ``spoof_bearing_deg``)
is preserved and extended with the inputs the runtime build path
needs.
"""
source_frames_dir: Path
blackout_seconds: float
spoof_offset_m: float
spoof_bearing_deg: float
seed: int = 0
spoof_offset_m: float = 350.0
spoof_bearing_deg: float = 45.0
source_fps: float = _DEFAULT_SRC_FPS
# AC-NEW-3: the proxy must START emitting spoofed GPS within ≤40 ms
# of the first all-black video frame. This is a documented invariant
# the runtime proxy enforces; we keep it in the plan as the
# "promised" alignment so tests can assert against it.
max_alignment_err_ms: float = 40.0
initial_lat_deg: float = 50.075
initial_lon_deg: float = 36.15
def build(plan: BlackoutSpoofPlan, out_root: Path) -> Path:
raise NotImplementedError("Owned by AZ-408 — AZ-406 supplies only the contract.")
@dataclass(frozen=True)
class SpoofGpsFrame:
"""One spoofed GPS record — what fc_proxy will inject in place of real GPS."""
monotonic_ms: int
lat_deg: float
lon_deg: float
alt_m: float
fix_type: int
hdop: float
@dataclass(frozen=True)
class BlackoutSpoofSchedule:
"""The full coordinated timeline written to ``schedule.json``."""
window_start_ms: int
window_end_ms: int
spoof_gps: list[SpoofGpsFrame] = field(default_factory=list)
blackout_frame_indices: list[int] = field(default_factory=list)
max_alignment_err_ms: float = 40.0
@dataclass(frozen=True)
class BlackoutSpoofReport:
"""Summary of a single ``build()`` run — written to ``summary.json``."""
out_root: Path
schedule: BlackoutSpoofSchedule
blackout_frame_count: int
spoof_frame_count: int
inter_spoof_delta_m_min: float
inter_spoof_delta_m_max: float
def _bearing_offset(lat: float, lon: float, bearing_deg: float, dist_m: float) -> tuple[float, float]:
"""Project ``(lat, lon)`` along ``bearing_deg`` by ``dist_m`` (great-circle)."""
R = 6_371_000.0
br = math.radians(bearing_deg)
lat1 = math.radians(lat)
lon1 = math.radians(lon)
ang = dist_m / R
lat2 = math.asin(math.sin(lat1) * math.cos(ang) + math.cos(lat1) * math.sin(ang) * math.cos(br))
lon2 = lon1 + math.atan2(
math.sin(br) * math.sin(ang) * math.cos(lat1),
math.cos(ang) - math.sin(lat1) * math.sin(lat2),
)
return math.degrees(lat2), math.degrees(lon2)
def _build_spoof_gps_track(
plan: BlackoutSpoofPlan,
window_start_ms: int,
window_end_ms: int,
rng: np.random.Generator,
) -> list[SpoofGpsFrame]:
"""Generate a spoofed-GPS track that satisfies AC-4 + AC-NEW-8.
The track starts at the plan's initial point + spoof_offset_m along
spoof_bearing_deg (the initial "jump" that defines the spoofed
position). Subsequent frames jump 200-500 m in a randomly-perturbed
bearing each step — enforced deterministically by the seeded RNG.
"""
cadence_ms = int(round(1000.0 / _SPOOF_HZ))
frames: list[SpoofGpsFrame] = []
cur_lat, cur_lon = _bearing_offset(
plan.initial_lat_deg, plan.initial_lon_deg, plan.spoof_bearing_deg, plan.spoof_offset_m
)
cur_alt = 300.0 # plausible-cruise altitude (matches `flight_derkachi/camera_info.md`)
cur_bearing = plan.spoof_bearing_deg
t = window_start_ms
while t <= window_end_ms:
delta_m = float(
rng.uniform(_MIN_INTER_SPOOF_DELTA_M, _MAX_INTER_SPOOF_DELTA_M)
)
# Perturb bearing ±60° per step so the spoofed track looks like
# a realistic-but-bad GPS noise pattern (not a straight line).
cur_bearing = (cur_bearing + float(rng.uniform(-60.0, 60.0))) % 360.0
cur_lat, cur_lon = _bearing_offset(cur_lat, cur_lon, cur_bearing, delta_m)
# Stay inside realistic flight altitude range; small noise only.
cur_alt += float(rng.uniform(-2.0, 2.0))
fix_type = int(rng.choice(_SPOOF_FIX_TYPES))
hdop = float(rng.uniform(*_SPOOF_HDOP_RANGE))
frames.append(
SpoofGpsFrame(
monotonic_ms=t,
lat_deg=round(cur_lat, 7),
lon_deg=round(cur_lon, 7),
alt_m=round(cur_alt, 3),
fix_type=fix_type,
hdop=round(hdop, 3),
)
)
t += cadence_ms
return frames
def _black_jpeg_bytes() -> bytes:
"""All-black 256×256 JPEG using the project's pinned PIL settings."""
from PIL import Image # noqa: PLC0415 — heavy import, deferred
img = Image.new("RGB", (_TILE_W, _TILE_H), color=(0, 0, 0))
buf = io.BytesIO()
img.save(
buf,
format="JPEG",
quality=85,
optimize=False,
progressive=False,
subsampling=2,
)
return buf.getvalue()
def build(plan: BlackoutSpoofPlan, out_root: Path) -> BlackoutSpoofReport:
"""Generate the blackout-spoof-derkachi fixture under ``out_root``."""
if plan.blackout_seconds <= 0:
raise ValueError(f"blackout_seconds must be > 0; got {plan.blackout_seconds}")
if out_root.exists():
shutil.rmtree(out_root)
(out_root / "frames").mkdir(parents=True)
src_dir = plan.source_frames_dir
if not src_dir.is_dir():
raise FileNotFoundError(f"source frames directory not found: {src_dir}")
frames = sorted(src_dir.glob("AD*.jpg"))
if not frames:
raise FileNotFoundError(f"no AD*.jpg frames under {src_dir}")
total_frames = len(frames)
src_duration_ms = int(round((total_frames / plan.source_fps) * 1000.0))
# Anchor the window at 30 % of the source duration. The window must
# fit inside the source — if the requested blackout is longer than
# the remaining flight, fall back to "blackout from 30 % to end".
window_start_ms = int(0.3 * src_duration_ms)
window_end_ms = min(
window_start_ms + int(plan.blackout_seconds * 1000), src_duration_ms
)
# Frame-index window in the source frame-stream (frames are at
# ``source_fps`` Hz so a window of ``W`` ms maps to ``W/1000 * fps``
# frames).
first_blackout_frame = int(round(window_start_ms / 1000.0 * plan.source_fps))
last_blackout_frame = int(round(window_end_ms / 1000.0 * plan.source_fps))
blackout_indices = list(range(first_blackout_frame, min(last_blackout_frame, total_frames)))
rng = derive_rng(
"blackout_spoof",
plan.seed,
plan.blackout_seconds,
plan.spoof_offset_m,
plan.spoof_bearing_deg,
)
spoof_frames = _build_spoof_gps_track(plan, window_start_ms, window_end_ms, rng)
schedule = BlackoutSpoofSchedule(
window_start_ms=window_start_ms,
window_end_ms=window_end_ms,
spoof_gps=spoof_frames,
blackout_frame_indices=blackout_indices,
max_alignment_err_ms=plan.max_alignment_err_ms,
)
black_jpeg = _black_jpeg_bytes()
manifest_rows: list[dict] = []
blackout_set = set(blackout_indices)
for frame_idx, frame_path in enumerate(frames):
out_path = out_root / "frames" / frame_path.name
if frame_idx in blackout_set:
out_path.write_bytes(black_jpeg)
manifest_rows.append(
{
"frame_idx": frame_idx,
"src_jpeg_path": frame_path.name,
"kind": "blackout",
"window_start_ms": window_start_ms,
"window_end_ms": window_end_ms,
"seed": plan.seed,
}
)
else:
shutil.copy2(frame_path, out_path)
_write_schedule(out_root, schedule)
_write_manifest(out_root, manifest_rows)
deltas_m: list[float] = []
for prev, nxt in zip(spoof_frames, spoof_frames[1:]):
from ._common import haversine_m as _hav
deltas_m.append(_hav(prev.lat_deg, prev.lon_deg, nxt.lat_deg, nxt.lon_deg))
report = BlackoutSpoofReport(
out_root=out_root,
schedule=schedule,
blackout_frame_count=len(blackout_indices),
spoof_frame_count=len(spoof_frames),
inter_spoof_delta_m_min=min(deltas_m) if deltas_m else 0.0,
inter_spoof_delta_m_max=max(deltas_m) if deltas_m else 0.0,
)
_write_summary(out_root, report)
return report
def _write_schedule(out_root: Path, schedule: BlackoutSpoofSchedule) -> None:
payload = {
"window_start_ms": schedule.window_start_ms,
"window_end_ms": schedule.window_end_ms,
"max_alignment_err_ms": schedule.max_alignment_err_ms,
"blackout_frame_indices": schedule.blackout_frame_indices,
"spoof_gps": [
{
"monotonic_ms": f.monotonic_ms,
"lat_deg": f.lat_deg,
"lon_deg": f.lon_deg,
"alt_m": f.alt_m,
"fix_type": f.fix_type,
"hdop": f.hdop,
}
for f in schedule.spoof_gps
],
}
(out_root / "schedule.json").write_text(
json.dumps(payload, sort_keys=True, indent=2) + "\n"
)
def _write_manifest(out_root: Path, rows: list[dict]) -> None:
manifest = out_root / "manifest.csv"
with manifest.open("w", newline="") as fp:
writer = csv.DictWriter(
fp,
fieldnames=["frame_idx", "src_jpeg_path", "kind", "window_start_ms", "window_end_ms", "seed"],
lineterminator="\n",
)
writer.writeheader()
for row in sorted(rows, key=lambda r: r["frame_idx"]):
writer.writerow(row)
def _write_summary(out_root: Path, report: BlackoutSpoofReport) -> None:
payload = {
"scenario": "blackout-spoof-derkachi",
"window_start_ms": report.schedule.window_start_ms,
"window_end_ms": report.schedule.window_end_ms,
"blackout_frame_count": report.blackout_frame_count,
"spoof_frame_count": report.spoof_frame_count,
"inter_spoof_delta_m_min": round(report.inter_spoof_delta_m_min, 3),
"inter_spoof_delta_m_max": round(report.inter_spoof_delta_m_max, 3),
"max_alignment_err_ms": report.schedule.max_alignment_err_ms,
}
(out_root / "summary.json").write_text(
json.dumps(payload, sort_keys=True, indent=2) + "\n"
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Blackout + spoofed-GPS injection (FT-N-04)")
parser.add_argument("--source-frames", type=Path, required=True)
parser.add_argument(
"--window-seconds",
type=float,
required=True,
help="Blackout window length in seconds (5/15/35 for FT-N-04 / NFT-RES-04 family)",
)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--spoof-offset-m", type=float, default=350.0)
parser.add_argument("--spoof-bearing-deg", type=float, default=45.0)
parser.add_argument("--source-fps", type=float, default=_DEFAULT_SRC_FPS)
parser.add_argument(
"--out-root",
type=Path,
default=None,
help="Output dir. If omitted, /tmp/<run_id>/blackout-spoof-<window_seconds>s/.",
)
parser.add_argument("--run-id", default="local")
parser.add_argument("--quiet", action="store_true")
args = parser.parse_args(argv)
logging.basicConfig(
level=logging.WARNING if args.quiet else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
out_root = args.out_root or tmpfs_root(
args.run_id, f"blackout-spoof-{int(args.window_seconds)}s"
)
plan = BlackoutSpoofPlan(
source_frames_dir=args.source_frames,
blackout_seconds=args.window_seconds,
seed=args.seed,
spoof_offset_m=args.spoof_offset_m,
spoof_bearing_deg=args.spoof_bearing_deg,
source_fps=args.source_fps,
)
report = build(plan, out_root)
summary = {
"scenario": "blackout-spoof-derkachi",
"out_root": str(report.out_root),
"window_start_ms": report.schedule.window_start_ms,
"window_end_ms": report.schedule.window_end_ms,
"blackout_frame_count": report.blackout_frame_count,
"spoof_frame_count": report.spoof_frame_count,
"inter_spoof_delta_m_min": round(report.inter_spoof_delta_m_min, 3),
"inter_spoof_delta_m_max": round(report.inter_spoof_delta_m_max, 3),
"max_alignment_err_ms": report.schedule.max_alignment_err_ms,
}
json.dump(summary, sys.stdout, sort_keys=True, indent=2)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+209
View File
@@ -0,0 +1,209 @@
"""FC-inbound proxy patch for blackout_spoof — coordinated GPS spoof injection.
The blackout_spoof injector ships a ``schedule.json`` with two paired
artefacts:
1. ``blackout_frame_indices`` — which video frames are replaced with
black frames (the video-overlay portion writes them to disk).
2. ``spoof_gps`` — the pre-computed spoofed GPS frames that must appear
on the FC inbound stream *during the same wall-clock window*.
This module is the runtime piece that consumes the ``spoof_gps`` list:
a stateless **pass-through proxy** with a "timed splice" rule.
Default behaviour: every inbound MAVLink GPS message is forwarded
unchanged to the FC. While the proxy's monotonic clock falls inside
``[window_start_ms, window_end_ms]``, the proxy *replaces* the next
inbound GPS frame with the next pre-computed spoofed record. The
``window_start_ms`` / ``window_end_ms`` are anchored to the proxy's own
monotonic clock (started by ``activate(now_ms_provider, t0)``), which the
test harness aligns with the video-overlay's first black-frame timestamp
to satisfy AC-3 (≤40 ms alignment).
The module is intentionally **transport-agnostic**: it takes a callable
that returns ``now_ms`` (for testability — pytest passes a fake clock)
and exposes ``process_inbound_message(raw_gps)`` which the actual
MAVLink-frame router calls. The router lives outside the AZ-408 task
scope (it's part of the runner image's docker-compose wiring, not the
injector module).
Public-boundary discipline: this module does NOT import any
``src/gps_denied_onboard`` symbol; it operates on opaque "raw GPS frame"
bytes/dicts at the MAVLink protocol level.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
NowMsProvider = Callable[[], int]
@dataclass(frozen=True)
class SpoofGpsRecord:
"""Mirror of `blackout_spoof.SpoofGpsFrame` — JSON-parsed at proxy init."""
monotonic_ms: int
lat_deg: float
lon_deg: float
alt_m: float
fix_type: int
hdop: float
@dataclass(frozen=True)
class ProxyAlignmentReport:
"""Reports the actual wall-clock alignment achieved at activation.
Tests assert ``alignment_err_ms <= max_alignment_err_ms`` (AC-3 / AC-NEW-3).
"""
window_start_ms: int
activation_now_ms: int
alignment_err_ms: int
class BlackoutSpoofProxy:
"""Coordinated pass-through proxy. NOT thread-safe; one per scenario.
Lifecycle:
proxy = BlackoutSpoofProxy.from_schedule_file(Path("schedule.json"))
report = proxy.activate(now_ms_provider=time.monotonic_ms)
# … runner forwards GPS frames …
while gps := router.next_inbound_gps():
forwarded = proxy.process_inbound_message(gps)
router.send_to_fc(forwarded)
"""
def __init__(
self,
window_start_ms: int,
window_end_ms: int,
spoof_gps: list[SpoofGpsRecord],
max_alignment_err_ms: float = 40.0,
) -> None:
self._window_start_ms = window_start_ms
self._window_end_ms = window_end_ms
self._spoof_gps = list(spoof_gps)
self._max_alignment_err_ms = max_alignment_err_ms
self._now_ms_provider: NowMsProvider | None = None
self._t0_ms: int | None = None
self._next_spoof_idx = 0
self._activated = False
self._activation_report: ProxyAlignmentReport | None = None
@classmethod
def from_schedule_file(cls, schedule_path: Path) -> "BlackoutSpoofProxy":
"""Load the proxy from a ``schedule.json`` written by blackout_spoof."""
if not schedule_path.is_file():
raise FileNotFoundError(f"schedule.json not found: {schedule_path}")
payload = json.loads(schedule_path.read_text())
spoof_gps = [
SpoofGpsRecord(
monotonic_ms=int(s["monotonic_ms"]),
lat_deg=float(s["lat_deg"]),
lon_deg=float(s["lon_deg"]),
alt_m=float(s["alt_m"]),
fix_type=int(s["fix_type"]),
hdop=float(s["hdop"]),
)
for s in payload["spoof_gps"]
]
return cls(
window_start_ms=int(payload["window_start_ms"]),
window_end_ms=int(payload["window_end_ms"]),
spoof_gps=spoof_gps,
max_alignment_err_ms=float(payload.get("max_alignment_err_ms", 40.0)),
)
def activate(
self,
now_ms_provider: NowMsProvider,
first_blackout_ms: int | None = None,
) -> ProxyAlignmentReport:
"""Bind the proxy to a clock and align ``t0`` to the first blackout frame.
``first_blackout_ms`` (in the proxy's monotonic clock space) is the
timestamp at which the video-overlay emitted its first all-black
frame. The proxy sets ``t0`` so that ``window_start_ms`` matches
that instant; this is what enforces AC-3 (≤40 ms alignment).
If ``first_blackout_ms`` is ``None`` the proxy uses ``now`` as the
anchor — useful for unit tests where the schedule's window starts
at t=0 in proxy time.
"""
now_ms = now_ms_provider()
anchor = first_blackout_ms if first_blackout_ms is not None else now_ms
# Adjust t0 so that ``proxy_time(now) = (now - t0) ≈ window_start_ms``
# at the moment of the first black frame.
self._t0_ms = anchor - self._window_start_ms
self._now_ms_provider = now_ms_provider
self._activated = True
self._activation_report = ProxyAlignmentReport(
window_start_ms=self._window_start_ms,
activation_now_ms=now_ms,
alignment_err_ms=abs(now_ms - anchor),
)
return self._activation_report
@property
def activation_report(self) -> ProxyAlignmentReport | None:
return self._activation_report
def _proxy_time_ms(self) -> int:
if not self._activated or self._now_ms_provider is None or self._t0_ms is None:
raise RuntimeError("proxy not activated — call activate(...) first")
return self._now_ms_provider() - self._t0_ms
def in_window(self) -> bool:
"""True iff the proxy clock is inside the blackout window."""
if not self._activated:
return False
t = self._proxy_time_ms()
return self._window_start_ms <= t <= self._window_end_ms
def process_inbound_message(self, raw_gps: dict) -> dict:
"""Pass-through (no-op) outside the window; spoofed-replace inside it.
``raw_gps`` is a dict in the shape of MAVLink ``GPS_INPUT`` /
``GPS_RAW_INT`` (we treat it as opaque; we just clone the keys
and overwrite the position fields). When the spoof list is
exhausted, the last spoofed frame keeps being emitted (the FC
sees a "stuck" spoofed position — that's what triggers
downstream failsafe escalation).
Calling this before ``activate()`` is a programming error and
raises ``RuntimeError`` — it would otherwise be a silent
passthrough that hides a mis-wired test setup.
"""
if not self._activated:
raise RuntimeError("proxy not activated — call activate(...) first")
if not self.in_window():
return raw_gps
spoof = self._next_spoof_record()
out = dict(raw_gps)
# Normalised + protocol-natural fields (the MAVLink router maps
# these to GPS_INPUT.lat / lon / alt / fix_type / hdop with the
# appropriate scaling; we keep degrees so the layer responsible
# for scaling owns it).
out["lat_deg"] = spoof.lat_deg
out["lon_deg"] = spoof.lon_deg
out["alt_m"] = spoof.alt_m
out["fix_type"] = spoof.fix_type
out["hdop"] = spoof.hdop
out["__spoofed__"] = True
return out
def _next_spoof_record(self) -> SpoofGpsRecord:
if self._next_spoof_idx < len(self._spoof_gps):
rec = self._spoof_gps[self._next_spoof_idx]
self._next_spoof_idx += 1
return rec
return self._spoof_gps[-1]
def emitted_spoof_count(self) -> int:
return self._next_spoof_idx
+291 -6
View File
@@ -1,20 +1,305 @@
"""multi-segment-derkachi — ≥3 disconnected segments via satellite re-loc (FT-P-08).
"""multi-segment-derkachi — ≥3 disjoint blackout windows, NO spoof (FT-P-08).
Concrete generator is owned by AZ-408. AZ-406 commits to the public
signature.
Generates a blackout-only fixture: ``n_segments`` disjoint all-black
windows distributed across the Derkachi flight, with no paired GPS spoof.
Drives the satellite-reference re-localization positive path; explicitly
NOT the security failsafe path (that's FT-N-04 / NFT-RES-04, owned by the
blackout_spoof injector).
Constraints (AC-5):
* ≥3 disjoint blackout windows.
* Consecutive windows separated by ≥30 s of normal frames.
* Total blackout coverage ≤25 % of the source duration.
Window placement is deterministic-positional (anchored at fixed fractions
of the source duration) rather than random — that keeps the test's
"window N starts at second X" assertion stable. The seed is still
accepted for API symmetry with the other injectors but currently does
not affect the output (documented in the dataclass docstring); future
NFT-RES-04 variants may use it to perturb segment lengths.
Public-boundary discipline: this module does NOT import any
``src/gps_denied_onboard`` symbol.
"""
from __future__ import annotations
import argparse
import csv
import io
import json
import logging
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
from ._common import tmpfs_root
logger = logging.getLogger(__name__)
# Constraint constants (AC-5 of AZ-408).
_MIN_INTER_SEGMENT_GAP_SECONDS = 30.0
_MAX_TOTAL_BLACKOUT_FRACTION = 0.25
_DEFAULT_SRC_FPS = 30.0
_TILE_W = 256
_TILE_H = 256
@dataclass(frozen=True)
class MultiSegmentPlan:
"""Configuration for the multi-segment-derkachi fixture.
AZ-408 replaces the AZ-406 scaffold dataclass; the previous shape
(just ``n_segments`` + ``gap_seconds``) is extended to include the
inputs the build path needs. ``seed`` is accepted for symmetry but
is not currently consumed — segment placement is deterministic-positional.
"""
source_frames_dir: Path
n_segments: int = 3
gap_seconds: float = 12.0
segment_seconds: float = 12.0
source_fps: float = _DEFAULT_SRC_FPS
seed: int = 0
def build(plan: MultiSegmentPlan, out_root: Path) -> Path:
raise NotImplementedError("Owned by AZ-408 — AZ-406 supplies only the contract.")
@dataclass(frozen=True)
class SegmentWindow:
start_ms: int
end_ms: int
first_frame_idx: int
last_frame_idx: int
@dataclass(frozen=True)
class MultiSegmentReport:
out_root: Path
segments: list[SegmentWindow]
source_duration_ms: int
total_blackout_frames: int
total_blackout_fraction: float
def _plan_segments(plan: MultiSegmentPlan, total_frames: int) -> list[SegmentWindow]:
"""Compute the segment windows that satisfy AC-5.
Strategy: place ``n_segments`` windows uniformly across the source
duration, each window starts at ``(i+1) / (n+1)`` of the duration
(so first window is not at t=0 and last window is not at t=END).
Then validate the gap constraint + the total-coverage constraint
and raise if the plan is infeasible (rather than silently truncating).
"""
if plan.n_segments < 3:
raise ValueError(f"n_segments must be ≥3 (AC-5); got {plan.n_segments}")
if plan.segment_seconds <= 0:
raise ValueError(f"segment_seconds must be > 0; got {plan.segment_seconds}")
src_duration_s = total_frames / plan.source_fps
src_duration_ms = int(round(src_duration_s * 1000.0))
seg_ms = int(round(plan.segment_seconds * 1000.0))
segments: list[SegmentWindow] = []
for i in range(plan.n_segments):
anchor_s = src_duration_s * (i + 1) / (plan.n_segments + 1)
start_ms = int(round(anchor_s * 1000.0))
end_ms = min(start_ms + seg_ms, src_duration_ms)
first_frame = int(round(start_ms / 1000.0 * plan.source_fps))
last_frame = int(round(end_ms / 1000.0 * plan.source_fps))
segments.append(
SegmentWindow(
start_ms=start_ms,
end_ms=end_ms,
first_frame_idx=first_frame,
last_frame_idx=min(last_frame, total_frames),
)
)
# AC-5 gap check.
for prev, nxt in zip(segments, segments[1:]):
gap_ms = nxt.start_ms - prev.end_ms
if gap_ms < _MIN_INTER_SEGMENT_GAP_SECONDS * 1000:
raise ValueError(
f"infeasible plan: gap between segment ending at {prev.end_ms} ms "
f"and segment starting at {nxt.start_ms} ms is {gap_ms} ms < "
f"{int(_MIN_INTER_SEGMENT_GAP_SECONDS * 1000)} ms (AC-5). Reduce "
"segment_seconds or n_segments, or use a longer source."
)
# AC-5 coverage check.
total_blackout_ms = sum(s.end_ms - s.start_ms for s in segments)
fraction = total_blackout_ms / max(1, src_duration_ms)
if fraction > _MAX_TOTAL_BLACKOUT_FRACTION:
raise ValueError(
f"infeasible plan: total blackout fraction is {fraction:.3f} "
f"> {_MAX_TOTAL_BLACKOUT_FRACTION:.2f} (AC-5). Reduce "
"segment_seconds or n_segments."
)
return segments
def _black_jpeg_bytes() -> bytes:
from PIL import Image # noqa: PLC0415 — heavy import, deferred
img = Image.new("RGB", (_TILE_W, _TILE_H), color=(0, 0, 0))
buf = io.BytesIO()
img.save(
buf,
format="JPEG",
quality=85,
optimize=False,
progressive=False,
subsampling=2,
)
return buf.getvalue()
def build(plan: MultiSegmentPlan, out_root: Path) -> MultiSegmentReport:
"""Generate the multi-segment-derkachi fixture under ``out_root``."""
if out_root.exists():
shutil.rmtree(out_root)
(out_root / "frames").mkdir(parents=True)
src_dir = plan.source_frames_dir
if not src_dir.is_dir():
raise FileNotFoundError(f"source frames directory not found: {src_dir}")
frames = sorted(src_dir.glob("AD*.jpg"))
if not frames:
raise FileNotFoundError(f"no AD*.jpg frames under {src_dir}")
total_frames = len(frames)
src_duration_ms = int(round(total_frames / plan.source_fps * 1000.0))
segments = _plan_segments(plan, total_frames)
black_jpeg = _black_jpeg_bytes()
manifest_rows: list[dict] = []
blackout_set: set[int] = set()
for seg_idx, seg in enumerate(segments):
for f in range(seg.first_frame_idx, min(seg.last_frame_idx, total_frames)):
blackout_set.add(f)
manifest_rows.append(
{
"frame_idx": f,
"src_jpeg_path": frames[f].name,
"segment_idx": seg_idx,
"segment_start_ms": seg.start_ms,
"segment_end_ms": seg.end_ms,
}
)
for frame_idx, frame_path in enumerate(frames):
out_path = out_root / "frames" / frame_path.name
if frame_idx in blackout_set:
out_path.write_bytes(black_jpeg)
else:
shutil.copy2(frame_path, out_path)
_write_schedule(out_root, segments)
_write_manifest(out_root, manifest_rows)
total_blackout = sum(s.last_frame_idx - s.first_frame_idx for s in segments)
fraction = (sum(s.end_ms - s.start_ms for s in segments)) / max(1, src_duration_ms)
report = MultiSegmentReport(
out_root=out_root,
segments=segments,
source_duration_ms=src_duration_ms,
total_blackout_frames=total_blackout,
total_blackout_fraction=fraction,
)
_write_summary(out_root, report)
return report
def _write_schedule(out_root: Path, segments: list[SegmentWindow]) -> None:
payload = {
"segments": [
{
"start_ms": s.start_ms,
"end_ms": s.end_ms,
"first_frame_idx": s.first_frame_idx,
"last_frame_idx": s.last_frame_idx,
}
for s in segments
]
}
(out_root / "schedule.json").write_text(
json.dumps(payload, sort_keys=True, indent=2) + "\n"
)
def _write_manifest(out_root: Path, rows: list[dict]) -> None:
manifest = out_root / "manifest.csv"
with manifest.open("w", newline="") as fp:
writer = csv.DictWriter(
fp,
fieldnames=["frame_idx", "src_jpeg_path", "segment_idx", "segment_start_ms", "segment_end_ms"],
lineterminator="\n",
)
writer.writeheader()
for row in sorted(rows, key=lambda r: (r["segment_idx"], r["frame_idx"])):
writer.writerow(row)
def _write_summary(out_root: Path, report: MultiSegmentReport) -> None:
payload = {
"scenario": "multi-segment-derkachi",
"n_segments": len(report.segments),
"source_duration_ms": report.source_duration_ms,
"total_blackout_frames": report.total_blackout_frames,
"total_blackout_fraction": round(report.total_blackout_fraction, 6),
"segments": [
{"start_ms": s.start_ms, "end_ms": s.end_ms} for s in report.segments
],
}
(out_root / "summary.json").write_text(
json.dumps(payload, sort_keys=True, indent=2) + "\n"
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Multi-segment blackout (FT-P-08)")
parser.add_argument("--source-frames", type=Path, required=True)
parser.add_argument("--n-segments", type=int, default=3)
parser.add_argument("--segment-seconds", type=float, default=12.0)
parser.add_argument("--source-fps", type=float, default=_DEFAULT_SRC_FPS)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument(
"--out-root",
type=Path,
default=None,
help="Output dir. If omitted, /tmp/<run_id>/multi-segment/.",
)
parser.add_argument("--run-id", default="local")
parser.add_argument("--quiet", action="store_true")
args = parser.parse_args(argv)
logging.basicConfig(
level=logging.WARNING if args.quiet else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
out_root = args.out_root or tmpfs_root(args.run_id, "multi-segment")
plan = MultiSegmentPlan(
source_frames_dir=args.source_frames,
n_segments=args.n_segments,
segment_seconds=args.segment_seconds,
source_fps=args.source_fps,
seed=args.seed,
)
report = build(plan, out_root)
summary = {
"scenario": "multi-segment-derkachi",
"out_root": str(report.out_root),
"n_segments": len(report.segments),
"source_duration_ms": report.source_duration_ms,
"total_blackout_frames": report.total_blackout_frames,
"total_blackout_fraction": round(report.total_blackout_fraction, 6),
}
json.dump(summary, sys.stdout, sort_keys=True, indent=2)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+296 -10
View File
@@ -1,24 +1,310 @@
"""outlier-injection-derkachi — injects up to 350 m position outliers (FT-N-01).
"""outlier-injection-derkachi — overlay far-away tile crops onto Derkachi frames (FT-N-01).
Concrete generator is owned by AZ-408. AZ-406 commits to the public
signature so test specs can plan against it.
Produces a per-test tmpfs fixture whose ``frames/`` subdirectory mirrors
the source Derkachi frames byte-for-byte EXCEPT that selected frames are
replaced with a JPEG crop pulled from a tile whose centre is ≥350 m
(AC-3.1) from the original frame's GT centre. The companion
``manifest.csv`` records, per replaced frame, ``(frame_idx, src_jpeg_path,
replacement_tile_x, replacement_tile_y, geodesic_offset_m, seed)`` so the
downstream FT-N-01 / FT-P-08 / NFT-RES-04 tests can assert AC-3.1 directly
without re-deriving the geo math.
Density flags ≈ AZ-408 AC-1 / AC-2:
* ``light`` → 1 in 100 frames (replacement ratio 0.01)
* ``medium`` → 1 in 10 frames (replacement ratio 0.10)
* ``heavy`` → 1 in 3 frames (replacement ratio ≈ 0.333)
Determinism (AC-1):
* The frame indices replaced are computed by a deterministic stride
(``_common.iter_video_frame_indices``) — not by random sampling — so two
runs replace the *same* frames.
* The replacement tile for each replaced frame is picked from a
``_common.derive_rng("outlier", seed, density)`` stream — same seed →
same picks.
* Output filenames mirror the source filenames; JPEG bodies are re-encoded
through a pinned PIL pipeline (``quality=85, optimize=False,
progressive=False, subsampling=2``) so the bytes are stable.
Tmpfs (AC-6): the injector writes only under the directory ``out_root``
passes in; the pytest fixture wrapper takes care of teardown.
Public-boundary discipline: this module does NOT import any
``src/gps_denied_onboard`` symbol.
"""
from __future__ import annotations
import argparse
import csv
import io
import json
import logging
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from ._common import (
derive_rng,
far_away_indices,
haversine_m,
iter_video_frame_indices,
read_tile_manifest,
tmpfs_root,
)
logger = logging.getLogger(__name__)
Density = Literal["light", "medium", "heavy"]
_DENSITY_RATIO: dict[Density, float] = {
"light": 1 / 100,
"medium": 1 / 10,
"heavy": 1 / 3,
}
_TILE_W = 256
_TILE_H = 256
@dataclass(frozen=True)
class OutlierInjectionPlan:
"""Configuration for the outlier-injection-derkachi fixture."""
"""Configuration for the outlier-injection-derkachi fixture.
target_segment_seconds: tuple[float, float]
max_offset_m: float = 350.0
n_outliers: int = 5
AZ-408 replaces the AZ-406 scaffold dataclass; the previous shape
(``target_segment_seconds`` / ``max_offset_m`` / ``n_outliers``) was
a placeholder and is no longer used by any test.
"""
source_frames_dir: Path
tile_cache_dir: Path
density: Density
seed: int = 0
min_offset_m: float = 350.0
def build(plan: OutlierInjectionPlan, out_root: Path) -> Path:
"""Generate the fixture under ``out_root``. Returns the produced directory."""
raise NotImplementedError("Owned by AZ-408 — AZ-406 supplies only the contract.")
@dataclass(frozen=True)
class OutlierInjectionReport:
"""Summary of a single ``build()`` run — written to ``manifest.csv``."""
out_root: Path
total_source_frames: int
replaced_frame_count: int
density: Density
min_geodesic_offset_m: float
max_geodesic_offset_m: float
def _gt_centre_for_frame(
frame_idx: int,
tiles: list,
) -> tuple[float, float, int]:
"""Map a source frame to a (lat, lon, src_tile_idx) triple.
For the Derkachi fixture each AD-frame has a paired tile entry in
the tile-cache manifest (`paired_gmaps:ADNNNNNN` in the
`provenance` column). For unpaired frames we fall back to the
bbox tile (`STUB_BBOX:derkachi:*`); if even that's missing we
fall back to the first tile so the injector still runs.
"""
for j, r in enumerate(tiles):
if r.provenance.startswith("paired_gmaps:") and r.provenance.endswith(
f"AD{frame_idx + 1:06d}"
):
return r.centre_lat_deg, r.centre_lon_deg, j
for j, r in enumerate(tiles):
if r.provenance.startswith("STUB_BBOX:"):
return r.centre_lat_deg, r.centre_lon_deg, j
return tiles[0].centre_lat_deg, tiles[0].centre_lon_deg, 0
def _read_replacement_jpeg(tile_cache_dir: Path, jpeg_path: str) -> bytes:
"""Read + re-encode a tile JPEG through PIL with pinned settings.
Re-encoding (rather than raw copy) guarantees the body matches the
builder's encode (PIL ``quality=85, optimize=False, progressive=False,
subsampling=2``) even if the tile was written by a foreign tool.
"""
from PIL import Image # noqa: PLC0415 — heavy import, deferred
src = tile_cache_dir / jpeg_path
img = Image.open(src).convert("RGB").resize((_TILE_W, _TILE_H), Image.BICUBIC)
buf = io.BytesIO()
img.save(
buf,
format="JPEG",
quality=85,
optimize=False,
progressive=False,
subsampling=2,
)
return buf.getvalue()
def build(plan: OutlierInjectionPlan, out_root: Path) -> OutlierInjectionReport:
"""Generate the outlier-injection-derkachi fixture under ``out_root``.
Returns an ``OutlierInjectionReport`` summarising the run. Writes:
<out_root>/
frames/AD000001.jpg # passthrough or replaced
frames/AD000002.jpg # …
manifest.csv # per-replaced-frame metadata
summary.json # report fields, machine-readable
"""
if out_root.exists():
shutil.rmtree(out_root)
(out_root / "frames").mkdir(parents=True)
src_dir = plan.source_frames_dir
if not src_dir.is_dir():
raise FileNotFoundError(f"source frames directory not found: {src_dir}")
frames = sorted(src_dir.glob("AD*.jpg"))
if not frames:
raise FileNotFoundError(f"no AD*.jpg frames under {src_dir}")
tiles = read_tile_manifest(plan.tile_cache_dir / "manifest.csv")
ratio = _DENSITY_RATIO[plan.density]
replace_indices = set(iter_video_frame_indices(len(frames), ratio))
rng = derive_rng("outlier", plan.seed, plan.density)
manifest_rows: list[dict] = []
geodesic_offsets: list[float] = []
for frame_idx, frame_path in enumerate(frames):
out_path = out_root / "frames" / frame_path.name
if frame_idx not in replace_indices:
shutil.copy2(frame_path, out_path)
continue
src_lat, src_lon, src_tile_idx = _gt_centre_for_frame(frame_idx, tiles)
candidates = far_away_indices(tiles, src_tile_idx, plan.min_offset_m)
if not candidates:
raise RuntimeError(
f"no tile in {plan.tile_cache_dir} is ≥{plan.min_offset_m} m "
f"from frame {frame_path.name} — tile cache too small for "
"outlier injection"
)
pick_idx = int(rng.integers(0, len(candidates)))
chosen = tiles[candidates[pick_idx]]
offset_m = haversine_m(
src_lat, src_lon, chosen.centre_lat_deg, chosen.centre_lon_deg
)
geodesic_offsets.append(offset_m)
jpeg = _read_replacement_jpeg(plan.tile_cache_dir, chosen.jpeg_path)
out_path.write_bytes(jpeg)
manifest_rows.append(
{
"frame_idx": frame_idx,
"src_jpeg_path": str(frame_path.name),
"replacement_tile_x": chosen.tile_x,
"replacement_tile_y": chosen.tile_y,
"replacement_zoom": chosen.zoom_level,
"geodesic_offset_m": f"{offset_m:.3f}",
"density": plan.density,
"seed": plan.seed,
}
)
_write_manifest(out_root, manifest_rows)
report = OutlierInjectionReport(
out_root=out_root,
total_source_frames=len(frames),
replaced_frame_count=len(manifest_rows),
density=plan.density,
min_geodesic_offset_m=min(geodesic_offsets) if geodesic_offsets else 0.0,
max_geodesic_offset_m=max(geodesic_offsets) if geodesic_offsets else 0.0,
)
_write_summary(out_root, report)
return report
def _write_manifest(out_root: Path, rows: list[dict]) -> None:
manifest = out_root / "manifest.csv"
with manifest.open("w", newline="") as fp:
writer = csv.DictWriter(
fp,
fieldnames=[
"frame_idx",
"src_jpeg_path",
"replacement_tile_x",
"replacement_tile_y",
"replacement_zoom",
"geodesic_offset_m",
"density",
"seed",
],
lineterminator="\n",
)
writer.writeheader()
for row in sorted(rows, key=lambda r: r["frame_idx"]):
writer.writerow(row)
def _write_summary(out_root: Path, report: OutlierInjectionReport) -> None:
payload = {
"scenario": "outlier-injection-derkachi",
"total_source_frames": report.total_source_frames,
"replaced_frame_count": report.replaced_frame_count,
"density": report.density,
"min_geodesic_offset_m": round(report.min_geodesic_offset_m, 3),
"max_geodesic_offset_m": round(report.max_geodesic_offset_m, 3),
}
(out_root / "summary.json").write_text(
json.dumps(payload, sort_keys=True, indent=2) + "\n"
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Outlier injection (FT-N-01)")
parser.add_argument("--source-frames", type=Path, required=True)
parser.add_argument("--tile-cache", type=Path, required=True)
parser.add_argument("--density", choices=("light", "medium", "heavy"), required=True)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--min-offset-m", type=float, default=350.0)
parser.add_argument(
"--out-root",
type=Path,
default=None,
help="Output dir. If omitted, /tmp/<run_id>/outlier-<density>/.",
)
parser.add_argument("--run-id", default="local")
parser.add_argument("--quiet", action="store_true")
args = parser.parse_args(argv)
logging.basicConfig(
level=logging.WARNING if args.quiet else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
out_root = args.out_root or tmpfs_root(args.run_id, f"outlier-{args.density}")
plan = OutlierInjectionPlan(
source_frames_dir=args.source_frames,
tile_cache_dir=args.tile_cache,
density=args.density,
seed=args.seed,
min_offset_m=args.min_offset_m,
)
report = build(plan, out_root)
summary = {
"scenario": "outlier-injection-derkachi",
"out_root": str(report.out_root),
"total_source_frames": report.total_source_frames,
"replaced_frame_count": report.replaced_frame_count,
"density": report.density,
"min_geodesic_offset_m": round(report.min_geodesic_offset_m, 3),
"max_geodesic_offset_m": round(report.max_geodesic_offset_m, 3),
}
json.dump(summary, sys.stdout, sort_keys=True, indent=2)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -212,4 +212,5 @@ pytest_plugins = [
"runner.reporting.csv_reporter",
"runner.reporting.evidence_bundler",
"runner.reporting.nfr_recorder",
"runner.helpers.injector_fixtures",
]
+287
View File
@@ -0,0 +1,287 @@
"""Anchor-pair detection + drift binning for FT-P-02 (AC-1.3).
Consumes a stream of FDR ``source_label`` transitions + position estimates
and produces:
* Anchor pairs: every (visual_propagated | dead_reckoned) → satellite_anchored
transition is one pair. The pair records the segment's propagated_centre
immediately before the new anchor, the anchor centre itself, and the
age of the previous satellite anchor at the moment of the new one.
* Drift per pair = geodesic distance (Vincenty / WGS84) between the
propagated centre and the new anchor centre.
* Drift bins by ``last_satellite_anchor_age_ms`` (defaults to the
spec's {<1 s, 1-3 s, 3-10 s, 10-30 s, >30 s} buckets).
* Aggregate pass/fail per AC-1.3:
- AC-2: ≥95 % of visual-only pairs satisfy drift < 100 m.
- AC-3: ≥95 % of IMU-fused pairs satisfy drift < 50 m.
- AC-4: bin medians grow monotonically with age; no >2× jump.
The classification (visual-only vs IMU-fused) is purely informational —
the test code reads it out of the segment's FDR records (any frame with
``imu_fused=True`` since the prior anchor makes the segment IMU-fused).
The helper is **transport-agnostic**: it takes typed FdrEstimate records
that the per-scenario test produces from the public FDR archive (no SUT
import). Unit tests construct synthetic streams directly.
Public-boundary discipline: this module does NOT import any
``src/gps_denied_onboard`` symbol.
"""
from __future__ import annotations
import statistics
from dataclasses import dataclass, field
from typing import Literal, Sequence
from .geo import distance_m
SourceLabel = Literal["satellite_anchored", "visual_propagated", "dead_reckoned"]
@dataclass(frozen=True)
class FdrEstimate:
"""One position estimate from the FDR archive (post-flight read).
The fields are the public-boundary contract — we never import the
SUT's ``FdrRecord`` dataclass; we materialise a parallel struct
from the FDR JSON payload.
"""
monotonic_ms: int
lat_deg: float
lon_deg: float
source_label: SourceLabel
imu_fused: bool = False
cov_semi_major_m: float = 0.0
last_satellite_anchor_age_ms: int = 0
@dataclass(frozen=True)
class AnchorPair:
"""One (propagated_centre, new_anchor) pair."""
segment_first_ms: int
propagated_centre_ms: int # timestamp of last estimate before anchor
anchor_ms: int
propagated_lat_deg: float
propagated_lon_deg: float
anchor_lat_deg: float
anchor_lon_deg: float
drift_m: float
last_satellite_anchor_age_ms: int
imu_fused_segment: bool
# Default bin edges per the spec: {<1 s, 1-3 s, 3-10 s, 10-30 s, >30 s}
DEFAULT_AGE_BIN_EDGES_MS: tuple[int, ...] = (1_000, 3_000, 10_000, 30_000)
@dataclass
class DriftBinStats:
"""Aggregate statistics for one age-bin."""
label: str
count: int = 0
median_m: float = 0.0
p95_m: float = 0.0
drifts_m: list[float] = field(default_factory=list)
@dataclass
class FtP02Report:
"""Aggregate report produced by the FT-P-02 scenario."""
pairs: list[AnchorPair]
visual_only_pairs: list[AnchorPair]
imu_fused_pairs: list[AnchorPair]
visual_only_pass_fraction: float
imu_fused_pass_fraction: float
bin_stats: list[DriftBinStats]
monotonic_violations: list[str]
def detect_anchor_pairs(stream: Sequence[FdrEstimate]) -> list[AnchorPair]:
"""Detect every ``visual_propagated|dead_reckoned → satellite_anchored`` transition.
Within a single segment (sequence of visual_propagated / dead_reckoned
estimates), the **propagated_centre** is the estimate immediately
preceding the next anchor — that's the SUT's last published centre
before the new anchor pulls it back to ground truth.
The "first anchor" of the stream has no predecessor segment and is
skipped (it is not a pair).
"""
pairs: list[AnchorPair] = []
last_anchor: FdrEstimate | None = None
current_segment: list[FdrEstimate] = []
imu_fused_in_segment = False
for est in stream:
if est.source_label == "satellite_anchored":
if last_anchor is not None and current_segment:
propagated = current_segment[-1]
drift = distance_m(
propagated.lat_deg, propagated.lon_deg,
est.lat_deg, est.lon_deg,
)
pairs.append(
AnchorPair(
segment_first_ms=current_segment[0].monotonic_ms,
propagated_centre_ms=propagated.monotonic_ms,
anchor_ms=est.monotonic_ms,
propagated_lat_deg=propagated.lat_deg,
propagated_lon_deg=propagated.lon_deg,
anchor_lat_deg=est.lat_deg,
anchor_lon_deg=est.lon_deg,
drift_m=drift,
last_satellite_anchor_age_ms=est.monotonic_ms - last_anchor.monotonic_ms,
imu_fused_segment=imu_fused_in_segment,
)
)
last_anchor = est
current_segment = []
imu_fused_in_segment = False
else:
current_segment.append(est)
if est.imu_fused:
imu_fused_in_segment = True
return pairs
def _bin_label(age_ms: int, edges: tuple[int, ...]) -> str:
"""Map an age in ms to a human-readable bin label."""
if age_ms < edges[0]:
return f"<{edges[0] // 1000}s"
for i in range(1, len(edges)):
if age_ms < edges[i]:
return f"{edges[i - 1] // 1000}-{edges[i] // 1000}s"
return f">{edges[-1] // 1000}s"
def bin_drifts(
pairs: Sequence[AnchorPair],
edges: tuple[int, ...] = DEFAULT_AGE_BIN_EDGES_MS,
) -> list[DriftBinStats]:
"""Bin drifts by ``last_satellite_anchor_age_ms``; return per-bin stats."""
bins: dict[str, list[float]] = {}
# Pre-create bins in display order so the output is stable.
labels = [_bin_label(0, edges)]
labels.extend(f"{edges[i] // 1000}-{edges[i + 1] // 1000}s" for i in range(len(edges) - 1))
labels.append(f">{edges[-1] // 1000}s")
for label in labels:
bins[label] = []
for p in pairs:
bins[_bin_label(p.last_satellite_anchor_age_ms, edges)].append(p.drift_m)
stats: list[DriftBinStats] = []
for label in labels:
drifts = bins[label]
if drifts:
sorted_drifts = sorted(drifts)
idx95 = max(0, int(round(0.95 * len(sorted_drifts))) - 1)
stats.append(
DriftBinStats(
label=label,
count=len(drifts),
median_m=statistics.median(drifts),
p95_m=sorted_drifts[idx95],
drifts_m=drifts,
)
)
else:
stats.append(DriftBinStats(label=label, count=0, median_m=0.0, p95_m=0.0))
return stats
def check_monotonic(bin_stats: Sequence[DriftBinStats]) -> list[str]:
"""AC-4: bin medians grow monotonically with age; no >2× jump between
adjacent populated bins. Returns a list of violation strings (empty
iff the AC holds).
"""
violations: list[str] = []
populated = [s for s in bin_stats if s.count > 0]
for prev, nxt in zip(populated, populated[1:]):
if nxt.median_m < prev.median_m:
violations.append(
f"non-monotonic median: bin {prev.label} median {prev.median_m:.2f} m > "
f"bin {nxt.label} median {nxt.median_m:.2f} m"
)
elif prev.median_m > 0 and nxt.median_m > 2 * prev.median_m:
violations.append(
f">2x median jump: bin {prev.label} median {prev.median_m:.2f} m → "
f"bin {nxt.label} median {nxt.median_m:.2f} m"
)
return violations
def compute_pass_fraction(pairs: Sequence[AnchorPair], drift_bound_m: float) -> float:
"""Fraction of pairs whose drift < ``drift_bound_m``. Returns 0.0 for empty."""
if not pairs:
return 0.0
pass_count = sum(1 for p in pairs if p.drift_m < drift_bound_m)
return pass_count / len(pairs)
def aggregate(
stream: Sequence[FdrEstimate],
visual_only_bound_m: float = 100.0,
imu_fused_bound_m: float = 50.0,
edges: tuple[int, ...] = DEFAULT_AGE_BIN_EDGES_MS,
) -> FtP02Report:
"""End-to-end aggregation: stream → pairs → bins → pass fractions → monotonicity."""
pairs = detect_anchor_pairs(stream)
visual_only = [p for p in pairs if not p.imu_fused_segment]
imu_fused = [p for p in pairs if p.imu_fused_segment]
bin_stats = bin_drifts(pairs, edges)
return FtP02Report(
pairs=pairs,
visual_only_pairs=visual_only,
imu_fused_pairs=imu_fused,
visual_only_pass_fraction=compute_pass_fraction(visual_only, visual_only_bound_m),
imu_fused_pass_fraction=compute_pass_fraction(imu_fused, imu_fused_bound_m),
bin_stats=bin_stats,
monotonic_violations=check_monotonic(bin_stats),
)
def write_csv_evidence(report: FtP02Report, csv_path) -> None: # type: ignore[no-untyped-def]
"""Emit one CSV row per anchor pair under ``csv_path`` (FT-P-02 evidence)."""
import csv as _csv
with csv_path.open("w", newline="") as fp:
writer = _csv.writer(fp, lineterminator="\n")
writer.writerow(
[
"segment_first_ms",
"propagated_centre_ms",
"anchor_ms",
"propagated_lat_deg",
"propagated_lon_deg",
"anchor_lat_deg",
"anchor_lon_deg",
"drift_m",
"last_satellite_anchor_age_ms",
"imu_fused_segment",
]
)
for p in report.pairs:
writer.writerow(
[
p.segment_first_ms,
p.propagated_centre_ms,
p.anchor_ms,
f"{p.propagated_lat_deg:.7f}",
f"{p.propagated_lon_deg:.7f}",
f"{p.anchor_lat_deg:.7f}",
f"{p.anchor_lon_deg:.7f}",
f"{p.drift_m:.3f}",
p.last_satellite_anchor_age_ms,
int(p.imu_fused_segment),
]
)
+188
View File
@@ -0,0 +1,188 @@
"""SUT outbound-estimate schema + WGS84 validation (FT-P-03, FT-P-14).
Two thin contract checks shared by AZ-411's scenario file:
1. **Schema completeness** (AC-1 of FT-P-03):
the outbound estimate must carry the four documented fields
``lat:float``, ``lon:float``, ``cov_semi_major_m:float``,
``last_satellite_anchor_age_ms:int`` — either inside the
``GPS_INPUT`` / ``MSP2_SENSOR_GPS`` payload, OR on a paired
side-channel (per AC-4.3).
2. **Source-label set containment** (AC-2): the side-channel emission
is exactly one of ``{satellite_anchored, visual_propagated,
dead_reckoned}`` — anything else is a real defect.
3. **WGS84 range** (AC-3 of FT-P-14): decoded ``lat`` ∈ [-90, 90],
``lon`` ∈ [-180, 180]; scaling matches the protocol convention
(AP/iNav `lat/lon` are 1e-7 scaled int32).
The helpers operate on pure Python dict-like records — the scenario
test pulls them from the SITL observer / tlog reader and hands them in.
That keeps these helpers unit-testable without any docker harness.
Public-boundary discipline: this module does NOT import any
``src/gps_denied_onboard`` symbol.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, Mapping
REQUIRED_FIELDS: tuple[tuple[str, type], ...] = (
("lat", float),
("lon", float),
("cov_semi_major_m", float),
("last_satellite_anchor_age_ms", int),
)
ALLOWED_SOURCE_LABELS: frozenset[str] = frozenset(
{"satellite_anchored", "visual_propagated", "dead_reckoned"}
)
# Protocol scaling factors — exact integer 1e-7 per MAVLink GPS_INPUT
# (`int32 lat / lon * 1e-7`) and iNav MSP2_SENSOR_GPS (same scaling).
LAT_LON_SCALE = 1e-7
@dataclass(frozen=True)
class SchemaValidationResult:
"""Outcome of a single ``validate_estimate_schema`` call."""
ok: bool
missing_fields: list[str]
wrong_typed_fields: list[str]
@dataclass(frozen=True)
class SourceLabelValidationResult:
ok: bool
observed: str | None
reason: str | None # filled when not ok
@dataclass(frozen=True)
class Wgs84ValidationResult:
ok: bool
lat_deg: float | None
lon_deg: float | None
reason: str | None
def validate_estimate_schema(record: Mapping[str, object]) -> SchemaValidationResult:
"""AC-1: all four documented fields present + correctly typed.
The record may be the merged ``{payload_fields, sidechannel_fields}``
dict the test produces from ``GPS_INPUT.x`` + the paired
``STATUSTEXT`` / ``NAMED_VALUE_FLOAT`` channel. The helper is
transport-agnostic; it just walks the four ``REQUIRED_FIELDS`` and
checks the type.
"""
missing: list[str] = []
wrong: list[str] = []
for name, expected in REQUIRED_FIELDS:
if name not in record:
missing.append(name)
continue
value = record[name]
# Accept bool only when bool is the expected type (Python's
# ``isinstance(True, int)`` is True; we don't want that to
# silently satisfy ``int``).
if expected is int and isinstance(value, bool):
wrong.append(name)
continue
if not isinstance(value, expected):
wrong.append(name)
return SchemaValidationResult(
ok=not missing and not wrong,
missing_fields=missing,
wrong_typed_fields=wrong,
)
def validate_source_label(label: object) -> SourceLabelValidationResult:
"""AC-2: label is exactly one of the three documented strings."""
if not isinstance(label, str):
return SourceLabelValidationResult(
ok=False, observed=None, reason=f"label is {type(label).__name__}, expected str"
)
if label in ALLOWED_SOURCE_LABELS:
return SourceLabelValidationResult(ok=True, observed=label, reason=None)
return SourceLabelValidationResult(
ok=False, observed=label, reason=f"label {label!r} not in {sorted(ALLOWED_SOURCE_LABELS)}"
)
def validate_wgs84_range(
lat_decoded_deg: float, lon_decoded_deg: float
) -> Wgs84ValidationResult:
"""AC-3 of FT-P-14: lat ∈ [-90, 90], lon ∈ [-180, 180]."""
if not isinstance(lat_decoded_deg, (int, float)) or not isinstance(
lon_decoded_deg, (int, float)
):
return Wgs84ValidationResult(
ok=False, lat_deg=None, lon_deg=None,
reason="lat/lon not numeric",
)
if lat_decoded_deg != lat_decoded_deg or lon_decoded_deg != lon_decoded_deg:
return Wgs84ValidationResult(
ok=False, lat_deg=lat_decoded_deg, lon_deg=lon_decoded_deg,
reason="lat/lon is NaN",
)
if not -90.0 <= lat_decoded_deg <= 90.0:
return Wgs84ValidationResult(
ok=False, lat_deg=lat_decoded_deg, lon_deg=lon_decoded_deg,
reason=f"lat {lat_decoded_deg} out of [-90, 90]",
)
if not -180.0 <= lon_decoded_deg <= 180.0:
return Wgs84ValidationResult(
ok=False, lat_deg=lat_decoded_deg, lon_deg=lon_decoded_deg,
reason=f"lon {lon_decoded_deg} out of [-180, 180]",
)
return Wgs84ValidationResult(
ok=True, lat_deg=lat_decoded_deg, lon_deg=lon_decoded_deg, reason=None
)
def decode_lat_lon_int32(lat_e7: int, lon_e7: int) -> tuple[float, float]:
"""Decode the AP/iNav 1e-7 int32 wire format to WGS84 degrees.
Raises ValueError for inputs outside the int32 range — that's a
transport corruption, not an out-of-bounds geographic value, and
the test should surface it as such.
"""
INT32_MIN = -(2 ** 31)
INT32_MAX = (2 ** 31) - 1
if not INT32_MIN <= lat_e7 <= INT32_MAX:
raise ValueError(f"lat_e7 {lat_e7} outside int32 range")
if not INT32_MIN <= lon_e7 <= INT32_MAX:
raise ValueError(f"lon_e7 {lon_e7} outside int32 range")
return lat_e7 * LAT_LON_SCALE, lon_e7 * LAT_LON_SCALE
def aggregate_validations(
records: Iterable[Mapping[str, object]],
) -> tuple[list[SchemaValidationResult], list[Wgs84ValidationResult]]:
"""Run schema + WGS84 validations over a record stream.
Used by FT-P-03 / FT-P-14 to assert "every record satisfies both
contracts" — typically against a single-image push (1 outbound
record) but stream-friendly for soak-test re-use.
"""
schemas: list[SchemaValidationResult] = []
wgs84s: list[Wgs84ValidationResult] = []
for rec in records:
schemas.append(validate_estimate_schema(rec))
lat = rec.get("lat")
lon = rec.get("lon")
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
wgs84s.append(validate_wgs84_range(float(lat), float(lon)))
else:
wgs84s.append(
Wgs84ValidationResult(
ok=False, lat_deg=None, lon_deg=None,
reason="missing or non-numeric lat/lon for WGS84 check",
)
)
return schemas, wgs84s
+180
View File
@@ -0,0 +1,180 @@
"""pytest fixtures wrapping the AZ-408 runtime synthetic-injection injectors.
Per-scenario tests (FT-N-01, FT-N-04, FT-P-08, NFT-RES-04, NFT-PERF-04)
opt into an injector by requesting one of the fixtures below. Each
fixture:
1. Builds the injector output under the pytest ``tmp_path_factory`` root
(so unit-test runs never touch ``/tmp``).
2. Yields a typed handle the test asserts against (out_root, schedule,
summary).
3. Tears down the scratch directory at fixture exit per AC-6 (≤2 s).
The fixtures are intentionally **session-scoped per parameter set** —
within one parametrize variant the same injector tree is reused across
multiple test methods so we don't pay the ~3 s build cost per assertion.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fixtures.injectors import blackout_spoof, multi_segment, outlier
from fixtures.injectors._common import cleanup_tmpfs
# ---------------------------------------------------------------------------
# Source data discovery
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def derkachi_source_frames() -> Path:
"""Path to the AD*.jpg frames the injectors operate on.
Looks up the project's ``_docs/00_problem/input_data/`` (the test
container mounts this read-only) and asserts the AD-stills exist.
"""
# Walk up from this file: e2e/runner/helpers/injector_fixtures.py
repo_root = Path(__file__).resolve().parents[3]
candidates = [
repo_root / "_docs/00_problem/input_data",
Path("/test-data"), # docker-compose bind-mount target
]
for c in candidates:
if (c / "AD000001.jpg").is_file():
return c
raise FileNotFoundError(
"Derkachi source frames not found in any of: "
+ ", ".join(str(c) for c in candidates)
)
@pytest.fixture(scope="session")
def tile_cache_fixture(pytestconfig: pytest.Config) -> Path:
"""Path to the AZ-407 tile-cache fixture tree.
Two strategies:
1. ``--tile-cache-fixture=<path>`` CLI flag (added by tests/fixtures
that explicitly need to point at a pre-built cache).
2. Default Docker mount at ``/tile-cache`` inside the runner image.
Skips the consuming test when the cache is missing — the injector
unit tests use a synthetic mini-cache (see ``test_outlier.py``) and
don't need this fixture.
"""
explicit = pytestconfig.getoption("--tile-cache-fixture", default=None)
if explicit is not None:
p = Path(str(explicit))
if p.is_dir():
return p
default = Path("/tile-cache")
if default.is_dir():
return default
pytest.skip("tile-cache fixture not available (build with `make fixtures`)")
# ---------------------------------------------------------------------------
# Per-injector fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def outlier_injection_derkachi(
request: pytest.FixtureRequest,
derkachi_source_frames: Path,
tile_cache_fixture: Path,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[outlier.OutlierInjectionReport]:
"""Build the outlier-injection-derkachi fixture for a single test.
Density is read from the parametrize ID (e.g.
``@pytest.mark.parametrize("density", ["medium"], indirect=True)``)
or defaults to ``"medium"``. Seed defaults to ``0`` — override via
``request.param["seed"]`` when a test needs a different stream.
"""
params = request.param if hasattr(request, "param") else {}
density = params.get("density", "medium")
seed = params.get("seed", 0)
out_root = tmp_path_factory.mktemp(f"outlier-{density}-{seed}")
report = outlier.build(
outlier.OutlierInjectionPlan(
source_frames_dir=derkachi_source_frames,
tile_cache_dir=tile_cache_fixture,
density=density,
seed=seed,
),
out_root,
)
yield report
cleanup_tmpfs(out_root)
@pytest.fixture
def blackout_spoof_derkachi(
request: pytest.FixtureRequest,
derkachi_source_frames: Path,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[blackout_spoof.BlackoutSpoofReport]:
"""Build the blackout-spoof-derkachi fixture for a single test."""
params = request.param if hasattr(request, "param") else {}
window_seconds = params.get("window_seconds", 15.0)
seed = params.get("seed", 0)
out_root = tmp_path_factory.mktemp(f"blackout-spoof-{int(window_seconds)}s-{seed}")
report = blackout_spoof.build(
blackout_spoof.BlackoutSpoofPlan(
source_frames_dir=derkachi_source_frames,
blackout_seconds=window_seconds,
seed=seed,
),
out_root,
)
yield report
cleanup_tmpfs(out_root)
@pytest.fixture
def multi_segment_derkachi(
request: pytest.FixtureRequest,
derkachi_source_frames: Path,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[multi_segment.MultiSegmentReport]:
"""Build the multi-segment-derkachi fixture for a single test."""
params = request.param if hasattr(request, "param") else {}
n_segments = params.get("n_segments", 3)
segment_seconds = params.get("segment_seconds", 12.0)
out_root = tmp_path_factory.mktemp(f"multi-segment-{n_segments}x{int(segment_seconds)}s")
report = multi_segment.build(
multi_segment.MultiSegmentPlan(
source_frames_dir=derkachi_source_frames,
n_segments=n_segments,
segment_seconds=segment_seconds,
),
out_root,
)
yield report
cleanup_tmpfs(out_root)
# ---------------------------------------------------------------------------
# Tile-cache CLI flag registration
# ---------------------------------------------------------------------------
def pytest_addoption(parser: pytest.Parser) -> None:
"""Register the ``--tile-cache-fixture`` flag at plugin load time.
Imported by the runner's ``conftest.py`` via ``pytest_plugins`` so it
runs once per session before fixture resolution.
"""
group = parser.getgroup("e2e-runner")
group.addoption(
"--tile-cache-fixture",
action="store",
default=None,
help="Path to a pre-built tile-cache fixture tree. Default: /tile-cache (Docker mount).",
)
@@ -0,0 +1,206 @@
"""FT-P-02 — Cumulative drift between satellite anchors on Derkachi (AC-1.3).
The full scenario:
1. Replay the Derkachi MP4 at 30 fps through the SUT's file-frame source.
2. Replay ``data_imu.csv`` at 10 Hz through the FC inbound (1 IMU per 3
video frames).
3. Observe the SUT's outbound estimate stream + the FDR archive.
4. Detect every (visual_propagated|dead_reckoned) → satellite_anchored
transition; compute drift = ||propagated_centre new_anchor||.
5. Bin drifts by ``last_satellite_anchor_age_ms``; assert AC-2/AC-3/AC-4.
6. Emit ``e2e-results/run-${RUN_ID}/ft-p-02.csv`` with one row per pair.
What this file owns:
* The AC-1.3 logic above, wired through the harness's ``fc_adapter`` /
``vio_strategy`` parametrize matrix (AC-5).
* CSV evidence emission via the AZ-410-owned ``anchor_pair_detector``.
What this file does NOT own:
* The MP4 video-replay path → ``runner.helpers.frame_source_replay``
(still a stub; AZ-408 was about the synthetic-injection injectors,
not the video replayer); the scenario is marked
``@pytest.mark.deferred_ac(reason=...)`` until that helper lands.
* The FDR-archive iteration → ``runner.helpers.fdr_reader`` (owned by
AZ-441); same skip gate.
* The MAVLink ``GLOBAL_POSITION_INT`` GT replay → handled by the
``imu_replay`` helper which currently raises NotImplementedError
(owned by AZ-407 in spec, but the helper file was not touched by
the AZ-407 batch).
When all three upstream helpers land, this file's runtime path activates
automatically — the skip is keyed off the ``NotImplementedError`` from
the helper imports, not off a hard-coded marker.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from runner.helpers import anchor_pair_detector as apd
@pytest.fixture(scope="module")
def _harness_helpers_implemented() -> bool:
"""True iff every upstream helper FT-P-02 needs has a real impl.
Used to gate the full-replay scenarios. Helper-level NotImplementedError
is the signal — we don't hard-code a "deferred until task X" marker
because then a developer who lands the helper would have to also
remember to flip the marker. The auto-detect pattern is also what
other downstream scenarios will reuse.
"""
from runner.helpers import fdr_reader, frame_source_replay, imu_replay
from runner.helpers.frame_source_replay import FrameSourceReplayer
try:
# The cheapest sentinel for each helper:
# - FrameSourceReplayer.replay_video raises NotImplementedError
# - fdr_reader.iter_records raises NotImplementedError
# - ImuReplayer.replay raises NotImplementedError
# We check by inspecting __doc__ / source rather than calling, so
# the gate stays cheap.
replayer = FrameSourceReplayer(sink=_NullSink()) # type: ignore[arg-type]
try:
replayer.replay_video(Path("/tmp/non-existent.mp4"))
except NotImplementedError:
return False
try:
list(fdr_reader.iter_records(Path("/tmp/non-existent")))
except NotImplementedError:
return False
try:
imu_replay.ImuReplayer(emitter=_NullImuEmitter()).replay(Path("/tmp/non-existent.csv")) # type: ignore[arg-type]
except NotImplementedError:
return False
return True
except Exception:
return False
class _NullSink:
def write_frame(self, jpeg_bytes: bytes, timestamp_ms: int) -> None:
return None
class _NullImuEmitter:
def emit(self, sample: object) -> None:
return None
@pytest.mark.traces_to("AC-1.3,AC-1,AC-2,AC-3,AC-4,AC-5")
def test_ft_p_02_derkachi_drift(
fc_adapter: str,
vio_strategy: str,
evidence_dir, # type: ignore[no-untyped-def]
run_id: str,
nfr_recorder, # type: ignore[no-untyped-def]
_harness_helpers_implemented: bool,
) -> None:
"""Full FT-P-02 scenario (AC-1.3). See module docstring.
AC-1: anchor-pair detection from FDR stream — covered by
``anchor_pair_detector.detect_anchor_pairs``; unit-tested in
``test_anchor_pair_detector.py``.
AC-2: visual-only drift bound (≥95 % < 100 m) — covered by aggregate().
AC-3: IMU-fused drift bound (≥95 % < 50 m) — covered by aggregate().
AC-4: bin medians monotonic with age — covered by check_monotonic().
AC-5: parametrized across (fc_adapter, vio_strategy).
"""
if not _harness_helpers_implemented:
pytest.skip(
"FT-P-02 full replay requires runner.helpers.{frame_source_replay,"
"fdr_reader,imu_replay} — currently AZ-441 / AZ-407 leftovers. "
"Pure-logic ACs covered by e2e/_unit_tests/helpers/test_anchor_pair_detector.py."
)
# Once the helpers land, the body below activates. We keep it
# under the gate rather than commenting it out so the wiring stays
# under code review.
from runner.helpers import fdr_reader, frame_source_replay, imu_replay
from runner.helpers.frame_source_replay import FrameSourceReplayer
# 1. Spin up the SUT through the boundary-driving fixtures
# (mock_suite_sat URL + sitl_observer for the requested fc_adapter +
# a frame-sink + a MAVLink emitter for the requested vio_strategy).
# The actual wiring lives in helpers; the scenario only orchestrates.
sitl_host = "sitl-ardupilot" if fc_adapter == "ardupilot" else "sitl-inav"
# 2. Replay video + IMU.
sink = _resolve_frame_sink()
emitter = _resolve_fc_inbound_emitter(fc_adapter, sitl_host)
video_path = Path("/test-data/flight_derkachi/flight_derkachi.mp4")
imu_csv = Path("/test-data/flight_derkachi/data_imu.csv")
FrameSourceReplayer(sink).replay_video(video_path)
imu_replay.ImuReplayer(emitter).replay(imu_csv)
# 3. Crawl the FDR archive for the outbound estimate stream.
fdr_root = Path(evidence_dir).parent / f"run-{run_id}" / "fdr"
estimates: list[apd.FdrEstimate] = []
for rec in fdr_reader.iter_records(fdr_root):
if rec.record_type == "estimate":
payload = rec.payload
estimates.append(
apd.FdrEstimate(
monotonic_ms=int(rec.monotonic_ms),
lat_deg=float(payload["lat_deg"]), # type: ignore[arg-type]
lon_deg=float(payload["lon_deg"]), # type: ignore[arg-type]
source_label=str(payload["source_label"]), # type: ignore[arg-type]
imu_fused=bool(payload.get("imu_fused", False)),
cov_semi_major_m=float(payload.get("cov_semi_major_m", 0.0)), # type: ignore[arg-type]
last_satellite_anchor_age_ms=int(
payload.get("last_satellite_anchor_age_ms", 0) # type: ignore[arg-type]
),
)
)
# 4. Aggregate + AC checks.
report = apd.aggregate(estimates)
apd.write_csv_evidence(report, evidence_dir / f"ft-p-02-{fc_adapter}-{vio_strategy}.csv")
# 5. Record metrics for the NFR/csv reporter.
nfr_recorder.record_metric(
"ft_p_02.visual_only_pass_fraction", report.visual_only_pass_fraction, ac_id="AC-2"
)
nfr_recorder.record_metric(
"ft_p_02.imu_fused_pass_fraction", report.imu_fused_pass_fraction, ac_id="AC-3"
)
nfr_recorder.record_metric("ft_p_02.total_pairs", float(len(report.pairs)), ac_id="AC-1")
# 6. AC assertions.
if len(report.visual_only_pairs) > 0:
assert report.visual_only_pass_fraction >= 0.95, (
f"AC-2 (visual-only drift <100 m) failed at "
f"{report.visual_only_pass_fraction:.2%} over {len(report.visual_only_pairs)} pairs"
)
if len(report.imu_fused_pairs) > 0:
assert report.imu_fused_pass_fraction >= 0.95, (
f"AC-3 (IMU-fused drift <50 m) failed at "
f"{report.imu_fused_pass_fraction:.2%} over {len(report.imu_fused_pairs)} pairs"
)
if len(report.pairs) >= 20:
# AC-4 requires statistical power; small-N flights skip the
# monotonicity check per the spec's "N<20 flagged" note.
assert not report.monotonic_violations, (
"AC-4 (monotonic drift vs anchor age) failed: "
+ "; ".join(report.monotonic_violations)
)
else:
nfr_recorder.partial("AC-4", reason=f"N={len(report.pairs)} < 20 — statistical power flagged")
def _resolve_frame_sink(): # type: ignore[no-untyped-def]
"""Stub helper resolved when the underlying replayer lands."""
raise NotImplementedError(
"frame sink resolution is owned by AZ-441 / runner.helpers.frame_source_replay"
)
def _resolve_fc_inbound_emitter(fc_adapter: str, host: str): # type: ignore[no-untyped-def]
"""Stub helper resolved when the FC inbound emitter lands."""
raise NotImplementedError(
"FC inbound emitter resolution is owned by AZ-416/AZ-417 / runner.helpers.imu_replay"
)
@@ -0,0 +1,150 @@
"""FT-P-03 + FT-P-14 — Estimate output schema + WGS84 coordinate validation.
Two thin contract checks on the SUT's outbound message — shared fixture
(single-image push of ``AD000001.jpg``), shared parameterization across
``(fc_adapter, vio_strategy)``, but distinct assertion sets.
* ``test_schema_and_source_label`` — FT-P-03 (AC-1.4 + AC-4.3):
- AC-1: lat / lon / cov_semi_major_m / last_satellite_anchor_age_ms
present & well-typed on the outbound payload + side-channel.
- AC-2: source-label side-channel value is in
``{satellite_anchored, visual_propagated, dead_reckoned}``.
* ``test_wgs84_coordinate_range`` — FT-P-14 (AC-6.3):
- AC-3: lat ∈ [-90, 90], lon ∈ [-180, 180], decoded from the AP/iNav
1e-7 int32 wire format.
* AC-4: both methods run under each (fc_adapter, vio_strategy)
parametrize variant.
What this file owns: the pytest scenario + the wiring of the helper
functions in ``runner.helpers.estimate_schema``. The full runtime path
(MAVLink listener + STATUSTEXT/NAMED_VALUE_FLOAT decoder + single-image
push through the SITL bridge) is gated on the same upstream helpers as
FT-P-02 (frame_source_replay + sitl_observer); when the helpers are
NotImplemented the scenario skips with a clear reason. Pure-logic ACs
are validated by ``e2e/_unit_tests/helpers/test_estimate_schema.py``.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from runner.helpers import estimate_schema
@pytest.fixture(scope="module")
def _harness_helpers_implemented() -> bool:
"""Same gate as FT-P-02: are frame replay + SITL observer + sidechannel
decoders all real? If not, skip the docker-bound runtime path.
"""
from runner.helpers import frame_source_replay, mavproxy_tlog_reader, sitl_observer
from runner.helpers.frame_source_replay import FrameSourceReplayer
try:
replayer = FrameSourceReplayer(sink=_NullSink()) # type: ignore[arg-type]
try:
replayer.replay_image_directory(Path("/tmp/non-existent"))
except NotImplementedError:
return False
try:
sitl_observer.get_observer("ardupilot", "test-host")
except NotImplementedError:
return False
try:
list(mavproxy_tlog_reader.iter_messages(Path("/tmp/non-existent.tlog")))
except NotImplementedError:
return False
return True
except Exception:
return False
class _NullSink:
def write_frame(self, jpeg_bytes: bytes, timestamp_ms: int) -> None:
return None
@pytest.mark.traces_to("AC-1.4,AC-4.3")
def test_schema_and_source_label(
fc_adapter: str,
vio_strategy: str,
evidence_dir, # type: ignore[no-untyped-def]
nfr_recorder, # type: ignore[no-untyped-def]
_harness_helpers_implemented: bool,
) -> None:
"""FT-P-03: schema completeness (AC-1) + source-label set containment (AC-2)."""
if not _harness_helpers_implemented:
pytest.skip(
"FT-P-03 single-image push requires runner.helpers.{frame_source_replay,"
"sitl_observer,mavproxy_tlog_reader} — currently pending AZ-407 / "
"AZ-416/417 leftovers. Pure-logic ACs covered by "
"e2e/_unit_tests/helpers/test_estimate_schema.py."
)
record, source_label = _push_single_image_and_observe(fc_adapter, vio_strategy)
schema_result = estimate_schema.validate_estimate_schema(record)
nfr_recorder.record_metric(
"ft_p_03.schema_ok", float(schema_result.ok), ac_id="AC-1.4"
)
assert schema_result.ok, (
f"AC-1 (schema completeness) failed: "
f"missing={schema_result.missing_fields}, "
f"wrong-typed={schema_result.wrong_typed_fields}"
)
label_result = estimate_schema.validate_source_label(source_label)
nfr_recorder.record_metric(
"ft_p_03.source_label_ok", float(label_result.ok), ac_id="AC-4.3"
)
assert label_result.ok, f"AC-2 (source-label set containment) failed: {label_result.reason}"
@pytest.mark.traces_to("AC-6.3")
def test_wgs84_coordinate_range(
fc_adapter: str,
vio_strategy: str,
evidence_dir, # type: ignore[no-untyped-def]
nfr_recorder, # type: ignore[no-untyped-def]
_harness_helpers_implemented: bool,
) -> None:
"""FT-P-14: decoded lat/lon inside WGS84 bounds (AC-3)."""
if not _harness_helpers_implemented:
pytest.skip(
"FT-P-14 single-image push requires the same upstream helpers as FT-P-03. "
"Pure-logic AC covered by e2e/_unit_tests/helpers/test_estimate_schema.py."
)
record, _label = _push_single_image_and_observe(fc_adapter, vio_strategy)
# Decode from wire (AP int32 1e-7); a record that carries already-decoded
# degrees works too — the helper accepts either.
if "lat_e7" in record and "lon_e7" in record:
lat_deg, lon_deg = estimate_schema.decode_lat_lon_int32(
int(record["lat_e7"]), int(record["lon_e7"])
)
else:
lat_deg = float(record["lat"])
lon_deg = float(record["lon"])
result = estimate_schema.validate_wgs84_range(lat_deg, lon_deg)
nfr_recorder.record_metric(
"ft_p_14.wgs84_ok", float(result.ok), ac_id="AC-6.3"
)
assert result.ok, f"AC-3 (WGS84 range) failed: {result.reason}"
def _push_single_image_and_observe(fc_adapter: str, vio_strategy: str): # type: ignore[no-untyped-def]
"""Push AD000001.jpg through the SUT and return (outbound_record, source_label).
Stub until runner.helpers.{frame_source_replay,sitl_observer,mavproxy_tlog_reader}
land; the scenario test's skip gate (``_harness_helpers_implemented``)
keeps this from executing prematurely.
"""
raise NotImplementedError(
"single-image push helper is owned by AZ-407 / AZ-416 / AZ-417 "
"(runner.helpers.frame_source_replay + sitl_observer + mavproxy_tlog_reader)"
)