starting to implement realistic Vehicle simulation

This commit is contained in:
2025-09-04 15:03:11 +02:00
parent 4c41be706d
commit 6a9d27c6cf
17 changed files with 1468 additions and 250 deletions

View File

@@ -116,13 +116,27 @@ Alternativ: App mit `sudo ./start.sh` starten.
## Projektstruktur ## Projektstruktur
``` ```
main.py Startpunkt
app/ app/
├─ gui.py Tkinter GUI ├─ gui.py ← main GUI with Simulator tabs + Save/Load
├─ can.py CAN-Responder + Link-Control (pyroute2) ├─ config.py
├─ simulator.py Physikmodell (Gang + Gas → Geschwindigkeit/RPM) ├─ can.py
config.py Settings + Logging obd2.py
settings.json Konfigurationsdatei (wird beim Speichern erzeugt) ├─ 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 ## Bekannte Einschränkungen

View File

@@ -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 from __future__ import annotations
import json import json
import threading import threading
import time import time
import tkinter as tk import tkinter as tk
from tkinter import ttk, messagebox from tkinter import ttk, messagebox, filedialog
from collections import deque, defaultdict from collections import deque
import subprocess 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 .config import load_settings, setup_logging
from .simulator import EcuState, DrivelineModel
from .obd2 import ObdResponder, make_speed_response, make_rpm_response from .obd2 import ObdResponder, make_speed_response, make_rpm_response
from .can import ( from .can import (
list_can_ifaces, link_up, link_down, link_state, link_kind, 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: 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): def __init__(self, channel: str):
self.channel = channel self.channel = channel
self.bus = None self.bus = None
@@ -41,7 +69,8 @@ class TraceCollector:
def _close(self): def _close(self):
try: try:
if self.bus: self.bus.shutdown() if self.bus: self.bus.shutdown()
except Exception: pass except Exception:
pass
self.bus = None self.bus = None
def start(self): def start(self):
@@ -49,8 +78,10 @@ class TraceCollector:
def stop(self): def stop(self):
self._run.clear() self._run.clear()
try: self._thread.join(timeout=1.0) try:
except RuntimeError: pass self._thread.join(timeout=1.0)
except RuntimeError:
pass
self._close() self._close()
def _rx_loop(self): def _rx_loop(self):
@@ -59,8 +90,7 @@ class TraceCollector:
if self.bus is None: if self.bus is None:
if link_state(self.channel) == "UP": if link_state(self.channel) == "UP":
try: try:
self._open() self._open(); backoff = 0.5
backoff = 0.5
except Exception: except Exception:
time.sleep(backoff); backoff = min(5.0, backoff*1.7) time.sleep(backoff); backoff = min(5.0, backoff*1.7)
continue continue
@@ -73,9 +103,7 @@ class TraceCollector:
with self.lock: with self.lock:
self.stream_buffer.append((ts, msg.arbitration_id, msg.dlc, bytes(msg.data))) self.stream_buffer.append((ts, msg.arbitration_id, msg.dlc, bytes(msg.data)))
except (can.CanOperationError, OSError): 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: except Exception:
time.sleep(0.05) time.sleep(0.05)
@@ -84,11 +112,15 @@ class TraceCollector:
return list(self.stream_buffer) return list(self.stream_buffer)
# =============================
# GUI Launcher (reworked layout)
# =============================
def launch_gui(): def launch_gui():
cfg = load_settings() cfg = load_settings()
logger = setup_logging(cfg) logger = setup_logging(cfg)
# read config values # Config
can_iface = (cfg.get("can", {}).get("interface")) or "can0" can_iface = (cfg.get("can", {}).get("interface")) or "can0"
resp_id_raw = (cfg.get("can", {}).get("resp_id")) or "0x7E8" resp_id_raw = (cfg.get("can", {}).get("resp_id")) or "0x7E8"
try: try:
@@ -98,80 +130,93 @@ def launch_gui():
timeout_ms = cfg.get("can", {}).get("timeout_ms", 200) timeout_ms = cfg.get("can", {}).get("timeout_ms", 200)
bitrate = cfg.get("can", {}).get("baudrate", 500000) 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 = 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 # Physics thread
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
running = True running = True
def physics_loop(): def physics_loop():
last = time.monotonic()
while running: 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) time.sleep(0.02)
t = threading.Thread(target=physics_loop, daemon=True) threading.Thread(target=physics_loop, daemon=True).start()
t.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 --- # Tk window
root = tk.Tk() root = tk.Tk(); root.title("OBD-II ECU Simulator SocketCAN")
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") family = cfg.get("ui", {}).get("font_family", "TkDefaultFont")
size = int(cfg.get("ui", {}).get("font_size", 10)) size = int(cfg.get("ui", {}).get("font_size", 10))
style = ttk.Style() style = ttk.Style()
style.configure("TLabel", font=(family, size)) style.configure("TLabel", font=(family, size))
style.configure("Header.TLabel", font=(family, size+2, "bold")) 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 # Menu (Load/Save config)
root.columnconfigure(0, weight=1); root.rowconfigure(0, weight=1) menubar = tk.Menu(root)
main = ttk.Frame(root, padding=10); main.grid(row=0, column=0, sticky="nsew") filemenu = tk.Menu(menubar, tearoff=0)
main.columnconfigure(1, weight=1)
# === Controls: Gear + Throttle === def action_load():
ttk.Label(main, text="Gang").grid(row=0, column=0, sticky="w") path = filedialog.askopenfilename(filetypes=[("JSON", "*.json"), ("All", "*.*")])
gear_var = tk.IntVar(value=0) if not path: return
gear_box = ttk.Combobox(main, textvariable=gear_var, state="readonly", values=[0,1,2,3,4,5,6], width=5) try:
gear_box.grid(row=0, column=1, sticky="w", padx=(6,12)) with open(path, "r", encoding="utf-8") as f:
gear_box.bind("<<ComboboxSelected>>", lambda _e: ecu.set_gear(gear_var.get())) 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") def action_save():
thr = ttk.Scale(main, from_=0, to=100, orient="horizontal", cfg_dict = sim.export_config()
command=lambda v: ecu.set_throttle(int(float(v)))) for tab in sim_tabs: tab.save_into_config(cfg_dict)
thr.set(0) path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON", "*.json"), ("All", "*.*")])
thr.grid(row=1, column=1, sticky="ew", padx=(6,12)) 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") filemenu.add_command(label="Konfiguration laden…", command=action_load)
lbl_rpm = ttk.Label(main, text="RPM: 0") filemenu.add_command(label="Konfiguration speichern…", command=action_save)
lbl_speed.grid(row=2, column=0, columnspan=2, sticky="w", pady=(10,0)) filemenu.add_separator(); filemenu.add_command(label="Beenden", command=root.destroy)
lbl_rpm.grid(row=3, column=0, columnspan=2, sticky="w") menubar.add_cascade(label="Datei", menu=filemenu)
root.config(menu=menubar)
# === CAN Panel === # ===== New Layout ======================================================
sep = ttk.Separator(main); sep.grid(row=4, column=0, columnspan=2, sticky="ew", pady=(10,10)) # 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) root.columnconfigure(0, weight=1)
can_frame.grid(row=5, column=0, columnspan=2, sticky="nsew") root.columnconfigure(1, weight=2)
can_frame.columnconfigure(1, weight=1) 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") ttk.Label(can_frame, text="Interface").grid(row=0, column=0, sticky="w")
iface_var = tk.StringVar(value=can_iface) iface_var = tk.StringVar(value=can_iface)
iface_list = list_can_ifaces() or [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 = 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(): def refresh_ifaces():
lst = list_can_ifaces() lst = list_can_ifaces()
@@ -179,44 +224,39 @@ def launch_gui():
messagebox.showwarning("Interfaces", "Keine can*/vcan* Interfaces gefunden.") messagebox.showwarning("Interfaces", "Keine can*/vcan* Interfaces gefunden.")
return return
iface_dd.config(values=lst) 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") 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_var = tk.StringVar(value=f"0x{resp_id:03X}")
resp_entry = ttk.Entry(can_frame, textvariable=resp_var, width=10) ttk.Entry(can_frame, textvariable=resp_var, width=10).grid(row=1, column=1, sticky="w", padx=(6,0))
resp_entry.grid(row=1, column=1, sticky="w", padx=(6,12))
ttk.Label(can_frame, text="Timeout (ms)").grid(row=2, column=0, sticky="w") ttk.Label(can_frame, text="Timeout (ms)").grid(row=2, column=0, sticky="w")
to_var = tk.IntVar(value=int(timeout_ms)) 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) 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))
to_spin.grid(row=2, column=1, sticky="w", padx=(6,12))
ttk.Label(can_frame, text="Bitrate").grid(row=3, column=0, sticky="w") ttk.Label(can_frame, text="Bitrate").grid(row=3, column=0, sticky="w")
br_var = tk.IntVar(value=int(bitrate)) br_var = tk.IntVar(value=int(bitrate))
br_spin = ttk.Spinbox(can_frame, from_=20000, to=1000000, increment=10000, textvariable=br_var, width=10) 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))
br_spin.grid(row=3, column=1, sticky="w", padx=(6,12))
# unter Bitrate-Spinbox
set_params = tk.BooleanVar(value=True) 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") 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)}", style="Small.TLabel")
kind_label = ttk.Label(can_frame, text=f"Kind: {link_kind(can_iface)}") kind_label.grid(row=4, column=0, columnspan=3, sticky="w", pady=(4,0))
kind_label.grid(row=0, column=3, sticky="w", padx=(12,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(): def do_link_up():
try: try:
# Kind-Anzeige aktualisieren (falls Interface gewechselt)
kind_label.config(text=f"Kind: {link_kind(iface_var.get())}") kind_label.config(text=f"Kind: {link_kind(iface_var.get())}")
if link_state(iface_var.get()) == "UP": if link_state(iface_var.get()) == "UP":
messagebox.showinfo("CAN", f"{iface_var.get()} ist bereits UP") messagebox.showinfo("CAN", f"{iface_var.get()} ist bereits UP"); return
return
# NEU: set_params aus Checkbox
link_up(iface_var.get(), bitrate=br_var.get(), fd=False, set_params=set_params.get()) 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: try:
out = subprocess.check_output(["ip", "-details", "-json", "link", "show", iface_var.get()], text=True) out = subprocess.check_output(["ip", "-details", "-json", "link", "show", iface_var.get()], text=True)
info = json.loads(out)[0] 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})") messagebox.showinfo("CAN", f"{iface_var.get()} ist UP @ {br} bit/s (sample-point {sp})")
except Exception: except Exception:
pass 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: 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(): def do_link_down():
try: try:
if link_state(iface_var.get()) == "DOWN": if link_state(iface_var.get()) == "DOWN":
messagebox.showinfo("CAN", f"{iface_var.get()} ist bereits DOWN") messagebox.showinfo("CAN", f"{iface_var.get()} ist bereits DOWN"); return
return link_down(iface_var.get()); messagebox.showinfo("CAN", f"{iface_var.get()} ist DOWN")
link_down(iface_var.get())
messagebox.showinfo("CAN", f"{iface_var.get()} ist DOWN")
except PermissionError as e:
messagebox.showerror("Berechtigung", str(e))
except Exception as e: 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) ttk.Button(btns, text="Link UP", command=do_link_up).grid(row=0, column=0, sticky="w")
btn_down = ttk.Button(can_frame, text="Link DOWN", command=do_link_down) ttk.Button(btns, text="Link DOWN", command=do_link_down).grid(row=0, column=1, sticky="w", padx=(6,0))
btn_up.grid(row=4, column=0, pady=(8,0), sticky="w")
btn_down.grid(row=4, column=1, pady=(8,0), sticky="w")
# Rebind responder
def do_rebind(): def do_rebind():
nonlocal can_iface, resp_id, timeout_ms, bitrate, tracer nonlocal can_iface, resp_id, timeout_ms, bitrate, tracer
can_iface = iface_var.get() can_iface = iface_var.get()
try: try:
new_resp = int(resp_var.get(), 16) new_resp = int(resp_var.get(), 16)
except Exception: except Exception:
messagebox.showerror("RESP-ID", "Bitte gültige Hex-Zahl, z.B. 0x7E8") messagebox.showerror("RESP-ID", "Bitte gültige Hex-Zahl, z.B. 0x7E8"); return
return resp_id = new_resp; timeout_ms = to_var.get(); bitrate = br_var.get()
resp_id = new_resp
timeout_ms = to_var.get()
bitrate = br_var.get()
try: try:
responder.rebind(interface=can_iface, resp_id=resp_id) responder.rebind(interface=can_iface, resp_id=resp_id)
# Trace-Collector auf neues IF neu binden tracer.stop(); tracer = TraceCollector(can_iface); tracer.start()
try:
tracer.stop()
except Exception:
pass
tracer = TraceCollector(can_iface)
tracer.start()
messagebox.showinfo("CAN", f"Responder neu gebunden: {can_iface}, RESP 0x{resp_id:03X}") messagebox.showinfo("CAN", f"Responder neu gebunden: {can_iface}, RESP 0x{resp_id:03X}")
except Exception as e: 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 ttk.Label(can_frame, text=f"CAP_NET_ADMIN: {'yes' if have_cap_netadmin() else 'no'}", style="Small.TLabel")\
caps_ok = have_cap_netadmin() .grid(row=6, column=0, columnspan=3, sticky="w", pady=(6,0))
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"])
# Statusbar # --- RIGHT: Simulator Tabs --------------------------------------------
status = ttk.Label(main, text=f"CAN: {can_iface} | RESP-ID: 0x{resp_id:03X}", relief="sunken", anchor="w") right = ttk.Frame(root)
status.grid(row=6, column=0, columnspan=2, sticky="ew", pady=(10,0)) 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 = ttk.LabelFrame(root, text="CAN Trace", padding=6)
trace_frame.grid(row=1, column=0, sticky="nsew") trace_frame.grid(row=1, column=0, columnspan=2, sticky="nsew", padx=8, pady=(0,8))
root.rowconfigure(1, weight=1)
trace_frame.columnconfigure(0, weight=1) trace_frame.columnconfigure(0, weight=1)
trace_frame.rowconfigure(1, weight=1) trace_frame.rowconfigure(1, weight=1)
# Controls: Mode, Pause, Clear, Autoscroll
ctrl = ttk.Frame(trace_frame) ctrl = ttk.Frame(trace_frame)
ctrl.grid(row=0, column=0, sticky="ew", pady=(0,4)) ctrl.grid(row=0, column=0, sticky="ew", pady=(0,4))
ctrl.columnconfigure(5, weight=1) 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") ttk.Label(ctrl, text="Modus:").grid(row=0, column=0, sticky="w")
mode_dd = ttk.Combobox(ctrl, textvariable=mode_var, state="readonly", width=10, ttk.Combobox(ctrl, textvariable=mode_var, state="readonly", width=10, values=["stream","aggregate"])\
values=["stream", "aggregate"]) .grid(row=0, column=1, sticky="w", padx=(4,12))
mode_dd.grid(row=0, column=1, sticky="w", padx=(4,12))
paused = tk.BooleanVar(value=False) paused = tk.BooleanVar(value=False)
ttk.Checkbutton(ctrl, text="Pause", variable=paused).grid(row=0, column=2, sticky="w") 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) autoscroll = tk.BooleanVar(value=True)
ttk.Checkbutton(ctrl, text="Auto-Scroll", variable=autoscroll).grid(row=0, column=3, sticky="w") ttk.Checkbutton(ctrl, text="Auto-Scroll", variable=autoscroll).grid(row=0, column=3, sticky="w")
def do_clear(): tree = ttk.Treeview(trace_frame, columns=("time","dir","id","dlc","data"), show="headings", height=10)
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.grid(row=1, column=0, sticky="nsew") tree.grid(row=1, column=0, sticky="nsew")
sb_y = ttk.Scrollbar(trace_frame, orient="vertical", command=tree.yview) sb_y = ttk.Scrollbar(trace_frame, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=sb_y.set) tree.configure(yscrollcommand=sb_y.set); sb_y.grid(row=1, column=1, sticky="ns")
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] = {}
def fmt_time(ts: float) -> str: def fmt_time(ts: float) -> str:
# hh:mm:ss.mmm
lt = time.localtime(ts) lt = time.localtime(ts)
return time.strftime("%H:%M:%S", lt) + f".{int((ts%1)*1000):03d}" 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 last_index = 0
def tick(): def tick():
nonlocal can_iface, resp_id, last_index nonlocal last_index
# Top-Status snap = sim.snapshot()
g, tval, rpm, spd = ecu.snapshot() # Optional: könnte in eine Statusbar ausgelagert werden
caps = "CAP:yes" if have_cap_netadmin() else "CAP:no" root.title(f"OBD-II ECU Simulator RPM {int(snap['rpm'])} | {int(round(snap['speed_kmh']))} km/h")
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}")
# Trace
if not paused.get(): if not paused.get():
mode = mode_var.get() mode = mode_var.get()
buf = tracer.snapshot_stream()
if mode == "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:]: for ts, cid, dlc, data in buf[last_index:]:
# Richtung heuristisch d = "RX" if cid == 0x7DF else ("TX" if cid == responder.resp_id else "?")
if cid == 0x7DF: tree.insert("", "end", values=(fmt_time(ts), d, fmt_id(cid), dlc, fmt_data(data)))
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
if autoscroll.get() and buf[last_index:]: if autoscroll.get() and buf[last_index:]:
tree.see(tree.get_children()[-1]) tree.see(tree.get_children()[-1])
last_index = len(buf)
else: else:
setup_columns("aggregate") if tree["columns"] != cols_agg else None tree.delete(*tree.get_children())
# baue Aggregat neu (leicht, schnell) agg = {}
buf = tracer.snapshot_stream()
agg: dict[tuple[int,str], dict] = {}
for ts, cid, dlc, data in buf: for ts, cid, dlc, data in buf:
if cid == 0x7DF: d = "RX" if cid == 0x7DF else ("TX" if cid == responder.resp_id else "?")
d = "RX"
elif cid == resp_id:
d = "TX"
else:
d = "?"
key = (cid, d) key = (cid, d)
entry = agg.get(key) e = agg.get(key)
if entry is None: if not e:
agg[key] = {"count":1, "last_ts":ts, "last_dlc":dlc, "last_data":data} agg[key] = {"count":1, "last_ts":ts, "last_dlc":dlc, "last_data":data}
else: else:
entry["count"] += 1 e["count"] += 1
if ts >= entry["last_ts"]: if ts >= e["last_ts"]:
entry["last_ts"] = ts e["last_ts"], e["last_dlc"], e["last_data"] = ts, dlc, data
entry["last_dlc"] = dlc for (cid, d) in sorted(agg.keys(), key=lambda k:(k[0], 0 if k[1]=="RX" else 1)):
entry["last_data"] = data e = agg[(cid, d)]
# nur neu zeichnen, wenn sich was ändert tree.insert("", "end", values=(fmt_id(cid), d, e["count"], fmt_time(e["last_ts"]), e["last_dlc"], fmt_data(e["last_data"])) )
if agg != aggregate_cache: last_index = len(buf)
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)
root.after(50, tick) root.after(50, tick)
@@ -439,12 +395,9 @@ def launch_gui():
def on_close(): def on_close():
nonlocal running nonlocal running
running = False running = False
try: try: tracer.stop()
tracer.stop() except Exception: pass
except Exception: try: responder.stop()
pass
try:
responder.stop()
finally: finally:
root.destroy() root.destroy()

View File

@@ -0,0 +1,6 @@
# =============================
# app/simulation/__init__.py
# =============================
# empty package marker

View File

@@ -0,0 +1,6 @@
# =============================
# app/simulation/modules/__init__.py
# =============================
# empty package marker

View File

@@ -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

View File

@@ -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))

View File

@@ -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))

View File

@@ -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))

View File

@@ -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),
}

122
app/simulation/vehicle.py Normal file
View File

@@ -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)

12
app/tabs/__init__.py Normal file
View File

@@ -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: ...

192
app/tabs/basic.py Normal file
View File

@@ -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!

77
app/tabs/dashboard.py Normal file
View File

@@ -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

41
app/tabs/dtc.py Normal file
View File

@@ -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)

176
app/tabs/engine.py Normal file
View File

@@ -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)

66
app/tabs/gearbox.py Normal file
View File

@@ -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)

View File

@@ -1,3 +1,4 @@
# main.py
from app.gui import launch_gui from app.gui import launch_gui
if __name__ == "__main__": if __name__ == "__main__":