Harden RF handoff and stream OTA decode

This commit is contained in:
LittleSam129
2026-09-22 17:43:58 +03:00
parent 168e03d9e7
commit a94f2db4ee
17 changed files with 1548 additions and 497 deletions

View 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

View File

@@ -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),
)

View 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

View File

@@ -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: