[{"id":"2cad21c6a8e916eb","type":"tab","label":"Charge Plan (fixed total capacity)","disabled":false,"info":"","env":[]},{"id":"5c6aa724f9ce8a27","type":"inject","z":"2cad21c6a8e916eb","name":"Forecast alle 15 min (06–21)","props":[],"repeat":"","crontab":"*/15 6-21 * * *","once":true,"onceDelay":"5","topic":"","x":170,"y":160,"wires":[["0e75b95ff2df7d7c","3e3174d84983927e"]]},{"id":"d5d37564c7be3735","type":"inject","z":"2cad21c6a8e916eb","name":"Set global defaults (once)","props":[{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":"2","topic":"","payload":"","payloadType":"str","x":150,"y":40,"wires":[["bab38848b55eb28f"]]},{"id":"3e3174d84983927e","type":"vrm-api","z":"2cad21c6a8e916eb","vrm":"4b83bc69600e688d","name":"Solar_FC_today","api_type":"installations","idUser":"","users":"","idSite":"{{global.vrm.idSite}}","installations":"stats","attribute":"vrm_pv_inverter_yield_fc","stats_interval":"hours","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":100,"wires":[["03b2a78e8461d80b"]]},{"id":"0e75b95ff2df7d7c","type":"vrm-api","z":"2cad21c6a8e916eb","vrm":"4b83bc69600e688d","name":"Cons_FC_today","api_type":"installations","idUser":"","users":"","idSite":"{{global.vrm.idSite}}","installations":"stats","attribute":"vrm_consumption_fc","stats_interval":"hours","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":160,"wires":[["81761f3f23143757"]]},{"id":"03b2a78e8461d80b","type":"function","z":"2cad21c6a8e916eb","name":"vrm_pv_inverter_yield_fc → global.forecast.pv","func":"var pv = msg.payload;\nif (!pv || !pv.records || !pv.records.vrm_pv_inverter_yield_fc) { node.warn('PV-Forecast leer'); return null; }\nvar totalPvWh = (pv.totals && typeof pv.totals.vrm_pv_inverter_yield_fc !== 'undefined') ? pv.totals.vrm_pv_inverter_yield_fc : 0;\nvar hourly = pv.records.vrm_pv_inverter_yield_fc.map(function(e){ return { ts:e[0], time:new Date(e[0]).toISOString(), valueWh:e[1] }; });\n\nglobal.set('forecast.pv', { totalWh: totalPvWh, totalkWh:(totalPvWh/1000).toFixed(2), hourly: hourly, raw: pv });\nmsg.topic = 'pv';\nmsg.payload = global.get('forecast.pv');\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":780,"y":100,"wires":[["9624017abe232840"]]},{"id":"81761f3f23143757","type":"function","z":"2cad21c6a8e916eb","name":"vrm_consumption_fc → global.forecast.consumption","func":"var consumption = msg.payload;\nif (!consumption || !consumption.records || !consumption.records.vrm_consumption_fc) { node.warn('Load-Forecast leer'); return null; }\nvar totalLoadWh = (consumption.totals && typeof consumption.totals.vrm_consumption_fc !== 'undefined') ? consumption.totals.vrm_consumption_fc : 0;\nvar hourly = consumption.records.vrm_consumption_fc.map(function(e){ return { ts:e[0], time:new Date(e[0]).toISOString(), valueWh:e[1] }; });\n\nglobal.set('forecast.consumption', { totalWh: totalLoadWh, totalkWh:(totalLoadWh/1000).toFixed(2), hourly: hourly, raw: consumption });\nmsg.topic = 'cons';\nmsg.payload = global.get('forecast.consumption');\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":800,"y":160,"wires":[["9624017abe232840"]]},{"id":"39388d7fe39f817b","type":"victron-output-ess","z":"2cad21c6a8e916eb","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":900,"y":220,"wires":[]},{"id":"bab38848b55eb28f","type":"function","z":"2cad21c6a8e916eb","name":"Defaults setzen: chargeCfg + vrm.idSite","func":"global.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: 1,\n allowGridBeforePV: false,\n capacityAh: 280\n});\n// Site-ID hier anpassen:\nglobal.set('vrm', { idSite: 'EIGENE_SITE_ID_HIER' });\n\nvar vrm = global.get('vrm');\nnode.status({fill:'green',shape:'dot',text:'chargeCfg & vrm.idSite gesetzt (' + (vrm && vrm.idSite ? vrm.idSite : '-') + ')'});\nmsg.payload = { chargeCfg: global.get('chargeCfg'), vrm: global.get('vrm') };\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":540,"y":40,"wires":[[]]},{"id":"8d409af00371d890","type":"victron-input-battery","z":"2cad21c6a8e916eb","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":340,"wires":[["d9dcd117b31d5960"]]},{"id":"a98d3c934406a8d6","type":"victron-input-battery","z":"2cad21c6a8e916eb","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":400,"wires":[["706ed12181b123e0"]]},{"id":"c05ea48629184fbe","type":"victron-input-battery","z":"2cad21c6a8e916eb","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,"x":90,"y":460,"wires":[["8ae7f6d10699dfda"]]},{"id":"d9dcd117b31d5960","type":"function","z":"2cad21c6a8e916eb","name":"SoC als Zahl → global.battery (ohne Total-Berechnung)","func":"function 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') { var t=v.trim().replace(/%/g,''); var n=Number(t); if(!Number.isNaN(n)) return n; try{var j=JSON.parse(v); if(j&&'value' in j) return Number(j.value);}catch(e){} }\n return Number(v);\n}\nvar raw = toNumber(msg.payload);\nif (!Number.isFinite(raw)) { node.status({fill:'red',shape:'ring',text:'SoC ungültig'}); return null; }\nif (raw>=0 && raw<=1) raw *= 100; // 0..1 → %\nvar soc = Math.min(100, Math.max(0, raw));\nvar b = global.get('battery') || {};\nb.socPercent = soc;\nb.currentAh = (typeof b.availableAh !== 'undefined') ? b.availableAh : null; // nur Info\nb.tsSoc = Date.now();\nglobal.set('battery', b);\nmsg.payload = b;\nnode.status({fill:'green',shape:'dot',text:'SoC ' + soc.toFixed(1) + ' %'});\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":590,"y":340,"wires":[[]]},{"id":"706ed12181b123e0","type":"function","z":"2cad21c6a8e916eb","name":"availableAh speichern (keine Total-Berechnung)","func":"function 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') { var t = v.trim(); var n = Number(t); if(!Number.isNaN(n)) return n; try { var j=JSON.parse(v); if(j && 'value' in j) return Number(j.value); } catch(e){} }\n return Number(v);\n}\n\nvar 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\nvar b = global.get('battery') || {};\nb.availableAh = Math.round(availableAh*100)/100;\nb.currentAh = b.availableAh; // Info\nb.tsCapacity = Date.now();\n\nglobal.set('battery', b);\nmsg.payload = { availableAh: b.availableAh };\nnode.status({fill:'green',shape:'dot',text:'Avail ' + b.availableAh + ' Ah'});\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":560,"y":400,"wires":[[]]},{"id":"8ae7f6d10699dfda","type":"function","z":"2cad21c6a8e916eb","name":"Spannung in Volt → global.battery","func":"function 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') { var t=v.trim(); var n=Number(t); if(!Number.isNaN(n)) return n; try{var j=JSON.parse(v); if(j&&'value' in j) return Number(j.value);}catch(e){} }\n return Number(v);\n}\nvar volt = toNumber(msg.payload);\nif (!Number.isFinite(volt) || volt<=0){ node.status({fill:'red',shape:'ring',text:'Voltage ungültig'}); return null; }\nvar b = global.get('battery') || {};\nb.voltageV = volt;\nif (typeof b.socPercent !== 'undefined' && typeof b.availableAh !== 'undefined') {\n b.currentAh = b.availableAh; // Info\n}\nb.tsVoltage = Date.now();\nglobal.set('battery', b);\nmsg.payload = b;\nnode.status({fill:'green',shape:'dot',text:'Voltage ' + volt.toFixed(2) + ' V'});\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":520,"y":460,"wires":[[]]},{"id":"72005b540a68c241","type":"function","z":"2cad21c6a8e916eb","name":"Evaluate Forecasts → global.analysis.forecast","func":"function num(v){ return Number.isFinite(v) ? v : 0; }\nfunction sum(arr, pick){ return arr.reduce((a,x)=>a+num(pick?x[pick]:x),0); }\nfunction get(obj, path, dflt){ try{ return path.split('.').reduce((o,k)=>o && o[k], obj) ?? dflt; } catch(e){ return dflt; } }\n\nconst pv = global.get('forecast.pv');\nconst cons= global.get('forecast.consumption');\n\nif (!pv || !pv.hourly || !Array.isArray(pv.hourly) ||\n !cons || !cons.hourly || !Array.isArray(cons.hourly)) {\n node.status({fill:'yellow', shape:'ring', text:'Forecast fehlt/inkomplett'});\n return null;\n}\n\nconst map = new Map();\nfor (const e of pv.hourly) {\n const ts = Number(e.ts);\n if (!Number.isFinite(ts)) continue;\n const item = map.get(ts) || { ts, iso:new Date(ts).toISOString(), pvWh:0, consWh:0 };\n item.pvWh = num(e.valueWh);\n item.hasPv = true;\n map.set(ts, item);\n}\nfor (const e of cons.hourly) {\n const ts = Number(e.ts);\n if (!Number.isFinite(ts)) continue;\n const item = map.get(ts) || { ts, iso:new Date(ts).toISOString(), pvWh:0, consWh:0 };\n item.consWh = num(e.valueWh);\n item.hasCons = true;\n map.set(ts, item);\n}\n\nconst hours = Array.from(map.values()).sort((a,b)=>a.ts-b.ts);\nlet netCum = 0;\nfor (const h of hours){\n h.netWh = num(h.pvWh) - num(h.consWh);\n netCum += h.netWh;\n h.netCumWh = netCum;\n h.hasPv = !!h.hasPv;\n h.hasCons= !!h.hasCons;\n}\n\nconst now = Date.now();\nconst horizon = {\n startTs: hours.length ? hours[0].ts : now,\n endTs : hours.length ? hours[hours.length-1].ts : now\n};\n\nfunction sumWindow(t0, t1){\n const slice = hours.filter(h => h.ts >= t0 && h.ts < t1);\n const pvWh = sum(slice, 'pvWh');\n const consWh = sum(slice, 'consWh');\n const netWh = pvWh - consWh;\n return { pvWh, consWh, netWh, pvKWh: pvWh/1000, consKWh: consWh/1000, netKWh: netWh/1000, count:slice.length };\n}\n\nfunction ceilToHour(t){ const d=new Date(t); d.setMinutes(0,0,0); const x=d.getTime(); return (t===x)?x:(x+3600e3); }\nconst h0 = ceilToHour(now);\nconst h1 = h0 + 1*3600e3;\nconst h3 = h0 + 3*3600e3;\nconst h6 = h0 + 6*3600e3;\nconst end= horizon.endTs + 3600e3;\n\nconst win_h1 = sumWindow(h0, Math.min(h1, end));\nconst win_h3 = sumWindow(h0, Math.min(h3, end));\nconst win_h6 = sumWindow(h0, Math.min(h6, end));\nconst win_rest = sumWindow(Math.min(h6, end), end);\n\nconst totals = {\n pvWh : sum(hours, 'pvWh'),\n consWh : sum(hours, 'consWh')\n};\ntotals.netWh = totals.pvWh - totals.consWh;\ntotals.pvKWh = totals.pvWh/1000;\ntotals.consKWh= totals.consWh/1000;\ntotals.netKWh = totals.netWh/1000;\n\nfunction sumWindowNextHour(){ return sumWindow(h0, Math.min(h1, end)); }\nconst nextHour = sumWindowNextHour();\n\nconst out = {\n meta:{\n ts: Date.now(),\n startTs: horizon.startTs,\n endTs: horizon.endTs,\n hoursCount: hours.length\n },\n totals,\n next:{\n startTs: h0,\n endTs: Math.min(h1, end),\n pvWh: nextHour.pvWh,\n consWh: nextHour.consWh,\n netWh: nextHour.netWh,\n pvKWh: nextHour.pvKWh,\n consKWh: nextHour.consKWh,\n netKWh: nextHour.netKWh\n },\n windows:{\n h1: win_h1,\n h3: win_h3,\n h6: win_h6,\n rest: win_rest\n },\n hours\n};\n\nglobal.set('analysis.forecast', out);\nmsg.payload = out;\nconst txt = `Σnet ${out.totals.netKWh.toFixed(2)} kWh | next1h ${out.next.netWh>=0?'+':''}${(out.next.netKWh).toFixed(2)} kWh`;\nnode.status({fill: out.next.netWh>=0 ? 'green':'blue', shape:'dot', text: txt});\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":560,"y":280,"wires":[[]]},{"id":"9624017abe232840","type":"function","z":"2cad21c6a8e916eb","name":"Charge Planner","func":"// Charge Planner — target SoC by (PV end - lead), prioritize PV peaks,\n// fixes: unique hour handling with floorToHour and current-hour minutesLeft\n\nfunction num(v, d=0){ const n = Number(v); return Number.isFinite(n) ? n : d; }\nfunction clamp(v, lo, hi){ return Math.min(hi, Math.max(lo, v)); }\nfunction sum(arr, pick){ return arr.reduce((a,x)=>a + (pick ? num(x[pick]) : num(x)), 0); }\nfunction byDesc(k){ return (a,b)=>num(b[k]) - num(a[k]); }\nfunction floorToHour(t){ const d = new Date(t); d.setMinutes(0,0,0); return d.getTime(); }\n\nconst cfg = global.get('chargeCfg') || {};\nconst bat = global.get('battery') || {};\nconst pv = global.get('forecast.pv') || {};\n\n// ---- Defaults robust lesen ----\nconst targetSocPercent = clamp(num(cfg.targetSocPercent, 100), 0, 100);\nconst reserveSocPercent = clamp(num(cfg.reserveSocPercent, 0), 0, 100);\nconst chargeEff = clamp(num(cfg.chargeEff, 0.9), 0.5, 1.0);\nconst maxA = num(cfg.maxA, 140);\nconst minA = num(cfg.minA, 5);\nconst headroomW = num(cfg.headroomW, 0);\nconst targetLeadHours = Math.max(0, num(cfg.targetLeadHours, 0));\nconst allowGridBeforePV = !!cfg.allowGridBeforePV;\nconst capacityAh = num(cfg.capacityAh, 500);\nconst pvThresholdWh = num(cfg.pvThresholdWh, 50);\n\n// ---- Livewerte ----\nconst volt = num(bat.voltageV);\nlet soc = num(bat.socPercent, 0);\nconst availAh = (bat && typeof bat.availableAh !== 'undefined') ? num(bat.availableAh) : NaN;\n\nif (!Number.isFinite(volt) || volt <= 0){\n node.status({fill:'red', shape:'ring', text:'Batteriespannung fehlt/ungültig'});\n return null;\n}\n\n// Aktueller Energieinhalt\nconst currentAh = Number.isFinite(availAh) ? availAh : capacityAh * (soc/100);\nconst targetAh = capacityAh * (targetSocPercent/100);\nconst reserveAh = capacityAh * (reserveSocPercent/100);\n\n// ---- PV-Horizont & Deadlines ----\nconst now = Date.now();\nconst h0 = floorToHour(now); // laufende Stunde\nconst hourEnd = h0 + 3600e3;\nconst minutesLeft = Math.max(1, Math.ceil((hourEnd - now)/60000)); // min. 1 Minute\n\nconst hoursFromPV = Array.isArray(pv.hourly) ? pv.hourly\n .map(h => ({ ts: num(h.ts), pvWh: num(h.valueWh) }))\n .filter(e => Number.isFinite(e.ts))\n .sort((a,b)=>a.ts-b.ts) : [];\n\n// PV-Ende = letzte Stunde >= Schwelle\nconst lastPv = [...hoursFromPV].reverse().find(h => h.pvWh >= pvThresholdWh);\nconst pvEndTs = lastPv ? lastPv.ts + 3600e3\n : (hoursFromPV.length ? hoursFromPV[hoursFromPV.length-1].ts + 3600e3 : now);\n\n// Ziel-Fertig-Zeit (vor PV-Ende)\nlet finishByTs = pvEndTs - targetLeadHours * 3600e3;\nif (finishByTs < now) finishByTs = pvEndTs; // falls Lead in der Vergangenheit liegt\n\n// Relevante Stunden: ab JETZT (inkl. laufender) bis finishBy\nconst horizon = hoursFromPV.filter(h => h.ts >= h0 && h.ts < finishByTs);\n\n// Kurzhilfen für „Grid vor PV vermeiden“\nconst curHour = horizon.find(h => h.ts === h0);\nconst nextHour = horizon.find(h => h.ts > h0);\nconst hasPvThisHour = curHour ? (curHour.pvWh >= pvThresholdWh) : false;\nconst nextHourPvWh = nextHour ? nextHour.pvWh : 0;\n\n// ---- Bedarf bis Ziel-SoC ----\nlet needAh = Math.max(0, targetAh - currentAh);\nlet needWh_dc = needAh * volt; // an Batterie\nlet needWh_ac = needWh_dc / chargeEff; // inkl. Verluste\n\n// ---- Notfall: Unter Reserve-SoC → sofort MaxA ----\nif (currentAh < reserveAh - 1e-6){\n const plan = {\n ts: now, targetSocPercent, reserveSocPercent,\n mode: 'EMERGENCY_RESERVE',\n finishByTs, pvEndTs,\n needWh_dc, needWh_ac,\n pvRemainingWh: sum(horizon, 'pvWh'),\n forcedMax: true,\n plannedHours: [],\n currentHour: { tsStart: h0, tsEnd: h0+3600e3, inPlan: true, pCmd_W: maxA*volt, amps: maxA },\n note: `SoC ${soc.toFixed(1)}% < Reserve ${reserveSocPercent}% → MaxA bis Reserve erreicht`\n };\n global.set('analysis.chargePlan', plan);\n msg.payload = maxA;\n msg.topic = 'dvcc.maxChargeCurrent';\n node.status({fill:'red', shape:'dot', text: plan.note});\n return msg;\n}\n\n// Verbleibende PV bis finishBy\nconst pvRemainingWh = sum(horizon, 'pvWh');\n\n// ---- PV reicht bis Deadline nicht → Force MaxA ----\nif (pvRemainingWh < needWh_ac - 1e-6){\n const plan = {\n ts: now, targetSocPercent, reserveSocPercent,\n mode: 'FORCE_MAX_PV_INSUFFICIENT',\n finishByTs, pvEndTs,\n needWh_dc, needWh_ac,\n pvRemainingWh,\n forcedMax: true,\n plannedHours: [],\n currentHour: { tsStart: h0, tsEnd: h0+3600e3, inPlan: true, pCmd_W: maxA*volt, amps: maxA },\n note: `PV ${ (pvRemainingWh/1000).toFixed(2) } kWh < Bedarf ${ (needWh_ac/1000).toFixed(2) } kWh → MaxA`\n };\n global.set('analysis.chargePlan', plan);\n msg.payload = maxA;\n msg.topic = 'dvcc.maxChargeCurrent';\n node.status({fill:'red', shape:'dot', text: plan.note});\n return msg;\n}\n\n// ---- Planung: PV-Spitzen priorisieren ----\nconst sortedByPv = [...horizon].sort(byDesc('pvWh'));\nlet pick = []; let acc = 0;\nfor (const h of sortedByPv){ pick.push(h); acc += h.pvWh; if (acc >= needWh_ac) break; }\n\n// Shares je Stunde (gedeckelt auf PV je Stunde)\nconst planned = pick.sort((a,b)=>a.ts-b.ts).map(h => ({ ts:h.ts, iso:new Date(h.ts).toISOString(), pvWh:h.pvWh, shareWh:0 }));\nlet remainingWh = needWh_ac;\nfor (let i=0;i 1e-6){ planned[planned.length-1].shareWh += remainingWh; remainingWh = 0; }\n\n// ---- Stromvorgabe für JETZT (laufende Stunde inkl. Restzeit) ----\nconst inPlan = planned.some(p => p.ts === h0);\n\n// Restbedarf aus geplanten Stunden ab jetzt\nconst remainingPlannedWh = planned.filter(p => p.ts >= h0).reduce((a,p)=>a + p.shareWh, 0);\n\n// Restzeit bis Deadline (h)\nconst hoursLeft = Math.max(0.1, (finishByTs - now)/3600e3);\n\nlet amps = 0;\nlet pCmd_W = 0;\nlet mode = 'IDLE_OUTSIDE_PLAN';\n\nif (inPlan){\n mode = 'IN_PLAN';\n const shareThisHourWh = (planned.find(p => p.ts === h0)?.shareWh) || 0;\n const shareFutureWh = remainingPlannedWh - shareThisHourWh;\n\n const pThisHour_W = shareThisHourWh > 0 ? (shareThisHourWh / (minutesLeft/60)) : 0; // Rest der Stunde\n const hoursLeftAfterThisHour = Math.max(0, hoursLeft - minutesLeft/60);\n const pFuture_W = (shareFutureWh > 0 && hoursLeftAfterThisHour > 0) ? (shareFutureWh / hoursLeftAfterThisHour) : 0;\n\n pCmd_W = pThisHour_W + pFuture_W + headroomW;\n\n amps = pCmd_W / volt;\n if (amps > 0 && amps < minA) amps = minA;\n amps = clamp(amps, 0, maxA);\n} else {\n // Außerhalb Plan: minA-Politik\n const allowMinAHere = allowGridBeforePV || hasPvThisHour || (nextHourPvWh >= pvThresholdWh);\n if (allowMinAHere){ mode = 'OUTSIDE_PLAN_MINA'; amps = clamp(minA, 0, maxA); }\n else { mode = 'OUTSIDE_PLAN_ZERO'; amps = 0; }\n}\n\n// ---- Plan speichern ----\nconst plan = {\n ts: now,\n targetSocPercent, reserveSocPercent,\n finishByTs, pvEndTs,\n needWh_dc, needWh_ac,\n pvRemainingWh,\n forcedMax: false,\n plannedHours: planned,\n currentHour: {\n tsStart: h0, tsEnd: h0+3600e3,\n minutesLeft,\n inPlan,\n mode,\n pCmd_W: Math.round(pCmd_W),\n amps: Number(amps.toFixed(1))\n },\n peak: (sortedByPv[0] ? { ts: sortedByPv[0].ts, pvWh: sortedByPv[0].pvWh } : null),\n note: inPlan\n ? `Plan aktiv (minA=${minA}A, headroom=${headroomW}W) bis ${new Date(finishByTs).toLocaleTimeString()}`\n : (mode==='OUTSIDE_PLAN_MINA' ? `Außerhalb Plan: minA (${minA}A)` : 'Außerhalb Plan: 0A (Grid vor PV vermeiden)')\n};\nglobal.set('analysis.chargePlan', plan);\n\n// ---- Ausgabe an DVCC ----\nmsg.payload = Number(amps.toFixed(1)); // A\nmsg.topic = 'dvcc.maxChargeCurrent';\n\nconst txt = `${mode} → ${amps.toFixed(1)} A | Ziel ${targetSocPercent}% | FinishBy ${new Date(finishByTs).toLocaleTimeString()} | left ${minutesLeft}min`;\nnode.status({fill: amps>0 ? 'green' : 'grey', shape:'dot', text: txt});\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":460,"y":220,"wires":[["39388d7fe39f817b"]]},{"id":"d9375d646343115c","type":"inject","z":"2cad21c6a8e916eb","name":"Aktualisieren","props":[],"repeat":"300","crontab":"","once":true,"onceDelay":"10","topic":"","x":120,"y":260,"wires":[["72005b540a68c241","9624017abe232840"]]},{"id":"c6f75033197170de","type":"function","z":"2cad21c6a8e916eb","name":"Global Context löschen (smart)","func":"// Global Context gezielt löschen – sicher & steuerbar\n// Standard: alles löschen, außer ['chargeCfg','vrm']\n// Steuerung per msg.payload:\n// - { scope: \"all\", keep: [] } -> wirklich alles\n// - { scope: \"only\", keys: [\"analysis\",\"forecast\"] }\n// - { pattern: \"^analysis\\\\.\" } -> Regex-Match\n// - { keep: [\"chargeCfg\",\"vrm\",\"battery\"] } -> behalten\n// Kombinierbar; Priorität: only > pattern > all/keep\n\nfunction asArray(x){ return Array.isArray(x) ? x : (x==null ? [] : [x]); }\n\nconst payload = msg && typeof msg.payload === 'object' ? msg.payload : {};\nconst scope = payload.scope || null; // \"all\" | \"only\" | null\nconst onlyKeys = asArray(payload.keys);\nconst keep = new Set(asArray(payload.keep).concat(['chargeCfg','vrm'])); // defaults behalten\nconst pattern = payload.pattern ? new RegExp(payload.pattern) : null;\n\nconst allKeys = (global.keys && typeof global.keys === 'function') ? global.keys() : [];\n\nlet toDelete = [];\n\nif (scope === 'only' && onlyKeys.length){\n // Nur diese Top-Level-Keys (exakt)\n toDelete = allKeys.filter(k => onlyKeys.includes(k));\n} else if (pattern){\n // Regex-Pattern\n toDelete = allKeys.filter(k => pattern.test(k));\n} else {\n // Standard oder scope === \"all\"\n toDelete = allKeys.slice(); // alle\n if (scope !== 'all'){\n // außer den Keep-Keys\n toDelete = toDelete.filter(k => !keep.has(k));\n }\n}\n\nlet deleted = [];\nfor (const k of toDelete){\n try {\n global.set(k, undefined); // entfernt Key\n deleted.push(k);\n } catch (e) {\n node.warn(`Konnte global.${k} nicht löschen: ${e}`);\n }\n}\n\nconst kept = allKeys.filter(k => !deleted.includes(k));\nconst info = {\n deletedCount: deleted.length,\n deleted,\n kept\n};\n\nnode.status({\n fill: deleted.length ? \"red\" : \"grey\",\n shape: \"dot\",\n text: deleted.length ? `Gelöscht: ${deleted.join(', ').slice(0,80)}${deleted.join(', ').length>80?'…':''}` : 'Nichts zu löschen'\n});\n\nreturn { payload: info };","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":510,"y":540,"wires":[["669c08aecc5e45a9"]]},{"id":"546f88646ffc4194","type":"inject","z":"2cad21c6a8e916eb","name":"Nur Analyse zurücksetzen","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"scope\":\"only\",\"keys\":[\"analysis\"]}","payloadType":"json","x":150,"y":520,"wires":[["c6f75033197170de"]]},{"id":"1f57462f0df147f0","type":"inject","z":"2cad21c6a8e916eb","name":"Alles zurücksetzen (hart)","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"scope\":\"all\",\"keep\":[]}","payloadType":"json","x":150,"y":560,"wires":[["c6f75033197170de"]]},{"id":"669c08aecc5e45a9","type":"debug","z":"2cad21c6a8e916eb","name":"Result","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":710,"y":540,"wires":[]},{"id":"4b83bc69600e688d","type":"config-vrm-api","name":"VRM"}]