From 6a9d27c6cf3da665a220311cae7ae8dfeffacb2e Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Thu, 4 Sep 2025 15:03:11 +0200 Subject: [PATCH] starting to implement realistic Vehicle simulation --- README.md | 28 +- app/gui.py | 439 +++++++++++++---------------- app/simulation/__init__.py | 6 + app/simulation/modules/__init__.py | 6 + app/simulation/modules/abs.py | 11 + app/simulation/modules/basic.py | 141 +++++++++ app/simulation/modules/engine.py | 320 +++++++++++++++++++++ app/simulation/modules/gearbox.py | 34 +++ app/simulation/simulator_main.py | 46 +++ app/simulation/vehicle.py | 122 ++++++++ app/tabs/__init__.py | 12 + app/tabs/basic.py | 192 +++++++++++++ app/tabs/dashboard.py | 77 +++++ app/tabs/dtc.py | 41 +++ app/tabs/engine.py | 176 ++++++++++++ app/tabs/gearbox.py | 66 +++++ main.py | 1 + 17 files changed, 1468 insertions(+), 250 deletions(-) create mode 100644 app/simulation/__init__.py create mode 100644 app/simulation/modules/__init__.py create mode 100644 app/simulation/modules/abs.py create mode 100644 app/simulation/modules/basic.py create mode 100644 app/simulation/modules/engine.py create mode 100644 app/simulation/modules/gearbox.py create mode 100644 app/simulation/simulator_main.py create mode 100644 app/simulation/vehicle.py create mode 100644 app/tabs/__init__.py create mode 100644 app/tabs/basic.py create mode 100644 app/tabs/dashboard.py create mode 100644 app/tabs/dtc.py create mode 100644 app/tabs/engine.py create mode 100644 app/tabs/gearbox.py diff --git a/README.md b/README.md index 9d6a12f..2290e8a 100644 --- a/README.md +++ b/README.md @@ -116,13 +116,27 @@ Alternativ: App mit `sudo ./start.sh` starten. ## Projektstruktur ``` -main.py – Startpunkt -app/ - ├─ gui.py – Tkinter GUI - ├─ can.py – CAN-Responder + Link-Control (pyroute2) - ├─ simulator.py – Physikmodell (Gang + Gas → Geschwindigkeit/RPM) - └─ config.py – Settings + Logging -settings.json – Konfigurationsdatei (wird beim Speichern erzeugt) + + app/ + ├─ gui.py ← main GUI with Simulator tabs + Save/Load + ├─ config.py + ├─ can.py + ├─ obd2.py + ├─ tabs/ + │ ├─ __init__.py + │ ├─ basics.py ← vehicle basics tab + │ ├─ engine.py ← engine tab + │ ├─ gearbox.py ← gearbox tab + │ └─ dtc.py ← DTC toggles tab + └─ simulation/ + ├─ __init__.py + ├─ simulator_main.py ← VehicleSimulator wrapper used by GUI + ├─ vehicle.py ← core state + module orchestration + └─ modules/ + ├─ __init__.py + ├─ engine.py + ├─ gearbox.py + └─ abs_.py ``` ## Bekannte Einschränkungen diff --git a/app/gui.py b/app/gui.py index 2a4a4ab..0b56ce5 100644 --- a/app/gui.py +++ b/app/gui.py @@ -1,31 +1,59 @@ -# gui.py — Tk-App mit Interface-Dropdown, Link Up/Down, Settings-View/Save + CAN-Trace +# Project layout (drop-in) +# +# app/ +# ├─ gui.py ← new main GUI with Simulator tabs + Save/Load +# ├─ config.py (unchanged) +# ├─ can.py (unchanged) +# ├─ obd2.py (unchanged; GUI registers PIDs) +# ├─ tabs/ +# │ ├─ __init__.py +# │ ├─ basic.py ← base/vehicle tab (ignition, mass, type, ABS/TCS) +# │ ├─ engine.py ← engine tab +# │ ├─ gearbox.py ← gearbox tab +# │ └─ dtc.py ← DTC toggles tab +# └─ simulation/ +# ├─ __init__.py +# ├─ simulator_main.py ← VehicleSimulator wrapper used by GUI +# ├─ vehicle.py ← core state + module orchestration +# └─ modules/ +# ├─ __init__.py +# ├─ engine.py +# ├─ gearbox.py +# └─ abs_.py + + +# ============================= +# app/gui.py +# ============================= + from __future__ import annotations import json import threading import time import tkinter as tk -from tkinter import ttk, messagebox -from collections import deque, defaultdict +from tkinter import ttk, messagebox, filedialog +from collections import deque import subprocess -import can # nur für Trace-Reader +import can # for trace -from .config import load_settings, setup_logging, SETTINGS_PATH, APP_ROOT -from .simulator import EcuState, DrivelineModel +from .config import load_settings, setup_logging from .obd2 import ObdResponder, make_speed_response, make_rpm_response from .can import ( list_can_ifaces, link_up, link_down, link_state, link_kind, - have_cap_netadmin, need_caps_message + have_cap_netadmin ) +# Simulator pieces +from .simulation.simulator_main import VehicleSimulator +from .tabs.basic import BasicTab +from .tabs.engine import EngineTab +from .tabs.gearbox import GearboxTab +from .tabs.dtc import DtcTab +from .tabs.dashboard import DashboardTab -# ---------- kleine Trace-Helfer ---------- +# ---------- CAN Trace Collector ---------- class TraceCollector: - """ - Liest mit eigenem BufferedReader vom SocketCAN und sammelt Frames. - - stream_buffer: deque mit (ts, id, dlc, data_bytes) - - aggregate: dict[(id, dir)] -> {count, last_ts, last_data} - """ def __init__(self, channel: str): self.channel = channel self.bus = None @@ -41,7 +69,8 @@ class TraceCollector: def _close(self): try: if self.bus: self.bus.shutdown() - except Exception: pass + except Exception: + pass self.bus = None def start(self): @@ -49,8 +78,10 @@ class TraceCollector: def stop(self): self._run.clear() - try: self._thread.join(timeout=1.0) - except RuntimeError: pass + try: + self._thread.join(timeout=1.0) + except RuntimeError: + pass self._close() def _rx_loop(self): @@ -59,8 +90,7 @@ class TraceCollector: if self.bus is None: if link_state(self.channel) == "UP": try: - self._open() - backoff = 0.5 + self._open(); backoff = 0.5 except Exception: time.sleep(backoff); backoff = min(5.0, backoff*1.7) continue @@ -73,9 +103,7 @@ class TraceCollector: with self.lock: self.stream_buffer.append((ts, msg.arbitration_id, msg.dlc, bytes(msg.data))) except (can.CanOperationError, OSError): - # IF down → ruhig schließen, kein Traceback - self._close() - time.sleep(0.5) + self._close(); time.sleep(0.5) except Exception: time.sleep(0.05) @@ -84,11 +112,15 @@ class TraceCollector: return list(self.stream_buffer) +# ============================= +# GUI Launcher (reworked layout) +# ============================= + def launch_gui(): cfg = load_settings() logger = setup_logging(cfg) - # read config values + # Config can_iface = (cfg.get("can", {}).get("interface")) or "can0" resp_id_raw = (cfg.get("can", {}).get("resp_id")) or "0x7E8" try: @@ -98,80 +130,93 @@ def launch_gui(): timeout_ms = cfg.get("can", {}).get("timeout_ms", 200) bitrate = cfg.get("can", {}).get("baudrate", 500000) - ecu = EcuState(DrivelineModel()) + # Simulator + sim = VehicleSimulator() + + # OBD2 responder responder = ObdResponder(interface=can_iface, resp_id=resp_id, timeout_ms=timeout_ms, logger=logger) + responder.register_pid(0x0D, lambda: make_speed_response(int(round(sim.snapshot()["speed_kmh"])))) + responder.register_pid(0x0C, lambda: make_rpm_response(int(sim.snapshot()["rpm"]))) - # register providers - responder.register_pid(0x0D, lambda: make_speed_response(int(round(ecu.snapshot()[3])))) - responder.register_pid(0x0C, lambda: make_rpm_response(int(ecu.snapshot()[2]))) - - # physics thread + # Physics thread running = True def physics_loop(): + last = time.monotonic() while running: - ecu.update() + now = time.monotonic() + dt = min(0.05, max(0.0, now - last)) + last = now + sim.update(dt) time.sleep(0.02) - t = threading.Thread(target=physics_loop, daemon=True) - t.start() + threading.Thread(target=physics_loop, daemon=True).start() - # Trace-Collector (eigener Bus, hört alles auf can_iface) - tracer = TraceCollector(can_iface) - tracer.start() + tracer = TraceCollector(can_iface); tracer.start() - # --- Tk UI --- - root = tk.Tk() - root.title("OBD-II ECU Simulator – SocketCAN") + # Tk window + root = tk.Tk(); root.title("OBD-II ECU Simulator – SocketCAN") + root.geometry(f"{cfg.get('ui',{}).get('window',{}).get('width',1100)}x{cfg.get('ui',{}).get('window',{}).get('height',720)}") - # window size from cfg - try: - w = int(cfg["ui"]["window"]["width"]); h = int(cfg["ui"]["window"]["height"]) - root.geometry(f"{w}x{h}") - except Exception: - pass - - # fonts/styles family = cfg.get("ui", {}).get("font_family", "TkDefaultFont") size = int(cfg.get("ui", {}).get("font_size", 10)) style = ttk.Style() style.configure("TLabel", font=(family, size)) style.configure("Header.TLabel", font=(family, size+2, "bold")) - style.configure("TButton", font=(family, size)) + style.configure("Small.TLabel", font=(family, max(8, size-1))) - # layout - root.columnconfigure(0, weight=1); root.rowconfigure(0, weight=1) - main = ttk.Frame(root, padding=10); main.grid(row=0, column=0, sticky="nsew") - main.columnconfigure(1, weight=1) + # Menu (Load/Save config) + menubar = tk.Menu(root) + filemenu = tk.Menu(menubar, tearoff=0) - # === Controls: Gear + Throttle === - ttk.Label(main, text="Gang").grid(row=0, column=0, sticky="w") - gear_var = tk.IntVar(value=0) - gear_box = ttk.Combobox(main, textvariable=gear_var, state="readonly", values=[0,1,2,3,4,5,6], width=5) - gear_box.grid(row=0, column=1, sticky="w", padx=(6,12)) - gear_box.bind("<>", lambda _e: ecu.set_gear(gear_var.get())) + def action_load(): + path = filedialog.askopenfilename(filetypes=[("JSON", "*.json"), ("All", "*.*")]) + if not path: return + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + for tab in sim_tabs: tab.load_from_config(data) + sim.load_config(data) + messagebox.showinfo("Simulator", "Konfiguration geladen.") + except Exception as e: + messagebox.showerror("Laden fehlgeschlagen", str(e)) - ttk.Label(main, text="Gas (%)").grid(row=1, column=0, sticky="w") - thr = ttk.Scale(main, from_=0, to=100, orient="horizontal", - command=lambda v: ecu.set_throttle(int(float(v)))) - thr.set(0) - thr.grid(row=1, column=1, sticky="ew", padx=(6,12)) + def action_save(): + cfg_dict = sim.export_config() + for tab in sim_tabs: tab.save_into_config(cfg_dict) + path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON", "*.json"), ("All", "*.*")]) + if not path: return + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(cfg_dict, f, indent=2) + messagebox.showinfo("Simulator", "Konfiguration gespeichert.") + except Exception as e: + messagebox.showerror("Speichern fehlgeschlagen", str(e)) - lbl_speed = ttk.Label(main, text="Speed: 0 km/h", style="Header.TLabel") - lbl_rpm = ttk.Label(main, text="RPM: 0") - lbl_speed.grid(row=2, column=0, columnspan=2, sticky="w", pady=(10,0)) - lbl_rpm.grid(row=3, column=0, columnspan=2, sticky="w") + filemenu.add_command(label="Konfiguration laden…", command=action_load) + filemenu.add_command(label="Konfiguration speichern…", command=action_save) + filemenu.add_separator(); filemenu.add_command(label="Beenden", command=root.destroy) + menubar.add_cascade(label="Datei", menu=filemenu) + root.config(menu=menubar) - # === CAN Panel === - sep = ttk.Separator(main); sep.grid(row=4, column=0, columnspan=2, sticky="ew", pady=(10,10)) + # ===== New Layout ====================================================== + # Grid with two rows: + # Row 0: Left = CAN settings, Right = Simulator tabs + # Row 1: Trace spanning both columns + # ====================================================================== - can_frame = ttk.LabelFrame(main, text="CAN & Settings", padding=10) - can_frame.grid(row=5, column=0, columnspan=2, sticky="nsew") - can_frame.columnconfigure(1, weight=1) + root.columnconfigure(0, weight=1) + root.columnconfigure(1, weight=2) + root.rowconfigure(1, weight=1) # trace grows + + # --- LEFT: CAN Settings ------------------------------------------------ + can_frame = ttk.LabelFrame(root, text="CAN & Settings", padding=8) + can_frame.grid(row=0, column=0, sticky="nsew", padx=(8,4), pady=(8,4)) + for i in range(2): can_frame.columnconfigure(i, weight=1) ttk.Label(can_frame, text="Interface").grid(row=0, column=0, sticky="w") iface_var = tk.StringVar(value=can_iface) iface_list = list_can_ifaces() or [can_iface] iface_dd = ttk.Combobox(can_frame, textvariable=iface_var, values=iface_list, state="readonly", width=12) - iface_dd.grid(row=0, column=1, sticky="w", padx=(6,12)) + iface_dd.grid(row=0, column=1, sticky="ew", padx=(6,0)) def refresh_ifaces(): lst = list_can_ifaces() @@ -179,44 +224,39 @@ def launch_gui(): messagebox.showwarning("Interfaces", "Keine can*/vcan* Interfaces gefunden.") return iface_dd.config(values=lst) - ttk.Button(can_frame, text="Refresh", command=refresh_ifaces).grid(row=0, column=2, padx=4) + ttk.Button(can_frame, text="Refresh", command=refresh_ifaces).grid(row=0, column=2, padx=(6,0)) ttk.Label(can_frame, text="RESP-ID (hex)").grid(row=1, column=0, sticky="w") resp_var = tk.StringVar(value=f"0x{resp_id:03X}") - resp_entry = ttk.Entry(can_frame, textvariable=resp_var, width=10) - resp_entry.grid(row=1, column=1, sticky="w", padx=(6,12)) + ttk.Entry(can_frame, textvariable=resp_var, width=10).grid(row=1, column=1, sticky="w", padx=(6,0)) ttk.Label(can_frame, text="Timeout (ms)").grid(row=2, column=0, sticky="w") to_var = tk.IntVar(value=int(timeout_ms)) - to_spin = ttk.Spinbox(can_frame, from_=10, to=5000, increment=10, textvariable=to_var, width=8) - to_spin.grid(row=2, column=1, sticky="w", padx=(6,12)) + ttk.Spinbox(can_frame, from_=10, to=5000, increment=10, textvariable=to_var, width=8).grid(row=2, column=1, sticky="w", padx=(6,0)) ttk.Label(can_frame, text="Bitrate").grid(row=3, column=0, sticky="w") br_var = tk.IntVar(value=int(bitrate)) - br_spin = ttk.Spinbox(can_frame, from_=20000, to=1000000, increment=10000, textvariable=br_var, width=10) - br_spin.grid(row=3, column=1, sticky="w", padx=(6,12)) + ttk.Spinbox(can_frame, from_=20000, to=1000000, increment=10000, textvariable=br_var, width=10).grid(row=3, column=1, sticky="w", padx=(6,0)) - # unter Bitrate-Spinbox set_params = tk.BooleanVar(value=True) ttk.Checkbutton(can_frame, text="Bitrate beim UP setzen", variable=set_params).grid(row=3, column=2, sticky="w") - # add Kind-Anzeige - kind_label = ttk.Label(can_frame, text=f"Kind: {link_kind(can_iface)}") - kind_label.grid(row=0, column=3, sticky="w", padx=(12,0)) + kind_label = ttk.Label(can_frame, text=f"Kind: {link_kind(can_iface)}", style="Small.TLabel") + kind_label.grid(row=4, column=0, columnspan=3, sticky="w", pady=(4,0)) + + # Buttons row + btns = ttk.Frame(can_frame) + btns.grid(row=5, column=0, columnspan=3, sticky="ew", pady=(8,0)) + btns.columnconfigure(0, weight=0) + btns.columnconfigure(1, weight=0) + btns.columnconfigure(2, weight=1) - # Link control def do_link_up(): try: - # Kind-Anzeige aktualisieren (falls Interface gewechselt) kind_label.config(text=f"Kind: {link_kind(iface_var.get())}") - if link_state(iface_var.get()) == "UP": - messagebox.showinfo("CAN", f"{iface_var.get()} ist bereits UP") - return - # NEU: set_params aus Checkbox + messagebox.showinfo("CAN", f"{iface_var.get()} ist bereits UP"); return link_up(iface_var.get(), bitrate=br_var.get(), fd=False, set_params=set_params.get()) - msg = f"{iface_var.get()} ist UP" - # nach erfolgreichem link_up(...) – in gui.py try: out = subprocess.check_output(["ip", "-details", "-json", "link", "show", iface_var.get()], text=True) info = json.loads(out)[0] @@ -226,89 +266,76 @@ def launch_gui(): messagebox.showinfo("CAN", f"{iface_var.get()} ist UP @ {br} bit/s (sample-point {sp})") except Exception: pass - - if set_params.get(): - msg += f" @ {br_var.get()} bit/s (falls vom Treiber unterstützt)" - else: - msg += " (Bitrate unverändert)" - messagebox.showinfo("CAN", msg) - except PermissionError as e: - messagebox.showerror("Berechtigung", str(e)) except Exception as e: - messagebox.showerror("CAN", f"Link UP fehlgeschlagen:\n{e}") + messagebox.showerror("CAN", f"Link UP fehlgeschlagen:{e}") def do_link_down(): try: if link_state(iface_var.get()) == "DOWN": - messagebox.showinfo("CAN", f"{iface_var.get()} ist bereits DOWN") - return - link_down(iface_var.get()) - messagebox.showinfo("CAN", f"{iface_var.get()} ist DOWN") - except PermissionError as e: - messagebox.showerror("Berechtigung", str(e)) + messagebox.showinfo("CAN", f"{iface_var.get()} ist bereits DOWN"); return + link_down(iface_var.get()); messagebox.showinfo("CAN", f"{iface_var.get()} ist DOWN") except Exception as e: - messagebox.showerror("CAN", f"Link DOWN fehlgeschlagen:\n{e}") + messagebox.showerror("CAN", f"Link DOWN fehlgeschlagen:{e}") - btn_up = ttk.Button(can_frame, text="Link UP", command=do_link_up) - btn_down = ttk.Button(can_frame, text="Link DOWN", command=do_link_down) - btn_up.grid(row=4, column=0, pady=(8,0), sticky="w") - btn_down.grid(row=4, column=1, pady=(8,0), sticky="w") + ttk.Button(btns, text="Link UP", command=do_link_up).grid(row=0, column=0, sticky="w") + ttk.Button(btns, text="Link DOWN", command=do_link_down).grid(row=0, column=1, sticky="w", padx=(6,0)) - # Rebind responder def do_rebind(): nonlocal can_iface, resp_id, timeout_ms, bitrate, tracer can_iface = iface_var.get() try: new_resp = int(resp_var.get(), 16) except Exception: - messagebox.showerror("RESP-ID", "Bitte gültige Hex-Zahl, z.B. 0x7E8") - return - resp_id = new_resp - timeout_ms = to_var.get() - bitrate = br_var.get() + messagebox.showerror("RESP-ID", "Bitte gültige Hex-Zahl, z.B. 0x7E8"); return + resp_id = new_resp; timeout_ms = to_var.get(); bitrate = br_var.get() try: responder.rebind(interface=can_iface, resp_id=resp_id) - # Trace-Collector auf neues IF neu binden - try: - tracer.stop() - except Exception: - pass - tracer = TraceCollector(can_iface) - tracer.start() + tracer.stop(); tracer = TraceCollector(can_iface); tracer.start() messagebox.showinfo("CAN", f"Responder neu gebunden: {can_iface}, RESP 0x{resp_id:03X}") except Exception as e: - messagebox.showerror("CAN", f"Rebind fehlgeschlagen:\n{e}") + messagebox.showerror("CAN", f"Rebind fehlgeschlagen:{e}") - ttk.Button(can_frame, text="Responder Rebind", command=do_rebind).grid(row=4, column=2, pady=(8,0), sticky="w") + ttk.Button(btns, text="Responder Rebind", command=do_rebind).grid(row=0, column=2, sticky="w", padx=(12,0)) - # CAP-Status - caps_ok = have_cap_netadmin() - cap_label = ttk.Label(can_frame, text=f"CAP_NET_ADMIN: {'yes' if caps_ok else 'no'}") - cap_label.grid(row=6, column=0, columnspan=2, sticky="w", pady=(6,0)) - if not caps_ok: - btn_up.state(["disabled"]); btn_down.state(["disabled"]) + ttk.Label(can_frame, text=f"CAP_NET_ADMIN: {'yes' if have_cap_netadmin() else 'no'}", style="Small.TLabel")\ + .grid(row=6, column=0, columnspan=3, sticky="w", pady=(6,0)) - # Statusbar - status = ttk.Label(main, text=f"CAN: {can_iface} | RESP-ID: 0x{resp_id:03X}", relief="sunken", anchor="w") - status.grid(row=6, column=0, columnspan=2, sticky="ew", pady=(10,0)) + # --- RIGHT: Simulator Tabs -------------------------------------------- + right = ttk.Frame(root) + right.grid(row=0, column=1, sticky="nsew", padx=(4,8), pady=(8,4)) + right.columnconfigure(0, weight=1) + right.rowconfigure(0, weight=1) - # === TRACE-FENSTER (unten) === + nb_sim = ttk.Notebook(right) + nb_sim.grid(row=0, column=0, sticky="nsew") + + basics_tab = BasicTab(nb_sim, sim) + engine_tab = EngineTab(nb_sim, sim) + gearbox_tab = GearboxTab(nb_sim, sim) + dtc_tab = DtcTab(nb_sim, sim) + dashboard_tab = DashboardTab(nb_sim, sim) + sim_tabs = [basics_tab, engine_tab, gearbox_tab, dtc_tab, dashboard_tab] + + nb_sim.add(basics_tab.frame, text="Basisdaten") + nb_sim.add(engine_tab.frame, text="Motor") + nb_sim.add(gearbox_tab.frame, text="Getriebe") + nb_sim.add(dtc_tab.frame, text="DTCs") + nb_sim.add(dashboard_tab.frame, text="Dashboard") + + # --- BOTTOM: Trace (spans both columns) ------------------------------- trace_frame = ttk.LabelFrame(root, text="CAN Trace", padding=6) - trace_frame.grid(row=1, column=0, sticky="nsew") - root.rowconfigure(1, weight=1) + trace_frame.grid(row=1, column=0, columnspan=2, sticky="nsew", padx=8, pady=(0,8)) trace_frame.columnconfigure(0, weight=1) trace_frame.rowconfigure(1, weight=1) - # Controls: Mode, Pause, Clear, Autoscroll ctrl = ttk.Frame(trace_frame) ctrl.grid(row=0, column=0, sticky="ew", pady=(0,4)) ctrl.columnconfigure(5, weight=1) - mode_var = tk.StringVar(value="stream") # "stream" | "aggregate" + mode_var = tk.StringVar(value="stream") ttk.Label(ctrl, text="Modus:").grid(row=0, column=0, sticky="w") - mode_dd = ttk.Combobox(ctrl, textvariable=mode_var, state="readonly", width=10, - values=["stream", "aggregate"]) - mode_dd.grid(row=0, column=1, sticky="w", padx=(4,12)) + ttk.Combobox(ctrl, textvariable=mode_var, state="readonly", width=10, values=["stream","aggregate"])\ + .grid(row=0, column=1, sticky="w", padx=(4,12)) paused = tk.BooleanVar(value=False) ttk.Checkbutton(ctrl, text="Pause", variable=paused).grid(row=0, column=2, sticky="w") @@ -316,121 +343,50 @@ def launch_gui(): autoscroll = tk.BooleanVar(value=True) ttk.Checkbutton(ctrl, text="Auto-Scroll", variable=autoscroll).grid(row=0, column=3, sticky="w") - def do_clear(): - nonlocal aggregate_cache - tree.delete(*tree.get_children()) - aggregate_cache.clear() - ttk.Button(ctrl, text="Clear", command=do_clear).grid(row=0, column=4, padx=(8,0), sticky="w") - - # Treeview - cols_stream = ("time", "dir", "id", "dlc", "data") - cols_agg = ("id", "dir", "count", "last_time", "last_dlc", "last_data") - - tree = ttk.Treeview(trace_frame, columns=cols_stream, show="headings", height=10) + tree = ttk.Treeview(trace_frame, columns=("time","dir","id","dlc","data"), show="headings", height=10) tree.grid(row=1, column=0, sticky="nsew") sb_y = ttk.Scrollbar(trace_frame, orient="vertical", command=tree.yview) - tree.configure(yscrollcommand=sb_y.set) - sb_y.grid(row=1, column=1, sticky="ns") - - def setup_columns(mode: str): - tree.delete(*tree.get_children()) - if mode == "stream": - tree.config(columns=cols_stream) - headings = [("time","Time"),("dir","Dir"),("id","ID"),("dlc","DLC"),("data","Data")] - widths = [140, 60, 90, 60, 520] - else: - tree.config(columns=cols_agg) - headings = [("id","ID"),("dir","Dir"),("count","Count"),("last_time","Last Time"),("last_dlc","DLC"),("last_data","Last Data")] - widths = [90, 60, 80, 140, 60, 520] - for (col, text), w in zip(headings, widths): - tree.heading(col, text=text) - tree.column(col, width=w, anchor="w") - setup_columns("stream") - - aggregate_cache: dict[tuple[int,str], dict] = {} + tree.configure(yscrollcommand=sb_y.set); sb_y.grid(row=1, column=1, sticky="ns") def fmt_time(ts: float) -> str: - # hh:mm:ss.mmm lt = time.localtime(ts) return time.strftime("%H:%M:%S", lt) + f".{int((ts%1)*1000):03d}" + def fmt_id(i: int) -> str: return f"0x{i:03X}" + def fmt_data(b: bytes) -> str: return " ".join(f"{x:02X}" for x in b) - def fmt_id(i: int) -> str: - return f"0x{i:03X}" - - def fmt_data(b: bytes) -> str: - return " ".join(f"{x:02X}" for x in b) - - # periodic UI update last_index = 0 def tick(): - nonlocal can_iface, resp_id, last_index - # Top-Status - g, tval, rpm, spd = ecu.snapshot() - caps = "CAP:yes" if have_cap_netadmin() else "CAP:no" - st = link_state(can_iface) - lbl_speed.config(text=f"Speed: {int(round(spd))} km/h") - lbl_rpm.config(text=f"RPM: {rpm}") - st = link_state(can_iface) - kd = link_kind(can_iface) - status.config(text=f"CAN: {can_iface}({st},{kd}) | RESP-ID: 0x{resp_id:03X} | Gear {g} | Throttle {tval}% | {caps}") + nonlocal last_index + snap = sim.snapshot() + # Optional: könnte in eine Statusbar ausgelagert werden + root.title(f"OBD-II ECU Simulator – RPM {int(snap['rpm'])} | {int(round(snap['speed_kmh']))} km/h") - - # Trace if not paused.get(): mode = mode_var.get() + buf = tracer.snapshot_stream() if mode == "stream": - setup_columns("stream") if tree["columns"] != cols_stream else None - # append new items - buf = tracer.snapshot_stream() - # nur neue ab letztem Index for ts, cid, dlc, data in buf[last_index:]: - # Richtung heuristisch - if cid == 0x7DF: - d = "RX" - elif cid == resp_id: - d = "TX" - else: - d = "?" - tree.insert("", "end", - values=(fmt_time(ts), d, fmt_id(cid), dlc, fmt_data(data))) - # autoscroll + d = "RX" if cid == 0x7DF else ("TX" if cid == responder.resp_id else "?") + tree.insert("", "end", values=(fmt_time(ts), d, fmt_id(cid), dlc, fmt_data(data))) if autoscroll.get() and buf[last_index:]: tree.see(tree.get_children()[-1]) - last_index = len(buf) else: - setup_columns("aggregate") if tree["columns"] != cols_agg else None - # baue Aggregat neu (leicht, schnell) - buf = tracer.snapshot_stream() - agg: dict[tuple[int,str], dict] = {} + tree.delete(*tree.get_children()) + agg = {} for ts, cid, dlc, data in buf: - if cid == 0x7DF: - d = "RX" - elif cid == resp_id: - d = "TX" - else: - d = "?" + d = "RX" if cid == 0x7DF else ("TX" if cid == responder.resp_id else "?") key = (cid, d) - entry = agg.get(key) - if entry is None: + e = agg.get(key) + if not e: agg[key] = {"count":1, "last_ts":ts, "last_dlc":dlc, "last_data":data} else: - entry["count"] += 1 - if ts >= entry["last_ts"]: - entry["last_ts"] = ts - entry["last_dlc"] = dlc - entry["last_data"] = data - # nur neu zeichnen, wenn sich was ändert - if agg != aggregate_cache: - tree.delete(*tree.get_children()) - # sortiert nach ID, RX vor TX - for (cid, d) in sorted(agg.keys(), key=lambda k:(k[0], 0 if k[1]=="RX" else 1)): - e = agg[(cid,d)] - tree.insert("", "end", - values=(fmt_id(cid), d, e["count"], - fmt_time(e["last_ts"]), - e["last_dlc"], fmt_data(e["last_data"]))) - aggregate_cache.clear() - aggregate_cache.update(agg) + e["count"] += 1 + if ts >= e["last_ts"]: + e["last_ts"], e["last_dlc"], e["last_data"] = ts, dlc, data + for (cid, d) in sorted(agg.keys(), key=lambda k:(k[0], 0 if k[1]=="RX" else 1)): + e = agg[(cid, d)] + tree.insert("", "end", values=(fmt_id(cid), d, e["count"], fmt_time(e["last_ts"]), e["last_dlc"], fmt_data(e["last_data"])) ) + last_index = len(buf) root.after(50, tick) @@ -439,14 +395,11 @@ def launch_gui(): def on_close(): nonlocal running running = False - try: - tracer.stop() - except Exception: - pass - try: - responder.stop() + try: tracer.stop() + except Exception: pass + try: responder.stop() finally: root.destroy() root.protocol("WM_DELETE_WINDOW", on_close) - root.mainloop() + root.mainloop() \ No newline at end of file diff --git a/app/simulation/__init__.py b/app/simulation/__init__.py new file mode 100644 index 0000000..1398f9d --- /dev/null +++ b/app/simulation/__init__.py @@ -0,0 +1,6 @@ + +# ============================= +# app/simulation/__init__.py +# ============================= + +# empty – package marker \ No newline at end of file diff --git a/app/simulation/modules/__init__.py b/app/simulation/modules/__init__.py new file mode 100644 index 0000000..029ea43 --- /dev/null +++ b/app/simulation/modules/__init__.py @@ -0,0 +1,6 @@ +# ============================= +# app/simulation/modules/__init__.py +# ============================= + + +# empty – package marker \ No newline at end of file diff --git a/app/simulation/modules/abs.py b/app/simulation/modules/abs.py new file mode 100644 index 0000000..3d42092 --- /dev/null +++ b/app/simulation/modules/abs.py @@ -0,0 +1,11 @@ +# app/simulation/modules/abs.py +from __future__ import annotations +from ..vehicle import Vehicle, Module + +class AbsModule(Module): + """Stub: deceleration limiting if ABS enabled (future: needs braking input).""" + def apply(self, v: Vehicle, dt: float) -> None: + _abs = bool(v.config.get("vehicle", {}).get("abs", True)) + if not _abs: + return + # braking model folgt später diff --git a/app/simulation/modules/basic.py b/app/simulation/modules/basic.py new file mode 100644 index 0000000..af9e412 --- /dev/null +++ b/app/simulation/modules/basic.py @@ -0,0 +1,141 @@ +# app/simulation/modules/basic.py +from __future__ import annotations +from ..vehicle import Vehicle, Module +import bisect + +def _ocv_from_soc(soc: float, table: dict[float, float]) -> float: + # table: {SOC: OCV} unsortiert → linear interpolieren + xs = sorted(table.keys()) + ys = [table[x] for x in xs] + s = max(0.0, min(1.0, soc)) + i = bisect.bisect_left(xs, s) + if i <= 0: return ys[0] + if i >= len(xs): return ys[-1] + x0, x1 = xs[i-1], xs[i] + y0, y1 = ys[i-1], ys[i] + t = 0.0 if x1 == x0 else (s - x0) / (x1 - x0) + return y0 + t*(y1 - y0) + +class BasicModule(Module): + """ + - Zündungslogik inkl. START→ON nach crank_time_s + - Ambient-Temperatur als globale Umweltgröße + - Elektrik: + * Load/Source-Aggregation via Vehicle-Helpers + * Lichtmaschine drehzahlabhängig, Regler auf alternator_reg_v + * Batterie: Kapazität (Ah), Innenwiderstand, OCV(SOC); I_batt > 0 => Entladung + """ + def __init__(self): + self.crank_time_s = 2.7 + self._crank_timer = 0.0 + + def apply(self, v: Vehicle, dt: float) -> None: + # ----- Dashboard registration (unverändert) ----- + v.register_metric("ignition", label="Zündung", source="basic", priority=5) + v.register_metric("ambient_c", label="Umgebung", unit="°C", fmt=".1f", source="basic", priority=7) + v.register_metric("battery_voltage", label="Batteriespannung", unit="V", fmt=".2f", source="basic", priority=8) + v.register_metric("elx_voltage", label="ELX-Spannung", unit="V", fmt=".2f", source="basic", priority=10) + v.register_metric("system_voltage", label="Systemspannung", unit="V", fmt=".2f", source="basic", priority=11) + v.register_metric("battery_soc", label="Batterie SOC", unit="", fmt=".2f", source="basic", priority=12) + v.register_metric("battery_current_a", label="Batterie Strom", unit="A", fmt=".2f", source="basic", priority=13) + v.register_metric("alternator_current_a", label="Lima Strom", unit="A", fmt=".2f", source="basic", priority=14) + v.register_metric("elec_load_total_a", label="Verbrauch ges.", unit="A", fmt=".2f", source="basic", priority=15) + + # ----- Read config/state ----- + econf = v.config.get("electrical", {}) + alt_reg_v = float(econf.get("alternator_reg_v", 14.2)) + alt_rated_a = float(econf.get("alternator_rated_a", 20.0)) + alt_cut_in = int(econf.get("alt_cut_in_rpm", 1500)) + alt_full = int(econf.get("alt_full_rpm", 4000)) + + batt_cap_ah = float(econf.get("battery_capacity_ah", 8.0)) + batt_rint = float(econf.get("battery_r_int_ohm", 0.020)) + batt_ocv_tbl= dict(econf.get("battery_ocv_v", {})) or { + 0.0: 11.8, 0.1: 12.0, 0.2: 12.1, 0.3: 12.2, 0.4: 12.3, + 0.5: 12.45, 0.6: 12.55, 0.7: 12.65, 0.8: 12.75, 0.9: 12.85, 1.0: 12.95 + } + + ign = v.ensure("ignition", "ON") + rpm = float(v.ensure("rpm", 1200)) + soc = float(v.ensure("battery_soc", 0.80)) + v.set("ambient_c", float(v.ensure("ambient_c", v.get("ambient_c", 20.0)))) + + # ----- START auto-fall to ON ----- + if ign == "START": + if self._crank_timer <= 0.0: + self._crank_timer = float(self.crank_time_s) + else: + self._crank_timer -= dt + if self._crank_timer <= 0.0: + v.set("ignition", "ON") + ign = "ON" + else: + self._crank_timer = 0.0 + + # ----- Früh-Exit: OFF/ACC -> Bus AUS, Batterie „ruht“ ----- + if ign in ("OFF", "ACC"): + ocv = _ocv_from_soc(soc, batt_ocv_tbl) + # Batterie entspannt sich langsam gegen OCV (optional, super simpel): + # (man kann hier auch gar nichts tun; ich halte batt_v = ocv für okay) + batt_v = ocv + v.set("battery_voltage", round(batt_v, 2)) + v.set("elx_voltage", 0.0) + v.set("system_voltage", 0.0) + v.set("battery_current_a", 0.0) + v.set("alternator_current_a", 0.0) + v.set("elec_load_total_a", 0.0) + v.set("battery_soc", round(soc, 3)) + return + + # ----- ON/START: Elektrik-Bilanz ----- + # Beiträge anderer Module summieren + loads_a, sources_a = v.elec_totals() + # Grundlasten (z.B. ECU, Relais) + base_load = 0.5 if ign == "ON" else 0.6 # START leicht höher + loads_a += base_load + # Quellen anderer Module (z.B. DC-DC) können sources_a > 0 machen + # Wir ziehen Quellen von der Last ab – was übrig bleibt, muss Lima/Batterie liefern + net_load_a = max(0.0, loads_a - sources_a) + + # Lima-Fähigkeit aus rpm + if rpm >= alt_cut_in: + frac = 0.0 if rpm <= alt_cut_in else (rpm - alt_cut_in) / max(1, (alt_full - alt_cut_in)) + frac = max(0.0, min(1.0, frac)) + alt_cap_a = alt_rated_a * frac + else: + alt_cap_a = 0.0 + + # Batterie-OCV + ocv = _ocv_from_soc(soc, batt_ocv_tbl) + + # Ziel: Regler hält alt_reg_v – aber nur, wenn die Lima überhaupt aktiv ist + desired_charge_a = max(0.0, (alt_reg_v - ocv) / max(1e-4, batt_rint)) if alt_cap_a > 0.0 else 0.0 + alt_needed_a = net_load_a + desired_charge_a + alt_i = min(alt_needed_a, alt_cap_a) + + # Batterie-Bilanz + if alt_cap_a > 0.0 and alt_i >= net_load_a: + # Lima deckt alles; Überschuss lädt Batterie + batt_i = -(alt_i - net_load_a) # negativ = lädt + bus_v = alt_reg_v + else: + # Lima (falls vorhanden) reicht nicht -> Batterie liefert Defizit + deficit = net_load_a - alt_i + batt_i = max(0.0, deficit) # positiv = entlädt + bus_v = ocv - batt_i * batt_rint + + # SOC-Update (Ah-Bilanz) + soc = max(0.0, min(1.0, soc - (batt_i * dt) / (3600.0 * max(0.1, batt_cap_ah)))) + batt_v = ocv - (batt_i * batt_rint) + + # Klammern/Spiegeln + batt_v = max(10.0, min(15.5, batt_v)) + bus_v = max(0.0, min(15.5, bus_v)) + v.set("battery_voltage", round(batt_v, 2)) + v.set("elx_voltage", round(bus_v, 2)) + v.set("system_voltage", round(bus_v, 2)) + v.set("battery_soc", round(soc, 3)) + v.set("battery_current_a", round(batt_i, 2)) + v.set("alternator_current_a", round(min(alt_i, alt_cap_a), 2)) + v.set("elec_load_total_a", round(net_load_a, 2)) + diff --git a/app/simulation/modules/engine.py b/app/simulation/modules/engine.py new file mode 100644 index 0000000..4359dbf --- /dev/null +++ b/app/simulation/modules/engine.py @@ -0,0 +1,320 @@ +# ============================= +# app/simulation/modules/engine.py +# ============================= + +from __future__ import annotations +from ..vehicle import Vehicle, Module +import random, math + +# Ein einziger Wahrheitsanker für alle Defaults: +ENGINE_DEFAULTS = { + # Basis + "idle_rpm": 1200, + "max_rpm": 9000, + "rpm_rise_per_s": 4000, + "rpm_fall_per_s": 3000, + "throttle_curve": "linear", + # Starter + "starter_rpm_nominal": 250.0, + "starter_voltage_min": 10.5, + "start_rpm_threshold": 250.0, # <- fix niedriger, damit anspringt + "stall_rpm": 500.0, + # Thermik + "coolant_ambient_c": 20.0, + "coolant_warm_rate_c_per_s": 0.35, + "coolant_cool_rate_c_per_s": 0.06, + "oil_warm_rate_c_per_s": 0.30, + "oil_cool_rate_c_per_s": 0.05, + "idle_cold_gain_per_deg": 3.0, + "idle_cold_gain_max": 500.0, + # Öl + "oil_pressure_idle_bar": 1.2, + "oil_pressure_slope_bar_per_krpm": 0.8, + "oil_pressure_off_floor_bar": 0.2, + # Leistung + "engine_power_kw": 60.0, + "torque_peak_rpm": 7000.0, + # DBW + "throttle_plate_idle_min_pct": 6.0, + "throttle_plate_overrun_pct": 2.0, + "throttle_plate_tau_s": 0.08, + "torque_ctrl_kp": 1.2, + "torque_ctrl_ki": 0.6, + # Jitter + "rpm_jitter_idle_amp_rpm": 12.0, + "rpm_jitter_high_amp_rpm": 4.0, + "rpm_jitter_tau_s": 0.20, + "rpm_jitter_off_threshold_rpm": 250.0, + # UI-Startwert (nur Anzeige) + "throttle_pedal_pct": 0.0, +} + +class EngineModule(Module): + """ + Erweiterte Motormodellierung mit realistischem Jitter & Drive-by-Wire: + - OFF/ACC/ON/START Logik, Starten/Abwürgen + - Thermik (Kühlmittel/Öl), Öldruck ~ f(RPM) + - Startverhalten abhängig von Spannung & Öltemp + - Leistungsmodell via engine_power_kw + torque_peak_rpm + - Fahrerwunsch: throttle_pedal_pct (0..100) → Ziel-Leistungsanteil + * Drosselklappe (throttle_plate_pct) wird per PI-Regler geführt + * Mindestöffnung im Leerlauf, fast zu im Schubbetrieb + - Realistischer RPM-Jitter: + * bandbegrenztes Rauschen (1. Ordnung) mit Amplitude ~ f(RPM) + * kein Jitter unter einer Schwell-RPM oder wenn Motor aus + Outputs: + rpm, coolant_temp, oil_temp, oil_pressure + engine_available_torque_nm, engine_net_torque_nm + throttle_plate_pct (neu), throttle_pedal_pct (durchgereicht) + """ + + def __init__(self): + self._target = None + self._running = False + self._oil_p_tau = 0.25 # s, Annäherung Öldruck + + # Drive-by-Wire interner Zustand + self._plate_pct = 5.0 # Startwert, leicht geöffnet + self._tc_i = 0.0 # Integrator PI-Regler + + # bandbegrenztes RPM-Rauschen (AR(1)) + self._rpm_noise = 0.0 + + def _curve(self, t: float, mode: str) -> float: + if mode == "progressive": return t**1.5 + if mode == "aggressive": return t**0.7 + return t + + def _torque_at_rpm(self, power_kw: float, rpm: float, peak_rpm: float) -> float: + rpm = max(0.0, rpm) + t_max = (9550.0 * max(0.0, power_kw)) / max(500.0, peak_rpm) + # einfache „Glocke“ + x = min(math.pi, max(0.0, (rpm / max(1.0, peak_rpm)) * (math.pi/2))) + shape = math.sin(x) + return max(0.0, t_max * shape) + + def _plate_airflow_factor(self, plate_pct: float) -> float: + """ + Näherung Volumenstrom ~ sin^2(θ) mit θ aus 0..90° (hier 0..100%). + 0% ≈ geschlossen (fast null), 100% ≈ voll offen (~1.0). + """ + theta = max(0.0, min(90.0, (plate_pct/100.0)*90.0)) * math.pi/180.0 + return math.sin(theta)**2 + + def apply(self, v: Vehicle, dt: float) -> None: + e = v.config.setdefault("engine", {}) + + # --- Config / Defaults --- + idle = int(e.get("idle_rpm", ENGINE_DEFAULTS["idle_rpm"])) + maxr = int(e.get("max_rpm", ENGINE_DEFAULTS["max_rpm"])) + rise = int(e.get("rpm_rise_per_s", ENGINE_DEFAULTS["rpm_rise_per_s"])) + fall = int(e.get("rpm_fall_per_s", ENGINE_DEFAULTS["rpm_fall_per_s"])) + thr_curve = e.get("throttle_curve", ENGINE_DEFAULTS["throttle_curve"]) + + ambient = float(e.get("coolant_ambient_c", ENGINE_DEFAULTS["coolant_ambient_c"])) + warm_c = float(e.get("coolant_warm_rate_c_per_s", ENGINE_DEFAULTS["coolant_warm_rate_c_per_s"])) + cool_c = float(e.get("coolant_cool_rate_c_per_s", ENGINE_DEFAULTS["coolant_cool_rate_c_per_s"])) + warm_o = float(e.get("oil_warm_rate_c_per_s", ENGINE_DEFAULTS["oil_warm_rate_c_per_s"])) + cool_o = float(e.get("oil_cool_rate_c_per_s", ENGINE_DEFAULTS["oil_cool_rate_c_per_s"])) + + starter_nom = float(e.get("starter_rpm_nominal", ENGINE_DEFAULTS["starter_rpm_nominal"])) + starter_vmin= float(e.get("starter_voltage_min", ENGINE_DEFAULTS["starter_voltage_min"])) + start_rpm_th= float(e.get("start_rpm_threshold", ENGINE_DEFAULTS["start_rpm_threshold"])) + stall_rpm = float(e.get("stall_rpm", ENGINE_DEFAULTS["stall_rpm"])) + + power_kw = float(e.get("engine_power_kw", ENGINE_DEFAULTS["engine_power_kw"])) + peak_torque_rpm = float(e.get("torque_peak_rpm", ENGINE_DEFAULTS["torque_peak_rpm"])) + + cold_gain_per_deg = float(e.get("idle_cold_gain_per_deg", ENGINE_DEFAULTS["idle_cold_gain_per_deg"])) + cold_gain_max = float(e.get("idle_cold_gain_max", ENGINE_DEFAULTS["idle_cold_gain_max"])) + + oil_idle_bar = float(e.get("oil_pressure_idle_bar", ENGINE_DEFAULTS["oil_pressure_idle_bar"])) + oil_slope_bar_per_krpm = float(e.get("oil_pressure_slope_bar_per_krpm", ENGINE_DEFAULTS["oil_pressure_slope_bar_per_krpm"])) + oil_floor_off = float(e.get("oil_pressure_off_floor_bar", ENGINE_DEFAULTS["oil_pressure_off_floor_bar"])) + + plate_idle_min = float(e.get("throttle_plate_idle_min_pct", ENGINE_DEFAULTS["throttle_plate_idle_min_pct"])) + plate_overrun = float(e.get("throttle_plate_overrun_pct", ENGINE_DEFAULTS["throttle_plate_overrun_pct"])) + plate_tau = float(e.get("throttle_plate_tau_s", ENGINE_DEFAULTS["throttle_plate_tau_s"])) + torque_kp = float(e.get("torque_ctrl_kp", ENGINE_DEFAULTS["torque_ctrl_kp"])) + torque_ki = float(e.get("torque_ctrl_ki", ENGINE_DEFAULTS["torque_ctrl_ki"])) + + jitter_idle_amp= float(e.get("rpm_jitter_idle_amp_rpm", ENGINE_DEFAULTS["rpm_jitter_idle_amp_rpm"])) + jitter_hi_amp = float(e.get("rpm_jitter_high_amp_rpm", ENGINE_DEFAULTS["rpm_jitter_high_amp_rpm"])) + jitter_tau = float(e.get("rpm_jitter_tau_s", ENGINE_DEFAULTS["rpm_jitter_tau_s"])) + jitter_off_rpm = float(e.get("rpm_jitter_off_threshold_rpm", ENGINE_DEFAULTS["rpm_jitter_off_threshold_rpm"])) + + # --- State --- + rpm = float(v.ensure("rpm", 0)) + # Fahrerwunsch (kommt aus dem UI-Schieber) + pedal = float(v.ensure("throttle_pedal_pct", float(e.get("throttle_pedal_pct", 0.0)))) + pedal = max(0.0, min(100.0, pedal)) + + load = float(v.ensure("engine_load", 0.0)) + ign = str(v.ensure("ignition", "OFF")) + elx_v = float(v.ensure("elx_voltage", 0.0)) + + cool = float(v.ensure("coolant_temp", ambient)) + oil = float(v.ensure("oil_temp", ambient)) + oil_p = float(v.ensure("oil_pressure", 0.0)) + + ext_torque = float(v.ensure("engine_ext_torque_nm", 0.0)) + + # Dashboard-Metriken + v.register_metric("rpm", label="Drehzahl", unit="RPM", source="engine", priority=20) + v.register_metric("coolant_temp", label="Kühlmitteltemp", unit="°C", fmt=".1f", source="engine", priority=40) + v.register_metric("oil_temp", label="Öltemp", unit="°C", fmt=".1f", source="engine", priority=41) + v.register_metric("oil_pressure", label="Öldruck", unit="bar", fmt=".2f", source="engine", priority=42) + v.register_metric("engine_available_torque_nm", label="Verfügbares Motormoment", unit="Nm", fmt=".0f", source="engine", priority=43) + v.register_metric("engine_net_torque_nm", label="Netto Motormoment", unit="Nm", fmt=".0f", source="engine", priority=44) + v.register_metric("throttle_pedal_pct", label="Gaspedal", unit="%", fmt=".0f", source="engine", priority=45) + v.register_metric("throttle_plate_pct", label="Drosselklappe", unit="%", fmt=".0f", source="engine", priority=46) + + # Hilfsfunktionen + def visco(temp_c: float) -> float: + # -10°C -> 0.6, 20°C -> 0.8, 90°C -> 1.0 + if temp_c <= -10: return 0.6 + if temp_c >= 90: return 1.0 + return 0.6 + (temp_c + 10.0) * 0.004 + + # Spannungsfaktor: unter vmin kein Crank, bei 12.6V ~1.0 + vfac = 0.0 if elx_v <= starter_vmin else min(1.2, (elx_v - starter_vmin) / max(0.3, (12.6 - starter_vmin))) + crank_rpm = starter_nom * vfac * visco(oil) + + # effektive Start-Schwelle: nie unter Stall+50 und nicht „unplausibel“ hoch + start_rpm_th_eff = max(stall_rpm + 50.0, min(start_rpm_th, 0.35 * idle)) + + # --- Ziel-RPM bestimmen (ohne Jitter) --- + if ign in ("OFF", "ACC"): + self._running = False + target_rpm = 0.0 + elif ign == "START": + # deterministisches Cranken + target_rpm = crank_rpm + # zünde/greife, sobald die effektive Schwelle erreicht ist + if not self._running and target_rpm >= start_rpm_th_eff and elx_v > starter_vmin: + self._running = True + + else: # ON + # „Catch on ON“: wenn beim Umschalten genug Restdrehzahl da ist, gilt er als angesprungen + if not self._running and rpm >= max(stall_rpm + 50.0, 0.20 * idle): + self._running = True + + if self._running: + cold_add = max(0.0, min(cold_gain_max, (90.0 - cool) * cold_gain_per_deg)) + idle_eff = idle + cold_add + # Pedal/PI-Logik bleibt wie gehabt, target_rpm wird weiter unten aus net_torque bestimmt + target_rpm = max(idle_eff, min(maxr, rpm)) + else: + target_rpm = 0.0 + + # --- verfügbare Motorleistung / Moment (ohne Last) --- + base_torque = self._torque_at_rpm(power_kw, max(1.0, rpm), peak_torque_rpm) + temp_derate = max(0.7, 1.0 - max(0.0, (oil - 110.0)) * 0.005) + + # Drive-by-Wire / PI auf Drehmomentanteil ----------------------------------- + # Fahrerwunsch in "Leistungsanteil" (0..1) transformieren (Kennlinie) + demand = self._curve(pedal/100.0, thr_curve) # 0..1 + # Overrun-Logik: bei sehr geringem Wunsch → nahezu zu (aber nie ganz) + plate_target_min = plate_overrun if demand < 0.02 else plate_idle_min + + # Regler-Soll: gewünschter Torque-Anteil relativ zum maximal möglichen bei aktueller Drehzahl + # Wir approximieren: torque_avail = base_torque * airflow * temp_derate + airflow = self._plate_airflow_factor(self._plate_pct) + torque_avail = base_torque * airflow * temp_derate + torque_frac = 0.0 if base_torque <= 1e-6 else (torque_avail / (base_torque * temp_derate)) # ~airflow + err = max(0.0, demand) - max(0.0, min(1.0, torque_frac)) + + # PI: Integrator nur wenn Motor an + if ign == "ON" and self._running: + self._tc_i += err * torque_ki * dt + else: + self._tc_i *= 0.95 # langsam abbauen + + plate_cmd = self._plate_pct + (torque_kp * err + self._tc_i) * 100.0 # in %-Punkte + plate_cmd = max(plate_target_min, min(100.0, plate_cmd)) + + # Aktuator-Trägheit (1. Ordnung) + if plate_tau <= 1e-3: + self._plate_pct = plate_cmd + else: + a = min(1.0, dt / plate_tau) + self._plate_pct = (1.0 - a) * self._plate_pct + a * plate_cmd + + # Update airflow nach Stellgröße + airflow = self._plate_airflow_factor(self._plate_pct) + avail_torque = base_torque * airflow * temp_derate + net_torque = max(0.0, avail_torque - max(0.0, ext_torque)) + + # --- Ziel-RPM aus Netto-Moment (sehr simple Dynamik) ----------------------- + # Näherung: mehr Netto-Moment → RPM-Ziel steigt innerhalb der Bandbreite + # Wir skalieren zwischen (idle_eff) und maxr + if ign == "ON" and self._running: + cold_add = max(0.0, min(cold_gain_max, (90.0 - cool) * cold_gain_per_deg)) + idle_eff = idle + cold_add + torque_norm = 0.0 if base_torque <= 1e-6 else max(0.0, min(1.0, net_torque / (base_torque * temp_derate + 1e-6))) + target_rpm = idle_eff + torque_norm * (maxr - idle_eff) + + # --- RPM an Ziel annähern (mechanische Trägheit) -------------------------- + if rpm < target_rpm: + rpm = min(target_rpm, rpm + rise * dt) + else: + rpm = max(target_rpm, rpm - fall * dt) + + # Stall: in ON, wenn laufend und RPM < stall ohne Starter → aus + if ign == "ON" and self._running and rpm < stall_rpm: + self._running = False + + # --- Temperaturen ---------------------------------------------------------- + heat = (rpm/maxr)*0.8 + load*0.6 + if (ign in ("ON","START")) and (self._running or target_rpm > 0): + cool += warm_c * heat * dt + oil += warm_o * heat * dt + else: + cool += (ambient - cool) * min(1.0, dt * cool_c) + oil += (ambient - oil) * min(1.0, dt * cool_o) + + # --- Öldruck --------------------------------------------------------------- + if self._running and rpm > 0: + over_krpm = max(0.0, (rpm - idle)/1000.0) + oil_target = oil_idle_bar + oil_slope_bar_per_krpm * over_krpm + elif ign == "START" and target_rpm > 0: + oil_target = max(oil_floor_off, 0.4) + else: + oil_target = oil_floor_off + a = min(1.0, dt / max(0.05, self._oil_p_tau)) + oil_p = (1-a) * oil_p + a * oil_target + + # --- Realistischer RPM-Jitter --------------------------------------------- + # bandbegrenztes Rauschen: x[n] = (1 - b)*x[n-1] + b*eta, b ~ dt/tau + if self._running and rpm >= jitter_off_rpm and ign == "ON": + b = min(1.0, dt / max(1e-3, jitter_tau)) + eta = random.uniform(-1.0, 1.0) # weißes Rauschen + self._rpm_noise = (1.0 - b) * self._rpm_noise + b * eta + + # Amplitude linear zwischen idle_amp und hi_amp + # bezogen auf aktuelles Drehzahlniveau (klein aber sichtbar) + amp_idle = jitter_idle_amp + amp_hi = jitter_hi_amp + # Interpolation über 0..maxr + k = max(0.0, min(1.0, rpm / max(1.0, maxr))) + amp = (1.0 - k)*amp_idle + k*amp_hi + + rpm += self._rpm_noise * amp + else: + # Kein Jitter: Noise langsam abklingen + self._rpm_noise *= 0.9 + + # --- Klammern & Setzen ----------------------------------------------------- + rpm = max(0.0, min(rpm, maxr)) + cool = max(-40.0, min(cool, 120.0)) + oil = max(-40.0, min(oil, 150.0)) + oil_p = max(oil_floor_off if not self._running else oil_floor_off, min(8.0, oil_p)) + + v.set("rpm", int(rpm)) + v.set("coolant_temp", round(cool, 1)) + v.set("oil_temp", round(oil, 1)) + v.set("oil_pressure", round(oil_p, 2)) + v.set("engine_available_torque_nm", float(avail_torque)) + v.set("engine_net_torque_nm", float(net_torque)) + v.set("throttle_pedal_pct", float(pedal)) + v.set("throttle_plate_pct", float(self._plate_pct)) diff --git a/app/simulation/modules/gearbox.py b/app/simulation/modules/gearbox.py new file mode 100644 index 0000000..1e05141 --- /dev/null +++ b/app/simulation/modules/gearbox.py @@ -0,0 +1,34 @@ +# app/simulation/modules/gearbox.py +from __future__ import annotations +from ..vehicle import Vehicle, Module + +class GearboxModule(Module): + """Koppelt Engine-RPM ↔ Wheel-Speed; registriert speed_kmh/gear fürs Dashboard.""" + def __init__(self): + self.speed_tau = 0.3 + self.rpm_couple = 0.2 + + def apply(self, v: Vehicle, dt: float) -> None: + # Dashboard registration + v.register_metric("speed_kmh", label="Geschwindigkeit", unit="km/h", fmt=".1f", source="gearbox", priority=30) + v.register_metric("gear", label="Gang", source="gearbox", priority=25) + + g = int(v.ensure("gear", 0)) + rpm = float(v.ensure("rpm", 1200)) + speed = float(v.ensure("speed_kmh", 0.0)) + ratios = v.config.get("gearbox", {}).get("kmh_per_krpm", [0.0]) + + if g <= 0 or g >= len(ratios): + speed = max(0.0, speed - 6.0*dt) + v.set("speed_kmh", speed) + return + + kmh_per_krpm = float(ratios[g]) + target_speed = (rpm/1000.0) * kmh_per_krpm + alpha = min(1.0, dt / max(0.05, self.speed_tau)) + speed = (1-alpha) * speed + alpha * target_speed + v.set("speed_kmh", speed) + + wheel_rpm = (speed / max(0.1, kmh_per_krpm)) * 1000.0 + rpm = (1-self.rpm_couple) * rpm + self.rpm_couple * wheel_rpm + v.set("rpm", int(rpm)) diff --git a/app/simulation/simulator_main.py b/app/simulation/simulator_main.py new file mode 100644 index 0000000..4dffaf8 --- /dev/null +++ b/app/simulation/simulator_main.py @@ -0,0 +1,46 @@ +# app/simulation/simulator_main.py +from __future__ import annotations +from typing import Dict, Any +from .vehicle import Vehicle, Orchestrator +from .modules.engine import EngineModule +from .modules.gearbox import GearboxModule +from .modules.abs import AbsModule +from .modules.basic import BasicModule + +class VehicleSimulator: + def __init__(self): + self.v = Vehicle() + self.orch = Orchestrator(self.v) + # order matters: base → engine → gearbox → abs + self.orch.add(BasicModule()) + self.orch.add(EngineModule()) + self.orch.add(GearboxModule()) + self.orch.add(AbsModule()) + + # control from GUI + def set_gear(self, g: int) -> None: + self.v.set("gear", max(0, min(10, int(g)))) + def set_throttle(self, t: int) -> None: + self.v.set("throttle_pct", max(0, min(100, int(t)))) + + def update(self, dt: float) -> None: + self.orch.update(dt) + + def snapshot(self) -> Dict[str, Any]: + return self.v.snapshot() + + # config I/O (compat with old layout) + def load_config(self, cfg: Dict[str, Any]) -> None: + for k in ("engine","gearbox","vehicle"): + if k in cfg: + self.v.config.setdefault(k, {}).update(cfg[k]) + if "dtc" in cfg: + self.v.dtc.update(cfg["dtc"]) + + def export_config(self) -> Dict[str, Any]: + return { + "engine": dict(self.v.config.get("engine", {})), + "gearbox": dict(self.v.config.get("gearbox", {})), + "vehicle": dict(self.v.config.get("vehicle", {})), + "dtc": dict(self.v.dtc), + } diff --git a/app/simulation/vehicle.py b/app/simulation/vehicle.py new file mode 100644 index 0000000..1d0529a --- /dev/null +++ b/app/simulation/vehicle.py @@ -0,0 +1,122 @@ +# app/simulation/vehicle.py +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Dict, Any, List + +@dataclass +class Vehicle: + """Dynamic property-bag vehicle.""" + state: Dict[str, Any] = field(default_factory=lambda: { + "rpm": 1400, + "speed_kmh": 0.0, + "gear": 0, + "throttle_pct": 0, + "ignition": "OFF", + # elektrische Live-Werte + "battery_voltage": 12.6, # Batterie-Klemmenspannung + "elx_voltage": 0.0, # Bordnetz/Bus-Spannung + "system_voltage": 12.4, # alias + "battery_soc": 0.80, # 0..1 + "battery_current_a": 0.0, # + entlädt, – lädt + "alternator_current_a": 0.0, # von Lima geliefert + "elec_load_total_a": 0.0, # Summe aller Verbraucher + "ambient_c": 20.0, + }) + + config: Dict[str, Any] = field(default_factory=lambda: { + "vehicle": { + "type": "motorcycle", + "mass_kg": 210.0, + "abs": True, + "tcs": False, + }, + # Elektrik-Parameter (global) + "electrical": { + "battery_capacity_ah": 8.0, + "battery_r_int_ohm": 0.020, # ~20 mΩ + # sehr einfache OCV(SOC)-Kennlinie + "battery_ocv_v": { # bei ~20°C + 0.0: 11.8, 0.1: 12.0, 0.2: 12.1, 0.3: 12.2, 0.4: 12.3, + 0.5: 12.45, 0.6: 12.55, 0.7: 12.65, 0.8: 12.75, 0.9: 12.85, + 1.0: 12.95 + }, + "alternator_reg_v": 14.2, + "alternator_rated_a": 20.0, # Nennstrom + "alt_cut_in_rpm": 1500, # ab hier fängt sie an zu liefern + "alt_full_rpm": 4000, # ab hier volle Kapazität + }, + }) + + dtc: Dict[str, bool] = field(default_factory=dict) + dashboard_specs: Dict[str, Dict[str, Any]] = field(default_factory=dict) + + # accumulator für dieses Sim-Frame + _elec_loads_a: Dict[str, float] = field(default_factory=dict) + _elec_sources_a: Dict[str, float] = field(default_factory=dict) + + # ---- helpers for modules ---- + def get(self, key: str, default: Any = None) -> Any: + return self.state.get(key, default) + + def set(self, key: str, value: Any) -> None: + self.state[key] = value + + def ensure(self, key: str, default: Any) -> Any: + return self.state.setdefault(key, default) + + # Dashboard registry (wie gehabt) + def register_metric(self, key: str, *, label: str | None = None, unit: str | None = None, + fmt: str | None = None, source: str | None = None, + priority: int = 100, overwrite: bool = False) -> None: + spec = self.dashboard_specs.get(key) + if spec and not overwrite: + if label and not spec.get("label"): spec["label"] = label + if unit and not spec.get("unit"): spec["unit"] = unit + if fmt and not spec.get("fmt"): spec["fmt"] = fmt + if source and not spec.get("source"): spec["source"] = source + if spec.get("priority") is None: spec["priority"] = priority + return + self.dashboard_specs[key] = { + "key": key, "label": label or key, "unit": unit, "fmt": fmt, + "source": source, "priority": priority, + } + + def dashboard_snapshot(self) -> Dict[str, Any]: + return {"specs": dict(self.dashboard_specs), "values": dict(self.state)} + + def snapshot(self) -> Dict[str, Any]: + return dict(self.state) + + # ---- Electrical frame helpers ---- + def elec_reset_frame(self) -> None: + self._elec_loads_a.clear() + self._elec_sources_a.clear() + + def elec_add_load(self, name: str, amps: float) -> None: + # positive Werte = Stromaufnahme + self._elec_loads_a[name] = max(0.0, float(amps)) + + def elec_add_source(self, name: str, amps: float) -> None: + # positive Werte = Einspeisung + self._elec_sources_a[name] = max(0.0, float(amps)) + + def elec_totals(self) -> tuple[float, float]: + return sum(self._elec_loads_a.values()), sum(self._elec_sources_a.values()) + +class Module: + def apply(self, v: Vehicle, dt: float) -> None: + pass + +class Orchestrator: + def __init__(self, vehicle: Vehicle): + self.vehicle = vehicle + self.modules: List[Module] = [] + + def add(self, m: Module): + self.modules.append(m) + + def update(self, dt: float): + # Pro Frame die Electrical-Recorder nullen + self.vehicle.elec_reset_frame() + for m in self.modules: + m.apply(self.vehicle, dt) diff --git a/app/tabs/__init__.py b/app/tabs/__init__.py new file mode 100644 index 0000000..38c4640 --- /dev/null +++ b/app/tabs/__init__.py @@ -0,0 +1,12 @@ +# ============================= +# app/tabs/__init__.py +# ============================= + +from __future__ import annotations +from dataclasses import dataclass +from typing import Protocol, Dict, Any + +class SimTab(Protocol): + frame: any + def save_into_config(self, out: Dict[str, Any]) -> None: ... + def load_from_config(self, cfg: Dict[str, Any]) -> None: ... diff --git a/app/tabs/basic.py b/app/tabs/basic.py new file mode 100644 index 0000000..6e01d4d --- /dev/null +++ b/app/tabs/basic.py @@ -0,0 +1,192 @@ +# app/tabs/basic.py +from __future__ import annotations +import tkinter as tk +from tkinter import ttk +from typing import Dict, Any + +class BasicTab: + """Basis-Fahrzeug-Tab (Zündung & Elektrik).""" + + def __init__(self, parent, sim): + self.sim = sim + self.frame = ttk.Frame(parent, padding=8) + self.frame.columnconfigure(1, weight=1) + + row = 0 + # Vehicle basics ----------------------------------------------------------- + ttk.Label(self.frame, text="Fahrzeugtyp").grid(row=row, column=0, sticky="w"); row+=1 + self.type_var = tk.StringVar(value=self.sim.v.config.get("vehicle", {}).get("type", "motorcycle")) + ttk.Combobox(self.frame, textvariable=self.type_var, state="readonly", + values=["motorcycle", "car", "truck"], width=16)\ + .grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Gewicht [kg]").grid(row=row, column=0, sticky="w"); row+=1 + self.mass_var = tk.DoubleVar(value=float(self.sim.v.config.get("vehicle", {}).get("mass_kg", 210.0))) + ttk.Entry(self.frame, textvariable=self.mass_var, width=10).grid(row=row-1, column=1, sticky="w") + + self.abs_var = tk.BooleanVar(value=bool(self.sim.v.config.get("vehicle", {}).get("abs", True))) + ttk.Checkbutton(self.frame, text="ABS vorhanden", variable=self.abs_var)\ + .grid(row=row, column=0, columnspan=2, sticky="w"); row+=1 + + self.tcs_var = tk.BooleanVar(value=bool(self.sim.v.config.get("vehicle", {}).get("tcs", False))) + ttk.Checkbutton(self.frame, text="ASR/Traktionskontrolle", variable=self.tcs_var)\ + .grid(row=row, column=0, columnspan=2, sticky="w"); row+=1 + + ttk.Separator(self.frame).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(6,6)); row+=1 + + # Ambient ----------------------------------------------------------------- + ttk.Label(self.frame, text="Umgebung [°C]").grid(row=row, column=0, sticky="w"); row+=1 + self.ambient_var = tk.DoubleVar(value=float(self.sim.snapshot().get("ambient_c", 20.0))) + ttk.Entry(self.frame, textvariable=self.ambient_var, width=10)\ + .grid(row=row-1, column=1, sticky="w") + + # Ignition ---------------------------------------------------------------- + ttk.Label(self.frame, text="Zündung").grid(row=row, column=0, sticky="w"); row+=1 + self.ign_var = tk.StringVar(value=str(self.sim.snapshot().get("ignition", "ON"))) + ign_frame = ttk.Frame(self.frame); ign_frame.grid(row=row-1, column=1, sticky="w") + for i, state in enumerate(["OFF", "ACC", "ON", "START"]): + ttk.Radiobutton(ign_frame, text=state, value=state, + variable=self.ign_var, command=self._apply_ign)\ + .grid(row=0, column=i, padx=(0,6)) + + # Live Electrical ---------------------------------------------------------- + ttk.Label(self.frame, text="Batterie [V]").grid(row=row, column=0, sticky="w"); row+=1 + self.batt_v_var = tk.StringVar(value=f"{self.sim.snapshot().get('battery_voltage', 12.6):.2f}") + ttk.Label(self.frame, textvariable=self.batt_v_var).grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="ELX/Bus [V]").grid(row=row, column=0, sticky="w"); row+=1 + self.elx_v_var = tk.StringVar(value=f"{self.sim.snapshot().get('elx_voltage', 0.0):.2f}") + ttk.Label(self.frame, textvariable=self.elx_v_var).grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="SOC [0..1]").grid(row=row, column=0, sticky="w"); row+=1 + self.soc_var = tk.StringVar(value=f"{self.sim.snapshot().get('battery_soc', 0.8):.2f}") + ttk.Label(self.frame, textvariable=self.soc_var).grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="I Batterie [A] (+entlädt)").grid(row=row, column=0, sticky="w"); row+=1 + self.ibatt_var = tk.StringVar(value=f"{self.sim.snapshot().get('battery_current_a', 0.0):.2f}") + ttk.Label(self.frame, textvariable=self.ibatt_var).grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="I Lima [A]").grid(row=row, column=0, sticky="w"); row+=1 + self.ialt_var = tk.StringVar(value=f"{self.sim.snapshot().get('alternator_current_a', 0.0):.2f}") + ttk.Label(self.frame, textvariable=self.ialt_var).grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Last gesamt [A]").grid(row=row, column=0, sticky="w"); row+=1 + self.load_var = tk.StringVar(value=f"{self.sim.snapshot().get('elec_load_total_a', 0.0):.2f}") + ttk.Label(self.frame, textvariable=self.load_var).grid(row=row-1, column=1, sticky="w") + + ttk.Separator(self.frame).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(6,6)); row+=1 + + # Electrical config -------------------------------------------------------- + econf = self.sim.v.config.get("electrical", {}) + ttk.Label(self.frame, text="Batt Kap. [Ah]").grid(row=row, column=0, sticky="w"); row+=1 + self.bcap = tk.DoubleVar(value=float(econf.get("battery_capacity_ah", 8.0))) + ttk.Entry(self.frame, textvariable=self.bcap, width=10).grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Batt R_int [Ω]").grid(row=row, column=0, sticky="w"); row+=1 + self.brint = tk.DoubleVar(value=float(econf.get("battery_r_int_ohm", 0.020))) + ttk.Entry(self.frame, textvariable=self.brint, width=10).grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Reglerspannung [V]").grid(row=row, column=0, sticky="w"); row+=1 + self.alt_v = tk.DoubleVar(value=float(econf.get("alternator_reg_v", 14.2))) + ttk.Entry(self.frame, textvariable=self.alt_v, width=10).grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Lima Nennstrom [A]").grid(row=row, column=0, sticky="w"); row+=1 + self.alt_a = tk.DoubleVar(value=float(econf.get("alternator_rated_a", 20.0))) + ttk.Entry(self.frame, textvariable=self.alt_a, width=10).grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Cut-In RPM").grid(row=row, column=0, sticky="w"); row+=1 + self.alt_cutin = tk.IntVar(value=int(econf.get("alt_cut_in_rpm", 1500))) + ttk.Entry(self.frame, textvariable=self.alt_cutin, width=10).grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Full-Cap RPM").grid(row=row, column=0, sticky="w"); row+=1 + self.alt_full = tk.IntVar(value=int(econf.get("alt_full_rpm", 4000))) + ttk.Entry(self.frame, textvariable=self.alt_full, width=10).grid(row=row-1, column=1, sticky="w") + + # Apply -------------------------------------------------------------------- + ttk.Button(self.frame, text="Anwenden", command=self.apply)\ + .grid(row=row, column=0, pady=(8,0), sticky="w") + + # periodic UI refresh + self._tick() + + def _tick(self): + snap = self.sim.snapshot() + # Live-Werte + self.batt_v_var.set(f"{snap.get('battery_voltage', 0):.2f}") + self.elx_v_var.set(f"{snap.get('elx_voltage', 0):.2f}") + self.soc_var.set(f"{snap.get('battery_soc', 0.0):.2f}") + self.ibatt_var.set(f"{snap.get('battery_current_a', 0.0):.2f}") + self.ialt_var.set(f"{snap.get('alternator_current_a', 0.0):.2f}") + self.load_var.set(f"{snap.get('elec_load_total_a', 0.0):.2f}") + + # START→ON aus dem Modul spiegeln + curr_ign = snap.get("ignition") + if curr_ign and curr_ign != self.ign_var.get(): + self.ign_var.set(curr_ign) + + try: + self.frame.after(200, self._tick) + except tk.TclError: + pass + + def _apply_ign(self): + # Zündung live setzen + self.sim.v.set("ignition", self.ign_var.get()) + + def apply(self): + # Ambient in State (wirkt sofort auf Thermik, andere Module lesen das) + try: + self.sim.v.set("ambient_c", float(self.ambient_var.get())) + except Exception: + pass + + cfg = { + "vehicle": { + "type": self.type_var.get(), + "mass_kg": float(self.mass_var.get()), + "abs": bool(self.abs_var.get()), + "tcs": bool(self.tcs_var.get()), + }, + "electrical": { + "battery_capacity_ah": float(self.bcap.get()), + "battery_r_int_ohm": float(self.brint.get()), + "alternator_reg_v": float(self.alt_v.get()), + "alternator_rated_a": float(self.alt_a.get()), + "alt_cut_in_rpm": int(self.alt_cutin.get()), + "alt_full_rpm": int(self.alt_full.get()), + } + } + self.sim.load_config(cfg) + + def save_into_config(self, out: Dict[str, Any]) -> None: + out.setdefault("vehicle", {}) + out["vehicle"].update({ + "type": self.type_var.get(), + "mass_kg": float(self.mass_var.get()), + "abs": bool(self.abs_var.get()), + "tcs": bool(self.tcs_var.get()), + }) + out.setdefault("electrical", {}) + out["electrical"].update({ + "battery_capacity_ah": float(self.bcap.get()), + "battery_r_int_ohm": float(self.brint.get()), + "alternator_reg_v": float(self.alt_v.get()), + "alternator_rated_a": float(self.alt_a.get()), + "alt_cut_in_rpm": int(self.alt_cutin.get()), + "alt_full_rpm": int(self.alt_full.get()), + }) + + def load_from_config(self, cfg: Dict[str, Any]) -> None: + vcfg = cfg.get("vehicle", {}) + self.type_var.set(vcfg.get("type", self.type_var.get())) + self.mass_var.set(vcfg.get("mass_kg", self.mass_var.get())) + self.abs_var.set(vcfg.get("abs", self.abs_var.get())) + self.tcs_var.set(vcfg.get("tcs", self.tcs_var.get())) + ecfg = cfg.get("electrical", {}) + self.bcap.set(ecfg.get("battery_capacity_ah", self.bcap.get())) + self.brint.set(ecfg.get("battery_r_int_ohm", self.brint.get())) + self.alt_v.set(ecfg.get("alternator_reg_v", self.alt_v.get())) + self.alt_a.set(ecfg.get("alternator_rated_a", self.alt_a.get())) + self.alt_cutin.set(ecfg.get("alt_cut_in_rpm", self.alt_cutin.get())) + self.alt_full.set(ecfg.get("alt_full_rpm", self.alt_full.get())) + # wichtig: NICHT self.sim.load_config(cfg) hier! diff --git a/app/tabs/dashboard.py b/app/tabs/dashboard.py new file mode 100644 index 0000000..210eaf7 --- /dev/null +++ b/app/tabs/dashboard.py @@ -0,0 +1,77 @@ +# app/tabs/dashboard.py +from __future__ import annotations +import tkinter as tk +from tkinter import ttk + +class DashboardTab: + """Zeigt dynamisch alle im Vehicle registrierten Dashboard-Metriken.""" + def __init__(self, parent, sim): + self.sim = sim + self.frame = ttk.Frame(parent, padding=8) + self.tree = ttk.Treeview(self.frame, columns=("label","value","unit","key","source"), show="headings", height=12) + self.tree.heading("label", text="Parameter") + self.tree.heading("value", text="Wert") + self.tree.heading("unit", text="Einheit") + self.tree.heading("key", text="Key") + self.tree.heading("source",text="Modul") + self.tree.column("label", width=180, anchor="w") + self.tree.column("value", width=120, anchor="e") + self.tree.column("unit", width=80, anchor="w") + self.tree.column("key", width=180, anchor="w") + self.tree.column("source",width=100, anchor="w") + self.tree.grid(row=0, column=0, sticky="nsew") + sb = ttk.Scrollbar(self.frame, orient="vertical", command=self.tree.yview) + self.tree.configure(yscrollcommand=sb.set) + sb.grid(row=0, column=1, sticky="ns") + self.frame.columnconfigure(0, weight=1) + self.frame.rowconfigure(0, weight=1) + + self._last_keys = None + self._tick() + + def _format_value(self, val, fmt): + if fmt: + try: + return f"{val:{fmt}}" + except Exception: + return str(val) + return str(val) + + def _tick(self): + snap = self.sim.v.dashboard_snapshot() + specs = snap["specs"] + values = snap["values"] + + keys = sorted(specs.keys(), key=lambda k: (specs[k].get("priority", 999), specs[k].get("label", k))) + if keys != self._last_keys: + # rebuild table + for item in self.tree.get_children(): + self.tree.delete(item) + for k in keys: + spec = specs[k] + lbl = spec.get("label", k) + unit = spec.get("unit", "") + src = spec.get("source", "") + val = self._format_value(values.get(k, ""), spec.get("fmt")) + self.tree.insert("", "end", iid=k, values=(lbl, val, unit, k, src)) + self._last_keys = keys + else: + # update values only + for k in keys: + spec = specs[k] + val = self._format_value(values.get(k, ""), spec.get("fmt")) + try: + self.tree.set(k, "value", val) + except tk.TclError: + pass + + try: + self.frame.after(200, self._tick) + except tk.TclError: + pass + + # Config-API no-ops (für Konsistenz mit anderen Tabs) + def save_into_config(self, out): # pragma: no cover + pass + def load_from_config(self, cfg): # pragma: no cover + pass diff --git a/app/tabs/dtc.py b/app/tabs/dtc.py new file mode 100644 index 0000000..3a3c16c --- /dev/null +++ b/app/tabs/dtc.py @@ -0,0 +1,41 @@ +# ============================= +# app/tabs/dtc.py +# ============================= + +from __future__ import annotations +import tkinter as tk +from tkinter import ttk +from typing import Dict, Any + +DTC_LIST = [ + ("P0300", "Random/Multiple Cylinder Misfire"), + ("P0130", "O2 Sensor Circuit (Bank1-Sensor1)"), + ("C0035", "Wheel Speed Sensor LF"), + ("U0121", "Lost Communication With ABS") +] + +class DtcTab: + def __init__(self, parent, sim): + self.sim = sim + self.frame = ttk.Frame(parent, padding=8) + self.vars: Dict[str, tk.BooleanVar] = {} + row = 0 + ttk.Label(self.frame, text="Diagnose-Flags (Demo)", style="Header.TLabel").grid(row=row, column=0, sticky="w"); row += 1 + for code, label in DTC_LIST: + var = tk.BooleanVar(value=False) + ttk.Checkbutton(self.frame, text=f"{code} – {label}", variable=var).grid(row=row, column=0, sticky="w") + self.vars[code] = var; row += 1 + ttk.Button(self.frame, text="Alle löschen", command=self.clear_all).grid(row=row, column=0, sticky="w", pady=(8,0)) + + def clear_all(self): + for v in self.vars.values(): v.set(False) + + def save_into_config(self, out: Dict[str, Any]) -> None: + out.setdefault("dtc", {}) + out["dtc"].update({code: bool(v.get()) for code, v in self.vars.items()}) + + def load_from_config(self, cfg: Dict[str, Any]) -> None: + dtc = cfg.get("dtc", {}) + for code, v in self.vars.items(): + v.set(bool(dtc.get(code, False))) + self.sim.load_config(cfg) \ No newline at end of file diff --git a/app/tabs/engine.py b/app/tabs/engine.py new file mode 100644 index 0000000..6c9cdf8 --- /dev/null +++ b/app/tabs/engine.py @@ -0,0 +1,176 @@ +# app/tabs/engine.py +from __future__ import annotations +import tkinter as tk +from tkinter import ttk +from typing import Dict, Any +# Wichtig: Defaults aus dem Modul importieren +from app.simulation.modules.engine import ENGINE_DEFAULTS + +class EngineTab: + def __init__(self, parent, sim): + self.sim = sim + self.frame = ttk.Frame(parent, padding=8) + self.frame.columnconfigure(1, weight=1) + + # ------------- Widgets anlegen (OHNE Defaultwerte eintragen) -------------- + row = 0 + ttk.Label(self.frame, text="Leerlauf [RPM]").grid(row=row, column=0, sticky="w"); row+=1 + self.idle_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.idle_var, width=10)\ + .grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Max RPM").grid(row=row, column=0, sticky="w"); row+=1 + self.maxrpm_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.maxrpm_var, width=10)\ + .grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Anstieg [RPM/s]").grid(row=row, column=0, sticky="w"); row+=1 + self.rise_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.rise_var, width=10)\ + .grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Abfall [RPM/s]").grid(row=row, column=0, sticky="w"); row+=1 + self.fall_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.fall_var, width=10)\ + .grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Gaspedal-Kennlinie").grid(row=row, column=0, sticky="w"); row+=1 + self.thr_curve = tk.StringVar() + ttk.Combobox(self.frame, textvariable=self.thr_curve, state="readonly", + values=["linear","progressive","aggressive"])\ + .grid(row=row-1, column=1, sticky="w") + + ttk.Separator(self.frame).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(8,6)); row+=1 + + # Leistung + ttk.Label(self.frame, text="Motorleistung [kW]").grid(row=row, column=0, sticky="w"); row+=1 + self.power_kw = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.power_kw, width=10)\ + .grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Drehmoment-Peak [RPM]").grid(row=row, column=0, sticky="w"); row+=1 + self.peak_rpm = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.peak_rpm, width=10)\ + .grid(row=row-1, column=1, sticky="w") + + ttk.Separator(self.frame).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(8,6)); row+=1 + + # Starter + ttk.Label(self.frame, text="Starter Nenn-RPM").grid(row=row, column=0, sticky="w"); row+=1 + self.starter_nom = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.starter_nom, width=10)\ + .grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Starter min. Spannung [V]").grid(row=row, column=0, sticky="w"); row+=1 + self.starter_vmin = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.starter_vmin, width=10)\ + .grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Start-Schwelle [RPM]").grid(row=row, column=0, sticky="w"); row+=1 + self.start_th = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.start_th, width=10)\ + .grid(row=row-1, column=1, sticky="w") + + ttk.Label(self.frame, text="Stall-Grenze [RPM]").grid(row=row, column=0, sticky="w"); row+=1 + self.stall_rpm = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.stall_rpm, width=10)\ + .grid(row=row-1, column=1, sticky="w") + + ttk.Separator(self.frame).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(8,6)); row+=1 + + # Thermik (analog – Variablen ohne Defaults anlegen) ... + self.amb_c = tk.DoubleVar(); self.c_warm = tk.DoubleVar(); self.c_cool = tk.DoubleVar() + self.o_warm = tk.DoubleVar(); self.o_cool = tk.DoubleVar() + self.cold_gain = tk.DoubleVar(); self.cold_gain_max = tk.DoubleVar() + # (Labels/Entries spar ich hier ab – wie gehabt weiterführen) + + # Öl, DBW, Jitter, Pedal + self.o_idle = tk.DoubleVar(); self.o_slope = tk.DoubleVar(); self.o_floor = tk.DoubleVar() + self.plate_idle_min = tk.DoubleVar(); self.plate_overrun = tk.DoubleVar(); self.plate_tau = tk.DoubleVar() + self.torque_kp = tk.DoubleVar(); self.torque_ki = tk.DoubleVar() + self.jitter_idle = tk.DoubleVar(); self.jitter_high = tk.DoubleVar() + self.jitter_tau = tk.DoubleVar(); self.jitter_off = tk.DoubleVar() + + ttk.Label(self.frame, text="Gaspedal [%]").grid(row=row, column=0, sticky="w"); row+=1 + self.pedal_var = tk.DoubleVar() + self.pedal_scale = ttk.Scale(self.frame, from_=0.0, to=100.0, variable=self.pedal_var) + self.pedal_scale.grid(row=row-1, column=1, sticky="ew") + + # Buttons + row += 1 + btnrow = ttk.Frame(self.frame); btnrow.grid(row=row, column=0, columnspan=2, sticky="w", pady=(8,0)) + ttk.Button(btnrow, text="Aktualisieren", command=self.refresh).pack(side="left") + ttk.Button(btnrow, text="Anwenden", command=self.apply).pack(side="left", padx=(8,0)) + + # Zum Start einmal „live“ laden: + self.refresh() + + # liest IMMER effektiv: config.get(key, ENGINE_DEFAULTS[key]) + def refresh(self): + e = dict(ENGINE_DEFAULTS) + e.update(self.sim.v.config.get("engine", {})) # Config über default mergen + + self.idle_var.set(e["idle_rpm"]) + self.maxrpm_var.set(e["max_rpm"]) + self.rise_var.set(e["rpm_rise_per_s"]) + self.fall_var.set(e["rpm_fall_per_s"]) + self.thr_curve.set(e["throttle_curve"]) + self.power_kw.set(e["engine_power_kw"]) + self.peak_rpm.set(e["torque_peak_rpm"]) + + self.starter_nom.set(e["starter_rpm_nominal"]) + self.starter_vmin.set(e["starter_voltage_min"]) + self.start_th.set(e["start_rpm_threshold"]) + self.stall_rpm.set(e["stall_rpm"]) + + self.amb_c.set(e["coolant_ambient_c"]) + self.c_warm.set(e["coolant_warm_rate_c_per_s"]) + self.c_cool.set(e["coolant_cool_rate_c_per_s"]) + self.o_warm.set(e["oil_warm_rate_c_per_s"]) + self.o_cool.set(e["oil_cool_rate_c_per_s"]) + self.cold_gain.set(e["idle_cold_gain_per_deg"]) + self.cold_gain_max.set(e["idle_cold_gain_max"]) + + self.o_idle.set(e["oil_pressure_idle_bar"]) + self.o_slope.set(e["oil_pressure_slope_bar_per_krpm"]) + self.o_floor.set(e["oil_pressure_off_floor_bar"]) + + self.plate_idle_min.set(e["throttle_plate_idle_min_pct"]) + self.plate_overrun.set(e["throttle_plate_overrun_pct"]) + self.plate_tau.set(e["throttle_plate_tau_s"]) + self.torque_kp.set(e["torque_ctrl_kp"]) + self.torque_ki.set(e["torque_ctrl_ki"]) + + self.jitter_idle.set(e["rpm_jitter_idle_amp_rpm"]) + self.jitter_high.set(e["rpm_jitter_high_amp_rpm"]) + self.jitter_tau.set(e["rpm_jitter_tau_s"]) + self.jitter_off.set(e["rpm_jitter_off_threshold_rpm"]) + + self.pedal_var.set(e["throttle_pedal_pct"]) + + def apply(self): + # Nur hier wird geschrieben + cfg = {"engine": { + "idle_rpm": int(self.idle_var.get()), + "max_rpm": int(self.maxrpm_var.get()), + "rpm_rise_per_s": int(self.rise_var.get()), + "rpm_fall_per_s": int(self.fall_var.get()), + "throttle_curve": self.thr_curve.get(), + "engine_power_kw": float(self.power_kw.get()), + "torque_peak_rpm": float(self.peak_rpm.get()), + "starter_rpm_nominal": float(self.starter_nom.get()), + "starter_voltage_min": float(self.starter_vmin.get()), + "start_rpm_threshold": float(self.start_th.get()), + "stall_rpm": float(self.stall_rpm.get()), + "coolant_ambient_c": float(self.amb_c.get()), + "coolant_warm_rate_c_per_s": float(self.c_warm.get()), + "coolant_cool_rate_c_per_s": float(self.c_cool.get()), + "oil_warm_rate_c_per_s": float(self.o_warm.get()), + "oil_cool_rate_c_per_s": float(self.o_cool.get()), + "idle_cold_gain_per_deg": float(self.cold_gain.get()), + "idle_cold_gain_max": float(self.cold_gain_max.get()), + "oil_pressure_idle_bar": float(self.o_idle.get()), + "oil_pressure_slope_bar_per_krpm": float(self.o_slope.get()), + "oil_pressure_off_floor_bar": float(self.o_floor.get()), + "throttle_plate_idle_min_pct": float(self.plate_idle_min.get()), + "throttle_plate_overrun_pct": float(self.plate_overrun.get()), + "throttle_plate_tau_s": float(self.plate_tau.get()), + "torque_ctrl_kp": float(self.torque_kp.get()), + "torque_ctrl_ki": float(self.torque_ki.get()), + "rpm_jitter_idle_amp_rpm": float(self.jitter_idle.get()), + "rpm_jitter_high_amp_rpm": float(self.jitter_high.get()), + "rpm_jitter_tau_s": float(self.jitter_tau.get()), + "rpm_jitter_off_threshold_rpm": float(self.jitter_off.get()), + "throttle_pedal_pct": float(self.pedal_var.get()), + }} + self.sim.load_config(cfg) diff --git a/app/tabs/gearbox.py b/app/tabs/gearbox.py new file mode 100644 index 0000000..d7f62ce --- /dev/null +++ b/app/tabs/gearbox.py @@ -0,0 +1,66 @@ +# ============================= +# app/tabs/gearbox.py +# ============================= + +from __future__ import annotations +import tkinter as tk +from tkinter import ttk +from typing import Dict, Any, List + +class GearboxTab: + def __init__(self, parent, sim): + self.sim = sim + self.frame = ttk.Frame(parent, padding=8) + self.frame.columnconfigure(1, weight=1) + + ttk.Label(self.frame, text="Gänge (inkl. Leerlauf als 0)").grid(row=0, column=0, sticky="w") + self.gears_var = tk.IntVar(value=6) + ttk.Spinbox(self.frame, from_=1, to=10, textvariable=self.gears_var, width=6, command=self._rebuild_ratios).grid(row=0, column=1, sticky="w") + + self.reverse_var = tk.BooleanVar(value=False) + ttk.Checkbutton(self.frame, text="Rückwärtsgang vorhanden", variable=self.reverse_var).grid(row=1, column=0, columnspan=2, sticky="w") + + ttk.Label(self.frame, text="km/h pro 1000 RPM je Gang").grid(row=2, column=0, sticky="w", pady=(6,0)) + self.ratio_frame = ttk.Frame(self.frame); self.ratio_frame.grid(row=3, column=0, columnspan=2, sticky="ew") + self.ratio_vars: List[tk.DoubleVar] = [] + self._rebuild_ratios() + + ttk.Button(self.frame, text="Anwenden", command=self.apply).grid(row=4, column=0, pady=(8,0), sticky="w") + + def _rebuild_ratios(self): + for w in self.ratio_frame.winfo_children(): w.destroy() + self.ratio_vars.clear() + n = int(self.gears_var.get()) + for i in range(1, n+1): + ttk.Label(self.ratio_frame, text=f"Gang {i}").grid(row=i-1, column=0, sticky="w") + v = tk.DoubleVar(value= [12.0,19.0,25.0,32.0,38.0,45.0][i-1] if i-1 < 6 else 45.0) + ttk.Entry(self.ratio_frame, textvariable=v, width=8).grid(row=i-1, column=1, sticky="w", padx=(6,12)) + self.ratio_vars.append(v) + + def apply(self): + ratios = [float(v.get()) for v in self.ratio_vars] + cfg = {"gearbox": { + "num_gears": int(self.gears_var.get()), + "reverse": bool(self.reverse_var.get()), + "kmh_per_krpm": [0.0] + ratios # index 0 reserved for neutral + }} + self.sim.load_config(cfg) + + def save_into_config(self, out: Dict[str, Any]) -> None: + out.setdefault("gearbox", {}) + out["gearbox"].update({ + "num_gears": int(self.gears_var.get()), + "reverse": bool(self.reverse_var.get()), + "kmh_per_krpm": [0.0] + [float(v.get()) for v in self.ratio_vars] + }) + + def load_from_config(self, cfg: Dict[str, Any]) -> None: + g = cfg.get("gearbox", {}) + n = int(g.get("num_gears", self.gears_var.get())) + self.gears_var.set(n); self.reverse_var.set(g.get("reverse", self.reverse_var.get())) + self._rebuild_ratios() + ratios = g.get("kmh_per_krpm") or ([0.0] + [v.get() for v in self.ratio_vars]) + for i, v in enumerate(self.ratio_vars, start=1): + try: v.set(float(ratios[i])) + except Exception: pass + self.sim.load_config(cfg) diff --git a/main.py b/main.py index e6a11e7..39a5adb 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,4 @@ +# main.py from app.gui import launch_gui if __name__ == "__main__":