OpenDTU-old/src/PowerLimiter.cpp
Bernhard Kirchen 74330a5617 Feature: restart unresponsive inverter
we found that the inverter sometimes stops responding to commands,
especially to the "start producing" command. we now count the number of
consecutive timeouts when trying to send a new limit or power state
commands. after two timeouts were recorded, every additional timeout
will send a restart command to the inverter.

as a last resort, if the counter keeps climbing, the DTU is restarted.

notice that this only targets unresponsive inverters which are
reachable. unreachable inverters are not restarted and do not cause a
DTU reboot. this is important for solar-driven inverters, which are
unreachable during the night. the DPL will not calculate a new limit and
hence the updateInverter() method will do nothing while the target
inverter is unreachable.

publish the timeout counter to MQTT for monitoring purposes.
2024-04-24 16:15:01 +02:00

957 lines
38 KiB
C++

// SPDX-License-Identifier: GPL-2.0-or-later
/*
* Copyright (C) 2022 Thomas Basler and others
*/
#include "Utils.h"
#include "Battery.h"
#include "PowerMeter.h"
#include "PowerLimiter.h"
#include "Configuration.h"
#include "MqttSettings.h"
#include "NetworkSettings.h"
#include "Huawei_can.h"
#include <VictronMppt.h>
#include "MessageOutput.h"
#include "inverters/HMS_4CH.h"
#include <ctime>
#include <cmath>
#include <frozen/map.h>
PowerLimiterClass PowerLimiter;
void PowerLimiterClass::init(Scheduler& scheduler)
{
scheduler.addTask(_loopTask);
_loopTask.setCallback(std::bind(&PowerLimiterClass::loop, this));
_loopTask.setIterations(TASK_FOREVER);
_loopTask.enable();
}
frozen::string const& PowerLimiterClass::getStatusText(PowerLimiterClass::Status status)
{
static const frozen::string missing = "programmer error: missing status text";
static const frozen::map<Status, frozen::string, 19> texts = {
{ Status::Initializing, "initializing (should not see me)" },
{ Status::DisabledByConfig, "disabled by configuration" },
{ Status::DisabledByMqtt, "disabled by MQTT" },
{ Status::WaitingForValidTimestamp, "waiting for valid date and time to be available" },
{ Status::PowerMeterPending, "waiting for sufficiently recent power meter reading" },
{ Status::InverterInvalid, "invalid inverter selection/configuration" },
{ Status::InverterChanged, "target inverter changed" },
{ Status::InverterOffline, "inverter is offline (polling enabled? radio okay?)" },
{ Status::InverterCommandsDisabled, "inverter configuration prohibits sending commands" },
{ Status::InverterLimitPending, "waiting for a power limit command to complete" },
{ Status::InverterPowerCmdPending, "waiting for a start/stop/restart command to complete" },
{ Status::InverterDevInfoPending, "waiting for inverter device information to be available" },
{ Status::InverterStatsPending, "waiting for sufficiently recent inverter data" },
{ Status::CalculatedLimitBelowMinLimit, "calculated limit is less than minimum power limit" },
{ Status::UnconditionalSolarPassthrough, "unconditionally passing through all solar power (MQTT override)" },
{ Status::NoVeDirect, "VE.Direct disabled, connection broken, or data outdated" },
{ Status::NoEnergy, "no energy source available to power the inverter from" },
{ Status::HuaweiPsu, "DPL stands by while Huawei PSU is enabled/charging" },
{ Status::Stable, "the system is stable, the last power limit is still valid" },
};
auto iter = texts.find(status);
if (iter == texts.end()) { return missing; }
return iter->second;
}
void PowerLimiterClass::announceStatus(PowerLimiterClass::Status status)
{
// this method is called with high frequency. print the status text if
// the status changed since we last printed the text of another one.
// otherwise repeat the info with a fixed interval.
if (_lastStatus == status && millis() < _lastStatusPrinted + 10 * 1000) { return; }
// after announcing once that the DPL is disabled by configuration, it
// should just be silent while it is disabled.
if (status == Status::DisabledByConfig && _lastStatus == status) { return; }
MessageOutput.printf("[DPL::announceStatus] %s\r\n",
getStatusText(status).data());
_lastStatus = status;
_lastStatusPrinted = millis();
}
/**
* returns true if the inverter state was changed or is about to change, i.e.,
* if it is actually in need of a shutdown. returns false otherwise, i.e., the
* inverter is already shut down.
*/
bool PowerLimiterClass::shutdown(PowerLimiterClass::Status status)
{
announceStatus(status);
_shutdownPending = true;
_oTargetPowerState = false;
return updateInverter();
}
void PowerLimiterClass::loop()
{
CONFIG_T const& config = Configuration.get();
_verboseLogging = config.PowerLimiter.VerboseLogging;
// we know that the Hoymiles library refuses to send any message to any
// inverter until the system has valid time information. until then we can
// do nothing, not even shutdown the inverter.
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 5)) {
return announceStatus(Status::WaitingForValidTimestamp);
}
// take care that the last requested power
// limit and power state are actually reached
if (updateInverter()) { return; }
if (_shutdownPending) {
_shutdownPending = false;
_inverter = nullptr;
}
if (!config.PowerLimiter.Enabled) {
shutdown(Status::DisabledByConfig);
return;
}
if (Mode::Disabled == _mode) {
shutdown(Status::DisabledByMqtt);
return;
}
std::shared_ptr<InverterAbstract> currentInverter =
Hoymiles.getInverterBySerial(config.PowerLimiter.InverterId);
if (currentInverter == nullptr && config.PowerLimiter.InverterId < INV_MAX_COUNT) {
// we previously had an index saved as InverterId. fall back to the
// respective positional lookup if InverterId is not a known serial.
currentInverter = Hoymiles.getInverterByPos(config.PowerLimiter.InverterId);
}
// in case of (newly) broken configuration, shut down
// the last inverter we worked with (if any)
if (currentInverter == nullptr) {
shutdown(Status::InverterInvalid);
return;
}
// if the DPL is supposed to manage another inverter now, we first
// shut down the previous one, if any. then we pick up the new one.
if (_inverter != nullptr && _inverter->serial() != currentInverter->serial()) {
shutdown(Status::InverterChanged);
return;
}
// update our pointer as the configuration might have changed
_inverter = currentInverter;
// data polling is disabled or the inverter is deemed offline
if (!_inverter->isReachable()) {
return announceStatus(Status::InverterOffline);
}
// sending commands to the inverter is disabled
if (!_inverter->getEnableCommands()) {
return announceStatus(Status::InverterCommandsDisabled);
}
// a calculated power limit will always be limited to the reported
// device's max power. that upper limit is only known after the first
// DevInfoSimpleCommand succeeded.
if (_inverter->DevInfo()->getMaxPower() <= 0) {
return announceStatus(Status::InverterDevInfoPending);
}
if (Mode::UnconditionalFullSolarPassthrough == _mode) {
// handle this mode of operation separately
return unconditionalSolarPassthrough(_inverter);
}
// concerns both power limits and start/stop/restart commands and is
// only updated if a respective response was received from the inverter
auto lastUpdateCmd = std::max(
_inverter->SystemConfigPara()->getLastUpdateCommand(),
_inverter->PowerCommand()->getLastUpdateCommand());
// we need inverter stats younger than the last update command
if (_oInverterStatsMillis.has_value() && lastUpdateCmd > *_oInverterStatsMillis) {
_oInverterStatsMillis = std::nullopt;
}
if (!_oInverterStatsMillis.has_value()) {
auto lastStats = _inverter->Statistics()->getLastUpdate();
if (lastStats <= lastUpdateCmd) {
return announceStatus(Status::InverterStatsPending);
}
_oInverterStatsMillis = lastStats;
}
// if the power meter is being used, i.e., if its data is valid, we want to
// wait for a new reading after adjusting the inverter limit. otherwise, we
// proceed as we will use a fallback limit independent of the power meter.
// the power meter reading is expected to be at most 2 seconds old when it
// arrives. this can be the case for readings provided by networked meter
// readers, where a packet needs to travel through the network for some
// time after the actual measurement was done by the reader.
if (PowerMeter.isDataValid() && PowerMeter.getLastPowerMeterUpdate() <= (*_oInverterStatsMillis + 2000)) {
return announceStatus(Status::PowerMeterPending);
}
// since _lastCalculation and _calculationBackoffMs are initialized to
// zero, this test is passed the first time the condition is checked.
if (millis() < (_lastCalculation + _calculationBackoffMs)) {
return announceStatus(Status::Stable);
}
if (_verboseLogging) {
MessageOutput.println("[DPL::loop] ******************* ENTER **********************");
}
// Check if next inverter restart time is reached
if ((_nextInverterRestart > 1) && (_nextInverterRestart <= millis())) {
MessageOutput.println("[DPL::loop] send inverter restart");
_inverter->sendRestartControlRequest();
calcNextInverterRestart();
}
// Check if NTP time is set and next inverter restart not calculated yet
if ((config.PowerLimiter.RestartHour >= 0) && (_nextInverterRestart == 0) ) {
// check every 5 seconds
if (_nextCalculateCheck < millis()) {
struct tm timeinfo;
if (getLocalTime(&timeinfo, 5)) {
calcNextInverterRestart();
} else {
MessageOutput.println("[DPL::loop] inverter restart calculation: NTP not ready");
_nextCalculateCheck += 5000;
}
}
}
auto getBatteryPower = [this,&config]() -> bool {
if (config.PowerLimiter.IsInverterSolarPowered) { return false; }
if (isStopThresholdReached()) { return false; }
if (isStartThresholdReached()) { return true; }
// with solar passthrough, and the respective switch enabled, we
// may start discharging the battery when it is nighttime. we also
// stop the discharge cycle if it becomes daytime again.
// TODO(schlimmchen): should be supported by sunrise and sunset, such
// that a thunderstorm or other events that drastically lower the solar
// power do not cause the start of a discharge cycle during the day.
if (config.PowerLimiter.SolarPassThroughEnabled &&
config.PowerLimiter.BatteryAlwaysUseAtNight) {
return getSolarPower() == 0;
}
// we are between start and stop threshold and keep the state that was
// last triggered, either charging or discharging.
return _batteryDischargeEnabled;
};
_batteryDischargeEnabled = getBatteryPower();
if (_verboseLogging && !config.PowerLimiter.IsInverterSolarPowered) {
MessageOutput.printf("[DPL::loop] battery interface %s, SoC: %d %%, StartTH: %d %%, StopTH: %d %%, SoC age: %d s, ignore: %s\r\n",
(config.Battery.Enabled?"enabled":"disabled"),
Battery.getStats()->getSoC(),
config.PowerLimiter.BatterySocStartThreshold,
config.PowerLimiter.BatterySocStopThreshold,
Battery.getStats()->getSoCAgeSeconds(),
(config.PowerLimiter.IgnoreSoc?"yes":"no"));
auto dcVoltage = getBatteryVoltage(true/*log voltages only once per DPL loop*/);
MessageOutput.printf("[DPL::loop] dcVoltage: %.2f V, loadCorrectedVoltage: %.2f V, StartTH: %.2f V, StopTH: %.2f V\r\n",
dcVoltage, getLoadCorrectedVoltage(),
config.PowerLimiter.VoltageStartThreshold,
config.PowerLimiter.VoltageStopThreshold);
MessageOutput.printf("[DPL::loop] StartTH reached: %s, StopTH reached: %s, SolarPT %sabled, use at night: %s\r\n",
(isStartThresholdReached()?"yes":"no"),
(isStopThresholdReached()?"yes":"no"),
(config.PowerLimiter.SolarPassThroughEnabled?"en":"dis"),
(config.PowerLimiter.BatteryAlwaysUseAtNight?"yes":"no"));
};
// Calculate and set Power Limit (NOTE: might reset _inverter to nullptr!)
bool limitUpdated = calcPowerLimit(_inverter, getSolarPower(), _batteryDischargeEnabled);
_lastCalculation = millis();
if (!limitUpdated) {
// increase polling backoff if system seems to be stable
_calculationBackoffMs = std::min<uint32_t>(1024, _calculationBackoffMs * 2);
return announceStatus(Status::Stable);
}
_calculationBackoffMs = _calculationBackoffMsDefault;
}
/**
* determines the battery's voltage, trying multiple data providers. the most
* accurate data is expected to be delivered by a BMS, if it's available. more
* accurate and more recent than the inverter's voltage reading is the volage
* at the charge controller's output, if it's available. only as a fallback
* the voltage reported by the inverter is used.
*/
float PowerLimiterClass::getBatteryVoltage(bool log) {
if (!_inverter) {
// there should be no need to call this method if no target inverter is known
MessageOutput.println("[DPL::getBatteryVoltage] no inverter (programmer error)");
return 0.0;
}
auto const& config = Configuration.get();
auto channel = static_cast<ChannelNum_t>(config.PowerLimiter.InverterChannelId);
float inverterVoltage = _inverter->Statistics()->getChannelFieldValue(TYPE_DC, channel, FLD_UDC);
float res = inverterVoltage;
float chargeControllerVoltage = -1;
if (VictronMppt.isDataValid()) {
res = chargeControllerVoltage = static_cast<float>(VictronMppt.getOutputVoltage());
}
float bmsVoltage = -1;
auto stats = Battery.getStats();
if (config.Battery.Enabled
&& stats->isVoltageValid()
&& stats->getVoltageAgeSeconds() < 60) {
res = bmsVoltage = stats->getVoltage();
}
if (log) {
MessageOutput.printf("[DPL::getBatteryVoltage] BMS: %.2f V, MPPT: %.2f V, inverter: %.2f V, returning: %.2fV\r\n",
bmsVoltage, chargeControllerVoltage, inverterVoltage, res);
}
return res;
}
/**
* calculate the AC output power (limit) to set, such that the inverter uses
* the given power on its DC side, i.e., adjust the power for the inverter's
* efficiency.
*/
int32_t PowerLimiterClass::inverterPowerDcToAc(std::shared_ptr<InverterAbstract> inverter, int32_t dcPower)
{
CONFIG_T& config = Configuration.get();
float inverterEfficiencyPercent = inverter->Statistics()->getChannelFieldValue(
TYPE_INV, CH0, FLD_EFF);
// fall back to hoymiles peak efficiency as per datasheet if inverter
// is currently not producing (efficiency is zero in that case)
float inverterEfficiencyFactor = (inverterEfficiencyPercent > 0) ? inverterEfficiencyPercent/100 : 0.967;
// account for losses between solar charger and inverter (cables, junctions...)
float lossesFactor = 1.00 - static_cast<float>(config.PowerLimiter.SolarPassThroughLosses)/100;
return dcPower * inverterEfficiencyFactor * lossesFactor;
}
/**
* implements the "unconditional solar passthrough" mode of operation, which
* can currently only be set using MQTT. in this mode of operation, the
* inverter shall behave as if it was connected to the solar panels directly,
* i.e., all solar power (and only solar power) is fed to the AC side,
* independent from the power meter reading. if the inverter is actually
* already connected to solar modules rather than a battery, the upper power
* limit is set as the inverter limit.
*/
void PowerLimiterClass::unconditionalSolarPassthrough(std::shared_ptr<InverterAbstract> inverter)
{
if ((millis() - _lastCalculation) < _calculationBackoffMs) { return; }
_lastCalculation = millis();
auto const& config = Configuration.get();
if (config.PowerLimiter.IsInverterSolarPowered) {
_calculationBackoffMs = 10 * 1000;
setNewPowerLimit(inverter, config.PowerLimiter.UpperPowerLimit);
announceStatus(Status::UnconditionalSolarPassthrough);
return;
}
if (!VictronMppt.isDataValid()) {
shutdown(Status::NoVeDirect);
return;
}
_calculationBackoffMs = 1 * 1000;
int32_t solarPower = VictronMppt.getPowerOutputWatts();
setNewPowerLimit(inverter, inverterPowerDcToAc(inverter, solarPower));
announceStatus(Status::UnconditionalSolarPassthrough);
}
uint8_t PowerLimiterClass::getPowerLimiterState() {
if (_inverter == nullptr || !_inverter->isReachable()) {
return PL_UI_STATE_INACTIVE;
}
if (_inverter->isProducing() && _batteryDischargeEnabled) {
return PL_UI_STATE_USE_SOLAR_AND_BATTERY;
}
if (_inverter->isProducing() && !_batteryDischargeEnabled) {
return PL_UI_STATE_USE_SOLAR_ONLY;
}
if(!_inverter->isProducing()) {
return PL_UI_STATE_CHARGING;
}
return PL_UI_STATE_INACTIVE;
}
// Logic table ("PowerMeter value" can be "base load setting" as a fallback)
// | Case # | batteryPower | solarPower | useFullSolarPassthrough | Resulting inverter limit |
// | 1 | false | < 20 W | doesn't matter | 0 (inverter off) |
// | 2 | false | >= 20 W | doesn't matter | min(PowerMeter value, solarPower) |
// | 3 | true | doesn't matter | false | PowerMeter value (Battery can supply unlimited energy) |
// | 4 | true | fully passed | true | max(PowerMeter value, solarPower) |
bool PowerLimiterClass::calcPowerLimit(std::shared_ptr<InverterAbstract> inverter, int32_t solarPowerDC, bool batteryPower)
{
if (_verboseLogging) {
MessageOutput.printf("[DPL::calcPowerLimit] battery use %s, solar power (DC): %d W\r\n",
(batteryPower?"allowed":"prevented"), solarPowerDC);
}
// Case 1:
if (solarPowerDC <= 0 && !batteryPower) {
return shutdown(Status::NoEnergy);
}
// We check if the PSU is on and disable the Power Limiter in this case.
// The PSU should reduce power or shut down first before the Power Limiter
// kicks in. The only case where this is not desired is if the battery is
// over the Full Solar Passthrough Threshold. In this case the Power
// Limiter should run and the PSU will shut down as a consequence.
if (!useFullSolarPassthrough() && HuaweiCan.getAutoPowerStatus()) {
return shutdown(Status::HuaweiPsu);
}
auto meterValid = PowerMeter.isDataValid();
auto meterValue = static_cast<int32_t>(PowerMeter.getPowerTotal());
// We don't use FLD_PAC from the statistics, because that data might be too
// old and unreliable. TODO(schlimmchen): is this comment outdated?
auto inverterOutput = static_cast<int32_t>(inverter->Statistics()->getChannelFieldValue(TYPE_AC, CH0, FLD_PAC));
auto solarPowerAC = inverterPowerDcToAc(inverter, solarPowerDC);
auto const& config = Configuration.get();
auto targetConsumption = config.PowerLimiter.TargetPowerConsumption;
auto baseLoad = config.PowerLimiter.BaseLoadLimit;
bool meterIncludesInv = config.PowerLimiter.IsInverterBehindPowerMeter;
if (_verboseLogging) {
MessageOutput.printf("[DPL::calcPowerLimit] target consumption: %d W, "
"base load: %d W, power meter does %sinclude inverter output\r\n",
targetConsumption,
baseLoad,
(meterIncludesInv?"":"NOT "));
MessageOutput.printf("[DPL::calcPowerLimit] power meter value: %d W, "
"power meter valid: %s, inverter output: %d W, solar power (AC): %d W\r\n",
meterValue,
(meterValid?"yes":"no"),
inverterOutput,
solarPowerAC);
}
auto newPowerLimit = baseLoad;
if (meterValid) {
newPowerLimit = meterValue;
if (meterIncludesInv) {
// If the inverter is wired behind the power meter, i.e., if its
// output is part of the power meter measurement, the produced
// power of this inverter has to be taken into account.
newPowerLimit += inverterOutput;
}
newPowerLimit -= targetConsumption;
}
// Case 2:
if (!batteryPower) {
newPowerLimit = std::min(newPowerLimit, solarPowerAC);
// do not drain the battery. use as much power as needed to match the
// household consumption, but not more than the available solar power.
if (_verboseLogging) {
MessageOutput.printf("[DPL::calcPowerLimit] limited to solar power: %d W\r\n",
newPowerLimit);
}
return setNewPowerLimit(inverter, newPowerLimit);
}
// Case 4:
// convert all solar power if full solar-passthrough is active
if (useFullSolarPassthrough()) {
newPowerLimit = std::max(newPowerLimit, solarPowerAC);
if (_verboseLogging) {
MessageOutput.printf("[DPL::calcPowerLimit] full solar-passthrough active: %d W\r\n",
newPowerLimit);
}
return setNewPowerLimit(inverter, newPowerLimit);
}
if (_verboseLogging) {
MessageOutput.printf("[DPL::calcPowerLimit] match household consumption with limit of %d W\r\n",
newPowerLimit);
}
// Case 3:
return setNewPowerLimit(inverter, newPowerLimit);
}
/**
* updates the inverter state (power production and limit). returns true if a
* change to its state was requested or is pending. this function only requests
* one change (limit value or production on/off) at a time.
*/
bool PowerLimiterClass::updateInverter()
{
auto reset = [this]() -> bool {
_oTargetPowerState = std::nullopt;
_oTargetPowerLimitWatts = std::nullopt;
_oUpdateStartMillis = std::nullopt;
return false;
};
if (nullptr == _inverter) { return reset(); }
// do not reset _inverterUpdateTimeouts below if no state change requested
if (!_oTargetPowerState.has_value() && !_oTargetPowerLimitWatts.has_value()) {
return reset();
}
if (!_oUpdateStartMillis.has_value()) {
_oUpdateStartMillis = millis();
}
if ((millis() - *_oUpdateStartMillis) > 30 * 1000) {
++_inverterUpdateTimeouts;
MessageOutput.printf("[DPL::updateInverter] timeout (%d in succession), "
"state transition pending: %s, limit pending: %s\r\n",
_inverterUpdateTimeouts,
(_oTargetPowerState.has_value()?"yes":"no"),
(_oTargetPowerLimitWatts.has_value()?"yes":"no"));
// NOTE that this is not always 5 minutes, since this counts timeouts,
// not absolute time. after any timeout, an update cycle ends. a new
// timeout can only happen after starting a new update cycle, which in
// turn is only started if the DPL did calculate a new limit, which in
// turn does not happen while the inverter is unreachable, no matter
// how long (a whole night) that might be.
if (_inverterUpdateTimeouts >= 10) {
MessageOutput.println("[DPL::loop] issuing inverter restart command after update timed out repeatedly");
_inverter->sendRestartControlRequest();
}
if (_inverterUpdateTimeouts >= 20) {
MessageOutput.println("[DPL::loop] restarting system since inverter is unresponsive");
Utils::restartDtu();
}
return reset();
}
auto constexpr halfOfAllMillis = std::numeric_limits<uint32_t>::max() / 2;
auto switchPowerState = [this](bool transitionOn) -> bool {
// no power state transition requested at all
if (!_oTargetPowerState.has_value()) { return false; }
// the transition that may be started is not the one which is requested
if (transitionOn != *_oTargetPowerState) { return false; }
// wait for pending power command(s) to complete
auto lastPowerCommandState = _inverter->PowerCommand()->getLastPowerCommandSuccess();
if (CMD_PENDING == lastPowerCommandState) {
announceStatus(Status::InverterPowerCmdPending);
return true;
}
// we need to wait for statistics that are more recent than the last
// power update command to reliably use _inverter->isProducing()
auto lastPowerCommandMillis = _inverter->PowerCommand()->getLastUpdateCommand();
auto lastStatisticsMillis = _inverter->Statistics()->getLastUpdate();
if ((lastStatisticsMillis - lastPowerCommandMillis) > halfOfAllMillis) { return true; }
if (_inverter->isProducing() != *_oTargetPowerState) {
MessageOutput.printf("[DPL::updateInverter] %s inverter...\r\n",
((*_oTargetPowerState)?"Starting":"Stopping"));
_inverter->sendPowerControlRequest(*_oTargetPowerState);
return true;
}
_oTargetPowerState = std::nullopt; // target power state reached
return false;
};
// we use a lambda function here to be able to use return statements,
// which allows to avoid if-else-indentions and improves code readability
auto updateLimit = [this]() -> bool {
// no limit update requested at all
if (!_oTargetPowerLimitWatts.has_value()) { return false; }
// wait for pending limit command(s) to complete
auto lastLimitCommandState = _inverter->SystemConfigPara()->getLastLimitCommandSuccess();
if (CMD_PENDING == lastLimitCommandState) {
announceStatus(Status::InverterLimitPending);
return true;
}
auto maxPower = _inverter->DevInfo()->getMaxPower();
auto newRelativeLimit = static_cast<float>(*_oTargetPowerLimitWatts * 100) / maxPower;
// if no limit command is pending, the SystemConfigPara does report the
// current limit, as the answer by the inverter to a limit command is
// the canonical source that updates the known current limit.
auto currentRelativeLimit = _inverter->SystemConfigPara()->getLimitPercent();
// we assume having exclusive control over the inverter. if the last
// limit command was successful and sent after we started the last
// update cycle, we should assume *our* requested limit was set.
uint32_t lastLimitCommandMillis = _inverter->SystemConfigPara()->getLastUpdateCommand();
if ((lastLimitCommandMillis - *_oUpdateStartMillis) < halfOfAllMillis &&
CMD_OK == lastLimitCommandState) {
MessageOutput.printf("[DPL::updateInverter] actual limit is %.1f %% "
"(%.0f W respectively), effective %d ms after update started, "
"requested were %.1f %%\r\n",
currentRelativeLimit,
(currentRelativeLimit * maxPower / 100),
(lastLimitCommandMillis - *_oUpdateStartMillis),
newRelativeLimit);
if (std::abs(newRelativeLimit - currentRelativeLimit) > 2.0) {
MessageOutput.printf("[DPL::updateInverter] NOTE: expected limit of %.1f %% "
"and actual limit of %.1f %% mismatch by more than 2 %%, "
"is the DPL in exclusive control over the inverter?\r\n",
newRelativeLimit, currentRelativeLimit);
}
_oTargetPowerLimitWatts = std::nullopt;
return false;
}
MessageOutput.printf("[DPL::updateInverter] sending limit of %.1f %% "
"(%.0f W respectively), max output is %d W\r\n",
newRelativeLimit, (newRelativeLimit * maxPower / 100), maxPower);
_inverter->sendActivePowerControlRequest(static_cast<float>(newRelativeLimit),
PowerLimitControlType::RelativNonPersistent);
_lastRequestedPowerLimit = *_oTargetPowerLimitWatts;
return true;
};
// disable power production as soon as possible.
// setting the power limit is less important once the inverter is off.
if (switchPowerState(false)) { return true; }
if (updateLimit()) { return true; }
// enable power production only after setting the desired limit
if (switchPowerState(true)) { return true; }
_inverterUpdateTimeouts = 0;
return reset();
}
/**
* scale the desired inverter limit such that the actual inverter AC output is
* close to the desired power limit, even if some input channels are producing
* less than the limit allows. this happens because the inverter seems to split
* the total power limit equally among all MPPTs (not inputs; some inputs share
* the same MPPT on some models).
*
* TODO(schlimmchen): the current implementation is broken and is in need of
* refactoring. currently it only works for inverters that provide one MPPT for
* each input. it also does not work as expected if any input produces *some*
* energy, but is limited by its respective solar input.
*/
static int32_t scalePowerLimit(std::shared_ptr<InverterAbstract> inverter, int32_t newLimit, int32_t currentLimitWatts)
{
// prevent scaling if inverter is not producing, as input channels are not
// producing energy and hence are detected as not-producing, causing
// unreasonable scaling.
if (!inverter->isProducing()) { return newLimit; }
std::list<ChannelNum_t> dcChnls = inverter->Statistics()->getChannelsByType(TYPE_DC);
size_t dcTotalChnls = dcChnls.size();
// according to the upstream projects README (table with supported devs),
// every 2 channel inverter has 2 MPPTs. then there are the HM*S* 4 channel
// models which have 4 MPPTs. all others have a different number of MPPTs
// than inputs. those are not supported by the current scaling mechanism.
bool supported = dcTotalChnls == 2;
supported |= dcTotalChnls == 4 && HMS_4CH::isValidSerial(inverter->serial());
if (!supported) { return newLimit; }
// test for a reasonable power limit that allows us to assume that an input
// channel with little energy is actually not producing, rather than
// producing very little due to the very low limit.
if (currentLimitWatts < dcTotalChnls * 10) { return newLimit; }
size_t dcProdChnls = 0;
for (auto& c : dcChnls) {
if (inverter->Statistics()->getChannelFieldValue(TYPE_DC, c, FLD_PDC) > 2.0) {
dcProdChnls++;
}
}
if (dcProdChnls == 0 || dcProdChnls == dcTotalChnls) { return newLimit; }
auto scaled = static_cast<int32_t>(newLimit * static_cast<float>(dcTotalChnls) / dcProdChnls);
MessageOutput.printf("[DPL::scalePowerLimit] %d/%d channels are producing, "
"scaling from %d to %d W\r\n", dcProdChnls, dcTotalChnls, newLimit, scaled);
return scaled;
}
/**
* enforces limits on the requested power limit, after scaling the power limit
* to the ratio of total and producing inverter channels. commits the sanitized
* power limit. returns true if an inverter update was committed, false
* otherwise.
*/
bool PowerLimiterClass::setNewPowerLimit(std::shared_ptr<InverterAbstract> inverter, int32_t newPowerLimit)
{
auto const& config = Configuration.get();
auto lowerLimit = config.PowerLimiter.LowerPowerLimit;
auto upperLimit = config.PowerLimiter.UpperPowerLimit;
auto hysteresis = config.PowerLimiter.TargetPowerConsumptionHysteresis;
if (_verboseLogging) {
MessageOutput.printf("[DPL::setNewPowerLimit] input limit: %d W, "
"min limit: %d W, max limit: %d W, hysteresis: %d W\r\n",
newPowerLimit, lowerLimit, upperLimit, hysteresis);
}
if (newPowerLimit < lowerLimit) {
if (!config.PowerLimiter.IsInverterSolarPowered) {
return shutdown(Status::CalculatedLimitBelowMinLimit);
}
MessageOutput.println("[DPL::setNewPowerLimit] keep solar-powered "
"inverter running at min limit");
newPowerLimit = lowerLimit;
}
// enforce configured upper power limit
int32_t effPowerLimit = std::min(newPowerLimit, upperLimit);
// early in the loop we make it a pre-requisite that this
// value is non-zero, so we can assume it to be valid.
auto maxPower = inverter->DevInfo()->getMaxPower();
float currentLimitPercent = inverter->SystemConfigPara()->getLimitPercent();
auto currentLimitAbs = static_cast<int32_t>(currentLimitPercent * maxPower / 100);
effPowerLimit = scalePowerLimit(inverter, effPowerLimit, currentLimitAbs);
effPowerLimit = std::min<int32_t>(effPowerLimit, maxPower);
auto diff = std::abs(currentLimitAbs - effPowerLimit);
if (_verboseLogging) {
MessageOutput.printf("[DPL::setNewPowerLimit] inverter max: %d W, "
"inverter %s producing, requesting: %d W, reported: %d W, "
"diff: %d W\r\n", maxPower, (inverter->isProducing()?"is":"is NOT"),
effPowerLimit, currentLimitAbs, diff);
}
if (diff > hysteresis) {
_oTargetPowerLimitWatts = effPowerLimit;
}
_oTargetPowerState = true;
return updateInverter();
}
int32_t PowerLimiterClass::getSolarPower()
{
auto const& config = Configuration.get();
if (config.PowerLimiter.IsInverterSolarPowered) {
// the returned value is arbitrary, as long as it's
// greater than the inverters max DC power consumption.
return 10 * 1000;
}
if (!config.PowerLimiter.SolarPassThroughEnabled
|| isBelowStopThreshold()
|| !VictronMppt.isDataValid()) {
return 0;
}
auto solarPower = VictronMppt.getPowerOutputWatts();
if (solarPower < 20) { return 0; } // too little to work with
return solarPower;
}
float PowerLimiterClass::getLoadCorrectedVoltage()
{
if (!_inverter) {
// there should be no need to call this method if no target inverter is known
MessageOutput.println("[DPL::getLoadCorrectedVoltage] no inverter (programmer error)");
return 0.0;
}
CONFIG_T& config = Configuration.get();
float acPower = _inverter->Statistics()->getChannelFieldValue(TYPE_AC, CH0, FLD_PAC);
float dcVoltage = getBatteryVoltage();
if (dcVoltage <= 0.0) {
return 0.0;
}
return dcVoltage + (acPower * config.PowerLimiter.VoltageLoadCorrectionFactor);
}
bool PowerLimiterClass::testThreshold(float socThreshold, float voltThreshold,
std::function<bool(float, float)> compare)
{
CONFIG_T& config = Configuration.get();
// prefer SoC provided through battery interface, unless disabled by user
auto stats = Battery.getStats();
if (!config.PowerLimiter.IgnoreSoc
&& config.Battery.Enabled
&& socThreshold > 0.0
&& stats->isSoCValid()
&& stats->getSoCAgeSeconds() < 60) {
return compare(stats->getSoC(), socThreshold);
}
// use voltage threshold as fallback
if (voltThreshold <= 0.0) { return false; }
return compare(getLoadCorrectedVoltage(), voltThreshold);
}
bool PowerLimiterClass::isStartThresholdReached()
{
CONFIG_T& config = Configuration.get();
return testThreshold(
config.PowerLimiter.BatterySocStartThreshold,
config.PowerLimiter.VoltageStartThreshold,
[](float a, float b) -> bool { return a >= b; }
);
}
bool PowerLimiterClass::isStopThresholdReached()
{
CONFIG_T& config = Configuration.get();
return testThreshold(
config.PowerLimiter.BatterySocStopThreshold,
config.PowerLimiter.VoltageStopThreshold,
[](float a, float b) -> bool { return a <= b; }
);
}
bool PowerLimiterClass::isBelowStopThreshold()
{
CONFIG_T& config = Configuration.get();
return testThreshold(
config.PowerLimiter.BatterySocStopThreshold,
config.PowerLimiter.VoltageStopThreshold,
[](float a, float b) -> bool { return a < b; }
);
}
/// @brief calculate next inverter restart in millis
void PowerLimiterClass::calcNextInverterRestart()
{
CONFIG_T& config = Configuration.get();
// first check if restart is configured at all
if (config.PowerLimiter.RestartHour < 0) {
_nextInverterRestart = 1;
MessageOutput.println("[DPL::calcNextInverterRestart] _nextInverterRestart disabled");
return;
}
if (config.PowerLimiter.IsInverterSolarPowered) {
_nextInverterRestart = 1;
MessageOutput.println("[DPL::calcNextInverterRestart] not restarting solar-powered inverters");
return;
}
// read time from timeserver, if time is not synced then return
struct tm timeinfo;
if (getLocalTime(&timeinfo, 5)) {
// calculation first step is offset to next restart in minutes
uint16_t dayMinutes = timeinfo.tm_hour * 60 + timeinfo.tm_min;
uint16_t targetMinutes = config.PowerLimiter.RestartHour * 60;
if (config.PowerLimiter.RestartHour > timeinfo.tm_hour) {
// next restart is on the same day
_nextInverterRestart = targetMinutes - dayMinutes;
} else {
// next restart is on next day
_nextInverterRestart = 1440 - dayMinutes + targetMinutes;
}
if (_verboseLogging) {
MessageOutput.printf("[DPL::calcNextInverterRestart] Localtime read %d %d / configured RestartHour %d\r\n", timeinfo.tm_hour, timeinfo.tm_min, config.PowerLimiter.RestartHour);
MessageOutput.printf("[DPL::calcNextInverterRestart] dayMinutes %d / targetMinutes %d\r\n", dayMinutes, targetMinutes);
MessageOutput.printf("[DPL::calcNextInverterRestart] next inverter restart in %d minutes\r\n", _nextInverterRestart);
}
// then convert unit for next restart to milliseconds and add current uptime millis()
_nextInverterRestart *= 60000;
_nextInverterRestart += millis();
} else {
MessageOutput.println("[DPL::calcNextInverterRestart] getLocalTime not successful, no calculation");
_nextInverterRestart = 0;
}
MessageOutput.printf("[DPL::calcNextInverterRestart] _nextInverterRestart @ %d millis\r\n", _nextInverterRestart);
}
bool PowerLimiterClass::useFullSolarPassthrough()
{
auto const& config = Configuration.get();
// solar passthrough only applies to setups with battery-powered inverters
if (config.PowerLimiter.IsInverterSolarPowered) { return false; }
// We only do full solar PT if general solar PT is enabled
if(!config.PowerLimiter.SolarPassThroughEnabled) { return false; }
if (testThreshold(config.PowerLimiter.FullSolarPassThroughSoc,
config.PowerLimiter.FullSolarPassThroughStartVoltage,
[](float a, float b) -> bool { return a >= b; })) {
_fullSolarPassThroughEnabled = true;
}
if (testThreshold(config.PowerLimiter.FullSolarPassThroughSoc,
config.PowerLimiter.FullSolarPassThroughStopVoltage,
[](float a, float b) -> bool { return a < b; })) {
_fullSolarPassThroughEnabled = false;
}
return _fullSolarPassThroughEnabled;
}