made everything modular

This commit is contained in:
2025-09-05 01:03:14 +02:00
parent 268dc201bf
commit 0276a3fb3c
21 changed files with 788 additions and 692 deletions

View File

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

View File

@@ -1,6 +1,9 @@
# =============================
# app/simulation/modules/basic.py
# =============================
from __future__ import annotations
from ..vehicle import Vehicle, Module
from app.simulation.simulator import Module, Vehicle
import bisect
def _ocv_from_soc(soc: float, table: dict[float, float]) -> float:
@@ -17,6 +20,8 @@ def _ocv_from_soc(soc: float, table: dict[float, float]) -> float:
return y0 + t*(y1 - y0)
class BasicModule(Module):
PRIO = 10
NAME = "basic"
"""
- Zündungslogik inkl. START→ON nach crank_time_s
- Ambient-Temperatur als globale Umweltgröße

View File

@@ -3,7 +3,7 @@
# =============================
from __future__ import annotations
from ..vehicle import Vehicle, Module
from app.simulation.simulator import Module, Vehicle
import random, math
# Ein einziger Wahrheitsanker für alle Defaults:
@@ -50,6 +50,8 @@ ENGINE_DEFAULTS = {
}
class EngineModule(Module):
PRIO = 20
NAME = "engine"
"""
Erweiterte Motormodellierung mit realistischem Jitter & Drive-by-Wire:
- OFF/ACC/ON/START Logik, Starten/Abwürgen
@@ -310,16 +312,17 @@ class EngineModule(Module):
self._rpm_noise *= 0.9
# --- Klammern & Setzen -----------------------------------------------------
rpm = max(0.0, min(rpm, maxr))
cool = max(-40.0, min(cool, 120.0))
oil = max(-40.0, min(oil, 150.0))
rpm = max(0.0, min(rpm, maxr))
cool = max(-40.0, min(cool, 120.0))
oil = max(-40.0, min(oil, 150.0))
oil_p = max(oil_floor_off if not self._running else oil_floor_off, min(8.0, oil_p))
v.set("rpm", int(rpm))
v.set("coolant_temp", round(cool, 1))
v.set("oil_temp", round(oil, 1))
v.set("oil_pressure", round(oil_p, 2))
# WICHTIG: NICHT runden das macht das Dashboard per fmt
v.set("coolant_temp", float(cool))
v.set("oil_temp", float(oil))
v.set("oil_pressure", float(oil_p))
v.set("engine_available_torque_nm", float(avail_torque))
v.set("engine_net_torque_nm", float(net_torque))
v.set("throttle_pedal_pct", float(pedal))
v.set("throttle_plate_pct", float(self._plate_pct))
v.set("throttle_plate_pct", float(self._plate_pct))

View File

@@ -1,8 +1,13 @@
# =============================
# app/simulation/modules/gearbox.py
# =============================
from __future__ import annotations
from ..vehicle import Vehicle, Module
from app.simulation.simulator import Module, Vehicle
class GearboxModule(Module):
PRIO = 30
NAME = "gearbox"
"""Koppelt Engine-RPM ↔ Wheel-Speed; registriert speed_kmh/gear fürs Dashboard."""
def __init__(self):
self.speed_tau = 0.3

213
app/simulation/simulator.py Normal file
View File

