diff --git a/docs/assets/img/hardware/shelly_solar_diverter_poc.jpeg b/docs/assets/img/hardware/shelly_solar_diverter_poc.jpeg index 31b0b05..727da41 100644 Binary files a/docs/assets/img/hardware/shelly_solar_diverter_poc.jpeg and b/docs/assets/img/hardware/shelly_solar_diverter_poc.jpeg differ diff --git a/docs/assets/img/schemas/Solar_Router_Diverter.jpg b/docs/assets/img/schemas/Solar_Router_Diverter.jpg index 497e67c..911df91 100644 Binary files a/docs/assets/img/schemas/Solar_Router_Diverter.jpg and b/docs/assets/img/schemas/Solar_Router_Diverter.jpg differ diff --git a/docs/blog/2024-07-01_shelly_solar_diverter.md b/docs/blog/2024-07-01_shelly_solar_diverter.md index 49cdf74..33b23c5 100644 --- a/docs/blog/2024-07-01_shelly_solar_diverter.md +++ b/docs/blog/2024-07-01_shelly_solar_diverter.md @@ -46,6 +46,7 @@ A router can also schedule some forced heating of the water tank to ensure the w - **Unlimited dimmers (output)** - **PID Controller** - **Excess sharing amongst dimmers with percentages** +- **Bypass (force heating)** and automatically turn the dimmer off - **Plus all the power of the Shelly ecosystem (rules, schedules, automations, etc)** This solar diverter based on Shelly devices and a Shelly script can control remotely dimmers and could even be enhanced with relays. @@ -91,8 +92,7 @@ First the easy part: the temperature sensor and the Shelly Add-On, which has to For example, the Shelly EM can be inside the main electric box, and the Shelly Dimmer + Add-On can be in the water tank electric panel, while the contactor and dimmer can be placed neat the water tank. They communicate through the network. - The dimmer will control the voltage regulator through the `COM` and `0-10V` ports -- The dimmer will also control the relay or contactor through the `A2` ports -- The wire from `Dimmer Output` to `Dimmer S1` is to set the switch mode to invert and make the dimmer detect when the contactor is OFF or ON and respectively disable or enable the dimming. +- The Shelly EM will control the relay or contactor through the `A2` ports. - The B clamp around the wire going from the voltage regulator to the water tank is to measure the current going through the water tank resistance is optional and for information purposes only. - The A clamp should be put around the main phase entering the house - The relay / contactor is optional and is used to schedule some forced heating of the water tank to ensure the water reaches a safe temperature, and consequently bypass the dimmed voltage. @@ -122,6 +122,8 @@ Also, this central place allows to control the 1, 2 or more dimmers remotely. - Make sure the switch (input) are enabled and inverted. S1 should replicate the inverse state of the relay / contactor, so that when you activate the contactor through the Dimmer output remotely, the dimmer will deactivate itself. - Set static IP addresses +- Use the min/max settings to remap the 0-100% to match the voltage regulator. + I noticed that the LSA allows a wider range but the one from LCTC has to be re-mapped from 10-80% ### Shelly Pro EM 50 Setup @@ -129,7 +131,7 @@ Also, this central place allows to control the 1, 2 or more dimmers remotely. - Make sure to place the A clamp around the main phase entering the house in the right direction - Add the `Shelly Solar Diverter` to the Shelly Pro EM - Configure the settings in the `CONFIG` object -- Start teh script +- Start the script ## How to use @@ -144,33 +146,53 @@ const CONFIG = { // Grid Power Read Interval (s) READ_INTERVAL_S: 1, PID: { + // Reverse + REVERSE: false, + // Proportional Mode: + // - "error" (proportional on error), + // - "input" (proportional on measurement), + // - "both" (proportional on 50% error and 50% measurement) + P_MODE: "input", + // Derivative Mode: + // - "error" (derivative on error), + // - "input" (derivative on measurement) + D_MODE: "error", + // Integral Correction + // - "off" (integral sum not clamped), + // - "clamp" (integral sum not clamped to OUT_MIN and OUT_MAX), + // - "advanced" (advanced anti-windup algorithm) + IC_MODE: "advanced", // Target Grid Power (W) - SET_POINT: 0, - // Number of Watts allowed to be above or below the set point (W) - SET_POINT_DELTA: 2, + SETPOINT: 0, // PID Proportional Gain - KP: 0.8, + KP: 0.3, // PID Integral Gain - KI: 0, + KI: 0.3, // PID Derivative Gain - KD: 0.8, - // PID Output Minimum Clamp (W) - OUTPUT_MIN: 0, + KD: 0.1, + // Output Minimum (W) + OUT_MIN: -10000, + // Output Maximum (W) + OUT_MAX: 10000, }, DIMMERS: { - "192.168.125.93": { + "192.168.125.98": { // Resistance (in Ohm) of the load connecter to the dimmer + voltage regulator // 0 will disable the dimmer RESISTANCE: 24, // Percentage of the remaining excess power that will be assigned to this dimmer // The remaining percentage will be given to the next dimmers RESERVED_EXCESS_PERCENT: 100, + // Set whether the Shelly EM with this script will be used to control the bypass relay to force a heating + // When set to true, if you activate remotely the bypass to force a heating, then the script will tur the dimmer off + BYPASS_CONTROLLED_BY_EM: true }, "192.168.125.97": { RESISTANCE: 0, RESERVED_EXCESS_PERCENT: 100, - }, - }, + BYPASS_CONTROLLED_BY_EM: false + } + } }; ``` @@ -240,51 +262,71 @@ http://192.168.125.92/script/1/status ```json { + "config": { + "DEBUG": 2, + "READ_INTERVAL_S": 1, + "PID": { + "REVERSE": false, + "P_MODE": "input", + "D_MODE": "error", + "IC_MODE": "advanced", + "SETPOINT": 0, + "KP": 0.3, + "KI": 0.3, + "KD": 0.1, + "OUT_MIN": -10000, + "OUT_MAX": 10000 + }, + "DIMMERS": { + "192.168.125.98": { + "RESISTANCE": 24, + "RESERVED_EXCESS_PERCENT": 100 + }, + "192.168.125.97": { + "RESISTANCE": 0, + "RESERVED_EXCESS_PERCENT": 100 + } + } + }, "pid": { "input": 0, "output": 0, "error": 0, "pTerm": 0, "iTerm": 0, - "dTerm": 0 + "dTerm": 0, + "sum": 0 }, "divert": { - "voltage": 237.9, - "gridPower": 0, - "divertPower": 173.76, + "lastTime": 1720281498691.629, "dimmers": { - "192.168.125.93": { - "divertPower": 86.88, - "nominalPower": 2358.18375, - "dutyCycle": 0.03684191276, - "powerFactor": 0.19194247253, - "dimmedVoltage": 45.66311421705, - "current": 1.90262975904, - "apparentPower": 452.63561967657, - "thdi": 5.11302248812, - "rpc": "pending" - }, - "192.168.125.97": { - "divertPower": 86.88, - "nominalPower": 2358.18375, - "dutyCycle": 0.03684191276, - "powerFactor": 0.19194247253, - "dimmedVoltage": 45.66311421705, - "current": 1.90262975904, - "apparentPower": 452.63561967657, - "thdi": 5.11302248812, - "rpc": "pending" + "192.168.125.98": { + "divertPower": 0, + "maximumPower": 2263.98374999999, + "dutyCycle": 0, + "powerFactor": 0, + "dimmedVoltage": 0, + "current": 0, + "apparentPower": 0, + "thdi": 0, + "rpc": "success" } } } } ``` +### PID Control and Tuning + +The script uses a complex PID controller that can be tuned to really obtain a very good routing precision. +The algorithm used and default parameters are the same as in the YaSolr project. +You will find a lot of information in the [YaSolR manual](/manual#dashboard--pid-controller). + ## Demo Here is the view of the Shelly device websites while the grid power is changing. -[![Shelly Solar Diverter Demo](http://img.youtube.com/vi/he5qPJx8_R4/0.jpg)](http://www.youtube.com/watch?v=he5qPJx8_R4 "Shelly Solar Diverter Demo") +[![Shelly Solar Diverter Demo](http://img.youtube.com/vi/qDV0VZnWXWU/0.jpg)](http://www.youtube.com/watch?v=qDV0VZnWXWU "Shelly Solar Diverter Demo") Here is a PoC box I am using for my testing with all the components wired. I am still waiting for the dimmer gen 3 which works in **current sourcing** mode, but everything else is working. diff --git a/docs/downloads/solar_diverter_v1.js b/docs/downloads/solar_diverter_v1.js index 5d24c7e..9482dd4 100644 --- a/docs/downloads/solar_diverter_v1.js +++ b/docs/downloads/solar_diverter_v1.js @@ -9,7 +9,7 @@ * * ====================================== */ -const scriptName = "solar_diverter.js"; +const scriptName = "solar_diverter"; // Config @@ -19,31 +19,51 @@ const CONFIG = { // Grid Power Read Interval (s) READ_INTERVAL_S: 1, PID: { + // Reverse + REVERSE: false, + // Proportional Mode: + // - "error" (proportional on error), + // - "input" (proportional on measurement), + // - "both" (proportional on 50% error and 50% measurement) + P_MODE: "input", + // Derivative Mode: + // - "error" (derivative on error), + // - "input" (derivative on measurement) + D_MODE: "error", + // Integral Correction + // - "off" (integral sum not clamped), + // - "clamp" (integral sum not clamped to OUT_MIN and OUT_MAX), + // - "advanced" (advanced anti-windup algorithm) + IC_MODE: "advanced", // Target Grid Power (W) - SET_POINT: 0, - // Number of Watts allowed to be above or below the set point (W) - SET_POINT_DELTA: 0, + SETPOINT: 0, // PID Proportional Gain - KP: 0.8, + KP: 0.3, // PID Integral Gain - KI: 0.01, + KI: 0.3, // PID Derivative Gain - KD: 0.8, - // PID Output Minimum Clamp (W) - ITERM_MIN: -10, + KD: 0.1, + // Output Minimum (W) + OUT_MIN: -10000, + // Output Maximum (W) + OUT_MAX: 10000, }, DIMMERS: { - "192.168.125.93": { + "192.168.125.98": { // Resistance (in Ohm) of the load connecter to the dimmer + voltage regulator // 0 will disable the dimmer RESISTANCE: 24, // Percentage of the remaining excess power that will be assigned to this dimmer // The remaining percentage will be given to the next dimmers - RESERVED_EXCESS_PERCENT: 100 + RESERVED_EXCESS_PERCENT: 100, + // Set whether the Shelly EM with this script will be used to control the bypass relay to force a heating + // When set to true, if you activate remotely the bypass to force a heating, then the script will tur the dimmer off + BYPASS_CONTROLLED_BY_EM: true }, "192.168.125.97": { RESISTANCE: 0, - RESERVED_EXCESS_PERCENT: 100 + RESERVED_EXCESS_PERCENT: 100, + BYPASS_CONTROLLED_BY_EM: false } } }; @@ -63,27 +83,26 @@ let PID = { iTerm: 0, // Derivative Term dTerm: 0, -} + // Sum + sum: 0, +}; // Divert Control let DIVERT = { lastTime: 0, - voltage: 0, - gridPower: CONFIG.PID.SET_POINT, - divertPower: CONFIG.PID.SET_POINT, dimmers: {} -} +}; function validateConfig(cb) { - print(scriptName, ":", "Validating Config...") + print(scriptName, ":", "Validating Config..."); if (CONFIG.DIMMERS.length === 0) { print(scriptName, ":", "ERR: No dimmer configured"); return; } - for (const ip in CONFIG.DIMMERS) { + for (let ip in CONFIG.DIMMERS) { if (CONFIG.DIMMERS[ip].RESISTANCE < 0) { print(scriptName, ":", "ERR: Dimmer resistance should be greater than 0"); return; @@ -97,32 +116,110 @@ function validateConfig(cb) { print(scriptName, ":", "Dimmer", ip, "is enabled"); DIVERT.dimmers[ip] = { divertPower: 0 - } + }; } cb(); } +function constrain(value, min, max) { return Math.min(Math.max(value, min), max); } + +// - https://github.com/Dlloydev/QuickPID +// - https://github.com/br3ttb/Arduino-PID-Library function calculatePID(input) { - PID.input = input; - const error = CONFIG.PID.SET_POINT - PID.input; - if (Math.abs(error) <= CONFIG.PID.SET_POINT_DELTA) { - PID.pTerm = 0; - PID.iTerm = 0; - PID.dTerm = 0; - PID.output = 0; - } else { - PID.pTerm = error * CONFIG.PID.KP; - PID.iTerm = Math.max(PID.iTerm + error * CONFIG.PID.KI, CONFIG.PID.ITERM_MIN); - PID.dTerm = (error - PID.error) * CONFIG.PID.KD; + const dInput = CONFIG.PID.REVERSE ? PID.input - input : input - PID.input; + const error = CONFIG.PID.REVERSE ? input - CONFIG.PID.SETPOINT : CONFIG.PID.SETPOINT - input; + const dError = error - PID.error; + + if (CONFIG.DEBUG > 1) { + print(scriptName, ":", "Input:", input, "W, Error:", error, "W, dError:", dError, "W"); + } + + let peTerm = CONFIG.PID.KP * error; + let pmTerm = CONFIG.PID.KP * dInput; + switch (CONFIG.PID.P_MODE) { + case "error": + pmTerm = 0; + break; + case "input": + peTerm = 0; + break; + case "both": + peTerm *= 0.5; + pmTerm *= 0.5; + break; + default: + return PID.output; + } + + // pTerm + PID.pTerm = peTerm - pmTerm; + + // iTerm + PID.iTerm = CONFIG.PID.KI * error; + + if (CONFIG.DEBUG > 1) { + print(scriptName, ":", "pTerm:", PID.pTerm, "W, iTerm:", PID.iTerm, "W"); + } + + // anti-windup + if (CONFIG.PID.IC_MODE == "advanced" && CONFIG.PID.KI) { + const iTermOut = PID.pTerm + CONFIG.PID.KI * (PID.iTerm + error); + if ((iTermOut > CONFIG.PID.OUT_MAX && dError > 0) || (iTermOut < CONFIG.PID.OUT_MIN && dError < 0)) { + _iTerm = constrain(iTermOut, -CONFIG.PID.OUT_MAX, CONFIG.PID.OUT_MAX); + } + } + + // integral sum + PID.sum = CONFIG.PID.IC_MODE == "off" ? (PID.sum + PID.iTerm - pmTerm) : constrain(PID.sum + PID.iTerm - pmTerm, CONFIG.PID.OUT_MIN, CONFIG.PID.OUT_MAX); + + // dTerm + switch (CONFIG.PID.D_MODE) { + case "error": + PID.dTerm = CONFIG.PID.KD * dError; + break; + case "input": + PID.dTerm = -CONFIG.PID.KD * dInput; + break; + default: + return PID.output; } - PID.output = PID.pTerm + PID.iTerm + PID.dTerm; + + PID.output = constrain(PID.sum + peTerm + PID.dTerm, CONFIG.PID.OUT_MIN, CONFIG.PID.OUT_MAX); + + PID.input = input; PID.error = error; + return PID.output; } +function callDimmerCallback(result, errCode, errMessage, data) { + if (errCode) { + print(scriptName, ":", "ERR callDimmerCallback:", errCode); + data.dimmer.rpc = "failed"; + } else if (result.code !== 200) { + const rpcResult = JSON.parse(result.body); + print(scriptName, ":", "ERR", rpcResult.code, ":", rpcResult.message); + data.dimmer.rpc = "failed"; + } else { + data.dimmer.rpc = "success"; + } + data.cb(); +} + +function callDimmer(ip, dimmer, cb) { + const url = "http://" + ip + "/rpc/Light.Set?id=0&on=" + (dimmer.dutyCycle > 0 ? "true" : "false") + "&brightness=" + (dimmer.dutyCycle * 100) + "&transition_duration=0.5"; + if (CONFIG.DEBUG > 1) + print(scriptName, ":", "Calling Dimmer: ", url); + Shelly.call("HTTP.GET", { url: url, timeout: 5 }, callDimmerCallback, { dimmer: dimmer, cb: cb }); +} + function callDimmers(cb) { - for (const ip in DIVERT.dimmers) { + function recallMe() { + callDimmers(cb) + } + + for (let ip in DIVERT.dimmers) { const dimmer = DIVERT.dimmers[ip]; // ignore contacted dimmers @@ -130,28 +227,14 @@ function callDimmers(cb) { continue; } - // build url - const url = "http://" + ip + "/rpc/Light.Set?id=0&on=" + (dimmer.dutyCycle > 0 ? "true" : "false") + "&brightness=" + (dimmer.dutyCycle * 100) + "&transition_duration=0.5"; - - if (CONFIG.DEBUG > 1) - print(scriptName, ":", "Calling Dimmer: ", url); + if (isNaN(dimmer.dutyCycle)) { + dimmer.rpc = "failed"; + print(scriptName, ":", "ERR: Invalid duty cycle for dimmer", ip); + continue; + } // call dimmer - Shelly.call("HTTP.GET", { url: url, timeout: 5 }, function (result, err) { - if (err) { - print(scriptName, ":", "ERR:", err); - dimmer.rpc = "failed"; - } else if (result.code !== 200) { - const rpcResult = JSON.parse(result.body); - print(scriptName, ":", "ERR", rpcResult.code, ":", rpcResult.message); - dimmer.rpc = "failed"; - } else { - dimmer.rpc = "success"; - } - - // once done, call ourself again until no dimmer is left - callDimmers(cb); - }); + callDimmer(ip, dimmer, recallMe); // exit the loop immediately to avoid multiple calls in case the yare made in parallel return; @@ -161,22 +244,43 @@ function callDimmers(cb) { cb(); } +function onSwitchGetStatus(result, errCode, errMessage, data) { + if (errCode) { + print(scriptName, ":", "ERR onSwitchGetStatus:", errCode); + throttleReadPower(); + return; + } + if (CONFIG.DEBUG > 1) + print(scriptName, ":", "onSwitchGetStatus:", JSON.stringify(result)); + if (result.output) { + for (let ip in DIVERT.dimmers) { + const dimmer = DIVERT.dimmers[ip]; + if (CONFIG.DIMMERS[ip].BYPASS_CONTROLLED_BY_EM) { + print(scriptName, ":", "Bypass is ON, turning off dimmer", ip); + dimmer.apparentPower = 0; + dimmer.current = 0; + dimmer.dimmedVoltage = 0; + dimmer.divertPower = 0; + dimmer.dutyCycle = 0; + dimmer.powerFactor = 0; + dimmer.thdi = 0; + } + } + } + callDimmers(throttleReadPower); +} + function divert(voltage, gridPower) { - DIVERT.voltage = voltage; - DIVERT.gridPower = gridPower; - const correction = calculatePID(DIVERT.gridPower); - DIVERT.divertPower = Math.max(0, DIVERT.divertPower + correction); + let newRoutingPower = calculatePID(gridPower); if (CONFIG.DEBUG > 0) - print(scriptName, ":", "Grid:", voltage, "V,", gridPower, "W. Correction:", correction, "W. Total Divert Power:", DIVERT.divertPower, "W"); - - let remaining = DIVERT.divertPower; + print(scriptName, ":", "Grid:", voltage, "V,", gridPower, "W. Power to divert:", newRoutingPower, "W"); - for (const ip in DIVERT.dimmers) { + for (let ip in DIVERT.dimmers) { const dimmer = DIVERT.dimmers[ip]; - dimmer.nominalPower = voltage * voltage / CONFIG.DIMMERS[ip].RESISTANCE; - dimmer.divertPower = Math.min(remaining * CONFIG.DIMMERS[ip].RESERVED_EXCESS_PERCENT / 100, dimmer.nominalPower); - dimmer.dutyCycle = dimmer.divertPower / dimmer.nominalPower; + dimmer.maximumPower = voltage * voltage / CONFIG.DIMMERS[ip].RESISTANCE; + dimmer.divertPower = Math.min(newRoutingPower * CONFIG.DIMMERS[ip].RESERVED_EXCESS_PERCENT / 100, dimmer.maximumPower); + dimmer.dutyCycle = dimmer.divertPower / dimmer.maximumPower; dimmer.powerFactor = Math.sqrt(dimmer.dutyCycle); dimmer.dimmedVoltage = dimmer.powerFactor * voltage; dimmer.current = dimmer.dimmedVoltage / CONFIG.DIMMERS[ip].RESISTANCE; @@ -184,18 +288,21 @@ function divert(voltage, gridPower) { dimmer.thdi = dimmer.dutyCycle === 0 ? 0 : Math.sqrt(1 / dimmer.dutyCycle - 1); dimmer.rpc = "pending"; - remaining -= dimmer.divertPower; + newRoutingPower -= dimmer.divertPower; if (CONFIG.DEBUG > 0) print(scriptName, ":", "Dimmer", ip, "=>", dimmer.divertPower, "W"); } - callDimmers(throttleReadPower); + Shelly.call("Switch.GetStatus", { id: 0 }, onSwitchGetStatus); } -function onEM1GetStatus(result, err) { - if (err) +function onEM1GetStatus(result, errCode, errMessage, data) { + if (errCode) { + print(scriptName, ":", "ERR onEM1GetStatus:", errCode); + throttleReadPower(); return; + } if (CONFIG.DEBUG > 1) print(scriptName, ":", "EM1.GetStatus:", JSON.stringify(result)); divert(result.voltage, result.act_power); @@ -218,12 +325,13 @@ function throttleReadPower() { // HTTP handlers -function onGetStatus(request, response) { +function onHttpGetStatus(request, response) { response.code = 200; response.headers = { "Content-Type": "application/json" - } + }; response.body = JSON.stringify({ + config: CONFIG, pid: PID, divert: DIVERT }); @@ -233,7 +341,7 @@ function onGetStatus(request, response) { // Main validateConfig(function () { - print(scriptName, ":", "Starting Shelly Solar Diverter...") - HTTPServer.registerEndpoint("status", onGetStatus); + print(scriptName, ":", "Starting Shelly Solar Diverter..."); + HTTPServer.registerEndpoint("status", onHttpGetStatus); readPower(); }); diff --git a/docs/manual.md b/docs/manual.md index 2370e7c..b66882e 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -168,6 +168,8 @@ The overview section shows some global information about the router. The temperature is coming from the sensor installed in the router box. +**A JSY or PZEM is required to see the measurements.** + ## Dashboard / Output 1 & 2 The output sections show the state of the outputs and the possibility to control them. @@ -184,6 +186,8 @@ The output sections show the state of the outputs and the possibility to control **Energy:** +**A JSY or PZEM is required to see the measurements.** + - `Power`: Routed power. - `Apparent Power`: Apparent power in VA circulating on the wires. - `Power Factor`: Power factor (if lower than 1, mainly composed of harmonic component). Ideal is close to 1. @@ -462,7 +466,7 @@ Here are some basic links to start with, which talks about the code used under t - Proportional Mode: `On Input` - Derivative Mode: `On Error` -- Integral Correction: `Anti-windup` +- Integral Correction: `Advanced` - Setpoint: `0` - Kp: `0.3` - Ki: `0.3`