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.
Files changed (46) hide show
  1. {rdsclock-0.2.2 → rdsclock-0.3.0}/PKG-INFO +7 -7
  2. {rdsclock-0.2.2 → rdsclock-0.3.0}/README.md +6 -6
  3. {rdsclock-0.2.2 → rdsclock-0.3.0}/pyproject.toml +1 -1
  4. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/__init__.py +1 -1
  5. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/decoder.py +53 -11
  6. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/rds_blocks.py +168 -20
  7. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock.egg-info/PKG-INFO +7 -7
  8. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_coverage_extra.py +7 -3
  9. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_rds_blocks.py +94 -0
  10. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_real_iq_regression.py +3 -1
  11. {rdsclock-0.2.2 → rdsclock-0.3.0}/LICENSE +0 -0
  12. {rdsclock-0.2.2 → rdsclock-0.3.0}/setup.cfg +0 -0
  13. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/__main__.py +0 -0
  14. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/audio.py +0 -0
  15. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/channelizer.py +0 -0
  16. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/cli.py +0 -0
  17. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/dsp.py +0 -0
  18. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/plot.py +0 -0
  19. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/py.typed +0 -0
  20. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/rds_clock.py +0 -0
  21. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/rds_groups.py +0 -0
  22. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/recon.py +0 -0
  23. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/rtl_tcp.py +0 -0
  24. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/synth.py +0 -0
  25. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock/time_consensus.py +0 -0
  26. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock.egg-info/SOURCES.txt +0 -0
  27. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock.egg-info/dependency_links.txt +0 -0
  28. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock.egg-info/entry_points.txt +0 -0
  29. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock.egg-info/requires.txt +0 -0
  30. {rdsclock-0.2.2 → rdsclock-0.3.0}/src/rdsclock.egg-info/top_level.txt +0 -0
  31. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_audio.py +0 -0
  32. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_channelizer.py +0 -0
  33. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_cli.py +0 -0
  34. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_cli_with_fake_sdr.py +0 -0
  35. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_coverage_edges.py +0 -0
  36. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_decoder_synthetic.py +0 -0
  37. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_dsp.py +0 -0
  38. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_plot.py +0 -0
  39. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_rds_clock.py +0 -0
  40. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_rds_groups.py +0 -0
  41. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_real_recordings.py +0 -0
  42. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_recon.py +0 -0
  43. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_recon_offline.py +0 -0
  44. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_rtl_tcp.py +0 -0
  45. {rdsclock-0.2.2 → rdsclock-0.3.0}/tests/test_synth.py +0 -0
  46. {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.2.2
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.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.
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.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.
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.2.2"
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"
@@ -19,4 +19,4 @@ Modules:
19
19
  scan / recon / demo.
20
20
  """
21
21
 
22
- __version__ = "0.2.2"
22
+ __version__ = "0.3.0"
@@ -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 find_groups_in_bitstream
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
- groups_candidate, variant_candidate = _best_variant_groups(bits_candidate)
105
- if best_groups is None or len(groups_candidate) > len(best_groups):
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 len(mm_groups) > len(bo_groups):
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(f"groups={len(groups)} variant={variant_used} freq_off={freq_off:+.1f} Hz")
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 _best_variant_groups(bits: np.ndarray) -> tuple[list[bytearray], str]:
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 = find_groups_in_bitstream(np.ascontiguousarray(bstream))
168
- if len(g) > len(best):
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
- return best, best_name
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 block_valid(block26: int, block_no: int, version_b: bool | None = None) -> bool:
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
- return (received ^ OFFSET_C_PRIME) == expected
113
- if version_b is False:
114
- return (received ^ OFFSET_C) == expected
115
- return (received ^ OFFSET_C) == expected or (received ^ OFFSET_C_PRIME) == expected
116
- return (received ^ OFFSETS[block_no]) == expected
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 find_groups_in_bitstream(bits: np.ndarray) -> list[bytearray]:
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 a list of 8-byte bytearrays (4 × big-endian 16-bit datawords).
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
- block_b_data = data[BLOCK_BITS : BLOCK_BITS + start_count]
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
- 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
- )
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(data[group_start + group_offsets]):
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.2.2
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.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.
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.n_groups >= 10
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