Files
bf-infra/docs/tp25-gehweg-auswertung.py
Christoph Emsenhuber ac3a125c82 TP25/TP28: Walk-Harness auf N Geräte, Analyzer generalisiert, AP-Map-Fix
- tp25-walk-messung.sh: halber Refactor geschlossen — Rumpf schleift ueber die
  PH_*-Arrays (pro Geraet ein Sampler + ein Core-Ping, sampler-<name>.csv /
  walk-ping-<name>.csv), PID-Arrays im Cleanup, Archiv-/Echo-Block generisch.
  Lief vorher unter set -u gar nicht (unbound $PHONE_MAC).
- tp25-gehweg-auswertung.py: liest N benannte Geraete per glob; altes 2-Rollen-
  Schema (sampler.csv/walk-ping.csv/walk-ping-iphone.csv/beide-telefone.csv) bleibt
  als Legacy-Fallback erhalten. Beide Pfade getestet.
- tp25-pin-s22.sh: karte() repariert — channel.frequency ist seit dem DFS-Kanalplan
  (2026-09-03, Frequenz-Listen) ein Array; der alte Skalar-Vergleich blankte map,
  wodurch ap-map.txt leer blieb. Fix nimmt bei :typeof=array das erste Element.

Live-Smoke (4 Geraete) gruen: alle erreichbar, sauberes Cleanup, korrekte AP-Map.
Naechster Schritt: In-situ-Walk (nur Zahlen, Commit/Rollback des Kanalplans separat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 12:42:30 +02:00

304 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
TP25 — Gehweg-Auswertung: Signal und Erreichbarkeit pro RAUM.
Verbindet die Mitschnitte der Gehweg-Messung zu einer Raumtabelle:
walk-ping.csv (5 Pings/s auf die SONDE) -> Verlust und laengste Luecke
walk-ping-iphone.csv (5 Pings/s auf die REFERENZ) -> dito, seit 2026-08-22
beide-telefone.csv (AP + Signal beider Geraete) -> seit 2026-08-22
sampler.csv (Funkdetail der Sonde) -> Signal, PHY-Rate, Durchsatz
markers.csv (RAUM=-Klicks) -> Raumgrenzen auf der Zeitachse
🔎 Zwei Geraete, zwei Rollen (Messaufbau seit 2026-08-22):
REFERENZ = iPhone, roamt frei -> was ein normaler Client hier wirklich erlebt.
SONDE = S22, per access-list auf den GEGEN-AP gezwungen -> was die Rueckfallebene
an derselben Stelle noch leisten koennte.
Nebeneinander beantworten sie die Frage, die eine Messung allein nicht kann:
ist es hier gut, und was passiert, wenn der gute AP wegfaellt?
Damit wird aus "irgendwo zwischen 15:07 und 15:09 war es schlecht" ein Satz mit Raumnamen.
python3 docs/tp25-gehweg-auswertung.py --dir <run-dir>
"""
import argparse
import csv
import glob
import os
import re
import sys
import urllib.parse
def sec(t):
h, m, s = map(int, t.split(":"))
return h * 3600 + m * 60 + s
# 🔴🔴 Hier stand bis 2026-08-22 eine feste Tabelle -- mit VERTAUSCHTEN APs: cap-wifi1/2
# sind ap2, nicht ap1 (live belegt ueber cap=, configuration= und Kanal). Die Baender
# stimmten, die Identitaet nicht. Der Manager vergibt die cap-wifiN-Nummern DYNAMISCH in
# der Reihenfolge, in der sich die CAPs anmelden -- eine Konstante dazu ist nur bis zum
# naechsten AP-Reboot richtig und faellt danach still falsch aus.
#
# ⚠️ OFFEN: ob die Messungen vom 2026-08-21 (Raumkarte, Sideboard-Test) mit vertauschten
# AP-Namen ausgewertet wurden, ist NICHT geprueft. Die Rohdaten sind unberuehrt -- es geht
# allein um die Beschriftung. Betroffen waere u.a. Fund F ("Schlafzimmer -61 ap1 -> -83
# ap2") und damit die Sideboard-Entscheidung. Vor dem Zitieren alter Zahlen klaeren.
def ap_karte(run_dir):
"""cap-wifiN -> apX/Band, aus der Karte DIESES Messlaufs (ap-map.txt).
🔴 Ausdruecklich NICHT live nachschlagen. Die Nummern gelten nur fuer den Zeitpunkt der
Messung; ein spaeter erzeugtes Mapping auf alte Daten anzuwenden ergibt eine
selbstbewusst falsche Antwort. Fehlt die Datei (alle Laeufe vor 2026-08-22), bleiben
die rohen cap-wifiN stehen -- unbeschriftet ist ehrlicher als falsch beschriftet.
"""
pfad = os.path.join(run_dir, "ap-map.txt")
if os.path.exists(pfad):
with open(pfad) as fh:
return dict(z.strip().split("=", 1) for z in fh if "=" in z)
print("⚠️ Kein ap-map.txt in diesem Lauf -- AP-Namen bleiben roh (cap-wifiN).\n"
" Fuer Laeufe vor 2026-08-22 ist die Zuordnung NICHT rekonstruierbar: sie haengt\n"
" daran, in welcher Reihenfolge sich die CAPs damals angemeldet haben.\n"
" Anhaltspunkt aus der Physik: der AP im Raum sollte dort das staerkste Signal\n"
" liefern (ap2 steht in Technik OG, ap1 haengt an core ether15).",
file=sys.stderr)
return {}
AP = {}
def ros_ms(v):
"""RouterOS-Dauer in Millisekunden. Schreibweisen: "12ms", "3ms441us", "529us", "1s200ms"."""
total = 0.0
for zahl, einheit in re.findall(r"(\d+)(us|ms|s)", v):
total += int(zahl) * {"us": 0.001, "ms": 1.0, "s": 1000.0}[einheit]
return total
def lade_pings(pfad):
"""(sekunde, seq) je Antwort. Verluste stehen als Luecken in den Sequenznummern."""
treffer = []
if not os.path.exists(pfad):
return treffer
for r in csv.DictReader(open(pfad)):
if not (r.get("seq") or "").isdigit():
continue # RECONNECT-Zeilen
if (r.get("ms") or "").strip():
treffer.append((sec(r["ts_local"]), int(r["seq"])))
return treffer
def lade_pings_alt(pfad):
"""Altes Format: BSD-ping vom Laptop."""
treffer = []
for line in open(pfad):
m = re.match(r"(\d\d:\d\d:\d\d) 64 bytes.*icmp_seq=(\d+)", line)
if m:
treffer.append((sec(m.group(1)), int(m.group(2))))
return treffer
def _sampler_funk(pfad):
"""funk-Liste (sekunde, iface, signal, phy_rate_oder_None) aus einer sampler-CSV."""
funk = []
if os.path.exists(pfad):
for r in csv.DictReader(open(pfad)):
if r.get("sta_signal"):
funk.append((sec(r["ts_local"]), r.get("sta_iface") or "",
int(r["sta_signal"]),
int(r["sta_tx_rate"]) if (r.get("sta_tx_rate") or "").isdigit() else None))
return funk
def geraete(run_dir):
"""Liste benannter Geraete: (name, ping-Liste, funk-Liste).
funk-Liste = (sekunde, iface, signal, phy_rate_oder_None).
Seit 2026-09-04 (N-Geraete-Umbau) schreibt die Harness pro Geraet walk-ping-<name>.csv
UND sampler-<name>.csv -- jedes Paar wird als EIN benanntes Geraet gelesen, beliebig viele,
ohne feste Sonde/Referenz-Rollen. Faellt auf das alte 2-Rollen-Schema zurueck (sampler.csv /
walk-ping.csv / walk-ping-iphone.csv / beide-telefone.csv), damit Laeufe vor dem Umbau
weiter auswertbar bleiben. Unterschieden wird an der NACKTEN sampler.csv/walk-ping.csv:
die schreibt nur das alte Schema -- die neue Harness nie (immer mit -<name>). Ohne diese
Klammer wuerde der glob unten die walk-ping-iphone.csv eines Alt-Laufs fassen und dessen
Sonde (walk-ping.csv, ohne Suffix) stillschweigend verlieren.
"""
pfad = lambda n: os.path.join(run_dir, n)
legacy = os.path.exists(pfad("sampler.csv")) or os.path.exists(pfad("walk-ping.csv"))
if not legacy:
gs = []
for pp in sorted(glob.glob(os.path.join(run_dir, "walk-ping-*.csv"))):
name = os.path.basename(pp)[len("walk-ping-"):-len(".csv")]
gs.append((name, lade_pings(pp), _sampler_funk(pfad("sampler-%s.csv" % name))))
if gs:
return gs
# --- Legacy: 2-Rollen-Schema (Laeufe vor 2026-09-04) ---
sonde_funk = _sampler_funk(pfad("sampler.csv"))
ref_funk = []
if os.path.exists(pfad("beide-telefone.csv")):
for r in csv.DictReader(open(pfad("beide-telefone.csv"))):
if (r.get("iphone_sig") or "").lstrip("-").isdigit():
ref_funk.append((sec(r["ts_local"]), r.get("iphone_iface") or "",
int(r["iphone_sig"]), None))
sonde_ping = lade_pings(pfad("walk-ping.csv"))
if not sonde_ping and os.path.exists(pfad("walk-ping.log")):
sonde_ping = lade_pings_alt(pfad("walk-ping.log"))
ref_ping = lade_pings(pfad("walk-ping-iphone.csv"))
if not ref_ping and not ref_funk:
# Lauf vor 2026-08-22: nur ein Geraet. Welches, ist aus den Dateien nicht
# ableitbar -- also wird es auch nicht behauptet.
return [("Gerät", sonde_ping, sonde_funk)]
return [("Referenz (frei)", ref_ping, ref_funk),
("Sonde (Gegen-AP)", sonde_ping, sonde_funk)]
def auswerten(ping, funk, start, stop, pin_zeiten=()):
"""Verlust, laengste Luecke, Signal und APs eines Geraets in einem Zeitfenster."""
p = [x for x in ping if start <= x[0] < stop]
f = [x for x in funk if start <= x[0] < stop]
luecke, verloren, luecke_ab = 0.0, 0, None
for i in range(1, len(p)):
miss = p[i][1] - p[i - 1][1] - 1
if miss > 0:
verloren += miss
if miss * 0.2 > luecke:
luecke, luecke_ab = miss * 0.2, p[i - 1][0]
# 🔴 Die groesste Luecke der Sonde ist oft KEIN Funkbefund, sondern unser eigener
# Eingriff: beim Umschalten des Gegen-AP wird das Geraet per Deauth geworfen und
# braucht ein paar Sekunden zurueck. Am 2026-08-22 stand deshalb auf der Treppe
# "4,6 s ⚠️ eher Ruhezustand" -- das Geraet war von 23:35:12 bis 23:35:16 schlicht
# abgemeldet, weil um 23:35:10 der Pin von ap1 auf ap2 wechselte. Ein Messartefakt
# als "Geraet war erreichbar, wollte nur nicht" zu beschriften ist doppelt falsch.
pin_luecke = luecke_ab is not None and any(
luecke_ab - 3 <= t <= luecke_ab + luecke + 3 for t in pin_zeiten)
gesamt = (p[-1][1] - p[0][1] + 1) if len(p) > 1 else 0
sig = [x[2] for x in f]
raten = [x[3] for x in f if x[3]]
# 🔴 Ruhezustand von Funkloch trennen: bleibt das Signal gut UND die PHY-Rate hoch,
# war das Geraet erreichbar und wollte nur nicht antworten (iOS bei gesperrtem
# Display). Ohne Ratendaten ist das nur ein Verdacht -- und wird so beschriftet.
gut = bool(sig) and (sum(sig) / len(sig)) > -68
schlaefrig = luecke >= 3 and gut and bool(raten) and (sum(raten) / len(raten)) > 100e6
verdacht = luecke >= 3 and gut and not raten
# 🔴 "Funk da, aber KEINE einzige Ping-Antwort" ist ein eigener Zustand und darf nicht
# als "—" (= keine Daten) erscheinen. Beim Abnahmetest am 2026-08-22 lag das iPhone
# gesperrt auf dem Tisch: angemeldet mit -50 dBm, und trotzdem stumm. Wer das als
# Datenluecke liest, sucht anschliessend ein Funkloch, das es nicht gibt.
stumm = (len(p) <= 1) and bool(sig)
return {
"sig": sig, "luecke": luecke, "verloren": verloren, "gesamt": gesamt,
"ifaces": sorted({AP.get(x[1], x[1]) for x in f if x[1]}),
"schlaefrig": schlaefrig, "verdacht": verdacht, "stumm": stumm, "gut": gut,
"pin_luecke": pin_luecke,
"leer": not p and not f,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dir", required=True)
a = ap.parse_args()
global AP
AP = ap_karte(a.dir)
gs = geraete(a.dir)
if not any(g[1] or g[2] for g in gs):
raise SystemExit("Weder Ping-Antworten noch Funkproben gefunden.")
# Alte 2-Rollen-Laeufe tragen die Namen "Referenz"/"Sonde" und bekommen den Rollen-Text;
# neue N-Geraete-Laeufe tragen Geraetenamen (iphone/s22/ipad/...) -- ohne Rollen-Erklaerung.
legacy_rollen = any("Sonde" in r or "Referenz" in r for r, _, _ in gs)
rooms = []
for m in csv.DictReader(open(os.path.join(a.dir, "markers.csv"))):
# 🔴 Marker kommen URL-kodiert aus dem Formular ("RAUM%3DDIELE%20UG") — dekodieren,
# sonst heisst der Raum "%3DDIELE%20UG" und die Tabelle ist unlesbar.
mk = urllib.parse.unquote(m["marker"])
if mk.upper().startswith("RAUM="):
rooms.append((sec(m["ts_local"]), mk[5:].title()))
pin_zeiten = [sec(m["ts_local"])
for m in csv.DictReader(open(os.path.join(a.dir, "markers.csv")))
if urllib.parse.unquote(m["marker"]).upper().startswith("PIN=")]
if not rooms:
raise SystemExit("Keine RAUM=-Marken in markers.csv — nichts zuzuordnen.")
ende = max([x[0] for g in gs for x in (g[1] + [(0, 0)])] +
[x[0] for g in gs for x in (g[2] + [(0, 0, 0, None)])]) + 1
fenster = [(r[0], rooms[i + 1][0] if i + 1 < len(rooms) else ende, r[1])
for i, r in enumerate(rooms)]
mehrere = len(gs) > 1
if mehrere and legacy_rollen:
print("REFERENZ = iPhone, roamt frei (was du hier wirklich hast)")
print("SONDE = S22, auf den Gegen-AP gezwungen (was die Rückfallebene noch kann)\n")
elif mehrere:
print("Ein Block je Raum, eine Zeile je Gerät (%s) — alle frei roamend.\n"
% ", ".join(r for r, _, _ in gs))
print("%-15s %-17s %5s %-22s %-17s %s"
% ("Raum", "Rolle", "Dauer", "Signal min/⌀/max", "Verlust", "längste Lücke"))
print("-" * 100)
for start_s, stop_s, name in fenster:
werte = [(rolle, auswerten(ping, funk, start_s, stop_s,
pin_zeiten if "Sonde" in rolle else ()))
for rolle, ping, funk in gs]
if all(w["leer"] for _, w in werte):
continue
erste = True
for rolle, w in werte:
if w["leer"]:
continue
sig, luecke = w["sig"], w["luecke"]
if w["stumm"]:
lz = "⚠️ Display gesperrt?" if w["gut"] else "⚠️ keine Antwort"
elif luecke and w["pin_luecke"]:
lz = "%.1f s ⚠️ Pin-Wechsel" % luecke
elif luecke and w["schlaefrig"]:
lz = "%.1f s ⚠️ eher Ruhezustand" % luecke
elif luecke and w["verdacht"]:
lz = "%.1f s ⚠️ evtl. Ruhezustand" % luecke
elif luecke:
lz = "%.1f s" % luecke
else:
lz = "keine"
print("%-15s %-17s %4ds %-22s %-17s %s" % (
name if erste else "", rolle, stop_s - start_s,
"%d / %.1f / %d dBm" % (min(sig), sum(sig) / len(sig), max(sig)) if sig else "—",
("%d von %d (%.0f %%)" % (w["verloren"], w["gesamt"],
100 * w["verloren"] / w["gesamt"]) if w["gesamt"]
else ("keine Antwort" if w["stumm"] else "—")),
lz))
if w["ifaces"]:
print("%-15s %-17s auf %s" % ("", "", ", ".join(w["ifaces"])))
erste = False
print("\n⚠️ eher Ruhezustand = Lücke bei gutem Signal UND hoher PHY-Rate -> das Gerät war")
print(" erreichbar und hat nur nicht geantwortet (iOS mit gesperrtem Display).")
print("⚠️ evtl. Ruhezustand = dasselbe Muster, aber ohne PHY-Rate zum Gegenprüfen")
print(" (die Referenz liefert nur AP und Signal). Verdacht, kein Befund.")
print("⚠️ Pin-Wechsel = die Lücke fällt mit dem Umschalten des Gegen-AP zusammen. Das ist")
print(" unser eigener Eingriff (Deauth + Neuanmeldung), kein Funkbefund. Nur die Sonde.")
print("⚠️ Display gesperrt? = Funkverbindung stand mit gutem Signal, aber KEINE einzige")
print(" Ping-Antwort. Das ist kein Funkloch und keine Datenlücke — bei iOS beantwortet")
print(" ein gesperrtes Display kein ICMP. Beim nächsten Lauf Display anlassen.")
print("\nLesehilfe: eine längste Lücke über ~2 s ist für einen Videocall eine hörbare")
print("Unterbrechung, über 10 s ist der Call praktisch tot. Signal unter −70 dBm ist die")
print("Zone, in der das passiert.")
if mehrere and legacy_rollen:
print("\nDie interessante Spalte ist der Unterschied: gute Referenz + schlechte Sonde")
print("= hier hängt alles an EINEM AP. Beide schlecht = echtes Loch ohne Rückfallebene.")
elif mehrere:
print("\nDie interessante Spalte ist der Unterschied ZWISCHEN den Geräten am selben Ort:")
print("streuen Signal/Verlust stark, liegt es am Gerät (Antenne/Standby), nicht am Funk;")
print("sind alle an einem Ort schlecht, ist es ein echtes Loch der Ausleuchtung.")
if __name__ == "__main__":
main()