Skip to content
  • Home
  • Aktuell
  • Tags
  • 0 Ungelesen 0
  • Kategorien
  • Unreplied
  • Beliebt
  • GitHub
  • Docu
  • Hilfe
Skins
  • Light
  • Brite
  • Cerulean
  • Cosmo
  • Flatly
  • Journal
  • Litera
  • Lumen
  • Lux
  • Materia
  • Minty
  • Morph
  • Pulse
  • Sandstone
  • Simplex
  • Sketchy
  • Spacelab
  • United
  • Yeti
  • Zephyr
  • Dark
  • Cyborg
  • Darkly
  • Quartz
  • Slate
  • Solar
  • Superhero
  • Vapor

  • Standard: (Kein Skin)
  • Kein Skin
Einklappen
ioBroker Logo

Community Forum

donate donate
  1. ioBroker Community Home
  2. Deutsch
  3. Skripten / Logik
  4. JavaScript
  5. [TypeSkript] Zendure SolarFlow Steuerung: KI

NEWS

  • Jahresrückblick 2025 – unser neuer Blogbeitrag ist online! ✨
    BluefoxB
    Bluefox
    16
    1
    1.8k

  • Neuer Blogbeitrag: Monatsrückblick - Dezember 2025 🎄
    BluefoxB
    Bluefox
    13
    1
    883

  • Weihnachtsangebot 2025! 🎄
    BluefoxB
    Bluefox
    25
    1
    2.1k

[TypeSkript] Zendure SolarFlow Steuerung: KI

Geplant Angeheftet Gesperrt Verschoben JavaScript
4 Beiträge 2 Kommentatoren 44 Aufrufe 2 Watching
  • Älteste zuerst
  • Neuste zuerst
  • Meiste Stimmen
Antworten
  • In einem neuen Thema antworten