@@ -0,0 +1,213 @@
# app/simulation/simulator.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, Any, List, Optional, Tuple, Type
import importlib, pkgutil, inspect, pathlib
# ---------------------- Core: Vehicle + Accumulator-API ----------------------
@dataclass
class Vehicle:
"""
State-/Config-Container + Dashboard-Registry + generische Frame-Akkumulatoren.
Grundprinzip:
- set(key, value): harter Setzer (eine Quelle „besitzt“ den Wert)
- get/ensure: lesen/initialisieren
- push(key, delta, source): additiv beitragen (Source/Sink über Vorzeichen)
- acc_total(key): Summe aller Beiträge in diesem Frame
- acc_breakdown(key): Beiträge je Quelle (Debug/Transparenz)
- acc_reset(): zu Framebeginn alle Akkus löschen
Konvention (Empfehlung, aber nicht erzwungen):
* Positive Beiträge „belasten“ (z. B. Widerstandsmoment, Laststrom)
* Negative Beiträge „speisen“ (z. B. Generator-Moment, Einspeisestrom)
"""
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)
# Accumulatoren: 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:
"""
Additiver Beitrag zu einer Größe.
Vorzeichen: + belastet / - speist (Empfehlung).
"""
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)
if not bucket: return 0.0
return sum(bucket.values())
def acc_breakdown(self, key: str) -> Dict[str, float]:
return dict(self._acc.get(key, {}))
# ---- Backwards-compat convenience for your current Basic code ----
def elec_reset_frame(self) -> None:
# map legacy helpers auf generisches System
# loads + sources werden in einem Kanal gesammelt
# (loads positiv, sources negativ)
# Diese Methode ist mittlerweile redundant, acc_reset() macht alles.
pass
def elec_add_load(self, name: str, amps: float) -> None:
self.push("elec.current", +max(0.0, float(amps)), source=name)
def elec_add_source(self, name: str, amps: float) -> None:
self.push("elec.current", -max(0.0, float(amps)), source=name)
def elec_totals(self) -> Tuple[float, float]:
"""
Gibt (loads_a_positiv, sources_a_positiv) zurück.
Intern liegt alles algebraisch in 'elec.current'.
"""
bd = self.acc_breakdown("elec.current")
loads = sum(v for v in bd.values() if v > 0)
sources = sum(-v for v in bd.values() if v < 0)
return (loads, sources)
# ---------------------------- Module Base + Loader ----------------------------
class Module:
"""
Basisklasse für alle Module. Jedes Modul:
- deklariert PRIO (klein = früher)
- hat NAME (für Debug/Registry)
- implementiert apply(v, dt)
"""
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]:
"""
Sucht in app/simulation/modules nach Klassen, die Module erben,
instanziert sie und sortiert nach PRIO.
"""
mods: List[Module] = []
try:
pkg = importlib.import_module(pkg_name)
except Exception as exc:
raise RuntimeError(f"Module package '{pkg_name}' konnte nicht geladen werden: {exc}")
pkg_path = pathlib.Path(pkg.__file__).parent
for _, modname, ispkg in pkgutil.iter_modules([str(pkg_path)]):
if ispkg: # optional: auch Subpackages zulassen
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 not issubclass(obj, Module):
continue
if obj is Module:
continue
try:
inst = obj() # Module ohne args
except Exception as exc:
print(f"[loader] Kann {obj.__name__} nicht instanziieren: {exc}")
continue
mods.append(inst)
# sortieren nach PRIO; bei Gleichstand NAME als Tie-Break
mods.sort(key=lambda x: (getattr(x, "PRIO", 100), getattr(x, "NAME", x.__class__.__name__)))
return mods
# ------------------------------- Simulator API --------------------------------
class VehicleSimulator:
"""
Öffentliche Fassade für GUI/Tests.
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)
def update(self, dt: float) -> None:
# pro Frame alle Akkumulatoren leeren
self.v.acc_reset()
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}")
# Kompatible Hilfsfunktionen für GUI
def snapshot(self) -> Dict[str, Any]:
return self.v.snapshot()
def load_config(self, cfg: Dict[str, Any]) -> None:
# Namespaced-Merge; Keys bleiben modul-spezifisch
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]:
return {ns: dict(data) for ns, data in self.v.config.items()} | {"dtc": dict(self.v.dtc)}
# für alte GUI-Knöpfe
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)))) # falls noch genutzt

View File

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

View 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

199
app/simulation/ui/basic.py Normal file
View File

@@ -0,0 +1,199 @@
# =============================
# 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
"""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!

45
app/simulation/ui/dtc.py Normal file
View File

@@ -0,0 +1,45 @@
# =============================
# 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"),
("P0130", "O2 Sensor Circuit (Bank1-Sensor1)"),
("C0035", "Wheel Speed Sensor LF"),
("U0121", "Lost Communication With ABS")
]
class DtcTab(UITab):
NAME = "dtc"
TITLE = "Fehlercodes"
PRIO = 10
def __init__(self, parent, sim):
self.sim = sim
self.frame = ttk.Frame(parent, padding=8)
self.vars: Dict[str, tk.BooleanVar] = {}
row = 0
ttk.Label(self.frame, text="Diagnose-Flags (Demo)", style="Header.TLabel").grid(row=row, column=0, sticky="w"); row += 1
for code, label in DTC_LIST:
var = tk.BooleanVar(value=False)
ttk.Checkbutton(self.frame, text=f"{code} {label}", variable=var).grid(row=row, column=0, sticky="w")
self.vars[code] = var; row += 1
ttk.Button(self.frame, text="Alle löschen", command=self.clear_all).grid(row=row, column=0, sticky="w", pady=(8,0))
def clear_all(self):
for v in self.vars.values(): v.set(False)
def save_into_config(self, out: Dict[str, Any]) -> None:
out.setdefault("dtc", {})
out["dtc"].update({code: bool(v.get()) for code, v in self.vars.items()})
def load_from_config(self, cfg: Dict[str, Any]) -> None:
dtc = cfg.get("dtc", {})
for code, v in self.vars.items():
v.set(bool(dtc.get(code, False)))
self.sim.load_config(cfg)

