#!/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 """ 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-.csv UND sampler-.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 -). 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()