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

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