starting to implement realistic Vehicle simulation
This commit is contained in:
26
README.md
26
README.md
@@ -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
|
||||||
|
433
app/gui.py
433
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
|
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()
|
||||||
if mode == "stream":
|
|
||||||
setup_columns("stream") if tree["columns"] != cols_stream else None
|
|
||||||
# append new items
|
|
||||||
buf = tracer.snapshot_stream()
|
buf = tracer.snapshot_stream()
|
||||||
# nur neue ab letztem Index
|
if mode == "stream":
|
||||||
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
|
|
||||||
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)):
|
for (cid, d) in sorted(agg.keys(), key=lambda k:(k[0], 0 if k[1]=="RX" else 1)):
|
||||||
e = agg[(cid, d)]
|
e = agg[(cid, d)]
|
||||||
tree.insert("", "end",
|
tree.insert("", "end", values=(fmt_id(cid), d, e["count"], fmt_time(e["last_ts"]), e["last_dlc"], fmt_data(e["last_data"])) )
|
||||||
values=(fmt_id(cid), d, e["count"],
|
last_index = len(buf)
|
||||||
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()
|
||||||
|
|
||||||
|
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