GNU Radio Companion
Test-Beispiel — FSK-Signal für die RTTY-Strecke
Ein synthetisches FSK-Testsignal mit bekannten Parametern, das jede Stufe der DDK9/RTTY-Verarbeitungskette einzeln verifiziert — mit Fokus auf die Messung der Signalparameter, die für korrekt eingestellte Filter entscheidend sind.
Dieses Dokument wendet die in Testen mit GRC beschriebene Methodik auf die in DDK9 RTTY — Finale Dimensionierung hergeleitete Verarbeitungskette an: ein synthetisches FSK-Signal mit bekannten Parametern (Frequenzhub, Baudrate, Arbeitsrate) durchläuft dieselben Stufen wie die echte Kette — Kanalfilter, Quadratur-Demodulator, Post-Detection-Tiefpass, Bit-Slicing — und jede Stufe wird gegen einen konkret gemessenen Wert verifiziert, nicht nur gegen "sieht plausibel aus".
numpy (keine GNU-Radio-Installation nötig). Im letzten Abschnitt steht zusätzlich dieselbe Kette mit echten GNU-Radio-Blöcken (filter.fir_filter_ccc, analog.quadrature_demod_cf usw., ausgeführt mit GNU Radio 3.10) nach dem Muster aus Testen mit GRC — beide Wege liefern identische Ergebnisse, sobald die (unterschiedliche) Behandlung der Filter-Gruppenlaufzeit berücksichtigt ist.Teststrategie: welche Stufen verdienen einen eigenen Test?
Nicht jeder Block aus der DDK9-Kette braucht denselben Testaufwand. Manche sind reine Regelkreise mit Zeitkonstanten im Sekundenbereich (AGC2, Auto-Polarity, SNR-Gate, AFC) — die lassen sich sinnvoll nur über längere, realistische Signalläufe prüfen, nicht über einen kurzen deterministischen Parametertest. Die vier Stufen unten dagegen haben eine feste, sofort überprüfbare Sollantwort und sind zugleich die Stellen, an denen eine falsche Parametrierung am schwersten zu entdecken ist, weil der Flowgraph dabei nicht abstürzt, sondern nur leise falsche Ergebnisse liefert:
| Stufe im Flowgraph | Warum ein Test hier wichtig ist | Entscheidende Parameter |
|---|---|---|
| Kanalfilter (Freq-Xlating-FIR) | Zu breit dimensioniert lässt Nachbarkanalstörer durch, die sich erst viele Stufen später als sporadische Bitfehler zeigen — weit weg von der eigentlichen Ursache. Zu schmal schneidet Nutzsignalanteile ab. | Grenzfrequenz, gemessene Interferer-Dämpfung in dB |
| Quadratur-Demodulator | Ein falscher Gain-Faktor verschiebt den Ausgangspegel proportional — alle nachgelagerten, fest verdrahteten Schwellwerte (Slicer bei 0, AFC-Fenster, SNR-Gate-Schwellen) sind dann leise falsch kalibriert, ohne dass irgendein Block einen Fehler meldet. | Gain-Formel, gemessener Pegel bei bekannter Frequenzabweichung (Soll: exakt ±1,0) |
| Post-Detection-Tiefpass | Grenzfrequenz zu hoch: Diskriminator-Rauschen bleibt im Nutzsignal. Zu niedrig: die NRZ-Hauptkeule wird selbst beschnitten, das Augendiagramm schließt sich. Beides sieht im Betrieb zunächst nach "funktioniert meistens" aus. | Grenzfrequenz (per Konvention der -6dB-Punkt), Sperrdämpfung bei einem Vielfachen der Grenzfrequenz |
| Symbol-Sync / Bit-Slicing | Ein nicht-ganzzahliges sps oder ein falsch platziertes Abtastfenster erzeugt Bitfehler, obwohl Signal und alle vorherigen Filter korrekt arbeiten — die Fehlerursache liegt dann "unsichtbar" in der Taktung, nicht im Signalpfad. | sps (muss ganzzahlig sein), Position des Symbolzentrums relativ zur Filter-Gruppenlaufzeit |
Eine fünfte, dem eigentlichen Flowgraph vorgelagerte Prüfung betrifft das Testsignal selbst: Ground-Truth-Prinzip aus Testen mit GRC — wenn der Signalgenerator nicht exakt den behaupteten Frequenzhub erzeugt, ist jeder nachfolgende Test wertlos, egal wie sorgfältig er sonst aufgebaut ist. Deshalb steht die Parameterverifikation des Testsignals selbst als Stufe 1 ganz am Anfang, noch vor dem ersten Filter.
Ground Truth: Signalparameter
Dieselben Werte wie in der DDK9-Dimensionierung, damit die Testergebnisse direkt übertragbar sind:
| Parameter | Wert | Bedeutung |
|---|---|---|
samp_rate |
1200 Hz | Arbeitsrate nach Dezimation (wie ddk9-final) |
baud |
50 Bd | DDK9/DWD-Sendenorm |
shift |
225 Hz | halber Frequenzhub |
sps |
24 | samp_rate / baud, muss ganzzahlig sein |
Stufe 0: Testsignal erzeugen
Ein komplexes Basisband-FSK-Signal, das direkt dem Signal nach dem Freq-Xlating-FIR-Filter entspricht (die Mischung von HF auf Basisband selbst wird hier übersprungen, da sie kein testbarer GRC-Block, sondern reine Frequenzverschiebung ist):
import numpy as np
samp_rate = 1200 # Arbeitsrate NACH Dezimation, nicht die 12 kHz der WAV-Aufnahme
baud = 50
shift = 225 # halber Hub: Mark = +225 Hz, Space = -225 Hz
sps = samp_rate // baud
assert samp_rate % baud == 0, "sps muss ganzzahlig sein"
rng = np.random.default_rng(42) # fester Seed -> reproduzierbares Testsignal
n_bits = 40
bits = rng.integers(0, 2, n_bits) # zufaellig, aber reproduzierbar (0=Space, 1=Mark)
# Jedes Bit auf sps Samples strecken und auf seine Momentanfrequenz abbilden:
# Mark (1) -> +shift Hz, Space (0) -> -shift Hz
freq_inst = np.where(np.repeat(bits, sps) == 1, shift, -shift).astype(np.float64)
# FSK = Frequenzmodulation: Phase ist das (zeitdiskrete) Integral der Momentanfrequenz.
# 2*pi/samp_rate wandelt Hz in Radiant pro Sample um.
phase = 2 * np.pi * np.cumsum(freq_inst) / samp_rate
# e^(j*phase) erzeugt daraus das komplexe IQ-Basisbandsignal
iq_signal = np.exp(1j * phase).astype(np.complex64)
import numpy as np
samp_rate = 1200 # Arbeitsrate NACH Dezimation, nicht die 12 kHz der WAV-Aufnahme
baud = 50
shift = 225 # halber Hub: Mark = +225 Hz, Space = -225 Hz
sps = samp_rate // baud
assert samp_rate % baud == 0, "sps muss ganzzahlig sein"
rng = np.random.default_rng(42) # fester Seed -> reproduzierbares Testsignal
n_bits = 40
bits = rng.integers(0, 2, n_bits) # zufaellig, aber reproduzierbar (0=Space, 1=Mark)
# Jedes Bit auf sps Samples strecken und auf seine Momentanfrequenz abbilden:
# Mark (1) -> +shift Hz, Space (0) -> -shift Hz
freq_inst = np.where(np.repeat(bits, sps) == 1, shift, -shift).astype(np.float64)
# FSK = Frequenzmodulation: Phase ist das (zeitdiskrete) Integral der Momentanfrequenz.
# 2*pi/samp_rate wandelt Hz in Radiant pro Sample um.
phase = 2 * np.pi * np.cumsum(freq_inst) / samp_rate
# e^(j*phase) erzeugt daraus das komplexe IQ-Basisbandsignal
iq_signal = np.exp(1j * phase).astype(np.complex64)
Stufe 1: Frequenzhub aus dem Signal messen
Bevor überhaupt gefiltert wird: Stimmt der tatsächliche Frequenzhub des generierten Signals mit dem Soll-Wert überein? Das ist zugleich eine Prüfung des Testsignal-Generators selbst (Ground-Truth-Prinzip aus Testen mit GRC).
sps=24 Samples beträgt die Bin-Auflösung samp_rate/sps = 50 Hz — ein Hub von 225 Hz kann damit nicht präzise getroffen werden (gemessen wurden zunächst fälschlich 250 Hz statt 225 Hz). Robuster: die Momentanfrequenz (Ableitung der entpackten Phase) zeitbasiert über alle Samples einer Symbolklasse mitteln, statt sich auf die Frequenzauflösung eines kurzen FFT-Fensters zu verlassen.# np.angle() liefert Phase nur in (-pi,+pi] - np.unwrap() entfernt die kuenstlichen
# Spruenge bei jedem Ueberlauf, damit diff() die echte, stetige Phasenaenderung liefert
inst_phase = np.unwrap(np.angle(iq_signal))
# Momentanfrequenz = Ableitung der Phase nach der Zeit (zeitdiskret: Differenz),
# zurueckskaliert von Radiant/Sample auf Hz
inst_freq = np.diff(inst_phase) * samp_rate / (2 * np.pi)
bit_labels = np.repeat(bits, sps)[1:] # gleiche Laenge wie inst_freq (diff() ist 1 kuerzer)
# Getrennte Mittelung ueber alle Mark- bzw. Space-Samples mittelt Rauschen gut heraus
f_mark = np.mean(inst_freq[bit_labels == 1])
f_space = np.mean(inst_freq[bit_labels == 0])
gemessener_hub = (f_mark - f_space) / 2 # Hub = halber Abstand der beiden Tonlagen
print(f"f_mark={f_mark:.1f} Hz, f_space={f_space:.1f} Hz, Hub={gemessener_hub:.1f} Hz (Soll {shift} Hz)")
assert abs(gemessener_hub - shift) < 2
# np.angle() liefert Phase nur in (-pi,+pi] - np.unwrap() entfernt die kuenstlichen
# Spruenge bei jedem Ueberlauf, damit diff() die echte, stetige Phasenaenderung liefert
inst_phase = np.unwrap(np.angle(iq_signal))
# Momentanfrequenz = Ableitung der Phase nach der Zeit (zeitdiskret: Differenz),
# zurueckskaliert von Radiant/Sample auf Hz
inst_freq = np.diff(inst_phase) * samp_rate / (2 * np.pi)
bit_labels = np.repeat(bits, sps)[1:] # gleiche Laenge wie inst_freq (diff() ist 1 kuerzer)
# Getrennte Mittelung ueber alle Mark- bzw. Space-Samples mittelt Rauschen gut heraus
f_mark = np.mean(inst_freq[bit_labels == 1])
f_space = np.mean(inst_freq[bit_labels == 0])
gemessener_hub = (f_mark - f_space) / 2 # Hub = halber Abstand der beiden Tonlagen
print(f"f_mark={f_mark:.1f} Hz, f_space={f_space:.1f} Hz, Hub={gemessener_hub:.1f} Hz (Soll {shift} Hz)")
assert abs(gemessener_hub - shift) < 2
Ergebnis: f_mark=225.0 Hz, f_space=-225.0 Hz, Hub=225.0 Hz — exakte Übereinstimmung.
Stufe 2: Kanalfilter — Interferer-Unterdrückung verifizieren
Simuliert die Aufgabe des Freq-Xlating-FIR-Filters: einen Störer außerhalb des Nutzbandes ausreichend dämpfen. Die Filter-Taps werden mit derselben Sinc+Hamming-Methode wie im FIR-Filter-Demonstrator erzeugt:
def generate_lowpass_taps(num_taps, cutoff_hz, fs):
"""Sinc-Tiefpass mit Hamming-Fenster (identische Methode zum FIR-Filter-Demonstrator)."""
center = num_taps // 2
fc = cutoff_hz / fs # Grenzfrequenz als Bruchteil der Samplerate
n = np.arange(num_taps) - center # Tap-Indizes symmetrisch um 0 (..., -1, 0, 1, ...)
n_safe = np.where(n == 0, 1, n) # vermeidet Div/0-Warnung bei n=0 (Wert wird eh ersetzt)
taps = np.where(n == 0, 2 * np.pi * fc, np.sin(2 * np.pi * fc * n_safe) / n_safe)
# Hamming-Fenster daempft die Ecken der (eigentlich unendlichen) Sinc-Funktion sanft aus
window = 0.54 - 0.46 * np.cos(2 * np.pi * np.arange(num_taps) / (num_taps - 1))
return (taps * window) / np.sum(taps * window) # Normierung auf Gain=1
t = np.arange(len(iq_signal)) / samp_rate
interferer_freq = 500.0 # bewusst NICHT auf 1200/2=600 Hz (Nyquist) - siehe Warnbox unten
interferer = 2.0 * np.exp(1j * 2 * np.pi * interferer_freq * t) # staerkerer Stoerer (Amplitude 2.0)
signal_mit_interferer = iq_signal + interferer
taps_channel = generate_lowpass_taps(65, 300, samp_rate) # 300 Hz laesst FSK (+-225Hz) durch
gefiltert = np.convolve(signal_mit_interferer, taps_channel, mode="same")
def band_power(signal, fs, f0, bw=20):
"""Mittlere Spektralleistung in einem +-bw Hz breiten Fenster um f0."""
spec = np.fft.fftshift(np.fft.fft(signal))
freqs = np.fft.fftshift(np.fft.fftfreq(len(signal), 1 / fs))
mask = (freqs > f0 - bw) & (freqs < f0 + bw)
return np.mean(np.abs(spec[mask]) ** 2)
# Leistung am Stoerer vor/nach dem Filter vergleichen - das Verhaeltnis in dB ist
# die tatsaechlich erzielte Daempfung, unabhaengig von der absoluten FFT-Skalierung
vorher = band_power(signal_mit_interferer, samp_rate, interferer_freq)
nachher = band_power(gefiltert, samp_rate, interferer_freq)
daempfung_db = 10 * np.log10(vorher / nachher)
print(f"Interferer-Daempfung: {daempfung_db:.1f} dB")
assert daempfung_db > 20
def generate_lowpass_taps(num_taps, cutoff_hz, fs):
"""Sinc-Tiefpass mit Hamming-Fenster (identische Methode zum FIR-Filter-Demonstrator)."""
center = num_taps // 2
fc = cutoff_hz / fs # Grenzfrequenz als Bruchteil der Samplerate
n = np.arange(num_taps) - center # Tap-Indizes symmetrisch um 0 (..., -1, 0, 1, ...)
n_safe = np.where(n == 0, 1, n) # vermeidet Div/0-Warnung bei n=0 (Wert wird eh ersetzt)
taps = np.where(n == 0, 2 * np.pi * fc, np.sin(2 * np.pi * fc * n_safe) / n_safe)
# Hamming-Fenster daempft die Ecken der (eigentlich unendlichen) Sinc-Funktion sanft aus
window = 0.54 - 0.46 * np.cos(2 * np.pi * np.arange(num_taps) / (num_taps - 1))
return (taps * window) / np.sum(taps * window) # Normierung auf Gain=1
t = np.arange(len(iq_signal)) / samp_rate
interferer_freq = 500.0 # bewusst NICHT auf 1200/2=600 Hz (Nyquist) - siehe Warnbox unten
interferer = 2.0 * np.exp(1j * 2 * np.pi * interferer_freq * t) # staerkerer Stoerer (Amplitude 2.0)
signal_mit_interferer = iq_signal + interferer
taps_channel = generate_lowpass_taps(65, 300, samp_rate) # 300 Hz laesst FSK (+-225Hz) durch
gefiltert = np.convolve(signal_mit_interferer, taps_channel, mode="same")
def band_power(signal, fs, f0, bw=20):
"""Mittlere Spektralleistung in einem +-bw Hz breiten Fenster um f0."""
spec = np.fft.fftshift(np.fft.fft(signal))
freqs = np.fft.fftshift(np.fft.fftfreq(len(signal), 1 / fs))
mask = (freqs > f0 - bw) & (freqs < f0 + bw)
return np.mean(np.abs(spec[mask]) ** 2)
# Leistung am Stoerer vor/nach dem Filter vergleichen - das Verhaeltnis in dB ist
# die tatsaechlich erzielte Daempfung, unabhaengig von der absoluten FFT-Skalierung
vorher = band_power(signal_mit_interferer, samp_rate, interferer_freq)
nachher = band_power(gefiltert, samp_rate, interferer_freq)
daempfung_db = 10 * np.log10(vorher / nachher)
print(f"Interferer-Daempfung: {daempfung_db:.1f} dB")
assert daempfung_db > 20
Ergebnis: 56,7 dB Dämpfung — deutlich über der geforderten Nachbarkanaldämpfung.
Stufe 3: Quadratur-Demodulator-Gain verifizieren
Die Gain-Formel aus dem Rechner (gain = fs / (2π · shift)) muss den demodulierten Pegel exakt auf ±1,0 normieren:
# Normiert den Ausgang so, dass eine Frequenzabweichung von genau +-shift Hz
# zu einem Pegel von genau +-1.0 fuehrt - unabhaengig von den konkreten Werten
gain = samp_rate / (2 * np.pi * shift)
# Winkel zwischen zwei aufeinanderfolgenden Samples (x[n]*konj(x[n-1])) ist
# proportional zur Momentanfrequenz - exakt das, was analog.quadrature_demod_cf() macht
demod = gain * np.angle(iq_signal[1:] * np.conj(iq_signal[:-1]))
mark_level = np.mean(demod[bit_labels == 1])
space_level = np.mean(demod[bit_labels == 0])
print(f"Mark={mark_level:.3f} (Soll +1.0), Space={space_level:.3f} (Soll -1.0)")
assert abs(mark_level - 1.0) < 0.02
assert abs(space_level + 1.0) < 0.02
# Normiert den Ausgang so, dass eine Frequenzabweichung von genau +-shift Hz
# zu einem Pegel von genau +-1.0 fuehrt - unabhaengig von den konkreten Werten
gain = samp_rate / (2 * np.pi * shift)
# Winkel zwischen zwei aufeinanderfolgenden Samples (x[n]*konj(x[n-1])) ist
# proportional zur Momentanfrequenz - exakt das, was analog.quadrature_demod_cf() macht
demod = gain * np.angle(iq_signal[1:] * np.conj(iq_signal[:-1]))
mark_level = np.mean(demod[bit_labels == 1])
space_level = np.mean(demod[bit_labels == 0])
print(f"Mark={mark_level:.3f} (Soll +1.0), Space={space_level:.3f} (Soll -1.0)")
assert abs(mark_level - 1.0) < 0.02
assert abs(space_level + 1.0) < 0.02
Ergebnis: Mark=1.000, Space=-1.000 — exakt.
Stufe 4: Grenzfrequenz des Post-Detection-Tiefpasses verifizieren
Statt nur die Entwurfsparameter zu glauben, wird der tatsächliche Frequenzgang aus den erzeugten Filter-Taps berechnet (FFT der nullgepadeten Taps) und an zwei Punkten geprüft:
taps_lp = generate_lowpass_taps(117, 50, samp_rate)
# Der Frequenzgang H(f) eines FIR-Filters ist die Fourier-Transformierte seiner
# Taps. Nullpadding auf n_fft=4096 (Taps haben nur 117 Werte) verfeinert nur die
# Mess-Aufloesung im Frequenzbereich, aendert aber nicht den Frequenzgang selbst.
n_fft = 4096
h = np.fft.rfft(taps_lp, n=n_fft)
freqs = np.fft.rfftfreq(n_fft, 1 / samp_rate)
h_db = 20 * np.log10(np.abs(h) + 1e-12) # +1e-12 verhindert log(0)
idx_50 = np.argmin(np.abs(freqs - 50)) # Bin-Index naechst 50 Hz
idx_150 = np.argmin(np.abs(freqs - 150)) # Bin-Index naechst 150 Hz (3x Grenzfrequenz)
print(f"Bei 50 Hz: {h_db[idx_50]:.1f} dB, bei 150 Hz: {h_db[idx_150]:.1f} dB")
assert h_db[idx_50] > -7 # firdes-Konvention: Grenzfrequenz = -6dB-Punkt
assert h_db[idx_150] < -20 # im Sperrbereich muss deutlich mehr gedaempft sein
taps_lp = generate_lowpass_taps(117, 50, samp_rate)
# Der Frequenzgang H(f) eines FIR-Filters ist die Fourier-Transformierte seiner
# Taps. Nullpadding auf n_fft=4096 (Taps haben nur 117 Werte) verfeinert nur die
# Mess-Aufloesung im Frequenzbereich, aendert aber nicht den Frequenzgang selbst.
n_fft = 4096
h = np.fft.rfft(taps_lp, n=n_fft)
freqs = np.fft.rfftfreq(n_fft, 1 / samp_rate)
h_db = 20 * np.log10(np.abs(h) + 1e-12) # +1e-12 verhindert log(0)
idx_50 = np.argmin(np.abs(freqs - 50)) # Bin-Index naechst 50 Hz
idx_150 = np.argmin(np.abs(freqs - 150)) # Bin-Index naechst 150 Hz (3x Grenzfrequenz)
print(f"Bei 50 Hz: {h_db[idx_50]:.1f} dB, bei 150 Hz: {h_db[idx_150]:.1f} dB")
assert h_db[idx_50] > -7 # firdes-Konvention: Grenzfrequenz = -6dB-Punkt
assert h_db[idx_150] < -20 # im Sperrbereich muss deutlich mehr gedaempft sein
Stufe 5: Bit-Slicing — kompletter Durchlauf
Demodulieren, tiefpassfiltern, im Symbolzentrum abtasten, mit den Original-Bits vergleichen:
# mode="same" zentriert die Faltung bereits selbst und gleicht damit die
# Gruppenlaufzeit des Filters ((117-1)/2=58 Samples) automatisch aus - siehe
# Stolperstein-Box unten, falls hier zusaetzlich manuell verzoegert wird
gefiltert_demod = np.convolve(demod, taps_lp, mode="same")
decided_bits = []
for i in range(n_bits):
# Symbolmitte = groesster Abstand zu beiden Nachbar-Uebergaengen, dort ist
# die Entscheidung am robustesten gegen Timing-Ungenauigkeiten
center = i * sps + sps // 2
if center < len(gefiltert_demod):
# Schwellwert bei 0: positiv -> Mark (1), negativ -> Space (0),
# entspricht digital.binary_slicer_fb()
decided_bits.append(1 if gefiltert_demod[center] > 0 else 0)
decided_bits = np.array(decided_bits)
fehler = np.sum(decided_bits != bits)
print(f"Bitfehler: {fehler} von {len(bits)}")
assert fehler == 0
# mode="same" zentriert die Faltung bereits selbst und gleicht damit die
# Gruppenlaufzeit des Filters ((117-1)/2=58 Samples) automatisch aus - siehe
# Stolperstein-Box unten, falls hier zusaetzlich manuell verzoegert wird
gefiltert_demod = np.convolve(demod, taps_lp, mode="same")
decided_bits = []
for i in range(n_bits):
# Symbolmitte = groesster Abstand zu beiden Nachbar-Uebergaengen, dort ist
# die Entscheidung am robustesten gegen Timing-Ungenauigkeiten
center = i * sps + sps // 2
if center < len(gefiltert_demod):
# Schwellwert bei 0: positiv -> Mark (1), negativ -> Space (0),
# entspricht digital.binary_slicer_fb()
decided_bits.append(1 if gefiltert_demod[center] > 0 else 0)
decided_bits = np.array(decided_bits)
fehler = np.sum(decided_bits != bits)
print(f"Bitfehler: {fehler} von {len(bits)}")
assert fehler == 0
center = i*sps + sps//2 + 58) — das ergab 20 von 38 Bitfehlern. Grund: np.convolve(..., mode="same") zentriert die Ausgabe bereits selbst und gleicht die Verzögerung intern aus. Die zusätzliche manuelle Kompensation hat doppelt korrigiert und die Symbolzentren komplett verschoben. Nach Entfernen der manuellen Verzögerung: 0 von 40 Bitfehlern.Ergebnis: 0 Bitfehler bei allen 40 Testbits.
pip install -r requirements.txt
pytest test_rtty_fsk.py -v
pip install -r requirements.txt
pytest test_rtty_fsk.py -v
Übertragung auf echte GNU-Radio-Blöcke
Die numpy-Version oben bildet dieselbe Signalverarbeitung nach, die die echten GNU-Radio-Blöcke aus DDK9 RTTY — Finale Dimensionierung ausführen. Die folgende Version wurde tatsächlich mit GNU Radio 3.10 ausgeführt und verifiziert (nach dem Muster aus Testen mit GRC):
from gnuradio import gr, blocks, filter, analog, digital
import numpy as np
# iq_signal, taps_channel, gain, taps_lp: wie oben mit numpy erzeugt/berechnet
tb = gr.top_block()
# repeat=False: run() kehrt automatisch zurueck, sobald iq_signal einmal
# komplett durchgelaufen ist (siehe "Testen mit GRC", Abschnitt C)
quelle = blocks.vector_source_c(iq_signal.tolist(), repeat=False)
# Jeder Block entspricht 1:1 einer Stufe aus dem numpy-Test oben:
kanalfilter = filter.fir_filter_ccc(1, taps_channel.tolist()) # Stufe 2
quad_demod = analog.quadrature_demod_cf(gain) # Stufe 3
post_tp = filter.fir_filter_fff(1, taps_lp.tolist()) # Stufe 4
slicer = digital.binary_slicer_fb() # Stufe 5
senke = blocks.vector_sink_b()
# Kurzschreibweise verkettet automatisch Ausgang->Eingang jedes Blocks
# (siehe "Testen mit GRC", Abschnitt C zur connect()-Mehrfachverbindung)
tb.connect(quelle, kanalfilter, quad_demod, post_tp, slicer, senke)
tb.run()
ergebnis_bits = np.array(senke.data())
from gnuradio import gr, blocks, filter, analog, digital
import numpy as np
# iq_signal, taps_channel, gain, taps_lp: wie oben mit numpy erzeugt/berechnet
tb = gr.top_block()
# repeat=False: run() kehrt automatisch zurueck, sobald iq_signal einmal
# komplett durchgelaufen ist (siehe "Testen mit GRC", Abschnitt C)
quelle = blocks.vector_source_c(iq_signal.tolist(), repeat=False)
# Jeder Block entspricht 1:1 einer Stufe aus dem numpy-Test oben:
kanalfilter = filter.fir_filter_ccc(1, taps_channel.tolist()) # Stufe 2
quad_demod = analog.quadrature_demod_cf(gain) # Stufe 3
post_tp = filter.fir_filter_fff(1, taps_lp.tolist()) # Stufe 4
slicer = digital.binary_slicer_fb() # Stufe 5
senke = blocks.vector_sink_b()
# Kurzschreibweise verkettet automatisch Ausgang->Eingang jedes Blocks
# (siehe "Testen mit GRC", Abschnitt C zur connect()-Mehrfachverbindung)
tb.connect(quelle, kanalfilter, quad_demod, post_tp, slicer, senke)
tb.run()
ergebnis_bits = np.array(senke.data())
filter.fir_filter_ccc/fir_filter_fff in GNU Radio sind kausale Filter und zentrieren ihre Ausgabe nicht automatisch — anders als np.convolve(..., mode="same") in der numpy-Version. Beide Filter zusammen verzögern das Signal um (65-1)/2 + (117-1)/2 = 90 Samples, die beim Abtasten der Symbolzentren manuell nachgeführt werden müssen:
delay_total = (65 - 1) // 2 + (117 - 1) // 2 # 32 + 58 = 90 Samples
decided_bits = []
for i in range(n_bits):
center = i * sps + sps // 2 + delay_total
if center < len(ergebnis_bits):
decided_bits.append(ergebnis_bits[center])
Nach dieser Korrektur: 0 von 40 Bitfehlern — identisch zum numpy-Ergebnis. Das ist der spiegelbildliche Fehler zum Stolperstein in Stufe 5 oben: dort musste eine manuelle Verzögerung entfernt werden (weil mode="same" schon zentriert), hier musste sie hinzugefügt werden (weil GNU Radios FIR-Blöcke kausal bleiben). Beide Systeme sind in sich korrekt — nur ihre Konventionen zur Gruppenlaufzeit unterscheiden sich, und genau das ist beim Portieren von numpy-Prototypen auf echte Blöcke die häufigste Fehlerquelle.
Testen mit GRC
Die allgemeine Methodik: synthetische Signale, Ground Truth, headless-Tests.
Test-Beispiel: BCD-Decoder
Dieselbe Methodik am BCD-Decoder aus dem DCF77-Projekt.
DDK9 RTTY — Finale Dimensionierung
Die hier getestete Verarbeitungskette im vollen Kontext, mit Herleitung jedes Parameters.
Rechner
Quadratur-Demod-Gain, sps und Rechenlast direkt aus den eigenen Parametern berechnen.