neraly complete Model of Driving done, but needs tweaking

This commit is contained in:
2025-09-05 14:54:29 +02:00
parent 0276a3fb3c
commit 6108413d7e
12 changed files with 1469 additions and 726 deletions

View File

@@ -95,18 +95,39 @@ def launch_gui():
path = filedialog.askopenfilename(filetypes=[("JSON","*.json"),("All","*.*")]) path = filedialog.askopenfilename(filetypes=[("JSON","*.json"),("All","*.*")])
if not path: return if not path: return
with open(path,"r",encoding="utf-8") as f: data = json.load(f) with open(path,"r",encoding="utf-8") as f: data = json.load(f)
for t in ui_tabs:
if hasattr(t, "load_from_config"): t.load_from_config(data) # 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) sim.load_config(data)
messagebox.showinfo("Simulator", "Konfiguration geladen.")
def do_save(): # Tabs dürfen zusätzliche eigene Daten ziehen
cfg_out = sim.export_config()
for t in ui_tabs: for t in ui_tabs:
if hasattr(t, "save_into_config"): t.save_into_config(cfg_out) 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")]) path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON","*.json")])
if not path: return if not path: return
with open(path,"w",encoding="utf-8") as f: json.dump(cfg_out, f, indent=2) with open(path,"w",encoding="utf-8") as f: json.dump(out, f, indent=2)
messagebox.showinfo("Simulator", "Konfiguration gespeichert.") messagebox.showinfo("Simulator", "Konfiguration gespeichert.")
filemenu.add_command(label="Konfiguration laden…", command=do_load) filemenu.add_command(label="Konfiguration laden…", command=do_load)
filemenu.add_command(label="Konfiguration speichern…", command=do_save) filemenu.add_command(label="Konfiguration speichern…", command=do_save)
filemenu.add_separator(); filemenu.add_command(label="Beenden", command=root.destroy) filemenu.add_separator(); filemenu.add_command(label="Beenden", command=root.destroy)

View File

