Compare commits
2 Commits
268dc201bf
...
6108413d7e
Author | SHA1 | Date | |
---|---|---|---|
6108413d7e | |||
0276a3fb3c |
153
app/app.py
Normal file
153
app/app.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# app/gui/app.py
|
||||
from __future__ import annotations
|
||||
import json, threading, time, tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
|
||||
from app.config import load_settings, setup_logging
|
||||
from app.obd2 import ObdResponder, make_speed_response, make_rpm_response
|
||||
from app.simulation.simulator import VehicleSimulator
|
||||
from app.simulation.ui import discover_ui_tabs
|
||||
|
||||
from app.gui.trace import TraceView
|
||||
from app.gui.dashboard import DashboardView
|
||||
from app.gui.can_panel import CanPanel
|
||||
|
||||
def launch_gui():
|
||||
cfg = load_settings(); setup_logging(cfg)
|
||||
|
||||
can_iface = (cfg.get("can", {}).get("interface")) or "can0"
|
||||
resp_id_raw = (cfg.get("can", {}).get("resp_id")) or "0x7E8"
|
||||
try: resp_id = int(resp_id_raw, 16) if isinstance(resp_id_raw, str) else int(resp_id_raw)
|
||||
except Exception: resp_id = 0x7E8
|
||||
timeout_ms = cfg.get("can", {}).get("timeout_ms", 200)
|
||||
bitrate = cfg.get("can", {}).get("baudrate", 500000)
|
||||
|
||||
# Simulator + OBD2
|
||||
sim = VehicleSimulator()
|
||||
responder = ObdResponder(interface=can_iface, resp_id=resp_id, timeout_ms=timeout_ms)
|
||||
responder.register_pid(0x0D, lambda: make_speed_response(int(round(sim.snapshot().get("speed_kmh", 0)))))
|
||||
responder.register_pid(0x0C, lambda: make_rpm_response(int(sim.snapshot().get("rpm", 0))))
|
||||
|
||||
# Physics thread
|
||||
running = True
|
||||
def physics_loop():
|
||||
last = time.monotonic()
|
||||
while running:
|
||||
now = time.monotonic()
|
||||
dt = min(0.05, max(0.0, now - last)); last = now
|
||||
sim.update(dt)
|
||||
time.sleep(0.02)
|
||||
threading.Thread(target=physics_loop, daemon=True).start()
|
||||
|
||||
# --- Tk Window ---------------------------------------------------------
|
||||
root = tk.Tk(); root.title("OBD-II ECU Simulator – SocketCAN")
|
||||
root.geometry(f"{cfg.get('ui',{}).get('window',{}).get('width',1100)}x{cfg.get('ui',{}).get('window',{}).get('height',720)}")
|
||||
|
||||
# ================== Panedwindow-Layout ==================
|
||||
# Haupt-Split: Links/Rechts (ein senkrechter Trenner)
|
||||
main_pw = tk.PanedWindow(root, orient="horizontal")
|
||||
main_pw.pack(fill="both", expand=True)
|
||||
|
||||
# linke Spalte
|
||||
left_pw = tk.PanedWindow(main_pw, orient="vertical")
|
||||
main_pw.add(left_pw)
|
||||
|
||||
# rechte Spalte
|
||||
right_pw = tk.PanedWindow(main_pw, orient="vertical")
|
||||
main_pw.add(right_pw)
|
||||
|
||||
# --- Callback-Bridge für Rebind: erst später existiert trace_view ---
|
||||
trace_ref = {"obj": None}
|
||||
def on_rebind_iface(new_iface: str):
|
||||
tv = trace_ref["obj"]
|
||||
if tv:
|
||||
tv.rebind_interface(new_iface)
|
||||
|
||||
# ----- Top-Left: CAN panel -----
|
||||
can_panel = CanPanel(
|
||||
parent=left_pw, responder=responder,
|
||||
initial_iface=can_iface, initial_resp_id=resp_id,
|
||||
initial_timeout_ms=timeout_ms, initial_bitrate=bitrate,
|
||||
on_rebind_iface=on_rebind_iface
|
||||
)
|
||||
left_pw.add(can_panel.frame)
|
||||
|
||||
# ----- Bottom-Left: Trace -----
|
||||
trace_view = TraceView(parent=left_pw, responder=responder, iface_initial=can_iface)
|
||||
trace_ref["obj"] = trace_view
|
||||
left_pw.add(trace_view.frame)
|
||||
|
||||
# ----- Top-Right: dynamic tabs -----
|
||||
nb = ttk.Notebook(right_pw)
|
||||
ui_tabs = discover_ui_tabs(nb, sim)
|
||||
for t in ui_tabs:
|
||||
title = getattr(t, "TITLE", getattr(t, "NAME", t.__class__.__name__))
|
||||
nb.add(t.frame, text=title)
|
||||
right_pw.add(nb)
|
||||
|
||||
# ----- Bottom-Right: Dashboard -----
|
||||
dash_view = DashboardView(parent=right_pw, sim=sim, refresh_ms=250)
|
||||
right_pw.add(dash_view.frame)
|
||||
|
||||
# ---------------- Menü (Load/Save) ----------------
|
||||
menubar = tk.Menu(root); filemenu = tk.Menu(menubar, tearoff=0)
|
||||
def do_load():
|
||||
path = filedialog.askopenfilename(filetypes=[("JSON","*.json"),("All","*.*")])
|
||||
if not path: return
|
||||
with open(path,"r",encoding="utf-8") as f: data = json.load(f)
|
||||
|
||||
# NEU: sowohl altes (flach) als auch neues Format ("sim"/"app") akzeptieren
|
||||
sim_block = data.get("sim") if isinstance(data, dict) else None
|
||||
if sim_block:
|
||||
sim.load_config(sim_block)
|
||||
else:
|
||||
sim.load_config(data)
|
||||
|
||||
# Tabs dürfen zusätzliche eigene Daten ziehen
|
||||
for t in ui_tabs:
|
||||
if hasattr(t, "load_from_config"):
|
||||
t.load_from_config(sim_block or data)
|
||||
|
||||
messagebox.showinfo("Simulator", "Konfiguration geladen.")
|
||||
|
||||
|
||||
def do_save():
|
||||
# NEU: vollständige Sim-Config (inkl. Defaults) + App-Settings bündeln
|
||||
sim_out = sim.export_config()
|
||||
for t in ui_tabs:
|
||||
if hasattr(t, "save_into_config"):
|
||||
t.save_into_config(sim_out)
|
||||
|
||||
out = {
|
||||
"app": cfg, # aktuelle App-Settings (CAN/UI/Logging etc.)
|
||||
"sim": sim_out, # vollständige Modul-Configs (mit Defaults)
|
||||
}
|
||||
|
||||
path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON","*.json")])
|
||||
if not path: return
|
||||
with open(path,"w",encoding="utf-8") as f: json.dump(out, f, indent=2)
|
||||
messagebox.showinfo("Simulator", "Konfiguration gespeichert.")
|
||||
|
||||
filemenu.add_command(label="Konfiguration laden…", command=do_load)
|
||||
filemenu.add_command(label="Konfiguration speichern…", command=do_save)
|
||||
filemenu.add_separator(); filemenu.add_command(label="Beenden", command=root.destroy)
|
||||
menubar.add_cascade(label="Datei", menu=filemenu); root.config(menu=menubar)
|
||||
|
||||
# Title updater
|
||||
def tick_title():
|
||||
snap = sim.snapshot()
|
||||
root.title(f"OBD-II ECU Simulator – RPM {int(snap.get('rpm',0))} | {int(round(snap.get('speed_kmh',0)))} km/h")
|
||||
try: root.after(300, tick_title)
|
||||
except tk.TclError: pass
|
||||
tick_title()
|
||||
|
||||
def on_close():
|
||||
nonlocal running
|
||||
running = False
|
||||
try: trace_view.stop()
|
||||
except Exception: pass
|
||||
try: responder.stop()
|
||||
finally:
|
||||
root.destroy()
|
||||
root.protocol("WM_DELETE_WINDOW", on_close)
|
||||
root.mainloop()
|
405
app/gui.py
405
app/gui.py
@@ -1,405 +0,0 @@
|
||||
# Project layout (drop-in)
|
||||
#
|
||||
# app/
|
||||
# ├─ gui.py ← new main GUI with Simulator tabs + Save/Load
|
||||
# ├─ config.py (unchanged)
|
||||
# ├─ can.py (unchanged)
|
||||
# ├─ obd2.py (unchanged; GUI registers PIDs)
|
||||
# ├─ tabs/
|
||||
# │ ├─ __init__.py
|
||||
# │ ├─ basic.py ← base/vehicle tab (ignition, mass, type, ABS/TCS)
|
||||
# │ ├─ engine.py ← engine tab
|
||||
# │ ├─ gearbox.py ← gearbox tab
|
||||
# │ └─ dtc.py ← DTC toggles tab
|
||||
# └─ simulation/
|
||||
# ├─ __init__.py
|
||||
# ├─ simulator_main.py ← VehicleSimulator wrapper used by GUI
|
||||
# ├─ vehicle.py ← core state + module orchestration
|
||||
# └─ modules/
|
||||
# ├─ __init__.py
|
||||
# ├─ engine.py
|
||||
# ├─ gearbox.py
|
||||
# └─ abs_.py
|
||||
|
||||
|
||||
# =============================
|
||||
# app/gui.py
|
||||
# =============================
|
||||
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from collections import deque
|
||||
import subprocess
|
||||
|
||||
import can # for trace
|
||||
|
||||
from .config import load_settings, setup_logging
|
||||
from .obd2 import ObdResponder, make_speed_response, make_rpm_response
|
||||
from .can import (
|
||||
list_can_ifaces, link_up, link_down, link_state, link_kind,
|
||||
have_cap_netadmin
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
# ---------- CAN Trace Collector ----------
|
||||
class TraceCollector:
|
||||
def __init__(self, channel: str):
|
||||
self.channel = channel
|
||||
self.bus = None
|
||||
self._run = threading.Event(); self._run.set()
|
||||
self._thread = threading.Thread(target=self._rx_loop, name="CAN-TRACE", daemon=True)
|
||||
self.stream_buffer = deque(maxlen=2000)
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def _open(self):
|
||||
self._close()
|
||||
self.bus = can.interface.Bus(channel=self.channel, interface="socketcan")
|
||||
|
||||
def _close(self):
|
||||
try:
|
||||
if self.bus: self.bus.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
self.bus = None
|
||||
|
||||
def start(self):
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._run.clear()
|
||||
try:
|
||||
self._thread.join(timeout=1.0)
|
||||
except RuntimeError:
|
||||
pass
|
||||
self._close()
|
||||
|
||||
def _rx_loop(self):
|
||||
backoff = 0.5
|
||||
while self._run.is_set():
|
||||
if self.bus is None:
|
||||
if link_state(self.channel) == "UP":
|
||||
try:
|
||||
self._open(); backoff = 0.5
|
||||
except Exception:
|
||||
time.sleep(backoff); backoff = min(5.0, backoff*1.7)
|
||||
continue
|
||||
else:
|
||||
time.sleep(0.5); continue
|
||||
try:
|
||||
msg = self.bus.recv(0.05)
|
||||
if msg and not msg.is_error_frame and not msg.is_remote_frame:
|
||||
ts = time.time()
|
||||
with self.lock:
|
||||
self.stream_buffer.append((ts, msg.arbitration_id, msg.dlc, bytes(msg.data)))
|
||||
except (can.CanOperationError, OSError):
|
||||
self._close(); time.sleep(0.5)
|
||||
except Exception:
|
||||
time.sleep(0.05)
|
||||
|
||||
def snapshot_stream(self):
|
||||
with self.lock:
|
||||
return list(self.stream_buffer)
|
||||
|
||||
|
||||
# =============================
|
||||
# GUI Launcher (reworked layout)
|
||||
# =============================
|
||||
|
||||
def launch_gui():
|
||||
cfg = load_settings()
|
||||
logger = setup_logging(cfg)
|
||||
|
||||
# Config
|
||||
can_iface = (cfg.get("can", {}).get("interface")) or "can0"
|
||||
resp_id_raw = (cfg.get("can", {}).get("resp_id")) or "0x7E8"
|
||||
try:
|
||||
resp_id = int(resp_id_raw, 16) if isinstance(resp_id_raw, str) else int(resp_id_raw)
|
||||
except Exception:
|
||||
resp_id = 0x7E8
|
||||
timeout_ms = cfg.get("can", {}).get("timeout_ms", 200)
|
||||
bitrate = cfg.get("can", {}).get("baudrate", 500000)
|
||||
|
||||
# Simulator
|
||||
sim = VehicleSimulator()
|
||||
|
||||
# OBD2 responder
|
||||
responder = ObdResponder(interface=can_iface, resp_id=resp_id, timeout_ms=timeout_ms, logger=logger)
|
||||
responder.register_pid(0x0D, lambda: make_speed_response(int(round(sim.snapshot()["speed_kmh"]))))
|
||||
responder.register_pid(0x0C, lambda: make_rpm_response(int(sim.snapshot()["rpm"])))
|
||||
|
||||
# Physics thread
|
||||
running = True
|
||||
def physics_loop():
|
||||
last = time.monotonic()
|
||||
while running:
|
||||
now = time.monotonic()
|
||||
dt = min(0.05, max(0.0, now - last))
|
||||
last = now
|
||||
sim.update(dt)
|
||||
time.sleep(0.02)
|
||||
threading.Thread(target=physics_loop, daemon=True).start()
|
||||
|
||||
tracer = TraceCollector(can_iface); tracer.start()
|
||||
|
||||
# Tk window
|
||||
root = tk.Tk(); root.title("OBD-II ECU Simulator – SocketCAN")
|
||||
root.geometry(f"{cfg.get('ui',{}).get('window',{}).get('width',1100)}x{cfg.get('ui',{}).get('window',{}).get('height',720)}")
|
||||
|
||||
family = cfg.get("ui", {}).get("font_family", "TkDefaultFont")
|
||||
size = int(cfg.get("ui", {}).get("font_size", 10))
|
||||
style = ttk.Style()
|
||||
style.configure("TLabel", font=(family, size))
|
||||
style.configure("Header.TLabel", font=(family, size+2, "bold"))
|
||||
style.configure("Small.TLabel", font=(family, max(8, size-1)))
|
||||
|
||||
# Menu (Load/Save config)
|
||||
menubar = tk.Menu(root)
|
||||
filemenu = tk.Menu(menubar, tearoff=0)
|
||||
|
||||
def action_load():
|
||||
path = filedialog.askopenfilename(filetypes=[("JSON", "*.json"), ("All", "*.*")])
|
||||
if not path: return
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
for tab in sim_tabs: tab.load_from_config(data)
|
||||
sim.load_config(data)
|
||||
messagebox.showinfo("Simulator", "Konfiguration geladen.")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Laden fehlgeschlagen", str(e))
|
||||
|
||||
def action_save():
|
||||
cfg_dict = sim.export_config()
|
||||
for tab in sim_tabs: tab.save_into_config(cfg_dict)
|
||||
path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON", "*.json"), ("All", "*.*")])
|
||||
if not path: return
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg_dict, f, indent=2)
|
||||
messagebox.showinfo("Simulator", "Konfiguration gespeichert.")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Speichern fehlgeschlagen", str(e))
|
||||
|
||||
filemenu.add_command(label="Konfiguration laden…", command=action_load)
|
||||
filemenu.add_command(label="Konfiguration speichern…", command=action_save)
|
||||
filemenu.add_separator(); filemenu.add_command(label="Beenden", command=root.destroy)
|
||||
menubar.add_cascade(label="Datei", menu=filemenu)
|
||||
root.config(menu=menubar)
|
||||
|
||||
# ===== New Layout ======================================================
|
||||
# Grid with two rows:
|
||||
# Row 0: Left = CAN settings, Right = Simulator tabs
|
||||
# Row 1: Trace spanning both columns
|
||||
# ======================================================================
|
||||
|
||||
root.columnconfigure(0, weight=1)
|
||||
root.columnconfigure(1, weight=2)
|
||||
root.rowconfigure(1, weight=1) # trace grows
|
||||
|
||||
# --- LEFT: CAN Settings ------------------------------------------------
|
||||
can_frame = ttk.LabelFrame(root, text="CAN & Settings", padding=8)
|
||||
can_frame.grid(row=0, column=0, sticky="nsew", padx=(8,4), pady=(8,4))
|
||||
for i in range(2): can_frame.columnconfigure(i, weight=1)
|
||||
|
||||
ttk.Label(can_frame, text="Interface").grid(row=0, column=0, sticky="w")
|
||||
iface_var = tk.StringVar(value=can_iface)
|
||||
iface_list = list_can_ifaces() or [can_iface]
|
||||
iface_dd = ttk.Combobox(can_frame, textvariable=iface_var, values=iface_list, state="readonly", width=12)
|
||||
iface_dd.grid(row=0, column=1, sticky="ew", padx=(6,0))
|
||||
|
||||
def refresh_ifaces():
|
||||
lst = list_can_ifaces()
|
||||
if not lst:
|
||||
messagebox.showwarning("Interfaces", "Keine can*/vcan* Interfaces gefunden.")
|
||||
return
|
||||
iface_dd.config(values=lst)
|
||||
ttk.Button(can_frame, text="Refresh", command=refresh_ifaces).grid(row=0, column=2, padx=(6,0))
|
||||
|
||||
ttk.Label(can_frame, text="RESP-ID (hex)").grid(row=1, column=0, sticky="w")
|
||||
resp_var = tk.StringVar(value=f"0x{resp_id:03X}")
|
||||
ttk.Entry(can_frame, textvariable=resp_var, width=10).grid(row=1, column=1, sticky="w", padx=(6,0))
|
||||
|
||||
ttk.Label(can_frame, text="Timeout (ms)").grid(row=2, column=0, sticky="w")
|
||||
to_var = tk.IntVar(value=int(timeout_ms))
|
||||
ttk.Spinbox(can_frame, from_=10, to=5000, increment=10, textvariable=to_var, width=8).grid(row=2, column=1, sticky="w", padx=(6,0))
|
||||
|
||||
ttk.Label(can_frame, text="Bitrate").grid(row=3, column=0, sticky="w")
|
||||
br_var = tk.IntVar(value=int(bitrate))
|
||||
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))
|
||||
|
||||
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")
|
||||
|
||||
kind_label = ttk.Label(can_frame, text=f"Kind: {link_kind(can_iface)}", style="Small.TLabel")
|
||||
kind_label.grid(row=4, column=0, columnspan=3, sticky="w", pady=(4,0))
|
||||
|
||||
# Buttons row
|
||||
btns = ttk.Frame(can_frame)
|
||||
btns.grid(row=5, column=0, columnspan=3, sticky="ew", pady=(8,0))
|
||||
btns.columnconfigure(0, weight=0)
|
||||
btns.columnconfigure(1, weight=0)
|
||||
btns.columnconfigure(2, weight=1)
|
||||
|
||||
def do_link_up():
|
||||
try:
|
||||
kind_label.config(text=f"Kind: {link_kind(iface_var.get())}")
|
||||
if link_state(iface_var.get()) == "UP":
|
||||
messagebox.showinfo("CAN", f"{iface_var.get()} ist bereits UP"); return
|
||||
link_up(iface_var.get(), bitrate=br_var.get(), fd=False, set_params=set_params.get())
|
||||
try:
|
||||
out = subprocess.check_output(["ip", "-details", "-json", "link", "show", iface_var.get()], text=True)
|
||||
info = json.loads(out)[0]
|
||||
bt = (info.get("linkinfo", {}) or {}).get("info_data", {}).get("bittiming") or {}
|
||||
br = bt.get("bitrate"); sp = bt.get("sample-point")
|
||||
if br:
|
||||
messagebox.showinfo("CAN", f"{iface_var.get()} ist UP @ {br} bit/s (sample-point {sp})")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
messagebox.showerror("CAN", f"Link UP fehlgeschlagen:{e}")
|
||||
|
||||
def do_link_down():
|
||||
try:
|
||||
if link_state(iface_var.get()) == "DOWN":
|
||||
messagebox.showinfo("CAN", f"{iface_var.get()} ist bereits DOWN"); return
|
||||
link_down(iface_var.get()); messagebox.showinfo("CAN", f"{iface_var.get()} ist DOWN")
|
||||
except Exception as e:
|
||||
messagebox.showerror("CAN", f"Link DOWN fehlgeschlagen:{e}")
|
||||
|
||||
ttk.Button(btns, text="Link UP", command=do_link_up).grid(row=0, column=0, sticky="w")
|
||||
ttk.Button(btns, text="Link DOWN", command=do_link_down).grid(row=0, column=1, sticky="w", padx=(6,0))
|
||||
|
||||
def do_rebind():
|
||||
nonlocal can_iface, resp_id, timeout_ms, bitrate, tracer
|
||||
can_iface = iface_var.get()
|
||||
try:
|
||||
new_resp = int(resp_var.get(), 16)
|
||||
except Exception:
|
||||
messagebox.showerror("RESP-ID", "Bitte gültige Hex-Zahl, z.B. 0x7E8"); return
|
||||
resp_id = new_resp; timeout_ms = to_var.get(); bitrate = br_var.get()
|
||||
try:
|
||||
responder.rebind(interface=can_iface, resp_id=resp_id)
|
||||
tracer.stop(); tracer = TraceCollector(can_iface); tracer.start()
|
||||
messagebox.showinfo("CAN", f"Responder neu gebunden: {can_iface}, RESP 0x{resp_id:03X}")
|
||||
except Exception as e:
|
||||
messagebox.showerror("CAN", f"Rebind fehlgeschlagen:{e}")
|
||||
|
||||
ttk.Button(btns, text="Responder Rebind", command=do_rebind).grid(row=0, column=2, sticky="w", padx=(12,0))
|
||||
|
||||
ttk.Label(can_frame, text=f"CAP_NET_ADMIN: {'yes' if have_cap_netadmin() else 'no'}", style="Small.TLabel")\
|
||||
.grid(row=6, column=0, columnspan=3, sticky="w", pady=(6,0))
|
||||
|
||||
# --- RIGHT: Simulator Tabs --------------------------------------------
|
||||
right = ttk.Frame(root)
|
||||
right.grid(row=0, column=1, sticky="nsew", padx=(4,8), pady=(8,4))
|
||||
right.columnconfigure(0, weight=1)
|
||||
right.rowconfigure(0, weight=1)
|
||||
|
||||
nb_sim = ttk.Notebook(right)
|
||||
nb_sim.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
basics_tab = BasicTab(nb_sim, sim)
|
||||
engine_tab = EngineTab(nb_sim, sim)
|
||||
gearbox_tab = GearboxTab(nb_sim, sim)
|
||||
dtc_tab = DtcTab(nb_sim, sim)
|
||||
dashboard_tab = DashboardTab(nb_sim, sim)
|
||||
sim_tabs = [basics_tab, engine_tab, gearbox_tab, dtc_tab, dashboard_tab]
|
||||
|
||||
nb_sim.add(basics_tab.frame, text="Basisdaten")
|
||||
nb_sim.add(engine_tab.frame, text="Motor")
|
||||
nb_sim.add(gearbox_tab.frame, text="Getriebe")
|
||||
nb_sim.add(dtc_tab.frame, text="DTCs")
|
||||
nb_sim.add(dashboard_tab.frame, text="Dashboard")
|
||||
|
||||
# --- BOTTOM: Trace (spans both columns) -------------------------------
|
||||
trace_frame = ttk.LabelFrame(root, text="CAN Trace", padding=6)
|
||||
trace_frame.grid(row=1, column=0, columnspan=2, sticky="nsew", padx=8, pady=(0,8))
|
||||
trace_frame.columnconfigure(0, weight=1)
|
||||
trace_frame.rowconfigure(1, weight=1)
|
||||
|
||||
ctrl = ttk.Frame(trace_frame)
|
||||
ctrl.grid(row=0, column=0, sticky="ew", pady=(0,4))
|
||||
ctrl.columnconfigure(5, weight=1)
|
||||
|
||||
mode_var = tk.StringVar(value="stream")
|
||||
ttk.Label(ctrl, text="Modus:").grid(row=0, column=0, sticky="w")
|
||||
ttk.Combobox(ctrl, textvariable=mode_var, state="readonly", width=10, values=["stream","aggregate"])\
|
||||
.grid(row=0, column=1, sticky="w", padx=(4,12))
|
||||
|
||||
paused = tk.BooleanVar(value=False)
|
||||
ttk.Checkbutton(ctrl, text="Pause", variable=paused).grid(row=0, column=2, sticky="w")
|
||||
|
||||
autoscroll = tk.BooleanVar(value=True)
|
||||
ttk.Checkbutton(ctrl, text="Auto-Scroll", variable=autoscroll).grid(row=0, column=3, sticky="w")
|
||||
|
||||
tree = ttk.Treeview(trace_frame, columns=("time","dir","id","dlc","data"), show="headings", height=10)
|
||||
tree.grid(row=1, column=0, sticky="nsew")
|
||||
sb_y = ttk.Scrollbar(trace_frame, orient="vertical", command=tree.yview)
|
||||
tree.configure(yscrollcommand=sb_y.set); sb_y.grid(row=1, column=1, sticky="ns")
|
||||
|
||||
def fmt_time(ts: float) -> str:
|
||||
lt = time.localtime(ts)
|
||||
return time.strftime("%H:%M:%S", lt) + f".{int((ts%1)*1000):03d}"
|
||||
def fmt_id(i: int) -> str: return f"0x{i:03X}"
|
||||
def fmt_data(b: bytes) -> str: return " ".join(f"{x:02X}" for x in b)
|
||||
|
||||
last_index = 0
|
||||
def tick():
|
||||
nonlocal last_index
|
||||
snap = sim.snapshot()
|
||||
# Optional: könnte in eine Statusbar ausgelagert werden
|
||||
root.title(f"OBD-II ECU Simulator – RPM {int(snap['rpm'])} | {int(round(snap['speed_kmh']))} km/h")
|
||||
|
||||
if not paused.get():
|
||||
mode = mode_var.get()
|
||||
buf = tracer.snapshot_stream()
|
||||
if mode == "stream":
|
||||
for ts, cid, dlc, data in buf[last_index:]:
|
||||
d = "RX" if cid == 0x7DF else ("TX" if cid == responder.resp_id else "?")
|
||||
tree.insert("", "end", values=(fmt_time(ts), d, fmt_id(cid), dlc, fmt_data(data)))
|
||||
if autoscroll.get() and buf[last_index:]:
|
||||
tree.see(tree.get_children()[-1])
|
||||
else:
|
||||
tree.delete(*tree.get_children())
|
||||
agg = {}
|
||||
for ts, cid, dlc, data in buf:
|
||||
d = "RX" if cid == 0x7DF else ("TX" if cid == responder.resp_id else "?")
|
||||
key = (cid, d)
|
||||
e = agg.get(key)
|
||||
if not e:
|
||||
agg[key] = {"count":1, "last_ts":ts, "last_dlc":dlc, "last_data":data}
|
||||
else:
|
||||
e["count"] += 1
|
||||
if ts >= e["last_ts"]:
|
||||
e["last_ts"], e["last_dlc"], e["last_data"] = ts, dlc, data
|
||||
for (cid, d) in sorted(agg.keys(), key=lambda k:(k[0], 0 if k[1]=="RX" else 1)):
|
||||
e = agg[(cid, d)]
|
||||
tree.insert("", "end", values=(fmt_id(cid), d, e["count"], fmt_time(e["last_ts"]), e["last_dlc"], fmt_data(e["last_data"])) )
|
||||
last_index = len(buf)
|
||||
|
||||
root.after(50, tick)
|
||||
|
||||
tick()
|
||||
|
||||
def on_close():
|
||||
nonlocal running
|
||||
running = False
|
||||
try: tracer.stop()
|
||||
except Exception: pass
|
||||
try: responder.stop()
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
root.protocol("WM_DELETE_WINDOW", on_close)
|
||||
root.mainloop()
|
0
app/gui/__init__.py
Normal file
0
app/gui/__init__.py
Normal file
108
app/gui/can_panel.py
Normal file
108
app/gui/can_panel.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# app/gui/can_panel.py
|
||||
from __future__ import annotations
|
||||
import json, subprocess, tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
from app.can import (
|
||||
list_can_ifaces, link_up, link_down, link_state, link_kind, have_cap_netadmin
|
||||
)
|
||||
|
||||
class CanPanel:
|
||||
"""Fixes CAN- & Responder-Panel (oben links)."""
|
||||
def __init__(self, parent, responder, initial_iface: str, initial_resp_id: int,
|
||||
initial_timeout_ms: int, initial_bitrate: int, on_rebind_iface=None):
|
||||
self.responder = responder
|
||||
self.on_rebind_iface = on_rebind_iface or (lambda iface: None)
|
||||
|
||||
self.frame = ttk.LabelFrame(parent, text="CAN & Settings", padding=8)
|
||||
for i in range(3): self.frame.columnconfigure(i, weight=1)
|
||||
|
||||
# Interface
|
||||
ttk.Label(self.frame, text="Interface").grid(row=0, column=0, sticky="w")
|
||||
self.iface_var = tk.StringVar(value=initial_iface)
|
||||
self.iface_dd = ttk.Combobox(self.frame, textvariable=self.iface_var,
|
||||
values=list_can_ifaces() or [initial_iface],
|
||||
state="readonly", width=14)
|
||||
self.iface_dd.grid(row=0, column=1, sticky="ew", padx=(6,0))
|
||||
ttk.Button(self.frame, text="Refresh", command=self._refresh_ifaces).grid(row=0, column=2, sticky="w")
|
||||
|
||||
# RESP-ID
|
||||
ttk.Label(self.frame, text="RESP-ID (hex)").grid(row=1, column=0, sticky="w")
|
||||
self.resp_var = tk.StringVar(value=f"0x{initial_resp_id:03X}")
|
||||
ttk.Entry(self.frame, textvariable=self.resp_var, width=10).grid(row=1, column=1, sticky="w", padx=(6,0))
|
||||
|
||||
# Timeout
|
||||
ttk.Label(self.frame, text="Timeout (ms)").grid(row=2, column=0, sticky="w")
|
||||
self.to_var = tk.IntVar(value=int(initial_timeout_ms))
|
||||
ttk.Spinbox(self.frame, from_=10, to=5000, increment=10, textvariable=self.to_var, width=10)\
|
||||
.grid(row=2, column=1, sticky="w", padx=(6,0))
|
||||
|
||||
# Bitrate
|
||||
ttk.Label(self.frame, text="Bitrate").grid(row=3, column=0, sticky="w")
|
||||
self.br_var = tk.IntVar(value=int(initial_bitrate))
|
||||
ttk.Spinbox(self.frame, from_=20000, to=1000000, increment=10000, textvariable=self.br_var, width=12)\
|
||||
.grid(row=3, column=1, sticky="w", padx=(6,0))
|
||||
|
||||
self.set_params = tk.BooleanVar(value=True)
|
||||
ttk.Checkbutton(self.frame, text="Bitrate beim UP setzen", variable=self.set_params)\
|
||||
.grid(row=3, column=2, sticky="w")
|
||||
|
||||
self.kind_label = ttk.Label(self.frame, text=f"Kind: {link_kind(initial_iface)}", style="Small.TLabel")
|
||||
self.kind_label.grid(row=4, column=0, columnspan=3, sticky="w", pady=(4,0))
|
||||
|
||||
# Buttons
|
||||
btns = ttk.Frame(self.frame); btns.grid(row=5, column=0, columnspan=3, sticky="ew", pady=(8,0))
|
||||
ttk.Button(btns, text="Link UP", command=self._do_link_up).grid(row=0, column=0, sticky="w")
|
||||
ttk.Button(btns, text="Link DOWN", command=self._do_link_down).grid(row=0, column=1, sticky="w", padx=(6,0))
|
||||
ttk.Button(btns, text="Responder Rebind", command=self._do_rebind).grid(row=0, column=2, sticky="w", padx=(12,0))
|
||||
|
||||
ttk.Label(self.frame, text=f"CAP_NET_ADMIN: {'yes' if have_cap_netadmin() else 'no'}",
|
||||
style="Small.TLabel").grid(row=6, column=0, columnspan=3, sticky="w", pady=(6,0))
|
||||
|
||||
# ---- actions ----
|
||||
def _refresh_ifaces(self):
|
||||
lst = list_can_ifaces()
|
||||
if not lst:
|
||||
messagebox.showwarning("Interfaces", "Keine can*/vcan* Interfaces gefunden.")
|
||||
return
|
||||
self.iface_dd.config(values=lst)
|
||||
|
||||
def _do_link_up(self):
|
||||
iface = self.iface_var.get()
|
||||
try:
|
||||
self.kind_label.config(text=f"Kind: {link_kind(iface)}")
|
||||
if link_state(iface) == "UP":
|
||||
messagebox.showinfo("CAN", f"{iface} ist bereits UP"); return
|
||||
link_up(iface, bitrate=self.br_var.get(), fd=False, set_params=self.set_params.get())
|
||||
try:
|
||||
out = subprocess.check_output(["ip","-details","-json","link","show",iface], text=True)
|
||||
info = json.loads(out)[0]
|
||||
bt = (info.get("linkinfo", {}) or {}).get("info_data", {}).get("bittiming") or {}
|
||||
br = bt.get("bitrate"); sp = bt.get("sample-point")
|
||||
if br:
|
||||
messagebox.showinfo("CAN", f"{iface} ist UP @ {br} bit/s (SP {sp})")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
messagebox.showerror("CAN", f"Link UP fehlgeschlagen:\n{e}")
|
||||
|
||||
def _do_link_down(self):
|
||||
iface = self.iface_var.get()
|
||||
try:
|
||||
if link_state(iface) == "DOWN":
|
||||
messagebox.showinfo("CAN", f"{iface} ist bereits DOWN"); return
|
||||
link_down(iface); messagebox.showinfo("CAN", f"{iface} ist DOWN")
|
||||
except Exception as e:
|
||||
messagebox.showerror("CAN", f"Link DOWN fehlgeschlagen:\n{e}")
|
||||
|
||||
def _do_rebind(self):
|
||||
iface = self.iface_var.get()
|
||||
try:
|
||||
new_resp = int(self.resp_var.get(), 16)
|
||||
except Exception:
|
||||
messagebox.showerror("RESP-ID", "Bitte gültige Hex-Zahl, z.B. 0x7E8"); return
|
||||
try:
|
||||
self.responder.rebind(interface=iface, resp_id=new_resp, timeout_ms=self.to_var.get())
|
||||
self.on_rebind_iface(iface) # TraceView umhängen
|
||||
messagebox.showinfo("CAN", f"Responder neu gebunden: {iface}, RESP 0x{new_resp:03X}")
|
||||
except Exception as e:
|
||||
messagebox.showerror("CAN", f"Rebind fehlgeschlagen:\n{e}")
|
65
app/gui/dashboard.py
Normal file
65
app/gui/dashboard.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# app/gui/dashboard.py
|
||||
from __future__ import annotations
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
class DashboardView:
|
||||
def __init__(self, parent, sim, refresh_ms: int = 250):
|
||||
self.sim = sim
|
||||
self.frame = ttk.LabelFrame(parent, text="Dashboard", padding=6)
|
||||
self.refresh_ms = refresh_ms
|
||||
|
||||
cols = ("label", "value", "unit", "key", "module")
|
||||
self.tree = ttk.Treeview(self.frame, columns=cols, show="headings")
|
||||
for c, w in zip(cols, (160, 80, 40, 160, 80)):
|
||||
self.tree.heading(c, text=c.capitalize())
|
||||
self.tree.column(c, width=w, anchor="w")
|
||||
self.tree.grid(row=0, column=0, sticky="nsew")
|
||||
sb_y = ttk.Scrollbar(self.frame, orient="vertical", command=self.tree.yview)
|
||||
self.tree.configure(yscrollcommand=sb_y.set); sb_y.grid(row=0, column=1, sticky="ns")
|
||||
|
||||
self.frame.columnconfigure(0, weight=1)
|
||||
self.frame.rowconfigure(0, weight=1)
|
||||
|
||||
self._rows = {}
|
||||
self._tick()
|
||||
|
||||
def _tick(self):
|
||||
snap = self.sim.v.dashboard_snapshot()
|
||||
specs = snap.get("specs", {})
|
||||
values = snap.get("values", {})
|
||||
# sort by priority then label
|
||||
ordered = sorted(specs.values(), key=lambda s: (s.get("priority", 100), s.get("label", s["key"])))
|
||||
|
||||
seen_keys = set()
|
||||
for spec in ordered:
|
||||
k = spec["key"]; seen_keys.add(k)
|
||||
label = spec.get("label", k)
|
||||
unit = spec.get("unit", "") or ""
|
||||
fmt = spec.get("fmt")
|
||||
src = spec.get("source", "")
|
||||
val = values.get(k, "")
|
||||
if fmt and isinstance(val, (int, float)):
|
||||
try:
|
||||
val = format(val, fmt)
|
||||
except Exception:
|
||||
pass
|
||||
row_id = self._rows.get(k)
|
||||
row_vals = (label, val, unit, k, src)
|
||||
if row_id is None:
|
||||
row_id = self.tree.insert("", "end", values=row_vals)
|
||||
self._rows[k] = row_id
|
||||
else:
|
||||
self.tree.item(row_id, values=row_vals)
|
||||
|
||||
# delete rows that disappeared
|
||||
for k, rid in list(self._rows.items()):
|
||||
if k not in seen_keys:
|
||||
try: self.tree.delete(rid)
|
||||
except Exception: pass
|
||||
self._rows.pop(k, None)
|
||||
|
||||
try:
|
||||
self.frame.after(self.refresh_ms, self._tick)
|
||||
except tk.TclError:
|
||||
pass
|
161
app/gui/trace.py
Normal file
161
app/gui/trace.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# app/gui/trace.py
|
||||
from __future__ import annotations
|
||||
import time, json, threading
|
||||
from collections import deque
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
import can
|
||||
|
||||
from app.can import list_can_ifaces, link_up, link_down, link_state, link_kind, have_cap_netadmin
|
||||
|
||||
class TraceCollector:
|
||||
def __init__(self, channel: str):
|
||||
self.channel = channel
|
||||
self.bus = None
|
||||
self._run = threading.Event(); self._run.set()
|
||||
self._thread = threading.Thread(target=self._rx_loop, name="CAN-TRACE", daemon=True)
|
||||
self.stream_buffer = deque(maxlen=2000)
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def _open(self):
|
||||
self._close()
|
||||
self.bus = can.interface.Bus(channel=self.channel, interface="socketcan")
|
||||
|
||||
def _close(self):
|
||||
try:
|
||||
if self.bus: self.bus.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
self.bus = None
|
||||
|
||||
def start(self):
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._run.clear()
|
||||
try: self._thread.join(timeout=1.0)
|
||||
except RuntimeError: pass
|
||||
self._close()
|
||||
|
||||
def _rx_loop(self):
|
||||
backoff = 0.5
|
||||
while self._run.is_set():
|
||||
if self.bus is None:
|
||||
if link_state(self.channel) == "UP":
|
||||
try:
|
||||
self._open(); backoff = 0.5
|
||||
except Exception:
|
||||
time.sleep(backoff); backoff = min(5.0, backoff*1.7)
|
||||
continue
|
||||
else:
|
||||
time.sleep(0.5); continue
|
||||
try:
|
||||
msg = self.bus.recv(0.05)
|
||||
if msg and not msg.is_error_frame and not msg.is_remote_frame:
|
||||
ts = time.time()
|
||||
with self.lock:
|
||||
self.stream_buffer.append((ts, msg.arbitration_id, msg.dlc, bytes(msg.data)))
|
||||
except (can.CanOperationError, OSError):
|
||||
self._close(); time.sleep(0.5)
|
||||
except Exception:
|
||||
time.sleep(0.05)
|
||||
|
||||
def snapshot_stream(self):
|
||||
with self.lock:
|
||||
return list(self.stream_buffer)
|
||||
|
||||
class TraceView:
|
||||
def __init__(self, parent, responder, iface_initial: str):
|
||||
self.responder = responder
|
||||
self.collector = TraceCollector(iface_initial); self.collector.start()
|
||||
|
||||
self.frame = ttk.LabelFrame(parent, text="CAN Trace", padding=6)
|
||||
self.frame.columnconfigure(0, weight=1)
|
||||
self.frame.rowconfigure(1, weight=1)
|
||||
|
||||
# controls
|
||||
ctrl = ttk.Frame(self.frame); ctrl.grid(row=0, column=0, sticky="ew", pady=(0,4))
|
||||
ctrl.columnconfigure(6, weight=1)
|
||||
|
||||
self.mode_var = tk.StringVar(value="stream")
|
||||
ttk.Label(ctrl, text="Modus:").grid(row=0, column=0, sticky="w")
|
||||
ttk.Combobox(ctrl, textvariable=self.mode_var, state="readonly", width=10, values=["stream","aggregate"])\
|
||||
.grid(row=0, column=1, sticky="w", padx=(4,12))
|
||||
|
||||
self.paused = tk.BooleanVar(value=False)
|
||||
ttk.Checkbutton(ctrl, text="Pause", variable=self.paused).grid(row=0, column=2, sticky="w")
|
||||
|
||||
self.autoscroll = tk.BooleanVar(value=True)
|
||||
ttk.Checkbutton(ctrl, text="Auto-Scroll", variable=self.autoscroll).grid(row=0, column=3, sticky="w")
|
||||
|
||||
ttk.Button(ctrl, text="Clear", command=self._clear).grid(row=0, column=4, padx=(8,0))
|
||||
|
||||
# tree
|
||||
self.tree = ttk.Treeview(self.frame, columns=("time","dir","id","dlc","data"), show="headings", height=10)
|
||||
self.tree.grid(row=1, column=0, sticky="nsew")
|
||||
sb_y = ttk.Scrollbar(self.frame, orient="vertical", command=self.tree.yview)
|
||||
self.tree.configure(yscrollcommand=sb_y.set); sb_y.grid(row=1, column=1, sticky="ns")
|
||||
|
||||
self._last_index = 0
|
||||
self._tick()
|
||||
|
||||
def _clear(self):
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
self._last_index = 0
|
||||
|
||||
def _tick(self):
|
||||
if not self.paused.get():
|
||||
buf = self.collector.snapshot_stream()
|
||||
mode = self.mode_var.get()
|
||||
if mode == "stream":
|
||||
for ts, cid, dlc, data in buf[self._last_index:]:
|
||||
d = "RX" if cid == 0x7DF else ("TX" if cid == self.responder.resp_id else "?")
|
||||
self.tree.insert("", "end", values=(self._fmt_time(ts), d, self._fmt_id(cid), dlc, self._fmt_data(data)))
|
||||
if self.autoscroll.get() and buf[self._last_index:]:
|
||||
self.tree.see(self.tree.get_children()[-1])
|
||||
else:
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
agg = {}
|
||||
for ts, cid, dlc, data in buf:
|
||||
d = "RX" if cid == 0x7DF else ("TX" if cid == self.responder.resp_id else "?")
|
||||
key = (cid, d)
|
||||
e = agg.get(key)
|
||||
if not e:
|
||||
agg[key] = {"count":1, "last_ts":ts, "last_dlc":dlc, "last_data":data}
|
||||
else:
|
||||
e["count"] += 1
|
||||
if ts >= e["last_ts"]:
|
||||
e["last_ts"], e["last_dlc"], e["last_data"] = ts, dlc, data
|
||||
for (cid, d) in sorted(agg.keys(), key=lambda k:(k[0], 0 if k[1]=="RX" else 1)):
|
||||
e = agg[(cid, d)]
|
||||
self.tree.insert("", "end",
|
||||
values=(self._fmt_id(cid), d, e["count"],
|
||||
self._fmt_time(e["last_ts"]), e["last_dlc"], self._fmt_data(e["last_data"])))
|
||||
self._last_index = len(buf)
|
||||
try:
|
||||
self.frame.after(50, self._tick)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _fmt_time(ts: float) -> str:
|
||||
import time as _t
|
||||
lt = _t.localtime(ts)
|
||||
return _t.strftime("%H:%M:%S", lt) + f".{int((ts%1)*1000):03d}"
|
||||
@staticmethod
|
||||
def _fmt_id(i: int) -> str: return f"0x{i:03X}"
|
||||
@staticmethod
|
||||
def _fmt_data(b: bytes) -> str: return " ".join(f"{x:02X}" for x in b)
|
||||
|
||||
def rebind_interface(self, iface: str):
|
||||
# Collector auf neues Interface umhängen
|
||||
try:
|
||||
self.collector.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self.collector = TraceCollector(iface)
|
||||
self.collector.start()
|
||||
|
||||
def stop(self):
|
||||
try: self.collector.stop()
|
||||
except Exception: pass
|
@@ -1,11 +0,0 @@
|
||||
# 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
|
@@ -1,141 +1,185 @@
|
||||
# =============================
|
||||
# app/simulation/modules/basic.py
|
||||
# =============================
|
||||
from __future__ import annotations
|
||||
from ..vehicle import Vehicle, Module
|
||||
import bisect
|
||||
from app.simulation.simulator import Module, Vehicle
|
||||
import bisect, math
|
||||
|
||||
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))
|
||||
s = 0.0 if soc is None else max(0.0, min(1.0, float(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]
|
||||
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)
|
||||
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
|
||||
"""
|
||||
PRIO = 90
|
||||
NAME = "basic"
|
||||
|
||||
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) -----
|
||||
# Dashboard
|
||||
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_soc", label="Batterie SOC", fmt=".3f", 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)
|
||||
v.register_metric("elec_load_total_a", label="Verbrauch netto", unit="A", fmt=".2f", source="basic", priority=15)
|
||||
# neue Detailmetriken (optional in UI)
|
||||
v.register_metric("elec_load_elx_a", label="Verbrauch ELX", unit="A", fmt=".2f", source="basic", priority=16)
|
||||
v.register_metric("elec_load_batt_a", label="Verbrauch Batt", unit="A", fmt=".2f", source="basic", priority=17)
|
||||
|
||||
# ----- Read config/state -----
|
||||
# Config
|
||||
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))
|
||||
|
||||
alt_eta_mech = float(econf.get("alternator_mech_efficiency", 0.55))
|
||||
alt_ratio = float(econf.get("alternator_pulley_ratio", 1.0))
|
||||
alt_drag_c0 = float(econf.get("alternator_drag_nm_idle", 0.15))
|
||||
alt_drag_c1 = float(econf.get("alternator_drag_nm_per_krpm", 0.05))
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
# State
|
||||
prev_ign = str(v.ensure("prev_ignition", v.get("ignition", "ON")))
|
||||
ign = v.ensure("ignition", "ON")
|
||||
rpm = float(v.ensure("rpm", 1200))
|
||||
v.set("prev_ignition", ign)
|
||||
|
||||
rpm = float(v.ensure("rpm", 1200.0))
|
||||
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 -----
|
||||
# START → ON Auto-Übergang
|
||||
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"
|
||||
v.set("ignition", "ON"); ign = "ON"
|
||||
else:
|
||||
self._crank_timer = 0.0
|
||||
|
||||
# ----- Früh-Exit: OFF/ACC -> Bus AUS, Batterie „ruht“ -----
|
||||
# --- Akkumulierte Lasten aus beiden Bussen ---
|
||||
# Verbraucher pushen jetzt wahlweise:
|
||||
# - v.push("elec.current_elx", +A, source="...")
|
||||
# - v.push("elec.current_batt", +A, source="...")
|
||||
elx_load_a = max(0.0, v.acc_total("elec.current_elx"))
|
||||
batt_load_a = max(0.0, v.acc_total("elec.current_batt"))
|
||||
|
||||
# Grundlast hängt an ELX (nur bei ON/START aktiv)
|
||||
if ign in ("ON", "START"):
|
||||
v.push("elec.current_elx", +0.5, source="basic:base")
|
||||
|
||||
# --- OFF/ACC: ELX tot, Batterie lebt weiter ---
|
||||
if ign in ("OFF", "ACC"):
|
||||
# nur Batteriepfad zählt
|
||||
total_batt_a = max(0.0, v.acc_total("elec.current_batt"))
|
||||
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)
|
||||
|
||||
# Batterie entlädt nach I*dt
|
||||
batt_i = total_batt_a
|
||||
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
|
||||
batt_v = max(10.0, min(15.5, batt_v))
|
||||
|
||||
v.set("battery_voltage", batt_v)
|
||||
v.set("elx_voltage", 0.0) # Bus aus
|
||||
v.set("system_voltage", batt_v) # für „alles was noch lebt“ = Batterie
|
||||
v.set("battery_soc", soc)
|
||||
v.set("battery_current_a", batt_i)
|
||||
v.set("alternator_current_a", 0.0)
|
||||
v.set("elec_load_total_a", 0.0)
|
||||
v.set("battery_soc", round(soc, 3))
|
||||
v.set("elec_load_elx_a", 0.0)
|
||||
v.set("elec_load_batt_a", total_batt_a)
|
||||
v.set("elec_load_total_a", total_batt_a)
|
||||
|
||||
# keine Limamechanik aktiv
|
||||
v.set("engine_ext_torque_nm", 0.0)
|
||||
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)
|
||||
# --- Ab hier: Zündung ON/START (ELX aktiv) ---
|
||||
elx_load_a = max(0.0, v.acc_total("elec.current_elx"))
|
||||
batt_load_a = max(0.0, v.acc_total("elec.current_batt"))
|
||||
net_load_a = elx_load_a + batt_load_a # Gesamtverbrauch
|
||||
|
||||
# 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:
|
||||
# 3) Lima-Kapazität
|
||||
if rpm < alt_cut_in:
|
||||
alt_cap_a = 0.0
|
||||
elif rpm >= alt_full:
|
||||
alt_cap_a = alt_rated_a
|
||||
else:
|
||||
frac = (rpm - alt_cut_in) / max(1, (alt_full - alt_cut_in))
|
||||
alt_cap_a = alt_rated_a * max(0.0, min(1.0, frac))
|
||||
|
||||
# Batterie-OCV
|
||||
ocv = _ocv_from_soc(soc, batt_ocv_tbl)
|
||||
desired_charge_a = ((alt_reg_v - ocv) / batt_rint) if alt_cap_a > 0.0 else 0.0
|
||||
if desired_charge_a < 0.0: desired_charge_a = 0.0
|
||||
|
||||
# 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)
|
||||
alt_i = min(alt_needed_a, alt_cap_a) if alt_cap_a > 0.0 else 0.0
|
||||
|
||||
# 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
|
||||
# Lima liefert in ELX-Bus (Quelle = negativ)
|
||||
if alt_i > 0.0:
|
||||
v.push("elec.current_elx", -alt_i, source="alternator")
|
||||
|
||||
# Rest geht von Batterie (angenommen gleicher Bus)
|
||||
remaining = net_load_a - alt_i
|
||||
if alt_cap_a > 0.0 and remaining <= 0.0:
|
||||
# Überschuss -> lädt Batt (wir zählen Lade-Strom negativ am Batterieklemmen)
|
||||
batt_i = remaining # ≤ 0
|
||||
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
|
||||
batt_i = max(0.0, remaining)
|
||||
bus_v = ocv - batt_i * batt_rint
|
||||
|
||||
# SOC-Update (Ah-Bilanz)
|
||||
# SOC integrieren
|
||||
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)
|
||||
batt_v = ocv - batt_i * batt_rint
|
||||
|
||||
# Klammern/Spiegeln
|
||||
# Clamps
|
||||
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))
|
||||
|
||||
# Mechanische Last Lima
|
||||
tau_base = 0.0
|
||||
if rpm > 0.0:
|
||||
tau_base = alt_drag_c0 + (rpm / 1000.0) * alt_drag_c1
|
||||
omega_engine = 2.0 * math.pi * max(0.0, rpm) / 60.0
|
||||
omega_alt = omega_engine * max(0.1, alt_ratio)
|
||||
tau_el = 0.0
|
||||
if alt_i > 0.0 and omega_alt > 1e-2 and alt_eta_mech > 0.05:
|
||||
p_el = alt_i * bus_v
|
||||
p_mech = p_el / alt_eta_mech
|
||||
tau_el = p_mech / omega_alt
|
||||
tau_alt = max(0.0, tau_base) + max(0.0, tau_el)
|
||||
if tau_alt > 0.0:
|
||||
v.push("engine.torque_load_nm", +tau_alt, source="alternator")
|
||||
|
||||
# Outputs
|
||||
v.set("battery_voltage", batt_v)
|
||||
v.set("elx_voltage", bus_v)
|
||||
v.set("system_voltage", bus_v)
|
||||
v.set("battery_soc", soc)
|
||||
v.set("battery_current_a", batt_i)
|
||||
v.set("alternator_current_a", min(alt_i, alt_cap_a))
|
||||
v.set("elec_load_elx_a", elx_load_a)
|
||||
v.set("elec_load_batt_a", batt_load_a)
|
||||
v.set("elec_load_total_a", net_load_a)
|
||||
|
202
app/simulation/modules/cooling.py
Normal file
202
app/simulation/modules/cooling.py
Normal file
@@ -0,0 +1,202 @@
|
||||
# =============================
|
||||
# app/simulation/modules/cooling.py
|
||||
# =============================
|
||||
from __future__ import annotations
|
||||
from app.simulation.simulator import Module, Vehicle
|
||||
import math
|
||||
|
||||
COOLING_DEFAULTS = {
|
||||
# Thermostat
|
||||
"thermostat_open_c": 85.0,
|
||||
"thermostat_full_c": 100.0,
|
||||
|
||||
# Radiator & Fahrtwind (W/K)
|
||||
"rad_base_u_w_per_k": 150.0,
|
||||
"ram_air_gain_per_kmh": 5.0,
|
||||
|
||||
# Lüfterstufe 1
|
||||
"fan1_on_c": 96.0,
|
||||
"fan1_off_c": 92.0,
|
||||
"fan1_power_w": 120.0,
|
||||
"fan1_airflow_gain": 250.0,
|
||||
|
||||
# Lüfterstufe 2
|
||||
"fan2_on_c": 102.0,
|
||||
"fan2_off_c": 98.0,
|
||||
"fan2_power_w": 180.0,
|
||||
"fan2_airflow_gain": 400.0,
|
||||
|
||||
# Wärmekapazitäten (J/K)
|
||||
"coolant_thermal_cap_j_per_k": 90_000.0,
|
||||
"oil_thermal_cap_j_per_k": 75_000.0,
|
||||
|
||||
# Öl↔Kühlmittel Kopplung / kleine Öl-Abstrahlung
|
||||
"oil_coolant_u_w_per_k": 120.0,
|
||||
"oil_to_amb_u_w_per_k": 10.0,
|
||||
|
||||
# Anteil der Motorwärme ans Kühlmittel
|
||||
"engine_heat_frac_to_coolant": 0.7,
|
||||
|
||||
# Versorgung / Nachlauf
|
||||
"fan_power_feed": "elx", # "elx" oder "battery"
|
||||
"fan_afterrun_enable": False,
|
||||
"fan_afterrun_threshold_c": 105.0,
|
||||
"fan_afterrun_max_s": 300.0
|
||||
}
|
||||
|
||||
class CoolingModule(Module):
|
||||
PRIO = 25
|
||||
NAME = "cooling"
|
||||
|
||||
def apply(self, v: Vehicle, dt: float) -> None:
|
||||
# --- Config lesen
|
||||
cfg = dict(COOLING_DEFAULTS);
|
||||
cfg.update(v.config.get("cooling", {}))
|
||||
|
||||
# --- Dashboard-Metriken registrieren (einmal pro Tick ist ok, Idempotenz erwartet) ---
|
||||
# Temps
|
||||
v.register_metric("coolant_temp", unit="°C", fmt=".1f", label="Kühlmitteltemp.", source="cooling", priority=30)
|
||||
v.register_metric("oil_temp", unit="°C", fmt=".1f", label="Öltemperatur", source="cooling", priority=31)
|
||||
# Thermostat & Kühlerwirkung
|
||||
v.register_metric("thermostat_open_pct", unit="%", fmt=".0f", label="Thermostat Öffnung", source="cooling", priority=32)
|
||||
v.register_metric("cooling_u_eff_w_per_k", unit="W/K", fmt=".0f", label="Eff. Kühlerleistung", source="cooling", priority=33)
|
||||
# Lüfterzustände + Last
|
||||
v.register_metric("fan1_on", unit="", fmt="", label="Lüfter 1", source="cooling", priority=34)
|
||||
v.register_metric("fan2_on", unit="", fmt="", label="Lüfter 2", source="cooling", priority=35)
|
||||
v.register_metric("cooling_fan_power_w", unit="W", fmt=".0f", label="Lüfterleistung", source="cooling", priority=36)
|
||||
v.register_metric("cooling_fan_current_a", unit="A", fmt=".2f", label="Lüfterstrom", source="cooling", priority=37)
|
||||
|
||||
# --- Konfigurationsparameter ---
|
||||
t_open = float(cfg.get("thermostat_open_c", COOLING_DEFAULTS["thermostat_open_c"]))
|
||||
t_full = float(cfg.get("thermostat_full_c", COOLING_DEFAULTS["thermostat_full_c"]))
|
||||
rad_base = float(cfg.get("rad_base_u_w_per_k", COOLING_DEFAULTS["rad_base_u_w_per_k"]))
|
||||
ram_gain = float(cfg.get("ram_air_gain_per_kmh", COOLING_DEFAULTS["ram_air_gain_per_kmh"]))
|
||||
|
||||
f1_on = float(cfg.get("fan1_on_c", COOLING_DEFAULTS["fan1_on_c"])); f1_off = float(cfg.get("fan1_off_c", COOLING_DEFAULTS["fan1_off_c"]))
|
||||
f1_w = float(cfg.get("fan1_power_w", COOLING_DEFAULTS["fan1_power_w"])); f1_air = float(cfg.get("fan1_airflow_gain", COOLING_DEFAULTS["fan1_airflow_gain"]))
|
||||
f2_on = float(cfg.get("fan2_on_c", COOLING_DEFAULTS["fan2_on_c"])); f2_off = float(cfg.get("fan2_off_c", COOLING_DEFAULTS["fan2_off_c"]))
|
||||
f2_w = float(cfg.get("fan2_power_w", COOLING_DEFAULTS["fan2_power_w"])); f2_air = float(cfg.get("fan2_airflow_gain", COOLING_DEFAULTS["fan2_airflow_gain"]))
|
||||
|
||||
Cc = float(cfg.get("coolant_thermal_cap_j_per_k", COOLING_DEFAULTS["coolant_thermal_cap_j_per_k"]))
|
||||
Coil = float(cfg.get("oil_thermal_cap_j_per_k", COOLING_DEFAULTS["oil_thermal_cap_j_per_k"]))
|
||||
Uoc = float(cfg.get("oil_coolant_u_w_per_k", COOLING_DEFAULTS["oil_coolant_u_w_per_k"]))
|
||||
Uoil_amb = float(cfg.get("oil_to_amb_u_w_per_k", COOLING_DEFAULTS["oil_to_amb_u_w_per_k"]))
|
||||
frac_to_coolant = float(cfg.get("engine_heat_frac_to_coolant", COOLING_DEFAULTS["engine_heat_frac_to_coolant"]))
|
||||
|
||||
# Versorgung / Nachlauf
|
||||
feed = str(cfg.get("fan_power_feed", COOLING_DEFAULTS["fan_power_feed"]))
|
||||
allow_ar = bool(cfg.get("fan_afterrun_enable", COOLING_DEFAULTS["fan_afterrun_enable"]))
|
||||
ar_thr = float(cfg.get("fan_afterrun_threshold_c", COOLING_DEFAULTS["fan_afterrun_threshold_c"]))
|
||||
ar_max = float(cfg.get("fan_afterrun_max_s", COOLING_DEFAULTS["fan_afterrun_max_s"]))
|
||||
|
||||
ign = str(v.ensure("ignition", "OFF"))
|
||||
|
||||
# --- State / Inputs ---
|
||||
amb = float(v.ensure("ambient_c", 20.0))
|
||||
speed = float(v.ensure("speed_kmh", 0.0))
|
||||
elx_v = float(v.get("elx_voltage", 0.0)) or 0.0
|
||||
batt_v= float(v.get("battery_voltage", 12.5)) or 12.5
|
||||
|
||||
# Temperaturen liegen hier (Cooling ist Owner)
|
||||
Tcool = float(v.ensure("coolant_temp", amb))
|
||||
Toil = float(v.ensure("oil_temp", amb))
|
||||
|
||||
# vom Motor gepushte Wärmeleistung (W); nur positive Leistung wird aufgeteilt
|
||||
q_in_total = v.acc_total("thermal.heat_w")
|
||||
q_cool_in = max(0.0, q_in_total) * frac_to_coolant
|
||||
q_oil_in = max(0.0, q_in_total) * (1.0 - frac_to_coolant)
|
||||
|
||||
# --- Thermostat-Öffnung (0..1) ---
|
||||
if Tcool <= t_open: tfrac = 0.0
|
||||
elif Tcool >= t_full: tfrac = 1.0
|
||||
else: tfrac = (Tcool - t_open) / max(1e-6, (t_full - t_open))
|
||||
|
||||
# --- Lüfter-Hysterese ---
|
||||
fan1_on_prev = bool(v.ensure("fan1_on", False))
|
||||
fan2_on_prev = bool(v.ensure("fan2_on", False))
|
||||
fan1_on = fan1_on_prev
|
||||
fan2_on = fan2_on_prev
|
||||
|
||||
if tfrac > 0.0:
|
||||
if not fan1_on and Tcool >= f1_on: fan1_on = True
|
||||
if fan1_on and Tcool <= f1_off: fan1_on = False
|
||||
|
||||
if not fan2_on and Tcool >= f2_on: fan2_on = True
|
||||
if fan2_on and Tcool <= f2_off: fan2_on = False
|
||||
else:
|
||||
fan1_on = False; fan2_on = False
|
||||
|
||||
# --- Nachlauf-Entscheidung ---
|
||||
# Basis: Lüfter je nach Temp/Hysterese an/aus (fan1_on/fan2_on).
|
||||
# Jetzt prüfen, ob die *Versorgung* verfügbar ist:
|
||||
# - feed=="elx": nur wenn ign in ("ON","START") und elx_v > 1V
|
||||
# - feed=="battery": immer, aber bei OFF nur wenn allow_afterrun & heiß
|
||||
fans_request = (fan1_on or fan2_on)
|
||||
|
||||
fans_powered = False
|
||||
bus_for_fans = "elx"
|
||||
bus_v = elx_v
|
||||
|
||||
if feed == "elx":
|
||||
if ign in ("ON","START") and elx_v > 1.0 and fans_request:
|
||||
fans_powered = True
|
||||
bus_for_fans = "elx"; bus_v = elx_v
|
||||
else: # battery
|
||||
if ign in ("ON","START"):
|
||||
if fans_request:
|
||||
fans_powered = True
|
||||
bus_for_fans = "batt"; bus_v = batt_v
|
||||
self._afterrun_timer_s = 0.0
|
||||
else:
|
||||
# OFF/ACC -> Nachlauf, wenn erlaubt und heiß
|
||||
hot = (Tcool >= ar_thr)
|
||||
if allow_ar and (hot or self._afterrun_timer_s > 0.0):
|
||||
if self._afterrun_timer_s <= 0.0:
|
||||
self._afterrun_timer_s = ar_max
|
||||
if fans_request or hot:
|
||||
fans_powered = True
|
||||
bus_for_fans = "batt"; bus_v = batt_v
|
||||
self._afterrun_timer_s = max(0.0, self._afterrun_timer_s - dt)
|
||||
else:
|
||||
self._afterrun_timer_s = 0.0
|
||||
|
||||
# --- Eff. Kühlerleistung (W/K) ---
|
||||
U_rad = (rad_base + ram_gain * max(0.0, speed)) * tfrac
|
||||
if fan1_on: U_rad += f1_air
|
||||
if fan2_on: U_rad += f2_air
|
||||
|
||||
# --- Elektrische Last je nach Bus ---
|
||||
fan_power_w = 0.0
|
||||
if fans_powered and bus_v > 1.0:
|
||||
if fan1_on: fan_power_w += f1_w
|
||||
if fan2_on: fan_power_w += f2_w
|
||||
if fan_power_w > 0.0:
|
||||
i = fan_power_w / bus_v
|
||||
if bus_for_fans == "elx":
|
||||
v.push("elec.current_elx", +i, source="fan")
|
||||
else:
|
||||
v.push("elec.current_batt", +i, source="fan_afterrun" if ign in ("OFF","ACC") else "fan")
|
||||
|
||||
# --- Wärmeströme (positiv Richtung Medium) ---
|
||||
q_rad = - max(0.0, U_rad * (Tcool - amb)) # Kühler zieht aus Kühlmittel
|
||||
q_oil_x = - Uoc * (Toil - Tcool) # Öl↔Kühlmittel
|
||||
q_oil_amb = - max(0.0, Uoil_amb * (Toil - amb)) # Öl an Umgebung
|
||||
|
||||
# --- Integration ---
|
||||
dT_cool = (q_cool_in + q_rad - q_oil_x) * dt / max(1e-3, Cc)
|
||||
dT_oil = (q_oil_in + q_oil_x + q_oil_amb) * dt / max(1e-3, Coil)
|
||||
Tcool += dT_cool
|
||||
Toil += dT_oil
|
||||
|
||||
# --- Setzen & Dashboard-Infos ---
|
||||
v.set("coolant_temp", float(Tcool))
|
||||
v.set("oil_temp", float(Toil))
|
||||
|
||||
# Anzeige-friendly zusätzlich in %
|
||||
v.set("thermostat_open_pct", float(tfrac * 100.0))
|
||||
v.set("cooling_u_eff_w_per_k", float(U_rad))
|
||||
|
||||
v.set("fan1_on", bool(fan1_on))
|
||||
v.set("fan2_on", bool(fan2_on))
|
||||
v.set("cooling_fan_power_w", float(fan_power_w))
|
||||
v.set("cooling_fan_current_a", float(fan_power_w / max(1.0, bus_v)))
|
@@ -3,10 +3,9 @@
|
||||
# =============================
|
||||
|
||||
from __future__ import annotations
|
||||
from ..vehicle import Vehicle, Module
|
||||
import random, math
|
||||
from app.simulation.simulator import Module, Vehicle
|
||||
import math, random
|
||||
|
||||
# Ein einziger Wahrheitsanker für alle Defaults:
|
||||
ENGINE_DEFAULTS = {
|
||||
# Basis
|
||||
"idle_rpm": 1200,
|
||||
@@ -14,42 +13,47 @@ ENGINE_DEFAULTS = {
|
||||
"rpm_rise_per_s": 4000,
|
||||
"rpm_fall_per_s": 3000,
|
||||
"throttle_curve": "linear",
|
||||
# Starter
|
||||
|
||||
# Starter / Startlogik
|
||||
"starter_rpm_nominal": 250.0,
|
||||
"starter_voltage_min": 10.5,
|
||||
"start_rpm_threshold": 250.0, # <- fix niedriger, damit anspringt
|
||||
"start_rpm_threshold": 210.0,
|
||||
"stall_rpm": 500.0,
|
||||
# Thermik
|
||||
|
||||
# Thermische Einflüsse (nur fürs Derating/Viskosität benutzt)
|
||||
"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
|
||||
|
||||
# Öl / Öldruck
|
||||
"oil_pressure_idle_bar": 1.2,
|
||||
"oil_pressure_slope_bar_per_krpm": 0.8,
|
||||
"oil_pressure_off_floor_bar": 0.2,
|
||||
# Leistung
|
||||
|
||||
# Leistungsdaten
|
||||
"engine_power_kw": 60.0,
|
||||
"torque_peak_rpm": 7000.0,
|
||||
# DBW
|
||||
|
||||
# Drive-by-wire / Regler
|
||||
"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
|
||||
"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)
|
||||
|
||||
# UI
|
||||
"throttle_pedal_pct": 0.0,
|
||||
}
|
||||
|
||||
class EngineModule(Module):
|
||||
PRIO = 20
|
||||
NAME = "engine"
|
||||
"""
|
||||
Erweiterte Motormodellierung mit realistischem Jitter & Drive-by-Wire:
|
||||
- OFF/ACC/ON/START Logik, Starten/Abwürgen
|
||||
@@ -69,17 +73,15 @@ class EngineModule(Module):
|
||||
"""
|
||||
|
||||
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._oil_p_tau = 0.25 # Zeitkonstante Öldruck
|
||||
# DBW intern
|
||||
self._plate_pct = 5.0
|
||||
self._tc_i = 0.0
|
||||
# AR(1)-Noise
|
||||
self._rpm_noise = 0.0
|
||||
|
||||
# ---- helpers ----------------------------------------------------------
|
||||
def _curve(self, t: float, mode: str) -> float:
|
||||
if mode == "progressive": return t**1.5
|
||||
if mode == "aggressive": return t**0.7
|
||||
@@ -88,34 +90,34 @@ class EngineModule(Module):
|
||||
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)
|
||||
return max(0.0, t_max * math.sin(x))
|
||||
|
||||
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 _visco(self, 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
|
||||
if temp_c <= 20: return 0.6 + (temp_c + 10.0) * (0.2/30.0)
|
||||
return 0.8 + (temp_c - 20.0) * (0.2/70.0)
|
||||
|
||||
# ---- main -------------------------------------------------------------
|
||||
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"]))
|
||||
# --- Config ---
|
||||
idle = float(e.get("idle_rpm", ENGINE_DEFAULTS["idle_rpm"]))
|
||||
maxr = float(e.get("max_rpm", ENGINE_DEFAULTS["max_rpm"]))
|
||||
rise = float(e.get("rpm_rise_per_s", ENGINE_DEFAULTS["rpm_rise_per_s"]))
|
||||
fall = float(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"]))
|
||||
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"]))
|
||||
|
||||
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"]))
|
||||
@@ -125,9 +127,6 @@ class EngineModule(Module):
|
||||
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"]))
|
||||
@@ -144,182 +143,145 @@ class EngineModule(Module):
|
||||
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)
|
||||
rpm = float(v.ensure("rpm", 0.0))
|
||||
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))
|
||||
cool = float(v.ensure("coolant_temp", ambient)) # nur lesen
|
||||
oil = float(v.ensure("oil_temp", ambient)) # nur lesen
|
||||
oil_p = float(v.ensure("oil_pressure", 0.0))
|
||||
|
||||
ext_torque = float(v.ensure("engine_ext_torque_nm", 0.0))
|
||||
# externe Momente (Alternator/Getriebe/…)
|
||||
torque_load = max(0.0, v.acc_total("engine.torque_load_nm"))
|
||||
torque_load = max(torque_load, float(v.get("engine_ext_torque_nm", 0.0))) # legacy fallback
|
||||
|
||||
# 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)
|
||||
v.register_metric("rpm", unit="RPM", fmt=".1f", label="Drehzahl", source="engine", priority=20)
|
||||
v.register_metric("oil_pressure", unit="bar", fmt=".2f", label="Öldruck", source="engine", priority=42)
|
||||
v.register_metric("engine_available_torque_nm", unit="Nm", fmt=".0f", label="Verfügbares Motormoment", source="engine", priority=43)
|
||||
v.register_metric("engine_torque_load_nm", unit="Nm", fmt=".0f", label="Lastmoment ges.", source="engine", priority=44)
|
||||
v.register_metric("engine_net_torque_nm", unit="Nm", fmt=".0f", label="Netto Motormoment", source="engine", priority=45)
|
||||
v.register_metric("throttle_pedal_pct", unit="%", fmt=".0f", label="Gaspedal", source="engine", priority=46)
|
||||
v.register_metric("throttle_plate_pct", unit="%", fmt=".0f", label="Drosselklappe", source="engine", priority=47)
|
||||
|
||||
# Hilfsfunktionen
|
||||
def visco(temp_c: float) -> float:
|
||||
# -10°C -> 0.6, 20°C -> 0.8, 90°C -> 1.0 (linear segmentiert)
|
||||
if temp_c <= -10: return 0.6
|
||||
if temp_c >= 90: return 1.0
|
||||
if temp_c <= 20:
|
||||
# -10..20°C: 0.6 -> 0.8 (30 K Schritt → +0.2 => +0.006666.. pro K)
|
||||
return 0.6 + (temp_c + 10.0) * (0.2 / 30.0)
|
||||
# 20..90°C: 0.8 -> 1.0 (70 K Schritt → +0.2)
|
||||
return 0.8 + (temp_c - 20.0) * (0.2 / 70.0)
|
||||
|
||||
# Spannungsfaktor: unter vmin kein Crank, bei 12.6V ~1.0
|
||||
# --- Start-/Ziel-RPM Logik ---
|
||||
# Starter-Viskositätseinfluss
|
||||
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)
|
||||
crank_rpm = starter_nom * vfac * self._visco(oil)
|
||||
|
||||
# sinnvolle effektive Startschwelle (unabhängig von stall)
|
||||
start_rpm_min = 0.15 * idle # 15 % vom Idle
|
||||
start_rpm_max = 0.45 * idle # 45 % vom Idle
|
||||
# effektive Startschwelle (15..45% Idle)
|
||||
start_rpm_min = 0.15 * idle
|
||||
start_rpm_max = 0.45 * idle
|
||||
start_rpm_th_eff = max(start_rpm_min, min(start_rpm_th, start_rpm_max))
|
||||
|
||||
# --- Ziel-RPM bestimmen ---
|
||||
if ign in ("OFF", "ACC"):
|
||||
self._running = False
|
||||
target_rpm = 0.0
|
||||
|
||||
elif ign == "START":
|
||||
target_rpm = crank_rpm # wie gehabt
|
||||
# Greifen, sobald Schwelle erreicht und Spannung reicht
|
||||
target_rpm = crank_rpm
|
||||
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 noch genug Drehzahl anliegt
|
||||
if not self._running and rpm >= max(0.15 * idle, start_rpm_th_eff * 0.9):
|
||||
if not self._running and rpm >= max(0.15*idle, start_rpm_th_eff*0.9):
|
||||
self._running = True
|
||||
|
||||
if self._running:
|
||||
cold_add = max(0.0, min(cold_gain_max, (90.0 - cool) * cold_gain_per_deg))
|
||||
cold_add = max(0.0, min(ENGINE_DEFAULTS["idle_cold_gain_max"],
|
||||
(90.0 - cool) * cold_gain_per_deg))
|
||||
idle_eff = idle + cold_add
|
||||
target_rpm = max(idle_eff, min(maxr, rpm))
|
||||
else:
|
||||
target_rpm = 0.0
|
||||
|
||||
# --- verfügbare Motorleistung / Moment (ohne Last) ---
|
||||
# --- Basis-Moment & Derating ---
|
||||
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)
|
||||
# --- DBW (PI auf Torque-Anteil) ---
|
||||
demand = self._curve(pedal/100.0, thr_curve)
|
||||
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
|
||||
torque_frac = 0.0 if base_torque <= 1e-6 else (torque_avail / (base_torque * temp_derate))
|
||||
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
|
||||
self._tc_i *= 0.95
|
||||
|
||||
plate_cmd = self._plate_pct + (torque_kp * err + self._tc_i) * 100.0 # in %-Punkte
|
||||
plate_cmd = self._plate_pct + (torque_kp * err + self._tc_i) * 100.0
|
||||
plate_cmd = max(plate_target_min, min(100.0, plate_cmd))
|
||||
a_tau = min(1.0, dt / max(1e-3, plate_tau))
|
||||
self._plate_pct = (1.0 - a_tau) * self._plate_pct + a_tau * 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
|
||||
# aktualisiertes Moment
|
||||
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))
|
||||
net_torque = max(0.0, avail_torque - max(0.0, torque_load))
|
||||
|
||||
# --- 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
|
||||
# --- Wärmeleistung pushen (W) ---
|
||||
# mechanische Leistung:
|
||||
mech_power_w = net_torque * (2.0 * math.pi * rpm / 60.0)
|
||||
# grober Wirkungsgrad (0.24..0.34 je nach Pedal/Kennlinie)
|
||||
eta = 0.24 + 0.10 * self._curve(pedal/100.0, thr_curve)
|
||||
eta = max(0.05, min(0.45, eta))
|
||||
fuel_power_w = mech_power_w / max(1e-3, eta)
|
||||
heat_w = max(0.0, fuel_power_w - mech_power_w)
|
||||
# Idle-Basiswärme, damit im Leerlauf nicht auskühlt:
|
||||
idle_heat_w = 1500.0 * (rpm / max(1.0, idle))
|
||||
heat_w = max(heat_w, idle_heat_w)
|
||||
v.push("thermal.heat_w", +heat_w, source="engine")
|
||||
|
||||
# --- Ziel-RPM aus Netto-Moment ---
|
||||
if ign == "ON" and self._running:
|
||||
cold_add = max(0.0, min(cold_gain_max, (90.0 - cool) * cold_gain_per_deg))
|
||||
cold_add = max(0.0, min(ENGINE_DEFAULTS["idle_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)))
|
||||
denom = (base_torque * temp_derate + 1e-6)
|
||||
torque_norm = 0.0 if denom <= 1e-8 else max(0.0, min(1.0, net_torque / denom))
|
||||
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)
|
||||
# Inertia
|
||||
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
|
||||
# Stall
|
||||
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:
|
||||
# --- Öldruck ---
|
||||
if self._running and rpm > 0.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:
|
||||
elif ign == "START" and target_rpm > 0.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
|
||||
# --- RPM-Jitter ---
|
||||
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
|
||||
eta_n = random.uniform(-1.0, 1.0)
|
||||
self._rpm_noise = (1.0 - b) * self._rpm_noise + b * eta_n
|
||||
k = max(0.0, min(1.0, rpm / max(1.0, maxr)))
|
||||
amp = (1.0 - k)*amp_idle + k*amp_hi
|
||||
|
||||
amp = (1.0 - k)*jitter_idle_amp + k*jitter_hi_amp
|
||||
rpm += self._rpm_noise * amp
|
||||
else:
|
||||
# Kein Jitter: Noise langsam abklingen
|
||||
self._rpm_noise *= 0.9
|
||||
|
||||
# --- Klammern & Setzen -----------------------------------------------------
|
||||
# --- Clamp & Set ---
|
||||
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))
|
||||
oil_p = max(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("rpm", float(rpm))
|
||||
# Temperaturen NICHT setzen – CoolingModule ist owner!
|
||||
v.set("oil_pressure", float(oil_p))
|
||||
v.set("engine_available_torque_nm", float(avail_torque))
|
||||
v.set("engine_torque_load_nm", float(torque_load))
|
||||
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))
|
||||
|
@@ -1,34 +1,227 @@
|
||||
# =============================
|
||||
# app/simulation/modules/gearbox.py
|
||||
# =============================
|
||||
from __future__ import annotations
|
||||
from ..vehicle import Vehicle, Module
|
||||
from app.simulation.simulator import Module, Vehicle
|
||||
import math
|
||||
|
||||
GEARBOX_DEFAULTS = {
|
||||
# Übersetzungen
|
||||
"primary_ratio": 1.84, # Kurbelwelle -> Getriebeeingang
|
||||
# Gangübersetzungen (Index 0 = Neutral/N = 0.0)
|
||||
"gear_ratios": [0.0, 2.60, 1.90, 1.55, 1.35, 1.20, 1.07],
|
||||
# Ketten-/Endübersetzung via Zähne
|
||||
"front_sprocket_teeth": 16,
|
||||
"rear_sprocket_teeth": 45,
|
||||
|
||||
# Rad/Reifen
|
||||
"wheel_radius_m": 0.31, # dynamischer Halbmesser
|
||||
"drivetrain_efficiency": 0.93, # Wirkungsgrad Kurbel -> Rad
|
||||
"rpm_couple_gain": 0.20, # wie stark Engine-RPM zum Rad synchronisiert wird (0..1)
|
||||
|
||||
# Fahrzeug / Widerstände
|
||||
"rolling_c": 0.015, # Rollwiderstandskoeff.
|
||||
"air_density": 1.2, # kg/m^3
|
||||
"aero_cd": 0.6,
|
||||
"frontal_area_m2": 0.6,
|
||||
|
||||
# Kupplung (auto)
|
||||
"clutch_max_torque_nm": 220.0, # max übertragbares Drehmoment (bei c=1)
|
||||
"clutch_aggressiveness": 0.6, # 0..1 (0 = sehr sanft, 1 = sehr bissig)
|
||||
"clutch_curve": "linear", # "linear" | "progressive" | "soft"
|
||||
"clutch_drag_nm": 1.0, # Restschleppmoment bei getrennt
|
||||
"shift_time_s": 0.15, # Schaltzeit, während der entkuppelt wird
|
||||
"sync_rpm_band": 200.0, # RPM-Band, in dem als „synchron“ gilt
|
||||
|
||||
# Reifenhaftung (einfaches Limit)
|
||||
"tire_mu_peak": 1.10, # statischer Reibkoeffizient (Peak)
|
||||
"tire_mu_slide": 0.85, # Gleitreibung
|
||||
"rear_static_weight_frac": 0.60 # statischer Lastanteil auf Antriebsrad
|
||||
}
|
||||
|
||||
class GearboxModule(Module):
|
||||
"""Koppelt Engine-RPM ↔ Wheel-Speed; registriert speed_kmh/gear fürs Dashboard."""
|
||||
PRIO = 30
|
||||
NAME = "gearbox"
|
||||
|
||||
def __init__(self):
|
||||
self.speed_tau = 0.3
|
||||
self.rpm_couple = 0.2
|
||||
# interner Zustand
|
||||
self._clutch = 0.0 # 0..1
|
||||
self._shift_t = 0.0
|
||||
self._target_gear = None
|
||||
self._wheel_v = 0.0 # m/s
|
||||
|
||||
def apply(self, v: Vehicle, dt: float) -> None:
|
||||
# Dashboard registration
|
||||
# --- Dashboard-Registrierungen ---
|
||||
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)
|
||||
v.register_metric("gear", label="Gang", fmt="", source="gearbox", priority=25)
|
||||
v.register_metric("clutch_pct", label="Kupplung", unit="%", fmt=".0f", source="gearbox", priority=26)
|
||||
v.register_metric("wheel_slip_pct", label="Reifenschlupf", unit="%", fmt=".0f", source="gearbox", priority=27)
|
||||
|
||||
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])
|
||||
# --- Config / Inputs ---
|
||||
gb = dict(GEARBOX_DEFAULTS)
|
||||
gb.update(v.config.get("gearbox", {}))
|
||||
|
||||
if g <= 0 or g >= len(ratios):
|
||||
speed = max(0.0, speed - 6.0*dt)
|
||||
v.set("speed_kmh", speed)
|
||||
return
|
||||
primary = float(gb["primary_ratio"])
|
||||
gear_ratios = list(gb["gear_ratios"])
|
||||
z_f = int(gb["front_sprocket_teeth"])
|
||||
z_r = int(gb["rear_sprocket_teeth"])
|
||||
final = (z_r / max(1, z_f))
|
||||
|
||||
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)
|
||||
r_w = float(gb["wheel_radius_m"])
|
||||
eta = float(gb["drivetrain_efficiency"])
|
||||
couple_gain = float(gb["rpm_couple_gain"])
|
||||
|
||||
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))
|
||||
c_rr = float(gb["rolling_c"])
|
||||
rho = float(gb["air_density"])
|
||||
cd = float(gb["aero_cd"])
|
||||
A = float(gb["frontal_area_m2"])
|
||||
|
||||
clutch_Tmax = float(gb["clutch_max_torque_nm"])
|
||||
clutch_agr = min(1.0, max(0.0, float(gb["clutch_aggressiveness"])))
|
||||
clutch_curve= str(gb["clutch_curve"]).lower()
|
||||
clutch_drag = float(gb["clutch_drag_nm"])
|
||||
shift_time = float(gb["shift_time_s"])
|
||||
sync_band = float(gb["sync_rpm_band"])
|
||||
|
||||
mu_peak = float(gb["tire_mu_peak"])
|
||||
mu_slide= float(gb["tire_mu_slide"])
|
||||
rear_w = float(gb["rear_static_weight_frac"])
|
||||
|
||||
m = float(v.config.get("vehicle", {}).get("mass_kg", 210.0))
|
||||
g = 9.81
|
||||
|
||||
# State
|
||||
gear = int(v.ensure("gear", 0))
|
||||
ign = str(v.ensure("ignition", "OFF"))
|
||||
rpm = float(v.ensure("rpm", 1200.0))
|
||||
pedal= float(v.ensure("throttle_pedal_pct", 0.0))
|
||||
pedal = max(0.0, min(100.0, pedal))
|
||||
|
||||
# verfügbare Motordaten
|
||||
eng_avail_T = float(v.get("engine_available_torque_nm", 0.0)) # „kann liefern“
|
||||
# Hinweis: die Engine zieht später v.acc_total("engine.torque_load_nm") ab.
|
||||
|
||||
# Pending Shift Commands (vom UI gesetzt und dann zurücksetzen)
|
||||
up_req = bool(v.ensure("gear_shift_up", False))
|
||||
down_req = bool(v.ensure("gear_shift_down", False))
|
||||
to_N_req = bool(v.ensure("gear_set_neutral", False))
|
||||
if up_req: v.set("gear_shift_up", False)
|
||||
if down_req: v.set("gear_shift_down", False)
|
||||
if to_N_req: v.set("gear_set_neutral", False)
|
||||
|
||||
# --- Schaltlogik ---
|
||||
if self._shift_t > 0.0:
|
||||
self._shift_t -= dt
|
||||
# währenddessen Kupplung öffnen
|
||||
self._clutch = max(0.0, self._clutch - self._rate_from_agr(1.0, clutch_agr) * dt)
|
||||
if self._shift_t <= 0.0 and self._target_gear is not None:
|
||||
gear = int(self._target_gear)
|
||||
v.set("gear", gear)
|
||||
self._target_gear = None
|
||||
else:
|
||||
# neue Requests annehmen, wenn nicht bereits am Limit
|
||||
if to_N_req:
|
||||
self._target_gear = 0
|
||||
self._shift_t = shift_time
|
||||
elif up_req and gear < min(6, len(gear_ratios)-1):
|
||||
self._target_gear = gear + 1
|
||||
self._shift_t = shift_time
|
||||
elif down_req and gear > 0:
|
||||
self._target_gear = gear - 1
|
||||
self._shift_t = shift_time
|
||||
|
||||
# --- Gesamtübersetzung und Soll-Drehzahlbezug ---
|
||||
gear_ratio = float(gear_ratios[gear]) if 0 <= gear < len(gear_ratios) else 0.0
|
||||
overall = primary * gear_ratio * final # Kurbel -> Rad
|
||||
wheel_omega = self._wheel_v / max(1e-6, r_w) # rad/s
|
||||
eng_omega_from_wheel = wheel_omega * overall
|
||||
rpm_from_wheel = eng_omega_from_wheel * 60.0 / (2.0 * math.pi)
|
||||
|
||||
# --- Kupplungs-Automat ---
|
||||
# Zielschließung aus Schlupf und Fahrerwunsch
|
||||
slip_rpm = abs(rpm - rpm_from_wheel)
|
||||
slip_norm = min(1.0, slip_rpm / max(1.0, sync_band))
|
||||
base_target = max(0.0, min(1.0, (pedal/100.0)*0.6 + (1.0 - slip_norm)*0.6))
|
||||
target_c = self._shape(base_target, clutch_curve)
|
||||
|
||||
# Bei N oder ohne Übersetzung kein Kraftschluss
|
||||
if gear == 0 or overall <= 1e-6 or ign in ("OFF","ACC"):
|
||||
target_c = 0.0
|
||||
|
||||
# Sanfte Anti-Abwürg-Logik: ist RPM sehr niedrig und Radlast hoch -> etwas öffnen
|
||||
if rpm < 1500.0 and slip_rpm > 200.0:
|
||||
target_c = min(target_c, 0.6)
|
||||
|
||||
# Dynamik der Kupplung (Annäherung Richtung target_c)
|
||||
rate = self._rate_from_agr(target_c, clutch_agr) # s^-1
|
||||
self._clutch += (target_c - self._clutch) * min(1.0, rate * dt)
|
||||
self._clutch = max(0.0, min(1.0, self._clutch))
|
||||
|
||||
# --- Übertragbares Motormoment durch Kupplung ---
|
||||
clutch_cap = clutch_Tmax * self._clutch
|
||||
T_engine_to_input = max(0.0, min(eng_avail_T, clutch_cap))
|
||||
|
||||
# --- Rad-Seite: aus Motor via Übersetzung ---
|
||||
T_wheel_from_engine = T_engine_to_input * overall * eta if overall > 0.0 else 0.0 # Nm am Rad
|
||||
|
||||
# --- Reibungs-/Luftwiderstand ---
|
||||
v_ms = max(0.0, self._wheel_v)
|
||||
F_roll = m * g * c_rr
|
||||
F_aero = 0.5 * rho * cd * A * v_ms * v_ms
|
||||
F_res = F_roll + F_aero
|
||||
|
||||
# --- Reifen-Force-Limit & Schlupf ---
|
||||
N_rear = m * g * rear_w
|
||||
F_trac_cap = mu_peak * N_rear
|
||||
F_from_engine = T_wheel_from_engine / max(1e-6, r_w)
|
||||
|
||||
slip = 0.0
|
||||
F_trac = F_from_engine
|
||||
if abs(F_from_engine) > F_trac_cap:
|
||||
slip = min(1.0, (abs(F_from_engine) - F_trac_cap) / max(1.0, F_from_engine))
|
||||
# im Schlupf auf Slide-Niveau kappen
|
||||
F_trac = math.copysign(mu_slide * N_rear, F_from_engine)
|
||||
|
||||
# --- Fahrzeugdynamik: a = (F_trac - F_res)/m ---
|
||||
a = (F_trac - F_res) / max(1.0, m)
|
||||
self._wheel_v = max(0.0, self._wheel_v + a * dt)
|
||||
speed_kmh = self._wheel_v * 3.6
|
||||
v.set("speed_kmh", float(speed_kmh))
|
||||
v.set("gear", int(gear))
|
||||
v.set("clutch_pct", float(self._clutch * 100.0))
|
||||
v.set("wheel_slip_pct", float(max(0.0, min(1.0, slip)) * 100.0))
|
||||
|
||||
# --- Reaktionsmoment zurück an den Motor (Last) ---
|
||||
# aus tatsächlich wirkender Traktionskraft (nach Grip-Limit)
|
||||
T_engine_load = 0.0
|
||||
if overall > 0.0 and self._clutch > 0.0:
|
||||
T_engine_load = (abs(F_trac) * r_w) / max(1e-6, (overall * eta))
|
||||
# kleiner Schlepp bei getrennt
|
||||
if self._clutch < 0.1:
|
||||
T_engine_load += clutch_drag * (1.0 - self._clutch)
|
||||
|
||||
if T_engine_load > 0.0:
|
||||
v.push("engine.torque_load_nm", +T_engine_load, source="driveline")
|
||||
|
||||
# --- RPM-Kopplung (sanfte Synchronisierung) ---
|
||||
if overall > 0.0 and self._clutch > 0.2 and ign in ("ON","START"):
|
||||
alpha = min(1.0, couple_gain * self._clutch * dt / max(1e-3, 0.1))
|
||||
rpm = (1.0 - alpha) * rpm + alpha * rpm_from_wheel
|
||||
v.set("rpm", float(rpm))
|
||||
|
||||
# ----- Helpers -----
|
||||
def _rate_from_agr(self, target_c: float, agr: float) -> float:
|
||||
"""Engage/Release-Geschwindigkeit [1/s] in Abhängigkeit der Aggressivität."""
|
||||
# 0.05s (bissig) bis 0.5s (sanft) für ~63%-Annäherung
|
||||
tau = 0.5 - 0.45 * agr
|
||||
if target_c < 0.1: # Öffnen etwas flotter
|
||||
tau *= 0.7
|
||||
return 1.0 / max(0.05, tau)
|
||||
|
||||
def _shape(self, x: float, curve: str) -> float:
|
||||
x = max(0.0, min(1.0, x))
|
||||
if curve == "progressive":
|
||||
return x * x
|
||||
if curve == "soft":
|
||||
return math.sqrt(x)
|
||||
return x # linear
|
||||
|
183
app/simulation/simulator.py
Normal file
183
app/simulation/simulator.py
Normal file
@@ -0,0 +1,183 @@
|
||||
# app/simulation/simulator.py
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Any, List, Optional
|
||||
import importlib, pkgutil, inspect, pathlib
|
||||
|
||||
# ---------------------- Core: Vehicle + Accumulator-API ----------------------
|
||||
|
||||
@dataclass
|
||||
class Vehicle:
|
||||
"""
|
||||
State-/Config-Container + Dashboard-Registry + generische Frame-Akkumulatoren.
|
||||
|
||||
- set/get/ensure: harte Zustandswerte
|
||||
- push(key, delta, source): additiver Beitrag pro Frame (Source/Sink via Vorzeichen)
|
||||
- acc_total(key): Summe aller Beiträge zu 'key'
|
||||
- acc_breakdown(key): Beiträge je Quelle (Debug/Transparenz)
|
||||
- acc_reset(): am Frame-Beginn alle Akkus löschen
|
||||
"""
|
||||
state: Dict[str, Any] = field(default_factory=dict)
|
||||
config: Dict[str, Any] = field(default_factory=dict)
|
||||
dtc: Dict[str, bool] = field(default_factory=dict)
|
||||
|
||||
dashboard_specs: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
# Accumulator: key -> {source_name: float}
|
||||
_acc: Dict[str, Dict[str, float]] = field(default_factory=dict)
|
||||
|
||||
# ---- state helpers ----
|
||||
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:
|
||||
if key not in self.state:
|
||||
self.state[key] = default
|
||||
return self.state[key]
|
||||
|
||||
# ---- dashboard helpers ----
|
||||
def register_metric(
|
||||
self, key: str, *,
|
||||
label: Optional[str] = None,
|
||||
unit: Optional[str] = None,
|
||||
fmt: Optional[str] = None,
|
||||
source: Optional[str] = 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)
|
||||
|
||||
# ---- generic accumulators (per frame) ----
|
||||
def acc_reset(self) -> None:
|
||||
self._acc.clear()
|
||||
|
||||
def push(self, key: str, delta: float, source: Optional[str] = None) -> None:
|
||||
src = source or "anon"
|
||||
bucket = self._acc.setdefault(key, {})
|
||||
bucket[src] = bucket.get(src, 0.0) + float(delta)
|
||||
|
||||
def acc_total(self, key: str) -> float:
|
||||
bucket = self._acc.get(key)
|
||||
return 0.0 if not bucket else sum(bucket.values())
|
||||
|
||||
def acc_breakdown(self, key: str) -> Dict[str, float]:
|
||||
return dict(self._acc.get(key, {}))
|
||||
|
||||
|
||||
# ---------------------------- Module Base + Loader ----------------------------
|
||||
|
||||
class Module:
|
||||
PRIO: int = 100
|
||||
NAME: str = "module"
|
||||
def apply(self, v: Vehicle, dt: float) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def _discover_modules(pkg_name: str = "app.simulation.modules") -> List[Module]:
|
||||
mods: List[Module] = []
|
||||
pkg = importlib.import_module(pkg_name)
|
||||
pkg_path = pathlib.Path(pkg.__file__).parent
|
||||
for _, modname, ispkg in pkgutil.iter_modules([str(pkg_path)]):
|
||||
if ispkg:
|
||||
continue
|
||||
full_name = f"{pkg_name}.{modname}"
|
||||
try:
|
||||
m = importlib.import_module(full_name)
|
||||
except Exception as exc:
|
||||
print(f"[loader] Fehler beim Import {full_name}: {exc}")
|
||||
continue
|
||||
for _, obj in inspect.getmembers(m, inspect.isclass):
|
||||
if obj is Module or not issubclass(obj, Module):
|
||||
continue
|
||||
try:
|
||||
inst = obj()
|
||||
except Exception as exc:
|
||||
print(f"[loader] Kann {obj.__name__} nicht instanziieren: {exc}")
|
||||
continue
|
||||
mods.append(inst)
|
||||
mods.sort(key=lambda x: (getattr(x, "PRIO", 100), getattr(x, "NAME", x.__class__.__name__)))
|
||||
return mods
|
||||
|
||||
# ------------------------------- Simulator API --------------------------------
|
||||
|
||||
class VehicleSimulator:
|
||||
"""Lädt Module dynamisch, führt sie pro Tick in PRIO-Reihenfolge aus."""
|
||||
def __init__(self, modules_package: str = "app.simulation.modules"):
|
||||
self.v = Vehicle()
|
||||
self.modules: List[Module] = _discover_modules(modules_package)
|
||||
|
||||
self.module_defaults: Dict[str, Dict[str, Any]] = {}
|
||||
for m in self.modules:
|
||||
ns = getattr(m, "NAME", "").lower() or m.__class__.__name__.lower()
|
||||
mod = importlib.import_module(m.__class__.__module__)
|
||||
# Konvention: UPPER(NAME) + _DEFAULTS
|
||||
key = f"{ns.upper()}_DEFAULTS"
|
||||
defaults = getattr(mod, key, None)
|
||||
if isinstance(defaults, dict):
|
||||
self.module_defaults[ns] = dict(defaults)
|
||||
|
||||
def update(self, dt: float) -> None:
|
||||
self.v.acc_reset() # pro Frame Akkus leeren
|
||||
for m in self.modules:
|
||||
try:
|
||||
m.apply(self.v, dt)
|
||||
except Exception as exc:
|
||||
print(f"[sim] Modul {getattr(m, 'NAME', m.__class__.__name__)} Fehler: {exc}")
|
||||
|
||||
def snapshot(self) -> Dict[str, Any]:
|
||||
return self.v.snapshot()
|
||||
|
||||
def load_config(self, cfg: Dict[str, Any]) -> None:
|
||||
for k, sub in cfg.items():
|
||||
self.v.config.setdefault(k, {}).update(sub if isinstance(sub, dict) else {})
|
||||
if "dtc" in cfg:
|
||||
self.v.dtc.update(cfg["dtc"])
|
||||
|
||||
def export_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Exportiert einen *vollständigen* Snapshot:
|
||||
- Modul-Defaults + Overrides (so fehlen keine Keys)
|
||||
- alle übrigen Namespaces unverändert
|
||||
- DTC separat
|
||||
"""
|
||||
out: Dict[str, Any] = {}
|
||||
|
||||
# 1) Modul-Namespaces: Defaults + Overrides mergen
|
||||
for ns, defs in self.module_defaults.items():
|
||||
merged = dict(defs)
|
||||
merged.update(self.v.config.get(ns, {}))
|
||||
out[ns] = merged
|
||||
|
||||
# 2) übrige Namespaces (ohne bekannte Modul-Defaults) 1:1 übernehmen
|
||||
for ns, data in self.v.config.items():
|
||||
if ns not in out:
|
||||
out[ns] = dict(data)
|
||||
|
||||
# 3) DTC anhängen
|
||||
out["dtc"] = dict(self.v.dtc)
|
||||
return out
|
||||
|
||||
# Falls noch benutzt:
|
||||
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))))
|
@@ -1,46 +0,0 @@
|
||||
# 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),
|
||||
}
|
52
app/simulation/ui/__init__.py
Normal file
52
app/simulation/ui/__init__.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# =============================
|
||||
# app/simulation/ui/__init__.py
|
||||
# =============================
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import List, Optional, Type
|
||||
import importlib, inspect, pkgutil, pathlib
|
||||
|
||||
class UITab:
|
||||
"""
|
||||
Basis für alle Tabs. Erwarte:
|
||||
- class-attr: NAME, TITLE, PRIO
|
||||
- __init__(parent, sim) erzeugt self.frame (tk.Frame/ttk.Frame)
|
||||
- optionale Methoden: apply(), save_into_config(out), load_from_config(cfg)
|
||||
"""
|
||||
NAME: str = "tab"
|
||||
TITLE: str = "Tab"
|
||||
PRIO: int = 100
|
||||
|
||||
# No-ops für Save/Load
|
||||
def apply(self): pass
|
||||
def save_into_config(self, out): pass
|
||||
def load_from_config(self, cfg): pass
|
||||
|
||||
def discover_ui_tabs(parent, sim, pkg_name: str = "app.simulation.ui") -> List[UITab]:
|
||||
"""Lädt alle Unter-Module von pkg_name, instanziiert Klassen, die UITab erben."""
|
||||
tabs: List[UITab] = []
|
||||
pkg = importlib.import_module(pkg_name)
|
||||
pkg_path = pathlib.Path(pkg.__file__).parent
|
||||
|
||||
for _, modname, ispkg in pkgutil.iter_modules([str(pkg_path)]):
|
||||
if ispkg: # (optional: Subpackages zulassen – hier überspringen)
|
||||
continue
|
||||
full = f"{pkg_name}.{modname}"
|
||||
try:
|
||||
m = importlib.import_module(full)
|
||||
except Exception as exc:
|
||||
print(f"[ui-loader] Importfehler {full}: {exc}")
|
||||
continue
|
||||
|
||||
for _, obj in inspect.getmembers(m, inspect.isclass):
|
||||
if obj is UITab or not issubclass(obj, UITab):
|
||||
continue
|
||||
try:
|
||||
inst = obj(parent, sim)
|
||||
except Exception as exc:
|
||||
print(f"[ui-loader] Instanzierung fehlgeschlagen {obj.__name__}: {exc}")
|
||||
continue
|
||||
tabs.append(inst)
|
||||
|
||||
tabs.sort(key=lambda t: (getattr(t, "PRIO", 100), getattr(t, "NAME", t.__class__.__name__)))
|
||||
return tabs
|
209
app/simulation/ui/basic.py
Normal file
209
app/simulation/ui/basic.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# =============================
|
||||
# app/simulation/ui/basic.py
|
||||
# =============================
|
||||
|
||||
from __future__ import annotations
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from typing import Dict, Any
|
||||
from app.simulation.ui import UITab
|
||||
|
||||
class BasicTab(UITab):
|
||||
NAME = "basic"
|
||||
TITLE = "Basisdaten"
|
||||
PRIO = 10
|
||||
|
||||
def __init__(self, parent, sim):
|
||||
self.sim = sim
|
||||
self.frame = ttk.Frame(parent, padding=8)
|
||||
for c in (0,1,2,3): self.frame.columnconfigure(c, weight=1)
|
||||
|
||||
# ---------- Linke Spalte ----------
|
||||
rowL = 0
|
||||
def L(lbl, var=None, w=12, kind="entry", values=None):
|
||||
nonlocal rowL
|
||||
ttk.Label(self.frame, text=lbl).grid(row=rowL, column=0, sticky="w")
|
||||
if kind == "entry":
|
||||
ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowL, column=1, sticky="w")
|
||||
elif kind == "label":
|
||||
ttk.Label(self.frame, textvariable=var).grid(row=rowL, column=1, sticky="w")
|
||||
elif kind == "combo":
|
||||
ttk.Combobox(self.frame, textvariable=var, state="readonly", values=values or [], width=w)\
|
||||
.grid(row=rowL, column=1, sticky="w")
|
||||
elif kind == "check":
|
||||
ttk.Checkbutton(self.frame, variable=var).grid(row=rowL, column=1, sticky="w")
|
||||
elif kind == "radio":
|
||||
f = ttk.Frame(self.frame); f.grid(row=rowL, column=1, sticky="w")
|
||||
for i,(t,vv) in enumerate(values or []):
|
||||
ttk.Radiobutton(f, text=t, value=vv, variable=var, command=self._apply_ign)\
|
||||
.grid(row=0, column=i, padx=(0,6))
|
||||
rowL += 1
|
||||
|
||||
# Vehicle
|
||||
self.type = tk.StringVar(); L("Fahrzeugtyp", self.type, kind="combo", values=["motorcycle","car","truck"])
|
||||
self.mass = tk.DoubleVar(); L("Gewicht [kg]", self.mass)
|
||||
self.abs = tk.BooleanVar(); L("ABS vorhanden", self.abs, kind="check")
|
||||
self.tcs = tk.BooleanVar(); L("ASR/Traktionskontrolle", self.tcs, kind="check")
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1
|
||||
|
||||
# Environment / Ignition
|
||||
self.amb = tk.DoubleVar(); L("Umgebung [°C]", self.amb)
|
||||
self.ign = tk.StringVar(); L("Zündung", self.ign, kind="radio",
|
||||
values=[("OFF","OFF"),("ACC","ACC"),("ON","ON"),("START","START")])
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1
|
||||
|
||||
# Live links (Labels)
|
||||
self.batt_v = tk.StringVar(); L("Batterie [V]", self.batt_v, kind="label")
|
||||
self.elx_v = tk.StringVar(); L("ELX/Bus [V]", self.elx_v, kind="label")
|
||||
self.soc = tk.StringVar(); L("SOC [0..1]", self.soc, kind="label")
|
||||
|
||||
# ---------- Rechte Spalte ----------
|
||||
rowR = 0
|
||||
def R(lbl, var=None, w=12, kind="entry"):
|
||||
nonlocal rowR
|
||||
ttk.Label(self.frame, text=lbl).grid(row=rowR, column=2, sticky="w")
|
||||
if kind == "entry":
|
||||
ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowR, column=3, sticky="w")
|
||||
elif kind == "label":
|
||||
ttk.Label(self.frame, textvariable=var).grid(row=rowR, column=3, sticky="w")
|
||||
rowR += 1
|
||||
|
||||
# Live rechts (Labels)
|
||||
self.ibatt = tk.StringVar(); R("I Batterie [A] (+entlädt)", self.ibatt, kind="label")
|
||||
self.ialt = tk.StringVar(); R("I Lima [A]", self.ialt, kind="label")
|
||||
self.load_elx= tk.StringVar(); R("Last ELX [A]", self.load_elx, kind="label")
|
||||
self.load_bat= tk.StringVar(); R("Last Batterie [A]", self.load_bat, kind="label")
|
||||
self.load_tot= tk.StringVar(); R("Last gesamt [A]", self.load_tot, kind="label")
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
|
||||
|
||||
# Electrical config
|
||||
self.bcap = tk.DoubleVar(); R("Batt Kap. [Ah]", self.bcap)
|
||||
self.brint = tk.DoubleVar(); R("Batt R_int [Ω]", self.brint)
|
||||
self.alt_v = tk.DoubleVar(); R("Reglerspannung [V]", self.alt_v)
|
||||
self.alt_a = tk.DoubleVar(); R("Lima Nennstrom [A]", self.alt_a)
|
||||
self.alt_ci = tk.IntVar(); R("Cut-In RPM", self.alt_ci)
|
||||
self.alt_fc = tk.IntVar(); R("Full-Cap RPM", self.alt_fc)
|
||||
self.alt_eta= tk.DoubleVar(); R("Lima η_mech [-]", self.alt_eta)
|
||||
self.alt_rat= tk.DoubleVar(); R("Lima Übersetzung [-]", self.alt_rat)
|
||||
self.alt_d0 = tk.DoubleVar(); R("Lima Drag Grund [Nm]", self.alt_d0)
|
||||
self.alt_d1 = tk.DoubleVar(); R("Lima Drag /krpm [Nm]", self.alt_d1)
|
||||
|
||||
# ---------- Buttons ----------
|
||||
rowBtns = max(rowL, rowR) + 1
|
||||
btnrow = ttk.Frame(self.frame); btnrow.grid(row=rowBtns, column=0, columnspan=4, 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))
|
||||
|
||||
self.refresh()
|
||||
|
||||
# ------------ Logic ------------
|
||||
def refresh(self):
|
||||
snap = self.sim.snapshot()
|
||||
vcfg = dict(self.sim.v.config.get("vehicle", {}))
|
||||
ecfg = dict(self.sim.v.config.get("electrical", {}))
|
||||
|
||||
# Vehicle
|
||||
self.type.set(vcfg.get("type", "motorcycle"))
|
||||
self.mass.set(float(vcfg.get("mass_kg", 210.0)))
|
||||
self.abs.set(bool(vcfg.get("abs", True)))
|
||||
self.tcs.set(bool(vcfg.get("tcs", False)))
|
||||
|
||||
# Env / Ign
|
||||
self.amb.set(float(snap.get("ambient_c", 20.0)))
|
||||
self.ign.set(str(snap.get("ignition", "ON")))
|
||||
|
||||
# Live left
|
||||
self.batt_v.set(f"{float(snap.get('battery_voltage', 12.6)):.2f}")
|
||||
self.elx_v.set(f"{float(snap.get('elx_voltage', 0.0)):.2f}")
|
||||
self.soc.set(f"{float(snap.get('battery_soc', 0.80)):.2f}")
|
||||
|
||||
# Live right
|
||||
self.ibatt.set(f"{float(snap.get('battery_current_a', 0.0)):.2f}")
|
||||
self.ialt.set(f"{float(snap.get('alternator_current_a', 0.0)):.2f}")
|
||||
self.load_elx.set(f"{float(snap.get('elec_load_elx_a', 0.0)):.2f}")
|
||||
self.load_bat.set(f"{float(snap.get('elec_load_batt_a', 0.0)):.2f}")
|
||||
self.load_tot.set(f"{float(snap.get('elec_load_total_a', 0.0)):.2f}")
|
||||
|
||||
# Electrical config
|
||||
self.bcap.set(float(ecfg.get("battery_capacity_ah", 8.0)))
|
||||
self.brint.set(float(ecfg.get("battery_r_int_ohm", 0.020)))
|
||||
self.alt_v.set(float(ecfg.get("alternator_reg_v", 14.2)))
|
||||
self.alt_a.set(float(ecfg.get("alternator_rated_a", 20.0)))
|
||||
self.alt_ci.set(int(ecfg.get("alt_cut_in_rpm", 1500)))
|
||||
self.alt_fc.set(int(ecfg.get("alt_full_rpm", 4000)))
|
||||
self.alt_eta.set(float(ecfg.get("alternator_mech_efficiency", 0.55)))
|
||||
self.alt_rat.set(float(ecfg.get("alternator_pulley_ratio", 1.0)))
|
||||
self.alt_d0.set(float(ecfg.get("alternator_drag_nm_idle", 0.15)))
|
||||
self.alt_d1.set(float(ecfg.get("alternator_drag_nm_per_krpm", 0.05)))
|
||||
|
||||
def _apply_ign(self):
|
||||
self.sim.v.set("ignition", self.ign.get())
|
||||
|
||||
def apply(self):
|
||||
# Umgebung sofort in den State (wirkt auf Thermik)
|
||||
try: self.sim.v.set("ambient_c", float(self.amb.get()))
|
||||
except: pass
|
||||
|
||||
cfg = {
|
||||
"vehicle": {
|
||||
"type": self.type.get(),
|
||||
"mass_kg": float(self.mass.get()),
|
||||
"abs": bool(self.abs.get()),
|
||||
"tcs": bool(self.tcs.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_ci.get()),
|
||||
"alt_full_rpm": int(self.alt_fc.get()),
|
||||
"alternator_mech_efficiency": float(self.alt_eta.get()),
|
||||
"alternator_pulley_ratio": float(self.alt_rat.get()),
|
||||
"alternator_drag_nm_idle": float(self.alt_d0.get()),
|
||||
"alternator_drag_nm_per_krpm": float(self.alt_d1.get()),
|
||||
}
|
||||
}
|
||||
self.sim.load_config(cfg)
|
||||
|
||||
# Save/Load Hooks für Gesamt-Export
|
||||
def save_into_config(self, out: Dict[str, Any]) -> None:
|
||||
out.setdefault("vehicle", {}).update({
|
||||
"type": self.type.get(),
|
||||
"mass_kg": float(self.mass.get()),
|
||||
"abs": bool(self.abs.get()),
|
||||
"tcs": bool(self.tcs.get()),
|
||||
})
|
||||
out.setdefault("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_ci.get()),
|
||||
"alt_full_rpm": int(self.alt_fc.get()),
|
||||
"alternator_mech_efficiency": float(self.alt_eta.get()),
|
||||
"alternator_pulley_ratio": float(self.alt_rat.get()),
|
||||
"alternator_drag_nm_idle": float(self.alt_d0.get()),
|
||||
"alternator_drag_nm_per_krpm": float(self.alt_d1.get()),
|
||||
})
|
||||
|
||||
def load_from_config(self, cfg: Dict[str, Any]) -> None:
|
||||
vcfg = cfg.get("vehicle", {}); ecfg = cfg.get("electrical", {})
|
||||
self.type.set(vcfg.get("type", self.type.get()))
|
||||
self.mass.set(vcfg.get("mass_kg", self.mass.get()))
|
||||
self.abs.set(vcfg.get("abs", self.abs.get()))
|
||||
self.tcs.set(vcfg.get("tcs", self.tcs.get()))
|
||||
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_ci.set(ecfg.get("alt_cut_in_rpm", self.alt_ci.get()))
|
||||
self.alt_fc.set(ecfg.get("alt_full_rpm", self.alt_fc.get()))
|
||||
self.alt_eta.set(ecfg.get("alternator_mech_efficiency", self.alt_eta.get()))
|
||||
self.alt_rat.set(ecfg.get("alternator_pulley_ratio", self.alt_rat.get()))
|
||||
self.alt_d0.set(ecfg.get("alternator_drag_nm_idle", self.alt_d0.get()))
|
||||
self.alt_d1.set(ecfg.get("alternator_drag_nm_per_krpm", self.alt_d1.get()))
|
||||
# wichtig: hier KEIN sim.load_config()
|
149
app/simulation/ui/cooling.py
Normal file
149
app/simulation/ui/cooling.py
Normal file
@@ -0,0 +1,149 @@
|
||||
# =============================
|
||||
# app/simulation/ui/cooling.py
|
||||
# =============================
|
||||
|
||||
from __future__ import annotations
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from app.simulation.modules.cooling import COOLING_DEFAULTS
|
||||
from app.simulation.ui import UITab
|
||||
|
||||
class CoolingTab(UITab):
|
||||
NAME = "cooling"
|
||||
TITLE = "Kühlung"
|
||||
PRIO = 11
|
||||
|
||||
def __init__(self, parent, sim):
|
||||
self.sim = sim
|
||||
self.frame = ttk.Frame(parent, padding=8)
|
||||
|
||||
for c in (0,1,2,3):
|
||||
self.frame.columnconfigure(c, weight=1)
|
||||
|
||||
# ---------- Linke Spalte ----------
|
||||
rowL = 0
|
||||
def L(lbl, var, w=12, kind="entry", values=None):
|
||||
nonlocal rowL
|
||||
ttk.Label(self.frame, text=lbl).grid(row=rowL, column=0, sticky="w")
|
||||
if kind == "entry":
|
||||
ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowL, column=1, sticky="w")
|
||||
elif kind == "combo":
|
||||
cb = ttk.Combobox(self.frame, textvariable=var, state="readonly", values=values or [])
|
||||
cb.grid(row=rowL, column=1, sticky="w")
|
||||
elif kind == "check":
|
||||
ttk.Checkbutton(self.frame, variable=var).grid(row=rowL, column=1, sticky="w")
|
||||
rowL += 1
|
||||
|
||||
self.t_open = tk.DoubleVar(); L("Thermostat öffnet ab [°C]", self.t_open)
|
||||
self.t_full = tk.DoubleVar(); L("Thermostat voll offen [°C]", self.t_full)
|
||||
self.rad_base = tk.DoubleVar(); L("Radiator-Basis [W/K]", self.rad_base)
|
||||
self.ram_gain = tk.DoubleVar(); L("Fahrtwind-Zuwachs [W/K pro km/h]", self.ram_gain)
|
||||
self.amb_c = tk.DoubleVar(); L("Umgebung [°C]", self.amb_c)
|
||||
self.Cc = tk.DoubleVar(); L("Wärmekapazität Kühlmittel [J/K]", self.Cc)
|
||||
self.Coil = tk.DoubleVar(); L("Wärmekapazität Öl [J/K]", self.Coil)
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1
|
||||
|
||||
# Versorgung & Nachlauf (links)
|
||||
self.feed = tk.StringVar()
|
||||
self.afteren = tk.BooleanVar()
|
||||
self.afterth = tk.DoubleVar()
|
||||
self.aftermax= tk.DoubleVar()
|
||||
|
||||
L("Lüfter-Versorgung", self.feed, kind="combo", values=["elx", "battery"])
|
||||
L("Nachlauf aktiv", self.afteren, kind="check")
|
||||
L("Nachlauf-Schwelle [°C]", self.afterth)
|
||||
L("Nachlauf max. Zeit [s]", self.aftermax)
|
||||
|
||||
# ---------- Rechte Spalte ----------
|
||||
rowR = 0
|
||||
def R(lbl, var, w=12):
|
||||
nonlocal rowR
|
||||
ttk.Label(self.frame, text=lbl).grid(row=rowR, column=2, sticky="w")
|
||||
ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowR, column=3, sticky="w")
|
||||
rowR += 1
|
||||
|
||||
self.f1_on = tk.DoubleVar(); R("Lüfter 1 EIN [°C]", self.f1_on)
|
||||
self.f1_off = tk.DoubleVar(); R("Lüfter 1 AUS [°C]", self.f1_off)
|
||||
self.f2_on = tk.DoubleVar(); R("Lüfter 2 EIN [°C]", self.f2_on)
|
||||
self.f2_off = tk.DoubleVar(); R("Lüfter 2 AUS [°C]", self.f2_off)
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
|
||||
|
||||
self.f1_w = tk.DoubleVar(); R("Lüfter 1 Leistung [W]", self.f1_w)
|
||||
self.f2_w = tk.DoubleVar(); R("Lüfter 2 Leistung [W]", self.f2_w)
|
||||
self.f1_air = tk.DoubleVar(); R("Lüfter 1 Luftstrom [W/K]", self.f1_air)
|
||||
self.f2_air = tk.DoubleVar(); R("Lüfter 2 Luftstrom [W/K]", self.f2_air)
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
|
||||
|
||||
self.Uoc = tk.DoubleVar(); R("Öl↔Kühlmittel Kopplung [W/K]", self.Uoc)
|
||||
self.Uoil = tk.DoubleVar(); R("Öl→Umgebung [W/K]", self.Uoil)
|
||||
self.frac = tk.DoubleVar(); R("Motorwärme→Kühlmittel [%]", self.frac)
|
||||
|
||||
# ---------- Buttons ----------
|
||||
rowBtns = max(rowL, rowR) + 1
|
||||
btnrow = ttk.Frame(self.frame)
|
||||
btnrow.grid(row=rowBtns, column=0, columnspan=4, 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))
|
||||
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
c = dict(COOLING_DEFAULTS)
|
||||
c.update(self.sim.v.config.get("cooling", {}))
|
||||
|
||||
# links
|
||||
self.t_open.set(c["thermostat_open_c"])
|
||||
self.t_full.set(c["thermostat_full_c"])
|
||||
self.rad_base.set(c["rad_base_u_w_per_k"])
|
||||
self.ram_gain.set(c["ram_air_gain_per_kmh"])
|
||||
self.amb_c.set(self.sim.v.get("ambient_c", 20.0))
|
||||
self.Cc.set(c["coolant_thermal_cap_j_per_k"])
|
||||
self.Coil.set(c["oil_thermal_cap_j_per_k"])
|
||||
|
||||
# Versorgung & Nachlauf
|
||||
self.feed.set(c.get("fan_power_feed", "elx"))
|
||||
self.afteren.set(bool(c.get("fan_afterrun_enable", False)))
|
||||
self.afterth.set(float(c.get("fan_afterrun_threshold_c", 105.0)))
|
||||
self.aftermax.set(float(c.get("fan_afterrun_max_s", 300.0)))
|
||||
|
||||
# rechts
|
||||
self.f1_on.set(c["fan1_on_c"]); self.f1_off.set(c["fan1_off_c"])
|
||||
self.f2_on.set(c["fan2_on_c"]); self.f2_off.set(c["fan2_off_c"])
|
||||
self.f1_w.set(c["fan1_power_w"]); self.f2_w.set(c["fan2_power_w"])
|
||||
self.f1_air.set(c["fan1_airflow_gain"]); self.f2_air.set(c["fan2_airflow_gain"])
|
||||
|
||||
self.Uoc.set(c["oil_coolant_u_w_per_k"])
|
||||
self.Uoil.set(c["oil_to_amb_u_w_per_k"])
|
||||
self.frac.set(c["engine_heat_frac_to_coolant"]*100.0)
|
||||
|
||||
def apply(self):
|
||||
cfg = {"cooling": {
|
||||
# links
|
||||
"thermostat_open_c": float(self.t_open.get()),
|
||||
"thermostat_full_c": float(self.t_full.get()),
|
||||
"rad_base_u_w_per_k": float(self.rad_base.get()),
|
||||
"ram_air_gain_per_kmh": float(self.ram_gain.get()),
|
||||
"coolant_thermal_cap_j_per_k": float(self.Cc.get()),
|
||||
"oil_thermal_cap_j_per_k": float(self.Coil.get()),
|
||||
# Versorgung & Nachlauf
|
||||
"fan_power_feed": self.feed.get(),
|
||||
"fan_afterrun_enable": bool(self.afteren.get()),
|
||||
"fan_afterrun_threshold_c": float(self.afterth.get()),
|
||||
"fan_afterrun_max_s": float(self.aftermax.get()),
|
||||
# rechts
|
||||
"fan1_on_c": float(self.f1_on.get()),
|
||||
"fan1_off_c": float(self.f1_off.get()),
|
||||
"fan2_on_c": float(self.f2_on.get()),
|
||||
"fan2_off_c": float(self.f2_off.get()),
|
||||
"fan1_power_w": float(self.f1_w.get()),
|
||||
"fan2_power_w": float(self.f2_w.get()),
|
||||
"fan1_airflow_gain": float(self.f1_air.get()),
|
||||
"fan2_airflow_gain": float(self.f2_air.get()),
|
||||
"oil_coolant_u_w_per_k": float(self.Uoc.get()),
|
||||
"oil_to_amb_u_w_per_k": float(self.Uoil.get()),
|
||||
"engine_heat_frac_to_coolant": float(self.frac.get())/100.0,
|
||||
}}
|
||||
self.sim.load_config(cfg)
|
@@ -1,11 +1,12 @@
|
||||
# =============================
|
||||
# app/tabs/dtc.py
|
||||
# app/simulation/ui/dtc.py
|
||||
# =============================
|
||||
|
||||
from __future__ import annotations
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from typing import Dict, Any
|
||||
from app.simulation.ui import UITab
|
||||
|
||||
DTC_LIST = [
|
||||
("P0300", "Random/Multiple Cylinder Misfire"),
|
||||
@@ -14,7 +15,10 @@ DTC_LIST = [
|
||||
("U0121", "Lost Communication With ABS")
|
||||
]
|
||||
|
||||
class DtcTab:
|
||||
class DtcTab(UITab):
|
||||
NAME = "dtc"
|
||||
TITLE = "Fehlercodes"
|
||||
PRIO = 10
|
||||
def __init__(self, parent, sim):
|
||||
self.sim = sim
|
||||
self.frame = ttk.Frame(parent, padding=8)
|
186
app/simulation/ui/engine.py
Normal file
186
app/simulation/ui/engine.py
Normal file
@@ -0,0 +1,186 @@
|
||||
# =============================
|
||||
# app/simulation/ui/engine.py
|
||||
# =============================
|
||||
|
||||
from __future__ import annotations
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from app.simulation.modules.engine import ENGINE_DEFAULTS
|
||||
from app.simulation.ui import UITab
|
||||
|
||||
class EngineTab(UITab):
|
||||
NAME = "engine"
|
||||
TITLE = "Motor"
|
||||
PRIO = 10
|
||||
|
||||
def __init__(self, parent, sim):
|
||||
self.sim = sim
|
||||
self.frame = ttk.Frame(parent, padding=8)
|
||||
for c in (0,1,2,3): self.frame.columnconfigure(c, weight=1)
|
||||
|
||||
# ---------- Linke Spalte ----------
|
||||
rowL = 0
|
||||
def L(lbl, var, w=12, kind="entry", values=None):
|
||||
nonlocal rowL
|
||||
ttk.Label(self.frame, text=lbl).grid(row=rowL, column=0, sticky="w")
|
||||
if kind == "entry":
|
||||
ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowL, column=1, sticky="w")
|
||||
elif kind == "combo":
|
||||
ttk.Combobox(self.frame, textvariable=var, state="readonly",
|
||||
values=values or [], width=w).grid(row=rowL, column=1, sticky="w")
|
||||
rowL += 1
|
||||
|
||||
self.idle = tk.IntVar(); L("Leerlauf [RPM]", self.idle)
|
||||
self.maxrpm = tk.IntVar(); L("Max RPM", self.maxrpm)
|
||||
self.rise = tk.IntVar(); L("Anstieg [RPM/s]", self.rise)
|
||||
self.fall = tk.IntVar(); L("Abfall [RPM/s]", self.fall)
|
||||
self.curve = tk.StringVar(); L("Gaspedal-Kennlinie", self.curve, kind="combo",
|
||||
values=["linear","progressive","aggressive"])
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1
|
||||
|
||||
self.power = tk.DoubleVar(); L("Motorleistung [kW]", self.power)
|
||||
self.tqpeak = tk.DoubleVar(); L("Drehmoment-Peak [RPM]", self.tqpeak)
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1
|
||||
|
||||
self.st_nom = tk.DoubleVar(); L("Starter Nenn-RPM", self.st_nom)
|
||||
self.st_vmin= tk.DoubleVar(); L("Starter min. Spannung [V]", self.st_vmin)
|
||||
self.st_thr = tk.DoubleVar(); L("Start-Schwelle [RPM]", self.st_thr)
|
||||
self.stall = tk.DoubleVar(); L("Stall-Grenze [RPM]", self.stall)
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1
|
||||
|
||||
self.o_idle = tk.DoubleVar(); L("Öldruck Leerlauf [bar]", self.o_idle)
|
||||
self.o_slope= tk.DoubleVar(); L("Öldruck Steigung [bar/krpm]", self.o_slope)
|
||||
self.o_floor= tk.DoubleVar(); L("Öldruck Boden [bar]", self.o_floor)
|
||||
|
||||
# ---------- Rechte Spalte ----------
|
||||
rowR = 0
|
||||
def R(lbl, var, w=12, kind="entry"):
|
||||
nonlocal rowR
|
||||
ttk.Label(self.frame, text=lbl).grid(row=rowR, column=2, sticky="w")
|
||||
if kind == "entry":
|
||||
ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowR, column=3, sticky="w")
|
||||
elif kind == "label":
|
||||
ttk.Label(self.frame, textvariable=var).grid(row=rowR, column=3, sticky="w")
|
||||
elif kind == "scale":
|
||||
s = ttk.Scale(self.frame, from_=0.0, to=100.0, variable=var,
|
||||
command=lambda _=None: self._on_pedal_change())
|
||||
s.grid(row=rowR, column=3, sticky="ew")
|
||||
rowR += 1
|
||||
|
||||
self.dk_idle = tk.DoubleVar(); R("DK min Leerlauf [%]", self.dk_idle)
|
||||
self.dk_over = tk.DoubleVar(); R("DK Schub [%]", self.dk_over)
|
||||
self.dk_tau = tk.DoubleVar(); R("DK Zeitkonstante [s]", self.dk_tau)
|
||||
self.tq_kp = tk.DoubleVar(); R("Torque-Kp", self.tq_kp)
|
||||
self.tq_ki = tk.DoubleVar(); R("Torque-Ki", self.tq_ki)
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
|
||||
|
||||
self.jit_idle= tk.DoubleVar(); R("Jitter Leerlauf [±RPM]", self.jit_idle)
|
||||
self.jit_high= tk.DoubleVar(); R("Jitter hoch [±RPM]", self.jit_high)
|
||||
self.jit_tau = tk.DoubleVar(); R("Jitter-Zeitkonstante [s]", self.jit_tau)
|
||||
self.jit_off = tk.DoubleVar(); R("Jitter aus unter [RPM]", self.jit_off)
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
|
||||
|
||||
self.amb_c = tk.DoubleVar(); R("Umgebung [°C]", self.amb_c)
|
||||
self.cold_k = tk.DoubleVar(); R("Kalt-Leerlauf +/°C [RPM/°C]", self.cold_k)
|
||||
self.cold_max=tk.DoubleVar(); R("Kalt-Leerlauf max [RPM]", self.cold_max)
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
|
||||
|
||||
self.pedal = tk.DoubleVar(); R("Gaspedal [%]", self.pedal, kind="scale")
|
||||
|
||||
# ---------- Buttons ----------
|
||||
rowBtns = max(rowL, rowR) + 1
|
||||
btn = ttk.Frame(self.frame); btn.grid(row=rowBtns, column=0, columnspan=4, sticky="w", pady=(8,0))
|
||||
ttk.Button(btn, text="Aktualisieren", command=self.refresh).pack(side="left")
|
||||
ttk.Button(btn, text="Anwenden", command=self.apply).pack(side="left", padx=(8,0))
|
||||
|
||||
self.refresh()
|
||||
|
||||
def _on_pedal_change(self):
|
||||
try: self.sim.v.set("throttle_pedal_pct", float(self.pedal.get()))
|
||||
except: pass
|
||||
|
||||
def refresh(self):
|
||||
e = dict(ENGINE_DEFAULTS); e.update(self.sim.v.config.get("engine", {}))
|
||||
|
||||
# links
|
||||
self.idle.set(e["idle_rpm"])
|
||||
self.maxrpm.set(e["max_rpm"])
|
||||
self.rise.set(e["rpm_rise_per_s"])
|
||||
self.fall.set(e["rpm_fall_per_s"])
|
||||
self.curve.set(e["throttle_curve"])
|
||||
|
||||
self.power.set(e["engine_power_kw"])
|
||||
self.tqpeak.set(e["torque_peak_rpm"])
|
||||
|
||||
self.st_nom.set(e["starter_rpm_nominal"])
|
||||
self.st_vmin.set(e["starter_voltage_min"])
|
||||
self.st_thr.set(e["start_rpm_threshold"])
|
||||
self.stall.set(e["stall_rpm"])
|
||||
|
||||
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"])
|
||||
|
||||
# rechts
|
||||
self.dk_idle.set(e["throttle_plate_idle_min_pct"])
|
||||
self.dk_over.set(e["throttle_plate_overrun_pct"])
|
||||
self.dk_tau.set(e["throttle_plate_tau_s"])
|
||||
self.tq_kp.set(e["torque_ctrl_kp"])
|
||||
self.tq_ki.set(e["torque_ctrl_ki"])
|
||||
|
||||
self.jit_idle.set(e["rpm_jitter_idle_amp_rpm"])
|
||||
self.jit_high.set(e["rpm_jitter_high_amp_rpm"])
|
||||
self.jit_tau.set(e["rpm_jitter_tau_s"])
|
||||
self.jit_off.set(e["rpm_jitter_off_threshold_rpm"])
|
||||
|
||||
self.amb_c.set(e["coolant_ambient_c"])
|
||||
self.cold_k.set(e["idle_cold_gain_per_deg"])
|
||||
self.cold_max.set(e["idle_cold_gain_max"])
|
||||
|
||||
self.pedal.set(e["throttle_pedal_pct"])
|
||||
self._on_pedal_change()
|
||||
|
||||
def apply(self):
|
||||
cfg = {"engine": {
|
||||
"idle_rpm": int(self.idle.get()),
|
||||
"max_rpm": int(self.maxrpm.get()),
|
||||
"rpm_rise_per_s": int(self.rise.get()),
|
||||
"rpm_fall_per_s": int(self.fall.get()),
|
||||
"throttle_curve": self.curve.get(),
|
||||
|
||||
"engine_power_kw": float(self.power.get()),
|
||||
"torque_peak_rpm": float(self.tqpeak.get()),
|
||||
|
||||
"starter_rpm_nominal": float(self.st_nom.get()),
|
||||
"starter_voltage_min": float(self.st_vmin.get()),
|
||||
"start_rpm_threshold": float(self.st_thr.get()),
|
||||
"stall_rpm": float(self.stall.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.dk_idle.get()),
|
||||
"throttle_plate_overrun_pct": float(self.dk_over.get()),
|
||||
"throttle_plate_tau_s": float(self.dk_tau.get()),
|
||||
"torque_ctrl_kp": float(self.tq_kp.get()),
|
||||
"torque_ctrl_ki": float(self.tq_ki.get()),
|
||||
|
||||
"rpm_jitter_idle_amp_rpm": float(self.jit_idle.get()),
|
||||
"rpm_jitter_high_amp_rpm": float(self.jit_high.get()),
|
||||
"rpm_jitter_tau_s": float(self.jit_tau.get()),
|
||||
"rpm_jitter_off_threshold_rpm": float(self.jit_off.get()),
|
||||
|
||||
"coolant_ambient_c": float(self.amb_c.get()),
|
||||
"idle_cold_gain_per_deg": float(self.cold_k.get()),
|
||||
"idle_cold_gain_max": float(self.cold_max.get()),
|
||||
|
||||
"throttle_pedal_pct": float(self.pedal.get()),
|
||||
}}
|
||||
self.sim.load_config(cfg)
|
241
app/simulation/ui/gearbox.py
Normal file
241
app/simulation/ui/gearbox.py
Normal file
@@ -0,0 +1,241 @@
|
||||
# =============================
|
||||
# app/simulation/ui/gearbox.py
|
||||
# =============================
|
||||
from __future__ import annotations
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from typing import Dict, Any
|
||||
from app.simulation.ui import UITab
|
||||
from app.simulation.modules.gearbox import GEARBOX_DEFAULTS
|
||||
|
||||
class GearboxTab(UITab):
|
||||
NAME = "gearbox"
|
||||
TITLE = "Getriebe & Antrieb"
|
||||
PRIO = 12
|
||||
|
||||
def __init__(self, parent, sim):
|
||||
self.sim = sim
|
||||
self.frame = ttk.Frame(parent, padding=8)
|
||||
for c in (0,1,2,3): self.frame.columnconfigure(c, weight=1)
|
||||
|
||||
# ---------- Linke Spalte ----------
|
||||
rowL = 0
|
||||
def L(lbl, var=None, w=12, kind="entry", values=None):
|
||||
nonlocal rowL
|
||||
ttk.Label(self.frame, text=lbl).grid(row=rowL, column=0, sticky="w")
|
||||
if kind == "entry":
|
||||
ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowL, column=1, sticky="w")
|
||||
elif kind == "label":
|
||||
ttk.Label(self.frame, textvariable=var).grid(row=rowL, column=1, sticky="w")
|
||||
elif kind == "combo":
|
||||
ttk.Combobox(self.frame, textvariable=var, state="readonly",
|
||||
values=values or [], width=w).grid(row=rowL, column=1, sticky="w")
|
||||
elif kind == "buttons":
|
||||
f = ttk.Frame(self.frame); f.grid(row=rowL, column=1, sticky="w")
|
||||
ttk.Button(f, text="▼", width=3, command=self.shift_down).pack(side="left", padx=(0,4))
|
||||
ttk.Button(f, text="N", width=3, command=self.set_neutral).pack(side="left", padx=(0,4))
|
||||
ttk.Button(f, text="▲", width=3, command=self.shift_up).pack(side="left")
|
||||
rowL += 1
|
||||
|
||||
# Live/Controls (Labels → werden im _tick() live aktualisiert)
|
||||
self.gear_var = tk.StringVar(); L("Gang", self.gear_var, kind="label")
|
||||
L("Schalten", kind="buttons")
|
||||
self.speed_var = tk.StringVar(); L("Geschwindigkeit [km/h]", self.speed_var, kind="label")
|
||||
self.clutch_v = tk.StringVar(); L("Kupplung [%]", self.clutch_v, kind="label")
|
||||
self.slip_v = tk.StringVar(); L("Reifenschlupf [%]", self.slip_v, kind="label")
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1
|
||||
|
||||
# Kupplung/Automation
|
||||
self.cl_Tmax = tk.DoubleVar(); L("Kupplung Tmax [Nm]", self.cl_Tmax)
|
||||
self.cl_agr = tk.DoubleVar(); L("Aggressivität [0..1]", self.cl_agr)
|
||||
self.cl_curve= tk.StringVar(); L("Kupplungs-Kurve", self.cl_curve, kind="combo",
|
||||
values=["linear","progressive","soft"])
|
||||
self.cl_drag = tk.DoubleVar(); L("Kupplungs-Schlepp [Nm]", self.cl_drag)
|
||||
self.sh_time = tk.DoubleVar(); L("Schaltzeit [s]", self.sh_time)
|
||||
self.sync_rb = tk.DoubleVar(); L("Sync-Band [RPM]", self.sync_rb)
|
||||
|
||||
# ---------- Rechte Spalte ----------
|
||||
rowR = 0
|
||||
def R(lbl, var=None, w=12, kind="entry"):
|
||||
nonlocal rowR
|
||||
ttk.Label(self.frame, text=lbl).grid(row=rowR, column=2, sticky="w")
|
||||
if kind == "entry":
|
||||
ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowR, column=3, sticky="w")
|
||||
elif kind == "label":
|
||||
ttk.Label(self.frame, textvariable=var).grid(row=rowR, column=3, sticky="w")
|
||||
rowR += 1
|
||||
|
||||
# Übersetzungen / Rad
|
||||
self.primary = tk.DoubleVar(); R("Primärübersetzung [-]", self.primary)
|
||||
self.zf = tk.IntVar(); R("Ritzel vorn [Z]", self.zf)
|
||||
self.zr = tk.IntVar(); R("Ritzel hinten [Z]", self.zr)
|
||||
self.rwheel = tk.DoubleVar(); R("Radradius [m]", self.rwheel)
|
||||
self.eta = tk.DoubleVar(); R("Wirkungsgrad [-]", self.eta)
|
||||
self.couple = tk.DoubleVar(); R("RPM-Kopplung [0..1]", self.couple)
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
|
||||
|
||||
# Gangübersetzungen 1..6
|
||||
self.g1 = tk.DoubleVar(); R("Gang 1 Ratio", self.g1)
|
||||
self.g2 = tk.DoubleVar(); R("Gang 2 Ratio", self.g2)
|
||||
self.g3 = tk.DoubleVar(); R("Gang 3 Ratio", self.g3)
|
||||
self.g4 = tk.DoubleVar(); R("Gang 4 Ratio", self.g4)
|
||||
self.g5 = tk.DoubleVar(); R("Gang 5 Ratio", self.g5)
|
||||
self.g6 = tk.DoubleVar(); R("Gang 6 Ratio", self.g6)
|
||||
|
||||
ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
|
||||
|
||||
# Widerstände / Reifen
|
||||
self.c_rr = tk.DoubleVar(); R("Rollkoeff. c_rr", self.c_rr)
|
||||
self.rho = tk.DoubleVar(); R("Luftdichte [kg/m³]", self.rho)
|
||||
self.cd = tk.DoubleVar(); R("c_d [-]", self.cd)
|
||||
self.A = tk.DoubleVar(); R("Stirnfläche [m²]", self.A)
|
||||
self.mu_p = tk.DoubleVar(); R("Reifen μ_peak", self.mu_p)
|
||||
self.mu_s = tk.DoubleVar(); R("Reifen μ_slide", self.mu_s)
|
||||
self.w_rear = tk.DoubleVar(); R("Gewichtsanteil hinten [-]", self.w_rear)
|
||||
|
||||
# ---------- Buttons ----------
|
||||
rowBtns = max(rowL, rowR) + 1
|
||||
btn = ttk.Frame(self.frame); btn.grid(row=rowBtns, column=0, columnspan=4, sticky="w", pady=(8,0))
|
||||
ttk.Button(btn, text="Aktualisieren", command=self.refresh).pack(side="left")
|
||||
ttk.Button(btn, text="Anwenden", command=self.apply).pack(side="left", padx=(8,0))
|
||||
|
||||
self.refresh()
|
||||
self._tick()
|
||||
|
||||
# --- Live-Update nur für Labels ---
|
||||
def _tick(self):
|
||||
snap = self.sim.snapshot()
|
||||
gear = int(snap.get("gear", 0))
|
||||
self.gear_var.set("N" if gear == 0 else str(gear))
|
||||
self.speed_var.set(f"{float(snap.get('speed_kmh', 0.0)):.1f}")
|
||||
self.clutch_v.set(f"{float(snap.get('clutch_pct', 0.0)):.0f}")
|
||||
self.slip_v.set(f"{float(snap.get('wheel_slip_pct', 0.0)):.0f}")
|
||||
try:
|
||||
self.frame.after(200, self._tick)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
# --- Actions (Buttons) ---
|
||||
def shift_up(self): self.sim.v.set("gear_shift_up", True)
|
||||
def shift_down(self): self.sim.v.set("gear_shift_down", True)
|
||||
def set_neutral(self): self.sim.v.set("gear_set_neutral", True)
|
||||
|
||||
# --- Data flow ---
|
||||
def refresh(self):
|
||||
# Live-Felder werden vom _tick() versorgt; hier nur Config mergen
|
||||
g = dict(GEARBOX_DEFAULTS)
|
||||
g.update(self.sim.v.config.get("gearbox", {}))
|
||||
|
||||
self.cl_Tmax.set(g["clutch_max_torque_nm"])
|
||||
self.cl_agr.set(g["clutch_aggressiveness"])
|
||||
self.cl_curve.set(g.get("clutch_curve", "linear"))
|
||||
self.cl_drag.set(g["clutch_drag_nm"])
|
||||
self.sh_time.set(g["shift_time_s"])
|
||||
self.sync_rb.set(g["sync_rpm_band"])
|
||||
|
||||
self.primary.set(g["primary_ratio"])
|
||||
self.zf.set(g["front_sprocket_teeth"])
|
||||
self.zr.set(g["rear_sprocket_teeth"])
|
||||
self.rwheel.set(g["wheel_radius_m"])
|
||||
self.eta.set(g["drivetrain_efficiency"])
|
||||
self.couple.set(g["rpm_couple_gain"])
|
||||
|
||||
ratios = list(g["gear_ratios"]) + [0.0]*7
|
||||
self.g1.set(ratios[1]); self.g2.set(ratios[2]); self.g3.set(ratios[3])
|
||||
self.g4.set(ratios[4]); self.g5.set(ratios[5]); self.g6.set(ratios[6])
|
||||
|
||||
self.c_rr.set(g["rolling_c"])
|
||||
self.rho.set(g["air_density"])
|
||||
self.cd.set(g["aero_cd"])
|
||||
self.A.set(g["frontal_area_m2"])
|
||||
self.mu_p.set(g["tire_mu_peak"])
|
||||
self.mu_s.set(g["tire_mu_slide"])
|
||||
self.w_rear.set(g["rear_static_weight_frac"])
|
||||
|
||||
def apply(self):
|
||||
cfg = {"gearbox": {
|
||||
"clutch_max_torque_nm": float(self.cl_Tmax.get()),
|
||||
"clutch_aggressiveness": float(self.cl_agr.get()),
|
||||
"clutch_curve": self.cl_curve.get(),
|
||||
"clutch_drag_nm": float(self.cl_drag.get()),
|
||||
"shift_time_s": float(self.sh_time.get()),
|
||||
"sync_rpm_band": float(self.sync_rb.get()),
|
||||
|
||||
"primary_ratio": float(self.primary.get()),
|
||||
"front_sprocket_teeth": int(self.zf.get()),
|
||||
"rear_sprocket_teeth": int(self.zr.get()),
|
||||
"wheel_radius_m": float(self.rwheel.get()),
|
||||
"drivetrain_efficiency": float(self.eta.get()),
|
||||
"rpm_couple_gain": float(self.couple.get()),
|
||||
|
||||
"gear_ratios": [
|
||||
0.0,
|
||||
float(self.g1.get()),
|
||||
float(self.g2.get()),
|
||||
float(self.g3.get()),
|
||||
float(self.g4.get()),
|
||||
float(self.g5.get()),
|
||||
float(self.g6.get())
|
||||
],
|
||||
|
||||
"rolling_c": float(self.c_rr.get()),
|
||||
"air_density": float(self.rho.get()),
|
||||
"aero_cd": float(self.cd.get()),
|
||||
"frontal_area_m2": float(self.A.get()),
|
||||
"tire_mu_peak": float(self.mu_p.get()),
|
||||
"tire_mu_slide": float(self.mu_s.get()),
|
||||
"rear_static_weight_frac": float(self.w_rear.get()),
|
||||
}}
|
||||
self.sim.load_config(cfg)
|
||||
|
||||
def save_into_config(self, out: Dict[str, Any]) -> None:
|
||||
out.setdefault("gearbox", {}).update({
|
||||
"clutch_max_torque_nm": float(self.cl_Tmax.get()),
|
||||
"clutch_aggressiveness": float(self.cl_agr.get()),
|
||||
"clutch_curve": self.cl_curve.get(),
|
||||
"clutch_drag_nm": float(self.cl_drag.get()),
|
||||
"shift_time_s": float(self.sh_time.get()),
|
||||
"sync_rpm_band": float(self.sync_rb.get()),
|
||||
"primary_ratio": float(self.primary.get()),
|
||||
"front_sprocket_teeth": int(self.zf.get()),
|
||||
"rear_sprocket_teeth": int(self.zr.get()),
|
||||
"wheel_radius_m": float(self.rwheel.get()),
|
||||
"drivetrain_efficiency": float(self.eta.get()),
|
||||
"rpm_couple_gain": float(self.couple.get()),
|
||||
"gear_ratios": [0.0, float(self.g1.get()), float(self.g2.get()), float(self.g3.get()),
|
||||
float(self.g4.get()), float(self.g5.get()), float(self.g6.get())],
|
||||
"rolling_c": float(self.c_rr.get()),
|
||||
"air_density": float(self.rho.get()),
|
||||
"aero_cd": float(self.cd.get()),
|
||||
"frontal_area_m2": float(self.A.get()),
|
||||
"tire_mu_peak": float(self.mu_p.get()),
|
||||
"tire_mu_slide": float(self.mu_s.get()),
|
||||
"rear_static_weight_frac": float(self.w_rear.get()),
|
||||
})
|
||||
|
||||
def load_from_config(self, cfg: Dict[str, Any]) -> None:
|
||||
g = dict(GEARBOX_DEFAULTS); g.update(cfg.get("gearbox", {}))
|
||||
self.cl_Tmax.set(g["clutch_max_torque_nm"])
|
||||
self.cl_agr.set(g["clutch_aggressiveness"])
|
||||
self.cl_curve.set(g.get("clutch_curve","linear"))
|
||||
self.cl_drag.set(g["clutch_drag_nm"])
|
||||
self.sh_time.set(g["shift_time_s"])
|
||||
self.sync_rb.set(g["sync_rpm_band"])
|
||||
self.primary.set(g["primary_ratio"])
|
||||
self.zf.set(g["front_sprocket_teeth"])
|
||||
self.zr.set(g["rear_sprocket_teeth"])
|
||||
self.rwheel.set(g["wheel_radius_m"])
|
||||
self.eta.set(g["drivetrain_efficiency"])
|
||||
self.couple.set(g["rpm_couple_gain"])
|
||||
ratios = list(g["gear_ratios"]) + [0.0]*7
|
||||
self.g1.set(ratios[1]); self.g2.set(ratios[2]); self.g3.set(ratios[3])
|
||||
self.g4.set(ratios[4]); self.g5.set(ratios[5]); self.g6.set(ratios[6])
|
||||
self.c_rr.set(g["rolling_c"])
|
||||
self.rho.set(g["air_density"])
|
||||
self.cd.set(g["aero_cd"])
|
||||
self.A.set(g["frontal_area_m2"])
|
||||
self.mu_p.set(g["tire_mu_peak"])
|
||||
self.mu_s.set(g["tire_mu_slide"])
|
||||
self.w_rear.set(g["rear_static_weight_frac"])
|
@@ -1,122 +0,0 @@
|
||||
# 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)
|
@@ -1,66 +0,0 @@
|
||||
# simulator.py — Driveline & ECU-State
|
||||
from __future__ import annotations
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class DrivelineModel:
|
||||
idle_rpm: int = 1400
|
||||
max_rpm: int = 9500
|
||||
kmh_per_krpm: tuple = (0.0, 12.0, 19.0, 25.0, 32.0, 38.0, 45.0)
|
||||
rpm_rise_per_s: int = 5000
|
||||
rpm_fall_per_s: int = 3500
|
||||
|
||||
def target_rpm_from_throttle(self, throttle_pct: int) -> int:
|
||||
t = max(0, min(100, throttle_pct)) / 100.0
|
||||
return int(self.idle_rpm + t * (self.max_rpm - self.idle_rpm))
|
||||
|
||||
def speed_from_rpm_gear(self, rpm: int, gear: int) -> float:
|
||||
if gear <= 0:
|
||||
return 0.0
|
||||
k = self.kmh_per_krpm[min(gear, len(self.kmh_per_krpm) - 1)]
|
||||
return (rpm / 1000.0) * k
|
||||
|
||||
class EcuState:
|
||||
"""Thread-sichere Zustandsmaschine (Gang, Gas, RPM, Speed)."""
|
||||
def __init__(self, model: DrivelineModel | None = None) -> None:
|
||||
self.model = model or DrivelineModel()
|
||||
self._lock = threading.Lock()
|
||||
self._gear = 0
|
||||
self._throttle = 0
|
||||
self._rpm = self.model.idle_rpm
|
||||
self._speed = 0.0
|
||||
self._last = time.monotonic()
|
||||
|
||||
def set_gear(self, gear: int) -> None:
|
||||
with self._lock:
|
||||
self._gear = max(0, min(6, int(gear)))
|
||||
|
||||
def set_throttle(self, thr: int) -> None:
|
||||
with self._lock:
|
||||
self._throttle = max(0, min(100, int(thr)))
|
||||
|
||||
def snapshot(self) -> tuple[int, int, int, float]:
|
||||
with self._lock:
|
||||
return self._gear, self._throttle, self._rpm, self._speed
|
||||
|
||||
def update(self) -> None:
|
||||
now = time.monotonic()
|
||||
dt = max(0.0, min(0.1, now - self._last))
|
||||
self._last = now
|
||||
with self._lock:
|
||||
target = self.model.target_rpm_from_throttle(self._throttle)
|
||||
if self._rpm < target:
|
||||
self._rpm = min(self._rpm + int(self.model.rpm_rise_per_s * dt), target)
|
||||
else:
|
||||
self._rpm = max(self._rpm - int(self.model.rpm_fall_per_s * dt), target)
|
||||
min_idle = 800 if self._gear == 0 and self._throttle == 0 else self.model.idle_rpm
|
||||
self._rpm = max(min_idle, min(self._rpm, self.model.max_rpm))
|
||||
|
||||
target_speed = self.model.speed_from_rpm_gear(self._rpm, self._gear)
|
||||
alpha = min(1.0, 4.0 * dt)
|
||||
if self._gear == 0:
|
||||
target_speed = 0.0
|
||||
self._speed = (1 - alpha) * self._speed + alpha * target_speed
|
||||
self._speed = max(0.0, min(self._speed, 299.0))
|
@@ -1,12 +0,0 @@
|
||||
# =============================
|
||||
# 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: ...
|
@@ -1,192 +0,0 @@
|
||||
# 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!
|
@@ -1,77 +0,0 @@
|
||||
# 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
|
@@ -1,176 +0,0 @@
|
||||
# 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)
|
@@ -1,66 +0,0 @@
|
||||
# =============================
|
||||
# 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)
|
100
default.json
Normal file
100
default.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"app": {
|
||||
"can": { "interface": "vcan0", "resp_id": "0x7E8", "timeout_ms": 200 },
|
||||
"ui": {
|
||||
"font_family": "DejaVu Sans",
|
||||
"font_size": 10,
|
||||
"window": { "width": 1100, "height": 720 }
|
||||
},
|
||||
"logging": { "level": "INFO", "file": "logs/app.log" }
|
||||
},
|
||||
"sim": {
|
||||
"engine": {
|
||||
"idle_rpm": 1200,
|
||||
"max_rpm": 9000,
|
||||
"rpm_rise_per_s": 4000,
|
||||
"rpm_fall_per_s": 3000,
|
||||
"throttle_curve": "linear",
|
||||
|
||||
"starter_rpm_nominal": 250.0,
|
||||
"starter_voltage_min": 10.5,
|
||||
"start_rpm_threshold": 210.0,
|
||||
"stall_rpm": 500.0,
|
||||
|
||||
"coolant_ambient_c": 20.0,
|
||||
"idle_cold_gain_per_deg": 3.0,
|
||||
"idle_cold_gain_max": 500.0,
|
||||
|
||||
"oil_pressure_idle_bar": 1.2,
|
||||
"oil_pressure_slope_bar_per_krpm": 0.8,
|
||||
"oil_pressure_off_floor_bar": 0.2,
|
||||
|
||||
"engine_power_kw": 40.0,
|
||||
"torque_peak_rpm": 5500.0,
|
||||
|
||||
"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,
|
||||
|
||||
"rpm_jitter_idle_amp_rpm": 12.0,
|
||||
"rpm_jitter_high_amp_rpm": 4.0,
|
||||
"rpm_jitter_tau_s": 0.2,
|
||||
"rpm_jitter_off_threshold_rpm": 250.0,
|
||||
|
||||
"throttle_pedal_pct": 0.0
|
||||
},
|
||||
"cooling": {
|
||||
"thermostat_open_c": 85.0,
|
||||
"thermostat_full_c": 100.0,
|
||||
|
||||
"rad_base_u_w_per_k": 220.0,
|
||||
"ram_air_gain_per_kmh": 7.0,
|
||||
|
||||
"fan1_on_c": 98.0,
|
||||
"fan1_off_c": 95.0,
|
||||
"fan1_power_w": 120.0,
|
||||
"fan1_airflow_gain": 300.0,
|
||||
|
||||
"fan2_on_c": 104.0,
|
||||
"fan2_off_c": 100.0,
|
||||
"fan2_power_w": 180.0,
|
||||
"fan2_airflow_gain": 500.0,
|
||||
|
||||
"coolant_thermal_cap_j_per_k": 120000.0,
|
||||
"oil_thermal_cap_j_per_k": 150000.0,
|
||||
|
||||
"oil_coolant_u_w_per_k": 80.0,
|
||||
"oil_to_amb_u_w_per_k": 25.0,
|
||||
|
||||
"engine_heat_frac_to_coolant": 0.8
|
||||
},
|
||||
"dtc": {
|
||||
"P0300": false,
|
||||
"P0130": false,
|
||||
"C0035": false,
|
||||
"U0121": false
|
||||
},
|
||||
"vehicle": {
|
||||
"type": "motorcycle",
|
||||
"mass_kg": 210.0,
|
||||
"abs": true,
|
||||
"tcs": false
|
||||
},
|
||||
"electrical": {
|
||||
"battery_capacity_ah": 8.0,
|
||||
"battery_r_int_ohm": 0.02,
|
||||
"alternator_reg_v": 14.2,
|
||||
"alternator_rated_a": 20.0,
|
||||
"alt_cut_in_rpm": 1500,
|
||||
"alt_full_rpm": 4000
|
||||
},
|
||||
"gearbox": {
|
||||
"num_gears": 6,
|
||||
"reverse": false,
|
||||
"kmh_per_krpm": [0.0, 12.0, 19.0, 25.0, 32.0, 38.0, 45.0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user