Advertisement
Guest User

deye-flow-3

a guest
Apr 13th, 2025
8
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 34.49 KB | None | 0 0
  1. [
  2. {
  3. "id": "combinedFlowTab",
  4. "type": "tab",
  5. "label": "PV Control (Estimate + Dynamic Charge + Dynamic Export)",
  6. "disabled": false,
  7. "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!**",
  8. "env": []
  9. },
  10. {
  11. "id": "triggerDailyTasks",
  12. "type": "inject",
  13. "z": "combinedFlowTab",
  14. "name": "Run Daily Tasks at 3:00 AM",
  15. "props": [],
  16. "repeat": "",
  17. "crontab": "0 3 * * *",
  18. "once": true,
  19. "onceDelay": "15",
  20. "topic": "",
  21. "payload": "",
  22. "payloadType": "date",
  23. "x": 160,
  24. "y": 100,
  25. "wires": [
  26. [
  27. "getWeatherForecast"
  28. ]
  29. ]
  30. },
  31. {
  32. "id": "getWeatherForecast",
  33. "type": "openweathermap",
  34. "z": "combinedFlowTab",
  35. "name": "Get Weather Forecast (OneCall API)",
  36. "wtype": "onecall",
  37. "lon": "25.939",
  38. "lat": "46.419",
  39. "city": "",
  40. "country": "",
  41. "language": "en",
  42. "x": 520,
  43. "y": 100,
  44. "wires": [
  45. [
  46. "processForecastData",
  47. "calculatePVEstimate",
  48. "8a1474719323688a"
  49. ]
  50. ]
  51. },
  52. {
  53. "id": "processForecastData",
  54. "type": "function",
  55. "z": "combinedFlowTab",
  56. "name": "Process & Store 24h Rain Forecast",
  57. "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}",
  58. "outputs": 1,
  59. "timeout": 0,
  60. "noerr": 0,
  61. "initialize": "",
  62. "finalize": "",
  63. "libs": [],
  64. "x": 860,
  65. "y": 180,
  66. "wires": [
  67. [
  68. "424558d406e966a6"
  69. ]
  70. ]
  71. },
  72. {
  73. "id": "calculatePVEstimate",
  74. "type": "function",
  75. "z": "combinedFlowTab",
  76. "name": "Calculate Hourly PV Estimate",
  77. "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)",
  78. "outputs": 1,
  79. "timeout": 0,
  80. "noerr": 0,
  81. "initialize": "",
  82. "finalize": "",
  83. "libs": [],
  84. "x": 860,
  85. "y": 20,
  86. "wires": [
  87. [
  88. "debugEstimation"
  89. ]
  90. ]
  91. },
  92. {
  93. "id": "debugEstimation",
  94. "type": "debug",
  95. "z": "combinedFlowTab",
  96. "name": "Show Estimation Result",
  97. "active": false,
  98. "tosidebar": true,
  99. "console": false,
  100. "tostatus": false,
  101. "complete": "pv_estimation",
  102. "targetType": "msg",
  103. "statusVal": "",
  104. "statusType": "auto",
  105. "x": 1190,
  106. "y": 100,
  107. "wires": []
  108. },
  109. {
  110. "id": "triggerRealtimeChecks",
  111. "type": "inject",
  112. "z": "combinedFlowTab",
  113. "name": "Check Status Every 30s",
  114. "props": [],
  115. "repeat": "30",
  116. "crontab": "",
  117. "once": true,
  118. "onceDelay": "5",
  119. "topic": "",
  120. "payload": "",
  121. "payloadType": "date",
  122. "x": 150,
  123. "y": 380,
  124. "wires": [
  125. [
  126. "getSoC",
  127. "getPhase1Voltage",
  128. "getPhase2Voltage",
  129. "getPhase3Voltage",
  130. "getPvPower",
  131. "getHomeLoad",
  132. "getBattVoltage"
  133. ]
  134. ]
  135. },
  136. {
  137. "id": "getSoC",
  138. "type": "api-current-state",
  139. "z": "combinedFlowTab",
  140. "name": "Get Battery SoC",
  141. "server": "YOUR_HA_SERVER_ID",
  142. "version": 3,
  143. "outputs": 1,
  144. "halt_if": "",
  145. "halt_if_type": "str",
  146. "halt_if_compare": "is",
  147. "entity_id": "sensor.inverter_battery",
  148. "state_type": "num",
  149. "blockInputOverrides": true,
  150. "outputProperties": [
  151. {
  152. "property": "payload",
  153. "propertyType": "msg",
  154. "value": "",
  155. "valueType": "entityState"
  156. }
  157. ],
  158. "for": "0",
  159. "forType": "num",
  160. "forUnits": "minutes",
  161. "override_topic": false,
  162. "state_location": "payload",
  163. "override_payload": "msg",
  164. "entity_location": "",
  165. "override_data": "",
  166. "x": 380,
  167. "y": 320,
  168. "wires": [
  169. [
  170. "storeSoC"
  171. ]
  172. ]
  173. },
  174. {
  175. "id": "storeSoC",
  176. "type": "change",
  177. "z": "combinedFlowTab",
  178. "name": "Store SoC Globally",
  179. "rules": [
  180. {
  181. "t": "set",
  182. "p": "latestBatterySoC",
  183. "pt": "global",
  184. "to": "payload",
  185. "tot": "msg"
  186. }
  187. ],
  188. "action": "",
  189. "property": "",
  190. "from": "",
  191. "to": "",
  192. "reg": false,
  193. "x": 730,
  194. "y": 320,
  195. "wires": [
  196. []
  197. ]
  198. },
  199. {
  200. "id": "getPhase1Voltage",
  201. "type": "api-current-state",
  202. "z": "combinedFlowTab",
  203. "name": "L1 Voltage",
  204. "server": "YOUR_HA_SERVER_ID",
  205. "version": 3,
  206. "outputs": 1,
  207. "halt_if": "",
  208. "halt_if_type": "str",
  209. "halt_if_compare": "is",
  210. "entity_id": "sensor.inverter_output_l1_voltage",
  211. "state_type": "num",
  212. "blockInputOverrides": true,
  213. "outputProperties": [
  214. {
  215. "property": "payload",
  216. "propertyType": "msg",
  217. "value": "",
  218. "valueType": "entityState"
  219. },
  220. {
  221. "property": "topic",
  222. "propertyType": "msg",
  223. "value": "phase1",
  224. "valueType": "str"
  225. }
  226. ],
  227. "for": "0",
  228. "forType": "num",
  229. "forUnits": "minutes",
  230. "override_topic": false,
  231. "state_location": "payload",
  232. "override_payload": "msg",
  233. "entity_location": "",
  234. "override_data": "",
  235. "x": 370,
  236. "y": 380,
  237. "wires": [
  238. [
  239. "joinVoltagesForCharge",
  240. "joinVoltagesForExport"
  241. ]
  242. ]
  243. },
  244. {
  245. "id": "getPhase2Voltage",
  246. "type": "api-current-state",
  247. "z": "combinedFlowTab",
  248. "name": "L2 Voltage",
  249. "server": "YOUR_HA_SERVER_ID",
  250. "version": 3,
  251. "outputs": 1,
  252. "halt_if": "",
  253. "halt_if_type": "str",
  254. "halt_if_compare": "is",
  255. "entity_id": "sensor.inverter_output_l2_voltage",
  256. "state_type": "num",
  257. "blockInputOverrides": true,
  258. "outputProperties": [
  259. {
  260. "property": "payload",
  261. "propertyType": "msg",
  262. "value": "",
  263. "valueType": "entityState"
  264. },
  265. {
  266. "property": "topic",
  267. "propertyType": "msg",
  268. "value": "phase2",
  269. "valueType": "str"
  270. }
  271. ],
  272. "for": "0",
  273. "forType": "num",
  274. "forUnits": "minutes",
  275. "override_topic": false,
  276. "state_location": "payload",
  277. "override_payload": "msg",
  278. "entity_location": "",
  279. "override_data": "",
  280. "x": 370,
  281. "y": 440,
  282. "wires": [
  283. [
  284. "joinVoltagesForCharge",
  285. "joinVoltagesForExport"
  286. ]
  287. ]
  288. },
  289. {
  290. "id": "getPhase3Voltage",
  291. "type": "api-current-state",
  292. "z": "combinedFlowTab",
  293. "name": "L3 Voltage",
  294. "server": "YOUR_HA_SERVER_ID",
  295. "version": 3,
  296. "outputs": 1,
  297. "halt_if": "",
  298. "halt_if_type": "str",
  299. "halt_if_compare": "is",
  300. "entity_id": "sensor.inverter_output_l3_voltage",
  301. "state_type": "num",
  302. "blockInputOverrides": true,
  303. "outputProperties": [
  304. {
  305. "property": "payload",
  306. "propertyType": "msg",
  307. "value": "",
  308. "valueType": "entityState"
  309. },
  310. {
  311. "property": "topic",
  312. "propertyType": "msg",
  313. "value": "phase3",
  314. "valueType": "str"
  315. }
  316. ],
  317. "for": "0",
  318. "forType": "num",
  319. "forUnits": "minutes",
  320. "override_topic": false,
  321. "state_location": "payload",
  322. "override_payload": "msg",
  323. "entity_location": "",
  324. "override_data": "",
  325. "x": 370,
  326. "y": 500,
  327. "wires": [
  328. [
  329. "joinVoltagesForCharge",
  330. "joinVoltagesForExport"
  331. ]
  332. ]
  333. },
  334. {
  335. "id": "joinVoltagesForCharge",
  336. "type": "join",
  337. "z": "combinedFlowTab",
  338. "name": "Combine V for Charge Ctrl",
  339. "mode": "custom",
  340. "build": "object",
  341. "property": "payload",
  342. "propertyType": "msg",
  343. "key": "topic",
  344. "joiner": "",
  345. "joinerType": "str",
  346. "useparts": true,
  347. "accumulate": false,
  348. "timeout": "1",
  349. "count": "6",
  350. "reduceRight": false,
  351. "reduceExp": "",
  352. "reduceInit": "",
  353. "reduceInitType": "",
  354. "reduceFixup": "",
  355. "x": 760,
  356. "y": 420,
  357. "wires": [
  358. [
  359. "calculateDynamicCharge",
  360. "b38d3df326d173b2"
  361. ]
  362. ]
  363. },
  364. {
  365. "id": "calculateDynamicCharge",
  366. "type": "function",
  367. "z": "combinedFlowTab",
  368. "name": "Calculate Dynamic Charge Current (SoC+Calib+Rain+Volt)",
  369. "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;",
  370. "outputs": 1,
  371. "timeout": 0,
  372. "noerr": 0,
  373. "initialize": "",
  374. "finalize": "",
  375. "libs": [],
  376. "x": 880,
  377. "y": 500,
  378. "wires": [
  379. [
  380. "filterUnchangedCharge",
  381. "debugChargeCalc"
  382. ]
  383. ]
  384. },
  385. {
  386. "id": "filterUnchangedCharge",
  387. "type": "rbe",
  388. "z": "combinedFlowTab",
  389. "name": "Send Only Changes",
  390. "func": "rbe",
  391. "gap": "",
  392. "start": "",
  393. "inout": "out",
  394. "septopics": false,
  395. "property": "payload.value",
  396. "topi": "topic",
  397. "x": 1220,
  398. "y": 400,
  399. "wires": [
  400. [
  401. "setChargeCurrent"
  402. ]
  403. ]
  404. },
  405. {
  406. "id": "setChargeCurrent",
  407. "type": "api-call-service",
  408. "z": "combinedFlowTab",
  409. "name": "Set Max Battery Charge Current",
  410. "server": "YOUR_HA_SERVER_ID",
  411. "version": 7,
  412. "debugenabled": false,
  413. "action": "number.set_value",
  414. "floorId": [],
  415. "areaId": [],
  416. "deviceId": [],
  417. "entityId": [
  418. "number.inverter_battery_max_charging_current"
  419. ],
  420. "labelId": [],
  421. "data": "{\"value\":{{payload.value}}}",
  422. "dataType": "json",
  423. "mergeContext": "",
  424. "mustacheAltTags": false,
  425. "outputProperties": [],
  426. "queue": "none",
  427. "blockInputOverrides": false,
  428. "domain": "number",
  429. "service": "set_value",
  430. "target": {
  431. "entity_id": [
  432. "number.inverter_battery_max_charging_current"
  433. ]
  434. },
  435. "x": 1470,
  436. "y": 480,
  437. "wires": [
  438. []
  439. ]
  440. },
  441. {
  442. "id": "debugChargeCalc",
  443. "type": "debug",
  444. "z": "combinedFlowTab",
  445. "name": "Debug Charge Calculation",
  446. "active": true,
  447. "tosidebar": true,
  448. "console": false,
  449. "tostatus": true,
  450. "complete": "$string(payload.value) & \" A (\" & $string($globalContext(\"latestBatterySoC\")) & \"% -> \" & $string($round($globalContext(\"latestBatterySoC\"))) & \"%)\"",
  451. "targetType": "jsonata",
  452. "statusVal": "payload.value",
  453. "statusType": "auto",
  454. "x": 1260,
  455. "y": 600,
  456. "wires": []
  457. },
  458. {
  459. "id": "separator_export",
  460. "type": "comment",
  461. "z": "combinedFlowTab",
  462. "d": true,
  463. "name": "--- DYNAMIC EXPORT CONTROL (RUNS IN PARALLEL) ---",
  464. "x": 940,
  465. "y": 560,
  466. "wires": []
  467. },
  468. {
  469. "id": "joinVoltagesForExport",
  470. "type": "join",
  471. "z": "combinedFlowTab",
  472. "name": "Combine V for Export Ctrl",
  473. "mode": "custom",
  474. "build": "object",
  475. "property": "payload",
  476. "propertyType": "msg",
  477. "key": "topic",
  478. "joiner": "",
  479. "joinerType": "str",
  480. "useparts": true,
  481. "accumulate": false,
  482. "timeout": "1",
  483. "count": "6",
  484. "reduceRight": false,
  485. "reduceExp": "",
  486. "reduceInit": "",
  487. "reduceInitType": "",
  488. "reduceFixup": "",
  489. "x": 760,
  490. "y": 620,
  491. "wires": [
  492. [
  493. "dynamicExportFunction"
  494. ]
  495. ]
  496. },
  497. {
  498. "id": "dynamicExportFunction",
  499. "type": "function",
  500. "z": "combinedFlowTab",
  501. "name": "Dynamic Export Logic (Based on Max Voltage)",
  502. "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",
  503. "outputs": 1,
  504. "timeout": 0,
  505. "noerr": 0,
  506. "initialize": "",
  507. "finalize": "",
  508. "libs": [],
  509. "x": 840,
  510. "y": 760,
  511. "wires": [
  512. [
  513. "filterUnchangedExport",
  514. "debugExportCalc"
  515. ]
  516. ]
  517. },
  518. {
  519. "id": "filterUnchangedExport",
  520. "type": "rbe",
  521. "z": "combinedFlowTab",
  522. "name": "Send Only Changes",
  523. "func": "rbe",
  524. "gap": "",
  525. "start": "",
  526. "inout": "out",
  527. "septopics": false,
  528. "property": "payload.value",
  529. "topi": "topic",
  530. "x": 1200,
  531. "y": 680,
  532. "wires": [
  533. [
  534. "setExportLimit"
  535. ]
  536. ]
  537. },
  538. {
  539. "id": "setExportLimit",
  540. "type": "api-call-service",
  541. "z": "combinedFlowTab",
  542. "name": "Set Grid Export Limit (Watts)",
  543. "server": "YOUR_HA_SERVER_ID",
  544. "version": 7,
  545. "debugenabled": false,
  546. "action": "number.set_value",
  547. "floorId": [],
  548. "areaId": [],
  549. "deviceId": [],
  550. "entityId": [
  551. "number.inverter_export_surplus_power"
  552. ],
  553. "labelId": [],
  554. "data": "{\"value\":{{payload.value}}}",
  555. "dataType": "json",
  556. "mergeContext": "",
  557. "mustacheAltTags": false,
  558. "outputProperties": [],
  559. "queue": "none",
  560. "blockInputOverrides": false,
  561. "domain": "number",
  562. "service": "set_value",
  563. "target": {
  564. "entity_id": [
  565. "number.inverter_export_surplus_power"
  566. ]
  567. },
  568. "x": 1480,
  569. "y": 680,
  570. "wires": [
  571. []
  572. ]
  573. },
  574. {
  575. "id": "debugExportCalc",
  576. "type": "debug",
  577. "z": "combinedFlowTab",
  578. "name": "Debug Export Calculation",
  579. "active": true,
  580. "tosidebar": true,
  581. "console": false,
  582. "tostatus": true,
  583. "complete": "payload.value",
  584. "targetType": "msg",
  585. "statusVal": "payload.value",
  586. "statusType": "auto",
  587. "x": 1230,
  588. "y": 860,
  589. "wires": []
  590. },
  591. {
  592. "id": "b38d3df326d173b2",
  593. "type": "debug",
  594. "z": "combinedFlowTab",
  595. "name": "debug 3",
  596. "active": true,
  597. "tosidebar": true,
  598. "console": false,
  599. "tostatus": false,
  600. "complete": "false",
  601. "statusVal": "",
  602. "statusType": "auto",
  603. "x": 1000,
  604. "y": 380,
  605. "wires": []
  606. },
  607. {
  608. "id": "8a1474719323688a",
  609. "type": "debug",
  610. "z": "combinedFlowTab",
  611. "name": "debug 4",
  612. "active": true,
  613. "tosidebar": true,
  614. "console": false,
  615. "tostatus": false,
  616. "complete": "false",
  617. "statusVal": "",
  618. "statusType": "auto",
  619. "x": 920,
  620. "y": 100,
  621. "wires": []
  622. },
  623. {
  624. "id": "424558d406e966a6",
  625. "type": "debug",
  626. "z": "combinedFlowTab",
  627. "name": "debug 5",
  628. "active": true,
  629. "tosidebar": true,
  630. "console": false,
  631. "tostatus": false,
  632. "complete": "false",
  633. "statusVal": "",
  634. "statusType": "auto",
  635. "x": 1160,
  636. "y": 200,
  637. "wires": []
  638. },
  639. {
  640. "id": "getPvPower",
  641. "type": "api-current-state",
  642. "z": "combinedFlowTab",
  643. "name": "Get PV Power",
  644. "server": "YOUR_HA_SERVER_ID",
  645. "version": 3,
  646. "outputs": 1,
  647. "halt_if": "",
  648. "halt_if_type": "str",
  649. "halt_if_compare": "is",
  650. "entity_id": "sensor.total_solar_power",
  651. "state_type": "num",
  652. "blockInputOverrides": true,
  653. "outputProperties": [
  654. {
  655. "property": "payload",
  656. "propertyType": "msg",
  657. "value": "",
  658. "valueType": "entityState"
  659. },
  660. {
  661. "property": "topic",
  662. "propertyType": "msg",
  663. "value": "pvPowerW",
  664. "valueType": "str"
  665. }
  666. ],
  667. "for": "0",
  668. "forType": "num",
  669. "forUnits": "minutes",
  670. "override_topic": false,
  671. "state_location": "payload",
  672. "override_payload": "msg",
  673. "entity_location": "",
  674. "override_data": "",
  675. "x": 380,
  676. "y": 580,
  677. "wires": [
  678. [
  679. "joinVoltagesForCharge",
  680. "joinVoltagesForExport"
  681. ]
  682. ]
  683. },
  684. {
  685. "id": "getHomeLoad",
  686. "type": "api-current-state",
  687. "z": "combinedFlowTab",
  688. "name": "Get Home Load",
  689. "server": "YOUR_HA_SERVER_ID",
  690. "version": 3,
  691. "outputs": 1,
  692. "halt_if": "",
  693. "halt_if_type": "str",
  694. "halt_if_compare": "is",
  695. "entity_id": "sensor.total_load_power",
  696. "state_type": "num",
  697. "blockInputOverrides": true,
  698. "outputProperties": [
  699. {
  700. "property": "payload",
  701. "propertyType": "msg",
  702. "value": "",
  703. "valueType": "entityState"
  704. },
  705. {
  706. "property": "topic",
  707. "propertyType": "msg",
  708. "value": "homeLoadW",
  709. "valueType": "str"
  710. }
  711. ],
  712. "for": "0",
  713. "forType": "num",
  714. "forUnits": "minutes",
  715. "override_topic": false,
  716. "state_location": "payload",
  717. "override_payload": "msg",
  718. "entity_location": "",
  719. "override_data": "",
  720. "x": 380,
  721. "y": 640,
  722. "wires": [
  723. [
  724. "joinVoltagesForCharge",
  725. "joinVoltagesForExport"
  726. ]
  727. ]
  728. },
  729. {
  730. "id": "getBattVoltage",
  731. "type": "api-current-state",
  732. "z": "combinedFlowTab",
  733. "name": "Get Battery Voltage",
  734. "server": "YOUR_HA_SERVER_ID",
  735. "version": 3,
  736. "outputs": 1,
  737. "halt_if": "",
  738. "halt_if_type": "str",
  739. "halt_if_compare": "is",
  740. "entity_id": "sensor.inverter_battery_voltage",
  741. "state_type": "num",
  742. "blockInputOverrides": true,
  743. "outputProperties": [
  744. {
  745. "property": "payload",
  746. "propertyType": "msg",
  747. "value": "",
  748. "valueType": "entityState"
  749. },
  750. {
  751. "property": "topic",
  752. "propertyType": "msg",
  753. "value": "batteryVoltageV",
  754. "valueType": "str"
  755. }
  756. ],
  757. "for": "0",
  758. "forType": "num",
  759. "forUnits": "minutes",
  760. "override_topic": false,
  761. "state_location": "payload",
  762. "override_payload": "msg",
  763. "entity_location": "",
  764. "override_data": "",
  765. "x": 390,
  766. "y": 700,
  767. "wires": [
  768. [
  769. "joinVoltagesForCharge",
  770. "joinVoltagesForExport"
  771. ]
  772. ]
  773. },
  774. {
  775. "id": "YOUR_HA_SERVER_ID",
  776. "type": "server",
  777. "name": "Home Assistant",
  778. "addon": true
  779. }
  780. ]
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement