starting to implement realistic Vehicle simulation

This commit is contained in:
2025-09-04 15:03:11 +02:00
parent 4c41be706d
commit 6a9d27c6cf
17 changed files with 1468 additions and 250 deletions

View File

@@ -0,0 +1,6 @@
# =============================
# app/simulation/__init__.py
# =============================
# empty package marker

View File

@@ -0,0 +1,6 @@
# =============================
# app/simulation/modules/__init__.py
# =============================
# empty package marker

View File

@@ -0,0 +1,11 @@
# app/simulation/modules/abs.py
from __future__ import annotations
from ..vehicle import Vehicle, Module
class AbsModule(Module):
"""Stub: deceleration limiting if ABS enabled (future: needs braking input)."""
def apply(self, v: Vehicle, dt: float) -> None:
_abs = bool(v.config.get("vehicle", {}).get("abs", True))
if not _abs:
return
# braking model folgt später

View File

@@ -0,0 +1,141 @@
# app/simulation/modules/basic.py
from __future__ import annotations
from ..vehicle import Vehicle, Module
import bisect
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))
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]
t = 0.0 if x1 == x0 else (s - x0) / (x1 - x0)
return y0 + t*(y1 - y0)
class BasicModule(Module):
"""
- Zündungslogik inkl. START→ON nach crank_time_s
- Ambient-Temperatur als globale Umweltgröße
- Elektrik:
* Load/Source-Aggregation via Vehicle-Helpers
* Lichtmaschine drehzahlabhängig, Regler auf alternator_reg_v
* Batterie: Kapazität (Ah), Innenwiderstand, OCV(SOC); I_batt > 0 => Entladung
"""
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)
# ----- 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))
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))
v.set("ambient_c", float(v.ensure("ambient_c", v.get("ambient_c", 20.0))))
# ----- START auto-fall to ON -----
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"
else:
self._crank_timer = 0.0
# ----- Früh-Exit: OFF/ACC -> Bus AUS, Batterie „ruht“ -----
if ign in ("OFF", "ACC"):
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)
v.set("alternator_current_a", 0.0)
v.set("elec_load_total_a", 0.0)
v.set("battery_soc", round(soc, 3))
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)
# 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:
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
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
# SOC-Update (Ah-Bilanz)
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)
# Klammern/Spiegeln
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))

View File

