Harden RF handoff and stream OTA decode
This commit is contained in:
109
tests/test_rf001a_streaming.py
Normal file
109
tests/test_rf001a_streaming.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Проверки оконного cs8-декодера RF-001A без SDR и RF TX."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from protocol.video003_modem import build_frame
|
||||
from protocol.video005_iq_waveform import shape_packet
|
||||
from tools.qpsk_rf_common import SEED, payload_for_sequence
|
||||
from tools.qpsk_rf_rx import decode_file
|
||||
from tools.rf001a_streaming import (
|
||||
MemoryPeakSampler,
|
||||
SampleWindow,
|
||||
count_cs8_clipping,
|
||||
cs8_sample_count,
|
||||
decode_cs8_streaming,
|
||||
read_cs8_window,
|
||||
receive_cs8_windows,
|
||||
required_overlap_samples,
|
||||
rf001_packet_waveform_samples,
|
||||
timestamp_sample_window,
|
||||
)
|
||||
|
||||
|
||||
def _write_cs8(path: Path, iq: np.ndarray) -> None:
|
||||
peak = float(np.max(np.abs(iq))) if iq.size else 1.0
|
||||
normalized = iq / max(peak, 1e-12) * 0.75
|
||||
interleaved = np.empty(iq.size * 2, dtype=np.int8)
|
||||
interleaved[0::2] = np.rint(normalized.real * 127).astype(np.int8)
|
||||
interleaved[1::2] = np.rint(normalized.imag * 127).astype(np.int8)
|
||||
interleaved.tofile(path)
|
||||
|
||||
|
||||
def test_timestamp_to_sample_window_uses_safe_margins() -> None:
|
||||
capture = datetime(2026, 9, 22, 12, 0, tzinfo=timezone.utc)
|
||||
window = timestamp_sample_window(
|
||||
capture_started_at=capture.isoformat(),
|
||||
tx_started_at=(capture + timedelta(seconds=10)).isoformat(),
|
||||
tx_finished_at=(capture + timedelta(seconds=11.1042285)).isoformat(),
|
||||
total_samples=160_000_000,
|
||||
margin_seconds=2.0,
|
||||
)
|
||||
assert window.start == 64_000_000
|
||||
assert window.stop == 104_833_824
|
||||
|
||||
|
||||
def test_chunked_cs8_reading_from_large_sparse_capture(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "large_sparse.cs8"
|
||||
with capture.open("wb") as stream:
|
||||
stream.seek(1_920_000_000 - 1)
|
||||
stream.write(b"\0")
|
||||
raw = np.memmap(capture, dtype=np.int8, mode="r")
|
||||
window = read_cs8_window(raw, 900_000_000, 900_001_000)
|
||||
assert capture.stat().st_size == 1_920_000_000
|
||||
assert cs8_sample_count(capture) == 960_000_000
|
||||
assert window.dtype == np.complex64
|
||||
assert window.size == 1_000
|
||||
assert window.nbytes == 8_000
|
||||
with MemoryPeakSampler() as memory:
|
||||
assert count_cs8_clipping(capture) == 0
|
||||
assert memory.additional_peak_bytes < 512_000_000
|
||||
|
||||
|
||||
def test_packet_across_chunk_boundary_is_decoded_once(tmp_path: Path) -> None:
|
||||
payload = payload_for_sequence(SEED, 7)
|
||||
frame = build_frame(payload, "QPSK", "2/3", sequence_number=7)
|
||||
waveform = shape_packet(frame)
|
||||
chunk_samples = 120_000
|
||||
packet_start = chunk_samples - waveform.size // 2
|
||||
iq = np.zeros(chunk_samples * 2, dtype=np.complex64)
|
||||
iq[packet_start:packet_start + waveform.size] = waveform
|
||||
capture = tmp_path / "boundary.cs8"
|
||||
_write_cs8(capture, iq)
|
||||
|
||||
result = receive_cs8_windows(
|
||||
capture,
|
||||
(SampleWindow(0, iq.size),),
|
||||
decoder_backend="legacy",
|
||||
chunk_samples=chunk_samples,
|
||||
)
|
||||
|
||||
assert required_overlap_samples() == rf001_packet_waveform_samples() == 48_865
|
||||
assert [packet.packet for packet in result.reception.packets] == [frame.packet]
|
||||
assert result.maximum_loaded_samples < iq.size
|
||||
|
||||
|
||||
def test_capture_without_rf001a_signal_has_zero_packets(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "noise_only.cs8"
|
||||
np.zeros(400_000, dtype=np.int8).tofile(capture)
|
||||
result = decode_cs8_streaming(capture, decoder_backend="legacy")
|
||||
assert result.window_source == "STREAMING_POWER_LOCATOR"
|
||||
assert result.windows == ()
|
||||
assert result.reception.packets == ()
|
||||
assert result.maximum_loaded_samples == 0
|
||||
|
||||
truth = Path(__file__).resolve().parents[1] / "configs/rf/rf001a_truth_metadata.json"
|
||||
report = decode_file(
|
||||
capture,
|
||||
iq_format="cs8",
|
||||
truth_metadata_path=truth,
|
||||
decoder_backend="legacy",
|
||||
)
|
||||
assert report["exact_packets"] == 0
|
||||
assert report["crc_valid_packets"] == 0
|
||||
assert report["streaming_decoder"] is True
|
||||
assert report["peak_additional_ram_bytes"] < 1_000_000_000
|
||||
@@ -31,6 +31,7 @@ from tools.rf_handoff_smoke import run_smoke
|
||||
from tools.rx_agent_watcher import (
|
||||
WatcherState as RxWatcherState,
|
||||
publish_result,
|
||||
require_live_ready_window,
|
||||
save_state as save_rx_state,
|
||||
)
|
||||
from tools.tx_agent_watcher import (
|
||||
@@ -59,7 +60,7 @@ def ready_result(**changes: object) -> RxHandoffResult:
|
||||
capture_pid=1234,
|
||||
published_at=NOW.isoformat(),
|
||||
capture_started_at=NOW.isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=120)).isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=60)).isoformat(),
|
||||
capture_path=r"C:\captures\rf001a_ota_capture.cs8",
|
||||
common_clock_verified=True,
|
||||
source_head=HEAD,
|
||||
@@ -97,10 +98,10 @@ def complete_result(
|
||||
rx_ready_for_tx=False,
|
||||
process_alive=False,
|
||||
file_growing=False,
|
||||
published_at=(NOW + timedelta(seconds=121)).isoformat(),
|
||||
published_at=(NOW + timedelta(seconds=61)).isoformat(),
|
||||
exit_code=0,
|
||||
file_size=1_920_000_000,
|
||||
duration_seconds=120.01,
|
||||
file_size=960_000_000,
|
||||
duration_seconds=60.01,
|
||||
overruns=0,
|
||||
usb_errors=0,
|
||||
file_errors=0,
|
||||
@@ -278,7 +279,7 @@ def test_complete_result_accepts_explicit_unreported_counters() -> None:
|
||||
ready_result(
|
||||
published_at=(NOW - timedelta(seconds=31)).isoformat(),
|
||||
capture_started_at=(NOW - timedelta(seconds=31)).isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=89)).isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=29)).isoformat(),
|
||||
),
|
||||
NOW,
|
||||
"STALE_READY",
|
||||
@@ -290,7 +291,7 @@ def test_complete_result_accepts_explicit_unreported_counters() -> None:
|
||||
),
|
||||
(
|
||||
ready_result(
|
||||
capture_started_at=(NOW - timedelta(seconds=111)).isoformat(),
|
||||
capture_started_at=(NOW - timedelta(seconds=51)).isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=9)).isoformat(),
|
||||
),
|
||||
NOW,
|
||||
@@ -329,7 +330,7 @@ def test_ready_evaluation_passes_with_more_than_ten_seconds() -> None:
|
||||
)
|
||||
assert evaluation.ready
|
||||
assert evaluation.reason == "READY"
|
||||
assert evaluation.seconds_remaining == pytest.approx(118.0)
|
||||
assert evaluation.seconds_remaining == pytest.approx(58.0)
|
||||
|
||||
|
||||
def test_red_approval_and_duplicate_suppression(tmp_path: Path) -> None:
|
||||
@@ -378,7 +379,7 @@ def test_window_and_commit_are_rechecked_after_exact_approval(tmp_path: Path) ->
|
||||
digest = result_digest(result)
|
||||
source = FakeSource(result, digest)
|
||||
executor = FakeExecutor(tmp_path / "fake_tx.txt")
|
||||
times = iter((NOW + timedelta(seconds=2), NOW + timedelta(seconds=111)))
|
||||
times = iter((NOW + timedelta(seconds=2), NOW + timedelta(seconds=51)))
|
||||
|
||||
status, _ = handle_result(
|
||||
result,
|
||||
@@ -600,7 +601,7 @@ def test_expired_historical_ready_is_observed_without_authorization(
|
||||
started = NOW - timedelta(seconds=200)
|
||||
expired = ready_result(
|
||||
capture_started_at=started.isoformat(),
|
||||
expected_capture_end=(started + timedelta(seconds=120)).isoformat(),
|
||||
expected_capture_end=(started + timedelta(seconds=60)).isoformat(),
|
||||
published_at=(NOW - timedelta(seconds=100)).isoformat(),
|
||||
)
|
||||
expired_outcome = channel.publish(expired)
|
||||
@@ -796,9 +797,16 @@ Fake task used only by the local coordination test.
|
||||
)
|
||||
source_head = _git(["rev-parse", "HEAD"], work)
|
||||
|
||||
fresh_start = datetime.now(timezone.utc)
|
||||
fresh_result = ready_result(
|
||||
source_head=source_head,
|
||||
published_at=fresh_start.isoformat(),
|
||||
capture_started_at=fresh_start.isoformat(),
|
||||
expected_capture_end=(fresh_start + timedelta(seconds=60)).isoformat(),
|
||||
)
|
||||
result_path = tmp_path / "ready.json"
|
||||
result_path.write_text(
|
||||
canonical_result_json(ready_result(source_head=source_head)),
|
||||
canonical_result_json(fresh_result),
|
||||
encoding="utf-8",
|
||||
)
|
||||
state_path = tmp_path / "rx_state.json"
|
||||
@@ -824,4 +832,12 @@ Fake task used only by the local coordination test.
|
||||
assert "RF TX = 0" in output
|
||||
|
||||
published, _digest, _head = GitResultChannel(work).read()
|
||||
assert published == ready_result(source_head=source_head)
|
||||
assert published == fresh_result
|
||||
|
||||
|
||||
def test_rx_publisher_refuses_ready_after_capture_window() -> None:
|
||||
with pytest.raises(ValueError, match="window has already ended"):
|
||||
require_live_ready_window(
|
||||
ready_result(),
|
||||
now=NOW + timedelta(seconds=60),
|
||||
)
|
||||
|
||||
145
tests/test_rf_handoff_reliability.py
Normal file
145
tests/test_rf_handoff_reliability.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Проверки повторной публикации READY и Git preflight без оборудования."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.rf_handoff_channel import GitResultChannel, ResultChannelError
|
||||
|
||||
|
||||
OLD_HEAD = "1" * 40
|
||||
READY_HEAD = "2" * 40
|
||||
|
||||
|
||||
class SimulatedPushChannel(GitResultChannel):
|
||||
def __init__(
|
||||
self,
|
||||
push_results: list[subprocess.CompletedProcess[str]],
|
||||
observed_heads: list[str],
|
||||
) -> None:
|
||||
self.remote = "origin"
|
||||
self.branch = "rx-node"
|
||||
self.push_results = iter(push_results)
|
||||
self.observed_heads = iter(observed_heads)
|
||||
self.push_arguments: list[tuple[str, ...]] = []
|
||||
self.fetch_calls = 0
|
||||
|
||||
def _run(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
del cwd, check
|
||||
assert arguments[0] == "push"
|
||||
self.push_arguments.append(tuple(arguments))
|
||||
return next(self.push_results)
|
||||
|
||||
def remote_head(self) -> str:
|
||||
return next(self.observed_heads)
|
||||
|
||||
def fetch(self) -> str:
|
||||
self.fetch_calls += 1
|
||||
return OLD_HEAD
|
||||
|
||||
|
||||
def _push_result(returncode: int, stderr: str = "") -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.CompletedProcess(["git", "push"], returncode, "", stderr)
|
||||
|
||||
|
||||
def _git(arguments: list[str], cwd: Path) -> str:
|
||||
return subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def test_transient_failure_retries_the_same_ready_commit_once() -> None:
|
||||
channel = SimulatedPushChannel(
|
||||
[_push_result(1, "Authentication failed"), _push_result(0)],
|
||||
[OLD_HEAD, READY_HEAD],
|
||||
)
|
||||
assert channel._push_exact_commit(Path("checkout"), READY_HEAD, OLD_HEAD) == READY_HEAD
|
||||
assert len(channel.push_arguments) == 2
|
||||
assert channel.push_arguments[0] == channel.push_arguments[1]
|
||||
assert channel.push_arguments[0][-1] == f"{READY_HEAD}:refs/heads/rx-node"
|
||||
assert channel.fetch_calls == 1
|
||||
|
||||
|
||||
def test_ambiguous_push_is_success_when_remote_already_has_ready() -> None:
|
||||
channel = SimulatedPushChannel(
|
||||
[_push_result(1, "TLS connection was non-properly terminated")],
|
||||
[READY_HEAD],
|
||||
)
|
||||
assert channel._push_exact_commit(Path("checkout"), READY_HEAD, OLD_HEAD) == READY_HEAD
|
||||
assert len(channel.push_arguments) == 1
|
||||
assert channel.fetch_calls == 0
|
||||
|
||||
|
||||
def test_second_transient_failure_does_not_create_another_ready() -> None:
|
||||
channel = SimulatedPushChannel(
|
||||
[
|
||||
_push_result(1, "Authentication failed"),
|
||||
_push_result(1, "Authentication failed"),
|
||||
],
|
||||
[OLD_HEAD, OLD_HEAD],
|
||||
)
|
||||
with pytest.raises(ResultChannelError, match="after one retry"):
|
||||
channel._push_exact_commit(Path("checkout"), READY_HEAD, OLD_HEAD)
|
||||
assert len(channel.push_arguments) == 2
|
||||
assert channel.push_arguments[0] == channel.push_arguments[1]
|
||||
|
||||
|
||||
def test_failed_write_preflight_stops_before_any_publication() -> None:
|
||||
class FailingPreflight(GitResultChannel):
|
||||
def __init__(self) -> None:
|
||||
self.remote = "origin"
|
||||
self.branch = "rx-node"
|
||||
|
||||
def fetch(self) -> str:
|
||||
return OLD_HEAD
|
||||
|
||||
def remote_head(self) -> str:
|
||||
raise AssertionError("remote confirmation must not follow failed dry-run")
|
||||
|
||||
def _run(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
del cwd, check
|
||||
assert arguments[:3] == ["push", "--dry-run", "--porcelain"]
|
||||
return _push_result(1, "Authentication failed")
|
||||
|
||||
with pytest.raises(ResultChannelError, match="push preflight failed"):
|
||||
FailingPreflight().verify_write_access()
|
||||
|
||||
|
||||
def test_write_preflight_checks_access_without_changing_remote(tmp_path: Path) -> None:
|
||||
remote = tmp_path / "remote.git"
|
||||
work = tmp_path / "work"
|
||||
_git(["init", "--bare", str(remote)], tmp_path)
|
||||
work.mkdir()
|
||||
_git(["init"], work)
|
||||
_git(["config", "user.name", "Test"], work)
|
||||
_git(["config", "user.email", "test@example.invalid"], work)
|
||||
(work / "seed.txt").write_text("seed\n", encoding="utf-8")
|
||||
_git(["add", "seed.txt"], work)
|
||||
_git(["commit", "-m", "Seed result branch"], work)
|
||||
_git(["remote", "add", "origin", str(remote)], work)
|
||||
_git(["push", "origin", "HEAD:refs/heads/rx-node"], work)
|
||||
channel = GitResultChannel(work)
|
||||
before = channel.remote_head()
|
||||
assert channel.verify_write_access() == before
|
||||
assert channel.remote_head() == before
|
||||
@@ -26,10 +26,15 @@ from tools.tx_agent_watcher import TxWatcherState, process_history_cycle
|
||||
from tools.tx_handoff_channel import (
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
CHANNEL_READY_OBSERVED,
|
||||
TX_ACK_SCHEMA_VERSION,
|
||||
TX_AUTHORIZED,
|
||||
TX_COMPLETE,
|
||||
TX_STARTED,
|
||||
TX_RESULT_PATHS,
|
||||
GitAcknowledgementChannel,
|
||||
TxHandoffAcknowledgement,
|
||||
acknowledgement_from_mapping,
|
||||
build_tx_execution_event,
|
||||
channel_test_acknowledgements,
|
||||
)
|
||||
|
||||
@@ -91,7 +96,7 @@ def channel_ready(**changes: object) -> RxHandoffResult:
|
||||
capture_pid=1234,
|
||||
published_at=NOW.isoformat(),
|
||||
capture_started_at=NOW.isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=120)).isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=60)).isoformat(),
|
||||
capture_path="MODEL_FREE_FAKE_CAPTURE",
|
||||
common_clock_verified=False,
|
||||
source_head=SOURCE_HEAD,
|
||||
@@ -175,6 +180,95 @@ def test_acknowledgement_schema_binds_digest_and_source_commit() -> None:
|
||||
acknowledgement_from_mapping({**raw, "rf_tx_authorized": True})
|
||||
|
||||
|
||||
def test_tx_events_preserve_authorization_binding_and_timestamps() -> None:
|
||||
authorization = TxHandoffAcknowledgement(
|
||||
schema_version=TX_ACK_SCHEMA_VERSION,
|
||||
task_id="RF001A_OTA_CAPTURE_004",
|
||||
state=TX_AUTHORIZED,
|
||||
source_rx_commit="1" * 40,
|
||||
source_rx_digest="2" * 64,
|
||||
source_head=SOURCE_HEAD,
|
||||
observed_at=NOW.isoformat(),
|
||||
main_watcher_pid=4321,
|
||||
rf_tx_authorized=True,
|
||||
rf_tx_started=False,
|
||||
rf_tx=0,
|
||||
tx_task_id="RF001A_OTA_TX_004",
|
||||
)
|
||||
started_at = (NOW + timedelta(seconds=1)).isoformat()
|
||||
started = build_tx_execution_event(
|
||||
authorization,
|
||||
state=TX_STARTED,
|
||||
observed_at=started_at,
|
||||
tx_started_at=started_at,
|
||||
)
|
||||
finished_at = (NOW + timedelta(seconds=2.2)).isoformat()
|
||||
complete = build_tx_execution_event(
|
||||
authorization,
|
||||
state=TX_COMPLETE,
|
||||
observed_at=finished_at,
|
||||
tx_started_at=started_at,
|
||||
tx_finished_at=finished_at,
|
||||
process_exit_code=0,
|
||||
underruns=0,
|
||||
errors=0,
|
||||
)
|
||||
assert started.source_rx_commit == authorization.source_rx_commit
|
||||
assert started.source_rx_digest == authorization.source_rx_digest
|
||||
assert started.rf_tx_started and started.rf_tx == 1
|
||||
assert complete.tx_finished_at == finished_at
|
||||
assert complete.tx_process_exit_code == 0
|
||||
|
||||
|
||||
def test_execution_event_publication_requires_authorization_and_start(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
authorization = TxHandoffAcknowledgement(
|
||||
schema_version=TX_ACK_SCHEMA_VERSION,
|
||||
task_id="RF001A_OTA_CAPTURE_004",
|
||||
state=TX_AUTHORIZED,
|
||||
source_rx_commit="1" * 40,
|
||||
source_rx_digest="2" * 64,
|
||||
source_head=SOURCE_HEAD,
|
||||
observed_at=NOW.isoformat(),
|
||||
main_watcher_pid=4321,
|
||||
rf_tx_authorized=True,
|
||||
rf_tx_started=False,
|
||||
rf_tx=0,
|
||||
tx_task_id="RF001A_OTA_TX_004",
|
||||
)
|
||||
started_at = (NOW + timedelta(seconds=1)).isoformat()
|
||||
finished_at = (NOW + timedelta(seconds=2.2)).isoformat()
|
||||
started = build_tx_execution_event(
|
||||
authorization,
|
||||
state=TX_STARTED,
|
||||
observed_at=started_at,
|
||||
tx_started_at=started_at,
|
||||
)
|
||||
complete = build_tx_execution_event(
|
||||
authorization,
|
||||
state=TX_COMPLETE,
|
||||
observed_at=finished_at,
|
||||
tx_started_at=started_at,
|
||||
tx_finished_at=finished_at,
|
||||
process_exit_code=0,
|
||||
underruns=0,
|
||||
errors=0,
|
||||
)
|
||||
|
||||
with pytest.raises(ResultChannelError, match="TX_AUTHORIZED"):
|
||||
channel.publish_execution_event(started)
|
||||
channel.publish(authorization)
|
||||
with pytest.raises(ResultChannelError, match="TX_STARTED"):
|
||||
channel.publish_execution_event(complete)
|
||||
started_outcome = channel.publish_execution_event(started)
|
||||
complete_outcome = channel.publish_execution_event(complete)
|
||||
assert started_outcome.status == "PUBLISHED"
|
||||
assert complete_outcome.status == "PUBLISHED"
|
||||
|
||||
|
||||
def test_main_publish_uses_exact_allowlist_and_suppresses_duplicate(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user