# ============================= # app/simulation/modules/engine.py # ============================= from __future__ import annotations from app.simulation.simulator import Module, Vehicle import math, random ENGINE_DEFAULTS = { "idle_rpm": 1200, "idle_rpm": 800, # normaler Idle "cold_idle_rpm": 1050, # Kaltlauf-Idle "cold_idle_end_c": 40.0, # bis zu dieser Kühlmitteltemp gilt cold_idle "idle_cold_mode": "two_point", # "two_point" | "slope" "max_rpm": 9000, "rpm_rise_per_s": 4000, "rpm_fall_per_s": 3000, "throttle_curve": "linear", "starter_rpm_nominal": 250.0, "starter_voltage_min": 10.5, "start_rpm_threshold": 210.0, "stall_rpm": 500.0, "coolant_ambient_c": 20.0, "idle_cold_gain_per_deg": 3.0, "idle_cold_gain_max": 500.0, "oil_pressure_idle_bar": 1.2, "oil_pressure_slope_bar_per_krpm": 0.8, "oil_pressure_off_floor_bar": 0.2, "engine_power_kw": 60.0, "torque_peak_rpm": 7000.0, "throttle_plate_idle_min_pct": 6.0, "throttle_plate_overrun_pct": 2.0, "throttle_plate_tau_s": 0.08, "torque_ctrl_kp": 1.2, "torque_ctrl_ki": 0.6, "rpm_jitter_idle_amp_rpm": 12.0, "rpm_jitter_high_amp_rpm": 4.0, "rpm_jitter_tau_s": 0.20, "rpm_jitter_off_threshold_rpm": 250.0, "throttle_pedal_pct": 0.0, } class EngineModule(Module): PRIO = 20 NAME = "engine" def __init__(self): self._running = False self._oil_p_tau = 0.25 self._plate_pct = 5.0 self._tc_i = 0.0 self._rpm_noise = 0.0 # NEU: Gnadenfrist nach dem Anspringen self._post_start_s = 0.0 # -- helpers (unverändert) -- 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 _tmax_at_rpm(self, power_kw: float, rpm: float, peak_rpm: float) -> float: rpm = max(0.0, rpm) t_peak = (9550.0 * max(0.0, power_kw)) / max(500.0, peak_rpm) x = min(math.pi, max(0.0, (rpm / max(1.0, peak_rpm)) * (math.pi/2))) return max(0.0, t_peak * math.sin(x)) def _plate_airflow_factor(self, plate_pct: float) -> float: 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: 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) def apply(self, v: Vehicle, dt: float) -> None: e = v.config.setdefault("engine", {}) # --- Config (wie gehabt) --- 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"])) 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"])) 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"])) 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.0)) pedal=float(v.ensure("throttle_pedal_pct", float(e.get("throttle_pedal_pct", 0.0)))) pedal=max(0.0, min(100.0, pedal)) 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)) 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))) # Dashboard (wie gehabt) 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) # --- Startlogik + Post-Start-Grace --- 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 * self._visco(oil) 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)) if ign in ("OFF","ACC"): self._running = False self._post_start_s = 0.0 elif ign == "START": # wenn Schwelle erreicht: Motor gilt als angesprungen + Gnadenfrist if not self._running and crank_rpm >= start_rpm_th_eff and elx_v > starter_vmin: self._running = True self._post_start_s = 1.2 rpm = max(rpm, crank_rpm) # Starter dreht mit else: # ON if not self._running and rpm >= max(0.15*idle, start_rpm_th_eff*0.9): self._running = True self._post_start_s = 1.0 if self._running and self._post_start_s > 0.0: self._post_start_s = max(0.0, self._post_start_s - dt) # --- Drehmomentmodell --- tmax_rpm = self._tmax_at_rpm(power_kw, max(1.0, rpm), peak_torque_rpm) cold_add = max(0.0, min(ENGINE_DEFAULTS["idle_cold_gain_max"], (90.0 - cool) * cold_gain_per_deg)) mode = str(e.get("idle_cold_mode", ENGINE_DEFAULTS["idle_cold_mode"])).lower() cold_idle = float(e.get("cold_idle_rpm", ENGINE_DEFAULTS["cold_idle_rpm"])) cold_end = float(e.get("cold_idle_end_c", ENGINE_DEFAULTS["cold_idle_end_c"])) if mode == "two_point": idle_eff = cold_idle if cool < cold_end else idle else: # Fallback: alte dynamische Rampe cold_add = max(0.0, min(ENGINE_DEFAULTS["idle_cold_gain_max"], (90.0 - cool) * float(e.get("idle_cold_gain_per_deg", ENGINE_DEFAULTS["idle_cold_gain_per_deg"])))) idle_eff = idle + cold_add rpm_err = max(0.0, idle_eff - rpm) t_idle_cap = 0.35 * max(5.0, self._tmax_at_rpm(power_kw, max(500.0, idle_eff), peak_torque_rpm)) t_idle_req = t_idle_cap * min(1.0, rpm_err / max(50.0, 0.2*idle_eff)) demand = self._curve(pedal/100.0, thr_curve) t_driver_req = demand * tmax_rpm # WICHTIG: Momentfreigabe auch in START, wenn _running bereits True running_mode = self._running and (ign in ("ON","START")) t_target = (max(t_idle_req, t_driver_req) if running_mode else 0.0) temp_derate = max(0.6, 1.0 - max(0.0, (oil - 120.0)) * 0.006) norm_target = 0.0 if tmax_rpm <= 1e-6 else max(0.0, min(1.0, t_target / (tmax_rpm * temp_derate))) norm_avail = self._plate_airflow_factor(self._plate_pct) err = norm_target - norm_avail if running_mode: self._tc_i += err * torque_ki * dt else: self._tc_i *= 0.9 plate_cmd = self._plate_pct + (torque_kp * err + self._tc_i) * 100.0 plate_min = plate_overrun if (demand < 0.02 and rpm > idle_eff + 200.0) else plate_idle_min plate_cmd = max(plate_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 airflow = self._plate_airflow_factor(self._plate_pct) avail_torque = tmax_rpm * airflow * temp_derate net_torque = max(0.0, avail_torque - max(0.0, torque_load)) # Wärme (wie gehabt) mech_power_w = net_torque * (2.0 * math.pi * rpm / 60.0) eta = 0.24 + 0.10 * demand 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_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") # RPM-Dynamik if running_mode: torque_norm = 0.0 if tmax_rpm <= 1e-6 else max(0.0, min(1.0, net_torque / tmax_rpm)) free_target = idle_eff + torque_norm * (maxr - idle_eff) if rpm < free_target: rpm = min(free_target, rpm + rise * max(0.2, torque_norm) * dt) else: rpm = max(free_target, rpm - fall * dt) elif ign == "START": rpm = max(rpm, crank_rpm) else: rpm *= 0.98 # Stall NUR wenn keine Gnadenfrist aktiv ist if ign == "ON" and self._running and self._post_start_s <= 0.0 and rpm < stall_rpm: self._running = False rpm = 0.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 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 # Jitter (wie gehabt) if self._running and rpm >= jitter_off_rpm and ign == "ON": b = min(1.0, dt / max(1e-3, jitter_tau)) 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)*jitter_idle_amp + k*jitter_hi_amp rpm += self._rpm_noise * amp else: self._rpm_noise *= 0.9 # Clamp & Set rpm = max(0.0, min(rpm, maxr)) oil_p = max(oil_floor_off, min(8.0, oil_p)) v.set("rpm", float(rpm)) 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))