//-------------------------------------------------------------------------------------
// ### ioBroker Skript: Batteriesteuerung (Zendure) mit PI-Regler & Tibber-KI ###
//
// **v5.5.11 - LOGGING INFO UPDATE (04.01.2026)**
// - DOCU: Kommentare hinzugefügt, welche Datenpunkte zwingend ein SQL-Logging
// benötigen und wie lange die Daten vorgehalten werden müssen.
// (Grundlage für die KI-Historien-Analyse).
//-------------------------------------------------------------------------------------
// --- 1. KONFIGURATION ---
const CONFIG = {
// MQTT Pfade (Anpassen an eigene Instanz)
MQTT_BASE_PATH: "mqtt.2.Zendure",
DEVICE_ID: "HOXXXXXXXXXX",
// Feature-Flags (Zum schnellen Deaktivieren von Teilbereichen)
ENABLE_TIBBER: true, // Aktiviert Preis-Analysen
ENABLE_EVCC: true, // Aktiviert Wallbox-Logik
ENABLE_PV_FORECAST: true, // Berücksichtigt Solar-Vorhersage
ENABLE_PREDICTIVE_CHARGE: true, // Berechnet nötige Lademenge für die Nacht
ENABLE_WEATHER_FORECAST: true, // Nutzt Temperatur für Heizbedarf-Prognose
// Logging & Debugging
INFO_LOGS: true, // Zeigt Status-Wechsel im Log
DEBUG: false, // Zeigt PI-Interna (P/I-Anteile, Fehler) im Log
// System-Instanzen
SQL_INSTANCE_ID: 'sql.0',
// Hardware-Grenzen & Parameter
BATTERY_CAPACITY_KWH: 8.64, // Gesamtkapazität der Batterie
MAX_CHARGE_W: 2400, // Hardware-Limit Laden
MAX_DISCHARGE_W: 2400, // Hardware-Limit Entladen
MIN_SOC_PERCENT: 5, // Tiefentladeschutz (Software-seitig)
// Effizienz (Wichtig für Wirtschaftlichkeits-Berechnung)
// Revert auf Standardwerte (SQL-Analyse war verzerrt durch hohen SoC)
CHARGE_EFFICIENCY: 0.915, // Wirkungsgrad AC->Akku
DISCHARGE_EFFICIENCY: 0.915, // Wirkungsgrad Akku->AC
// -> Roundtrip ca. 83.7%
// Tibber / Wirtschaftlichkeit
MIN_PRICE_FLOOR_CT_KWH: 23, // "Schmerzgrenze": Unter diesem Preis lohnt Entladen meist nicht (Netzbezug günstiger)
HISTORY_DAYS: 21, // Lerndauer für das Verbrauchsprofil (bestimmt Logging-Dauer!)
// Wärmepumpen-Logik (KI)
MAX_HEATING_RESERVE_KWH: 6.0, // Max. Reserve für Heizung
HEATING_FACTOR: 0.5, // kWh Mehrverbrauch pro Grad kälter
// Regelungs-Parameter (PI-Controller)
REGEL_INTERVALL_MS: 2000, // Wie oft geregelt wird
AC_MODE_COOLDOWN_MS: 60000, // Schutzzeit gegen hektisches Umschalten (Input<->Output)
HYSTERESIS_SWITCH_W: 200, // Hysterese beim Umschalten Lade/Entlademodus
DEADBAND_W: 30, // Toleranzbereich um 0W (keine Regelung nötig)
KP: 0.70, // Proportional-Anteil (Reaktionsschnelle)
KI: 0.08, // Integral-Anteil (Genauigkeit/Ausgleich bleibender Fehler)
INTEGRAL_MAX: 30000, // Windup-Schutz (Max Speicher des Integrals)
INTEGRAL_MIN: -30000,
TARGET_W: 0 // Zielwert am Netzanschlusspunkt
};
// --- 2. DATENPUNKTE (IDs) ---
// Hier werden alle externen Datenpunkte gemappt.
// WICHTIG: "EVCC_Modus" Definition beachten!
function zendurePath(type, endpoint) {
return `${CONFIG.MQTT_BASE_PATH}.${type}.${CONFIG.DEVICE_ID}.${endpoint}`;
}
const IDs = {
// --- Hardware & Echtzeit (Zendure & Shelly) ---
netz: "shelly.0.SHEM-3#8CAAB5619A05#1.Total.InstantPower", // Aktueller Hausverbrauch (Positiv=Bezug, Negativ=Einspeisung)
// Zendure Hub Steuerung
acMode: zendurePath("select", "acMode"), // Aktueller Modus (lesen)
acModeSet: zendurePath("select", "acMode.set"), // Modus setzen (Input/Output)
currentInput: zendurePath("number", "inputLimit"), // Aktuelles Ladelimit (lesen)
inputSet: zendurePath("number", "inputLimit.set"), // Ladelimit setzen
currentOutput: zendurePath("number", "outputLimit"),// Aktuelles Entladelimit (lesen)
outputSet: zendurePath("number", "outputLimit.set"),// Entladelimit setzen
soc: zendurePath("sensor", "electricLevel"), // Akku-Stand in %
minSoc: zendurePath("number", "minSoc"), // MinSoC Einstellung am Hub
// --- Energie-Historie (Zählerstände für KI-Analyse) ---
// [LOGGING]: SQL-Logging aktivieren! Mind. 21 Tage (CONFIG.HISTORY_DAYS) Vorhaltezeit.
// Werden genutzt, um den Durchschnittsverbrauch zu lernen
batTotalEnergy: "shelly.0.shellyplugsg3#d0cf13daf7f0#1.Relay0.Energy",
batDischargeTotal: "shelly.0.shellyplugsg3#d0cf13daf7f0#1.Relay0.ReturnedEnergy",
// --- Last-Analyse & Umgebungsfaktoren ---
// [LOGGING]: SQL-Logging aktivieren! Mind. 21 Tage Vorhaltezeit.
temperature: "0_userdata.0.zendure.KI.Temperatur_Mittelwert",
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",
// --- Wettervorhersage (DasWetter Adapter) ---
forecastMinTempToday: "daswetter.0.location_1.ForecastDaily.Day_1.Temperature_Min",
forecastMinTempTomorrow: "daswetter.0.location_1.ForecastDaily.Day_2.Temperature_Min",
// --- PV-Anlage Daten ---
// [LOGGING]: SQL-Logging aktivieren! Mind. 21 Tage Vorhaltezeit.
pvTodayKWh: "0_userdata.0.PV-Anlage.Erzeugt_heute", // Genutzt für PV-Accuracy Berechnung
// --- Netz-Historie ---
// [LOGGING]: SQL-Logging aktivieren! Mind. 21 Tage Vorhaltezeit.
netImportTotalKWh: "tibberlink.0.LocalPulse.0.Import_total",
netExportTotalKWh: "tibberlink.0.LocalPulse.0.Export_total",
// --- Tibber & Preise ---
tibberPricesToday: "tibberlink.0.Homes.f7xxxxxxxxxxxx.PricesToday.json",
tibberPricesTomorrow: "tibberlink.0.Homes.f7xxxxxxxxxxxx.PricesTomorrow.json",
// --- Externe Steuerung & Prognose ---
// WICHTIG: EVCC Modus Definition
// 1 = Normalbetrieb (Regler darf arbeiten, Entladen erlaubt)
// 2 = Entladesperre (Entladen verboten, Laden bei PV-Überschuss erlaubt)
// 3 = Force Charge (Ladebefehl: Akku wird mit MAX_CHARGE_W geladen, egal ob Überschuss)
evccModus: "0_userdata.0.zendure.EVCC_Modus",
consumptionProfile: "0_userdata.0.zendure.KI.ConsumptionProfile", // Cache für das gelernte Profil
vacationMode: "0_userdata.0.Sonstiges.UrlaubsModus_An", // true = Urlaubsmodus (weniger Grundlast)
// PV-Prognosen (Adapter pvforecast)
pvForecastRest: "pvforecast.0.summary.energy.nowUntilEndOfDay",
pvForecastNow: "pvforecast.0.summary.energy.now",
pvForecastTomorrow: "pvforecast.0.summary.energy.tomorrow"
};
const AC_MODES = { INPUT: "Input mode", OUTPUT: "Output mode" };
// --- 3. GLOBALE VARIABLEN ---
let integral = 0.0; // Speicher für den I-Anteil des Reglers
let lastAcModeSwitch = 0; // Timestamp des letzten Moduswechsels (Cooldown)
let currentStatusText = "Initialisierung";
let isFirstRun = true;
// KI & Strategie Variablen
let predictedChargeKWh = 0; // Berechneter Bedarf für die Nacht
let tibberPriceData = []; // Rohdaten der Preise
let cheapestIntervals = []; // Liste der Zeitstempel für "Force Charge"
let expensiveIntervals = []; // Liste der Zeitstempel für "Reserve halten"
let historicalConsumptionProfile = {}; // Gelerntes Verhalten (Wochentag/Uhrzeit)
let globalReserveKWh = 0; // Aktuell nötige Reserve im Akku
let dynamicPriceReference = 0; // Berechneter Referenzpreis (Durchschnitt/Floor)
// --- 4. LOGGING & UTILS ---
function logInfo(message) { if (CONFIG.INFO_LOGS) { log(`[Info] ${message}`, 'info'); } }
function logDebug(message) { if (CONFIG.DEBUG) { log(`[Debug] ${message}`, 'info'); } }
function logWarn(message) { log(`[Warn] ${message}`, 'warn'); }
function setStatus(text) {
if (currentStatusText !== text) {
currentStatusText = text;
logInfo(`[Status] ${text}`);
}
}
// Wrapper für asynchrone sendTo Befehle (SQL)
function sendToAsync(instance, command, message) {
return new Promise((resolve, reject) => {
sendTo(instance, command, message, function (result) {
if (result.error) reject(new Error(result.error));
else resolve(result);
});
});
}
// Berechnet wie zuverlässig die PV-Prognose heute war (0.0 - 1.0)
function calculatePVAccuracy(today, forecastNow) {
if (!forecastNow || forecastNow < 0.1) return 1.0;
const deviation = Math.abs(today - forecastNow);
const acc = Math.max(0.0, 1.0 - (deviation / forecastNow));
if (CONFIG.DEBUG) logDebug(`[KI-Check] PV Accuracy: ${(acc*100).toFixed(1)}% (Ist: ${today.toFixed(1)}kWh / Soll: ${forecastNow.toFixed(1)}kWh)`);
return acc;
}
function logStrategyDetails(intervalTimestamps, type, logLevel) {
if (logLevel === 'info' && !CONFIG.INFO_LOGS) return;
if (logLevel === 'debug' && !CONFIG.DEBUG) return;
if (intervalTimestamps.length === 0) {
if (type === "Lade") {
const bep = calculateBreakevenPrice();
logInfo(`[Strategie] Keine Ladeintervalle unter ${bep.toFixed(2)} ct/kWh gefunden oder benötigt.`);
}
return;
}
let output = `[Strategie] Geplante **${type}**-Zeiten (${intervalTimestamps.length}x):\n`;
const relevantIntervals = tibberPriceData.filter(i => intervalTimestamps.includes(i.start.getTime()));
relevantIntervals.sort((a,b) => a.start.getTime() - b.start.getTime());
relevantIntervals.forEach(i => { output += `- ${i.start.toLocaleString()} (${i.price.toFixed(2)} ct/kWh)\n`; });
if (logLevel === 'info') logInfo(output); else logDebug(output);
}
// --- 5. KI-LOGIK & ANALYSE ---
// Holt historische Deltas (Verbrauch/Erzeugung) aus SQL
async function getHistoryDeltas(id, scaleFactor = 1.0) {
if (!id || !existsState(id)) return [];
const end = Date.now();
const start = end - (CONFIG.HISTORY_DAYS * 24 * 3600 * 1000);
try {
const result = await sendToAsync(CONFIG.SQL_INSTANCE_ID, 'getHistory', {
id: id,
options: { start: start, end: end, aggregate: 'none', limit: 45000, returnNewestEntries: true, removeBorderValues: true, ignoreNull: false, ack: true }
});
const data = result.result;
if (!data || data.length < 2) return [];
data.sort((a, b) => a.ts - b.ts);
const res = [];
let lastVal = null;
for(let i=0; i<data.length; i++) {
if (data[i].val === null) continue;
if (lastVal === null) { lastVal = data[i].val; continue; }
let delta = (data[i].val - lastVal) * scaleFactor;
lastVal = data[i].val;
if (delta < 0 || delta > 120) continue;
res.push({ time: data[i].ts, deltaKWh: delta });
}
return res;
} catch(e) { logDebug(`Fehler SQL ${id}: ${e.message}`); return []; }
}
async function getHistoryStats(id) {
const end = Date.now();
const start = end - (CONFIG.HISTORY_DAYS * 24 * 3600 * 1000);
try {
const result = await sendToAsync(CONFIG.SQL_INSTANCE_ID, 'getHistory', {
id: id,
options: { start: start, end: end, aggregate: 'average', count: 1, ack: true }
});
if(result.result && result.result[0]) return result.result[0].val || 0;
return 0;
} catch(e) { return 0; }
}
// Lädt/Speichert das gelernte Profil in einen Datenpunkt (Caching)
function tryLoadConsumptionProfile() {
if(!IDs.consumptionProfile) return false;
try {
const state = getState(IDs.consumptionProfile);
if (state && state.val) {
const data = JSON.parse(state.val);
if (data && Object.keys(data).length > 0) {
historicalConsumptionProfile = data;
logInfo("[Cache] Verbrauchsprofil erfolgreich geladen.");
return true;
}
}
} catch (e) { logDebug(`[Cache] Konnte Profil nicht laden: ${e.message}`); }
return false;
}
function saveConsumptionProfile() {
if(!IDs.consumptionProfile) return;
try {
const jsonStr = JSON.stringify(historicalConsumptionProfile);
setState(IDs.consumptionProfile, jsonStr, true);
logInfo("[Cache] Verbrauchsprofil im Datenpunkt gesichert.");
} catch (e) { logWarn(`[Cache] Fehler beim Speichern: ${e.message}`); }
}
function getBucketKey(timestamp) {
const date = new Date(timestamp);
const wd = date.getDay();
const minutes = Math.floor(date.getMinutes() / 15) * 15;
const hm = `${('0'+date.getHours()).slice(-2)}:${('0'+minutes).slice(-2)}`;
return { wd, hm };
}
function getMedian(values) {
if (values.length === 0) return 0;
values.sort((a, b) => a - b);
const half = Math.floor(values.length / 2);
if (values.length % 2) return values[half];
return (values[half - 1] + values[half]) / 2.0;
}
// Hauptfunktion zum Lernen des Verbrauchsprofils
async function updateConsumptionProfileFromSQL() {
if(!IDs.netImportTotalKWh || !IDs.batTotalEnergy) return;
logInfo("[KI] Starte SQL-Analyse für Verbrauchsprofil...");
historicalConsumptionProfile = {};
// Daten holen...
const [importD, exportD, pvDeltas, wbD, batTotalD, batDischargeD] = await Promise.all([
getHistoryDeltas(IDs.netImportTotalKWh, 1.0),
getHistoryDeltas(IDs.netExportTotalKWh, 1.0),
getHistoryDeltas(IDs.pvTodayKWh, 1.0),
getHistoryDeltas(IDs.wbTotalKWh, 1.0),
getHistoryDeltas(IDs.batTotalEnergy, 0.001),
getHistoryDeltas(IDs.batDischargeTotal, 0.001)
]);
// Daten aggregieren...
const dailyBuckets = {};
const dailySums = {};
const addDeltas = (deltas, type) => {
if(!deltas) return;
deltas.forEach(d => {
const dateObj = new Date(d.time);
const dateKey = dateObj.toISOString().split('T')[0];
const { hm } = getBucketKey(d.time);
if (!dailyBuckets[dateKey]) dailyBuckets[dateKey] = {};
if (!dailyBuckets[dateKey][hm]) dailyBuckets[dateKey][hm] = { import: 0, export: 0, pv: 0, wb: 0, batIn: 0, batOut: 0, wd: dateObj.getDay() };
dailyBuckets[dateKey][hm][type] += d.deltaKWh;
});
};
addDeltas(importD, 'import'); addDeltas(exportD, 'export'); addDeltas(pvDeltas, 'pv');
addDeltas(wbD, 'wb'); addDeltas(batTotalD, 'batIn'); addDeltas(batDischargeD, 'batOut');
// Ausreißer filtern (Urlaubstage etc.)
const consumptionSums = [];
for (const dateKey in dailyBuckets) {
let daySum = 0;
const dayData = dailyBuckets[dateKey];
for (const hm in dayData) {
const b = dayData[hm];
const realBatIn = Math.max(0, b.batIn - b.batOut);
const rawSystem = b.import + b.pv - b.export;
const wbSum = b.wb || 0;
const batEffect = b.batOut - realBatIn;
let consumption = Math.max(0, rawSystem - wbSum + batEffect);
daySum += consumption;
}
dailySums[dateKey] = daySum;
consumptionSums.push(daySum);
}
const medianConsumption = getMedian(consumptionSums);
const outlierThreshold = medianConsumption * 0.4; // Tage < 40% des Medians ignorieren
if (CONFIG.DEBUG) logDebug(`[KI-Filter] Median-Verbrauch: ${medianConsumption.toFixed(2)} kWh. Filter-Schwelle: < ${outlierThreshold.toFixed(2)} kWh`);
let ignoredDays = 0;
for (const dateKey in dailyBuckets) {
if (dailySums[dateKey] < outlierThreshold) {
ignoredDays++;
continue;
}
const dayData = dailyBuckets[dateKey];
for (const hm in dayData) {
const b = dayData[hm];
const realBatIn = Math.max(0, b.batIn - b.batOut);
const rawSystem = b.import + b.pv - b.export;
const wbSum = b.wb || 0;
const batEffect = b.batOut - realBatIn;
let consumption = Math.max(0, rawSystem - wbSum + batEffect);
const wd = b.wd;
if (!historicalConsumptionProfile[wd]) historicalConsumptionProfile[wd] = {};
if (!historicalConsumptionProfile[wd][hm]) historicalConsumptionProfile[wd][hm] = [];
historicalConsumptionProfile[wd][hm].push(consumption);
}
}
if (ignoredDays > 0) logInfo(`[KI] ${ignoredDays} Tage wegen ungewöhnlich niedrigem Verbrauch (Urlaub?) aus dem Lernprofil gefiltert.`);
saveConsumptionProfile();
}
function getSumAndDays(deltas) {
if (!deltas || deltas.length === 0) return { sum: 0, days: 0 };
const totalSum = deltas.reduce((sum, d) => sum + d.deltaKWh, 0);
const uniqueDays = new Set(deltas.map(d => new Date(d.time).toISOString().split('T')[0])).size;
return { sum: totalSum, days: Math.max(1, uniqueDays) };
}
// Berechnet den Ladbedarf für die Nacht basierend auf Historie, Wetter und Urlaub
async function getPredictedChargeNeedKWh() {
if (!CONFIG.ENABLE_PREDICTIVE_CHARGE) return 0;
try {
const [importD, exportD, pvD, wbD, batTotalD, batOutD, heatD, wwD] = await Promise.all([
getHistoryDeltas(IDs.netImportTotalKWh, 1.0), getHistoryDeltas(IDs.netExportTotalKWh, 1.0),
getHistoryDeltas(IDs.pvTodayKWh, 1.0), getHistoryDeltas(IDs.wbTotalKWh, 1.0),
getHistoryDeltas(IDs.batTotalEnergy, 0.001), getHistoryDeltas(IDs.batDischargeTotal, 0.001),
getHistoryDeltas(IDs.heatLoadTotal, 1.0), getHistoryDeltas(IDs.wwLoadTotal, 1.0)
]);
const days = Math.max(1, getSumAndDays(importD).days);
const avgPV = getSumAndDays(pvD).sum / days;
const avgImp = getSumAndDays(importD).sum / days;
const avgExp = getSumAndDays(exportD).sum / days;
const avgWB = getSumAndDays(wbD).sum / days;
const avgBatOut = getSumAndDays(batOutD).sum / days;
const avgBatIn = Math.max(0, (getSumAndDays(batTotalD).sum / days) - avgBatOut);
const avgHeat = getSumAndDays(heatD).sum / days;
const avgWW = getSumAndDays(wwD).sum / days;
const totalCons = (avgPV + avgImp - avgExp) - avgWB + (avgBatOut - avgBatIn);
let baseLoad = Math.max(0, totalCons - avgHeat - avgWW);
// --- VACATION MODE CHECK ---
let isVacation = false;
if (IDs.vacationMode && existsState(IDs.vacationMode)) {
isVacation = getState(IDs.vacationMode).val || false;
}
if (isVacation) {
baseLoad = baseLoad * 0.5; // 50% Reduktion im Urlaub
}
// Temperatur-Einfluss (Heizbedarf)
let calcTemp = 10, avgHistTemp = 10;
let isUsingForecast = false;
if (existsState(IDs.temperature)) {
avgHistTemp = await getHistoryStats(IDs.temperature) || 10;
}
if (CONFIG.ENABLE_WEATHER_FORECAST && existsState(IDs.forecastMinTempToday)) {
const minToday = getState(IDs.forecastMinTempToday).val;
const minTomorrow = getState(IDs.forecastMinTempTomorrow).val;
if ((typeof minToday === 'number' || typeof minToday === 'string') &&
(typeof minTomorrow === 'number' || typeof minTomorrow === 'string')) {
const tToday = parseFloat(minToday);
const tTomorrow = parseFloat(minTomorrow);
if (!isNaN(tToday) && !isNaN(tTomorrow)) {
const currentT = getState(IDs.temperature).val || 10;
const forecastMin = Math.min(tToday, tTomorrow);
calcTemp = Math.min(currentT, forecastMin);
isUsingForecast = true;
}
}
}
if (!isUsingForecast && existsState(IDs.temperature)) {
calcTemp = getState(IDs.temperature).val || 10;
}
const tempDelta = avgHistTemp - calcTemp;
let predHeat = avgHeat + avgWW;
if (tempDelta > 0) predHeat += (tempDelta * CONFIG.HEATING_FACTOR);
else predHeat = Math.max(avgWW, predHeat - (Math.abs(tempDelta) * CONFIG.HEATING_FACTOR));
if (isVacation) {
predHeat = 0; // Keine Komfort-Reserve im Urlaub
} else {
predHeat = Math.min(predHeat, CONFIG.MAX_HEATING_RESERVE_KWH);
}
let tempInfo = `${calcTemp.toFixed(1)}°C`;
if (isUsingForecast) tempInfo += " (Forecast)";
if (isVacation) tempInfo += " [URLAUB: Wärme-Res deaktiviert]";
logInfo(`[KI-Analyse] Base: ${baseLoad.toFixed(2)}kWh | Wärme: ${predHeat.toFixed(2)}kWh (Kalkulations-Basis: ${tempInfo})`);
// PV Prognose einbeziehen
const actualPV = getState(IDs.pvTodayKWh).val || 0;
const acc = calculatePVAccuracy(actualPV, getState(IDs.pvForecastNow).val||1);
const effPVToday = actualPV + ((getState(IDs.pvForecastRest).val || 0) * acc);
const safePVMorgen = (getState(IDs.pvForecastTomorrow).val || 0) * Math.max(0, acc - 0.2);
let need = (baseLoad + predHeat) / CONFIG.DISCHARGE_EFFICIENCY - (effPVToday + (safePVMorgen * 0.5));
let charge = Math.max(0, need) / CONFIG.CHARGE_EFFICIENCY;
const soc = getState(IDs.soc).val || 0;
return Math.min(charge, CONFIG.BATTERY_CAPACITY_KWH * (100 - soc) / 100.0);
} catch(e) { return 0; }
}
function calculateDynamicPriceReference() {
if (tibberPriceData.length === 0) { dynamicPriceReference = CONFIG.MIN_PRICE_FLOOR_CT_KWH; return; }
const avg = tibberPriceData.reduce((sum, i) => sum + i.price, 0) / tibberPriceData.length;
dynamicPriceReference = Math.max(avg, CONFIG.MIN_PRICE_FLOOR_CT_KWH);
}
function calculateBreakevenPrice() {
// Preis, ab dem Laden+Entladen teurer ist als Direktbezug
return dynamicPriceReference * CONFIG.CHARGE_EFFICIENCY * CONFIG.DISCHARGE_EFFICIENCY;
}
async function loadTibberPriceData() {
if(!IDs.tibberPricesToday) return;
let today=[], tomorrow=[];
try {
const t = getState(IDs.tibberPricesToday).val; if(t) today = JSON.parse(t);
const m = getState(IDs.tibberPricesTomorrow).val; if(m) tomorrow = JSON.parse(m);
} catch(e) {}
tibberPriceData = [];
[...today, ...tomorrow].forEach(i => {
tibberPriceData.push({ start: new Date(i.startsAt), price: i.total * 100, durationMs: 15 * 60 * 1000 });
});
}
// Führt die strategische Analyse durch (wird stündlich aufgerufen)
async function runPredictiveAnalysis() {
if (!CONFIG.ENABLE_TIBBER) return;
await loadTibberPriceData();
calculateDynamicPriceReference();
if (Object.keys(historicalConsumptionProfile).length === 0) tryLoadConsumptionProfile();
predictedChargeKWh = await getPredictedChargeNeedKWh();
// Strategie-Zeiten berechnen
const now = new Date();
const bep = calculateBreakevenPrice();
const future = tibberPriceData.filter(i => (i.start.getTime() + i.durationMs) > now.getTime());
// CHEAPEST: Wann laden wir? (Preis < Break-Even)
cheapestIntervals = future.filter(i => i.price < bep).sort((a,b) => a.price - b.price).slice(0, Math.ceil(predictedChargeKWh/((CONFIG.MAX_CHARGE_W/1000)*0.25*CONFIG.CHARGE_EFFICIENCY))).map(i => i.start.getTime());
// EXPENSIVE: Wann auf jeden Fall entladen/reservieren?
expensiveIntervals = future.filter(i => i.price > dynamicPriceReference).sort((a,b) => b.price - a.price).slice(0, 10).map(i => i.start.getTime());
logStrategyDetails(cheapestIntervals, "Lade", "info");
logStrategyDetails(expensiveIntervals, "Entlade", "info");
// Reserve für Hochpreisphasen berechnen
let totalPeakKWh = 0;
for (const ts of expensiveIntervals) {
const d = new Date(ts); const wd = d.getDay(); const hm = `${('0'+d.getHours()).slice(-2)}:${('0'+(Math.floor(d.getMinutes()/15)*15)).slice(-2)}`;
if (historicalConsumptionProfile[wd]?.[hm]) {
const sorted = [...historicalConsumptionProfile[wd][hm]].sort((a, b) => a - b);
totalPeakKWh += sorted[Math.floor(sorted.length / 2)];
}
}
const bruteReserve = totalPeakKWh / CONFIG.DISCHARGE_EFFICIENCY;
const acc = calculatePVAccuracy(getState(IDs.pvTodayKWh).val||0, getState(IDs.pvForecastNow).val||1);
const safePVMorgen = (getState(IDs.pvForecastTomorrow).val || 0) * Math.max(0, acc - 0.2);
globalReserveKWh = Math.max(bruteReserve * 0.2, bruteReserve - safePVMorgen);
globalReserveKWh = Math.max(0.5, globalReserveKWh);
if (CONFIG.INFO_LOGS) logInfo(`[KI] PV-Genauigkeit: ${(acc*100).toFixed(0)}% | Brutto-Res: ${bruteReserve.toFixed(2)}kWh | Netto-Res: ${globalReserveKWh.toFixed(2)}kWh`);
isFirstRun = false;
}
// --- 6. HARDWARE-KONTROLLE ---
// Diese Funktion sendet die berechneten Werte an den Zendure Hub
// Sie verhindert auch zu schnelles Umschalten (Ping-Pong)
function setBatteryPower(power, states) {
power = Math.round(power); const curMode = states.acMode;
let targetMode = states.acMode; // Default to CURRENT mode
let targetVal = Math.abs(power);
if (power > 0) targetMode = AC_MODES.INPUT;
if (power < 0) targetMode = AC_MODES.OUTPUT;
const lastSetMode = getState(IDs.acModeSet).val;
const isSwitchingPending = (lastSetMode === targetMode && curMode !== targetMode);
if (curMode !== targetMode && !isSwitchingPending) {
// Cooldown Check
if (Date.now() - lastAcModeSwitch < CONFIG.AC_MODE_COOLDOWN_MS) {
const setID = (curMode === AC_MODES.INPUT) ? IDs.inputSet : IDs.outputSet;
if (getState(setID).val != 0) setState(setID, "0", false);
return;
}
logInfo(`Moduswechsel: ${curMode} -> ${targetMode} (Power: ${targetVal}W). Reset Integral.`);
integral = 0; // WICHTIG: Integral Reset bei Moduswechsel um Überschwingen zu vermeiden
lastAcModeSwitch = Date.now();
setState(IDs.acModeSet, targetMode, false);
} else {
// Werte setzen
const setID = (targetMode === AC_MODES.INPUT) ? IDs.inputSet : IDs.outputSet;
if (isSwitchingPending) {
if (Date.now() - lastAcModeSwitch > 120000) {
logWarn("Hardware reagiert nicht auf Moduswechsel. Sende erneut...");
lastAcModeSwitch = Date.now();
setState(IDs.acModeSet, targetMode, false);
}
}
// Nur senden wenn Änderung signifikant (>50W) oder Wert noch nicht stimmt
if (getState(setID).val != String(targetVal) || Math.abs((targetMode === AC_MODES.INPUT ? states.currentInput : states.currentOutput) - targetVal) > 50) {
setState(setID, String(targetVal), false);
}
}
}
// --- 7. REGELUNG (MAIN LOOP) ---
function mainControlLoop() {
try {
// --- 7a. SENSOR-CHECK ---
const rawNetz = getState(IDs.netz).val;
if (rawNetz === null || typeof rawNetz !== 'number') {
logWarn("Warnung: Netz-Datenpunkt liefert ungültigen Wert (null/NaN). Regelung pausiert.");
return;
}
const s = {
netz: rawNetz,
soc: getState(IDs.soc).val || 0,
minSoc: getState(IDs.minSoc).val || 0,
acMode: getState(IDs.acMode).val || AC_MODES.OUTPUT,
currentInput: getState(IDs.currentInput).val || 0,
currentOutput: getState(IDs.currentOutput).val || 0,
evccModus: getState(IDs.evccModus).val || 0
};
let dynamicTargetW = CONFIG.TARGET_W;
let dischargeLimit = CONFIG.MAX_DISCHARGE_W;
let chargeLimit = CONFIG.MAX_CHARGE_W;
let statusOverride = null;
let forceMaxCharge = false;
const nowMs = Date.now();
const currentInterval = new Date(Math.floor(nowMs / 900000) * 900000).getTime();
// --- 7b. ENTSCHEIDUNGS-LOGIK ---
// 1. Tibber "Force Charge" Condition (Preis sehr niedrig)
const isTibberForceCharge = (CONFIG.ENABLE_TIBBER && cheapestIntervals.includes(currentInterval) && s.soc < 100);
// 2. EVCC Conditions
// Modus 3: Force Charge (Auto lädt schnell)
// Modus 2: Entladesperre (Auto lädt Min+PV)
const isEvccForceCharge = (CONFIG.ENABLE_EVCC && s.evccModus === 3);
const isEvccDischargeLock = (CONFIG.ENABLE_EVCC && s.evccModus === 2);
// --- PRIORITY 1: FORCE CHARGE ---
// (Höchste Prio: Wir müssen laden, egal was Haus oder EVCC sagen)
if (isTibberForceCharge || isEvccForceCharge) {
forceMaxCharge = true;
dischargeLimit = 0;
if (isEvccForceCharge) statusOverride = "EVCC: Force Charge (Modus 3)";
else statusOverride = "KI: Strategische Ladung (MAX-Power)";
}
// --- PRIORITY 2: DISCHARGE LOCKS ---
// (Greift nur wenn kein Force Charge aktiv ist)
if (!forceMaxCharge) {
// A. EVCC Lock (Hausakku soll voll bleiben für Nacht)
if (isEvccDischargeLock) {
dischargeLimit = 0;
statusOverride = "EVCC: Entladesperre (Modus 2)";
}
// B. Profit Guard (Preis zu niedrig zum Einspeisen/Verbrauchen)
if (CONFIG.ENABLE_TIBBER) {
const currentPriceObj = tibberPriceData.find(i => nowMs >= i.start.getTime() && nowMs < (i.start.getTime() + i.durationMs));
if (currentPriceObj) {
const currentPrice = currentPriceObj.price;
// Wenn Preis < Floor UND Akku nicht proppenvoll
if (currentPrice < CONFIG.MIN_PRICE_FLOOR_CT_KWH && s.soc < 90) {
if (dischargeLimit > 0) {
dischargeLimit = 0;
statusOverride = `Eco-Stop: Preis ${currentPrice.toFixed(1)}ct < Limit ${CONFIG.MIN_PRICE_FLOOR_CT_KWH}ct`;
} else if (!isEvccDischargeLock) {
statusOverride = `Eco-Stop: Preis ${currentPrice.toFixed(1)}ct < Limit ${CONFIG.MIN_PRICE_FLOOR_CT_KWH}ct`;
}
}
}
}
}
let output = 0;
let pShare = 0;
let iShare = 0;
let error = 0;
// --- 7c. REGELUNG ---
if (forceMaxCharge) {
// BYPASS: Volle Ladeleistung erzwingen
output = chargeLimit;
integral = CONFIG.INTEGRAL_MAX; // Integral "vollpumpen", damit nach Ende des ForceCharge nicht sofort entladen wird
if(CONFIG.DEBUG) logDebug("Bypass aktiv: Force Max Charge. Integral set to Max.");
} else {
// NORMALER PI-REGLER
// Reserve-Check (KI)
const isExpNow = expensiveIntervals.some(ts => nowMs >= ts && nowMs < (ts + 900000));
const usableKWh = (s.soc - s.minSoc) / 100 * CONFIG.BATTERY_CAPACITY_KWH;
if (dischargeLimit > 0) {
if (s.soc <= s.minSoc) {
dischargeLimit = 0; // Akku leer
if(CONFIG.DEBUG) logDebug(`[Limit] Entladung gestoppt: SoC (${s.soc}%) <= MinSoc (${s.minSoc}%)`);
} else if (!isExpNow && usableKWh <= globalReserveKWh) {
dischargeLimit = 0; // Reserve erreicht
if(CONFIG.DEBUG) logDebug(`[Limit] Entladung gestoppt: Reserve unterschritten (Verfügbar: ${usableKWh.toFixed(2)}kWh < Reserve: ${globalReserveKWh.toFixed(2)}kWh)`);
}
}
error = dynamicTargetW - s.netz;
if (Math.abs(s.netz) <= CONFIG.DEADBAND_W) error = 0.0; // Totzone
// Verhindern, dass Integral wegläuft, wenn Entladung gesperrt ist
if (error < 0 && dischargeLimit === 0) {
if (integral <= 0) error = 0;
}
// Anti-Deadlock
if (dischargeLimit === 0 && error > 200 && integral < 0) {
if(CONFIG.DEBUG) logDebug("Deadlock Break: Reset negative Integral to allow charging.");
integral = 0;
}
// Saturation Protection
let stopIntegration = false;
if (s.soc >= 99 && error > 0) { stopIntegration = true; error = 0; }
if (s.soc <= s.minSoc && error < 0) { stopIntegration = true; error = 0; }
// PI Berechnung
const dt = CONFIG.REGEL_INTERVALL_MS / 1000.0;
const curSetMode = getState(IDs.acModeSet).val;
const wantChange = (curSetMode === AC_MODES.INPUT && error < -CONFIG.HYSTERESIS_SWITCH_W) ||
(curSetMode === AC_MODES.OUTPUT && error > CONFIG.HYSTERESIS_SWITCH_W);
const inCooldown = (nowMs - lastAcModeSwitch < CONFIG.AC_MODE_COOLDOWN_MS);
let potentialIntegral = integral;
if ((!inCooldown || !wantChange) && !stopIntegration) {
potentialIntegral = Math.max(CONFIG.INTEGRAL_MIN, Math.min(CONFIG.INTEGRAL_MAX, integral + (error * dt)));
}
pShare = CONFIG.KP * error;
iShare = CONFIG.KI * potentialIntegral;
output = pShare + iShare;
// Output begrenzen (Hardware-Limits & Logik-Limits)
const rawOutput = output;
output = Math.max(-dischargeLimit, Math.min(chargeLimit, output));
// Anti-Windup
const isClamped = (output !== rawOutput);
if (!isClamped) {
integral = potentialIntegral;
} else {
// Nur integrieren, wenn wir uns aus der Begrenzung herausbewegen
const isHelping = (output === chargeLimit && error < 0) || (output === -dischargeLimit && error > 0);
if (isHelping) integral = potentialIntegral;
}
// Hysterese für 0-Punkt (verhindert Flattern bei geringer Last)
const isInputIntended = (curSetMode === AC_MODES.INPUT);
if (output > 0 && !isInputIntended && output < CONFIG.HYSTERESIS_SWITCH_W) output = 0;
else if (output < 0 && isInputIntended && output > -CONFIG.HYSTERESIS_SWITCH_W) output = 0;
}
if(CONFIG.DEBUG && !forceMaxCharge) {
logDebug(`PI-Regler: Error=${Math.round(error)}W | P=${Math.round(pShare)} | I=${Math.round(iShare)} (Int=${Math.round(integral)}) | Out=${Math.round(output)}W`);
}
// Hardware ansteuern
setBatteryPower(output, s);
// Status setzen
if (statusOverride) {
setStatus(statusOverride);
} else {
setStatus(dischargeLimit === 0 && output === 0 && s.netz > 50 ? "PI: Reserve-Stop" : "PI: Regelung");
}
} catch (e) { log(`Loop Error: ${e}`, 'error'); }
}
// --- 8. INITIALISIERUNG ---
async function initialize() {
logInfo("--- Batteriesteuerung v5.5.11 (Logging Info) ---");
// Versuche, den Regler sanft zu starten, indem der aktuelle Hardware-Zustand übernommen wird
try {
const curMode = getState(IDs.acMode).val;
const curIn = getState(IDs.currentInput).val || 0;
const curOut = getState(IDs.currentOutput).val || 0;
let startPower = 0;
if (curMode === AC_MODES.INPUT) startPower = curIn;
else if (curMode === AC_MODES.OUTPUT) startPower = -curOut;
if (CONFIG.KI > 0 && startPower !== 0) {
integral = startPower / CONFIG.KI;
integral = Math.max(CONFIG.INTEGRAL_MIN, Math.min(CONFIG.INTEGRAL_MAX, integral));
logInfo(`[Init] Laufenden Betrieb erkannt: ${curMode} @ ${Math.abs(startPower)}W. Integral auf ${Math.round(integral)} gesetzt.`);
}
} catch (e) {
logWarn(`[Init] Konnte Start-Zustand nicht ermitteln: ${e}`);
}
lastAcModeSwitch = Date.now() - CONFIG.AC_MODE_COOLDOWN_MS;
// Zeitpläne starten
await runPredictiveAnalysis();
schedule('5 * * * *', runPredictiveAnalysis); // Jede Stunde xx:05 Strategie neu berechnen
schedule('0 3 * * *', updateConsumptionProfileFromSQL); // Nachts um 03:00 Profil lernen
setInterval(mainControlLoop, CONFIG.REGEL_INTERVALL_MS); // Hauptschleife
}
initialize();