@@ -0,0 +1,320 @@
# =============================
# app/simulation/modules/engine.py
# =============================
from __future__ import annotations
from ..vehicle import Vehicle, Module
import random, math
# Ein einziger Wahrheitsanker für alle Defaults:
ENGINE_DEFAULTS = {
# Basis
"idle_rpm": 1200,
"max_rpm": 9000,
"rpm_rise_per_s": 4000,
"rpm_fall_per_s": 3000,
"throttle_curve": "linear",
# Starter
"starter_rpm_nominal": 250.0,
"starter_voltage_min": 10.5,
"start_rpm_threshold": 250.0, # <- fix niedriger, damit anspringt
"stall_rpm": 500.0,
# Thermik
"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
"oil_pressure_idle_bar": 1.2,
"oil_pressure_slope_bar_per_krpm": 0.8,
"oil_pressure_off_floor_bar": 0.2,
# Leistung
"engine_power_kw": 60.0,
"torque_peak_rpm": 7000.0,
# DBW
"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_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)
"throttle_pedal_pct": 0.0,
}
class EngineModule(Module):
"""
Erweiterte Motormodellierung mit realistischem Jitter & Drive-by-Wire:
- OFF/ACC/ON/START Logik, Starten/Abwürgen
- Thermik (Kühlmittel/Öl), Öldruck ~ f(RPM)
- Startverhalten abhängig von Spannung & Öltemp
- Leistungsmodell via engine_power_kw + torque_peak_rpm
- Fahrerwunsch: throttle_pedal_pct (0..100) → Ziel-Leistungsanteil
* Drosselklappe (throttle_plate_pct) wird per PI-Regler geführt
* Mindestöffnung im Leerlauf, fast zu im Schubbetrieb
- Realistischer RPM-Jitter:
* bandbegrenztes Rauschen (1. Ordnung) mit Amplitude ~ f(RPM)
* kein Jitter unter einer Schwell-RPM oder wenn Motor aus
Outputs:
rpm, coolant_temp, oil_temp, oil_pressure
engine_available_torque_nm, engine_net_torque_nm
throttle_plate_pct (neu), throttle_pedal_pct (durchgereicht)
"""
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._rpm_noise = 0.0
def _curve(self, t: float, mode: str) -> float:
if mode == "progressive": return t**1.5
if mode == "aggressive": return t**0.7
return t
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)
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 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"])
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"]))
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"]))
start_rpm_th= float(e.get("start_rpm_threshold", ENGINE_DEFAULTS["start_rpm_threshold"]))
stall_rpm = float(e.get("stall_rpm", ENGINE_DEFAULTS["stall_rpm"]))
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"]))
plate_idle_min = float(e.get("throttle_plate_idle_min_pct", ENGINE_DEFAULTS["throttle_plate_idle_min_pct"]))
plate_overrun = float(e.get("throttle_plate_overrun_pct", ENGINE_DEFAULTS["throttle_plate_overrun_pct"]))
plate_tau = float(e.get("throttle_plate_tau_s", ENGINE_DEFAULTS["throttle_plate_tau_s"]))
torque_kp = float(e.get("torque_ctrl_kp", ENGINE_DEFAULTS["torque_ctrl_kp"]))
torque_ki = float(e.get("torque_ctrl_ki", ENGINE_DEFAULTS["torque_ctrl_ki"]))
jitter_idle_amp= float(e.get("rpm_jitter_idle_amp_rpm", ENGINE_DEFAULTS["rpm_jitter_idle_amp_rpm"]))
jitter_hi_amp = float(e.get("rpm_jitter_high_amp_rpm", ENGINE_DEFAULTS["rpm_jitter_high_amp_rpm"]))
jitter_tau = float(e.get("rpm_jitter_tau_s", ENGINE_DEFAULTS["rpm_jitter_tau_s"]))
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)
pedal = float(v.ensure("throttle_pedal_pct", float(e.get("throttle_pedal_pct", 0.0))))
pedal = max(0.0, min(100.0, pedal))
load = float(v.ensure("engine_load", 0.0))
ign = str(v.ensure("ignition", "OFF"))
elx_v = float(v.ensure("elx_voltage", 0.0))
cool = float(v.ensure("coolant_temp", ambient))
oil = float(v.ensure("oil_temp", ambient))
oil_p = float(v.ensure("oil_pressure", 0.0))
ext_torque = float(v.ensure("engine_ext_torque_nm", 0.0))
# 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)
# Hilfsfunktionen
def visco(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
return 0.6 + (temp_c + 10.0) * 0.004
# Spannungsfaktor: unter vmin kein Crank, bei 12.6V ~1.0
vfac = 0.0 if elx_v <= starter_vmin else min(1.2, (elx_v - starter_vmin) / max(0.3, (12.6 - starter_vmin)))
crank_rpm = starter_nom * vfac * visco(oil)
# effektive Start-Schwelle: nie unter Stall+50 und nicht „unplausibel“ hoch
start_rpm_th_eff = max(stall_rpm + 50.0, min(start_rpm_th, 0.35 * idle))
# --- Ziel-RPM bestimmen (ohne Jitter) ---
if ign in ("OFF", "ACC"):
self._running = False
target_rpm = 0.0
elif ign == "START":
# deterministisches Cranken
target_rpm = crank_rpm
# zünde/greife, sobald die effektive Schwelle erreicht ist
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 genug Restdrehzahl da ist, gilt er als angesprungen
if not self._running and rpm >= max(stall_rpm + 50.0, 0.20 * idle):
self._running = True
if self._running:
cold_add = max(0.0, min(cold_gain_max, (90.0 - cool) * cold_gain_per_deg))
idle_eff = idle + cold_add
# Pedal/PI-Logik bleibt wie gehabt, target_rpm wird weiter unten aus net_torque bestimmt
target_rpm = max(idle_eff, min(maxr, rpm))
else:
target_rpm = 0.0
# --- verfügbare Motorleistung / Moment (ohne Last) ---
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)
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
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
plate_cmd = self._plate_pct + (torque_kp * err + self._tc_i) * 100.0 # in %-Punkte
plate_cmd = max(plate_target_min, min(100.0, 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
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))
# --- 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
if ign == "ON" and self._running:
cold_add = max(0.0, min(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)))
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)
# Stall: in ON, wenn laufend und RPM < stall ohne Starter → aus
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:
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:
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
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
k = max(0.0, min(1.0, rpm / max(1.0, maxr)))
amp = (1.0 - k)*amp_idle + k*amp_hi
rpm += self._rpm_noise * amp
else:
# Kein Jitter: Noise langsam abklingen
self._rpm_noise *= 0.9
# --- Klammern & Setzen -----------------------------------------------------
rpm = max(0.0, min(rpm, maxr))
cool = max(-40.0, min(cool, 120.0))
oil = max(-40.0, min(oil, 150.0))
oil_p = max(oil_floor_off if not self._running else oil_floor_off, min(8.0, oil_p))
v.set("rpm", int(rpm))
v.set("coolant_temp", round(cool, 1))
v.set("oil_temp", round(oil, 1))
v.set("oil_pressure", round(oil_p, 2))
v.set("engine_available_torque_nm", float(avail_torque))
v.set("engine_net_torque_nm", float(net_torque))
v.set("throttle_pedal_pct", float(pedal))
v.set("throttle_plate_pct", float(self._plate_pct))

