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