neraly complete Model of Driving done, but needs tweaking
This commit is contained in:
		
							
								
								
									
										31
									
								
								app/app.py
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								app/app.py
									
									
									
									
									
								
							| @@ -95,18 +95,39 @@ def launch_gui(): | ||||
|         path = filedialog.askopenfilename(filetypes=[("JSON","*.json"),("All","*.*")]) | ||||
|         if not path: return | ||||
|         with open(path,"r",encoding="utf-8") as f: data = json.load(f) | ||||
|  | ||||
|         # NEU: sowohl altes (flach) als auch neues Format ("sim"/"app") akzeptieren | ||||
|         sim_block = data.get("sim") if isinstance(data, dict) else None | ||||
|         if sim_block: | ||||
|             sim.load_config(sim_block) | ||||
|         else: | ||||
|             sim.load_config(data) | ||||
|  | ||||
|         # Tabs dürfen zusätzliche eigene Daten ziehen | ||||
|         for t in ui_tabs: | ||||
|             if hasattr(t, "load_from_config"): t.load_from_config(data) | ||||
|         sim.load_config(data) | ||||
|             if hasattr(t, "load_from_config"):  | ||||
|                 t.load_from_config(sim_block or data) | ||||
|  | ||||
|         messagebox.showinfo("Simulator", "Konfiguration geladen.") | ||||
|  | ||||
|  | ||||
|     def do_save(): | ||||
|         cfg_out = sim.export_config() | ||||
|         # NEU: vollständige Sim-Config (inkl. Defaults) + App-Settings bündeln | ||||
|         sim_out = sim.export_config() | ||||
|         for t in ui_tabs: | ||||
|             if hasattr(t, "save_into_config"): t.save_into_config(cfg_out) | ||||
|             if hasattr(t, "save_into_config"): | ||||
|                 t.save_into_config(sim_out) | ||||
|  | ||||
|         out = { | ||||
|             "app": cfg,       # aktuelle App-Settings (CAN/UI/Logging etc.) | ||||
|             "sim": sim_out,   # vollständige Modul-Configs (mit Defaults) | ||||
|         } | ||||
|  | ||||
|         path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON","*.json")]) | ||||
|         if not path: return | ||||
|         with open(path,"w",encoding="utf-8") as f: json.dump(cfg_out, f, indent=2) | ||||
|         with open(path,"w",encoding="utf-8") as f: json.dump(out, f, indent=2) | ||||
|         messagebox.showinfo("Simulator", "Konfiguration gespeichert.") | ||||
|          | ||||
|     filemenu.add_command(label="Konfiguration laden…", command=do_load) | ||||
|     filemenu.add_command(label="Konfiguration speichern…", command=do_save) | ||||
|     filemenu.add_separator(); filemenu.add_command(label="Beenden", command=root.destroy) | ||||
|   | ||||
| @@ -1,146 +1,185 @@ | ||||
| # ============================= | ||||
| # app/simulation/modules/basic.py | ||||
| # ============================= | ||||
|  | ||||
| from __future__ import annotations | ||||
| from app.simulation.simulator import Module, Vehicle | ||||
| import bisect | ||||
| import bisect, math | ||||
|  | ||||
| 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)) | ||||
|     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] | ||||
|     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) | ||||
|     return y0 + t * (y1 - y0) | ||||
|  | ||||
| class BasicModule(Module): | ||||
|     PRIO = 10 | ||||
|     PRIO = 90 | ||||
|     NAME = "basic" | ||||
|     """ | ||||
|     - 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) | ||||
|         # 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) | ||||
|  | ||||
|         # ----- 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)) | ||||
|         # 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)) | ||||
|  | ||||
|         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 { | ||||
|         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 | ||||
|         } | ||||
|  | ||||
|         ign   = v.ensure("ignition", "ON") | ||||
|         rpm   = float(v.ensure("rpm", 1200)) | ||||
|         soc   = float(v.ensure("battery_soc", 0.80)) | ||||
|         # 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 auto-fall to ON ----- | ||||
|         # 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" | ||||
|                     v.set("ignition", "ON"); ign = "ON" | ||||
|         else: | ||||
|             self._crank_timer = 0.0 | ||||
|  | ||||
|         # ----- Früh-Exit: OFF/ACC -> Bus AUS, Batterie „ruht“ ----- | ||||
|         # --- 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 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) | ||||
|  | ||||
|             # 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_total_a", 0.0) | ||||
|             v.set("battery_soc", round(soc, 3)) | ||||
|             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 | ||||
|  | ||||
|         # ----- 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) | ||||
|         # --- 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 | ||||
|  | ||||
|         # 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: | ||||
|         # 3) Lima-Kapazität | ||||
|         if rpm < alt_cut_in: | ||||
|             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 | ||||
|         elif rpm >= alt_full: | ||||
|             alt_cap_a = alt_rated_a | ||||
|         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 | ||||
|             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)) | ||||
|  | ||||
|         # SOC-Update (Ah-Bilanz) | ||||
|         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) | ||||
|         batt_v = ocv - batt_i * batt_rint | ||||
|  | ||||
|         # Klammern/Spiegeln | ||||
|         # Clamps | ||||
|         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)) | ||||
|         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) | ||||
|   | ||||
							
								
								
									
										202
									
								
								app/simulation/modules/cooling.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										202
									
								
								app/simulation/modules/cooling.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,202 @@ | ||||
| # ============================= | ||||
| # 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))) | ||||
| @@ -4,9 +4,8 @@ | ||||
|  | ||||
| from __future__ import annotations | ||||
| from app.simulation.simulator import Module, Vehicle | ||||
| import random, math | ||||
| import math, random | ||||
|  | ||||
| # Ein einziger Wahrheitsanker für alle Defaults: | ||||
| ENGINE_DEFAULTS = { | ||||
|     # Basis | ||||
|     "idle_rpm": 1200, | ||||
| @@ -14,38 +13,41 @@ ENGINE_DEFAULTS = { | ||||
|     "rpm_rise_per_s": 4000, | ||||
|     "rpm_fall_per_s": 3000, | ||||
|     "throttle_curve": "linear", | ||||
|     # Starter | ||||
|  | ||||
|     # Starter / Startlogik | ||||
|     "starter_rpm_nominal": 250.0, | ||||
|     "starter_voltage_min": 10.5, | ||||
|     "start_rpm_threshold": 250.0,   # <- fix niedriger, damit anspringt | ||||
|     "start_rpm_threshold": 210.0, | ||||
|     "stall_rpm": 500.0, | ||||
|     # Thermik | ||||
|  | ||||
|     # Thermische Einflüsse (nur fürs Derating/Viskosität benutzt) | ||||
|     "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 | ||||
|  | ||||
|     # Öl / Öldruck | ||||
|     "oil_pressure_idle_bar": 1.2, | ||||
|     "oil_pressure_slope_bar_per_krpm": 0.8, | ||||
|     "oil_pressure_off_floor_bar": 0.2, | ||||
|     # Leistung | ||||
|  | ||||
|     # Leistungsdaten | ||||
|     "engine_power_kw": 60.0, | ||||
|     "torque_peak_rpm": 7000.0, | ||||
|     # DBW | ||||
|  | ||||
|     # Drive-by-wire / Regler | ||||
|     "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 | ||||
|     "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) | ||||
|  | ||||
|     # UI | ||||
|     "throttle_pedal_pct": 0.0, | ||||
| } | ||||
|  | ||||
| @@ -71,17 +73,15 @@ class EngineModule(Module): | ||||
|     """ | ||||
|  | ||||
|     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._oil_p_tau = 0.25  # Zeitkonstante Öldruck | ||||
|         # DBW intern | ||||
|         self._plate_pct = 5.0 | ||||
|         self._tc_i = 0.0 | ||||
|         # AR(1)-Noise | ||||
|         self._rpm_noise = 0.0 | ||||
|  | ||||
|     # ---- helpers ---------------------------------------------------------- | ||||
|     def _curve(self, t: float, mode: str) -> float: | ||||
|         if mode == "progressive": return t**1.5 | ||||
|         if mode == "aggressive":  return t**0.7 | ||||
| @@ -90,34 +90,34 @@ class EngineModule(Module): | ||||
|     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) | ||||
|         return max(0.0, t_max * math.sin(x)) | ||||
|  | ||||
|     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 _visco(self, 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 | ||||
|         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) | ||||
|  | ||||
|     # ---- main ------------------------------------------------------------- | ||||
|     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"]) | ||||
|         # --- Config --- | ||||
|         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"])) | ||||
|         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"])) | ||||
|         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"])) | ||||
| @@ -127,9 +127,6 @@ class EngineModule(Module): | ||||
|         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"])) | ||||
| @@ -146,183 +143,145 @@ class EngineModule(Module): | ||||
|         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) | ||||
|         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)) | ||||
|  | ||||
|         load = float(v.ensure("engine_load", 0.0)) | ||||
|         ign = str(v.ensure("ignition", "OFF")) | ||||
|         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)) | ||||
|         cool  = float(v.ensure("coolant_temp", ambient))  # nur lesen | ||||
|         oil   = float(v.ensure("oil_temp", ambient))      # nur lesen | ||||
|         oil_p = float(v.ensure("oil_pressure", 0.0)) | ||||
|  | ||||
|         ext_torque = float(v.ensure("engine_ext_torque_nm", 0.0)) | ||||
|         # externe Momente (Alternator/Getriebe/…) | ||||
|         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)))  # legacy fallback | ||||
|  | ||||
|         # 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) | ||||
|         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) | ||||
|  | ||||
|         # 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 | ||||
|         # --- Start-/Ziel-RPM Logik --- | ||||
|         # Starter-Viskositätseinfluss | ||||
|         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) | ||||
|         crank_rpm = starter_nom * vfac * self._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 | ||||
|         # effektive Startschwelle (15..45% Idle) | ||||
|         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)) | ||||
|  | ||||
|         # --- 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 | ||||
|             target_rpm = crank_rpm | ||||
|             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): | ||||
|             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)) | ||||
|                 cold_add = max(0.0, min(ENGINE_DEFAULTS["idle_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) --- | ||||
|         # --- Basis-Moment & Derating --- | ||||
|         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) | ||||
|         # --- DBW (PI auf Torque-Anteil) --- | ||||
|         demand = self._curve(pedal/100.0, thr_curve) | ||||
|         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 | ||||
|         torque_frac = 0.0 if base_torque <= 1e-6 else (torque_avail / (base_torque * temp_derate)) | ||||
|         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 | ||||
|             self._tc_i *= 0.95 | ||||
|  | ||||
|         plate_cmd = self._plate_pct + (torque_kp * err + self._tc_i) * 100.0  # in %-Punkte | ||||
|         plate_cmd = self._plate_pct + (torque_kp * err + self._tc_i) * 100.0 | ||||
|         plate_cmd = max(plate_target_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 | ||||
|  | ||||
|         # 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 | ||||
|         # aktualisiertes Moment | ||||
|         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)) | ||||
|         net_torque = max(0.0, avail_torque - max(0.0, torque_load)) | ||||
|  | ||||
|         # --- 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 | ||||
|         # --- Wärmeleistung pushen (W) --- | ||||
|         # mechanische Leistung: | ||||
|         mech_power_w = net_torque * (2.0 * math.pi * rpm / 60.0) | ||||
|         # grober Wirkungsgrad (0.24..0.34 je nach Pedal/Kennlinie) | ||||
|         eta = 0.24 + 0.10 * self._curve(pedal/100.0, thr_curve) | ||||
|         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-Basiswärme, damit im Leerlauf nicht auskühlt: | ||||
|         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") | ||||
|  | ||||
|         # --- Ziel-RPM aus Netto-Moment --- | ||||
|         if ign == "ON" and self._running: | ||||
|             cold_add = max(0.0, min(cold_gain_max, (90.0 - cool) * cold_gain_per_deg)) | ||||
|             cold_add = max(0.0, min(ENGINE_DEFAULTS["idle_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))) | ||||
|             denom = (base_torque * temp_derate + 1e-6) | ||||
|             torque_norm = 0.0 if denom <= 1e-8 else max(0.0, min(1.0, net_torque / denom)) | ||||
|             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) | ||||
|         # Inertia | ||||
|         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 | ||||
|         # Stall | ||||
|         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: | ||||
|         # --- Ö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 target_rpm > 0: | ||||
|         elif ign == "START" and target_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 | ||||
|  | ||||
|         # --- Realistischer RPM-Jitter --------------------------------------------- | ||||
|         # bandbegrenztes Rauschen: x[n] = (1 - b)*x[n-1] + b*eta, b ~ dt/tau | ||||
|         # --- RPM-Jitter --- | ||||
|         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 | ||||
|             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)*amp_idle + k*amp_hi | ||||
|  | ||||
|             amp = (1.0 - k)*jitter_idle_amp + k*jitter_hi_amp | ||||
|             rpm += self._rpm_noise * amp | ||||
|         else: | ||||
|             # Kein Jitter: Noise langsam abklingen | ||||
|             self._rpm_noise *= 0.9 | ||||
|  | ||||
|         # --- Klammern & Setzen ----------------------------------------------------- | ||||
|         # --- Clamp & Set --- | ||||
|         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)) | ||||
|         oil_p = max(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("rpm", float(rpm)) | ||||
|         # Temperaturen NICHT setzen – CoolingModule ist owner! | ||||
|         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)) | ||||
|         v.set("throttle_plate_pct", float(self._plate_pct)) | ||||
|   | ||||
| @@ -1,39 +1,227 @@ | ||||
| # ============================= | ||||
| # app/simulation/modules/gearbox.py | ||||
| # ============================= | ||||
|  | ||||
| from __future__ import annotations | ||||
| from app.simulation.simulator import Module, Vehicle | ||||
| import math | ||||
|  | ||||
| GEARBOX_DEFAULTS = { | ||||
|     # Übersetzungen | ||||
|     "primary_ratio": 1.84,              # Kurbelwelle -> Getriebeeingang | ||||
|     # Gangübersetzungen (Index 0 = Neutral/N = 0.0) | ||||
|     "gear_ratios": [0.0, 2.60, 1.90, 1.55, 1.35, 1.20, 1.07], | ||||
|     # Ketten-/Endübersetzung via Zähne | ||||
|     "front_sprocket_teeth": 16, | ||||
|     "rear_sprocket_teeth": 45, | ||||
|  | ||||
|     # Rad/Reifen | ||||
|     "wheel_radius_m": 0.31,             # dynamischer Halbmesser | ||||
|     "drivetrain_efficiency": 0.93,      # Wirkungsgrad Kurbel -> Rad | ||||
|     "rpm_couple_gain": 0.20,            # wie stark Engine-RPM zum Rad synchronisiert wird (0..1) | ||||
|  | ||||
|     # Fahrzeug / Widerstände | ||||
|     "rolling_c": 0.015,                 # Rollwiderstandskoeff. | ||||
|     "air_density": 1.2,                 # kg/m^3 | ||||
|     "aero_cd": 0.6, | ||||
|     "frontal_area_m2": 0.6, | ||||
|  | ||||
|     # Kupplung (auto) | ||||
|     "clutch_max_torque_nm": 220.0,      # max übertragbares Drehmoment (bei c=1) | ||||
|     "clutch_aggressiveness": 0.6,       # 0..1 (0 = sehr sanft, 1 = sehr bissig) | ||||
|     "clutch_curve": "linear",           # "linear" | "progressive" | "soft" | ||||
|     "clutch_drag_nm": 1.0,              # Restschleppmoment bei getrennt | ||||
|     "shift_time_s": 0.15,               # Schaltzeit, während der entkuppelt wird | ||||
|     "sync_rpm_band": 200.0,             # RPM-Band, in dem als „synchron“ gilt | ||||
|  | ||||
|     # Reifenhaftung (einfaches Limit) | ||||
|     "tire_mu_peak": 1.10,               # statischer Reibkoeffizient (Peak) | ||||
|     "tire_mu_slide": 0.85,              # Gleitreibung | ||||
|     "rear_static_weight_frac": 0.60     # statischer Lastanteil auf Antriebsrad | ||||
| } | ||||
|  | ||||
| class GearboxModule(Module): | ||||
|     PRIO = 30 | ||||
|     NAME = "gearbox" | ||||
|     """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 | ||||
|         # interner Zustand | ||||
|         self._clutch = 0.0               # 0..1 | ||||
|         self._shift_t = 0.0 | ||||
|         self._target_gear = None | ||||
|         self._wheel_v = 0.0              # m/s | ||||
|  | ||||
|     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) | ||||
|         # --- Dashboard-Registrierungen --- | ||||
|         v.register_metric("speed_kmh",       label="Geschwindigkeit", unit="km/h", fmt=".1f", source="gearbox", priority=30) | ||||
|         v.register_metric("gear",            label="Gang",                          fmt="",    source="gearbox", priority=25) | ||||
|         v.register_metric("clutch_pct",      label="Kupplung",       unit="%",     fmt=".0f", source="gearbox", priority=26) | ||||
|         v.register_metric("wheel_slip_pct",  label="Reifenschlupf",  unit="%",     fmt=".0f", source="gearbox", priority=27) | ||||
|  | ||||
|         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]) | ||||
|         # --- Config / Inputs --- | ||||
|         gb = dict(GEARBOX_DEFAULTS) | ||||
|         gb.update(v.config.get("gearbox", {})) | ||||
|  | ||||
|         if g <= 0 or g >= len(ratios): | ||||
|             speed = max(0.0, speed - 6.0*dt) | ||||
|             v.set("speed_kmh", speed) | ||||
|             return | ||||
|         primary = float(gb["primary_ratio"]) | ||||
|         gear_ratios = list(gb["gear_ratios"]) | ||||
|         z_f = int(gb["front_sprocket_teeth"]) | ||||
|         z_r = int(gb["rear_sprocket_teeth"]) | ||||
|         final = (z_r / max(1, z_f)) | ||||
|  | ||||
|         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) | ||||
|         r_w = float(gb["wheel_radius_m"]) | ||||
|         eta = float(gb["drivetrain_efficiency"]) | ||||
|         couple_gain = float(gb["rpm_couple_gain"]) | ||||
|  | ||||
|         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)) | ||||
|         c_rr = float(gb["rolling_c"]) | ||||
|         rho  = float(gb["air_density"]) | ||||
|         cd   = float(gb["aero_cd"]) | ||||
|         A    = float(gb["frontal_area_m2"]) | ||||
|  | ||||
|         clutch_Tmax = float(gb["clutch_max_torque_nm"]) | ||||
|         clutch_agr  = min(1.0, max(0.0, float(gb["clutch_aggressiveness"]))) | ||||
|         clutch_curve= str(gb["clutch_curve"]).lower() | ||||
|         clutch_drag = float(gb["clutch_drag_nm"]) | ||||
|         shift_time  = float(gb["shift_time_s"]) | ||||
|         sync_band   = float(gb["sync_rpm_band"]) | ||||
|  | ||||
|         mu_peak = float(gb["tire_mu_peak"]) | ||||
|         mu_slide= float(gb["tire_mu_slide"]) | ||||
|         rear_w  = float(gb["rear_static_weight_frac"]) | ||||
|  | ||||
|         m = float(v.config.get("vehicle", {}).get("mass_kg", 210.0)) | ||||
|         g = 9.81 | ||||
|  | ||||
|         # State | ||||
|         gear = int(v.ensure("gear", 0)) | ||||
|         ign  = str(v.ensure("ignition", "OFF")) | ||||
|         rpm  = float(v.ensure("rpm", 1200.0)) | ||||
|         pedal= float(v.ensure("throttle_pedal_pct", 0.0)) | ||||
|         pedal = max(0.0, min(100.0, pedal)) | ||||
|  | ||||
|         # verfügbare Motordaten | ||||
|         eng_avail_T = float(v.get("engine_available_torque_nm", 0.0))  # „kann liefern“ | ||||
|         # Hinweis: die Engine zieht später v.acc_total("engine.torque_load_nm") ab. | ||||
|  | ||||
|         # Pending Shift Commands (vom UI gesetzt und dann zurücksetzen) | ||||
|         up_req   = bool(v.ensure("gear_shift_up", False)) | ||||
|         down_req = bool(v.ensure("gear_shift_down", False)) | ||||
|         to_N_req = bool(v.ensure("gear_set_neutral", False)) | ||||
|         if up_req:   v.set("gear_shift_up", False) | ||||
|         if down_req: v.set("gear_shift_down", False) | ||||
|         if to_N_req: v.set("gear_set_neutral", False) | ||||
|  | ||||
|         # --- Schaltlogik --- | ||||
|         if self._shift_t > 0.0: | ||||
|             self._shift_t -= dt | ||||
|             # währenddessen Kupplung öffnen | ||||
|             self._clutch = max(0.0, self._clutch - self._rate_from_agr(1.0, clutch_agr) * dt) | ||||
|             if self._shift_t <= 0.0 and self._target_gear is not None: | ||||
|                 gear = int(self._target_gear) | ||||
|                 v.set("gear", gear) | ||||
|                 self._target_gear = None | ||||
|         else: | ||||
|             # neue Requests annehmen, wenn nicht bereits am Limit | ||||
|             if to_N_req: | ||||
|                 self._target_gear = 0 | ||||
|                 self._shift_t = shift_time | ||||
|             elif up_req and gear < min(6, len(gear_ratios)-1): | ||||
|                 self._target_gear = gear + 1 | ||||
|                 self._shift_t = shift_time | ||||
|             elif down_req and gear > 0: | ||||
|                 self._target_gear = gear - 1 | ||||
|                 self._shift_t = shift_time | ||||
|  | ||||
|         # --- Gesamtübersetzung und Soll-Drehzahlbezug --- | ||||
|         gear_ratio = float(gear_ratios[gear]) if 0 <= gear < len(gear_ratios) else 0.0 | ||||
|         overall = primary * gear_ratio * final  # Kurbel -> Rad | ||||
|         wheel_omega = self._wheel_v / max(1e-6, r_w)  # rad/s | ||||
|         eng_omega_from_wheel = wheel_omega * overall | ||||
|         rpm_from_wheel = eng_omega_from_wheel * 60.0 / (2.0 * math.pi) | ||||
|  | ||||
|         # --- Kupplungs-Automat --- | ||||
|         # Zielschließung aus Schlupf und Fahrerwunsch | ||||
|         slip_rpm = abs(rpm - rpm_from_wheel) | ||||
|         slip_norm = min(1.0, slip_rpm / max(1.0, sync_band)) | ||||
|         base_target = max(0.0, min(1.0, (pedal/100.0)*0.6 + (1.0 - slip_norm)*0.6)) | ||||
|         target_c = self._shape(base_target, clutch_curve) | ||||
|  | ||||
|         # Bei N oder ohne Übersetzung kein Kraftschluss | ||||
|         if gear == 0 or overall <= 1e-6 or ign in ("OFF","ACC"): | ||||
|             target_c = 0.0 | ||||
|  | ||||
|         # Sanfte Anti-Abwürg-Logik: ist RPM sehr niedrig und Radlast hoch -> etwas öffnen | ||||
|         if rpm < 1500.0 and slip_rpm > 200.0: | ||||
|             target_c = min(target_c, 0.6) | ||||
|  | ||||
|         # Dynamik der Kupplung (Annäherung Richtung target_c) | ||||
|         rate = self._rate_from_agr(target_c, clutch_agr)  # s^-1 | ||||
|         self._clutch += (target_c - self._clutch) * min(1.0, rate * dt) | ||||
|         self._clutch = max(0.0, min(1.0, self._clutch)) | ||||
|  | ||||
|         # --- Übertragbares Motormoment durch Kupplung --- | ||||
|         clutch_cap = clutch_Tmax * self._clutch | ||||
|         T_engine_to_input = max(0.0, min(eng_avail_T, clutch_cap)) | ||||
|  | ||||
|         # --- Rad-Seite: aus Motor via Übersetzung --- | ||||
|         T_wheel_from_engine = T_engine_to_input * overall * eta if overall > 0.0 else 0.0  # Nm am Rad | ||||
|  | ||||
|         # --- Reibungs-/Luftwiderstand --- | ||||
|         v_ms = max(0.0, self._wheel_v) | ||||
|         F_roll = m * g * c_rr | ||||
|         F_aero = 0.5 * rho * cd * A * v_ms * v_ms | ||||
|         F_res  = F_roll + F_aero | ||||
|  | ||||
|         # --- Reifen-Force-Limit & Schlupf --- | ||||
|         N_rear = m * g * rear_w | ||||
|         F_trac_cap = mu_peak * N_rear | ||||
|         F_from_engine = T_wheel_from_engine / max(1e-6, r_w) | ||||
|  | ||||
|         slip = 0.0 | ||||
|         F_trac = F_from_engine | ||||
|         if abs(F_from_engine) > F_trac_cap: | ||||
|             slip = min(1.0, (abs(F_from_engine) - F_trac_cap) / max(1.0, F_from_engine)) | ||||
|             # im Schlupf auf Slide-Niveau kappen | ||||
|             F_trac = math.copysign(mu_slide * N_rear, F_from_engine) | ||||
|  | ||||
|         # --- Fahrzeugdynamik: a = (F_trac - F_res)/m --- | ||||
|         a = (F_trac - F_res) / max(1.0, m) | ||||
|         self._wheel_v = max(0.0, self._wheel_v + a * dt) | ||||
|         speed_kmh = self._wheel_v * 3.6 | ||||
|         v.set("speed_kmh", float(speed_kmh)) | ||||
|         v.set("gear", int(gear)) | ||||
|         v.set("clutch_pct", float(self._clutch * 100.0)) | ||||
|         v.set("wheel_slip_pct", float(max(0.0, min(1.0, slip)) * 100.0)) | ||||
|  | ||||
|         # --- Reaktionsmoment zurück an den Motor (Last) --- | ||||
|         # aus tatsächlich wirkender Traktionskraft (nach Grip-Limit) | ||||
|         T_engine_load = 0.0 | ||||
|         if overall > 0.0 and self._clutch > 0.0: | ||||
|             T_engine_load = (abs(F_trac) * r_w) / max(1e-6, (overall * eta)) | ||||
|         # kleiner Schlepp bei getrennt | ||||
|         if self._clutch < 0.1: | ||||
|             T_engine_load += clutch_drag * (1.0 - self._clutch) | ||||
|  | ||||
|         if T_engine_load > 0.0: | ||||
|             v.push("engine.torque_load_nm", +T_engine_load, source="driveline") | ||||
|  | ||||
|         # --- RPM-Kopplung (sanfte Synchronisierung) --- | ||||
|         if overall > 0.0 and self._clutch > 0.2 and ign in ("ON","START"): | ||||
|             alpha = min(1.0, couple_gain * self._clutch * dt / max(1e-3, 0.1)) | ||||
|             rpm = (1.0 - alpha) * rpm + alpha * rpm_from_wheel | ||||
|             v.set("rpm", float(rpm)) | ||||
|  | ||||
|     # ----- Helpers ----- | ||||
|     def _rate_from_agr(self, target_c: float, agr: float) -> float: | ||||
|         """Engage/Release-Geschwindigkeit [1/s] in Abhängigkeit der Aggressivität.""" | ||||
|         # 0.05s (bissig) bis 0.5s (sanft) für ~63%-Annäherung | ||||
|         tau = 0.5 - 0.45 * agr | ||||
|         if target_c < 0.1:  # Öffnen etwas flotter | ||||
|             tau *= 0.7 | ||||
|         return 1.0 / max(0.05, tau) | ||||
|  | ||||
|     def _shape(self, x: float, curve: str) -> float: | ||||
|         x = max(0.0, min(1.0, x)) | ||||
|         if curve == "progressive": | ||||
|             return x * x | ||||
|         if curve == "soft": | ||||
|             return math.sqrt(x) | ||||
|         return x  # linear | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| # app/simulation/simulator.py | ||||
| from __future__ import annotations | ||||
| from dataclasses import dataclass, field | ||||
| from typing import Dict, Any, List, Optional, Tuple, Type | ||||
| from typing import Dict, Any, List, Optional | ||||
| import importlib, pkgutil, inspect, pathlib | ||||
|  | ||||
| # ---------------------- Core: Vehicle + Accumulator-API ---------------------- | ||||
| @@ -11,17 +11,11 @@ class Vehicle: | ||||
|     """ | ||||
|     State-/Config-Container + Dashboard-Registry + generische Frame-Akkumulatoren. | ||||
|  | ||||
|     Grundprinzip: | ||||
|       - set(key, value): harter Setzer (eine Quelle „besitzt“ den Wert) | ||||
|       - get/ensure: lesen/initialisieren | ||||
|       - push(key, delta, source): additiv beitragen (Source/Sink über Vorzeichen) | ||||
|       - acc_total(key): Summe aller Beiträge in diesem Frame | ||||
|       - acc_breakdown(key): Beiträge je Quelle (Debug/Transparenz) | ||||
|       - acc_reset(): zu Framebeginn alle Akkus löschen | ||||
|  | ||||
|     Konvention (Empfehlung, aber nicht erzwungen): | ||||
|       * Positive Beiträge „belasten“ (z. B. Widerstandsmoment, Laststrom) | ||||
|       * Negative Beiträge „speisen“ (z. B. Generator-Moment, Einspeisestrom) | ||||
|     - set/get/ensure: harte Zustandswerte | ||||
|     - push(key, delta, source): additiver Beitrag pro Frame (Source/Sink via Vorzeichen) | ||||
|     - acc_total(key): Summe aller Beiträge zu 'key' | ||||
|     - acc_breakdown(key): Beiträge je Quelle (Debug/Transparenz) | ||||
|     - acc_reset(): am Frame-Beginn alle Akkus löschen | ||||
|     """ | ||||
|     state: Dict[str, Any] = field(default_factory=dict) | ||||
|     config: Dict[str, Any] = field(default_factory=dict) | ||||
| @@ -29,7 +23,7 @@ class Vehicle: | ||||
|  | ||||
|     dashboard_specs: Dict[str, Dict[str, Any]] = field(default_factory=dict) | ||||
|  | ||||
|     # Accumulatoren: key -> {source_name: float} | ||||
|     # Accumulator: key -> {source_name: float} | ||||
|     _acc: Dict[str, Dict[str, float]] = field(default_factory=dict) | ||||
|  | ||||
|     # ---- state helpers ---- | ||||
| @@ -73,80 +67,37 @@ class Vehicle: | ||||
|     def snapshot(self) -> Dict[str, Any]: | ||||
|         return dict(self.state) | ||||
|  | ||||
|     # ---- generic accumulators (per-frame) ---- | ||||
|     # ---- generic accumulators (per frame) ---- | ||||
|     def acc_reset(self) -> None: | ||||
|         self._acc.clear() | ||||
|  | ||||
|     def push(self, key: str, delta: float, source: Optional[str] = None) -> None: | ||||
|         """ | ||||
|         Additiver Beitrag zu einer Größe. | ||||
|         Vorzeichen: + belastet / - speist (Empfehlung). | ||||
|         """ | ||||
|         src = source or "anon" | ||||
|         bucket = self._acc.setdefault(key, {}) | ||||
|         bucket[src] = bucket.get(src, 0.0) + float(delta) | ||||
|  | ||||
|     def acc_total(self, key: str) -> float: | ||||
|         bucket = self._acc.get(key) | ||||
|         if not bucket: return 0.0 | ||||
|         return sum(bucket.values()) | ||||
|         return 0.0 if not bucket else sum(bucket.values()) | ||||
|  | ||||
|     def acc_breakdown(self, key: str) -> Dict[str, float]: | ||||
|         return dict(self._acc.get(key, {})) | ||||
|  | ||||
|     # ---- Backwards-compat convenience for your current Basic code ---- | ||||
|     def elec_reset_frame(self) -> None: | ||||
|         # map legacy helpers auf generisches System | ||||
|         # loads + sources werden in einem Kanal gesammelt | ||||
|         # (loads positiv, sources negativ) | ||||
|         # Diese Methode ist mittlerweile redundant, acc_reset() macht alles. | ||||
|         pass | ||||
|  | ||||
|     def elec_add_load(self, name: str, amps: float) -> None: | ||||
|         self.push("elec.current", +max(0.0, float(amps)), source=name) | ||||
|  | ||||
|     def elec_add_source(self, name: str, amps: float) -> None: | ||||
|         self.push("elec.current", -max(0.0, float(amps)), source=name) | ||||
|  | ||||
|     def elec_totals(self) -> Tuple[float, float]: | ||||
|         """ | ||||
|         Gibt (loads_a_positiv, sources_a_positiv) zurück. | ||||
|         Intern liegt alles algebraisch in 'elec.current'. | ||||
|         """ | ||||
|         bd = self.acc_breakdown("elec.current") | ||||
|         loads = sum(v for v in bd.values() if v > 0) | ||||
|         sources = sum(-v for v in bd.values() if v < 0) | ||||
|         return (loads, sources) | ||||
|  | ||||
| # ---------------------------- Module Base + Loader ---------------------------- | ||||
|  | ||||
| class Module: | ||||
|     """ | ||||
|     Basisklasse für alle Module. Jedes Modul: | ||||
|       - deklariert PRIO (klein = früher) | ||||
|       - hat NAME (für Debug/Registry) | ||||
|       - implementiert apply(v, dt) | ||||
|     """ | ||||
|     PRIO: int = 100 | ||||
|     NAME: str = "module" | ||||
|  | ||||
|     def apply(self, v: Vehicle, dt: float) -> None: | ||||
|         raise NotImplementedError | ||||
|  | ||||
| def _discover_modules(pkg_name: str = "app.simulation.modules") -> List[Module]: | ||||
|     """ | ||||
|     Sucht in app/simulation/modules nach Klassen, die Module erben, | ||||
|     instanziert sie und sortiert nach PRIO. | ||||
|     """ | ||||
|     mods: List[Module] = [] | ||||
|     try: | ||||
|         pkg = importlib.import_module(pkg_name) | ||||
|     except Exception as exc: | ||||
|         raise RuntimeError(f"Module package '{pkg_name}' konnte nicht geladen werden: {exc}") | ||||
|  | ||||
|     pkg = importlib.import_module(pkg_name) | ||||
|     pkg_path = pathlib.Path(pkg.__file__).parent | ||||
|     for _, modname, ispkg in pkgutil.iter_modules([str(pkg_path)]): | ||||
|         if ispkg:  # optional: auch Subpackages zulassen | ||||
|         if ispkg:  | ||||
|             continue | ||||
|         full_name = f"{pkg_name}.{modname}" | ||||
|         try: | ||||
| @@ -154,60 +105,79 @@ def _discover_modules(pkg_name: str = "app.simulation.modules") -> List[Module]: | ||||
|         except Exception as exc: | ||||
|             print(f"[loader] Fehler beim Import {full_name}: {exc}") | ||||
|             continue | ||||
|  | ||||
|         for _, obj in inspect.getmembers(m, inspect.isclass): | ||||
|             if not issubclass(obj, Module):  | ||||
|                 continue | ||||
|             if obj is Module:  | ||||
|             if obj is Module or not issubclass(obj, Module): | ||||
|                 continue | ||||
|             try: | ||||
|                 inst = obj()  # Module ohne args | ||||
|                 inst = obj() | ||||
|             except Exception as exc: | ||||
|                 print(f"[loader] Kann {obj.__name__} nicht instanziieren: {exc}") | ||||
|                 continue | ||||
|             mods.append(inst) | ||||
|  | ||||
|     # sortieren nach PRIO; bei Gleichstand NAME als Tie-Break | ||||
|     mods.sort(key=lambda x: (getattr(x, "PRIO", 100), getattr(x, "NAME", x.__class__.__name__))) | ||||
|     return mods | ||||
|  | ||||
| # ------------------------------- Simulator API -------------------------------- | ||||
|  | ||||
| class VehicleSimulator: | ||||
|     """ | ||||
|     Öffentliche Fassade für GUI/Tests. | ||||
|     Lädt Module dynamisch, führt sie pro Tick in PRIO-Reihenfolge aus. | ||||
|     """ | ||||
|     """Lädt Module dynamisch, führt sie pro Tick in PRIO-Reihenfolge aus.""" | ||||
|     def __init__(self, modules_package: str = "app.simulation.modules"): | ||||
|         self.v = Vehicle() | ||||
|         self.modules: List[Module] = _discover_modules(modules_package) | ||||
|  | ||||
|         self.module_defaults: Dict[str, Dict[str, Any]] = {} | ||||
|         for m in self.modules: | ||||
|             ns = getattr(m, "NAME", "").lower() or m.__class__.__name__.lower() | ||||
|             mod = importlib.import_module(m.__class__.__module__) | ||||
|             # Konvention: UPPER(NAME) + _DEFAULTS | ||||
|             key = f"{ns.upper()}_DEFAULTS" | ||||
|             defaults = getattr(mod, key, None) | ||||
|             if isinstance(defaults, dict): | ||||
|                 self.module_defaults[ns] = dict(defaults) | ||||
|  | ||||
|     def update(self, dt: float) -> None: | ||||
|         # pro Frame alle Akkumulatoren leeren | ||||
|         self.v.acc_reset() | ||||
|         self.v.acc_reset()  # pro Frame Akkus leeren | ||||
|         for m in self.modules: | ||||
|             try: | ||||
|                 m.apply(self.v, dt) | ||||
|             except Exception as exc: | ||||
|                 print(f"[sim] Modul {getattr(m, 'NAME', m.__class__.__name__)} Fehler: {exc}") | ||||
|  | ||||
|     # Kompatible Hilfsfunktionen für GUI | ||||
|     def snapshot(self) -> Dict[str, Any]: | ||||
|         return self.v.snapshot() | ||||
|  | ||||
|     def load_config(self, cfg: Dict[str, Any]) -> None: | ||||
|         # Namespaced-Merge; Keys bleiben modul-spezifisch | ||||
|         for k, sub in cfg.items(): | ||||
|             self.v.config.setdefault(k, {}).update(sub if isinstance(sub, dict) else {}) | ||||
|         if "dtc" in cfg: | ||||
|             self.v.dtc.update(cfg["dtc"]) | ||||
|  | ||||
|     def export_config(self) -> Dict[str, Any]: | ||||
|         return {ns: dict(data) for ns, data in self.v.config.items()} | {"dtc": dict(self.v.dtc)} | ||||
|         """ | ||||
|         Exportiert einen *vollständigen* Snapshot: | ||||
|         - Modul-Defaults + Overrides (so fehlen keine Keys) | ||||
|         - alle übrigen Namespaces unverändert | ||||
|         - DTC separat | ||||
|         """ | ||||
|         out: Dict[str, Any] = {} | ||||
|  | ||||
|     # für alte GUI-Knöpfe | ||||
|         # 1) Modul-Namespaces: Defaults + Overrides mergen | ||||
|         for ns, defs in self.module_defaults.items(): | ||||
|             merged = dict(defs) | ||||
|             merged.update(self.v.config.get(ns, {})) | ||||
|             out[ns] = merged | ||||
|  | ||||
|         # 2) übrige Namespaces (ohne bekannte Modul-Defaults) 1:1 übernehmen | ||||
|         for ns, data in self.v.config.items(): | ||||
|             if ns not in out: | ||||
|                 out[ns] = dict(data) | ||||
|  | ||||
|         # 3) DTC anhängen | ||||
|         out["dtc"] = dict(self.v.dtc) | ||||
|         return out | ||||
|  | ||||
|     # Falls noch benutzt: | ||||
|     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))))  # falls noch genutzt | ||||
|         self.v.set("throttle_pct", max(0, min(100, int(t)))) | ||||
|   | ||||
| @@ -6,194 +6,204 @@ from __future__ import annotations | ||||
| import tkinter as tk | ||||
| from tkinter import ttk | ||||
| from typing import Dict, Any | ||||
| from app.simulation.ui import UITab  | ||||
| from app.simulation.ui import UITab | ||||
|  | ||||
| class BasicTab(UITab): | ||||
|     NAME  = "basic" | ||||
|     TITLE = "Basisdaten" | ||||
|     PRIO  = 10 | ||||
|     """Basis-Fahrzeug-Tab (Zündung & Elektrik).""" | ||||
|  | ||||
|     def __init__(self, parent, sim): | ||||
|         self.sim = sim | ||||
|         self.frame = ttk.Frame(parent, padding=8) | ||||
|         self.frame.columnconfigure(1, weight=1) | ||||
|         for c in (0,1,2,3): self.frame.columnconfigure(c, weight=1) | ||||
|  | ||||
|         row = 0 | ||||
|         # Vehicle basics ----------------------------------------------------------- | ||||
|         ttk.Label(self.frame, text="Fahrzeugtyp").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.type_var = tk.StringVar(value=self.sim.v.config.get("vehicle", {}).get("type", "motorcycle")) | ||||
|         ttk.Combobox(self.frame, textvariable=self.type_var, state="readonly", | ||||
|                      values=["motorcycle", "car", "truck"], width=16)\ | ||||
|             .grid(row=row-1, column=1, sticky="w") | ||||
|         # ---------- Linke Spalte ---------- | ||||
|         rowL = 0 | ||||
|         def L(lbl, var=None, w=12, kind="entry", values=None): | ||||
|             nonlocal rowL | ||||
|             ttk.Label(self.frame, text=lbl).grid(row=rowL, column=0, sticky="w") | ||||
|             if kind == "entry": | ||||
|                 ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowL, column=1, sticky="w") | ||||
|             elif kind == "label": | ||||
|                 ttk.Label(self.frame, textvariable=var).grid(row=rowL, column=1, sticky="w") | ||||
|             elif kind == "combo": | ||||
|                 ttk.Combobox(self.frame, textvariable=var, state="readonly", values=values or [], width=w)\ | ||||
|                     .grid(row=rowL, column=1, sticky="w") | ||||
|             elif kind == "check": | ||||
|                 ttk.Checkbutton(self.frame, variable=var).grid(row=rowL, column=1, sticky="w") | ||||
|             elif kind == "radio": | ||||
|                 f = ttk.Frame(self.frame); f.grid(row=rowL, column=1, sticky="w") | ||||
|                 for i,(t,vv) in enumerate(values or []): | ||||
|                     ttk.Radiobutton(f, text=t, value=vv, variable=var, command=self._apply_ign)\ | ||||
|                         .grid(row=0, column=i, padx=(0,6)) | ||||
|             rowL += 1 | ||||
|  | ||||
|         ttk.Label(self.frame, text="Gewicht [kg]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.mass_var = tk.DoubleVar(value=float(self.sim.v.config.get("vehicle", {}).get("mass_kg", 210.0))) | ||||
|         ttk.Entry(self.frame, textvariable=self.mass_var, width=10).grid(row=row-1, column=1, sticky="w") | ||||
|         # Vehicle | ||||
|         self.type = tk.StringVar();   L("Fahrzeugtyp", self.type, kind="combo", values=["motorcycle","car","truck"]) | ||||
|         self.mass = tk.DoubleVar();   L("Gewicht [kg]", self.mass) | ||||
|         self.abs  = tk.BooleanVar();  L("ABS vorhanden", self.abs, kind="check") | ||||
|         self.tcs  = tk.BooleanVar();  L("ASR/Traktionskontrolle", self.tcs, kind="check") | ||||
|  | ||||
|         self.abs_var = tk.BooleanVar(value=bool(self.sim.v.config.get("vehicle", {}).get("abs", True))) | ||||
|         ttk.Checkbutton(self.frame, text="ABS vorhanden", variable=self.abs_var)\ | ||||
|             .grid(row=row, column=0, columnspan=2, sticky="w"); row+=1 | ||||
|         ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1 | ||||
|  | ||||
|         self.tcs_var = tk.BooleanVar(value=bool(self.sim.v.config.get("vehicle", {}).get("tcs", False))) | ||||
|         ttk.Checkbutton(self.frame, text="ASR/Traktionskontrolle", variable=self.tcs_var)\ | ||||
|             .grid(row=row, column=0, columnspan=2, sticky="w"); row+=1 | ||||
|         # Environment / Ignition | ||||
|         self.amb  = tk.DoubleVar();   L("Umgebung [°C]", self.amb) | ||||
|         self.ign  = tk.StringVar();   L("Zündung", self.ign, kind="radio", | ||||
|                                         values=[("OFF","OFF"),("ACC","ACC"),("ON","ON"),("START","START")]) | ||||
|  | ||||
|         ttk.Separator(self.frame).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(6,6)); row+=1 | ||||
|         ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1 | ||||
|  | ||||
|         # Ambient ----------------------------------------------------------------- | ||||
|         ttk.Label(self.frame, text="Umgebung [°C]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.ambient_var = tk.DoubleVar(value=float(self.sim.snapshot().get("ambient_c", 20.0))) | ||||
|         ttk.Entry(self.frame, textvariable=self.ambient_var, width=10)\ | ||||
|             .grid(row=row-1, column=1, sticky="w") | ||||
|         # Live links (Labels) | ||||
|         self.batt_v = tk.StringVar(); L("Batterie [V]", self.batt_v, kind="label") | ||||
|         self.elx_v  = tk.StringVar(); L("ELX/Bus [V]", self.elx_v, kind="label") | ||||
|         self.soc    = tk.StringVar(); L("SOC [0..1]",  self.soc,   kind="label") | ||||
|  | ||||
|         # Ignition ---------------------------------------------------------------- | ||||
|         ttk.Label(self.frame, text="Zündung").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.ign_var = tk.StringVar(value=str(self.sim.snapshot().get("ignition", "ON"))) | ||||
|         ign_frame = ttk.Frame(self.frame); ign_frame.grid(row=row-1, column=1, sticky="w") | ||||
|         for i, state in enumerate(["OFF", "ACC", "ON", "START"]): | ||||
|             ttk.Radiobutton(ign_frame, text=state, value=state, | ||||
|                             variable=self.ign_var, command=self._apply_ign)\ | ||||
|                 .grid(row=0, column=i, padx=(0,6)) | ||||
|         # ---------- Rechte Spalte ---------- | ||||
|         rowR = 0 | ||||
|         def R(lbl, var=None, w=12, kind="entry"): | ||||
|             nonlocal rowR | ||||
|             ttk.Label(self.frame, text=lbl).grid(row=rowR, column=2, sticky="w") | ||||
|             if kind == "entry": | ||||
|                 ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowR, column=3, sticky="w") | ||||
|             elif kind == "label": | ||||
|                 ttk.Label(self.frame, textvariable=var).grid(row=rowR, column=3, sticky="w") | ||||
|             rowR += 1 | ||||
|  | ||||
|         # Live Electrical ---------------------------------------------------------- | ||||
|         ttk.Label(self.frame, text="Batterie [V]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.batt_v_var = tk.StringVar(value=f"{self.sim.snapshot().get('battery_voltage', 12.6):.2f}") | ||||
|         ttk.Label(self.frame, textvariable=self.batt_v_var).grid(row=row-1, column=1, sticky="w") | ||||
|         # Live rechts (Labels) | ||||
|         self.ibatt   = tk.StringVar(); R("I Batterie [A] (+entlädt)", self.ibatt, kind="label") | ||||
|         self.ialt    = tk.StringVar(); R("I Lima [A]",                self.ialt,  kind="label") | ||||
|         self.load_elx= tk.StringVar(); R("Last ELX [A]",              self.load_elx, kind="label") | ||||
|         self.load_bat= tk.StringVar(); R("Last Batterie [A]",         self.load_bat, kind="label") | ||||
|         self.load_tot= tk.StringVar(); R("Last gesamt [A]",           self.load_tot, kind="label") | ||||
|  | ||||
|         ttk.Label(self.frame, text="ELX/Bus [V]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.elx_v_var = tk.StringVar(value=f"{self.sim.snapshot().get('elx_voltage', 0.0):.2f}") | ||||
|         ttk.Label(self.frame, textvariable=self.elx_v_var).grid(row=row-1, column=1, sticky="w") | ||||
|         ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1 | ||||
|  | ||||
|         ttk.Label(self.frame, text="SOC [0..1]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.soc_var = tk.StringVar(value=f"{self.sim.snapshot().get('battery_soc', 0.8):.2f}") | ||||
|         ttk.Label(self.frame, textvariable=self.soc_var).grid(row=row-1, column=1, sticky="w") | ||||
|         # Electrical config | ||||
|         self.bcap   = tk.DoubleVar(); R("Batt Kap. [Ah]",                self.bcap) | ||||
|         self.brint  = tk.DoubleVar(); R("Batt R_int [Ω]",                self.brint) | ||||
|         self.alt_v  = tk.DoubleVar(); R("Reglerspannung [V]",            self.alt_v) | ||||
|         self.alt_a  = tk.DoubleVar(); R("Lima Nennstrom [A]",            self.alt_a) | ||||
|         self.alt_ci = tk.IntVar();    R("Cut-In RPM",                    self.alt_ci) | ||||
|         self.alt_fc = tk.IntVar();    R("Full-Cap RPM",                  self.alt_fc) | ||||
|         self.alt_eta= tk.DoubleVar(); R("Lima η_mech [-]",               self.alt_eta) | ||||
|         self.alt_rat= tk.DoubleVar(); R("Lima Übersetzung [-]",          self.alt_rat) | ||||
|         self.alt_d0 = tk.DoubleVar(); R("Lima Drag Grund [Nm]",          self.alt_d0) | ||||
|         self.alt_d1 = tk.DoubleVar(); R("Lima Drag /krpm [Nm]",          self.alt_d1) | ||||
|  | ||||
|         ttk.Label(self.frame, text="I Batterie [A] (+entlädt)").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.ibatt_var = tk.StringVar(value=f"{self.sim.snapshot().get('battery_current_a', 0.0):.2f}") | ||||
|         ttk.Label(self.frame, textvariable=self.ibatt_var).grid(row=row-1, column=1, sticky="w") | ||||
|         # ---------- Buttons ---------- | ||||
|         rowBtns = max(rowL, rowR) + 1 | ||||
|         btnrow = ttk.Frame(self.frame); btnrow.grid(row=rowBtns, column=0, columnspan=4, sticky="w", pady=(8,0)) | ||||
|         ttk.Button(btnrow, text="Aktualisieren", command=self.refresh).pack(side="left") | ||||
|         ttk.Button(btnrow, text="Anwenden",     command=self.apply).pack(side="left", padx=(8,0)) | ||||
|  | ||||
|         ttk.Label(self.frame, text="I Lima [A]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.ialt_var = tk.StringVar(value=f"{self.sim.snapshot().get('alternator_current_a', 0.0):.2f}") | ||||
|         ttk.Label(self.frame, textvariable=self.ialt_var).grid(row=row-1, column=1, sticky="w") | ||||
|         self.refresh() | ||||
|  | ||||
|         ttk.Label(self.frame, text="Last gesamt [A]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.load_var = tk.StringVar(value=f"{self.sim.snapshot().get('elec_load_total_a', 0.0):.2f}") | ||||
|         ttk.Label(self.frame, textvariable=self.load_var).grid(row=row-1, column=1, sticky="w") | ||||
|  | ||||
|         ttk.Separator(self.frame).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(6,6)); row+=1 | ||||
|  | ||||
|         # Electrical config -------------------------------------------------------- | ||||
|         econf = self.sim.v.config.get("electrical", {}) | ||||
|         ttk.Label(self.frame, text="Batt Kap. [Ah]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.bcap = tk.DoubleVar(value=float(econf.get("battery_capacity_ah", 8.0))) | ||||
|         ttk.Entry(self.frame, textvariable=self.bcap, width=10).grid(row=row-1, column=1, sticky="w") | ||||
|  | ||||
|         ttk.Label(self.frame, text="Batt R_int [Ω]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.brint = tk.DoubleVar(value=float(econf.get("battery_r_int_ohm", 0.020))) | ||||
|         ttk.Entry(self.frame, textvariable=self.brint, width=10).grid(row=row-1, column=1, sticky="w") | ||||
|  | ||||
|         ttk.Label(self.frame, text="Reglerspannung [V]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.alt_v = tk.DoubleVar(value=float(econf.get("alternator_reg_v", 14.2))) | ||||
|         ttk.Entry(self.frame, textvariable=self.alt_v, width=10).grid(row=row-1, column=1, sticky="w") | ||||
|  | ||||
|         ttk.Label(self.frame, text="Lima Nennstrom [A]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.alt_a = tk.DoubleVar(value=float(econf.get("alternator_rated_a", 20.0))) | ||||
|         ttk.Entry(self.frame, textvariable=self.alt_a, width=10).grid(row=row-1, column=1, sticky="w") | ||||
|  | ||||
|         ttk.Label(self.frame, text="Cut-In RPM").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.alt_cutin = tk.IntVar(value=int(econf.get("alt_cut_in_rpm", 1500))) | ||||
|         ttk.Entry(self.frame, textvariable=self.alt_cutin, width=10).grid(row=row-1, column=1, sticky="w") | ||||
|  | ||||
|         ttk.Label(self.frame, text="Full-Cap RPM").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.alt_full = tk.IntVar(value=int(econf.get("alt_full_rpm", 4000))) | ||||
|         ttk.Entry(self.frame, textvariable=self.alt_full, width=10).grid(row=row-1, column=1, sticky="w") | ||||
|  | ||||
|         # Apply -------------------------------------------------------------------- | ||||
|         ttk.Button(self.frame, text="Anwenden", command=self.apply)\ | ||||
|             .grid(row=row, column=0, pady=(8,0), sticky="w") | ||||
|  | ||||
|         # periodic UI refresh | ||||
|         self._tick() | ||||
|  | ||||
|     def _tick(self): | ||||
|     # ------------ Logic ------------ | ||||
|     def refresh(self): | ||||
|         snap = self.sim.snapshot() | ||||
|         # Live-Werte | ||||
|         self.batt_v_var.set(f"{snap.get('battery_voltage', 0):.2f}") | ||||
|         self.elx_v_var.set(f"{snap.get('elx_voltage', 0):.2f}") | ||||
|         self.soc_var.set(f"{snap.get('battery_soc', 0.0):.2f}") | ||||
|         self.ibatt_var.set(f"{snap.get('battery_current_a', 0.0):.2f}") | ||||
|         self.ialt_var.set(f"{snap.get('alternator_current_a', 0.0):.2f}") | ||||
|         self.load_var.set(f"{snap.get('elec_load_total_a', 0.0):.2f}") | ||||
|         vcfg = dict(self.sim.v.config.get("vehicle", {})) | ||||
|         ecfg = dict(self.sim.v.config.get("electrical", {})) | ||||
|  | ||||
|         # START→ON aus dem Modul spiegeln | ||||
|         curr_ign = snap.get("ignition") | ||||
|         if curr_ign and curr_ign != self.ign_var.get(): | ||||
|             self.ign_var.set(curr_ign) | ||||
|         # Vehicle | ||||
|         self.type.set(vcfg.get("type", "motorcycle")) | ||||
|         self.mass.set(float(vcfg.get("mass_kg", 210.0))) | ||||
|         self.abs.set(bool(vcfg.get("abs", True))) | ||||
|         self.tcs.set(bool(vcfg.get("tcs", False))) | ||||
|  | ||||
|         try: | ||||
|             self.frame.after(200, self._tick) | ||||
|         except tk.TclError: | ||||
|             pass | ||||
|         # Env / Ign | ||||
|         self.amb.set(float(snap.get("ambient_c", 20.0))) | ||||
|         self.ign.set(str(snap.get("ignition", "ON"))) | ||||
|  | ||||
|         # Live left | ||||
|         self.batt_v.set(f"{float(snap.get('battery_voltage', 12.6)):.2f}") | ||||
|         self.elx_v.set(f"{float(snap.get('elx_voltage', 0.0)):.2f}") | ||||
|         self.soc.set(f"{float(snap.get('battery_soc', 0.80)):.2f}") | ||||
|  | ||||
|         # Live right | ||||
|         self.ibatt.set(f"{float(snap.get('battery_current_a', 0.0)):.2f}") | ||||
|         self.ialt.set(f"{float(snap.get('alternator_current_a', 0.0)):.2f}") | ||||
|         self.load_elx.set(f"{float(snap.get('elec_load_elx_a', 0.0)):.2f}") | ||||
|         self.load_bat.set(f"{float(snap.get('elec_load_batt_a', 0.0)):.2f}") | ||||
|         self.load_tot.set(f"{float(snap.get('elec_load_total_a', 0.0)):.2f}") | ||||
|  | ||||
|         # Electrical config | ||||
|         self.bcap.set(float(ecfg.get("battery_capacity_ah", 8.0))) | ||||
|         self.brint.set(float(ecfg.get("battery_r_int_ohm", 0.020))) | ||||
|         self.alt_v.set(float(ecfg.get("alternator_reg_v", 14.2))) | ||||
|         self.alt_a.set(float(ecfg.get("alternator_rated_a", 20.0))) | ||||
|         self.alt_ci.set(int(ecfg.get("alt_cut_in_rpm", 1500))) | ||||
|         self.alt_fc.set(int(ecfg.get("alt_full_rpm", 4000))) | ||||
|         self.alt_eta.set(float(ecfg.get("alternator_mech_efficiency", 0.55))) | ||||
|         self.alt_rat.set(float(ecfg.get("alternator_pulley_ratio", 1.0))) | ||||
|         self.alt_d0.set(float(ecfg.get("alternator_drag_nm_idle", 0.15))) | ||||
|         self.alt_d1.set(float(ecfg.get("alternator_drag_nm_per_krpm", 0.05))) | ||||
|  | ||||
|     def _apply_ign(self): | ||||
|         # Zündung live setzen | ||||
|         self.sim.v.set("ignition", self.ign_var.get()) | ||||
|         self.sim.v.set("ignition", self.ign.get()) | ||||
|  | ||||
|     def apply(self): | ||||
|         # Ambient in State (wirkt sofort auf Thermik, andere Module lesen das) | ||||
|         try: | ||||
|             self.sim.v.set("ambient_c", float(self.ambient_var.get())) | ||||
|         except Exception: | ||||
|             pass | ||||
|         # Umgebung sofort in den State (wirkt auf Thermik) | ||||
|         try: self.sim.v.set("ambient_c", float(self.amb.get())) | ||||
|         except: pass | ||||
|  | ||||
|         cfg = { | ||||
|             "vehicle": { | ||||
|                 "type": self.type_var.get(), | ||||
|                 "mass_kg": float(self.mass_var.get()), | ||||
|                 "abs": bool(self.abs_var.get()), | ||||
|                 "tcs": bool(self.tcs_var.get()), | ||||
|                 "type": self.type.get(), | ||||
|                 "mass_kg": float(self.mass.get()), | ||||
|                 "abs": bool(self.abs.get()), | ||||
|                 "tcs": bool(self.tcs.get()), | ||||
|             }, | ||||
|             "electrical": { | ||||
|                 "battery_capacity_ah": float(self.bcap.get()), | ||||
|                 "battery_r_int_ohm": float(self.brint.get()), | ||||
|                 "alternator_reg_v": float(self.alt_v.get()), | ||||
|                 "alternator_rated_a": float(self.alt_a.get()), | ||||
|                 "alt_cut_in_rpm": int(self.alt_cutin.get()), | ||||
|                 "alt_full_rpm": int(self.alt_full.get()), | ||||
|                 "alt_cut_in_rpm": int(self.alt_ci.get()), | ||||
|                 "alt_full_rpm": int(self.alt_fc.get()), | ||||
|                 "alternator_mech_efficiency": float(self.alt_eta.get()), | ||||
|                 "alternator_pulley_ratio": float(self.alt_rat.get()), | ||||
|                 "alternator_drag_nm_idle": float(self.alt_d0.get()), | ||||
|                 "alternator_drag_nm_per_krpm": float(self.alt_d1.get()), | ||||
|             } | ||||
|         } | ||||
|         self.sim.load_config(cfg) | ||||
|  | ||||
|     # Save/Load Hooks für Gesamt-Export | ||||
|     def save_into_config(self, out: Dict[str, Any]) -> None: | ||||
|         out.setdefault("vehicle", {}) | ||||
|         out["vehicle"].update({ | ||||
|             "type": self.type_var.get(), | ||||
|             "mass_kg": float(self.mass_var.get()), | ||||
|             "abs": bool(self.abs_var.get()), | ||||
|             "tcs": bool(self.tcs_var.get()), | ||||
|         out.setdefault("vehicle", {}).update({ | ||||
|             "type": self.type.get(), | ||||
|             "mass_kg": float(self.mass.get()), | ||||
|             "abs": bool(self.abs.get()), | ||||
|             "tcs": bool(self.tcs.get()), | ||||
|         }) | ||||
|         out.setdefault("electrical", {}) | ||||
|         out["electrical"].update({ | ||||
|         out.setdefault("electrical", {}).update({ | ||||
|             "battery_capacity_ah": float(self.bcap.get()), | ||||
|             "battery_r_int_ohm": float(self.brint.get()), | ||||
|             "alternator_reg_v": float(self.alt_v.get()), | ||||
|             "alternator_rated_a": float(self.alt_a.get()), | ||||
|             "alt_cut_in_rpm": int(self.alt_cutin.get()), | ||||
|             "alt_full_rpm": int(self.alt_full.get()), | ||||
|             "alt_cut_in_rpm": int(self.alt_ci.get()), | ||||
|             "alt_full_rpm": int(self.alt_fc.get()), | ||||
|             "alternator_mech_efficiency": float(self.alt_eta.get()), | ||||
|             "alternator_pulley_ratio": float(self.alt_rat.get()), | ||||
|             "alternator_drag_nm_idle": float(self.alt_d0.get()), | ||||
|             "alternator_drag_nm_per_krpm": float(self.alt_d1.get()), | ||||
|         }) | ||||
|  | ||||
|     def load_from_config(self, cfg: Dict[str, Any]) -> None: | ||||
|         vcfg = cfg.get("vehicle", {}) | ||||
|         self.type_var.set(vcfg.get("type", self.type_var.get())) | ||||
|         self.mass_var.set(vcfg.get("mass_kg", self.mass_var.get())) | ||||
|         self.abs_var.set(vcfg.get("abs", self.abs_var.get())) | ||||
|         self.tcs_var.set(vcfg.get("tcs", self.tcs_var.get())) | ||||
|         ecfg = cfg.get("electrical", {}) | ||||
|         vcfg = cfg.get("vehicle", {}); ecfg = cfg.get("electrical", {}) | ||||
|         self.type.set(vcfg.get("type", self.type.get())) | ||||
|         self.mass.set(vcfg.get("mass_kg", self.mass.get())) | ||||
|         self.abs.set(vcfg.get("abs", self.abs.get())) | ||||
|         self.tcs.set(vcfg.get("tcs", self.tcs.get())) | ||||
|         self.bcap.set(ecfg.get("battery_capacity_ah", self.bcap.get())) | ||||
|         self.brint.set(ecfg.get("battery_r_int_ohm", self.brint.get())) | ||||
|         self.alt_v.set(ecfg.get("alternator_reg_v", self.alt_v.get())) | ||||
|         self.alt_a.set(ecfg.get("alternator_rated_a", self.alt_a.get())) | ||||
|         self.alt_cutin.set(ecfg.get("alt_cut_in_rpm", self.alt_cutin.get())) | ||||
|         self.alt_full.set(ecfg.get("alt_full_rpm", self.alt_full.get())) | ||||
|         # wichtig: NICHT self.sim.load_config(cfg) hier! | ||||
|         self.alt_ci.set(ecfg.get("alt_cut_in_rpm", self.alt_ci.get())) | ||||
|         self.alt_fc.set(ecfg.get("alt_full_rpm", self.alt_fc.get())) | ||||
|         self.alt_eta.set(ecfg.get("alternator_mech_efficiency", self.alt_eta.get())) | ||||
|         self.alt_rat.set(ecfg.get("alternator_pulley_ratio", self.alt_rat.get())) | ||||
|         self.alt_d0.set(ecfg.get("alternator_drag_nm_idle", self.alt_d0.get())) | ||||
|         self.alt_d1.set(ecfg.get("alternator_drag_nm_per_krpm", self.alt_d1.get())) | ||||
|         # wichtig: hier KEIN sim.load_config() | ||||
|   | ||||
							
								
								
									
										149
									
								
								app/simulation/ui/cooling.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								app/simulation/ui/cooling.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,149 @@ | ||||
| # ============================= | ||||
| # app/simulation/ui/cooling.py | ||||
| # ============================= | ||||
|  | ||||
| from __future__ import annotations | ||||
| import tkinter as tk | ||||
| from tkinter import ttk | ||||
| from app.simulation.modules.cooling import COOLING_DEFAULTS | ||||
| from app.simulation.ui import UITab | ||||
|  | ||||
| class CoolingTab(UITab): | ||||
|     NAME  = "cooling" | ||||
|     TITLE = "Kühlung" | ||||
|     PRIO  = 11 | ||||
|  | ||||
|     def __init__(self, parent, sim): | ||||
|         self.sim = sim | ||||
|         self.frame = ttk.Frame(parent, padding=8) | ||||
|  | ||||
|         for c in (0,1,2,3): | ||||
|             self.frame.columnconfigure(c, weight=1) | ||||
|  | ||||
|         # ---------- Linke Spalte ---------- | ||||
|         rowL = 0 | ||||
|         def L(lbl, var, w=12, kind="entry", values=None): | ||||
|             nonlocal rowL | ||||
|             ttk.Label(self.frame, text=lbl).grid(row=rowL, column=0, sticky="w") | ||||
|             if kind == "entry": | ||||
|                 ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowL, column=1, sticky="w") | ||||
|             elif kind == "combo": | ||||
|                 cb = ttk.Combobox(self.frame, textvariable=var, state="readonly", values=values or []) | ||||
|                 cb.grid(row=rowL, column=1, sticky="w") | ||||
|             elif kind == "check": | ||||
|                 ttk.Checkbutton(self.frame, variable=var).grid(row=rowL, column=1, sticky="w") | ||||
|             rowL += 1 | ||||
|  | ||||
|         self.t_open   = tk.DoubleVar();  L("Thermostat öffnet ab [°C]", self.t_open) | ||||
|         self.t_full   = tk.DoubleVar();  L("Thermostat voll offen [°C]", self.t_full) | ||||
|         self.rad_base = tk.DoubleVar();  L("Radiator-Basis [W/K]", self.rad_base) | ||||
|         self.ram_gain = tk.DoubleVar();  L("Fahrtwind-Zuwachs [W/K pro km/h]", self.ram_gain) | ||||
|         self.amb_c    = tk.DoubleVar();  L("Umgebung [°C]", self.amb_c) | ||||
|         self.Cc       = tk.DoubleVar();  L("Wärmekapazität Kühlmittel [J/K]", self.Cc) | ||||
|         self.Coil     = tk.DoubleVar();  L("Wärmekapazität Öl [J/K]", self.Coil) | ||||
|  | ||||
|         ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1 | ||||
|  | ||||
|         # Versorgung & Nachlauf (links) | ||||
|         self.feed    = tk.StringVar() | ||||
|         self.afteren = tk.BooleanVar() | ||||
|         self.afterth = tk.DoubleVar() | ||||
|         self.aftermax= tk.DoubleVar() | ||||
|  | ||||
|         L("Lüfter-Versorgung", self.feed, kind="combo", values=["elx", "battery"]) | ||||
|         L("Nachlauf aktiv", self.afteren, kind="check") | ||||
|         L("Nachlauf-Schwelle [°C]", self.afterth) | ||||
|         L("Nachlauf max. Zeit [s]", self.aftermax) | ||||
|  | ||||
|         # ---------- Rechte Spalte ---------- | ||||
|         rowR = 0 | ||||
|         def R(lbl, var, w=12): | ||||
|             nonlocal rowR | ||||
|             ttk.Label(self.frame, text=lbl).grid(row=rowR, column=2, sticky="w") | ||||
|             ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowR, column=3, sticky="w") | ||||
|             rowR += 1 | ||||
|  | ||||
|         self.f1_on  = tk.DoubleVar(); R("Lüfter 1 EIN [°C]", self.f1_on) | ||||
|         self.f1_off = tk.DoubleVar(); R("Lüfter 1 AUS [°C]", self.f1_off) | ||||
|         self.f2_on  = tk.DoubleVar(); R("Lüfter 2 EIN [°C]", self.f2_on) | ||||
|         self.f2_off = tk.DoubleVar(); R("Lüfter 2 AUS [°C]", self.f2_off) | ||||
|  | ||||
|         ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1 | ||||
|  | ||||
|         self.f1_w   = tk.DoubleVar(); R("Lüfter 1 Leistung [W]", self.f1_w) | ||||
|         self.f2_w   = tk.DoubleVar(); R("Lüfter 2 Leistung [W]", self.f2_w) | ||||
|         self.f1_air = tk.DoubleVar(); R("Lüfter 1 Luftstrom [W/K]", self.f1_air) | ||||
|         self.f2_air = tk.DoubleVar(); R("Lüfter 2 Luftstrom [W/K]", self.f2_air) | ||||
|  | ||||
|         ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1 | ||||
|  | ||||
|         self.Uoc    = tk.DoubleVar(); R("Öl↔Kühlmittel Kopplung [W/K]", self.Uoc) | ||||
|         self.Uoil   = tk.DoubleVar(); R("Öl→Umgebung [W/K]", self.Uoil) | ||||
|         self.frac   = tk.DoubleVar(); R("Motorwärme→Kühlmittel [%]", self.frac) | ||||
|  | ||||
|         # ---------- Buttons ---------- | ||||
|         rowBtns = max(rowL, rowR) + 1 | ||||
|         btnrow = ttk.Frame(self.frame) | ||||
|         btnrow.grid(row=rowBtns, column=0, columnspan=4, sticky="w", pady=(8,0)) | ||||
|         ttk.Button(btnrow, text="Aktualisieren", command=self.refresh).pack(side="left") | ||||
|         ttk.Button(btnrow, text="Anwenden", command=self.apply).pack(side="left", padx=(8,0)) | ||||
|  | ||||
|         self.refresh() | ||||
|  | ||||
|     def refresh(self): | ||||
|         c = dict(COOLING_DEFAULTS) | ||||
|         c.update(self.sim.v.config.get("cooling", {})) | ||||
|  | ||||
|         # links | ||||
|         self.t_open.set(c["thermostat_open_c"]) | ||||
|         self.t_full.set(c["thermostat_full_c"]) | ||||
|         self.rad_base.set(c["rad_base_u_w_per_k"]) | ||||
|         self.ram_gain.set(c["ram_air_gain_per_kmh"]) | ||||
|         self.amb_c.set(self.sim.v.get("ambient_c", 20.0)) | ||||
|         self.Cc.set(c["coolant_thermal_cap_j_per_k"]) | ||||
|         self.Coil.set(c["oil_thermal_cap_j_per_k"]) | ||||
|  | ||||
|         # Versorgung & Nachlauf | ||||
|         self.feed.set(c.get("fan_power_feed", "elx")) | ||||
|         self.afteren.set(bool(c.get("fan_afterrun_enable", False))) | ||||
|         self.afterth.set(float(c.get("fan_afterrun_threshold_c", 105.0))) | ||||
|         self.aftermax.set(float(c.get("fan_afterrun_max_s", 300.0))) | ||||
|  | ||||
|         # rechts | ||||
|         self.f1_on.set(c["fan1_on_c"]);   self.f1_off.set(c["fan1_off_c"]) | ||||
|         self.f2_on.set(c["fan2_on_c"]);   self.f2_off.set(c["fan2_off_c"]) | ||||
|         self.f1_w.set(c["fan1_power_w"]); self.f2_w.set(c["fan2_power_w"]) | ||||
|         self.f1_air.set(c["fan1_airflow_gain"]); self.f2_air.set(c["fan2_airflow_gain"]) | ||||
|  | ||||
|         self.Uoc.set(c["oil_coolant_u_w_per_k"]) | ||||
|         self.Uoil.set(c["oil_to_amb_u_w_per_k"]) | ||||
|         self.frac.set(c["engine_heat_frac_to_coolant"]*100.0) | ||||
|  | ||||
|     def apply(self): | ||||
|         cfg = {"cooling": { | ||||
|             # links | ||||
|             "thermostat_open_c": float(self.t_open.get()), | ||||
|             "thermostat_full_c": float(self.t_full.get()), | ||||
|             "rad_base_u_w_per_k": float(self.rad_base.get()), | ||||
|             "ram_air_gain_per_kmh": float(self.ram_gain.get()), | ||||
|             "coolant_thermal_cap_j_per_k": float(self.Cc.get()), | ||||
|             "oil_thermal_cap_j_per_k": float(self.Coil.get()), | ||||
|             # Versorgung & Nachlauf | ||||
|             "fan_power_feed": self.feed.get(), | ||||
|             "fan_afterrun_enable": bool(self.afteren.get()), | ||||
|             "fan_afterrun_threshold_c": float(self.afterth.get()), | ||||
|             "fan_afterrun_max_s": float(self.aftermax.get()), | ||||
|             # rechts | ||||
|             "fan1_on_c": float(self.f1_on.get()), | ||||
|             "fan1_off_c": float(self.f1_off.get()), | ||||
|             "fan2_on_c": float(self.f2_on.get()), | ||||
|             "fan2_off_c": float(self.f2_off.get()), | ||||
|             "fan1_power_w": float(self.f1_w.get()), | ||||
|             "fan2_power_w": float(self.f2_w.get()), | ||||
|             "fan1_airflow_gain": float(self.f1_air.get()), | ||||
|             "fan2_airflow_gain": float(self.f2_air.get()), | ||||
|             "oil_coolant_u_w_per_k": float(self.Uoc.get()), | ||||
|             "oil_to_amb_u_w_per_k": float(self.Uoil.get()), | ||||
|             "engine_heat_frac_to_coolant": float(self.frac.get())/100.0, | ||||
|         }} | ||||
|         self.sim.load_config(cfg) | ||||
| @@ -5,180 +5,182 @@ | ||||
| from __future__ import annotations | ||||
| import tkinter as tk | ||||
| from tkinter import ttk | ||||
| from typing import Dict, Any | ||||
| # Wichtig: Defaults aus dem Modul importieren | ||||
| from app.simulation.modules.engine import ENGINE_DEFAULTS | ||||
| from app.simulation.ui import UITab  | ||||
|  | ||||
| from app.simulation.ui import UITab | ||||
|  | ||||
| class EngineTab(UITab): | ||||
|     NAME  = "engine" | ||||
|     TITLE = "Motor" | ||||
|     PRIO  = 10 | ||||
|  | ||||
|     def __init__(self, parent, sim): | ||||
|         self.sim = sim | ||||
|         self.frame = ttk.Frame(parent, padding=8) | ||||
|         self.frame.columnconfigure(1, weight=1) | ||||
|         for c in (0,1,2,3): self.frame.columnconfigure(c, weight=1) | ||||
|  | ||||
|         # ------------- Widgets anlegen (OHNE Defaultwerte eintragen) -------------- | ||||
|         row = 0 | ||||
|         ttk.Label(self.frame, text="Leerlauf [RPM]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.idle_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.idle_var, width=10)\ | ||||
|             .grid(row=row-1, column=1, sticky="w") | ||||
|         # ---------- Linke Spalte ---------- | ||||
|         rowL = 0 | ||||
|         def L(lbl, var, w=12, kind="entry", values=None): | ||||
|             nonlocal rowL | ||||
|             ttk.Label(self.frame, text=lbl).grid(row=rowL, column=0, sticky="w") | ||||
|             if kind == "entry": | ||||
|                 ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowL, column=1, sticky="w") | ||||
|             elif kind == "combo": | ||||
|                 ttk.Combobox(self.frame, textvariable=var, state="readonly", | ||||
|                              values=values or [], width=w).grid(row=rowL, column=1, sticky="w") | ||||
|             rowL += 1 | ||||
|  | ||||
|         ttk.Label(self.frame, text="Max RPM").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.maxrpm_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.maxrpm_var, width=10)\ | ||||
|             .grid(row=row-1, column=1, sticky="w") | ||||
|         self.idle   = tk.IntVar();    L("Leerlauf [RPM]", self.idle) | ||||
|         self.maxrpm = tk.IntVar();    L("Max RPM", self.maxrpm) | ||||
|         self.rise   = tk.IntVar();    L("Anstieg [RPM/s]", self.rise) | ||||
|         self.fall   = tk.IntVar();    L("Abfall [RPM/s]", self.fall) | ||||
|         self.curve  = tk.StringVar(); L("Gaspedal-Kennlinie", self.curve, kind="combo", | ||||
|                                          values=["linear","progressive","aggressive"]) | ||||
|  | ||||
|         ttk.Label(self.frame, text="Anstieg [RPM/s]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.rise_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.rise_var, width=10)\ | ||||
|             .grid(row=row-1, column=1, sticky="w") | ||||
|         ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1 | ||||
|  | ||||
|         ttk.Label(self.frame, text="Abfall [RPM/s]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.fall_var = tk.IntVar(); ttk.Entry(self.frame, textvariable=self.fall_var, width=10)\ | ||||
|             .grid(row=row-1, column=1, sticky="w") | ||||
|         self.power  = tk.DoubleVar(); L("Motorleistung [kW]", self.power) | ||||
|         self.tqpeak = tk.DoubleVar(); L("Drehmoment-Peak [RPM]", self.tqpeak) | ||||
|  | ||||
|         ttk.Label(self.frame, text="Gaspedal-Kennlinie").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.thr_curve = tk.StringVar() | ||||
|         ttk.Combobox(self.frame, textvariable=self.thr_curve, state="readonly", | ||||
|                      values=["linear","progressive","aggressive"])\ | ||||
|             .grid(row=row-1, column=1, sticky="w") | ||||
|         ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1 | ||||
|  | ||||
|         ttk.Separator(self.frame).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(8,6)); row+=1 | ||||
|         self.st_nom = tk.DoubleVar(); L("Starter Nenn-RPM", self.st_nom) | ||||
|         self.st_vmin= tk.DoubleVar(); L("Starter min. Spannung [V]", self.st_vmin) | ||||
|         self.st_thr = tk.DoubleVar(); L("Start-Schwelle [RPM]", self.st_thr) | ||||
|         self.stall  = tk.DoubleVar(); L("Stall-Grenze [RPM]", self.stall) | ||||
|  | ||||
|         # Leistung | ||||
|         ttk.Label(self.frame, text="Motorleistung [kW]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.power_kw = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.power_kw, width=10)\ | ||||
|             .grid(row=row-1, column=1, sticky="w") | ||||
|         ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1 | ||||
|  | ||||
|         ttk.Label(self.frame, text="Drehmoment-Peak [RPM]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.peak_rpm = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.peak_rpm, width=10)\ | ||||
|             .grid(row=row-1, column=1, sticky="w") | ||||
|         self.o_idle = tk.DoubleVar(); L("Öldruck Leerlauf [bar]", self.o_idle) | ||||
|         self.o_slope= tk.DoubleVar(); L("Öldruck Steigung [bar/krpm]", self.o_slope) | ||||
|         self.o_floor= tk.DoubleVar(); L("Öldruck Boden [bar]", self.o_floor) | ||||
|  | ||||
|         ttk.Separator(self.frame).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(8,6)); row+=1 | ||||
|         # ---------- Rechte Spalte ---------- | ||||
|         rowR = 0 | ||||
|         def R(lbl, var, w=12, kind="entry"): | ||||
|             nonlocal rowR | ||||
|             ttk.Label(self.frame, text=lbl).grid(row=rowR, column=2, sticky="w") | ||||
|             if kind == "entry": | ||||
|                 ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowR, column=3, sticky="w") | ||||
|             elif kind == "label": | ||||
|                 ttk.Label(self.frame, textvariable=var).grid(row=rowR, column=3, sticky="w") | ||||
|             elif kind == "scale": | ||||
|                 s = ttk.Scale(self.frame, from_=0.0, to=100.0, variable=var, | ||||
|                               command=lambda _=None: self._on_pedal_change()) | ||||
|                 s.grid(row=rowR, column=3, sticky="ew") | ||||
|             rowR += 1 | ||||
|  | ||||
|         # Starter | ||||
|         ttk.Label(self.frame, text="Starter Nenn-RPM").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.starter_nom = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.starter_nom, width=10)\ | ||||
|             .grid(row=row-1, column=1, sticky="w") | ||||
|         self.dk_idle = tk.DoubleVar(); R("DK min Leerlauf [%]", self.dk_idle) | ||||
|         self.dk_over = tk.DoubleVar(); R("DK Schub [%]", self.dk_over) | ||||
|         self.dk_tau  = tk.DoubleVar(); R("DK Zeitkonstante [s]", self.dk_tau) | ||||
|         self.tq_kp   = tk.DoubleVar(); R("Torque-Kp", self.tq_kp) | ||||
|         self.tq_ki   = tk.DoubleVar(); R("Torque-Ki", self.tq_ki) | ||||
|  | ||||
|         ttk.Label(self.frame, text="Starter min. Spannung [V]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.starter_vmin = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.starter_vmin, width=10)\ | ||||
|             .grid(row=row-1, column=1, sticky="w") | ||||
|         ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1 | ||||
|  | ||||
|         ttk.Label(self.frame, text="Start-Schwelle [RPM]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.start_th = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.start_th, width=10)\ | ||||
|             .grid(row=row-1, column=1, sticky="w") | ||||
|         self.jit_idle= tk.DoubleVar(); R("Jitter Leerlauf [±RPM]", self.jit_idle) | ||||
|         self.jit_high= tk.DoubleVar(); R("Jitter hoch [±RPM]", self.jit_high) | ||||
|         self.jit_tau = tk.DoubleVar(); R("Jitter-Zeitkonstante [s]", self.jit_tau) | ||||
|         self.jit_off = tk.DoubleVar(); R("Jitter aus unter [RPM]", self.jit_off) | ||||
|  | ||||
|         ttk.Label(self.frame, text="Stall-Grenze [RPM]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.stall_rpm = tk.DoubleVar(); ttk.Entry(self.frame, textvariable=self.stall_rpm, width=10)\ | ||||
|             .grid(row=row-1, column=1, sticky="w") | ||||
|         ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1 | ||||
|  | ||||
|         ttk.Separator(self.frame).grid(row=row, column=0, columnspan=2, sticky="ew", pady=(8,6)); row+=1 | ||||
|         self.amb_c  = tk.DoubleVar(); R("Umgebung [°C]", self.amb_c) | ||||
|         self.cold_k = tk.DoubleVar(); R("Kalt-Leerlauf +/°C [RPM/°C]", self.cold_k) | ||||
|         self.cold_max=tk.DoubleVar(); R("Kalt-Leerlauf max [RPM]", self.cold_max) | ||||
|  | ||||
|         # Thermik (analog – Variablen ohne Defaults anlegen) ... | ||||
|         self.amb_c = tk.DoubleVar(); self.c_warm = tk.DoubleVar(); self.c_cool = tk.DoubleVar() | ||||
|         self.o_warm = tk.DoubleVar(); self.o_cool = tk.DoubleVar() | ||||
|         self.cold_gain = tk.DoubleVar(); self.cold_gain_max = tk.DoubleVar() | ||||
|         # (Labels/Entries spar ich hier ab – wie gehabt weiterführen) | ||||
|         ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1 | ||||
|  | ||||
|         # Öl, DBW, Jitter, Pedal | ||||
|         self.o_idle = tk.DoubleVar(); self.o_slope = tk.DoubleVar(); self.o_floor = tk.DoubleVar() | ||||
|         self.plate_idle_min = tk.DoubleVar(); self.plate_overrun = tk.DoubleVar(); self.plate_tau = tk.DoubleVar() | ||||
|         self.torque_kp = tk.DoubleVar(); self.torque_ki = tk.DoubleVar() | ||||
|         self.jitter_idle = tk.DoubleVar(); self.jitter_high = tk.DoubleVar() | ||||
|         self.jitter_tau = tk.DoubleVar(); self.jitter_off = tk.DoubleVar() | ||||
|         self.pedal  = tk.DoubleVar(); R("Gaspedal [%]", self.pedal, kind="scale") | ||||
|  | ||||
|         ttk.Label(self.frame, text="Gaspedal [%]").grid(row=row, column=0, sticky="w"); row+=1 | ||||
|         self.pedal_var = tk.DoubleVar() | ||||
|         self.pedal_scale = ttk.Scale(self.frame, from_=0.0, to=100.0, variable=self.pedal_var) | ||||
|         self.pedal_scale.grid(row=row-1, column=1, sticky="ew") | ||||
|         # ---------- Buttons ---------- | ||||
|         rowBtns = max(rowL, rowR) + 1 | ||||
|         btn = ttk.Frame(self.frame); btn.grid(row=rowBtns, column=0, columnspan=4, sticky="w", pady=(8,0)) | ||||
|         ttk.Button(btn, text="Aktualisieren", command=self.refresh).pack(side="left") | ||||
|         ttk.Button(btn, text="Anwenden",     command=self.apply).pack(side="left", padx=(8,0)) | ||||
|  | ||||
|         # Buttons | ||||
|         row += 1 | ||||
|         btnrow = ttk.Frame(self.frame); btnrow.grid(row=row, column=0, columnspan=2, sticky="w", pady=(8,0)) | ||||
|         ttk.Button(btnrow, text="Aktualisieren", command=self.refresh).pack(side="left") | ||||
|         ttk.Button(btnrow, text="Anwenden", command=self.apply).pack(side="left", padx=(8,0)) | ||||
|  | ||||
|         # Zum Start einmal „live“ laden: | ||||
|         self.refresh() | ||||
|  | ||||
|     # liest IMMER effektiv: config.get(key, ENGINE_DEFAULTS[key]) | ||||
|     def _on_pedal_change(self): | ||||
|         try: self.sim.v.set("throttle_pedal_pct", float(self.pedal.get())) | ||||
|         except: pass | ||||
|  | ||||
|     def refresh(self): | ||||
|         e = dict(ENGINE_DEFAULTS) | ||||
|         e.update(self.sim.v.config.get("engine", {}))  # Config über default mergen | ||||
|         e = dict(ENGINE_DEFAULTS); e.update(self.sim.v.config.get("engine", {})) | ||||
|  | ||||
|         self.idle_var.set(e["idle_rpm"]) | ||||
|         self.maxrpm_var.set(e["max_rpm"]) | ||||
|         self.rise_var.set(e["rpm_rise_per_s"]) | ||||
|         self.fall_var.set(e["rpm_fall_per_s"]) | ||||
|         self.thr_curve.set(e["throttle_curve"]) | ||||
|         self.power_kw.set(e["engine_power_kw"]) | ||||
|         self.peak_rpm.set(e["torque_peak_rpm"]) | ||||
|         # links | ||||
|         self.idle.set(e["idle_rpm"]) | ||||
|         self.maxrpm.set(e["max_rpm"]) | ||||
|         self.rise.set(e["rpm_rise_per_s"]) | ||||
|         self.fall.set(e["rpm_fall_per_s"]) | ||||
|         self.curve.set(e["throttle_curve"]) | ||||
|  | ||||
|         self.starter_nom.set(e["starter_rpm_nominal"]) | ||||
|         self.starter_vmin.set(e["starter_voltage_min"]) | ||||
|         self.start_th.set(e["start_rpm_threshold"]) | ||||
|         self.stall_rpm.set(e["stall_rpm"]) | ||||
|         self.power.set(e["engine_power_kw"]) | ||||
|         self.tqpeak.set(e["torque_peak_rpm"]) | ||||
|  | ||||
|         self.amb_c.set(e["coolant_ambient_c"]) | ||||
|         self.c_warm.set(e["coolant_warm_rate_c_per_s"]) | ||||
|         self.c_cool.set(e["coolant_cool_rate_c_per_s"]) | ||||
|         self.o_warm.set(e["oil_warm_rate_c_per_s"]) | ||||
|         self.o_cool.set(e["oil_cool_rate_c_per_s"]) | ||||
|         self.cold_gain.set(e["idle_cold_gain_per_deg"]) | ||||
|         self.cold_gain_max.set(e["idle_cold_gain_max"]) | ||||
|         self.st_nom.set(e["starter_rpm_nominal"]) | ||||
|         self.st_vmin.set(e["starter_voltage_min"]) | ||||
|         self.st_thr.set(e["start_rpm_threshold"]) | ||||
|         self.stall.set(e["stall_rpm"]) | ||||
|  | ||||
|         self.o_idle.set(e["oil_pressure_idle_bar"]) | ||||
|         self.o_slope.set(e["oil_pressure_slope_bar_per_krpm"]) | ||||
|         self.o_floor.set(e["oil_pressure_off_floor_bar"]) | ||||
|  | ||||
|         self.plate_idle_min.set(e["throttle_plate_idle_min_pct"]) | ||||
|         self.plate_overrun.set(e["throttle_plate_overrun_pct"]) | ||||
|         self.plate_tau.set(e["throttle_plate_tau_s"]) | ||||
|         self.torque_kp.set(e["torque_ctrl_kp"]) | ||||
|         self.torque_ki.set(e["torque_ctrl_ki"]) | ||||
|         # rechts | ||||
|         self.dk_idle.set(e["throttle_plate_idle_min_pct"]) | ||||
|         self.dk_over.set(e["throttle_plate_overrun_pct"]) | ||||
|         self.dk_tau.set(e["throttle_plate_tau_s"]) | ||||
|         self.tq_kp.set(e["torque_ctrl_kp"]) | ||||
|         self.tq_ki.set(e["torque_ctrl_ki"]) | ||||
|  | ||||
|         self.jitter_idle.set(e["rpm_jitter_idle_amp_rpm"]) | ||||
|         self.jitter_high.set(e["rpm_jitter_high_amp_rpm"]) | ||||
|         self.jitter_tau.set(e["rpm_jitter_tau_s"]) | ||||
|         self.jitter_off.set(e["rpm_jitter_off_threshold_rpm"]) | ||||
|         self.jit_idle.set(e["rpm_jitter_idle_amp_rpm"]) | ||||
|         self.jit_high.set(e["rpm_jitter_high_amp_rpm"]) | ||||
|         self.jit_tau.set(e["rpm_jitter_tau_s"]) | ||||
|         self.jit_off.set(e["rpm_jitter_off_threshold_rpm"]) | ||||
|  | ||||
|         self.pedal_var.set(e["throttle_pedal_pct"]) | ||||
|         self.amb_c.set(e["coolant_ambient_c"]) | ||||
|         self.cold_k.set(e["idle_cold_gain_per_deg"]) | ||||
|         self.cold_max.set(e["idle_cold_gain_max"]) | ||||
|  | ||||
|         self.pedal.set(e["throttle_pedal_pct"]) | ||||
|         self._on_pedal_change() | ||||
|  | ||||
|     def apply(self): | ||||
|         # Nur hier wird geschrieben | ||||
|         cfg = {"engine": { | ||||
|             "idle_rpm": int(self.idle_var.get()), | ||||
|             "max_rpm": int(self.maxrpm_var.get()), | ||||
|             "rpm_rise_per_s": int(self.rise_var.get()), | ||||
|             "rpm_fall_per_s": int(self.fall_var.get()), | ||||
|             "throttle_curve": self.thr_curve.get(), | ||||
|             "engine_power_kw": float(self.power_kw.get()), | ||||
|             "torque_peak_rpm": float(self.peak_rpm.get()), | ||||
|             "starter_rpm_nominal": float(self.starter_nom.get()), | ||||
|             "starter_voltage_min": float(self.starter_vmin.get()), | ||||
|             "start_rpm_threshold": float(self.start_th.get()), | ||||
|             "stall_rpm": float(self.stall_rpm.get()), | ||||
|             "coolant_ambient_c": float(self.amb_c.get()), | ||||
|             "coolant_warm_rate_c_per_s": float(self.c_warm.get()), | ||||
|             "coolant_cool_rate_c_per_s": float(self.c_cool.get()), | ||||
|             "oil_warm_rate_c_per_s": float(self.o_warm.get()), | ||||
|             "oil_cool_rate_c_per_s": float(self.o_cool.get()), | ||||
|             "idle_cold_gain_per_deg": float(self.cold_gain.get()), | ||||
|             "idle_cold_gain_max": float(self.cold_gain_max.get()), | ||||
|             "idle_rpm": int(self.idle.get()), | ||||
|             "max_rpm": int(self.maxrpm.get()), | ||||
|             "rpm_rise_per_s": int(self.rise.get()), | ||||
|             "rpm_fall_per_s": int(self.fall.get()), | ||||
|             "throttle_curve": self.curve.get(), | ||||
|  | ||||
|             "engine_power_kw": float(self.power.get()), | ||||
|             "torque_peak_rpm": float(self.tqpeak.get()), | ||||
|  | ||||
|             "starter_rpm_nominal": float(self.st_nom.get()), | ||||
|             "starter_voltage_min": float(self.st_vmin.get()), | ||||
|             "start_rpm_threshold": float(self.st_thr.get()), | ||||
|             "stall_rpm": float(self.stall.get()), | ||||
|  | ||||
|             "oil_pressure_idle_bar": float(self.o_idle.get()), | ||||
|             "oil_pressure_slope_bar_per_krpm": float(self.o_slope.get()), | ||||
|             "oil_pressure_off_floor_bar": float(self.o_floor.get()), | ||||
|             "throttle_plate_idle_min_pct": float(self.plate_idle_min.get()), | ||||
|             "throttle_plate_overrun_pct": float(self.plate_overrun.get()), | ||||
|             "throttle_plate_tau_s": float(self.plate_tau.get()), | ||||
|             "torque_ctrl_kp": float(self.torque_kp.get()), | ||||
|             "torque_ctrl_ki": float(self.torque_ki.get()), | ||||
|             "rpm_jitter_idle_amp_rpm": float(self.jitter_idle.get()), | ||||
|             "rpm_jitter_high_amp_rpm": float(self.jitter_high.get()), | ||||
|             "rpm_jitter_tau_s": float(self.jitter_tau.get()), | ||||
|             "rpm_jitter_off_threshold_rpm": float(self.jitter_off.get()), | ||||
|             "throttle_pedal_pct": float(self.pedal_var.get()), | ||||
|  | ||||
|             "throttle_plate_idle_min_pct": float(self.dk_idle.get()), | ||||
|             "throttle_plate_overrun_pct": float(self.dk_over.get()), | ||||
|             "throttle_plate_tau_s": float(self.dk_tau.get()), | ||||
|             "torque_ctrl_kp": float(self.tq_kp.get()), | ||||
|             "torque_ctrl_ki": float(self.tq_ki.get()), | ||||
|  | ||||
|             "rpm_jitter_idle_amp_rpm": float(self.jit_idle.get()), | ||||
|             "rpm_jitter_high_amp_rpm": float(self.jit_high.get()), | ||||
|             "rpm_jitter_tau_s": float(self.jit_tau.get()), | ||||
|             "rpm_jitter_off_threshold_rpm": float(self.jit_off.get()), | ||||
|  | ||||
|             "coolant_ambient_c": float(self.amb_c.get()), | ||||
|             "idle_cold_gain_per_deg": float(self.cold_k.get()), | ||||
|             "idle_cold_gain_max": float(self.cold_max.get()), | ||||
|  | ||||
|             "throttle_pedal_pct": float(self.pedal.get()), | ||||
|         }} | ||||
|         self.sim.load_config(cfg) | ||||
|   | ||||
| @@ -1,72 +1,241 @@ | ||||
| # ============================= | ||||
| # app/simulation/ui/gearbox.py | ||||
| # ============================= | ||||
|  | ||||
| from __future__ import annotations | ||||
| import tkinter as tk | ||||
| from tkinter import ttk | ||||
| from typing import Dict, Any, List | ||||
| from app.simulation.ui import UITab  | ||||
|  | ||||
| from typing import Dict, Any | ||||
| from app.simulation.ui import UITab | ||||
| from app.simulation.modules.gearbox import GEARBOX_DEFAULTS | ||||
|  | ||||
| class GearboxTab(UITab): | ||||
|     NAME  = "gearbox" | ||||
|     TITLE = "Getriebe" | ||||
|     PRIO  = 10 | ||||
|     TITLE = "Getriebe & Antrieb" | ||||
|     PRIO  = 12 | ||||
|  | ||||
|     def __init__(self, parent, sim): | ||||
|         self.sim = sim | ||||
|         self.frame = ttk.Frame(parent, padding=8) | ||||
|         self.frame.columnconfigure(1, weight=1) | ||||
|         for c in (0,1,2,3): self.frame.columnconfigure(c, weight=1) | ||||
|  | ||||
|         ttk.Label(self.frame, text="Gänge (inkl. Leerlauf als 0)").grid(row=0, column=0, sticky="w") | ||||
|         self.gears_var = tk.IntVar(value=6) | ||||
|         ttk.Spinbox(self.frame, from_=1, to=10, textvariable=self.gears_var, width=6, command=self._rebuild_ratios).grid(row=0, column=1, sticky="w") | ||||
|         # ---------- Linke Spalte ---------- | ||||
|         rowL = 0 | ||||
|         def L(lbl, var=None, w=12, kind="entry", values=None): | ||||
|             nonlocal rowL | ||||
|             ttk.Label(self.frame, text=lbl).grid(row=rowL, column=0, sticky="w") | ||||
|             if kind == "entry": | ||||
|                 ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowL, column=1, sticky="w") | ||||
|             elif kind == "label": | ||||
|                 ttk.Label(self.frame, textvariable=var).grid(row=rowL, column=1, sticky="w") | ||||
|             elif kind == "combo": | ||||
|                 ttk.Combobox(self.frame, textvariable=var, state="readonly", | ||||
|                              values=values or [], width=w).grid(row=rowL, column=1, sticky="w") | ||||
|             elif kind == "buttons": | ||||
|                 f = ttk.Frame(self.frame); f.grid(row=rowL, column=1, sticky="w") | ||||
|                 ttk.Button(f, text="▼", width=3, command=self.shift_down).pack(side="left", padx=(0,4)) | ||||
|                 ttk.Button(f, text="N", width=3, command=self.set_neutral).pack(side="left", padx=(0,4)) | ||||
|                 ttk.Button(f, text="▲", width=3, command=self.shift_up).pack(side="left") | ||||
|             rowL += 1 | ||||
|  | ||||
|         self.reverse_var = tk.BooleanVar(value=False) | ||||
|         ttk.Checkbutton(self.frame, text="Rückwärtsgang vorhanden", variable=self.reverse_var).grid(row=1, column=0, columnspan=2, sticky="w") | ||||
|         # Live/Controls (Labels → werden im _tick() live aktualisiert) | ||||
|         self.gear_var  = tk.StringVar(); L("Gang", self.gear_var, kind="label") | ||||
|         L("Schalten", kind="buttons") | ||||
|         self.speed_var = tk.StringVar(); L("Geschwindigkeit [km/h]", self.speed_var, kind="label") | ||||
|         self.clutch_v  = tk.StringVar(); L("Kupplung [%]", self.clutch_v, kind="label") | ||||
|         self.slip_v    = tk.StringVar(); L("Reifenschlupf [%]", self.slip_v, kind="label") | ||||
|  | ||||
|         ttk.Label(self.frame, text="km/h pro 1000 RPM je Gang").grid(row=2, column=0, sticky="w", pady=(6,0)) | ||||
|         self.ratio_frame = ttk.Frame(self.frame); self.ratio_frame.grid(row=3, column=0, columnspan=2, sticky="ew") | ||||
|         self.ratio_vars: List[tk.DoubleVar] = [] | ||||
|         self._rebuild_ratios() | ||||
|         ttk.Separator(self.frame).grid(row=rowL, column=0, columnspan=2, sticky="ew", pady=(8,6)); rowL += 1 | ||||
|  | ||||
|         ttk.Button(self.frame, text="Anwenden", command=self.apply).grid(row=4, column=0, pady=(8,0), sticky="w") | ||||
|         # Kupplung/Automation | ||||
|         self.cl_Tmax = tk.DoubleVar(); L("Kupplung Tmax [Nm]", self.cl_Tmax) | ||||
|         self.cl_agr  = tk.DoubleVar(); L("Aggressivität [0..1]", self.cl_agr) | ||||
|         self.cl_curve= tk.StringVar(); L("Kupplungs-Kurve", self.cl_curve, kind="combo", | ||||
|                                          values=["linear","progressive","soft"]) | ||||
|         self.cl_drag = tk.DoubleVar(); L("Kupplungs-Schlepp [Nm]", self.cl_drag) | ||||
|         self.sh_time = tk.DoubleVar(); L("Schaltzeit [s]", self.sh_time) | ||||
|         self.sync_rb = tk.DoubleVar(); L("Sync-Band [RPM]", self.sync_rb) | ||||
|  | ||||
|     def _rebuild_ratios(self): | ||||
|         for w in self.ratio_frame.winfo_children(): w.destroy() | ||||
|         self.ratio_vars.clear() | ||||
|         n = int(self.gears_var.get()) | ||||
|         for i in range(1, n+1): | ||||
|             ttk.Label(self.ratio_frame, text=f"Gang {i}").grid(row=i-1, column=0, sticky="w") | ||||
|             v = tk.DoubleVar(value= [12.0,19.0,25.0,32.0,38.0,45.0][i-1] if i-1 < 6 else 45.0) | ||||
|             ttk.Entry(self.ratio_frame, textvariable=v, width=8).grid(row=i-1, column=1, sticky="w", padx=(6,12)) | ||||
|             self.ratio_vars.append(v) | ||||
|         # ---------- Rechte Spalte ---------- | ||||
|         rowR = 0 | ||||
|         def R(lbl, var=None, w=12, kind="entry"): | ||||
|             nonlocal rowR | ||||
|             ttk.Label(self.frame, text=lbl).grid(row=rowR, column=2, sticky="w") | ||||
|             if kind == "entry": | ||||
|                 ttk.Entry(self.frame, textvariable=var, width=w).grid(row=rowR, column=3, sticky="w") | ||||
|             elif kind == "label": | ||||
|                 ttk.Label(self.frame, textvariable=var).grid(row=rowR, column=3, sticky="w") | ||||
|             rowR += 1 | ||||
|  | ||||
|         # Übersetzungen / Rad | ||||
|         self.primary = tk.DoubleVar(); R("Primärübersetzung [-]", self.primary) | ||||
|         self.zf      = tk.IntVar();    R("Ritzel vorn [Z]", self.zf) | ||||
|         self.zr      = tk.IntVar();    R("Ritzel hinten [Z]", self.zr) | ||||
|         self.rwheel  = tk.DoubleVar(); R("Radradius [m]", self.rwheel) | ||||
|         self.eta     = tk.DoubleVar(); R("Wirkungsgrad [-]", self.eta) | ||||
|         self.couple  = tk.DoubleVar(); R("RPM-Kopplung [0..1]", self.couple) | ||||
|  | ||||
|         ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1 | ||||
|  | ||||
|         # Gangübersetzungen 1..6 | ||||
|         self.g1 = tk.DoubleVar(); R("Gang 1 Ratio", self.g1) | ||||
|         self.g2 = tk.DoubleVar(); R("Gang 2 Ratio", self.g2) | ||||
|         self.g3 = tk.DoubleVar(); R("Gang 3 Ratio", self.g3) | ||||
|         self.g4 = tk.DoubleVar(); R("Gang 4 Ratio", self.g4) | ||||
|         self.g5 = tk.DoubleVar(); R("Gang 5 Ratio", self.g5) | ||||
|         self.g6 = tk.DoubleVar(); R("Gang 6 Ratio", self.g6) | ||||
|  | ||||
|         ttk.Separator(self.frame).grid(row=rowR, column=2, columnspan=2, sticky="ew", pady=(8,6)); rowR += 1 | ||||
|  | ||||
|         # Widerstände / Reifen | ||||
|         self.c_rr = tk.DoubleVar(); R("Rollkoeff. c_rr", self.c_rr) | ||||
|         self.rho  = tk.DoubleVar(); R("Luftdichte [kg/m³]", self.rho) | ||||
|         self.cd   = tk.DoubleVar(); R("c_d [-]", self.cd) | ||||
|         self.A    = tk.DoubleVar(); R("Stirnfläche [m²]", self.A) | ||||
|         self.mu_p = tk.DoubleVar(); R("Reifen μ_peak", self.mu_p) | ||||
|         self.mu_s = tk.DoubleVar(); R("Reifen μ_slide", self.mu_s) | ||||
|         self.w_rear = tk.DoubleVar(); R("Gewichtsanteil hinten [-]", self.w_rear) | ||||
|  | ||||
|         # ---------- Buttons ---------- | ||||
|         rowBtns = max(rowL, rowR) + 1 | ||||
|         btn = ttk.Frame(self.frame); btn.grid(row=rowBtns, column=0, columnspan=4, sticky="w", pady=(8,0)) | ||||
|         ttk.Button(btn, text="Aktualisieren", command=self.refresh).pack(side="left") | ||||
|         ttk.Button(btn, text="Anwenden",     command=self.apply).pack(side="left", padx=(8,0)) | ||||
|  | ||||
|         self.refresh() | ||||
|         self._tick() | ||||
|  | ||||
|     # --- Live-Update nur für Labels --- | ||||
|     def _tick(self): | ||||
|         snap = self.sim.snapshot() | ||||
|         gear = int(snap.get("gear", 0)) | ||||
|         self.gear_var.set("N" if gear == 0 else str(gear)) | ||||
|         self.speed_var.set(f"{float(snap.get('speed_kmh', 0.0)):.1f}") | ||||
|         self.clutch_v.set(f"{float(snap.get('clutch_pct', 0.0)):.0f}") | ||||
|         self.slip_v.set(f"{float(snap.get('wheel_slip_pct', 0.0)):.0f}") | ||||
|         try: | ||||
|             self.frame.after(200, self._tick) | ||||
|         except tk.TclError: | ||||
|             pass | ||||
|  | ||||
|     # --- Actions (Buttons) --- | ||||
|     def shift_up(self):     self.sim.v.set("gear_shift_up", True) | ||||
|     def shift_down(self):   self.sim.v.set("gear_shift_down", True) | ||||
|     def set_neutral(self):  self.sim.v.set("gear_set_neutral", True) | ||||
|  | ||||
|     # --- Data flow --- | ||||
|     def refresh(self): | ||||
|         # Live-Felder werden vom _tick() versorgt; hier nur Config mergen | ||||
|         g = dict(GEARBOX_DEFAULTS) | ||||
|         g.update(self.sim.v.config.get("gearbox", {})) | ||||
|  | ||||
|         self.cl_Tmax.set(g["clutch_max_torque_nm"]) | ||||
|         self.cl_agr.set(g["clutch_aggressiveness"]) | ||||
|         self.cl_curve.set(g.get("clutch_curve", "linear")) | ||||
|         self.cl_drag.set(g["clutch_drag_nm"]) | ||||
|         self.sh_time.set(g["shift_time_s"]) | ||||
|         self.sync_rb.set(g["sync_rpm_band"]) | ||||
|  | ||||
|         self.primary.set(g["primary_ratio"]) | ||||
|         self.zf.set(g["front_sprocket_teeth"]) | ||||
|         self.zr.set(g["rear_sprocket_teeth"]) | ||||
|         self.rwheel.set(g["wheel_radius_m"]) | ||||
|         self.eta.set(g["drivetrain_efficiency"]) | ||||
|         self.couple.set(g["rpm_couple_gain"]) | ||||
|  | ||||
|         ratios = list(g["gear_ratios"]) + [0.0]*7 | ||||
|         self.g1.set(ratios[1]); self.g2.set(ratios[2]); self.g3.set(ratios[3]) | ||||
|         self.g4.set(ratios[4]); self.g5.set(ratios[5]); self.g6.set(ratios[6]) | ||||
|  | ||||
|         self.c_rr.set(g["rolling_c"]) | ||||
|         self.rho.set(g["air_density"]) | ||||
|         self.cd.set(g["aero_cd"]) | ||||
|         self.A.set(g["frontal_area_m2"]) | ||||
|         self.mu_p.set(g["tire_mu_peak"]) | ||||
|         self.mu_s.set(g["tire_mu_slide"]) | ||||
|         self.w_rear.set(g["rear_static_weight_frac"]) | ||||
|  | ||||
|     def apply(self): | ||||
|         ratios = [float(v.get()) for v in self.ratio_vars] | ||||
|         cfg = {"gearbox": { | ||||
|             "num_gears": int(self.gears_var.get()), | ||||
|             "reverse": bool(self.reverse_var.get()), | ||||
|             "kmh_per_krpm": [0.0] + ratios  # index 0 reserved for neutral | ||||
|             "clutch_max_torque_nm": float(self.cl_Tmax.get()), | ||||
|             "clutch_aggressiveness": float(self.cl_agr.get()), | ||||
|             "clutch_curve": self.cl_curve.get(), | ||||
|             "clutch_drag_nm": float(self.cl_drag.get()), | ||||
|             "shift_time_s": float(self.sh_time.get()), | ||||
|             "sync_rpm_band": float(self.sync_rb.get()), | ||||
|  | ||||
|             "primary_ratio": float(self.primary.get()), | ||||
|             "front_sprocket_teeth": int(self.zf.get()), | ||||
|             "rear_sprocket_teeth": int(self.zr.get()), | ||||
|             "wheel_radius_m": float(self.rwheel.get()), | ||||
|             "drivetrain_efficiency": float(self.eta.get()), | ||||
|             "rpm_couple_gain": float(self.couple.get()), | ||||
|  | ||||
|             "gear_ratios": [ | ||||
|                 0.0, | ||||
|                 float(self.g1.get()), | ||||
|                 float(self.g2.get()), | ||||
|                 float(self.g3.get()), | ||||
|                 float(self.g4.get()), | ||||
|                 float(self.g5.get()), | ||||
|                 float(self.g6.get()) | ||||
|             ], | ||||
|  | ||||
|             "rolling_c": float(self.c_rr.get()), | ||||
|             "air_density": float(self.rho.get()), | ||||
|             "aero_cd": float(self.cd.get()), | ||||
|             "frontal_area_m2": float(self.A.get()), | ||||
|             "tire_mu_peak": float(self.mu_p.get()), | ||||
|             "tire_mu_slide": float(self.mu_s.get()), | ||||
|             "rear_static_weight_frac": float(self.w_rear.get()), | ||||
|         }} | ||||
|         self.sim.load_config(cfg) | ||||
|  | ||||
|     def save_into_config(self, out: Dict[str, Any]) -> None: | ||||
|         out.setdefault("gearbox", {}) | ||||
|         out["gearbox"].update({ | ||||
|             "num_gears": int(self.gears_var.get()), | ||||
|             "reverse": bool(self.reverse_var.get()), | ||||
|             "kmh_per_krpm": [0.0] + [float(v.get()) for v in self.ratio_vars] | ||||
|         out.setdefault("gearbox", {}).update({ | ||||
|             "clutch_max_torque_nm": float(self.cl_Tmax.get()), | ||||
|             "clutch_aggressiveness": float(self.cl_agr.get()), | ||||
|             "clutch_curve": self.cl_curve.get(), | ||||
|             "clutch_drag_nm": float(self.cl_drag.get()), | ||||
|             "shift_time_s": float(self.sh_time.get()), | ||||
|             "sync_rpm_band": float(self.sync_rb.get()), | ||||
|             "primary_ratio": float(self.primary.get()), | ||||
|             "front_sprocket_teeth": int(self.zf.get()), | ||||
|             "rear_sprocket_teeth": int(self.zr.get()), | ||||
|             "wheel_radius_m": float(self.rwheel.get()), | ||||
|             "drivetrain_efficiency": float(self.eta.get()), | ||||
|             "rpm_couple_gain": float(self.couple.get()), | ||||
|             "gear_ratios": [0.0, float(self.g1.get()), float(self.g2.get()), float(self.g3.get()), | ||||
|                             float(self.g4.get()), float(self.g5.get()), float(self.g6.get())], | ||||
|             "rolling_c": float(self.c_rr.get()), | ||||
|             "air_density": float(self.rho.get()), | ||||
|             "aero_cd": float(self.cd.get()), | ||||
|             "frontal_area_m2": float(self.A.get()), | ||||
|             "tire_mu_peak": float(self.mu_p.get()), | ||||
|             "tire_mu_slide": float(self.mu_s.get()), | ||||
|             "rear_static_weight_frac": float(self.w_rear.get()), | ||||
|         }) | ||||
|  | ||||
|     def load_from_config(self, cfg: Dict[str, Any]) -> None: | ||||
|         g = cfg.get("gearbox", {}) | ||||
|         n = int(g.get("num_gears", self.gears_var.get())) | ||||
|         self.gears_var.set(n); self.reverse_var.set(g.get("reverse", self.reverse_var.get())) | ||||
|         self._rebuild_ratios() | ||||
|         ratios = g.get("kmh_per_krpm") or ([0.0] + [v.get() for v in self.ratio_vars]) | ||||
|         for i, v in enumerate(self.ratio_vars, start=1): | ||||
|             try: v.set(float(ratios[i])) | ||||
|             except Exception: pass | ||||
|         self.sim.load_config(cfg) | ||||
|         g = dict(GEARBOX_DEFAULTS); g.update(cfg.get("gearbox", {})) | ||||
|         self.cl_Tmax.set(g["clutch_max_torque_nm"]) | ||||
|         self.cl_agr.set(g["clutch_aggressiveness"]) | ||||
|         self.cl_curve.set(g.get("clutch_curve","linear")) | ||||
|         self.cl_drag.set(g["clutch_drag_nm"]) | ||||
|         self.sh_time.set(g["shift_time_s"]) | ||||
|         self.sync_rb.set(g["sync_rpm_band"]) | ||||
|         self.primary.set(g["primary_ratio"]) | ||||
|         self.zf.set(g["front_sprocket_teeth"]) | ||||
|         self.zr.set(g["rear_sprocket_teeth"]) | ||||
|         self.rwheel.set(g["wheel_radius_m"]) | ||||
|         self.eta.set(g["drivetrain_efficiency"]) | ||||
|         self.couple.set(g["rpm_couple_gain"]) | ||||
|         ratios = list(g["gear_ratios"]) + [0.0]*7 | ||||
|         self.g1.set(ratios[1]); self.g2.set(ratios[2]); self.g3.set(ratios[3]) | ||||
|         self.g4.set(ratios[4]); self.g5.set(ratios[5]); self.g6.set(ratios[6]) | ||||
|         self.c_rr.set(g["rolling_c"]) | ||||
|         self.rho.set(g["air_density"]) | ||||
|         self.cd.set(g["aero_cd"]) | ||||
|         self.A.set(g["frontal_area_m2"]) | ||||
|         self.mu_p.set(g["tire_mu_peak"]) | ||||
|         self.mu_s.set(g["tire_mu_slide"]) | ||||
|         self.w_rear.set(g["rear_static_weight_frac"]) | ||||
|   | ||||
| @@ -1,66 +0,0 @@ | ||||
| # simulator.py — Driveline & ECU-State | ||||
| from __future__ import annotations | ||||
| import threading | ||||
| import time | ||||
| from dataclasses import dataclass | ||||
|  | ||||
| @dataclass | ||||
| class DrivelineModel: | ||||
|     idle_rpm: int = 1400 | ||||
|     max_rpm: int = 9500 | ||||
|     kmh_per_krpm: tuple = (0.0, 12.0, 19.0, 25.0, 32.0, 38.0, 45.0) | ||||
|     rpm_rise_per_s: int = 5000 | ||||
|     rpm_fall_per_s: int = 3500 | ||||
|  | ||||
|     def target_rpm_from_throttle(self, throttle_pct: int) -> int: | ||||
|         t = max(0, min(100, throttle_pct)) / 100.0 | ||||
|         return int(self.idle_rpm + t * (self.max_rpm - self.idle_rpm)) | ||||
|  | ||||
|     def speed_from_rpm_gear(self, rpm: int, gear: int) -> float: | ||||
|         if gear <= 0: | ||||
|             return 0.0 | ||||
|         k = self.kmh_per_krpm[min(gear, len(self.kmh_per_krpm) - 1)] | ||||
|         return (rpm / 1000.0) * k | ||||
|  | ||||
| class EcuState: | ||||
|     """Thread-sichere Zustandsmaschine (Gang, Gas, RPM, Speed).""" | ||||
|     def __init__(self, model: DrivelineModel | None = None) -> None: | ||||
|         self.model = model or DrivelineModel() | ||||
|         self._lock = threading.Lock() | ||||
|         self._gear = 0 | ||||
|         self._throttle = 0 | ||||
|         self._rpm = self.model.idle_rpm | ||||
|         self._speed = 0.0 | ||||
|         self._last = time.monotonic() | ||||
|  | ||||
|     def set_gear(self, gear: int) -> None: | ||||
|         with self._lock: | ||||
|             self._gear = max(0, min(6, int(gear))) | ||||
|  | ||||
|     def set_throttle(self, thr: int) -> None: | ||||
|         with self._lock: | ||||
|             self._throttle = max(0, min(100, int(thr))) | ||||
|  | ||||
|     def snapshot(self) -> tuple[int, int, int, float]: | ||||
|         with self._lock: | ||||
|             return self._gear, self._throttle, self._rpm, self._speed | ||||
|  | ||||
|     def update(self) -> None: | ||||
|         now = time.monotonic() | ||||
|         dt = max(0.0, min(0.1, now - self._last)) | ||||
|         self._last = now | ||||
|         with self._lock: | ||||
|             target = self.model.target_rpm_from_throttle(self._throttle) | ||||
|             if self._rpm < target: | ||||
|                 self._rpm = min(self._rpm + int(self.model.rpm_rise_per_s * dt), target) | ||||
|             else: | ||||
|                 self._rpm = max(self._rpm - int(self.model.rpm_fall_per_s * dt), target) | ||||
|             min_idle = 800 if self._gear == 0 and self._throttle == 0 else self.model.idle_rpm | ||||
|             self._rpm = max(min_idle, min(self._rpm, self.model.max_rpm)) | ||||
|  | ||||
|             target_speed = self.model.speed_from_rpm_gear(self._rpm, self._gear) | ||||
|             alpha = min(1.0, 4.0 * dt) | ||||
|             if self._gear == 0: | ||||
|                 target_speed = 0.0 | ||||
|             self._speed = (1 - alpha) * self._speed + alpha * target_speed | ||||
|             self._speed = max(0.0, min(self._speed, 299.0)) | ||||
		Reference in New Issue
	
	Block a user