"""CW Element Tagger (Stufe 3, PLL-Fassung).

Nimmt das binaere Mark/Space-Signal (nach dem hysteresebehafteten
Schwellwert-Block) entgegen und klassifiziert Impuls- und Pausendauern
relativ zur Dot-Laenge (1:3:7-Timing, PARIS-Standard). Haengt Tags
'cw_symbol' ('.'/'-') an das Ende jedes Marks und 'cw_gap' ('char'/'word')
an das Ende jeder Pause an - analog zum DCF77 Debug Tagger.

## Warum ein Regelkreis statt Cluster-Mittelwerten

Drei Verfahren zur Geschwindigkeitsschaetzung wurden gegeneinander abgewogen:
ein exponentiell geglaetteter Mittelwert (IIR) auf zwei unabhaengigen
Punkt-/Strich-Referenzen, ein Sliding-Window-Median ueber die letzten N
Elemente, und ein entscheidungsgesteuerter Regelkreis (die Struktur einer
digitalen PLL - siehe /themen/synchronisation/phase-locked-loop-pll/ - nur
angewandt auf Lauflaengen statt auf eine Traegerphase). Alle drei sind in
der Praxis brauchbar; gewaehlt wurde der Regelkreis, weil er die physikalische
Randbedingung des Signals (Strich = exakt 3x Punkt, Wortpause = exakt 7/3x
Zeichenpause) direkt ins Modell einbaut, statt sie implizit ueber zwei separat
gelernte Werte zu approximieren - ein Freiheitsgrad weniger, und strukturell
naeher an den Element-Detektoren, wie sie in der Synchronisationstheorie fuer
Taktrueckgewinnung ueblich sind (Phasendetektor -> Loop-Filter -> gesteuerter
Referenztakt, hier: Fehler zwischen Mark-Dauer und naechstliegendem Vielfachen
der Periode T -> Schleifenverstaerkung -> Update von T).

## Warum zusaetzlich ein Bias-Term (T *und* b, nicht nur T)

Der naive Ansatz (nur T schaetzen, Dauer = n*T annehmen) wurde zuerst
getestet und lieferte einen durchgehenden Klassifikationsfehler: Filter und
Hysterese verlaengern jede gemessene Mark-/Pausendauer um einen ungefaehr
KONSTANTEN Betrag, unabhaengig von n (gemessen: ein echter 60-ms-Punkt bei
20 WPM kommt nach der Kette bei ca. 85-90 Samples an, ein 180-ms-Strich bei
ca. 205-225 Samples - der Offset ist fuer n=1 und n=3 fast gleich groß, nicht
proportional zu n). Ein Modell "Dauer = n*T" kann diese konstante Verschiebung
nicht abbilden: Ein T, das zum Punkt passt, unterschaetzt den Strich, und
umgekehrt. Die Loesung ist dieselbe wie bei der Trennung von Taktfehler und
Laufzeit in der Synchronisationstheorie: zwei Parameter statt einem, "Dauer =
n*T + b", wobei b den festen Verarbeitungs-Bias auffaengt und T die reine
Taktrate. Beide werden gemeinsam per NLMS (Normalized Least Mean Squares -
derselbe adaptive Standardfilter wie in einem Kanalentzerrer) aus jeder neuen
Messung nachgefuehrt: der Regressor ist x=[n, 1], die Gewichte sind [T, b].

## Farnsworth-Trennung (siehe Stufe 1)

Zwei komplett unabhaengige Regelkreise: einer fuer Marks (T_mark/b_mark,
n in {1,3}), einer fuer Pausen (T_gap/b_gap, n in {1,3,7}). Farnsworth-Timing
sendet Zeichen mit fester (hoher) Geschwindigkeit, streckt aber Zeichen-/
Wortpausen kuenstlich - der Pausen-Regelkreis darf den Mark-Regelkreis deshalb
nie beeinflussen. Die einzige bewusste Kopplung: die untere Pausen-
Klassifikationsgrenze (Element-Innenabstand vs. Zeichenpause) nutzt die
vorhergesagte Mark-Periode (T_mark+b_mark), weil der Innenabstand *innerhalb*
eines Zeichens auch bei Farnsworth nicht gestreckt wird.

## Bekannte Grenze

Wie schon die Cluster-Variante braucht auch dieser Regelkreis eine ungefaehr
richtige Startschaetzung (wpm-Parameter) - bei stark falscher Anfangsannahme
(Faktor 2) werden anfangs Striche als Punkte fehlinterpretiert, was T in die
falsche Richtung zieht statt sie zu korrigieren (siehe Artikeltext).
"""
import numpy as np
from gnuradio import gr
import pmt


