Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions include/Configuration.h
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,14 @@ struct POWERLIMITER_CONFIG_T {
uint8_t InverterChannelIdForDcVoltage;
uint8_t RestartHour;
uint16_t TotalUpperPowerLimit;
bool SurplusEnabled;
bool SurplusTweakEnabled;
uint16_t SurplusPowerLimit;
int16_t SurplusSafetyMinutes;
int16_t SurplusSafetyFactor;
bool SurplusSlopeEnabled;
int16_t SurplusSlopeTarget;
int16_t SurplusSlopeDecreaseRate;
PowerLimiterInverterConfig Inverters[INV_MAX_COUNT];
};
using PowerLimiterConfig = struct POWERLIMITER_CONFIG_T;
Expand Down Expand Up @@ -241,6 +249,8 @@ struct BATTERY_CONFIG_T {
float DischargeCurrentLimitBelowSoc;
float DischargeCurrentLimitBelowVoltage;
bool UseBatteryReportedDischargeCurrentLimit;
float NominalVoltage;
uint16_t NominalCapacity;
};
using BatteryConfig = struct BATTERY_CONFIG_T;

Expand Down
65 changes: 65 additions & 0 deletions include/Statistic.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once

/*
* Weighted average and statistics template class
*
* Functions: Weighted average, Maximum, Minimum, Last value, Counts the added values and Reset
* Note: Use the constructor to configure the weighted average, weighted average = 100% / factor,
* Example: WeightedAVG<uint16_t> myData {20}; weighted average = 5% (100% / 20)
*
* 18.05.2025 - 1.10 - improvement: use always float for average value to avoid truncating errors
*/

template <typename T>
class WeightedAVG {
public:
// Constructor with a factor to calculate the weighted average
// Example: WeightedAVG<uint16_t> myData {5}; weighted average = 20% (100% / 5)
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.0f; _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 static_cast<T>(_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)
float _avgV; // average value, always float to avoid truncating errors
T _minV; // minimum value
T _maxV; // maximum value
T _lastV; // last value
};

139 changes: 139 additions & 0 deletions include/SurplusPower.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once

#include <Arduino.h>
#include <frozen/string.h>
#include <solarcharger/Controller.h>
#include <TaskSchedulerDeclarations.h>
#include "Statistic.h"


struct SurplusReport {
uint16_t surplusPower = 0; // surplus power [W]
uint16_t slopePower = 0; // slope power [W]
bool stageIActive = false; // true if stage-I is active
bool stageIIActive = false; // true if stage-II is active
bool slopeActive = false; // true if slope mode is active
tm stageITimeStart; // last time we enter stage-I
tm stageITimeStop; // last time we exit from stage-I
tm stageIITimeStart; // last time we enter stage-II
tm stageIITimeStop; // last time we exit from stage-II
float qualityCounter = 0.0f; // quality counter average
String qualityText; // quality as readable text
String errorText; // error as readable text
};

