# ============================= # app/simulation/modules/basic.py # ============================= from __future__ import annotations from app.simulation.simulator import Module, Vehicle import bisect, math def _ocv_from_soc(soc: float, table: dict[float, float]) -> float: xs = sorted(table.keys()) ys = [table[x] for x in xs] 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] t = 0.0 if x1 == x0 else (s - x0) / (x1 - x0) return y0 + t * (y1 - y0) class BasicModule(Module): PRIO = 90 NAME = "basic" def __init__(self): self.crank_time_s = 2.7 self._crank_timer = 0.0 def apply(self, v: Vehicle, dt: float) -> None: # 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) # 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)) 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 } # 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 → 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" else: self._crank_timer = 0.0 # --- 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 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_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 # --- 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 # 3) Lima-Kapazität if rpm < alt_cut_in: alt_cap_a = 0.0 elif rpm >= alt_full: alt_cap_a = alt_rated_a else: 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)) 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 # Clamps batt_v = max(10.0, min(15.5, batt_v)) 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)