Anmelden zum Antworten
Dieses Thema wurde gelöscht. Nur Nutzer mit entsprechenden Rechten können es sehen.
  • S Online
    S Online
    Schimi
    schrieb am zuletzt editiert von Schimi
    #1

    Zendure CLUSTER Batteriesteuerung (TypeScript)

    Funktionsweise

    Dieses Skript steuert ein Cluster aus Zendure AC-Batterien und optimiert den Lade- und Entladebetrieb basierend auf Netzbezug, PV-Erzeugung, Wetter, Heizbedarf und dynamischen Strompreisen (Tibber).
    Kernfunktionen:

    • Cluster-Management: Verteilt Lade-/Entladeleistung auf mehrere Batterien, nutzt "Sticky Master" bei geringen Lasten und "Weighted Parallel" bei höheren Lasten.
    • PI-Regler: Regelt den Netzbezug auf Zielwert (z. B. 0 W) unter Berücksichtigung von Deadband, Hysterese und maximalen Lade-/Entladeleistungen.
    • Prädiktive Analyse: Nutzt historische Verbrauchsdaten (SQL), Wetterdaten, PV-Vorhersage und Tibber-Preise zur Vorhersage von Ladebedarf und optimalen Zeitfenstern.
    • Effizienzberechnung: Bestimmt die tatsächliche Lade-/Entladeeffizienz der Units, bevorzugt Lifetime-Zähler, optional manuell überschreibbar.
    • Sicherheitslogik: Cooldowns, Mindestlaufzeiten für Master-Wechsel, Reserve-Schutz für Heizung und PV-Prognosebasierte Ladevermeidung.

    Konfiguration

    1. System-Einstellungen (SYSTEM_CONFIG)

    • MQTT_BASE_PATH: Basis-Pfad für MQTT-Kommunikation.
    • INFO_LOGS, DEBUG: Logging-Level.
    • SQL_INSTANCE_ID: SQL-Adapter für historische Daten (erforderlich für prädiktives Laden).
    • Feature Flags: ENABLE_TIBBER, ENABLE_EVCC, ENABLE_PREDICTIVE_CHARGE, ENABLE_WEATHER_FORECAST.
    • PI-Regler: REGEL_INTERVALL_MS, TARGET_W, DEADBAND_W, HYSTERESIS_SWITCH_W, PID-Parameter (KP_N, KI_N, KAW, LEAK).
    • Wirtschaftlichkeit: MIN_PRICE_FLOOR_CT_KWH, HISTORY_DAYS.
    • Heizungsreserve: HEATING_START_TEMP_C, HEATING_KWH_PER_DEGREE, MAX_HEATING_RESERVE_KWH.

    2. Cluster-Konfiguration (CLUSTER_CONFIG)

    • MIN_LOAD_PARALLEL_W: Last-Schwelle für parallele Batterien.
    • SOC_SWITCH_HYSTERESIS: Hysterese für Master-Wechsel.
    • MIN_MASTER_RUNTIME_MS: Mindestlaufzeit eines Masters.
    • UNITS: Liste der Batteriesysteme mit Parametern wie id, deviceId, capacityKWh, maxChargeW, maxDischargeW, Cooldown, Energie-IDs und optionaler Effizienz-Override.

    3. Zentrale Datenpunkte (GLOBAL_IDS)

    • Pflicht: netz (aktuelle Netzleistung).
    • Optional, aber empfohlen: Temperatur, Heizlast, WW-Last, PV-Ertrag, Wallboxverbrauch.
    • Tibber: Import/Export-KWh, Preis-JSON für heute und morgen.
    • Steuerung: EVCC-Modus, Urlaubsmodus.
    • Outputs: Cluster-Status, SoC, TotalPower, Verbrauchsprofil.

    4. PV Forecast Adapter (PV_FC_IDS)

    • Optional: IDs für aktuelle und prognostizierte PV-Erträge. Leerstring, wenn nicht vorhanden.

    Kurze Anleitung zur Inbetriebnahme

    1. MQTT-Pfade und SQL-Instanz konfigurieren.
    2. Cluster-Einheiten (UNITS) anlegen und Parameter anpassen.
    3. GLOBAL_IDS korrekt zuordnen (Netz, PV, Tibber, Verbraucher).
    4. Optional: Feature Flags aktivieren (ENABLE_TIBBER, ENABLE_EVCC etc.).
    5. Skript starten; es erzeugt automatisch die notwendigen Zustände (clusterStatus, clusterTotalSoC, clusterTotalPower etc.).
    6. Prüfen der Logs (INFO_LOGS) für Status und Debug-Informationen.

    Hinweis: SQL-Logging sollte "Nur Änderungen" aktiviert haben, Aufbewahrung ≥ HISTORY_DAYS.
    Für Batterien ohne Shelly-Messung wird Effizienz standardmäßig auf 90 % gesetzt.

    letztes Update: 27.01.2026 - 15:20

    /**
    * Batteriesteuerung Zendure CLUSTER (TypeScript)
    * * CHANGELOG:
    * - v8.0.34-TS: [27.01.2026] - HARDENING: Tibber-Referenzpreis benötigt min. 8 Slots (2h) Vorschau. Schutz vor API-Verzögerung am Abend.
    * - v8.0.33-TS: [27.01.2026] - DOCS: Vollständige Dokumentations-Überarbeitung.
    * - v8.0.32-TS: [27.01.2026] - CONFIG: 'RECORD_LIMIT' auf 100k fixiert.
    * * KONTEXT:
    * - Hardware: 1-3x Zendure SolarFlow 2400 AC (Reiner AC-Betrieb) + Shelly 3EM/Pro3EM
    * - Architektur: Master-Controller regelt Netzbezug (PI), Dispatcher verteilt Last auf Units.
    * * ZIELE:
    * - Maximale Autarkie bei minimiertem Relais-Verschleiß ("Sticky Master").
    * - Langzeit-Wartbarkeit durch KI-optimierte Kommentare.
    */
    
    // =====================================================================================
    // === 1. USER KONFIGURATION ===
    // =====================================================================================
    
    /**
    * Globale Systemeinstellungen
    * Hier werden die grundlegenden Verhaltensweisen des Skripts definiert.
    */
    const SYSTEM_CONFIG = {
       // --- MQTT ---
       /** * Basis-Pfad im ioBroker Objects-Baum. 
        * @example "mqtt.0.Zendure" 
        * WICHTIG: Ohne abschließenden Punkt! 
        */
       MQTT_BASE_PATH: "mqtt.2.Zendure",
    
       // --- LOGGING & SYSTEM ---
       /** Gibt verständliche Statusmeldungen (Laden/Entladen/Cluster-Wechsel) im Log aus. Empfohlen: true */
       INFO_LOGS: true,                  
       /** Zeigt technische Details (PI-Regler-Interna, SQL-Fehler). Nur bei Fehlersuche: true */
       DEBUG: false,                     
       /** * Instanz-ID des SQL-Adapters. 
        * @KI_HINWEIS Zwingend erforderlich für 'getHistoryDeltas'. Ohne SQL funktioniert die KI-Prognose nicht.
        */
       SQL_INSTANCE_ID: 'sql.0',
    
       // --- FEATURE FLAGS ---
       /** Aktiviert Preis-Optimierung. Benötigt gefüllte Tibber-Datenpunkte in GLOBAL_IDS. */
       ENABLE_TIBBER: true,              
       /** Reagiert auf EVCC-Status (z.B. Ladesperre während Auto-Ladung). */
       ENABLE_EVCC: true,                
       /** Berechnet Ladebedarf basierend auf Historie & Wetter. Benötigt SQL-Logging auf Verbrauchs-IDs. */
       ENABLE_PREDICTIVE_CHARGE: true,   
       /** Passt Heiz-Reserve dynamisch an Außentemperatur an. */
       ENABLE_WEATHER_FORECAST: true,    
    
       // --- PI-REGLER (Global für Netzbezug) ---
       /** * Regelintervall in Millisekunden. 
        * @KI_HINWEIS 2000ms ist ein guter Kompromiss aus Reaktionszeit und Shelly/MQTT-Latenz. 
        * Zu kleine Werte (<1000ms) führen zu Schwingungen.
        */
       REGEL_INTERVALL_MS: 2000,         
       /** Zielwert am Netzanschlusspunkt in Watt. 0 = Nulleinspeisung. */
       TARGET_W: 0,                      
       /** * Totzone in Watt.
        * @KI_HINWEIS Regelabweichungen innerhalb dieses Bereichs werden ignoriert (Error = 0),
        * um unnötige Regelvorgänge bei Grundrauschen zu verhindern.
        */
       DEADBAND_W: 30,                   
       /** Hysterese für Richtungswechsel (Laden <-> Entladen) in Watt. Schont die Relais. */
       HYSTERESIS_SWITCH_W: 200,         
       
       // Tuning (PID-Parameter) - Vorsicht beim Ändern!
       KP_N: 0.70,                       // Proportional: Reaktion auf aktuellen Fehler
       KI_N: 0.08,                       // Integral: Beseitigung von Restfehlern (Memory)
       KAW: 0.9,                         // Anti-Windup: Begrenzung des Integrals
       LEAK: 0.998,                      // Leak: Langsames Vergessen alter Integral-Werte (Drift-Schutz)
    
       // --- WIRTSCHAFTLICHKEIT ---
       /** Unter diesem Strompreis (ct/kWh) wird der Akku nicht entladen (Entladesperre). */
       MIN_PRICE_FLOOR_CT_KWH: 23,       
       /** * Zeitraum für die Analyse des Verbrauchsverhaltens in Tagen.
        * @KI_HINWEIS Bestimmt, wie weit 'getHistoryDeltas' zurückblickt.
        * SQL-Daten sollten für diesen Zeitraum verfügbar sein.
        */
       HISTORY_DAYS: 21,                 
       
       // --- HEIZUNG / KI ---
       /** Ab welcher Außentemperatur (°C) beginnt die Reserve-Bildung für Heizung? */
       HEATING_START_TEMP_C: 15,         
       /** Wie viel kWh pro Grad unter Start-Temp werden reserviert? */
       HEATING_KWH_PER_DEGREE: 0.4,      
       /** Absolute Obergrenze der Reserve in kWh (Schutz vor zu hoher Reservierung). */
       MAX_HEATING_RESERVE_KWH: 6.0      
    };
    
    /**
    * Cluster-Konfiguration
    * Definiert das Zusammenspiel der Batterien.
    */
    const CLUSTER_CONFIG = {
       /** * Ab welcher Gesamtlast (Watt) sollen mehrere Akkus parallel laufen?
        * @KI_HINWEIS Unter diesem Wert greift die "Sticky Master" Logik (nur einer aktiv), 
        * um Wandlerverluste zu minimieren.
        */
       MIN_LOAD_PARALLEL_W: 100, 
       
       /** Hysterese (%) für den Wechsel des Masters im Low-Load Bereich. */
       SOC_SWITCH_HYSTERESIS: 5,
       
       /** Mindestlaufzeit eines Masters (ms), bevor gewechselt werden darf (Ping-Pong Schutz). */
       MIN_MASTER_RUNTIME_MS: 300000, 
    
       /** Liste der Batterien im Cluster */
       UNITS: [
           { 
               id: "BAT_1",                  // Interne ID für Logs (frei wählbar)
               deviceId: "HO........................",  // MQTT ID (Topic-Name des Geräts)
               enabled: true,                
               capacityKWh: 8.64,            // Gesamtkapazität der Unit (z.B. 4x AB2000)
               maxChargeW: 2400,             // Hardware-Limit
               maxDischargeW: 2400,          
               acModeCooldownMs: 60000,      // Schutzzeit nach Moduswechsel (Hardware-Vorgabe Zendure)
               
               // Energiemessung (Shelly)
               // @KI_HINWEIS Werden für die Effizienzberechnung benötigt.
               // Falls nicht vorhanden: Leerstrings "" eintragen (Effizienz dann fix 90%).
               energyId: "shelly.0.shellyplugsg3#d0cf13daf7f0#1.Relay0.Energy", 
               returnedEnergyId: "shelly.0.shellyplugsg3#d0cf13daf7f0#1.Relay0.ReturnedEnergy",
    
               // Optional: Override der Effizienz-Berechnung.
               // Falls null/undefined: Automatische Berechnung aus Shelly-Werten.
               // Falls Zahl (z.B. 0.90): Fixer Wert 90%.
               // manualEfficiency: 0.90 
           }
           // Weitere Units hier hinzufügen...
       ] as UnitConfig[]
    };
    
    /**
    * Zentrale Datenpunkte
    * * KONFIGURATIONS-LEGENDE:
    * - [PFLICHT]: Muss konfiguriert sein, sonst läuft das Skript nicht.
    * - [OPTIONAL]: Leerstring "" eintragen, um Feature zu deaktivieren.
    * - [SQL]: SQL-Logging im ioBroker aktivieren ("Nur Änderungen", Aufbewahrung >= HISTORY_DAYS).
    */
    const GLOBAL_IDS = {
       // [PFLICHT] Aktuelle Netzleistung (Watt). Negativ=Einspeisung, Positiv=Bezug.
       netz: "shelly.0.SHEM-3#8CAAB5619A05#1.Total.InstantPower",
       
       // --- Wetter & KI ---
       // [OPTIONAL] [SQL EMPFOHLEN] Außentemperatur (°C). Wichtig für Heizprognose.
       temperature: "0_userdata.0.zendure.KI.Temperatur_Mittelwert",
       
       // [OPTIONAL] [SQL PFLICHT] Verbraucher, die NICHT aus dem Akku bedient werden (zum Rausrechnen aus Profil).
       // Wenn Feature nicht genutzt: "" eintragen.
       heatLoadTotal: "0_userdata.0.zendure.KI.Heizung_Leistung_Tag", 
       wwLoadTotal: "0_userdata.0.zendure.KI.WW_Leistung_Tag",        
       wbTotalKWh: "0_userdata.0.zendure.KI.Wallboxen_Verbrauch",     
       
       // [OPTIONAL] Wettervorhersage (Min-Temp Heute/Morgen). Für Frost-Schutz Logik.
       forecastMinTempToday: "0_userdata.0.wetter_com.day_0.temp_min",
       forecastMinTempTomorrow: "0_userdata.0.wetter_com.day_1.temp_min",
       
       // [OPTIONAL] [SQL PFLICHT] PV-Ertrag Heute (kWh). Für Genauigkeits-Check der Prognose.
       pvTodayKWh: "0_userdata.0.PV-Anlage.Erzeugt_heute",
       
       // --- Tibber ---
       // [OPTIONAL] [SQL PFLICHT] Zählerstände Import/Export für Verbrauchsprofil-Bildung.
       netImportTotalKWh: "tibberlink.0.LocalPulse.0.Import_total",
       netExportTotalKWh: "tibberlink.0.LocalPulse.0.Export_total",
       // [OPTIONAL] Preis-Daten (JSON).
       tibberPricesToday: "tibberlink.0.Homes.1111111-22222222-333333-444444.PricesToday.json",
       tibberPricesTomorrow: "tibberlink.0.Homes.1111111-22222222-333333-444444.PricesTomorrow.json",
       
       // --- Steuerung ---
       // [OPTIONAL] Externe Steuerung (1=Auto, 2=Sperre, 3=Zwangsladen).
       evccModus: "0_userdata.0.zendure.EVCC_Modus",
       // [OPTIONAL] Urlaubsmodus (true/false).
       vacationMode: "0_userdata.0.Sonstiges.UrlaubsModus_An",
       
       // --- Outputs (Automatisch erstellt) ---
       // Diese Datenpunkte werden vom Skript erzeugt. Keine Änderung nötig.
       lastPVAccuracy: "0_userdata.0.zendure.KI.lastPVAccuracy",
       consumptionProfile: "0_userdata.0.zendure.KI.ConsumptionProfile",
       clusterTotalSoC: "0_userdata.0.zendure.Cluster.TotalSoC",
       clusterTotalPower: "0_userdata.0.zendure.Cluster.TotalPower",
       clusterStatus: "0_userdata.0.zendure.Cluster.Status"
    };
    
    /**
    * PV Forecast Adapter IDs
    * [OPTIONAL] Wenn Adapter nicht installiert, Strings leer lassen ("").
    */
    const PV_FC_IDS = {
       now: "pvforecast.0.summary.energy.now",                   
       rest: "pvforecast.0.summary.energy.nowUntilEndOfDay",     
       total: "pvforecast.0.summary.energy.today",               
       tomorrow: "pvforecast.0.summary.energy.tomorrow",
       jsonData: "pvforecast.0.summary.JSONData"   
    };
    
    
    // =====================================================================================
    // === 2. INTERFACES & TYPES ===
    // =====================================================================================
    
    interface UnitConfig {
       id: string; deviceId: string; enabled: boolean; capacityKWh: number;
       maxChargeW: number; maxDischargeW: number; acModeCooldownMs: number;
       energyId?: string; returnedEnergyId?: string;
       manualEfficiency?: number; // Override
    }
    
    interface UnitRuntimeState {
       soc: number; minSoc: number; acMode: string;
       usableSoC: number; isAvailable: boolean; canCharge: boolean;     
       lastSwitchTime: number; currentTargetW: number; 
       efficiency: number;
       ids: { acMode: string; acModeSet: string; inputSet: string; outputSet: string; soc: string; minSoc: string; }
    }
    
    interface ClusterState {
       totalSoC: number; totalCapacityKWh: number; totalUsableKWh: number;     
       virtualChargeLimit: number; virtualDischargeLimit: number; 
       activeMasterId: string | null; lastMasterSwitch: number;
       averageEfficiency: number;
    }
    
    interface ControlCache { 
       isTibberChargeNow: boolean; isTibberDischargeNow: boolean; isPriceBelowFloor: boolean; 
       currentPrice: number; predictedChargeKWh: number; globalReserveKWh: number;
       pvForecastAvoid: boolean;
    }
    
    interface TibberPriceProcessed { start: Date; price: number; durationMs: number; }
    interface TimeInterval { start: number; end: number; }
    interface SqlEntry { val: number | null; ts: number; }
    interface SqlResult { result: SqlEntry[]; error?: string; }
    interface DeltaData { time: number; deltaKWh: number; }
    interface DailyBucket { import: number; export: number; pv: number; wb: number; batOut: number; wd: number; }
    interface ConsumptionProfile { [weekday: number]: { [time: string]: number[]; }; }
    
    const AC_MODES = { INPUT: "Input mode", OUTPUT: "Output mode" };
    
    // =====================================================================================
    // === 3. GLOBALE STATES ===
    // =====================================================================================
    
    const units = new Map<string, UnitRuntimeState>();
    
    const cluster: ClusterState = {
       totalSoC: 0, totalCapacityKWh: 0, totalUsableKWh: 0, virtualChargeLimit: 0, virtualDischargeLimit: 0,
       activeMasterId: null, lastMasterSwitch: 0, averageEfficiency: 0.9 
    };
    
    let integral = 0.0;
    const controlCache: ControlCache = {
       isTibberChargeNow: false, isTibberDischargeNow: false, isPriceBelowFloor: false, currentPrice: 0,
       predictedChargeKWh: 0, globalReserveKWh: 0, pvForecastAvoid: false
    };
    
    let tibberPriceData: TibberPriceProcessed[] = [];
    let cheapestIntervals: TimeInterval[] = [];
    let expensiveIntervals: TimeInterval[] = [];
    
    let historicalConsumptionProfile: ConsumptionProfile = {};
    let lastValidAccuracy: number | null = null;
    let dynamicPriceReference = 0;
    let lastStatusLog = "";
    
    // =====================================================================================
    // === 4. HELPER FUNCTIONS ===
    // =====================================================================================
    
    function logInfo(msg: string) { if(SYSTEM_CONFIG.INFO_LOGS) log(`[Cluster] ${msg}`, 'info'); }
    function logWarn(msg: string) { log(`[Cluster WARN] ${msg}`, 'warn'); }
    function logDebug(msg: string) { if(SYSTEM_CONFIG.DEBUG) log(`[Cluster DEBUG] ${msg}`, 'debug'); }
    
    function zendurePath(deviceId: string, type: string, endpoint: string): string {
       return `${SYSTEM_CONFIG.MQTT_BASE_PATH}.${type}.${deviceId}.${endpoint}`;
    }
    function safeGetNumber(id: string, defaultVal: number): number {
       try { if (!id) return defaultVal; const s = getState(id); return (s && s.val != null && !isNaN(Number(s.val))) ? Number(s.val) : defaultVal; } catch { return defaultVal; }
    }
    function safeGetString(id: string, defaultVal: string): string {
       try { if (!id) return defaultVal; const s = getState(id); return (s && s.val != null) ? String(s.val) : defaultVal; } catch { return defaultVal; }
    }
    function normalizeAcMode(val: unknown): string {
       if (!val) return AC_MODES.OUTPUT;
       const s = String(val).toLowerCase();
       return s.includes('input') ? AC_MODES.INPUT : AC_MODES.OUTPUT;
    }
    function delay(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); }
    function sqlRequest(instance: string, command: string, message: any): Promise<SqlResult> {
       return new Promise((resolve, reject) => {
           const timeout = setTimeout(() => reject(new Error("SQL Timeout")), 5000);
           sendTo(instance, command, message, (res: SqlResult) => { clearTimeout(timeout); if(res.error) reject(new Error(res.error)); else resolve(res); });
       });
    }
    
    function logStrategyLists(limitCharge: number, limitDischarge: number) {
       if (!SYSTEM_CONFIG.INFO_LOGS) return;
       const format = (intervals: TimeInterval[], type: string) => {
           if (!intervals.length) return;
           const lines = intervals.slice(0, 5).map(iv => {
               const match = tibberPriceData.find(p => p.start.getTime() === iv.start);
               if (!match) return null;
               const d = match.start;
               const timeStr = `${d.getHours()}:${(d.getMinutes()<10?'0':'')+d.getMinutes()}`;
               return `- ${timeStr} (${match.price.toFixed(2)}ct)`;
           }).filter(l => l !== null);
           if (lines.length) logInfo(`[Strategie] Geplante **${type}**-Zeiten (Auszug): \n${lines.join("\n")}`);
       };
       format(cheapestIntervals, "Lade");
       format(expensiveIntervals, "Entlade");
    }
    
    function getPVForecastForTime(ts: number): number {
       if (pvForecastMap.size === 0) return 0;
       const d = new Date(ts);
       d.setMinutes(0,0,0);
       const key = d.getTime();
       return pvForecastMap.get(key) || 0;
    }
    
    // =====================================================================================
    // === 5. KI & DATA ANALYSIS ===
    // =====================================================================================
    
    /**
    * Holt historische Deltas (Änderungen) eines Datenpunkts aus SQL.
    * * @param id ioBroker ID des Datenpunkts
    * @param scaleFactor Faktor zur Skalierung (z.B. für Wh zu kWh)
    * @returns Array von Zeit/Delta Objekten
    * * @KI_HINWEIS
    * Das Limit wurde auf 100.000 gesetzt, um auch bei sekündlichem Logging
    * über 21 Tage sicherzustellen, dass keine Datenpunkte abgeschnitten werden.
    * Das Skript aggregiert diese Rohdaten dann intern auf 15-Minuten-Slots.
    */
    async function getHistoryDeltas(id: string, scaleFactor: number = 1.0): Promise<DeltaData[]> {
       if (!id || !existsState(id)) return []; 
       const end = Date.now();
       const start = end - (SYSTEM_CONFIG.HISTORY_DAYS * 24 * 3600 * 1000);
       
       // Safety Limit für SQL. 
       // Bei 21 Tagen und sekündlichem Logging können das > 1 Mio Punkte sein.
       // 100k ist ein Kompromiss für Performance vs. Genauigkeit.
       // Empfehlung: Im SQL-Adapter "Nur Änderungen aufzeichnen" aktivieren!
       const RECORD_LIMIT = Math.max(100000, SYSTEM_CONFIG.HISTORY_DAYS * 24 * 60 * 2);
    
       try {
           const result = await sqlRequest(SYSTEM_CONFIG.SQL_INSTANCE_ID, 'getHistory', {
               id: id, options: { start, end, aggregate: 'none', limit: RECORD_LIMIT, returnNewestEntries: true, removeBorderValues: true, ignoreNull: true, ack: true }
           });
           const data = result.result;
           if (!data || data.length < 2) return [];
    
           data.sort((a, b) => a.ts - b.ts);
           const res: DeltaData[] = [];
           let lastVal: number | null = null;
           for(let i=0; i<data.length; i++) {
               if (i % 2000 === 0 && i > 0) await delay(1); // Anti-Blocking
               if (data[i].val === null) continue;
               const curVal = data[i].val as number;
               if (lastVal === null) { lastVal = curVal; continue; }
               let delta = (curVal - lastVal) * scaleFactor;
               if (delta >= 0 && delta < 100) res.push({ time: data[i].ts, deltaKWh: delta });
               lastVal = curVal;
           }
           return res;
       } catch(e) { logDebug(`Fehler SQL ${id}: ${e}`); return []; }
    }
    
    function calculatePVAccuracy(actual: number, fc: number, temp: number, last: number | null): number {
       if (actual < 0.2 && fc < 0.5) return last ?? 0.8;
       if (fc < 0.5) { if (temp < 0) return last ?? 0.5; return last ?? 1.0; }
       const dev = Math.abs(actual - fc);
       const acc = 1.0 - (dev / Math.max(fc * 1.5, 1.0)); 
       return Math.max(0.3, Math.min(1.0, acc));
    }
    
    async function loadTibberPriceData() {
       if(!GLOBAL_IDS.tibberPricesToday) return; 
       let today: any[] = [], tomorrow: any[] = [];
       try {
           const t = getState(GLOBAL_IDS.tibberPricesToday); 
           const m = getState(GLOBAL_IDS.tibberPricesTomorrow);
           if(t && t.val) today = JSON.parse(t.val as string);
           if(m && m.val) tomorrow = JSON.parse(m.val as string);
       } catch(e) {}
       tibberPriceData = [...today, ...tomorrow].map(i => ({ 
           start: new Date(i.startsAt), price: i.total * 100, durationMs: 15 * 60 * 1000 
       }));
    }
    
    const pvForecastMap = new Map<number, number>();
    async function loadPVForecastData() {
       if(!PV_FC_IDS.jsonData) return;
       try {
           const s = getState(PV_FC_IDS.jsonData);
           if(s && s.val) {
               const raw = JSON.parse(s.val as string) as {t:number,y:number}[];
               pvForecastMap.clear();
               raw.forEach(e => pvForecastMap.set(e.t, e.y * 1000)); 
           }
       } catch(e) { logDebug("Fehler beim Laden der PV JSON Daten"); }
    }
    
    function calculateDynamicPriceReference() {
       if (tibberPriceData.length < 4) { 
           dynamicPriceReference = SYSTEM_CONFIG.MIN_PRICE_FLOOR_CT_KWH; 
           return; 
       }
       
       const now = Date.now();
       const future = tibberPriceData.filter(i => (i.start.getTime() + i.durationMs) > now);
    
       // @KI_HINWEIS: Hardening-Patch (v8.0.34)
       // Wenn am späten Abend die Preise für morgen fehlen (API-Verzögerung), haben wir evtl. nur noch 2-3 Slots.
       // Das verzerrt den Durchschnitt massiv (z.B. 3 teure Stunden = sehr hoher Durchschnitt).
       // Daher: Min. 8 Slots (2 Stunden) Vorschau erforderlich, sonst Fallback auf Floor-Preis.
       if (future.length < 8) { 
               logDebug(`[Tibber] Zu wenige Zukunftswerte (${future.length}) für dynamische Referenz. Nutze Fallback.`);
               dynamicPriceReference = SYSTEM_CONFIG.MIN_PRICE_FLOOR_CT_KWH;
               return;
       }
    
       const sorted = [...future].sort((a, b) => b.price - a.price);
       const topSlice = sorted.slice(0, Math.ceil(sorted.length * 0.35)); 
       
       if (topSlice.length) {
           const avgTop = topSlice.reduce((sum, i) => sum + i.price, 0) / topSlice.length;
           dynamicPriceReference = Math.max(avgTop, SYSTEM_CONFIG.MIN_PRICE_FLOOR_CT_KWH);
       }
    }
    
    /**
    * Aktualisiert die Effizienz der Units basierend auf Lifetime-Zählern.
    * * @KI_HINWEIS
    * Wir nutzen Lifetime-Zähler (Total - Returned) statt SQL-Deltas, da diese
    * robuster gegen Verbindungsausfälle sind.
    * Voraussetzung: Shelly ist so konfiguriert, dass er AC-coupled misst
    * (Energy = Bezug + Einspeisung, Returned = Einspeisung).
    */
    async function updateUnitEfficiencies() {
       logInfo("[KI] Starte Effizienz-Berechnung (Basis: Lifetime-Counter)...");
       let totalInput = 0;
       let totalOutput = 0;
       let measuredCount = 0; 
    
       for (const unit of CLUSTER_CONFIG.UNITS) {
           const u = units.get(unit.id);
           if (!u) continue;
           
           if (unit.manualEfficiency) {
               logInfo(`[Effizienz] ${unit.id}: Nutze manuellen Override ${(unit.manualEfficiency * 100).toFixed(1)}%.`);
               u.efficiency = unit.manualEfficiency;
               units.set(unit.id, u);
               totalInput += 10; totalOutput += 10 * u.efficiency; measuredCount++;
               continue;
           }
    
           if (!unit.enabled || !unit.energyId || !unit.returnedEnergyId) {
                u.efficiency = 0.9; 
                units.set(unit.id, u);
                continue;
           }
    
           // --- LIFETIME CALCULATION ---
           // ⚠️ GILT NUR für ShellyPlugSG3 im AC-Coupled Zendure-Setup (Bidirektional messend, absolute Summen)
           // Energy = Total (Import+Export), Returned = Export. Input = Energy - Returned.
           const lifeTotal = safeGetNumber(unit.energyId, 0);
           const lifeRet = safeGetNumber(unit.returnedEnergyId, 0);
           
           const lifeInput = Math.max(0.1, lifeTotal - lifeRet);
           const calcEff = lifeRet / lifeInput;
    
           logInfo(`[Effizienz] ${unit.id}: Lifetime-Input=${lifeInput.toFixed(1)} kWh, Lifetime-Output=${lifeRet.toFixed(1)} kWh`);
    
           let eff = 0.9;
           let isPlausible = false;
    
           if (calcEff > 1.05 || calcEff < 0.4) {
                logWarn(`[Effizienz] Unplausible Messwerte bei ${unit.id} (Effizienz ${Math.round(calcEff*100)}%). Nutze Fallback 90% oder setze 'manualEfficiency'.`);
                eff = 0.9;
           } else {
                eff = Math.min(1.0, calcEff); 
                isPlausible = true;
                logInfo(`[Effizienz] ${unit.id}: Berechnet ${(eff*100).toFixed(1)}%`);
           }
    
           u.efficiency = eff;
           units.set(unit.id, u);
           
           if (isPlausible) {
               totalInput += lifeInput;
               totalOutput += lifeRet;
               measuredCount++;
           }
       }
    
       if (totalInput > 0) {
           cluster.averageEfficiency = totalOutput / totalInput;
       } else {
           cluster.averageEfficiency = 0.9;
       }
       
       logInfo(`[Effizienz] Cluster Durchschnitt (Gewichtet aus ${measuredCount} Units): ${(cluster.averageEfficiency*100).toFixed(1)}%`);
    }
    
    /**
    * Erstellt ein Verbrauchsprofil basierend auf historischen Daten.
    * Nutzt 15-Minuten Buckets.
    */
    async function updateConsumptionProfileFromSQL() {
       logInfo("[KI] Starte SQL-Analyse für Verbrauchsprofil (21-Tage Historie)...");
       historicalConsumptionProfile = {}; 
       
       const [imp, exp, pv, wb] = await Promise.all([
           getHistoryDeltas(GLOBAL_IDS.netImportTotalKWh), getHistoryDeltas(GLOBAL_IDS.netExportTotalKWh),
           getHistoryDeltas(GLOBAL_IDS.pvTodayKWh), getHistoryDeltas(GLOBAL_IDS.wbTotalKWh)
       ]);
    
       const buckets: { [d: string]: { [hm: string]: DailyBucket } } = {}; 
       const addToBucket = (ds: DeltaData[], k: keyof DailyBucket) => {
           ds.forEach(d => {
               const dt = new Date(d.time);
               const dk = dt.toISOString().split('T')[0];
               const hm = `${('0'+dt.getHours()).slice(-2)}:${('0'+(Math.floor(dt.getMinutes()/15)*15)).slice(-2)}`;
               if (!buckets[dk]) buckets[dk] = {};
               if (!buckets[dk][hm]) buckets[dk][hm] = { import:0, export:0, pv:0, wb:0, batOut:0, wd: dt.getDay() };
               buckets[dk][hm][k] += d.deltaKWh;
           });
       };
    
       addToBucket(imp, 'import'); addToBucket(exp, 'export'); addToBucket(pv, 'pv'); addToBucket(wb, 'wb');
    
       for (const unit of CLUSTER_CONFIG.UNITS) {
           if (!unit.enabled) continue;
           if (unit.returnedEnergyId) {
               const d = await getHistoryDeltas(unit.returnedEnergyId, 0.001); 
               addToBucket(d, 'batOut');
           }
       }
    
       for (const dk in buckets) {
           for (const hm in buckets[dk]) {
               const b = buckets[dk][hm];
               let rawCons = (b.import + b.pv + b.batOut) - b.export;
               if (rawCons < 0) rawCons = 0; 
               const finalCons = Math.max(0, rawCons - (b.wb || 0));
    
               if (!historicalConsumptionProfile[b.wd]) historicalConsumptionProfile[b.wd] = {};
               if (!historicalConsumptionProfile[b.wd][hm]) historicalConsumptionProfile[b.wd][hm] = [];
               historicalConsumptionProfile[b.wd][hm].push(finalCons);
           }
       }
       
       const sortedProfile: any = {};
       Object.keys(historicalConsumptionProfile).forEach(wd => {
           sortedProfile[wd] = {};
           Object.keys(historicalConsumptionProfile[Number(wd)]).sort().forEach(hm => {
               sortedProfile[wd][hm] = historicalConsumptionProfile[Number(wd)][hm];
           });
       });
       setState(GLOBAL_IDS.consumptionProfile, JSON.stringify(sortedProfile), true);
       logInfo("[KI] Verbrauchsprofil aktualisiert.");
    }
    
    /**
    * Führt die prädiktive Analyse durch (Wetter, Preise, Bedarf).
    * Aktualisiert 'cheapestIntervals' und 'controlCache'.
    */
    async function runPredictiveAnalysis() {
       await loadTibberPriceData();
       await loadPVForecastData();
       calculateDynamicPriceReference();
    
       const actPV = safeGetNumber(GLOBAL_IDS.pvTodayKWh, 0);
       const fcSoFar = safeGetNumber(PV_FC_IDS.now, 0);
       const minTemp = parseFloat(safeGetString(GLOBAL_IDS.forecastMinTempToday, "99")) || 99;
       const acc = calculatePVAccuracy(actPV, fcSoFar, minTemp, lastValidAccuracy);
       setState(GLOBAL_IDS.lastPVAccuracy, Math.round(acc * 100), true);
       lastValidAccuracy = acc;
    
       const curTemp = safeGetNumber(GLOBAL_IDS.temperature, 10);
       const heatingDelta = Math.max(0, SYSTEM_CONFIG.HEATING_START_TEMP_C - curTemp);
       const dailyExtraKWh = heatingDelta * SYSTEM_CONFIG.HEATING_KWH_PER_DEGREE;
       const heatingPowerBaseW = (dailyExtraKWh * 1000) / 24; 
    
       // Verbrauch für die Nacht berechnen (Zeitstempel-basiert)
       const now = new Date();
       let profileDemandSum = 0;
       
       // Simulationszeitraum: Nächste 16 Stunden (4 * 15min pro Stunde)
       for(let i=0; i<16*4; i++) { 
           const simTs = now.getTime() + i * 15 * 60 * 1000;
           const simDate = new Date(simTs);
           const hour = simDate.getHours();
           
           // Nachtfenster: 22:00 bis 06:00
           if (hour >= 22 || hour < 6) {
                const wd = simDate.getDay();
                const hm = `${('0'+hour).slice(-2)}:${('0'+(Math.floor(simDate.getMinutes()/15)*15)).slice(-2)}`;
                
                let baseLoad = 0;
                if (historicalConsumptionProfile[wd] && historicalConsumptionProfile[wd][hm]) {
                   const vals = historicalConsumptionProfile[wd][hm];
                   if (vals.length) baseLoad = vals.reduce((a,b)=>a+b,0)/vals.length;
                }
                
                const totalLoadKWh = baseLoad + (heatingPowerBaseW / 1000 * 0.25); 
                profileDemandSum += totalLoadKWh;
           }
       }
    
       const pvTom = safeGetNumber(PV_FC_IDS.tomorrow, 0) * acc;
       const neededReserve = Math.max(0.5, profileDemandSum - (pvTom * 0.1)); 
       controlCache.globalReserveKWh = neededReserve;
    
       logInfo(`[KI-Analyse] Matrix-Reserve: ${neededReserve.toFixed(2)}kWh (Profil+Heizung: ${profileDemandSum.toFixed(2)}kWh)`);
    
       const nowMs = Date.now();
       const bep = dynamicPriceReference * cluster.averageEfficiency;
       
       cheapestIntervals = tibberPriceData
           .filter(i => {
               if ((i.start.getTime() + i.durationMs) <= nowMs) return false;
               if (i.price >= bep) return false; 
               const pvFc = getPVForecastForTime(i.start.getTime());
               if (pvFc > 800 && i.price > 0) return false; // Nicht laden wenn Sonne scheint
               return true;
           })
           .map(i => ({ start: i.start.getTime(), end: i.start.getTime() + i.durationMs }));
           
       expensiveIntervals = tibberPriceData
           .filter(i => (i.start.getTime() + i.durationMs) > nowMs && i.price > dynamicPriceReference)
           .map(i => ({ start: i.start.getTime(), end: i.start.getTime() + i.durationMs }));
    
       logStrategyLists(bep, dynamicPriceReference);
       updateControlFlags();
    }
    
    function updateControlFlags() {
       if (!tibberPriceData.length) return;
       const nowMs = Date.now();
       controlCache.isTibberChargeNow = cheapestIntervals.some(i => nowMs >= i.start && nowMs < i.end);
       controlCache.isTibberDischargeNow = expensiveIntervals.some(i => nowMs >= i.start && nowMs < i.end);
       
       const cur = tibberPriceData.find(i => nowMs >= i.start.getTime() && nowMs < (i.start.getTime() + i.durationMs));
       if (cur) {
           controlCache.currentPrice = cur.price;
           controlCache.isPriceBelowFloor = cur.price < SYSTEM_CONFIG.MIN_PRICE_FLOOR_CT_KWH;
           const pvNow = getPVForecastForTime(nowMs);
           controlCache.pvForecastAvoid = (pvNow > 800 && cur.price > 0);
       }
    }
    
    // =====================================================================================
    // === 6. DISPATCHER LOGIC ===
    // =====================================================================================
    
    /**
    * Sammelt die Status aller Units und berechnet die Cluster-Gesamtsummen.
    */
    function updateClusterState() {
       let sumStoredKWh = 0, sumCap = 0, sumUsableKWh = 0, sumChargeW = 0, sumDischargeW = 0;
       CLUSTER_CONFIG.UNITS.forEach(cfg => {
           if (!cfg.enabled) return;
           let u = units.get(cfg.id);
           if (!u) return;
    
           u.soc = safeGetNumber(u.ids.soc, 0);
           u.minSoc = safeGetNumber(u.ids.minSoc, 10);
           u.acMode = normalizeAcMode(safeGetString(u.ids.acMode, AC_MODES.OUTPUT));
           u.usableSoC = Math.max(0, u.soc - u.minSoc);
           u.isAvailable = u.soc > (u.minSoc + 1);
           u.canCharge = u.soc < 100;
    
           sumStoredKWh += (u.soc / 100) * cfg.capacityKWh;
           sumUsableKWh += (u.usableSoC / 100) * cfg.capacityKWh;
           sumCap += cfg.capacityKWh;
           if (u.canCharge) sumChargeW += cfg.maxChargeW;
           if (u.isAvailable) sumDischargeW += cfg.maxDischargeW;
           units.set(cfg.id, u);
       });
    
       cluster.totalSoC = sumCap > 0 ? ((sumStoredKWh / sumCap) * 100) : 0;
       cluster.totalCapacityKWh = sumCap;
       cluster.totalUsableKWh = sumUsableKWh;
       cluster.virtualChargeLimit = sumChargeW;
       cluster.virtualDischargeLimit = sumDischargeW;
       setState(GLOBAL_IDS.clusterTotalSoC, parseFloat(cluster.totalSoC.toFixed(1)), true);
    }
    
    /**
    * Verteilt die Ziel-Leistung auf die verfügbaren Units.
    * Nutzt "Sticky Master" (Low Load) oder "Weighted Parallel" (High Load).
    */
    function distributePower(totalTargetW: number): Map<string, number> {
       const distribution = new Map<string, number>();
       const absTarget = Math.abs(totalTargetW);
       const isCharging = totalTargetW > 0;
       
       const candidates = CLUSTER_CONFIG.UNITS.filter(cfg => {
           const u = units.get(cfg.id);
           if (!u || !cfg.enabled) return false;
           return isCharging ? u.canCharge : u.isAvailable;
       });
    
       if (candidates.length === 0) return distribution; 
    
       // STICKY MASTER (< 100W)
       if (absTarget < CLUSTER_CONFIG.MIN_LOAD_PARALLEL_W) {
           let masterId = cluster.activeMasterId;
           const currentMasterCfg = candidates.find(c => c.id === masterId);
           let masterNeedsSwitch = !currentMasterCfg; 
           
           if (!masterNeedsSwitch && currentMasterCfg) {
               const currentMasterState = units.get(masterId!)!;
               if ((Date.now() - cluster.lastMasterSwitch) > CLUSTER_CONFIG.MIN_MASTER_RUNTIME_MS) {
                   const better = candidates.find(c => {
                       if (c.id === masterId) return false;
                       const u = units.get(c.id)!;
                       if (isCharging) return u.soc < (currentMasterState.soc - CLUSTER_CONFIG.SOC_SWITCH_HYSTERESIS);
                       else return u.soc > (currentMasterState.soc + CLUSTER_CONFIG.SOC_SWITCH_HYSTERESIS);
                   });
                   if (better) {
                       logInfo(`[Dispatcher] Master-Wechsel: ${masterId} -> ${better.id}`);
                       masterId = better.id;
                       cluster.lastMasterSwitch = Date.now();
                   }
               }
           }
           if (!masterId || masterNeedsSwitch) {
               candidates.sort((a, b) => {
                   const uA = units.get(a.id)!, uB = units.get(b.id)!;
                   return isCharging ? uA.soc - uB.soc : uB.soc - uA.soc; 
               });
               masterId = candidates[0].id;
               cluster.lastMasterSwitch = Date.now();
           }
           cluster.activeMasterId = masterId;
           distribution.set(masterId!, totalTargetW);
       } 
       // PARALLEL (> 100W)
       else {
           let totalWeight = 0;
           const weightedUnits: { id: string, weight: number, cfg: UnitConfig }[] = [];
           candidates.forEach(cfg => {
               const u = units.get(cfg.id)!;
               let weight = isCharging ? ((100 - u.soc) + 1) : (u.usableSoC + 1);
               totalWeight += weight;
               weightedUnits.push({ id: cfg.id, weight, cfg });
           });
           let remainingW = totalTargetW;
           weightedUnits.forEach((item, index) => {
               let targetW = (index === weightedUnits.length - 1) ? remainingW : Math.round(totalTargetW * (item.weight / totalWeight));
               
               // Hard Limit Check auch für letzte Unit
               if (isCharging) targetW = Math.min(targetW, item.cfg.maxChargeW);
               else targetW = Math.max(targetW, -item.cfg.maxDischargeW);
               
               distribution.set(item.id, targetW);
               remainingW -= targetW;
           });
       }
       return distribution;
    }
    
    /**
    * Sendet die berechneten Leistungen an die Hardware (via MQTT).
    * Beachtet Cooldowns und erzwingt 0W während Wartezeiten.
    */
    function applyClusterPower(distribution: Map<string, number>) {
       let totalP = 0;
       for (const cfg of CLUSTER_CONFIG.UNITS) {
           if (!cfg.enabled) continue;
           const u = units.get(cfg.id);
           if (!u) continue;
           
           const targetW = distribution.get(cfg.id) || 0;
           const absW = Math.abs(targetW);
           const targetMode = targetW > 0 ? AC_MODES.INPUT : AC_MODES.OUTPUT;
           const cooldownExpired = (Date.now() - u.lastSwitchTime) > cfg.acModeCooldownMs;
           const needsSwitch = normalizeAcMode(u.acMode) !== normalizeAcMode(targetMode);
           
           if (needsSwitch && absW > 0) {
               if (cooldownExpired) {
                   setState(u.ids.acModeSet, targetMode);
                   u.lastSwitchTime = Date.now();
                   setState(u.ids.inputSet, "0");
                   setState(u.ids.outputSet, "0");
                   u.currentTargetW = targetW;
                   units.set(cfg.id, u);
                   logInfo(`[${cfg.id}] Switch zu ${targetMode}`);
                   continue;
               } else {
                   // @KI_HINWEIS: Safety First. Wenn Cooldown aktiv, zwingend 0W senden.
                   setState(u.ids.inputSet, "0");
                   setState(u.ids.outputSet, "0");
                   logInfo(`[${cfg.id}] Warte auf Cooldown... (0W erzwungen)`);
               }
           } else {
               const setID = (normalizeAcMode(u.acMode) === AC_MODES.INPUT) ? u.ids.inputSet : u.ids.outputSet;
               const currentSet = safeGetNumber(setID, 0);
               if (Math.abs(currentSet - absW) > 30 || (absW === 0 && currentSet !== 0)) setState(setID, String(absW));
           }
           
           u.currentTargetW = targetW;
           units.set(cfg.id, u);
           
           if (!needsSwitch) totalP += targetW;
       }
       setState(GLOBAL_IDS.clusterTotalPower, totalP, true);
    }
    
    // =====================================================================================
    // === 7. PI CONTROLLER ===
    // =====================================================================================
    
    function mainControlLoop() {
       updateClusterState();
       const netzW = safeGetNumber(GLOBAL_IDS.netz, 0);
       const evccMode = safeGetNumber(GLOBAL_IDS.evccModus, 1);
       
       let maxCharge = cluster.virtualChargeLimit;
       let maxDischarge = cluster.virtualDischargeLimit;
       let overrideStatus = "";
       
       const forceCharge = (controlCache.isTibberChargeNow || evccMode === 3);
       
       if (forceCharge) {
           maxDischarge = 0;
           overrideStatus = "Force Charge";
           if (controlCache.isTibberChargeNow) overrideStatus += " (Tibber)";
           if (evccMode === 3) overrideStatus += " (EVCC)";
       } else {
           if (evccMode === 2) {
               maxDischarge = 0;
               overrideStatus = "EVCC Sperre";
               if (netzW > -SYSTEM_CONFIG.DEADBAND_W) maxCharge = 0; 
           }
           if (SYSTEM_CONFIG.ENABLE_TIBBER && controlCache.isPriceBelowFloor) {
               maxDischarge = 0;
               overrideStatus = "Eco Price Stop";
           }
           if (!controlCache.isTibberDischargeNow && cluster.totalUsableKWh < controlCache.globalReserveKWh) {
                maxDischarge = 0; 
                overrideStatus = "Reserve Schutz";
           }
       }
       
       let error = SYSTEM_CONFIG.TARGET_W - netzW;
       if (Math.abs(netzW) < SYSTEM_CONFIG.DEADBAND_W) error = 0;
       
       if (maxCharge === 0 && maxDischarge === 0) {
           integral = 0;
           error = 0;
           if (!overrideStatus) overrideStatus = "Cluster nicht bereit";
       }
       
       let output = 0;
       if (forceCharge) {
           integral = 0;
           output = maxCharge;
       } else {
           const NORM_P = 2400; 
           const eNorm = error / NORM_P;
           const dt = SYSTEM_CONFIG.REGEL_INTERVALL_MS / 1000;
           
           integral = (integral * SYSTEM_CONFIG.LEAK) + (eNorm * dt);
           const maxInt = 1.0 / SYSTEM_CONFIG.KI_N; 
           integral = Math.max(-maxInt, Math.min(maxInt, integral));
           output = (SYSTEM_CONFIG.KP_N * eNorm + SYSTEM_CONFIG.KI_N * integral) * NORM_P;
       }
       
       output = Math.max(-maxDischarge, Math.min(maxCharge, output));
       if (Math.abs(output) < SYSTEM_CONFIG.HYSTERESIS_SWITCH_W && !forceCharge && Math.abs(output) < 50) output = 0;
    
       const distribution = distributePower(output);
       applyClusterPower(distribution);
       
       let logState = "Standby", textState = "Standby";
       if (overrideStatus) { logState = overrideStatus; textState = overrideStatus; }
       else if (output > 0) { logState = "Laden"; textState = `Laden (${Math.round(output)}W)`; }
       else if (output < 0) { logState = "Entladen"; textState = `Entladen (${Math.round(output)}W)`; }
       else { logState = "Standby"; textState = "Standby / Leerlauf"; }
    
       if (logState !== lastStatusLog) {
           logInfo(`[Status] Wechsel zu: ${logState} (Aktuell: ${textState})`);
           lastStatusLog = logState;
       }
       setState(GLOBAL_IDS.clusterStatus, textState, true);
    }
    
    // =====================================================================================
    // === 8. INIT ===
    // =====================================================================================
    
    async function createDataPoints() {
       await createStateAsync(GLOBAL_IDS.clusterTotalSoC, { name: "Cluster SoC", type: "number", unit: "%", role: "value.battery" });
       await createStateAsync(GLOBAL_IDS.clusterTotalPower, { name: "Cluster Power", type: "number", unit: "W", role: "value.power" });
       await createStateAsync(GLOBAL_IDS.lastPVAccuracy, { name: "PV Genauigkeit", type: "number", unit: "%", role: "value" });
       await createStateAsync(GLOBAL_IDS.consumptionProfile, { name: "Verbrauchsprofil", type: "string", role: "json" });
       await createStateAsync(GLOBAL_IDS.clusterStatus, { name: "Cluster Status", type: "string", role: "text" });
    }
    
    async function initialize() {
       logInfo("--- Zendure CLUSTER Controller v8.0.32 (LIMIT FIX) Start ---");
       await createDataPoints();
       
       // History laden
       try {
           const acc = getState(GLOBAL_IDS.lastPVAccuracy);
           if(acc && acc.val != null) lastValidAccuracy = Number(acc.val) / 100;
           const prof = getState(GLOBAL_IDS.consumptionProfile);
           if(prof && prof.val) historicalConsumptionProfile = JSON.parse(String(prof.val));
       } catch(e) {}
    
       let initialTotalPower = 0;
    
       // Units registrieren & IST-Zustand lesen
       for (const cfg of CLUSTER_CONFIG.UNITS) {
           if (!cfg.enabled) continue;
           
           // Live-Werte lesen für "Seamless Handover"
           const curMode = normalizeAcMode(safeGetString(zendurePath(cfg.deviceId, "select", "acMode"), AC_MODES.OUTPUT));
           const curIn = safeGetNumber(zendurePath(cfg.deviceId, "number", "inputLimit.set"), 0);
           const curOut = safeGetNumber(zendurePath(cfg.deviceId, "number", "outputLimit.set"), 0);
           
           // Aktuelle Leistung dieser Unit schätzen
           let currentW = 0;
           if (curMode === AC_MODES.INPUT) currentW = curIn;
           else currentW = -curOut;
           initialTotalPower += currentW;
    
           const uState: UnitRuntimeState = {
               soc: 0, minSoc: 10, acMode: curMode, usableSoC: 0, isAvailable: false, canCharge: false,
               lastSwitchTime: Date.now() - cfg.acModeCooldownMs, 
               currentTargetW: currentW, // Startet mit echtem Wert
               efficiency: 0.9, 
               ids: {
                   acMode: zendurePath(cfg.deviceId, "select", "acMode"),
                   acModeSet: zendurePath(cfg.deviceId, "select", "acMode.set"),
                   inputSet: zendurePath(cfg.deviceId, "number", "inputLimit.set"),
                   outputSet: zendurePath(cfg.deviceId, "number", "outputLimit.set"),
                   soc: zendurePath(cfg.deviceId, "sensor", "electricLevel"),
                   minSoc: zendurePath(cfg.deviceId, "number", "minSoc")
               }
           };
           units.set(cfg.id, uState);
           logInfo(`Unit ${cfg.id} registriert. Startet mit ${currentW}W.`);
       }
    
       // Bumpless Transfer: Integrator "vorladen"
       const initialGrid = safeGetNumber(GLOBAL_IDS.netz, 0);
       if (Math.abs(initialGrid) < SYSTEM_CONFIG.DEADBAND_W) {
           // Netz ist ruhig -> Wir nehmen an, die aktuelle Batterieleistung ist genau richtig.
           // Output = (Kp*e + Ki*Int) * 2400. Wenn e~0, dann Output = Ki*Int*2400.
           // Int = Output / (Ki * 2400).
           if (SYSTEM_CONFIG.KI_N > 0) {
               integral = initialTotalPower / (SYSTEM_CONFIG.KI_N * 2400);
               // Limitieren auf maxInt
               const maxInt = 1.0 / SYSTEM_CONFIG.KI_N; 
               integral = Math.max(-maxInt, Math.min(maxInt, integral));
               logInfo(`[Soft-Start] Netz ist ruhig (${initialGrid}W). Übernehme ${initialTotalPower}W Batterieleistung in Regler (Int=${integral.toFixed(3)}).`);
           }
       } else {
           logInfo(`[Soft-Start] Netzabweichung vorhanden (${initialGrid}W). Starte regulären Regelvorgang.`);
       }
    
       // Initiale Berechnungen
       await updateUnitEfficiencies();
       await runPredictiveAnalysis();
       updateControlFlags();
    
       // Loop starten (Sofort einmal ausführen!)
       mainControlLoop(); 
       setInterval(mainControlLoop, SYSTEM_CONFIG.REGEL_INTERVALL_MS);
       
       schedule('5 * * * *', runPredictiveAnalysis); 
       schedule('0 3 * * *', async () => {
           await updateUnitEfficiencies();
           await updateConsumptionProfileFromSQL();
       }); 
       schedule('*/3 * * * *', updateControlFlags); 
    }
    
    initialize();
    

    1 Antwort Letzte Antwort
    0
    • S Online
      S Online
      Schimi
      schrieb am zuletzt editiert von
      #2

      so, lange dran rumgewerkelt und bei mir funktioniert es recht gut.... habe aber oft das Gefühl das der Akku viel größer sein müsste um es wirklich effektiv zu nutzen.

      Wollte es aber trotzdem mal veröffentlichen um den ganzen HomeAssistent Jungs nicht das Feld alleine zu überlassen ;-)

      Falls kein Interesse besteht (oder es zu kompliziert ist, ist halt sehr speziell) können wir es löschen oder in der Versenkung verschwinden lassen...

      Deshalb auch erstmal nur für ein Gerät, hatte auch kurz eine Version für max. 3, aber mangels Möglichkeit des testens, habe ich es erstmal gelassen

      1 Antwort Letzte Antwort
      0
      • L Online
        L Online
        lesiflo
        Most Active
        schrieb am zuletzt editiert von
        #3

        Hi,
        so was ähnliches habe ich mir auch schon mit javascript gebaut. Allerdings mit mehrere Scripten. Ein einziges wurde mir irgendwann zu unübersichtlich. Bei mir läuft ein Hauptscript für die beiden Hyper, zusätzlich habe ich auch noch eine große PV-Anlage. Weitere Scripts für PV-Forecast, Logging in InfluxDB, Balkendiagrammanzeige ...

        1 Antwort Letzte Antwort
        1
        • S Online
          S Online
          Schimi
          schrieb am zuletzt editiert von
          #4

          habe es mal auf mehrere Geräte erweitert.. die "Single" Regelung läuft bei mir, aber wie gut es mit mehreren läuft kann ich leider nicht testen

          1 Antwort Letzte Antwort
          0
          Antworten
          • In einem neuen Thema antworten
          Anmelden zum Antworten
          • Älteste zuerst
          • Neuste zuerst
          • Meiste Stimmen


          Support us

          ioBroker
          Community Adapters
          Donate

          746

          Online

          32.6k

          Benutzer

          82.1k

          Themen

          1.3m

          Beiträge
          Community
          Impressum | Datenschutz-Bestimmungen | Nutzungsbedingungen | Einwilligungseinstellungen
          ioBroker Community 2014-2025
          logo
          • Anmelden

          • Du hast noch kein Konto? Registrieren

          • Anmelden oder registrieren, um zu suchen
          • Erster Beitrag
            Letzter Beitrag
          0
          • Home
          • Aktuell
          • Tags
          • Ungelesen 0
          • Kategorien
          • Unreplied
          • Beliebt
          • GitHub
          • Docu
          • Hilfe