From faf17a7f0773558d80476159900f617fa8cceaf3 Mon Sep 17 00:00:00 2001 From: rpwolff Date: Tue, 10 Feb 2026 12:10:35 +0100 Subject: [PATCH] auslieferung --- cost_centers.json | 6 +- data/mmp.sqlite | Bin 45056 -> 69632 bytes mmp_logger.py | 225 ++++++++++++++++++++++++------------- reports/report_weekly.html | 47 ++++++++ reports/report_weekly.md | 46 ++++++++ 5 files changed, 245 insertions(+), 79 deletions(-) create mode 100644 reports/report_weekly.html create mode 100644 reports/report_weekly.md diff --git a/cost_centers.json b/cost_centers.json index 0f1e723..846b741 100644 --- a/cost_centers.json +++ b/cost_centers.json @@ -1,6 +1,6 @@ { - "W": "Werkstatt", - "P": "Produktion", - "A": "Allgemein", + "W": "Wolff", + "A": "Austinat", + "P": "Paulisch", "_default": "Unbekannt" } \ No newline at end of file diff --git a/data/mmp.sqlite b/data/mmp.sqlite index 9c0b549851b136c2daa4d30fe94301c58916f0c3..89f248dfc0d8caeae048eb98775b038ee667af24 100644 GIT binary patch literal 69632 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 diff --git a/mmp_logger.py b/mmp_logger.py index f76add0..baa414b 100755 --- a/mmp_logger.py +++ b/mmp_logger.py @@ -7,8 +7,8 @@ mmp_logger.py - Schreibt Messwerte in SQLite - Fehlende Felder werden mit letztem Messwert ersetzt (filled + filled_fields werden protokolliert) - Report als Markdown (und optional HTML/PDF) nach ./reports/ - -Projektidee: Alles in EINEM Verzeichnis. +- Outlets im Report in physischer Reihenfolge (port_index 1..N, wie von der MMP geliefert) +- Kostenstellen device-übergreifend aggregiert; nur Codes aus cost_centers.json (ohne _default) """ import argparse @@ -23,6 +23,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Tuple + # ------------------------- # Parsing / Prompt # ------------------------- @@ -42,8 +43,6 @@ class DeviceCfg: host: str port: int enabled: bool = True - username: Optional[str] = None # nicht genutzt (kein Login), bleibt für Zukunft - password: Optional[str] = None # ------------------------- @@ -89,6 +88,8 @@ def db_connect(db_path: str) -> sqlite3.Connection: return con def db_init(con: sqlite3.Connection) -> None: + # Hinweis: wenn du schon eine alte DB hast, ist ein Schema-Migration nötig. + # Da du ohnehin "rm -f data/mmp.sqlite" nutzt, ist das hier ok. con.executescript(""" CREATE TABLE IF NOT EXISTS device ( id INTEGER PRIMARY KEY, @@ -99,15 +100,16 @@ def db_init(con: sqlite3.Connection) -> None: last_seen_at TEXT ); - CREATE TABLE IF NOT EXISTS outlet ( +CREATE TABLE IF NOT EXISTS outlet ( id INTEGER PRIMARY KEY, device_id INTEGER NOT NULL, + port_index INTEGER NOT NULL, outlet_name TEXT NOT NULL, cost_code TEXT NOT NULL, cost_name TEXT NOT NULL, created_at TEXT NOT NULL, last_seen_at TEXT, - UNIQUE(device_id, outlet_name), + UNIQUE(device_id, port_index), FOREIGN KEY(device_id) REFERENCES device(id) ); @@ -146,6 +148,7 @@ def db_init(con: sqlite3.Connection) -> None: CREATE INDEX IF NOT EXISTS idx_reading_ts ON reading(ts); CREATE INDEX IF NOT EXISTS idx_reading_outlet_ts ON reading(outlet_id, ts); + CREATE INDEX IF NOT EXISTS idx_outlet_device_port ON outlet(device_id, port_index); """) con.commit() @@ -167,26 +170,37 @@ def get_or_create_device(con: sqlite3.Connection, d: DeviceCfg) -> int: con.commit() return int(cur.lastrowid) -def get_or_create_outlet(con: sqlite3.Connection, device_id: int, outlet_name: str, +def get_or_create_outlet(con: sqlite3.Connection, device_id: int, port_index: int, outlet_name: str, cost_code: str, cost_name: str) -> int: now = utc_now_iso() - cur = con.execute("SELECT id FROM outlet WHERE device_id=? AND outlet_name=?", - (device_id, outlet_name)) + if port_index <= 0: + raise ValueError(f"port_index invalid: {port_index} for outlet_name={outlet_name!r}") + + cur = con.execute("SELECT id FROM outlet WHERE device_id=? AND port_index=?", + (device_id, port_index)) row = cur.fetchone() if row: outlet_id = int(row[0]) + # Name darf sich ändern -> immer aktualisieren con.execute(""" UPDATE outlet - SET cost_code=?, cost_name=?, last_seen_at=? + SET outlet_name=?, cost_code=?, cost_name=?, last_seen_at=? WHERE id=? - """, (cost_code, cost_name, now, outlet_id)) + """, (outlet_name, cost_code, cost_name, now, outlet_id)) con.commit() return outlet_id - + cur = con.execute(""" - INSERT INTO outlet(device_id,outlet_name,cost_code,cost_name,created_at,last_seen_at) - VALUES(?,?,?,?,?,?) - """, (device_id, outlet_name, cost_code, cost_name, now, now)) + INSERT INTO outlet(device_id,port_index,outlet_name,cost_code,cost_name,created_at,last_seen_at) + VALUES(?,?,?,?,?,?,?) + """, (device_id, port_index, outlet_name, cost_code, cost_name, now, now)) + con.commit() + return int(cur.lastrowid) + + cur = con.execute(""" + INSERT INTO outlet(device_id,port_index,outlet_name,cost_code,cost_name,created_at,last_seen_at) + VALUES(?,?,?,?,?,?,?) + """, (device_id, port_index, outlet_name, cost_code, cost_name, now, now)) con.commit() return int(cur.lastrowid) @@ -227,12 +241,6 @@ def cost_center_for(outlet_name: str, cc_map: Dict[str, str]) -> Tuple[str, str] # ------------------------- def tcp_read_all(sock: socket.socket, read_timeout: int, stop_on_prompt: bool = True) -> bytes: - """ - Liest Daten bis: - - prompt '>' gesehen (optional) - - remote close (recv == b'') - - timeout - """ sock.settimeout(read_timeout) buf = bytearray() while True: @@ -260,15 +268,14 @@ def tcp_fetch_ostatus_raw( stop_on_prompt: bool = True ) -> bytes: """ - Ablauf (weil Session ggf. schließt): - 1) connect - 2) ENTER senden (wecken), Pause - 3) kurz lesen (prompt_timeout) -> wenn kein Prompt: nochmal ENTER+Pause+kurz lesen - 4) cmd senden, kurze Pause - 5) lesen bis prompt/close/timeout + Bridge-Verhalten: + - Session evtl. geschlossen -> recv==b'' beachten + - Kein Login + - ENTER senden, ggf. Pause, ggf. nochmal ENTER """ with socket.create_connection((host, port), timeout=connect_timeout) as s: pre = b"" + if enter_first: s.sendall(b"\r\n") time.sleep(prompt_pause_sec) @@ -276,12 +283,10 @@ def tcp_fetch_ostatus_raw( pre += tcp_read_all(s, read_timeout=prompt_timeout, stop_on_prompt=stop_on_prompt) if stop_on_prompt and (PROMPT_END not in pre): - # nochmal wecken s.sendall(b"\r\n") time.sleep(prompt_pause_sec) pre += tcp_read_all(s, read_timeout=prompt_timeout, stop_on_prompt=stop_on_prompt) - # Kommando s.sendall(cmd.encode("utf-8") + b"\r\n") time.sleep(cmd_pause_sec) @@ -290,23 +295,30 @@ def tcp_fetch_ostatus_raw( # ------------------------- -# Parse ostatus output +# Parse ostatus output (physische Reihenfolge) # ------------------------- def parse_ostatus(text: str) -> List[dict]: """ - Erwartet Tabellenzeilen mit '|'. - Beispiel: - | W Power1 | 0.0 A | 0.0 A | 230.3 V | 0 W | 4 VA | On | + Erwartet Datenzeilen mit '|'. + Wichtig: port_index wird als fortlaufender Index in der Tabellen-Reihenfolge vergeben. + Dadurch entspricht port_index der physischen Reihenfolge, wie sie die MMP ausgibt. """ rows: List[dict] = [] + port_index = 0 + for ln in text.splitlines(): if "|" not in ln: continue - # Header / Trenner filtern - if "Outlet" in ln and "True RMS" in ln: + + s = ln.strip() + + # Header/Trenner filtern (wichtig: zweite Headerzeile!) + if ("Outlet" in ln and "True RMS" in ln): continue - if set(ln.strip()) <= set("-| "): + if ("Name" in ln and "Current" in ln): + continue + if set(s) <= set("-| "): continue parts = [p.strip() for p in ln.split("|")] @@ -314,14 +326,20 @@ def parse_ostatus(text: str) -> List[dict]: if len(parts) < 7: continue + # Schutz: falls doch Header-Schrott durchrutscht + if parts[0].strip().lower() in ("outlet", "name"): + continue + outlet_name = parts[0] cur_s, peak_s, volt_s, power_s, va_s, state = parts[1:7] - def parse_num(s: str) -> Optional[float]: - m = NUM_RE.search(s.replace(",", ".")) + def parse_num(val: str) -> Optional[float]: + m = NUM_RE.search(val.replace(",", ".")) return float(m.group(1)) if m else None + port_index += 1 rows.append({ + "port_index": port_index, "outlet_name": outlet_name, "current_a": parse_num(cur_s), "peak_a": parse_num(peak_s), @@ -330,6 +348,7 @@ def parse_ostatus(text: str) -> List[dict]: "va": parse_num(va_s), "state": state if state else None }) + return rows @@ -399,8 +418,10 @@ def poll_device(con: sqlite3.Connection, dev: DeviceCfg, cc_map: Dict[str, str], for r in rows: outlet_name = r["outlet_name"].strip() + port_index = int(r.get("port_index") or 0) + cost_code, cost_name = cost_center_for(outlet_name, cc_map) - outlet_id = get_or_create_outlet(con, device_id, outlet_name, cost_code, cost_name) + outlet_id = get_or_create_outlet(con, device_id, port_index, outlet_name, cost_code, cost_name) last = last_reading_for_outlet(con, outlet_id) r2, filled_flag, filled_fields_cnt = apply_fill(r, last) @@ -432,7 +453,6 @@ def poll_device(con: sqlite3.Connection, dev: DeviceCfg, cc_map: Dict[str, str], """, (utc_now_iso(), outlets_received, outlets_filled, fields_filled, duration_ms, run_id)) con.commit() - # kleine Info für CLI print(f"{dev.name}: OK outlets={outlets_received} filled_outlets={outlets_filled} filled_fields={fields_filled}") except Exception as e: @@ -486,8 +506,6 @@ def write_report_files(project_root: str, name: str, md_text: str, write_md: boo html_path.write_text(html_full, encoding="utf-8") if write_pdf: - # robust über pandoc (muss installiert sein) - # sudo apt-get install -y pandoc if not write_md: md_path.write_text(md_text, encoding="utf-8") os.system(f"pandoc '{md_path}' -o '{pdf_path}'") @@ -498,9 +516,6 @@ def write_report_files(project_root: str, name: str, md_text: str, write_md: boo # ------------------------- def parse_period_args(args) -> Tuple[str, str, str]: - """ - Returns (from_iso, to_iso, suffix_name) in UTC. - """ now = dt.datetime.now(dt.timezone.utc).replace(microsecond=0) if args.last_days is not None: @@ -525,10 +540,12 @@ def parse_period_args(args) -> Tuple[str, str, str]: return start.isoformat(), now.isoformat(), suffix -def report(con: sqlite3.Connection, from_iso: str, to_iso: str, device_name: Optional[str]) -> str: +def report(con: sqlite3.Connection, from_iso: str, to_iso: str, device_name: Optional[str], cc_map: Dict[str, str]) -> str: """ - Markdown Report als String. - Energie: approx_Wh = Sum(power_w)*1h (bei stündlichem Poll). + Layout: + - Outlets gesamt: physische Reihenfolge (port_index ASC), kein device, kein cost_code + - Kostenstellen: device-übergreifend aggregiert, nur Codes aus cost_centers.json (ohne _default) + - Job-Statistik wie bisher """ params = [from_iso, to_iso] dev_filter = "" @@ -536,25 +553,69 @@ def report(con: sqlite3.Connection, from_iso: str, to_iso: str, device_name: Opt dev_filter = "AND d.name = ?" params.append(device_name) - q_cost = f""" + # ---- Outlets ---- + q_outlets = f""" SELECT - d.name AS device, - o.cost_code, + o.port_index, + o.outlet_name, o.cost_name, COUNT(*) AS samples, - SUM(CASE WHEN r.filled=1 THEN 1 ELSE 0 END) AS samples_with_fill, - ROUND(AVG(r.power_w), 2) AS avg_power_w, + SUM(CASE WHEN r.filled=1 THEN 1 ELSE 0 END) AS filled_samples, + ROUND(AVG(COALESCE(r.power_w,0.0)), 2) AS avg_power_w, ROUND(SUM(COALESCE(r.power_w,0.0))*1.0, 2) AS approx_energy_wh FROM reading r JOIN outlet o ON o.id = r.outlet_id JOIN device d ON d.id = r.device_id WHERE r.ts >= ? AND r.ts <= ? {dev_filter} - GROUP BY d.name, o.cost_code, o.cost_name - ORDER BY d.name, o.cost_code + GROUP BY o.id, o.port_index, o.outlet_name, o.cost_name + ORDER BY o.port_index ASC """ - cost_rows = con.execute(q_cost, params).fetchall() + outlet_rows = con.execute(q_outlets, params).fetchall() + q_totals = f""" + SELECT + COUNT(DISTINCT o.id) AS outlets_count, + COUNT(*) AS samples_count, + SUM(CASE WHEN r.filled=1 THEN 1 ELSE 0 END) AS filled_samples_count, + ROUND(SUM(COALESCE(r.power_w,0.0))*1.0, 2) AS approx_energy_wh_total + FROM reading r + JOIN outlet o ON o.id = r.outlet_id + JOIN device d ON d.id = r.device_id + WHERE r.ts >= ? AND r.ts <= ? + {dev_filter} + """ + totals = con.execute(q_totals, params).fetchone() + outlets_count, samples_count, filled_samples_count, approx_wh_total = totals + + # ---- Kostenstellen (device-übergreifend) ---- + valid_codes = [k for k in cc_map.keys() if k != "_default"] + valid_codes = sorted(set([c.upper() for c in valid_codes])) + + cost_rows = [] + if valid_codes: + placeholders = ",".join(["?"] * len(valid_codes)) + q_cost = f""" + SELECT + o.cost_name, + COUNT(DISTINCT o.id) AS outlets_in_costcenter, + COUNT(*) AS samples, + SUM(CASE WHEN r.filled=1 THEN 1 ELSE 0 END) AS filled_samples, + ROUND(AVG(COALESCE(r.power_w,0.0)), 2) AS avg_power_w, + ROUND(SUM(COALESCE(r.power_w,0.0))*1.0, 2) AS approx_energy_wh + FROM reading r + JOIN outlet o ON o.id = r.outlet_id + JOIN device d ON d.id = r.device_id + WHERE r.ts >= ? AND r.ts <= ? + {dev_filter} + AND o.cost_code IN ({placeholders}) + GROUP BY o.cost_name + ORDER BY approx_energy_wh DESC, o.cost_name + """ + cost_params = params + valid_codes + cost_rows = con.execute(q_cost, cost_params).fetchall() + + # ---- Job-Statistik ---- q_job = f""" SELECT d.name, @@ -574,21 +635,38 @@ def report(con: sqlite3.Connection, from_iso: str, to_iso: str, device_name: Opt """ job_rows = con.execute(q_job, params).fetchall() + # ---- Markdown ---- md: List[str] = [] md.append("# MMP Report") md.append("") md.append(f"- Zeitraum (UTC): **{from_iso}** .. **{to_iso}**") if device_name: - md.append(f"- Device: **{device_name}**") + md.append(f"- Device-Filter: **{device_name}**") + md.append("") + + md.append("## Outlets gesamt") + md.append("") + md.append(f"- Ports (distinct): **{outlets_count}**") + md.append(f"- Samples: **{samples_count}** (filled: **{filled_samples_count}**)") + md.append(f"- Kumuliert (approx): **{approx_wh_total} Wh**") + md.append("") + md.append("| port | outlet | cost_name | samples | filled | avg_W | approx_Wh |") + md.append("|---:|---|---|---:|---:|---:|---:|") + for port_index, outlet_name, cost_name, samples, filled, avg_w, wh in outlet_rows: + md.append(f"| {port_index} | {outlet_name} | {cost_name} | {samples} | {filled} | {avg_w} | {wh} |") md.append("") md.append("## Kostenstellen") md.append("") - md.append("| device | code | name | samples | filled_samples | avg_W | approx_Wh |") - md.append("|---|---:|---|---:|---:|---:|---:|") - for device, code, name, samples, filled, avg_w, wh in cost_rows: - md.append(f"| {device} | {code} | {name} | {samples} | {filled} | {avg_w} | {wh} |") - md.append("") + if not cost_rows: + md.append("_Keine Kostenstellen aus cost_centers.json im Zeitraum gefunden._") + md.append("") + else: + md.append("| name | outlets | samples | filled | avg_W | approx_Wh |") + md.append("|---|---:|---:|---:|---:|---:|") + for name, outlets_cc, samples, filled, avg_w, wh in cost_rows: + md.append(f"| {name} | {outlets_cc} | {samples} | {filled} | {avg_w} | {wh} |") + md.append("") md.append("## Job-Statistik") md.append("") @@ -625,12 +703,10 @@ def main(): cfg = load_json(args.config) proj_root = project_root_from_config(args.config) - # Projekt-Unterordner sicherstellen safe_mkdir(proj_root, "data") safe_mkdir(proj_root, "logs") safe_mkdir(proj_root, "reports") - # Pfade relativ zur config.json db_path = resolve_path(args.config, cfg["db_path"]) cc_path = resolve_path(args.config, cfg["cost_center_map"]) cc_map = load_json(cc_path) @@ -639,7 +715,7 @@ def main(): db_init(con) # TCP / Timeouts / Verhalten - tcp_cfg = cfg.get("tcp", cfg.get("telnet", {})) # erlaubt "telnet" alt, oder "tcp" neu + tcp_cfg = cfg.get("tcp", cfg.get("telnet", {})) # akzeptiert "tcp" oder (alt) "telnet" read_timeout = int(tcp_cfg.get("read_timeout_sec", 35)) connect_timeout = int(tcp_cfg.get("connect_timeout_sec", 10)) prompt_timeout = int(tcp_cfg.get("prompt_timeout_sec", 8)) @@ -650,14 +726,6 @@ def main(): stop_on_prompt = bool(tcp_cfg.get("stop_on_prompt", True)) debug_dump_raw = bool(tcp_cfg.get("debug_dump_raw", False)) - # Report-Ausgabe - rep_cfg = cfg.get("report", {}) - write_md = bool(rep_cfg.get("write_markdown", True)) - write_html = bool(rep_cfg.get("write_html", True)) - write_pdf = bool(rep_cfg.get("write_pdf", False)) - name_prefix = rep_cfg.get("report_name_prefix", "report") - - # Devices devices: List[DeviceCfg] = [] for d in cfg.get("devices", []): devices.append(DeviceCfg( @@ -665,10 +733,15 @@ def main(): host=d["host"], port=int(d.get("port", 20108)), enabled=bool(d.get("enabled", True)), - username=d.get("username"), - password=d.get("password"), )) + # Report-Ausgabe + rep_cfg = cfg.get("report", {}) + write_md = bool(rep_cfg.get("write_markdown", True)) + write_html = bool(rep_cfg.get("write_html", True)) + write_pdf = bool(rep_cfg.get("write_pdf", False)) + name_prefix = rep_cfg.get("report_name_prefix", "report") + if args.cmd == "poll": any_ran = False for d in devices: @@ -691,7 +764,7 @@ def main(): elif args.cmd == "report": from_iso, to_iso, suffix = parse_period_args(args) - md_text = report(con, from_iso, to_iso, args.device) + md_text = report(con, from_iso, to_iso, args.device, cc_map) print(md_text) diff --git a/reports/report_weekly.html b/reports/report_weekly.html new file mode 100644 index 0000000..a0531a9 --- /dev/null +++ b/reports/report_weekly.html @@ -0,0 +1,47 @@ +
# MMP Report
+
+- Zeitraum (UTC): **2026-02-03T11:09:39+00:00** .. **2026-02-10T11:09:39+00:00**
+
+## Outlets gesamt
+
+- Ports (distinct): **20**
+- Samples: **160** (filled: **0**)
+- Kumuliert (approx): **173.0 Wh**
+
+| port | outlet | cost_name | samples | filled | avg_W | approx_Wh |
+|---:|---|---|---:|---:|---:|---:|
+| 1 | W pc1 | Wolff | 8 | 0 | 7.25 | 58.0 |
+| 2 | P pc2 | Paulisch | 8 | 0 | 14.38 | 115.0 |
+| 3 | W router | Wolff | 8 | 0 | 0.0 | 0.0 |
+| 4 | A pc4 | Austinat | 8 | 0 | 0.0 | 0.0 |
+| 5 | MOD 1 Outlet 5 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 6 | MOD 2 Outlet 1 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 7 | MOD 2 Outlet 2 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 8 | MOD 2 Outlet 3 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 9 | MOD 2 Outlet 4 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 10 | MOD 2 Outlet 5 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 11 | MOD 3 Outlet 1 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 12 | MOD 3 Outlet 2 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 13 | MOD 3 Outlet 3 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 14 | MOD 3 Outlet 4 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 15 | MOD 3 Outlet 5 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 16 | MOD 4 Outlet 1 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 17 | MOD 4 Outlet 2 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 18 | MOD 4 Outlet 3 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 19 | MOD 4 Outlet 4 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+| 20 | MOD 4 Outlet 5 | Unbekannt | 8 | 0 | 0.0 | 0.0 |
+
+## Kostenstellen
+
+| name | outlets | samples | filled | avg_W | approx_Wh |
+|---|---:|---:|---:|---:|---:|
+| Paulisch | 1 | 8 | 0 | 14.38 | 115.0 |
+| Wolff | 2 | 16 | 0 | 3.63 | 58.0 |
+| Austinat | 1 | 8 | 0 | 0.0 | 0.0 |
+
+## Job-Statistik
+
+| device | runs | ok | failed | outlets_total | outlets_filled | fields_filled | avg_ms |
+|---|---:|---:|---:|---:|---:|---:|---:|
+| mmp17-1 | 8 | 8 | 0 | 160 | 0 | 0 | 5260.4 |
+
\ No newline at end of file diff --git a/reports/report_weekly.md b/reports/report_weekly.md new file mode 100644 index 0000000..1f5859b --- /dev/null +++ b/reports/report_weekly.md @@ -0,0 +1,46 @@ +# MMP Report + +- Zeitraum (UTC): **2026-02-03T11:09:39+00:00** .. **2026-02-10T11:09:39+00:00** + +## Outlets gesamt + +- Ports (distinct): **20** +- Samples: **160** (filled: **0**) +- Kumuliert (approx): **173.0 Wh** + +| port | outlet | cost_name | samples | filled | avg_W | approx_Wh | +|---:|---|---|---:|---:|---:|---:| +| 1 | W pc1 | Wolff | 8 | 0 | 7.25 | 58.0 | +| 2 | P pc2 | Paulisch | 8 | 0 | 14.38 | 115.0 | +| 3 | W router | Wolff | 8 | 0 | 0.0 | 0.0 | +| 4 | A pc4 | Austinat | 8 | 0 | 0.0 | 0.0 | +| 5 | MOD 1 Outlet 5 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 6 | MOD 2 Outlet 1 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 7 | MOD 2 Outlet 2 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 8 | MOD 2 Outlet 3 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 9 | MOD 2 Outlet 4 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 10 | MOD 2 Outlet 5 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 11 | MOD 3 Outlet 1 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 12 | MOD 3 Outlet 2 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 13 | MOD 3 Outlet 3 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 14 | MOD 3 Outlet 4 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 15 | MOD 3 Outlet 5 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 16 | MOD 4 Outlet 1 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 17 | MOD 4 Outlet 2 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 18 | MOD 4 Outlet 3 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 19 | MOD 4 Outlet 4 | Unbekannt | 8 | 0 | 0.0 | 0.0 | +| 20 | MOD 4 Outlet 5 | Unbekannt | 8 | 0 | 0.0 | 0.0 | + +## Kostenstellen + +| name | outlets | samples | filled | avg_W | approx_Wh | +|---|---:|---:|---:|---:|---:| +| Paulisch | 1 | 8 | 0 | 14.38 | 115.0 | +| Wolff | 2 | 16 | 0 | 3.63 | 58.0 | +| Austinat | 1 | 8 | 0 | 0.0 | 0.0 | + +## Job-Statistik + +| device | runs | ok | failed | outlets_total | outlets_filled | fields_filled | avg_ms | +|---|---:|---:|---:|---:|---:|---:|---:| +| mmp17-1 | 8 | 8 | 0 | 160 | 0 | 0 | 5260.4 |