@@ -1,146 +1,185 @@
# ============================= # =============================
# app/simulation/modules/basic.py # app/simulation/modules/basic.py
# ============================= # =============================
from __future__ import annotations from __future__ import annotations
from app.simulation.simulator import Module, Vehicle from app.simulation.simulator import Module, Vehicle
import bisect import bisect, math
def _ocv_from_soc(soc: float, table: dict[float, float]) -> float: def _ocv_from_soc(soc: float, table: dict[float, float]) -> float:
# table: {SOC: OCV} unsortiert → linear interpolieren
xs = sorted(table.keys()) xs = sorted(table.keys())
ys = [table[x] for x in xs] 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) i = bisect.bisect_left(xs, s)
if i <= 0: return ys[0] if i <= 0: return ys[0]
if i >= len(xs): return ys[-1] if i >= len(xs): return ys[-1]
x0, x1 = xs[i-1], xs[i] x0, x1 = xs[i-1], xs[i]; y0, y1 = ys[i-1], ys[i]
y0, y1 = ys[i-1], ys[i]
t = 0.0 if x1 == x0 else (s - x0) / (x1 - x0) t = 0.0 if x1 == x0 else (s - x0) / (x1 - x0)
return y0 + t*(y1 - y0) return y0 + t * (y1 - y0)
class BasicModule(Module): class BasicModule(Module):
PRIO = 10 PRIO = 90
NAME = "basic" NAME = "basic"
"""
- Zündungslogik inkl. START→ON nach crank_time_s
- Ambient-Temperatur als globale Umweltgröße
- Elektrik:
* Load/Source-Aggregation via Vehicle-Helpers
* Lichtmaschine drehzahlabhängig, Regler auf alternator_reg_v
* Batterie: Kapazität (Ah), Innenwiderstand, OCV(SOC); I_batt > 0 => Entladung
"""
def __init__(self): def __init__(self):
self.crank_time_s = 2.7 self.crank_time_s = 2.7
self._crank_timer = 0.0 self._crank_timer = 0.0
def apply(self, v: Vehicle, dt: float) -> None: 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("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("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("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("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("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("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("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", {}) econf = v.config.get("electrical", {})
alt_reg_v = float(econf.get("alternator_reg_v", 14.2)) alt_reg_v = float(econf.get("alternator_reg_v", 14.2))
alt_rated_a = float(econf.get("alternator_rated_a", 20.0)) alt_rated_a = float(econf.get("alternator_rated_a", 20.0))
alt_cut_in = int(econf.get("alt_cut_in_rpm", 1500)) alt_cut_in = int(econf.get("alt_cut_in_rpm", 1500))
alt_full = int(econf.get("alt_full_rpm", 4000)) 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_cap_ah = float(econf.get("battery_capacity_ah", 8.0))
batt_rint = float(econf.get("battery_r_int_ohm", 0.020)) 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.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 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") 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)) soc = float(v.ensure("battery_soc", 0.80))
v.set("ambient_c", float(v.ensure("ambient_c", v.get("ambient_c", 20.0)))) 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 ign == "START":
if self._crank_timer <= 0.0: if self._crank_timer <= 0.0:
self._crank_timer = float(self.crank_time_s) self._crank_timer = float(self.crank_time_s)
else: else:
self._crank_timer -= dt self._crank_timer -= dt
if self._crank_timer <= 0.0: if self._crank_timer <= 0.0:
v.set("ignition", "ON") v.set("ignition", "ON"); ign = "ON"
ign = "ON"
else: else:
self._crank_timer = 0.0 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"): 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) 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) # Batterie entlädt nach I*dt
batt_v = ocv batt_i = total_batt_a
v.set("battery_voltage", round(batt_v, 2)) soc = max(0.0, min(1.0, soc - (batt_i * dt) / (3600.0 * max(0.1, batt_cap_ah))))
v.set("elx_voltage", 0.0) batt_v = ocv - batt_i * batt_rint
v.set("system_voltage", 0.0) batt_v = max(10.0, min(15.5, batt_v))
v.set("battery_current_a", 0.0)
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("alternator_current_a", 0.0)
v.set("elec_load_total_a", 0.0) v.set("elec_load_elx_a", 0.0)
v.set("battery_soc", round(soc, 3)) 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 return
# ----- ON/START: Elektrik-Bilanz ----- # --- Ab hier: Zündung ON/START (ELX aktiv) ---
# Beiträge anderer Module summieren elx_load_a = max(0.0, v.acc_total("elec.current_elx"))
loads_a, sources_a = v.elec_totals() batt_load_a = max(0.0, v.acc_total("elec.current_batt"))
# Grundlasten (z.B. ECU, Relais) net_load_a = elx_load_a + batt_load_a # Gesamtverbrauch
base_load = 0.5 if ign == "ON" else 0.6 # START leicht höher
loads_a += base_load
# Quellen anderer Module (z.B. DC-DC) können sources_a > 0 machen
# Wir ziehen Quellen von der Last ab was übrig bleibt, muss Lima/Batterie liefern
net_load_a = max(0.0, loads_a - sources_a)
# Lima-Fähigkeit aus rpm # 3) Lima-Kapazität
if rpm >= alt_cut_in: if rpm < alt_cut_in:
frac = 0.0 if rpm <= alt_cut_in else (rpm - alt_cut_in) / max(1, (alt_full - alt_cut_in))
frac = max(0.0, min(1.0, frac))
alt_cap_a = alt_rated_a * frac
else:
alt_cap_a = 0.0 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) 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_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 # Lima liefert in ELX-Bus (Quelle = negativ)
if alt_cap_a > 0.0 and alt_i >= net_load_a: if alt_i > 0.0:
# Lima deckt alles; Überschuss lädt Batterie v.push("elec.current_elx", -alt_i, source="alternator")
batt_i = -(alt_i - net_load_a) # negativ = lädt
# 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 bus_v = alt_reg_v
else: else:
# Lima (falls vorhanden) reicht nicht -> Batterie liefert Defizit batt_i = max(0.0, remaining)
deficit = net_load_a - alt_i
batt_i = max(0.0, deficit) # positiv = entlädt
bus_v = ocv - batt_i * batt_rint 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)))) 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)) batt_v = max(10.0, min(15.5, batt_v))
bus_v = max(0.0, min(15.5, bus_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)

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

View File

@@ -4,9 +4,8 @@
from __future__ import annotations from __future__ import annotations
from app.simulation.simulator import Module, Vehicle from app.simulation.simulator import Module, Vehicle
import random, math import math, random
# Ein einziger Wahrheitsanker für alle Defaults:
ENGINE_DEFAULTS = { ENGINE_DEFAULTS = {
# Basis # Basis
"idle_rpm": 1200, "idle_rpm": 1200,
@@ -14,38 +13,41 @@ ENGINE_DEFAULTS = {
"rpm_rise_per_s": 4000, "rpm_rise_per_s": 4000,
"rpm_fall_per_s": 3000, "rpm_fall_per_s": 3000,
"throttle_curve": "linear", "throttle_curve": "linear",
# Starter
# Starter / Startlogik
"starter_rpm_nominal": 250.0, "starter_rpm_nominal": 250.0,
"starter_voltage_min": 10.5, "starter_voltage_min": 10.5,
"start_rpm_threshold": 250.0, # <- fix niedriger, damit anspringt "start_rpm_threshold": 210.0,
"stall_rpm": 500.0, "stall_rpm": 500.0,
# Thermik
# Thermische Einflüsse (nur fürs Derating/Viskosität benutzt)
"coolant_ambient_c": 20.0, "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_per_deg": 3.0,
"idle_cold_gain_max": 500.0, "idle_cold_gain_max": 500.0,
# Öl
# Öl / Öldruck
"oil_pressure_idle_bar": 1.2, "oil_pressure_idle_bar": 1.2,
"oil_pressure_slope_bar_per_krpm": 0.8, "oil_pressure_slope_bar_per_krpm": 0.8,
"oil_pressure_off_floor_bar": 0.2, "oil_pressure_off_floor_bar": 0.2,
# Leistung
# Leistungsdaten
"engine_power_kw": 60.0, "engine_power_kw": 60.0,
"torque_peak_rpm": 7000.0, "torque_peak_rpm": 7000.0,
# DBW
# Drive-by-wire / Regler
"throttle_plate_idle_min_pct": 6.0, "throttle_plate_idle_min_pct": 6.0,
"throttle_plate_overrun_pct": 2.0, "throttle_plate_overrun_pct": 2.0,
"throttle_plate_tau_s": 0.08, "throttle_plate_tau_s": 0.08,
"torque_ctrl_kp": 1.2, "torque_ctrl_kp": 1.2,
"torque_ctrl_ki": 0.6, "torque_ctrl_ki": 0.6,
# Jitter
# RPM-Jitter
"rpm_jitter_idle_amp_rpm": 12.0, "rpm_jitter_idle_amp_rpm": 12.0,
"rpm_jitter_high_amp_rpm": 4.0, "rpm_jitter_high_amp_rpm": 4.0,
"rpm_jitter_tau_s": 0.20, "rpm_jitter_tau_s": 0.20,
"rpm_jitter_off_threshold_rpm": 250.0, "rpm_jitter_off_threshold_rpm": 250.0,
# UI-Startwert (nur Anzeige)
# UI
"throttle_pedal_pct": 0.0, "throttle_pedal_pct": 0.0,
} }
@@ -71,17 +73,15 @@ class EngineModule(Module):
""" """
def __init__(self): def __init__(self):
self._target = None
self._running = False self._running = False
self._oil_p_tau = 0.25 # s, Annäherung Öldruck self._oil_p_tau = 0.25 # Zeitkonstante Öldruck
# DBW intern
# Drive-by-Wire interner Zustand self._plate_pct = 5.0
self._plate_pct = 5.0 # Startwert, leicht geöffnet self._tc_i = 0.0
self._tc_i = 0.0 # Integrator PI-Regler # AR(1)-Noise
# bandbegrenztes RPM-Rauschen (AR(1))
self._rpm_noise = 0.0 self._rpm_noise = 0.0
# ---- helpers ----------------------------------------------------------
def _curve(self, t: float, mode: str) -> float: def _curve(self, t: float, mode: str) -> float:
if mode == "progressive": return t**1.5 if mode == "progressive": return t**1.5
if mode == "aggressive": return t**0.7 if mode == "aggressive": return t**0.7
@@ -90,34 +90,34 @@ class EngineModule(Module):
def _torque_at_rpm(self, power_kw: float, rpm: float, peak_rpm: float) -> float: def _torque_at_rpm(self, power_kw: float, rpm: float, peak_rpm: float) -> float:
rpm = max(0.0, rpm) rpm = max(0.0, rpm)
t_max = (9550.0 * max(0.0, power_kw)) / max(500.0, peak_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))) 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 * math.sin(x))
return max(0.0, t_max * shape)
def _plate_airflow_factor(self, plate_pct: float) -> float: 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 theta = max(0.0, min(90.0, (plate_pct/100.0)*90.0)) * math.pi/180.0
return math.sin(theta)**2 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: def apply(self, v: Vehicle, dt: float) -> None:
e = v.config.setdefault("engine", {}) e = v.config.setdefault("engine", {})
# --- Config / Defaults --- # --- Config ---
idle = int(e.get("idle_rpm", ENGINE_DEFAULTS["idle_rpm"])) idle = float(e.get("idle_rpm", ENGINE_DEFAULTS["idle_rpm"]))
maxr = int(e.get("max_rpm", ENGINE_DEFAULTS["max_rpm"])) maxr = float(e.get("max_rpm", ENGINE_DEFAULTS["max_rpm"]))
rise = int(e.get("rpm_rise_per_s", ENGINE_DEFAULTS["rpm_rise_per_s"])) rise = float(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"])) fall = float(e.get("rpm_fall_per_s", ENGINE_DEFAULTS["rpm_fall_per_s"]))
thr_curve = e.get("throttle_curve", ENGINE_DEFAULTS["throttle_curve"]) thr_curve = e.get("throttle_curve", ENGINE_DEFAULTS["throttle_curve"])
ambient = float(e.get("coolant_ambient_c", ENGINE_DEFAULTS["coolant_ambient_c"])) 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"])) cold_gain_per_deg = float(e.get("idle_cold_gain_per_deg", ENGINE_DEFAULTS["idle_cold_gain_per_deg"]))
cool_c = float(e.get("coolant_cool_rate_c_per_s", ENGINE_DEFAULTS["coolant_cool_rate_c_per_s"])) cold_gain_max = float(e.get("idle_cold_gain_max", ENGINE_DEFAULTS["idle_cold_gain_max"]))
warm_o = float(e.get("oil_warm_rate_c_per_s", ENGINE_DEFAULTS["oil_warm_rate_c_per_s"]))
cool_o = float(e.get("oil_cool_rate_c_per_s", ENGINE_DEFAULTS["oil_cool_rate_c_per_s"]))
starter_nom = float(e.get("starter_rpm_nominal", ENGINE_DEFAULTS["starter_rpm_nominal"])) starter_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"])) starter_vmin= float(e.get("starter_voltage_min", ENGINE_DEFAULTS["starter_voltage_min"]))
@@ -127,9 +127,6 @@ class EngineModule(Module):
power_kw = float(e.get("engine_power_kw", ENGINE_DEFAULTS["engine_power_kw"])) 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"])) 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_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_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"])) oil_floor_off = float(e.get("oil_pressure_off_floor_bar", ENGINE_DEFAULTS["oil_pressure_off_floor_bar"]))
@@ -146,183 +143,145 @@ class EngineModule(Module):
jitter_off_rpm = float(e.get("rpm_jitter_off_threshold_rpm", ENGINE_DEFAULTS["rpm_jitter_off_threshold_rpm"])) jitter_off_rpm = float(e.get("rpm_jitter_off_threshold_rpm", ENGINE_DEFAULTS["rpm_jitter_off_threshold_rpm"]))
# --- State --- # --- State ---
rpm = float(v.ensure("rpm", 0)) rpm = float(v.ensure("rpm", 0.0))
# Fahrerwunsch (kommt aus dem UI-Schieber)
pedal = float(v.ensure("throttle_pedal_pct", float(e.get("throttle_pedal_pct", 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)) pedal = max(0.0, min(100.0, pedal))
load = float(v.ensure("engine_load", 0.0))
ign = str(v.ensure("ignition", "OFF")) ign = str(v.ensure("ignition", "OFF"))
elx_v = float(v.ensure("elx_voltage", 0.0)) elx_v = float(v.ensure("elx_voltage", 0.0))
cool = float(v.ensure("coolant_temp", ambient)) # nur lesen
cool = float(v.ensure("coolant_temp", ambient)) oil = float(v.ensure("oil_temp", ambient)) # nur lesen
oil = float(v.ensure("oil_temp", ambient))
oil_p = float(v.ensure("oil_pressure", 0.0)) 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 # Dashboard-Metriken
v.register_metric("rpm", label="Drehzahl", unit="RPM", source="engine", priority=20) v.register_metric("rpm", unit="RPM", fmt=".1f", label="Drehzahl", source="engine", priority=20)
v.register_metric("coolant_temp", label="Kühlmitteltemp", unit="°C", fmt=".1f", source="engine", priority=40) v.register_metric("oil_pressure", unit="bar", fmt=".2f", label="Öldruck", source="engine", priority=42)
v.register_metric("oil_temp", label="Öltemp", unit="°C", fmt=".1f", source="engine", priority=41) v.register_metric("engine_available_torque_nm", unit="Nm", fmt=".0f", label="Verfügbares Motormoment", source="engine", priority=43)
v.register_metric("oil_pressure", label="Öldruck", unit="bar", fmt=".2f", source="engine", priority=42) v.register_metric("engine_torque_load_nm", unit="Nm", fmt=".0f", label="Lastmoment ges.", source="engine", priority=44)
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", unit="Nm", fmt=".0f", label="Netto Motormoment", source="engine", priority=45)
v.register_metric("engine_net_torque_nm", label="Netto Motormoment", unit="Nm", fmt=".0f", source="engine", priority=44) v.register_metric("throttle_pedal_pct", unit="%", fmt=".0f", label="Gaspedal", source="engine", priority=46)
v.register_metric("throttle_pedal_pct", label="Gaspedal", unit="%", fmt=".0f", source="engine", priority=45) v.register_metric("throttle_plate_pct", unit="%", fmt=".0f", label="Drosselklappe", source="engine", priority=47)
v.register_metric("throttle_plate_pct", label="Drosselklappe", unit="%", fmt=".0f", source="engine", priority=46)
# Hilfsfunktionen # --- Start-/Ziel-RPM Logik ---
def visco(temp_c: float) -> float: # Starter-Viskositätseinfluss
# -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
vfac = 0.0 if elx_v <= starter_vmin else min(1.2, (elx_v - starter_vmin) / max(0.3, (12.6 - starter_vmin))) 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) # effektive Startschwelle (15..45% Idle)
start_rpm_min = 0.15 * idle # 15 % vom Idle start_rpm_min = 0.15 * idle
start_rpm_max = 0.45 * idle # 45 % vom Idle start_rpm_max = 0.45 * idle
start_rpm_th_eff = max(start_rpm_min, min(start_rpm_th, start_rpm_max)) start_rpm_th_eff = max(start_rpm_min, min(start_rpm_th, start_rpm_max))
# --- Ziel-RPM bestimmen ---
if ign in ("OFF", "ACC"): if ign in ("OFF", "ACC"):
self._running = False self._running = False
target_rpm = 0.0 target_rpm = 0.0
elif ign == "START": elif ign == "START":
target_rpm = crank_rpm # wie gehabt target_rpm = crank_rpm
# Greifen, sobald Schwelle erreicht und Spannung reicht
if not self._running and target_rpm >= start_rpm_th_eff and elx_v > starter_vmin: if not self._running and target_rpm >= start_rpm_th_eff and elx_v > starter_vmin:
self._running = True self._running = True
else: # ON 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 self._running = True
if self._running: 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 idle_eff = idle + cold_add
target_rpm = max(idle_eff, min(maxr, rpm)) target_rpm = max(idle_eff, min(maxr, rpm))
else: else:
target_rpm = 0.0 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) 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) temp_derate = max(0.7, 1.0 - max(0.0, (oil - 110.0)) * 0.005)
# Drive-by-Wire / PI auf Drehmomentanteil ----------------------------------- # --- DBW (PI auf Torque-Anteil) ---
# Fahrerwunsch in "Leistungsanteil" (0..1) transformieren (Kennlinie) demand = self._curve(pedal/100.0, thr_curve)
demand = self._curve(pedal/100.0, thr_curve) # 0..1
# Overrun-Logik: bei sehr geringem Wunsch → nahezu zu (aber nie ganz)
plate_target_min = plate_overrun if demand < 0.02 else plate_idle_min 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) airflow = self._plate_airflow_factor(self._plate_pct)
torque_avail = base_torque * airflow * temp_derate 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)) 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: if ign == "ON" and self._running:
self._tc_i += err * torque_ki * dt self._tc_i += err * torque_ki * dt
else: 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)) 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) # aktualisiertes Moment
if plate_tau <= 1e-3:
self._plate_pct = plate_cmd
else:
a = min(1.0, dt / plate_tau)
self._plate_pct = (1.0 - a) * self._plate_pct + a * plate_cmd
# Update airflow nach Stellgröße
airflow = self._plate_airflow_factor(self._plate_pct) airflow = self._plate_airflow_factor(self._plate_pct)
avail_torque = base_torque * airflow * temp_derate 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) ----------------------- # --- Wärmeleistung pushen (W) ---
# Näherung: mehr Netto-Moment → RPM-Ziel steigt innerhalb der Bandbreite # mechanische Leistung:
# Wir skalieren zwischen (idle_eff) und maxr 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: 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 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) target_rpm = idle_eff + torque_norm * (maxr - idle_eff)
# --- RPM an Ziel annähern (mechanische Trägheit) -------------------------- # Inertia
if rpm < target_rpm: if rpm < target_rpm: rpm = min(target_rpm, rpm + rise * dt)
rpm = min(target_rpm, rpm + rise * dt) else: rpm = max(target_rpm, rpm - fall * 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: if ign == "ON" and self._running and rpm < stall_rpm:
self._running = False self._running = False
# --- Temperaturen ---------------------------------------------------------- # --- Öldruck ---
heat = (rpm/maxr)*0.8 + load*0.6 if self._running and rpm > 0.0:
if (ign in ("ON","START")) and (self._running or target_rpm > 0):
cool += warm_c * heat * dt
oil += warm_o * heat * dt
else:
cool += (ambient - cool) * min(1.0, dt * cool_c)
oil += (ambient - oil) * min(1.0, dt * cool_o)
# --- Öldruck ---------------------------------------------------------------
if self._running and rpm > 0:
over_krpm = max(0.0, (rpm - idle)/1000.0) over_krpm = max(0.0, (rpm - idle)/1000.0)
oil_target = oil_idle_bar + oil_slope_bar_per_krpm * over_krpm 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) oil_target = max(oil_floor_off, 0.4)
else: else:
oil_target = oil_floor_off oil_target = oil_floor_off
a = min(1.0, dt / max(0.05, self._oil_p_tau)) a = min(1.0, dt / max(0.05, self._oil_p_tau))
oil_p = (1-a) * oil_p + a * oil_target oil_p = (1-a) * oil_p + a * oil_target
# --- Realistischer RPM-Jitter --------------------------------------------- # --- RPM-Jitter ---
# bandbegrenztes Rauschen: x[n] = (1 - b)*x[n-1] + b*eta, b ~ dt/tau
if self._running and rpm >= jitter_off_rpm and ign == "ON": if self._running and rpm >= jitter_off_rpm and ign == "ON":
b = min(1.0, dt / max(1e-3, jitter_tau)) b = min(1.0, dt / max(1e-3, jitter_tau))
eta = random.uniform(-1.0, 1.0) # weißes Rauschen eta_n = random.uniform(-1.0, 1.0)
self._rpm_noise = (1.0 - b) * self._rpm_noise + b * eta self._rpm_noise = (1.0 - b) * self._rpm_noise + b * eta_n
# Amplitude linear zwischen idle_amp und hi_amp
# bezogen auf aktuelles Drehzahlniveau (klein aber sichtbar)
amp_idle = jitter_idle_amp
amp_hi = jitter_hi_amp
# Interpolation über 0..maxr
k = max(0.0, min(1.0, rpm / max(1.0, maxr))) 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 rpm += self._rpm_noise * amp
else: else:
# Kein Jitter: Noise langsam abklingen
self._rpm_noise *= 0.9 self._rpm_noise *= 0.9
# --- Klammern & Setzen ----------------------------------------------------- # --- Clamp & Set ---
rpm = max(0.0, min(rpm, maxr)) rpm = max(0.0, min(rpm, maxr))
cool = max(-40.0, min(cool, 120.0)) oil_p = max(oil_floor_off, min(8.0, oil_p))
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("rpm", float(rpm))
# WICHTIG: NICHT runden das macht das Dashboard per fmt # Temperaturen NICHT setzen CoolingModule ist owner!
v.set("coolant_temp", float(cool))
v.set("oil_temp", float(oil))
v.set("oil_pressure", float(oil_p)) v.set("oil_pressure", float(oil_p))
v.set("engine_available_torque_nm", float(avail_torque)) 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("engine_net_torque_nm", float(net_torque))
v.set("throttle_pedal_pct", float(pedal)) 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,39 +1,227 @@
# ============================= # =============================
# app/simulation/modules/gearbox.py # app/simulation/modules/gearbox.py
# ============================= # =============================
from __future__ import annotations from __future__ import annotations
from app.simulation.simulator import Module, Vehicle 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): class GearboxModule(Module):
PRIO = 30 PRIO = 30
NAME = "gearbox" NAME = "gearbox"
"""Koppelt Engine-RPM ↔ Wheel-Speed; registriert speed_kmh/gear fürs Dashboard."""
def __init__(self): def __init__(self):
self.speed_tau = 0.3 # interner Zustand
self.rpm_couple = 0.2 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: 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("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)) # --- Config / Inputs ---
rpm = float(v.ensure("rpm", 1200)) gb = dict(GEARBOX_DEFAULTS)
speed = float(v.ensure("speed_kmh", 0.0)) gb.update(v.config.get("gearbox", {}))
ratios = v.config.get("gearbox", {}).get("kmh_per_krpm", [0.0])
if g <= 0 or g >= len(ratios): primary = float(gb["primary_ratio"])
speed = max(0.0, speed - 6.0*dt) gear_ratios = list(gb["gear_ratios"])
v.set("speed_kmh", speed) z_f = int(gb["front_sprocket_teeth"])
return z_r = int(gb["rear_sprocket_teeth"])
final = (z_r / max(1, z_f))
kmh_per_krpm = float(ratios[g]) r_w = float(gb["wheel_radius_m"])
target_speed = (rpm/1000.0) * kmh_per_krpm eta = float(gb["drivetrain_efficiency"])
alpha = min(1.0, dt / max(0.05, self.speed_tau)) couple_gain = float(gb["rpm_couple_gain"])
speed = (1-alpha) * speed + alpha * target_speed
v.set("speed_kmh", speed)
wheel_rpm = (speed / max(0.1, kmh_per_krpm)) * 1000.0 c_rr = float(gb["rolling_c"])
rpm = (1-self.rpm_couple) * rpm + self.rpm_couple * wheel_rpm rho = float(gb["air_density"])
v.set("rpm", int(rpm)) 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

View File

@@ -1,7 +1,7 @@
# app/simulation/simulator.py # app/simulation/simulator.py
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, Any, List, Optional, Tuple, Type from typing import Dict, Any, List, Optional
import importlib, pkgutil, inspect, pathlib import importlib, pkgutil, inspect, pathlib
# ---------------------- Core: Vehicle + Accumulator-API ---------------------- # ---------------------- Core: Vehicle + Accumulator-API ----------------------
@@ -11,17 +11,11 @@ class Vehicle:
""" """
State-/Config-Container + Dashboard-Registry + generische Frame-Akkumulatoren. State-/Config-Container + Dashboard-Registry + generische Frame-Akkumulatoren.
Grundprinzip: - set/get/ensure: harte Zustandswerte
- set(key, value): harter Setzer (eine Quelle „besitzt“ den Wert) - push(key, delta, source): additiver Beitrag pro Frame (Source/Sink via Vorzeichen)
- get/ensure: lesen/initialisieren - acc_total(key): Summe aller Beiträge zu 'key'
- 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_breakdown(key): Beiträge je Quelle (Debug/Transparenz)
- acc_reset(): zu Framebeginn alle Akkus löschen - acc_reset(): am Frame-Beginn 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) state: Dict[str, Any] = field(default_factory=dict)
config: Dict[str, Any] = field(default_factory=dict) config: Dict[str, Any] = field(default_factory=dict)
@@ -29,7 +23,7 @@ class Vehicle:
dashboard_specs: Dict[str, Dict[str, Any]] = field(default_factory=dict) dashboard_specs: Dict[str, Dict[str, Any]] = field(default_factory=dict)
# Accumulatoren: key -> {source_name: float} # Accumulator: key -> {source_name: float}
_acc: Dict[str, Dict[str, float]] = field(default_factory=dict) _acc: Dict[str, Dict[str, float]] = field(default_factory=dict)
# ---- state helpers ---- # ---- state helpers ----
@@ -73,80 +67,37 @@ class Vehicle:
def snapshot(self) -> Dict[str, Any]: def snapshot(self) -> Dict[str, Any]:
return dict(self.state) return dict(self.state)
# ---- generic accumulators (per-frame) ---- # ---- generic accumulators (per frame) ----
def acc_reset(self) -> None: def acc_reset(self) -> None:
self._acc.clear() self._acc.clear()
def push(self, key: str, delta: float, source: Optional[str] = None) -> None: 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" src = source or "anon"
bucket = self._acc.setdefault(key, {}) bucket = self._acc.setdefault(key, {})
bucket[src] = bucket.get(src, 0.0) + float(delta) bucket[src] = bucket.get(src, 0.0) + float(delta)
def acc_total(self, key: str) -> float: def acc_total(self, key: str) -> float:
bucket = self._acc.get(key) bucket = self._acc.get(key)
if not bucket: return 0.0 return 0.0 if not bucket else sum(bucket.values())
return sum(bucket.values())
def acc_breakdown(self, key: str) -> Dict[str, float]: def acc_breakdown(self, key: str) -> Dict[str, float]:
return dict(self._acc.get(key, {})) 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 ---------------------------- # ---------------------------- Module Base + Loader ----------------------------
class Module: 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 PRIO: int = 100
NAME: str = "module" NAME: str = "module"
def apply(self, v: Vehicle, dt: float) -> None: def apply(self, v: Vehicle, dt: float) -> None:
raise NotImplementedError raise NotImplementedError
def _discover_modules(pkg_name: str = "app.simulation.modules") -> List[Module]: 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] = [] mods: List[Module] = []
try:
pkg = importlib.import_module(pkg_name) 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 pkg_path = pathlib.Path(pkg.__file__).parent
for _, modname, ispkg in pkgutil.iter_modules([str(pkg_path)]): for _, modname, ispkg in pkgutil.iter_modules([str(pkg_path)]):
if ispkg: # optional: auch Subpackages zulassen if ispkg:
continue continue
full_name = f"{pkg_name}.{modname}" full_name = f"{pkg_name}.{modname}"
try: try:
@@ -154,60 +105,79 @@ def _discover_modules(pkg_name: str = "app.simulation.modules") -> List[Module]:
except Exception as exc: except Exception as exc:
print(f"[loader] Fehler beim Import {full_name}: {exc}") print(f"[loader] Fehler beim Import {full_name}: {exc}")
continue continue
for _, obj in inspect.getmembers(m, inspect.isclass): for _, obj in inspect.getmembers(m, inspect.isclass):
if not issubclass(obj, Module): if obj is Module or not issubclass(obj, Module):
continue
if obj is Module:
continue continue
try: try:
inst = obj() # Module ohne args inst = obj()
except Exception as exc: except Exception as exc:
print(f"[loader] Kann {obj.__name__} nicht instanziieren: {exc}") print(f"[loader] Kann {obj.__name__} nicht instanziieren: {exc}")
continue continue
mods.append(inst) 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__))) mods.sort(key=lambda x: (getattr(x, "PRIO", 100), getattr(x, "NAME", x.__class__.__name__)))
return mods return mods
# ------------------------------- Simulator API -------------------------------- # ------------------------------- Simulator API --------------------------------
class VehicleSimulator: class VehicleSimulator:
""" """Lädt Module dynamisch, führt sie pro Tick in PRIO-Reihenfolge aus."""
Ö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"): def __init__(self, modules_package: str = "app.simulation.modules"):
self.v = Vehicle() self.v = Vehicle()
self.modules: List[Module] = _discover_modules(modules_package) 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: def update(self, dt: float) -> None:
# pro Frame alle Akkumulatoren leeren self.v.acc_reset() # pro Frame Akkus leeren
self.v.acc_reset()
for m in self.modules: for m in self.modules:
try: try:
m.apply(self.v, dt) m.apply(self.v, dt)
except Exception as exc: except Exception as exc:
print(f"[sim] Modul {getattr(m, 'NAME', m.__class__.__name__)} Fehler: {exc}") print(f"[sim] Modul {getattr(m, 'NAME', m.__class__.__name__)} Fehler: {exc}")
# Kompatible Hilfsfunktionen für GUI
def snapshot(self) -> Dict[str, Any]: def snapshot(self) -> Dict[str, Any]:
return self.v.snapshot() return self.v.snapshot()
def load_config(self, cfg: Dict[str, Any]) -> None: def load_config(self, cfg: Dict[str, Any]) -> None:
# Namespaced-Merge; Keys bleiben modul-spezifisch
for k, sub in cfg.items(): for k, sub in cfg.items():
self.v.config.setdefault(k, {}).update(sub if isinstance(sub, dict) else {}) self.v.config.setdefault(k, {}).update(sub if isinstance(sub, dict) else {})
if "dtc" in cfg: if "dtc" in cfg:
self.v.dtc.update(cfg["dtc"]) self.v.dtc.update(cfg["dtc"])
def export_config(self) -> Dict[str, Any]: def export_config(self) -> Dict[str, Any]:
return {ns: dict(data) for ns, data in self.v.config.items()} | {"dtc": dict(self.v.dtc)} """
Exportiert einen *vollständigen* Snapshot:
- Modul-Defaults + Overrides (so fehlen keine Keys)
- alle übrigen Namespaces unverändert
- DTC separat
"""
out: Dict[str, Any] = {}
# für alte GUI-Knöpfe # 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: def set_gear(self, g: int) -> None:
self.v.set("gear", max(0, min(10, int(g)))) self.v.set("gear", max(0, min(10, int(g))))
def set_throttle(self, t: int) -> None: def set_throttle(self, t: int) -> None:
self.v.set("throttle_pct", max(0, min(100, int(t)))) # falls noch genutzt self.v.set("throttle_pct", max(0, min(100, int(t))))

View File

@@ -12,188 +12,198 @@ class BasicTab(UITab):
NAME = "basic" NAME = "basic"
TITLE = "Basisdaten" TITLE = "Basisdaten"
PRIO = 10 PRIO = 10
"""Basis-Fahrzeug-Tab (Zündung & Elektrik)."""
def __init__(self, parent, sim): def __init__(self, parent, sim):
self.sim = sim self.sim = sim
self.frame = ttk.Frame(parent, padding=8) self.frame = ttk.Frame(parent, padding=8)
self.frame.columnconfigure(1, weight=1) for c in (0,1,2,3): self.frame.columnconfigure(c, weight=1)
row = 0 # ---------- Linke Spalte ----------
# Vehicle basics ----------------------------------------------------------- rowL = 0
ttk.Label(self.frame, text="Fahrzeugtyp").grid(row=row, column=0, sticky="w"); row+=1 def L(lbl, var=None, w=12, kind="entry", values=None):
self.type_var = tk.StringVar(value=self.sim.v.config.get("vehicle", {}).get("type", "motorcycle")) nonlocal rowL
ttk.Combobox(self.frame, textvariable=self.type_var, state="readonly", ttk.Label(self.frame, text=lbl).grid(row=rowL, column=0, sticky="w")
values=["motorcycle", "car", "truck"], width=16)\ if kind == "entry":
.grid(row=row-1, column=1, sticky="w") ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowL, column=1, sticky="w")
elif kind == "label":
ttk.Label(self.frame, text="Gewicht [kg]").grid(row=row, column=0, sticky="w"); row+=1 ttk.Label(self.frame, textvariable=var).grid(row=rowL, column=1, sticky="w")
self.mass_var = tk.DoubleVar(value=float(self.sim.v.config.get("vehicle", {}).get("mass_kg", 210.0))) elif kind == "combo":
ttk.Entry(self.frame, textvariable=self.mass_var, width=10).grid(row=row-1, column=1, sticky="w") ttk.Combobox(self.frame, textvariable=var, state="readonly", values=values or [], width=w)\
.grid(row=rowL, column=1, sticky="w")
self.abs_var = tk.BooleanVar(value=bool(self.sim.v.config.get("vehicle", {}).get("abs", True))) elif kind == "check":
ttk.Checkbutton(self.frame, text="ABS vorhanden", variable=self.abs_var)\ ttk.Checkbutton(self.frame, variable=var).grid(row=rowL, column=1, sticky="w")
.grid(row=row, column=0, columnspan=2, sticky="w"); row+=1 elif kind == "radio":
f = ttk.Frame(self.frame); f.grid(row=rowL, column=1, sticky="w")
self.tcs_var = tk.BooleanVar(value=bool(self.sim.v.config.get("vehicle", {}).get("tcs", False))) for i,(t,vv) in enumerate(values or []):
ttk.Checkbutton(self.frame, text="ASR/Traktionskontrolle", variable=self.tcs_var)\ ttk.Radiobutton(f, text=t, value=vv, variable=var, command=self._apply_ign)\
.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)) .grid(row=0, column=i, padx=(0,6))
rowL += 1
# Live Electrical ---------------------------------------------------------- # Vehicle
ttk.Label(self.frame, text="Batterie [V]").grid(row=row, column=0, sticky="w"); row+=1 self.type = tk.StringVar(); L("Fahrzeugtyp", self.type, kind="combo", values=["motorcycle","car","truck"])
self.batt_v_var = tk.StringVar(value=f"{self.sim.snapshot().get('battery_voltage', 12.6):.2f}") self.mass = tk.DoubleVar(); L("Gewicht [kg]", self.mass)
ttk.Label(self.frame, textvariable=self.batt_v_var).grid(row=row-1, column=1, sticky="w") self.abs = tk.BooleanVar(); L("ABS vorhanden", self.abs, kind="check")
self.tcs = tk.BooleanVar(); L("ASR/Traktionskontrolle", self.tcs, kind="check")
ttk.Label(self.frame, text="ELX/Bus [V]").grid(row=row, column=0, sticky="w"); row+=1 ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 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 # Environment / Ignition
self.soc_var = tk.StringVar(value=f"{self.sim.snapshot().get('battery_soc', 0.8):.2f}") self.amb = tk.DoubleVar(); L("Umgebung [°C]", self.amb)
ttk.Label(self.frame, textvariable=self.soc_var).grid(row=row-1, column=1, sticky="w") self.ign = tk.StringVar(); L("Zündung", self.ign, kind="radio",
values=[("OFF","OFF"),("ACC","ACC"),("ON","ON"),("START","START")])
ttk.Label(self.frame, text="I Batterie [A] (+entlädt)").grid(row=row, column=0, sticky="w"); row+=1 ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 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 # Live links (Labels)
self.ialt_var = tk.StringVar(value=f"{self.sim.snapshot().get('alternator_current_a', 0.0):.2f}") self.batt_v = tk.StringVar(); L("Batterie [V]", self.batt_v, kind="label")
ttk.Label(self.frame, textvariable=self.ialt_var).grid(row=row-1, column=1, sticky="w") 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")
ttk.Label(self.frame, text="Last gesamt [A]").grid(row=row, column=0, sticky="w"); row+=1 # ---------- Rechte Spalte ----------
self.load_var = tk.StringVar(value=f"{self.sim.snapshot().get('elec_load_total_a', 0.0):.2f}") rowR = 0
ttk.Label(self.frame, textvariable=self.load_var).grid(row=row-1, column=1, sticky="w") 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
ttk.Separator(self.frame).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(6,6)); row+=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")
# Electrical config -------------------------------------------------------- ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
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 # Electrical config
self.brint = tk.DoubleVar(value=float(econf.get("battery_r_int_ohm", 0.020))) self.bcap = tk.DoubleVar(); R("Batt Kap. [Ah]", self.bcap)
ttk.Entry(self.frame, textvariable=self.brint, width=10).grid(row=row-1, column=1, sticky="w") 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)
ttk.Label(self.frame, text="Reglerspannung [V]").grid(row=row, column=0, sticky="w"); row+=1 # ---------- Buttons ----------
self.alt_v = tk.DoubleVar(value=float(econf.get("alternator_reg_v", 14.2))) rowBtns = max(rowL, rowR) + 1
ttk.Entry(self.frame, textvariable=self.alt_v, width=10).grid(row=row-1, column=1, sticky="w") 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))
ttk.Label(self.frame, text="Lima Nennstrom [A]").grid(row=row, column=0, sticky="w"); row+=1 self.refresh()
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 # ------------ Logic ------------
self.alt_cutin = tk.IntVar(value=int(econf.get("alt_cut_in_rpm", 1500))) def refresh(self):
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() snap = self.sim.snapshot()
# Live-Werte vcfg = dict(self.sim.v.config.get("vehicle", {}))
self.batt_v_var.set(f"{snap.get('battery_voltage', 0):.2f}") ecfg = dict(self.sim.v.config.get("electrical", {}))
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 # Vehicle
curr_ign = snap.get("ignition") self.type.set(vcfg.get("type", "motorcycle"))
if curr_ign and curr_ign != self.ign_var.get(): self.mass.set(float(vcfg.get("mass_kg", 210.0)))
self.ign_var.set(curr_ign) self.abs.set(bool(vcfg.get("abs", True)))
self.tcs.set(bool(vcfg.get("tcs", False)))
try: # Env / Ign
self.frame.after(200, self._tick) self.amb.set(float(snap.get("ambient_c", 20.0)))
except tk.TclError: self.ign.set(str(snap.get("ignition", "ON")))
pass
# 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): def _apply_ign(self):
# Zündung live setzen self.sim.v.set("ignition", self.ign.get())
self.sim.v.set("ignition", self.ign_var.get())
def apply(self): def apply(self):
# Ambient in State (wirkt sofort auf Thermik, andere Module lesen das) # Umgebung sofort in den State (wirkt auf Thermik)
try: try: self.sim.v.set("ambient_c", float(self.amb.get()))
self.sim.v.set("ambient_c", float(self.ambient_var.get())) except: pass
except Exception:
pass
cfg = { cfg = {
"vehicle": { "vehicle": {
"type": self.type_var.get(), "type": self.type.get(),
"mass_kg": float(self.mass_var.get()), "mass_kg": float(self.mass.get()),
"abs": bool(self.abs_var.get()), "abs": bool(self.abs.get()),
"tcs": bool(self.tcs_var.get()), "tcs": bool(self.tcs.get()),
}, },
"electrical": { "electrical": {
"battery_capacity_ah": float(self.bcap.get()), "battery_capacity_ah": float(self.bcap.get()),
"battery_r_int_ohm": float(self.brint.get()), "battery_r_int_ohm": float(self.brint.get()),
"alternator_reg_v": float(self.alt_v.get()), "alternator_reg_v": float(self.alt_v.get()),
"alternator_rated_a": float(self.alt_a.get()), "alternator_rated_a": float(self.alt_a.get()),
"alt_cut_in_rpm": int(self.alt_cutin.get()), "alt_cut_in_rpm": int(self.alt_ci.get()),
"alt_full_rpm": int(self.alt_full.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) self.sim.load_config(cfg)
# Save/Load Hooks für Gesamt-Export
def save_into_config(self, out: Dict[str, Any]) -> None: def save_into_config(self, out: Dict[str, Any]) -> None:
out.setdefault("vehicle", {}) out.setdefault("vehicle", {}).update({
out["vehicle"].update({ "type": self.type.get(),
"type": self.type_var.get(), "mass_kg": float(self.mass.get()),
"mass_kg": float(self.mass_var.get()), "abs": bool(self.abs.get()),
"abs": bool(self.abs_var.get()), "tcs": bool(self.tcs.get()),
"tcs": bool(self.tcs_var.get()),
}) })
out.setdefault("electrical", {}) out.setdefault("electrical", {}).update({
out["electrical"].update({
"battery_capacity_ah": float(self.bcap.get()), "battery_capacity_ah": float(self.bcap.get()),
"battery_r_int_ohm": float(self.brint.get()), "battery_r_int_ohm": float(self.brint.get()),
"alternator_reg_v": float(self.alt_v.get()), "alternator_reg_v": float(self.alt_v.get()),
"alternator_rated_a": float(self.alt_a.get()), "alternator_rated_a": float(self.alt_a.get()),
"alt_cut_in_rpm": int(self.alt_cutin.get()), "alt_cut_in_rpm": int(self.alt_ci.get()),
"alt_full_rpm": int(self.alt_full.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: def load_from_config(self, cfg: Dict[str, Any]) -> None:
vcfg = cfg.get("vehicle", {}) vcfg = cfg.get("vehicle", {}); ecfg = cfg.get("electrical", {})
self.type_var.set(vcfg.get("type", self.type_var.get())) self.type.set(vcfg.get("type", self.type.get()))
self.mass_var.set(vcfg.get("mass_kg", self.mass_var.get())) self.mass.set(vcfg.get("mass_kg", self.mass.get()))
self.abs_var.set(vcfg.get("abs", self.abs_var.get())) self.abs.set(vcfg.get("abs", self.abs.get()))
self.tcs_var.set(vcfg.get("tcs", self.tcs_var.get())) self.tcs.set(vcfg.get("tcs", self.tcs.get()))
ecfg = cfg.get("electrical", {})
self.bcap.set(ecfg.get("battery_capacity_ah", self.bcap.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.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_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_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_ci.set(ecfg.get("alt_cut_in_rpm", self.alt_ci.get()))
self.alt_full.set(ecfg.get("alt_full_rpm", self.alt_full.get())) self.alt_fc.set(ecfg.get("alt_full_rpm", self.alt_fc.get()))
# wichtig: NICHT self.sim.load_config(cfg) hier! 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()

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

View File

@@ -5,180 +5,182 @@
from __future__ import annotations from __future__ import annotations
import tkinter as tk import tkinter as tk
from tkinter import ttk 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.modules.engine import ENGINE_DEFAULTS
from app.simulation.ui import UITab from app.simulation.ui import UITab
class EngineTab(UITab): class EngineTab(UITab):
NAME = "engine" NAME = "engine"
TITLE = "Motor" TITLE = "Motor"
PRIO = 10 PRIO = 10
def __init__(self, parent, sim): def __init__(self, parent, sim):
self.sim = sim self.sim = sim
self.frame = ttk.Frame(parent, padding=8) self.frame = ttk.Frame(parent, padding=8)
self.frame.columnconfigure(1, weight=1) for c in (0,1,2,3): self.frame.columnconfigure(c, weight=1)
# ------------- Widgets anlegen (OHNE Defaultwerte eintragen) -------------- # ---------- Linke Spalte ----------
row = 0 rowL = 0
ttk.Label(self.frame, text="Leerlauf [RPM]").grid(row=row, column=0, sticky="w"); row+=1 def L(lbl, var, w=12, kind="entry", values=None):
self.idle_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.idle_var, width=10)\ nonlocal rowL
.grid(row=row-1, column=1, sticky="w") 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
ttk.Label(self.frame, text="Max RPM").grid(row=row, column=0, sticky="w"); row+=1 self.idle = tk.IntVar(); L("Leerlauf [RPM]", self.idle)
self.maxrpm_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.maxrpm_var, width=10)\ self.maxrpm = tk.IntVar(); L("Max RPM", self.maxrpm)
.grid(row=row-1, column=1, sticky="w") 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.Label(self.frame, text="Anstieg [RPM/s]").grid(row=row, column=0, sticky="w"); row+=1 ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 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.power = tk.DoubleVar(); L("Motorleistung [kW]", self.power)
self.fall_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.fall_var, width=10)\ self.tqpeak = tk.DoubleVar(); L("Drehmoment-Peak [RPM]", self.tqpeak)
.grid(row=row-1, column=1, sticky="w")
ttk.Label(self.frame, text="Gaspedal-Kennlinie").grid(row=row, column=0, sticky="w"); row+=1 ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 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 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)
# Leistung ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1
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.o_idle = tk.DoubleVar(); L("Öldruck Leerlauf [bar]", self.o_idle)
self.peak_rpm = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.peak_rpm, width=10)\ self.o_slope= tk.DoubleVar(); L("Öldruck Steigung [bar/krpm]", self.o_slope)
.grid(row=row-1, column=1, sticky="w") self.o_floor= tk.DoubleVar(); L("Öldruck Boden [bar]", self.o_floor)
ttk.Separator(self.frame).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(8,6)); row+=1 # ---------- 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
# Starter self.dk_idle = tk.DoubleVar(); R("DK min Leerlauf [%]", self.dk_idle)
ttk.Label(self.frame, text="Starter Nenn-RPM").grid(row=row, column=0, sticky="w"); row+=1 self.dk_over = tk.DoubleVar(); R("DK Schub [%]", self.dk_over)
self.starter_nom = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.starter_nom, width=10)\ self.dk_tau = tk.DoubleVar(); R("DK Zeitkonstante [s]", self.dk_tau)
.grid(row=row-1, column=1, sticky="w") self.tq_kp = tk.DoubleVar(); R("Torque-Kp", self.tq_kp)
self.tq_ki = tk.DoubleVar(); R("Torque-Ki", self.tq_ki)
ttk.Label(self.frame, text="Starter min. Spannung [V]").grid(row=row, column=0, sticky="w"); row+=1 ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 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.jit_idle= tk.DoubleVar(); R("Jitter Leerlauf [±RPM]", self.jit_idle)
self.start_th = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.start_th, width=10)\ self.jit_high= tk.DoubleVar(); R("Jitter hoch [±RPM]", self.jit_high)
.grid(row=row-1, column=1, sticky="w") 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.Label(self.frame, text="Stall-Grenze [RPM]").grid(row=row, column=0, sticky="w"); row+=1 ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 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 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)
# Thermik (analog Variablen ohne Defaults anlegen) ... ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
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.pedal = tk.DoubleVar(); R("Gaspedal [%]", self.pedal, kind="scale")
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 # ---------- Buttons ----------
self.pedal_var = tk.DoubleVar() rowBtns = max(rowL, rowR) + 1
self.pedal_scale = ttk.Scale(self.frame, from_=0.0, to=100.0, variable=self.pedal_var) btn = ttk.Frame(self.frame); btn.grid(row=rowBtns, column=0, columnspan=4, sticky="w", pady=(8,0))
self.pedal_scale.grid(row=row-1, column=1, sticky="ew") 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))
# 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() self.refresh()
# liest IMMER effektiv: config.get(key, ENGINE_DEFAULTS[key]) def _on_pedal_change(self):
try: self.sim.v.set("throttle_pedal_pct", float(self.pedal.get()))
except: pass
def refresh(self): def refresh(self):
e = dict(ENGINE_DEFAULTS) e = dict(ENGINE_DEFAULTS); e.update(self.sim.v.config.get("engine", {}))
e.update(self.sim.v.config.get("engine", {})) # Config über default mergen
self.idle_var.set(e["idle_rpm"]) # links
self.maxrpm_var.set(e["max_rpm"]) self.idle.set(e["idle_rpm"])
self.rise_var.set(e["rpm_rise_per_s"]) self.maxrpm.set(e["max_rpm"])
self.fall_var.set(e["rpm_fall_per_s"]) self.rise.set(e["rpm_rise_per_s"])
self.thr_curve.set(e["throttle_curve"]) self.fall.set(e["rpm_fall_per_s"])
self.power_kw.set(e["engine_power_kw"]) self.curve.set(e["throttle_curve"])
self.peak_rpm.set(e["torque_peak_rpm"])
self.starter_nom.set(e["starter_rpm_nominal"]) self.power.set(e["engine_power_kw"])
self.starter_vmin.set(e["starter_voltage_min"]) self.tqpeak.set(e["torque_peak_rpm"])
self.start_th.set(e["start_rpm_threshold"])
self.stall_rpm.set(e["stall_rpm"])
self.amb_c.set(e["coolant_ambient_c"]) self.st_nom.set(e["starter_rpm_nominal"])
self.c_warm.set(e["coolant_warm_rate_c_per_s"]) self.st_vmin.set(e["starter_voltage_min"])
self.c_cool.set(e["coolant_cool_rate_c_per_s"]) self.st_thr.set(e["start_rpm_threshold"])
self.o_warm.set(e["oil_warm_rate_c_per_s"]) self.stall.set(e["stall_rpm"])
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_idle.set(e["oil_pressure_idle_bar"])
self.o_slope.set(e["oil_pressure_slope_bar_per_krpm"]) self.o_slope.set(e["oil_pressure_slope_bar_per_krpm"])
self.o_floor.set(e["oil_pressure_off_floor_bar"]) self.o_floor.set(e["oil_pressure_off_floor_bar"])
self.plate_idle_min.set(e["throttle_plate_idle_min_pct"]) # rechts
self.plate_overrun.set(e["throttle_plate_overrun_pct"]) self.dk_idle.set(e["throttle_plate_idle_min_pct"])
self.plate_tau.set(e["throttle_plate_tau_s"]) self.dk_over.set(e["throttle_plate_overrun_pct"])
self.torque_kp.set(e["torque_ctrl_kp"]) self.dk_tau.set(e["throttle_plate_tau_s"])
self.torque_ki.set(e["torque_ctrl_ki"]) self.tq_kp.set(e["torque_ctrl_kp"])
self.tq_ki.set(e["torque_ctrl_ki"])
self.jitter_idle.set(e["rpm_jitter_idle_amp_rpm"]) self.jit_idle.set(e["rpm_jitter_idle_amp_rpm"])
self.jitter_high.set(e["rpm_jitter_high_amp_rpm"]) self.jit_high.set(e["rpm_jitter_high_amp_rpm"])
self.jitter_tau.set(e["rpm_jitter_tau_s"]) self.jit_tau.set(e["rpm_jitter_tau_s"])
self.jitter_off.set(e["rpm_jitter_off_threshold_rpm"]) self.jit_off.set(e["rpm_jitter_off_threshold_rpm"])
self.pedal_var.set(e["throttle_pedal_pct"]) 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): def apply(self):
# Nur hier wird geschrieben
cfg = {"engine": { cfg = {"engine": {
"idle_rpm": int(self.idle_var.get()), "idle_rpm": int(self.idle.get()),
"max_rpm": int(self.maxrpm_var.get()), "max_rpm": int(self.maxrpm.get()),
"rpm_rise_per_s": int(self.rise_var.get()), "rpm_rise_per_s": int(self.rise.get()),
"rpm_fall_per_s": int(self.fall_var.get()), "rpm_fall_per_s": int(self.fall.get()),
"throttle_curve": self.thr_curve.get(), "throttle_curve": self.curve.get(),
"engine_power_kw": float(self.power_kw.get()),
"torque_peak_rpm": float(self.peak_rpm.get()), "engine_power_kw": float(self.power.get()),
"starter_rpm_nominal": float(self.starter_nom.get()), "torque_peak_rpm": float(self.tqpeak.get()),
"starter_voltage_min": float(self.starter_vmin.get()),
"start_rpm_threshold": float(self.start_th.get()), "starter_rpm_nominal": float(self.st_nom.get()),
"stall_rpm": float(self.stall_rpm.get()), "starter_voltage_min": float(self.st_vmin.get()),
"coolant_ambient_c": float(self.amb_c.get()), "start_rpm_threshold": float(self.st_thr.get()),
"coolant_warm_rate_c_per_s": float(self.c_warm.get()), "stall_rpm": float(self.stall.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_idle_bar": float(self.o_idle.get()),
"oil_pressure_slope_bar_per_krpm": float(self.o_slope.get()), "oil_pressure_slope_bar_per_krpm": float(self.o_slope.get()),
"oil_pressure_off_floor_bar": float(self.o_floor.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_idle_min_pct": float(self.dk_idle.get()),
"throttle_plate_tau_s": float(self.plate_tau.get()), "throttle_plate_overrun_pct": float(self.dk_over.get()),
"torque_ctrl_kp": float(self.torque_kp.get()), "throttle_plate_tau_s": float(self.dk_tau.get()),
"torque_ctrl_ki": float(self.torque_ki.get()), "torque_ctrl_kp": float(self.tq_kp.get()),
"rpm_jitter_idle_amp_rpm": float(self.jitter_idle.get()), "torque_ctrl_ki": float(self.tq_ki.get()),
"rpm_jitter_high_amp_rpm": float(self.jitter_high.get()),
"rpm_jitter_tau_s": float(self.jitter_tau.get()), "rpm_jitter_idle_amp_rpm": float(self.jit_idle.get()),
"rpm_jitter_off_threshold_rpm": float(self.jitter_off.get()), "rpm_jitter_high_amp_rpm": float(self.jit_high.get()),
"throttle_pedal_pct": float(self.pedal_var.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) self.sim.load_config(cfg)

View File

@@ -1,72 +1,241 @@
# ============================= # =============================
# app/simulation/ui/gearbox.py # app/simulation/ui/gearbox.py
# ============================= # =============================
from __future__ import annotations from __future__ import annotations
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk
from typing import Dict, Any, List from typing import Dict, Any
from app.simulation.ui import UITab from app.simulation.ui import UITab
from app.simulation.modules.gearbox import GEARBOX_DEFAULTS
class GearboxTab(UITab): class GearboxTab(UITab):
NAME = "gearbox" NAME = "gearbox"
TITLE = "Getriebe" TITLE = "Getriebe & Antrieb"
PRIO = 10 PRIO = 12
def __init__(self, parent, sim): def __init__(self, parent, sim):
self.sim = sim self.sim = sim
self.frame = ttk.Frame(parent, padding=8) self.frame = ttk.Frame(parent, padding=8)
self.frame.columnconfigure(1, weight=1) for c in (0,1,2,3): self.frame.columnconfigure(c, weight=1)
ttk.Label(self.frame, text="Gänge (inkl. Leerlauf als 0)").grid(row=0, column=0, sticky="w") # ---------- Linke Spalte ----------
self.gears_var = tk.IntVar(value=6) rowL = 0
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") 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
self.reverse_var = tk.BooleanVar(value=False) # Live/Controls (Labels → werden im _tick() live aktualisiert)
ttk.Checkbutton(self.frame, text="Rückwärtsgang vorhanden", variable=self.reverse_var).grid(row=1, column=0, columnspan=2, sticky="w") 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.Label(self.frame, text="km/h pro 1000 RPM je Gang").grid(row=2, column=0, sticky="w", pady=(6,0)) ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1
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") # 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)
def _rebuild_ratios(self): # ---------- Rechte Spalte ----------
for w in self.ratio_frame.winfo_children(): w.destroy() rowR = 0
self.ratio_vars.clear() def R(lbl, var=None, w=12, kind="entry"):
n = int(self.gears_var.get()) nonlocal rowR
for i in range(1, n+1): ttk.Label(self.frame, text=lbl).grid(row=rowR, column=2, sticky="w")
ttk.Label(self.ratio_frame, text=f"Gang {i}").grid(row=i-1, column=0, sticky="w") if kind == "entry":
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.frame, textvariable=var, width=w).grid(row=rowR, column=3, sticky="w")
ttk.Entry(self.ratio_frame, textvariable=v, width=8).grid(row=i-1, column=1, sticky="w", padx=(6,12)) elif kind == "label":
self.ratio_vars.append(v) 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): def apply(self):
ratios = [float(v.get()) for v in self.ratio_vars]
cfg = {"gearbox": { cfg = {"gearbox": {
"num_gears": int(self.gears_var.get()), "clutch_max_torque_nm": float(self.cl_Tmax.get()),
"reverse": bool(self.reverse_var.get()), "clutch_aggressiveness": float(self.cl_agr.get()),
"kmh_per_krpm": [0.0] + ratios # index 0 reserved for neutral "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) self.sim.load_config(cfg)
def save_into_config(self, out: Dict[str, Any]) -> None: def save_into_config(self, out: Dict[str, Any]) -> None:
out.setdefault("gearbox", {}) out.setdefault("gearbox", {}).update({
out["gearbox"].update({ "clutch_max_torque_nm": float(self.cl_Tmax.get()),
"num_gears": int(self.gears_var.get()), "clutch_aggressiveness": float(self.cl_agr.get()),
"reverse": bool(self.reverse_var.get()), "clutch_curve": self.cl_curve.get(),
"kmh_per_krpm": [0.0] + [float(v.get()) for v in self.ratio_vars] "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: def load_from_config(self, cfg: Dict[str, Any]) -> None:
g = cfg.get("gearbox", {}) g = dict(GEARBOX_DEFAULTS); g.update(cfg.get("gearbox", {}))
n = int(g.get("num_gears", self.gears_var.get())) self.cl_Tmax.set(g["clutch_max_torque_nm"])
self.gears_var.set(n); self.reverse_var.set(g.get("reverse", self.reverse_var.get())) self.cl_agr.set(g["clutch_aggressiveness"])
self._rebuild_ratios() self.cl_curve.set(g.get("clutch_curve","linear"))
ratios = g.get("kmh_per_krpm") or ([0.0] + [v.get() for v in self.ratio_vars]) self.cl_drag.set(g["clutch_drag_nm"])
for i, v in enumerate(self.ratio_vars, start=1): self.sh_time.set(g["shift_time_s"])
try: v.set(float(ratios[i])) self.sync_rb.set(g["sync_rpm_band"])
except Exception: pass self.primary.set(g["primary_ratio"])
self.sim.load_config(cfg) 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"])

View File

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

100
default.json Normal file
View 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]
}
}
}