starting to implement realistic Vehicle simulation
This commit is contained in:
28
README.md
28
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
|
||||
|
439
app/gui.py
439
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("<<ComboboxSelected>>", 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()
|
6
app/simulation/__init__.py
Normal file
6
app/simulation/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
# =============================
|
||||
# app/simulation/__init__.py
|
||||
# =============================
|
||||
|
||||
# empty – package marker
|
6
app/simulation/modules/__init__.py
Normal file
6
app/simulation/modules/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# =============================
|
||||
# app/simulation/modules/__init__.py
|
||||
# =============================
|
||||
|
||||
|
||||
# empty – package marker
|
11
app/simulation/modules/abs.py
Normal file
11
app/simulation/modules/abs.py
Normal 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
|
141
app/simulation/modules/basic.py
Normal file
141
app/simulation/modules/basic.py
Normal 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))
|
||||
|
320
app/simulation/modules/engine.py
Normal file
320
app/simulation/modules/engine.py
Normal 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))
|
34
app/simulation/modules/gearbox.py
Normal file
34
app/simulation/modules/gearbox.py
Normal 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))
|
46
app/simulation/simulator_main.py
Normal file
46
app/simulation/simulator_main.py
Normal 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
122
app/simulation/vehicle.py
Normal 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
12
app/tabs/__init__.py
Normal 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
192
app/tabs/basic.py
Normal 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
77
app/tabs/dashboard.py
Normal 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
41
app/tabs/dtc.py
Normal 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
176
app/tabs/engine.py
Normal 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
66
app/tabs/gearbox.py
Normal 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)
|
Reference in New Issue
Block a user