# ============================= # app/simulation/modules/engine.py # ============================= from __future__ import annotations from app.simulation.simulator import Module, Vehicle 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): PRIO = 20 NAME = "engine" """ 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 (linear segmentiert) if temp_c <= -10: return 0.6 if temp_c >= 90: return 1.0 if temp_c <= 20: # -10..20°C: 0.6 -> 0.8 (30 K Schritt → +0.2 => +0.006666.. pro K) return 0.6 + (temp_c + 10.0) * (0.2 / 30.0) # 20..90°C: 0.8 -> 1.0 (70 K Schritt → +0.2) return 0.8 + (temp_c - 20.0) * (0.2 / 70.0) # Spannungsfaktor: unter vmin kein Crank, bei 12.6V ~1.0 vfac = 0.0 if elx_v <= starter_vmin else min(1.2, (elx_v - starter_vmin) / max(0.3, (12.6 - starter_vmin))) crank_rpm = starter_nom * vfac * 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 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 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): 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 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)) # WICHTIG: NICHT runden – das macht das Dashboard per fmt v.set("coolant_temp", float(cool)) v.set("oil_temp", float(oil)) v.set("oil_pressure", float(oil_p)) v.set("engine_available_torque_nm", float(avail_torque)) v.set("engine_net_torque_nm", float(net_torque)) v.set("throttle_pedal_pct", float(pedal)) v.set("throttle_plate_pct", float(self._plate_pct))