184
app/simulation/ui/engine.py Normal file
View File

@@ -0,0 +1,184 @@
# =============================
# app/simulation/ui/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
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)
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)

View File

@@ -0,0 +1,72 @@
# =============================
# app/simulation/ui/gearbox.py
# =============================
from __future__ import annotations
import tkinter as tk
from tkinter import ttk
from typing import Dict, Any, List
from app.simulation.ui import UITab
class GearboxTab(UITab):
NAME = "gearbox"
TITLE = "Getriebe"
PRIO = 10
def __init__(self, parent, sim):
self.sim = sim
self.frame = ttk.Frame(parent, padding=8)
self.frame.columnconfigure(1, weight=1)
ttk.Label(self.frame, text="Gänge (inkl. Leerlauf als 0)").grid(row=0, column=0, sticky="w")
self.gears_var = tk.IntVar(value=6)
ttk.Spinbox(self.frame, from_=1, to=10, textvariable=self.gears_var, width=6, command=self._rebuild_ratios).grid(row=0, column=1, sticky="w")
self.reverse_var = tk.BooleanVar(value=False)
ttk.Checkbutton(self.frame, text="Rückwärtsgang vorhanden", variable=self.reverse_var).grid(row=1, column=0, columnspan=2, sticky="w")
ttk.Label(self.frame, text="km/h pro 1000 RPM je Gang").grid(row=2, column=0, sticky="w", pady=(6,0))
self.ratio_frame = ttk.Frame(self.frame); self.ratio_frame.grid(row=3, column=0, columnspan=2, sticky="ew")
self.ratio_vars: List[tk.DoubleVar] = []
self._rebuild_ratios()
ttk.Button(self.frame, text="Anwenden", command=self.apply).grid(row=4, column=0, pady=(8,0), sticky="w")
def _rebuild_ratios(self):
for w in self.ratio_frame.winfo_children(): w.destroy()
self.ratio_vars.clear()
n = int(self.gears_var.get())
for i in range(1, n+1):
ttk.Label(self.ratio_frame, text=f"Gang {i}").grid(row=i-1, column=0, sticky="w")
v = tk.DoubleVar(value= [12.0,19.0,25.0,32.0,38.0,45.0][i-1] if i-1 < 6 else 45.0)
ttk.Entry(self.ratio_frame, textvariable=v, width=8).grid(row=i-1, column=1, sticky="w", padx=(6,12))
self.ratio_vars.append(v)
def apply(self):
ratios = [float(v.get()) for v in self.ratio_vars]
cfg = {"gearbox": {
"num_gears": int(self.gears_var.get()),
"reverse": bool(self.reverse_var.get()),
"kmh_per_krpm": [0.0] + ratios # index 0 reserved for neutral
}}
self.sim.load_config(cfg)
def save_into_config(self, out: Dict[str, Any]) -> None:
out.setdefault("gearbox", {})
out["gearbox"].update({
"num_gears": int(self.gears_var.get()),
"reverse": bool(self.reverse_var.get()),
"kmh_per_krpm": [0.0] + [float(v.get()) for v in self.ratio_vars]
})
def load_from_config(self, cfg: Dict[str, Any]) -> None:
g = cfg.get("gearbox", {})
n = int(g.get("num_gears", self.gears_var.get()))
self.gears_var.set(n); self.reverse_var.set(g.get("reverse", self.reverse_var.get()))
self._rebuild_ratios()
ratios = g.get("kmh_per_krpm") or ([0.0] + [v.get() for v in self.ratio_vars])
for i, v in enumerate(self.ratio_vars, start=1):
try: v.set(float(ratios[i]))
except Exception: pass
self.sim.load_config(cfg)

View File

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