class SurplusClass {
public:
SurplusClass() = default;
~SurplusClass() = default;
SurplusClass(const SurplusClass&) = delete;
SurplusClass& operator=(const SurplusClass&) = delete;
SurplusClass(SurplusClass&&) = delete;
SurplusClass& operator=(SurplusClass&&) = delete;

void init(Scheduler& scheduler);
bool isSurplusEnabled(void) const { return (_stageIEnabled || _stageIIEnabled); }
void stopSurplus(void);
uint16_t calculateSurplus(uint16_t const requestedPower, uint16_t const nowPower, uint32_t const nowMillis);
void reloadConfig(void);

// can be used to temporary disable surplus
enum class Switch : uint8_t { ON, OFF, ASK };
bool switchSurplusOnOff(SurplusClass::Switch const onoff);

// getter functions: surplus power, slope power and report data
uint16_t getSurplusPower() const { return _surplusPower; }
uint16_t getSlopePower() const { return _slopePower; }
SurplusReport const& getReportData();
String getDatumText(tm const& start, tm const& stop) const;

private:
enum class State : uint8_t {
OFF, IDLE, TRY_MORE, WAIT_CHARGING, LIMIT_CHARGING, REDUCE_POWER, IN_TARGET, MAXIMUM_POWER,
REQUESTED_POWER, BULK_POWER
};
enum class ReturnState : uint8_t {
ERR_TIME = 0, ERR_CHARGER = 1, ERR_BATTERY = 2, ERR_SOLAR_POWER = 3, OK_STAGE_I, OK_STAGE_II
};
enum class ErrorState : uint8_t {
NO_ERROR, NO_DPL, NO_BATTERY_SOC, NO_BATTERY_INVERTER, NO_CHARGER_STATE, NO_BATTERY_CAPACITY
};
frozen::string const _missing = "missing text";

void loop(void);
frozen::string const& getStatusText(SurplusClass::State const state) const;
frozen::string const& getExitText(SurplusClass::ReturnState const status) const;
frozen::string const& getQualityText(float const qNr) const;
frozen::string const& getErrorText(SurplusClass::ErrorState const status) const;
void printReport(void);
uint16_t calcBulkMode(uint16_t const requestedPower, uint16_t const nowPower, uint32_t const nowMillis);
uint16_t calcSlopePower(uint16_t const requestedPower, int32_t const surplusPower);
using CHARGER_STATE = SolarChargers::Stats::StateOfOperation;
uint16_t calcAbsorptionMode(uint16_t const requestedPower, CHARGER_STATE const modeMppt, uint32_t const nowMillis);
uint16_t returnFromSurplus(uint16_t const requestedPower, uint16_t const exitPower, SurplusClass::ReturnState const status);
std::optional<uint16_t> getSolarPower(uint16_t const invPower) const;
int16_t getTimeToSunset(void);
uint16_t getUpperPowerLimitSum(void) const;
void triggerStageState(bool stageI, bool stageII);
ErrorState checkSurplusRequirements(void);
void resetRuntimeVariables(void);
void exitSurplus(void);
bool isSurplusActive(void) const { return (_data.stageIActive || _data.stageIIActive); }

State _surplusState = State::OFF; // state machine
int32_t _surplusPower = 0; // actual surplus power [W]
uint16_t _surplusUpperPowerLimit = 0; // upper power limit [W]
ErrorState _errorState = ErrorState::NO_ERROR; // error state
Task _loopTask; // task to print the report
uint32_t _lastDebugPrint = 0; // last millis we printed the debug logging
uint16_t _lastLoggingPower = 0; // the last logged surplus or slope power
std::array<size_t, 4> _errorCounter {0}; // counts all detected errors

// to handle stage-I (bulk mode)
bool _stageIEnabled = false; // surplus-stage-I enable / disable
bool _stageITempOff = false; // can be used for temporary deactivation
float _batterySafetyPercent = 20.0f; // battery reserve power safety factor [%] (20.0 = 20%)
int16_t _sunsetSafetyMinutes = 60; // time between absorption start and sunset [minutes] (60 = 1h)
int32_t _batteryReserve = 0; // battery reserve power [W]
uint32_t _lastReserveCalcMillis = 0; // last millis we calculated the battery reserve power
WeightedAVG<uint16_t> _avgSolSlow {30}; // the average helps by cloudy weather (4%) [W]
WeightedAVG<uint16_t> _avgSolFast {5}; // the average helps by cloudy weather (20%) [W]
uint16_t _solarPowerFiltered = 0; // filtered solar power used for calculation [W]

// to handle time to sunset (bulk mode)
int16_t _timeToSunset = 0; // time to sunset [minutes]
uint32_t _lastTimeToSunsetMillis = 0; // last millis we calculated the time to sunset

// to handle the slope power (bulk mode)
bool _slopeEnabled = false; // slope mode enable / disable
int16_t _slopeTarget = -20; // power target, on top of the requested power [W]
int16_t _slopeFactor = -10; // slope decrease factor [W/s]
int32_t _slopePower = 0; // actual slope power [W]
uint32_t _slopeLastMillis = 0; // last millis we calculated the decrease of the slope power

// to handle stage-II (absorption- and float-mode)
bool _stageIIEnabled = false; // surplus-stage-II enable / disable
bool _stageIITempOff = false; // can be used for temporary deactivation
int16_t _powerStepSize = 0; // approximation step size [W]
float _lastBatteryCurrent = 0.0f; // last battery current [A]
uint32_t _lastInTargetMillis = 0; // last millis we hit the target
uint32_t _lastCalcMillis = 0; // last millis we calculated the surplus power
uint32_t _lastUpdate = 0; // last millis we updated the battery current
uint32_t _regulationTime = 5; // time to regulate the surplus power [s]
WeightedAVG<float> _avgTargetCurrent {4}; // average battery current for the target range[A]
float _lowerTarget = 0.0f; // lower target for the battery current target range [A]
float _upperTarget = 0.0f; // upper target for the battery current target range [A]
float _chargeTarget = 0.0f; // target for the battery charge current [A]
std::pair<uint32_t,float> _pLastI = {0, 0.0f}; // first of two voltages and related current [V,A]

// to handle the quality counter (absorption- and float-mode)
int8_t _qualityCounter = 0; // quality counter
WeightedAVG<float> _qualityAVG {10}; // quality counter average
int16_t _lastAddPower = 0; // last power step

// to handle the report
SurplusReport _data {}; // surplus report data
};

