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","*.*")])
if not path: return
with open(path,"r",encoding="utf-8") as f: data = json.load(f)
# NEU: sowohl altes (flach) als auch neues Format ("sim"/"app") akzeptieren
sim_block = data.get("sim") if isinstance(data, dict) else None
if sim_block:
sim.load_config(sim_block)
else:
sim.load_config(data)
# Tabs dürfen zusätzliche eigene Daten ziehen
for t in ui_tabs:
if hasattr(t, "load_from_config"): t.load_from_config(data)
sim.load_config(data)
if hasattr(t, "load_from_config"):
t.load_from_config(sim_block or data)
messagebox.showinfo("Simulator", "Konfiguration geladen.")
def do_save():
cfg_out = sim.export_config()
# 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(cfg_out)
if hasattr(t, "save_into_config"):
t.save_into_config(sim_out)
out = {
"app": cfg, # aktuelle App-Settings (CAN/UI/Logging etc.)
"sim": sim_out, # vollständige Modul-Configs (mit Defaults)
}
path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON","*.json")])
if not path: return
with open(path,"w",encoding="utf-8") as f: json.dump(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.")
filemenu.add_command(label="Konfiguration laden…", command=do_load)
filemenu.add_command(label="Konfiguration speichern…", command=do_save)
filemenu.add_separator(); filemenu.add_command(label="Beenden", command=root.destroy)

View File

@@ -1,146 +1,185 @@
# =============================
# app/simulation/modules/basic.py
# =============================
from __future__ import annotations
from app.simulation.simulator import Module, Vehicle
import bisect
import bisect, math
def _ocv_from_soc(soc: float, table: dict[float, float]) -> float:
# table: {SOC: OCV} unsortiert → linear interpolieren
xs = sorted(table.keys())
ys = [table[x] for x in xs]
s = max(0.0, min(1.0, soc))
s = 0.0 if soc is None else max(0.0, min(1.0, float(soc)))
i = bisect.bisect_left(xs, s)
if i <= 0: return ys[0]
if i >= len(xs): return ys[-1]
x0, x1 = xs[i-1], xs[i]
y0, y1 = ys[i-1], ys[i]
x0, x1 = xs[i-1], xs[i]; y0, y1 = ys[i-1], ys[i]
t = 0.0 if x1 == x0 else (s - x0) / (x1 - x0)
return y0 + t*(y1 - y0)
return y0 + t * (y1 - y0)
class BasicModule(Module):
PRIO = 10
PRIO = 90
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):
self.crank_time_s = 2.7
self._crank_timer = 0.0
def apply(self, v: Vehicle, dt: float) -> None:
# ----- Dashboard registration (unverändert) -----
v.register_metric("ignition", label="Zündung", source="basic", priority=5)
v.register_metric("ambient_c", label="Umgebung", unit="°C", fmt=".1f", source="basic", priority=7)
v.register_metric("battery_voltage", label="Batteriespannung", unit="V", fmt=".2f", source="basic", priority=8)
v.register_metric("elx_voltage", label="ELX-Spannung", unit="V", fmt=".2f", source="basic", priority=10)
v.register_metric("system_voltage", label="Systemspannung", unit="V", fmt=".2f", source="basic", priority=11)
v.register_metric("battery_soc", label="Batterie SOC", unit="", fmt=".2f", source="basic", priority=12)
v.register_metric("battery_current_a", label="Batterie Strom", unit="A", fmt=".2f", source="basic", priority=13)
v.register_metric("alternator_current_a", label="Lima Strom", unit="A", fmt=".2f", source="basic", priority=14)
v.register_metric("elec_load_total_a", label="Verbrauch ges.", unit="A", fmt=".2f", source="basic", priority=15)
# Dashboard
v.register_metric("ignition", label="Zündung", source="basic", priority=5)
v.register_metric("ambient_c", label="Umgebung", unit="°C", fmt=".1f", source="basic", priority=7)
v.register_metric("battery_voltage", label="Batteriespannung", unit="V", fmt=".2f", source="basic", priority=8)
v.register_metric("elx_voltage", label="ELX-Spannung", unit="V", fmt=".2f", source="basic", priority=10)
v.register_metric("system_voltage", label="Systemspannung", unit="V", fmt=".2f", source="basic", priority=11)
v.register_metric("battery_soc", label="Batterie SOC", fmt=".3f", source="basic", priority=12)
v.register_metric("battery_current_a", label="Batterie Strom", unit="A", fmt=".2f", source="basic", priority=13)
v.register_metric("alternator_current_a", label="Lima Strom", unit="A", fmt=".2f", source="basic", priority=14)
v.register_metric("elec_load_total_a", label="Verbrauch 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 -----
econf = v.config.get("electrical", {})
alt_reg_v = float(econf.get("alternator_reg_v", 14.2))
alt_rated_a = float(econf.get("alternator_rated_a", 20.0))
alt_cut_in = int(econf.get("alt_cut_in_rpm", 1500))
alt_full = int(econf.get("alt_full_rpm", 4000))
# Config
econf = v.config.get("electrical", {})
alt_reg_v = float(econf.get("alternator_reg_v", 14.2))
alt_rated_a = float(econf.get("alternator_rated_a", 20.0))
alt_cut_in = int(econf.get("alt_cut_in_rpm", 1500))
alt_full = int(econf.get("alt_full_rpm", 4000))
batt_cap_ah = float(econf.get("battery_capacity_ah", 8.0))
batt_rint = float(econf.get("battery_r_int_ohm", 0.020))
batt_ocv_tbl= dict(econf.get("battery_ocv_v", {})) or {
alt_eta_mech = float(econf.get("alternator_mech_efficiency", 0.55))
alt_ratio = float(econf.get("alternator_pulley_ratio", 1.0))
alt_drag_c0 = float(econf.get("alternator_drag_nm_idle", 0.15))
alt_drag_c1 = float(econf.get("alternator_drag_nm_per_krpm", 0.05))
batt_cap_ah = float(econf.get("battery_capacity_ah", 8.0))
batt_rint = float(econf.get("battery_r_int_ohm", 0.020))
batt_ocv_tbl = dict(econf.get("battery_ocv_v", {})) or {
0.0: 11.8, 0.1: 12.0, 0.2: 12.1, 0.3: 12.2, 0.4: 12.3,
0.5: 12.45, 0.6: 12.55, 0.7: 12.65, 0.8: 12.75, 0.9: 12.85, 1.0: 12.95
}
ign = v.ensure("ignition", "ON")
rpm = float(v.ensure("rpm", 1200))
soc = float(v.ensure("battery_soc", 0.80))
# State
prev_ign = str(v.ensure("prev_ignition", v.get("ignition", "ON")))
ign = v.ensure("ignition", "ON")
v.set("prev_ignition", ign)
rpm = float(v.ensure("rpm", 1200.0))
soc = float(v.ensure("battery_soc", 0.80))
v.set("ambient_c", float(v.ensure("ambient_c", v.get("ambient_c", 20.0))))
# ----- START auto-fall to ON -----
# START → ON Auto-Übergang
if ign == "START":
if self._crank_timer <= 0.0:
self._crank_timer = float(self.crank_time_s)
else:
self._crank_timer -= dt
if self._crank_timer <= 0.0:
v.set("ignition", "ON")
ign = "ON"
v.set("ignition", "ON"); ign = "ON"
else:
self._crank_timer = 0.0
# ----- Früh-Exit: OFF/ACC -> Bus AUS, Batterie „ruht“ -----
# --- Akkumulierte Lasten aus beiden Bussen ---
# Verbraucher pushen jetzt wahlweise:
# - v.push("elec.current_elx", +A, source="...")
# - v.push("elec.current_batt", +A, source="...")
elx_load_a = max(0.0, v.acc_total("elec.current_elx"))
batt_load_a = max(0.0, v.acc_total("elec.current_batt"))
# Grundlast hängt an ELX (nur bei ON/START aktiv)
if ign in ("ON", "START"):
v.push("elec.current_elx", +0.5, source="basic:base")
# --- OFF/ACC: ELX tot, Batterie lebt weiter ---
if ign in ("OFF", "ACC"):
# nur Batteriepfad zählt
total_batt_a = max(0.0, v.acc_total("elec.current_batt"))
ocv = _ocv_from_soc(soc, batt_ocv_tbl)
# Batterie entspannt sich langsam gegen OCV (optional, super simpel):
# (man kann hier auch gar nichts tun; ich halte batt_v = ocv für okay)
batt_v = ocv
v.set("battery_voltage", round(batt_v, 2))
v.set("elx_voltage", 0.0)
v.set("system_voltage", 0.0)
v.set("battery_current_a", 0.0)
# Batterie entlädt nach I*dt
batt_i = total_batt_a
soc = max(0.0, min(1.0, soc - (batt_i * dt) / (3600.0 * max(0.1, batt_cap_ah))))
batt_v = ocv - batt_i * batt_rint
batt_v = max(10.0, min(15.5, batt_v))
v.set("battery_voltage", batt_v)
v.set("elx_voltage", 0.0) # Bus aus
v.set("system_voltage", batt_v) # für „alles was noch lebt“ = Batterie
v.set("battery_soc", soc)
v.set("battery_current_a", batt_i)
v.set("alternator_current_a", 0.0)
v.set("elec_load_total_a", 0.0)
v.set("battery_soc", round(soc, 3))
v.set("elec_load_elx_a", 0.0)
v.set("elec_load_batt_a", total_batt_a)
v.set("elec_load_total_a", total_batt_a)
# keine Limamechanik aktiv
v.set("engine_ext_torque_nm", 0.0)
return
# ----- ON/START: Elektrik-Bilanz -----
# Beiträge anderer Module summieren
loads_a, sources_a = v.elec_totals()
# Grundlasten (z.B. ECU, Relais)
base_load = 0.5 if ign == "ON" else 0.6 # START leicht höher
loads_a += base_load
# Quellen anderer Module (z.B. DC-DC) können sources_a > 0 machen
# Wir ziehen Quellen von der Last ab was übrig bleibt, muss Lima/Batterie liefern
net_load_a = max(0.0, loads_a - sources_a)
# --- Ab hier: Zündung ON/START (ELX aktiv) ---
elx_load_a = max(0.0, v.acc_total("elec.current_elx"))
batt_load_a = max(0.0, v.acc_total("elec.current_batt"))
net_load_a = elx_load_a + batt_load_a # Gesamtverbrauch
# Lima-Fähigkeit aus rpm
if rpm >= alt_cut_in:
frac = 0.0 if rpm <= alt_cut_in else (rpm - alt_cut_in) / max(1, (alt_full - alt_cut_in))
frac = max(0.0, min(1.0, frac))
alt_cap_a = alt_rated_a * frac
else:
# 3) Lima-Kapazität
if rpm < alt_cut_in:
alt_cap_a = 0.0
# Batterie-OCV
ocv = _ocv_from_soc(soc, batt_ocv_tbl)
# Ziel: Regler hält alt_reg_v aber nur, wenn die Lima überhaupt aktiv ist
desired_charge_a = max(0.0, (alt_reg_v - ocv) / max(1e-4, batt_rint)) if alt_cap_a > 0.0 else 0.0
alt_needed_a = net_load_a + desired_charge_a
alt_i = min(alt_needed_a, alt_cap_a)
# Batterie-Bilanz
if alt_cap_a > 0.0 and alt_i >= net_load_a:
# Lima deckt alles; Überschuss lädt Batterie
batt_i = -(alt_i - net_load_a) # negativ = lädt
bus_v = alt_reg_v
elif rpm >= alt_full:
alt_cap_a = alt_rated_a
else:
# Lima (falls vorhanden) reicht nicht -> Batterie liefert Defizit
deficit = net_load_a - alt_i
batt_i = max(0.0, deficit) # positiv = entlädt
bus_v = ocv - batt_i * batt_rint
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))
# SOC-Update (Ah-Bilanz)
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
alt_needed_a = net_load_a + desired_charge_a
alt_i = min(alt_needed_a, alt_cap_a) if alt_cap_a > 0.0 else 0.0
# Lima liefert in ELX-Bus (Quelle = negativ)
if alt_i > 0.0:
v.push("elec.current_elx", -alt_i, source="alternator")
# Rest geht von Batterie (angenommen gleicher Bus)
remaining = net_load_a - alt_i
if alt_cap_a > 0.0 and remaining <= 0.0:
# Überschuss -> lädt Batt (wir zählen Lade-Strom negativ am Batterieklemmen)
batt_i = remaining # ≤ 0
bus_v = alt_reg_v
else:
batt_i = max(0.0, remaining)
bus_v = ocv - batt_i * batt_rint
# SOC integrieren
soc = max(0.0, min(1.0, soc - (batt_i * dt) / (3600.0 * max(0.1, batt_cap_ah))))
batt_v = ocv - (batt_i * batt_rint)
batt_v = ocv - batt_i * batt_rint
# Klammern/Spiegeln
# Clamps
batt_v = max(10.0, min(15.5, batt_v))
bus_v = max(0.0, min(15.5, bus_v))
v.set("battery_voltage", round(batt_v, 2))
v.set("elx_voltage", round(bus_v, 2))
v.set("system_voltage", round(bus_v, 2))
v.set("battery_soc", round(soc, 3))
v.set("battery_current_a", round(batt_i, 2))
v.set("alternator_current_a", round(min(alt_i, alt_cap_a), 2))
v.set("elec_load_total_a", round(net_load_a, 2))
bus_v = max(0.0, min(15.5, bus_v))
# 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 app.simulation.simulator import Module, Vehicle
import random, math
import math, random
# Ein einziger Wahrheitsanker für alle Defaults:
ENGINE_DEFAULTS = {
# Basis
"idle_rpm": 1200,
@@ -14,38 +13,41 @@ ENGINE_DEFAULTS = {
"rpm_rise_per_s": 4000,
"rpm_fall_per_s": 3000,
"throttle_curve": "linear",
# Starter
# Starter / Startlogik
"starter_rpm_nominal": 250.0,
"starter_voltage_min": 10.5,
"start_rpm_threshold": 250.0, # <- fix niedriger, damit anspringt
"start_rpm_threshold": 210.0,
"stall_rpm": 500.0,
# Thermik
# Thermische Einflüsse (nur fürs Derating/Viskosität benutzt)
"coolant_ambient_c": 20.0,
"coolant_warm_rate_c_per_s": 0.35,
"coolant_cool_rate_c_per_s": 0.06,
"oil_warm_rate_c_per_s": 0.30,
"oil_cool_rate_c_per_s": 0.05,
"idle_cold_gain_per_deg": 3.0,
"idle_cold_gain_max": 500.0,
# Öl
# Öl / Öldruck
"oil_pressure_idle_bar": 1.2,
"oil_pressure_slope_bar_per_krpm": 0.8,
"oil_pressure_off_floor_bar": 0.2,
# Leistung
# Leistungsdaten
"engine_power_kw": 60.0,
"torque_peak_rpm": 7000.0,
# DBW
# Drive-by-wire / Regler
"throttle_plate_idle_min_pct": 6.0,
"throttle_plate_overrun_pct": 2.0,
"throttle_plate_tau_s": 0.08,
"torque_ctrl_kp": 1.2,
"torque_ctrl_ki": 0.6,
# Jitter
# RPM-Jitter
"rpm_jitter_idle_amp_rpm": 12.0,
"rpm_jitter_high_amp_rpm": 4.0,
"rpm_jitter_tau_s": 0.20,
"rpm_jitter_off_threshold_rpm": 250.0,
# UI-Startwert (nur Anzeige)
# UI
"throttle_pedal_pct": 0.0,
}
@@ -71,17 +73,15 @@ class EngineModule(Module):
"""
def __init__(self):
self._target = None
self._running = False
self._oil_p_tau = 0.25 # s, Annäherung Öldruck
# Drive-by-Wire interner Zustand
self._plate_pct = 5.0 # Startwert, leicht geöffnet
self._tc_i = 0.0 # Integrator PI-Regler
# bandbegrenztes RPM-Rauschen (AR(1))
self._oil_p_tau = 0.25 # Zeitkonstante Öldruck
# DBW intern
self._plate_pct = 5.0
self._tc_i = 0.0
# AR(1)-Noise
self._rpm_noise = 0.0
# ---- helpers ----------------------------------------------------------
def _curve(self, t: float, mode: str) -> float:
if mode == "progressive": return t**1.5
if mode == "aggressive": return t**0.7
@@ -90,34 +90,34 @@ class EngineModule(Module):
def _torque_at_rpm(self, power_kw: float, rpm: float, peak_rpm: float) -> float:
rpm = max(0.0, rpm)
t_max = (9550.0 * max(0.0, power_kw)) / max(500.0, peak_rpm)
# einfache „Glocke“
x = min(math.pi, max(0.0, (rpm / max(1.0, peak_rpm)) * (math.pi/2)))
shape = math.sin(x)
return max(0.0, t_max * shape)
return max(0.0, t_max * math.sin(x))
def _plate_airflow_factor(self, plate_pct: float) -> float:
"""
Näherung Volumenstrom ~ sin^2(θ) mit θ aus 0..90° (hier 0..100%).
0% ≈ geschlossen (fast null), 100% ≈ voll offen (~1.0).
"""
theta = max(0.0, min(90.0, (plate_pct/100.0)*90.0)) * math.pi/180.0
return math.sin(theta)**2
def _visco(self, temp_c: float) -> float:
# -10°C -> 0.6 … 20°C -> 0.8 … 90°C -> 1.0
if temp_c <= -10: return 0.6
if temp_c >= 90: return 1.0
if temp_c <= 20: return 0.6 + (temp_c + 10.0) * (0.2/30.0)
return 0.8 + (temp_c - 20.0) * (0.2/70.0)
# ---- main -------------------------------------------------------------
def apply(self, v: Vehicle, dt: float) -> None:
e = v.config.setdefault("engine", {})
# --- Config / Defaults ---
idle = int(e.get("idle_rpm", ENGINE_DEFAULTS["idle_rpm"]))
maxr = int(e.get("max_rpm", ENGINE_DEFAULTS["max_rpm"]))
rise = int(e.get("rpm_rise_per_s", ENGINE_DEFAULTS["rpm_rise_per_s"]))
fall = int(e.get("rpm_fall_per_s", ENGINE_DEFAULTS["rpm_fall_per_s"]))
thr_curve = e.get("throttle_curve", ENGINE_DEFAULTS["throttle_curve"])
# --- Config ---
idle = float(e.get("idle_rpm", ENGINE_DEFAULTS["idle_rpm"]))
maxr = float(e.get("max_rpm", ENGINE_DEFAULTS["max_rpm"]))
rise = float(e.get("rpm_rise_per_s", ENGINE_DEFAULTS["rpm_rise_per_s"]))
fall = float(e.get("rpm_fall_per_s", ENGINE_DEFAULTS["rpm_fall_per_s"]))
thr_curve = e.get("throttle_curve", ENGINE_DEFAULTS["throttle_curve"])
ambient = float(e.get("coolant_ambient_c", ENGINE_DEFAULTS["coolant_ambient_c"]))
warm_c = float(e.get("coolant_warm_rate_c_per_s", ENGINE_DEFAULTS["coolant_warm_rate_c_per_s"]))
cool_c = float(e.get("coolant_cool_rate_c_per_s", ENGINE_DEFAULTS["coolant_cool_rate_c_per_s"]))
warm_o = float(e.get("oil_warm_rate_c_per_s", ENGINE_DEFAULTS["oil_warm_rate_c_per_s"]))
cool_o = float(e.get("oil_cool_rate_c_per_s", ENGINE_DEFAULTS["oil_cool_rate_c_per_s"]))
cold_gain_per_deg = float(e.get("idle_cold_gain_per_deg", ENGINE_DEFAULTS["idle_cold_gain_per_deg"]))
cold_gain_max = float(e.get("idle_cold_gain_max", ENGINE_DEFAULTS["idle_cold_gain_max"]))
starter_nom = float(e.get("starter_rpm_nominal", ENGINE_DEFAULTS["starter_rpm_nominal"]))
starter_vmin= float(e.get("starter_voltage_min", ENGINE_DEFAULTS["starter_voltage_min"]))
@@ -127,9 +127,6 @@ class EngineModule(Module):
power_kw = float(e.get("engine_power_kw", ENGINE_DEFAULTS["engine_power_kw"]))
peak_torque_rpm = float(e.get("torque_peak_rpm", ENGINE_DEFAULTS["torque_peak_rpm"]))
cold_gain_per_deg = float(e.get("idle_cold_gain_per_deg", ENGINE_DEFAULTS["idle_cold_gain_per_deg"]))
cold_gain_max = float(e.get("idle_cold_gain_max", ENGINE_DEFAULTS["idle_cold_gain_max"]))
oil_idle_bar = float(e.get("oil_pressure_idle_bar", ENGINE_DEFAULTS["oil_pressure_idle_bar"]))
oil_slope_bar_per_krpm = float(e.get("oil_pressure_slope_bar_per_krpm", ENGINE_DEFAULTS["oil_pressure_slope_bar_per_krpm"]))
oil_floor_off = float(e.get("oil_pressure_off_floor_bar", ENGINE_DEFAULTS["oil_pressure_off_floor_bar"]))
@@ -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"]))
# --- State ---
rpm = float(v.ensure("rpm", 0))
# Fahrerwunsch (kommt aus dem UI-Schieber)
rpm = float(v.ensure("rpm", 0.0))
pedal = float(v.ensure("throttle_pedal_pct", float(e.get("throttle_pedal_pct", 0.0))))
pedal = max(0.0, min(100.0, pedal))
load = float(v.ensure("engine_load", 0.0))
ign = str(v.ensure("ignition", "OFF"))
ign = str(v.ensure("ignition", "OFF"))
elx_v = float(v.ensure("elx_voltage", 0.0))
cool = float(v.ensure("coolant_temp", ambient))
oil = float(v.ensure("oil_temp", ambient))
cool = float(v.ensure("coolant_temp", ambient)) # nur lesen
oil = float(v.ensure("oil_temp", ambient)) # nur lesen
oil_p = float(v.ensure("oil_pressure", 0.0))
ext_torque = float(v.ensure("engine_ext_torque_nm", 0.0))
# externe Momente (Alternator/Getriebe/…)
torque_load = max(0.0, v.acc_total("engine.torque_load_nm"))
torque_load = max(torque_load, float(v.get("engine_ext_torque_nm", 0.0))) # legacy fallback
# Dashboard-Metriken
v.register_metric("rpm", label="Drehzahl", unit="RPM", source="engine", priority=20)
v.register_metric("coolant_temp", label="Kühlmitteltemp", unit="°C", fmt=".1f", source="engine", priority=40)
v.register_metric("oil_temp", label="Öltemp", unit="°C", fmt=".1f", source="engine", priority=41)
v.register_metric("oil_pressure", label="Öldruck", unit="bar", fmt=".2f", source="engine", priority=42)
v.register_metric("engine_available_torque_nm", label="Verfügbares Motormoment", unit="Nm", fmt=".0f", source="engine", priority=43)
v.register_metric("engine_net_torque_nm", label="Netto Motormoment", unit="Nm", fmt=".0f", source="engine", priority=44)
v.register_metric("throttle_pedal_pct", label="Gaspedal", unit="%", fmt=".0f", source="engine", priority=45)
v.register_metric("throttle_plate_pct", label="Drosselklappe", unit="%", fmt=".0f", source="engine", priority=46)
v.register_metric("rpm", unit="RPM", fmt=".1f", label="Drehzahl", source="engine", priority=20)
v.register_metric("oil_pressure", unit="bar", fmt=".2f", label="Öldruck", source="engine", priority=42)
v.register_metric("engine_available_torque_nm", unit="Nm", fmt=".0f", label="Verfügbares Motormoment", source="engine", priority=43)
v.register_metric("engine_torque_load_nm", unit="Nm", fmt=".0f", label="Lastmoment ges.", source="engine", priority=44)
v.register_metric("engine_net_torque_nm", unit="Nm", fmt=".0f", label="Netto Motormoment", source="engine", priority=45)
v.register_metric("throttle_pedal_pct", unit="%", fmt=".0f", label="Gaspedal", source="engine", priority=46)
v.register_metric("throttle_plate_pct", unit="%", fmt=".0f", label="Drosselklappe", source="engine", priority=47)
# Hilfsfunktionen
def visco(temp_c: float) -> float:
# -10°C -> 0.6, 20°C -> 0.8, 90°C -> 1.0 (linear segmentiert)
if temp_c <= -10: return 0.6
if temp_c >= 90: return 1.0
if temp_c <= 20:
# -10..20°C: 0.6 -> 0.8 (30 K Schritt → +0.2 => +0.006666.. pro K)
return 0.6 + (temp_c + 10.0) * (0.2 / 30.0)
# 20..90°C: 0.8 -> 1.0 (70 K Schritt → +0.2)
return 0.8 + (temp_c - 20.0) * (0.2 / 70.0)
# Spannungsfaktor: unter vmin kein Crank, bei 12.6V ~1.0
# --- Start-/Ziel-RPM Logik ---
# Starter-Viskositätseinfluss
vfac = 0.0 if elx_v <= starter_vmin else min(1.2, (elx_v - starter_vmin) / max(0.3, (12.6 - starter_vmin)))
crank_rpm = starter_nom * vfac * visco(oil)
crank_rpm = starter_nom * vfac * self._visco(oil)
# sinnvolle effektive Startschwelle (unabhängig von stall)
start_rpm_min = 0.15 * idle # 15 % vom Idle
start_rpm_max = 0.45 * idle # 45 % vom Idle
# effektive Startschwelle (15..45% Idle)
start_rpm_min = 0.15 * idle
start_rpm_max = 0.45 * idle
start_rpm_th_eff = max(start_rpm_min, min(start_rpm_th, start_rpm_max))
# --- Ziel-RPM bestimmen ---
if ign in ("OFF", "ACC"):
self._running = False
target_rpm = 0.0
elif ign == "START":
target_rpm = crank_rpm # wie gehabt
# Greifen, sobald Schwelle erreicht und Spannung reicht
target_rpm = crank_rpm
if not self._running and target_rpm >= start_rpm_th_eff and elx_v > starter_vmin:
self._running = True
else: # ON
# Catch-on-ON: wenn beim Umschalten noch genug Drehzahl anliegt
if not self._running and rpm >= max(0.15 * idle, start_rpm_th_eff * 0.9):
if not self._running and rpm >= max(0.15*idle, start_rpm_th_eff*0.9):
self._running = True
if self._running:
cold_add = max(0.0, min(cold_gain_max, (90.0 - cool) * cold_gain_per_deg))
cold_add = max(0.0, min(ENGINE_DEFAULTS["idle_cold_gain_max"],
(90.0 - cool) * cold_gain_per_deg))
idle_eff = idle + cold_add
target_rpm = max(idle_eff, min(maxr, rpm))
else:
target_rpm = 0.0
# --- verfügbare Motorleistung / Moment (ohne Last) ---
# --- Basis-Moment & Derating ---
base_torque = self._torque_at_rpm(power_kw, max(1.0, rpm), peak_torque_rpm)
temp_derate = max(0.7, 1.0 - max(0.0, (oil - 110.0)) * 0.005)
# Drive-by-Wire / PI auf Drehmomentanteil -----------------------------------
# Fahrerwunsch in "Leistungsanteil" (0..1) transformieren (Kennlinie)
demand = self._curve(pedal/100.0, thr_curve) # 0..1
# Overrun-Logik: bei sehr geringem Wunsch → nahezu zu (aber nie ganz)
# --- DBW (PI auf Torque-Anteil) ---
demand = self._curve(pedal/100.0, thr_curve)
plate_target_min = plate_overrun if demand < 0.02 else plate_idle_min
# Regler-Soll: gewünschter Torque-Anteil relativ zum maximal möglichen bei aktueller Drehzahl
# Wir approximieren: torque_avail = base_torque * airflow * temp_derate
airflow = self._plate_airflow_factor(self._plate_pct)
torque_avail = base_torque * airflow * temp_derate
torque_frac = 0.0 if base_torque <= 1e-6 else (torque_avail / (base_torque * temp_derate)) # ~airflow
torque_frac = 0.0 if base_torque <= 1e-6 else (torque_avail / (base_torque * temp_derate))
err = max(0.0, demand) - max(0.0, min(1.0, torque_frac))
# PI: Integrator nur wenn Motor an
if ign == "ON" and self._running:
self._tc_i += err * torque_ki * dt
else:
self._tc_i *= 0.95 # langsam abbauen
self._tc_i *= 0.95
plate_cmd = self._plate_pct + (torque_kp * err + self._tc_i) * 100.0 # in %-Punkte
plate_cmd = self._plate_pct + (torque_kp * err + self._tc_i) * 100.0
plate_cmd = max(plate_target_min, min(100.0, plate_cmd))
a_tau = min(1.0, dt / max(1e-3, plate_tau))
self._plate_pct = (1.0 - a_tau) * self._plate_pct + a_tau * plate_cmd
# Aktuator-Trägheit (1. Ordnung)
if plate_tau <= 1e-3:
self._plate_pct = plate_cmd
else:
a = min(1.0, dt / plate_tau)
self._plate_pct = (1.0 - a) * self._plate_pct + a * plate_cmd
# Update airflow nach Stellgröße
# aktualisiertes Moment
airflow = self._plate_airflow_factor(self._plate_pct)
avail_torque = base_torque * airflow * temp_derate
net_torque = max(0.0, avail_torque - max(0.0, ext_torque))
net_torque = max(0.0, avail_torque - max(0.0, torque_load))
# --- Ziel-RPM aus Netto-Moment (sehr simple Dynamik) -----------------------
# Näherung: mehr Netto-Moment → RPM-Ziel steigt innerhalb der Bandbreite
# Wir skalieren zwischen (idle_eff) und maxr
# --- Wärmeleistung pushen (W) ---
# mechanische Leistung:
mech_power_w = net_torque * (2.0 * math.pi * rpm / 60.0)
# grober Wirkungsgrad (0.24..0.34 je nach Pedal/Kennlinie)
eta = 0.24 + 0.10 * self._curve(pedal/100.0, thr_curve)
eta = max(0.05, min(0.45, eta))
fuel_power_w = mech_power_w / max(1e-3, eta)
heat_w = max(0.0, fuel_power_w - mech_power_w)
# Idle-Basiswärme, damit im Leerlauf nicht auskühlt:
idle_heat_w = 1500.0 * (rpm / max(1.0, idle))
heat_w = max(heat_w, idle_heat_w)
v.push("thermal.heat_w", +heat_w, source="engine")
# --- Ziel-RPM aus Netto-Moment ---
if ign == "ON" and self._running:
cold_add = max(0.0, min(cold_gain_max, (90.0 - cool) * cold_gain_per_deg))
cold_add = max(0.0, min(ENGINE_DEFAULTS["idle_cold_gain_max"],
(90.0 - cool) * cold_gain_per_deg))
idle_eff = idle + cold_add
torque_norm = 0.0 if base_torque <= 1e-6 else max(0.0, min(1.0, net_torque / (base_torque * temp_derate + 1e-6)))
denom = (base_torque * temp_derate + 1e-6)
torque_norm = 0.0 if denom <= 1e-8 else max(0.0, min(1.0, net_torque / denom))
target_rpm = idle_eff + torque_norm * (maxr - idle_eff)
# --- RPM an Ziel annähern (mechanische Trägheit) --------------------------
if rpm < target_rpm:
rpm = min(target_rpm, rpm + rise * dt)
else:
rpm = max(target_rpm, rpm - fall * dt)
# Inertia
if rpm < target_rpm: rpm = min(target_rpm, rpm + rise * dt)
else: rpm = max(target_rpm, rpm - fall * dt)
# Stall: in ON, wenn laufend und RPM < stall ohne Starter → aus
# Stall
if ign == "ON" and self._running and rpm < stall_rpm:
self._running = False
# --- Temperaturen ----------------------------------------------------------
heat = (rpm/maxr)*0.8 + load*0.6
if (ign in ("ON","START")) and (self._running or target_rpm > 0):
cool += warm_c * heat * dt
oil += warm_o * heat * dt
else:
cool += (ambient - cool) * min(1.0, dt * cool_c)
oil += (ambient - oil) * min(1.0, dt * cool_o)
# --- Öldruck ---------------------------------------------------------------
if self._running and rpm > 0:
# --- Öldruck ---
if self._running and rpm > 0.0:
over_krpm = max(0.0, (rpm - idle)/1000.0)
oil_target = oil_idle_bar + oil_slope_bar_per_krpm * over_krpm
elif ign == "START" and target_rpm > 0:
elif ign == "START" and target_rpm > 0.0:
oil_target = max(oil_floor_off, 0.4)
else:
oil_target = oil_floor_off
a = min(1.0, dt / max(0.05, self._oil_p_tau))
oil_p = (1-a) * oil_p + a * oil_target
# --- Realistischer RPM-Jitter ---------------------------------------------
# bandbegrenztes Rauschen: x[n] = (1 - b)*x[n-1] + b*eta, b ~ dt/tau
# --- RPM-Jitter ---
if self._running and rpm >= jitter_off_rpm and ign == "ON":
b = min(1.0, dt / max(1e-3, jitter_tau))
eta = random.uniform(-1.0, 1.0) # weißes Rauschen
self._rpm_noise = (1.0 - b) * self._rpm_noise + b * eta
# Amplitude linear zwischen idle_amp und hi_amp
# bezogen auf aktuelles Drehzahlniveau (klein aber sichtbar)
amp_idle = jitter_idle_amp
amp_hi = jitter_hi_amp
# Interpolation über 0..maxr
eta_n = random.uniform(-1.0, 1.0)
self._rpm_noise = (1.0 - b) * self._rpm_noise + b * eta_n
k = max(0.0, min(1.0, rpm / max(1.0, maxr)))
amp = (1.0 - k)*amp_idle + k*amp_hi
amp = (1.0 - k)*jitter_idle_amp + k*jitter_hi_amp
rpm += self._rpm_noise * amp
else:
# Kein Jitter: Noise langsam abklingen
self._rpm_noise *= 0.9
# --- Klammern & Setzen -----------------------------------------------------
# --- Clamp & Set ---
rpm = max(0.0, min(rpm, maxr))
cool = max(-40.0, min(cool, 120.0))
oil = max(-40.0, min(oil, 150.0))
oil_p = max(oil_floor_off if not self._running else oil_floor_off, min(8.0, oil_p))
oil_p = max(oil_floor_off, min(8.0, oil_p))
v.set("rpm", int(rpm))
# WICHTIG: NICHT runden das macht das Dashboard per fmt
v.set("coolant_temp", float(cool))
v.set("oil_temp", float(oil))
v.set("rpm", float(rpm))
# Temperaturen NICHT setzen CoolingModule ist owner!
v.set("oil_pressure", float(oil_p))
v.set("engine_available_torque_nm", float(avail_torque))
v.set("engine_torque_load_nm", float(torque_load))
v.set("engine_net_torque_nm", float(net_torque))
v.set("throttle_pedal_pct", float(pedal))
v.set("throttle_plate_pct", float(self._plate_pct))
v.set("throttle_plate_pct", float(self._plate_pct))

View File

@@ -1,39 +1,227 @@
# =============================
# app/simulation/modules/gearbox.py
# =============================
from __future__ import annotations
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):
PRIO = 30
NAME = "gearbox"
"""Koppelt Engine-RPM ↔ Wheel-Speed; registriert speed_kmh/gear fürs Dashboard."""
def __init__(self):
self.speed_tau = 0.3
self.rpm_couple = 0.2
# interner Zustand
self._clutch = 0.0 # 0..1
self._shift_t = 0.0
self._target_gear = None
self._wheel_v = 0.0 # m/s
def apply(self, v: Vehicle, dt: float) -> None:
# Dashboard registration
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)
# --- Dashboard-Registrierungen ---
v.register_metric("speed_kmh", label="Geschwindigkeit", unit="km/h", fmt=".1f", source="gearbox", priority=30)
v.register_metric("gear", label="Gang", fmt="", source="gearbox", priority=25)
v.register_metric("clutch_pct", label="Kupplung", unit="%", fmt=".0f", source="gearbox", priority=26)
v.register_metric("wheel_slip_pct", label="Reifenschlupf", unit="%", fmt=".0f", source="gearbox", priority=27)
g = int(v.ensure("gear", 0))
rpm = float(v.ensure("rpm", 1200))
speed = float(v.ensure("speed_kmh", 0.0))
ratios = v.config.get("gearbox", {}).get("kmh_per_krpm", [0.0])
# --- Config / Inputs ---
gb = dict(GEARBOX_DEFAULTS)
gb.update(v.config.get("gearbox", {}))
if g <= 0 or g >= len(ratios):
speed = max(0.0, speed - 6.0*dt)
v.set("speed_kmh", speed)
return
primary = float(gb["primary_ratio"])
gear_ratios = list(gb["gear_ratios"])
z_f = int(gb["front_sprocket_teeth"])
z_r = int(gb["rear_sprocket_teeth"])
final = (z_r / max(1, z_f))
kmh_per_krpm = float(ratios[g])
target_speed = (rpm/1000.0) * kmh_per_krpm
alpha = min(1.0, dt / max(0.05, self.speed_tau))
speed = (1-alpha) * speed + alpha * target_speed
v.set("speed_kmh", speed)
r_w = float(gb["wheel_radius_m"])
eta = float(gb["drivetrain_efficiency"])
couple_gain = float(gb["rpm_couple_gain"])
wheel_rpm = (speed / max(0.1, kmh_per_krpm)) * 1000.0
rpm = (1-self.rpm_couple) * rpm + self.rpm_couple * wheel_rpm
v.set("rpm", int(rpm))
c_rr = float(gb["rolling_c"])
rho = float(gb["air_density"])
cd = float(gb["aero_cd"])
A = float(gb["frontal_area_m2"])
clutch_Tmax = float(gb["clutch_max_torque_nm"])
clutch_agr = min(1.0, max(0.0, float(gb["clutch_aggressiveness"])))
clutch_curve= str(gb["clutch_curve"]).lower()
clutch_drag = float(gb["clutch_drag_nm"])
shift_time = float(gb["shift_time_s"])
sync_band = float(gb["sync_rpm_band"])
mu_peak = float(gb["tire_mu_peak"])
mu_slide= float(gb["tire_mu_slide"])
rear_w = float(gb["rear_static_weight_frac"])
m = float(v.config.get("vehicle", {}).get("mass_kg", 210.0))
g = 9.81
# State
gear = int(v.ensure("gear", 0))
ign = str(v.ensure("ignition", "OFF"))
rpm = float(v.ensure("rpm", 1200.0))
pedal= float(v.ensure("throttle_pedal_pct", 0.0))
pedal = max(0.0, min(100.0, pedal))
# verfügbare Motordaten
eng_avail_T = float(v.get("engine_available_torque_nm", 0.0)) # „kann liefern“
# Hinweis: die Engine zieht später v.acc_total("engine.torque_load_nm") ab.
# Pending Shift Commands (vom UI gesetzt und dann zurücksetzen)
up_req = bool(v.ensure("gear_shift_up", False))
down_req = bool(v.ensure("gear_shift_down", False))
to_N_req = bool(v.ensure("gear_set_neutral", False))
if up_req: v.set("gear_shift_up", False)
if down_req: v.set("gear_shift_down", False)
if to_N_req: v.set("gear_set_neutral", False)
# --- Schaltlogik ---
if self._shift_t > 0.0:
self._shift_t -= dt
# währenddessen Kupplung öffnen
self._clutch = max(0.0, self._clutch - self._rate_from_agr(1.0, clutch_agr) * dt)
if self._shift_t <= 0.0 and self._target_gear is not None:
gear = int(self._target_gear)
v.set("gear", gear)
self._target_gear = None
else:
# neue Requests annehmen, wenn nicht bereits am Limit
if to_N_req:
self._target_gear = 0
self._shift_t = shift_time
elif up_req and gear < min(6, len(gear_ratios)-1):
self._target_gear = gear + 1
self._shift_t = shift_time
elif down_req and gear > 0:
self._target_gear = gear - 1
self._shift_t = shift_time
# --- Gesamtübersetzung und Soll-Drehzahlbezug ---
gear_ratio = float(gear_ratios[gear]) if 0 <= gear < len(gear_ratios) else 0.0
overall = primary * gear_ratio * final # Kurbel -> Rad
wheel_omega = self._wheel_v / max(1e-6, r_w) # rad/s
eng_omega_from_wheel = wheel_omega * overall
rpm_from_wheel = eng_omega_from_wheel * 60.0 / (2.0 * math.pi)
# --- Kupplungs-Automat ---
# Zielschließung aus Schlupf und Fahrerwunsch
slip_rpm = abs(rpm - rpm_from_wheel)
slip_norm = min(1.0, slip_rpm / max(1.0, sync_band))
base_target = max(0.0, min(1.0, (pedal/100.0)*0.6 + (1.0 - slip_norm)*0.6))
target_c = self._shape(base_target, clutch_curve)
# Bei N oder ohne Übersetzung kein Kraftschluss
if gear == 0 or overall <= 1e-6 or ign in ("OFF","ACC"):
target_c = 0.0
# Sanfte Anti-Abwürg-Logik: ist RPM sehr niedrig und Radlast hoch -> etwas öffnen
if rpm < 1500.0 and slip_rpm > 200.0:
target_c = min(target_c, 0.6)
# Dynamik der Kupplung (Annäherung Richtung target_c)
rate = self._rate_from_agr(target_c, clutch_agr) # s^-1
self._clutch += (target_c - self._clutch) * min(1.0, rate * dt)
self._clutch = max(0.0, min(1.0, self._clutch))
# --- Übertragbares Motormoment durch Kupplung ---
clutch_cap = clutch_Tmax * self._clutch
T_engine_to_input = max(0.0, min(eng_avail_T, clutch_cap))
# --- Rad-Seite: aus Motor via Übersetzung ---
T_wheel_from_engine = T_engine_to_input * overall * eta if overall > 0.0 else 0.0 # Nm am Rad
# --- Reibungs-/Luftwiderstand ---
v_ms = max(0.0, self._wheel_v)
F_roll = m * g * c_rr
F_aero = 0.5 * rho * cd * A * v_ms * v_ms
F_res = F_roll + F_aero
# --- Reifen-Force-Limit & Schlupf ---
N_rear = m * g * rear_w
F_trac_cap = mu_peak * N_rear
F_from_engine = T_wheel_from_engine / max(1e-6, r_w)
slip = 0.0
F_trac = F_from_engine
if abs(F_from_engine) > F_trac_cap:
slip = min(1.0, (abs(F_from_engine) - F_trac_cap) / max(1.0, F_from_engine))
# im Schlupf auf Slide-Niveau kappen
F_trac = math.copysign(mu_slide * N_rear, F_from_engine)
# --- Fahrzeugdynamik: a = (F_trac - F_res)/m ---
a = (F_trac - F_res) / max(1.0, m)
self._wheel_v = max(0.0, self._wheel_v + a * dt)
speed_kmh = self._wheel_v * 3.6
v.set("speed_kmh", float(speed_kmh))
v.set("gear", int(gear))
v.set("clutch_pct", float(self._clutch * 100.0))
v.set("wheel_slip_pct", float(max(0.0, min(1.0, slip)) * 100.0))
# --- Reaktionsmoment zurück an den Motor (Last) ---
# aus tatsächlich wirkender Traktionskraft (nach Grip-Limit)
T_engine_load = 0.0
if overall > 0.0 and self._clutch > 0.0:
T_engine_load = (abs(F_trac) * r_w) / max(1e-6, (overall * eta))
# kleiner Schlepp bei getrennt
if self._clutch < 0.1:
T_engine_load += clutch_drag * (1.0 - self._clutch)
if T_engine_load > 0.0:
v.push("engine.torque_load_nm", +T_engine_load, source="driveline")
# --- RPM-Kopplung (sanfte Synchronisierung) ---
if overall > 0.0 and self._clutch > 0.2 and ign in ("ON","START"):
alpha = min(1.0, couple_gain * self._clutch * dt / max(1e-3, 0.1))
rpm = (1.0 - alpha) * rpm + alpha * rpm_from_wheel
v.set("rpm", float(rpm))
# ----- Helpers -----
def _rate_from_agr(self, target_c: float, agr: float) -> float:
"""Engage/Release-Geschwindigkeit [1/s] in Abhängigkeit der Aggressivität."""
# 0.05s (bissig) bis 0.5s (sanft) für ~63%-Annäherung
tau = 0.5 - 0.45 * agr
if target_c < 0.1: # Öffnen etwas flotter
tau *= 0.7
return 1.0 / max(0.05, tau)
def _shape(self, x: float, curve: str) -> float:
x = max(0.0, min(1.0, x))
if curve == "progressive":
return x * x
if curve == "soft":
return math.sqrt(x)
return x # linear

View File

@@ -1,7 +1,7 @@
# app/simulation/simulator.py
from __future__ import annotations
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
# ---------------------- Core: Vehicle + Accumulator-API ----------------------
@@ -11,17 +11,11 @@ class Vehicle:
"""
State-/Config-Container + Dashboard-Registry + generische Frame-Akkumulatoren.
Grundprinzip:
- set(key, value): harter Setzer (eine Quelle „besitzt“ den Wert)
- get/ensure: lesen/initialisieren
- push(key, delta, source): additiv beitragen (Source/Sink über Vorzeichen)
- acc_total(key): Summe aller Beiträge in diesem Frame
- acc_breakdown(key): Beiträge je Quelle (Debug/Transparenz)
- acc_reset(): zu Framebeginn alle Akkus löschen
Konvention (Empfehlung, aber nicht erzwungen):
* Positive Beiträge „belasten“ (z. B. Widerstandsmoment, Laststrom)
* Negative Beiträge „speisen“ (z. B. Generator-Moment, Einspeisestrom)
- set/get/ensure: harte Zustandswerte
- push(key, delta, source): additiver Beitrag pro Frame (Source/Sink via Vorzeichen)
- acc_total(key): Summe aller Beiträge zu 'key'
- acc_breakdown(key): Beiträge je Quelle (Debug/Transparenz)
- acc_reset(): am Frame-Beginn alle Akkus löschen
"""
state: Dict[str, Any] = field(default_factory=dict)
config: Dict[str, Any] = field(default_factory=dict)
@@ -29,7 +23,7 @@ class Vehicle:
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)
# ---- state helpers ----
@@ -73,80 +67,37 @@ class Vehicle:
def snapshot(self) -> Dict[str, Any]:
return dict(self.state)
# ---- generic accumulators (per-frame) ----
# ---- generic accumulators (per frame) ----
def acc_reset(self) -> None:
self._acc.clear()
def push(self, key: str, delta: float, source: Optional[str] = None) -> None:
"""
Additiver Beitrag zu einer Größe.
Vorzeichen: + belastet / - speist (Empfehlung).
"""
src = source or "anon"
bucket = self._acc.setdefault(key, {})
bucket[src] = bucket.get(src, 0.0) + float(delta)
def acc_total(self, key: str) -> float:
bucket = self._acc.get(key)
if not bucket: return 0.0
return sum(bucket.values())
return 0.0 if not bucket else sum(bucket.values())
def acc_breakdown(self, key: str) -> Dict[str, float]:
return dict(self._acc.get(key, {}))
# ---- Backwards-compat convenience for your current Basic code ----
def elec_reset_frame(self) -> None:
# map legacy helpers auf generisches System
# loads + sources werden in einem Kanal gesammelt
# (loads positiv, sources negativ)
# Diese Methode ist mittlerweile redundant, acc_reset() macht alles.
pass
def elec_add_load(self, name: str, amps: float) -> None:
self.push("elec.current", +max(0.0, float(amps)), source=name)
def elec_add_source(self, name: str, amps: float) -> None:
self.push("elec.current", -max(0.0, float(amps)), source=name)
def elec_totals(self) -> Tuple[float, float]:
"""
Gibt (loads_a_positiv, sources_a_positiv) zurück.
Intern liegt alles algebraisch in 'elec.current'.
"""
bd = self.acc_breakdown("elec.current")
loads = sum(v for v in bd.values() if v > 0)
sources = sum(-v for v in bd.values() if v < 0)
return (loads, sources)
# ---------------------------- Module Base + Loader ----------------------------
class Module:
"""
Basisklasse für alle Module. Jedes Modul:
- deklariert PRIO (klein = früher)
- hat NAME (für Debug/Registry)
- implementiert apply(v, dt)
"""
PRIO: int = 100
NAME: str = "module"
def apply(self, v: Vehicle, dt: float) -> None:
raise NotImplementedError
def _discover_modules(pkg_name: str = "app.simulation.modules") -> List[Module]:
"""
Sucht in app/simulation/modules nach Klassen, die Module erben,
instanziert sie und sortiert nach PRIO.
"""
mods: List[Module] = []
try:
pkg = importlib.import_module(pkg_name)
except Exception as exc:
raise RuntimeError(f"Module package '{pkg_name}' konnte nicht geladen werden: {exc}")
pkg = importlib.import_module(pkg_name)
pkg_path = pathlib.Path(pkg.__file__).parent
for _, modname, ispkg in pkgutil.iter_modules([str(pkg_path)]):
if ispkg: # optional: auch Subpackages zulassen
if ispkg:
continue
full_name = f"{pkg_name}.{modname}"
try:
@@ -154,60 +105,79 @@ def _discover_modules(pkg_name: str = "app.simulation.modules") -> List[Module]:
except Exception as exc:
print(f"[loader] Fehler beim Import {full_name}: {exc}")
continue
for _, obj in inspect.getmembers(m, inspect.isclass):
if not issubclass(obj, Module):
continue
if obj is Module:
if obj is Module or not issubclass(obj, Module):
continue
try:
inst = obj() # Module ohne args
inst = obj()
except Exception as exc:
print(f"[loader] Kann {obj.__name__} nicht instanziieren: {exc}")
continue
mods.append(inst)
# sortieren nach PRIO; bei Gleichstand NAME als Tie-Break
mods.sort(key=lambda x: (getattr(x, "PRIO", 100), getattr(x, "NAME", x.__class__.__name__)))
return mods
# ------------------------------- Simulator API --------------------------------
class VehicleSimulator:
"""
Öffentliche Fassade für GUI/Tests.
Lädt Module dynamisch, führt sie pro Tick in PRIO-Reihenfolge aus.
"""
"""Lädt Module dynamisch, führt sie pro Tick in PRIO-Reihenfolge aus."""
def __init__(self, modules_package: str = "app.simulation.modules"):
self.v = Vehicle()
self.modules: List[Module] = _discover_modules(modules_package)
self.module_defaults: Dict[str, Dict[str, Any]] = {}
for m in self.modules:
ns = getattr(m, "NAME", "").lower() or m.__class__.__name__.lower()
mod = importlib.import_module(m.__class__.__module__)
# Konvention: UPPER(NAME) + _DEFAULTS
key = f"{ns.upper()}_DEFAULTS"
defaults = getattr(mod, key, None)
if isinstance(defaults, dict):
self.module_defaults[ns] = dict(defaults)
def update(self, dt: float) -> None:
# pro Frame alle Akkumulatoren leeren
self.v.acc_reset()
self.v.acc_reset() # pro Frame Akkus leeren
for m in self.modules:
try:
m.apply(self.v, dt)
except Exception as exc:
print(f"[sim] Modul {getattr(m, 'NAME', m.__class__.__name__)} Fehler: {exc}")
# Kompatible Hilfsfunktionen für GUI
def snapshot(self) -> Dict[str, Any]:
return self.v.snapshot()
def load_config(self, cfg: Dict[str, Any]) -> None:
# Namespaced-Merge; Keys bleiben modul-spezifisch
for k, sub in cfg.items():
self.v.config.setdefault(k, {}).update(sub if isinstance(sub, dict) else {})
if "dtc" in cfg:
self.v.dtc.update(cfg["dtc"])
def export_config(self) -> Dict[str, Any]:
return {ns: dict(data) for ns, data in self.v.config.items()} | {"dtc": dict(self.v.dtc)}
"""
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:
self.v.set("gear", max(0, min(10, int(g))))
def set_throttle(self, t: int) -> None:
self.v.set("throttle_pct", max(0, min(100, int(t)))) # falls noch genutzt
self.v.set("throttle_pct", max(0, min(100, int(t))))

View File

@@ -6,194 +6,204 @@ from __future__ import annotations
import tkinter as tk
from tkinter import ttk
from typing import Dict, Any
from app.simulation.ui import UITab
from app.simulation.ui import UITab
class BasicTab(UITab):
NAME = "basic"
TITLE = "Basisdaten"
PRIO = 10
"""Basis-Fahrzeug-Tab (Zündung & Elektrik)."""
def __init__(self, parent, sim):
self.sim = sim
self.frame = ttk.Frame(parent, padding=8)
self.frame.columnconfigure(1, weight=1)
for c in (0,1,2,3): self.frame.columnconfigure(c, weight=1)
row = 0
# Vehicle basics -----------------------------------------------------------
ttk.Label(self.frame, text="Fahrzeugtyp").grid(row=row, column=0, sticky="w"); row+=1
self.type_var = tk.StringVar(value=self.sim.v.config.get("vehicle", {}).get("type", "motorcycle"))
ttk.Combobox(self.frame, textvariable=self.type_var, state="readonly",
values=["motorcycle", "car", "truck"], width=16)\
.grid(row=row-1, column=1, sticky="w")
# ---------- Linke Spalte ----------
rowL = 0
def L(lbl, var=None, w=12, kind="entry", values=None):
nonlocal rowL
ttk.Label(self.frame, text=lbl).grid(row=rowL, column=0, sticky="w")
if kind == "entry":
ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowL, column=1, sticky="w")
elif kind == "label":
ttk.Label(self.frame, textvariable=var).grid(row=rowL, column=1, sticky="w")
elif kind == "combo":
ttk.Combobox(self.frame, textvariable=var, state="readonly", values=values or [], width=w)\
.grid(row=rowL, column=1, sticky="w")
elif kind == "check":
ttk.Checkbutton(self.frame, variable=var).grid(row=rowL, column=1, sticky="w")
elif kind == "radio":
f = ttk.Frame(self.frame); f.grid(row=rowL, column=1, sticky="w")
for i,(t,vv) in enumerate(values or []):
ttk.Radiobutton(f, text=t, value=vv, variable=var, command=self._apply_ign)\
.grid(row=0, column=i, padx=(0,6))
rowL += 1
ttk.Label(self.frame, text="Gewicht [kg]").grid(row=row, column=0, sticky="w"); row+=1
self.mass_var = tk.DoubleVar(value=float(self.sim.v.config.get("vehicle", {}).get("mass_kg", 210.0)))
ttk.Entry(self.frame, textvariable=self.mass_var, width=10).grid(row=row-1, column=1, sticky="w")
# Vehicle
self.type = tk.StringVar(); L("Fahrzeugtyp", self.type, kind="combo", values=["motorcycle","car","truck"])
self.mass = tk.DoubleVar(); L("Gewicht [kg]", self.mass)
self.abs = tk.BooleanVar(); L("ABS vorhanden", self.abs, kind="check")
self.tcs = tk.BooleanVar(); L("ASR/Traktionskontrolle", self.tcs, kind="check")
self.abs_var = tk.BooleanVar(value=bool(self.sim.v.config.get("vehicle", {}).get("abs", True)))
ttk.Checkbutton(self.frame, text="ABS vorhanden", variable=self.abs_var)\
.grid(row=row, column=0, columnspan=2, sticky="w"); row+=1
ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1
self.tcs_var = tk.BooleanVar(value=bool(self.sim.v.config.get("vehicle", {}).get("tcs", False)))
ttk.Checkbutton(self.frame, text="ASR/Traktionskontrolle", variable=self.tcs_var)\
.grid(row=row, column=0, columnspan=2, sticky="w"); row+=1
# Environment / Ignition
self.amb = tk.DoubleVar(); L("Umgebung [°C]", self.amb)
self.ign = tk.StringVar(); L("Zündung", self.ign, kind="radio",
values=[("OFF","OFF"),("ACC","ACC"),("ON","ON"),("START","START")])
ttk.Separator(self.frame).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(6,6)); row+=1
ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 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")
# Live links (Labels)
self.batt_v = tk.StringVar(); L("Batterie [V]", self.batt_v, kind="label")
self.elx_v = tk.StringVar(); L("ELX/Bus [V]", self.elx_v, kind="label")
self.soc = tk.StringVar(); L("SOC [0..1]", self.soc, kind="label")
# 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))
# ---------- Rechte Spalte ----------
rowR = 0
def R(lbl, var=None, w=12, kind="entry"):
nonlocal rowR
ttk.Label(self.frame, text=lbl).grid(row=rowR, column=2, sticky="w")
if kind == "entry":
ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowR, column=3, sticky="w")
elif kind == "label":
ttk.Label(self.frame, textvariable=var).grid(row=rowR, column=3, sticky="w")
rowR += 1
# Live Electrical ----------------------------------------------------------
ttk.Label(self.frame, text="Batterie [V]").grid(row=row, column=0, sticky="w"); row+=1
self.batt_v_var = tk.StringVar(value=f"{self.sim.snapshot().get('battery_voltage', 12.6):.2f}")
ttk.Label(self.frame, textvariable=self.batt_v_var).grid(row=row-1, column=1, sticky="w")
# Live rechts (Labels)
self.ibatt = tk.StringVar(); R("I Batterie [A] (+entlädt)", self.ibatt, kind="label")
self.ialt = tk.StringVar(); R("I Lima [A]", self.ialt, kind="label")
self.load_elx= tk.StringVar(); R("Last ELX [A]", self.load_elx, kind="label")
self.load_bat= tk.StringVar(); R("Last Batterie [A]", self.load_bat, kind="label")
self.load_tot= tk.StringVar(); R("Last gesamt [A]", self.load_tot, kind="label")
ttk.Label(self.frame, text="ELX/Bus [V]").grid(row=row, column=0, sticky="w"); row+=1
self.elx_v_var = tk.StringVar(value=f"{self.sim.snapshot().get('elx_voltage', 0.0):.2f}")
ttk.Label(self.frame, textvariable=self.elx_v_var).grid(row=row-1, column=1, sticky="w")
ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
ttk.Label(self.frame, text="SOC [0..1]").grid(row=row, column=0, sticky="w"); row+=1
self.soc_var = tk.StringVar(value=f"{self.sim.snapshot().get('battery_soc', 0.8):.2f}")
ttk.Label(self.frame, textvariable=self.soc_var).grid(row=row-1, column=1, sticky="w")
# Electrical config
self.bcap = tk.DoubleVar(); R("Batt Kap. [Ah]", self.bcap)
self.brint = tk.DoubleVar(); R("Batt R_int [Ω]", self.brint)
self.alt_v = tk.DoubleVar(); R("Reglerspannung [V]", self.alt_v)
self.alt_a = tk.DoubleVar(); R("Lima Nennstrom [A]", self.alt_a)
self.alt_ci = tk.IntVar(); R("Cut-In RPM", self.alt_ci)
self.alt_fc = tk.IntVar(); R("Full-Cap RPM", self.alt_fc)
self.alt_eta= tk.DoubleVar(); R("Lima η_mech [-]", self.alt_eta)
self.alt_rat= tk.DoubleVar(); R("Lima Übersetzung [-]", self.alt_rat)
self.alt_d0 = tk.DoubleVar(); R("Lima Drag Grund [Nm]", self.alt_d0)
self.alt_d1 = tk.DoubleVar(); R("Lima Drag /krpm [Nm]", self.alt_d1)
ttk.Label(self.frame, text="I Batterie [A] (+entlädt)").grid(row=row, column=0, sticky="w"); row+=1
self.ibatt_var = tk.StringVar(value=f"{self.sim.snapshot().get('battery_current_a', 0.0):.2f}")
ttk.Label(self.frame, textvariable=self.ibatt_var).grid(row=row-1, column=1, sticky="w")
# ---------- 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))
ttk.Label(self.frame, text="I Lima [A]").grid(row=row, column=0, sticky="w"); row+=1
self.ialt_var = tk.StringVar(value=f"{self.sim.snapshot().get('alternator_current_a', 0.0):.2f}")
ttk.Label(self.frame, textvariable=self.ialt_var).grid(row=row-1, column=1, sticky="w")
self.refresh()
ttk.Label(self.frame, text="Last gesamt [A]").grid(row=row, column=0, sticky="w"); row+=1
self.load_var = tk.StringVar(value=f"{self.sim.snapshot().get('elec_load_total_a', 0.0):.2f}")
ttk.Label(self.frame, textvariable=self.load_var).grid(row=row-1, column=1, sticky="w")
ttk.Separator(self.frame).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(6,6)); row+=1
# Electrical config --------------------------------------------------------
econf = self.sim.v.config.get("electrical", {})
ttk.Label(self.frame, text="Batt Kap. [Ah]").grid(row=row, column=0, sticky="w"); row+=1
self.bcap = tk.DoubleVar(value=float(econf.get("battery_capacity_ah", 8.0)))
ttk.Entry(self.frame, textvariable=self.bcap, width=10).grid(row=row-1, column=1, sticky="w")
ttk.Label(self.frame, text="Batt R_int [Ω]").grid(row=row, column=0, sticky="w"); row+=1
self.brint = tk.DoubleVar(value=float(econf.get("battery_r_int_ohm", 0.020)))
ttk.Entry(self.frame, textvariable=self.brint, width=10).grid(row=row-1, column=1, sticky="w")
ttk.Label(self.frame, text="Reglerspannung [V]").grid(row=row, column=0, sticky="w"); row+=1
self.alt_v = tk.DoubleVar(value=float(econf.get("alternator_reg_v", 14.2)))
ttk.Entry(self.frame, textvariable=self.alt_v, width=10).grid(row=row-1, column=1, sticky="w")
ttk.Label(self.frame, text="Lima Nennstrom [A]").grid(row=row, column=0, sticky="w"); row+=1
self.alt_a = tk.DoubleVar(value=float(econf.get("alternator_rated_a", 20.0)))
ttk.Entry(self.frame, textvariable=self.alt_a, width=10).grid(row=row-1, column=1, sticky="w")
ttk.Label(self.frame, text="Cut-In RPM").grid(row=row, column=0, sticky="w"); row+=1
self.alt_cutin = tk.IntVar(value=int(econf.get("alt_cut_in_rpm", 1500)))
ttk.Entry(self.frame, textvariable=self.alt_cutin, width=10).grid(row=row-1, column=1, sticky="w")
ttk.Label(self.frame, text="Full-Cap RPM").grid(row=row, column=0, sticky="w"); row+=1
self.alt_full = tk.IntVar(value=int(econf.get("alt_full_rpm", 4000)))
ttk.Entry(self.frame, textvariable=self.alt_full, width=10).grid(row=row-1, column=1, sticky="w")
# Apply --------------------------------------------------------------------
ttk.Button(self.frame, text="Anwenden", command=self.apply)\
.grid(row=row, column=0, pady=(8,0), sticky="w")
# periodic UI refresh
self._tick()
def _tick(self):
# ------------ Logic ------------
def refresh(self):
snap = self.sim.snapshot()
# Live-Werte
self.batt_v_var.set(f"{snap.get('battery_voltage', 0):.2f}")
self.elx_v_var.set(f"{snap.get('elx_voltage', 0):.2f}")
self.soc_var.set(f"{snap.get('battery_soc', 0.0):.2f}")
self.ibatt_var.set(f"{snap.get('battery_current_a', 0.0):.2f}")
self.ialt_var.set(f"{snap.get('alternator_current_a', 0.0):.2f}")
self.load_var.set(f"{snap.get('elec_load_total_a', 0.0):.2f}")
vcfg = dict(self.sim.v.config.get("vehicle", {}))
ecfg = dict(self.sim.v.config.get("electrical", {}))
# START→ON aus dem Modul spiegeln
curr_ign = snap.get("ignition")
if curr_ign and curr_ign != self.ign_var.get():
self.ign_var.set(curr_ign)
# Vehicle
self.type.set(vcfg.get("type", "motorcycle"))
self.mass.set(float(vcfg.get("mass_kg", 210.0)))
self.abs.set(bool(vcfg.get("abs", True)))
self.tcs.set(bool(vcfg.get("tcs", False)))
try:
self.frame.after(200, self._tick)
except tk.TclError:
pass
# Env / Ign
self.amb.set(float(snap.get("ambient_c", 20.0)))
self.ign.set(str(snap.get("ignition", "ON")))
# Live left
self.batt_v.set(f"{float(snap.get('battery_voltage', 12.6)):.2f}")
self.elx_v.set(f"{float(snap.get('elx_voltage', 0.0)):.2f}")
self.soc.set(f"{float(snap.get('battery_soc', 0.80)):.2f}")
# Live right
self.ibatt.set(f"{float(snap.get('battery_current_a', 0.0)):.2f}")
self.ialt.set(f"{float(snap.get('alternator_current_a', 0.0)):.2f}")
self.load_elx.set(f"{float(snap.get('elec_load_elx_a', 0.0)):.2f}")
self.load_bat.set(f"{float(snap.get('elec_load_batt_a', 0.0)):.2f}")
self.load_tot.set(f"{float(snap.get('elec_load_total_a', 0.0)):.2f}")
# Electrical config
self.bcap.set(float(ecfg.get("battery_capacity_ah", 8.0)))
self.brint.set(float(ecfg.get("battery_r_int_ohm", 0.020)))
self.alt_v.set(float(ecfg.get("alternator_reg_v", 14.2)))
self.alt_a.set(float(ecfg.get("alternator_rated_a", 20.0)))
self.alt_ci.set(int(ecfg.get("alt_cut_in_rpm", 1500)))
self.alt_fc.set(int(ecfg.get("alt_full_rpm", 4000)))
self.alt_eta.set(float(ecfg.get("alternator_mech_efficiency", 0.55)))
self.alt_rat.set(float(ecfg.get("alternator_pulley_ratio", 1.0)))
self.alt_d0.set(float(ecfg.get("alternator_drag_nm_idle", 0.15)))
self.alt_d1.set(float(ecfg.get("alternator_drag_nm_per_krpm", 0.05)))
def _apply_ign(self):
# Zündung live setzen
self.sim.v.set("ignition", self.ign_var.get())
self.sim.v.set("ignition", self.ign.get())
def apply(self):
# Ambient in State (wirkt sofort auf Thermik, andere Module lesen das)
try:
self.sim.v.set("ambient_c", float(self.ambient_var.get()))
except Exception:
pass
# Umgebung sofort in den State (wirkt auf Thermik)
try: self.sim.v.set("ambient_c", float(self.amb.get()))
except: pass
cfg = {
"vehicle": {
"type": self.type_var.get(),
"mass_kg": float(self.mass_var.get()),
"abs": bool(self.abs_var.get()),
"tcs": bool(self.tcs_var.get()),
"type": self.type.get(),
"mass_kg": float(self.mass.get()),
"abs": bool(self.abs.get()),
"tcs": bool(self.tcs.get()),
},
"electrical": {
"battery_capacity_ah": float(self.bcap.get()),
"battery_r_int_ohm": float(self.brint.get()),
"alternator_reg_v": float(self.alt_v.get()),
"alternator_rated_a": float(self.alt_a.get()),
"alt_cut_in_rpm": int(self.alt_cutin.get()),
"alt_full_rpm": int(self.alt_full.get()),
"alt_cut_in_rpm": int(self.alt_ci.get()),
"alt_full_rpm": int(self.alt_fc.get()),
"alternator_mech_efficiency": float(self.alt_eta.get()),
"alternator_pulley_ratio": float(self.alt_rat.get()),
"alternator_drag_nm_idle": float(self.alt_d0.get()),
"alternator_drag_nm_per_krpm": float(self.alt_d1.get()),
}
}
self.sim.load_config(cfg)
# Save/Load Hooks für Gesamt-Export
def save_into_config(self, out: Dict[str, Any]) -> None:
out.setdefault("vehicle", {})
out["vehicle"].update({
"type": self.type_var.get(),
"mass_kg": float(self.mass_var.get()),
"abs": bool(self.abs_var.get()),
"tcs": bool(self.tcs_var.get()),
out.setdefault("vehicle", {}).update({
"type": self.type.get(),
"mass_kg": float(self.mass.get()),
"abs": bool(self.abs.get()),
"tcs": bool(self.tcs.get()),
})
out.setdefault("electrical", {})
out["electrical"].update({
out.setdefault("electrical", {}).update({
"battery_capacity_ah": float(self.bcap.get()),
"battery_r_int_ohm": float(self.brint.get()),
"alternator_reg_v": float(self.alt_v.get()),
"alternator_rated_a": float(self.alt_a.get()),
"alt_cut_in_rpm": int(self.alt_cutin.get()),
"alt_full_rpm": int(self.alt_full.get()),
"alt_cut_in_rpm": int(self.alt_ci.get()),
"alt_full_rpm": int(self.alt_fc.get()),
"alternator_mech_efficiency": float(self.alt_eta.get()),
"alternator_pulley_ratio": float(self.alt_rat.get()),
"alternator_drag_nm_idle": float(self.alt_d0.get()),
"alternator_drag_nm_per_krpm": float(self.alt_d1.get()),
})
def load_from_config(self, cfg: Dict[str, Any]) -> None:
vcfg = cfg.get("vehicle", {})
self.type_var.set(vcfg.get("type", self.type_var.get()))
self.mass_var.set(vcfg.get("mass_kg", self.mass_var.get()))
self.abs_var.set(vcfg.get("abs", self.abs_var.get()))
self.tcs_var.set(vcfg.get("tcs", self.tcs_var.get()))
ecfg = cfg.get("electrical", {})
vcfg = cfg.get("vehicle", {}); ecfg = cfg.get("electrical", {})
self.type.set(vcfg.get("type", self.type.get()))
self.mass.set(vcfg.get("mass_kg", self.mass.get()))
self.abs.set(vcfg.get("abs", self.abs.get()))
self.tcs.set(vcfg.get("tcs", self.tcs.get()))
self.bcap.set(ecfg.get("battery_capacity_ah", self.bcap.get()))
self.brint.set(ecfg.get("battery_r_int_ohm", self.brint.get()))
self.alt_v.set(ecfg.get("alternator_reg_v", self.alt_v.get()))
self.alt_a.set(ecfg.get("alternator_rated_a", self.alt_a.get()))
self.alt_cutin.set(ecfg.get("alt_cut_in_rpm", self.alt_cutin.get()))
self.alt_full.set(ecfg.get("alt_full_rpm", self.alt_full.get()))
# wichtig: NICHT self.sim.load_config(cfg) hier!
self.alt_ci.set(ecfg.get("alt_cut_in_rpm", self.alt_ci.get()))
self.alt_fc.set(ecfg.get("alt_full_rpm", self.alt_fc.get()))
self.alt_eta.set(ecfg.get("alternator_mech_efficiency", self.alt_eta.get()))
self.alt_rat.set(ecfg.get("alternator_pulley_ratio", self.alt_rat.get()))
self.alt_d0.set(ecfg.get("alternator_drag_nm_idle", self.alt_d0.get()))
self.alt_d1.set(ecfg.get("alternator_drag_nm_per_krpm", self.alt_d1.get()))
# wichtig: hier KEIN sim.load_config()

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
import tkinter as tk
from tkinter import ttk
from typing import Dict, Any
# Wichtig: Defaults aus dem Modul importieren
from app.simulation.modules.engine import ENGINE_DEFAULTS
from app.simulation.ui import UITab
from app.simulation.ui import UITab
class EngineTab(UITab):
NAME = "engine"
TITLE = "Motor"
PRIO = 10
def __init__(self, parent, sim):
self.sim = sim
self.frame = ttk.Frame(parent, padding=8)
self.frame.columnconfigure(1, weight=1)
for c in (0,1,2,3): self.frame.columnconfigure(c, weight=1)
# ------------- Widgets anlegen (OHNE Defaultwerte eintragen) --------------
row = 0
ttk.Label(self.frame, text="Leerlauf [RPM]").grid(row=row, column=0, sticky="w"); row+=1
self.idle_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.idle_var, width=10)\
.grid(row=row-1, column=1, sticky="w")
# ---------- Linke Spalte ----------
rowL = 0
def L(lbl, var, w=12, kind="entry", values=None):
nonlocal rowL
ttk.Label(self.frame, text=lbl).grid(row=rowL, column=0, sticky="w")
if kind == "entry":
ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowL, column=1, sticky="w")
elif kind == "combo":
ttk.Combobox(self.frame, textvariable=var, state="readonly",
values=values or [], width=w).grid(row=rowL, column=1, sticky="w")
rowL += 1
ttk.Label(self.frame, text="Max RPM").grid(row=row, column=0, sticky="w"); row+=1
self.maxrpm_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.maxrpm_var, width=10)\
.grid(row=row-1, column=1, sticky="w")
self.idle = tk.IntVar(); L("Leerlauf [RPM]", self.idle)
self.maxrpm = tk.IntVar(); L("Max RPM", self.maxrpm)
self.rise = tk.IntVar(); L("Anstieg [RPM/s]", self.rise)
self.fall = tk.IntVar(); L("Abfall [RPM/s]", self.fall)
self.curve = tk.StringVar(); L("Gaspedal-Kennlinie", self.curve, kind="combo",
values=["linear","progressive","aggressive"])
ttk.Label(self.frame, text="Anstieg [RPM/s]").grid(row=row, column=0, sticky="w"); row+=1
self.rise_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.rise_var, width=10)\
.grid(row=row-1, column=1, sticky="w")
ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1
ttk.Label(self.frame, text="Abfall [RPM/s]").grid(row=row, column=0, sticky="w"); row+=1
self.fall_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.fall_var, width=10)\
.grid(row=row-1, column=1, sticky="w")
self.power = tk.DoubleVar(); L("Motorleistung [kW]", self.power)
self.tqpeak = tk.DoubleVar(); L("Drehmoment-Peak [RPM]", self.tqpeak)
ttk.Label(self.frame, text="Gaspedal-Kennlinie").grid(row=row, column=0, sticky="w"); row+=1
self.thr_curve = tk.StringVar()
ttk.Combobox(self.frame, textvariable=self.thr_curve, state="readonly",
values=["linear","progressive","aggressive"])\
.grid(row=row-1, column=1, sticky="w")
ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1
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.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.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1
ttk.Label(self.frame, text="Drehmoment-Peak [RPM]").grid(row=row, column=0, sticky="w"); row+=1
self.peak_rpm = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.peak_rpm, width=10)\
.grid(row=row-1, column=1, sticky="w")
self.o_idle = tk.DoubleVar(); L("Öldruck Leerlauf [bar]", self.o_idle)
self.o_slope= tk.DoubleVar(); L("Öldruck Steigung [bar/krpm]", self.o_slope)
self.o_floor= tk.DoubleVar(); L("Öldruck Boden [bar]", self.o_floor)
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
ttk.Label(self.frame, text="Starter Nenn-RPM").grid(row=row, column=0, sticky="w"); row+=1
self.starter_nom = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.starter_nom, width=10)\
.grid(row=row-1, column=1, sticky="w")
self.dk_idle = tk.DoubleVar(); R("DK min Leerlauf [%]", self.dk_idle)
self.dk_over = tk.DoubleVar(); R("DK Schub [%]", self.dk_over)
self.dk_tau = tk.DoubleVar(); R("DK Zeitkonstante [s]", self.dk_tau)
self.tq_kp = tk.DoubleVar(); R("Torque-Kp", self.tq_kp)
self.tq_ki = tk.DoubleVar(); R("Torque-Ki", self.tq_ki)
ttk.Label(self.frame, text="Starter min. Spannung [V]").grid(row=row, column=0, sticky="w"); row+=1
self.starter_vmin = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.starter_vmin, width=10)\
.grid(row=row-1, column=1, sticky="w")
ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
ttk.Label(self.frame, text="Start-Schwelle [RPM]").grid(row=row, column=0, sticky="w"); row+=1
self.start_th = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.start_th, width=10)\
.grid(row=row-1, column=1, sticky="w")
self.jit_idle= tk.DoubleVar(); R("Jitter Leerlauf [±RPM]", self.jit_idle)
self.jit_high= tk.DoubleVar(); R("Jitter hoch [±RPM]", self.jit_high)
self.jit_tau = tk.DoubleVar(); R("Jitter-Zeitkonstante [s]", self.jit_tau)
self.jit_off = tk.DoubleVar(); R("Jitter aus unter [RPM]", self.jit_off)
ttk.Label(self.frame, text="Stall-Grenze [RPM]").grid(row=row, column=0, sticky="w"); row+=1
self.stall_rpm = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.stall_rpm, width=10)\
.grid(row=row-1, column=1, sticky="w")
ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
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) ...
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)
ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
# Öl, DBW, Jitter, Pedal
self.o_idle = tk.DoubleVar(); self.o_slope = tk.DoubleVar(); self.o_floor = tk.DoubleVar()
self.plate_idle_min = tk.DoubleVar(); self.plate_overrun = tk.DoubleVar(); self.plate_tau = tk.DoubleVar()
self.torque_kp = tk.DoubleVar(); self.torque_ki = tk.DoubleVar()
self.jitter_idle = tk.DoubleVar(); self.jitter_high = tk.DoubleVar()
self.jitter_tau = tk.DoubleVar(); self.jitter_off = tk.DoubleVar()
self.pedal = tk.DoubleVar(); R("Gaspedal [%]", self.pedal, kind="scale")
ttk.Label(self.frame, text="Gaspedal [%]").grid(row=row, column=0, sticky="w"); row+=1
self.pedal_var = tk.DoubleVar()
self.pedal_scale = ttk.Scale(self.frame, from_=0.0, to=100.0, variable=self.pedal_var)
self.pedal_scale.grid(row=row-1, column=1, sticky="ew")
# ---------- Buttons ----------
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))
# Buttons
row += 1
btnrow = ttk.Frame(self.frame); btnrow.grid(row=row, column=0, columnspan=2, sticky="w", pady=(8,0))
ttk.Button(btnrow, text="Aktualisieren", command=self.refresh).pack(side="left")
ttk.Button(btnrow, text="Anwenden", command=self.apply).pack(side="left", padx=(8,0))
# Zum Start einmal „live“ laden:
self.refresh()
# liest IMMER effektiv: config.get(key, ENGINE_DEFAULTS[key])
def _on_pedal_change(self):
try: self.sim.v.set("throttle_pedal_pct", float(self.pedal.get()))
except: pass
def refresh(self):
e = dict(ENGINE_DEFAULTS)
e.update(self.sim.v.config.get("engine", {})) # Config über default mergen
e = dict(ENGINE_DEFAULTS); e.update(self.sim.v.config.get("engine", {}))
self.idle_var.set(e["idle_rpm"])
self.maxrpm_var.set(e["max_rpm"])
self.rise_var.set(e["rpm_rise_per_s"])
self.fall_var.set(e["rpm_fall_per_s"])
self.thr_curve.set(e["throttle_curve"])
self.power_kw.set(e["engine_power_kw"])
self.peak_rpm.set(e["torque_peak_rpm"])
# links
self.idle.set(e["idle_rpm"])
self.maxrpm.set(e["max_rpm"])
self.rise.set(e["rpm_rise_per_s"])
self.fall.set(e["rpm_fall_per_s"])
self.curve.set(e["throttle_curve"])
self.starter_nom.set(e["starter_rpm_nominal"])
self.starter_vmin.set(e["starter_voltage_min"])
self.start_th.set(e["start_rpm_threshold"])
self.stall_rpm.set(e["stall_rpm"])
self.power.set(e["engine_power_kw"])
self.tqpeak.set(e["torque_peak_rpm"])
self.amb_c.set(e["coolant_ambient_c"])
self.c_warm.set(e["coolant_warm_rate_c_per_s"])
self.c_cool.set(e["coolant_cool_rate_c_per_s"])
self.o_warm.set(e["oil_warm_rate_c_per_s"])
self.o_cool.set(e["oil_cool_rate_c_per_s"])
self.cold_gain.set(e["idle_cold_gain_per_deg"])
self.cold_gain_max.set(e["idle_cold_gain_max"])
self.st_nom.set(e["starter_rpm_nominal"])
self.st_vmin.set(e["starter_voltage_min"])
self.st_thr.set(e["start_rpm_threshold"])
self.stall.set(e["stall_rpm"])
self.o_idle.set(e["oil_pressure_idle_bar"])
self.o_slope.set(e["oil_pressure_slope_bar_per_krpm"])
self.o_floor.set(e["oil_pressure_off_floor_bar"])
self.plate_idle_min.set(e["throttle_plate_idle_min_pct"])
self.plate_overrun.set(e["throttle_plate_overrun_pct"])
self.plate_tau.set(e["throttle_plate_tau_s"])
self.torque_kp.set(e["torque_ctrl_kp"])
self.torque_ki.set(e["torque_ctrl_ki"])
# rechts
self.dk_idle.set(e["throttle_plate_idle_min_pct"])
self.dk_over.set(e["throttle_plate_overrun_pct"])
self.dk_tau.set(e["throttle_plate_tau_s"])
self.tq_kp.set(e["torque_ctrl_kp"])
self.tq_ki.set(e["torque_ctrl_ki"])
self.jitter_idle.set(e["rpm_jitter_idle_amp_rpm"])
self.jitter_high.set(e["rpm_jitter_high_amp_rpm"])
self.jitter_tau.set(e["rpm_jitter_tau_s"])
self.jitter_off.set(e["rpm_jitter_off_threshold_rpm"])
self.jit_idle.set(e["rpm_jitter_idle_amp_rpm"])
self.jit_high.set(e["rpm_jitter_high_amp_rpm"])
self.jit_tau.set(e["rpm_jitter_tau_s"])
self.jit_off.set(e["rpm_jitter_off_threshold_rpm"])
self.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):
# Nur hier wird geschrieben
cfg = {"engine": {
"idle_rpm": int(self.idle_var.get()),
"max_rpm": int(self.maxrpm_var.get()),
"rpm_rise_per_s": int(self.rise_var.get()),
"rpm_fall_per_s": int(self.fall_var.get()),
"throttle_curve": self.thr_curve.get(),
"engine_power_kw": float(self.power_kw.get()),
"torque_peak_rpm": float(self.peak_rpm.get()),
"starter_rpm_nominal": float(self.starter_nom.get()),
"starter_voltage_min": float(self.starter_vmin.get()),
"start_rpm_threshold": float(self.start_th.get()),
"stall_rpm": float(self.stall_rpm.get()),
"coolant_ambient_c": float(self.amb_c.get()),
"coolant_warm_rate_c_per_s": float(self.c_warm.get()),
"coolant_cool_rate_c_per_s": float(self.c_cool.get()),
"oil_warm_rate_c_per_s": float(self.o_warm.get()),
"oil_cool_rate_c_per_s": float(self.o_cool.get()),
"idle_cold_gain_per_deg": float(self.cold_gain.get()),
"idle_cold_gain_max": float(self.cold_gain_max.get()),
"idle_rpm": int(self.idle.get()),
"max_rpm": int(self.maxrpm.get()),
"rpm_rise_per_s": int(self.rise.get()),
"rpm_fall_per_s": int(self.fall.get()),
"throttle_curve": self.curve.get(),
"engine_power_kw": float(self.power.get()),
"torque_peak_rpm": float(self.tqpeak.get()),
"starter_rpm_nominal": float(self.st_nom.get()),
"starter_voltage_min": float(self.st_vmin.get()),
"start_rpm_threshold": float(self.st_thr.get()),
"stall_rpm": float(self.stall.get()),
"oil_pressure_idle_bar": float(self.o_idle.get()),
"oil_pressure_slope_bar_per_krpm": float(self.o_slope.get()),
"oil_pressure_off_floor_bar": float(self.o_floor.get()),
"throttle_plate_idle_min_pct": float(self.plate_idle_min.get()),
"throttle_plate_overrun_pct": float(self.plate_overrun.get()),
"throttle_plate_tau_s": float(self.plate_tau.get()),
"torque_ctrl_kp": float(self.torque_kp.get()),
"torque_ctrl_ki": float(self.torque_ki.get()),
"rpm_jitter_idle_amp_rpm": float(self.jitter_idle.get()),
"rpm_jitter_high_amp_rpm": float(self.jitter_high.get()),
"rpm_jitter_tau_s": float(self.jitter_tau.get()),
"rpm_jitter_off_threshold_rpm": float(self.jitter_off.get()),
"throttle_pedal_pct": float(self.pedal_var.get()),
"throttle_plate_idle_min_pct": float(self.dk_idle.get()),
"throttle_plate_overrun_pct": float(self.dk_over.get()),
"throttle_plate_tau_s": float(self.dk_tau.get()),
"torque_ctrl_kp": float(self.tq_kp.get()),
"torque_ctrl_ki": float(self.tq_ki.get()),
"rpm_jitter_idle_amp_rpm": float(self.jit_idle.get()),
"rpm_jitter_high_amp_rpm": float(self.jit_high.get()),
"rpm_jitter_tau_s": float(self.jit_tau.get()),
"rpm_jitter_off_threshold_rpm": float(self.jit_off.get()),
"coolant_ambient_c": float(self.amb_c.get()),
"idle_cold_gain_per_deg": float(self.cold_k.get()),
"idle_cold_gain_max": float(self.cold_max.get()),
"throttle_pedal_pct": float(self.pedal.get()),
}}
self.sim.load_config(cfg)

