[ { "id": "8548a0d587e012fd", "type": "tab", "label": "Charge Plan (fixed total capacity)", "disabled": false, "info": "", "env": [] }, { "id": "d9631b1d8aad9d45", "type": "inject", "z": "8548a0d587e012fd", "name": "Set defaults", "props": [ { "p": "payload" } ], "repeat": "600", "crontab": "", "once": true, "onceDelay": "2", "topic": "", "payload": "", "payloadType": "str", "x": 110, "y": 80, "wires": [ [ "182e46ce5bc67e5d" ] ] }, { "id": "8aa4bf0b401fba0f", "type": "victron-output-ess", "z": "8548a0d587e012fd", "service": "com.victronenergy.settings", "path": "/Settings/SystemSetup/MaxChargeCurrent", "serviceObj": { "service": "com.victronenergy.settings", "name": "Venus settings" }, "pathObj": { "path": "/Settings/SystemSetup/MaxChargeCurrent", "type": "float", "name": "DVCC Charge current limit (A)", "mode": "both" }, "initial": 0, "name": "DVCC Charge Current Limit", "onlyChanges": false, "x": 760, "y": 440, "wires": [] }, { "id": "182e46ce5bc67e5d", "type": "function", "z": "8548a0d587e012fd", "name": "Defaults setzen", "func": "\"use strict\";\n\n// Lade-Konfiguration in Flow speichern\nflow.set(\"chargeCfg\", {\n targetSocPercent: 100,\n reserveSocPercent: 1,\n chargeEff: 0.90,\n maxA: 210,\n minA: 10,\n headroomW: 0,\n pvThresholdWh: 50,\n targetLeadHours: 3,\n allowGridBeforePV: false,\n capacityAh: 280,\n minAOutside: 5 // Mindestladestrom außerhalb des Plans (0 = deaktiviert)\n});\n\n// Site-ID hier anpassen:\nflow.set(\"vrm\", { idSite: \"794347\" });\n\n// Werte abrufen\nlet vrm = flow.get(\"vrm\");\nlet cfg = flow.get(\"chargeCfg\");\n\n// Statusanzeige\nnode.status({\n fill: \"green\",\n shape: \"dot\",\n text: \"chargeCfg & vrm.idSite gesetzt (\" + (vrm && vrm.idSite ? vrm.idSite : \"-\") + \")\"\n});\n\n// Ausgabe\nmsg.payload = {\n chargeCfg: cfg,\n vrm: vrm\n};\n\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 440, "y": 80, "wires": [ [] ] }, { "id": "6af4f52129985da2", "type": "victron-input-battery", "z": "8548a0d587e012fd", "service": "com.victronenergy.battery/512", "path": "/Soc", "serviceObj": { "service": "com.victronenergy.battery/512", "name": "JK-BMS" }, "pathObj": { "path": "/Soc", "type": "float", "name": "State of charge (%)" }, "name": "SoC", "onlyChanges": false, "x": 90, "y": 520, "wires": [ [ "5155e747c5fd1a97" ] ] }, { "id": "f7cf17c47931af4c", "type": "victron-input-battery", "z": "8548a0d587e012fd", "service": "com.victronenergy.battery/512", "path": "/Capacity", "serviceObj": { "service": "com.victronenergy.battery/512", "name": "JK-BMS" }, "pathObj": { "path": "/Capacity", "type": "float", "name": "Capacity (Ah) — JK liefert availableAh" }, "name": "Capacity (availableAh)", "onlyChanges": false, "x": 140, "y": 640, "wires": [ [ "e071e20d1dde2f3a" ] ] }, { "id": "cbf3a50dda14ba6e", "type": "victron-input-battery", "z": "8548a0d587e012fd", "service": "com.victronenergy.battery/512", "path": "/Dc/0/Voltage", "serviceObj": { "service": "com.victronenergy.battery/512", "name": "JK-BMS" }, "pathObj": { "path": "/Dc/0/Voltage", "type": "float", "name": "Battery voltage (V)" }, "name": "Voltage", "onlyChanges": false, "roundValues": "2", "x": 90, "y": 760, "wires": [ [ "32aea44cf5f57080" ] ] }, { "id": "5155e747c5fd1a97", "type": "function", "z": "8548a0d587e012fd", "name": "SoC als Zahl", "func": "\"use strict\";\n\n// Helfer: robust in Zahl umwandeln\nfunction toNumber(v) {\n if (Array.isArray(v) && v.length >= 2) return Number(v[1]);\n if (typeof v === \"object\" && v && \"value\" in v) return Number(v.value);\n if (typeof v === \"string\") {\n let t = v.trim().replace(/%/g, \"\");\n let n = Number(t);\n if (!Number.isNaN(n)) return n;\n try {\n let j = JSON.parse(v);\n if (j && \"value\" in j) return Number(j.value);\n } catch (e) {}\n }\n return Number(v);\n}\n\n// SoC aus msg.payload lesen\nlet raw = toNumber(msg.payload);\nif (!Number.isFinite(raw)) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"SoC ungültig\" });\n return null;\n}\n\n// Nur Werte zwischen 0 und 1 auf Prozent hochskalieren (aber 1 bleibt 1)\nif (raw > 0) {\n if (raw < 1) raw = raw * 100;\n}\n\n// Auf 0..100 klemmen\nlet soc = raw;\nif (soc < 0) soc = 0;\nif (soc > 100) soc = 100;\n\n// Flow-Objekt \"battery\" holen/aktualisieren\nlet b = flow.get(\"battery\") || {};\nb.socPercent = soc;\nb.currentAh = typeof b.availableAh !== \"undefined\" ? b.availableAh : null; // nur Info\nb.tsSoc = Date.now();\n\n// zurück in Flow schreiben\nflow.set(\"battery\", b);\n\n// Ausgabe & Status\nmsg.payload = b;\nnode.status({ fill: \"green\", shape: \"dot\", text: \"SoC \" + soc.toFixed(1) + \" %\" });\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 110, "y": 460, "wires": [ [] ] }, { "id": "e071e20d1dde2f3a", "type": "function", "z": "8548a0d587e012fd", "name": "availableAh speichern", "func": "\"use strict\";\n\n// Helferfunktion: robust nach Zahl umwandeln\nfunction toNumber(v){\n if (Array.isArray(v) && v.length >= 2) return Number(v[1]);\n if (v && typeof v === \"object\" && \"value\" in v) return Number(v.value);\n if (typeof v === \"string\") {\n let t = v.trim();\n let n = Number(t);\n if (!Number.isNaN(n)) return n;\n try {\n let j = JSON.parse(v);\n if (j && \"value\" in j) return Number(j.value);\n } catch(e){}\n }\n return Number(v);\n}\n\n// Wert aus Payload\nlet availableAh = toNumber(msg.payload);\nif (!Number.isFinite(availableAh) || availableAh < 0) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"availableAh ungültig\" });\n return null;\n}\n\n// Flow-Variable \"battery\" lesen oder anlegen\nlet b = flow.get(\"battery\") || {};\n\n// Werte setzen\nb.availableAh = Math.round(availableAh * 100) / 100;\nb.currentAh = b.availableAh; // nur Info\nb.tsCapacity = Date.now();\n\n// Flow-Variable schreiben\nflow.set(\"battery\", b);\n\n// Ausgabe & Status\nmsg.payload = { availableAh: b.availableAh };\nnode.status({ fill: \"green\", shape: \"dot\", text: \"Avail \" + b.availableAh + \" Ah\" });\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 140, "y": 580, "wires": [ [] ] }, { "id": "32aea44cf5f57080", "type": "function", "z": "8548a0d587e012fd", "name": "Spannung in Volt ", "func": "\"use strict\";\n\n// Helfer: robust in Zahl umwandeln\nfunction toNumber(v) {\n if (Array.isArray(v) && v.length >= 2) return Number(v[1]);\n if (typeof v === \"object\" && v && \"value\" in v) return Number(v.value);\n if (typeof v === \"string\") {\n let t = v.trim();\n let n = Number(t);\n if (!Number.isNaN(n)) return n;\n try {\n let j = JSON.parse(v);\n if (j && \"value\" in j) return Number(j.value);\n } catch (e) {}\n }\n return Number(v);\n}\n\n// Spannung lesen\nlet volt = toNumber(msg.payload);\nif (!Number.isFinite(volt) || volt <= 0) {\n node.status({ fill: \"red\", shape: \"ring\", text: \"Voltage ungültig\" });\n return null;\n}\n\n// Flow-Variable \"battery\" holen oder neu anlegen\nlet b = flow.get(\"battery\") || {};\n\n// Werte setzen\nb.voltageV = volt;\n\n// Wenn SoC & Kapazität bekannt sind, aktuelle Ah übernehmen\nif (typeof b.socPercent !== \"undefined\" && typeof b.availableAh !== \"undefined\") {\n b.currentAh = b.availableAh; // Info\n}\n\nb.tsVoltage = Date.now();\n\n// Flow-Variable schreiben\nflow.set(\"battery\", b);\n\n// Ausgabe & Status\nmsg.payload = b;\nnode.status({ fill: \"green\", shape: \"dot\", text: \"Voltage \" + volt.toFixed(2) + \" V\" });\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 130, "y": 700, "wires": [ [] ] }, { "id": "7fa3531e75f87136", "type": "function", "z": "8548a0d587e012fd", "name": "alle Variablen löschen ", "func": "// Löscht alle globalen und flow Variablen\n// (Kompatibel mit Node.js v20 / Node-RED 3+)\n\nconst delGlobals = global.keys();\nconst delFlows = flow.keys();\n\nlet deletedGlobals = 0;\nlet deletedFlows = 0;\n\nif (Array.isArray(delGlobals)) {\n for (const k of delGlobals) {\n global.set(k, undefined);\n deletedGlobals++;\n }\n}\n\nif (Array.isArray(delFlows)) {\n for (const k of delFlows) {\n flow.set(k, undefined);\n deletedFlows++;\n }\n}\n\nnode.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: `Globals: ${deletedGlobals} | Flows: ${deletedFlows} gelöscht`\n});\n\nmsg.payload = {\n deletedGlobals,\n deletedFlows,\n globals: delGlobals,\n flows: delFlows\n};\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 1000, "y": 780, "wires": [ [ "13f4eef4c2b93fca" ] ] }, { "id": "2e5b0206a3b9a6db", "type": "inject", "z": "8548a0d587e012fd", "name": "Alles zurücksetzen (hart)", "props": [ { "p": "payload" } ], "repeat": "", "crontab": "00 00 * * *", "once": false, "onceDelay": 0.1, "topic": "", "payload": "{\"scope\":\"all\",\"keep\":[]}", "payloadType": "json", "x": 770, "y": 780, "wires": [ [ "7fa3531e75f87136" ] ] }, { "id": "13f4eef4c2b93fca", "type": "debug", "z": "8548a0d587e012fd", "name": "Result", "active": false, "tosidebar": true, "console": false, "tostatus": false, "complete": "payload", "targetType": "msg", "statusVal": "", "statusType": "auto", "x": 1170, "y": 800, "wires": [] }, { "id": "c7ec8becfd200465", "type": "inject", "z": "8548a0d587e012fd", "name": "Forecast alle 10 min ", "props": [], "repeat": "600", "crontab": "", "once": true, "onceDelay": "10", "topic": "", "x": 140, "y": 320, "wires": [ [ "f4090490d5940591" ] ] }, { "id": "f4090490d5940591", "type": "function", "z": "8548a0d587e012fd", "name": "PV-Überschuss berechnen", "func": "\"use strict\";\n/**\n * PV-Überschuss (heute) aus flow.fc.pv15 / flow.fc.cons15\n * - erwartet 15-min Bins (Wh) unter flow.fc.{pv15,cons15}.bins\n * - aggregiert stündlich\n * - berechnet Überschuss Wh/h und Summen\n * - legt Ergebnis in flow.analysis.pv_surplus ab\n * - gibt als payload ein kompaktes Objekt aus\n */\n\nfunction num(v){ let n = Number(v); return Number.isFinite(n) ? n : 0; }\nfunction floorHour(ts){ let d=new Date(ts); d.setMinutes(0,0,0); return d.getTime(); }\nfunction aggHourWh(bins){\n let by={}, out=[], i, h;\n for(i=0;i=0 ? \"green\":\"blue\", shape:\"dot\", text: \"Überschuss Σ \"+(sumSur/1000).toFixed(2)+\" kWh\"});\nmsg.payload = out;\nreturn msg;\n", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 480, "y": 320, "wires": [ [ "27cffddac178a67e" ] ] }, { "id": "27cffddac178a67e", "type": "debug", "z": "8548a0d587e012fd", "name": "Berechnung", "active": false, "tosidebar": true, "console": false, "tostatus": false, "complete": "payload", "targetType": "msg", "statusVal": "", "statusType": "auto", "x": 710, "y": 320, "wires": [] }, { "id": "954e1b4f0a8d6a7b", "type": "inject", "z": "8548a0d587e012fd", "name": "Planner jetzt ausführen", "props": [], "repeat": "60", "crontab": "", "once": true, "onceDelay": "15", "topic": "", "x": 150, "y": 400, "wires": [ [ "165933a8ae7eaa18", "cdf8bf4132d7b29c", "1b3693e9553e8281", "charge-planner-no-hysteresis" ] ] }, { "id": "b815a06bf1925154", "type": "debug", "z": "8548a0d587e012fd", "name": "Plan Objekt", "active": false, "tosidebar": true, "console": false, "tostatus": false, "complete": "payload", "statusVal": "", "statusType": "auto", "x": 670, "y": 500, "wires": [] }, { "id": "34e616bb166eb809", "type": "ui_text", "z": "8548a0d587e012fd", "group": "88a2166eb4c3a551", "order": 1, "width": "6", "height": "1", "name": "Note", "label": "Note", "format": "{{msg.payload}}", "layout": "row-spread", "className": "", "style": false, "font": "", "fontSize": "", "color": "#000000", "x": 1030, "y": 400, "wires": [] }, { "id": "bd4e9bdb092a0e2c", "type": "ui_text", "z": "8548a0d587e012fd", "group": "88a2166eb4c3a551", "order": 2, "width": "6", "height": 1, "name": "Finish by", "label": "Finish by", "format": "{{msg.payload}}", "layout": "row-spread", "className": "", "style": false, "font": "", "fontSize": "", "color": "#000000", "x": 1040, "y": 420, "wires": [] }, { "id": "fa5c76078d2c7d21", "type": "ui_text", "z": "8548a0d587e012fd", "group": "88a2166eb4c3a551", "order": 3, "width": "6", "height": 1, "name": "PV Ende", "label": "PV Ende", "format": "{{msg.payload}}", "layout": "row-spread", "className": "", "style": false, "font": "", "fontSize": "", "color": "#000000", "x": 1040, "y": 440, "wires": [] }, { "id": "5dbd53d008b6bfb1", "type": "ui_text", "z": "8548a0d587e012fd", "group": "88a2166eb4c3a551", "order": 4, "width": "6", "height": 1, "name": "Bedarf DC/AC", "label": "Bedarf DC/AC", "format": "{{msg.payload}}", "layout": "row-spread", "className": "", "style": false, "font": "", "fontSize": "", "color": "#000000", "x": 1060, "y": 460, "wires": [] }, { "id": "3fdb4cd3c53a8691", "type": "ui_text", "z": "8548a0d587e012fd", "group": "88a2166eb4c3a551", "order": 5, "width": "6", "height": 1, "name": "Modus", "label": "Modus", "format": "{{msg.payload}}", "layout": "row-spread", "className": "", "style": false, "font": "", "fontSize": "", "color": "#000000", "x": 1030, "y": 480, "wires": [] }, { "id": "8684429741300239", "type": "ui_text", "z": "8548a0d587e012fd", "group": "88a2166eb4c3a551", "order": 6, "width": "6", "height": 1, "name": "Jetzt", "label": "Jetzt", "format": "{{msg.payload}}", "layout": "row-spread", "className": "", "style": false, "font": "", "fontSize": "", "color": "#000000", "x": 1030, "y": 500, "wires": [] }, { "id": "647e518c143a7a9c", "type": "inject", "z": "8548a0d587e012fd", "name": "Pull alle 10 min", "props": [], "repeat": "600", "crontab": "", "once": true, "onceDelay": "3", "topic": "", "x": 120, "y": 200, "wires": [ [ "988587e98cb53de6", "27e2ca8fcafe11df", "2b19cb59486c9525" ] ] }, { "id": "988587e98cb53de6", "type": "vrm-api", "z": "8548a0d587e012fd", "vrm": "5e4484a98a7a02c8", "name": "Solar_FC_today (PV 15min)", "api_type": "installations", "idUser": "", "users": "", "idSite": "{{flow.vrm.idSite}}", "installations": "stats", "attribute": "vrm_pv_inverter_yield_fc", "stats_interval": "15mins", "show_instance": false, "stats_start": "bod", "stats_end": "eod", "use_utc": false, "gps_start": "", "gps_end": "", "widgets": "GlobalLinkSummary", "instance": "", "vrm_id": "", "country": "", "b_max": "", "tb_max": "", "fb_max": "", "tg_max": "", "fg_max": "", "b_cycle_cost": "", "buy_price_formula": "", "sell_price_formula": "", "green_mode_on": "", "feed_in_possible": "", "feed_in_control_on": "", "b_goal_hour": "", "b_goal_SOC": "", "store_in_global_context": false, "verbose": false, "x": 480, "y": 140, "wires": [ [ "3fb02623b0b5df2f", "09d73aaea1f25caf" ] ] }, { "id": "27e2ca8fcafe11df", "type": "vrm-api", "z": "8548a0d587e012fd", "vrm": "5e4484a98a7a02c8", "name": "Cons_FC_today (Load 15min)", "api_type": "installations", "idUser": "", "users": "", "idSite": "{{flow.vrm.idSite}}", "installations": "stats", "attribute": "vrm_consumption_fc", "stats_interval": "15mins", "show_instance": false, "stats_start": "bod", "stats_end": "eod", "use_utc": false, "gps_start": "", "gps_end": "", "widgets": "GlobalLinkSummary", "instance": "", "vrm_id": "", "country": "", "b_max": "", "tb_max": "", "fb_max": "", "tg_max": "", "fg_max": "", "b_cycle_cost": "", "buy_price_formula": "", "sell_price_formula": "", "green_mode_on": "", "feed_in_possible": "", "feed_in_control_on": "", "b_goal_hour": "", "b_goal_SOC": "", "store_in_global_context": false, "verbose": false, "x": 490, "y": 200, "wires": [ [ "d5d0e9614f755262", "09d73aaea1f25caf" ] ] }, { "id": "2b19cb59486c9525", "type": "vrm-api", "z": "8548a0d587e012fd", "vrm": "5e4484a98a7a02c8", "name": "Solar_Total (Ist 15min)", "api_type": "installations", "idUser": "", "users": "", "idSite": "{{flow.vrm.idSite}}", "installations": "stats", "attribute": "total_solar_yield", "stats_interval": "15mins", "show_instance": false, "stats_start": "bod", "stats_end": "eod", "use_utc": false, "gps_start": "", "gps_end": "", "widgets": "GlobalLinkSummary", "instance": "", "vrm_id": "", "country": "", "b_max": "", "tb_max": "", "fb_max": "", "tg_max": "", "fg_max": "", "b_cycle_cost": "", "buy_price_formula": "", "sell_price_formula": "", "green_mode_on": "", "feed_in_possible": "", "feed_in_control_on": "", "b_goal_hour": "", "b_goal_SOC": "", "store_in_global_context": false, "verbose": false, "x": 460, "y": 260, "wires": [ [ "a3e98ac42c19aa35", "09d73aaea1f25caf", "d5bfa5475055b0c4" ] ] }, { "id": "3fb02623b0b5df2f", "type": "function", "z": "8548a0d587e012fd", "name": "Store → flow.fc.pv15", "func": "// speichert PV-Forecast (15min, heute) in flow.fc.pv15\nfunction toYMD(ts){let d=new Date(ts);return d.getFullYear()+\"-\"+('0'+(d.getMonth()+1)).slice(-2)+\"-\"+('0'+d.getDate()).slice(-2);} \nlet p=msg.payload||{}; let rows=p.records&&p.records.vrm_pv_inverter_yield_fc; \nif(!rows||!rows.length){ node.status({fill:'yellow',shape:'ring',text:'keine PV-FC Daten'}); return null; }\nlet bins=[], totalWh=0; \nfor (let i=0;i=0; if(!isKWh){ let max=0; for(let i=0;imax) max=v; } if(max>0 && max<=20) isKWh=true; }\nlet f=isKWh?1000:1; let bins=[], totalWh=0; \nfor (let j=0;ja-b);\n for(i=0;ia-b); }\nfunction mapOf(arr){ let m={},i; for(i=0;ia-b);\n let cum=0,out=[];\n for(i=0;i({x:x,y:(mpv[x]||0)})); // PV (kWh/h)\nlet s2=xs.map(x=>({x:x,y:(mco[x]||0)})); // Verbrauch (kWh/h)\nlet s3=cumHourly(xs,mpv); // PV Σ\nlet s4=cumHourly(xs,mco); // Verbrauch Σ\nlet s5=cumIstFrom15(ist.bins||[]); // PV Ist Σ\n\n// --- Ladekurve (Prognose + Live) ---\nlet plan = flow.get(\"analysis.chargePlan\") || {};\nlet planned = Array.isArray(plan.plannedHours) ? plan.plannedHours : [];\nlet cfg = flow.get(\"chargeCfg\") || {};\nlet bat = flow.get(\"battery\") || {};\n\nlet capacityAh = num(cfg.capacityAh, 500);\nlet chargeEff = clamp(num(cfg.chargeEff, 0.9), 0.5, 1.0);\nlet volt = num(bat.voltageV, 48);\nlet socStart = clamp(num(bat.socPercent, 0), 0, 100);\n\n// Zeitpunkte h0 (Stundenbeginn) und jetzt\nlet now = Date.now();\nlet h0 = h(now);\n\n// aktuellen Stundenanteil bestimmen, damit \"jetzt\" ein sinnvoller Punkt ist\nlet minutesLeft = 60; // Default\ntry {\n if (plan.currentHour && Number.isFinite(plan.currentHour.minutesLeft)) {\n minutesLeft = num(plan.currentHour.minutesLeft, minutesLeft);\n } else {\n // Fallback: rechne es selbst\n minutesLeft = Math.max(1, Math.ceil((h0 + 3600e3 - now)/60000));\n }\n} catch(e){}\n\nlet minutesPassed = 60 - minutesLeft;\nlet shareThisHourWh = 0;\nlet mapShareWh = {};\n\n// Map geplante AC-Wh/h (ganze Stunden)\nfor (let i=0;i W\nlet pNow_W = pHourAvg_W; // als horizontale Leistung in dieser Stunde\nlet kW_now = pNow_W/1000;\n\n// SoC bis jetzt fortschreiben (DC-Anteil)\nlet socNow = socStart;\nif (shareThisHourWh > 0 && volt > 0 && capacityAh > 0) {\n let dcWhNow = shareThisHourWh * (minutesPassed/60) * chargeEff; // Anteil der Stunde * Effizienz\n let dAhNow = dcWhNow / volt;\n let dSocNow = (dAhNow / capacityAh) * 100;\n socNow = clamp(socStart + dSocNow, 0, 100);\n}\n\n// Prognose über die weiteren (vollen) Stunden\nlet xsFull = xs.slice(); // Stundenraster der bestehenden Charts\nif (xsFull.indexOf(h0) === -1) xsFull.push(h0);\nxsFull = xsFull.sort((a,b)=>a-b);\n\n// Serien vorbereiten\nlet s6 = []; // Ladeleistung (kW) Prognose + Live\nlet s7 = []; // SoC progn. (%) inkl. Live\n\n// 1) Zwei Live-Punkte, damit eine Linie entsteht (h0 & jetzt)\ns6.push({ x: h0, y: Number(kW_now.toFixed(3)) });\ns6.push({ x: now, y: Number(kW_now.toFixed(3)) });\n\ns7.push({ x: h0, y: Number(socStart.toFixed(2)) });\ns7.push({ x: now, y: Number(socNow.toFixed(2)) });\n\n// 2) Zukünftige Stunden aus dem Plan auf dem gleichen Raster\nlet socRun = socNow;\nfor (let i=0;i 0 && volt > 0 && capacityAh > 0) {\n let dcWh = shareWh * chargeEff;\n let dAh = dcWh / volt;\n let dSoc = (dAh / capacityAh) * 100;\n socRun = clamp(socRun + dSoc, 0, 100);\n }\n s7.push({ x:x, y:Number(socRun.toFixed(2)) });\n}\n\n// Falls es GAR KEINE geplanten Stunden gibt, zeichnen wir trotzdem eine kleine horizontale Linie um den Livepunkt:\nif (!planned.length){\n s6 = [\n { x: h0-1, y: 0 },\n { x: now, y: 0 }\n ];\n s7 = [\n { x: h0-1, y: Number(socStart.toFixed(2)) },\n { x: now, y: Number(socStart.toFixed(2)) }\n ];\n}\n\n// --- Payload im selben Format wie dein bestehender Chart ---\nlet payload=[{\n series:[\n \"PV (kWh/h)\",\n \"Verbrauch (kWh/h)\",\n \"PV Σ (kWh)\",\n \"Verbrauch Σ (kWh)\",\n \"PV Ist Σ (kWh)\",\n \"Ladeleistung (kW) Prognose\",\n \"SoC progn. (%)\"\n ],\n data:[ s1, s2, s3, s4, s5, s6, s7 ],\n labels:[]\n}];\n\nflow.set('chart.data', payload);\n\n// Statusanzeige\nlet pvSum = s3.length?s3[s3.length-1].y:0,\n coSum = s4.length?s4[s4.length-1].y:0,\n istSum= s5.length?s5[s5.length-1].y:0;\nnode.status({fill:'green',shape:'dot',text:'PV Σ '+pvSum.toFixed(2)+' | Load Σ '+coSum.toFixed(2)+' | PV Ist Σ '+istSum.toFixed(2)+' kWh'});\n\nmsg.payload = null;\nreturn msg;\n", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 450, "y": 20, "wires": [ [] ] }, { "id": "1b3693e9553e8281", "type": "function", "z": "8548a0d587e012fd", "name": "Performance-Warnung", "func": "\nlet pv = flow.get('fc.pv15');\nif (pv && pv.bins && pv.bins.length > 1000) {\n node.warn('Sehr viele Prognosedaten – Performance kann leiden!');\n}\nreturn null;\n", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 460, "y": 460, "wires": [ [] ] }, { "id": "165933a8ae7eaa18", "type": "function", "z": "8548a0d587e012fd", "name": "Build Ist + Prognose (Chart)", "func": "\n\"use strict\";\nfunction h(ts){ let d=new Date(ts); d.setSeconds(0,0); return d.getTime(); }\nfunction num(v,d){ let n=Number(v); return Number.isFinite(n)?n:(d||0); }\nfunction clamp(v,lo,hi){ return Math.min(hi, Math.max(lo, v)); }\nfunction uniqSorted(arr){ return Array.from(new Set(arr)).sort((a,b)=>a-b); }\n\n// Read sources\nlet ist = flow.get(\"analysis.chargeIst\") || { points: [] };\nlet nowSnap = flow.get(\"analysis.chargeNow\") || null;\nlet plan = flow.get(\"analysis.chargePlan\") || {};\nlet planned = Array.isArray(plan.plannedHours) ? plan.plannedHours.slice() : [];\n\nlet cfg = flow.get(\"chargeCfg\") || {};\nlet bat = flow.get(\"battery\") || {};\nlet capacityAh = num(cfg.capacityAh, 500);\nlet eff = clamp(num(cfg.chargeEff, 0.9), 0.5, 1.0);\nlet volt = num(bat.voltageV, 48);\nlet soc0 = clamp(num(bat.socPercent, 0), 0, 100);\n\n// --- Build IST series (resample to minute grid if needed) ---\nlet istPts = Array.isArray(ist.points) ? ist.points.slice() : [];\nistPts.sort((a,b)=>num(a.ts)-num(b.ts));\n\nlet istKW = [];\nlet istSOC = [];\nfor (let i=0;i0 && volt>0 && capacityAh>0){\n let dcWh = shareWh * eff;\n let dAh = dcWh / volt;\n let dSoc = (dAh / capacityAh) * 100;\n socRun = clamp(socRun + dSoc, 0, 100);\n }\n progSOC.push({x:x, y:Number(socRun.toFixed(2))});\n}\n\n// Ensure at least two points for chart lines\nif (progKW.length===1) { progKW.push({x: anchorX+60000, y: progKW[0].y}); }\nif (progSOC.length===1){ progSOC.push({x: anchorX+60000, y: progSOC[0].y}); }\nif (istKW.length===1) { istKW.push({x: istKW[0].x+60000, y: istKW[0].y}); }\nif (istSOC.length===1) { istSOC.push({x: istSOC[0].x+60000, y: istSOC[0].y}); }\n\n// Payload for ui_chart\nlet payload = [{\n series: [\n \"Ist Leistung (kW)\",\n \"Ist SoC (%)\",\n \"Prognose Leistung (kW)\",\n \"Prognose SoC (%)\"\n ],\n data: [ istKW, istSOC, progKW, progSOC ],\n labels: []\n}];\n\nflow.set(\"ladeanalyse.chart\", payload);\nmsg.payload = payload;\nreturn msg;\n", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 480, "y": 380, "wires": [ [ "9474c65091eb72d7" ] ] }, { "id": "cdf8bf4132d7b29c", "type": "function", "z": "8548a0d587e012fd", "name": "Build Tabelle (Ist + Plan)", "func": "\n\"use strict\";\nfunction h(ts){ let d=new Date(ts); d.setMinutes(0,0,0); return d.getTime(); }\nfunction num(v,d){ let n=Number(v); return Number.isFinite(n)?n:(d||0); }\nfunction clamp(v,lo,hi){ return Math.min(hi, Math.max(lo, v)); }\nfunction fmt(ts){ try { return new Date(ts).toLocaleString(); } catch(e){ return String(ts); } }\n\nlet plan = flow.get(\"analysis.chargePlan\") || {};\nlet planned = Array.isArray(plan.plannedHours) ? plan.plannedHours.slice() : [];\nlet nowSnap = flow.get(\"analysis.chargeNow\") || null;\nlet cfg = flow.get(\"chargeCfg\") || {};\nlet bat = flow.get(\"battery\") || {};\n\nlet capacityAh = num(cfg.capacityAh, 500);\nlet eff = Math.max(0.5, Math.min(1.0, num(cfg.chargeEff, 0.9)));\nlet volt = num(bat.voltageV, 48);\nlet soc0 = num(bat.socPercent, 0);\n\n// Start row for NOW\nlet rows = [];\nif (nowSnap){\n rows.push({\n Zeitpunkt: fmt(nowSnap.ts),\n Typ: \"Jetzt\",\n \"kW\": Number((num(nowSnap.pCmd_W,0)/1000).toFixed(3)),\n \"SoC (%)\": Number(num(nowSnap.socPercent, soc0).toFixed(2)),\n \"PV Wh\": \"\",\n \"Share Wh\": \"\",\n \"Modus\": String(nowSnap.mode||\"\")\n });\n}\n\n// Planned rows\nplanned.sort((a,b)=>num(a.ts)-num(b.ts));\nlet socRun = nowSnap ? num(nowSnap.socPercent, soc0) : soc0;\n\nfor (let i=0;i0 && volt>0 && capacityAh>0){\n let dcWh = share * eff;\n let dAh = dcWh / volt;\n let dSoc = (dAh / capacityAh) * 100;\n socRun = clamp(socRun + dSoc, 0, 100);\n }\n\n rows.push({\n Zeitpunkt: fmt(ts),\n Typ: \"Plan\",\n \"kW\": Number(kW.toFixed(3)),\n \"SoC (%)\": Number(socRun.toFixed(2)),\n \"PV Wh\": Math.round(pvWh),\n \"Share Wh\": Math.round(share),\n \"Modus\": \"\"\n });\n}\n\nmsg.payload = rows;\nreturn msg;\n", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 470, "y": 420, "wires": [ [ "6c09d1339d32b9db" ] ] }, { "id": "9474c65091eb72d7", "type": "ui_chart", "z": "8548a0d587e012fd", "name": "Ist + Prognose", "group": "88a2166eb4c3a551", "order": 1, "width": "18", "height": "10", "label": "Ist + Prognose", "chartType": "line", "legend": "true", "xformat": "HH:mm", "interpolate": "linear", "nodata": "Warte auf Daten…", "dot": false, "ymin": "", "ymax": "", "removeOlder": 0, "removeOlderPoints": "", "removeOlderUnit": "3600", "cutout": 0, "useOneColor": false, "useUTC": false, "colors": [ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22" ], "outputs": 1, "useDifferentColor": false, "className": "", "x": 1060, "y": 300, "wires": [ [] ] }, { "id": "6c09d1339d32b9db", "type": "ui_table", "z": "8548a0d587e012fd", "group": "88a2166eb4c3a551", "name": "Plan-Tabelle", "order": 1, "width": "18", "height": "6", "columns": [], "outputs": 0, "cts": false, "x": 1050, "y": 340, "wires": [] }, { "id": "10b725ef9becf143", "type": "function", "z": "8548a0d587e012fd", "name": "Prognose löschen", "func": "\"use strict\";\n// Funktion: löscht Tagesdaten und Prognosen um Mitternacht\n\n// Datum merken, um später ggf. prüfen zu können\nlet today = new Date().toISOString().slice(0,10);\nflow.set(\"analysis.lastResetDate\", today);\n\n// Alte Analyse-Daten löschen\nflow.set(\"analysis.chargeIst\", null);\nflow.set(\"analysis.chargeNow\", null);\nflow.set(\"analysis.chargePlan\", null);\nflow.set(\"ladeanalyse.chart\", null);\n\n// Optional: Forecast-Daten löschen (falls neu eingelesen werden)\nflow.set(\"fc.pv15\", null);\nflow.set(\"fc.cons15\", null);\n\nnode.status({fill:\"green\", shape:\"dot\", text:\"Ladeanalyse um Mitternacht zurückgesetzt\"});\nreturn null;\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 990, "y": 840, "wires": [ [ "13f4eef4c2b93fca" ] ] }, { "id": "9cdc12336bfd9638", "type": "inject", "z": "8548a0d587e012fd", "name": "0 Uhr ausführen", "props": [], "repeat": "", "crontab": "00 00 * * *", "once": false, "onceDelay": 0.1, "topic": "", "x": 750, "y": 840, "wires": [ [ "10b725ef9becf143" ] ] }, { "id": "f606fc80169c30d1", "type": "inject", "z": "8548a0d587e012fd", "name": "Pull alle 10 min", "props": [], "repeat": "600", "crontab": "", "once": true, "onceDelay": "3", "topic": "", "x": 120, "y": 820, "wires": [ [ "ac285f792e362389", "1b346ff43236e8d8", "1009e57fbe465610", "f6750f5b1619a7b6", "7ba2a8bd61e215be" ] ] }, { "id": "ac285f792e362389", "type": "vrm-api", "z": "8548a0d587e012fd", "vrm": "5e4484a98a7a02c8", "name": "solar total 1d 12w", "api_type": "installations", "idUser": "", "users": "", "idSite": "794347", "installations": "stats", "attribute": "total_solar_yield", "stats_interval": "days", "show_instance": false, "stats_start": "-7257600", "stats_end": "eod", "use_utc": false, "gps_start": "", "gps_end": "", "widgets": "GlobalLinkSummary", "instance": "", "vrm_id": "", "country": "", "b_max": "", "tb_max": "", "fb_max": "", "tg_max": "", "fg_max": "", "b_cycle_cost": "", "buy_price_formula": "", "sell_price_formula": "", "green_mode_on": "", "feed_in_possible": "", "feed_in_control_on": "", "b_goal_hour": "", "b_goal_SOC": "", "store_in_global_context": false, "verbose": false, "x": 450, "y": 580, "wires": [ [ "4f92c975e2946de6" ] ] }, { "id": "1b346ff43236e8d8", "type": "vrm-api", "z": "8548a0d587e012fd", "vrm": "5e4484a98a7a02c8", "name": "grid direct use 1d 12w", "api_type": "installations", "idUser": "", "users": "", "idSite": "794347", "installations": "stats", "attribute": "Gc", "stats_interval": "days", "show_instance": false, "stats_start": "-7257600", "stats_end": "eod", "use_utc": false, "gps_start": "", "gps_end": "", "widgets": "GlobalLinkSummary", "instance": "", "vrm_id": "", "country": "", "b_max": "", "tb_max": "", "fb_max": "", "tg_max": "", "fg_max": "", "b_cycle_cost": "", "buy_price_formula": "", "sell_price_formula": "", "green_mode_on": "", "feed_in_possible": "", "feed_in_control_on": "", "b_goal_hour": "", "b_goal_SOC": "", "store_in_global_context": false, "verbose": false, "x": 460, "y": 640, "wires": [ [ "4f92c975e2946de6" ] ] }, { "id": "1009e57fbe465610", "type": "vrm-api", "z": "8548a0d587e012fd", "vrm": "5e4484a98a7a02c8", "name": "solar direct use 1d 12w", "api_type": "installations", "idUser": "", "users": "", "idSite": "794347", "installations": "stats", "attribute": "Pc", "stats_interval": "days", "show_instance": false, "stats_start": "-7257600", "stats_end": "eod", "use_utc": false, "gps_start": "", "gps_end": "", "widgets": "GlobalLinkSummary", "instance": "", "vrm_id": "", "country": "", "b_max": "", "tb_max": "", "fb_max": "", "tg_max": "", "fg_max": "", "b_cycle_cost": "", "buy_price_formula": "", "sell_price_formula": "", "green_mode_on": "", "feed_in_possible": "", "feed_in_control_on": "", "b_goal_hour": "", "b_goal_SOC": "", "store_in_global_context": false, "verbose": false, "x": 470, "y": 760, "wires": [ [ "4f92c975e2946de6" ] ] }, { "id": "f6750f5b1619a7b6", "type": "vrm-api", "z": "8548a0d587e012fd", "vrm": "5e4484a98a7a02c8", "name": "solar to battery 1d 12w", "api_type": "installations", "idUser": "", "users": "", "idSite": "794347", "installations": "stats", "attribute": "Pb", "stats_interval": "days", "show_instance": false, "stats_start": "-7257600", "stats_end": "eod", "use_utc": false, "gps_start": "", "gps_end": "", "widgets": "GlobalLinkSummary", "instance": "", "vrm_id": "", "country": "", "b_max": "", "tb_max": "", "fb_max": "", "tg_max": "", "fg_max": "", "b_cycle_cost": "", "buy_price_formula": "", "sell_price_formula": "", "green_mode_on": "", "feed_in_possible": "", "feed_in_control_on": "", "b_goal_hour": "", "b_goal_SOC": "", "store_in_global_context": false, "verbose": false, "x": 460, "y": 700, "wires": [ [ "4f92c975e2946de6" ] ] }, { "id": "7ba2a8bd61e215be", "type": "vrm-api", "z": "8548a0d587e012fd", "vrm": "5e4484a98a7a02c8", "name": "battery direct use 1d 12w", "api_type": "installations", "idUser": "", "users": "", "idSite": "794347", "installations": "stats", "attribute": "Bc", "stats_interval": "days", "show_instance": false, "stats_start": "-7257600", "stats_end": "eod", "use_utc": false, "gps_start": "", "gps_end": "", "widgets": "GlobalLinkSummary", "instance": "", "vrm_id": "", "country": "", "b_max": "", "tb_max": "", "fb_max": "", "tg_max": "", "fg_max": "", "b_cycle_cost": "", "buy_price_formula": "", "sell_price_formula": "", "green_mode_on": "", "feed_in_possible": "", "feed_in_control_on": "", "b_goal_hour": "", "b_goal_SOC": "", "store_in_global_context": false, "verbose": false, "x": 470, "y": 820, "wires": [ [ "4f92c975e2946de6" ] ] }, { "id": "4f92c975e2946de6", "type": "function", "z": "8548a0d587e012fd", "name": "Aggregate & Einspeisung", "func": "function sumArray(pairs){ if(!Array.isArray(pairs)) return 0; return pairs.reduce((a,p)=>a+(Array.isArray(p)?(Number(p[1])||0):0),0); }\nfunction toMap(pairs){ const m=new Map(); if(Array.isArray(pairs)){ for(const p of pairs){ if(Array.isArray(p)&&p.length>=2){ m.set(Number(p[0]), Number(p[1])||0); } } } return m; }\nfunction getMetricNameFromRecords(records){ if(!records||typeof records!==\"object\") return null; const keys=Object.keys(records); return keys.length===1?keys[0]:null; }\n\nlet state=context.get(\"state\")||{parts:{},totals:{},options:{},topic:null};\nconst payload=msg.payload||{};\nconst records=payload.records||{};\nconst totals =payload.totals ||{};\nconst options=payload.options||{};\nconst metricName=getMetricNameFromRecords(records);\nif(!metricName){ node.warn(\"Erwarte genau eine Kennzahl in payload.records.\"); return null; }\n\nstate.parts[metricName]=records[metricName];\nif(totals && totals[metricName]!=null) state.totals[metricName]=Number(totals[metricName])||0;\nstate.options=options; if(!state.topic&&msg.topic) state.topic=msg.topic;\ncontext.set(\"state\", state);\n\n// Für die Einspeisung brauchen wir diese drei Quellen\nif(!(Array.isArray(state.parts.total_solar_yield)&&Array.isArray(state.parts.Pb)&&Array.isArray(state.parts.Pc))){ return null; }\n\nconst yieldArr=state.parts.total_solar_yield||[];\nconst pbMap=toMap(state.parts.Pb);\nconst pcMap=toMap(state.parts.Pc);\nconst einspeisung=[];\nfor(const row of yieldArr){ if(!Array.isArray(row)||row.length<2) continue; const ts=Number(row[0]); const y=Number(row[1])||0; const pb=pbMap.get(ts)||0; const pc=pcMap.get(ts)||0; einspeisung.push([ts, y-(pb+pc)]); }\n\nmsg.payload={ success:true, records:{ total_solar_yield:yieldArr, Pb:state.parts.Pb, Pc:state.parts.Pc, einspeisung:einspeisung }, totals:{ total_solar_yield: state.totals.total_solar_yield || sumArray(yieldArr), Pb: state.totals.Pb || sumArray(state.parts.Pb), Pc: state.totals.Pc || sumArray(state.parts.Pc), einspeisung: sumArray(einspeisung) }, options: state.options||{} };\n\n// Bc (Zusatzwert) nur durchreichen und summieren – kein Einfluss auf Einspeisung\nif(Array.isArray(state.parts.Bc)){\n msg.payload.records.Bc = state.parts.Bc;\n msg.payload.totals.Bc = state.totals.Bc || sumArray(state.parts.Bc);\n}\n\nreturn msg;", "outputs": 1, "noerr": 0, "x": 750, "y": 600, "wires": [ [ "64133105d6a6f7da", "b11e7b2c601c5ec9" ] ] }, { "id": "64133105d6a6f7da", "type": "function", "z": "8548a0d587e012fd", "name": "Totals", "func": "// Output: [pv, pb, pc, einspeisung, bc]\nfunction fmt(n){ return Number(n||0).toFixed(2); }\nconst t=(msg.payload&&msg.payload.totals)?msg.payload.totals:{};\nconst out = [\n {payload: fmt(t.total_solar_yield) + \" kWh\"},\n {payload: fmt(t.Pb) + \" kWh\"},\n {payload: fmt(t.Pc) + \" kWh\"},\n {payload: fmt(t.einspeisung) + \" kWh\"},\n {payload: (t.Bc!==undefined?fmt(t.Bc):\"0.00\") + \" kWh\"}\n];\nreturn out;", "outputs": 5, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 750, "y": 700, "wires": [ [ "9d21e16e4549e535" ], [ "4a014520e423fc14" ], [ "04264376e0eb276d" ], [ "d67d0ba3c671af51" ], [ "d4f2dfee8db19e2b" ] ] }, { "id": "9d21e16e4549e535", "type": "ui_text", "z": "8548a0d587e012fd", "group": "ui_group_einspeisung", "order": 1, "width": "7", "height": "1", "name": "PV-Ertrag (Summe)", "label": "PV-Ertrag", "format": "{{msg.payload}}", "layout": "row-spread", "className": "", "style": false, "font": "", "fontSize": "", "color": "#000000", "x": 1070, "y": 640, "wires": [] }, { "id": "4a014520e423fc14", "type": "ui_text", "z": "8548a0d587e012fd", "group": "ui_group_einspeisung", "order": 2, "width": "7", "height": "1", "name": "Direktverbrauch PV (Summe)", "label": "Direktverbrauch PV ", "format": "{{msg.payload}}", "layout": "row-spread", "className": "", "style": false, "font": "", "fontSize": "", "color": "#000000", "x": 1100, "y": 660, "wires": [] }, { "id": "04264376e0eb276d", "type": "ui_text", "z": "8548a0d587e012fd", "group": "ui_group_einspeisung", "order": 3, "width": "7", "height": "1", "name": "Batterieladung (Summe)", "label": "Batterieladung ", "format": "{{msg.payload}}", "layout": "row-spread", "className": "", "style": false, "font": "", "fontSize": "", "color": "#000000", "x": 1090, "y": 680, "wires": [] }, { "id": "d67d0ba3c671af51", "type": "ui_text", "z": "8548a0d587e012fd", "group": "ui_group_einspeisung", "order": 4, "width": "7", "height": "1", "name": "Einspeisung (Summe)", "label": "Einspeisung ", "format": "{{msg.payload}}", "layout": "row-spread", "className": "", "style": false, "font": "", "fontSize": "", "color": "#000000", "x": 1080, "y": 700, "wires": [] }, { "id": "d4f2dfee8db19e2b", "type": "ui_text", "z": "8548a0d587e012fd", "group": "ui_group_einspeisung", "order": 5, "width": "7", "height": "1", "name": "Bc (Summe)", "label": "Battery Verbrauch", "format": "{{msg.payload}}", "layout": "row-spread", "className": "", "style": false, "font": "", "fontSize": "", "color": "#000000", "x": 1050, "y": 720, "wires": [] }, { "id": "b11e7b2c601c5ec9", "type": "function", "z": "8548a0d587e012fd", "name": "Tabellenzeilen", "func": "function toMap(pairs){ const m=new Map(); if(Array.isArray(pairs)){ for(const p of pairs){ if(Array.isArray(p)&&p.length>=2){ m.set(Number(p[0]), Number(p[1])||0); } } } return m; }\nfunction fmt(n){ return Number(n||0).toFixed(2); }\nconst r=msg.payload&&msg.payload.records?msg.payload.records:{};\nconst yArr=r.total_solar_yield||[];\nconst pb=toMap(r.Pb||[]);\nconst pc=toMap(r.Pc||[]);\nconst ex=toMap(r.einspeisung||[]);\nconst bc=toMap(r.Bc||[]); // optional\nconst rows=[];\nfor(const row of yArr){\n if(!Array.isArray(row)||row.length<2) continue;\n const ts=Number(row[0]);\n const y =Number(row[1])||0;\n const datum=new Date(ts).toISOString().slice(0,19).replace('T',' ');\n rows.push({\n datum,\n total_solar_yield: fmt(y),\n Pb: fmt(pb.get(ts)||0),\n Pc: fmt(pc.get(ts)||0),\n einspeisung: fmt(ex.get(ts)||(y-((pb.get(ts)||0)+(pc.get(ts)||0)))),\n Bc: fmt(bc.get(ts)||0)\n });\n}\nmsg.payload=rows;\nreturn msg;", "outputs": 1, "timeout": "", "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 760, "y": 640, "wires": [ [ "90dcdaf04067609a" ] ] }, { "id": "90dcdaf04067609a", "type": "ui_table", "z": "8548a0d587e012fd", "group": "ui_group_einspeisung", "name": "Einspeisung Tabelle", "order": 6, "width": "21", "height": "28", "columns": [ { "field": "datum", "title": "Datum", "width": "", "align": "left", "formatter": "plaintext", "formatterParams": { "target": "_blank" } }, { "field": "einspeisung", "title": "Einspeisung", "width": "", "align": "left", "formatter": "plaintext", "formatterParams": { "target": "_blank" } }, { "field": "total_solar_yield", "title": "PV Ertrag", "width": "", "align": "left", "formatter": "plaintext", "formatterParams": { "target": "_blank" } }, { "field": "Pb", "title": "Direktverbrauch PV ", "width": "", "align": "left", "formatter": "plaintext", "formatterParams": { "target": "_blank" } }, { "field": "Pc", "title": "Batterieladung ", "width": "", "align": "left", "formatter": "plaintext", "formatterParams": { "target": "_blank" } }, { "field": "Bc", "title": "Battery Verbrauch", "width": "", "align": "left", "formatter": "plaintext", "formatterParams": { "target": "_blank" } } ], "outputs": 0, "cts": false, "x": 1080, "y": 600, "wires": [] }, { "id": "2720b07727673495", "type": "ui_text", "z": "8548a0d587e012fd", "group": "88a2166eb4c3a551", "order": 7, "width": "8", "height": "1", "name": "Plan Start", "label": "Plan Start", "format": "{{msg.payload}}", "layout": "row-spread", "className": "", "style": false, "font": "", "fontSize": "", "color": "#000000", "x": 1040, "y": 520, "wires": [] }, { "id": "a16ad1acf3b720bd", "type": "ui_text", "z": "8548a0d587e012fd", "group": "88a2166eb4c3a551", "order": 8, "width": "8", "height": "1", "name": "Plan Ende", "label": "Plan Ende", "format": "{{msg.payload}}", "layout": "row-spread", "className": "", "style": false, "font": "", "fontSize": "", "color": "#000000", "x": 1050, "y": 540, "wires": [] }, { "id": "planfunc8out_v2", "type": "function", "z": "8548a0d587e012fd", "name": "Plan", "func": "\"use strict\";\nfunction fmt(ts) {\n if (ts === undefined || ts === null) return \"-\";\n if (typeof ts === \"number\") {\n const d = new Date(ts);\n return isNaN(d.getTime()) ? \"-\" : d.toLocaleString();\n }\n if (typeof ts === \"string\" && /^\\d+$/.test(ts)) {\n const d = new Date(Number(ts));\n return isNaN(d.getTime()) ? \"-\" : d.toLocaleString();\n }\n const d = new Date(ts);\n return isNaN(d.getTime()) ? \"-\" : d.toLocaleString();\n}\nfunction derivePlanWindow(p){\n // 1) Wenn Felder vorhanden sind, direkt nutzen\n if (p && p.planStartTs !== undefined && p.planEndTs !== undefined) {\n return {start: p.planStartTs, end: p.planEndTs};\n }\n // 2) Aus plannedHours ableiten\n if (p && Array.isArray(p.plannedHours) && p.plannedHours.length){\n let first = -1, last = -1;\n for (let i=0;i 0.1)) { first = i; break; }\n }\n for (let i=p.plannedHours.length-1;i>=0;i--){\n if ((p.plannedHours[i] && Number(p.plannedHours[i].shareWh) > 0.1)) { last = i; break; }\n }\n if (first >= 0 && last >= 0){\n const s = p.plannedHours[first].ts;\n const e = p.plannedHours[last].ts + 3600e3;\n return {start: s, end: e};\n }\n }\n // 3) Falls Force-Max aktiv: Start = aktuelle Stunde (falls bekannt), Ende = finishByTs\n if (p && p.forcedMax === true && p.finishByTs){\n const h0 = (p.currentHour && Number.isFinite(p.currentHour.tsStart)) ? p.currentHour.tsStart : Date.now();\n return {start: h0, end: p.finishByTs};\n }\n // 4) Kein Plan ermittelbar\n return {start: null, end: null};\n}\n\nlet p = msg.payload || {};\n\nlet out1 = { payload: p.note || \"-\" };\nlet out2 = { payload: fmt(p.finishByTs) };\nlet out3 = { payload: fmt(p.pvEndTs) };\nlet out4 = { payload: (Math.round((p.needWh_dc||0)) + \" Wh / \" + Math.round((p.needWh_ac||0)) + \" Wh\") };\nlet out5 = { payload: (p.currentHour && p.currentHour.mode) ? p.currentHour.mode : \"-\" };\nlet out6 = { payload: (p.currentHour ? (p.currentHour.amps + \" A (\" + p.currentHour.pCmd_W + \" W)\") : \"-\") };\n\nconst win = derivePlanWindow(p);\nlet startText = win.start === null ? (p.forcedMax ? \"sofort\" : \"kein Plan\") : fmt(win.start);\nlet endText = win.end === null ? (p.forcedMax ? fmt(p.finishByTs) : \"kein Plan\") : fmt(win.end);\n\nlet out7 = { payload: startText }; // Plan Start\nlet out8 = { payload: endText }; // Plan Ende\n\nreturn [out1,out2,out3,out4,out5,out6,out7,out8];\n", "outputs": 8, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 850, "y": 520, "wires": [ [ "34e616bb166eb809" ], [ "bd4e9bdb092a0e2c" ], [ "fa5c76078d2c7d21" ], [ "5dbd53d008b6bfb1" ], [ "3fdb4cd3c53a8691" ], [ "8684429741300239" ], [ "2720b07727673495" ], [ "a16ad1acf3b720bd" ] ] }, { "id": "charge-planner-no-hysteresis", "type": "function", "z": "8548a0d587e012fd", "name": "Charge Planner", "func": "/* Charge Planner – ohne Hysterese\n - PV-Glättung innerhalb Planfenster\n - Force Max bei PV-Defizit\n - Mindeststrom innerhalb des Plans: minA\n - Mindeststrom außerhalb des Plans: minAOutside (NEU)\n - Slew-Rate-Limit bleibt aktiv (max. 10 A pro Tick)\n*/\n\"use strict\";\n\n/* ---------- Helpers ---------- */\nfunction num(v, d){\n var n = Number(v);\n return Number.isFinite(n) ? n : (typeof d !== \"undefined\" ? d : 0);\n}\nfunction clamp(v, lo, hi){ return Math.min(hi, Math.max(lo, v)); }\nfunction floorToHour(t){ var d=new Date(t); d.setMinutes(0,0,0); return d.getTime(); }\nfunction ymd(ts){\n var d=new Date(ts);\n return d.getFullYear()+\"-\"+String(d.getMonth()+1).padStart(2,\"0\")+\"-\"+String(d.getDate()).padStart(2,\"0\");\n}\n\n/* ---------- Konfiguration & Livewerte ---------- */\nvar cfg = flow.get(\"chargeCfg\") || {};\nvar bat = flow.get(\"battery\") || {};\n\nvar maxA = num(cfg.maxA, 140);\nvar minA = num(cfg.minA, 5);\nvar minAOutside = num(cfg.minAOutside, 0);\nif (minA > maxA) minA = maxA;\nif (minAOutside < 0) minAOutside = 0;\nif (minAOutside > maxA) minAOutside = maxA;\n\nvar targetSocPercent = clamp(num(cfg.targetSocPercent, 100), 0, 100);\nvar reserveSocPercent = clamp(num(cfg.reserveSocPercent, 0), 0, 100);\nvar chargeEff = clamp(num(cfg.chargeEff, 0.9), 0.5, 1.0);\nvar headroomW = num(cfg.headroomW, 0);\nvar targetLeadHours = Math.max(0, num(cfg.targetLeadHours, 0));\nvar capacityAh = num(cfg.capacityAh, 500);\nvar pvThresholdWh = num(cfg.pvThresholdWh, 50);\n\nif (!Number.isFinite(capacityAh) || capacityAh <= 0){\n node.status({fill:\"red\",shape:\"ring\",text:\"capacityAh ungültig\"});\n return null;\n}\n\nvar volt = num(bat.voltageV);\nvar soc = num(bat.socPercent, 0);\nvar availAh = (bat && typeof bat.availableAh !== \"undefined\") ? num(bat.availableAh) : NaN;\n\nif (!Number.isFinite(volt) || volt <= 0) {\n var plan0 = { ts: Date.now(), note: \"Batteriespannung fehlt/ungültig – 0A\", forcedMax: false };\n node.status({fill:\"yellow\",shape:\"ring\",text:plan0.note});\n return [ { topic:\"dvcc.maxChargeCurrent\", payload: 0 }, { payload: plan0 } ];\n}\n\n/* ---------- Forecast / Überschuss ---------- */\nvar today = ymd(Date.now());\n\n// 1) Versuche Überschuss zu lesen\nvar surplus = flow.get(\"analysis.pv_surplus\") || null;\nvar hourlyRows = (surplus && Array.isArray(surplus.hourly)) ? surplus.hourly.slice() : null;\n\n// 2) Falls kein Überschuss verfügbar: PV-Wh/h aggregieren\nif (!hourlyRows || !hourlyRows.length) {\n var fcPv = flow.get(\"fc.pv15\") || { bins: [], date: null };\n var pvBins = Array.isArray(fcPv.bins) ? fcPv.bins : [];\n if (!pvBins.length || (fcPv.date && fcPv.date !== today)) {\n var planNoFc = { ts: Date.now(), note: \"Kein aktueller PV/Überschuss-Forecast – 0A\", forcedMax: false };\n node.status({fill:\"yellow\",shape:\"ring\",text:planNoFc.note});\n return [ { topic:\"dvcc.maxChargeCurrent\", payload: 0 }, { payload: planNoFc } ];\n }\n var byH = Object.create(null);\n for (var i=0;i= 0; i--) {\n if (num(hourlyRows[i].surplusWh) >= pvThresholdWh) { lastH = hourlyRows[i]; break; }\n}\nif (!lastH) {\n var planNoSur = { ts: now, note: \"Heute kein PV-Überschuss ≥ Schwellwert – 0A\", forcedMax: false };\n node.status({fill:\"blue\",shape:\"ring\",text:planNoSur.note});\n return [ { topic:\"dvcc.maxChargeCurrent\", payload: 0 }, { payload: planNoSur } ];\n}\nvar pvEndTs = lastH.ts + 3600e3;\nvar finishByTs = pvEndTs - targetLeadHours * 3600e3;\nif (finishByTs < now) finishByTs = pvEndTs;\n\nvar horizon = [];\nfor (i=0;i= h0 && r.ts < finishByTs) horizon.push({ ts: r.ts, surWh: num(r.surplusWh) });\n}\nif (!horizon.length) {\n var planNoWin = { ts: now, note: \"Kein relevantes Zeitfenster bis Finish – 0A\", forcedMax: false };\n node.status({fill:\"yellow\",shape:\"ring\",text:planNoWin.note});\n return [ { topic:\"dvcc.maxChargeCurrent\", payload: 0 }, { payload: planNoWin } ];\n}\n\n/* ---------- Bedarf bis Ziel-SoC ---------- */\nvar currentAh = Number.isFinite(availAh) ? availAh : capacityAh * (soc/100);\nvar targetAh = capacityAh * (targetSocPercent/100);\nvar reserveAh = capacityAh * (reserveSocPercent/100);\n\nvar needAh = Math.max(0, targetAh - currentAh);\nvar needWh_dc = needAh * volt;\nvar needWh_ac = needWh_dc / chargeEff;\n\n/* ---------- PV-Überschuss prüfen ---------- */\nvar pvRemainingWh = horizon.reduce(function(s,h){ return s + Math.max(0, h.surWh); }, 0);\n\nif (pvRemainingWh < needWh_ac - 1e-6) {\n // PV reicht nicht -> hart maxA\n var ampsForce = maxA;\n var planF = {\n ts: now,\n targetSocPercent: targetSocPercent, reserveSocPercent: reserveSocPercent,\n mode: \"PV_INSUFFICIENT_FORCE_MAX\",\n finishByTs: finishByTs, pvEndTs: pvEndTs,\n needWh_dc: needWh_dc, needWh_ac: needWh_ac,\n pvRemainingWh: pvRemainingWh,\n forcedMax: true,\n currentHour: { tsStart:h0, tsEnd:h0+3600e3, inPlan:true, pCmd_W:Math.round(ampsForce*volt), amps:ampsForce },\n note: \"PV-Überschuss < Bedarf bis Finish – DVCC = maxA (\"+ampsForce+\"A)\"\n };\n flow.set(\"analysis.chargePlan\", planF);\n node.status({fill:\"red\",shape:\"dot\",text:planF.note});\n var thisA_F = ampsForce, lastA_F = flow.get(\"last.dvccA\"); flow.set(\"last.dvccA\", thisA_F);\n // OHNE HYSTERESE: immer senden (nach Slew-Rate weiter unten vereinheitlicht)\n return [ {topic:\"dvcc.maxChargeCurrent\", payload:thisA_F}, {payload:planF} ];\n}\n\n/* ---------- Glättung über den Tag ---------- */\nvar hoursLeftTotal = Math.max(0.1, (finishByTs - now)/3600e3);\nvar avgP_W = needWh_ac / hoursLeftTotal;\n\nvar planned = [];\nvar remainingWhAll = needWh_ac;\n\nfor (i=0;i 1e-6 && planned.length){\n var last = planned[planned.length-1];\n var extra = Math.min(remainingWhAll, Math.max(0, last.pvSurplusWh));\n last.shareWh += extra;\n remainingWhAll -= extra;\n}\n\n/* ---------- Live-Vorgabe ---------- */\nvar inPlan=false, shareThisHourWh=0;\nfor (i=0;i0.1; shareThisHourWh = planned[i].shareWh; break; }\n}\n\nvar mode = inPlan ? \"SMOOTH_PV_ONLY\" : \"OUTSIDE_PLAN_MIN\";\nvar pCmd_W = 0, amps = 0;\n\nif (inPlan){\n var pThisHour_W = (shareThisHourWh>0 && minutesLeft>0) ? (shareThisHourWh/(minutesLeft/60)) : 0;\n var pSmooth_W = avgP_W + headroomW;\n var pvWhThisObj = null;\n for (i=0;i 0 && amps < minA) amps = minA;\n amps = clamp(amps, 0, maxA);\n} else {\n // Mindest-A außerhalb Plan\n amps = minAOutside;\n amps = clamp(amps, 0, maxA);\n}\n\n/* ---------- Plan & Ausgabe ---------- */\nvar firstIdx = -1, lastIdx = -1;\nfor (i = 0; i < planned.length; i++) { if (planned[i].shareWh > 0.1) { firstIdx = i; break; } }\nfor (i = planned.length - 1; i >= 0; i--) { if (planned[i].shareWh > 0.1) { lastIdx = i; break; } }\nvar planStartTs = (firstIdx >= 0) ? planned[firstIdx].ts : h0;\nvar planEndTs = (lastIdx >= 0) ? (planned[lastIdx].ts + 3600e3) : finishByTs;\n\nvar plan = {\n ts: now,\n targetSocPercent: targetSocPercent,\n reserveSocPercent: reserveSocPercent,\n finishByTs: finishByTs, pvEndTs: pvEndTs,\n planStartTs: planStartTs, planEndTs: planEndTs,\n needWh_dc: needWh_dc, needWh_ac: needWh_ac, pvRemainingWh: pvRemainingWh,\n forcedMax: false,\n plannedHours: planned,\n currentHour: { tsStart:h0, tsEnd:h0+3600e3, minutesLeft:minutesLeft, inPlan:inPlan, mode:mode, pCmd_W:Math.round(pCmd_W), amps:Number(amps.toFixed(1)) },\n note: inPlan\n ? (\"PV-Glättung aktiv bis \" + new Date(finishByTs).toLocaleTimeString())\n : (minAOutside > 0 ? (\"Außerhalb Plan: min \" + minAOutside + \"A\") : \"Außerhalb Plan: 0A\")\n};\nflow.set(\"analysis.chargePlan\", plan);\n\n/* Live-Snapshot */\nvar chargeNow = {\n ts: now, h0: h0,\n mode: mode, inPlan: inPlan,\n amps: Number(amps.toFixed(2)),\n pCmd_W: Math.round(pCmd_W),\n pDc_W: Math.round(Math.max(0, pCmd_W) * chargeEff),\n volt: volt,\n socPercent: Number(soc.toFixed(2)),\n minutesLeft: minutesLeft\n};\nflow.set(\"analysis.chargeNow\", chargeNow);\n\n// Tagesserie (Ringpuffer)\nvar ist = flow.get(\"analysis.chargeIst\") || { date: ymd(now), points: [] };\nif (ist.date !== ymd(now)) ist = { date: ymd(now), points: [] };\nist.points.push({ ts: now, amps: chargeNow.amps, pCmd_W: chargeNow.pCmd_W, pDc_W: chargeNow.pDc_W, kW: Number((chargeNow.pCmd_W/1000).toFixed(3)), soc: chargeNow.socPercent });\nif (ist.points.length > 2880) ist.points.shift();\nflow.set(\"analysis.chargeIst\", ist);\n\n/* ---------- DVCC ausgeben: Slew-Rate-Limit (ohne Hysterese) ---------- */\nvar thisA = Number(amps.toFixed(1));\nvar lastA = flow.get(\"last.dvccA\");\n\n// Slew-Rate-Limit (max. 10 A pro Tick)\nif (typeof lastA !== \"undefined\") {\n var maxDelta = 10;\n var delta = thisA - lastA;\n if (delta > maxDelta) thisA = lastA + maxDelta;\n if (delta < -maxDelta) thisA = lastA - maxDelta;\n}\n// neuen Wert speichern\nflow.set(\"last.dvccA\", thisA);\n\n// Node-Status\nnode.status({\n fill: (plan && plan.currentHour && plan.currentHour.inPlan) ? \"green\" : \"grey\",\n shape: (plan && plan.currentHour && plan.currentHour.inPlan) ? \"dot\" : \"ring\",\n text: ((plan && plan.currentHour && plan.currentHour.mode) ? plan.currentHour.mode : \"IDLE\") + \" \" + thisA + \"A\"\n});\n\n// OHNE HYSTERESE: immer senden (nach Slew-Rate-Limit)\nvar out1 = { topic: \"dvcc.maxChargeCurrent\", payload: thisA };\n\n// Rückgabe\nreturn [ out1, { payload: plan } ];\n", "outputs": 2, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 440, "y": 520, "wires": [ [ "8aa4bf0b401fba0f" ], [ "planfunc8out_v2", "b815a06bf1925154" ] ] }, { "id": "88a2166eb4c3a551", "type": "ui_group", "name": "Plan-Status", "tab": "b346c962d3ab5cbd", "order": 1, "disp": true, "width": "18", "collapse": false, "className": "" }, { "id": "5e4484a98a7a02c8", "type": "config-vrm-api", "name": "VRM" }, { "id": "ui_group_einspeisung", "type": "ui_group", "name": "Einspeisung", "tab": "ui_tab_energy", "order": 1, "disp": true, "width": "21", "collapse": false, "className": "" }, { "id": "b346c962d3ab5cbd", "type": "ui_tab", "name": "Ladeplan", "icon": "dashboard", "disabled": false, "hidden": false }, { "id": "ui_tab_energy", "type": "ui_tab", "name": "Energie", "icon": "dashboard", "disabled": false, "hidden": false } ]