extern SurplusClass Surplus;
1 change: 1 addition & 0 deletions include/battery/Stats.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class Stats {
uint32_t getVoltageAgeSeconds() const { return (millis() - _lastUpdateVoltage) / 1000; }

float getChargeCurrent() const { return _current; };
uint32_t getLastCurrentUpdate() const { return _lastUpdateCurrent; }
uint8_t getChargeCurrentPrecision() const { return _currentPrecision; }

float getDischargeCurrentLimit() const { return _dischargeCurrentLimit; };
Expand Down
20 changes: 20 additions & 0 deletions src/Configuration.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ void ConfigurationClass::serializeBatteryConfig(BatteryConfig const& source, Jso
target["discharge_current_limit_below_soc"] = config.Battery.DischargeCurrentLimitBelowSoc;
target["discharge_current_limit_below_voltage"] = config.Battery.DischargeCurrentLimitBelowVoltage;
target["use_battery_reported_discharge_current_limit"] = config.Battery.UseBatteryReportedDischargeCurrentLimit;
target["nominal_voltage"] = config.Battery.NominalVoltage;
target["nominal_capacity"] = config.Battery.NominalCapacity;
}

void ConfigurationClass::serializeBatteryZendureConfig(BatteryZendureConfig const& source, JsonObject& target)
Expand Down Expand Up @@ -211,6 +213,14 @@ void ConfigurationClass::serializePowerLimiterConfig(PowerLimiterConfig const& s
target["inverter_channel_id_for_dc_voltage"] = source.InverterChannelIdForDcVoltage;
target["inverter_restart_hour"] = source.RestartHour;
target["total_upper_power_limit"] = source.TotalUpperPowerLimit;
target["surplus_enabled"] = source.SurplusEnabled;
target["surplus_tweak_enabled"] = source.SurplusTweakEnabled;
target["surplus_power_limit"] = source.SurplusPowerLimit;
target["surplus_safety_minutes"] = source.SurplusSafetyMinutes;
target["surplus_safety_factor"] = source.SurplusSafetyFactor;
target["surplus_slope_enabled"] = source.SurplusSlopeEnabled;
target["surplus_slope_target"] = source.SurplusSlopeTarget;
target["surplus_slope_decrease_rate"] = source.SurplusSlopeDecreaseRate;

JsonArray inverters = target["inverters"].to<JsonArray>();
for (size_t i = 0; i < INV_MAX_COUNT; ++i) {
Expand Down Expand Up @@ -559,6 +569,8 @@ void ConfigurationClass::deserializeBatteryConfig(JsonObject const& source, Batt
target.DischargeCurrentLimitBelowSoc = source["discharge_current_limit_below_soc"] | BATTERY_DISCHARGE_CURRENT_LIMIT_BELOW_SOC;
target.DischargeCurrentLimitBelowVoltage = source["discharge_current_limit_below_voltage"] | BATTERY_DISCHARGE_CURRENT_LIMIT_BELOW_VOLTAGE;
target.UseBatteryReportedDischargeCurrentLimit = source["use_battery_reported_discharge_current_limit"] | BATTERY_USE_BATTERY_REPORTED_DISCHARGE_CURRENT_LIMIT;
target.NominalVoltage = source["nominal_voltage"] | 0.0f;
target.NominalCapacity = source["nominal_capacity"] | 0;
}

void ConfigurationClass::deserializeBatteryZendureConfig(JsonObject const& source, BatteryZendureConfig& target)
Expand Down Expand Up @@ -631,6 +643,14 @@ void ConfigurationClass::deserializePowerLimiterConfig(JsonObject const& source,
target.InverterChannelIdForDcVoltage = source["inverter_channel_id_for_dc_voltage"] | POWERLIMITER_INVERTER_CHANNEL_ID;
target.RestartHour = source["inverter_restart_hour"] | POWERLIMITER_RESTART_HOUR;
target.TotalUpperPowerLimit = source["total_upper_power_limit"] | POWERLIMITER_UPPER_POWER_LIMIT;
target.SurplusEnabled = source["surplus_enabled"] | false;
target.SurplusTweakEnabled = source["surplus_tweak_enabled"] | false;
target.SurplusPowerLimit = source["surplus_power_limit"] | 0;
target.SurplusSafetyMinutes = source["surplus_safety_minutes"] | 0;
target.SurplusSafetyFactor = source["surplus_safety_factor"] | 0.0f;
target.SurplusSlopeEnabled = source["surplus_slope_enabled"] | true;
target.SurplusSlopeTarget = source["surplus_slope_target"] | 0;
target.SurplusSlopeDecreaseRate = source["surplus_slope_decrease_rate"] | 0;

JsonArray inverters = source["inverters"].as<JsonArray>();
for (size_t i = 0; i < INV_MAX_COUNT; ++i) {
Expand Down
52 changes: 44 additions & 8 deletions src/PowerLimiter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#include <frozen/map.h>
#include "SunPosition.h"
#include <LogHelper.h>
#include "SurplusPower.h"

#undef TAG
static const char* TAG = "dynamicPowerLimiter";
Expand Down Expand Up @@ -163,6 +164,7 @@ void PowerLimiterClass::loop()

if (_reloadConfigFlag) {
reloadConfig();
Surplus.reloadConfig();
return announceStatus(Status::ConfigReload);
}

Expand Down Expand Up @@ -272,8 +274,9 @@ void PowerLimiterClass::loop()
};

auto getFullSolarPassthrough = [this,&config]() -> bool {
// we only do full solar PT if general solar PT is enabled
if (!isSolarPassThroughEnabled()) { return false; }
// we only do full solar PT if general solar PT is enabled or if solar-surplus is enabled
// Note: Maybe rename the flag _fullSolarPassThroughActive to _useExcessiveSolarPower?
if (!isSolarPassThroughEnabled() && !Surplus.isSurplusEnabled()) { return false; }

if (testThreshold(config.PowerLimiter.FullSolarPassThroughSoc,
config.PowerLimiter.FullSolarPassThroughStartVoltage,
Expand Down Expand Up @@ -704,13 +707,46 @@ uint16_t PowerLimiterClass::calcPowerBusUsage(uint16_t powerRequested) const
return 0;
}

auto solarOutputDc = getSolarPassthroughPower();
auto solarOutputAc = dcPowerBusToInverterAc(solarOutputDc);
if (isFullSolarPassthroughActive() && solarOutputAc > powerRequested) {
DTU_LOGD("using %u/%u W DC/AC from DC power bus (full solar-passthrough)",
solarOutputDc, solarOutputAc);
uint16_t solarOutputDc = 0;
uint16_t solarOutputAc = 0;

return solarOutputAc;
if (isFullSolarPassthroughActive()) {
if (Surplus.isSurplusEnabled()) {
// we use the solar-surplus power to determine how much power
// the battery-powered inverters shall draw from the DC power bus.

// first, we need the latest stats of the battery-powered inverters
uint32_t latestBatteryInverterStats = 0;
for (auto const& upInv : _inverters) {
if (!upInv->isEligible()) { continue; }
if (!upInv->isBatteryPowered()) { continue; }
auto oStatsMillis = upInv->getLatestStatsMillis();
if (oStatsMillis.has_value()) { latestBatteryInverterStats = std::max(*oStatsMillis, latestBatteryInverterStats); }
}

// second, we calculate the surplus power
solarOutputAc = Surplus.calculateSurplus(powerRequested, getBatteryInvertersOutputAcWatts(), latestBatteryInverterStats);
solarOutputDc = solarOutputAc / 0.95f; // convert AC to DC power
if (solarOutputAc > powerRequested) {
DTU_LOGD("using %u/%u W DC/AC from DC power bus (solar-surplus)",
solarOutputDc, solarOutputAc);
return solarOutputAc;
}
} else {
// we use the full-solar-passthrough power to determine how much power
// the battery-powered inverters shall draw from the DC power bus.
solarOutputDc = getSolarPassthroughPower();
solarOutputAc = dcPowerBusToInverterAc(solarOutputDc);
if (solarOutputAc > powerRequested) {
DTU_LOGD("using %u/%u W DC/AC from DC power bus (full solar-passthrough)",
solarOutputDc, solarOutputAc);

return solarOutputAc;
}
}
} else {
// in case of stop threshold reached, we switch to idle and to cleanup the state machine
if (Surplus.isSurplusEnabled()) { Surplus.stopSurplus(); }
}

auto oBatteryDischargeLimit = getBatteryDischargeLimit();
Expand Down
Loading