ka9q-python 3.18.0__tar.gz → 3.20.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/CHANGELOG.md +49 -0
- {ka9q_python-3.18.0/ka9q_python.egg-info → ka9q_python-3.20.0}/PKG-INFO +1 -1
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/__init__.py +2 -1
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/discovery.py +8 -33
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/slot_clock.py +54 -32
- {ka9q_python-3.18.0 → ka9q_python-3.20.0/ka9q_python.egg-info}/PKG-INFO +1 -1
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/pyproject.toml +1 -1
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_slot_clock.py +45 -1
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/LICENSE +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/MANIFEST.in +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/README.md +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/API_REFERENCE.md +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/ARCHITECTURE.md +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/CLI_GUIDE.md +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/MULTI_STREAM.md +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/RECIPES.md +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/REQUIREMENTS.md +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/SECURITY.md +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/docs/TUI_GUIDE.md +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/discover_example.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/spectrum_example.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/stream_example.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/test_improvements.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/tune.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/examples/tune_example.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/_multicast.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/addressing.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/cli.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/compat.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/control.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/managed_stream.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/monitor.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/multi_stream.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/rtp_recorder.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/spectrum_stream.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/status.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/status_listener.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/stream.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/tui.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/types.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q/utils.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q_python.egg-info/SOURCES.txt +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q_python.egg-info/entry_points.txt +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/ka9q_radio_compat +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/scripts/check_upstream_drift.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/scripts/sync_types.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/setup.cfg +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/__init__.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/conftest.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_addressing.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_client_id_destination.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_decode_description.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_filter_edges.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_integration.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_lifetime.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_monitor.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_multicast_helpers.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_multistream_gap_storm.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_multistream_prune.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_parse_rtp_samples_iq.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_protocol_compat.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_rtp_recorder.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_security_features.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_spectrum.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_status_decoder.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_status_listener.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_tune.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_tune_live.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_tune_method.py +0 -0
- {ka9q_python-3.18.0 → ka9q_python-3.20.0}/tests/test_upstream_drift.py +0 -0
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.20.0] - 2026-06-29
|
|
4
|
+
|
|
5
|
+
### Added — `SlotClock` ambiguous-unwrap guard (fail loud, never silent)
|
|
6
|
+
|
|
7
|
+
- **`SlotClock.offset_of_rtp` now raises `SlotClockDesyncError`** when a wrapped
|
|
8
|
+
RTP timestamp is more than `2**30` samples (half the signed-32 window, ~24.8 h
|
|
9
|
+
@ 12 kHz / ~4.7 h @ 64 kHz IQ) from the unwrap high-water in either direction.
|
|
10
|
+
The high-water unwrap (3.18.0) keeps continuous streams correct past the
|
|
11
|
+
`2**31` window, but it only disambiguates while each step stays within
|
|
12
|
+
`±2**31` of the high-water. A *single* forward jump `>= 2**31` samples (a
|
|
13
|
+
`>49.7 h` stall-then-resume, a missed re-anchor, or a discontinuous timestamp)
|
|
14
|
+
aliased into a backward step — `advance()` then harvested **0 slots forever on
|
|
15
|
+
live RF**, the exact silent failure `SlotClock` exists to prevent.
|
|
16
|
+
- **`SlotClock.advance` catches the desync, logs an ERROR, drops the anchor, and
|
|
17
|
+
returns `[]`**, so the caller's normal `if not clk.anchored: clk.anchor(...)`
|
|
18
|
+
path re-establishes the grid on a fresh RTP reference instead of stalling. The
|
|
19
|
+
`2**30` margin never false-fires: continuous advancing steps a few hundred
|
|
20
|
+
samples per call.
|
|
21
|
+
- **`SlotClockDesyncError` is exported** from the package.
|
|
22
|
+
|
|
23
|
+
## [3.19.0] - 2026-06-28
|
|
24
|
+
|
|
25
|
+
### Removed / Changed — simplify how we look at radiod (defer to RTP)
|
|
26
|
+
|
|
27
|
+
- **Removed `SlotClock.divergence_sec`** (added in 3.18.0). It recomputed a
|
|
28
|
+
boundary's UTC from the live, StatusListener-refreshed `channel_info` and
|
|
29
|
+
compared it to the grid — inviting consumers to re-anchor when the two
|
|
30
|
+
disagree. In practice a busy radiod's status pair (`gps_time`/`rtp_timesnap`)
|
|
31
|
+
jitters ~0.45 s and occasionally tears between ~450 ms snapshots, so the check
|
|
32
|
+
reported large spurious divergences and drove downstream recorders into a
|
|
33
|
+
re-anchor storm. The `SlotClock` grid is RTP-driven and drift-immune by
|
|
34
|
+
design; nothing should second-guess it against the noisy status feed.
|
|
35
|
+
|
|
36
|
+
- **Removed the per-update RTP↔GPS offset-step detection in
|
|
37
|
+
`ChannelInfo.update_anchor`** (`discovery.py`). It bumped `anchor_epoch` and
|
|
38
|
+
logged a loud `radiod RTP↔GPS offset STEPPED … (consumers will re-anchor)`
|
|
39
|
+
WARNING whenever consecutive status snapshots implied a stepped offset — but
|
|
40
|
+
that "step" was almost always status-feed jitter/tearing, not a real radiod
|
|
41
|
+
slide (radiod's RTP data path stayed consistent; decodes never stopped).
|
|
42
|
+
`update_anchor` now simply adopts the latest anchor pair. The `anchor_epoch`,
|
|
43
|
+
`last_offset_step_sec`, and `anchor_step_threshold_sec` fields remain
|
|
44
|
+
(vestigial, at their defaults) for backward compatibility.
|
|
45
|
+
|
|
46
|
+
The principle: **anchor once off radiod's RTP timestamp and defer to it.** A
|
|
47
|
+
genuine radiod restart is still handled downstream via the stream-restored path
|
|
48
|
+
(MultiStream's drop/restore callback), not by polling the status feed for
|
|
49
|
+
divergence. The sigmond recorders (psk/meteor/wspr) drop their matching
|
|
50
|
+
re-anchor machinery in lockstep.
|
|
51
|
+
|
|
3
52
|
## [3.18.0] - 2026-06-28
|
|
4
53
|
|
|
5
54
|
### Added
|
|
@@ -115,7 +115,7 @@ from .managed_stream import (
|
|
|
115
115
|
StreamState,
|
|
116
116
|
)
|
|
117
117
|
from .multi_stream import MultiStream
|
|
118
|
-
from .slot_clock import SlotClock, Slot, rtp_diff
|
|
118
|
+
from .slot_clock import SlotClock, Slot, SlotClockDesyncError, rtp_diff
|
|
119
119
|
from .spectrum_stream import SpectrumStream
|
|
120
120
|
from .status_listener import StatusListener, StatusListenerStats
|
|
121
121
|
|
|
@@ -165,6 +165,7 @@ __all__ = [
|
|
|
165
165
|
# Slot timing (epoch-aligned, RTP-referenced)
|
|
166
166
|
'SlotClock',
|
|
167
167
|
'Slot',
|
|
168
|
+
'SlotClockDesyncError',
|
|
168
169
|
'rtp_diff',
|
|
169
170
|
|
|
170
171
|
# Stream API (sample-oriented)
|
|
@@ -132,9 +132,6 @@ class ChannelInfo:
|
|
|
132
132
|
``_anchor_pair``. Tuple is written first so the atomic snapshot
|
|
133
133
|
is the leading edge of any state change.
|
|
134
134
|
"""
|
|
135
|
-
# Snapshot the previous pair BEFORE overwriting, for step detection.
|
|
136
|
-
prev = getattr(self, '_anchor_pair', None)
|
|
137
|
-
|
|
138
135
|
# Atomic pair first — this is the source of truth for
|
|
139
136
|
# transactional readers.
|
|
140
137
|
self._anchor_pair = (gps_time, rtp_timesnap)
|
|
@@ -144,36 +141,14 @@ class ChannelInfo:
|
|
|
144
141
|
# ``.gps_time`` directly sees the current value.
|
|
145
142
|
self.gps_time = gps_time
|
|
146
143
|
self.rtp_timesnap = rtp_timesnap
|
|
147
|
-
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
# epoch is bumped AFTER ``_anchor_pair`` is published so any reader
|
|
156
|
-
# that observes the new epoch also reads the new (or newer) anchor.
|
|
157
|
-
if (
|
|
158
|
-
prev is not None
|
|
159
|
-
and prev[0] is not None
|
|
160
|
-
and prev[1] is not None
|
|
161
|
-
and self.sample_rate
|
|
162
|
-
):
|
|
163
|
-
d_rtp = int((rtp_timesnap - prev[1]) & 0xFFFFFFFF)
|
|
164
|
-
if d_rtp > 0x7FFFFFFF:
|
|
165
|
-
d_rtp -= 0x100000000
|
|
166
|
-
move_sec = (
|
|
167
|
-
(gps_time - prev[0]) - d_rtp * 1_000_000_000 / self.sample_rate
|
|
168
|
-
) / 1e9
|
|
169
|
-
if abs(move_sec) > self.anchor_step_threshold_sec:
|
|
170
|
-
self.last_offset_step_sec = move_sec
|
|
171
|
-
self.anchor_epoch += 1
|
|
172
|
-
logger.warning(
|
|
173
|
-
"ChannelInfo ssrc=%s: radiod RTP↔GPS offset STEPPED "
|
|
174
|
-
"%+.3fs — anchor_epoch=%d (consumers will re-anchor)",
|
|
175
|
-
self.ssrc, move_sec, self.anchor_epoch,
|
|
176
|
-
)
|
|
144
|
+
# NOTE (2026-06-28): the per-update RTP↔GPS offset-step detection was
|
|
145
|
+
# removed. It compared consecutive ~450 ms status snapshots and bumped
|
|
146
|
+
# ``anchor_epoch`` (with a loud WARNING) on any jump — but a busy
|
|
147
|
+
# radiod's status pair jitters/tears at that cadence, so it fired
|
|
148
|
+
# constantly and drove downstream recorders into a re-anchor storm.
|
|
149
|
+
# We now simply adopt the latest pair and defer to radiod's RTP; the
|
|
150
|
+
# ``anchor_epoch`` / ``last_offset_step_sec`` fields remain (vestigial,
|
|
151
|
+
# at their defaults) for backward compatibility.
|
|
177
152
|
|
|
178
153
|
|
|
179
154
|
def _create_status_listener_socket(multicast_addr: str, interface: Optional[str] = None) -> socket.socket:
|
|
@@ -68,6 +68,28 @@ def rtp_diff(a: int, b: int) -> int:
|
|
|
68
68
|
return d
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
# Largest single unwrap step (in samples) we trust from the high-water. The
|
|
72
|
+
# signed-32 unwrap is only unambiguous within +/-2**31 of the high-water; we
|
|
73
|
+
# refuse anything past the half-window (2**30 ~= 24.8 h @ 12 kHz, ~4.7 h @
|
|
74
|
+
# 64 kHz IQ) so the unwrap fails LOUD with a wide margin instead of silently
|
|
75
|
+
# aliasing a huge forward jump into a backward step (-> advance() harvests 0
|
|
76
|
+
# slots on live RF: the exact silent failure SlotClock exists to prevent).
|
|
77
|
+
# Continuous advancing steps by a few hundred samples per call, so this never
|
|
78
|
+
# false-fires in normal operation — only a >24.8 h stall, a missed re-anchor,
|
|
79
|
+
# or a bogus/discontinuous timestamp reaches it.
|
|
80
|
+
_SAFE_UNWRAP_SAMPLES = 2 ** 30
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SlotClockDesyncError(RuntimeError):
|
|
84
|
+
"""A wrapped RTP timestamp is too far from the unwrap high-water to be
|
|
85
|
+
disambiguated (a >24.8 h gap, a missed re-anchor, or a discontinuous/bogus
|
|
86
|
+
timestamp). The grid can no longer be trusted from this timestamp alone;
|
|
87
|
+
the caller must re-anchor against a fresh RTP reference. ``advance``
|
|
88
|
+
catches this, drops the anchor, and returns no slots, so the caller's
|
|
89
|
+
normal ``if not clk.anchored: clk.anchor(...)`` path re-establishes the
|
|
90
|
+
grid rather than silently stalling at zero decodes."""
|
|
91
|
+
|
|
92
|
+
|
|
71
93
|
@dataclass(frozen=True)
|
|
72
94
|
class Slot:
|
|
73
95
|
"""One completed, epoch-aligned slot.
|
|
@@ -189,10 +211,26 @@ class SlotClock:
|
|
|
189
211
|
within the last slot), which are always within one signed-32 window of
|
|
190
212
|
the high-water — so the unwrap is unambiguous. Advances the high-water
|
|
191
213
|
when a query is ahead of it; never rewinds it on a stale/past query.
|
|
214
|
+
|
|
215
|
+
Raises ``SlotClockDesyncError`` if the timestamp is more than
|
|
216
|
+
``_SAFE_UNWRAP_SAMPLES`` (half the signed-32 window) from the
|
|
217
|
+
high-water in either direction: at that distance a wrapped 32-bit
|
|
218
|
+
value can no longer be told apart from a ~2**32-aliased one, so any
|
|
219
|
+
unwrap would be a guess. This converts the old silent alias (huge
|
|
220
|
+
forward jump read as a backward step -> 0 slots forever) into a loud,
|
|
221
|
+
recoverable signal.
|
|
192
222
|
"""
|
|
193
223
|
assert self._anchor_rtp is not None
|
|
194
224
|
ref_rtp = (self._anchor_rtp + self._latest_off) & 0xFFFFFFFF
|
|
195
|
-
|
|
225
|
+
delta = rtp_diff(rtp_timestamp, ref_rtp)
|
|
226
|
+
if abs(delta) > _SAFE_UNWRAP_SAMPLES:
|
|
227
|
+
raise SlotClockDesyncError(
|
|
228
|
+
f"rtp={rtp_timestamp} is {delta} samples from the unwrap "
|
|
229
|
+
f"high-water (offset {self._latest_off}); |delta| exceeds the "
|
|
230
|
+
f"safe window {_SAFE_UNWRAP_SAMPLES} — cannot unwrap "
|
|
231
|
+
f"unambiguously, re-anchor required"
|
|
232
|
+
)
|
|
233
|
+
off = self._latest_off + delta
|
|
196
234
|
if off > self._latest_off:
|
|
197
235
|
self._latest_off = off
|
|
198
236
|
return off
|
|
@@ -209,7 +247,21 @@ class SlotClock:
|
|
|
209
247
|
"""
|
|
210
248
|
if self._anchor_rtp is None or self._next_boundary_off is None:
|
|
211
249
|
return []
|
|
212
|
-
|
|
250
|
+
try:
|
|
251
|
+
latest_off = self.offset_of_rtp(latest_rtp_timestamp)
|
|
252
|
+
except SlotClockDesyncError as exc:
|
|
253
|
+
# A wrapped timestamp this far from the high-water can't be
|
|
254
|
+
# unwrapped safely. Fail LOUD and drop the anchor so the caller's
|
|
255
|
+
# ``if not anchored: anchor()`` path re-establishes the grid on a
|
|
256
|
+
# fresh RTP reference — instead of silently harvesting 0 slots on
|
|
257
|
+
# live RF (the failure mode this whole class exists to prevent).
|
|
258
|
+
logger.error(
|
|
259
|
+
"SlotClock(cadence=%.3fs, sr=%d): %s; dropping anchor to force "
|
|
260
|
+
"a clean re-anchor (was at slot index %d)",
|
|
261
|
+
self.cadence_sec, self.sample_rate, exc, self._next_index,
|
|
262
|
+
)
|
|
263
|
+
self.reset()
|
|
264
|
+
return []
|
|
213
265
|
out: List[Slot] = []
|
|
214
266
|
while latest_off >= (
|
|
215
267
|
self._next_boundary_off + self.cadence_samples + self.settle_samples
|
|
@@ -226,33 +278,3 @@ class SlotClock:
|
|
|
226
278
|
self._next_boundary_off = start_off + self.cadence_samples
|
|
227
279
|
self._next_index += 1
|
|
228
280
|
return out
|
|
229
|
-
|
|
230
|
-
# ── RTP-reference re-validation ──────────────────────────────────
|
|
231
|
-
|
|
232
|
-
def divergence_sec(self, channel_info, rtp_to_utc) -> Optional[float]:
|
|
233
|
-
"""Grid-vs-GPS divergence at the next boundary, in seconds.
|
|
234
|
-
|
|
235
|
-
Recomputes the next boundary's true UTC straight from radiod's
|
|
236
|
-
(StatusListener-refreshed) ``channel_info`` via ``rtp_to_utc``
|
|
237
|
-
and compares it to the grid projection. A sustained nonzero result
|
|
238
|
-
means the anchor is stale/wrong — the caller should ``anchor()``
|
|
239
|
-
again off the fresh reference. Returns None if it can't be computed.
|
|
240
|
-
|
|
241
|
-
``rtp_to_utc`` is passed in (rather than imported) so the caller binds
|
|
242
|
-
the exact projector it anchored with; ``ka9q.rtp_to_wallclock`` works
|
|
243
|
-
too as a deprecated alias of the same function.
|
|
244
|
-
"""
|
|
245
|
-
if self._anchor_rtp is None or self._next_boundary_off is None:
|
|
246
|
-
return None
|
|
247
|
-
boundary_rtp = self.rtp_of_offset(self._next_boundary_off)
|
|
248
|
-
projected = self.utc_of_offset(self._next_boundary_off)
|
|
249
|
-
try:
|
|
250
|
-
ref = rtp_to_utc(
|
|
251
|
-
boundary_rtp, channel_info, wallclock_hint_sec=projected,
|
|
252
|
-
)
|
|
253
|
-
except Exception as exc: # noqa: BLE001 — detection must not crash audio
|
|
254
|
-
logger.debug("SlotClock divergence check raised: %s", exc)
|
|
255
|
-
return None
|
|
256
|
-
if ref is None:
|
|
257
|
-
return None
|
|
258
|
-
return projected - ref
|
|
@@ -3,7 +3,13 @@ import math
|
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
5
|
|
|
6
|
-
from ka9q.slot_clock import
|
|
6
|
+
from ka9q.slot_clock import (
|
|
7
|
+
SlotClock,
|
|
8
|
+
Slot,
|
|
9
|
+
SlotClockDesyncError,
|
|
10
|
+
rtp_diff,
|
|
11
|
+
_SAFE_UNWRAP_SAMPLES,
|
|
12
|
+
)
|
|
7
13
|
|
|
8
14
|
|
|
9
15
|
SR = 12000
|
|
@@ -136,6 +142,44 @@ def test_offset_of_rtp_unwraps_past_window():
|
|
|
136
142
|
assert off > 2 ** 31 # we genuinely crossed the signed-32 boundary
|
|
137
143
|
|
|
138
144
|
|
|
145
|
+
def test_safe_unwrap_limit_allows_full_half_window_step():
|
|
146
|
+
"""A single step of exactly the safe limit (half the signed-32 window) is
|
|
147
|
+
still unambiguous and must be accepted — the guard rejects only *past* it."""
|
|
148
|
+
clk = SlotClock(15.0, SR)
|
|
149
|
+
clk.anchor(rtp_timestamp=0, utc=900_000_000.0)
|
|
150
|
+
off = _SAFE_UNWRAP_SAMPLES
|
|
151
|
+
assert clk.offset_of_rtp(off & 0xFFFFFFFF) == off
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_offset_of_rtp_raises_on_ambiguous_jump():
|
|
155
|
+
"""A single unwrap step past the safe half-window can alias a huge forward
|
|
156
|
+
jump into a backward step; offset_of_rtp must raise rather than guess."""
|
|
157
|
+
clk = SlotClock(15.0, SR)
|
|
158
|
+
clk.anchor(rtp_timestamp=0, utc=900_000_000.0)
|
|
159
|
+
with pytest.raises(SlotClockDesyncError):
|
|
160
|
+
clk.offset_of_rtp((_SAFE_UNWRAP_SAMPLES + 1) & 0xFFFFFFFF)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_advance_big_jump_fails_loud_and_drops_anchor(caplog):
|
|
164
|
+
"""Regression: a single advance() forward jump > 2**31 from the anchor used
|
|
165
|
+
to alias to a backward step and silently harvest 0 slots forever (live RF,
|
|
166
|
+
zero decodes). The guard must instead log loudly, drop the anchor, and
|
|
167
|
+
return [] so the caller re-anchors — turning a silent stall into recovery."""
|
|
168
|
+
clk = SlotClock(15.0, SR, settle_sec=1.5)
|
|
169
|
+
clk.anchor(rtp_timestamp=0, utc=900_000_000.0)
|
|
170
|
+
# 50 h in samples: 2,160,000,000 > 2**31 (2,147,483,648) in ONE call.
|
|
171
|
+
big = 50 * 3600 * SR & 0xFFFFFFFF
|
|
172
|
+
with caplog.at_level("ERROR"):
|
|
173
|
+
slots = clk.advance(big)
|
|
174
|
+
assert slots == [] # no garbage slots
|
|
175
|
+
assert not clk.anchored # anchor dropped -> caller must re-anchor
|
|
176
|
+
assert any("SlotClock" in r.message for r in caplog.records)
|
|
177
|
+
# and recovery works: re-anchor near the real leading edge, harvest normally
|
|
178
|
+
clk.anchor(rtp_timestamp=big, utc=900_180_000.0) # multiple of 15
|
|
179
|
+
out = clk.advance((big + 180000 + 24000 + 1) & 0xFFFFFFFF)
|
|
180
|
+
assert len(out) == 1 and out[0].index == 0
|
|
181
|
+
|
|
182
|
+
|
|
139
183
|
def test_settle_delays_completion():
|
|
140
184
|
clk = SlotClock(15.0, SR, settle_sec=2.0)
|
|
141
185
|
clk.anchor(rtp_timestamp=0, utc=900_000_000.0) # 900000000 % 15 == 0 -> boundary0 at offset 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|