ka9q-python 3.17.0__tar.gz → 3.19.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.17.0 → ka9q_python-3.19.0}/CHANGELOG.md +56 -0
- {ka9q_python-3.17.0/ka9q_python.egg-info → ka9q_python-3.19.0}/PKG-INFO +1 -1
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/__init__.py +6 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/discovery.py +8 -33
- ka9q_python-3.19.0/ka9q/slot_clock.py +228 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0/ka9q_python.egg-info}/PKG-INFO +1 -1
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q_python.egg-info/SOURCES.txt +2 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/pyproject.toml +1 -1
- ka9q_python-3.19.0/tests/test_slot_clock.py +145 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/LICENSE +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/MANIFEST.in +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/README.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/docs/API_REFERENCE.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/docs/ARCHITECTURE.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/docs/CLI_GUIDE.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/docs/MULTI_STREAM.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/docs/RECIPES.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/docs/REQUIREMENTS.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/docs/SECURITY.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/docs/TUI_GUIDE.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/discover_example.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/spectrum_example.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/stream_example.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/test_improvements.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/tune.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/examples/tune_example.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/_multicast.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/addressing.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/cli.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/compat.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/control.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/managed_stream.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/monitor.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/multi_stream.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/rtp_recorder.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/spectrum_stream.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/status.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/status_listener.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/stream.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/tui.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/types.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q/utils.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q_python.egg-info/entry_points.txt +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/ka9q_radio_compat +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/scripts/check_upstream_drift.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/scripts/sync_types.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/setup.cfg +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/__init__.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/conftest.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_addressing.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_client_id_destination.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_decode_description.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_filter_edges.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_integration.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_lifetime.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_monitor.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_multicast_helpers.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_multistream_gap_storm.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_multistream_prune.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_parse_rtp_samples_iq.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_protocol_compat.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_rtp_recorder.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_security_features.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_spectrum.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_status_decoder.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_status_listener.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_tune.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_tune_live.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_tune_method.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.19.0}/tests/test_upstream_drift.py +0 -0
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.19.0] - 2026-06-28
|
|
4
|
+
|
|
5
|
+
### Removed / Changed — simplify how we look at radiod (defer to RTP)
|
|
6
|
+
|
|
7
|
+
- **Removed `SlotClock.divergence_sec`** (added in 3.18.0). It recomputed a
|
|
8
|
+
boundary's UTC from the live, StatusListener-refreshed `channel_info` and
|
|
9
|
+
compared it to the grid — inviting consumers to re-anchor when the two
|
|
10
|
+
disagree. In practice a busy radiod's status pair (`gps_time`/`rtp_timesnap`)
|
|
11
|
+
jitters ~0.45 s and occasionally tears between ~450 ms snapshots, so the check
|
|
12
|
+
reported large spurious divergences and drove downstream recorders into a
|
|
13
|
+
re-anchor storm. The `SlotClock` grid is RTP-driven and drift-immune by
|
|
14
|
+
design; nothing should second-guess it against the noisy status feed.
|
|
15
|
+
|
|
16
|
+
- **Removed the per-update RTP↔GPS offset-step detection in
|
|
17
|
+
`ChannelInfo.update_anchor`** (`discovery.py`). It bumped `anchor_epoch` and
|
|
18
|
+
logged a loud `radiod RTP↔GPS offset STEPPED … (consumers will re-anchor)`
|
|
19
|
+
WARNING whenever consecutive status snapshots implied a stepped offset — but
|
|
20
|
+
that "step" was almost always status-feed jitter/tearing, not a real radiod
|
|
21
|
+
slide (radiod's RTP data path stayed consistent; decodes never stopped).
|
|
22
|
+
`update_anchor` now simply adopts the latest anchor pair. The `anchor_epoch`,
|
|
23
|
+
`last_offset_step_sec`, and `anchor_step_threshold_sec` fields remain
|
|
24
|
+
(vestigial, at their defaults) for backward compatibility.
|
|
25
|
+
|
|
26
|
+
The principle: **anchor once off radiod's RTP timestamp and defer to it.** A
|
|
27
|
+
genuine radiod restart is still handled downstream via the stream-restored path
|
|
28
|
+
(MultiStream's drop/restore callback), not by polling the status feed for
|
|
29
|
+
divergence. The sigmond recorders (psk/meteor/wspr) drop their matching
|
|
30
|
+
re-anchor machinery in lockstep.
|
|
31
|
+
|
|
32
|
+
## [3.18.0] - 2026-06-28
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **`SlotClock` — epoch-aligned slot boundaries in RTP-timestamp space**
|
|
37
|
+
(`ka9q.slot_clock`). The canonical, drift-immune timing primitive for every
|
|
38
|
+
sigmond slot/period recorder (psk FT8/FT4, wspr WSPR/FST4W, meteor-scatter,
|
|
39
|
+
…). Slot boundaries are driven by radiod's GPSDO-disciplined RTP timestamp
|
|
40
|
+
(which advances exactly once per output sample of real time) rather than a
|
|
41
|
+
delivered-sample-count projection that silently drifts when the receive path
|
|
42
|
+
over/under-counts samples — the failure that labels a WAV with a UTC the audio
|
|
43
|
+
doesn't match and zeroes out decodes on good RF. Boundary stepping is exact
|
|
44
|
+
integer arithmetic (`cadence_sec * sample_rate` must be integer). Absolute
|
|
45
|
+
positions are tracked as an unwrapped 64-bit count relative to a monotonic
|
|
46
|
+
high-water, so harvesting keeps working past the 2³¹-sample signed-32 window
|
|
47
|
+
(~49.7 h @ 12 kHz / ~9.3 h @ 64 kHz IQ) — a long WSPR run no longer stalls.
|
|
48
|
+
Exposes `SlotClock`, `Slot`, and `rtp_diff` (Karn signed-32 difference).
|
|
49
|
+
Pure timing logic — owns no socket, ring, or thread.
|
|
50
|
+
|
|
51
|
+
### Notes
|
|
52
|
+
|
|
53
|
+
- This is the release that promotes `SlotClock` (previously parked) to public,
|
|
54
|
+
consumed API: the sigmond recorders are migrating their slot/period timing
|
|
55
|
+
onto it so an upstream timing fix lands once instead of being re-patched per
|
|
56
|
+
client. Pairs with `hamsci_dsp.timing.acquire_anchor_utc` (the shared RTP→UTC
|
|
57
|
+
anchor) on the sigmond side.
|
|
58
|
+
|
|
3
59
|
## [3.17.0] - 2026-06-28
|
|
4
60
|
|
|
5
61
|
First release marked **Production/Stable** (trove classifier 4 → 5). Folds in
|
|
@@ -115,6 +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
119
|
from .spectrum_stream import SpectrumStream
|
|
119
120
|
from .status_listener import StatusListener, StatusListenerStats
|
|
120
121
|
|
|
@@ -161,6 +162,11 @@ __all__ = [
|
|
|
161
162
|
'rtp_to_utc',
|
|
162
163
|
'rtp_to_wallclock',
|
|
163
164
|
|
|
165
|
+
# Slot timing (epoch-aligned, RTP-referenced)
|
|
166
|
+
'SlotClock',
|
|
167
|
+
'Slot',
|
|
168
|
+
'rtp_diff',
|
|
169
|
+
|
|
164
170
|
# Stream API (sample-oriented)
|
|
165
171
|
'RadiodStream',
|
|
166
172
|
'StreamQuality',
|
|
@@ -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:
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""SlotClock — epoch-aligned slot boundaries in GPS-true RTP-timestamp space.
|
|
2
|
+
|
|
3
|
+
The canonical, drift-immune timing primitive shared by every sigmond slot/
|
|
4
|
+
period recorder (psk-recorder FT8/FT4, wspr-recorder WSPR/FST4W, hfdl, …).
|
|
5
|
+
It exists because clients kept re-implementing slot timing on top of a
|
|
6
|
+
*delivered-sample-count* projection, which silently drifts: if the receive
|
|
7
|
+
path ever over- or under-counts samples relative to real time (gap-fill
|
|
8
|
+
accounting, a late anchor, a dropped burst the resequencer mis-sizes), the
|
|
9
|
+
projected UTC runs ahead of or behind the actual RF, the WAV gets a label
|
|
10
|
+
that doesn't match its content, and decode_ft8/wsprd align to the wrong grid
|
|
11
|
+
point → zero decodes on perfectly good audio. A self-check built from the
|
|
12
|
+
same delivered-count can't catch this — both sides move together.
|
|
13
|
+
|
|
14
|
+
The fix, and this class's whole premise: **drive boundaries from the RTP
|
|
15
|
+
timestamp radiod stamps on every packet.** radiod's RTP counter is
|
|
16
|
+
GPS/PPS-disciplined and advances by exactly one per output sample of real
|
|
17
|
+
time, regardless of what the client's delivery bookkeeping does. So:
|
|
18
|
+
|
|
19
|
+
* Anchor ONCE: map a single RTP timestamp to UTC via ``rtp_to_utc``
|
|
20
|
+
(the §18/METROLOGY RTP-reference rule — the only wall-clock-ish read).
|
|
21
|
+
* Every slot boundary is an epoch-aligned UTC instant (a multiple of the
|
|
22
|
+
cadence: FT8 :00/:15/:30/:45, FT4 every 7.5 s, WSPR every 120 s) whose
|
|
23
|
+
RTP timestamp is computed by integer arithmetic from the anchor.
|
|
24
|
+
* A slot is *complete* once the stream's latest RTP timestamp has passed
|
|
25
|
+
the slot's end (plus a small settle). The sample window to extract is
|
|
26
|
+
expressed in RTP units, so it's immune to delivered-count drift.
|
|
27
|
+
* ``cadence_sec * sample_rate`` must be an integer number of samples
|
|
28
|
+
(true for every real mode: 15·12000, 7.5·12000, 120·12000, …), so
|
|
29
|
+
boundary-to-boundary stepping is exact integer arithmetic — no float
|
|
30
|
+
accumulation.
|
|
31
|
+
|
|
32
|
+
RTP timestamps are unsigned 32-bit and wrap (~99 h at 12 kHz, ~16 h at
|
|
33
|
+
64 kHz IQ). All differences use Phil Karn's signed-32 technique so a wrap
|
|
34
|
+
is just normal arithmetic. Absolute RTP positions are tracked as an
|
|
35
|
+
unwrapped 64-bit count off the anchor: a monotonic high-water offset is
|
|
36
|
+
carried, and every wrapped timestamp is unwrapped *relative to that
|
|
37
|
+
high-water* (not relative to the anchor). This is what makes the offset
|
|
38
|
+
correct for streams that run arbitrarily long past their anchor — a raw
|
|
39
|
+
``anchor``-relative signed-32 diff would alias once the stream is more than
|
|
40
|
+
2**31 samples (~49.7 h @ 12 kHz, ~9.3 h @ 64 kHz IQ) from the anchor, at
|
|
41
|
+
which point ``advance`` would silently stop harvesting slots. All real
|
|
42
|
+
queries (the latest timestamp, a just-passed boundary) are within one slot
|
|
43
|
+
of the high-water, so the unwrap is unambiguous.
|
|
44
|
+
|
|
45
|
+
This class is pure timing logic — it owns no socket, ring, or thread. The
|
|
46
|
+
caller feeds it RTP timestamps and asks which slots are now complete.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import logging
|
|
52
|
+
import math
|
|
53
|
+
from dataclasses import dataclass
|
|
54
|
+
from typing import List, Optional
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def rtp_diff(a: int, b: int) -> int:
|
|
60
|
+
"""Signed 32-bit RTP timestamp difference ``a - b`` (Karn's technique).
|
|
61
|
+
|
|
62
|
+
Returns a value in [-2**31, 2**31) so timestamp wraps are handled as
|
|
63
|
+
ordinary arithmetic: positive => a is ahead of b.
|
|
64
|
+
"""
|
|
65
|
+
d = (a - b) & 0xFFFFFFFF
|
|
66
|
+
if d >= 0x80000000:
|
|
67
|
+
d -= 0x100000000
|
|
68
|
+
return d
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Slot:
|
|
73
|
+
"""One completed, epoch-aligned slot.
|
|
74
|
+
|
|
75
|
+
index monotonic slot counter from the anchor (k)
|
|
76
|
+
start_rtp RTP timestamp of the first sample of the slot
|
|
77
|
+
start_utc UTC of that first sample (epoch-aligned: multiple
|
|
78
|
+
of cadence_sec to within <1 sample)
|
|
79
|
+
n_samples number of samples in the slot (== cadence_samples)
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
index: int
|
|
83
|
+
start_rtp: int
|
|
84
|
+
start_utc: float
|
|
85
|
+
n_samples: int
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SlotClock:
|
|
89
|
+
"""Epoch-aligned slot boundaries tracked in RTP-timestamp space.
|
|
90
|
+
|
|
91
|
+
Usage::
|
|
92
|
+
|
|
93
|
+
clk = SlotClock(cadence_sec=15.0, sample_rate=12000)
|
|
94
|
+
clk.anchor(rtp_timestamp=first_rtp, utc=rtp_to_utc(first_rtp, ci))
|
|
95
|
+
...
|
|
96
|
+
# as packets arrive, advance the high-water mark and harvest slots:
|
|
97
|
+
for slot in clk.advance(latest_rtp_timestamp):
|
|
98
|
+
samples = ring.extract_rtp(slot.start_rtp, slot.n_samples)
|
|
99
|
+
...
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
cadence_sec: float,
|
|
105
|
+
sample_rate: int,
|
|
106
|
+
settle_sec: float = 1.5,
|
|
107
|
+
) -> None:
|
|
108
|
+
cadence_samples = cadence_sec * sample_rate
|
|
109
|
+
if abs(cadence_samples - round(cadence_samples)) > 1e-6:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"cadence_sec * sample_rate must be an integer sample count; "
|
|
112
|
+
f"got {cadence_sec} * {sample_rate} = {cadence_samples}"
|
|
113
|
+
)
|
|
114
|
+
self.cadence_sec = float(cadence_sec)
|
|
115
|
+
self.sample_rate = int(sample_rate)
|
|
116
|
+
self.cadence_samples = int(round(cadence_samples))
|
|
117
|
+
self.settle_samples = int(round(settle_sec * sample_rate))
|
|
118
|
+
|
|
119
|
+
self._anchor_rtp: Optional[int] = None
|
|
120
|
+
self._anchor_utc: Optional[float] = None
|
|
121
|
+
# Absolute (unwrapped) RTP position of the most recent boundary we
|
|
122
|
+
# have already emitted, as an offset in samples from the anchor.
|
|
123
|
+
self._next_boundary_off: Optional[int] = None
|
|
124
|
+
self._next_index: int = 0
|
|
125
|
+
# Monotonic high-water unwrapped offset from the anchor. Every
|
|
126
|
+
# ``offset_of_rtp`` unwraps relative to this (never the bare anchor),
|
|
127
|
+
# so positions stay correct past the 2**31-sample signed-32 window.
|
|
128
|
+
self._latest_off: int = 0
|
|
129
|
+
|
|
130
|
+
# ── anchoring ────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def anchored(self) -> bool:
|
|
134
|
+
return self._anchor_rtp is not None
|
|
135
|
+
|
|
136
|
+
def reset(self) -> None:
|
|
137
|
+
"""Drop the anchor so the next ``anchor()`` re-establishes the grid.
|
|
138
|
+
|
|
139
|
+
The monotonic slot index is preserved. Callers that key a buffer by
|
|
140
|
+
``offset_of_rtp`` MUST also discard that buffer, since offsets are
|
|
141
|
+
relative to the (now-cleared) anchor.
|
|
142
|
+
"""
|
|
143
|
+
self._anchor_rtp = None
|
|
144
|
+
self._anchor_utc = None
|
|
145
|
+
self._next_boundary_off = None
|
|
146
|
+
self._latest_off = 0
|
|
147
|
+
|
|
148
|
+
def anchor(self, rtp_timestamp: int, utc: float) -> None:
|
|
149
|
+
"""Pin (rtp_timestamp -> utc). Call once; call again to re-anchor.
|
|
150
|
+
|
|
151
|
+
``utc`` should come from ``ka9q.rtp_to_utc(rtp_timestamp, ci)``
|
|
152
|
+
so the whole grid is RTP/GPS-referenced. Re-anchoring preserves the
|
|
153
|
+
monotonic slot index but recomputes the first upcoming boundary, so a
|
|
154
|
+
corrected anchor takes effect on the next clean boundary.
|
|
155
|
+
"""
|
|
156
|
+
self._anchor_rtp = int(rtp_timestamp) & 0xFFFFFFFF
|
|
157
|
+
self._anchor_utc = float(utc)
|
|
158
|
+
# The anchor IS offset 0; the unwrap high-water restarts here.
|
|
159
|
+
self._latest_off = 0
|
|
160
|
+
# First epoch-aligned boundary at or after the anchor instant.
|
|
161
|
+
t0 = math.ceil(self._anchor_utc / self.cadence_sec) * self.cadence_sec
|
|
162
|
+
self._next_boundary_off = int(round((t0 - self._anchor_utc) * self.sample_rate))
|
|
163
|
+
logger.info(
|
|
164
|
+
"SlotClock(cadence=%.3fs, sr=%d): anchored rtp=%d utc=%.3f; "
|
|
165
|
+
"first boundary at utc=%.3f (+%d samples)",
|
|
166
|
+
self.cadence_sec, self.sample_rate, self._anchor_rtp,
|
|
167
|
+
self._anchor_utc, t0, self._next_boundary_off,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# ── projection helpers ───────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def utc_of_offset(self, sample_off: int) -> float:
|
|
173
|
+
"""UTC of the sample ``sample_off`` samples after the anchor."""
|
|
174
|
+
assert self._anchor_utc is not None
|
|
175
|
+
return self._anchor_utc + sample_off / self.sample_rate
|
|
176
|
+
|
|
177
|
+
def rtp_of_offset(self, sample_off: int) -> int:
|
|
178
|
+
"""Wrapped 32-bit RTP timestamp ``sample_off`` samples after anchor."""
|
|
179
|
+
assert self._anchor_rtp is not None
|
|
180
|
+
return (self._anchor_rtp + sample_off) & 0xFFFFFFFF
|
|
181
|
+
|
|
182
|
+
def offset_of_rtp(self, rtp_timestamp: int) -> int:
|
|
183
|
+
"""Unwrapped 64-bit sample offset from the anchor for a wrapped RTP ts.
|
|
184
|
+
|
|
185
|
+
Unwraps relative to the monotonic high-water (``_latest_off``), not the
|
|
186
|
+
bare anchor, so the result stays correct once the stream is more than
|
|
187
|
+
2**31 samples past the anchor. Callers query this only for timestamps
|
|
188
|
+
near the stream's leading edge (the latest timestamp, or a boundary
|
|
189
|
+
within the last slot), which are always within one signed-32 window of
|
|
190
|
+
the high-water — so the unwrap is unambiguous. Advances the high-water
|
|
191
|
+
when a query is ahead of it; never rewinds it on a stale/past query.
|
|
192
|
+
"""
|
|
193
|
+
assert self._anchor_rtp is not None
|
|
194
|
+
ref_rtp = (self._anchor_rtp + self._latest_off) & 0xFFFFFFFF
|
|
195
|
+
off = self._latest_off + rtp_diff(rtp_timestamp, ref_rtp)
|
|
196
|
+
if off > self._latest_off:
|
|
197
|
+
self._latest_off = off
|
|
198
|
+
return off
|
|
199
|
+
|
|
200
|
+
# ── slot harvesting ──────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def advance(self, latest_rtp_timestamp: int) -> List[Slot]:
|
|
203
|
+
"""Return every slot that has fully arrived as of ``latest_rtp``.
|
|
204
|
+
|
|
205
|
+
``latest_rtp_timestamp`` is the RTP timestamp just past the newest
|
|
206
|
+
sample the caller holds (i.e. first_rtp + samples_buffered). A slot
|
|
207
|
+
is complete once latest_rtp has passed slot_end + settle. Boundaries
|
|
208
|
+
step by exact integer ``cadence_samples`` so no drift accumulates.
|
|
209
|
+
"""
|
|
210
|
+
if self._anchor_rtp is None or self._next_boundary_off is None:
|
|
211
|
+
return []
|
|
212
|
+
latest_off = self.offset_of_rtp(latest_rtp_timestamp)
|
|
213
|
+
out: List[Slot] = []
|
|
214
|
+
while latest_off >= (
|
|
215
|
+
self._next_boundary_off + self.cadence_samples + self.settle_samples
|
|
216
|
+
):
|
|
217
|
+
start_off = self._next_boundary_off
|
|
218
|
+
out.append(
|
|
219
|
+
Slot(
|
|
220
|
+
index=self._next_index,
|
|
221
|
+
start_rtp=self.rtp_of_offset(start_off),
|
|
222
|
+
start_utc=self.utc_of_offset(start_off),
|
|
223
|
+
n_samples=self.cadence_samples,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
self._next_boundary_off = start_off + self.cadence_samples
|
|
227
|
+
self._next_index += 1
|
|
228
|
+
return out
|
|
@@ -49,6 +49,7 @@ ka9q/multi_stream.py
|
|
|
49
49
|
ka9q/pps_calibrator.py
|
|
50
50
|
ka9q/resequencer.py
|
|
51
51
|
ka9q/rtp_recorder.py
|
|
52
|
+
ka9q/slot_clock.py
|
|
52
53
|
ka9q/spectrum_stream.py
|
|
53
54
|
ka9q/status.py
|
|
54
55
|
ka9q/status_listener.py
|
|
@@ -94,6 +95,7 @@ tests/test_protocol_compat.py
|
|
|
94
95
|
tests/test_remove_channel.py
|
|
95
96
|
tests/test_rtp_recorder.py
|
|
96
97
|
tests/test_security_features.py
|
|
98
|
+
tests/test_slot_clock.py
|
|
97
99
|
tests/test_spectrum.py
|
|
98
100
|
tests/test_ssrc_dest_unit.py
|
|
99
101
|
tests/test_ssrc_encoding_unit.py
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Unit tests for ka9q.slot_clock.SlotClock."""
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from ka9q.slot_clock import SlotClock, Slot, rtp_diff
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SR = 12000
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_rtp_diff_wrap():
|
|
13
|
+
assert rtp_diff(10, 5) == 5
|
|
14
|
+
assert rtp_diff(5, 10) == -5
|
|
15
|
+
# wrap: 2 just past 0xFFFFFFFE
|
|
16
|
+
assert rtp_diff(2, 0xFFFFFFFE) == 4
|
|
17
|
+
assert rtp_diff(0xFFFFFFFE, 2) == -4
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_rejects_non_integer_cadence():
|
|
21
|
+
# 7.4 s * 12000 = 88800 ok; 7.5*12000=90000 ok; but 0.0001 off must fail
|
|
22
|
+
SlotClock(7.5, SR) # ok (90000 samples)
|
|
23
|
+
SlotClock(15.0, SR) # ok
|
|
24
|
+
SlotClock(120.0, SR) # ok
|
|
25
|
+
with pytest.raises(ValueError):
|
|
26
|
+
SlotClock(15.000001, SR)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_cadence_samples():
|
|
30
|
+
assert SlotClock(15.0, SR).cadence_samples == 180000
|
|
31
|
+
assert SlotClock(7.5, SR).cadence_samples == 90000
|
|
32
|
+
assert SlotClock(120.0, SR).cadence_samples == 1440000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_boundaries_epoch_aligned():
|
|
36
|
+
"""First boundary lands on a cadence multiple of UTC, regardless of where
|
|
37
|
+
the anchor falls within a slot."""
|
|
38
|
+
clk = SlotClock(15.0, SR, settle_sec=0.0)
|
|
39
|
+
# anchor at an awkward 7.3 s into a slot: utc = 1000.000 ... pick utc not
|
|
40
|
+
# on a 15 s grid point.
|
|
41
|
+
anchor_utc = 1_000_000_007.3
|
|
42
|
+
clk.anchor(rtp_timestamp=0, utc=anchor_utc)
|
|
43
|
+
# next boundary utc must be a multiple of 15
|
|
44
|
+
boundary_utc = clk.utc_of_offset(clk._next_boundary_off)
|
|
45
|
+
assert abs((boundary_utc % 15.0)) < 1e-3 or abs((boundary_utc % 15.0) - 15.0) < 1e-3
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_advance_yields_aligned_slots_no_drift():
|
|
49
|
+
clk = SlotClock(15.0, SR, settle_sec=1.5)
|
|
50
|
+
# anchor exactly on a grid point for easy reasoning
|
|
51
|
+
clk.anchor(rtp_timestamp=1000, utc=1_000_000_005.0) # 005 -> next boundary 015
|
|
52
|
+
# feed latest rtp far enough to complete several slots
|
|
53
|
+
# boundary0 offset = (15-5)*12000 = 120000 ; +cadence+settle to complete
|
|
54
|
+
slots = []
|
|
55
|
+
# advance in steps to simulate streaming
|
|
56
|
+
for secs in range(11, 80, 1):
|
|
57
|
+
latest_rtp = (1000 + secs * SR) & 0xFFFFFFFF
|
|
58
|
+
slots.extend(clk.advance(latest_rtp))
|
|
59
|
+
assert len(slots) >= 3
|
|
60
|
+
# every slot start_utc is a multiple of 15, and consecutive starts differ
|
|
61
|
+
# by EXACTLY cadence_samples (no float drift)
|
|
62
|
+
for s in slots:
|
|
63
|
+
assert abs(s.start_utc % 15.0) < 1e-3 or abs(s.start_utc % 15.0 - 15.0) < 1e-3
|
|
64
|
+
assert s.n_samples == 180000
|
|
65
|
+
for a, b in zip(slots, slots[1:]):
|
|
66
|
+
assert b.index == a.index + 1
|
|
67
|
+
# start_utc advances by exactly 15 s
|
|
68
|
+
assert abs((b.start_utc - a.start_utc) - 15.0) < 1e-9
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_advance_handles_rtp_wrap():
|
|
72
|
+
clk = SlotClock(15.0, SR, settle_sec=0.5)
|
|
73
|
+
# anchor near the 32-bit wrap
|
|
74
|
+
base = 0xFFFFFFFF - 5 * SR # ~5 s before wrap
|
|
75
|
+
clk.anchor(rtp_timestamp=base & 0xFFFFFFFF, utc=1_000_000_000.0)
|
|
76
|
+
got = []
|
|
77
|
+
for secs in range(1, 60):
|
|
78
|
+
latest = (base + secs * SR) & 0xFFFFFFFF
|
|
79
|
+
got.extend(clk.advance(latest))
|
|
80
|
+
assert len(got) >= 2
|
|
81
|
+
for a, b in zip(got, got[1:]):
|
|
82
|
+
assert b.index == a.index + 1
|
|
83
|
+
assert abs((b.start_utc - a.start_utc) - 15.0) < 1e-9
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_offset_of_rtp_roundtrip():
|
|
87
|
+
clk = SlotClock(7.5, SR)
|
|
88
|
+
clk.anchor(rtp_timestamp=12345, utc=1_000.0)
|
|
89
|
+
off = 90000 * 3 + 17
|
|
90
|
+
rtp = clk.rtp_of_offset(off)
|
|
91
|
+
assert clk.offset_of_rtp(rtp) == off
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_long_run_past_signed32_window_keeps_harvesting():
|
|
95
|
+
"""Regression: a stream running past 2**31 samples from the anchor must
|
|
96
|
+
keep harvesting slots. A bare anchor-relative signed-32 diff aliases at
|
|
97
|
+
~49.7 h @ 12 kHz and silently stops `advance` (real RF, no slots -> 0
|
|
98
|
+
decodes); the high-water unwrap must carry through it.
|
|
99
|
+
|
|
100
|
+
Steps the leading edge in 1-hour jumps (43.2M samples << 2**31, so each
|
|
101
|
+
individual unwrap is unambiguous) across the ~49.7 h boundary out to 55 h.
|
|
102
|
+
"""
|
|
103
|
+
clk = SlotClock(15.0, SR, settle_sec=1.5)
|
|
104
|
+
clk.anchor(rtp_timestamp=0, utc=900_000_000.0) # multiple of 15 -> boundary0 at offset 0
|
|
105
|
+
samples_per_hour = 3600 * SR # 43,200,000 (< 2**31 = 2,147,483,648)
|
|
106
|
+
boundary_hour = (2 ** 31) / samples_per_hour # ~49.7 h
|
|
107
|
+
last_index = -1
|
|
108
|
+
crossed = False
|
|
109
|
+
for hour in range(1, 56):
|
|
110
|
+
latest_off = hour * samples_per_hour
|
|
111
|
+
latest_rtp = latest_off & 0xFFFFFFFF
|
|
112
|
+
for s in clk.advance(latest_rtp):
|
|
113
|
+
# contiguous, monotonic indices and exact 15 s grid throughout
|
|
114
|
+
assert s.index == last_index + 1
|
|
115
|
+
assert abs(s.start_utc - (900_000_000.0 + s.index * 15.0)) < 1e-3
|
|
116
|
+
last_index = s.index
|
|
117
|
+
if hour > boundary_hour:
|
|
118
|
+
crossed = True
|
|
119
|
+
# still producing slots well past the old-bug cutoff
|
|
120
|
+
assert last_index > int(boundary_hour * 3600 / 15)
|
|
121
|
+
assert crossed
|
|
122
|
+
# 55 h / 15 s ≈ 13200 slots; we should be near the end, not stalled early
|
|
123
|
+
assert last_index > 13000
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_offset_of_rtp_unwraps_past_window():
|
|
127
|
+
"""offset_of_rtp must return the true 64-bit offset past 2**31, not alias."""
|
|
128
|
+
clk = SlotClock(15.0, SR)
|
|
129
|
+
clk.anchor(rtp_timestamp=7, utc=900_000_000.0)
|
|
130
|
+
# walk the high-water forward in sub-2**31 steps to 3 billion samples
|
|
131
|
+
step = 2 ** 30 # 1,073,741,824
|
|
132
|
+
off = 0
|
|
133
|
+
for _ in range(3):
|
|
134
|
+
off += step
|
|
135
|
+
assert clk.offset_of_rtp((7 + off) & 0xFFFFFFFF) == off
|
|
136
|
+
assert off > 2 ** 31 # we genuinely crossed the signed-32 boundary
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_settle_delays_completion():
|
|
140
|
+
clk = SlotClock(15.0, SR, settle_sec=2.0)
|
|
141
|
+
clk.anchor(rtp_timestamp=0, utc=900_000_000.0) # 900000000 % 15 == 0 -> boundary0 at offset 0
|
|
142
|
+
# slot0 spans [0,180000); completes only after 180000 + settle(24000)
|
|
143
|
+
assert clk.advance((180000 + 24000 - 1) & 0xFFFFFFFF) == []
|
|
144
|
+
slots = clk.advance((180000 + 24000 + 1) & 0xFFFFFFFF)
|
|
145
|
+
assert len(slots) == 1 and slots[0].index == 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
|