View File

@@ -0,0 +1,34 @@
# app/simulation/modules/gearbox.py
from __future__ import annotations
from ..vehicle import Vehicle, Module
class GearboxModule(Module):
"""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
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)
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])
if g <= 0 or g >= len(ratios):
speed = max(0.0, speed - 6.0*dt)
v.set("speed_kmh", speed)
return
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)
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))

View File

@@ -0,0 +1,46 @@
# app/simulation/simulator_main.py
from __future__ import annotations
from typing import Dict, Any
from .vehicle import Vehicle, Orchestrator
from .modules.engine import EngineModule
from .modules.gearbox import GearboxModule
from .modules.abs import AbsModule
from .modules.basic import BasicModule
class VehicleSimulator:
def __init__(self):
self.v = Vehicle()
self.orch = Orchestrator(self.v)
# order matters: base → engine → gearbox → abs
self.orch.add(BasicModule())
self.orch.add(EngineModule())
self.orch.add(GearboxModule())
self.orch.add(AbsModule())
# control from GUI
def set_gear(self, g: int) -> None:
self.v.set("gear", max(0, min(10, int(g))))
def set_throttle(self, t: int) -> None:
self.v.set("throttle_pct", max(0, min(100, int(t))))
def update(self, dt: float) -> None:
self.orch.update(dt)
def snapshot(self) -> Dict[str, Any]:
return self.v.snapshot()
# config I/O (compat with old layout)
def load_config(self, cfg: Dict[str, Any]) -> None:
for k in ("engine","gearbox","vehicle"):
if k in cfg:
self.v.config.setdefault(k, {}).update(cfg[k])
if "dtc" in cfg:
self.v.dtc.update(cfg["dtc"])
def export_config(self) -> Dict[str, Any]:
return {
"engine": dict(self.v.config.get("engine", {})),
"gearbox": dict(self.v.config.get("gearbox", {})),
"vehicle": dict(self.v.config.get("vehicle", {})),
"dtc": dict(self.v.dtc),
}

122
app/simulation/vehicle.py Normal file
View File

@@ -0,0 +1,122 @@
# app/simulation/vehicle.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, Any, List
@dataclass
class Vehicle:
"""Dynamic property-bag vehicle."""
state: Dict[str, Any] = field(default_factory=lambda: {
"rpm": 1400,
"speed_kmh": 0.0,
"gear": 0,
"throttle_pct": 0,
"ignition": "OFF",
# elektrische Live-Werte
"battery_voltage": 12.6, # Batterie-Klemmenspannung
"elx_voltage": 0.0, # Bordnetz/Bus-Spannung
"system_voltage": 12.4, # alias
"battery_soc": 0.80, # 0..1
"battery_current_a": 0.0, # + entlädt, lädt
"alternator_current_a": 0.0, # von Lima geliefert
"elec_load_total_a": 0.0, # Summe aller Verbraucher
"ambient_c": 20.0,
})
config: Dict[str, Any] = field(default_factory=lambda: {
"vehicle": {
"type": "motorcycle",
"mass_kg": 210.0,
"abs": True,
"tcs": False,
},
# Elektrik-Parameter (global)
"electrical": {
"battery_capacity_ah": 8.0,
"battery_r_int_ohm": 0.020, # ~20 mΩ
# sehr einfache OCV(SOC)-Kennlinie
"battery_ocv_v": { # bei ~20°C
0.0: 11.8, 0.1: 12.0, 0.2: 12.1, 0.3: 12.2, 0.4: 12.3,
0.5: 12.45, 0.6: 12.55, 0.7: 12.65, 0.8: 12.75, 0.9: 12.85,
1.0: 12.95
},
"alternator_reg_v": 14.2,
"alternator_rated_a": 20.0, # Nennstrom
"alt_cut_in_rpm": 1500, # ab hier fängt sie an zu liefern
"alt_full_rpm": 4000, # ab hier volle Kapazität
},
})
dtc: Dict[str, bool] = field(default_factory=dict)
dashboard_specs: Dict[str, Dict[str, Any]] = field(default_factory=dict)
# accumulator für dieses Sim-Frame
_elec_loads_a: Dict[str, float] = field(default_factory=dict)
_elec_sources_a: Dict[str, float] = field(default_factory=dict)
# ---- helpers for modules ----
def get(self, key: str, default: Any = None) -> Any:
return self.state.get(key, default)
def set(self, key: str, value: Any) -> None:
self.state[key] = value
def ensure(self, key: str, default: Any) -> Any:
return self.state.setdefault(key, default)
# Dashboard registry (wie gehabt)
def register_metric(self, key: str, *, label: str | None = None, unit: str | None = None,
fmt: str | None = None, source: str | None = None,
priority: int = 100, overwrite: bool = False) -> None:
spec = self.dashboard_specs.get(key)
if spec and not overwrite:
if label and not spec.get("label"): spec["label"] = label
if unit and not spec.get("unit"): spec["unit"] = unit
if fmt and not spec.get("fmt"): spec["fmt"] = fmt
if source and not spec.get("source"): spec["source"] = source
if spec.get("priority") is None: spec["priority"] = priority
return
self.dashboard_specs[key] = {
"key": key, "label": label or key, "unit": unit, "fmt": fmt,
"source": source, "priority": priority,
}
def dashboard_snapshot(self) -> Dict[str, Any]:
return {"specs": dict(self.dashboard_specs), "values": dict(self.state)}
def snapshot(self) -> Dict[str, Any]:
return dict(self.state)
# ---- Electrical frame helpers ----
def elec_reset_frame(self) -> None:
self._elec_loads_a.clear()
self._elec_sources_a.clear()
def elec_add_load(self, name: str, amps: float) -> None:
# positive Werte = Stromaufnahme
self._elec_loads_a[name] = max(0.0, float(amps))
def elec_add_source(self, name: str, amps: float) -> None:
# positive Werte = Einspeisung
self._elec_sources_a[name] = max(0.0, float(amps))
def elec_totals(self) -> tuple[float, float]:
return sum(self._elec_loads_a.values()), sum(self._elec_sources_a.values())
class Module:
def apply(self, v: Vehicle, dt: float) -> None:
pass
class Orchestrator:
def __init__(self, vehicle: Vehicle):
self.vehicle = vehicle
self.modules: List[Module] = []
def add(self, m: Module):
self.modules.append(m)
def update(self, dt: float):
# Pro Frame die Electrical-Recorder nullen
self.vehicle.elec_reset_frame()
for m in self.modules:
m.apply(self.vehicle, dt)