diff --git a/include/BatteryGuard.h b/include/BatteryGuard.h new file mode 100644 index 000000000..ca93e9ff5 --- /dev/null +++ b/include/BatteryGuard.h @@ -0,0 +1,63 @@ +#pragma once +#include +#include +#include +#include "Statistic.h" + + +class BatteryGuardClass { + public: + BatteryGuardClass() = default; + ~BatteryGuardClass() = default; + + void init(Scheduler& scheduler); + void updateSettings(void); + void updateBatteryValues(float const nowVoltage, float const nowCurrent, uint32_t const millisStamp); + std::optional getOpenCircuitVoltage(void); + std::optional getInternalResistance(void) const; + + private: + enum class Text : uint8_t { + Q_NODATA = 0, + Q_EXCELLENT = 1, + Q_GOOD = 2, + Q_BAD = 3, + T_HEAD = 4 + }; + + void loop(void); + bool calculateOpenCircuitVoltage(float const nowVoltage, float const nowCurrent); + bool calculateInternalResistance(float const nowVoltage, float const nowCurrent); + void printOpenCircuitVoltageInformationBlock(void); + frozen::string const& getText(Text tNr); + + // Returns true if battery data is not older as 30 seconds + bool isDataValid() { return (millis() - _battMillis) < 30*1000; } + + // the following values ​​are used to calculate the "Open circuit voltage" + float _battVoltage = 0.0f; // actual battery voltage [V] + float _battCurrent = 0.0f; // actual battery current [A] + uint32_t _battMillis = 0; // measurement time stamp [millis()] + WeightedAVG _battPeriod {20}; // measurement period [ms] + WeightedAVG _battVoltageAVG {5}; // average battery voltage [V] + WeightedAVG _openCircuitVoltageAVG {5}; // average battery open circuit voltage [V] + float _resistanceFromConfig = 0.0f; // configured battery resistance [Ohm] + size_t _notAvailableCounter = 0; // voltage or current were not available counter + + // the following values ​​are used to calculate the "Battery internal resistance" + WeightedAVG _resistanceFromCalcAVG {10}; // calculated battery resistance [Ohm] + bool _firstOfTwoAvailable = false; // true after to got the first of two values + bool _minMaxAvailable = false; // true if minimum and maximum values are available + std::pair _pFirstVolt = {0.0f,0.0f}; // first of two voltages and related current [V,A] + std::pair _pMaxVolt = {0.0f,0.0f}; // maximum voltage and related current [V,A] + std::pair _pMinVolt = {0.0f,0.0f}; // minimum voltage and related current [V,A] + uint32_t _lastMinMaxMillis = 0; // last millis from the first min/max values [millis()] + float const _minDiffVoltage = 0.05f; // minimum required difference [V] + // unclear if this value will also fit to other battery provider + + Task _loopTask; // Task + bool _verboseLogging = false; // Logging On/Off + bool _useBatteryGuard = false; // "Battery guard" On/Off +}; + +extern BatteryGuardClass BatteryGuard; diff --git a/include/BatteryStats.h b/include/BatteryStats.h index cfce8ea88..13ba9664d 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -338,6 +338,9 @@ class VictronSmartShuntStats : public BatteryStats { bool _alarmLowSOC; bool _alarmLowTemperature; bool _alarmHighTemperature; + + std::optional _oBatteryResistor = std::nullopt; + std::optional _oOpenCircuitVoltage = std::nullopt; }; class MqttBatteryStats : public BatteryStats { diff --git a/include/Statistic.h b/include/Statistic.h new file mode 100644 index 000000000..bfc05acc4 --- /dev/null +++ b/include/Statistic.h @@ -0,0 +1,56 @@ +#pragma once + +/* + * Weighted average and statistics class (initialising value defines the weighted average 10 = 10%) +*/ +template +class WeightedAVG { +public: + explicit WeightedAVG(size_t factor) + : _countMax(factor) + , _count(0), _countNum(0), _avgV(0), _minV(0), _maxV(0), _lastV(0) {} + + // Add a value to the statistics + void addNumber(const T& num) { + if (_count == 0){ + _count++; + _avgV = num; + _minV = num; + _maxV = num; + _countNum = 1; + } else { + if (_count < _countMax) + _count++; + _avgV = (_avgV * (_count - 1) + num) / _count; + if (num < _minV) { _minV = num; } + if (num > _maxV) { _maxV = num; } + if (_countNum < 10000) { _countNum++; } + } + _lastV = num; + } + + // Reset the statistic data + void reset(void) { _count = 0; _avgV = 0; _minV = 0; _maxV = 0; _lastV = 0; _countNum = 0; } + // Reset the statistic data and initialize with first value + void reset(const T& num) { _count = 0; addNumber(num); } + // Returns the weighted average + T getAverage() const { return _avgV; } + // Returns the minimum value + T getMin() const { return _minV; } + // Returns the maximum value + T getMax() const { return _maxV; } + // Returns the last added value + T getLast() const { return _lastV; } + // Returns the amount of added values. Limited to 10000 + size_t getCounts() const { return _countNum; } + +private: + size_t _countMax; // weighting factor (10 => 1/10 => 10%) + size_t _count; // counter (0 - _countMax) + size_t _countNum; // counts the amount of added values (0 - 10000) + T _avgV; // average value + T _minV; // minimum value + T _maxV; // maximum value + T _lastV; // last value +}; + diff --git a/src/BatteryGuard.cpp b/src/BatteryGuard.cpp new file mode 100644 index 000000000..b1ed272a5 --- /dev/null +++ b/src/BatteryGuard.cpp @@ -0,0 +1,278 @@ +/* Battery-Guard + * + * The Battery-Guard has several features. + * - Calculate the battery open circuit voltage + * - Calculate the battery internal resistance + * - Limit the power drawn from the battery, if the battery voltage is close to the stop threshold. (draft) + * - Periodically recharge the battery to 100% SoC (draft) + * + * Basic principe of the feature: "Battery open circuit voltage" + * As soon as we kow the battery internal resistance we calculate the open circuit voltage. + * open circuit voltage = battery voltage - battery current * resistance. + * + * Basic principe of the feature: "Battery internal resistance" + * Collects minimum and maximum values (voltage and current) over a time frame of 30sec. + * Calculates the resistance from these values and build a weighed average. + * Note: We need load changes to get sufficient calculation results. About 100W on 24VDC or 180W on 48VDC. + * The resistance on LifePO4 batteries is not a fixed value, he depends from temperature, charge and time + * after a load change. + * + * Basic principe of the function: "Low voltage limiter" + * If the battery voltage is close to the stop threshold, the battery limiter will calculate a maximum power limit + * to keep the battery voltage above the voltage threshold. + * The inverter is only switched-off when the threshold is exceeded and the inverter output cannot be reduced any further. + * + * Basic principe of the function: "Periodically recharge the battery" + * After some days we start to reduce barriers, to make it more easier for the sun to fully charge the battery. + * When we reach 100% SoC we remove all restrictions and start a new period. + * Especially usefull during winter to support the SoC calibration of the BMS + * + * Notes: + * Some function are still under development. + * These functions were developed for the battery provider "Smart Shunt", but should also work with other providers + * + * 01.08.2024 - 0.1 - first version. "Low voltage power limiter" + * 09.12.2024 - 0.2 - add of function "Periodically recharge the battery" + * 11.12.2024 - 0.3 - add of function "Battery internal resistance" and "Open circuit voltage" + */ + +#include +#include "Configuration.h" +#include "MessageOutput.h" +#include "BatteryGuard.h" + + +// support for debugging, 0 = without extended logging, 1 = with extended logging, 2 = with much more logging +constexpr int MODULE_DEBUG = 0; + +BatteryGuardClass BatteryGuard; + + +/* + * Initialize the battery guard + */ +void BatteryGuardClass::init(Scheduler& scheduler) { + + // init the task loop + scheduler.addTask(_loopTask); + _loopTask.setCallback(std::bind(&BatteryGuardClass::loop, this)); + _loopTask.setIterations(TASK_FOREVER); + _loopTask.setInterval(60*1000); + _loopTask.enable(); + + updateSettings(); +} + + +/* + * Update some settings of the battery guard + */ +void BatteryGuardClass::updateSettings(void) { + + // todo: get values from the configuration + _verboseLogging = true; + _useBatteryGuard = true; + + // used for "Open circuit voltage" + _resistanceFromConfig = 0.0f; // if 0 we must wait until the resistance is calculated + _battPeriod.addNumber(1000); // start with 1 second +} + + +/* + * Periodical tasks, will be called once a minute + */ +void BatteryGuardClass::loop(void) { + + if (!_useBatteryGuard) { return; } + + if (_verboseLogging) { + MessageOutput.printf("%s\r\n", getText(Text::T_HEAD).data()); + MessageOutput.printf("%s ---------------- Battery-Guard information block (every minute) ----------------\r\n", + getText(Text::T_HEAD).data()); + + // "Open circuit voltage" + printOpenCircuitVoltageInformationBlock(); + } + + // "Low voltage power limiter" + + + // "Periodically recharge the battery" + + + if (_verboseLogging) { + MessageOutput.printf("%s\r\n", getText(Text::T_HEAD).data()); + MessageOutput.printf("%s --------------------------------------------------------------------------------\r\n", + getText(Text::T_HEAD).data()); + MessageOutput.printf("%s\r\n", getText(Text::T_HEAD).data()); + } +} + + +/* + * Update the battery guard with new values. (voltage[V], current[A], millisStamp[ms]) + * Function should be called from battery provider + * Note: Just call the function if new values are available. + */ +void BatteryGuardClass::updateBatteryValues(float const voltage, float const current, uint32_t const millisStamp) { + if (_useBatteryGuard && (voltage >= 0.0f)) { + // analyse the measurement period + if ((_battMillis != 0) && (voltage != _battVoltage)) { + _battPeriod.addNumber(millisStamp - _battMillis); + } + _battVoltage = voltage; + _battCurrent = current; + _battMillis = millisStamp; + _battVoltageAVG.addNumber(_battVoltage); + calculateInternalResistance(_battVoltage, _battCurrent); + calculateOpenCircuitVoltage(_battVoltage, _battCurrent); + } +} + + +/* + * Calculate the battery open circuit voltage. + * Returns true if a new value was calculated + */ +bool BatteryGuardClass::calculateOpenCircuitVoltage(float const nowVoltage, float const nowCurrent) { + + // calculate the open circuit battery voltage (current flow into the battery must be positive) + auto oResistor = getInternalResistance(); + if (oResistor.has_value()) { + _openCircuitVoltageAVG.addNumber(nowVoltage - nowCurrent * oResistor.value()); + return true; + } + return false; +} + + +/* + * Returns the battery internal resistance, calculated / configured or nullopt if neither value is valid + */ +std::optional BatteryGuardClass::getInternalResistance(void) const { + if (_resistanceFromCalcAVG.getCounts() > 4) { return _resistanceFromCalcAVG.getAverage(); } + if (_resistanceFromConfig != 0.0f) { return _resistanceFromConfig; } + return std::nullopt; +} + + +/* + * Returns the battery open circuit voltage or nullopt if value is not valid + */ +std::optional BatteryGuardClass::getOpenCircuitVoltage(void) { + if ((_openCircuitVoltageAVG.getCounts() > 0) && isDataValid()) { + return _openCircuitVoltageAVG.getAverage(); + } else { + _notAvailableCounter++; + return std::nullopt; + } +} + + +/* + * Calculate the battery resistance between the battery cells and the voltage measurement device. + * Returns true if a new resistance value was calculated + */ +bool BatteryGuardClass::calculateInternalResistance(float const nowVoltage, float const nowCurrent) { + + // we must avoid to use measurement values during any power transition. + // To solve this problem, we check whether two consecutive measurements are almost identical + if (!_firstOfTwoAvailable || (std::abs(_pFirstVolt.first - nowVoltage) > 0.005f) || + (std::abs(_pFirstVolt.second - nowCurrent) > 0.2f)) { + _pFirstVolt.first = nowVoltage; + _pFirstVolt.second = nowCurrent; + _firstOfTwoAvailable = true; + return false; + } + _firstOfTwoAvailable = false; // prepair for the next calculation + + // store the average in min or max buffer + std::pair avgVolt = std::make_pair((nowVoltage + _pFirstVolt.first) / 2.0f, (nowCurrent + _pFirstVolt.second) / 2.0f); + if (!_minMaxAvailable) { + _pMinVolt = _pMaxVolt = avgVolt; + _lastMinMaxMillis = millis(); + _minMaxAvailable = true; + } else { + if (avgVolt.first < _pMinVolt.first) { _pMinVolt = avgVolt; } + if (avgVolt.first > _pMaxVolt.first) { _pMaxVolt = avgVolt; } + } + + // we evaluate min and max values in a time duration of 30 sec + if ((!_minMaxAvailable || (millis() - _lastMinMaxMillis) < 30*1000)) { return false; } + _minMaxAvailable = false; // prepair for the next calculation + + // we need a minimum voltage difference to get a sufficiently good result (failure < 10%) + // SmartShunt: 50mV (about 100W on VDC: 24V, Ri: 12mOhm) + if ((_pMaxVolt.first - _pMinVolt.first) >= _minDiffVoltage) { + float resistor = std::abs((_pMaxVolt.first - _pMinVolt.first) / (_pMaxVolt.second - _pMinVolt.second)); + + // we try to keep out bad values from the average + if (_resistanceFromCalcAVG.getCounts() < 10) { + _resistanceFromCalcAVG.addNumber(resistor); + } else { + if ((resistor > _resistanceFromCalcAVG.getAverage() / 2.0f) && (resistor < _resistanceFromCalcAVG.getAverage() * 2.0f)) { + _resistanceFromCalcAVG.addNumber(resistor); + } + } + + // todo: delete after testing + if constexpr(MODULE_DEBUG >= 1) { + MessageOutput.printf("%s Resistor - Calculated: %0.3fOhm\r\n", getText(Text::T_HEAD).data(), resistor); + } + return true; + } + return false; +} + + +/* + * Prints the "Battery open circuit voltage" information block + */ +void BatteryGuardClass::printOpenCircuitVoltageInformationBlock(void) +{ + MessageOutput.printf("%s\r\n", getText(Text::T_HEAD).data()); + MessageOutput.printf("%s 1) Function: Battery open circuit voltage\r\n", + getText(Text::T_HEAD).data()); + + MessageOutput.printf("%s Actual voltage: %0.3fV, Avarage voltage: %0.3fV, Open circuit voltage: %0.3fV\r\n", + getText(Text::T_HEAD).data(), _battVoltage, _battVoltageAVG.getAverage(), _openCircuitVoltageAVG.getAverage()); + + auto oResistance = getInternalResistance(); + if (!oResistance.has_value()) { + MessageOutput.printf("%s Resistance neither calculated or configured\r\n", getText(Text::T_HEAD).data()); + } else { + auto resCalc = (_resistanceFromCalcAVG.getCounts() > 4) ? _resistanceFromCalcAVG.getAverage() * 1000.0f : 0.0f; + MessageOutput.printf("%s Resistance in use: %0.1fmOhm, (Calculated: %0.1fmOhm, Configured: %0.1fmOhm)\r\n", + getText(Text::T_HEAD).data(), oResistance.value() * 1000.0f, resCalc, _resistanceFromConfig * 1000.0f); + } + + MessageOutput.printf("%s Calculated resistance: %0.1fmOhm (Min: %0.1f, Max: %0.1f, Last: %0.1f, Amount: %i)\r\n", + getText(Text::T_HEAD).data(), _resistanceFromCalcAVG.getAverage()*1000.0f, _resistanceFromCalcAVG.getMin()*1000.0f, + _resistanceFromCalcAVG.getMax()*1000.0f, _resistanceFromCalcAVG.getLast()*1000.0f, _resistanceFromCalcAVG.getCounts()); + + MessageOutput.printf("%s Measurement period: %ims, Voltage and current not available counter: %i\r\n", + getText(Text::T_HEAD).data(), _battPeriod.getAverage(), _notAvailableCounter); +} + + +/* + * Returns a string according to current text number + */ +frozen::string const& BatteryGuardClass::getText(BatteryGuardClass::Text tNr) +{ + static const frozen::string missing = "programmer error: missing status text"; + + static const frozen::map texts = { + { Text::Q_NODATA, "Insufficient data" }, + { Text::Q_EXCELLENT, "Excellent" }, + { Text::Q_GOOD, "Good" }, + { Text::Q_BAD, "Bad" }, + { Text::T_HEAD, "[Battery-Guard]"} + }; + + auto iter = texts.find(tNr); + if (iter == texts.end()) { return missing; } + + return iter->second; +} diff --git a/src/BatteryStats.cpp b/src/BatteryStats.cpp index f4e383b29..843625823 100644 --- a/src/BatteryStats.cpp +++ b/src/BatteryStats.cpp @@ -7,6 +7,7 @@ #include "JkBmsDataPoints.h" #include "JbdBmsDataPoints.h" #include "MqttSettings.h" +#include "BatteryGuard.h" template static void addLiveViewInSection(JsonVariant& root, @@ -863,6 +864,10 @@ void VictronSmartShuntStats::updateFrom(VeDirectShuntController::data_t const& s _alarmLowTemperature = shuntData.alarmReason_AR & 32; _alarmHighTemperature = shuntData.alarmReason_AR & 64; + BatteryGuard.updateBatteryValues(shuntData.batteryVoltage_V_mV / 1000.0f, shuntData.batteryCurrent_I_mA / 1000.0f, millis()); //todo: why millis()? + _oBatteryResistor = BatteryGuard.getInternalResistance(); + _oOpenCircuitVoltage = BatteryGuard.getOpenCircuitVoltage(); + _lastUpdate = VeDirectShunt.getLastUpdate(); } @@ -881,6 +886,12 @@ void VictronSmartShuntStats::getLiveViewData(JsonVariant& root) const { if (_tempPresent) { addLiveViewValue(root, "temperature", _temperature, "°C", 0); } + if (_oBatteryResistor.has_value()) { + addLiveViewValue(root, "resistor", _oBatteryResistor.value() * 1000.0f, "mOhm", 1); + } + if (_oOpenCircuitVoltage.has_value()) { + addLiveViewValue(root, "openCircuitVoltage", _oOpenCircuitVoltage.value(), "V", 3); + } addLiveViewAlarm(root, "lowVoltage", _alarmLowVoltage); addLiveViewAlarm(root, "highVoltage", _alarmHighVoltage); diff --git a/src/PowerLimiter.cpp b/src/PowerLimiter.cpp index e3e7e461a..4cfcff232 100644 --- a/src/PowerLimiter.cpp +++ b/src/PowerLimiter.cpp @@ -17,6 +17,7 @@ #include #include #include "SunPosition.h" +#include "BatteryGuard.h" static auto sBatteryPoweredFilter = [](PowerLimiterInverter const& inv) { return !inv.isSolarPowered(); @@ -725,6 +726,13 @@ float PowerLimiterClass::getLoadCorrectedVoltage() { if (_oLoadCorrectedVoltage) { return *_oLoadCorrectedVoltage; } + // use "open circuit voltage" if available + auto oOpenCV = BatteryGuard.getOpenCircuitVoltage(); + if (oOpenCV) { + _oLoadCorrectedVoltage = *oOpenCV; + return *_oLoadCorrectedVoltage; + } + auto const& config = Configuration.get(); // TODO(schlimmchen): use the battery's data if available, diff --git a/src/main.cpp b/src/main.cpp index 9a4ac2506..e608b1c15 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -40,6 +40,7 @@ #include #include #include +#include "BatteryGuard.h" void setup() { @@ -197,6 +198,8 @@ void setup() } Battery.init(scheduler); + + BatteryGuard.init(scheduler); } void loop() diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 8159e4cd0..57cc51773 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -995,6 +995,8 @@ "capacity": "Gesamtkapazität", "availableCapacity": "Verfügbare Kapazität", "temperature": "Temperatur", + "resistor": "Innenwiderstand", + "openCircuitVoltage": "Leerlaufspannung", "bmsTemp": "BMS-Temperatur", "chargeVoltage": "Gewünschte Ladespannung (BMS)", "chargeCurrentLimitation": "Ladestromlimit", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index cad075339..412678f68 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -999,6 +999,8 @@ "capacity": "Total capacity", "availableCapacity": "Available capacity", "temperature": "Temperature", + "resistor": "Internal resistance", + "openCircuitVoltage": "Open circuit voltage", "bmsTemp": "BMS temperature", "chargeVoltage": "Requested charge voltage", "chargeCurrentLimitation": "Charge current limit", diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index 5dc795908..c43d6fe0c 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -948,6 +948,8 @@ "current": "Current", "power": "Power", "temperature": "Temperature", + "resistor": "Internal resistance", + "openCircuitVoltage": "Open circuit voltage", "bmsTemp": "BMS temperature", "chargeVoltage": "Requested charge voltage", "chargeCurrentLimitation": "Charge current limit",