Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- [
- {
- "id": "combinedFlowTab",
- "type": "tab",
- "label": "PV Control (Estimate + Dynamic Charge + Dynamic Export)",
- "disabled": false,
- "info": "Combines PV production estimation, 48h rain forecast check, bi-weekly calibration, dynamic voltage-based charging, and dynamic voltage-based export control.\n\n**REQUIRES:** \n- node-red-contrib-openweathermap\n- node-red-contrib-sun-position\n- File context storage enabled in settings.js (or via init_commands)\n- OWM API Key\n- HA Server ID & Entity IDs configured\n- **Review & Edit ALL Configuration sections in Function nodes!**",
- "env": []
- },
- {
- "id": "triggerDailyTasks",
- "type": "inject",
- "z": "combinedFlowTab",
- "name": "Run Daily Tasks at 3:00 AM",
- "props": [],
- "repeat": "",
- "crontab": "0 3 * * *",
- "once": true,
- "onceDelay": "15",
- "topic": "",
- "payload": "",
- "payloadType": "date",
- "x": 160,
- "y": 100,
- "wires": [
- [
- "getWeatherForecast"
- ]
- ]
- },
- {
- "id": "getWeatherForecast",
- "type": "openweathermap",
- "z": "combinedFlowTab",
- "name": "Get Weather Forecast (OneCall API)",
- "wtype": "onecall",
- "lon": "25.939",
- "lat": "46.419",
- "city": "",
- "country": "",
- "language": "en",
- "x": 520,
- "y": 100,
- "wires": [
- [
- "processForecastData",
- "calculatePVEstimate",
- "8a1474719323688a"
- ]
- ]
- },
- {
- "id": "processForecastData",
- "type": "function",
- "z": "combinedFlowTab",
- "name": "Process & Store 24h Rain Forecast",
- "func": "// Extracts hourly data needed and stores it globally.\n// Assumes OpenWeatherMap OneCall API output structure.\n\nconst onecallData = msg.payload;\n// **** CHANGE: Store more data in a new global variable ****\nlet relevantForecast = []; // Will store {dt, pop, clouds}\n\nif (onecallData && Array.isArray(onecallData.hourly)) {\n const now = Date.now();\n const lookaheadMillis = 24 * 60 * 60 * 1000; // 24 hours\n\n for (const hourData of onecallData.hourly) {\n // Check required fields exist\n if (hourData && typeof hourData.dt !== 'undefined' && typeof hourData.pop !== 'undefined' && typeof hourData.clouds !== 'undefined') {\n const forecastTimeMillis = hourData.dt * 1000;\n // Check if the forecast hour is within the next 24 hours from now\n if (forecastTimeMillis > now && forecastTimeMillis <= now + lookaheadMillis) {\n // **** CHANGE: Store clouds along with dt and pop ****\n relevantForecast.push({\n dt: hourData.dt,\n pop: hourData.pop, // Probability of Precipitation (0-1)\n clouds: hourData.clouds // Cloud cover percentage\n });\n }\n }\n }\n // **** CHANGE: Use a new global variable name ****\n global.set(\"hourlyWeatherDataForecast\", relevantForecast);\n // **** CHANGE: Update log message slightly ****\n node.log(`Stored ${relevantForecast.length} hourly weather forecast entries (incl clouds) for next 48h.`);\n // Pass the original payload through if needed by other branches\n return msg;\n} else {\n node.warn(\"Could not process OneCall forecast data for rain/cloud check. 'msg.payload.hourly' not found or not an array.\");\n // Clear potentially stale forecast data using the new name\n global.set(\"hourlyWeatherDataForecast\", []);\n return null; // Stop this path if data is bad\n}",
- "outputs": 1,
- "timeout": 0,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 860,
- "y": 180,
- "wires": [
- [
- "424558d406e966a6"
- ]
- ]
- },
- {
- "id": "calculatePVEstimate",
- "type": "function",
- "z": "combinedFlowTab",
- "name": "Calculate Hourly PV Estimate",
- "func": "// --- Configuration ---\n// !! IMPORTANT: SET YOUR LOCATION AND SYSTEM DETAILS !!\nconst LATITUDE = 46.41; // Your latitude (Using Oradea as requested)\nconst LONGITUDE = 25.93; // Your longitude (Using Oradea as requested)\nconst MAX_PV_POWER_KWP = 27; // Your system's peak PV power (kWp)\nconst EFFICIENCY_FACTOR = 0.8; // Adjust based on observation (0.75-0.85 typical)\nconst MIN_CLOUD_FACTOR = 0.10; // Minimum factor for 100% clouds (from your previous setting)\n// --- End Configuration ---\n\n// Requires OWM forecast (OneCall preferred, provides .hourly and .current)\nconst weatherData = msg.payload;\nconst hourlyForecast = weatherData.hourly;\n\n// Check for necessary data structures from OpenWeatherMap payload\nif (!weatherData || !weatherData.current || !weatherData.current.sunrise || !weatherData.current.sunset || !hourlyForecast) {\n node.error(\"Missing current weather data (sunrise/sunset) or hourly forecast data\");\n // **** Store null or 0 in global context on error ****\n global.set(\"dailyPvEstimateKWh\", 0);\n return null; // Stop if essential data is missing\n}\n\n// Use sunrise/sunset from the weather data payload\nconst todaySunrise = new Date(weatherData.current.sunrise * 1000); // Convert seconds to milliseconds\nconst todaySunset = new Date(weatherData.current.sunset * 1000); // Convert seconds to milliseconds\nconst hourlyEstimates = [];\nlet totalDailyEstimateKWh = 0;\n\n// Basic check for valid forecast data array\nif (!Array.isArray(hourlyForecast)) {\n node.error(\"Forecast data (msg.payload.hourly) is not an array.\");\n // **** Store null or 0 in global context on error ****\n global.set(\"dailyPvEstimateKWh\", 0);\n return null;\n}\n\n// Calculate daylight duration for sine curve approximation\nconst daylightMillis = todaySunset.getTime() - todaySunrise.getTime();\nconst daylightHours = daylightMillis > 0 ? daylightMillis / (1000 * 60 * 60) : 0;\n\n// Limit forecast processing to a reasonable range (e.g., first 24 hours for daily estimate)\nconst hoursToProcess = Math.min(hourlyForecast.length, 24);\n\nfor (let i = 0; i < hoursToProcess; i++) {\n const hourData = hourlyForecast[i];\n // Check if hourData and required fields exist\n if (!hourData || typeof hourData.dt === 'undefined' || typeof hourData.clouds === 'undefined') {\n node.warn(`Skipping PV estimate for forecast item index ${i} due to missing data.`);\n continue;\n }\n\n const hourDate = new Date(hourData.dt * 1000);\n const hour = hourDate.getHours();\n\n let estimatedPowerKW = 0;\n\n // Check if the current hour is within daylight hours\n if (hourDate >= todaySunrise && hourDate < todaySunset && daylightHours > 0) {\n // Simplified sun factor using sine curve based on time since sunrise\n const hoursSinceSunrise = (hourDate.getTime() - todaySunrise.getTime()) / (1000 * 60 * 60);\n const sunFactor = Math.sin((hoursSinceSunrise / daylightHours) * Math.PI);\n\n const cloudCover = hourData.clouds; // Percentage\n\n // MODIFIED Cloud Factor Calculation (from your previous setting)\n let cloudFactor = (100 - cloudCover) / 100;\n cloudFactor = Math.max(MIN_CLOUD_FACTOR, cloudFactor);\n\n // Calculate estimated power for the hour\n estimatedPowerKW = MAX_PV_POWER_KWP * Math.max(0, sunFactor) * Math.max(0, cloudFactor) * EFFICIENCY_FACTOR;\n }\n\n hourlyEstimates.push({ hour: hour, estimateKW: estimatedPowerKW.toFixed(2) });\n // This sums kW for each hour; approximately kWh for the day\n totalDailyEstimateKWh += estimatedPowerKW;\n}\n\n// **** CHANGE: Store result in GLOBAL context instead of flow ****\nglobal.set(\"dailyPvEstimateKWh\", totalDailyEstimateKWh); // Store for charging logic access\nglobal.set(\"hourlyPvEstimates\", hourlyEstimates); // Optional: Store hourly breakdown too if needed elsewhere\n\n// Prepare output message (optional, can be removed if not needed downstream)\nmsg.pv_estimation = {\n hourly: hourlyEstimates,\n totalKWh: totalDailyEstimateKWh.toFixed(2)\n};\nnode.log(\"PV Estimation Complete. Est. Total KWh: \" + totalDailyEstimateKWh.toFixed(2) + \" (Stored Globally)\");\n// Pass original msg through if other branches need it, otherwise return new payload\nreturn msg; // Pass original payload through (contains weather data)",
- "outputs": 1,
- "timeout": 0,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 860,
- "y": 20,
- "wires": [
- [
- "debugEstimation"
- ]
- ]
- },
- {
- "id": "debugEstimation",
- "type": "debug",
- "z": "combinedFlowTab",
- "name": "Show Estimation Result",
- "active": false,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "pv_estimation",
- "targetType": "msg",
- "statusVal": "",
- "statusType": "auto",
- "x": 1190,
- "y": 100,
- "wires": []
- },
- {
- "id": "triggerRealtimeChecks",
- "type": "inject",
- "z": "combinedFlowTab",
- "name": "Check Status Every 30s",
- "props": [],
- "repeat": "30",
- "crontab": "",
- "once": true,
- "onceDelay": "5",
- "topic": "",
- "payload": "",
- "payloadType": "date",
- "x": 150,
- "y": 380,
- "wires": [
- [
- "getSoC",
- "getPhase1Voltage",
- "getPhase2Voltage",
- "getPhase3Voltage",
- "getPvPower",
- "getHomeLoad",
- "getBattVoltage"
- ]
- ]
- },
- {
- "id": "getSoC",
- "type": "api-current-state",
- "z": "combinedFlowTab",
- "name": "Get Battery SoC",
- "server": "YOUR_HA_SERVER_ID",
- "version": 3,
- "outputs": 1,
- "halt_if": "",
- "halt_if_type": "str",
- "halt_if_compare": "is",
- "entity_id": "sensor.inverter_battery",
- "state_type": "num",
- "blockInputOverrides": true,
- "outputProperties": [
- {
- "property": "payload",
- "propertyType": "msg",
- "value": "",
- "valueType": "entityState"
- }
- ],
- "for": "0",
- "forType": "num",
- "forUnits": "minutes",
- "override_topic": false,
- "state_location": "payload",
- "override_payload": "msg",
- "entity_location": "",
- "override_data": "",
- "x": 380,
- "y": 320,
- "wires": [
- [
- "storeSoC"
- ]
- ]
- },
- {
- "id": "storeSoC",
- "type": "change",
- "z": "combinedFlowTab",
- "name": "Store SoC Globally",
- "rules": [
- {
- "t": "set",
- "p": "latestBatterySoC",
- "pt": "global",
- "to": "payload",
- "tot": "msg"
- }
- ],
- "action": "",
- "property": "",
- "from": "",
- "to": "",
- "reg": false,
- "x": 730,
- "y": 320,
- "wires": [
- []
- ]
- },
- {
- "id": "getPhase1Voltage",
- "type": "api-current-state",
- "z": "combinedFlowTab",
- "name": "L1 Voltage",
- "server": "YOUR_HA_SERVER_ID",
- "version": 3,
- "outputs": 1,
- "halt_if": "",
- "halt_if_type": "str",
- "halt_if_compare": "is",
- "entity_id": "sensor.inverter_output_l1_voltage",
- "state_type": "num",
- "blockInputOverrides": true,
- "outputProperties": [
- {
- "property": "payload",
- "propertyType": "msg",
- "value": "",
- "valueType": "entityState"
- },
- {
- "property": "topic",
- "propertyType": "msg",
- "value": "phase1",
- "valueType": "str"
- }
- ],
- "for": "0",
- "forType": "num",
- "forUnits": "minutes",
- "override_topic": false,
- "state_location": "payload",
- "override_payload": "msg",
- "entity_location": "",
- "override_data": "",
- "x": 370,
- "y": 380,
- "wires": [
- [
- "joinVoltagesForCharge",
- "joinVoltagesForExport"
- ]
- ]
- },
- {
- "id": "getPhase2Voltage",
- "type": "api-current-state",
- "z": "combinedFlowTab",
- "name": "L2 Voltage",
- "server": "YOUR_HA_SERVER_ID",
- "version": 3,
- "outputs": 1,
- "halt_if": "",
- "halt_if_type": "str",
- "halt_if_compare": "is",
- "entity_id": "sensor.inverter_output_l2_voltage",
- "state_type": "num",
- "blockInputOverrides": true,
- "outputProperties": [
- {
- "property": "payload",
- "propertyType": "msg",
- "value": "",
- "valueType": "entityState"
- },
- {
- "property": "topic",
- "propertyType": "msg",
- "value": "phase2",
- "valueType": "str"
- }
- ],
- "for": "0",
- "forType": "num",
- "forUnits": "minutes",
- "override_topic": false,
- "state_location": "payload",
- "override_payload": "msg",
- "entity_location": "",
- "override_data": "",
- "x": 370,
- "y": 440,
- "wires": [
- [
- "joinVoltagesForCharge",
- "joinVoltagesForExport"
- ]
- ]
- },
- {
- "id": "getPhase3Voltage",
- "type": "api-current-state",
- "z": "combinedFlowTab",
- "name": "L3 Voltage",
- "server": "YOUR_HA_SERVER_ID",
- "version": 3,
- "outputs": 1,
- "halt_if": "",
- "halt_if_type": "str",
- "halt_if_compare": "is",
- "entity_id": "sensor.inverter_output_l3_voltage",
- "state_type": "num",
- "blockInputOverrides": true,
- "outputProperties": [
- {
- "property": "payload",
- "propertyType": "msg",
- "value": "",
- "valueType": "entityState"
- },
- {
- "property": "topic",
- "propertyType": "msg",
- "value": "phase3",
- "valueType": "str"
- }
- ],
- "for": "0",
- "forType": "num",
- "forUnits": "minutes",
- "override_topic": false,
- "state_location": "payload",
- "override_payload": "msg",
- "entity_location": "",
- "override_data": "",
- "x": 370,
- "y": 500,
- "wires": [
- [
- "joinVoltagesForCharge",
- "joinVoltagesForExport"
- ]
- ]
- },
- {
- "id": "joinVoltagesForCharge",
- "type": "join",
- "z": "combinedFlowTab",
- "name": "Combine V for Charge Ctrl",
- "mode": "custom",
- "build": "object",
- "property": "payload",
- "propertyType": "msg",
- "key": "topic",
- "joiner": "",
- "joinerType": "str",
- "useparts": true,
- "accumulate": false,
- "timeout": "1",
- "count": "6",
- "reduceRight": false,
- "reduceExp": "",
- "reduceInit": "",
- "reduceInitType": "",
- "reduceFixup": "",
- "x": 760,
- "y": 420,
- "wires": [
- [
- "calculateDynamicCharge",
- "b38d3df326d173b2"
- ]
- ]
- },
- {
- "id": "calculateDynamicCharge",
- "type": "function",
- "z": "combinedFlowTab",
- "name": "Calculate Dynamic Charge Current (SoC+Calib+Rain+Volt)",
- "func": "// --- Configuration ---\n// !! IMPORTANT: SET YOUR CURRENT LIMITS & TARGETS !!\nconst MAX_CHARGE_CURRENT = 150; // Maximum charging current (A) - SET TO YOUR BATTERY/INVERTER LIMIT\nconst MIN_CHARGE_CURRENT_PV_AWARE = 1; // Min current to command when surplus > 0 (A) (set >0 to avoid rapid start/stop)\nconst MIN_SURPLUS_WATTAGE = 50; // Minimum PV surplus (W) required to start/maintain charging\nconst NOMINAL_BATTERY_VOLTAGE = 51.2; // Approx/Nominal Voltage (V) - USED IF REAL-TIME sensor unavailable/invalid\n\nconst DEFAULT_MAX_SOC = 80; // Normal target SoC %\nconst FULL_CHARGE_MAX_SOC = 100; // Target SoC for calibration/rain/low PV estimate %\nconst CALIBRATION_INTERVAL_DAYS = 14; // How often to force calibration\nconst RAIN_FORECAST_HOURS = 24; // Lookahead for rain forecast\nconst RAIN_PROBABILITY_THRESHOLD = 0.5; // Min probability of precip (0 to 1) to trigger charge\nconst CALIBRATION_SOC_CONFIRM = 99; // SoC level to confirm calibration success (%)\n\n// **** NEW CONSTANT for Low PV Estimate Trigger ****\n// Set this threshold based on your system's typical production.\n// If the 3AM estimate is below this (kWh), it will force charge to 100%.\nconst LOW_PV_DAY_ESTIMATE_THRESHOLD = 80; // Example threshold (kWh) - ADJUST THIS!\n\n// --- End Configuration ---\n\n// --- Get Real-time Data ---\n// Expects payload like: { phase1: V1, phase2: V2, phase3: V3, pvPowerW: W, homeLoadW: W, batteryVoltageV: V }\nconst allData = msg.payload;\nconst batterySoC = Number(global.get(\"latestBatterySoC\")); // Assumes SoC is stored globally\nconst pvPowerW = Number(allData.pvPowerW);\nconst homeLoadW = Number(allData.homeLoadW);\n// Use real battery voltage if available and valid, otherwise use nominal\nlet batteryVoltage = Number(allData.batteryVoltageV);\nif (isNaN(batteryVoltage) || batteryVoltage <= 0) {\n batteryVoltage = NOMINAL_BATTERY_VOLTAGE;\n}\n\n// --- Get Persistent/Forecast Data ---\nconst lastCalibrationTime = global.get(\"lastCalibrationTime\", \"file\") || 0;\nconst hourlyForecast = global.get(\"hourlyWeatherDataForecast\"); // Used for Rain Check\nconst dailyPvEstimateKWh = global.get(\"dailyPvEstimateKWh\"); // Get PV estimate calculated at 3 AM\n\n// --- Initial Checks ---\nif (isNaN(batterySoC) || isNaN(pvPowerW) || isNaN(homeLoadW)) {\n node.warn(`Invalid input data received. SoC: ${batterySoC}, PV: ${pvPowerW}, Load: ${homeLoadW}. Setting charge to 0A.`);\n msg.payload = { value: 0 };\n return msg;\n}\n// Voltage check no longer needed for charge logic itself\n// const maxVoltage = Math.max(Number(allData.phase1), Number(allData.phase2), Number(allData.phase3));\n\n\n// --- Determine Target SoC & Force Charge ---\nlet targetSoC = DEFAULT_MAX_SOC;\nlet forceChargeReason = `Default ${DEFAULT_MAX_SOC}%`;\nlet forceCharge = false; // Will be true if Calib, Low PV Est, or Rain triggered\nconst now = Date.now();\nconst calibrationIntervalMillis = CALIBRATION_INTERVAL_DAYS * 24 * 60 * 60 * 1000;\nlet dailyPvEstimate = (typeof dailyPvEstimateKWh === 'number') ? dailyPvEstimateKWh : null; // Handle missing estimate\n\n// 1. Check Calibration Trigger\nif (now - lastCalibrationTime >= calibrationIntervalMillis) {\n targetSoC = FULL_CHARGE_MAX_SOC;\n forceCharge = true;\n forceChargeReason = \"Calibration Due\";\n node.log(`Calibration charge triggered (due >= ${CALIBRATION_INTERVAL_DAYS} days). Target: ${targetSoC}%`);\n}\n\n// **** NEW: Check for Low PV Estimate (only if calibration not already active) ****\nif (!forceCharge && dailyPvEstimate !== null && dailyPvEstimate < LOW_PV_DAY_ESTIMATE_THRESHOLD) {\n targetSoC = FULL_CHARGE_MAX_SOC;\n forceCharge = true; // Treat low PV day like a forced charge event\n forceChargeReason = \"Low PV Estimate\";\n node.log(`Low PV estimate charge triggered (${dailyPvEstimate.toFixed(1)} < ${LOW_PV_DAY_ESTIMATE_THRESHOLD} kWh). Target: ${targetSoC}%`);\n}\n\n// 3. Check Rain Trigger (only if *neither* calibration NOR low PV already active)\nif (!forceCharge && Array.isArray(hourlyForecast)) {\n const lookaheadMillis = RAIN_FORECAST_HOURS * 60 * 60 * 1000;\n let rainFound = false;\n for (const forecast of hourlyForecast) {\n if (forecast && typeof forecast.dt !== 'undefined' && typeof forecast.pop !== 'undefined') {\n if (forecast.dt * 1000 > now && forecast.dt * 1000 <= now + lookaheadMillis) {\n if (forecast.pop >= RAIN_PROBABILITY_THRESHOLD) {\n rainFound = true;\n break;\n }\n }\n }\n }\n if (rainFound) {\n targetSoC = FULL_CHARGE_MAX_SOC;\n forceCharge = true;\n forceChargeReason = \"Rain Forecasted\";\n node.log(`Pre-rain charge triggered (pop >= ${RAIN_PROBABILITY_THRESHOLD} in next ${RAIN_FORECAST_HOURS}h). Target: ${targetSoC}%`);\n }\n} else if (!forceCharge && !Array.isArray(hourlyForecast)) {\n // node.log(\"Rain check skipped: forecastData48h not available or not an array.\");\n}\n\n\n// --- Check if Target SoC is Reached ---\nif (batterySoC >= targetSoC) {\n node.log(`Target SoC (${targetSoC}%) reached or exceeded (Current: ${batterySoC}%). Reason: ${forceChargeReason}. Setting charge to 0 A.`);\n if (forceChargeReason === \"Calibration Due\" && batterySoC >= CALIBRATION_SOC_CONFIRM) {\n global.set(\"lastCalibrationTime\", now, \"file\");\n node.log(`Calibration timestamp updated to ${new Date(now).toISOString()}`);\n }\n msg.payload = { value: 0 };\n return msg;\n}\n\n\n// **** PV-AWARE CHARGING LOGIC (Applies when forceCharge is false) ****\n// --- Calculate Charge Current ---\nlet calculatedCurrent = 0; // Initialize\nlet chargeReason = \"\";\n\n// This section only runs if batterySoC < targetSoC\n\nif (forceCharge) {\n // --- Forced Charge (Calibration, Low PV Estimate, or Rain) ---\n calculatedCurrent = MAX_CHARGE_CURRENT; // Use Max for forced charges, may use grid\n chargeReason = `Forced (${forceChargeReason})`;\n\n} else {\n // --- Normal Charge (Target 80%) - Use PV Surplus ---\n let surplusPowerW = pvPowerW - homeLoadW;\n\n if (surplusPowerW >= MIN_SURPLUS_WATTAGE) {\n // Calculate target current based on surplus power and battery voltage\n let targetChargeCurrentA = surplusPowerW / batteryVoltage;\n\n // Clamp the current between the PV-aware minimum and the absolute maximum\n calculatedCurrent = Math.max(MIN_CHARGE_CURRENT_PV_AWARE, Math.min(targetChargeCurrentA, MAX_CHARGE_CURRENT));\n chargeReason = `PV Surplus (${surplusPowerW.toFixed(0)}W)`;\n\n } else {\n // No significant PV surplus available\n calculatedCurrent = 0;\n chargeReason = `No/Low PV Surplus (${surplusPowerW.toFixed(0)}W)`;\n }\n} // End else (Normal Charge)\n\n// **** END PV-AWARE CHARGING LOGIC ****\n\n\n// Final clamping (mostly relevant for forced charge) and rounding\nlet finalCurrent = Math.max(0, Math.min(MAX_CHARGE_CURRENT, Math.round(calculatedCurrent)));\n\n// Updated log message - removed voltage info as it's not used for charge decision\nnode.log(`Target: ${targetSoC}% (${forceChargeReason}), SoC: ${batterySoC}% -> Charge: ${finalCurrent}A (${chargeReason})`);\n\nmsg.payload = { value: finalCurrent };\nreturn msg;",
- "outputs": 1,
- "timeout": 0,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 880,
- "y": 500,
- "wires": [
- [
- "filterUnchangedCharge",
- "debugChargeCalc"
- ]
- ]
- },
- {
- "id": "filterUnchangedCharge",
- "type": "rbe",
- "z": "combinedFlowTab",
- "name": "Send Only Changes",
- "func": "rbe",
- "gap": "",
- "start": "",
- "inout": "out",
- "septopics": false,
- "property": "payload.value",
- "topi": "topic",
- "x": 1220,
- "y": 400,
- "wires": [
- [
- "setChargeCurrent"
- ]
- ]
- },
- {
- "id": "setChargeCurrent",
- "type": "api-call-service",
- "z": "combinedFlowTab",
- "name": "Set Max Battery Charge Current",
- "server": "YOUR_HA_SERVER_ID",
- "version": 7,
- "debugenabled": false,
- "action": "number.set_value",
- "floorId": [],
- "areaId": [],
- "deviceId": [],
- "entityId": [
- "number.inverter_battery_max_charging_current"
- ],
- "labelId": [],
- "data": "{\"value\":{{payload.value}}}",
- "dataType": "json",
- "mergeContext": "",
- "mustacheAltTags": false,
- "outputProperties": [],
- "queue": "none",
- "blockInputOverrides": false,
- "domain": "number",
- "service": "set_value",
- "target": {
- "entity_id": [
- "number.inverter_battery_max_charging_current"
- ]
- },
- "x": 1470,
- "y": 480,
- "wires": [
- []
- ]
- },
- {
- "id": "debugChargeCalc",
- "type": "debug",
- "z": "combinedFlowTab",
- "name": "Debug Charge Calculation",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": true,
- "complete": "$string(payload.value) & \" A (\" & $string($globalContext(\"latestBatterySoC\")) & \"% -> \" & $string($round($globalContext(\"latestBatterySoC\"))) & \"%)\"",
- "targetType": "jsonata",
- "statusVal": "payload.value",
- "statusType": "auto",
- "x": 1260,
- "y": 600,
- "wires": []
- },
- {
- "id": "separator_export",
- "type": "comment",
- "z": "combinedFlowTab",
- "d": true,
- "name": "--- DYNAMIC EXPORT CONTROL (RUNS IN PARALLEL) ---",
- "x": 940,
- "y": 560,
- "wires": []
- },
- {
- "id": "joinVoltagesForExport",
- "type": "join",
- "z": "combinedFlowTab",
- "name": "Combine V for Export Ctrl",
- "mode": "custom",
- "build": "object",
- "property": "payload",
- "propertyType": "msg",
- "key": "topic",
- "joiner": "",
- "joinerType": "str",
- "useparts": true,
- "accumulate": false,
- "timeout": "1",
- "count": "6",
- "reduceRight": false,
- "reduceExp": "",
- "reduceInit": "",
- "reduceInitType": "",
- "reduceFixup": "",
- "x": 760,
- "y": 620,
- "wires": [
- [
- "dynamicExportFunction"
- ]
- ]
- },
- {
- "id": "dynamicExportFunction",
- "type": "function",
- "z": "combinedFlowTab",
- "name": "Dynamic Export Logic (Based on Max Voltage)",
- "func": "// Define desired voltage and export limits (in Watts)\nconst desiredVoltage = 252; // Used for downward adjustments\nconst releaseVoltage = 249; // Upward adjustments allowed only if voltage < this value\nconst minExport = 1000; // Minimum export limit (e.g., 1,000W)\nconst maxExport = 12000; // Maximum export limit (e.g., 9,000W)\n\n// Proportional gains for increasing vs. decreasing export\nconst KpUp = 200; // Gain for increasing export (voltage is below releaseVoltage)\nconst KpDown = 400; // Gain for decreasing export (voltage is above desiredVoltage)\n\n// Delay for upward adjustment (2 minutes in milliseconds)\nconst delayUp = 2 * 60 * 1000;\n\n// Read phase voltages from msg.payload (expected as an object with phase1, phase2, phase3)\nlet v1 = Number(msg.payload.phase1);\nlet v2 = Number(msg.payload.phase2);\nlet v3 = Number(msg.payload.phase3);\nif (isNaN(v1) || isNaN(v2) || isNaN(v3)) {\n node.warn(\"Invalid phase voltage data provided.\");\n return null;\n}\n\n// Determine the highest voltage among the three phases\nlet highestVoltage = Math.max(v1, v2, v3);\nnode.log(\"Highest Voltage: \" + highestVoltage + \" V\");\n\n// Retrieve the previous export limit (default to minExport if not set)\nlet prevExport = Number(global.get(\"exportLimit\")) || minExport;\n\n// Calculate error: positive if measured voltage is below desired, negative if above\nlet error = desiredVoltage - highestVoltage;\nlet newExport;\n\nif (error > 0) {\n // We want to increase export, but only if voltage is sufficiently low AND has been low for at least delayUp.\n if (highestVoltage >= releaseVoltage) {\n // Voltage is still above the release threshold: reset the timer and hold export.\n context.set(\"belowReleaseStart\", null);\n node.log(\"Voltage (\" + highestVoltage + \" V) is above the release threshold (\" + releaseVoltage + \" V). Holding export at \" + prevExport + \" W.\");\n newExport = prevExport;\n } else {\n // Voltage is below the release threshold.\n let belowReleaseStart = context.get(\"belowReleaseStart\");\n if (!belowReleaseStart) {\n // Timer not started yet: start it and hold the export until 2 minutes pass.\n context.set(\"belowReleaseStart\", Date.now());\n newExport = prevExport;\n node.log(\"Voltage is below release threshold. Starting 2-minute timer.\");\n } else {\n let elapsed = Date.now() - belowReleaseStart;\n if (elapsed >= delayUp) {\n // Voltage has been below the threshold for 2 minutse: increase export gradually.\n newExport = prevExport + KpUp * error;\n node.log(\"2 minutes elapsed below release threshold. Increasing export: error = \" + error + \", adjustment = \" + (KpUp * error));\n } else {\n // Not enough time has passed: hold export.\n newExport = prevExport;\n node.log(\"Waiting for 2 minutes below release threshold (\" + elapsed + \" ms elapsed).\");\n }\n }\n }\n} else {\n // Voltage is above desired; decrease export rapidly.\n // Reset the timer since voltage is too high.\n context.set(\"belowReleaseStart\", null);\n newExport = prevExport + KpDown * error;\n node.log(\"Decreasing export: error = \" + error + \", adjustment = \" + (KpDown * error));\n}\n\n// Clamp the new export limit between minExport and maxExport\nif (newExport < minExport) newExport = minExport;\nif (newExport > maxExport) newExport = maxExport;\n\n// Store the new export limit for the next cycle\nglobal.set(\"exportLimit\", newExport);\n\n// Prepare the payload for the service call\nmsg.payload = { value: Math.round(newExport) };\nnode.log(\"Calculated Export Limit: \" + Math.round(newExport) + \" W (Error: \" + error + \")\");\nreturn msg;\n",
- "outputs": 1,
- "timeout": 0,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 840,
- "y": 760,
- "wires": [
- [
- "filterUnchangedExport",
- "debugExportCalc"
- ]
- ]
- },
- {
- "id": "filterUnchangedExport",
- "type": "rbe",
- "z": "combinedFlowTab",
- "name": "Send Only Changes",
- "func": "rbe",
- "gap": "",
- "start": "",
- "inout": "out",
- "septopics": false,
- "property": "payload.value",
- "topi": "topic",
- "x": 1200,
- "y": 680,
- "wires": [
- [
- "setExportLimit"
- ]
- ]
- },
- {
- "id": "setExportLimit",
- "type": "api-call-service",
- "z": "combinedFlowTab",
- "name": "Set Grid Export Limit (Watts)",
- "server": "YOUR_HA_SERVER_ID",
- "version": 7,
- "debugenabled": false,
- "action": "number.set_value",
- "floorId": [],
- "areaId": [],
- "deviceId": [],
- "entityId": [
- "number.inverter_export_surplus_power"
- ],
- "labelId": [],
- "data": "{\"value\":{{payload.value}}}",
- "dataType": "json",
- "mergeContext": "",
- "mustacheAltTags": false,
- "outputProperties": [],
- "queue": "none",
- "blockInputOverrides": false,
- "domain": "number",
- "service": "set_value",
- "target": {
- "entity_id": [
- "number.inverter_export_surplus_power"
- ]
- },
- "x": 1480,
- "y": 680,
- "wires": [
- []
- ]
- },
- {
- "id": "debugExportCalc",
- "type": "debug",
- "z": "combinedFlowTab",
- "name": "Debug Export Calculation",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": true,
- "complete": "payload.value",
- "targetType": "msg",
- "statusVal": "payload.value",
- "statusType": "auto",
- "x": 1230,
- "y": 860,
- "wires": []
- },
- {
- "id": "b38d3df326d173b2",
- "type": "debug",
- "z": "combinedFlowTab",
- "name": "debug 3",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "false",
- "statusVal": "",
- "statusType": "auto",
- "x": 1000,
- "y": 380,
- "wires": []
- },
- {
- "id": "8a1474719323688a",
- "type": "debug",
- "z": "combinedFlowTab",
- "name": "debug 4",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "false",
- "statusVal": "",
- "statusType": "auto",
- "x": 920,
- "y": 100,
- "wires": []
- },
- {
- "id": "424558d406e966a6",
- "type": "debug",
- "z": "combinedFlowTab",
- "name": "debug 5",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "false",
- "statusVal": "",
- "statusType": "auto",
- "x": 1160,
- "y": 200,
- "wires": []
- },
- {
- "id": "getPvPower",
- "type": "api-current-state",
- "z": "combinedFlowTab",
- "name": "Get PV Power",
- "server": "YOUR_HA_SERVER_ID",
- "version": 3,
- "outputs": 1,
- "halt_if": "",
- "halt_if_type": "str",
- "halt_if_compare": "is",
- "entity_id": "sensor.total_solar_power",
- "state_type": "num",
- "blockInputOverrides": true,
- "outputProperties": [
- {
- "property": "payload",
- "propertyType": "msg",
- "value": "",
- "valueType": "entityState"
- },
- {
- "property": "topic",
- "propertyType": "msg",
- "value": "pvPowerW",
- "valueType": "str"
- }
- ],
- "for": "0",
- "forType": "num",
- "forUnits": "minutes",
- "override_topic": false,
- "state_location": "payload",
- "override_payload": "msg",
- "entity_location": "",
- "override_data": "",
- "x": 380,
- "y": 580,
- "wires": [
- [
- "joinVoltagesForCharge",
- "joinVoltagesForExport"
- ]
- ]
- },
- {
- "id": "getHomeLoad",
- "type": "api-current-state",
- "z": "combinedFlowTab",
- "name": "Get Home Load",
- "server": "YOUR_HA_SERVER_ID",
- "version": 3,
- "outputs": 1,
- "halt_if": "",
- "halt_if_type": "str",
- "halt_if_compare": "is",
- "entity_id": "sensor.total_load_power",
- "state_type": "num",
- "blockInputOverrides": true,
- "outputProperties": [
- {
- "property": "payload",
- "propertyType": "msg",
- "value": "",
- "valueType": "entityState"
- },
- {
- "property": "topic",
- "propertyType": "msg",
- "value": "homeLoadW",
- "valueType": "str"
- }
- ],
- "for": "0",
- "forType": "num",
- "forUnits": "minutes",
- "override_topic": false,
- "state_location": "payload",
- "override_payload": "msg",
- "entity_location": "",
- "override_data": "",
- "x": 380,
- "y": 640,
- "wires": [
- [
- "joinVoltagesForCharge",
- "joinVoltagesForExport"
- ]
- ]
- },
- {
- "id": "getBattVoltage",
- "type": "api-current-state",
- "z": "combinedFlowTab",
- "name": "Get Battery Voltage",
- "server": "YOUR_HA_SERVER_ID",
- "version": 3,
- "outputs": 1,
- "halt_if": "",
- "halt_if_type": "str",
- "halt_if_compare": "is",
- "entity_id": "sensor.inverter_battery_voltage",
- "state_type": "num",
- "blockInputOverrides": true,
- "outputProperties": [
- {
- "property": "payload",
- "propertyType": "msg",
- "value": "",
- "valueType": "entityState"
- },
- {
- "property": "topic",
- "propertyType": "msg",
- "value": "batteryVoltageV",
- "valueType": "str"
- }
- ],
- "for": "0",
- "forType": "num",
- "forUnits": "minutes",
- "override_topic": false,
- "state_location": "payload",
- "override_payload": "msg",
- "entity_location": "",
- "override_data": "",
- "x": 390,
- "y": 700,
- "wires": [
- [
- "joinVoltagesForCharge",
- "joinVoltagesForExport"
- ]
- ]
- },
- {
- "id": "YOUR_HA_SERVER_ID",
- "type": "server",
- "name": "Home Assistant",
- "addon": true
- }
- ]
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement