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.
Files changed (76) hide show
  1. rdsclock-2.0.0/PKG-INFO +411 -0
  2. rdsclock-2.0.0/README.md +371 -0
  3. {rdsclock-1.0.1 → rdsclock-2.0.0}/pyproject.toml +17 -2
  4. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/__init__.py +5 -1
  5. rdsclock-2.0.0/src/rdsclock/channelizer.py +188 -0
  6. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/cli.py +435 -47
  7. rdsclock-2.0.0/src/rdsclock/dashboard.py +810 -0
  8. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/decoder.py +158 -87
  9. rdsclock-2.0.0/src/rdsclock/gui.py +308 -0
  10. rdsclock-2.0.0/src/rdsclock/i18n.py +276 -0
  11. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/rds_blocks.py +140 -93
  12. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/rds_groups.py +103 -44
  13. rdsclock-2.0.0/src/rdsclock/recon.py +960 -0
  14. rdsclock-2.0.0/src/rdsclock/rtl_tcp.py +445 -0
  15. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/time_consensus.py +121 -33
  16. rdsclock-2.0.0/src/rdsclock/tui.py +330 -0
  17. rdsclock-2.0.0/src/rdsclock/web.py +377 -0
  18. rdsclock-2.0.0/src/rdsclock/webui/app.css +205 -0
  19. rdsclock-2.0.0/src/rdsclock/webui/app.js +121 -0
  20. rdsclock-2.0.0/src/rdsclock/webui/index.html +66 -0
  21. rdsclock-2.0.0/src/rdsclock.egg-info/PKG-INFO +411 -0
  22. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock.egg-info/SOURCES.txt +20 -1
  23. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock.egg-info/requires.txt +3 -0
  24. rdsclock-2.0.0/tests/test_benchmark.py +122 -0
  25. rdsclock-2.0.0/tests/test_channelizer.py +227 -0
  26. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_cli_with_fake_sdr.py +75 -1
  27. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_coverage_edges.py +51 -19
  28. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_coverage_extra.py +34 -21
  29. rdsclock-2.0.0/tests/test_dashboard.py +443 -0
  30. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_decoder_synthetic.py +40 -1
  31. rdsclock-2.0.0/tests/test_gui.py +267 -0
  32. rdsclock-2.0.0/tests/test_i18n.py +178 -0
  33. rdsclock-2.0.0/tests/test_measure_settle.py +78 -0
  34. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_rds_blocks.py +3 -3
  35. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_rds_groups.py +61 -0
  36. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_real_iq_regression.py +2 -0
  37. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_recon.py +7 -0
  38. rdsclock-2.0.0/tests/test_recon_events.py +484 -0
  39. rdsclock-2.0.0/tests/test_recon_hop.py +114 -0
  40. rdsclock-2.0.0/tests/test_recon_wide.py +254 -0
  41. rdsclock-2.0.0/tests/test_release.py +196 -0
  42. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_time_consensus.py +170 -4
  43. rdsclock-2.0.0/tests/test_transport_fresh.py +521 -0
  44. rdsclock-2.0.0/tests/test_tui.py +405 -0
  45. rdsclock-2.0.0/tests/test_web.py +349 -0
  46. rdsclock-1.0.1/PKG-INFO +0 -346
  47. rdsclock-1.0.1/README.md +0 -308
  48. rdsclock-1.0.1/src/rdsclock/channelizer.py +0 -111
  49. rdsclock-1.0.1/src/rdsclock/recon.py +0 -399
  50. rdsclock-1.0.1/src/rdsclock/rtl_tcp.py +0 -128
  51. rdsclock-1.0.1/src/rdsclock.egg-info/PKG-INFO +0 -346
  52. rdsclock-1.0.1/tests/test_benchmark.py +0 -28
  53. rdsclock-1.0.1/tests/test_channelizer.py +0 -129
  54. {rdsclock-1.0.1 → rdsclock-2.0.0}/LICENSE +0 -0
  55. {rdsclock-1.0.1 → rdsclock-2.0.0}/setup.cfg +0 -0
  56. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/__main__.py +0 -0
  57. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/audio.py +0 -0
  58. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/dsp.py +0 -0
  59. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/plot.py +0 -0
  60. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/py.typed +0 -0
  61. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/rds_clock.py +0 -0
  62. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock/synth.py +0 -0
  63. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock.egg-info/dependency_links.txt +0 -0
  64. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock.egg-info/entry_points.txt +0 -0
  65. {rdsclock-1.0.1 → rdsclock-2.0.0}/src/rdsclock.egg-info/top_level.txt +0 -0
  66. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_audio.py +0 -0
  67. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_cli.py +0 -0
  68. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_dsp.py +0 -0
  69. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_pipeline_delay.py +0 -0
  70. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_plot.py +0 -0
  71. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_public_api.py +0 -0
  72. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_rds_clock.py +0 -0
  73. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_real_recordings.py +0 -0
  74. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_recon_offline.py +0 -0
  75. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_rtl_tcp.py +0 -0
  76. {rdsclock-1.0.1 → rdsclock-2.0.0}/tests/test_synth.py +0 -0
@@ -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
+ ![rdsclock: a clock dial with a north marker and radio waves](docs/images/rdsclock-banner.svg)
44
+
45
+ # rdsclock — FM RDS decoding and clock comparison
46
+
47
+ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
48
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](pyproject.toml)
49
+ [![Tests](https://github.com/mateusz-klatt/rdsclock/actions/workflows/test.yml/badge.svg)](https://github.com/mateusz-klatt/rdsclock/actions/workflows/test.yml)
50
+ [![Sonar Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=mateusz-klatt_rdsclock&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=mateusz-klatt_rdsclock)
51
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=mateusz-klatt_rdsclock&metric=coverage)](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
+ ![FM band scan: relative channel power and decoded RDS groups](docs/images/full-band-scan-2026-11-02.png)
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
+ ![Terminal dashboard during live WIDE reception: trust MEDIUM from two stations in one capture, both with Clock-Time](docs/images/dashboard-wide-tui-en.png)
266
+
267
+ **Browser** (`--web`)
268
+
269
+ ![Browser dashboard during live WIDE reception of RMF Classic and PR Trójka, with Rescan and Pause view buttons](docs/images/dashboard-wide-web-en.png)
270
+
271
+ **Desktop window** (`--gui`)
272
+
273
+ ![Desktop window during live WIDE reception of RMF Classic and PR Trójka, with Rescan, Pause view and Quit buttons](docs/images/dashboard-wide-gui-en.png)
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
+ ![Terminal dashboard during live HOP reception: trust HIGH from three stations, each with Clock-Time](docs/images/dashboard-hop-tui-en.png)
295
+
296
+ **Browser** (`--web`)
297
+
298
+ ![Browser dashboard during live HOP reception of Radio ZET, PR Jedynka and PR Trójka](docs/images/dashboard-hop-web-en.png)
299
+
300
+ **Desktop window** (`--gui`)
301
+
302
+ ![Desktop window during live HOP reception of Radio ZET, PR Jedynka and PR Trójka](docs/images/dashboard-hop-gui-en.png)
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.