View File

@@ -1,72 +1,241 @@
# =============================
# app/simulation/ui/gearbox.py
# =============================
from __future__ import annotations
import tkinter as tk
from tkinter import ttk
from typing import Dict, Any, List
from app.simulation.ui import UITab
from typing import Dict, Any
from app.simulation.ui import UITab
from app.simulation.modules.gearbox import GEARBOX_DEFAULTS
class GearboxTab(UITab):
NAME = "gearbox"
TITLE = "Getriebe"
PRIO = 10
TITLE = "Getriebe & Antrieb"
PRIO = 12
def __init__(self, parent, sim):
self.sim = sim
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")
self.gears_var = tk.IntVar(value=6)
ttk.Spinbox(self.frame, from_=1, to=10, textvariable=self.gears_var, width=6, command=self._rebuild_ratios).grid(row=0, column=1, sticky="w")
# ---------- Linke Spalte ----------
rowL = 0
def L(lbl, var=None, w=12, kind="entry", values=None):
nonlocal rowL
ttk.Label(self.frame, text=lbl).grid(row=rowL, column=0, sticky="w")
if kind == "entry":
ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowL, column=1, sticky="w")
elif kind == "label":
ttk.Label(self.frame, textvariable=var).grid(row=rowL, column=1, sticky="w")
elif kind == "combo":
ttk.Combobox(self.frame, textvariable=var, state="readonly",
values=values or [], width=w).grid(row=rowL, column=1, sticky="w")
elif kind == "buttons":
f = ttk.Frame(self.frame); f.grid(row=rowL, column=1, sticky="w")
ttk.Button(f, text="", width=3, command=self.shift_down).pack(side="left", padx=(0,4))
ttk.Button(f, text="N", width=3, command=self.set_neutral).pack(side="left", padx=(0,4))
ttk.Button(f, text="", width=3, command=self.shift_up).pack(side="left")
rowL += 1
self.reverse_var = tk.BooleanVar(value=False)
ttk.Checkbutton(self.frame, text="Rückwärtsgang vorhanden", variable=self.reverse_var).grid(row=1, column=0, columnspan=2, sticky="w")
# Live/Controls (Labels → werden im _tick() live aktualisiert)
self.gear_var = tk.StringVar(); L("Gang", self.gear_var, kind="label")
L("Schalten", kind="buttons")
self.speed_var = tk.StringVar(); L("Geschwindigkeit [km/h]", self.speed_var, kind="label")
self.clutch_v = tk.StringVar(); L("Kupplung [%]", self.clutch_v, kind="label")
self.slip_v = tk.StringVar(); L("Reifenschlupf [%]", self.slip_v, kind="label")
ttk.Label(self.frame, text="km/h pro 1000 RPM je Gang").grid(row=2, column=0, sticky="w", pady=(6,0))
self.ratio_frame = ttk.Frame(self.frame); self.ratio_frame.grid(row=3, column=0, columnspan=2, sticky="ew")
self.ratio_vars: List[tk.DoubleVar] = []
self._rebuild_ratios()
ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1
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):
for w in self.ratio_frame.winfo_children(): w.destroy()
self.ratio_vars.clear()
n = int(self.gears_var.get())
for i in range(1, n+1):
ttk.Label(self.ratio_frame, text=f"Gang {i}").grid(row=i-1, column=0, sticky="w")
v = tk.DoubleVar(value= [12.0,19.0,25.0,32.0,38.0,45.0][i-1] if i-1 < 6 else 45.0)
ttk.Entry(self.ratio_frame, textvariable=v, width=8).grid(row=i-1, column=1, sticky="w", padx=(6,12))
self.ratio_vars.append(v)
# ---------- Rechte Spalte ----------
rowR = 0
def R(lbl, var=None, w=12, kind="entry"):
nonlocal rowR
ttk.Label(self.frame, text=lbl).grid(row=rowR, column=2, sticky="w")
if kind == "entry":
ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowR, column=3, sticky="w")
elif kind == "label":
ttk.Label(self.frame, textvariable=var).grid(row=rowR, column=3, sticky="w")
rowR += 1
# Übersetzungen / Rad
self.primary = tk.DoubleVar(); R("Primärübersetzung [-]", self.primary)
self.zf = tk.IntVar(); R("Ritzel vorn [Z]", self.zf)
self.zr = tk.IntVar(); R("Ritzel hinten [Z]", self.zr)
self.rwheel = tk.DoubleVar(); R("Radradius [m]", self.rwheel)
self.eta = tk.DoubleVar(); R("Wirkungsgrad [-]", self.eta)
self.couple = tk.DoubleVar(); R("RPM-Kopplung [0..1]", self.couple)
ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
# Gangübersetzungen 1..6
self.g1 = tk.DoubleVar(); R("Gang 1 Ratio", self.g1)
self.g2 = tk.DoubleVar(); R("Gang 2 Ratio", self.g2)
self.g3 = tk.DoubleVar(); R("Gang 3 Ratio", self.g3)
self.g4 = tk.DoubleVar(); R("Gang 4 Ratio", self.g4)
self.g5 = tk.DoubleVar(); R("Gang 5 Ratio", self.g5)
self.g6 = tk.DoubleVar(); R("Gang 6 Ratio", self.g6)
ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1
# Widerstände / Reifen
self.c_rr = tk.DoubleVar(); R("Rollkoeff. c_rr", self.c_rr)
self.rho = tk.DoubleVar(); R("Luftdichte [kg/m³]", self.rho)
self.cd = tk.DoubleVar(); R("c_d [-]", self.cd)
self.A = tk.DoubleVar(); R("Stirnfläche [m²]", self.A)
self.mu_p = tk.DoubleVar(); R("Reifen μ_peak", self.mu_p)
self.mu_s = tk.DoubleVar(); R("Reifen μ_slide", self.mu_s)
self.w_rear = tk.DoubleVar(); R("Gewichtsanteil hinten [-]", self.w_rear)
# ---------- Buttons ----------
rowBtns = max(rowL, rowR) + 1
btn = ttk.Frame(self.frame); btn.grid(row=rowBtns, column=0, columnspan=4, sticky="w", pady=(8,0))
ttk.Button(btn, text="Aktualisieren", command=self.refresh).pack(side="left")
ttk.Button(btn, text="Anwenden", command=self.apply).pack(side="left", padx=(8,0))
self.refresh()
self._tick()
# --- Live-Update nur für Labels ---
def _tick(self):
snap = self.sim.snapshot()
gear = int(snap.get("gear", 0))
self.gear_var.set("N" if gear == 0 else str(gear))
self.speed_var.set(f"{float(snap.get('speed_kmh', 0.0)):.1f}")
self.clutch_v.set(f"{float(snap.get('clutch_pct', 0.0)):.0f}")
self.slip_v.set(f"{float(snap.get('wheel_slip_pct', 0.0)):.0f}")
try:
self.frame.after(200, self._tick)
except tk.TclError:
pass
# --- Actions (Buttons) ---
def shift_up(self): self.sim.v.set("gear_shift_up", True)
def shift_down(self): self.sim.v.set("gear_shift_down", True)
def set_neutral(self): self.sim.v.set("gear_set_neutral", True)
# --- Data flow ---
def refresh(self):
# Live-Felder werden vom _tick() versorgt; hier nur Config mergen
g = dict(GEARBOX_DEFAULTS)
g.update(self.sim.v.config.get("gearbox", {}))
self.cl_Tmax.set(g["clutch_max_torque_nm"])
self.cl_agr.set(g["clutch_aggressiveness"])
self.cl_curve.set(g.get("clutch_curve", "linear"))
self.cl_drag.set(g["clutch_drag_nm"])
self.sh_time.set(g["shift_time_s"])
self.sync_rb.set(g["sync_rpm_band"])
self.primary.set(g["primary_ratio"])
self.zf.set(g["front_sprocket_teeth"])
self.zr.set(g["rear_sprocket_teeth"])
self.rwheel.set(g["wheel_radius_m"])
self.eta.set(g["drivetrain_efficiency"])
self.couple.set(g["rpm_couple_gain"])
ratios = list(g["gear_ratios"]) + [0.0]*7
self.g1.set(ratios[1]); self.g2.set(ratios[2]); self.g3.set(ratios[3])
self.g4.set(ratios[4]); self.g5.set(ratios[5]); self.g6.set(ratios[6])
self.c_rr.set(g["rolling_c"])
self.rho.set(g["air_density"])
self.cd.set(g["aero_cd"])
self.A.set(g["frontal_area_m2"])
self.mu_p.set(g["tire_mu_peak"])
self.mu_s.set(g["tire_mu_slide"])
self.w_rear.set(g["rear_static_weight_frac"])
def apply(self):
ratios = [float(v.get()) for v in self.ratio_vars]
cfg = {"gearbox": {
"num_gears": int(self.gears_var.get()),
"reverse": bool(self.reverse_var.get()),
"kmh_per_krpm": [0.0] + ratios # index 0 reserved for neutral
"clutch_max_torque_nm": float(self.cl_Tmax.get()),
"clutch_aggressiveness": float(self.cl_agr.get()),
"clutch_curve": self.cl_curve.get(),
"clutch_drag_nm": float(self.cl_drag.get()),
"shift_time_s": float(self.sh_time.get()),
"sync_rpm_band": float(self.sync_rb.get()),
"primary_ratio": float(self.primary.get()),
"front_sprocket_teeth": int(self.zf.get()),
"rear_sprocket_teeth": int(self.zr.get()),
"wheel_radius_m": float(self.rwheel.get()),
"drivetrain_efficiency": float(self.eta.get()),
"rpm_couple_gain": float(self.couple.get()),
"gear_ratios": [
0.0,
float(self.g1.get()),
float(self.g2.get()),
float(self.g3.get()),
float(self.g4.get()),
float(self.g5.get()),
float(self.g6.get())
],
"rolling_c": float(self.c_rr.get()),
"air_density": float(self.rho.get()),
"aero_cd": float(self.cd.get()),
"frontal_area_m2": float(self.A.get()),
"tire_mu_peak": float(self.mu_p.get()),
"tire_mu_slide": float(self.mu_s.get()),
"rear_static_weight_frac": float(self.w_rear.get()),
}}
self.sim.load_config(cfg)
def save_into_config(self, out: Dict[str, Any]) -> None:
out.setdefault("gearbox", {})
out["gearbox"].update({
"num_gears": int(self.gears_var.get()),
"reverse": bool(self.reverse_var.get()),
"kmh_per_krpm": [0.0] + [float(v.get()) for v in self.ratio_vars]
out.setdefault("gearbox", {}).update({
"clutch_max_torque_nm": float(self.cl_Tmax.get()),
"clutch_aggressiveness": float(self.cl_agr.get()),
"clutch_curve": self.cl_curve.get(),
"clutch_drag_nm": float(self.cl_drag.get()),
"shift_time_s": float(self.sh_time.get()),
"sync_rpm_band": float(self.sync_rb.get()),
"primary_ratio": float(self.primary.get()),
"front_sprocket_teeth": int(self.zf.get()),
"rear_sprocket_teeth": int(self.zr.get()),
"wheel_radius_m": float(self.rwheel.get()),
"drivetrain_efficiency": float(self.eta.get()),
"rpm_couple_gain": float(self.couple.get()),
"gear_ratios": [0.0, float(self.g1.get()), float(self.g2.get()), float(self.g3.get()),
float(self.g4.get()), float(self.g5.get()), float(self.g6.get())],
"rolling_c": float(self.c_rr.get()),
"air_density": float(self.rho.get()),
"aero_cd": float(self.cd.get()),
"frontal_area_m2": float(self.A.get()),
"tire_mu_peak": float(self.mu_p.get()),
"tire_mu_slide": float(self.mu_s.get()),
"rear_static_weight_frac": float(self.w_rear.get()),
})
def load_from_config(self, cfg: Dict[str, Any]) -> None:
g = cfg.get("gearbox", {})
n = int(g.get("num_gears", self.gears_var.get()))
self.gears_var.set(n); self.reverse_var.set(g.get("reverse", self.reverse_var.get()))
self._rebuild_ratios()
ratios = g.get("kmh_per_krpm") or ([0.0] + [v.get() for v in self.ratio_vars])
for i, v in enumerate(self.ratio_vars, start=1):
try: v.set(float(ratios[i]))
except Exception: pass
self.sim.load_config(cfg)
g = dict(GEARBOX_DEFAULTS); g.update(cfg.get("gearbox", {}))
self.cl_Tmax.set(g["clutch_max_torque_nm"])
self.cl_agr.set(g["clutch_aggressiveness"])
self.cl_curve.set(g.get("clutch_curve","linear"))
self.cl_drag.set(g["clutch_drag_nm"])
self.sh_time.set(g["shift_time_s"])
self.sync_rb.set(g["sync_rpm_band"])
self.primary.set(g["primary_ratio"])
self.zf.set(g["front_sprocket_teeth"])
self.zr.set(g["rear_sprocket_teeth"])
self.rwheel.set(g["wheel_radius_m"])
self.eta.set(g["drivetrain_efficiency"])
self.couple.set(g["rpm_couple_gain"])
ratios = list(g["gear_ratios"]) + [0.0]*7
self.g1.set(ratios[1]); self.g2.set(ratios[2]); self.g3.set(ratios[3])
self.g4.set(ratios[4]); self.g5.set(ratios[5]); self.g6.set(ratios[6])
self.c_rr.set(g["rolling_c"])
self.rho.set(g["air_density"])
self.cd.set(g["aero_cd"])
self.A.set(g["frontal_area_m2"])
self.mu_p.set(g["tire_mu_peak"])
self.mu_s.set(g["tire_mu_slide"])
self.w_rear.set(g["rear_static_weight_frac"])

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