Beim Bauen der Gegen-AP-Automatik fiel auf, dass die feste AP-Tabelle in
server.py und tp25-gehweg-auswertung.py nicht zum Live-Zustand passt: heute
sind cap-wifi1/2 = ap2 und cap-wifi3/4 = ap1. Der Manager vergibt die Nummern
dynamisch beim Anmelden der CAPs; ap1 hat sich beim Rueckbau auf ether15 neu
angemeldet.
Der erste Schluss daraus war voreilig ("die alten Auswertungen sind
falsch beschriftet"). Die Gegenprobe widerlegt ihn: mit der HEUTIGEN Karte
haette Technik OG am 08-21 von ap1 mit -43 dBm versorgt worden sein -- in dem
Raum, in dem ap2 steht. Die alte Tabelle war zu ihrer Zeit also richtig und
ist erst durch das Umstecken falsch geworden.
Konsequenz, und das ist der eigentliche Fix: eine live nachgeschlagene Karte
auf alte Daten anzuwenden ist nicht die Loesung, sondern derselbe Fehler mit
umgekehrtem Vorzeichen. Die Zuordnung gehoert zum Messzeitpunkt.
- tp25-walk-messung.sh friert sie als ap-map.txt im Laufverzeichnis ein.
- tp25-gehweg-auswertung.py nimmt ausschliesslich diese Datei. Fehlt sie
(alle Laeufe vor heute), bleiben die rohen cap-wifiN stehen, mit lauter
Warnung -- unbeschriftet ist ehrlicher als falsch beschriftet.
teilprojekte.md: TP25 um den Werkzeug-Fund, den neuen Messaufbau (eine Runde
statt zwei) und den Pausenstand ergaenzt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 lines
7.3 KiB
Python
156 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
TP25 — Gehweg-Auswertung: Signal und Erreichbarkeit pro RAUM.
|
||
|
||
Verbindet die drei Mitschnitte der Gehweg-Messung zu einer Raumtabelle:
|
||
walk-ping.log (5 Pings/s auf das Telefon) -> Verlust und laengste Luecke
|
||
sampler.csv (Funkwerte am Manager) -> AP/Band und Signal
|
||
markers.csv (RAUM=-Klicks) -> die Raumgrenzen auf der Zeitachse
|
||
|
||
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 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 main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--dir", required=True)
|
||
a = ap.parse_args()
|
||
|
||
global AP
|
||
AP = ap_karte(a.dir)
|
||
|
||
pings = []
|
||
csv_path = os.path.join(a.dir, "walk-ping.csv")
|
||
if os.path.exists(csv_path):
|
||
# Neues Format: Ping vom Core, "ts_local,seq,ms" — leeres ms = Verlust.
|
||
for r in csv.DictReader(open(csv_path)):
|
||
if not (r.get("seq") or "").isdigit():
|
||
continue # RECONNECT-Zeilen
|
||
ms = (r.get("ms") or "").strip()
|
||
if ms:
|
||
pings.append((sec(r["ts_local"]), int(r["seq"]), ros_ms(ms)))
|
||
else:
|
||
# Altes Format: BSD-ping vom Laptop.
|
||
for line in open(os.path.join(a.dir, "walk-ping.log")):
|
||
m = re.match(r"(\d\d:\d\d:\d\d) 64 bytes.*icmp_seq=(\d+).*time=([\d.]+)", line)
|
||
if m:
|
||
pings.append((sec(m.group(1)), int(m.group(2)), float(m.group(3))))
|
||
if not pings:
|
||
raise SystemExit("Keine Ping-Antworten gefunden.")
|
||
|
||
rows = [r for r in csv.DictReader(open(os.path.join(a.dir, "sampler.csv")))
|
||
if r.get("sta_signal")]
|
||
|
||
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((int(m["ts_epoch"]), sec(m["ts_local"]), mk[5:].title()))
|
||
if not rooms:
|
||
raise SystemExit("Keine RAUM=-Marken in markers.csv — nichts zuzuordnen.")
|
||
|
||
ende = max(pings[-1][0], sec(rows[-1]["ts_local"]) if rows else 0) + 1
|
||
fenster = [(r[1], rooms[i + 1][1] if i + 1 < len(rooms) else ende, r[2])
|
||
for i, r in enumerate(rooms)]
|
||
|
||
print("%-16s %6s %-24s %-18s %s" % ("Raum", "Dauer", "Signal min/avg/max", "Verlust", "längste Lücke"))
|
||
print("-" * 92)
|
||
for start, stop, name in fenster:
|
||
p = [x for x in pings if start <= x[0] < stop]
|
||
r = [x for x in rows if start <= sec(x["ts_local"]) < stop]
|
||
if not p and not r:
|
||
continue
|
||
# Verlust und laengste Luecke aus den Sequenznummern
|
||
luecke, verloren = 0.0, 0
|
||
for i in range(1, len(p)):
|
||
miss = p[i][1] - p[i - 1][1] - 1
|
||
if miss > 0:
|
||
verloren += miss
|
||
luecke = max(luecke, miss * 0.2)
|
||
gesamt = (p[-1][1] - p[0][1] + 1) if len(p) > 1 else 0
|
||
# 🔴 Ruhezustand von Funkloch trennen. Ein iPhone mit gesperrtem Display
|
||
# beantwortet ICMP nicht — das sieht in der Verlustspalte aus wie ein Funkloch.
|
||
# Unterscheidbar ist es am Funk: bleibt das Signal gut UND die PHY-Rate hoch,
|
||
# war das Geraet erreichbar und wollte nur nicht antworten.
|
||
# (Am 2026-08-21 auf der Terrasse: 58 s Ping-Luecke bei -53 dBm und 275 Mbps.)
|
||
sig = [int(x["sta_signal"]) for x in r]
|
||
raten = [int(x["sta_tx_rate"] or 0) for x in r if x.get("sta_tx_rate")]
|
||
# Bewertet wird der DURCHSCHNITT des Abschnitts, nicht sein Minimum: ein
|
||
# einzelner schwacher Sample beim Durchgehen darf den Befund nicht kippen.
|
||
schlaefrig = (luecke >= 3 and sig and (sum(sig) / len(sig)) > -68
|
||
and raten and (sum(raten) / len(raten)) > 100e6)
|
||
ifaces = sorted({AP.get(x["sta_iface"], x["sta_iface"]) for x in r if x["sta_iface"]})
|
||
print("%-16s %5ds %-24s %-18s %s" % (
|
||
name, stop - start,
|
||
"%d / %.1f / %d dBm" % (min(sig), sum(sig) / len(sig), max(sig)) if sig else "—",
|
||
"%d von %d (%.0f %%)" % (verloren, gesamt, 100 * verloren / gesamt) if gesamt else "—",
|
||
(("%.1f s ⚠️ eher Ruhezustand" % luecke) if schlaefrig
|
||
else ("%.1f s" % luecke)) if luecke else "keine"))
|
||
if ifaces:
|
||
print("%-16s auf %s" % ("", ", ".join(ifaces)))
|
||
print("\n⚠️ = Luecke bei gutem Signal und hoher PHY-Rate -> das Geraet war erreichbar und hat")
|
||
print(" nur nicht geantwortet (iOS-Ruhezustand). Solche Luecken NICHT als Funkloch lesen.")
|
||
print("\nLesehilfe: eine längste Lücke über ~2 s ist für einen Videocall eine hörbare Unterbrechung,")
|
||
print("über 10 s ist der Call praktisch tot. Signal unter −70 dBm ist die Zone, in der das passiert.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|