rdsclock 1.0.1__tar.gz → 2.0.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.
- rdsclock-2.0.0/PKG-INFO +411 -0
- rdsclock-2.0.0/README.md +371 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/pyproject.toml +17 -2
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/__init__.py +5 -1
- rdsclock-2.0.0/src/rdsclock/channelizer.py +188 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/cli.py +435 -47
- rdsclock-2.0.0/src/rdsclock/dashboard.py +810 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/decoder.py +158 -87
- rdsclock-2.0.0/src/rdsclock/gui.py +308 -0
- rdsclock-2.0.0/src/rdsclock/i18n.py +276 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/rds_blocks.py +140 -93
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/rds_groups.py +103 -44
- rdsclock-2.0.0/src/rdsclock/recon.py +960 -0
- rdsclock-2.0.0/src/rdsclock/rtl_tcp.py +445 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/time_consensus.py +121 -33
- rdsclock-2.0.0/src/rdsclock/tui.py +330 -0
- rdsclock-2.0.0/src/rdsclock/web.py +377 -0
- rdsclock-2.0.0/src/rdsclock/webui/app.css +205 -0
- rdsclock-2.0.0/src/rdsclock/webui/app.js +121 -0
- rdsclock-2.0.0/src/rdsclock/webui/index.html +66 -0
- rdsclock-2.0.0/src/rdsclock.egg-info/PKG-INFO +411 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock.egg-info/SOURCES.txt +20 -1
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock.egg-info/requires.txt +3 -0
- rdsclock-2.0.0/tests/test_benchmark.py +122 -0
- rdsclock-2.0.0/tests/test_channelizer.py +227 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_cli_with_fake_sdr.py +75 -1
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_coverage_edges.py +51 -19
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_coverage_extra.py +34 -21
- rdsclock-2.0.0/tests/test_dashboard.py +443 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_decoder_synthetic.py +40 -1
- rdsclock-2.0.0/tests/test_gui.py +267 -0
- rdsclock-2.0.0/tests/test_i18n.py +178 -0
- rdsclock-2.0.0/tests/test_measure_settle.py +78 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_rds_blocks.py +3 -3
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_rds_groups.py +61 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_real_iq_regression.py +2 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_recon.py +7 -0
- rdsclock-2.0.0/tests/test_recon_events.py +484 -0
- rdsclock-2.0.0/tests/test_recon_hop.py +114 -0
- rdsclock-2.0.0/tests/test_recon_wide.py +254 -0
- rdsclock-2.0.0/tests/test_release.py +196 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_time_consensus.py +170 -4
- rdsclock-2.0.0/tests/test_transport_fresh.py +521 -0
- rdsclock-2.0.0/tests/test_tui.py +405 -0
- rdsclock-2.0.0/tests/test_web.py +349 -0
- rdsclock-1.0.1/PKG-INFO +0 -346
- rdsclock-1.0.1/README.md +0 -308
- rdsclock-1.0.1/src/rdsclock/channelizer.py +0 -111
- rdsclock-1.0.1/src/rdsclock/recon.py +0 -399
- rdsclock-1.0.1/src/rdsclock/rtl_tcp.py +0 -128
- rdsclock-1.0.1/src/rdsclock.egg-info/PKG-INFO +0 -346
- rdsclock-1.0.1/tests/test_benchmark.py +0 -28
- rdsclock-1.0.1/tests/test_channelizer.py +0 -129
- {rdsclock-1.0.1 → rdsclock-2.0.0}/LICENSE +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/setup.cfg +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/__main__.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/audio.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/dsp.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/plot.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/py.typed +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/rds_clock.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/synth.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock.egg-info/dependency_links.txt +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock.egg-info/entry_points.txt +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock.egg-info/top_level.txt +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_audio.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_cli.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_dsp.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_pipeline_delay.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_plot.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_public_api.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_rds_clock.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_real_recordings.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_recon_offline.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_rtl_tcp.py +0 -0
- {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_synth.py +0 -0
rdsclock-2.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rdsclock
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Passive RDS Clock-Time receiver for FM via RTL-SDR, with multi-source consensus for GPS-denied environments
|
|
5
|
+
Author-email: Mateusz Klatt <mateusz@klatt.ie>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/mateusz-klatt/rdsclock
|
|
8
|
+
Project-URL: Documentation, https://github.com/mateusz-klatt/rdsclock/tree/master/docs
|
|
9
|
+
Project-URL: Issues, https://github.com/mateusz-klatt/rdsclock/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/mateusz-klatt/rdsclock/blob/master/CHANGELOG.md
|
|
11
|
+
Keywords: sdr,rds,fm,time-sync,passive-rf,rtl-sdr
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Telecommunications Industry
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Communications :: Ham Radio
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: numpy>=1.24.0
|
|
24
|
+
Requires-Dist: scipy>=1.10.0
|
|
25
|
+
Provides-Extra: audio
|
|
26
|
+
Requires-Dist: sounddevice>=0.4.6; extra == "audio"
|
|
27
|
+
Provides-Extra: plot
|
|
28
|
+
Requires-Dist: matplotlib>=3.7.0; extra == "plot"
|
|
29
|
+
Provides-Extra: fast
|
|
30
|
+
Requires-Dist: numba>=0.60.0; extra == "fast"
|
|
31
|
+
Provides-Extra: tui
|
|
32
|
+
Requires-Dist: textual>=8.2; extra == "tui"
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
35
|
+
Requires-Dist: pytest-timeout>=2.1.0; extra == "dev"
|
|
36
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|
37
|
+
Requires-Dist: ruff>=0.5.0; extra == "dev"
|
|
38
|
+
Requires-Dist: black>=24.0; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
[English](README.md) · [Polski](README.pl.md) · [Français](README.fr.md)
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
# rdsclock — FM RDS decoding and clock comparison
|
|
46
|
+
|
|
47
|
+
[](LICENSE)
|
|
48
|
+
[](pyproject.toml)
|
|
49
|
+
[](https://github.com/mateusz-klatt/rdsclock/actions/workflows/test.yml)
|
|
50
|
+
[](https://sonarcloud.io/summary/new_code?id=mateusz-klatt_rdsclock)
|
|
51
|
+
[](https://sonarcloud.io/component_measures?id=mateusz-klatt_rdsclock&metric=coverage)
|
|
52
|
+
|
|
53
|
+
**Decode broadcast FM metadata, receive several stations from one SDR capture,
|
|
54
|
+
and compare their declared time.** `rdsclock` implements the RDS signal-processing
|
|
55
|
+
chain in Python with NumPy and SciPy, from IQ samples to station identity and
|
|
56
|
+
Group 4A Clock-Time (CT).
|
|
57
|
+
|
|
58
|
+
It is an engineering demonstrator for exploring broadcast time when GNSS or NTP
|
|
59
|
+
is unavailable. It supports RTL-SDR reception through `rtl_tcp`, offline IQ files,
|
|
60
|
+
synthetic signals and time consensus with outlier detection and holdover.
|
|
61
|
+
Reception requires no RF transmission; a remote receiver still uses a network
|
|
62
|
+
connection.
|
|
63
|
+
|
|
64
|
+
**Live decoding is validated; absolute UTC accuracy is not.** The latest antenna
|
|
65
|
+
tests recovered CT from three services, with declarations approximately one hour
|
|
66
|
+
ahead of host UTC. Source agreement and valid CRCs do not establish correct or
|
|
67
|
+
authenticated time.
|
|
68
|
+
|
|
69
|
+
[Demo walkthrough](docs/demo.md) ·
|
|
70
|
+
[Full-band hardware demo](docs/full-band-demo-2026-11-02.md) ·
|
|
71
|
+
[Architecture](docs/ARCHITECTURE.md) · [Threat model](docs/THREAT_MODEL.md)
|
|
72
|
+
|
|
73
|
+
## What has been demonstrated
|
|
74
|
+
|
|
75
|
+
Measurements from Warsaw–Wilanów on **2 November 2026**, using one RTL-SDR:
|
|
76
|
+
|
|
77
|
+
| Experiment | Observed result |
|
|
78
|
+
|---|---|
|
|
79
|
+
| FM band scan, 87.5–108 MHz | 206 tuning points, 2 s each; 16 distinct confirmed PI codes |
|
|
80
|
+
| WIDE, one 12 s capture | Eska ROCK 102.0, Jedynka 102.4 and Radio Kolor 103.0 MHz: 136, 136 and 137 RDS groups |
|
|
81
|
+
| WIDE, another 12 s capture | RMF Classic 98.3 and Trójka 98.8 MHz: 136 groups each |
|
|
82
|
+
| HOP, 75 s per station | Jedynka / Trójka / Radio ZET: 829 / 643 / 846 groups; CT recovered on all three |
|
|
83
|
+
| Software validation | 329 passed, 1 optional local-IQ test skipped, 3 hardware tests deselected |
|
|
84
|
+
|
|
85
|
+
WIDE channels share the same capture interval and are decoded after capture.
|
|
86
|
+
HOP receives stations sequentially. Neither mode receives the entire FM band
|
|
87
|
+
simultaneously with one dongle. The scan also found repeated PIs 1 MHz apart;
|
|
88
|
+
their cause remains unresolved, so tuning points are not counted as separate
|
|
89
|
+
transmitters. Short captures without CT do not establish that a station never
|
|
90
|
+
broadcasts it.
|
|
91
|
+
|
|
92
|
+

|
|
93
|
+
|
|
94
|
+
The figure is a sequence of measurements, not a simultaneous or calibrated RF
|
|
95
|
+
spectrum. See the [full report](docs/full-band-demo-2026-11-02.md) for parameters,
|
|
96
|
+
WIDE spectra and limitations, and the [gain study](docs/hardware-validation-2026-10-31.md)
|
|
97
|
+
for earlier comparisons. These are recorded outcomes, not reception guarantees.
|
|
98
|
+
|
|
99
|
+
## Quick start — no SDR required
|
|
100
|
+
|
|
101
|
+
From a checkout, with **Python 3.12+** and **uv** installed:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
git clone https://github.com/mateusz-klatt/rdsclock.git
|
|
105
|
+
cd rdsclock
|
|
106
|
+
uv sync --frozen --extra dev --extra plot --no-install-project --no-build
|
|
107
|
+
export PYTHONPATH="$PWD/src"
|
|
108
|
+
|
|
109
|
+
# Known signals through the complete multi-channel decoder.
|
|
110
|
+
.venv/bin/python -m rdsclock demo --time 2026-11-02T12:00:00+00:00 --seed 42
|
|
111
|
+
|
|
112
|
+
# Three agreeing sources, a ten-minute outlier and a repeated PI.
|
|
113
|
+
.venv/bin/python examples/consensus_demo.py
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The first example should recover the expected CT for all three generated
|
|
117
|
+
stations. The second exercises consensus separately: a repeated PI adds no vote,
|
|
118
|
+
and the outlier is identified. Both work offline after dependency installation.
|
|
119
|
+
The commands below use the same shell and environment.
|
|
120
|
+
|
|
121
|
+
## Live reception
|
|
122
|
+
|
|
123
|
+
Use an RTL-SDR with an FM antenna and exclusive access to `rtl_tcp`. On the
|
|
124
|
+
machine with the dongle, start the server on loopback:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
rtl_tcp -a 127.0.0.1 -p 1234
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
For a remote dongle, leave an SSH tunnel running in another terminal; replace
|
|
131
|
+
`SDR_HOST` with your receiver host:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
ssh -N -L 1234:127.0.0.1:1234 SDR_HOST
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The client then connects to `127.0.0.1:1234` in either case. RTL-TCP has no
|
|
138
|
+
built-in authentication or encryption. Stop other SDR clients before connecting.
|
|
139
|
+
|
|
140
|
+
### One station and its clock
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
.venv/bin/python -m rdsclock live --host 127.0.0.1 \
|
|
144
|
+
--freq 102.4 --duration 75 --gain 19.7 --settle-seconds 1 -v
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Choose a locally receivable frequency. This example uses a tested Warsaw
|
|
148
|
+
channel; 75 seconds gives CT more opportunity to appear than a short scan,
|
|
149
|
+
but does not guarantee it. Compare clean/corrected groups, confirmed PI and
|
|
150
|
+
I/Q endpoint occupancy when choosing gain. One gain setting is not optimal
|
|
151
|
+
for every station; see the [measured comparisons](docs/hardware-validation-2026-10-31.md).
|
|
152
|
+
|
|
153
|
+
### Several stations: WIDE and HOP
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# Simultaneous capture; decode three channels from the same IQ samples.
|
|
157
|
+
.venv/bin/python -m rdsclock multi --host 127.0.0.1 --mode wide \
|
|
158
|
+
--freqs 102.0,102.4,103.0 --center 102.85 --fs 2400000 \
|
|
159
|
+
--duration 12 --gain 28 --settle-seconds 1
|
|
160
|
+
|
|
161
|
+
# Sequential tuning; 75 seconds per station, plus settling and decoding.
|
|
162
|
+
.venv/bin/python -m rdsclock multi --host 127.0.0.1 --mode hop \
|
|
163
|
+
--freqs 102.4,98.8,107.5 --duration 75 --gain 19.7 --settle-seconds 1
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
WIDE requires all channels to fit inside the receiver bandwidth with filter
|
|
167
|
+
margins. At 2.4 MS/s, a 12-second `complex64` array alone occupies about 230 MB;
|
|
168
|
+
processing needs additional memory. This is capture-then-decode, not continuous
|
|
169
|
+
multi-station audio playback. HOP can cover widely separated frequencies.
|
|
170
|
+
|
|
171
|
+
`--settle-seconds` actively receives and discards samples before each full
|
|
172
|
+
capture. It does not shorten `--duration`. A bounded drain reduces queued-sample
|
|
173
|
+
carryover, but RTL-TCP provides neither tuning acknowledgements nor hardware
|
|
174
|
+
timestamps.
|
|
175
|
+
|
|
176
|
+
### Band discovery and continuous observation
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
# 206 tuning points; at least 412 seconds of samples, plus overhead.
|
|
180
|
+
.venv/bin/python -m rdsclock scan --host 127.0.0.1 \
|
|
181
|
+
--start 87.5 --end 108.0 --step 0.1 --duration 2 --settle-seconds 0.3
|
|
182
|
+
|
|
183
|
+
# Scan a smaller range, build a watchlist, then repeat observation cycles.
|
|
184
|
+
.venv/bin/python -m rdsclock recon --host 127.0.0.1 \
|
|
185
|
+
--start 98.0 --end 108.0 --step 0.1 --scan-dwell 2 --dwell 75 \
|
|
186
|
+
--max-stations 3 --gain 19.7 --rssi-threshold -35 --settle-seconds 1 --iterations 3
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The short discovery scan looks for decodable RDS; it can miss CT and weak
|
|
190
|
+
stations. Adjust the signal threshold for your antenna and receiver; `-35`
|
|
191
|
+
is a starting point for including weaker channels. `recon` ranks candidates,
|
|
192
|
+
hops through a watchlist and reports a
|
|
193
|
+
consensus with holdover between observations. Its acquisition scan and repeated
|
|
194
|
+
captures take longer than the short demos. See the
|
|
195
|
+
[operator guide](docs/operator-quickstart.md) for details.
|
|
196
|
+
|
|
197
|
+
### Dashboards
|
|
198
|
+
|
|
199
|
+
`recon` can show its progress live in a terminal, a browser or a desktop
|
|
200
|
+
window. Add one of these flags to the `recon` command above and leave out
|
|
201
|
+
`--iterations`, so that observation continues until you quit:
|
|
202
|
+
|
|
203
|
+
- `--tui`: a terminal dashboard; add `--extra tui` to `uv sync` first.
|
|
204
|
+
- `--web`: a page at <http://127.0.0.1:8080/>, with no extra dependency;
|
|
205
|
+
`--web-port` changes the port.
|
|
206
|
+
- `--gui`: a desktop window, using Python's tkinter.
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
uv sync --frozen --extra dev --extra plot --extra tui --no-install-project --no-build
|
|
210
|
+
|
|
211
|
+
.venv/bin/python -m rdsclock recon --host 127.0.0.1 \
|
|
212
|
+
--start 98.0 --end 108.0 --step 0.1 --scan-dwell 2 --dwell 75 \
|
|
213
|
+
--max-stations 3 --gain 19.7 --rssi-threshold -35 --settle-seconds 1 --tui
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Each dashboard runs `recon --jsonl` as a child process with the same receiver
|
|
217
|
+
options and shows the consensus, the stations and a log. *Rescan* (`r`) starts
|
|
218
|
+
an acquisition scan after the current cycle, and *Pause view* (`p`) freezes the
|
|
219
|
+
display while reception continues; the page offers both as buttons. `q` quits
|
|
220
|
+
the terminal dashboard and the window, and `Ctrl+C` stops the browser
|
|
221
|
+
dashboard. The page has no authentication and listens on loopback only, so to
|
|
222
|
+
watch `recon` running on another machine, forward the port:
|
|
223
|
+
`ssh -N -L 8080:127.0.0.1:8080 SDR_HOST`. A system Python on Debian or Ubuntu
|
|
224
|
+
gets tkinter from `apt install python3-tk`. With `--jsonl`, `recon` instead
|
|
225
|
+
writes one JSON event per line for other programs; the
|
|
226
|
+
[operator guide](docs/operator-quickstart.md) describes all these modes.
|
|
227
|
+
|
|
228
|
+
#### Dashboard language
|
|
229
|
+
|
|
230
|
+
All three dashboards support English, Polish and French in 2.0.0. Use
|
|
231
|
+
`--lang en`, `--lang pl` or `--lang fr` with `--tui`, `--web` or `--gui`;
|
|
232
|
+
English is the default. For example, to open a Polish browser dashboard:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
.venv/bin/python -m rdsclock recon --host 127.0.0.1 \
|
|
236
|
+
--wide 98.3,98.8 --gain 28 --dwell 75 --web --lang pl
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The selected language applies to controls, headings, status messages and the
|
|
240
|
+
operator log. Broadcast PS/PI/CT values and JSON Lines field names remain
|
|
241
|
+
unchanged. Each dashboard owns one receiver process; run one of these commands
|
|
242
|
+
at a time against a single RTL-TCP server. The live screenshots below show the
|
|
243
|
+
English interface; the Polish and French READMEs show their own.
|
|
244
|
+
|
|
245
|
+
[2.0.0 release notes — EN / PL / FR](docs/releases/2.0.0.md) ·
|
|
246
|
+
[Release procedure](docs/releasing.md)
|
|
247
|
+
|
|
248
|
+
#### WIDE: two stations from one capture
|
|
249
|
+
|
|
250
|
+
With `--wide`, `recon` records stations that fit in the receiver bandwidth
|
|
251
|
+
from one capture, so the dashboards show them all at once instead of hopping
|
|
252
|
+
from one to the next. Here RMF Classic and PR Trójka, both broadcasting
|
|
253
|
+
Clock-Time, share each 75-second capture at 2.4 MS/s: they report the same
|
|
254
|
+
minute, and the consensus combines them (N=2, trust MEDIUM). The screenshots
|
|
255
|
+
come from this command, with `--web` or `--gui` in place of `--tui`:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
.venv/bin/python -m rdsclock recon --host 127.0.0.1 \
|
|
259
|
+
--wide 98.3,98.8 --gain 28 --scan-dwell 3 --dwell 75 \
|
|
260
|
+
--settle-seconds 1 --tui
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**Terminal** (`--tui`)
|
|
264
|
+
|
|
265
|
+

|
|
266
|
+
|
|
267
|
+
**Browser** (`--web`)
|
|
268
|
+
|
|
269
|
+

|
|
270
|
+
|
|
271
|
+
**Desktop window** (`--gui`)
|
|
272
|
+
|
|
273
|
+

|
|
274
|
+
|
|
275
|
+
#### HOP: three stations, one after another
|
|
276
|
+
|
|
277
|
+
With `--hop`, `recon` visits the listed stations wherever they are in the
|
|
278
|
+
band, one 75-second capture after another. Radio ZET, PR Jedynka and PR Trójka
|
|
279
|
+
each report a different minute, and all three agree (N=3). The receiver
|
|
280
|
+
assesses trust once per cycle, and the dashboards keep that label until the next
|
|
281
|
+
assessment. HIGH needs three confirmed stations whose median Clock-Time is under
|
|
282
|
+
two minutes old at that moment; MEDIUM needs two under three minutes. A cycle of
|
|
283
|
+
three 75-second captures can end on either side of the two-minute limit; the
|
|
284
|
+
cycle in these screenshots ended at HIGH:
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
.venv/bin/python -m rdsclock recon --host 127.0.0.1 \
|
|
288
|
+
--hop 98.8,102.4,107.5 --gain 19.7 --scan-dwell 3 --dwell 75 \
|
|
289
|
+
--settle-seconds 1 --tui
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Terminal** (`--tui`)
|
|
293
|
+
|
|
294
|
+

|
|
295
|
+
|
|
296
|
+
**Browser** (`--web`)
|
|
297
|
+
|
|
298
|
+

|
|
299
|
+
|
|
300
|
+
**Desktop window** (`--gui`)
|
|
301
|
+
|
|
302
|
+

|
|
303
|
+
|
|
304
|
+
*Live reception in Warsaw on 4 November 2026. Received UTC declarations
|
|
305
|
+
were approximately one hour ahead of host UTC. This is an observed disagreement;
|
|
306
|
+
source agreement does not establish the cause or calibrate absolute accuracy.*
|
|
307
|
+
|
|
308
|
+
## How it works
|
|
309
|
+
|
|
310
|
+
```mermaid
|
|
311
|
+
flowchart LR
|
|
312
|
+
A[RTL-TCP or IQ file] --> B[Optional channelizer]
|
|
313
|
+
B --> C[FM demodulation]
|
|
314
|
+
C --> D[57 kHz RDS recovery]
|
|
315
|
+
D --> E[Block checks and group framing]
|
|
316
|
+
E --> F[PI-isolated PS / RT / CT]
|
|
317
|
+
F --> G[Deduplication and time consensus]
|
|
318
|
+
H[Synthetic IQ generator] --> B
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
The decoder uses the 19 kHz stereo pilot to locate the RDS subcarrier, then
|
|
322
|
+
recovers biphase symbols, validates block checkwords and parses station identity
|
|
323
|
+
(PI), Programme Service (PS), RadioText (RT) and Clock-Time. Clean and corrected
|
|
324
|
+
groups are reported separately. Payloads from different PIs remain isolated.
|
|
325
|
+
|
|
326
|
+
Consensus uses estimated receipt time rather than decoder completion time,
|
|
327
|
+
rejects stale observations and deduplicates channels and confirmed PIs. A median
|
|
328
|
+
and outlier checks support holdover on the host monotonic clock. `HIGH` denotes
|
|
329
|
+
agreement under this model; distinct PIs can still share an incorrect upstream
|
|
330
|
+
clock. Reported uncertainty describes source dispersion and holdover, not a
|
|
331
|
+
calibrated absolute UTC error bound. The experimental subsecond API has the
|
|
332
|
+
same limitation.
|
|
333
|
+
|
|
334
|
+
Module details and the consensus algorithm are in the
|
|
335
|
+
[architecture document](docs/ARCHITECTURE.md); trust assumptions and remaining
|
|
336
|
+
risks are in the [threat model](docs/THREAT_MODEL.md).
|
|
337
|
+
|
|
338
|
+
### Group 4A: time fields
|
|
339
|
+
|
|
340
|
+
The UTC hour/minute and Modified Julian Day (MJD) are separate from the local
|
|
341
|
+
time offset (LTO). The data-word layout is:
|
|
342
|
+
|
|
343
|
+
| Field | Block B | Block C | Block D |
|
|
344
|
+
|---|---|---|---|
|
|
345
|
+
| MJD, 17 bits | `[1:0]` (high bits) | `[15:1]` (low bits) | — |
|
|
346
|
+
| Hour, 5 bits | — | `[0]` (high bit) | `[15:12]` (low bits) |
|
|
347
|
+
| Minute, 6 bits | — | — | `[11:6]` |
|
|
348
|
+
| LTO sign / magnitude | — | — | `[5]` / `[4:0]` |
|
|
349
|
+
|
|
350
|
+
MJD starts at 1858-11-17; LTO uses half-hour units. Received declarations are
|
|
351
|
+
preserved even when they disagree with the host. See the
|
|
352
|
+
[standards references](docs/REFERENCES.md) and the
|
|
353
|
+
[independent field extraction](docs/hardware-validation-2026-10-31.md#clock-time-decoding-succeeded-clock-agreement-did-not).
|
|
354
|
+
|
|
355
|
+
## Python API and optional features
|
|
356
|
+
|
|
357
|
+
The supported public API is exported from `rdsclock`. For a local IQ file
|
|
358
|
+
recorded at 250 kS/s:
|
|
359
|
+
|
|
360
|
+
```python
|
|
361
|
+
from rdsclock import decode_file
|
|
362
|
+
|
|
363
|
+
result = decode_file("build/local.iq", fs=250_000)
|
|
364
|
+
pi = result.info.pi
|
|
365
|
+
print(f"PI: {pi:#06x}" if pi is not None else "PI not confirmed")
|
|
366
|
+
print(f"Groups: {result.n_groups}; clean: {result.n_groups_clean}")
|
|
367
|
+
for ct in result.clock_times:
|
|
368
|
+
print(ct.utc, ct.rx_monotonic_ns)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Create your own input using `live --save build/local.iq` after `mkdir -p build`,
|
|
372
|
+
or use `generate build/local.iq --seed 42` with the CLI above. Match `fs` to the
|
|
373
|
+
capture sample rate. A file without a reception-time anchor cannot establish
|
|
374
|
+
when its samples were received.
|
|
375
|
+
|
|
376
|
+
| Optional extra | Purpose |
|
|
377
|
+
|---|---|
|
|
378
|
+
| `audio` | Single-station FM audio playback via `sounddevice` |
|
|
379
|
+
| `plot` | MPX spectrum and waterfall figures via `matplotlib` |
|
|
380
|
+
| `fast` | Optional Numba acceleration |
|
|
381
|
+
| `tui` | Terminal dashboard for `recon --tui` via `textual` |
|
|
382
|
+
| `dev` | Tests, coverage, Ruff and Black |
|
|
383
|
+
|
|
384
|
+
Add the relevant `--extra` to `uv sync`. The live multi-station examples do not
|
|
385
|
+
require the audio extra. Use `.venv/bin/python -m rdsclock --help` for all commands.
|
|
386
|
+
|
|
387
|
+
## Verification and documentation
|
|
388
|
+
|
|
389
|
+
```bash
|
|
390
|
+
# Software tests, including slow synthetic checks; no live receiver required.
|
|
391
|
+
.venv/bin/python -m pytest tests/ -m 'not real_sdr' -q -W error
|
|
392
|
+
.venv/bin/ruff check src/ tests/ examples/
|
|
393
|
+
.venv/bin/black --check src/ tests/ examples/
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
CI uses the checked-in `uv.lock`; the fast test selection additionally excludes
|
|
397
|
+
`slow`. Synthetic round trips check known input through the complete decoder;
|
|
398
|
+
transport tests exercise retuning, buffering and connection cleanup. Local IQ
|
|
399
|
+
regressions and antenna measurements complement those tests. A missing optional
|
|
400
|
+
recording is reported as a skip, not a pass.
|
|
401
|
+
|
|
402
|
+
- [Demo walkthrough](docs/demo.md) — a short engineering presentation.
|
|
403
|
+
- [Full-band demo, 2026-11-02](docs/full-band-demo-2026-11-02.md) — scan, WIDE, HOP and unresolved observations.
|
|
404
|
+
- [Gain and timing validation](docs/hardware-validation-2026-10-31.md) — measurements and interpretation.
|
|
405
|
+
- [Architecture](docs/ARCHITECTURE.md), [threat model](docs/THREAT_MODEL.md) and [operator guide](docs/operator-quickstart.md).
|
|
406
|
+
- [Datasets](docs/datasets.md), [references](docs/REFERENCES.md), [changelog](CHANGELOG.md) and [contributing](CONTRIBUTING.md).
|
|
407
|
+
|
|
408
|
+
The project uses the [Apache License 2.0](LICENSE). Keep captured IQ, audio and
|
|
409
|
+
broadcast text local; the hardware reports contain aggregate measurements and
|
|
410
|
+
figures. Synthetic signals support reproducible demonstrations without sharing
|
|
411
|
+
broadcast recordings. See [SECURITY.md](SECURITY.md) for handling guidance.
|