rdsclock 0.2.2__tar.gz → 0.3.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-0.2.2 → rdsclock-0.3.0}/PKG-INFO +7 -7
- {rdsclock-0.2.2 → rdsclock-0.3.0}/README.md +6 -6
- {rdsclock-0.2.2 → rdsclock-0.3.0}/pyproject.toml +1 -1
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/__init__.py +1 -1
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/decoder.py +53 -11
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/rds_blocks.py +168 -20
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock.egg-info/PKG-INFO +7 -7
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_coverage_extra.py +7 -3
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_rds_blocks.py +94 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_real_iq_regression.py +3 -1
- {rdsclock-0.2.2 → rdsclock-0.3.0}/LICENSE +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/setup.cfg +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/__main__.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/audio.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/channelizer.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/cli.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/dsp.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/plot.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/py.typed +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/rds_clock.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/rds_groups.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/recon.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/rtl_tcp.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/synth.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/time_consensus.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock.egg-info/SOURCES.txt +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock.egg-info/dependency_links.txt +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock.egg-info/entry_points.txt +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock.egg-info/requires.txt +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock.egg-info/top_level.txt +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_audio.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_channelizer.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_cli.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_cli_with_fake_sdr.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_coverage_edges.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_decoder_synthetic.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_dsp.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_plot.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_rds_clock.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_rds_groups.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_real_recordings.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_recon.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_recon_offline.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_rtl_tcp.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_synth.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_time_consensus.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rdsclock
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -267,12 +267,12 @@ the local antenna picks up.
|
|
|
267
267
|
|
|
268
268
|
## Status
|
|
269
269
|
|
|
270
|
-
- **0.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
Pre-1.0; the CLI and on-disk formats may still change.
|
|
270
|
+
- **0.3.0** — current release. The decoder now uses conservative
|
|
271
|
+
single-bit RDS block syndrome correction during IQ decode, allowing
|
|
272
|
+
weak captures to recover groups that previously failed when one bit
|
|
273
|
+
in a 104-bit group was wrong. Corrected groups are reported separately
|
|
274
|
+
from clean groups; group totals are intentionally not comparable with
|
|
275
|
+
0.2.x outputs. Pre-1.0; the CLI and on-disk formats may still change.
|
|
276
276
|
- 240+ tests including a real-IQ regression backed by a 6 s capture
|
|
277
277
|
of Polskie Radio Trójka 98.8 from Warsaw; line coverage **100 %**
|
|
278
278
|
(tracked by SonarCloud).
|
|
@@ -229,12 +229,12 @@ the local antenna picks up.
|
|
|
229
229
|
|
|
230
230
|
## Status
|
|
231
231
|
|
|
232
|
-
- **0.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
Pre-1.0; the CLI and on-disk formats may still change.
|
|
232
|
+
- **0.3.0** — current release. The decoder now uses conservative
|
|
233
|
+
single-bit RDS block syndrome correction during IQ decode, allowing
|
|
234
|
+
weak captures to recover groups that previously failed when one bit
|
|
235
|
+
in a 104-bit group was wrong. Corrected groups are reported separately
|
|
236
|
+
from clean groups; group totals are intentionally not comparable with
|
|
237
|
+
0.2.x outputs. Pre-1.0; the CLI and on-disk formats may still change.
|
|
238
238
|
- 240+ tests including a real-IQ regression backed by a 6 s capture
|
|
239
239
|
of Polskie Radio Trójka 98.8 from Warsaw; line coverage **100 %**
|
|
240
240
|
(tracked by SonarCloud).
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rdsclock"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
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"
|
|
@@ -11,7 +11,7 @@ from dataclasses import dataclass
|
|
|
11
11
|
import numpy as np
|
|
12
12
|
|
|
13
13
|
from . import dsp
|
|
14
|
-
from .rds_blocks import
|
|
14
|
+
from .rds_blocks import _find_groups_in_bitstream_with_counts
|
|
15
15
|
from .rds_clock import ClockTime
|
|
16
16
|
from .rds_groups import StationInfo, parse_groups
|
|
17
17
|
|
|
@@ -25,6 +25,12 @@ class DecodeResult:
|
|
|
25
25
|
n_bits: int
|
|
26
26
|
freq_offset_hz: float
|
|
27
27
|
symbol_offset: int
|
|
28
|
+
n_groups_clean: int = -1
|
|
29
|
+
n_groups_corrected: int = 0
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if self.n_groups_clean < 0:
|
|
33
|
+
self.n_groups_clean = self.n_groups
|
|
28
34
|
|
|
29
35
|
@property
|
|
30
36
|
def clock_times(self) -> list[ClockTime]:
|
|
@@ -93,6 +99,8 @@ def decode_iq(
|
|
|
93
99
|
emit(f"biphase matched filter + offset search (sps {sps})")
|
|
94
100
|
matched = dsp.biphase_matched_filter(rds_sync, sps_bit=sps)
|
|
95
101
|
best_groups: list[bytearray] | None = None
|
|
102
|
+
best_groups_clean = 0
|
|
103
|
+
best_groups_corrected = 0
|
|
96
104
|
best_bits: np.ndarray | None = None
|
|
97
105
|
variant_used = "biphase/none"
|
|
98
106
|
sym_offset = 0
|
|
@@ -101,9 +109,18 @@ def decode_iq(
|
|
|
101
109
|
if len(sampled) < 100:
|
|
102
110
|
continue
|
|
103
111
|
bits_candidate = dsp.bits_from_symbols_diff(sampled)
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
(
|
|
113
|
+
groups_candidate,
|
|
114
|
+
variant_candidate,
|
|
115
|
+
groups_clean_candidate,
|
|
116
|
+
groups_corrected_candidate,
|
|
117
|
+
) = _best_variant_groups(bits_candidate)
|
|
118
|
+
if best_groups is None or _group_score(
|
|
119
|
+
groups_candidate, groups_clean_candidate, groups_corrected_candidate
|
|
120
|
+
) > _group_score(best_groups, best_groups_clean, best_groups_corrected):
|
|
106
121
|
best_groups = groups_candidate
|
|
122
|
+
best_groups_clean = groups_clean_candidate
|
|
123
|
+
best_groups_corrected = groups_corrected_candidate
|
|
107
124
|
best_bits = bits_candidate
|
|
108
125
|
variant_used = f"biphase/off{offset}/{variant_candidate}"
|
|
109
126
|
sym_offset = offset
|
|
@@ -114,28 +131,39 @@ def decode_iq(
|
|
|
114
131
|
emit(f"clock recovery fallback: best_offset + mueller_muller (sps {sps})")
|
|
115
132
|
bo_symbols, bo_sym_off = dsp.best_symbol_offset(rds_sync, sps=sps)
|
|
116
133
|
bo_bits = dsp.bits_from_symbols_diff(bo_symbols)
|
|
117
|
-
bo_groups, bo_variant = _best_variant_groups(bo_bits)
|
|
134
|
+
bo_groups, bo_variant, bo_groups_clean, bo_groups_corrected = _best_variant_groups(bo_bits)
|
|
118
135
|
|
|
119
136
|
mm_symbols = dsp.clock_recovery_mm(rds_sync, sps=sps)
|
|
120
137
|
mm_bits = dsp.bits_from_symbols_diff(mm_symbols)
|
|
121
|
-
mm_groups, mm_variant = _best_variant_groups(mm_bits)
|
|
138
|
+
mm_groups, mm_variant, mm_groups_clean, mm_groups_corrected = _best_variant_groups(mm_bits)
|
|
122
139
|
|
|
123
|
-
if
|
|
140
|
+
if _group_score(mm_groups, mm_groups_clean, mm_groups_corrected) > _group_score(
|
|
141
|
+
bo_groups, bo_groups_clean, bo_groups_corrected
|
|
142
|
+
):
|
|
124
143
|
groups = mm_groups
|
|
144
|
+
groups_clean = mm_groups_clean
|
|
145
|
+
groups_corrected = mm_groups_corrected
|
|
125
146
|
variant_used = f"mm/{mm_variant}"
|
|
126
147
|
bits = mm_bits
|
|
127
148
|
sym_offset = -1 # MM has no fixed offset, only its mu state
|
|
128
149
|
else:
|
|
129
150
|
groups = bo_groups
|
|
151
|
+
groups_clean = bo_groups_clean
|
|
152
|
+
groups_corrected = bo_groups_corrected
|
|
130
153
|
variant_used = f"bo/{bo_variant}"
|
|
131
154
|
bits = bo_bits
|
|
132
155
|
sym_offset = bo_sym_off
|
|
133
156
|
else:
|
|
134
157
|
groups = best_groups
|
|
158
|
+
groups_clean = best_groups_clean
|
|
159
|
+
groups_corrected = best_groups_corrected
|
|
135
160
|
bits = best_bits
|
|
136
161
|
|
|
137
162
|
info = parse_groups(groups)
|
|
138
|
-
emit(
|
|
163
|
+
emit(
|
|
164
|
+
f"groups={len(groups)} clean={groups_clean} corrected={groups_corrected} "
|
|
165
|
+
f"variant={variant_used} freq_off={freq_off:+.1f} Hz"
|
|
166
|
+
)
|
|
139
167
|
|
|
140
168
|
return DecodeResult(
|
|
141
169
|
info=info,
|
|
@@ -143,10 +171,18 @@ def decode_iq(
|
|
|
143
171
|
n_bits=len(bits),
|
|
144
172
|
freq_offset_hz=float(freq_off),
|
|
145
173
|
symbol_offset=sym_offset,
|
|
174
|
+
n_groups_clean=groups_clean,
|
|
175
|
+
n_groups_corrected=groups_corrected,
|
|
146
176
|
)
|
|
147
177
|
|
|
148
178
|
|
|
149
|
-
def
|
|
179
|
+
def _group_score(
|
|
180
|
+
groups: list[bytearray], n_groups_clean: int, n_groups_corrected: int
|
|
181
|
+
) -> tuple[int, int, int]:
|
|
182
|
+
return len(groups), n_groups_clean, -n_groups_corrected
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _best_variant_groups(bits: np.ndarray) -> tuple[list[bytearray], str, int, int]:
|
|
150
186
|
"""Try the four bitstream polarity/order variants and return the one
|
|
151
187
|
that yields the most groups.
|
|
152
188
|
|
|
@@ -163,12 +199,18 @@ def _best_variant_groups(bits: np.ndarray) -> tuple[list[bytearray], str]:
|
|
|
163
199
|
]
|
|
164
200
|
best: list[bytearray] = []
|
|
165
201
|
best_name = "normal"
|
|
202
|
+
best_clean = 0
|
|
203
|
+
best_corrected = 0
|
|
166
204
|
for name, bstream in candidates:
|
|
167
|
-
g =
|
|
168
|
-
|
|
205
|
+
g, n_clean, n_corrected = _find_groups_in_bitstream_with_counts(
|
|
206
|
+
np.ascontiguousarray(bstream), tolerate_single_bit=True
|
|
207
|
+
)
|
|
208
|
+
if _group_score(g, n_clean, n_corrected) > _group_score(best, best_clean, best_corrected):
|
|
169
209
|
best = g
|
|
170
210
|
best_name = name
|
|
171
|
-
|
|
211
|
+
best_clean = n_clean
|
|
212
|
+
best_corrected = n_corrected
|
|
213
|
+
return best, best_name, best_clean, best_corrected
|
|
172
214
|
|
|
173
215
|
|
|
174
216
|
def decode_file(
|
|
@@ -46,6 +46,9 @@ _DATA_WORD_MASK = (1 << DATA_BITS) - 1
|
|
|
46
46
|
_CRC_TOP_BIT = 1 << CRC_BITS
|
|
47
47
|
_CRC_POLY_WITH_TOP_BIT = CRC_POLY | _CRC_TOP_BIT
|
|
48
48
|
_BLOCK_WEIGHTS = (1 << np.arange(BLOCK_BITS - 1, -1, -1, dtype=np.uint32)).astype(np.uint32)
|
|
49
|
+
_DATA_BIT_CORRECTION_MASKS = (1 << np.arange(DATA_BITS - 1, -1, -1, dtype=np.uint32)).astype(
|
|
50
|
+
np.uint32
|
|
51
|
+
)
|
|
49
52
|
|
|
50
53
|
|
|
51
54
|
def _build_crc10_table() -> np.ndarray:
|
|
@@ -93,7 +96,39 @@ def block_checkword(block26: int) -> int:
|
|
|
93
96
|
return block26 & _BLOCK_WORD_MASK
|
|
94
97
|
|
|
95
98
|
|
|
96
|
-
def
|
|
99
|
+
def _block_syndrome(block26: int, offset_word: int) -> int:
|
|
100
|
+
data = block_dataword(block26)
|
|
101
|
+
return block_checkword(block26) ^ offset_word ^ crc10(data)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _build_single_bit_syndrome_table() -> dict[int, int]:
|
|
105
|
+
table: dict[int, int] = {}
|
|
106
|
+
clean = encode_block(0, OFFSET_A)
|
|
107
|
+
for pos in range(BLOCK_BITS):
|
|
108
|
+
corrupted = clean ^ (1 << (BLOCK_BITS - 1 - pos))
|
|
109
|
+
syndrome = _block_syndrome(corrupted, OFFSET_A)
|
|
110
|
+
if syndrome == 0 or syndrome in table:
|
|
111
|
+
raise RuntimeError(f"non-unique RDS single-bit syndrome at position {pos}")
|
|
112
|
+
table[syndrome] = pos
|
|
113
|
+
return table
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_SINGLE_BIT_SYNDROME_TABLE = _build_single_bit_syndrome_table()
|
|
117
|
+
_SINGLE_BIT_SYNDROME_LOOKUP = np.full(1 << CRC_BITS, -1, dtype=np.int8)
|
|
118
|
+
for _syndrome, _position in _SINGLE_BIT_SYNDROME_TABLE.items():
|
|
119
|
+
_SINGLE_BIT_SYNDROME_LOOKUP[_syndrome] = _position
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _single_bit_error_position(block26: int, offset_word: int) -> int:
|
|
123
|
+
return _SINGLE_BIT_SYNDROME_TABLE.get(_block_syndrome(block26, offset_word), -1)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def block_valid(
|
|
127
|
+
block26: int,
|
|
128
|
+
block_no: int,
|
|
129
|
+
version_b: bool | None = None,
|
|
130
|
+
correct_single_bit: bool = False,
|
|
131
|
+
) -> bool:
|
|
97
132
|
"""Return True if a 26-bit word is a valid block at the given position 0..3.
|
|
98
133
|
|
|
99
134
|
For block C (index 2):
|
|
@@ -104,16 +139,26 @@ def block_valid(block26: int, block_no: int, version_b: bool | None = None) -> b
|
|
|
104
139
|
"""
|
|
105
140
|
if not 0 <= block_no < GROUP_BLOCKS:
|
|
106
141
|
raise ValueError(f"block_no out of [0..3]: {block_no}")
|
|
107
|
-
data = block_dataword(block26)
|
|
108
|
-
expected = crc10(data)
|
|
109
|
-
received = block_checkword(block26)
|
|
110
142
|
if block_no == 2:
|
|
111
143
|
if version_b is True:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
144
|
+
offset_words = (OFFSET_C_PRIME,)
|
|
145
|
+
elif version_b is False:
|
|
146
|
+
offset_words = (OFFSET_C,)
|
|
147
|
+
else:
|
|
148
|
+
offset_words = (OFFSET_C, OFFSET_C_PRIME)
|
|
149
|
+
else:
|
|
150
|
+
offset_words = (OFFSETS[block_no],)
|
|
151
|
+
|
|
152
|
+
for offset_word in offset_words:
|
|
153
|
+
if _block_syndrome(block26, offset_word) == 0:
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
if correct_single_bit:
|
|
157
|
+
for offset_word in offset_words:
|
|
158
|
+
if _single_bit_error_position(block26, offset_word) >= 0:
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
return False
|
|
117
162
|
|
|
118
163
|
|
|
119
164
|
def encode_group(words: Sequence[int], version_b: bool = False) -> list[int]:
|
|
@@ -161,20 +206,44 @@ def _coerce_bits(bits: np.ndarray) -> np.ndarray:
|
|
|
161
206
|
return np.bitwise_and(bits, 1)
|
|
162
207
|
|
|
163
208
|
|
|
164
|
-
def
|
|
209
|
+
def _correct_datawords(datawords: np.ndarray, error_positions: np.ndarray) -> np.ndarray:
|
|
210
|
+
corrected = np.asarray(datawords, dtype=np.uint32).copy()
|
|
211
|
+
data_errors = (error_positions >= 0) & (error_positions < DATA_BITS)
|
|
212
|
+
if np.any(data_errors):
|
|
213
|
+
corrected[data_errors] ^= _DATA_BIT_CORRECTION_MASKS[error_positions[data_errors]]
|
|
214
|
+
return corrected
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _drop_corrected_starts_overlapping_clean(
|
|
218
|
+
group_starts: np.ndarray, correction_counts: np.ndarray
|
|
219
|
+
) -> np.ndarray:
|
|
220
|
+
clean_starts = group_starts[correction_counts[group_starts] == 0]
|
|
221
|
+
corrected_starts = group_starts[correction_counts[group_starts] > 0]
|
|
222
|
+
if len(clean_starts) == 0 or len(corrected_starts) == 0:
|
|
223
|
+
return group_starts
|
|
224
|
+
|
|
225
|
+
left = np.searchsorted(clean_starts, corrected_starts - GROUP_BITS + 1, side="left")
|
|
226
|
+
right = np.searchsorted(clean_starts, corrected_starts + GROUP_BITS, side="left")
|
|
227
|
+
corrected_without_clean_overlap = corrected_starts[left == right]
|
|
228
|
+
return np.sort(np.concatenate((clean_starts, corrected_without_clean_overlap)))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _find_groups_in_bitstream_with_counts(
|
|
232
|
+
bits: np.ndarray, tolerate_single_bit: bool = False
|
|
233
|
+
) -> tuple[list[bytearray], int, int]:
|
|
165
234
|
"""Slide over a bitstream and extract groups of 4 consecutively valid blocks.
|
|
166
235
|
|
|
167
236
|
Enforces version consistency: if block B advertises Version A,
|
|
168
237
|
block C must use offset C; for Version B, block 3 must use offset C'.
|
|
169
238
|
This significantly reduces false-positive group matches on weak streams.
|
|
170
239
|
|
|
171
|
-
Returns
|
|
240
|
+
Returns 8-byte bytearrays plus clean/corrected group counters.
|
|
172
241
|
"""
|
|
173
242
|
bits = _coerce_bits(bits)
|
|
174
243
|
n = len(bits)
|
|
175
244
|
groups: list[bytearray] = []
|
|
176
245
|
if n < GROUP_BITS:
|
|
177
|
-
return groups
|
|
246
|
+
return groups, 0, 0
|
|
178
247
|
|
|
179
248
|
windows = np.lib.stride_tricks.sliding_window_view(bits, BLOCK_BITS)
|
|
180
249
|
words = _bits_to_words_26(windows, assume_binary=True)
|
|
@@ -189,32 +258,111 @@ def find_groups_in_bitstream(bits: np.ndarray) -> list[bytearray]:
|
|
|
189
258
|
valid_d = (check ^ OFFSET_D) == expected
|
|
190
259
|
|
|
191
260
|
start_count = n - GROUP_BITS + 1
|
|
192
|
-
|
|
261
|
+
block_b_slice = slice(BLOCK_BITS, BLOCK_BITS + start_count)
|
|
262
|
+
block_b_data = data[block_b_slice]
|
|
263
|
+
correction_counts = np.zeros(start_count, dtype=np.uint8)
|
|
264
|
+
error_positions_by_block: tuple[np.ndarray, ...] = ()
|
|
265
|
+
|
|
266
|
+
if tolerate_single_bit:
|
|
267
|
+
syndrome_a = check ^ OFFSET_A ^ expected
|
|
268
|
+
syndrome_b = check ^ OFFSET_B ^ expected
|
|
269
|
+
syndrome_c = check ^ OFFSET_C ^ expected
|
|
270
|
+
syndrome_c_prime = check ^ OFFSET_C_PRIME ^ expected
|
|
271
|
+
syndrome_d = check ^ OFFSET_D ^ expected
|
|
272
|
+
|
|
273
|
+
error_pos_a_all = _SINGLE_BIT_SYNDROME_LOOKUP[syndrome_a]
|
|
274
|
+
error_pos_b_all = _SINGLE_BIT_SYNDROME_LOOKUP[syndrome_b]
|
|
275
|
+
error_pos_c_all = _SINGLE_BIT_SYNDROME_LOOKUP[syndrome_c]
|
|
276
|
+
error_pos_c_prime_all = _SINGLE_BIT_SYNDROME_LOOKUP[syndrome_c_prime]
|
|
277
|
+
error_pos_d_all = _SINGLE_BIT_SYNDROME_LOOKUP[syndrome_d]
|
|
278
|
+
|
|
279
|
+
error_pos_a = error_pos_a_all[:start_count]
|
|
280
|
+
error_pos_b = error_pos_b_all[block_b_slice]
|
|
281
|
+
block_b_data = _correct_datawords(block_b_data, error_pos_b)
|
|
282
|
+
else:
|
|
283
|
+
error_pos_a = np.empty(0, dtype=np.int8)
|
|
284
|
+
error_pos_b = np.empty(0, dtype=np.int8)
|
|
285
|
+
|
|
193
286
|
version_b = ((block_b_data >> 11) & 1).astype(bool)
|
|
194
287
|
block_c_valid = np.where(
|
|
195
288
|
version_b,
|
|
196
289
|
valid_c_prime[2 * BLOCK_BITS : 2 * BLOCK_BITS + start_count],
|
|
197
290
|
valid_c[2 * BLOCK_BITS : 2 * BLOCK_BITS + start_count],
|
|
198
291
|
)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
292
|
+
if tolerate_single_bit:
|
|
293
|
+
error_pos_c = np.where(
|
|
294
|
+
version_b,
|
|
295
|
+
error_pos_c_prime_all[2 * BLOCK_BITS : 2 * BLOCK_BITS + start_count],
|
|
296
|
+
error_pos_c_all[2 * BLOCK_BITS : 2 * BLOCK_BITS + start_count],
|
|
297
|
+
)
|
|
298
|
+
error_pos_d = error_pos_d_all[3 * BLOCK_BITS : 3 * BLOCK_BITS + start_count]
|
|
299
|
+
block_a_clean = valid_a[:start_count]
|
|
300
|
+
block_b_clean = valid_b[block_b_slice]
|
|
301
|
+
block_d_clean = valid_d[3 * BLOCK_BITS : 3 * BLOCK_BITS + start_count]
|
|
302
|
+
block_a_corrected = error_pos_a >= 0
|
|
303
|
+
block_b_corrected = error_pos_b >= 0
|
|
304
|
+
block_c_corrected = error_pos_c >= 0
|
|
305
|
+
block_d_corrected = error_pos_d >= 0
|
|
306
|
+
correction_counts = (
|
|
307
|
+
block_a_corrected.astype(np.uint8)
|
|
308
|
+
+ block_b_corrected.astype(np.uint8)
|
|
309
|
+
+ block_c_corrected.astype(np.uint8)
|
|
310
|
+
+ block_d_corrected.astype(np.uint8)
|
|
311
|
+
)
|
|
312
|
+
group_starts = np.flatnonzero(
|
|
313
|
+
(block_a_clean | block_a_corrected)
|
|
314
|
+
& (block_b_clean | block_b_corrected)
|
|
315
|
+
& (block_c_valid | block_c_corrected)
|
|
316
|
+
& (block_d_clean | block_d_corrected)
|
|
317
|
+
& (correction_counts <= 1)
|
|
318
|
+
)
|
|
319
|
+
group_starts = _drop_corrected_starts_overlapping_clean(group_starts, correction_counts)
|
|
320
|
+
error_positions_by_block = (error_pos_a, error_pos_b, error_pos_c, error_pos_d)
|
|
321
|
+
else:
|
|
322
|
+
group_starts = np.flatnonzero(
|
|
323
|
+
valid_a[:start_count]
|
|
324
|
+
& valid_b[block_b_slice]
|
|
325
|
+
& block_c_valid
|
|
326
|
+
& valid_d[3 * BLOCK_BITS : 3 * BLOCK_BITS + start_count]
|
|
327
|
+
)
|
|
205
328
|
|
|
206
329
|
next_scan_start = 0
|
|
330
|
+
n_groups_clean = 0
|
|
331
|
+
n_groups_corrected = 0
|
|
207
332
|
group_offsets = np.array([0, BLOCK_BITS, 2 * BLOCK_BITS, 3 * BLOCK_BITS], dtype=np.intp)
|
|
208
333
|
for start in group_starts:
|
|
209
334
|
group_start = int(start)
|
|
210
335
|
if group_start >= next_scan_start:
|
|
336
|
+
datawords = data[group_start + group_offsets].astype(np.uint32, copy=True)
|
|
337
|
+
corrected_count = int(correction_counts[group_start])
|
|
338
|
+
if corrected_count:
|
|
339
|
+
for idx, error_positions in enumerate(error_positions_by_block):
|
|
340
|
+
error_pos = int(error_positions[group_start])
|
|
341
|
+
if 0 <= error_pos < DATA_BITS:
|
|
342
|
+
datawords[idx] ^= _DATA_BIT_CORRECTION_MASKS[error_pos]
|
|
343
|
+
n_groups_corrected += 1
|
|
344
|
+
else:
|
|
345
|
+
n_groups_clean += 1
|
|
211
346
|
buf = bytearray(8)
|
|
212
|
-
for idx, dw_raw in enumerate(
|
|
347
|
+
for idx, dw_raw in enumerate(datawords):
|
|
213
348
|
dw = int(dw_raw)
|
|
214
349
|
buf[idx * 2] = (dw >> 8) & 0xFF
|
|
215
350
|
buf[idx * 2 + 1] = dw & 0xFF
|
|
216
351
|
groups.append(buf)
|
|
217
352
|
next_scan_start = group_start + GROUP_BITS
|
|
353
|
+
return groups, n_groups_clean, n_groups_corrected
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def find_groups_in_bitstream(
|
|
357
|
+
bits: np.ndarray, tolerate_single_bit: bool = False
|
|
358
|
+
) -> list[bytearray]:
|
|
359
|
+
"""Slide over a bitstream and extract groups of 4 consecutively valid blocks.
|
|
360
|
+
|
|
361
|
+
By default every block must pass CRC exactly. When ``tolerate_single_bit``
|
|
362
|
+
is true, a group may contain one block whose syndrome identifies a single
|
|
363
|
+
flipped bit; groups requiring two or more block corrections are dropped.
|
|
364
|
+
"""
|
|
365
|
+
groups, _, _ = _find_groups_in_bitstream_with_counts(bits, tolerate_single_bit)
|
|
218
366
|
return groups
|
|
219
367
|
|
|
220
368
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rdsclock
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -267,12 +267,12 @@ the local antenna picks up.
|
|
|
267
267
|
|
|
268
268
|
## Status
|
|
269
269
|
|
|
270
|
-
- **0.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
Pre-1.0; the CLI and on-disk formats may still change.
|
|
270
|
+
- **0.3.0** — current release. The decoder now uses conservative
|
|
271
|
+
single-bit RDS block syndrome correction during IQ decode, allowing
|
|
272
|
+
weak captures to recover groups that previously failed when one bit
|
|
273
|
+
in a 104-bit group was wrong. Corrected groups are reported separately
|
|
274
|
+
from clean groups; group totals are intentionally not comparable with
|
|
275
|
+
0.2.x outputs. Pre-1.0; the CLI and on-disk formats may still change.
|
|
276
276
|
- 240+ tests including a real-IQ regression backed by a 6 s capture
|
|
277
277
|
of Polskie Radio Trójka 98.8 from Warsaw; line coverage **100 %**
|
|
278
278
|
(tracked by SonarCloud).
|
|
@@ -186,6 +186,8 @@ class TestDecoderCoverage:
|
|
|
186
186
|
symbol_offset=0,
|
|
187
187
|
)
|
|
188
188
|
assert result.clock_times == [clock]
|
|
189
|
+
assert result.n_groups_clean == 1
|
|
190
|
+
assert result.n_groups_corrected == 0
|
|
189
191
|
|
|
190
192
|
def test_decode_iq_uses_default_carrier_when_auto_disabled(self, monkeypatch):
|
|
191
193
|
monkeypatch.setattr(decoder.dsp, "channel_filter", lambda iq, fs: iq)
|
|
@@ -208,7 +210,7 @@ class TestDecoderCoverage:
|
|
|
208
210
|
monkeypatch.setattr(
|
|
209
211
|
decoder.dsp, "clock_recovery_mm", lambda data, sps: np.zeros(0, dtype=np.complex64)
|
|
210
212
|
)
|
|
211
|
-
monkeypatch.setattr(decoder, "_best_variant_groups", lambda bits: ([], "normal"))
|
|
213
|
+
monkeypatch.setattr(decoder, "_best_variant_groups", lambda bits: ([], "normal", 0, 0))
|
|
212
214
|
monkeypatch.setattr(decoder, "parse_groups", lambda groups: StationInfo())
|
|
213
215
|
|
|
214
216
|
decoder.decode_iq(np.ones(65, dtype=np.complex64), auto_carrier=False)
|
|
@@ -238,8 +240,8 @@ class TestDecoderCoverage:
|
|
|
238
240
|
)
|
|
239
241
|
variants = iter(
|
|
240
242
|
[
|
|
241
|
-
([], "bo"),
|
|
242
|
-
([bytearray(b"\x00" * 8)], "mm"),
|
|
243
|
+
([], "bo", 0, 0),
|
|
244
|
+
([bytearray(b"\x00" * 8)], "mm", 1, 0),
|
|
243
245
|
]
|
|
244
246
|
)
|
|
245
247
|
monkeypatch.setattr(decoder, "_best_variant_groups", lambda bits: next(variants))
|
|
@@ -248,6 +250,8 @@ class TestDecoderCoverage:
|
|
|
248
250
|
result = decoder.decode_iq(np.ones(65, dtype=np.complex64), auto_carrier=False)
|
|
249
251
|
|
|
250
252
|
assert result.n_groups == 1
|
|
253
|
+
assert result.n_groups_clean == 1
|
|
254
|
+
assert result.n_groups_corrected == 0
|
|
251
255
|
assert result.symbol_offset == -1
|
|
252
256
|
|
|
253
257
|
def test_decode_file_autodetects_u8_when_complex_sniff_is_invalid(self, monkeypatch):
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""Tests for the block layer: CRC, block encode/decode, syndromes, bitstream."""
|
|
2
2
|
|
|
3
3
|
import numpy as np
|
|
4
|
+
import pytest
|
|
4
5
|
|
|
6
|
+
import rdsclock.rds_blocks as rds_blocks
|
|
5
7
|
from rdsclock.rds_blocks import (
|
|
8
|
+
_SINGLE_BIT_SYNDROME_TABLE,
|
|
6
9
|
BLOCK_BITS,
|
|
7
10
|
DATA_BITS,
|
|
8
11
|
GROUP_BITS,
|
|
@@ -12,6 +15,8 @@ from rdsclock.rds_blocks import (
|
|
|
12
15
|
OFFSETS,
|
|
13
16
|
_bits_to_words_26,
|
|
14
17
|
_crc10_many,
|
|
18
|
+
_drop_corrected_starts_overlapping_clean,
|
|
19
|
+
_find_groups_in_bitstream_with_counts,
|
|
15
20
|
bits_to_word,
|
|
16
21
|
block_dataword,
|
|
17
22
|
block_valid,
|
|
@@ -112,6 +117,32 @@ class TestEncodeBlock:
|
|
|
112
117
|
assert block_valid(blk_c, 2)
|
|
113
118
|
assert block_valid(blk_c_prime, 2)
|
|
114
119
|
|
|
120
|
+
def test_single_bit_syndrome_table_covers_block_positions(self):
|
|
121
|
+
assert len(_SINGLE_BIT_SYNDROME_TABLE) == BLOCK_BITS
|
|
122
|
+
assert set(_SINGLE_BIT_SYNDROME_TABLE.values()) == set(range(BLOCK_BITS))
|
|
123
|
+
assert 0 not in _SINGLE_BIT_SYNDROME_TABLE
|
|
124
|
+
|
|
125
|
+
def test_single_bit_syndrome_table_rejects_collisions(self, monkeypatch):
|
|
126
|
+
monkeypatch.setattr(rds_blocks, "_block_syndrome", lambda block, offset: 1)
|
|
127
|
+
with pytest.raises(RuntimeError, match="non-unique"):
|
|
128
|
+
rds_blocks._build_single_bit_syndrome_table()
|
|
129
|
+
|
|
130
|
+
def test_block_valid_can_accept_one_single_bit_error_when_requested(self):
|
|
131
|
+
data = 0xBEEF
|
|
132
|
+
blk = encode_block(data, OFFSET_A)
|
|
133
|
+
corrupted_data = blk ^ (1 << (BLOCK_BITS - 1 - 3))
|
|
134
|
+
corrupted_crc = blk ^ (1 << (BLOCK_BITS - 1 - 20))
|
|
135
|
+
corrupted_double = corrupted_data ^ (1 << (BLOCK_BITS - 1 - 20))
|
|
136
|
+
|
|
137
|
+
assert not block_valid(corrupted_data, 0)
|
|
138
|
+
assert block_valid(corrupted_data, 0, correct_single_bit=True)
|
|
139
|
+
assert block_valid(corrupted_crc, 0, correct_single_bit=True)
|
|
140
|
+
assert not block_valid(corrupted_double, 0, correct_single_bit=True)
|
|
141
|
+
|
|
142
|
+
blk_c_prime = encode_block(data, OFFSET_C_PRIME)
|
|
143
|
+
corrupted_c_prime = blk_c_prime ^ (1 << (BLOCK_BITS - 1 - 4))
|
|
144
|
+
assert block_valid(corrupted_c_prime, 2, correct_single_bit=True)
|
|
145
|
+
|
|
115
146
|
|
|
116
147
|
class TestEncodeGroup:
|
|
117
148
|
def test_basic_a_version(self):
|
|
@@ -188,6 +219,69 @@ class TestBitstream:
|
|
|
188
219
|
def test_find_groups_short_stream_returns_empty(self):
|
|
189
220
|
assert find_groups_in_bitstream(np.zeros(GROUP_BITS - 1, dtype=np.uint8)) == []
|
|
190
221
|
|
|
222
|
+
def test_find_groups_can_recover_one_corrupted_data_bit(self):
|
|
223
|
+
words = (0xCAFE, 0x4000, 0x1234, 0x5678)
|
|
224
|
+
bits = blocks_to_bits(encode_group(words)).copy()
|
|
225
|
+
bits[BLOCK_BITS + 4] ^= 1 # block B version bit: raw data would select C'.
|
|
226
|
+
|
|
227
|
+
assert find_groups_in_bitstream(bits) == []
|
|
228
|
+
groups, n_clean, n_corrected = _find_groups_in_bitstream_with_counts(
|
|
229
|
+
bits, tolerate_single_bit=True
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
assert n_clean == 0
|
|
233
|
+
assert n_corrected == 1
|
|
234
|
+
assert len(groups) == 1
|
|
235
|
+
assert group_bytes_to_words(groups[0]) == words
|
|
236
|
+
|
|
237
|
+
def test_find_groups_can_recover_one_corrupted_crc_bit_without_changing_data(self):
|
|
238
|
+
words = (0xCAFE, 0x4000, 0x1234, 0x5678)
|
|
239
|
+
bits = blocks_to_bits(encode_group(words)).copy()
|
|
240
|
+
bits[3 * BLOCK_BITS + 20] ^= 1
|
|
241
|
+
|
|
242
|
+
groups = find_groups_in_bitstream(bits, tolerate_single_bit=True)
|
|
243
|
+
|
|
244
|
+
assert len(groups) == 1
|
|
245
|
+
assert group_bytes_to_words(groups[0]) == words
|
|
246
|
+
|
|
247
|
+
def test_find_groups_drops_candidates_requiring_two_corrections(self):
|
|
248
|
+
bits = blocks_to_bits(encode_group((0xCAFE, 0x4000, 0x1234, 0x5678))).copy()
|
|
249
|
+
bits[3] ^= 1
|
|
250
|
+
bits[3 * BLOCK_BITS + 20] ^= 1
|
|
251
|
+
|
|
252
|
+
groups, n_clean, n_corrected = _find_groups_in_bitstream_with_counts(
|
|
253
|
+
bits, tolerate_single_bit=True
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
assert groups == []
|
|
257
|
+
assert n_clean == 0
|
|
258
|
+
assert n_corrected == 0
|
|
259
|
+
|
|
260
|
+
def test_corrected_group_starts_do_not_displace_overlapping_clean_starts(self):
|
|
261
|
+
correction_counts = np.zeros(400, dtype=np.uint8)
|
|
262
|
+
correction_counts[[40, 260]] = 1
|
|
263
|
+
starts = np.array([40, 120, 260], dtype=np.intp)
|
|
264
|
+
|
|
265
|
+
filtered = _drop_corrected_starts_overlapping_clean(starts, correction_counts)
|
|
266
|
+
|
|
267
|
+
np.testing.assert_array_equal(filtered, np.array([120, 260], dtype=np.intp))
|
|
268
|
+
np.testing.assert_array_equal(
|
|
269
|
+
_drop_corrected_starts_overlapping_clean(
|
|
270
|
+
np.array([40], dtype=np.intp), correction_counts
|
|
271
|
+
),
|
|
272
|
+
np.array([40], dtype=np.intp),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def test_find_groups_tolerant_mode_rejects_random_noise_smoke(self):
|
|
276
|
+
"""Single-bit correction must not turn random noise into many fake groups."""
|
|
277
|
+
noise = np.random.RandomState(42).randint(0, 2, size=10_000).astype(np.uint8)
|
|
278
|
+
groups, n_clean, n_corrected = _find_groups_in_bitstream_with_counts(
|
|
279
|
+
noise, tolerate_single_bit=True
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
assert len(groups) < 10
|
|
283
|
+
assert n_clean + n_corrected == len(groups)
|
|
284
|
+
|
|
191
285
|
|
|
192
286
|
class TestDifferential:
|
|
193
287
|
def test_roundtrip(self):
|
|
@@ -13,6 +13,8 @@ FIXTURE = Path(__file__).parent / "fixtures" / "trojka_98p8_6s_250k_u8.iq"
|
|
|
13
13
|
def test_trojka_98p8_real_iq_decodes_groups_and_polskie_radio_pi():
|
|
14
14
|
result = decode_file(str(FIXTURE), fs=250_000, fmt="u8")
|
|
15
15
|
|
|
16
|
-
assert result.
|
|
16
|
+
assert result.n_groups_clean + result.n_groups_corrected == result.n_groups
|
|
17
|
+
assert result.n_groups_clean + result.n_groups_corrected >= 30
|
|
18
|
+
assert result.n_groups_corrected < result.n_groups_clean * 0.5
|
|
17
19
|
assert result.info.pi is not None
|
|
18
20
|
assert (result.info.pi & 0xFF00) == 0x3200
|
|
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
|