Compare commits

..
1 Commits
Author SHA1 Message Date
SandraGuefack 75868d549c Hinzufügen der MUSIC- und AoA-Schätzungsskripte 2026-09-12 14:04:22 +02:00
8 changed files with 318 additions and 3039 deletions

No files matched your search

-7
View File
@@ -1,7 +0,0 @@
__pycache__/
*.pyc
.venv/
venv/
*.jsonl
*.log
.vscode/
-670
View File
@@ -1,670 +0,0 @@
"""AoA-Auswertung mit 2D-Beamformer fuer den Silicon Labs BG22 (Board BRD4191A, 4 x 4, V + H).
Eigenstaendiges Skript, keine RTL-Bibliothek. Enthaelt alle Schritte und Filter:
1. Vorverarbeitung Frequenzversatz entfernen, Mittelwert je Antenne
2. Kalibrierung je Kanal und Polarisation, mit der echten Tag-Richtung
3. Richtung je Paket Bartlett-Beamformer ueber alle Antennen (grobe Suche + Verfeinerung)
4. Qualitaet je Paket Kohaerenz, Nebenmaximum, Keulenbreite, SNR, Jitter
5. Zeitfilter Fenster 0,2 s, Median je Kanal, Hampel-Filter, Kalman-Filter mit Gating
Winkel wie im AoA Analyzer: Azimut 0 = Unterkante, +90 = rechts,
Elevation 90 = direkt vor dem Board, 0 = in der Board-Ebene.
Aufruf:
python aoa_beamformer_komplett.py test_a.jsonl --kalibrierung boresight_vh3.jsonl \
--kal-az -134 --kal-el 87.8 --ref-az -89.1 --ref-el 52.0
"""
from __future__ import annotations
import argparse
import json
import os
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime
import numpy as np
# =============================================================================
# Konstanten
# =============================================================================
C0 = 299_792_458.0
GRAD = np.pi / 180.0
# Umschaltreihenfolge des BRD4191A (Patch 1..16), laut Silicon-Labs-Dokumentation
MUSTER_V = (2, 0, 14, 12, 4, 8, 20, 10, 6, 22, 16, 19, 9, 26, 28, 31)
MUSTER_H = (3, 1, 15, 13, 5, 24, 21, 11, 7, 23, 17, 18, 25, 27, 29, 30)
CP_ANTENNE = 40 # Referenzantenne (Patch 6, zirkular)
ABSTAND_M = 0.036 # Antennenabstand, gemessen ca. 36,7 mm
# =============================================================================
# Winkel und Richtungen
# =============================================================================
def kanal_frequenz(kanal: int) -> float:
"""Frequenz (Hz) eines BLE-Kanals 0..39."""
if kanal == 37:
return 2402e6
if kanal == 38:
return 2426e6
if kanal == 39:
return 2480e6
if 0 <= kanal <= 10:
return (2404 + 2 * kanal) * 1e6
if 11 <= kanal <= 36:
return (2428 + 2 * (kanal - 11)) * 1e6
raise ValueError(f"ungueltiger BLE-Kanal: {kanal}")
def richtung(az, el):
"""Einheitsvektor aus Azimut und Elevation (rad)."""
az, el = np.asarray(az, float), np.asarray(el, float)
return np.stack([np.cos(el) * np.cos(az), np.cos(el) * np.sin(az), np.sin(el)], axis=-1)
def az_el(u):
"""Azimut und Elevation (rad) aus einem Einheitsvektor."""
u = np.asarray(u, float)
return np.arctan2(u[..., 1], u[..., 0]), np.arcsin(np.clip(u[..., 2], -1, 1))
def winkelabstand(u, v):
"""Tatsaechlicher Winkel (rad) zwischen zwei Richtungen ("Abweichung gesamt")."""
return np.arccos(np.clip(np.sum(np.asarray(u) * np.asarray(v), -1), -1, 1))
def sphaerischer_median(U, w=None, iterationen=50):
"""Gewichteter geometrischer Median von Richtungen (robust gegen Ausreisser)."""
U = np.atleast_2d(U)
w = np.ones(len(U)) if w is None else np.asarray(w, float)
m = (w[:, None] * U).sum(0)
m /= np.linalg.norm(m) + 1e-12
for _ in range(iterationen):
d = np.maximum(winkelabstand(U, m), 1e-4)
neu = ((w / d)[:, None] * U).sum(0)
neu /= np.linalg.norm(neu) + 1e-12
if winkelabstand(neu, m) < 1e-7:
return neu
m = neu
return m
def gewichteter_median(v, w):
v, w = np.asarray(v, float), np.asarray(w, float)
i = np.argsort(v)
c = np.cumsum(w[i])
return v[i][np.searchsorted(c, 0.5 * c[-1])]
# =============================================================================
# Antennenboard
# =============================================================================
@dataclass
class Array:
positionen: np.ndarray # (N, 2) in m: x nach unten, y nach rechts
polarisation: np.ndarray # (N,) 0 = vertikal, 1 = horizontal
abstand: float
@property
def n(self) -> int:
return len(self.positionen)
def steuervektoren(self, U, lam):
"""Steuervektoren a(theta) fuer die Richtungen U, Form (G, N)."""
return np.exp(1j * 2 * np.pi / lam * (np.asarray(U)[..., :2] @ self.positionen.T))
def index(self, pol: int, zeile: int, spalte: int) -> int:
"""Antennenindex an Rasterposition (zeile 0 = oben, spalte 0 = links)."""
z = np.rint(self.positionen[:, 0] / self.abstand + 1.5)
s = np.rint(self.positionen[:, 1] / self.abstand + 1.5)
return int(np.flatnonzero((self.polarisation == pol) & (z == zeile) & (s == spalte))[0])
def brd4191a(polar: str = "VH", abstand_m: float = ABSTAND_M) -> Array:
"""BRD4191A: Patch 1..16 spaltenweise von rechts nach links, je von oben nach unten."""
pos = []
for k in range(16):
zeile, spalte = k % 4, 3 - k // 4
pos.append(((zeile - 1.5) * abstand_m, (spalte - 1.5) * abstand_m))
pos = np.array(pos)
if polar == "V":
return Array(pos, np.zeros(16, int), abstand_m)
if polar == "VH":
return Array(np.vstack([pos, pos]), np.r_[np.zeros(16, int), np.ones(16, int)], abstand_m)
raise ValueError("polar muss 'V' oder 'VH' sein")
# =============================================================================
# IQ-Daten einlesen
# =============================================================================
@dataclass
class Paket:
t: float # Zeit (s)
kanal: int
ref: np.ndarray # 8 Referenz-Samples
t_ref: np.ndarray # Zeitpunkte (us)
samples: np.ndarray # Samples der Umschaltphase
t_samples: np.ndarray # Zeitpunkte (us)
antennen: np.ndarray # Antennenindex je Sample
def paket_aus_iq(i_werte, q_werte, kanal, t, n_ant, slot_us=2.0):
"""Ein Paket aus I/Q-Listen: 8 Referenz-Samples, danach Zyklus CP + n_ant Antennen."""
z = np.asarray(i_werte, float) - 1j * np.asarray(q_werte, float)
if len(z) <= 8 + n_ant:
return None
k = np.arange(len(z) - 8)
idx = k % (n_ant + 1)
g = idx < n_ant
return Paket(t, int(kanal), z[:8], np.arange(8.0), z[8:][g],
(8.0 + slot_us + 2 * slot_us * k)[g], idx[g])
def lese_iq_datei(pfad: str, n_ant: int | None = None, jedes: int = 1) -> list[Paket]:
"""Liest eine IQ-Datei (.jsonl). Anzahl Antennen aus <pfad>.meta.json, sonst 16."""
meta = {}
if os.path.exists(pfad + ".meta.json"):
with open(pfad + ".meta.json", encoding="utf-8") as f:
meta = json.load(f)
n_ant = n_ant or int(meta.get("n_antennes", 16))
slot_us = float(meta.get("slot_us", 2.0))
pakete = []
with open(pfad, encoding="utf-8") as f:
for nr, zeile in enumerate(f):
if nr % jedes:
continue
d = json.loads(zeile)
t = datetime.fromisoformat(d["zeit"]).timestamp()
p = paket_aus_iq(d["i"], d["q"], d["channel"], t, n_ant, slot_us)
if p is not None:
pakete.append(p)
return pakete
# =============================================================================
# Vorverarbeitung: Frequenzversatz entfernen, Mittelwert je Antenne
# =============================================================================
def vorverarbeiten(p: Paket, n_ant: int):
"""Gibt x (N,) komplex (NaN = Antenne fehlt), SNR und Phasen-Jitter je Antenne zurueck."""
ref, tr = np.asarray(p.ref), np.asarray(p.t_ref, float)
omega = np.angle(np.sum(ref[1:] * np.conj(ref[:-1]))) / np.median(np.diff(tr))
phi0 = np.angle(np.sum(ref * np.exp(-1j * omega * tr)))
r = ref * np.exp(-1j * (omega * tr + phi0))
m = r.mean()
snr = abs(m) ** 2 / max(np.mean(np.abs(r - m) ** 2), 1e-12)
s, t, ant = np.asarray(p.samples), np.asarray(p.t_samples, float), np.asarray(p.antennen)
y = s * np.exp(-1j * (omega * t + phi0))
# Feinkorrektur der Frequenz ueber wiederholte Antennen
num, dts = 0j, []
for i in np.unique(ant):
k = np.flatnonzero(ant == i)
if k.size >= 2:
num += np.sum(y[k[1:]] * np.conj(y[k[:-1]]))
dts.extend(np.diff(t[k]))
if dts:
y = y * np.exp(-1j * (np.angle(num) / np.mean(dts)) * t)
x = np.full(n_ant, np.nan + 0j)
jitter = np.zeros(n_ant)
for i in range(n_ant):
si = y[ant == i]
if si.size == 0:
continue
x[i] = si.mean()
if si.size > 1:
R = abs(np.mean(np.exp(1j * np.angle(si * np.conj(x[i])))))
jitter[i] = np.sqrt(-2 * np.log(max(R, 1e-9)))
return x, snr, jitter
# =============================================================================
# Kalibrierung
# =============================================================================
class Kalibrierung:
"""Komplexer Faktor je Antenne und Kanal, aus einer Aufnahme mit bekannter Tag-Richtung."""
def __init__(self, n: int):
self.global_ = np.ones(n, complex)
self.je_kanal: dict[int, np.ndarray] = {}
def anwenden(self, x, kanal):
return x / self.je_kanal.get(kanal, self.global_)
@classmethod
def aus_aufnahme(cls, array: Array, pakete, az_grad: float, el_grad: float, min_pakete: int = 5):
u = richtung(az_grad * GRAD, el_grad * GRAD)
gruppen: dict[int, list] = {}
for p in pakete:
x, _, _ = vorverarbeiten(p, array.n)
if np.all(np.isfinite(x)):
gruppen.setdefault(p.kanal, []).append(x)
kal, alle = cls(array.n), []
for kanal, X in gruppen.items():
if len(X) < min_pakete:
continue
X = np.array(X)
a = array.steuervektoren(u[None], C0 / kanal_frequenz(kanal))[0]
c = np.empty(array.n, complex)
for pol in np.unique(array.polarisation): # jede Polarisation getrennt
m = array.polarisation == pol
R = X[:, m].T @ X[:, m].conj() / len(X)
v = np.linalg.eigh(R)[1][:, -1] # dominanter Pfad
cm = v / a[m]
c[m] = cm * np.exp(-1j * np.angle(cm[0])) / np.mean(np.abs(cm))
kal.je_kanal[kanal] = c
alle.append(c)
if not alle:
raise ValueError("zu wenige Pakete fuer die Kalibrierung")
g = np.mean(alle, axis=0)
for pol in np.unique(array.polarisation):
m = array.polarisation == pol
g[m] /= np.mean(np.abs(g[m]))
kal.global_ = g
return kal
# =============================================================================
# Einstellungen und Ergebnisse
# =============================================================================
@dataclass
class Einstellungen:
raster_grad: float = 2.0 # Suchraster
polarisationen: tuple = (0, 1) # (0,) = nur V, (0, 1) = V und H
schwache_antenne: float = 0.3 # Amplitude < 30 % des Medians -> ignorieren
q_min: float = 0.15 # Pakete mit geringerer Qualitaet verwerfen
fenster_s: float = 0.2 # Pakete je Ausgabewert
hampel_n: int = 9
hampel_k: float = 3.5
hampel_boden_grad: float = 2.0
sigma_min_grad: float = 1.5 # Messrauschen bei bester Qualitaet
k_rauschen: float = 4e-4 # R = sigma_min^2 + k_rauschen / Qualitaet
beschleunigung: float = 0.05 # Prozessrauschen Kalman
gating: float = 9.21 # Chi^2, 2 Freiheitsgrade, 99 %
n_neustart: int = 4 # Verwerfungen in Folge -> neue Position uebernehmen
@dataclass
class PaketMessung:
t: float
kanal: int
u: np.ndarray
qualitaet: float
kohaerenz: float
nebenmaximum: float
keulenbreite: float
snr: float
gruende: list = field(default_factory=list)
@dataclass
class Schaetzung:
t: float
az_grad: float
el_grad: float
unsicherheit_az: float
unsicherheit_el: float
konfidenz: float
roh_az_grad: float
roh_el_grad: float
pakete: int
pakete_gueltig: int
gruende: list = field(default_factory=list)
# =============================================================================
# Kalman-Filter auf den Richtungskosinus (ux, uy)
# =============================================================================
class KalmanRichtung:
"""Zustand [ux, uy, vx, vy], Modell konstanter Geschwindigkeit."""
def __init__(self, beschleunigung):
self.q = beschleunigung
self.x = self.P = self.t = None
def starten(self, z, R, t):
self.x = np.r_[z, 0.0, 0.0]
self.P = np.diag([R[0, 0], R[1, 1], 0.05, 0.05])
self.t = t
def vorhersagen(self, t):
dt = max(t - self.t, 1e-3)
F = np.eye(4)
F[0, 2] = F[1, 3] = dt
Qb = self.q * np.array([[dt**4 / 4, dt**3 / 2], [dt**3 / 2, dt**2]])
Q = np.zeros((4, 4))
Q[np.ix_([0, 2], [0, 2])] = Qb
Q[np.ix_([1, 3], [1, 3])] = Qb
self.x = F @ self.x
self.P = F @ self.P @ F.T + Q
self.t = t
self._begrenzen()
def abstand(self, z, R):
nu = z - self.x[:2]
return float(nu @ np.linalg.solve(self.P[:2, :2] + R, nu))
def korrigieren(self, z, R):
H = np.zeros((2, 4))
H[0, 0] = H[1, 1] = 1
K = self.P @ H.T @ np.linalg.inv(H @ self.P @ H.T + R)
self.x = self.x + K @ (z - self.x[:2])
self.P = (np.eye(4) - K @ H) @ self.P
self._begrenzen()
def _begrenzen(self):
r = np.hypot(*self.x[:2])
if r > 0.9999:
self.x[:2] *= 0.9999 / r
def ausgabe(self):
"""Azimut, Elevation und Unsicherheiten in Grad."""
ux, uy = self.x[:2]
r = max(np.hypot(ux, uy), 1e-3)
uz = np.sqrt(max(1 - r * r, 0.0))
az, el = np.arctan2(uy, ux), np.arctan2(uz, r)
P = self.P[:2, :2]
j_az = np.array([-uy, ux]) / r**2
j_r = np.array([ux, uy]) / r
s_r = np.sqrt(max(j_r @ P @ j_r, 0))
s_az = np.sqrt(max(j_az @ P @ j_az, 0)) / GRAD
s_el = 0.5 * abs(np.arccos(np.clip(r - s_r, 0, 1)) - np.arccos(np.clip(r + s_r, 0, 1))) / GRAD
return az / GRAD, el / GRAD, min(s_az, 180.0), min(s_el, 90.0)
# =============================================================================
# Beamformer-Schaetzer
# =============================================================================
class BeamformerSchaetzer:
"""Ablauf je Paket: Vorverarbeitung -> Kalibrierung -> Beamformer -> Qualitaet.
Ablauf je Fenster: Median je Kanal -> Hampel -> Kalman."""
NAME = "Beamformer"
def __init__(self, array: Array, kal: Kalibrierung | None = None, einst: Einstellungen | None = None):
self.array = array
self.kal = kal or Kalibrierung(array.n)
self.einst = einst or Einstellungen()
schritt = self.einst.raster_grad * GRAD
AZ, EL = np.meshgrid(np.arange(-np.pi, np.pi, schritt), np.arange(0, np.pi / 2 + 1e-9, schritt))
self.form = AZ.shape
self.U = richtung(AZ.ravel(), EL.ravel())
self.flaeche = np.cos(EL.ravel()) * schritt * schritt
self._A_cache: dict[float, np.ndarray] = {}
self.kalman = KalmanRichtung(self.einst.beschleunigung)
self.verlauf = deque(maxlen=self.einst.hampel_n)
self.puffer: list[PaketMessung] = []
self.verworfen = 0
# ---- Steuervektoren auf dem Suchraster (Cache je Wellenlaenge) ----
def A_raster(self, lam):
key = round(lam * 1e6, 3)
if key not in self._A_cache:
self._A_cache[key] = self.array.steuervektoren(self.U, lam).astype(np.complex64)
return self._A_cache[key]
# ---- Bartlett-Spektrum (Beamformer), Werte 0..1 ----
def bartlett(self, U, x, maske, lam, A=None):
"""V und H getrennt berechnen, nach Energie gewichtet zusammenfassen."""
A = self.array.steuervektoren(U, lam) if A is None else A
P, E_ges = np.zeros(len(U)), 0.0
for pol in self.einst.polarisationen:
m = maske & (self.array.polarisation == pol)
if m.sum() < 3:
continue
xs = x[m]
E = float(np.sum(np.abs(xs) ** 2))
P += E * np.abs(np.conj(A[:, m]) @ xs) ** 2 / (m.sum() * E)
E_ges += E
return P / E_ges if E_ges > 0 else P
def fein_suchen(self, spektrum, u0, breite_grad=3.0, schritt_grad=0.25):
"""Verfeinerung um u0: gibt beste Richtung und Spektrumswert zurueck."""
az0, el0 = az_el(u0)
d = np.arange(-breite_grad, breite_grad + 1e-3, schritt_grad) * GRAD
AZ, EL = np.meshgrid(az0 + d, np.clip(el0 + d, 0, np.pi / 2))
Uf = richtung(AZ.ravel(), EL.ravel())
P = spektrum(Uf)
k = int(np.argmax(P))
return Uf[k], float(P[k])
def _nebenmaximum(self, P, u, ausschluss_grad=15.0):
"""Hoechstes lokales Maximum ausserhalb der Hauptkeule."""
G = P.reshape(self.form)
nachbarn = [np.roll(G, 1, 1), np.roll(G, -1, 1),
np.vstack([G[:1] * 0, G[:-1]]), np.vstack([G[1:], G[-1:] * 0])]
lokal = np.all([G >= v for v in nachbarn], axis=0).ravel()
lokal &= self.U @ u < np.cos(ausschluss_grad * GRAD)
return float(P[lokal].max()) if lokal.any() else 0.0
def _keulenflaeche(self, P_nah, nah):
if not nah.any() or P_nah.max() <= 0:
return np.nan
return float(np.sum(self.flaeche[nah][P_nah >= 0.5 * P_nah.max()]))
# ---- Auswertung eines Pakets ----
def paket_auswerten(self, p: Paket) -> PaketMessung:
e = self.einst
gruende = []
x, snr, jitter = vorverarbeiten(p, self.array.n)
x = self.kal.anwenden(x, p.kanal)
gueltig = np.isfinite(x)
amp = np.abs(x)
# Schwache Antennen ausblenden (je Polarisation)
maske = np.zeros(self.array.n, bool)
for pol in np.unique(self.array.polarisation):
m = gueltig & (self.array.polarisation == pol)
if m.any():
maske |= m & (amp >= e.schwache_antenne * np.median(amp[m]))
maske &= np.isin(self.array.polarisation, e.polarisationen)
erwartet = np.isin(self.array.polarisation, e.polarisationen).sum()
if maske.sum() < erwartet:
gruende.append("schwache oder fehlende Antenne")
lam = C0 / kanal_frequenz(p.kanal)
xm = np.where(maske, x, 0)
# Beamformer: grobe Suche, Verfeinerung
A = self.A_raster(lam)
P = self.bartlett(self.U, xm, maske, lam, A)
k = int(np.argmax(P))
u_bf, kohaerenz = self.fein_suchen(lambda Uf: self.bartlett(Uf, xm, maske, lam), self.U[k])
# Qualitaetsmerkmale
neben = self._nebenmaximum(P, u_bf) / P[k] if P[k] > 0 else 0.0
nah = self.U @ u_bf > np.cos(30 * GRAD)
a_ideal = self.array.steuervektoren(u_bf[None], lam)[0] * maske
P_ideal = self.bartlett(self.U[nah], a_ideal, maske, lam, A[nah])
breite = self._keulenflaeche(P[nah], nah) / max(self._keulenflaeche(P_ideal, nah), 1e-9)
jit = float(np.median(jitter[maske])) if maske.any() else np.pi
q = (np.clip((kohaerenz - 0.3) / 0.6, 0, 1)
* np.clip((0.85 - neben) / 0.5, 0, 1)
* (np.clip(1.0 / max(breite, 1e-6), 0, 1) if np.isfinite(breite) else 0.5)
* snr / (snr + 10.0)
* maske.sum() / max(erwartet, 1)
* np.exp(-0.5 * (jit / (20 * GRAD)) ** 2))
if kohaerenz < 0.6:
gruende.append("geringe Kohaerenz (mehrere Pfade)")
if neben > 0.6:
gruende.append("mehrere aehnliche Maxima")
if breite > 1.6:
gruende.append("verbreiterte Keule")
if snr < 5:
gruende.append("geringes SNR")
return PaketMessung(p.t, p.kanal, u_bf, float(q), kohaerenz, neben, breite, snr, gruende)
# ---- Laufende Verarbeitung ----
def verarbeiten(self, p: Paket) -> Schaetzung | None:
"""Fuer jedes Paket aufrufen. Liefert am Ende jedes Fensters eine Schaetzung."""
m = self.paket_auswerten(p)
if self.puffer and m.t - self.puffer[0].t >= self.einst.fenster_s:
s = self._fenster_abschliessen(self.puffer)
self.puffer = [m]
return s
self.puffer.append(m)
return None
def _fenster_abschliessen(self, messungen: list[PaketMessung]) -> Schaetzung:
e = self.einst
t = messungen[-1].t
gueltig = [m for m in messungen if m.qualitaet >= e.q_min]
gruende = sorted({g for m in messungen for g in m.gruende
if sum(g in mm.gruende for mm in messungen) > len(messungen) / 2})
# Keine gueltige Messung: nur Vorhersage
if not gueltig:
if self.kalman.x is not None:
self.kalman.vorhersagen(t)
az, el, saz, sel = self.kalman.ausgabe()
else:
az = el = saz = sel = np.nan
return Schaetzung(t, az, el, saz, sel, 0.0, np.nan, np.nan,
len(messungen), 0, gruende + ["keine gueltige Messung"])
# Median je Kanal, dann ueber die Kanaele
je_kanal: dict[int, list] = {}
for m in gueltig:
je_kanal.setdefault(m.kanal, []).append(m)
Uk = np.array([sphaerischer_median(np.array([m.u for m in ms]), [m.qualitaet for m in ms])
for ms in je_kanal.values()])
Wk = np.array([np.median([m.qualitaet for m in ms]) for ms in je_kanal.values()])
u_f = sphaerischer_median(Uk, Wk)
streuung = gewichteter_median(winkelabstand(Uk, u_f), Wk) / GRAD if len(Uk) > 1 else 0.0
U_alle = np.array([m.u for m in gueltig])
q_alle = np.array([m.qualitaet for m in gueltig])
anteil_weit = float(np.sum(q_alle[winkelabstand(U_alle, u_f) > 15 * GRAD]) / q_alle.sum())
q_f = float(np.median(q_alle)) * np.exp(-0.5 * (streuung / 10.0) ** 2)
if streuung > 10:
gruende.append("Kanaele uneinig")
if anteil_weit > 0.3:
gruende.append("Pakete gestreut")
# Hampel: Sprung gegenueber dem juengsten Verlauf?
sprung = False
if len(self.verlauf) >= 5:
H = np.array(self.verlauf)
c = sphaerischer_median(H)
skala = 1.4826 * np.median(winkelabstand(H, c)) + e.hampel_boden_grad * GRAD
sprung = winkelabstand(u_f, c) > e.hampel_k * skala
self.verlauf.append(u_f)
# Kalman mit qualitaetsabhaengigem Messrauschen und Gating
z = u_f[:2]
R = np.eye(2) * ((e.sigma_min_grad * GRAD) ** 2 + e.k_rauschen / max(q_f, 1e-3))
angenommen = True
if self.kalman.x is None:
self.kalman.starten(z, R, t)
else:
self.kalman.vorhersagen(t)
if sprung or self.kalman.abstand(z, R) > e.gating:
angenommen = False
self.verworfen += 1
gruende.append("Sprung erkannt (Hampel)" if sprung else "vom Kalman verworfen")
if self.verworfen >= e.n_neustart: # Tag hat sich wirklich bewegt
neu = np.array(list(self.verlauf)[-e.n_neustart:])
self.kalman.starten(sphaerischer_median(neu)[:2], R, t)
self.verlauf.clear()
self.verlauf.extend(neu)
self.verworfen = 0
gruende.append("Neustart (neue Position)")
else:
self.kalman.korrigieren(z, R)
self.verworfen = 0
az, el, saz, sel = self.kalman.ausgabe()
if el < 15:
gruende.append("nahe der Board-Ebene: Elevation unsicher")
konfidenz = q_f * (1.0 if angenommen else 0.3)
raz, rel = az_el(u_f)
return Schaetzung(t, az, el, saz, sel, float(np.clip(konfidenz, 0, 1)),
raz / GRAD, rel / GRAD, len(messungen), len(gueltig), gruende)
# =============================================================================
# Ergebnis und Vergleich mit dem AoA Analyzer
# =============================================================================
def ergebnis(schaetzungen: list[Schaetzung], ref_az=None, ref_el=None) -> dict:
"""Median aller Schaetzungen, optional Abweichung zum Analyzer."""
S = [s for s in schaetzungen if np.isfinite(s.az_grad)]
if not S:
return {}
u = sphaerischer_median(np.array([richtung(s.az_grad * GRAD, s.el_grad * GRAD) for s in S]))
az, el = az_el(u)
res = {"az": az / GRAD, "el": el / GRAD, "konfidenz": float(np.median([s.konfidenz for s in S])),
"anzahl": len(S)}
if ref_az is not None and ref_el is not None:
res["abweichung"] = float(winkelabstand(u, richtung(ref_az * GRAD, ref_el * GRAD)) / GRAD)
res["abw_az"] = ((az / GRAD - ref_az + 180) % 360) - 180
res["abw_el"] = el / GRAD - ref_el
return res
# =============================================================================
# Kommandozeile
# =============================================================================
def main():
ap = argparse.ArgumentParser(description="AoA-Auswertung mit 2D-Beamformer und allen Filtern")
ap.add_argument("iq", help="IQ-Datei (.jsonl)")
ap.add_argument("--kalibrierung", required=True, help="Kalibrieraufnahme am selben Standort")
ap.add_argument("--kal-az", type=float, default=0.0, help="Analyzer-Azimut waehrend der Kalibrierung")
ap.add_argument("--kal-el", type=float, default=90.0, help="Analyzer-Elevation waehrend der Kalibrierung")
ap.add_argument("--polar", default="VH", choices=["V", "VH"])
ap.add_argument("--abstand-mm", type=float, default=ABSTAND_M * 1000)
ap.add_argument("--ref-az", type=float, help="Analyzer-Azimut an dieser Position (Vergleich)")
ap.add_argument("--ref-el", type=float, help="Analyzer-Elevation an dieser Position (Vergleich)")
ap.add_argument("--jedes", type=int, default=1, help="nur jedes n-te Paket auswerten")
ap.add_argument("--ausgabe", help="Schaetzungen als JSON speichern")
ap.add_argument("--verlauf", action="store_true", help="jede Schaetzung ausgeben")
a = ap.parse_args()
# Board, Kalibrierung, Schaetzer
array = brd4191a(a.polar, a.abstand_mm / 1000)
kal = Kalibrierung.aus_aufnahme(array, lese_iq_datei(a.kalibrierung, array.n), a.kal_az, a.kal_el)
sch = BeamformerSchaetzer(array, kal, Einstellungen(polarisationen=(0,) if a.polar == "V" else (0, 1)))
# Pakete auswerten
schaetzungen = []
for p in lese_iq_datei(a.iq, array.n, a.jedes):
s = sch.verarbeiten(p)
if s is None:
continue
schaetzungen.append(s)
if a.verlauf and np.isfinite(s.az_grad):
print(f"{s.az_grad:+7.1f} / {s.el_grad:5.1f} Konfidenz {s.konfidenz:.2f} {', '.join(s.gruende)}")
if a.ausgabe:
with open(a.ausgabe, "w", encoding="utf-8") as f:
json.dump([s.__dict__ for s in schaetzungen], f, indent=1, ensure_ascii=False, default=float)
# Ergebnis ausgeben
r = ergebnis(schaetzungen, a.ref_az, a.ref_el)
if not r:
print("Keine gueltige Schaetzung.")
return
zeile = f"[Beamformer] Ergebnis: Azimut {r['az']:+.1f}, Elevation {r['el']:.1f}, Konfidenz {r['konfidenz']:.2f}"
if "abweichung" in r:
zeile += (f" | Abweichung zum Analyzer {r['abweichung']:.1f} Grad "
f"(Az {r['abw_az']:+.1f}, El {r['abw_el']:+.1f})")
print(zeile)
if __name__ == "__main__":
main()
-743
View File
@@ -1,743 +0,0 @@
"""AoA-Auswertung mit 2D-MUSIC fuer den Silicon Labs BG22 (Board BRD4191A, 4 x 4, V + H).
Eigenstaendiges Skript, keine RTL-Bibliothek. Enthaelt alle Schritte und Filter:
1. Vorverarbeitung Frequenzversatz entfernen, Mittelwert je Antenne
2. Kalibrierung je Kanal und Polarisation, mit der echten Tag-Richtung
3. Qualitaet je Paket aus dem Beamformer-Spektrum (Kohaerenz, Nebenmaximum, Keule, SNR, Jitter)
4. Richtung je Paket MUSIC mit Spatial Smoothing + Forward-Backward,
Suche um die Beamformer-Richtung, Gegenprobe gegen den Beamformer
5. Zeitfilter Fenster 0,2 s, Median je Kanal, Hampel-Filter, Kalman-Filter mit Gating
Winkel wie im AoA Analyzer: Azimut 0 = Unterkante, +90 = rechts,
Elevation 90 = direkt vor dem Board, 0 = in der Board-Ebene.
Aufruf:
python aoa_music_komplett.py test_a.jsonl --kalibrierung boresight_vh3.jsonl \
--kal-az -134 --kal-el 87.8 --ref-az -89.1 --ref-el 52.0
"""
from __future__ import annotations
import argparse
import json
import os
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime
import numpy as np
# =============================================================================
# Konstanten
# =============================================================================
C0 = 299_792_458.0
GRAD = np.pi / 180.0
# Umschaltreihenfolge des BRD4191A (Patch 1..16), laut Silicon-Labs-Dokumentation
MUSTER_V = (2, 0, 14, 12, 4, 8, 20, 10, 6, 22, 16, 19, 9, 26, 28, 31)
MUSTER_H = (3, 1, 15, 13, 5, 24, 21, 11, 7, 23, 17, 18, 25, 27, 29, 30)
CP_ANTENNE = 40 # Referenzantenne (Patch 6, zirkular)
ABSTAND_M = 0.036 # Antennenabstand, gemessen ca. 36,7 mm
# =============================================================================
# Winkel und Richtungen
# =============================================================================
def kanal_frequenz(kanal: int) -> float:
"""Frequenz (Hz) eines BLE-Kanals 0..39."""
if kanal == 37:
return 2402e6
if kanal == 38:
return 2426e6
if kanal == 39:
return 2480e6
if 0 <= kanal <= 10:
return (2404 + 2 * kanal) * 1e6
if 11 <= kanal <= 36:
return (2428 + 2 * (kanal - 11)) * 1e6
raise ValueError(f"ungueltiger BLE-Kanal: {kanal}")
def richtung(az, el):
"""Einheitsvektor aus Azimut und Elevation (rad)."""
az, el = np.asarray(az, float), np.asarray(el, float)
return np.stack([np.cos(el) * np.cos(az), np.cos(el) * np.sin(az), np.sin(el)], axis=-1)
def az_el(u):
"""Azimut und Elevation (rad) aus einem Einheitsvektor."""
u = np.asarray(u, float)
return np.arctan2(u[..., 1], u[..., 0]), np.arcsin(np.clip(u[..., 2], -1, 1))
def winkelabstand(u, v):
"""Tatsaechlicher Winkel (rad) zwischen zwei Richtungen ("Abweichung gesamt")."""
return np.arccos(np.clip(np.sum(np.asarray(u) * np.asarray(v), -1), -1, 1))
def sphaerischer_median(U, w=None, iterationen=50):
"""Gewichteter geometrischer Median von Richtungen (robust gegen Ausreisser)."""
U = np.atleast_2d(U)
w = np.ones(len(U)) if w is None else np.asarray(w, float)
m = (w[:, None] * U).sum(0)
m /= np.linalg.norm(m) + 1e-12
for _ in range(iterationen):
d = np.maximum(winkelabstand(U, m), 1e-4)
neu = ((w / d)[:, None] * U).sum(0)
neu /= np.linalg.norm(neu) + 1e-12
if winkelabstand(neu, m) < 1e-7:
return neu
m = neu
return m
def gewichteter_median(v, w):
v, w = np.asarray(v, float), np.asarray(w, float)
i = np.argsort(v)
c = np.cumsum(w[i])
return v[i][np.searchsorted(c, 0.5 * c[-1])]
# =============================================================================
# Antennenboard
# =============================================================================
@dataclass
class Array:
positionen: np.ndarray # (N, 2) in m: x nach unten, y nach rechts
polarisation: np.ndarray # (N,) 0 = vertikal, 1 = horizontal
abstand: float
@property
def n(self) -> int:
return len(self.positionen)
def steuervektoren(self, U, lam):
"""Steuervektoren a(theta) fuer die Richtungen U, Form (G, N)."""
return np.exp(1j * 2 * np.pi / lam * (np.asarray(U)[..., :2] @ self.positionen.T))
def index(self, pol: int, zeile: int, spalte: int) -> int:
"""Antennenindex an Rasterposition (zeile 0 = oben, spalte 0 = links)."""
z = np.rint(self.positionen[:, 0] / self.abstand + 1.5)
s = np.rint(self.positionen[:, 1] / self.abstand + 1.5)
return int(np.flatnonzero((self.polarisation == pol) & (z == zeile) & (s == spalte))[0])
def brd4191a(polar: str = "VH", abstand_m: float = ABSTAND_M) -> Array:
"""BRD4191A: Patch 1..16 spaltenweise von rechts nach links, je von oben nach unten."""
pos = []
for k in range(16):
zeile, spalte = k % 4, 3 - k // 4
pos.append(((zeile - 1.5) * abstand_m, (spalte - 1.5) * abstand_m))
pos = np.array(pos)
if polar == "V":
return Array(pos, np.zeros(16, int), abstand_m)
if polar == "VH":
return Array(np.vstack([pos, pos]), np.r_[np.zeros(16, int), np.ones(16, int)], abstand_m)
raise ValueError("polar muss 'V' oder 'VH' sein")
# =============================================================================
# IQ-Daten einlesen
# =============================================================================
@dataclass
class Paket:
t: float # Zeit (s)
kanal: int
ref: np.ndarray # 8 Referenz-Samples
t_ref: np.ndarray # Zeitpunkte (us)
samples: np.ndarray # Samples der Umschaltphase
t_samples: np.ndarray # Zeitpunkte (us)
antennen: np.ndarray # Antennenindex je Sample
def paket_aus_iq(i_werte, q_werte, kanal, t, n_ant, slot_us=2.0):
"""Ein Paket aus I/Q-Listen: 8 Referenz-Samples, danach Zyklus CP + n_ant Antennen."""
z = np.asarray(i_werte, float) - 1j * np.asarray(q_werte, float)
if len(z) <= 8 + n_ant:
return None
k = np.arange(len(z) - 8)
idx = k % (n_ant + 1)
g = idx < n_ant
return Paket(t, int(kanal), z[:8], np.arange(8.0), z[8:][g],
(8.0 + slot_us + 2 * slot_us * k)[g], idx[g])
def lese_iq_datei(pfad: str, n_ant: int | None = None, jedes: int = 1) -> list[Paket]:
"""Liest eine IQ-Datei (.jsonl). Anzahl Antennen aus <pfad>.meta.json, sonst 16."""
meta = {}
if os.path.exists(pfad + ".meta.json"):
with open(pfad + ".meta.json", encoding="utf-8") as f:
meta = json.load(f)
n_ant = n_ant or int(meta.get("n_antennes", 16))
slot_us = float(meta.get("slot_us", 2.0))
pakete = []
with open(pfad, encoding="utf-8") as f:
for nr, zeile in enumerate(f):
if nr % jedes:
continue
d = json.loads(zeile)
t = datetime.fromisoformat(d["zeit"]).timestamp()
p = paket_aus_iq(d["i"], d["q"], d["channel"], t, n_ant, slot_us)
if p is not None:
pakete.append(p)
return pakete
# =============================================================================
# Vorverarbeitung: Frequenzversatz entfernen, Mittelwert je Antenne
# =============================================================================
def vorverarbeiten(p: Paket, n_ant: int):
"""Gibt x (N,) komplex (NaN = Antenne fehlt), SNR und Phasen-Jitter je Antenne zurueck."""
ref, tr = np.asarray(p.ref), np.asarray(p.t_ref, float)
omega = np.angle(np.sum(ref[1:] * np.conj(ref[:-1]))) / np.median(np.diff(tr))
phi0 = np.angle(np.sum(ref * np.exp(-1j * omega * tr)))
r = ref * np.exp(-1j * (omega * tr + phi0))
m = r.mean()
snr = abs(m) ** 2 / max(np.mean(np.abs(r - m) ** 2), 1e-12)
s, t, ant = np.asarray(p.samples), np.asarray(p.t_samples, float), np.asarray(p.antennen)
y = s * np.exp(-1j * (omega * t + phi0))
# Feinkorrektur der Frequenz ueber wiederholte Antennen
num, dts = 0j, []
for i in np.unique(ant):
k = np.flatnonzero(ant == i)
if k.size >= 2:
num += np.sum(y[k[1:]] * np.conj(y[k[:-1]]))
dts.extend(np.diff(t[k]))
if dts:
y = y * np.exp(-1j * (np.angle(num) / np.mean(dts)) * t)
x = np.full(n_ant, np.nan + 0j)
jitter = np.zeros(n_ant)
for i in range(n_ant):
si = y[ant == i]
if si.size == 0:
continue
x[i] = si.mean()
if si.size > 1:
R = abs(np.mean(np.exp(1j * np.angle(si * np.conj(x[i])))))
jitter[i] = np.sqrt(-2 * np.log(max(R, 1e-9)))
return x, snr, jitter
# =============================================================================
# Kalibrierung
# =============================================================================
class Kalibrierung:
"""Komplexer Faktor je Antenne und Kanal, aus einer Aufnahme mit bekannter Tag-Richtung."""
def __init__(self, n: int):
self.global_ = np.ones(n, complex)
self.je_kanal: dict[int, np.ndarray] = {}
def anwenden(self, x, kanal):
return x / self.je_kanal.get(kanal, self.global_)
@classmethod
def aus_aufnahme(cls, array: Array, pakete, az_grad: float, el_grad: float, min_pakete: int = 5):
u = richtung(az_grad * GRAD, el_grad * GRAD)
gruppen: dict[int, list] = {}
for p in pakete:
x, _, _ = vorverarbeiten(p, array.n)
if np.all(np.isfinite(x)):
gruppen.setdefault(p.kanal, []).append(x)
kal, alle = cls(array.n), []
for kanal, X in gruppen.items():
if len(X) < min_pakete:
continue
X = np.array(X)
a = array.steuervektoren(u[None], C0 / kanal_frequenz(kanal))[0]
c = np.empty(array.n, complex)
for pol in np.unique(array.polarisation): # jede Polarisation getrennt
m = array.polarisation == pol
R = X[:, m].T @ X[:, m].conj() / len(X)
v = np.linalg.eigh(R)[1][:, -1] # dominanter Pfad
cm = v / a[m]
c[m] = cm * np.exp(-1j * np.angle(cm[0])) / np.mean(np.abs(cm))
kal.je_kanal[kanal] = c
alle.append(c)
if not alle:
raise ValueError("zu wenige Pakete fuer die Kalibrierung")
g = np.mean(alle, axis=0)
for pol in np.unique(array.polarisation):
m = array.polarisation == pol
g[m] /= np.mean(np.abs(g[m]))
kal.global_ = g
return kal
# =============================================================================
# Einstellungen und Ergebnisse
# =============================================================================
@dataclass
class Einstellungen:
raster_grad: float = 2.0 # Suchraster
polarisationen: tuple = (0, 1) # (0,) = nur V, (0, 1) = V und H
schwache_antenne: float = 0.3 # Amplitude < 30 % des Medians -> ignorieren
q_min: float = 0.15 # Pakete mit geringerer Qualitaet verwerfen
fenster_s: float = 0.2 # Pakete je Ausgabewert
hampel_n: int = 9
hampel_k: float = 3.5
hampel_boden_grad: float = 2.0
sigma_min_grad: float = 1.5 # Messrauschen bei bester Qualitaet
k_rauschen: float = 4e-4 # R = sigma_min^2 + k_rauschen / Qualitaet
beschleunigung: float = 0.05 # Prozessrauschen Kalman
gating: float = 9.21 # Chi^2, 2 Freiheitsgrade, 99 %
n_neustart: int = 4 # Verwerfungen in Folge -> neue Position uebernehmen
n_quellen: int = 1 # MUSIC: Anzahl Signale (direkter Pfad)
teilarray: int = 3 # MUSIC: Teilarrays 3 x 3 (4 Stueck im 4 x 4 Raster)
suchradius_grad: float = 12.0 # MUSIC sucht in diesem Umkreis der Beamformer-Richtung
max_abweichung_grad: float = 10.0 # MUSIC weiter entfernt -> Beamformer-Richtung verwenden
@dataclass
class PaketMessung:
t: float
kanal: int
u: np.ndarray
qualitaet: float
kohaerenz: float
nebenmaximum: float
keulenbreite: float
snr: float
gruende: list = field(default_factory=list)
@dataclass
class Schaetzung:
t: float
az_grad: float
el_grad: float
unsicherheit_az: float
unsicherheit_el: float
konfidenz: float
roh_az_grad: float
roh_el_grad: float
pakete: int
pakete_gueltig: int
gruende: list = field(default_factory=list)
# =============================================================================
# MUSIC: Kovarianz mit Spatial Smoothing und Spektrum
# =============================================================================
def teilarrays(array: Array, pol: int, groesse: int):
"""Antennenindizes aller Teilarrays groesse x groesse einer Polarisation."""
schritte = 4 - groesse + 1
return [[array.index(pol, z0 + a, s0 + b) for a in range(groesse) for b in range(groesse)]
for z0 in range(schritte) for s0 in range(schritte)]
def geglaettete_kovarianz(x, gruppen):
"""Mittel der Teilarray-Kovarianzen, danach Forward-Backward-Mittelung.
Trennt den direkten Pfad von Reflexionen (kohaerente Signale)."""
n = len(gruppen[0])
R = np.zeros((n, n), complex)
for g in gruppen:
v = x[g]
R += np.outer(v, v.conj())
R /= len(gruppen)
J = np.eye(n)[::-1]
return (R + J @ R.conj() @ J) / 2
def music_spektrum(R, A, n_quellen):
"""P = 1 / |A^H E_n|^2 (E_n = Rauschunterraum), normiert auf Maximum 1."""
_, V = np.linalg.eigh(R)
En = V[:, : R.shape[0] - n_quellen]
p = 1.0 / np.maximum(np.sum(np.abs(np.conj(A) @ En) ** 2, axis=1), 1e-12)
return p / p.max()
# =============================================================================
# Kalman-Filter auf den Richtungskosinus (ux, uy)
# =============================================================================
class KalmanRichtung:
"""Zustand [ux, uy, vx, vy], Modell konstanter Geschwindigkeit."""
def __init__(self, beschleunigung):
self.q = beschleunigung
self.x = self.P = self.t = None
def starten(self, z, R, t):
self.x = np.r_[z, 0.0, 0.0]
self.P = np.diag([R[0, 0], R[1, 1], 0.05, 0.05])
self.t = t
def vorhersagen(self, t):
dt = max(t - self.t, 1e-3)
F = np.eye(4)
F[0, 2] = F[1, 3] = dt
Qb = self.q * np.array([[dt**4 / 4, dt**3 / 2], [dt**3 / 2, dt**2]])
Q = np.zeros((4, 4))
Q[np.ix_([0, 2], [0, 2])] = Qb
Q[np.ix_([1, 3], [1, 3])] = Qb
self.x = F @ self.x
self.P = F @ self.P @ F.T + Q
self.t = t
self._begrenzen()
def abstand(self, z, R):
nu = z - self.x[:2]
return float(nu @ np.linalg.solve(self.P[:2, :2] + R, nu))
def korrigieren(self, z, R):
H = np.zeros((2, 4))
H[0, 0] = H[1, 1] = 1
K = self.P @ H.T @ np.linalg.inv(H @ self.P @ H.T + R)
self.x = self.x + K @ (z - self.x[:2])
self.P = (np.eye(4) - K @ H) @ self.P
self._begrenzen()
def _begrenzen(self):
r = np.hypot(*self.x[:2])
if r > 0.9999:
self.x[:2] *= 0.9999 / r
def ausgabe(self):
"""Azimut, Elevation und Unsicherheiten in Grad."""
ux, uy = self.x[:2]
r = max(np.hypot(ux, uy), 1e-3)
uz = np.sqrt(max(1 - r * r, 0.0))
az, el = np.arctan2(uy, ux), np.arctan2(uz, r)
P = self.P[:2, :2]
j_az = np.array([-uy, ux]) / r**2
j_r = np.array([ux, uy]) / r
s_r = np.sqrt(max(j_r @ P @ j_r, 0))
s_az = np.sqrt(max(j_az @ P @ j_az, 0)) / GRAD
s_el = 0.5 * abs(np.arccos(np.clip(r - s_r, 0, 1)) - np.arccos(np.clip(r + s_r, 0, 1))) / GRAD
return az / GRAD, el / GRAD, min(s_az, 180.0), min(s_el, 90.0)
# =============================================================================
# MUSIC-Schaetzer
# =============================================================================
class MusicSchaetzer:
"""Ablauf je Paket: Vorverarbeitung -> Kalibrierung -> Qualitaet (Beamformer) -> MUSIC.
Ablauf je Fenster: Median je Kanal -> Hampel -> Kalman."""
NAME = "MUSIC"
def __init__(self, array: Array, kal: Kalibrierung | None = None, einst: Einstellungen | None = None):
self.array = array
self.kal = kal or Kalibrierung(array.n)
self.einst = einst or Einstellungen()
schritt = self.einst.raster_grad * GRAD
AZ, EL = np.meshgrid(np.arange(-np.pi, np.pi, schritt), np.arange(0, np.pi / 2 + 1e-9, schritt))
self.form = AZ.shape
self.U = richtung(AZ.ravel(), EL.ravel())
self.flaeche = np.cos(EL.ravel()) * schritt * schritt
self._A_cache: dict[float, np.ndarray] = {}
self.kalman = KalmanRichtung(self.einst.beschleunigung)
self.verlauf = deque(maxlen=self.einst.hampel_n)
self.puffer: list[PaketMessung] = []
self.verworfen = 0
self.gruppen = {pol: teilarrays(array, pol, self.einst.teilarray) for pol in self.einst.polarisationen}
# ---- Steuervektoren auf dem Suchraster (Cache je Wellenlaenge) ----
def A_raster(self, lam):
key = round(lam * 1e6, 3)
if key not in self._A_cache:
self._A_cache[key] = self.array.steuervektoren(self.U, lam).astype(np.complex64)
return self._A_cache[key]
# ---- Bartlett-Spektrum (Beamformer), Werte 0..1 ----
def bartlett(self, U, x, maske, lam, A=None):
"""V und H getrennt berechnen, nach Energie gewichtet zusammenfassen."""
A = self.array.steuervektoren(U, lam) if A is None else A
P, E_ges = np.zeros(len(U)), 0.0
for pol in self.einst.polarisationen:
m = maske & (self.array.polarisation == pol)
if m.sum() < 3:
continue
xs = x[m]
E = float(np.sum(np.abs(xs) ** 2))
P += E * np.abs(np.conj(A[:, m]) @ xs) ** 2 / (m.sum() * E)
E_ges += E
return P / E_ges if E_ges > 0 else P
def fein_suchen(self, spektrum, u0, breite_grad=3.0, schritt_grad=0.25):
"""Verfeinerung um u0: gibt beste Richtung und Spektrumswert zurueck."""
az0, el0 = az_el(u0)
d = np.arange(-breite_grad, breite_grad + 1e-3, schritt_grad) * GRAD
AZ, EL = np.meshgrid(az0 + d, np.clip(el0 + d, 0, np.pi / 2))
Uf = richtung(AZ.ravel(), EL.ravel())
P = spektrum(Uf)
k = int(np.argmax(P))
return Uf[k], float(P[k])
def _nebenmaximum(self, P, u, ausschluss_grad=15.0):
"""Hoechstes lokales Maximum ausserhalb der Hauptkeule."""
G = P.reshape(self.form)
nachbarn = [np.roll(G, 1, 1), np.roll(G, -1, 1),
np.vstack([G[:1] * 0, G[:-1]]), np.vstack([G[1:], G[-1:] * 0])]
lokal = np.all([G >= v for v in nachbarn], axis=0).ravel()
lokal &= self.U @ u < np.cos(ausschluss_grad * GRAD)
return float(P[lokal].max()) if lokal.any() else 0.0
def _keulenflaeche(self, P_nah, nah):
if not nah.any() or P_nah.max() <= 0:
return np.nan
return float(np.sum(self.flaeche[nah][P_nah >= 0.5 * P_nah.max()]))
# ---- MUSIC-Spektrum je Polarisation, nach Energie gewichtet ----
def music(self, U, x, gueltig, lam, A=None):
A = self.array.steuervektoren(U, lam) if A is None else A
P, E_ges = np.zeros(len(U)), 0.0
for gruppen in self.gruppen.values():
alle = sorted({i for g in gruppen for i in g})
if not np.all(gueltig[alle]): # MUSIC braucht alle Antennen der Polarisation
continue
R = geglaettete_kovarianz(x, gruppen)
E = float(np.sum(np.abs(x[alle]) ** 2))
P += E * music_spektrum(R, A[:, gruppen[0]], self.einst.n_quellen)
E_ges += E
return P / max(E_ges, 1e-12)
# ---- Richtung: MUSIC um die Beamformer-Richtung, mit Gegenprobe ----
def richtung_bestimmen(self, x_kal, gueltig, lam, u_bf):
e = self.einst
x = np.where(gueltig, x_kal, 0)
nah = self.U @ u_bf > np.cos(e.suchradius_grad * GRAD)
P = self.music(self.U[nah], x, gueltig, lam, self.A_raster(lam)[nah])
if not np.any(P > 0):
return u_bf, ["MUSIC nicht moeglich, Beamformer verwendet"]
u, _ = self.fein_suchen(lambda Uf: self.music(Uf, x, gueltig, lam), self.U[nah][int(np.argmax(P))])
if winkelabstand(u, u_bf) > e.max_abweichung_grad * GRAD:
return u_bf, ["MUSIC weicht ab, Beamformer verwendet"]
return u, []
# ---- Auswertung eines Pakets ----
def paket_auswerten(self, p: Paket) -> PaketMessung:
e = self.einst
gruende = []
x, snr, jitter = vorverarbeiten(p, self.array.n)
x = self.kal.anwenden(x, p.kanal)
gueltig = np.isfinite(x)
amp = np.abs(x)
# Schwache Antennen ausblenden (je Polarisation)
maske = np.zeros(self.array.n, bool)
for pol in np.unique(self.array.polarisation):
m = gueltig & (self.array.polarisation == pol)
if m.any():
maske |= m & (amp >= e.schwache_antenne * np.median(amp[m]))
maske &= np.isin(self.array.polarisation, e.polarisationen)
erwartet = np.isin(self.array.polarisation, e.polarisationen).sum()
if maske.sum() < erwartet:
gruende.append("schwache oder fehlende Antenne")
lam = C0 / kanal_frequenz(p.kanal)
xm = np.where(maske, x, 0)
# Beamformer: grobe Suche, Verfeinerung
A = self.A_raster(lam)
P = self.bartlett(self.U, xm, maske, lam, A)
k = int(np.argmax(P))
u_bf, kohaerenz = self.fein_suchen(lambda Uf: self.bartlett(Uf, xm, maske, lam), self.U[k])
# Qualitaetsmerkmale
neben = self._nebenmaximum(P, u_bf) / P[k] if P[k] > 0 else 0.0
nah = self.U @ u_bf > np.cos(30 * GRAD)
a_ideal = self.array.steuervektoren(u_bf[None], lam)[0] * maske
P_ideal = self.bartlett(self.U[nah], a_ideal, maske, lam, A[nah])
breite = self._keulenflaeche(P[nah], nah) / max(self._keulenflaeche(P_ideal, nah), 1e-9)
jit = float(np.median(jitter[maske])) if maske.any() else np.pi
q = (np.clip((kohaerenz - 0.3) / 0.6, 0, 1)
* np.clip((0.85 - neben) / 0.5, 0, 1)
* (np.clip(1.0 / max(breite, 1e-6), 0, 1) if np.isfinite(breite) else 0.5)
* snr / (snr + 10.0)
* maske.sum() / max(erwartet, 1)
* np.exp(-0.5 * (jit / (20 * GRAD)) ** 2))
if kohaerenz < 0.6:
gruende.append("geringe Kohaerenz (mehrere Pfade)")
if neben > 0.6:
gruende.append("mehrere aehnliche Maxima")
if breite > 1.6:
gruende.append("verbreiterte Keule")
if snr < 5:
gruende.append("geringes SNR")
# Richtung mit MUSIC
u, g2 = self.richtung_bestimmen(x, gueltig, lam, u_bf)
return PaketMessung(p.t, p.kanal, u, float(q), kohaerenz, neben, breite, snr, gruende + g2)
# ---- Laufende Verarbeitung ----
def verarbeiten(self, p: Paket) -> Schaetzung | None:
"""Fuer jedes Paket aufrufen. Liefert am Ende jedes Fensters eine Schaetzung."""
m = self.paket_auswerten(p)
if self.puffer and m.t - self.puffer[0].t >= self.einst.fenster_s:
s = self._fenster_abschliessen(self.puffer)
self.puffer = [m]
return s
self.puffer.append(m)
return None
def _fenster_abschliessen(self, messungen: list[PaketMessung]) -> Schaetzung:
e = self.einst
t = messungen[-1].t
gueltig = [m for m in messungen if m.qualitaet >= e.q_min]
gruende = sorted({g for m in messungen for g in m.gruende
if sum(g in mm.gruende for mm in messungen) > len(messungen) / 2})
# Keine gueltige Messung: nur Vorhersage
if not gueltig:
if self.kalman.x is not None:
self.kalman.vorhersagen(t)
az, el, saz, sel = self.kalman.ausgabe()
else:
az = el = saz = sel = np.nan
return Schaetzung(t, az, el, saz, sel, 0.0, np.nan, np.nan,
len(messungen), 0, gruende + ["keine gueltige Messung"])
# Median je Kanal, dann ueber die Kanaele
je_kanal: dict[int, list] = {}
for m in gueltig:
je_kanal.setdefault(m.kanal, []).append(m)
Uk = np.array([sphaerischer_median(np.array([m.u for m in ms]), [m.qualitaet for m in ms])
for ms in je_kanal.values()])
Wk = np.array([np.median([m.qualitaet for m in ms]) for ms in je_kanal.values()])
u_f = sphaerischer_median(Uk, Wk)
streuung = gewichteter_median(winkelabstand(Uk, u_f), Wk) / GRAD if len(Uk) > 1 else 0.0
U_alle = np.array([m.u for m in gueltig])
q_alle = np.array([m.qualitaet for m in gueltig])
anteil_weit = float(np.sum(q_alle[winkelabstand(U_alle, u_f) > 15 * GRAD]) / q_alle.sum())
q_f = float(np.median(q_alle)) * np.exp(-0.5 * (streuung / 10.0) ** 2)
if streuung > 10:
gruende.append("Kanaele uneinig")
if anteil_weit > 0.3:
gruende.append("Pakete gestreut")
# Hampel: Sprung gegenueber dem juengsten Verlauf?
sprung = False
if len(self.verlauf) >= 5:
H = np.array(self.verlauf)
c = sphaerischer_median(H)
skala = 1.4826 * np.median(winkelabstand(H, c)) + e.hampel_boden_grad * GRAD
sprung = winkelabstand(u_f, c) > e.hampel_k * skala
self.verlauf.append(u_f)
# Kalman mit qualitaetsabhaengigem Messrauschen und Gating
z = u_f[:2]
R = np.eye(2) * ((e.sigma_min_grad * GRAD) ** 2 + e.k_rauschen / max(q_f, 1e-3))
angenommen = True
if self.kalman.x is None:
self.kalman.starten(z, R, t)
else:
self.kalman.vorhersagen(t)
if sprung or self.kalman.abstand(z, R) > e.gating:
angenommen = False
self.verworfen += 1
gruende.append("Sprung erkannt (Hampel)" if sprung else "vom Kalman verworfen")
if self.verworfen >= e.n_neustart: # Tag hat sich wirklich bewegt
neu = np.array(list(self.verlauf)[-e.n_neustart:])
self.kalman.starten(sphaerischer_median(neu)[:2], R, t)
self.verlauf.clear()
self.verlauf.extend(neu)
self.verworfen = 0
gruende.append("Neustart (neue Position)")
else:
self.kalman.korrigieren(z, R)
self.verworfen = 0
az, el, saz, sel = self.kalman.ausgabe()
if el < 15:
gruende.append("nahe der Board-Ebene: Elevation unsicher")
konfidenz = q_f * (1.0 if angenommen else 0.3)
raz, rel = az_el(u_f)
return Schaetzung(t, az, el, saz, sel, float(np.clip(konfidenz, 0, 1)),
raz / GRAD, rel / GRAD, len(messungen), len(gueltig), gruende)
# =============================================================================
# Ergebnis und Vergleich mit dem AoA Analyzer
# =============================================================================
def ergebnis(schaetzungen: list[Schaetzung], ref_az=None, ref_el=None) -> dict:
"""Median aller Schaetzungen, optional Abweichung zum Analyzer."""
S = [s for s in schaetzungen if np.isfinite(s.az_grad)]
if not S:
return {}
u = sphaerischer_median(np.array([richtung(s.az_grad * GRAD, s.el_grad * GRAD) for s in S]))
az, el = az_el(u)
res = {"az": az / GRAD, "el": el / GRAD, "konfidenz": float(np.median([s.konfidenz for s in S])),
"anzahl": len(S)}
if ref_az is not None and ref_el is not None:
res["abweichung"] = float(winkelabstand(u, richtung(ref_az * GRAD, ref_el * GRAD)) / GRAD)
res["abw_az"] = ((az / GRAD - ref_az + 180) % 360) - 180
res["abw_el"] = el / GRAD - ref_el
return res
# =============================================================================
# Kommandozeile
# =============================================================================
def main():
ap = argparse.ArgumentParser(description="AoA-Auswertung mit 2D-MUSIC und allen Filtern")
ap.add_argument("iq", help="IQ-Datei (.jsonl)")
ap.add_argument("--kalibrierung", help="Kalibrieraufnahme am selben Standort (ohne: keine Kalibrierung)")
ap.add_argument("--kal-az", type=float, default=0.0, help="Analyzer-Azimut waehrend der Kalibrierung")
ap.add_argument("--kal-el", type=float, default=90.0, help="Analyzer-Elevation waehrend der Kalibrierung")
ap.add_argument("--polar", default="VH", choices=["V", "VH"])
ap.add_argument("--abstand-mm", type=float, default=ABSTAND_M * 1000)
ap.add_argument("--ref-az", type=float, help="Analyzer-Azimut an dieser Position (Vergleich)")
ap.add_argument("--ref-el", type=float, help="Analyzer-Elevation an dieser Position (Vergleich)")
ap.add_argument("--jedes", type=int, default=1, help="nur jedes n-te Paket auswerten")
ap.add_argument("--ausgabe", help="Schaetzungen als JSON speichern")
ap.add_argument("--verlauf", action="store_true", help="jede Schaetzung ausgeben")
a = ap.parse_args()
# Board, Kalibrierung, Schaetzer
array = brd4191a(a.polar, a.abstand_mm / 1000)
if a.kalibrierung:
kal = Kalibrierung.aus_aufnahme(array, lese_iq_datei(a.kalibrierung, array.n), a.kal_az, a.kal_el)
else:
kal = Kalibrierung(array.n) # ohne Kalibrierung: alle Faktoren = 1
print("Hinweis: OHNE Kalibrierung")
sch = MusicSchaetzer(array, kal, Einstellungen(polarisationen=(0,) if a.polar == "V" else (0, 1)))
# Pakete auswerten
schaetzungen = []
for p in lese_iq_datei(a.iq, array.n, a.jedes):
s = sch.verarbeiten(p)
if s is None:
continue
schaetzungen.append(s)
if a.verlauf and np.isfinite(s.az_grad):
print(f"{s.az_grad:+7.1f} / {s.el_grad:5.1f} Konfidenz {s.konfidenz:.2f} {', '.join(s.gruende)}")
if a.ausgabe:
with open(a.ausgabe, "w", encoding="utf-8") as f:
json.dump([s.__dict__ for s in schaetzungen], f, indent=1, ensure_ascii=False, default=float)
# Ergebnis ausgeben
r = ergebnis(schaetzungen, a.ref_az, a.ref_el)
if not r:
print("Keine gueltige Schaetzung.")
return
zeile = f"[MUSIC] Ergebnis: Azimut {r['az']:+.1f}, Elevation {r['el']:.1f}, Konfidenz {r['konfidenz']:.2f}"
if "abweichung" in r:
zeile += (f" | Abweichung zum Analyzer {r['abweichung']:.1f} Grad "
f"(Az {r['abw_az']:+.1f}, El {r['abw_el']:+.1f})")
print(zeile)
if __name__ == "__main__":
main()
-1063
View File
File diff suppressed because it is too large. Load diff
+34 -1
View File
@@ -6,6 +6,7 @@ import queue
from datetime import datetime, timezone
import bgapi
import music_estimator
# --- Einstellungen, bei Bedarf anpassen ---
@@ -19,6 +20,8 @@ SLOT_DURATIONS = 2 # 1 oder 2 (Mikrosekunden pro Slot)
SWITCHING_PATTERN = bytes(range(0, 16)) # welche Antennen der Reihe nach genutzt werden
CTE_COUNT = 0 # 0 = unbegrenzt weiter empfangen
CALIBRATION_PACKET_COUNT = 20 # wie viele Pakete für die Auto-Kalibrierung gesammelt werden
# --- Ab hier läuft das Programm, nichts mehr anpassen ---
lib = None
@@ -27,6 +30,9 @@ sync_handle = None
already_synced = False
pending_actions = queue.Queue()
calibrating = "--calibrate" in sys.argv
calibration_samples = []
def on_event(event):
"""Diese Funktion wird jedes Mal aufgerufen, wenn der BG22 uns etwas meldet."""
@@ -89,16 +95,36 @@ def enable_cte(sync):
def save_iq_report(event):
"""Ein IQ-Paket ist angekommen: dekodieren, anzeigen und speichern."""
"""Ein IQ-Paket ist angekommen: dekodieren, Winkel schätzen, anzeigen und speichern."""
global calibrating
raw_bytes = event.samples
values = list(struct.unpack(f"{len(raw_bytes)}b", raw_bytes))
i_values = values[0::2]
q_values = values[1::2]
if calibrating:
calibration_samples.append((i_values, q_values))
print(f"Kalibrierung: Paket {len(calibration_samples)}/{CALIBRATION_PACKET_COUNT} "
f"(Tag muss bei 0°/0° stehen)")
if len(calibration_samples) >= CALIBRATION_PACKET_COUNT:
print("\nGenug Pakete gesammelt, berechne Kalibrierung ...")
music_estimator.compute_calibration_from_samples(calibration_samples, 0, 0)
music_estimator.reset_calibration_cache()
calibrating = False
print("Kalibrierung aktiv. Normaler Betrieb läuft weiter.\n")
return
azimuth, elevation = music_estimator.estimate_angle(i_values, q_values)
print(f"IQ-Paket #{event.event_counter} Kanal={event.channel} "
f"RSSI={event.rssi} dBm {len(i_values)} Samples")
print(f" Rohbytes: {raw_bytes[:8]} ...")
print(f" I/Q-Werte: {list(zip(i_values[:4], q_values[:4]))} ...")
if azimuth is not None:
print(f" Winkel: Azimut={azimuth:.0f}° Elevation={elevation:.0f}°")
else:
print(" Winkel: zu wenige Samples für eine Schätzung")
entry = {
"zeit": datetime.now(timezone.utc).isoformat(),
@@ -109,6 +135,8 @@ def save_iq_report(event):
"antennen_pattern": list(SWITCHING_PATTERN),
"i": i_values,
"q": q_values,
"azimut": azimuth,
"elevation": elevation,
}
output_file.write(json.dumps(entry) + "\n")
output_file.flush()
@@ -120,6 +148,11 @@ def main():
output_file = open(OUTPUT_FILE, "a", encoding="utf-8")
print(f"IQ-Samples werden gespeichert in: {OUTPUT_FILE}")
if calibrating:
print(f"\n*** KALIBRIERUNGSMODUS ***")
print(f"Tag jetzt bei 0° Azimut / 0° Elevation vor dem Array platzieren!")
print(f"Sammle automatisch {CALIBRATION_PACKET_COUNT} Pakete ...\n")
connector = bgapi.SerialConnector(COM_PORT, baudrate=BAUD_RATE)
lib = bgapi.BGLib(connector, XAPI_PATH, on_event)
lib.open()
-374
View File
@@ -1,374 +0,0 @@
import time
import sys
import json
import struct
import queue
from datetime import datetime, timezone
import numpy as np
import bgapi
# =============================================================================
# EINSTELLUNGEN: BG22 / CTE
# =============================================================================
COM_PORT = "COM13"
BAUD_RATE = 115200
XAPI_PATH = r"C:\Users\sandr\.silabs\slt\installs\conan\p\simpleca33d691c539\p\bluetooth_le_host\api\sl_bt.xapi"
OUTPUT_FILE = "iq_samples.jsonl"
ANGLE_TABLE_FILE = "winkel_tabelle.json" # nur Azimut/Elevation, ein Eintrag pro neuem Wert
SLOT_DURATIONS = 2 # 1 oder 2 Mikrosekunden pro Slot
SWITCHING_PATTERN = bytes(range(0, 16)) # Reihenfolge der Antennen
CTE_COUNT = 0 # 0 = unbegrenzt weiter empfangen
# =============================================================================
# EINSTELLUNGEN: EIGENER MUSIC-ALGORITHMUS
# =============================================================================
CALIBRATION_FILE = "calibration.json"
FREQUENCY_HZ = 2.44e9
SPEED_OF_LIGHT = 3e8
WAVELENGTH = SPEED_OF_LIGHT / FREQUENCY_HZ
ANTENNA_SPACING = 0.0400 # 40 mm, laut AN1195 (nicht lambda/2)
ARRAY_ROWS = 4
ARRAY_COLS = 4
NUM_ANTENNAS = ARRAY_ROWS * ARRAY_COLS
AZIMUTH_SIGN = 1
ELEVATION_SIGN = 1
REFERENCE_SAMPLES = 8 # feste Referenzperiode am Anfang jedes CTE
NUM_SOURCES = 1 # nur ein Tag sendet gleichzeitig
AZIMUTH_RANGE = np.arange(-180, 181, 1)
ELEVATION_RANGE = np.arange(-90, 91, 1)
ACCUMULATE_PACKETS = 20 # Pakete pro Winkel-Update
# =============================================================================
# GLOBALER ZUSTAND
# =============================================================================
lib = None
output_file = None
sync_handle = None
already_synced = False
pending_actions = queue.Queue()
_cached_positions = None
_cached_offsets = None
_calibration_loaded = False
_snapshot_buffer = []
_latest_stable_angle = (None, None)
_angle_table = []
# =============================================================================
# BLUETOOTH-EVENTS
# =============================================================================
def on_event(event):
"""Wird bei jedem eingehenden Ereignis vom BG22 aufgerufen."""
global sync_handle
name = str(event).split("(")[0]
if name in ("bt_evt_scanner_scan_report",
"bt_evt_scanner_extended_advertisement_report"):
look_for_periodic_advertising(event)
elif name in ("bt_evt_sync_opened",
"bt_evt_sync_scanner_opened",
"bt_evt_periodic_sync_opened"):
sync_handle = event.sync
print(f"Verbindung zum Tag hergestellt (sync={sync_handle}).")
pending_actions.put(("enable_cte", sync_handle))
elif name in ("bt_evt_sync_closed",
"bt_evt_sync_scanner_closed",
"bt_evt_periodic_sync_closed"):
print("Verbindung zum Tag wurde beendet.")
elif name in ("bt_evt_cte_receiver_connectionless_iq_report",
"bt_evt_periodic_sync_iq_report",
"bt_evt_cte_receiver_iq_report"):
save_iq_report(event)
elif hasattr(event, "result") and event.result != 0:
print(f"Fehler vom BG22: {event}")
def look_for_periodic_advertising(event):
"""Prüft ein gefundenes Gerät: sendet es CTE über periodisches Advertising?"""
global already_synced
if already_synced:
return
periodic_interval = getattr(event, "periodic_interval", None)
adv_sid = getattr(event, "adv_sid", None)
if not periodic_interval:
return
print(f"Tag gefunden! RSSI={event.rssi} dBm, Interval={periodic_interval} ms")
already_synced = True
pending_actions.put(("connect_to_tag", event.address, event.address_type, adv_sid))
# =============================================================================
# BLUETOOTH-BEFEHLE
# =============================================================================
def start_scan():
"""Startet die Suche nach Bluetooth-Geräten in der Umgebung."""
print("Suche nach einem Tag ...")
lib.bt.scanner.set_parameters(1, 160, 160)
lib.bt.scanner.start(1, 2)
def connect_to_tag(address, address_type, adv_sid):
"""Beendet die Suche und verbindet sich mit dem gefundenen Tag."""
lib.bt.scanner.stop()
lib.bt.sync_scanner.open(address, address_type, adv_sid)
def enable_cte(sync):
"""Aktiviert den CTE-Empfang auf der bestehenden Synchronisation."""
lib.bt.cte_receiver.enable_connectionless_cte(
sync, SLOT_DURATIONS, CTE_COUNT, SWITCHING_PATTERN
)
print("CTE-Empfang läuft. Warte auf IQ-Daten ...\n")
# =============================================================================
# EIGENER MUSIC-ALGORITHMUS (2D, Azimut + Elevation, 4x4 URA)
# =============================================================================
def antenna_positions():
"""(x, y)-Position jeder Antenne im 4x4-Raster."""
positions = []
for row in range(ARRAY_ROWS):
for col in range(ARRAY_COLS):
positions.append((row * ANTENNA_SPACING, col * ANTENNA_SPACING))
return np.array(positions)
def steering_vector(azimuth_deg, elevation_deg, positions):
"""Theoretischer Steering-Vector für einen Winkel."""
az = np.radians(azimuth_deg) * AZIMUTH_SIGN
el = np.radians(elevation_deg) * ELEVATION_SIGN
u = np.cos(el) * np.sin(az)
v = np.sin(el)
phase = (2 * np.pi / WAVELENGTH) * (positions[:, 0] * u + positions[:, 1] * v)
return np.exp(-1j * phase)
def extract_snapshots(i_values, q_values):
"""Wandelt IQ-Rohwerte in Antennen-Snapshots um."""
complex_samples = np.array(i_values) + 1j * np.array(q_values)
switching_samples = complex_samples[REFERENCE_SAMPLES:]
num_full_cycles = len(switching_samples) // NUM_ANTENNAS
if num_full_cycles == 0:
return None
usable = switching_samples[: num_full_cycles * NUM_ANTENNAS]
return usable.reshape(num_full_cycles, NUM_ANTENNAS).T
def circular_mean(angles):
"""Mittelwert von Winkeln, robust gegen den Sprung bei ±π."""
return np.angle(np.mean(np.exp(1j * angles)))
def music_spectrum(snapshots, positions):
"""MUSIC-Pseudospektrum, gibt den stärksten Peak zurück."""
covariance = snapshots @ snapshots.conj().T / snapshots.shape[1]
eigenvalues, eigenvectors = np.linalg.eigh(covariance)
noise_subspace = eigenvectors[:, : NUM_ANTENNAS - NUM_SOURCES]
best_azimuth, best_elevation, best_power = 0.0, 0.0, -np.inf
for az in AZIMUTH_RANGE:
for el in ELEVATION_RANGE:
a = steering_vector(az, el, positions)
denom = a.conj().T @ noise_subspace @ noise_subspace.conj().T @ a
power = 1.0 / np.abs(denom)
if power > best_power:
best_power = power
best_azimuth, best_elevation = az, el
return int(best_azimuth), int(best_elevation)
def load_calibration():
"""Lädt gespeicherte Phasenoffsets, falls vorhanden."""
try:
with open(CALIBRATION_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return np.array(data["phasenoffsets_rad"])
except FileNotFoundError:
return None
def apply_calibration(snapshots, offsets):
"""Entfernt bekannte Phasenoffsets aus den Rohdaten."""
correction = np.exp(-1j * offsets).reshape(-1, 1)
return snapshots * correction
def add_packet_and_maybe_estimate(i_values, q_values):
"""Sammelt Snapshots; berechnet alle ACCUMULATE_PACKETS Pakete einen Winkel."""
global _cached_positions, _cached_offsets, _calibration_loaded
global _snapshot_buffer, _latest_stable_angle
if _cached_positions is None:
_cached_positions = antenna_positions()
if not _calibration_loaded:
_cached_offsets = load_calibration()
_calibration_loaded = True
snapshots = extract_snapshots(i_values, q_values)
if snapshots is not None:
_snapshot_buffer.append(snapshots)
if len(_snapshot_buffer) < ACCUMULATE_PACKETS:
return _latest_stable_angle
combined = np.concatenate(_snapshot_buffer, axis=1)
_snapshot_buffer = []
if _cached_offsets is not None:
combined = apply_calibration(combined, _cached_offsets)
_latest_stable_angle = music_spectrum(combined, _cached_positions)
return _latest_stable_angle
# =============================================================================
# IQ-DATEN VERARBEITEN
# =============================================================================
def decode_samples(raw_bytes):
"""Wandelt die rohen Bytes in I- und Q-Wertelisten um."""
values = list(struct.unpack(f"{len(raw_bytes)}b", raw_bytes))
return values[0::2], values[1::2]
def add_to_angle_table(zeit, counter, azimuth, elevation):
"""Fügt einen neuen Winkel-Eintrag hinzu und schreibt die Tabelle neu."""
_angle_table.append({
"zeit": zeit, "counter": counter,
"azimut": azimuth, "elevation": elevation,
})
with open(ANGLE_TABLE_FILE, "w", encoding="utf-8") as f:
json.dump(_angle_table, f, indent=2, ensure_ascii=False)
def save_iq_report(event):
"""Dekodiert, berechnet den Winkel, zeigt an, speichert."""
i_values, q_values = decode_samples(event.samples)
azimuth, elevation = add_packet_and_maybe_estimate(i_values, q_values)
print(f"IQ-Paket #{event.event_counter} Kanal={event.channel} "
f"RSSI={event.rssi} dBm {len(i_values)} Samples "
f"({len(_snapshot_buffer)}/{ACCUMULATE_PACKETS} gesammelt)")
print(f" Rohbytes: {event.samples[:8]} ...")
print(f" I/Q-Werte: {list(zip(i_values[:4], q_values[:4]))} ...")
if azimuth is not None:
print(f" Azimut={azimuth}° Elevation={elevation}°")
if not _angle_table or (azimuth, elevation) != (
_angle_table[-1]["azimut"], _angle_table[-1]["elevation"]
):
add_to_angle_table(
datetime.now(timezone.utc).isoformat(),
event.event_counter, azimuth, elevation,
)
else:
print(" sammle noch Pakete ...")
entry = {
"zeit": datetime.now(timezone.utc).isoformat(),
"counter": event.event_counter,
"kanal": event.channel,
"rssi": event.rssi,
"slot_dauer_us": SLOT_DURATIONS,
"antennen_pattern": list(SWITCHING_PATTERN),
"i": i_values,
"q": q_values,
"azimut": azimuth,
"elevation": elevation,
}
output_file.write(json.dumps(entry) + "\n")
output_file.flush()
# =============================================================================
# HAUPTPROGRAMM
# =============================================================================
def connect_to_bg22():
"""Öffnet die serielle Verbindung, stellt sauberen Zustand her."""
connector = bgapi.SerialConnector(COM_PORT, baudrate=BAUD_RATE)
module = bgapi.BGLib(connector, XAPI_PATH, on_event)
module.open()
print(f"Verbunden mit {COM_PORT}.")
module.bt.system.reboot()
time.sleep(2.0)
module.bt.system.hello()
print("BG22 antwortet, alles bereit.")
return module
def process_pending_actions():
"""Führt Aktionen aus, die nicht direkt im Event-Handler laufen dürfen."""
while True:
try:
action, *details = pending_actions.get(timeout=0.1)
except queue.Empty:
continue
if action == "connect_to_tag":
connect_to_tag(*details)
elif action == "enable_cte":
enable_cte(*details)
def main():
global lib, output_file
output_file = open(OUTPUT_FILE, "a", encoding="utf-8")
print(f"IQ-Samples werden gespeichert in: {OUTPUT_FILE}")
lib = connect_to_bg22()
start_scan()
process_pending_actions()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nBeendet.")
except Exception as error:
print(f"Es ist ein Fehler aufgetreten: {error}")
sys.exit(1)
finally:
if lib is not None:
lib.close()
if output_file is not None:
output_file.close()
-181
View File
@@ -1,181 +0,0 @@
"""Live-Messung mit dem BG22 und dem hybriden Algorithmus (aoa_music_komplett.py).
Verbindung, Tag-Suche und CTE-Empfang kommen aus bg22_AoA_v2.py (unveraendert).
Beide Dateien muessen im selben Ordner liegen.
1. Kalibrierung aufnehmen (Tag vor dem Board, ca. 60 s, Analyzer-Werte notieren):
python bg22_live_hybrid.py --nur-aufnehmen --iq-out kalibrierung_neu.jsonl
2. Messen (ohne --kalibrierung: Test ohne Kalibrierung):
python bg22_live_hybrid.py --kalibrierung kalibrierung_neu.jsonl \
--kal-az <Az> --kal-el <El> --ref-az <Az Analyzer> --ref-el <El Analyzer> --iq-out test_neu_a.jsonl
"""
import argparse
import importlib.util
import json
import os
import sys
import time as _time
from datetime import datetime, timezone
import numpy as np
hier = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, hier)
import aoa_music_komplett as ab # noqa: E402
# =============================================================================
# Kommandozeile
# =============================================================================
ap = argparse.ArgumentParser(add_help=False)
ap.add_argument("--kalibrierung")
ap.add_argument("--kal-az", type=float, default=0.0)
ap.add_argument("--kal-el", type=float, default=90.0)
ap.add_argument("--polar", default="VH", choices=["V", "VH"])
ap.add_argument("--abstand-mm", type=float, default=ab.ABSTAND_M * 1000)
ap.add_argument("--ref-az", type=float)
ap.add_argument("--ref-el", type=float)
ap.add_argument("--nur-aufnehmen", action="store_true")
ap.add_argument("--winkel-out", default="winkel_tabelle.json")
ap.add_argument("--iq-out", default="iq_samples.jsonl")
a, rest = ap.parse_known_args()
sys.argv = [sys.argv[0], "--iq-out", a.iq_out, "--angle-out", "winkel_tabelle_alt.json"] + rest
# =============================================================================
# Verbindung aus bg22_AoA_v2.py uebernehmen
# =============================================================================
spec = importlib.util.spec_from_file_location("bg22_AoA_v2", os.path.join(hier, "bg22_AoA_v2.py"))
bg22 = importlib.util.module_from_spec(spec)
sys.modules["bg22_AoA_v2"] = bg22
spec.loader.exec_module(bg22)
N_ANT = 16 if a.polar == "V" else 32
if a.polar == "VH":
bg22.RF_ORDER = tuple(ab.MUSTER_V) + tuple(ab.MUSTER_H)
with open(a.iq_out + ".meta.json", "w", encoding="utf-8") as f:
json.dump({"n_antennes": N_ANT, "polarisations": a.polar, "slot_us": bg22.SLOT_DURATION_US,
"motif": [bg22.CP_RF_VALUE] + list(bg22.RF_ORDER)}, f, indent=1)
bg22.SELBSTKAL_AKTIV = False
bg22.KALIBRIER_DATEI = "__nicht_verwendet__.json" # alte Kalibrierung abgeschaltet
bg22.process_packet = lambda paket, cfg: None # alte MUSIC-Berechnung abgeschaltet
open(a.iq_out, "w", encoding="utf-8").close() # IQ-Datei bei jedem Start leeren
# =============================================================================
# Schaetzer vorbereiten
# =============================================================================
schaetzer = None
if not a.nur_aufnehmen:
array = ab.brd4191a(a.polar, a.abstand_mm / 1000)
if a.kalibrierung:
if not os.path.exists(a.kalibrierung):
sys.exit(f"Kalibrierdatei nicht gefunden: {a.kalibrierung}")
kal = ab.Kalibrierung.aus_aufnahme(array, ab.lese_iq_datei(a.kalibrierung, N_ANT), a.kal_az, a.kal_el)
else:
kal = ab.Kalibrierung(array.n) # ohne Kalibrierung: alle Faktoren = 1
print("bg22_live: OHNE Kalibrierung (nur zum Test)", file=sys.stderr)
einst = ab.Einstellungen(polarisationen=(0,) if a.polar == "V" else (0, 1))
schaetzer = ab.MusicSchaetzer(array, kal, einst)
print(f"bg22_live: Hybrid (Beamformer + MUSIC), {N_ANT} Antennen, {a.abstand_mm:.1f} mm, "
f"Kalibrierung {a.kalibrierung or 'keine'} ({len(kal.je_kanal)} Kanaele)", file=sys.stderr)
else:
print(f"bg22_live: nur Aufnahme ({N_ANT} Antennen) nach {a.iq_out}", file=sys.stderr)
tabelle = []
# =============================================================================
# Verarbeitung je Paket
# =============================================================================
def paket_verarbeiten(i_werte, q_werte, kanal, t):
if schaetzer is None:
return
p = ab.paket_aus_iq(i_werte, q_werte, kanal, t, N_ANT, float(bg22.SLOT_DURATION_US))
s = schaetzer.verarbeiten(p) if p is not None else None
if s is None or not np.isfinite(s.az_grad):
return
vergleich = ""
if a.ref_az is not None and a.ref_el is not None:
d = ab.winkelabstand(ab.richtung(s.az_grad * ab.GRAD, s.el_grad * ab.GRAD),
ab.richtung(a.ref_az * ab.GRAD, a.ref_el * ab.GRAD)) / ab.GRAD
vergleich = f" | Abweichung {d:4.1f}"
print(f">>> Az {s.az_grad:+7.1f} +-{s.unsicherheit_az:4.1f} El {s.el_grad:5.1f} +-{s.unsicherheit_el:4.1f} "
f"Konfidenz {s.konfidenz:.2f}{vergleich} {', '.join(s.gruende)}", file=sys.stderr, flush=True)
tabelle.append({"zeit": datetime.fromtimestamp(t, timezone.utc).isoformat(),
"azimut": round(s.az_grad, 2), "elevation": round(s.el_grad, 2),
"unsicherheit_az": round(s.unsicherheit_az, 2), "unsicherheit_el": round(s.unsicherheit_el, 2),
"konfidenz": round(s.konfidenz, 3), "gruende": s.gruende})
if len(tabelle) % 5 == 0:
speichern()
def speichern():
with open(a.winkel_out, "w", encoding="utf-8") as f:
json.dump(tabelle, f, indent=1, ensure_ascii=False)
_original_speichern = bg22.save_iq_report
def save_iq_report(event):
_original_speichern(event) # IQ-Datei wie bisher schreiben
try:
i_werte, q_werte = bg22.signed_iq(event.samples)
paket_verarbeiten(i_werte, q_werte, int(event.channel), _time.time())
except Exception as fehler: # Rechenfehler darf die Messung nicht abbrechen
print(f"bg22_live: Paket uebersprungen ({fehler})", file=sys.stderr)
bg22.save_iq_report = save_iq_report
# =============================================================================
# Zusammenfassung beim Beenden (Strg+C)
# =============================================================================
def zusammenfassung():
T = tabelle[len(tabelle) // 10:] # Einschwingphase weglassen
if not T:
return
S = [ab.Schaetzung(0, t["azimut"], t["elevation"], 0, 0, t["konfidenz"], 0, 0, 0, 0) for t in T]
r = ab.ergebnis(S, a.ref_az, a.ref_el)
txt = f"\n===== ERGEBNIS: Azimut {r['az']:+.1f}, Elevation {r['el']:.1f}, Konfidenz {r['konfidenz']:.2f}"
if "abweichung" in r:
txt += (f"\n===== ANALYZER: Azimut {a.ref_az:+.1f}, Elevation {a.ref_el:.1f} -> "
f"ABWEICHUNG {r['abweichung']:.1f} Grad (Az {r['abw_az']:+.1f}, El {r['abw_el']:+.1f})")
print(txt + "\n", file=sys.stderr, flush=True)
# =============================================================================
# Start (5 s Bootzeit, bis zu 3 Versuche)
# =============================================================================
class _LaengereBootzeit:
def __getattr__(self, name):
return getattr(_time, name)
def sleep(self, sekunden):
_time.sleep(5.0 if sekunden == 2.0 else sekunden)
bg22.time = _LaengereBootzeit()
if __name__ == "__main__":
try:
from bgapi.bglib import CommandError
except ImportError:
CommandError = Exception
try:
for versuch in range(1, 4):
try:
bg22.main()
break
except CommandError as fehler:
print(f"bg22_live: Versuch {versuch}/3, BG22 antwortet nicht ({fehler}).", file=sys.stderr)
if versuch == 3:
sys.exit("Abbruch: AoA Analyzer trennen, Reset am BG22, neu starten.")
bg22.SHUTTING_DOWN = False
_time.sleep(3.0)
finally:
speichern()
print(f"bg22_live: {len(tabelle)} Schaetzungen in {a.winkel_out}", file=sys.stderr)
zusammenfassung()
+284
View File
@@ -0,0 +1,284 @@
"""
music_estimator.py
Eigene 2D-MUSIC-Implementierung (Azimut + Elevation) fuer ein 4x4 URA
(Uniform Rectangular Array), passend zu den Daten aus bg22_iq_logger.py
(Datei iq_samples.jsonl).
Diese Implementierung basiert auf der allgemeinen, oeffentlich bekannten
MUSIC-Theorie (Schmidt, 1986) und wurde von Grund auf fuer dieses Projekt
geschrieben - keine Uebernahme aus einem bestehenden Repository, da die
gefundenen Beispiele im Netz auf andere Array-Geometrien/Datenformate
(Radar, 1D-Array) zugeschnitten sind.
Voraussetzungen:
pip install numpy --break-system-packages
Kalibrierung (Phasenoffsets pro Antenne):
1. Tag bei bekanntem Winkel (Standard: 0°/0°, direkt vor dem Array)
platzieren und mit bg22_iq_logger.py eine eigene Datei aufnehmen,
z. B. "iq_samples_0deg.jsonl".
2. Kalibrierung berechnen und speichern:
python music_estimator.py calibrate iq_samples_0deg.jsonl
Das schreibt "calibration.json" mit den Phasenoffsets pro Antenne.
3. Normale Auswertung (nutzt calibration.json automatisch, falls
vorhanden):
python music_estimator.py
"""
import sys
import json
import numpy as np
# --- Einstellungen ---
INPUT_FILE = "iq_samples.jsonl"
CALIBRATION_FILE = "calibration.json"
FREQUENCY_HZ = 2.44e9 # Bluetooth-Mittenfrequenz (Kanal ~19, ca. 2.44 GHz)
SPEED_OF_LIGHT = 3e8
WAVELENGTH = SPEED_OF_LIGHT / FREQUENCY_HZ
# WICHTIG: Der reale Antennenabstand des BRD4191A ist NICHT lambda/2!
# Laut AN1195 (Antenna Array Design Guidelines): Patch-Antennen 27,9 x 27,9 mm,
# mit 12,1 mm Abstand zueinander platziert -> Mitte-zu-Mitte-Abstand = 40,0 mm.
# (lambda/2 waere hier ca. 61,5 mm gewesen - deutlich zu groß!)
ANTENNA_SPACING = 0.0400 # Meter, aus AN1195 (27.9mm Patch + 12.1mm Abstand)
ARRAY_ROWS = 4
ARRAY_COLS = 4
NUM_ANTENNAS = ARRAY_ROWS * ARRAY_COLS # 16, entspricht SWITCHING_PATTERN
# Vorzeichen-Konvention testen/anpassen, falls sich beim Vergleich mit der
# RTL-Bibliothek ein systematisches Vorzeichen-Muster zeigt (siehe Chat-
# Diskussion: (-37,-65) eigener Algo. vs (50,58) RTL-Referenz -> Verdacht
# auf umgekehrte Vorzeichen-Konvention).
AZIMUTH_SIGN = -1 # auf -1 setzen, um das Vorzeichen zu testen/korrigieren
ELEVATION_SIGN = -1 # auf -1 setzen, um das Vorzeichen zu testen/korrigieren
REFERENCE_SAMPLES = 8 # feste Referenzperiode, siehe Chat-Diskussion
NUM_SOURCES = 1 # nur ein Tag sendet gleichzeitig
# Suchraster fuer die Winkelsuche (Grad)
AZIMUTH_RANGE = np.arange(-90, 91, 1)
ELEVATION_RANGE = np.arange(-90, 91, 1)
def antenna_positions():
"""Liefert die (x, y)-Position jeder Antenne im 4x4-Raster,
in der Reihenfolge des SWITCHING_PATTERN (0..15)."""
positions = []
for row in range(ARRAY_ROWS):
for col in range(ARRAY_COLS):
x = col * ANTENNA_SPACING
y = row * ANTENNA_SPACING
positions.append((x, y))
return np.array(positions) # Form: (16, 2)
def steering_vector(azimuth_deg, elevation_deg, positions):
"""Berechnet den theoretischen Steering-Vector fuer einen Winkel.
Noch OHNE Phasenkalibrierung der einzelnen Antennen."""
az = np.radians(azimuth_deg) * AZIMUTH_SIGN
el = np.radians(elevation_deg) * ELEVATION_SIGN
# Richtungsvektor der ankommenden Welle
u = np.cos(el) * np.sin(az)
v = np.sin(el)
phase = (2 * np.pi / WAVELENGTH) * (positions[:, 0] * u + positions[:, 1] * v)
return np.exp(1j * phase) # Form: (16,)
def extract_snapshots(i_values, q_values):
"""Wandelt die IQ-Rohwerte eines Pakets in mehrere Antennen-Snapshots um.
Die Referenzperiode wird uebersprungen; danach wird zyklisch durch die
16 Antennen des Switching-Patterns gegangen. Jeder volle Durchlauf durch
alle 16 Antennen ergibt einen Snapshot (eine Spalte der Datenmatrix)."""
complex_samples = np.array(i_values) + 1j * np.array(q_values)
switching_samples = complex_samples[REFERENCE_SAMPLES:]
num_full_cycles = len(switching_samples) // NUM_ANTENNAS
if num_full_cycles == 0:
return None # zu wenige Samples fuer einen vollen Durchlauf
usable = switching_samples[: num_full_cycles * NUM_ANTENNAS]
snapshots = usable.reshape(num_full_cycles, NUM_ANTENNAS).T
return snapshots # Form: (16, num_full_cycles)
def circular_mean(angles):
"""Mittelwert von Winkeln (Radiant), robust gegen den Sprung bei ±π."""
return np.angle(np.mean(np.exp(1j * angles)))
def compute_calibration_from_samples(samples, known_azimuth, known_elevation, positions=None):
"""Wie compute_calibration(), aber nimmt eine Liste von (i_values, q_values)
Tupeln direkt entgegen, statt eine Datei zu lesen. Nützlich für eine
LIVE-Kalibrierung waehrend das Skript laeuft (siehe bg22_iq_logger.py)."""
if positions is None:
positions = antenna_positions()
theoretical = steering_vector(known_azimuth, known_elevation, positions)
theoretical_phase = np.angle(theoretical / theoretical[0])
offsets_per_antenna = [[] for _ in range(NUM_ANTENNAS)]
for i_values, q_values in samples:
snapshots = extract_snapshots(i_values, q_values)
if snapshots is None:
continue
for snapshot in snapshots.T:
observed_phase = np.angle(snapshot / snapshot[0])
error = observed_phase - theoretical_phase
for antenna in range(NUM_ANTENNAS):
offsets_per_antenna[antenna].append(error[antenna])
offsets = [circular_mean(np.array(values)) if values else 0.0
for values in offsets_per_antenna]
with open(CALIBRATION_FILE, "w", encoding="utf-8") as f:
json.dump({
"referenz_azimut": known_azimuth,
"referenz_elevation": known_elevation,
"phasenoffsets_rad": offsets,
}, f, indent=2)
print(f"Kalibrierung gespeichert in {CALIBRATION_FILE}")
print(f"Phasenoffsets (Grad): {np.degrees(offsets).round(1).tolist()}")
return offsets
def compute_calibration(input_file, known_azimuth, known_elevation, positions):
"""Berechnet den Phasenoffset jeder Antenne relativ zu Antenne 0, aus
einer Aufnahme bei bekanntem Winkel (Standard: 0°/0°), gelesen aus
einer JSON-Lines-Datei."""
samples = []
with open(input_file, "r", encoding="utf-8") as f:
for line in f:
record = json.loads(line)
samples.append((record["i"], record["q"]))
return compute_calibration_from_samples(samples, known_azimuth, known_elevation, positions)
def reset_calibration_cache():
"""Erzwingt, dass estimate_angle() die Kalibrierungsdatei beim naechsten
Aufruf neu einliest (z. B. nachdem gerade eine neue Kalibrierung
gespeichert wurde)."""
global _calibration_loaded
_calibration_loaded = False
def load_calibration():
"""Laedt gespeicherte Phasenoffsets, falls vorhanden. Sonst None."""
try:
with open(CALIBRATION_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return np.array(data["phasenoffsets_rad"])
except FileNotFoundError:
return None
def apply_calibration(snapshots, offsets):
"""Entfernt die bekannten Phasenoffsets aus den Rohdaten, bevor MUSIC
darauf angewendet wird."""
correction = np.exp(-1j * offsets).reshape(-1, 1)
return snapshots * correction
def music_spectrum(snapshots, positions):
"""Berechnet das MUSIC-Pseudospektrum ueber das Winkelraster und gibt
den Winkel mit dem staerksten Peak zurueck."""
# Kovarianzmatrix des Signals (16x16)
covariance = snapshots @ snapshots.conj().T / snapshots.shape[1]
eigenvalues, eigenvectors = np.linalg.eigh(covariance)
# Sortierung: groesster Eigenwert zuletzt -> Rauschraum sind die ersten
noise_subspace = eigenvectors[:, : NUM_ANTENNAS - NUM_SOURCES]
best_azimuth, best_elevation, best_power = 0.0, 0.0, -np.inf
for az in AZIMUTH_RANGE:
for el in ELEVATION_RANGE:
a = steering_vector(az, el, positions)
denom = a.conj().T @ noise_subspace @ noise_subspace.conj().T @ a
power = 1.0 / np.abs(denom)
if power > best_power:
best_power = power
best_azimuth, best_elevation = az, el
return int(best_azimuth), int(best_elevation)
# --- Öffentliche Funktion zum direkten Aufruf aus anderem Code ---
_cached_positions = None
_cached_offsets = None
_calibration_loaded = False
def estimate_angle(i_values, q_values):
"""Nimmt die dekodierten I- und Q-Werte EINES IQ-Reports entgegen
(genau das, was decode_samples() in bg22_iq_logger.py liefert) und
gibt (azimuth, elevation) in Grad zurück, oder (None, None), falls
zu wenige Samples für einen vollen Antennen-Durchlauf vorliegen.
Kalibrierung wird beim ersten Aufruf automatisch geladen (falls
calibration.json existiert) und danach zwischengespeichert.
"""
global _cached_positions, _cached_offsets, _calibration_loaded
if _cached_positions is None:
_cached_positions = antenna_positions()
if not _calibration_loaded:
_cached_offsets = load_calibration()
_calibration_loaded = True
snapshots = extract_snapshots(i_values, q_values)
if snapshots is None or snapshots.shape[1] < 2:
return None, None
if _cached_offsets is not None:
snapshots = apply_calibration(snapshots, _cached_offsets)
return music_spectrum(snapshots, _cached_positions)
def main():
positions = antenna_positions()
if len(sys.argv) >= 2 and sys.argv[1] == "calibrate":
calibration_file = sys.argv[2] if len(sys.argv) >= 3 else INPUT_FILE
compute_calibration(calibration_file, known_azimuth=0, known_elevation=0,
positions=positions)
return
offsets = load_calibration()
if offsets is None:
print("Keine Kalibrierung gefunden (calibration.json fehlt). "
"Ergebnisse sind unkalibriert und möglicherweise verzerrt.\n"
"Kalibrierung erstellen mit: python music_estimator.py calibrate <datei>\n")
else:
print("Kalibrierung geladen, wird angewendet.\n")
with open(INPUT_FILE, "r", encoding="utf-8") as f:
for line_number, line in enumerate(f, start=1):
record = json.loads(line)
snapshots = extract_snapshots(record["i"], record["q"])
if snapshots is None or snapshots.shape[1] < 2:
print(f"Zeile {line_number}: zu wenige Snapshots, übersprungen.")
continue
if offsets is not None:
snapshots = apply_calibration(snapshots, offsets)
azimuth, elevation = music_spectrum(snapshots, positions)
print(f"Zeile {line_number} (counter={record['counter']}): "
f"Azimut={azimuth:.0f}° Elevation={elevation:.0f}°")
if __name__ == "__main__":
main()