rdsclock 0.2.1__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.
Files changed (46) hide show
  1. {rdsclock-0.2.1 → rdsclock-0.2.2}/PKG-INFO +7 -7
  2. {rdsclock-0.2.1 → rdsclock-0.2.2}/README.md +6 -6
  3. {rdsclock-0.2.1 → rdsclock-0.2.2}/pyproject.toml +1 -1
  4. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/__init__.py +1 -1
  5. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/audio.py +23 -30
  6. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/rds_groups.py +39 -2
  7. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock.egg-info/PKG-INFO +7 -7
  8. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_audio.py +16 -3
  9. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_coverage_edges.py +26 -1
  10. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_coverage_extra.py +1 -1
  11. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_rds_groups.py +54 -4
  12. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_synth.py +1 -1
  13. {rdsclock-0.2.1 → rdsclock-0.2.2}/LICENSE +0 -0
  14. {rdsclock-0.2.1 → rdsclock-0.2.2}/setup.cfg +0 -0
  15. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/__main__.py +0 -0
  16. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/channelizer.py +0 -0
  17. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/cli.py +0 -0
  18. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/decoder.py +0 -0
  19. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/dsp.py +0 -0
  20. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/plot.py +0 -0
  21. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/py.typed +0 -0
  22. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/rds_blocks.py +0 -0
  23. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/rds_clock.py +0 -0
  24. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/recon.py +0 -0
  25. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/rtl_tcp.py +0 -0
  26. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/synth.py +0 -0
  27. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/time_consensus.py +0 -0
  28. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock.egg-info/SOURCES.txt +0 -0
  29. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock.egg-info/dependency_links.txt +0 -0
  30. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock.egg-info/entry_points.txt +0 -0
  31. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock.egg-info/requires.txt +0 -0
  32. {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock.egg-info/top_level.txt +0 -0
  33. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_channelizer.py +0 -0
  34. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_cli.py +0 -0
  35. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_cli_with_fake_sdr.py +0 -0
  36. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_decoder_synthetic.py +0 -0
  37. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_dsp.py +0 -0
  38. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_plot.py +0 -0
  39. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_rds_blocks.py +0 -0
  40. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_rds_clock.py +0 -0
  41. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_real_iq_regression.py +0 -0
  42. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_real_recordings.py +0 -0
  43. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_recon.py +0 -0
  44. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_recon_offline.py +0 -0
  45. {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_rtl_tcp.py +0 -0
  46. {rdsclock-0.2.1 → 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.1
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
@@ -267,12 +267,12 @@ the local antenna picks up.
267
267
 
268
268
  ## Status
269
269
 
270
- - **0.2.1** — current release. Decoder hot path is now ~7× faster
271
- than 0.2.0 (5 min broadcast capture decodes in ~50 s on commodity
272
- hardware fast enough for multi-station real-time consensus on
273
- a single thread). See [`CHANGELOG.md`](CHANGELOG.md) for the 0.2.0
274
- biphase fix that preceded this and made the decoder actually work
275
- on real FM. Pre-1.0; the CLI and on-disk formats may still change.
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
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.1** — current release. Decoder hot path is now ~7× faster
233
- than 0.2.0 (5 min broadcast capture decodes in ~50 s on commodity
234
- hardware fast enough for multi-station real-time consensus on
235
- a single thread). See [`CHANGELOG.md`](CHANGELOG.md) for the 0.2.0
236
- biphase fix that preceded this and made the decoder actually work
237
- on real FM. Pre-1.0; the CLI and on-disk formats may still change.
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
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.1"
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"
@@ -19,4 +19,4 @@ Modules:
19
19
  scan / recon / demo.
20
20
  """
21
21
 
22
- __version__ = "0.2.1"
22
+ __version__ = "0.2.2"
@@ -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)``. ``fs_in`` must be an integer
37
- multiple of ``fs_out``.
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 != 0:
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``) → decimation
57
- to ``fs_out`` → optional peak normalisation to ±0.5 to avoid clipping.
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
- taps, decim = design_audio_filter(fs_sdr, fs_audio)
92
- zi = lfilter_zi(taps, 1.0)
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
- fm = fm_demod(iq)
121
- audio, zi = lfilter(taps, 1.0, fm, zi=zi)
122
- audio = audio[::decim]
123
- peak = float(np.max(np.abs(audio)) + 1e-6)
124
- audio = (audio / peak) * 0.5
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
- # If the file was captured at the standard 250 kS/s, decimate by 5 to
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:
@@ -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
- info.ps_chars[seg * 2] = _safe_char((d >> 8) & 0xFF)
87
- info.ps_chars[seg * 2 + 1] = _safe_char(d & 0xFF)
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.1
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
@@ -267,12 +267,12 @@ the local antenna picks up.
267
267
 
268
268
  ## Status
269
269
 
270
- - **0.2.1** — current release. Decoder hot path is now ~7× faster
271
- than 0.2.0 (5 min broadcast capture decodes in ~50 s on commodity
272
- hardware fast enough for multi-station real-time consensus on
273
- a single thread). See [`CHANGELOG.md`](CHANGELOG.md) for the 0.2.0
274
- biphase fix that preceded this and made the decoder actually work
275
- on real FM. Pre-1.0; the CLI and on-disk formats may still change.
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
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).
@@ -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 test_rejects_non_integer_ratio(self):
45
- with pytest.raises(ValueError):
46
- design_audio_filter(1_000_000, 48_000)
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] == 2_000
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=2_000)
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
 
@@ -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 == "RM"
28
+ assert info.ps_name == ""
29
+ assert info.latest_ps_candidate == ""
24
30
 
25
31
  def test_all_segments(self):
26
- groups = encode_ps_groups(pi=0x3F44, ps_name="RMF FM ")
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 = encode_ps_groups(pi=0x1234, ps_name="ABC")
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 = encode_ps_groups(pi=0x4321, ps_name="RadioZET")
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