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..009dc74 100644 --- a/docs/blog/2024-07-01_shelly_solar_diverter.md +++ b/docs/blog/2024-07-01_shelly_solar_diverter.md @@ -91,8 +91,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 +121,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 +130,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,18 +145,34 @@ 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": { @@ -280,6 +297,12 @@ http://192.168.125.92/script/1/status } ``` +### 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. diff --git a/docs/downloads/solar_diverter_v1.js b/docs/downloads/solar_diverter_v1.js index 5d24c7e..6908795 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,21 +19,37 @@ 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, @@ -63,27 +79,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 +112,85 @@ 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"); } - PID.output = PID.pTerm + PID.iTerm + PID.dTerm; + + // 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 = constrain(PID.sum + peTerm + PID.dTerm, CONFIG.PID.OUT_MIN, CONFIG.PID.OUT_MAX); + + PID.input = input; PID.error = error; + return PID.output; } function callDimmers(cb) { - for (const ip in DIVERT.dimmers) { + for (let ip in DIVERT.dimmers) { const dimmer = DIVERT.dimmers[ip]; // ignore contacted dimmers @@ -130,6 +198,12 @@ function callDimmers(cb) { continue; } + if (isNaN(dimmer.dutyCycle)) { + dimmer.rpc = "failed"; + print(scriptName, ":", "ERR: Invalid duty cycle for dimmer", ip); + 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"; @@ -162,21 +236,16 @@ function callDimmers(cb) { } 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,7 +253,7 @@ 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"); @@ -222,8 +291,9 @@ function onGetStatus(request, response) { response.code = 200; response.headers = { "Content-Type": "application/json" - } + }; response.body = JSON.stringify({ + config: CONFIG, pid: PID, divert: DIVERT }); @@ -233,7 +303,7 @@ function onGetStatus(request, response) { // Main validateConfig(function () { - print(scriptName, ":", "Starting Shelly Solar Diverter...") + print(scriptName, ":", "Starting Shelly Solar Diverter..."); HTTPServer.registerEndpoint("status", onGetStatus); 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`