class NlmsUnitTracker:
    """Schaetzt gemeinsam Periode T und Bias b aus Beobachtungen (n, dauer)
    per Normalized-LMS: dauer_geschaetzt = n*T + b."""

    def __init__(self, t0, mu=0.5, t_lo=None, t_hi=None, b_max=None):
        self.T = t0
        self.b = 0.0
        self.mu = mu
        self._t_lo = t_lo
        self._t_hi = t_hi
        self._b_max = b_max if b_max is not None else 0.5 * t0

    def update(self, n, duration):
        x0, x1 = float(n), 1.0
        pred = x0 * self.T + x1 * self.b
        err = duration - pred
        norm = x0 * x0 + x1 * x1 + 1e-6
        step = self.mu * err / norm
        self.T = min(max(self.T + step * x0, self._t_lo), self._t_hi)
        self.b = min(max(self.b + step * x1, -self._b_max), self._b_max)


class blk(gr.sync_block):
    def __init__(self, samp_rate=1000.0, wpm=20.0, mu=0.7):
        gr.sync_block.__init__(
            self, name="CW Element Tagger",
            in_sig=[np.float32], out_sig=[np.float32])
        self.samp_rate = samp_rate

        dot0 = (1.2 / wpm) * samp_rate
        t_lo = (1.2 / 60.0) * samp_rate
        t_hi = (1.2 / 3.0) * samp_rate

        self.mark = NlmsUnitTracker(dot0, mu=mu, t_lo=t_lo, t_hi=t_hi)
        self.gap = NlmsUnitTracker(dot0, mu=mu, t_lo=t_lo, t_hi=t_hi)

        self.prev_bit = 0
        self.high_count = 0
        self.low_count = 0

    def work(self, input_items, output_items):
        in0 = input_items[0]
        out = output_items[0]
        base = self.nitems_read(0)

        for i in range(len(in0)):
            bit = 1 if in0[i] > 0.5 else 0

            if bit == 1 and self.prev_bit == 0:
                # steigende Flanke: vorangegangene Pause klassifizieren
                mark_period = self.mark.T + self.mark.b  # unstretched, Farnsworth-Referenz
                char_pred = 3 * self.gap.T + self.gap.b
                word_pred = 7 * self.gap.T + self.gap.b
                dot_char_boundary = (mark_period + char_pred) / 2
                char_word_boundary = (char_pred + word_pred) / 2

                if self.low_count >= char_word_boundary:
                    self.add_item_tag(0, base + i, pmt.intern("cw_gap"), pmt.intern("word"))
                    self.gap.update(7, self.low_count)
                elif self.low_count >= dot_char_boundary:
                    self.add_item_tag(0, base + i, pmt.intern("cw_gap"), pmt.intern("char"))
                    self.gap.update(3, self.low_count)
                self.high_count = 0
            elif bit == 0 and self.prev_bit == 1:
                # fallende Flanke: Mark-Dauer klassifizieren
                # (Geschwindigkeitsschaetzung ausschliesslich hieraus - nie aus Pausen)
                dot_pred = self.mark.T + self.mark.b
                dash_pred = 3 * self.mark.T + self.mark.b
                mark_boundary = (dot_pred + dash_pred) / 2

                if self.high_count >= mark_boundary:
                    symbol = "-"
                    self.mark.update(3, self.high_count)
                else:
                    symbol = "."
                    self.mark.update(1, self.high_count)
                self.add_item_tag(0, base + i, pmt.intern("cw_symbol"), pmt.intern(symbol))
                self.low_count = 0

            if bit == 1:
                self.high_count += 1
            else:
                self.low_count += 1

            self.prev_bit = bit
            out[i] = in0[i]

        return len(out)
