rdsclock 0.2.0__tar.gz → 0.2.2__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-0.2.0 → rdsclock-0.2.2}/PKG-INFO +10 -6
- {rdsclock-0.2.0 → rdsclock-0.2.2}/README.md +7 -5
- {rdsclock-0.2.0 → rdsclock-0.2.2}/pyproject.toml +4 -1
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/__init__.py +1 -1
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/audio.py +23 -30
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/dsp.py +19 -3
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/rds_blocks.py +93 -53
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/rds_groups.py +39 -2
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock.egg-info/PKG-INFO +10 -6
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock.egg-info/requires.txt +3 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_audio.py +16 -3
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_coverage_edges.py +26 -1
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_coverage_extra.py +1 -1
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_dsp.py +13 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_rds_blocks.py +36 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_rds_groups.py +54 -4
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_synth.py +1 -1
- {rdsclock-0.2.0 → rdsclock-0.2.2}/LICENSE +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/setup.cfg +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/__main__.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/channelizer.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/cli.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/decoder.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/plot.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/py.typed +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/rds_clock.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/recon.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/rtl_tcp.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/synth.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/time_consensus.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock.egg-info/SOURCES.txt +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock.egg-info/dependency_links.txt +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock.egg-info/entry_points.txt +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock.egg-info/top_level.txt +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_channelizer.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_cli.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_cli_with_fake_sdr.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_decoder_synthetic.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_plot.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_rds_clock.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_real_iq_regression.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_real_recordings.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_recon.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_recon_offline.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_rtl_tcp.py +0 -0
- {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_time_consensus.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rdsclock
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Passive RDS Clock-Time receiver for FM via RTL-SDR, with multi-source consensus for GPS-denied environments
|
|
5
5
|
Author-email: Mateusz Klatt <mateusz@klatt.ie>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -26,6 +26,8 @@ Provides-Extra: audio
|
|
|
26
26
|
Requires-Dist: sounddevice>=0.4.6; extra == "audio"
|
|
27
27
|
Provides-Extra: plot
|
|
28
28
|
Requires-Dist: matplotlib>=3.7.0; extra == "plot"
|
|
29
|
+
Provides-Extra: fast
|
|
30
|
+
Requires-Dist: numba>=0.60.0; extra == "fast"
|
|
29
31
|
Provides-Extra: dev
|
|
30
32
|
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
31
33
|
Requires-Dist: pytest-timeout>=2.1.0; extra == "dev"
|
|
@@ -265,11 +267,13 @@ the local antenna picks up.
|
|
|
265
267
|
|
|
266
268
|
## Status
|
|
267
269
|
|
|
268
|
-
- **0.2.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
270
|
+
- **0.2.2** — current release. Operator-facing polish: 250 kS/s SDR
|
|
271
|
+
captures can now play at 48 kHz audio via rational resampling, and
|
|
272
|
+
Programme Service names are displayed only after validation so
|
|
273
|
+
dynamic-PS stations do not show mixed scrolling fragments. Decoder
|
|
274
|
+
group counts and PI codes remain locked to the 0.2.1 baseline.
|
|
275
|
+
Pre-1.0; the CLI and on-disk formats may still change.
|
|
276
|
+
- 240+ tests including a real-IQ regression backed by a 6 s capture
|
|
273
277
|
of Polskie Radio Trójka 98.8 from Warsaw; line coverage **100 %**
|
|
274
278
|
(tracked by SonarCloud).
|
|
275
279
|
|
|
@@ -229,11 +229,13 @@ the local antenna picks up.
|
|
|
229
229
|
|
|
230
230
|
## Status
|
|
231
231
|
|
|
232
|
-
- **0.2.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
232
|
+
- **0.2.2** — current release. Operator-facing polish: 250 kS/s SDR
|
|
233
|
+
captures can now play at 48 kHz audio via rational resampling, and
|
|
234
|
+
Programme Service names are displayed only after validation so
|
|
235
|
+
dynamic-PS stations do not show mixed scrolling fragments. Decoder
|
|
236
|
+
group counts and PI codes remain locked to the 0.2.1 baseline.
|
|
237
|
+
Pre-1.0; the CLI and on-disk formats may still change.
|
|
238
|
+
- 240+ tests including a real-IQ regression backed by a 6 s capture
|
|
237
239
|
of Polskie Radio Trójka 98.8 from Warsaw; line coverage **100 %**
|
|
238
240
|
(tracked by SonarCloud).
|
|
239
241
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rdsclock"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Passive RDS Clock-Time receiver for FM via RTL-SDR, with multi-source consensus for GPS-denied environments"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -36,6 +36,9 @@ audio = [
|
|
|
36
36
|
plot = [
|
|
37
37
|
"matplotlib>=3.7.0",
|
|
38
38
|
]
|
|
39
|
+
fast = [
|
|
40
|
+
"numba>=0.60.0",
|
|
41
|
+
]
|
|
39
42
|
dev = [
|
|
40
43
|
"pytest>=7.4.0",
|
|
41
44
|
"pytest-timeout>=2.1.0",
|
|
@@ -10,7 +10,7 @@ audio sink is needed; the rest of the package works without it.
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import numpy as np
|
|
13
|
-
from scipy.signal import firwin, lfilter, lfilter_zi
|
|
13
|
+
from scipy.signal import firwin, lfilter, lfilter_zi, resample_poly
|
|
14
14
|
|
|
15
15
|
from .rtl_tcp import RtlTcpClient
|
|
16
16
|
|
|
@@ -33,12 +33,11 @@ def design_audio_filter(
|
|
|
33
33
|
) -> tuple[np.ndarray, int]:
|
|
34
34
|
"""Design the mono-audio LPF used during decimation from ``fs_in`` to ``fs_out``.
|
|
35
35
|
|
|
36
|
-
Returns ``(taps, decimation_factor)``.
|
|
37
|
-
|
|
36
|
+
Returns ``(taps, decimation_factor)``. Integer SDR/audio ratios return the
|
|
37
|
+
real decimation factor; rational ratios return ``1`` so callers can apply
|
|
38
|
+
polyphase resampling after the same low-pass stage.
|
|
38
39
|
"""
|
|
39
|
-
if fs_in % fs_out
|
|
40
|
-
raise ValueError(f"SDR sample rate ({fs_in}) must be a multiple of audio rate ({fs_out})")
|
|
41
|
-
decim = fs_in // fs_out
|
|
40
|
+
decim = fs_in // fs_out if fs_in % fs_out == 0 else 1
|
|
42
41
|
nyq = fs_in / 2.0
|
|
43
42
|
taps = firwin(numtaps=numtaps, cutoff=cutoff_hz / nyq).astype(np.float32)
|
|
44
43
|
return taps, decim
|
|
@@ -53,13 +52,15 @@ def fm_audio_from_iq(
|
|
|
53
52
|
) -> np.ndarray:
|
|
54
53
|
"""Convert an IQ buffer into a mono audio float32 buffer at ``fs_out``.
|
|
55
54
|
|
|
56
|
-
Steps: FM phase demodulation → LPF (cutoff = ``cutoff_hz``) →
|
|
57
|
-
to ``fs_out`` → optional peak normalisation to ±0.5 to avoid
|
|
55
|
+
Steps: FM phase demodulation → LPF (cutoff = ``cutoff_hz``) → rate
|
|
56
|
+
conversion to ``fs_out`` → optional peak normalisation to ±0.5 to avoid
|
|
57
|
+
clipping. Integer SDR/audio ratios use the fast FIR decimation path;
|
|
58
|
+
rational ratios use polyphase resampling after the same LPF stage.
|
|
58
59
|
"""
|
|
59
60
|
taps, decim = design_audio_filter(fs_in, fs_out, cutoff_hz=cutoff_hz)
|
|
60
61
|
fm = fm_demod(iq)
|
|
61
62
|
audio, _ = lfilter(taps, 1.0, fm), None
|
|
62
|
-
audio = audio[::decim]
|
|
63
|
+
audio = audio[::decim] if fs_in % fs_out == 0 else resample_poly(audio, fs_out, fs_in)
|
|
63
64
|
if normalise:
|
|
64
65
|
peak = float(np.max(np.abs(audio)) + 1e-6)
|
|
65
66
|
audio = (audio / peak) * 0.5
|
|
@@ -88,8 +89,10 @@ def play_iq_live(
|
|
|
88
89
|
) from exc
|
|
89
90
|
|
|
90
91
|
chunk_samples = chunk_samples or fs_sdr // 10 # 100 ms per block
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
integer_decim = fs_sdr % fs_audio == 0
|
|
93
|
+
if integer_decim:
|
|
94
|
+
taps, decim = design_audio_filter(fs_sdr, fs_audio)
|
|
95
|
+
zi = lfilter_zi(taps, 1.0)
|
|
93
96
|
|
|
94
97
|
sd.default.samplerate = fs_audio
|
|
95
98
|
sd.default.channels = 1
|
|
@@ -117,11 +120,14 @@ def play_iq_live(
|
|
|
117
120
|
try:
|
|
118
121
|
while True:
|
|
119
122
|
iq = client.read_iq(chunk_samples, settle_s=0.0)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
if integer_decim:
|
|
124
|
+
fm = fm_demod(iq)
|
|
125
|
+
audio, zi = lfilter(taps, 1.0, fm, zi=zi)
|
|
126
|
+
audio = audio[::decim]
|
|
127
|
+
peak = float(np.max(np.abs(audio)) + 1e-6)
|
|
128
|
+
audio = (audio / peak) * 0.5
|
|
129
|
+
else:
|
|
130
|
+
audio = fm_audio_from_iq(iq, fs_in=fs_sdr, fs_out=fs_audio)
|
|
125
131
|
stream.write(audio.astype("float32"))
|
|
126
132
|
except KeyboardInterrupt:
|
|
127
133
|
print("\nInterrupted by operator (Ctrl+C).")
|
|
@@ -151,20 +157,7 @@ def play_iq_file(
|
|
|
151
157
|
if len(iq) == 0 or np.max(np.abs(iq[:1000])) > 100:
|
|
152
158
|
iq = dsp.read_iq_u8(path)
|
|
153
159
|
|
|
154
|
-
|
|
155
|
-
# reach 50 kS/s, then by ~1.04 to 48 kHz. We keep it simple: design a
|
|
156
|
-
# filter using a chosen integer ratio and accept whatever audio rate
|
|
157
|
-
# the file allows.
|
|
158
|
-
if fs_in % fs_audio == 0:
|
|
159
|
-
audio = fm_audio_from_iq(iq, fs_in=fs_in, fs_out=fs_audio)
|
|
160
|
-
else:
|
|
161
|
-
# Fall back: drop to the largest fs_audio divisor of fs_in.
|
|
162
|
-
from math import gcd
|
|
163
|
-
|
|
164
|
-
target = gcd(fs_in, fs_audio)
|
|
165
|
-
audio = fm_audio_from_iq(iq, fs_in=fs_in, fs_out=target)
|
|
166
|
-
fs_audio = target
|
|
167
|
-
|
|
160
|
+
audio = fm_audio_from_iq(iq, fs_in=fs_in, fs_out=fs_audio)
|
|
168
161
|
duration_s = len(audio) / fs_audio
|
|
169
162
|
print(f"Playing {path}: {duration_s:.1f}s of audio @ {fs_audio} Hz (Ctrl+C to stop)…")
|
|
170
163
|
try:
|
|
@@ -8,6 +8,11 @@ for FM-demodulated baseband).
|
|
|
8
8
|
import numpy as np
|
|
9
9
|
from scipy.signal import filtfilt, firwin, lfilter, resample_poly
|
|
10
10
|
|
|
11
|
+
try:
|
|
12
|
+
from numba import njit as _numba_njit
|
|
13
|
+
except ImportError: # pragma: no cover - optional acceleration dependency
|
|
14
|
+
_numba_njit = None
|
|
15
|
+
|
|
11
16
|
# RDS / FM constants
|
|
12
17
|
FM_CHANNEL_BW_HZ = 100_000 # half-width of an FM channel (~200 kHz total)
|
|
13
18
|
RDS_CARRIER_HZ = 57_000
|
|
@@ -165,9 +170,7 @@ def symbol_lpf(x: np.ndarray, fs: float, cutoff: float = SYMBOL_LPF_HZ) -> np.nd
|
|
|
165
170
|
return filtfilt(taps, [1.0], x)
|
|
166
171
|
|
|
167
172
|
|
|
168
|
-
def
|
|
169
|
-
"""Second-order Costas loop for BPSK. Stabilises carrier phase."""
|
|
170
|
-
samples = np.asarray(samples, dtype=np.complex64)
|
|
173
|
+
def _costas_loop_bpsk_python(samples: np.ndarray, alpha: float, beta: float) -> np.ndarray:
|
|
171
174
|
out = np.empty_like(samples)
|
|
172
175
|
phase = 0.0
|
|
173
176
|
freq = 0.0
|
|
@@ -184,6 +187,19 @@ def costas_loop_bpsk(samples: np.ndarray, alpha: float = 0.3, beta: float = 0.00
|
|
|
184
187
|
return out
|
|
185
188
|
|
|
186
189
|
|
|
190
|
+
_COSTAS_LOOP_BPSK_NUMBA = (
|
|
191
|
+
_numba_njit(cache=True)(_costas_loop_bpsk_python) if _numba_njit is not None else None
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def costas_loop_bpsk(samples: np.ndarray, alpha: float = 0.3, beta: float = 0.005) -> np.ndarray:
|
|
196
|
+
"""Second-order Costas loop for BPSK. Stabilises carrier phase."""
|
|
197
|
+
samples = np.asarray(samples, dtype=np.complex64)
|
|
198
|
+
if _COSTAS_LOOP_BPSK_NUMBA is not None:
|
|
199
|
+
return _COSTAS_LOOP_BPSK_NUMBA(samples, alpha, beta) # pragma: no cover
|
|
200
|
+
return _costas_loop_bpsk_python(samples, alpha, beta)
|
|
201
|
+
|
|
202
|
+
|
|
187
203
|
def agc(samples: np.ndarray, eps: float = 1e-9) -> np.ndarray:
|
|
188
204
|
"""Normalize complex baseband amplitude by its mean magnitude."""
|
|
189
205
|
samples = np.asarray(samples, dtype=np.complex64)
|
|
@@ -41,23 +41,40 @@ OFFSET_BY_NAME = {
|
|
|
41
41
|
GROUP_BLOCKS = 4
|
|
42
42
|
GROUP_BITS = BLOCK_BITS * GROUP_BLOCKS # 104
|
|
43
43
|
|
|
44
|
+
_BLOCK_WORD_MASK = (1 << CRC_BITS) - 1
|
|
45
|
+
_DATA_WORD_MASK = (1 << DATA_BITS) - 1
|
|
46
|
+
_CRC_TOP_BIT = 1 << CRC_BITS
|
|
47
|
+
_CRC_POLY_WITH_TOP_BIT = CRC_POLY | _CRC_TOP_BIT
|
|
48
|
+
_BLOCK_WEIGHTS = (1 << np.arange(BLOCK_BITS - 1, -1, -1, dtype=np.uint32)).astype(np.uint32)
|
|
44
49
|
|
|
45
|
-
def crc10(dataword: int) -> int:
|
|
46
|
-
"""Compute the 10-bit RDS CRC of a 16-bit dataword.
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
reg = 0
|
|
51
|
+
def _build_crc10_table() -> np.ndarray:
|
|
52
|
+
datawords = np.arange(1 << DATA_BITS, dtype=np.uint32)
|
|
53
|
+
reg = np.zeros_like(datawords)
|
|
52
54
|
for i in range(DATA_BITS - 1, -1, -1):
|
|
53
|
-
reg = (reg << 1) | ((
|
|
54
|
-
|
|
55
|
-
reg ^= CRC_POLY | (1 << CRC_BITS)
|
|
55
|
+
reg = (reg << 1) | ((datawords >> i) & 1)
|
|
56
|
+
reg = np.where((reg & _CRC_TOP_BIT) != 0, reg ^ _CRC_POLY_WITH_TOP_BIT, reg)
|
|
56
57
|
for _ in range(CRC_BITS):
|
|
57
58
|
reg <<= 1
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
reg = np.where((reg & _CRC_TOP_BIT) != 0, reg ^ _CRC_POLY_WITH_TOP_BIT, reg)
|
|
60
|
+
return (reg & _BLOCK_WORD_MASK).astype(np.uint16)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_CRC10_TABLE = _build_crc10_table()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _crc10_many(datawords: np.ndarray) -> np.ndarray:
|
|
67
|
+
return _CRC10_TABLE[np.asarray(datawords, dtype=np.uint32) & _DATA_WORD_MASK]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def crc10(dataword: int) -> int:
|
|
71
|
+
"""Compute the 10-bit RDS CRC of a 16-bit dataword.
|
|
72
|
+
|
|
73
|
+
Table-driven equivalent of the standard shift-register implementation:
|
|
74
|
+
feed 16 data bits into a 10-bit register tapped by ``CRC_POLY``, then
|
|
75
|
+
flush 10 zero bits.
|
|
76
|
+
"""
|
|
77
|
+
return int(_CRC10_TABLE[int(dataword) & _DATA_WORD_MASK])
|
|
61
78
|
|
|
62
79
|
|
|
63
80
|
def encode_block(dataword: int, offset_word: int) -> int:
|
|
@@ -73,7 +90,7 @@ def block_dataword(block26: int) -> int:
|
|
|
73
90
|
|
|
74
91
|
|
|
75
92
|
def block_checkword(block26: int) -> int:
|
|
76
|
-
return block26 &
|
|
93
|
+
return block26 & _BLOCK_WORD_MASK
|
|
77
94
|
|
|
78
95
|
|
|
79
96
|
def block_valid(block26: int, block_no: int, version_b: bool | None = None) -> bool:
|
|
@@ -120,12 +137,30 @@ def blocks_to_bits(blocks: Iterable[int]) -> np.ndarray:
|
|
|
120
137
|
|
|
121
138
|
def bits_to_word(bits: Sequence[int]) -> int:
|
|
122
139
|
"""Pack a bit sequence into an int (MSB first)."""
|
|
140
|
+
bit_array = np.asarray(bits, dtype=np.uint8)
|
|
141
|
+
if bit_array.size == BLOCK_BITS:
|
|
142
|
+
return int(_bits_to_words_26(bit_array))
|
|
123
143
|
word = 0
|
|
124
|
-
for b in
|
|
144
|
+
for b in bit_array:
|
|
125
145
|
word = (word << 1) | (int(b) & 1)
|
|
126
146
|
return word
|
|
127
147
|
|
|
128
148
|
|
|
149
|
+
def _bits_to_words_26(bit_windows: np.ndarray, assume_binary: bool = False) -> np.ndarray:
|
|
150
|
+
bit_windows = np.asarray(bit_windows, dtype=np.uint8)
|
|
151
|
+
if not assume_binary:
|
|
152
|
+
bit_windows = np.bitwise_and(bit_windows, 1)
|
|
153
|
+
return bit_windows @ _BLOCK_WEIGHTS
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _coerce_bits(bits: np.ndarray) -> np.ndarray:
|
|
157
|
+
if not isinstance(bits, np.ndarray):
|
|
158
|
+
bits = np.asarray(bits, dtype=np.uint8)
|
|
159
|
+
if bits.dtype != np.uint8:
|
|
160
|
+
bits = bits.astype(np.uint8)
|
|
161
|
+
return np.bitwise_and(bits, 1)
|
|
162
|
+
|
|
163
|
+
|
|
129
164
|
def find_groups_in_bitstream(bits: np.ndarray) -> list[bytearray]:
|
|
130
165
|
"""Slide over a bitstream and extract groups of 4 consecutively valid blocks.
|
|
131
166
|
|
|
@@ -135,52 +170,57 @@ def find_groups_in_bitstream(bits: np.ndarray) -> list[bytearray]:
|
|
|
135
170
|
|
|
136
171
|
Returns a list of 8-byte bytearrays (4 × big-endian 16-bit datawords).
|
|
137
172
|
"""
|
|
138
|
-
|
|
139
|
-
bits = np.asarray(bits, dtype=np.uint8)
|
|
140
|
-
if bits.dtype != np.uint8:
|
|
141
|
-
bits = bits.astype(np.uint8)
|
|
142
|
-
|
|
173
|
+
bits = _coerce_bits(bits)
|
|
143
174
|
n = len(bits)
|
|
144
175
|
groups: list[bytearray] = []
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
176
|
+
if n < GROUP_BITS:
|
|
177
|
+
return groups
|
|
178
|
+
|
|
179
|
+
windows = np.lib.stride_tricks.sliding_window_view(bits, BLOCK_BITS)
|
|
180
|
+
words = _bits_to_words_26(windows, assume_binary=True)
|
|
181
|
+
data = (words >> CRC_BITS) & _DATA_WORD_MASK
|
|
182
|
+
check = words & _BLOCK_WORD_MASK
|
|
183
|
+
expected = _crc10_many(data)
|
|
184
|
+
|
|
185
|
+
valid_a = (check ^ OFFSET_A) == expected
|
|
186
|
+
valid_b = (check ^ OFFSET_B) == expected
|
|
187
|
+
valid_c = (check ^ OFFSET_C) == expected
|
|
188
|
+
valid_c_prime = (check ^ OFFSET_C_PRIME) == expected
|
|
189
|
+
valid_d = (check ^ OFFSET_D) == expected
|
|
190
|
+
|
|
191
|
+
start_count = n - GROUP_BITS + 1
|
|
192
|
+
block_b_data = data[BLOCK_BITS : BLOCK_BITS + start_count]
|
|
193
|
+
version_b = ((block_b_data >> 11) & 1).astype(bool)
|
|
194
|
+
block_c_valid = np.where(
|
|
195
|
+
version_b,
|
|
196
|
+
valid_c_prime[2 * BLOCK_BITS : 2 * BLOCK_BITS + start_count],
|
|
197
|
+
valid_c[2 * BLOCK_BITS : 2 * BLOCK_BITS + start_count],
|
|
198
|
+
)
|
|
199
|
+
group_starts = np.flatnonzero(
|
|
200
|
+
valid_a[:start_count]
|
|
201
|
+
& valid_b[BLOCK_BITS : BLOCK_BITS + start_count]
|
|
202
|
+
& block_c_valid
|
|
203
|
+
& valid_d[3 * BLOCK_BITS : 3 * BLOCK_BITS + start_count]
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
next_scan_start = 0
|
|
207
|
+
group_offsets = np.array([0, BLOCK_BITS, 2 * BLOCK_BITS, 3 * BLOCK_BITS], dtype=np.intp)
|
|
208
|
+
for start in group_starts:
|
|
209
|
+
group_start = int(start)
|
|
210
|
+
if group_start >= next_scan_start:
|
|
211
|
+
buf = bytearray(8)
|
|
212
|
+
for idx, dw_raw in enumerate(data[group_start + group_offsets]):
|
|
213
|
+
dw = int(dw_raw)
|
|
214
|
+
buf[idx * 2] = (dw >> 8) & 0xFF
|
|
215
|
+
buf[idx * 2 + 1] = dw & 0xFF
|
|
216
|
+
groups.append(buf)
|
|
217
|
+
next_scan_start = group_start + GROUP_BITS
|
|
177
218
|
return groups
|
|
178
219
|
|
|
179
220
|
|
|
180
221
|
def count_valid_blocks(bits: np.ndarray) -> int:
|
|
181
222
|
"""Diagnostic: count 26-bit windows that pass CRC for any block position 0..3."""
|
|
182
|
-
|
|
183
|
-
bits = np.asarray(bits, dtype=np.uint8)
|
|
223
|
+
bits = _coerce_bits(bits)
|
|
184
224
|
total = 0
|
|
185
225
|
for i in range(0, len(bits) - BLOCK_BITS + 1, BLOCK_BITS):
|
|
186
226
|
word = bits_to_word(bits[i : i + BLOCK_BITS])
|
|
@@ -13,6 +13,7 @@ Supported types:
|
|
|
13
13
|
4A — Clock-Time
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
+
from collections import deque
|
|
16
17
|
from collections.abc import Iterable, Sequence
|
|
17
18
|
from dataclasses import dataclass, field
|
|
18
19
|
from datetime import datetime
|
|
@@ -31,6 +32,10 @@ class StationInfo:
|
|
|
31
32
|
pi: int | None = None
|
|
32
33
|
pty: int | None = None
|
|
33
34
|
ps_chars: list[str] = field(default_factory=lambda: [" "] * PS_LEN)
|
|
35
|
+
_ps_candidate: list[str] = field(default_factory=lambda: [" "] * PS_LEN)
|
|
36
|
+
_ps_segments_seen: int = 0
|
|
37
|
+
_ps_history: deque[str] = field(default_factory=lambda: deque(maxlen=4))
|
|
38
|
+
_ps_stable_count: int = 0
|
|
34
39
|
rt_chars: list[str] = field(default_factory=lambda: [" "] * RT_LEN)
|
|
35
40
|
rt_ab_flag: int | None = None
|
|
36
41
|
clock_times: list[ClockTime] = field(default_factory=list)
|
|
@@ -38,8 +43,25 @@ class StationInfo:
|
|
|
38
43
|
|
|
39
44
|
@property
|
|
40
45
|
def ps_name(self) -> str:
|
|
46
|
+
"""Validated Programme Service name.
|
|
47
|
+
|
|
48
|
+
Dynamic-PS stations often scroll text through the PS field. This value
|
|
49
|
+
remains empty until the same complete 8-character frame has been seen
|
|
50
|
+
in two consecutive PS completion cycles, then exposes the validated
|
|
51
|
+
frame with the historical right-padding stripped.
|
|
52
|
+
"""
|
|
41
53
|
return "".join(self.ps_chars).rstrip()
|
|
42
54
|
|
|
55
|
+
@property
|
|
56
|
+
def validated_ps_name(self) -> str:
|
|
57
|
+
"""Alias for :attr:`ps_name`, explicit about the validation semantics."""
|
|
58
|
+
return self.ps_name
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def latest_ps_candidate(self) -> str:
|
|
62
|
+
"""Most recently received complete 8-character PS frame, without validation."""
|
|
63
|
+
return self._ps_history[-1] if self._ps_history else ""
|
|
64
|
+
|
|
43
65
|
@property
|
|
44
66
|
def rt_text(self) -> str:
|
|
45
67
|
text = "".join(self.rt_chars)
|
|
@@ -83,8 +105,23 @@ def parse_group(group_bytes: Sequence[int], info: StationInfo) -> StationInfo:
|
|
|
83
105
|
if group_type == 0:
|
|
84
106
|
seg = b & 0x3 # 2-bit segment address (0..3)
|
|
85
107
|
# Block D carries two PS characters
|
|
86
|
-
|
|
87
|
-
info.
|
|
108
|
+
bit = 1 << seg
|
|
109
|
+
if not info._ps_segments_seen & bit:
|
|
110
|
+
info._ps_candidate[seg * 2] = _safe_char((d >> 8) & 0xFF)
|
|
111
|
+
info._ps_candidate[seg * 2 + 1] = _safe_char(d & 0xFF)
|
|
112
|
+
info._ps_segments_seen |= bit
|
|
113
|
+
if info._ps_segments_seen == 0b1111:
|
|
114
|
+
candidate = "".join(info._ps_candidate)
|
|
115
|
+
previous = info._ps_history[-1] if info._ps_history else None
|
|
116
|
+
info._ps_history.append(candidate)
|
|
117
|
+
if candidate == previous:
|
|
118
|
+
info._ps_stable_count += 1
|
|
119
|
+
else:
|
|
120
|
+
info._ps_stable_count = 1
|
|
121
|
+
if info._ps_stable_count >= 2:
|
|
122
|
+
info.ps_chars[:] = list(candidate)
|
|
123
|
+
info._ps_candidate = [" "] * PS_LEN
|
|
124
|
+
info._ps_segments_seen = 0
|
|
88
125
|
|
|
89
126
|
elif group_type == 2:
|
|
90
127
|
ab_flag = (b >> 4) & 0x1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rdsclock
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Passive RDS Clock-Time receiver for FM via RTL-SDR, with multi-source consensus for GPS-denied environments
|
|
5
5
|
Author-email: Mateusz Klatt <mateusz@klatt.ie>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -26,6 +26,8 @@ Provides-Extra: audio
|
|
|
26
26
|
Requires-Dist: sounddevice>=0.4.6; extra == "audio"
|
|
27
27
|
Provides-Extra: plot
|
|
28
28
|
Requires-Dist: matplotlib>=3.7.0; extra == "plot"
|
|
29
|
+
Provides-Extra: fast
|
|
30
|
+
Requires-Dist: numba>=0.60.0; extra == "fast"
|
|
29
31
|
Provides-Extra: dev
|
|
30
32
|
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
31
33
|
Requires-Dist: pytest-timeout>=2.1.0; extra == "dev"
|
|
@@ -265,11 +267,13 @@ the local antenna picks up.
|
|
|
265
267
|
|
|
266
268
|
## Status
|
|
267
269
|
|
|
268
|
-
- **0.2.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
270
|
+
- **0.2.2** — current release. Operator-facing polish: 250 kS/s SDR
|
|
271
|
+
captures can now play at 48 kHz audio via rational resampling, and
|
|
272
|
+
Programme Service names are displayed only after validation so
|
|
273
|
+
dynamic-PS stations do not show mixed scrolling fragments. Decoder
|
|
274
|
+
group counts and PI codes remain locked to the 0.2.1 baseline.
|
|
275
|
+
Pre-1.0; the CLI and on-disk formats may still change.
|
|
276
|
+
- 240+ tests including a real-IQ regression backed by a 6 s capture
|
|
273
277
|
of Polskie Radio Trójka 98.8 from Warsaw; line coverage **100 %**
|
|
274
278
|
(tracked by SonarCloud).
|
|
275
279
|
|
|
@@ -41,9 +41,10 @@ class TestDesignAudioFilter:
|
|
|
41
41
|
assert taps.dtype == np.float32
|
|
42
42
|
assert len(taps) == 129 # default numtaps
|
|
43
43
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
def test_non_integer_ratio_returns_filter_without_decimation(self):
|
|
45
|
+
taps, decim = design_audio_filter(1_000_000, 48_000)
|
|
46
|
+
assert decim == 1
|
|
47
|
+
assert taps.dtype == np.float32
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
class TestFmAudioFromIq:
|
|
@@ -65,6 +66,18 @@ class TestFmAudioFromIq:
|
|
|
65
66
|
# Normalisation pushes peak to 0.5.
|
|
66
67
|
assert np.max(np.abs(audio)) == pytest.approx(0.5, rel=0.01)
|
|
67
68
|
|
|
69
|
+
def test_rational_resampling_ratio_succeeds(self):
|
|
70
|
+
fs_in = 250_000
|
|
71
|
+
fs_out = 48_000
|
|
72
|
+
n = np.arange(fs_in // 10)
|
|
73
|
+
iq = np.exp(1j * 2 * np.pi * 1_000 * n / fs_in).astype(np.complex64)
|
|
74
|
+
|
|
75
|
+
audio = fm_audio_from_iq(iq, fs_in=fs_in, fs_out=fs_out)
|
|
76
|
+
|
|
77
|
+
expected_len = len(iq) * fs_out / fs_in
|
|
78
|
+
assert abs(len(audio) - expected_len) <= 2
|
|
79
|
+
assert audio.dtype == np.float32
|
|
80
|
+
|
|
68
81
|
def test_skip_normalisation(self):
|
|
69
82
|
fs_in = 1_200_000
|
|
70
83
|
n = np.arange(fs_in // 2)
|
|
@@ -160,7 +160,7 @@ def test_audio_play_file_complex64_and_u8_paths(tmp_path, monkeypatch):
|
|
|
160
160
|
u8_path = tmp_path / "u8-ish.iq"
|
|
161
161
|
np.full(512, 200 + 0j, dtype=np.complex64).tofile(u8_path)
|
|
162
162
|
play_iq_file(str(u8_path), fs_in=50_000, fs_audio=48_000)
|
|
163
|
-
assert sd.play_calls[-1][1] ==
|
|
163
|
+
assert sd.play_calls[-1][1] == 48_000
|
|
164
164
|
|
|
165
165
|
|
|
166
166
|
def test_audio_play_file_keyboard_interrupt_stops(tmp_path, monkeypatch, capsys):
|
|
@@ -207,6 +207,31 @@ def test_audio_play_live_streams_until_keyboard_interrupt(
|
|
|
207
207
|
assert "Interrupted" in capsys.readouterr().out
|
|
208
208
|
|
|
209
209
|
|
|
210
|
+
def test_audio_play_live_supports_rational_audio_rate(monkeypatch, capsys):
|
|
211
|
+
sd = _FakeSoundDevice()
|
|
212
|
+
clients: list[_FakeAudioClient] = []
|
|
213
|
+
|
|
214
|
+
class RecordingClient(_FakeAudioClient):
|
|
215
|
+
def __init__(self, *args, **kwargs):
|
|
216
|
+
super().__init__(*args, **kwargs)
|
|
217
|
+
clients.append(self)
|
|
218
|
+
|
|
219
|
+
monkeypatch.setitem(sys.modules, "sounddevice", sd)
|
|
220
|
+
monkeypatch.setattr("rdsclock.audio.RtlTcpClient", RecordingClient)
|
|
221
|
+
|
|
222
|
+
play_iq_live(
|
|
223
|
+
95.5,
|
|
224
|
+
fs_sdr=50_000,
|
|
225
|
+
fs_audio=48_000,
|
|
226
|
+
chunk_samples=256,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
assert ("sample_rate", 50_000) in clients[0].calls
|
|
230
|
+
assert sd.stream_writes
|
|
231
|
+
assert sd.stream_writes[0].dtype == np.float32
|
|
232
|
+
assert "Interrupted" in capsys.readouterr().out
|
|
233
|
+
|
|
234
|
+
|
|
210
235
|
@pytest.mark.parametrize("func,args", [(play_iq_file, ("x.iq",)), (play_iq_live, (95.5,))])
|
|
211
236
|
def test_audio_play_requires_sounddevice(func, args, monkeypatch):
|
|
212
237
|
real_import = builtins.__import__
|
|
@@ -171,7 +171,7 @@ class TestAudioPlaybackCoverage:
|
|
|
171
171
|
):
|
|
172
172
|
audio.play_iq_file("recording.iq", fs_in=250_000, fs_audio=48_000)
|
|
173
173
|
|
|
174
|
-
fm_audio_from_iq.assert_called_once_with(iq, fs_in=250_000, fs_out=
|
|
174
|
+
fm_audio_from_iq.assert_called_once_with(iq, fs_in=250_000, fs_out=48_000)
|
|
175
175
|
sd_stop.assert_called_once()
|
|
176
176
|
|
|
177
177
|
|
|
@@ -78,6 +78,19 @@ class TestCostas:
|
|
|
78
78
|
# After a short transient, the signal still has constant phase
|
|
79
79
|
assert np.max(np.abs(np.imag(out[20:]))) < 1.0
|
|
80
80
|
|
|
81
|
+
def test_python_fallback_matches_public_wrapper(self, monkeypatch):
|
|
82
|
+
samples = np.array([1 + 0j, 0.5 + 0.2j, -1 + 0.1j], dtype=np.complex64)
|
|
83
|
+
monkeypatch.setattr(dsp, "_COSTAS_LOOP_BPSK_NUMBA", None)
|
|
84
|
+
wrapper = dsp.costas_loop_bpsk(samples, alpha=0.1, beta=0.002)
|
|
85
|
+
direct = dsp._costas_loop_bpsk_python(samples, alpha=0.1, beta=0.002)
|
|
86
|
+
np.testing.assert_array_equal(wrapper, direct)
|
|
87
|
+
|
|
88
|
+
def test_python_fallback_wraps_phase_both_directions(self):
|
|
89
|
+
positive = np.array([1 + 1j, 1 + 0j], dtype=np.complex64)
|
|
90
|
+
negative = np.array([1 - 1j, 1 + 0j], dtype=np.complex64)
|
|
91
|
+
assert dsp._costas_loop_bpsk_python(positive, alpha=4.0, beta=0.0).shape == positive.shape
|
|
92
|
+
assert dsp._costas_loop_bpsk_python(negative, alpha=4.0, beta=0.0).shape == negative.shape
|
|
93
|
+
|
|
81
94
|
|
|
82
95
|
class TestBiphaseRecovery:
|
|
83
96
|
def test_agc_normalizes_mean_magnitude(self):
|
|
@@ -10,6 +10,8 @@ from rdsclock.rds_blocks import (
|
|
|
10
10
|
OFFSET_C,
|
|
11
11
|
OFFSET_C_PRIME,
|
|
12
12
|
OFFSETS,
|
|
13
|
+
_bits_to_words_26,
|
|
14
|
+
_crc10_many,
|
|
13
15
|
bits_to_word,
|
|
14
16
|
block_dataword,
|
|
15
17
|
block_valid,
|
|
@@ -25,6 +27,19 @@ from rdsclock.rds_blocks import (
|
|
|
25
27
|
)
|
|
26
28
|
|
|
27
29
|
|
|
30
|
+
def _crc10_reference(dataword: int) -> int:
|
|
31
|
+
reg = 0
|
|
32
|
+
for i in range(DATA_BITS - 1, -1, -1):
|
|
33
|
+
reg = (reg << 1) | ((dataword >> i) & 1)
|
|
34
|
+
if reg & (1 << 10):
|
|
35
|
+
reg ^= 0x5B9 | (1 << 10)
|
|
36
|
+
for _ in range(10):
|
|
37
|
+
reg <<= 1
|
|
38
|
+
if reg & (1 << 10):
|
|
39
|
+
reg ^= 0x5B9 | (1 << 10)
|
|
40
|
+
return reg & ((1 << 10) - 1)
|
|
41
|
+
|
|
42
|
+
|
|
28
43
|
class TestCrc10:
|
|
29
44
|
def test_zero_input(self):
|
|
30
45
|
assert crc10(0) == 0
|
|
@@ -45,6 +60,16 @@ class TestCrc10:
|
|
|
45
60
|
b = crc10(0x1235)
|
|
46
61
|
assert a != b
|
|
47
62
|
|
|
63
|
+
def test_matches_shift_register_for_full_u16_space(self):
|
|
64
|
+
expected = np.fromiter(
|
|
65
|
+
(_crc10_reference(v) for v in range(1 << DATA_BITS)),
|
|
66
|
+
dtype=np.uint16,
|
|
67
|
+
count=1 << DATA_BITS,
|
|
68
|
+
)
|
|
69
|
+
actual = _crc10_many(np.arange(1 << DATA_BITS, dtype=np.uint32))
|
|
70
|
+
np.testing.assert_array_equal(actual, expected)
|
|
71
|
+
assert crc10(0x1FFFF) == int(expected[-1])
|
|
72
|
+
|
|
48
73
|
|
|
49
74
|
class TestEncodeBlock:
|
|
50
75
|
def test_roundtrip(self):
|
|
@@ -116,6 +141,14 @@ class TestBitstream:
|
|
|
116
141
|
bits = [1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0]
|
|
117
142
|
assert bits_to_word(bits) == 0xCCCC
|
|
118
143
|
|
|
144
|
+
def test_bits_to_word_26_uses_vectorized_weights(self):
|
|
145
|
+
bits = np.array(
|
|
146
|
+
[1, 0, 1, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1],
|
|
147
|
+
dtype=np.uint8,
|
|
148
|
+
)
|
|
149
|
+
assert bits_to_word(bits) == 0b10110010101011100101101001
|
|
150
|
+
np.testing.assert_array_equal(_bits_to_words_26(bits[None, :]), [bits_to_word(bits)])
|
|
151
|
+
|
|
119
152
|
def test_find_groups_in_clean_bitstream(self):
|
|
120
153
|
# Encode 3 groups with different data, then find them all.
|
|
121
154
|
all_blocks = []
|
|
@@ -152,6 +185,9 @@ class TestBitstream:
|
|
|
152
185
|
a, _, _, _ = group_bytes_to_words(g)
|
|
153
186
|
assert a in (0xBABE, 0xCAFE)
|
|
154
187
|
|
|
188
|
+
def test_find_groups_short_stream_returns_empty(self):
|
|
189
|
+
assert find_groups_in_bitstream(np.zeros(GROUP_BITS - 1, dtype=np.uint8)) == []
|
|
190
|
+
|
|
155
191
|
|
|
156
192
|
class TestDifferential:
|
|
157
193
|
def test_roundtrip(self):
|
|
@@ -14,30 +14,80 @@ from rdsclock.rds_groups import (
|
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _validated_ps_groups(pi: int, ps_name: str) -> list[bytearray]:
|
|
18
|
+
groups = encode_ps_groups(pi=pi, ps_name=ps_name)
|
|
19
|
+
return groups + groups
|
|
20
|
+
|
|
21
|
+
|
|
17
22
|
class TestPsParsing:
|
|
18
23
|
def test_single_segment(self):
|
|
19
24
|
g = encode_group_0a(pi=0xABCD, ps_segment_index=0, ps_chars="RM")
|
|
20
25
|
info = StationInfo()
|
|
21
26
|
parse_group(g, info)
|
|
22
27
|
assert info.pi == 0xABCD
|
|
23
|
-
assert info.ps_name == "
|
|
28
|
+
assert info.ps_name == ""
|
|
29
|
+
assert info.latest_ps_candidate == ""
|
|
24
30
|
|
|
25
31
|
def test_all_segments(self):
|
|
26
|
-
groups =
|
|
32
|
+
groups = _validated_ps_groups(pi=0x3F44, ps_name="RMF FM ")
|
|
27
33
|
info = parse_groups(groups)
|
|
28
34
|
assert info.pi == 0x3F44
|
|
29
35
|
assert info.ps_name == "RMF FM"
|
|
36
|
+
assert info.validated_ps_name == "RMF FM"
|
|
37
|
+
assert info.latest_ps_candidate == "RMF FM "
|
|
30
38
|
|
|
31
39
|
def test_short_name_padded(self):
|
|
32
|
-
groups =
|
|
40
|
+
groups = _validated_ps_groups(pi=0x1234, ps_name="ABC")
|
|
33
41
|
info = parse_groups(groups)
|
|
34
42
|
assert info.ps_name == "ABC"
|
|
35
43
|
|
|
36
44
|
def test_full_eight_chars(self):
|
|
37
|
-
groups =
|
|
45
|
+
groups = _validated_ps_groups(pi=0x4321, ps_name="RadioZET")
|
|
38
46
|
info = parse_groups(groups)
|
|
39
47
|
assert info.ps_name == "RadioZET"
|
|
40
48
|
|
|
49
|
+
def test_single_rotation_is_only_latest_candidate(self):
|
|
50
|
+
groups = encode_ps_groups(pi=0x3001, ps_name="JEDYNKA ")
|
|
51
|
+
info = parse_groups(groups)
|
|
52
|
+
assert info.ps_name == ""
|
|
53
|
+
assert info.latest_ps_candidate == "JEDYNKA "
|
|
54
|
+
assert info._ps_stable_count == 1
|
|
55
|
+
|
|
56
|
+
def test_static_ps_validates_after_two_rotations(self):
|
|
57
|
+
groups = _validated_ps_groups(pi=0x3001, ps_name="JEDYNKA ")
|
|
58
|
+
info = parse_groups(groups)
|
|
59
|
+
assert info.ps_name == "JEDYNKA"
|
|
60
|
+
assert info.validated_ps_name == "JEDYNKA"
|
|
61
|
+
assert info.latest_ps_candidate == "JEDYNKA "
|
|
62
|
+
|
|
63
|
+
def test_dynamic_ps_requires_consecutive_matching_completions(self):
|
|
64
|
+
plus = encode_ps_groups(pi=0x3002, ps_name="+PLUS+ ")
|
|
65
|
+
web = encode_ps_groups(pi=0x3002, ps_name="WWW.RPL ")
|
|
66
|
+
info = parse_groups(plus + web + plus)
|
|
67
|
+
assert info.ps_name == ""
|
|
68
|
+
assert info.latest_ps_candidate == "+PLUS+ "
|
|
69
|
+
assert info._ps_stable_count == 1
|
|
70
|
+
|
|
71
|
+
for group in plus:
|
|
72
|
+
parse_group(group, info)
|
|
73
|
+
|
|
74
|
+
assert info.ps_name == "+PLUS+"
|
|
75
|
+
assert info.latest_ps_candidate == "+PLUS+ "
|
|
76
|
+
assert list(info._ps_history) == ["+PLUS+ ", "WWW.RPL ", "+PLUS+ ", "+PLUS+ "]
|
|
77
|
+
|
|
78
|
+
def test_duplicate_segment_does_not_overwrite_candidate_rotation(self):
|
|
79
|
+
groups = [
|
|
80
|
+
encode_group_0a(pi=0x3003, ps_segment_index=0, ps_chars="JE"),
|
|
81
|
+
encode_group_0a(pi=0x3003, ps_segment_index=0, ps_chars="XX"),
|
|
82
|
+
encode_group_0a(pi=0x3003, ps_segment_index=1, ps_chars="DY"),
|
|
83
|
+
encode_group_0a(pi=0x3003, ps_segment_index=2, ps_chars="NK"),
|
|
84
|
+
encode_group_0a(pi=0x3003, ps_segment_index=3, ps_chars="A "),
|
|
85
|
+
]
|
|
86
|
+
info = parse_groups(groups + groups)
|
|
87
|
+
assert info.ps_name == "JEDYNKA"
|
|
88
|
+
assert info.latest_ps_candidate == "JEDYNKA "
|
|
89
|
+
assert info._ps_segments_seen == 0
|
|
90
|
+
|
|
41
91
|
def test_group_count(self):
|
|
42
92
|
groups = encode_ps_groups(pi=0x1111, ps_name="HELLO ")
|
|
43
93
|
info = parse_groups(groups)
|
|
@@ -35,7 +35,7 @@ class TestBitstreamSynth:
|
|
|
35
35
|
bits = rds_groups_to_bits(ps, differential=False) # no differential coding for simplicity
|
|
36
36
|
groups = find_groups_in_bitstream(bits)
|
|
37
37
|
assert len(groups) >= len(ps)
|
|
38
|
-
info = parse_groups(groups[: len(ps)])
|
|
38
|
+
info = parse_groups(groups[: len(ps)] + groups[: len(ps)])
|
|
39
39
|
assert info.pi == 0xCAFE
|
|
40
40
|
assert info.ps_name == "HELLO"
|
|
41
41
|
|
|
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
|