diff --git a/include/Configuration.h b/include/Configuration.h index 851c43c34..79d3e61c9 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -132,6 +132,43 @@ struct POWERMETER_HTTP_SML_CONFIG_T { }; using PowerMeterHttpSmlConfig = struct POWERMETER_HTTP_SML_CONFIG_T; +struct POWERLIMITER_INVERTER_CONFIG_T { + uint64_t Serial; + bool IsGoverned; + bool IsBehindPowerMeter; + bool IsSolarPowered; + bool UseOverscalingToCompensateShading; + uint16_t LowerPowerLimit; + uint16_t UpperPowerLimit; +}; +using PowerLimiterInverterConfig = struct POWERLIMITER_INVERTER_CONFIG_T; + +struct POWERLIMITER_CONFIG_T { + bool Enabled; + bool VerboseLogging; + bool SolarPassThroughEnabled; + uint8_t SolarPassThroughLosses; + bool BatteryAlwaysUseAtNight; + int16_t TargetPowerConsumption; + uint16_t TargetPowerConsumptionHysteresis; + uint16_t BaseLoadLimit; + bool IgnoreSoc; + uint16_t BatterySocStartThreshold; + uint16_t BatterySocStopThreshold; + float VoltageStartThreshold; + float VoltageStopThreshold; + float VoltageLoadCorrectionFactor; + uint16_t FullSolarPassThroughSoc; + float FullSolarPassThroughStartVoltage; + float FullSolarPassThroughStopVoltage; + uint64_t InverterSerialForDcVoltage; + uint8_t InverterChannelIdForDcVoltage; + int8_t RestartHour; + uint16_t TotalUpperPowerLimit; + PowerLimiterInverterConfig Inverters[INV_MAX_COUNT]; +}; +using PowerLimiterConfig = struct POWERLIMITER_CONFIG_T; + enum BatteryVoltageUnit { Volts = 0, DeciVolts = 1, CentiVolts = 2, MilliVolts = 3 }; enum BatteryAmperageUnit { Amps = 0, MilliAmps = 1 }; @@ -284,34 +321,7 @@ struct CONFIG_T { PowerMeterHttpSmlConfig HttpSml; } PowerMeter; - struct { - bool Enabled; - bool VerboseLogging; - bool SolarPassThroughEnabled; - uint8_t SolarPassThroughLosses; - bool BatteryAlwaysUseAtNight; - uint32_t Interval; - bool IsInverterBehindPowerMeter; - bool IsInverterSolarPowered; - bool UseOverscalingToCompensateShading; - uint64_t InverterId; - uint8_t InverterChannelId; - int32_t TargetPowerConsumption; - int32_t TargetPowerConsumptionHysteresis; - int32_t LowerPowerLimit; - int32_t BaseLoadLimit; - int32_t UpperPowerLimit; - bool IgnoreSoc; - uint32_t BatterySocStartThreshold; - uint32_t BatterySocStopThreshold; - float VoltageStartThreshold; - float VoltageStopThreshold; - float VoltageLoadCorrectionFactor; - int8_t RestartHour; - uint32_t FullSolarPassThroughSoc; - float FullSolarPassThroughStartVoltage; - float FullSolarPassThroughStopVoltage; - } PowerLimiter; + PowerLimiterConfig PowerLimiter; BatteryConfig Battery; @@ -365,6 +375,7 @@ class ConfigurationClass { static void serializePowerMeterHttpJsonConfig(PowerMeterHttpJsonConfig const& source, JsonObject& target); static void serializePowerMeterHttpSmlConfig(PowerMeterHttpSmlConfig const& source, JsonObject& target); static void serializeBatteryConfig(BatteryConfig const& source, JsonObject& target); + static void serializePowerLimiterConfig(PowerLimiterConfig const& source, JsonObject& target); static void deserializeHttpRequestConfig(JsonObject const& source, HttpRequestConfig& target); static void deserializePowerMeterMqttConfig(JsonObject const& source, PowerMeterMqttConfig& target); @@ -372,6 +383,7 @@ class ConfigurationClass { static void deserializePowerMeterHttpJsonConfig(JsonObject const& source, PowerMeterHttpJsonConfig& target); static void deserializePowerMeterHttpSmlConfig(JsonObject const& source, PowerMeterHttpSmlConfig& target); static void deserializeBatteryConfig(JsonObject const& source, BatteryConfig& target); + static void deserializePowerLimiterConfig(JsonObject const& source, PowerLimiterConfig& target); private: void loop(); diff --git a/include/PowerLimiter.h b/include/PowerLimiter.h index c55d1d4bb..e7cd30631 100644 --- a/include/PowerLimiter.h +++ b/include/PowerLimiter.h @@ -2,9 +2,11 @@ #pragma once #include "Configuration.h" +#include "PowerLimiterInverter.h" #include #include -#include +#include +#include #include #include #include @@ -18,6 +20,8 @@ class PowerLimiterClass { public: + PowerLimiterClass() = default; + enum class Status : unsigned { Initializing, DisabledByConfig, @@ -25,25 +29,19 @@ class PowerLimiterClass { WaitingForValidTimestamp, PowerMeterPending, InverterInvalid, - InverterChanged, - InverterOffline, - InverterCommandsDisabled, - InverterLimitPending, - InverterPowerCmdPending, - InverterDevInfoPending, + InverterCmdPending, + ConfigReload, InverterStatsPending, - CalculatedLimitBelowMinLimit, + FullSolarPassthrough, UnconditionalSolarPassthrough, - NoVeDirect, - NoEnergy, - HuaweiPsu, Stable, }; void init(Scheduler& scheduler); - uint8_t getInverterUpdateTimeouts() const { return _inverterUpdateTimeouts; } + void triggerReloadingConfig() { _reloadConfigFlag = true; } + uint8_t getInverterUpdateTimeouts() const; uint8_t getPowerLimiterState(); - int32_t getLastRequestedPowerLimit() { return _lastRequestedPowerLimit; } + int32_t getInverterOutput() { return _lastExpectedInverterOutput; } bool getFullSolarPassThroughEnabled() const { return _fullSolarPassThroughEnabled; } enum class Mode : unsigned { @@ -54,54 +52,54 @@ class PowerLimiterClass { void setMode(Mode m) { _mode = m; } Mode getMode() const { return _mode; } - void calcNextInverterRestart(); + bool usesBatteryPoweredInverter(); + bool isGovernedInverterProducing(); private: void loop(); Task _loopTask; - int32_t _lastRequestedPowerLimit = 0; - bool _shutdownPending = false; - std::optional _oInverterStatsMillis = std::nullopt; - std::optional _oUpdateStartMillis = std::nullopt; - std::optional _oTargetPowerLimitWatts = std::nullopt; - std::optional _oTargetPowerState = std::nullopt; + std::atomic _reloadConfigFlag = true; + uint16_t _lastExpectedInverterOutput = 0; Status _lastStatus = Status::Initializing; uint32_t _lastStatusPrinted = 0; uint32_t _lastCalculation = 0; static constexpr uint32_t _calculationBackoffMsDefault = 128; uint32_t _calculationBackoffMs = _calculationBackoffMsDefault; Mode _mode = Mode::Normal; - std::shared_ptr _inverter = nullptr; + + std::deque> _inverters; bool _batteryDischargeEnabled = false; bool _nighttimeDischarging = false; - uint32_t _nextInverterRestart = 0; // Values: 0->not calculated / 1->no restart configured / >1->time of next inverter restart in millis() - uint32_t _nextCalculateCheck = 5000; // time in millis for next NTP check to calulate restart + std::pair _nextInverterRestart = { false, 0 }; bool _fullSolarPassThroughEnabled = false; bool _verboseLogging = true; - uint8_t _inverterUpdateTimeouts = 0; frozen::string const& getStatusText(Status status); void announceStatus(Status status); bool shutdown(Status status); - bool shutdown() { return shutdown(_lastStatus); } + void reloadConfig(); + std::pair getInverterDcVoltage(); float getBatteryVoltage(bool log = false); - int32_t inverterPowerDcToAc(std::shared_ptr inverter, int32_t dcPower); - void unconditionalSolarPassthrough(std::shared_ptr inverter); - bool canUseDirectSolarPower(); - bool calcPowerLimit(std::shared_ptr inverter, int32_t solarPower, int32_t batteryPowerLimit, bool batteryPower); - bool updateInverter(); - bool setNewPowerLimit(std::shared_ptr inverter, int32_t newPowerLimit); - int32_t getSolarPower(); - int32_t getBatteryDischargeLimit(); + uint16_t solarDcToInverterAc(uint16_t dcPower); + void fullSolarPassthrough(PowerLimiterClass::Status reason); + int16_t calcHouseholdConsumption(); + using inverter_filter_t = std::function; + uint16_t updateInverterLimits(uint16_t powerRequested, inverter_filter_t filter, std::string const& filterExpression); + uint16_t calcBatteryAllowance(uint16_t powerRequested); + bool updateInverters(); + uint16_t getSolarPassthroughPower(); + std::optional getBatteryDischargeLimit(); + float getBatteryInvertersOutputAcWatts(); float getLoadCorrectedVoltage(); bool testThreshold(float socThreshold, float voltThreshold, std::function compare); bool isStartThresholdReached(); bool isStopThresholdReached(); bool isBelowStopThreshold(); - bool useFullSolarPassthrough(); + void calcNextInverterRestart(); + bool isFullSolarPassthroughActive(); }; extern PowerLimiterClass PowerLimiter; diff --git a/include/PowerLimiterBatteryInverter.h b/include/PowerLimiterBatteryInverter.h new file mode 100644 index 000000000..348a41f95 --- /dev/null +++ b/include/PowerLimiterBatteryInverter.h @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include "PowerLimiterInverter.h" + +class PowerLimiterBatteryInverter : public PowerLimiterInverter { +public: + PowerLimiterBatteryInverter(bool verboseLogging, PowerLimiterInverterConfig const& config); + + uint16_t getMaxReductionWatts(bool allowStandby) const final; + uint16_t getMaxIncreaseWatts() const final; + uint16_t applyReduction(uint16_t reduction, bool allowStandby) final; + uint16_t applyIncrease(uint16_t increase) final; + uint16_t standby() final; + bool isSolarPowered() const final { return false; } + +private: + void setAcOutput(uint16_t expectedOutputWatts) final; +}; diff --git a/include/PowerLimiterInverter.h b/include/PowerLimiterInverter.h new file mode 100644 index 000000000..ccd1db127 --- /dev/null +++ b/include/PowerLimiterInverter.h @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include "Configuration.h" +#include +#include +#include + +class PowerLimiterInverter { +public: + static std::unique_ptr create(bool verboseLogging, PowerLimiterInverterConfig const& config); + + // send command(s) to inverter to reach desired target state (limit and + // production). return true if an update is pending, i.e., if the target + // state is NOT yet reached, false otherwise. + bool update(); + + // returns the timestamp of the oldest stats received for this inverter + // *after* its last command completed. return std::nullopt if new stats + // are pending after the last command completed. + std::optional getLatestStatsMillis() const; + + // the amount of times an update command issued to the inverter timed out + uint8_t getUpdateTimeouts() const { return _updateTimeouts; } + + // maximum amount of AC power the inverter is able to produce + // (not regarding the configured upper power limit) + uint16_t getInverterMaxPowerWatts() const; + + // maximum amount of AC power the inverter is allowed to produce as per + // upper power limit (additionally restricted by inverter's absolute max) + uint16_t getConfiguredMaxPowerWatts() const; + + uint16_t getCurrentOutputAcWatts() const; + + // this differs from current output power if new limit was assigned + uint16_t getExpectedOutputAcWatts() const; + + // the maximum reduction of power output the inverter + // can achieve with or withouth going into standby. + virtual uint16_t getMaxReductionWatts(bool allowStandby) const = 0; + + // the maximum increase of power output the inverter can achieve + // (is expected to achieve), possibly coming out of standby. + virtual uint16_t getMaxIncreaseWatts() const = 0; + + // change the target limit such that the requested change becomes effective + // on the expected AC power output. returns the change in the range + // [0..reduction] that will become effective (once update() returns false). + virtual uint16_t applyReduction(uint16_t reduction, bool allowStandby) = 0; + virtual uint16_t applyIncrease(uint16_t increase) = 0; + + // stop producing AC power. returns the change in power output + // that will become effective (once update() returns false). + virtual uint16_t standby() = 0; + + // wake the inverter from standby and set it to produce + // as much power as permissible by its upper power limit. + void setMaxOutput(); + + void restart(); + + float getDcVoltage(uint8_t input); + bool isSendingCommandsEnabled() const { return _spInverter->getEnableCommands(); } + bool isReachable() const { return _spInverter->isReachable(); } + bool isProducing() const { return _spInverter->isProducing(); } + + uint64_t getSerial() const { return _config.Serial; } + char const* getSerialStr() const { return _serialStr; } + bool isBehindPowerMeter() const { return _config.IsBehindPowerMeter; } + virtual bool isSolarPowered() const = 0; + + void debug() const; + +protected: + PowerLimiterInverter(bool verboseLogging, PowerLimiterInverterConfig const& config); + + // returns false if the inverter cannot participate + // in achieving the requested change in power output + bool isEligible() const; + + uint16_t getCurrentLimitWatts() const; + + void setTargetPowerLimitWatts(uint16_t power) { _oTargetPowerLimitWatts = power; } + void setTargetPowerState(bool enable) { _oTargetPowerState = enable; } + void setExpectedOutputAcWatts(uint16_t power) { _expectedOutputAcWatts = power; } + + // copied to avoid races with web UI + PowerLimiterInverterConfig _config; + + // Hoymiles lib inverter instance + std::shared_ptr _spInverter = nullptr; + + bool _verboseLogging; + char _logPrefix[32]; + +private: + virtual void setAcOutput(uint16_t expectedOutputWatts) = 0; + + char _serialStr[16]; + + // track (target) state + uint8_t _updateTimeouts = 0; + std::optional _oUpdateStartMillis = std::nullopt; + std::optional _oTargetPowerLimitWatts = std::nullopt; + std::optional _oTargetPowerState = std::nullopt; + mutable std::optional _oStatsMillis = std::nullopt; + + // the expected AC output (possibly is different from the target limit) + uint16_t _expectedOutputAcWatts = 0; +}; diff --git a/include/PowerLimiterSolarInverter.h b/include/PowerLimiterSolarInverter.h new file mode 100644 index 000000000..72023211c --- /dev/null +++ b/include/PowerLimiterSolarInverter.h @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include "PowerLimiterInverter.h" + +class PowerLimiterSolarInverter : public PowerLimiterInverter { +public: + PowerLimiterSolarInverter(bool verboseLogging, PowerLimiterInverterConfig const& config); + + uint16_t getMaxReductionWatts(bool allowStandby) const final; + uint16_t getMaxIncreaseWatts() const final; + uint16_t applyReduction(uint16_t reduction, bool allowStandby) final; + uint16_t applyIncrease(uint16_t increase) final; + uint16_t standby() final; + bool isSolarPowered() const final { return true; } + +private: + uint16_t scaleLimit(uint16_t expectedOutputWatts); + void setAcOutput(uint16_t expectedOutputWatts) final; +}; diff --git a/include/defaults.h b/include/defaults.h index 5fea40f61..b1bd81a10 100644 --- a/include/defaults.h +++ b/include/defaults.h @@ -130,18 +130,16 @@ #define POWERLIMITER_SOLAR_PASSTHROUGH_ENABLED true #define POWERLIMITER_SOLAR_PASSTHROUGH_LOSSES 3 #define POWERLIMITER_BATTERY_ALWAYS_USE_AT_NIGHT false -#define POWERLIMITER_INTERVAL 10 #define POWERLIMITER_IS_INVERTER_BEHIND_POWER_METER true #define POWERLIMITER_IS_INVERTER_SOLAR_POWERED false #define POWERLIMITER_USE_OVERSCALING_TO_COMPENSATE_SHADING false -#define POWERLIMITER_INVERTER_ID 0ULL #define POWERLIMITER_INVERTER_CHANNEL_ID 0 #define POWERLIMITER_TARGET_POWER_CONSUMPTION 0 #define POWERLIMITER_TARGET_POWER_CONSUMPTION_HYSTERESIS 0 #define POWERLIMITER_LOWER_POWER_LIMIT 10 #define POWERLIMITER_BASE_LOAD_LIMIT 100 #define POWERLIMITER_UPPER_POWER_LIMIT 800 -#define POWERLIMITER_IGNORE_SOC false +#define POWERLIMITER_IGNORE_SOC true #define POWERLIMITER_BATTERY_SOC_START_THRESHOLD 80 #define POWERLIMITER_BATTERY_SOC_STOP_THRESHOLD 20 #define POWERLIMITER_VOLTAGE_START_THRESHOLD 50.0 diff --git a/lib/Hoymiles/src/inverters/HERF_1CH.cpp b/lib/Hoymiles/src/inverters/HERF_1CH.cpp index 49531d99c..17e0a6183 100644 --- a/lib/Hoymiles/src/inverters/HERF_1CH.cpp +++ b/lib/Hoymiles/src/inverters/HERF_1CH.cpp @@ -29,6 +29,10 @@ static const byteAssign_t byteAssignment[] = { { TYPE_INV, CH0, FLD_EFF, UNIT_PCT, CALC_TOTAL_EFF, 0, CMD_CALC, false, 3 } }; +static const channelMetaData_t channelMetaData[] = { + { CH0, MPPT_A } +}; + HERF_1CH::HERF_1CH(HoymilesRadio* radio, const uint64_t serial) : HM_Abstract(radio, serial) {}; @@ -53,3 +57,13 @@ uint8_t HERF_1CH::getByteAssignmentSize() const { return sizeof(byteAssignment) / sizeof(byteAssignment[0]); } + +const channelMetaData_t* HERF_1CH::getChannelMetaData() const +{ + return channelMetaData; +} + +uint8_t HERF_1CH::getChannelMetaDataSize() const +{ + return sizeof(channelMetaData) / sizeof(channelMetaData[0]); +} diff --git a/lib/Hoymiles/src/inverters/HERF_1CH.h b/lib/Hoymiles/src/inverters/HERF_1CH.h index 8220272e3..fc6f1e38a 100644 --- a/lib/Hoymiles/src/inverters/HERF_1CH.h +++ b/lib/Hoymiles/src/inverters/HERF_1CH.h @@ -10,4 +10,6 @@ class HERF_1CH : public HM_Abstract { String typeName() const; const byteAssign_t* getByteAssignment() const; uint8_t getByteAssignmentSize() const; + const channelMetaData_t* getChannelMetaData() const; + uint8_t getChannelMetaDataSize() const; }; diff --git a/lib/Hoymiles/src/inverters/HERF_2CH.cpp b/lib/Hoymiles/src/inverters/HERF_2CH.cpp index f0216a643..14f4644bc 100644 --- a/lib/Hoymiles/src/inverters/HERF_2CH.cpp +++ b/lib/Hoymiles/src/inverters/HERF_2CH.cpp @@ -36,6 +36,11 @@ static const byteAssign_t byteAssignment[] = { { TYPE_INV, CH0, FLD_EFF, UNIT_PCT, CALC_TOTAL_EFF, 0, CMD_CALC, false, 3 } }; +static const channelMetaData_t channelMetaData[] = { + { CH0, MPPT_A }, + { CH1, MPPT_B } +}; + HERF_2CH::HERF_2CH(HoymilesRadio* radio, const uint64_t serial) : HM_Abstract(radio, serial) {}; @@ -60,3 +65,13 @@ uint8_t HERF_2CH::getByteAssignmentSize() const { return sizeof(byteAssignment) / sizeof(byteAssignment[0]); } + +const channelMetaData_t* HERF_2CH::getChannelMetaData() const +{ + return channelMetaData; +} + +uint8_t HERF_2CH::getChannelMetaDataSize() const +{ + return sizeof(channelMetaData) / sizeof(channelMetaData[0]); +} diff --git a/lib/Hoymiles/src/inverters/HERF_2CH.h b/lib/Hoymiles/src/inverters/HERF_2CH.h index 048ccb618..e606a8437 100644 --- a/lib/Hoymiles/src/inverters/HERF_2CH.h +++ b/lib/Hoymiles/src/inverters/HERF_2CH.h @@ -10,4 +10,6 @@ class HERF_2CH : public HM_Abstract { String typeName() const; const byteAssign_t* getByteAssignment() const; uint8_t getByteAssignmentSize() const; + const channelMetaData_t* getChannelMetaData() const; + uint8_t getChannelMetaDataSize() const; }; diff --git a/lib/Hoymiles/src/inverters/HMS_1CH.cpp b/lib/Hoymiles/src/inverters/HMS_1CH.cpp index 2c7e3857b..a04b0b7d2 100644 --- a/lib/Hoymiles/src/inverters/HMS_1CH.cpp +++ b/lib/Hoymiles/src/inverters/HMS_1CH.cpp @@ -28,6 +28,10 @@ static const byteAssign_t byteAssignment[] = { { TYPE_INV, CH0, FLD_EFF, UNIT_PCT, CALC_TOTAL_EFF, 0, CMD_CALC, false, 3 } }; +static const channelMetaData_t channelMetaData[] = { + { CH0, MPPT_A } +}; + HMS_1CH::HMS_1CH(HoymilesRadio* radio, const uint64_t serial) : HMS_Abstract(radio, serial) {}; @@ -52,3 +56,13 @@ uint8_t HMS_1CH::getByteAssignmentSize() const { return sizeof(byteAssignment) / sizeof(byteAssignment[0]); } + +const channelMetaData_t* HMS_1CH::getChannelMetaData() const +{ + return channelMetaData; +} + +uint8_t HMS_1CH::getChannelMetaDataSize() const +{ + return sizeof(channelMetaData) / sizeof(channelMetaData[0]); +} diff --git a/lib/Hoymiles/src/inverters/HMS_1CH.h b/lib/Hoymiles/src/inverters/HMS_1CH.h index a5a64c177..de53d501d 100644 --- a/lib/Hoymiles/src/inverters/HMS_1CH.h +++ b/lib/Hoymiles/src/inverters/HMS_1CH.h @@ -11,4 +11,6 @@ class HMS_1CH : public HMS_Abstract { String typeName() const; const byteAssign_t* getByteAssignment() const; uint8_t getByteAssignmentSize() const; -}; \ No newline at end of file + const channelMetaData_t* getChannelMetaData() const; + uint8_t getChannelMetaDataSize() const; +}; diff --git a/lib/Hoymiles/src/inverters/HMS_1CHv2.cpp b/lib/Hoymiles/src/inverters/HMS_1CHv2.cpp index d79d2c1d2..3716cd98f 100644 --- a/lib/Hoymiles/src/inverters/HMS_1CHv2.cpp +++ b/lib/Hoymiles/src/inverters/HMS_1CHv2.cpp @@ -28,6 +28,10 @@ static const byteAssign_t byteAssignment[] = { { TYPE_INV, CH0, FLD_EFF, UNIT_PCT, CALC_TOTAL_EFF, 0, CMD_CALC, false, 3 } }; +static const channelMetaData_t channelMetaData[] = { + { CH0, MPPT_A } +}; + HMS_1CHv2::HMS_1CHv2(HoymilesRadio* radio, const uint64_t serial) : HMS_Abstract(radio, serial) {}; @@ -52,3 +56,13 @@ uint8_t HMS_1CHv2::getByteAssignmentSize() const { return sizeof(byteAssignment) / sizeof(byteAssignment[0]); } + +const channelMetaData_t* HMS_1CHv2::getChannelMetaData() const +{ + return channelMetaData; +} + +uint8_t HMS_1CHv2::getChannelMetaDataSize() const +{ + return sizeof(channelMetaData) / sizeof(channelMetaData[0]); +} diff --git a/lib/Hoymiles/src/inverters/HMS_1CHv2.h b/lib/Hoymiles/src/inverters/HMS_1CHv2.h index c831d1204..2274ae53c 100644 --- a/lib/Hoymiles/src/inverters/HMS_1CHv2.h +++ b/lib/Hoymiles/src/inverters/HMS_1CHv2.h @@ -11,4 +11,6 @@ class HMS_1CHv2 : public HMS_Abstract { String typeName() const; const byteAssign_t* getByteAssignment() const; uint8_t getByteAssignmentSize() const; -}; \ No newline at end of file + const channelMetaData_t* getChannelMetaData() const; + uint8_t getChannelMetaDataSize() const; +}; diff --git a/lib/Hoymiles/src/inverters/HMS_2CH.cpp b/lib/Hoymiles/src/inverters/HMS_2CH.cpp index 4cbc686cd..c22fcf0d4 100644 --- a/lib/Hoymiles/src/inverters/HMS_2CH.cpp +++ b/lib/Hoymiles/src/inverters/HMS_2CH.cpp @@ -35,6 +35,11 @@ static const byteAssign_t byteAssignment[] = { { TYPE_INV, CH0, FLD_EFF, UNIT_PCT, CALC_TOTAL_EFF, 0, CMD_CALC, false, 3 } }; +static const channelMetaData_t channelMetaData[] = { + { CH0, MPPT_A }, + { CH1, MPPT_B } +}; + HMS_2CH::HMS_2CH(HoymilesRadio* radio, const uint64_t serial) : HMS_Abstract(radio, serial) {}; @@ -59,3 +64,13 @@ uint8_t HMS_2CH::getByteAssignmentSize() const { return sizeof(byteAssignment) / sizeof(byteAssignment[0]); } + +const channelMetaData_t* HMS_2CH::getChannelMetaData() const +{ + return channelMetaData; +} + +uint8_t HMS_2CH::getChannelMetaDataSize() const +{ + return sizeof(channelMetaData) / sizeof(channelMetaData[0]); +} diff --git a/lib/Hoymiles/src/inverters/HMS_2CH.h b/lib/Hoymiles/src/inverters/HMS_2CH.h index 9f1ed91f6..75d3f9b0b 100644 --- a/lib/Hoymiles/src/inverters/HMS_2CH.h +++ b/lib/Hoymiles/src/inverters/HMS_2CH.h @@ -11,4 +11,6 @@ class HMS_2CH : public HMS_Abstract { String typeName() const; const byteAssign_t* getByteAssignment() const; uint8_t getByteAssignmentSize() const; -}; \ No newline at end of file + const channelMetaData_t* getChannelMetaData() const; + uint8_t getChannelMetaDataSize() const; +}; diff --git a/lib/Hoymiles/src/inverters/HMS_4CH.cpp b/lib/Hoymiles/src/inverters/HMS_4CH.cpp index b3cf1f380..1616d013e 100644 --- a/lib/Hoymiles/src/inverters/HMS_4CH.cpp +++ b/lib/Hoymiles/src/inverters/HMS_4CH.cpp @@ -49,6 +49,13 @@ static const byteAssign_t byteAssignment[] = { { TYPE_INV, CH0, FLD_EFF, UNIT_PCT, CALC_TOTAL_EFF, 0, CMD_CALC, false, 3 } }; +static const channelMetaData_t channelMetaData[] = { + { CH0, MPPT_A }, + { CH1, MPPT_B }, + { CH2, MPPT_C }, + { CH3, MPPT_D } +}; + HMS_4CH::HMS_4CH(HoymilesRadio* radio, const uint64_t serial) : HMS_Abstract(radio, serial) {}; @@ -73,3 +80,13 @@ uint8_t HMS_4CH::getByteAssignmentSize() const { return sizeof(byteAssignment) / sizeof(byteAssignment[0]); } + +const channelMetaData_t* HMS_4CH::getChannelMetaData() const +{ + return channelMetaData; +} + +uint8_t HMS_4CH::getChannelMetaDataSize() const +{ + return sizeof(channelMetaData) / sizeof(channelMetaData[0]); +} diff --git a/lib/Hoymiles/src/inverters/HMS_4CH.h b/lib/Hoymiles/src/inverters/HMS_4CH.h index 9d49de07a..cb7573390 100644 --- a/lib/Hoymiles/src/inverters/HMS_4CH.h +++ b/lib/Hoymiles/src/inverters/HMS_4CH.h @@ -10,4 +10,6 @@ class HMS_4CH : public HMS_Abstract { String typeName() const; const byteAssign_t* getByteAssignment() const; uint8_t getByteAssignmentSize() const; -}; \ No newline at end of file + const channelMetaData_t* getChannelMetaData() const; + uint8_t getChannelMetaDataSize() const; +}; diff --git a/lib/Hoymiles/src/inverters/HMT_4CH.cpp b/lib/Hoymiles/src/inverters/HMT_4CH.cpp index c84eff478..48caf8ce8 100644 --- a/lib/Hoymiles/src/inverters/HMT_4CH.cpp +++ b/lib/Hoymiles/src/inverters/HMT_4CH.cpp @@ -58,6 +58,13 @@ static const byteAssign_t byteAssignment[] = { { TYPE_INV, CH0, FLD_EFF, UNIT_PCT, CALC_TOTAL_EFF, 0, CMD_CALC, false, 3 } }; +static const channelMetaData_t channelMetaData[] = { + { CH0, MPPT_A }, + { CH1, MPPT_A }, + { CH2, MPPT_B }, + { CH3, MPPT_B } +}; + HMT_4CH::HMT_4CH(HoymilesRadio* radio, const uint64_t serial) : HMT_Abstract(radio, serial) {}; @@ -82,3 +89,13 @@ uint8_t HMT_4CH::getByteAssignmentSize() const { return sizeof(byteAssignment) / sizeof(byteAssignment[0]); } + +const channelMetaData_t* HMT_4CH::getChannelMetaData() const +{ + return channelMetaData; +} + +uint8_t HMT_4CH::getChannelMetaDataSize() const +{ + return sizeof(channelMetaData) / sizeof(channelMetaData[0]); +} diff --git a/lib/Hoymiles/src/inverters/HMT_4CH.h b/lib/Hoymiles/src/inverters/HMT_4CH.h index 01d328938..8f26c14b4 100644 --- a/lib/Hoymiles/src/inverters/HMT_4CH.h +++ b/lib/Hoymiles/src/inverters/HMT_4CH.h @@ -10,4 +10,6 @@ class HMT_4CH : public HMT_Abstract { String typeName() const; const byteAssign_t* getByteAssignment() const; uint8_t getByteAssignmentSize() const; -}; \ No newline at end of file + const channelMetaData_t* getChannelMetaData() const; + uint8_t getChannelMetaDataSize() const; +}; diff --git a/lib/Hoymiles/src/inverters/HMT_6CH.cpp b/lib/Hoymiles/src/inverters/HMT_6CH.cpp index 2c3dd5f3a..9e835671d 100644 --- a/lib/Hoymiles/src/inverters/HMT_6CH.cpp +++ b/lib/Hoymiles/src/inverters/HMT_6CH.cpp @@ -72,6 +72,15 @@ static const byteAssign_t byteAssignment[] = { { TYPE_INV, CH0, FLD_EFF, UNIT_PCT, CALC_TOTAL_EFF, 0, CMD_CALC, false, 3 } }; +static const channelMetaData_t channelMetaData[] = { + { CH0, MPPT_A }, + { CH1, MPPT_A }, + { CH2, MPPT_B }, + { CH3, MPPT_B }, + { CH4, MPPT_C }, + { CH5, MPPT_C } +}; + HMT_6CH::HMT_6CH(HoymilesRadio* radio, const uint64_t serial) : HMT_Abstract(radio, serial) {}; @@ -96,3 +105,13 @@ uint8_t HMT_6CH::getByteAssignmentSize() const { return sizeof(byteAssignment) / sizeof(byteAssignment[0]); } + +const channelMetaData_t* HMT_6CH::getChannelMetaData() const +{ + return channelMetaData; +} + +uint8_t HMT_6CH::getChannelMetaDataSize() const +{ + return sizeof(channelMetaData) / sizeof(channelMetaData[0]); +} diff --git a/lib/Hoymiles/src/inverters/HMT_6CH.h b/lib/Hoymiles/src/inverters/HMT_6CH.h index 6b7280068..ab8491743 100644 --- a/lib/Hoymiles/src/inverters/HMT_6CH.h +++ b/lib/Hoymiles/src/inverters/HMT_6CH.h @@ -10,4 +10,6 @@ class HMT_6CH : public HMT_Abstract { String typeName() const; const byteAssign_t* getByteAssignment() const; uint8_t getByteAssignmentSize() const; -}; \ No newline at end of file + const channelMetaData_t* getChannelMetaData() const; + uint8_t getChannelMetaDataSize() const; +}; diff --git a/lib/Hoymiles/src/inverters/HM_1CH.cpp b/lib/Hoymiles/src/inverters/HM_1CH.cpp index eef82c5c3..37761942b 100644 --- a/lib/Hoymiles/src/inverters/HM_1CH.cpp +++ b/lib/Hoymiles/src/inverters/HM_1CH.cpp @@ -28,6 +28,10 @@ static const byteAssign_t byteAssignment[] = { { TYPE_INV, CH0, FLD_EFF, UNIT_PCT, CALC_TOTAL_EFF, 0, CMD_CALC, false, 3 } }; +static const channelMetaData_t channelMetaData[] = { + { CH0, MPPT_A } +}; + HM_1CH::HM_1CH(HoymilesRadio* radio, const uint64_t serial) : HM_Abstract(radio, serial) {}; @@ -65,3 +69,13 @@ uint8_t HM_1CH::getByteAssignmentSize() const { return sizeof(byteAssignment) / sizeof(byteAssignment[0]); } + +const channelMetaData_t* HM_1CH::getChannelMetaData() const +{ + return channelMetaData; +} + +uint8_t HM_1CH::getChannelMetaDataSize() const +{ + return sizeof(channelMetaData) / sizeof(channelMetaData[0]); +} diff --git a/lib/Hoymiles/src/inverters/HM_1CH.h b/lib/Hoymiles/src/inverters/HM_1CH.h index a35b4e568..60724aab0 100644 --- a/lib/Hoymiles/src/inverters/HM_1CH.h +++ b/lib/Hoymiles/src/inverters/HM_1CH.h @@ -11,4 +11,6 @@ class HM_1CH : public HM_Abstract { String typeName() const; const byteAssign_t* getByteAssignment() const; uint8_t getByteAssignmentSize() const; -}; \ No newline at end of file + const channelMetaData_t* getChannelMetaData() const; + uint8_t getChannelMetaDataSize() const; +}; diff --git a/lib/Hoymiles/src/inverters/HM_2CH.cpp b/lib/Hoymiles/src/inverters/HM_2CH.cpp index 91228ff31..28778463d 100644 --- a/lib/Hoymiles/src/inverters/HM_2CH.cpp +++ b/lib/Hoymiles/src/inverters/HM_2CH.cpp @@ -36,6 +36,11 @@ static const byteAssign_t byteAssignment[] = { { TYPE_INV, CH0, FLD_EFF, UNIT_PCT, CALC_TOTAL_EFF, 0, CMD_CALC, false, 3 } }; +static const channelMetaData_t channelMetaData[] = { + { CH0, MPPT_A }, + { CH1, MPPT_B } +}; + HM_2CH::HM_2CH(HoymilesRadio* radio, const uint64_t serial) : HM_Abstract(radio, serial) {}; @@ -73,3 +78,13 @@ uint8_t HM_2CH::getByteAssignmentSize() const { return sizeof(byteAssignment) / sizeof(byteAssignment[0]); } + +const channelMetaData_t* HM_2CH::getChannelMetaData() const +{ + return channelMetaData; +} + +uint8_t HM_2CH::getChannelMetaDataSize() const +{ + return sizeof(channelMetaData) / sizeof(channelMetaData[0]); +} diff --git a/lib/Hoymiles/src/inverters/HM_2CH.h b/lib/Hoymiles/src/inverters/HM_2CH.h index 1fd54496a..34e9dbc05 100644 --- a/lib/Hoymiles/src/inverters/HM_2CH.h +++ b/lib/Hoymiles/src/inverters/HM_2CH.h @@ -10,4 +10,6 @@ class HM_2CH : public HM_Abstract { String typeName() const; const byteAssign_t* getByteAssignment() const; uint8_t getByteAssignmentSize() const; -}; \ No newline at end of file + const channelMetaData_t* getChannelMetaData() const; + uint8_t getChannelMetaDataSize() const; +}; diff --git a/lib/Hoymiles/src/inverters/HM_4CH.cpp b/lib/Hoymiles/src/inverters/HM_4CH.cpp index 45ebab40a..4d90e4ad4 100644 --- a/lib/Hoymiles/src/inverters/HM_4CH.cpp +++ b/lib/Hoymiles/src/inverters/HM_4CH.cpp @@ -49,6 +49,13 @@ static const byteAssign_t byteAssignment[] = { { TYPE_INV, CH0, FLD_EFF, UNIT_PCT, CALC_TOTAL_EFF, 0, CMD_CALC, false, 3 } }; +static const channelMetaData_t channelMetaData[] = { + { CH0, MPPT_A }, + { CH1, MPPT_A }, + { CH2, MPPT_B }, + { CH3, MPPT_B } +}; + HM_4CH::HM_4CH(HoymilesRadio* radio, const uint64_t serial) : HM_Abstract(radio, serial) {}; @@ -86,3 +93,13 @@ uint8_t HM_4CH::getByteAssignmentSize() const { return sizeof(byteAssignment) / sizeof(byteAssignment[0]); } + +const channelMetaData_t* HM_4CH::getChannelMetaData() const +{ + return channelMetaData; +} + +uint8_t HM_4CH::getChannelMetaDataSize() const +{ + return sizeof(channelMetaData) / sizeof(channelMetaData[0]); +} diff --git a/lib/Hoymiles/src/inverters/HM_4CH.h b/lib/Hoymiles/src/inverters/HM_4CH.h index e54f33234..cce045789 100644 --- a/lib/Hoymiles/src/inverters/HM_4CH.h +++ b/lib/Hoymiles/src/inverters/HM_4CH.h @@ -10,4 +10,6 @@ class HM_4CH : public HM_Abstract { String typeName() const; const byteAssign_t* getByteAssignment() const; uint8_t getByteAssignmentSize() const; -}; \ No newline at end of file + const channelMetaData_t* getChannelMetaData() const; + uint8_t getChannelMetaDataSize() const; +}; diff --git a/lib/Hoymiles/src/inverters/InverterAbstract.cpp b/lib/Hoymiles/src/inverters/InverterAbstract.cpp index 26a89c131..033fbb058 100644 --- a/lib/Hoymiles/src/inverters/InverterAbstract.cpp +++ b/lib/Hoymiles/src/inverters/InverterAbstract.cpp @@ -298,3 +298,35 @@ void InverterAbstract::resetRadioStats() { RadioStats = {}; } + +std::vector InverterAbstract::getChannelsDC() const +{ + std::vector l; + for (uint8_t i = 0; i < getChannelMetaDataSize(); i++) { + l.push_back(getChannelMetaData()[i].ch); + } + return l; +} + +std::vector InverterAbstract::getMppts() const +{ + std::vector l; + for (uint8_t i = 0; i < getChannelMetaDataSize(); i++) { + auto m = getChannelMetaData()[i].mppt; + if (l.end() == std::find(l.begin(), l.end(), m)){ + l.push_back(m); + } + } + return l; +} + +std::vector InverterAbstract::getChannelsDCByMppt(const MpptNum_t mppt) const +{ + std::vector l; + for (uint8_t i = 0; i < getChannelMetaDataSize(); i++) { + if (getChannelMetaData()[i].mppt == mppt) { + l.push_back(getChannelMetaData()[i].ch); + } + } + return l; +} diff --git a/lib/Hoymiles/src/inverters/InverterAbstract.h b/lib/Hoymiles/src/inverters/InverterAbstract.h index 29fba12fa..db2ed2556 100644 --- a/lib/Hoymiles/src/inverters/InverterAbstract.h +++ b/lib/Hoymiles/src/inverters/InverterAbstract.h @@ -24,6 +24,20 @@ enum { FRAGMENT_OK = 0 }; +enum MpptNum_t { + MPPT_A = 0, + MPPT_B, + MPPT_C, + MPPT_D, + MPPT_CNT +}; + +// additional meta data per input channel +typedef struct { + ChannelNum_t ch; // channel 0 - 5 + MpptNum_t mppt; // mppt a - d (0 - 3) +} channelMetaData_t; + #define MAX_RF_FRAGMENT_COUNT 13 class CommandAbstract; @@ -40,6 +54,9 @@ class InverterAbstract { virtual const byteAssign_t* getByteAssignment() const = 0; virtual uint8_t getByteAssignmentSize() const = 0; + virtual const channelMetaData_t* getChannelMetaData() const = 0; + virtual uint8_t getChannelMetaDataSize() const = 0; + bool isProducing(); bool isReachable(); @@ -112,6 +129,10 @@ class InverterAbstract { StatisticsParser* Statistics(); SystemConfigParaParser* SystemConfigPara(); + std::vector getMppts() const; + std::vector getChannelsDC() const; + std::vector getChannelsDCByMppt(const MpptNum_t mppt) const; + protected: HoymilesRadio* _radio; diff --git a/src/Configuration.cpp b/src/Configuration.cpp index 715659cb9..4b05e047c 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -105,6 +105,66 @@ void ConfigurationClass::serializeBatteryConfig(BatteryConfig const& source, Jso target["mqtt_amperage_unit"] = config.Battery.MqttAmperageUnit; } +void ConfigurationClass::serializePowerLimiterConfig(PowerLimiterConfig const& source, JsonObject& target) +{ + char serialBuffer[sizeof(uint64_t) * 8 + 1]; + auto serialStr = [&serialBuffer](uint64_t const& serial) -> String { + snprintf(serialBuffer, sizeof(serialBuffer), "%0x%08x", + static_cast((serial >> 32) & 0xFFFFFFFF), + static_cast(serial & 0xFFFFFFFF)); + return String(serialBuffer); + }; + + // we want a representation of our floating-point value in the JSON that + // uses the least amount of decimal digits possible to convey the value that + // is actually represented by the float. this is no easy task. ArduinoJson + // does this for us, however, it does it as expected only for variables of + // type double. this is probably because it assumes all floating-point + // values to have the precision of a double (64 bits), so it prints the + // respective number of siginificant decimals, which are too many if the + // actual value is a float (32 bits). + auto roundedFloat = [](float val) -> double { + return static_cast(val * 100 + (val > 0 ? 0.5 : -0.5)) / 100.0; + }; + + target["enabled"] = source.Enabled; + target["verbose_logging"] = source.VerboseLogging; + target["solar_passthrough_enabled"] = source.SolarPassThroughEnabled; + target["solar_passthrough_losses"] = source.SolarPassThroughLosses; + target["battery_always_use_at_night"] = source.BatteryAlwaysUseAtNight; + target["target_power_consumption"] = source.TargetPowerConsumption; + target["target_power_consumption_hysteresis"] = source.TargetPowerConsumptionHysteresis; + target["base_load_limit"] = source.BaseLoadLimit; + target["ignore_soc"] = source.IgnoreSoc; + target["battery_soc_start_threshold"] = source.BatterySocStartThreshold; + target["battery_soc_stop_threshold"] = source.BatterySocStopThreshold; + target["voltage_start_threshold"] = roundedFloat(source.VoltageStartThreshold); + target["voltage_stop_threshold"] = roundedFloat(source.VoltageStopThreshold); + target["voltage_load_correction_factor"] = source.VoltageLoadCorrectionFactor; + target["full_solar_passthrough_soc"] = source.FullSolarPassThroughSoc; + target["full_solar_passthrough_start_voltage"] = roundedFloat(source.FullSolarPassThroughStartVoltage); + target["full_solar_passthrough_stop_voltage"] = roundedFloat(source.FullSolarPassThroughStopVoltage); + target["inverter_serial_for_dc_voltage"] = serialStr(source.InverterSerialForDcVoltage); + target["inverter_channel_id_for_dc_voltage"] = source.InverterChannelIdForDcVoltage; + target["inverter_restart_hour"] = source.RestartHour; + target["total_upper_power_limit"] = source.TotalUpperPowerLimit; + + JsonArray inverters = target["inverters"].to(); + for (size_t i = 0; i < INV_MAX_COUNT; ++i) { + PowerLimiterInverterConfig const& s = source.Inverters[i]; + if (s.Serial == 0ULL) { break; } + JsonObject t = inverters.add(); + + t["serial"] = serialStr(s.Serial); + t["is_governed"] = s.IsGoverned; + t["is_behind_power_meter"] = s.IsBehindPowerMeter; + t["is_solar_powered"] = s.IsSolarPowered; + t["use_overscaling_to_compensate_shading"] = s.UseOverscalingToCompensateShading; + t["lower_power_limit"] = s.LowerPowerLimit; + t["upper_power_limit"] = s.UpperPowerLimit; + } +} + bool ConfigurationClass::write() { File f = LittleFS.open(CONFIG_FILENAME, "w"); @@ -259,32 +319,7 @@ bool ConfigurationClass::write() serializePowerMeterHttpSmlConfig(config.PowerMeter.HttpSml, powermeter_http_sml); JsonObject powerlimiter = doc["powerlimiter"].to(); - powerlimiter["enabled"] = config.PowerLimiter.Enabled; - powerlimiter["verbose_logging"] = config.PowerLimiter.VerboseLogging; - powerlimiter["solar_passtrough_enabled"] = config.PowerLimiter.SolarPassThroughEnabled; - powerlimiter["solar_passthrough_losses"] = config.PowerLimiter.SolarPassThroughLosses; - powerlimiter["battery_always_use_at_night"] = config.PowerLimiter.BatteryAlwaysUseAtNight; - powerlimiter["interval"] = config.PowerLimiter.Interval; - powerlimiter["is_inverter_behind_powermeter"] = config.PowerLimiter.IsInverterBehindPowerMeter; - powerlimiter["is_inverter_solar_powered"] = config.PowerLimiter.IsInverterSolarPowered; - powerlimiter["use_overscaling_to_compensate_shading"] = config.PowerLimiter.UseOverscalingToCompensateShading; - powerlimiter["inverter_id"] = config.PowerLimiter.InverterId; - powerlimiter["inverter_channel_id"] = config.PowerLimiter.InverterChannelId; - powerlimiter["target_power_consumption"] = config.PowerLimiter.TargetPowerConsumption; - powerlimiter["target_power_consumption_hysteresis"] = config.PowerLimiter.TargetPowerConsumptionHysteresis; - powerlimiter["lower_power_limit"] = config.PowerLimiter.LowerPowerLimit; - powerlimiter["base_load_limit"] = config.PowerLimiter.BaseLoadLimit; - powerlimiter["upper_power_limit"] = config.PowerLimiter.UpperPowerLimit; - powerlimiter["ignore_soc"] = config.PowerLimiter.IgnoreSoc; - powerlimiter["battery_soc_start_threshold"] = config.PowerLimiter.BatterySocStartThreshold; - powerlimiter["battery_soc_stop_threshold"] = config.PowerLimiter.BatterySocStopThreshold; - powerlimiter["voltage_start_threshold"] = config.PowerLimiter.VoltageStartThreshold; - powerlimiter["voltage_stop_threshold"] = config.PowerLimiter.VoltageStopThreshold; - powerlimiter["voltage_load_correction_factor"] = config.PowerLimiter.VoltageLoadCorrectionFactor; - powerlimiter["inverter_restart_hour"] = config.PowerLimiter.RestartHour; - powerlimiter["full_solar_passthrough_soc"] = config.PowerLimiter.FullSolarPassThroughSoc; - powerlimiter["full_solar_passthrough_start_voltage"] = config.PowerLimiter.FullSolarPassThroughStartVoltage; - powerlimiter["full_solar_passthrough_stop_voltage"] = config.PowerLimiter.FullSolarPassThroughStopVoltage; + serializePowerLimiterConfig(config.PowerLimiter, powerlimiter); JsonObject battery = doc["battery"].to(); serializeBatteryConfig(config.Battery, battery); @@ -322,7 +357,7 @@ void ConfigurationClass::deserializeHttpRequestConfig(JsonObject const& source, JsonObject source_http_config = source["http_request"]; // http request parameters of HTTP/JSON power meter were previously stored - // alongside other settings. TODO(schlimmchen): remove in early 2025. + // alongside other settings. TODO(schlimmchen): remove in mid 2025. if (source_http_config.isNull()) { source_http_config = source; } strlcpy(target.Url, source_http_config["url"] | "", sizeof(target.Url)); @@ -402,6 +437,49 @@ void ConfigurationClass::deserializeBatteryConfig(JsonObject const& source, Batt target.MqttAmperageUnit = source["mqtt_amperage_unit"] | BatteryAmperageUnit::Amps; } +void ConfigurationClass::deserializePowerLimiterConfig(JsonObject const& source, PowerLimiterConfig& target) +{ + auto serialBin = [](String const& input) -> uint64_t { + return strtoll(input.c_str(), NULL, 16); + }; + + target.Enabled = source["enabled"] | POWERLIMITER_ENABLED; + target.VerboseLogging = source["verbose_logging"] | VERBOSE_LOGGING; + target.SolarPassThroughEnabled = source["solar_passthrough_enabled"] | POWERLIMITER_SOLAR_PASSTHROUGH_ENABLED; + target.SolarPassThroughLosses = source["solar_passthrough_losses"] | POWERLIMITER_SOLAR_PASSTHROUGH_LOSSES; + target.BatteryAlwaysUseAtNight = source["battery_always_use_at_night"] | POWERLIMITER_BATTERY_ALWAYS_USE_AT_NIGHT; + target.TargetPowerConsumption = source["target_power_consumption"] | POWERLIMITER_TARGET_POWER_CONSUMPTION; + target.TargetPowerConsumptionHysteresis = source["target_power_consumption_hysteresis"] | POWERLIMITER_TARGET_POWER_CONSUMPTION_HYSTERESIS; + target.BaseLoadLimit = source["base_load_limit"] | POWERLIMITER_BASE_LOAD_LIMIT; + target.IgnoreSoc = source["ignore_soc"] | POWERLIMITER_IGNORE_SOC; + target.BatterySocStartThreshold = source["battery_soc_start_threshold"] | POWERLIMITER_BATTERY_SOC_START_THRESHOLD; + target.BatterySocStopThreshold = source["battery_soc_stop_threshold"] | POWERLIMITER_BATTERY_SOC_STOP_THRESHOLD; + target.VoltageStartThreshold = source["voltage_start_threshold"] | POWERLIMITER_VOLTAGE_START_THRESHOLD; + target.VoltageStopThreshold = source["voltage_stop_threshold"] | POWERLIMITER_VOLTAGE_STOP_THRESHOLD; + target.VoltageLoadCorrectionFactor = source["voltage_load_correction_factor"] | POWERLIMITER_VOLTAGE_LOAD_CORRECTION_FACTOR; + target.FullSolarPassThroughSoc = source["full_solar_passthrough_soc"] | POWERLIMITER_FULL_SOLAR_PASSTHROUGH_SOC; + target.FullSolarPassThroughStartVoltage = source["full_solar_passthrough_start_voltage"] | POWERLIMITER_FULL_SOLAR_PASSTHROUGH_START_VOLTAGE; + target.FullSolarPassThroughStopVoltage = source["full_solar_passthrough_stop_voltage"] | POWERLIMITER_FULL_SOLAR_PASSTHROUGH_STOP_VOLTAGE; + target.InverterSerialForDcVoltage = serialBin(source["inverter_serial_for_dc_voltage"] | String("0")); + 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; + + JsonArray inverters = source["inverters"].as(); + for (size_t i = 0; i < INV_MAX_COUNT; ++i) { + PowerLimiterInverterConfig& inv = target.Inverters[i]; + JsonObject s = inverters[i]; + + inv.Serial = serialBin(s["serial"] | String("0")); // 0 marks inverter slot as unused + inv.IsGoverned = s["is_governed"] | false; + inv.IsBehindPowerMeter = s["is_behind_power_meter"] | POWERLIMITER_IS_INVERTER_BEHIND_POWER_METER; + inv.IsSolarPowered = s["is_solar_powered"] | POWERLIMITER_IS_INVERTER_SOLAR_POWERED; + inv.UseOverscalingToCompensateShading = s["use_overscaling_to_compensate_shading"] | POWERLIMITER_USE_OVERSCALING_TO_COMPENSATE_SHADING; + inv.LowerPowerLimit = s["lower_power_limit"] | POWERLIMITER_LOWER_POWER_LIMIT; + inv.UpperPowerLimit = s["upper_power_limit"] | POWERLIMITER_UPPER_POWER_LIMIT; + } +} + bool ConfigurationClass::read() { File f = LittleFS.open(CONFIG_FILENAME, "r", false); @@ -591,7 +669,7 @@ bool ConfigurationClass::read() deserializePowerMeterMqttConfig(powermeter["mqtt"], config.PowerMeter.Mqtt); // process settings from legacy config if they are present - // TODO(schlimmchen): remove in early 2025. + // TODO(schlimmchen): remove in mid 2025. if (!powermeter["mqtt_topic_powermeter_1"].isNull()) { auto& values = config.PowerMeter.Mqtt.Values; strlcpy(values[0].Topic, powermeter["mqtt_topic_powermeter_1"], sizeof(values[0].Topic)); @@ -602,7 +680,7 @@ bool ConfigurationClass::read() deserializePowerMeterSerialSdmConfig(powermeter["serial_sdm"], config.PowerMeter.SerialSdm); // process settings from legacy config if they are present - // TODO(schlimmchen): remove in early 2025. + // TODO(schlimmchen): remove in mid 2025. if (!powermeter["sdmaddress"].isNull()) { config.PowerMeter.SerialSdm.Address = powermeter["sdmaddress"]; } @@ -614,7 +692,7 @@ bool ConfigurationClass::read() deserializePowerMeterHttpSmlConfig(powermeter_sml, config.PowerMeter.HttpSml); // process settings from legacy config if they are present - // TODO(schlimmchen): remove in early 2025. + // TODO(schlimmchen): remove in mid 2025. if (!powermeter["http_phases"].isNull()) { auto& target = config.PowerMeter.HttpJson; @@ -634,33 +712,48 @@ bool ConfigurationClass::read() } JsonObject powerlimiter = doc["powerlimiter"]; - config.PowerLimiter.Enabled = powerlimiter["enabled"] | POWERLIMITER_ENABLED; - config.PowerLimiter.VerboseLogging = powerlimiter["verbose_logging"] | VERBOSE_LOGGING; - config.PowerLimiter.SolarPassThroughEnabled = powerlimiter["solar_passtrough_enabled"] | POWERLIMITER_SOLAR_PASSTHROUGH_ENABLED; - config.PowerLimiter.SolarPassThroughLosses = powerlimiter["solar_passthrough_losses"] | powerlimiter["solar_passtrough_losses"] | POWERLIMITER_SOLAR_PASSTHROUGH_LOSSES; // solar_passthrough_losses was previously saved as solar_passtrough_losses. Be nice and also try mistyped key. - config.PowerLimiter.BatteryAlwaysUseAtNight = powerlimiter["battery_always_use_at_night"] | POWERLIMITER_BATTERY_ALWAYS_USE_AT_NIGHT; - if (powerlimiter["battery_drain_strategy"].as() == 1) { config.PowerLimiter.BatteryAlwaysUseAtNight = true; } // convert legacy setting - config.PowerLimiter.Interval = powerlimiter["interval"] | POWERLIMITER_INTERVAL; - config.PowerLimiter.IsInverterBehindPowerMeter = powerlimiter["is_inverter_behind_powermeter"] | POWERLIMITER_IS_INVERTER_BEHIND_POWER_METER; - config.PowerLimiter.IsInverterSolarPowered = powerlimiter["is_inverter_solar_powered"] | POWERLIMITER_IS_INVERTER_SOLAR_POWERED; - config.PowerLimiter.UseOverscalingToCompensateShading = powerlimiter["use_overscaling_to_compensate_shading"] | POWERLIMITER_USE_OVERSCALING_TO_COMPENSATE_SHADING; - config.PowerLimiter.InverterId = powerlimiter["inverter_id"] | POWERLIMITER_INVERTER_ID; - config.PowerLimiter.InverterChannelId = powerlimiter["inverter_channel_id"] | POWERLIMITER_INVERTER_CHANNEL_ID; - config.PowerLimiter.TargetPowerConsumption = powerlimiter["target_power_consumption"] | POWERLIMITER_TARGET_POWER_CONSUMPTION; - config.PowerLimiter.TargetPowerConsumptionHysteresis = powerlimiter["target_power_consumption_hysteresis"] | POWERLIMITER_TARGET_POWER_CONSUMPTION_HYSTERESIS; - config.PowerLimiter.LowerPowerLimit = powerlimiter["lower_power_limit"] | POWERLIMITER_LOWER_POWER_LIMIT; - config.PowerLimiter.BaseLoadLimit = powerlimiter["base_load_limit"] | POWERLIMITER_BASE_LOAD_LIMIT; - config.PowerLimiter.UpperPowerLimit = powerlimiter["upper_power_limit"] | POWERLIMITER_UPPER_POWER_LIMIT; - config.PowerLimiter.IgnoreSoc = powerlimiter["ignore_soc"] | POWERLIMITER_IGNORE_SOC; - config.PowerLimiter.BatterySocStartThreshold = powerlimiter["battery_soc_start_threshold"] | POWERLIMITER_BATTERY_SOC_START_THRESHOLD; - config.PowerLimiter.BatterySocStopThreshold = powerlimiter["battery_soc_stop_threshold"] | POWERLIMITER_BATTERY_SOC_STOP_THRESHOLD; - config.PowerLimiter.VoltageStartThreshold = powerlimiter["voltage_start_threshold"] | POWERLIMITER_VOLTAGE_START_THRESHOLD; - config.PowerLimiter.VoltageStopThreshold = powerlimiter["voltage_stop_threshold"] | POWERLIMITER_VOLTAGE_STOP_THRESHOLD; - config.PowerLimiter.VoltageLoadCorrectionFactor = powerlimiter["voltage_load_correction_factor"] | POWERLIMITER_VOLTAGE_LOAD_CORRECTION_FACTOR; - config.PowerLimiter.RestartHour = powerlimiter["inverter_restart_hour"] | POWERLIMITER_RESTART_HOUR; - config.PowerLimiter.FullSolarPassThroughSoc = powerlimiter["full_solar_passthrough_soc"] | POWERLIMITER_FULL_SOLAR_PASSTHROUGH_SOC; - config.PowerLimiter.FullSolarPassThroughStartVoltage = powerlimiter["full_solar_passthrough_start_voltage"] | POWERLIMITER_FULL_SOLAR_PASSTHROUGH_START_VOLTAGE; - config.PowerLimiter.FullSolarPassThroughStopVoltage = powerlimiter["full_solar_passthrough_stop_voltage"] | POWERLIMITER_FULL_SOLAR_PASSTHROUGH_STOP_VOLTAGE; + deserializePowerLimiterConfig(powerlimiter, config.PowerLimiter); + + if (powerlimiter["battery_drain_strategy"].as() == 1) { + config.PowerLimiter.BatteryAlwaysUseAtNight = true; // convert legacy setting + } + + if (!powerlimiter["solar_passtrough_enabled"].isNull()) { + // solar_passthrough_enabled was previously saved as + // solar_passtrough_enabled. be nice and also try misspelled key. + config.PowerLimiter.SolarPassThroughEnabled = powerlimiter["solar_passtrough_enabled"].as(); + } + + if (!powerlimiter["solar_passtrough_losses"].isNull()) { + // solar_passthrough_losses was previously saved as + // solar_passtrough_losses. be nice and also try misspelled key. + config.PowerLimiter.SolarPassThroughLosses = powerlimiter["solar_passtrough_losses"].as(); + } + + // process settings from legacy config if they are present + // TODO(schlimmchen): remove in mid 2025. + if (!powerlimiter["inverter_id"].isNull()) { + config.PowerLimiter.InverterChannelIdForDcVoltage = powerlimiter["inverter_channel_id"] | POWERLIMITER_INVERTER_CHANNEL_ID; + + auto& inv = config.PowerLimiter.Inverters[0]; + uint64_t previousInverterSerial = powerlimiter["inverter_id"].as(); + if (previousInverterSerial < INV_MAX_COUNT) { + // we previously had an index (not a serial) saved as inverter_id. + previousInverterSerial = config.Inverter[inv.Serial].Serial; // still 0 if no inverters configured + } + inv.Serial = previousInverterSerial; + config.PowerLimiter.InverterSerialForDcVoltage = previousInverterSerial; + inv.IsGoverned = true; + inv.IsBehindPowerMeter = powerlimiter["is_inverter_behind_powermeter"] | POWERLIMITER_IS_INVERTER_BEHIND_POWER_METER; + inv.IsSolarPowered = powerlimiter["is_inverter_solar_powered"] | POWERLIMITER_IS_INVERTER_SOLAR_POWERED; + inv.UseOverscalingToCompensateShading = powerlimiter["use_overscaling_to_compensate_shading"] | POWERLIMITER_USE_OVERSCALING_TO_COMPENSATE_SHADING; + inv.LowerPowerLimit = powerlimiter["lower_power_limit"] | POWERLIMITER_LOWER_POWER_LIMIT; + inv.UpperPowerLimit = powerlimiter["upper_power_limit"] | POWERLIMITER_UPPER_POWER_LIMIT; + + config.PowerLimiter.TotalUpperPowerLimit = inv.UpperPowerLimit; + + config.PowerLimiter.Inverters[1].Serial = 0; + } deserializeBatteryConfig(doc["battery"], config.Battery); diff --git a/src/Huawei_can.cpp b/src/Huawei_can.cpp index c215ddb16..d1ed98b9b 100644 --- a/src/Huawei_can.cpp +++ b/src/Huawei_can.cpp @@ -356,25 +356,12 @@ void HuaweiCanClass::loop() _autoPowerEnabledCounter = 10; } - - // Check if inverter used by the power limiter is active - std::shared_ptr inverter = - Hoymiles.getInverterBySerial(config.PowerLimiter.InverterId); - - if (inverter == 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. - inverter = Hoymiles.getInverterByPos(config.PowerLimiter.InverterId); - } - - if (inverter != nullptr) { - if(inverter->isProducing()) { - _setValue(0.0, HUAWEI_ONLINE_CURRENT); - // Don't run auto mode for a second now. Otherwise we may send too much over the CAN bus - _autoModeBlockedTillMillis = millis() + 1000; - MessageOutput.printf("[HuaweiCanClass::loop] Inverter is active, disable\r\n"); - return; - } + if (PowerLimiter.isGovernedInverterProducing()) { + _setValue(0.0, HUAWEI_ONLINE_CURRENT); + // Don't run auto mode for a second now. Otherwise we may send too much over the CAN bus + _autoModeBlockedTillMillis = millis() + 1000; + MessageOutput.printf("[HuaweiCanClass::loop] Inverter is active, disable\r\n"); + return; } if (PowerMeter.getLastUpdate() > _lastPowerMeterUpdateReceivedMillis && diff --git a/src/MqttHandlePowerLimiter.cpp b/src/MqttHandlePowerLimiter.cpp index 0676129cd..d4861e629 100644 --- a/src/MqttHandlePowerLimiter.cpp +++ b/src/MqttHandlePowerLimiter.cpp @@ -87,15 +87,15 @@ void MqttHandlePowerLimiterClass::loop() auto val = static_cast(PowerLimiter.getMode()); MqttSettings.publish("powerlimiter/status/mode", String(val)); - - MqttSettings.publish("powerlimiter/status/upper_power_limit", String(config.PowerLimiter.UpperPowerLimit)); + + MqttSettings.publish("powerlimiter/status/upper_power_limit", String(config.PowerLimiter.TotalUpperPowerLimit)); MqttSettings.publish("powerlimiter/status/target_power_consumption", String(config.PowerLimiter.TargetPowerConsumption)); MqttSettings.publish("powerlimiter/status/inverter_update_timeouts", String(PowerLimiter.getInverterUpdateTimeouts())); // no thresholds are relevant for setups without a battery - if (config.PowerLimiter.IsInverterSolarPowered) { return; } + if (!PowerLimiter.usesBatteryPoweredInverter()) { return; } MqttSettings.publish("powerlimiter/status/threshold/voltage/start", String(config.PowerLimiter.VoltageStartThreshold)); MqttSettings.publish("powerlimiter/status/threshold/voltage/stop", String(config.PowerLimiter.VoltageStopThreshold)); @@ -195,9 +195,9 @@ void MqttHandlePowerLimiterClass::onMqttCmd(MqttPowerLimiterCommand command, con config.PowerLimiter.FullSolarPassThroughStopVoltage = payload_val; break; case MqttPowerLimiterCommand::UpperPowerLimit: - if (config.PowerLimiter.UpperPowerLimit == intValue) { return; } - MessageOutput.printf("Setting upper power limit to: %d W\r\n", intValue); - config.PowerLimiter.UpperPowerLimit = intValue; + if (config.PowerLimiter.TotalUpperPowerLimit == intValue) { return; } + MessageOutput.printf("Setting total upper power limit to: %d W\r\n", intValue); + config.PowerLimiter.TotalUpperPowerLimit = intValue; break; case MqttPowerLimiterCommand::TargetPowerConsumption: if (config.PowerLimiter.TargetPowerConsumption == intValue) { return; } diff --git a/src/MqttHandlePowerLimiterHass.cpp b/src/MqttHandlePowerLimiterHass.cpp index e5706cd85..43642f9fb 100644 --- a/src/MqttHandlePowerLimiterHass.cpp +++ b/src/MqttHandlePowerLimiterHass.cpp @@ -9,6 +9,7 @@ #include "NetworkSettings.h" #include "MessageOutput.h" #include "Utils.h" +#include "PowerLimiter.h" #include "__compiled_constants.h" MqttHandlePowerLimiterHassClass MqttHandlePowerLimiterHass; @@ -64,7 +65,7 @@ void MqttHandlePowerLimiterHassClass::publishConfig() publishSelect("DPL Mode", "mdi:gauge", "config", "mode", "mode"); - if (config.PowerLimiter.IsInverterSolarPowered) { + if (!PowerLimiter.usesBatteryPoweredInverter()) { return; } diff --git a/src/PowerLimiter.cpp b/src/PowerLimiter.cpp index 1e0123329..922f2a840 100644 --- a/src/PowerLimiter.cpp +++ b/src/PowerLimiter.cpp @@ -3,7 +3,6 @@ * Copyright (C) 2022 Thomas Basler and others */ -#include "RestartHelper.h" #include "Battery.h" #include "PowerMeter.h" #include "PowerLimiter.h" @@ -13,12 +12,24 @@ #include "Huawei_can.h" #include #include "MessageOutput.h" -#include "inverters/HMS_4CH.h" #include #include +#include #include #include "SunPosition.h" +static auto sBatteryPoweredFilter = [](PowerLimiterInverter const& inv) { + return !inv.isSolarPowered(); +}; + +static const char sBatteryPoweredExpression[] = "battery-powered inverters"; + +static auto sSolarPoweredFilter = [](PowerLimiterInverter const& inv) { + return inv.isSolarPowered(); +}; + +static const char sSolarPoweredExpression[] = "solar-powered inverters"; + PowerLimiterClass PowerLimiter; void PowerLimiterClass::init(Scheduler& scheduler) @@ -33,25 +44,18 @@ frozen::string const& PowerLimiterClass::getStatusText(PowerLimiterClass::Status { static const frozen::string missing = "programmer error: missing status text"; - static const frozen::map texts = { + static const frozen::map 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::InverterCmdPending, "waiting for a start/stop/restart/limit command to complete" }, + { Status::ConfigReload, "reloading DPL configuration" }, { Status::InverterStatsPending, "waiting for sufficiently recent inverter data" }, - { Status::CalculatedLimitBelowMinLimit, "calculated limit is less than minimum power limit" }, + { Status::FullSolarPassthrough, "passing through all solar power (full solar passthrough)" }, { 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" }, }; @@ -80,25 +84,65 @@ void PowerLimiterClass::announceStatus(PowerLimiterClass::Status status) } /** - * 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. + * returns true if the inverters' state was changed or is about to change, i.e., + * if any are actually in need of a shutdown. returns false otherwise, i.e., the + * inverters are already shut down. */ bool PowerLimiterClass::shutdown(PowerLimiterClass::Status status) { announceStatus(status); - _shutdownPending = true; + for (auto& upInv : _inverters) { upInv->standby(); } - _oTargetPowerState = false; + return updateInverters(); +} + +void PowerLimiterClass::reloadConfig() +{ + auto const& config = Configuration.get(); - return updateInverter(); + _verboseLogging = config.PowerLimiter.VerboseLogging; + + // clean up all inverter instances. put inverters into + // standby if they will not be governed any more. + auto iter = _inverters.begin(); + while (iter != _inverters.end()) { + bool stillGoverned = false; + + for (size_t i = 0; i < INV_MAX_COUNT; ++i) { + auto const& inv = config.PowerLimiter.Inverters[i]; + if (inv.Serial == 0ULL) { break; } + stillGoverned = inv.Serial == (*iter)->getSerial() && inv.IsGoverned; + if (stillGoverned) { break; } + } + + if (!stillGoverned) { + (*iter)->standby(); + if ((*iter)->update()) { return; } + } + + iter = _inverters.erase(iter); + } + + for (size_t i = 0; i < INV_MAX_COUNT; ++i) { + auto const& invConfig = config.PowerLimiter.Inverters[i]; + + if (invConfig.Serial == 0ULL) { break; } + + if (!invConfig.IsGoverned) { continue; } + + auto upInv = PowerLimiterInverter::create(_verboseLogging, invConfig); + if (upInv) { _inverters.push_back(std::move(upInv)); } + } + + calcNextInverterRestart(); + + _reloadConfigFlag = false; } void PowerLimiterClass::loop() { auto 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 @@ -109,12 +153,9 @@ void PowerLimiterClass::loop() } // take care that the last requested power - // limit and power state are actually reached - if (updateInverter()) { return; } - - if (_shutdownPending) { - _shutdownPending = false; - _inverter = nullptr; + // limits and power states are actually reached + if (updateInverters()) { + return announceStatus(Status::InverterCmdPending); } if (!config.PowerLimiter.Enabled) { @@ -127,72 +168,32 @@ void PowerLimiterClass::loop() return; } - std::shared_ptr 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); + if (_reloadConfigFlag) { + reloadConfig(); + return announceStatus(Status::ConfigReload); } - // 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 (_inverters.empty()) { + return announceStatus(Status::InverterInvalid); } if (Mode::UnconditionalFullSolarPassthrough == _mode) { - // handle this mode of operation separately - return unconditionalSolarPassthrough(_inverter); + return fullSolarPassthrough(Status::UnconditionalSolarPassthrough); } - // 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 (isFullSolarPassthroughActive()) { + return fullSolarPassthrough(Status::FullSolarPassthrough); } - if (!_oInverterStatsMillis.has_value()) { - auto lastStats = _inverter->Statistics()->getLastUpdate(); - if (lastStats <= lastUpdateCmd) { + uint32_t latestInverterStats = 0; + + for (auto const& upInv : _inverters) { + auto oStatsMillis = upInv->getLatestStatsMillis(); + if (!oStatsMillis) { return announceStatus(Status::InverterStatsPending); } - _oInverterStatsMillis = lastStats; + latestInverterStats = std::max(*oStatsMillis, latestInverterStats); } // if the power meter is being used, i.e., if its data is valid, we want to @@ -202,13 +203,13 @@ void PowerLimiterClass::loop() // 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.getLastUpdate() <= (*_oInverterStatsMillis + 2000)) { + if (PowerMeter.isDataValid() && PowerMeter.getLastUpdate() <= (latestInverterStats + 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)) { + if ((millis() - _lastCalculation) < _calculationBackoffMs) { return announceStatus(Status::Stable); } @@ -216,31 +217,27 @@ void PowerLimiterClass::loop() 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(); - } + auto autoRestartInverters = [this]() -> void { + if (!_nextInverterRestart.first) { return; } // no automatic restarts - // 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 constexpr halfOfAllMillis = std::numeric_limits::max() / 2; + auto diff = _nextInverterRestart.second - millis(); + if (diff < halfOfAllMillis) { return; } + + MessageOutput.println("[DPL::loop] send inverter restart command"); + for (auto& upInv : _inverters) { + if (!upInv->isSolarPowered()) { upInv->restart(); } } - } + + calcNextInverterRestart(); + }; + + autoRestartInverters(); auto getBatteryPower = [this,&config]() -> bool { - if (config.PowerLimiter.IsInverterSolarPowered) { return false; } + if (!usesBatteryPoweredInverter()) { return false; } - auto isDayPeriod = SunPosition.isSunsetAvailable() ? SunPosition.isDayPeriod() : getSolarPower() > 0; + auto isDayPeriod = SunPosition.isSunsetAvailable() ? SunPosition.isDayPeriod() : true; if (_nighttimeDischarging && isDayPeriod) { _nighttimeDischarging = false; @@ -265,8 +262,8 @@ void PowerLimiterClass::loop() _batteryDischargeEnabled = getBatteryPower(); - if (_verboseLogging && !config.PowerLimiter.IsInverterSolarPowered) { - MessageOutput.printf("[DPL::loop] battery interface %s, SoC: %f %%, StartTH: %d %%, StopTH: %d %%, SoC age: %d s, ignore: %s\r\n", + if (_verboseLogging && usesBatteryPoweredInverter()) { + MessageOutput.printf("[DPL::loop] battery interface %s, SoC: %.1f %%, StartTH: %d %%, StopTH: %d %%, SoC age: %d s, ignore: %s\r\n", (config.Battery.Enabled?"enabled":"disabled"), Battery.getStats()->getSoC(), config.PowerLimiter.BatterySocStartThreshold, @@ -287,8 +284,32 @@ void PowerLimiterClass::loop() (config.PowerLimiter.BatteryAlwaysUseAtNight?"yes":"no")); }; - // Calculate and set Power Limit (NOTE: might reset _inverter to nullptr!) - bool limitUpdated = calcPowerLimit(_inverter, getSolarPower(), getBatteryDischargeLimit(), _batteryDischargeEnabled); + // this value is negative if we are exporting power to the grid + // from power sources other than DPL-governed inverters. + int16_t consumption = calcHouseholdConsumption(); + + uint16_t inverterTotalPower = (consumption > 0) ? static_cast(consumption) : 0; + + auto totalAllowance = config.PowerLimiter.TotalUpperPowerLimit; + inverterTotalPower = std::min(inverterTotalPower, totalAllowance); + + auto coveredBySolar = updateInverterLimits(inverterTotalPower, sSolarPoweredFilter, sSolarPoweredExpression); + auto remaining = (inverterTotalPower >= coveredBySolar) ? inverterTotalPower - coveredBySolar : 0; + auto batteryAllowance = calcBatteryAllowance(remaining); + auto coveredByBattery = updateInverterLimits(batteryAllowance, sBatteryPoweredFilter, sBatteryPoweredExpression); + + if (_verboseLogging) { + MessageOutput.printf("[DPL::loop] consumption: %d W, " + "target output: %u W (limited to %d W), " + "solar inverters output: %u W, battery allowance: " + "%u W, battery inverters output: %u W\r\n", + consumption, inverterTotalPower, totalAllowance, + coveredBySolar, batteryAllowance, coveredByBattery); + } + + _lastExpectedInverterOutput = coveredBySolar + coveredByBattery; + + bool limitUpdated = updateInverters(); _lastCalculation = millis(); @@ -301,6 +322,25 @@ void PowerLimiterClass::loop() _calculationBackoffMs = _calculationBackoffMsDefault; } +std::pair PowerLimiterClass::getInverterDcVoltage() { + auto const& config = Configuration.get(); + + auto iter = _inverters.begin(); + while(iter != _inverters.end()) { + if ((*iter)->getSerial() == config.PowerLimiter.InverterSerialForDcVoltage) { + break; + } + ++iter; + } + + if (iter == _inverters.end()) { + return { -1.0, "" }; + } + + auto voltage = (*iter)->getDcVoltage(config.PowerLimiter.InverterChannelIdForDcVoltage); + return { voltage, (*iter)->getSerialStr() }; +} + /** * 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 @@ -309,16 +349,12 @@ void PowerLimiterClass::loop() * 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(config.PowerLimiter.InverterChannelId); - float inverterVoltage = _inverter->Statistics()->getChannelFieldValue(TYPE_DC, channel, FLD_UDC); - float res = inverterVoltage; + + float res = 0; + + auto inverter = getInverterDcVoltage(); + if (inverter.first > 0) { res = inverter.first; } float chargeControllerVoltage = -1; if (VictronMppt.isDataValid()) { @@ -334,602 +370,343 @@ float PowerLimiterClass::getBatteryVoltage(bool log) { } if (log) { - MessageOutput.printf("[DPL::getBatteryVoltage] BMS: %.2f V, MPPT: %.2f V, inverter: %.2f V, returning: %.2fV\r\n", - bmsVoltage, chargeControllerVoltage, inverterVoltage, res); + MessageOutput.printf("[DPL::getBatteryVoltage] BMS: %.2f V, MPPT: %.2f V, " + "inverter %s: %.2f V, returning: %.2fV\r\n", bmsVoltage, + chargeControllerVoltage, inverter.second, inverter.first, res); } return res; } -static float getInverterEfficiency(std::shared_ptr inverter) -{ - 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) - return (inverterEfficiencyPercent > 0) ? inverterEfficiencyPercent/100 : 0.967; -} - /** * 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 inverter, int32_t dcPower) +uint16_t PowerLimiterClass::solarDcToInverterAc(uint16_t dcPower) { - auto const& config = Configuration.get(); - - float inverterEfficiencyFactor = getInverterEfficiency(inverter); - // account for losses between solar charger and inverter (cables, junctions...) + auto const& config = Configuration.get(); float lossesFactor = 1.00 - static_cast(config.PowerLimiter.SolarPassThroughLosses)/100; - return dcPower * inverterEfficiencyFactor * lossesFactor; + // we cannot know the efficiency at the new limit. even if we could we + // cannot know which inverter is assigned which limit. hence we use a + // reasonable, conservative, fixed inverter efficiency. + return 0.95 * lossesFactor * dcPower; } /** - * 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. + * implements the "full solar passthrough" mode of operation. in this mode of + * operation, the inverters shall behave as if they were connected to the solar + * panels directly, i.e., all solar power (and only solar power) is converted + * to AC power, independent from the power meter reading. */ -void PowerLimiterClass::unconditionalSolarPassthrough(std::shared_ptr inverter) +void PowerLimiterClass::fullSolarPassthrough(PowerLimiterClass::Status reason) { 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; + for (auto& upInv : _inverters) { + if (upInv->isSolarPowered()) { upInv->setMaxOutput(); } } - if (!VictronMppt.isDataValid()) { - shutdown(Status::NoVeDirect); - return; + uint16_t targetOutput = 0; + + if (VictronMppt.isDataValid()) { + targetOutput = static_cast(std::max(0, VictronMppt.getPowerOutputWatts())); + targetOutput = solarDcToInverterAc(targetOutput); } _calculationBackoffMs = 1 * 1000; - int32_t solarPower = VictronMppt.getPowerOutputWatts(); - setNewPowerLimit(inverter, inverterPowerDcToAc(inverter, solarPower)); - announceStatus(Status::UnconditionalSolarPassthrough); + updateInverterLimits(targetOutput, sBatteryPoweredFilter, sBatteryPoweredExpression); + return announceStatus(reason); } -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; +uint8_t PowerLimiterClass::getInverterUpdateTimeouts() const +{ + uint8_t res = 0; + for (auto const& upInv : _inverters) { + res += upInv->getUpdateTimeouts(); } - - return PL_UI_STATE_INACTIVE; + return res; } -// Logic table ("PowerMeter value" can be "base load setting" as a fallback) -// | Case # | batteryPower | solarPower | batteryLimit | useFullSolarPassthrough | Resulting inverter limit | -// | 1 | false | < 20 W | doesn't matter | doesn't matter | 0 (inverter off) | -// | 2 | false | >= 20 W | doesn't matter | doesn't matter | min(PowerMeter value, solarPower) | -// | 3 | true | fully passed | applied | false | min(PowerMeter value batteryLimit+solarPower) | -// | 4 | true | fully passed | doesn't matter | true | max(PowerMeter value, solarPower) | - -bool PowerLimiterClass::calcPowerLimit(std::shared_ptr inverter, int32_t solarPowerDC, int32_t batteryPowerLimitDC, bool batteryPower) +uint8_t PowerLimiterClass::getPowerLimiterState() { - if (_verboseLogging) { - MessageOutput.printf("[DPL::calcPowerLimit] battery use %s, solar power (DC): %d W, battery limit (DC): %d W\r\n", - (batteryPower?"allowed":"prevented"), solarPowerDC, batteryPowerLimitDC); + bool reachable = false; + bool producing = false; + for (auto const& upInv : _inverters) { + reachable |= upInv->isReachable(); + producing |= upInv->isProducing(); } - // Case 1: - if (solarPowerDC <= 0 && !batteryPower) { - return shutdown(Status::NoEnergy); + if (!reachable) { + return PL_UI_STATE_INACTIVE; } - // 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); + if (!producing) { + return PL_UI_STATE_CHARGING; } - auto meterValid = PowerMeter.isDataValid(); - - auto meterValue = static_cast(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(inverter->Statistics()->getChannelFieldValue(TYPE_AC, CH0, FLD_PAC)); - - auto batteryPowerLimitAC = inverterPowerDcToAc(inverter, batteryPowerLimitDC); - auto solarPowerAC = inverterPowerDcToAc(inverter, solarPowerDC); + return _batteryDischargeEnabled ? PL_UI_STATE_USE_SOLAR_AND_BATTERY : PL_UI_STATE_USE_SOLAR_ONLY; +} +int16_t PowerLimiterClass::calcHouseholdConsumption() +{ 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, battery limit (AC): %d W\r\n", - meterValue, - (meterValid?"yes":"no"), - inverterOutput, - solarPowerAC, - batteryPowerLimitAC); - } - - auto newPowerLimit = baseLoad; - if (meterValid) { - newPowerLimit = meterValue; + auto meterValid = PowerMeter.isDataValid(); + auto meterValue = PowerMeter.getPowerTotal(); - 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; - } + if (_verboseLogging) { + MessageOutput.printf("[DPL::calcHouseholdConsumption] target " + "consumption: %d W, base load: %u W\r\n", + targetConsumption, baseLoad); - newPowerLimit -= targetConsumption; + MessageOutput.printf("[DPL::calcHouseholdConsumption] power meter " + "value: %.1f W, power meter valid: %s\r\n", + meterValue, (meterValid?"yes":"no")); } - // Case 2: - if (!batteryPower) { - newPowerLimit = std::min(newPowerLimit, solarPowerAC); + if (!meterValid) { return baseLoad; } - // 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); - } else { // on batteryPower - // Apply battery-provided discharge power limit. - if (newPowerLimit > batteryPowerLimitAC + solarPowerAC) { - newPowerLimit = batteryPowerLimitAC + solarPowerAC; - if (_verboseLogging) { - MessageOutput.printf("[DPL::calcPowerLimit] limited by battery to: %d W\r\n", - newPowerLimit); - } - } - } + auto consumption = static_cast(meterValue + (meterValue > 0 ? 0.5 : -0.5)); - // Case 4: - // convert all solar power if full solar-passthrough is active - if (useFullSolarPassthrough()) { - newPowerLimit = std::max(newPowerLimit, solarPowerAC); + for (auto const& upInv : _inverters) { + if (!upInv->isBehindPowerMeter()) { continue; } + // 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. + auto invOutput = upInv->getCurrentOutputAcWatts(); + consumption += invOutput; if (_verboseLogging) { - MessageOutput.printf("[DPL::calcPowerLimit] full solar-passthrough active: %d W\r\n", - newPowerLimit); + MessageOutput.printf("[DPL::calcHouseholdConsumption] inverter %s is " + "behind power meter producing %u W\r\n", upInv->getSerialStr(), invOutput); } - - 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); + return consumption - targetConsumption; } /** - * 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. + * assigns new limits to all inverters matching the filter. returns the total + * amount of power these inverters are expected to produce after the new limits + * were applied. */ -bool PowerLimiterClass::updateInverter() +uint16_t PowerLimiterClass::updateInverterLimits(uint16_t powerRequested, + PowerLimiterClass::inverter_filter_t filter, std::string const& filterExpression) { - auto reset = [this]() -> bool { - _oTargetPowerState = std::nullopt; - _oTargetPowerLimitWatts = std::nullopt; - _oUpdateStartMillis = std::nullopt; - return false; - }; + std::vector matchingInverters; + uint16_t producing = 0; // sum of AC power the matching inverters produce now - if (nullptr == _inverter) { return reset(); } + for (auto& upInv : _inverters) { + if (!filter(*upInv)) { continue; } - // 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"); - RestartHelper.triggerRestart(); - } - - return reset(); - } - - auto constexpr halfOfAllMillis = std::numeric_limits::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; + if (!upInv->isReachable()) { + if (_verboseLogging) { + MessageOutput.printf("[DPL::updateInverterLimits] skipping %s " + "as it is not reachable\r\n", upInv->getSerialStr()); + } + continue; } - auto maxPower = _inverter->DevInfo()->getMaxPower(); - auto newRelativeLimit = static_cast(*_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); + if (!upInv->isSendingCommandsEnabled()) { + if (_verboseLogging) { + MessageOutput.printf("[DPL::updateInverterLimits] skipping %s " + "as sending commands is disabled\r\n", upInv->getSerialStr()); } - - _oTargetPowerLimitWatts = std::nullopt; - return false; + continue; } - 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(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; + producing += upInv->getCurrentOutputAcWatts(); + matchingInverters.push_back(upInv.get()); + } - 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. - */ -static int32_t scalePowerLimit(std::shared_ptr inverter, int32_t newLimit, int32_t currentLimitWatts, bool log) -{ - // 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 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; } + int32_t diff = powerRequested - producing; auto const& config = Configuration.get(); - auto allowOverscaling = config.PowerLimiter.UseOverscalingToCompensateShading; - auto isInverterSolarPowered = config.PowerLimiter.IsInverterSolarPowered; - - // overscalling allows us to compensate for shaded panels by increasing the - // total power limit, if the inverter is solar powered. - if (allowOverscaling && isInverterSolarPowered) { - auto inverterOutputAC = inverter->Statistics()->getChannelFieldValue(TYPE_AC, CH0, FLD_PAC); - float inverterEfficiencyFactor = getInverterEfficiency(inverter); + uint16_t hysteresis = config.PowerLimiter.TargetPowerConsumptionHysteresis; - // 98% of the expected power is good enough - auto expectedAcPowerPerChannel = (currentLimitWatts / dcTotalChnls) * 0.98; + if (_verboseLogging) { + MessageOutput.printf("[DPL::updateInverterLimits] requested: %d W, " + "producing: %d W using %d %s, diff: %i W, hysteresis: %d W\r\n", + powerRequested, producing, matchingInverters.size(), + filterExpression.c_str(), diff, hysteresis); + } - if (log) { - MessageOutput.printf("[DPL::scalePowerLimit] expected AC power per channel %f W\r\n", - expectedAcPowerPerChannel); - } + if (matchingInverters.empty()) { return 0; } - size_t dcShadedChnls = 0; - auto shadedChannelACPowerSum = 0.0; + if (std::abs(diff) < static_cast(hysteresis)) { return producing; } - for (auto& c : dcChnls) { - auto channelPowerAC = inverter->Statistics()->getChannelFieldValue(TYPE_DC, c, FLD_PDC) * inverterEfficiencyFactor; + uint16_t covered = 0; - if (channelPowerAC < expectedAcPowerPerChannel) { - dcShadedChnls++; - shadedChannelACPowerSum += channelPowerAC; - } + if (diff < 0) { + uint16_t reduction = static_cast(diff * -1); - if (log) { - MessageOutput.printf("[DPL::scalePowerLimit] ch %d AC power %f W\r\n", - c, channelPowerAC); - } + uint16_t totalMaxReduction = 0; + for (auto const pInv : matchingInverters) { + totalMaxReduction += pInv->getMaxReductionWatts(false/*no standby*/); } - // no shading or the shaded channels provide more power than what - // we currently need. - if (dcShadedChnls == 0 || shadedChannelACPowerSum >= newLimit) { return newLimit; } - - if (dcShadedChnls == dcTotalChnls) { - // keep the currentLimit when: - // - all channels are shaded - // - currentLimit >= newLimit - // - we get the expected AC power or less and - if (currentLimitWatts >= newLimit && inverterOutputAC <= newLimit) { - if (log) { - MessageOutput.printf("[DPL::scalePowerLimit] all channels are shaded, " - "keeping the current limit of %d W\r\n", currentLimitWatts); - } - - return currentLimitWatts; - - } else { - return newLimit; + // test whether we need to put at least one of the inverters into + // standby to achieve the requested reduction. + bool allowStandby = (totalMaxReduction < reduction); + + std::sort(matchingInverters.begin(), matchingInverters.end(), + [allowStandby](auto const a, auto const b) { + auto aReduction = a->getMaxReductionWatts(allowStandby); + auto bReduction = b->getMaxReductionWatts(allowStandby); + return aReduction > bReduction; + }); + + for (auto pInv : matchingInverters) { + auto maxReduction = pInv->getMaxReductionWatts(allowStandby); + if (reduction >= hysteresis && maxReduction >= hysteresis) { + reduction -= pInv->applyReduction(reduction, allowStandby); } + covered += pInv->getExpectedOutputAcWatts(); } + } + else { + uint16_t increase = static_cast(diff); - size_t dcNonShadedChnls = dcTotalChnls - dcShadedChnls; - auto overScaledLimit = static_cast((newLimit - shadedChannelACPowerSum) / dcNonShadedChnls * dcTotalChnls); - - if (overScaledLimit <= newLimit) { return newLimit; } + std::sort(matchingInverters.begin(), matchingInverters.end(), + [](auto const a, auto const b) { + return a->getMaxIncreaseWatts() > b->getMaxIncreaseWatts(); + }); - if (log) { - MessageOutput.printf("[DPL::scalePowerLimit] %d/%d channels are shaded, " - "scaling %d W\r\n", dcShadedChnls, dcTotalChnls, overScaledLimit); + for (auto pInv : matchingInverters) { + auto maxIncrease = pInv->getMaxIncreaseWatts(); + if (increase >= hysteresis && maxIncrease >= hysteresis) { + increase -= pInv->applyIncrease(increase); + } + covered += pInv->getExpectedOutputAcWatts(); } - - return overScaledLimit; } - size_t dcProdChnls = 0; - for (auto& c : dcChnls) { - if (inverter->Statistics()->getChannelFieldValue(TYPE_DC, c, FLD_PDC) > 2.0) { - dcProdChnls++; - } + if (_verboseLogging) { + MessageOutput.printf("[DPL::updateInverterLimits] will cover %d W using " + "%s\r\n", covered, filterExpression.c_str()); + for (auto pInv : matchingInverters) { pInv->debug(); } } - if (dcProdChnls == 0 || dcProdChnls == dcTotalChnls) { return newLimit; } - - auto scaled = static_cast(newLimit * static_cast(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; + return covered; } -/** - * 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 inverter, int32_t newPowerLimit) +uint16_t PowerLimiterClass::calcBatteryAllowance(uint16_t powerRequested) { - 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); + MessageOutput.printf("[DPL::calcBatteryAllowance] power requested: %d W\r\n", + powerRequested); } - if (newPowerLimit < lowerLimit) { - if (!config.PowerLimiter.IsInverterSolarPowered) { - return shutdown(Status::CalculatedLimitBelowMinLimit); + // We check if the PSU is on and disable battery-powered inverters in this + // case. The PSU should reduce power or shut down first before the + // battery-powered inverters kick in. The only case where this is not + // desired is if the battery is over the Full Solar Passthrough Threshold. + // In this case battery-powered inverters should produce power and the PSU + // will shut down as a consequence. + if (!isFullSolarPassthroughActive() && HuaweiCan.getAutoPowerStatus()) { + if (_verboseLogging) { + MessageOutput.println("[DPL::calcBatteryAllowance] disabled " + "by HuaweiCan auto power"); } - - MessageOutput.println("[DPL::setNewPowerLimit] keep solar-powered " - "inverter running at min limit"); - newPowerLimit = lowerLimit; + return 0; } - // enforce configured upper power limit - int32_t effPowerLimit = std::min(newPowerLimit, upperLimit); + auto oBatteryPowerDc = getBatteryDischargeLimit(); + if (!oBatteryPowerDc.has_value()) { return powerRequested; } - // 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(); + auto batteryPowerAC = solarDcToInverterAc(*oBatteryPowerDc); + auto solarPowerAC = solarDcToInverterAc(getSolarPassthroughPower()); - float currentLimitPercent = inverter->SystemConfigPara()->getLimitPercent(); - auto currentLimitAbs = static_cast(currentLimitPercent * maxPower / 100); + if (powerRequested > batteryPowerAC + solarPowerAC) { + // respect battery-provided discharge power limit + auto res = batteryPowerAC + solarPowerAC; - effPowerLimit = scalePowerLimit(inverter, effPowerLimit, currentLimitAbs, _verboseLogging); + if (_verboseLogging) { + MessageOutput.printf("[DPL::calcBatteryAllowance] limited by " + "battery (%d W) and/or solar power (%d W): %d W\r\n", + batteryPowerAC, solarPowerAC, res); + } - effPowerLimit = std::min(effPowerLimit, maxPower); + return res; + } - auto diff = std::abs(currentLimitAbs - effPowerLimit); + return powerRequested; +} - 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); - } +bool PowerLimiterClass::updateInverters() +{ + bool busy = false; - if (diff > hysteresis) { - _oTargetPowerLimitWatts = effPowerLimit; + for (auto& upInv : _inverters) { + if (upInv->update()) { busy = true; } } - _oTargetPowerState = true; - return updateInverter(); + return busy; } -int32_t PowerLimiterClass::getSolarPower() +uint16_t PowerLimiterClass::getSolarPassthroughPower() { 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 VictronMppt.getPowerOutputWatts(); +} + +float PowerLimiterClass::getBatteryInvertersOutputAcWatts() +{ + float res = 0; - return solarPower; + for (auto const& upInv : _inverters) { + if (upInv->isSolarPowered()) { continue; } + // TODO(schlimmchen): we must use the DC power instead, as the battery + // voltage drops proportional to the DC current draw, but the AC power + // output does not correlate with the battery current or voltage. + res += upInv->getCurrentOutputAcWatts(); + } + + return res; } -int32_t PowerLimiterClass::getBatteryDischargeLimit() +std::optional PowerLimiterClass::getBatteryDischargeLimit() { + if (!_batteryDischargeEnabled) { return 0; } + auto currentLimit = Battery.getDischargeCurrentLimit(); + if (currentLimit == FLT_MAX) { return std::nullopt; } - if (currentLimit == FLT_MAX) { - // the returned value is arbitrary, as long as it's - // greater than the inverters max DC power consumption. - return 10 * 1000; - } + if (currentLimit <= 0) { currentLimit = -currentLimit; } - // This uses inverter voltage since there is a voltage drop between + // this uses inverter voltage since there is a voltage drop between // battery and inverter, so since we are regulating the inverter // power we should use its voltage. - auto const& config = Configuration.get(); - auto channel = static_cast(config.PowerLimiter.InverterChannelId); - float inverterVoltage = _inverter->Statistics()->getChannelFieldValue(TYPE_DC, channel, FLD_UDC); + auto inverter = getInverterDcVoltage(); + if (inverter.first <= 0) { + MessageOutput.println("[DPL::getBatteryDischargeLimit]: could not " + "determine inverter voltage"); + return 0; + } - return static_cast(inverterVoltage * currentLimit); + return inverter.first * currentLimit; } 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; - } - auto const& config = Configuration.get(); - float acPower = _inverter->Statistics()->getChannelFieldValue(TYPE_AC, CH0, FLD_PAC); + // TODO(schlimmchen): use the battery's data if available, + // i.e., the current drawn from the battery as reported by the battery. + float acPower = getBatteryInvertersOutputAcWatts(); float dcVoltage = getBatteryVoltage(); if (dcVoltage <= 0.0) { @@ -993,58 +770,59 @@ bool PowerLimiterClass::isBelowStopThreshold() ); } -/// @brief calculate next inverter restart in millis void PowerLimiterClass::calcNextInverterRestart() { auto const& config = Configuration.get(); - // first check if restart is configured at all if (config.PowerLimiter.RestartHour < 0) { - _nextInverterRestart = 1; - MessageOutput.println("[DPL::calcNextInverterRestart] _nextInverterRestart disabled"); + _nextInverterRestart = { false, 0 }; + MessageOutput.println("[DPL::calcNextInverterRestart] automatic inverter restart 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(); + getLocalTime(&timeinfo, 5); // always succeeds as we call this method only + // from the DPL loop *after* we already made + // sure that time information is available. + + // 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; + uint32_t restartMillis = 0; + if (config.PowerLimiter.RestartHour > timeinfo.tm_hour) { + // next restart is on the same day + restartMillis = targetMinutes - dayMinutes; } else { - MessageOutput.println("[DPL::calcNextInverterRestart] getLocalTime not successful, no calculation"); - _nextInverterRestart = 0; + // next restart is on next day + restartMillis = 1440 - dayMinutes + targetMinutes; + } + + if (_verboseLogging) { + MessageOutput.printf("[DPL::calcNextInverterRestart] Localtime " + "read %02d:%02d / 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", restartMillis); } - MessageOutput.printf("[DPL::calcNextInverterRestart] _nextInverterRestart @ %d millis\r\n", _nextInverterRestart); + + // convert unit for next restart to milliseconds and add current uptime + restartMillis *= 60000; + restartMillis += millis(); + + MessageOutput.printf("[DPL::calcNextInverterRestart] next inverter " + "restart @ %d millis\r\n", restartMillis); + + _nextInverterRestart = { true, restartMillis }; } -bool PowerLimiterClass::useFullSolarPassthrough() +bool PowerLimiterClass::isFullSolarPassthroughActive() { auto const& config = Configuration.get(); // solar passthrough only applies to setups with battery-powered inverters - if (config.PowerLimiter.IsInverterSolarPowered) { return false; } + if (!usesBatteryPoweredInverter()) { return false; } // We only do full solar PT if general solar PT is enabled if(!config.PowerLimiter.SolarPassThroughEnabled) { return false; } @@ -1063,3 +841,20 @@ bool PowerLimiterClass::useFullSolarPassthrough() return _fullSolarPassThroughEnabled; } + +bool PowerLimiterClass::usesBatteryPoweredInverter() +{ + for (auto const& upInv : _inverters) { + if (!upInv->isSolarPowered()) { return true; } + } + + return false; +} + +bool PowerLimiterClass::isGovernedInverterProducing() +{ + for (auto const& upInv : _inverters) { + if (upInv->isProducing()) { return true; } + } + return false; +} diff --git a/src/PowerLimiterBatteryInverter.cpp b/src/PowerLimiterBatteryInverter.cpp new file mode 100644 index 000000000..f4eaf1a6b --- /dev/null +++ b/src/PowerLimiterBatteryInverter.cpp @@ -0,0 +1,104 @@ +#include "PowerLimiterBatteryInverter.h" + +PowerLimiterBatteryInverter::PowerLimiterBatteryInverter(bool verboseLogging, PowerLimiterInverterConfig const& config) + : PowerLimiterInverter(verboseLogging, config) { } + +uint16_t PowerLimiterBatteryInverter::getMaxReductionWatts(bool allowStandby) const +{ + if (!isEligible()) { return 0; } + + if (!isProducing()) { return 0; } + + if (allowStandby) { return getCurrentOutputAcWatts(); } + + if (getCurrentOutputAcWatts() <= _config.LowerPowerLimit) { return 0; } + + return getCurrentOutputAcWatts() - _config.LowerPowerLimit; +} + +uint16_t PowerLimiterBatteryInverter::getMaxIncreaseWatts() const +{ + if (!isEligible()) { return 0; } + + if (!isProducing()) { + return getConfiguredMaxPowerWatts(); + } + + // this should not happen for battery-powered inverters, but we want to + // be robust in case something else set a limit on the inverter (or in + // case we did something wrong...). + if (getCurrentLimitWatts() >= getConfiguredMaxPowerWatts()) { return 0; } + + // we must not substract the current AC output here, but the current + // limit value, so we avoid trying to produce even more even if the + // inverter is already at the maximum limit value (the actual AC + // output may be less than the inverter's current power limit). + return getConfiguredMaxPowerWatts() - getCurrentLimitWatts(); +} + +uint16_t PowerLimiterBatteryInverter::applyReduction(uint16_t reduction, bool allowStandby) +{ + if (!isEligible()) { return 0; } + + if (reduction == 0) { return 0; } + + auto low = std::min(getCurrentLimitWatts(), getCurrentOutputAcWatts()); + if (low <= _config.LowerPowerLimit) { + if (allowStandby) { + standby(); + return std::min(reduction, getCurrentOutputAcWatts()); + } + return 0; + } + + if ((getCurrentLimitWatts() - _config.LowerPowerLimit) >= reduction) { + setAcOutput(getCurrentLimitWatts() - reduction); + return reduction; + } + + if (allowStandby) { + standby(); + return std::min(reduction, getCurrentOutputAcWatts()); + } + + setAcOutput(_config.LowerPowerLimit); + return getCurrentOutputAcWatts() - _config.LowerPowerLimit; +} + +uint16_t PowerLimiterBatteryInverter::applyIncrease(uint16_t increase) +{ + if (!isEligible()) { return 0; } + + if (increase == 0) { return 0; } + + // do not wake inverter up if it would produce too much power + if (!isProducing() && _config.LowerPowerLimit > increase) { return 0; } + + auto baseline = getCurrentLimitWatts(); + + // battery-powered inverters in standby can have an arbitrary limit, yet + // the baseline is 0 in case we are about to wake it up from standby. + if (!isProducing()) { baseline = 0; } + + auto actualIncrease = std::min(increase, getMaxIncreaseWatts()); + setAcOutput(baseline + actualIncrease); + return actualIncrease; +} + +uint16_t PowerLimiterBatteryInverter::standby() +{ + setTargetPowerState(false); + setExpectedOutputAcWatts(0); + return getCurrentOutputAcWatts(); +} + +void PowerLimiterBatteryInverter::setAcOutput(uint16_t expectedOutputWatts) +{ + // make sure to enforce the lower and upper bounds + expectedOutputWatts = std::min(expectedOutputWatts, getConfiguredMaxPowerWatts()); + expectedOutputWatts = std::max(expectedOutputWatts, _config.LowerPowerLimit); + + setExpectedOutputAcWatts(expectedOutputWatts); + setTargetPowerLimitWatts(expectedOutputWatts); + setTargetPowerState(true); +} diff --git a/src/PowerLimiterInverter.cpp b/src/PowerLimiterInverter.cpp new file mode 100644 index 000000000..c4fc0e9f8 --- /dev/null +++ b/src/PowerLimiterInverter.cpp @@ -0,0 +1,311 @@ +#include "RestartHelper.h" +#include "MessageOutput.h" +#include "PowerLimiterInverter.h" +#include "PowerLimiterBatteryInverter.h" +#include "PowerLimiterSolarInverter.h" + +std::unique_ptr PowerLimiterInverter::create( + bool verboseLogging, PowerLimiterInverterConfig const& config) +{ + std::unique_ptr upInverter; + + if (config.IsSolarPowered) { + upInverter = std::make_unique(verboseLogging, config); + } + else { + upInverter = std::make_unique(verboseLogging, config); + } + + if (nullptr == upInverter->_spInverter) { return nullptr; } + + return std::move(upInverter); +} + +PowerLimiterInverter::PowerLimiterInverter(bool verboseLogging, PowerLimiterInverterConfig const& config) + : _config(config) + , _verboseLogging(verboseLogging) +{ + _spInverter = Hoymiles.getInverterBySerial(config.Serial); + if (!_spInverter) { return; } + + snprintf(_serialStr, sizeof(_serialStr), "%0x%08x", + static_cast((config.Serial >> 32) & 0xFFFFFFFF), + static_cast(config.Serial & 0xFFFFFFFF)); + + snprintf(_logPrefix, sizeof(_logPrefix), "[DPL inverter %s]:", _serialStr); +} + +bool PowerLimiterInverter::isEligible() const +{ + if (!isReachable() || !isSendingCommandsEnabled()) { return false; } + + // after startup, the limit effective at the inverter is not known. the + // respective message to request this info is only sent after a significant + // backoff (4 minutes). this is to avoid error messages to appear in the + // inverter's event log. we will wait until the current limit is known. + if (getCurrentLimitWatts() == 0) { return false; } + + // the model-dependent maximum AC power output is only known after the + // first DevInfoSimpleCommand succeeded. we desperately need this info, so + // the inverter is not eligible until this value is known. + if (getInverterMaxPowerWatts() == 0) { return false; } + + return true; +} + +bool PowerLimiterInverter::update() +{ + auto reset = [this]() -> bool { + _oTargetPowerState = std::nullopt; + _oTargetPowerLimitWatts = std::nullopt; + _oUpdateStartMillis = std::nullopt; + return false; + }; + + // do not reset _updateTimeouts 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) { + ++_updateTimeouts; + MessageOutput.printf("%s timeout (%d in succession), " + "state transition pending: %s, limit pending: %s\r\n", + _logPrefix, _updateTimeouts, + (_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 (_updateTimeouts >= 10) { + MessageOutput.printf("%s issuing restart command after update " + "timed out repeatedly\r\n", _logPrefix); + _spInverter->sendRestartControlRequest(); + } + + if (_updateTimeouts >= 20) { + MessageOutput.printf("%s restarting system since inverter is " + "unresponsive\r\n", _logPrefix); + RestartHelper.triggerRestart(); + } + + return reset(); + } + + auto constexpr halfOfAllMillis = std::numeric_limits::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 = _spInverter->PowerCommand()->getLastPowerCommandSuccess(); + if (CMD_PENDING == lastPowerCommandState) { + return true; + } + + // we need to wait for statistics that are more recent than + // the last power update command to reliably use isProducing() + auto lastPowerCommandMillis = _spInverter->PowerCommand()->getLastUpdateCommand(); + auto lastStatisticsMillis = _spInverter->Statistics()->getLastUpdate(); + if ((lastStatisticsMillis - lastPowerCommandMillis) > halfOfAllMillis) { return true; } + + if (isProducing() != *_oTargetPowerState) { + MessageOutput.printf("%s %s inverter...\r\n", _logPrefix, + ((*_oTargetPowerState)?"Starting":"Stopping")); + _spInverter->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 = _spInverter->SystemConfigPara()->getLastLimitCommandSuccess(); + if (CMD_PENDING == lastLimitCommandState) { + return true; + } + + float newRelativeLimit = static_cast(*_oTargetPowerLimitWatts * 100) / getInverterMaxPowerWatts(); + + // 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 = _spInverter->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 = _spInverter->SystemConfigPara()->getLastUpdateCommand(); + if ((lastLimitCommandMillis - *_oUpdateStartMillis) < halfOfAllMillis && + CMD_OK == lastLimitCommandState) { + MessageOutput.printf("%s actual limit is %.1f %% (%.0f W " + "respectively), effective %d ms after update started, " + "requested were %.1f %%\r\n", + _logPrefix, currentRelativeLimit, + (currentRelativeLimit * getInverterMaxPowerWatts() / 100), + (lastLimitCommandMillis - *_oUpdateStartMillis), + newRelativeLimit); + + if (std::abs(newRelativeLimit - currentRelativeLimit) > 2.0) { + MessageOutput.printf("%s 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", + _logPrefix, newRelativeLimit, currentRelativeLimit); + } + + _oTargetPowerLimitWatts = std::nullopt; + return false; + } + + MessageOutput.printf("%s sending limit of %.1f %% (%.0f W " + "respectively), max output is %d W\r\n", _logPrefix, + newRelativeLimit, (newRelativeLimit * getInverterMaxPowerWatts() / 100), + getInverterMaxPowerWatts()); + + _spInverter->sendActivePowerControlRequest(newRelativeLimit, + PowerLimitControlType::RelativNonPersistent); + + 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; } + + _updateTimeouts = 0; + + return reset(); +} + +std::optional PowerLimiterInverter::getLatestStatsMillis() const +{ + uint32_t now = millis(); + + // concerns both power limits and start/stop/restart commands and is + // only updated if a respective response was received from the inverter + auto lastUpdateCmdAge = std::min( + now - _spInverter->SystemConfigPara()->getLastUpdateCommand(), + now - _spInverter->PowerCommand()->getLastUpdateCommand() + ); + + // we use _oStatsMillis to persist a stats update timestamp, as we are + // looking for the single oldest inverter stats which is still younger than + // the last update command. we shall not just return the actual youngest + // stats timestamp if newer stats arrived while no update command was sent + // in the meantime. + if (_oStatsMillis && lastUpdateCmdAge < (now - *_oStatsMillis)) { + _oStatsMillis.reset(); + } + + if (!_oStatsMillis) { + auto lastStatsMillis = _spInverter->Statistics()->getLastUpdate(); + auto lastStatsAge = now - lastStatsMillis; + if (lastStatsAge > lastUpdateCmdAge) { + return std::nullopt; + } + + _oStatsMillis = lastStatsMillis; + } + + return _oStatsMillis; +} + +uint16_t PowerLimiterInverter::getInverterMaxPowerWatts() const +{ + return _spInverter->DevInfo()->getMaxPower(); +} + +uint16_t PowerLimiterInverter::getConfiguredMaxPowerWatts() const +{ + return std::min(getInverterMaxPowerWatts(), _config.UpperPowerLimit); +} + +uint16_t PowerLimiterInverter::getCurrentOutputAcWatts() const +{ + return _spInverter->Statistics()->getChannelFieldValue(TYPE_AC, CH0, FLD_PAC); +} + +uint16_t PowerLimiterInverter::getExpectedOutputAcWatts() const +{ + if (!_oTargetPowerLimitWatts && !_oTargetPowerState) { + // the inverter's output will not change due to commands being sent + return getCurrentOutputAcWatts(); + } + + return _expectedOutputAcWatts; +} + +void PowerLimiterInverter::setMaxOutput() +{ + _oTargetPowerState = true; + setAcOutput(getConfiguredMaxPowerWatts()); +} + +void PowerLimiterInverter::restart() +{ + _spInverter->sendRestartControlRequest(); +} + +float PowerLimiterInverter::getDcVoltage(uint8_t input) +{ + return _spInverter->Statistics()->getChannelFieldValue(TYPE_DC, + static_cast(input), FLD_UDC); +} + +uint16_t PowerLimiterInverter::getCurrentLimitWatts() const +{ + auto currentLimitPercent = _spInverter->SystemConfigPara()->getLimitPercent(); + return static_cast(currentLimitPercent * getInverterMaxPowerWatts() / 100); +} + +void PowerLimiterInverter::debug() const +{ + if (!_verboseLogging) { return; } + + MessageOutput.printf( + "%s\r\n" + " %s-powered, %s %d W\r\n" + " lower/current/upper limit: %d/%d/%d W, output capability: %d W\r\n" + " sending commands %s, %s, %s\r\n" + " max reduction production/standby: %d/%d W, max increase: %d W\r\n" + " target limit/output/state: %i W (%s)/%d W/%s, %d update timeouts\r\n", + _logPrefix, + (isSolarPowered()?"solar":"battery"), + (isProducing()?"producing":"standing by at"), getCurrentOutputAcWatts(), + _config.LowerPowerLimit, getCurrentLimitWatts(), _config.UpperPowerLimit, + getInverterMaxPowerWatts(), + (isSendingCommandsEnabled()?"enabled":"disabled"), + (isReachable()?"reachable":"offline"), + (isEligible()?"eligible":"disqualified"), + getMaxReductionWatts(false), getMaxReductionWatts(true), getMaxIncreaseWatts(), + (_oTargetPowerLimitWatts.has_value()?*_oTargetPowerLimitWatts:-1), + (_oTargetPowerLimitWatts.has_value()?"update":"unchanged"), + getExpectedOutputAcWatts(), + (_oTargetPowerState.has_value()?(*_oTargetPowerState?"production":"standby"):"unchanged"), + getUpdateTimeouts() + ); +} diff --git a/src/PowerLimiterSolarInverter.cpp b/src/PowerLimiterSolarInverter.cpp new file mode 100644 index 000000000..548e7c047 --- /dev/null +++ b/src/PowerLimiterSolarInverter.cpp @@ -0,0 +1,248 @@ +#include "MessageOutput.h" +#include "PowerLimiterSolarInverter.h" + +PowerLimiterSolarInverter::PowerLimiterSolarInverter(bool verboseLogging, PowerLimiterInverterConfig const& config) + : PowerLimiterInverter(verboseLogging, config) { } + +uint16_t PowerLimiterSolarInverter::getMaxReductionWatts(bool) const +{ + if (!isEligible()) { return 0; } + + auto low = std::min(getCurrentLimitWatts(), getCurrentOutputAcWatts()); + if (low <= _config.LowerPowerLimit) { return 0; } + + return getCurrentOutputAcWatts() - _config.LowerPowerLimit; +} + +uint16_t PowerLimiterSolarInverter::getMaxIncreaseWatts() const +{ + if (!isEligible()) { return 0; } + + // the maximum increase possible for this inverter + int16_t maxTotalIncrease = getConfiguredMaxPowerWatts() - getCurrentOutputAcWatts(); + + if (!isProducing()) { + // the inverter is not producing, we don't know how much we can increase + // the power, so we return the maximum possible increase + return maxTotalIncrease; + } + + auto pStats = _spInverter->Statistics(); + std::vector dcMppts = _spInverter->getMppts(); + size_t dcTotalMppts = dcMppts.size(); + + float inverterEfficiencyFactor = pStats->getChannelFieldValue(TYPE_INV, CH0, FLD_EFF) / 100; + + // 98% of the expected power is good enough + auto expectedAcPowerPerMppt = (getCurrentLimitWatts() / dcTotalMppts) * 0.98; + + size_t dcNonShadedMppts = 0; + auto nonShadedMpptACPowerSum = 0.0; + + for (auto& m : dcMppts) { + float mpptPowerAC = 0.0; + std::vector mpptChnls = _spInverter->getChannelsDCByMppt(m); + + for (auto& c : mpptChnls) { + mpptPowerAC += pStats->getChannelFieldValue(TYPE_DC, c, FLD_PDC) * inverterEfficiencyFactor; + } + + if (mpptPowerAC >= expectedAcPowerPerMppt) { + nonShadedMpptACPowerSum += mpptPowerAC; + dcNonShadedMppts++; + } + } + + if (dcNonShadedMppts == 0) { + // all mppts are shaded, we can't increase the power + return 0; + } + + if (dcNonShadedMppts == dcTotalMppts) { + // no MPPT is shaded, we assume that we can increase the power by the maximum + return maxTotalIncrease; + } + + int16_t maxPowerPerMppt = getConfiguredMaxPowerWatts() / dcTotalMppts; + + int16_t currentPowerPerNonShadedMppt = nonShadedMpptACPowerSum / dcNonShadedMppts; + + int16_t maxIncreasePerNonShadedMppt = maxPowerPerMppt - currentPowerPerNonShadedMppt; + + // maximum increase based on the non-shaded mppts + return maxIncreasePerNonShadedMppt * dcNonShadedMppts; +} + +uint16_t PowerLimiterSolarInverter::applyReduction(uint16_t reduction, bool) +{ + if (!isEligible()) { return 0; } + + if (reduction == 0) { return 0; } + + if ((getCurrentOutputAcWatts() - _config.LowerPowerLimit) >= reduction) { + setAcOutput(getCurrentOutputAcWatts() - reduction); + return reduction; + } + + setAcOutput(_config.LowerPowerLimit); + return getCurrentOutputAcWatts() - _config.LowerPowerLimit; +} + +uint16_t PowerLimiterSolarInverter::applyIncrease(uint16_t increase) +{ + if (!isEligible()) { return 0; } + + if (increase == 0) { return 0; } + + // do not wake inverter up if it would produce too much power + if (!isProducing() && _config.LowerPowerLimit > increase) { return 0; } + + // the limit for solar-powered inverters might be scaled, so we use the + // current output as the baseline. solar-powered inverters in standby have + // no output (baseline is zero). + auto baseline = getCurrentOutputAcWatts(); + + auto actualIncrease = std::min(increase, getMaxIncreaseWatts()); + setAcOutput(baseline + actualIncrease); + return actualIncrease; +} + +uint16_t PowerLimiterSolarInverter::standby() +{ + // solar-powered inverters are never actually put into standby (by the + // DPL), but only set to the configured lower power limit instead. + setAcOutput(_config.LowerPowerLimit); + return getCurrentOutputAcWatts() - _config.LowerPowerLimit; +} + +uint16_t PowerLimiterSolarInverter::scaleLimit(uint16_t expectedOutputWatts) +{ + // 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 (!isProducing()) { return expectedOutputWatts; } + + auto pStats = _spInverter->Statistics(); + std::vector dcChnls = _spInverter->getChannelsDC(); + std::vector dcMppts = _spInverter->getMppts(); + size_t dcTotalChnls = dcChnls.size(); + size_t dcTotalMppts = dcMppts.size(); + + // if there is only one MPPT available, there is nothing we can do + if (dcTotalMppts <= 1) { return expectedOutputWatts; } + + // 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 (getCurrentLimitWatts() < dcTotalChnls * 10) { return expectedOutputWatts; } + + // overscalling allows us to compensate for shaded panels by increasing the + // total power limit, if the inverter is solar powered. + if (_config.UseOverscalingToCompensateShading) { + auto inverterOutputAC = pStats->getChannelFieldValue(TYPE_AC, CH0, FLD_PAC); + + float inverterEfficiencyFactor = pStats->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) + inverterEfficiencyFactor = (inverterEfficiencyFactor > 0) ? inverterEfficiencyFactor/100 : 0.967; + + // 98% of the expected power is good enough + auto expectedAcPowerPerMppt = (getCurrentLimitWatts() / dcTotalMppts) * 0.98; + + if (_verboseLogging) { + MessageOutput.printf("%s expected AC power per MPPT %.0f W\r\n", + _logPrefix, expectedAcPowerPerMppt); + } + + size_t dcShadedMppts = 0; + auto shadedChannelACPowerSum = 0.0; + + for (auto& m : dcMppts) { + float mpptPowerAC = 0.0; + std::vector mpptChnls = _spInverter->getChannelsDCByMppt(m); + + for (auto& c : mpptChnls) { + mpptPowerAC += pStats->getChannelFieldValue(TYPE_DC, c, FLD_PDC) * inverterEfficiencyFactor; + } + + if (mpptPowerAC < expectedAcPowerPerMppt) { + dcShadedMppts++; + shadedChannelACPowerSum += mpptPowerAC; + } + + if (_verboseLogging) { + MessageOutput.printf("%s MPPT-%c AC power %.0f W\r\n", + _logPrefix, m + 'a', mpptPowerAC); + } + } + + // no shading or the shaded channels provide more power than what + // we currently need. + if (dcShadedMppts == 0 || shadedChannelACPowerSum >= expectedOutputWatts) { + return expectedOutputWatts; + } + + if (dcShadedMppts == dcTotalMppts) { + // keep the currentLimit when: + // - all channels are shaded + // - currentLimit >= expectedOutputWatts + // - we get the expected AC power or less and + if (getCurrentLimitWatts() >= expectedOutputWatts && + inverterOutputAC <= expectedOutputWatts) { + if (_verboseLogging) { + MessageOutput.printf("%s all mppts are shaded, " + "keeping the current limit of %d W\r\n", + _logPrefix, getCurrentLimitWatts()); + } + + return getCurrentLimitWatts(); + + } else { + return expectedOutputWatts; + } + } + + size_t dcNonShadedMppts = dcTotalMppts - dcShadedMppts; + uint16_t overScaledLimit = (expectedOutputWatts - shadedChannelACPowerSum) / dcNonShadedMppts * dcTotalMppts; + + if (overScaledLimit <= expectedOutputWatts) { return expectedOutputWatts; } + + if (_verboseLogging) { + MessageOutput.printf("%s %d/%d mppts are shaded, scaling %d W\r\n", + _logPrefix, dcShadedMppts, dcTotalMppts, overScaledLimit); + } + + return overScaledLimit; + } + + size_t dcProdMppts = 0; + for (auto& m : dcMppts) { + float dcPowerMppt = 0.0; + std::vector mpptChnls = _spInverter->getChannelsDCByMppt(m); + + for (auto& c : mpptChnls) { + dcPowerMppt += pStats->getChannelFieldValue(TYPE_DC, c, FLD_PDC); + } + + if (dcPowerMppt > 2.0 * mpptChnls.size()) { + dcProdMppts++; + } + } + + if (dcProdMppts == 0 || dcProdMppts == dcTotalMppts) { return expectedOutputWatts; } + + uint16_t scaled = expectedOutputWatts / dcProdMppts * dcTotalMppts; + MessageOutput.printf("%s %d/%d mppts are producing, scaling from %d to " + "%d W\r\n", _logPrefix, dcProdMppts, dcTotalMppts, + expectedOutputWatts, scaled); + + return scaled; +} + +void PowerLimiterSolarInverter::setAcOutput(uint16_t expectedOutputWatts) +{ + setExpectedOutputAcWatts(expectedOutputWatts); + setTargetPowerLimitWatts(scaleLimit(expectedOutputWatts)); + setTargetPowerState(true); +} diff --git a/src/WebApi_inverter.cpp b/src/WebApi_inverter.cpp index a67e65416..caf5fcd17 100644 --- a/src/WebApi_inverter.cpp +++ b/src/WebApi_inverter.cpp @@ -5,6 +5,7 @@ #include "WebApi_inverter.h" #include "Configuration.h" #include "MqttHandleHass.h" +#include "PowerLimiter.h" #include "WebApi.h" #include "WebApi_errors.h" #include "defaults.h" @@ -150,6 +151,8 @@ void WebApiInverterClass::onInverterAdd(AsyncWebServerRequest* request) } MqttHandleHass.forceUpdate(); + + PowerLimiter.triggerReloadingConfig(); } void WebApiInverterClass::onInverterEdit(AsyncWebServerRequest* request) @@ -274,6 +277,8 @@ void WebApiInverterClass::onInverterEdit(AsyncWebServerRequest* request) } MqttHandleHass.forceUpdate(); + + PowerLimiter.triggerReloadingConfig(); } void WebApiInverterClass::onInverterDelete(AsyncWebServerRequest* request) @@ -316,6 +321,8 @@ void WebApiInverterClass::onInverterDelete(AsyncWebServerRequest* request) WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); MqttHandleHass.forceUpdate(); + + PowerLimiter.triggerReloadingConfig(); } void WebApiInverterClass::onInverterOrder(AsyncWebServerRequest* request) diff --git a/src/WebApi_powerlimiter.cpp b/src/WebApi_powerlimiter.cpp index 0f125b70d..e1654fb23 100644 --- a/src/WebApi_powerlimiter.cpp +++ b/src/WebApi_powerlimiter.cpp @@ -12,6 +12,7 @@ #include "WebApi.h" #include "helper.h" #include "WebApi_errors.h" +#include "Configuration.h" void WebApiPowerLimiterClass::init(AsyncWebServer& server, Scheduler& scheduler) { @@ -32,35 +33,9 @@ void WebApiPowerLimiterClass::onStatus(AsyncWebServerRequest* request) } AsyncJsonResponse* response = new AsyncJsonResponse(); - auto& root = response->getRoot(); + auto root = response->getRoot().as(); auto const& config = Configuration.get(); - - root["enabled"] = config.PowerLimiter.Enabled; - root["verbose_logging"] = config.PowerLimiter.VerboseLogging; - root["solar_passthrough_enabled"] = config.PowerLimiter.SolarPassThroughEnabled; - root["solar_passthrough_losses"] = config.PowerLimiter.SolarPassThroughLosses; - root["battery_always_use_at_night"] = config.PowerLimiter.BatteryAlwaysUseAtNight; - root["is_inverter_behind_powermeter"] = config.PowerLimiter.IsInverterBehindPowerMeter; - root["is_inverter_solar_powered"] = config.PowerLimiter.IsInverterSolarPowered; - root["use_overscaling_to_compensate_shading"] = config.PowerLimiter.UseOverscalingToCompensateShading; - root["inverter_serial"] = String(config.PowerLimiter.InverterId); - root["inverter_channel_id"] = config.PowerLimiter.InverterChannelId; - root["target_power_consumption"] = config.PowerLimiter.TargetPowerConsumption; - root["target_power_consumption_hysteresis"] = config.PowerLimiter.TargetPowerConsumptionHysteresis; - root["lower_power_limit"] = config.PowerLimiter.LowerPowerLimit; - root["base_load_limit"] = config.PowerLimiter.BaseLoadLimit; - root["upper_power_limit"] = config.PowerLimiter.UpperPowerLimit; - root["ignore_soc"] = config.PowerLimiter.IgnoreSoc; - root["battery_soc_start_threshold"] = config.PowerLimiter.BatterySocStartThreshold; - root["battery_soc_stop_threshold"] = config.PowerLimiter.BatterySocStopThreshold; - root["voltage_start_threshold"] = static_cast(config.PowerLimiter.VoltageStartThreshold * 100 +0.5) / 100.0; - root["voltage_stop_threshold"] = static_cast(config.PowerLimiter.VoltageStopThreshold * 100 +0.5) / 100.0;; - root["voltage_load_correction_factor"] = config.PowerLimiter.VoltageLoadCorrectionFactor; - root["inverter_restart_hour"] = config.PowerLimiter.RestartHour; - root["full_solar_passthrough_soc"] = config.PowerLimiter.FullSolarPassThroughSoc; - root["full_solar_passthrough_start_voltage"] = static_cast(config.PowerLimiter.FullSolarPassThroughStartVoltage * 100 + 0.5) / 100.0; - root["full_solar_passthrough_stop_voltage"] = static_cast(config.PowerLimiter.FullSolarPassThroughStopVoltage * 100 + 0.5) / 100.0; - + ConfigurationClass::serializePowerLimiterConfig(config.PowerLimiter, root); WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); } @@ -70,11 +45,6 @@ void WebApiPowerLimiterClass::onMetaData(AsyncWebServerRequest* request) auto const& config = Configuration.get(); - size_t invAmount = 0; - for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { - if (config.Inverter[i].Serial != 0) { ++invAmount; } - } - AsyncJsonResponse* response = new AsyncJsonResponse(); auto& root = response->getRoot(); @@ -82,30 +52,23 @@ void WebApiPowerLimiterClass::onMetaData(AsyncWebServerRequest* request) root["battery_enabled"] = config.Battery.Enabled; root["charge_controller_enabled"] = config.Vedirect.Enabled; - JsonObject inverters = root["inverters"].to(); + JsonArray inverters = root["inverters"].to(); for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { - if (config.Inverter[i].Serial == 0) { continue; } + auto inv = Hoymiles.getInverterBySerial(config.Inverter[i].Serial); + if (!inv) { continue; } - // we use the integer (base 10) representation of the inverter serial, - // rather than the hex represenation as used when handling the inverter - // serial elsewhere in the web application, because in this case, the - // serial is actually not displayed but only used as a value/index. - JsonObject obj = inverters[String(config.Inverter[i].Serial)].to(); + JsonObject obj = inverters.add(); + obj["serial"] = inv->serialString(); obj["pos"] = i; obj["name"] = String(config.Inverter[i].Name); obj["poll_enable"] = config.Inverter[i].Poll_Enable; obj["poll_enable_night"] = config.Inverter[i].Poll_Enable_Night; obj["command_enable"] = config.Inverter[i].Command_Enable; obj["command_enable_night"] = config.Inverter[i].Command_Enable_Night; - - obj["type"] = "Unknown"; - obj["channels"] = 1; - auto inv = Hoymiles.getInverterBySerial(config.Inverter[i].Serial); - if (inv != nullptr) { - obj["type"] = inv->typeName(); - auto channels = inv->Statistics()->getChannelsByType(TYPE_DC); - obj["channels"] = channels.size(); - } + obj["max_power"] = inv->DevInfo()->getMaxPower(); // okay if zero/unknown + obj["type"] = inv->typeName(); + auto channels = inv->Statistics()->getChannelsByType(TYPE_DC); + obj["channels"] = channels.size(); } WebApi.sendJsonResponse(request, response, __FUNCTION__, __LINE__); @@ -134,62 +97,10 @@ void WebApiPowerLimiterClass::onAdminPost(AsyncWebServerRequest* request) auto& retMsg = response->getRoot(); - // we were not actually checking for all the keys we (unconditionally) - // access below for a long time, and it is technically not needed if users - // use the web application to submit settings. the web app will always - // submit all keys. users who send HTTP requests manually need to beware - // anyways to always include the keys accessed below. if we wanted to - // support a simpler API, like only sending the "enabled" key which only - // changes that key, we need to refactor all of the code below. - if (!root["enabled"].is()) { - retMsg["message"] = "Values are missing!"; - retMsg["code"] = WebApiError::GenericValueMissing; - response->setLength(); - request->send(response); - return; - } - { auto guard = Configuration.getWriteGuard(); auto& config = guard.getConfig(); - config.PowerLimiter.Enabled = root["enabled"].as(); - PowerLimiter.setMode(PowerLimiterClass::Mode::Normal); // User input sets PL to normal operation - config.PowerLimiter.VerboseLogging = root["verbose_logging"].as(); - - if (config.Vedirect.Enabled) { - config.PowerLimiter.SolarPassThroughEnabled = root["solar_passthrough_enabled"].as(); - config.PowerLimiter.SolarPassThroughLosses = root["solar_passthrough_losses"].as(); - config.PowerLimiter.FullSolarPassThroughStartVoltage = static_cast(root["full_solar_passthrough_start_voltage"].as() * 100) / 100.0; - config.PowerLimiter.FullSolarPassThroughStopVoltage = static_cast(root["full_solar_passthrough_stop_voltage"].as() * 100) / 100.0; - } - - config.PowerLimiter.IsInverterBehindPowerMeter = root["is_inverter_behind_powermeter"].as(); - config.PowerLimiter.IsInverterSolarPowered = root["is_inverter_solar_powered"].as(); - config.PowerLimiter.BatteryAlwaysUseAtNight = root["battery_always_use_at_night"].as(); - config.PowerLimiter.UseOverscalingToCompensateShading = root["use_overscaling_to_compensate_shading"].as(); - config.PowerLimiter.InverterId = root["inverter_serial"].as(); - config.PowerLimiter.InverterChannelId = root["inverter_channel_id"].as(); - config.PowerLimiter.TargetPowerConsumption = root["target_power_consumption"].as(); - config.PowerLimiter.TargetPowerConsumptionHysteresis = root["target_power_consumption_hysteresis"].as(); - config.PowerLimiter.LowerPowerLimit = root["lower_power_limit"].as(); - config.PowerLimiter.BaseLoadLimit = root["base_load_limit"].as(); - config.PowerLimiter.UpperPowerLimit = root["upper_power_limit"].as(); - - if (config.Battery.Enabled) { - config.PowerLimiter.IgnoreSoc = root["ignore_soc"].as(); - config.PowerLimiter.BatterySocStartThreshold = root["battery_soc_start_threshold"].as(); - config.PowerLimiter.BatterySocStopThreshold = root["battery_soc_stop_threshold"].as(); - if (config.Vedirect.Enabled) { - config.PowerLimiter.FullSolarPassThroughSoc = root["full_solar_passthrough_soc"].as(); - } - } - - config.PowerLimiter.VoltageStartThreshold = root["voltage_start_threshold"].as(); - config.PowerLimiter.VoltageStartThreshold = static_cast(config.PowerLimiter.VoltageStartThreshold * 100) / 100.0; - config.PowerLimiter.VoltageStopThreshold = root["voltage_stop_threshold"].as(); - config.PowerLimiter.VoltageStopThreshold = static_cast(config.PowerLimiter.VoltageStopThreshold * 100) / 100.0; - config.PowerLimiter.VoltageLoadCorrectionFactor = root["voltage_load_correction_factor"].as(); - config.PowerLimiter.RestartHour = root["inverter_restart_hour"].as(); + ConfigurationClass::deserializePowerLimiterConfig(root.as(), config.PowerLimiter); } WebApi.writeConfig(retMsg); @@ -197,7 +108,7 @@ void WebApiPowerLimiterClass::onAdminPost(AsyncWebServerRequest* request) response->setLength(); request->send(response); - PowerLimiter.calcNextInverterRestart(); + PowerLimiter.triggerReloadingConfig(); // potentially make thresholds auto-discoverable MqttHandlePowerLimiterHass.forceUpdate(); diff --git a/src/WebApi_ws_vedirect_live.cpp b/src/WebApi_ws_vedirect_live.cpp index 4a56646e0..509ccc43b 100644 --- a/src/WebApi_ws_vedirect_live.cpp +++ b/src/WebApi_ws_vedirect_live.cpp @@ -154,7 +154,7 @@ void WebApiWsVedirectLiveClass::generateCommonJsonResponse(JsonVariant& root, bo root["dpl"]["PLSTATE"] = -1; if (Configuration.get().PowerLimiter.Enabled) root["dpl"]["PLSTATE"] = PowerLimiter.getPowerLimiterState(); - root["dpl"]["PLLIMIT"] = PowerLimiter.getLastRequestedPowerLimit(); + root["dpl"]["PLLIMIT"] = PowerLimiter.getInverterOutput(); } void WebApiWsVedirectLiveClass::populateJson(const JsonObject &root, const VeDirectMpptController::data_t &mpptData) { diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 1b3fddff9..b0a89339f 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -659,11 +659,12 @@ "ConfigHintsIntro": "Folgende Hinweise zur Konfiguration des Dynamic Power Limiter (DPL) sollen beachtet werden:", "ConfigHintPowerMeterDisabled": "Der DPL stellt ohne Stromzählerschnittstelle lediglich die konfigurierte Grundlast als Limit am Wechselrichter ein (Ausnahme: (Full) Solar-Passthrough).", "ConfigHintNoInverter": "Vor dem Festlegen von Einstellungen des DPL muss mindestens ein Inverter konfiguriert sein.", - "ConfigHintInverterCommunication": "Das Abrufen von Daten und Senden von Kommandos muss für den zu regelnden Wechselrichter aktiviert sein.", + "ConfigHintInverterCommunication": "Das Abrufen von Daten und Senden von Kommandos muss für jeden zu regelnden Wechselrichter aktiviert sein.", "ConfigHintNoChargeController": "Die Solar-Passthrough Funktion kann nur mit aktivierter VE.Direct Schnittstelle genutzt werden.", "ConfigHintNoBatteryInterface": "SoC-basierte Schwellwerte können nur mit konfigurierter Batteriekommunikationsschnittstelle genutzt werden.", "General": "Allgemein", "Enable": "Aktiviert", + "GovernInverter": "Steuere Wechselrichter \"{name}\"", "VerboseLogging": "@:base.VerboseLogging", "SolarPassthrough": "Solar-Passthrough", "EnableSolarPassthrough": "Aktiviere Solar-Passthrough", @@ -671,34 +672,51 @@ "SolarPassthroughLossesInfo": "Hinweis: Bei der Übertragung von Energie vom Solarladeregler zum Inverter sind Leitungsverluste zu erwarten. Um eine schleichende Entladung der Batterie im (Full) Solar-Passthrough Modus zu unterbinden, können diese Verluste berücksichtigt werden. Das am Inverter einzustellende Power Limit wird nach Berücksichtigung von dessen Effizienz zusätzlich um diesen Faktor verringert.", "BatteryDischargeAtNight": "Batterie nachts sogar teilweise geladen nutzen", "SolarpassthroughInfo": "Diese Funktion ermöglicht den unmittelbaren Verbauch der verfügbaren Solarleistung. Dazu wird die aktuell vom Laderegler gemeldete Solarleistung am Wechselrichter als Limit eingestellt, selbst wenn sich die Batterie in einem Ladezyklus befindet. Somit wird eine unnötige Speicherung vermieden, die verlustbehaftet wäre.", - "InverterSettings": "Wechselrichter", - "Inverter": "Zu regelnder Wechselrichter", + "InverterSettings": "Wechselrichtereinstellungen", "SelectInverter": "Inverter auswählen...", + "InverterForDcVoltage": "Wechselrichter für Spannungsmessungen", "InverterChannelId": "Eingang für Spannungsmessungen", "TargetPowerConsumption": "Angestrebter Netzbezug", - "TargetPowerConsumptionHint": "Angestrebter erlaubter Stromverbrauch aus dem Netz. Wert darf negativ sein.", + "TargetPowerConsumptionHint": "Angestrebter Stromverbrauch aus dem Netz. Wert darf negativ sein.", "TargetPowerConsumptionHysteresis": "Hysterese", - "TargetPowerConsumptionHysteresisHint": "Neu berechnetes Limit nur dann an den Inverter senden, wenn es vom zurückgemeldeten Limit um mindestens diesen Betrag abweicht.", + "TargetPowerConsumptionHysteresisHint": "Neu berechnetes Limit nur dann an den jeweiligen Inverter senden, wenn es vom zurückgemeldeten Limit um mindestens diesen Betrag abweicht.", "LowerPowerLimit": "Minimales Leistungslimit", "LowerPowerLimitHint": "Dieser Wert muss so gewählt werden, dass ein stabiler Betrieb mit diesem Limit möglich ist. Falls der Wechselrichter nur mit einem kleineren Limit betrieben werden könnte, wird er stattdessen in Standby versetzt.", "BaseLoadLimit": "Grundlast", - "BaseLoadLimitHint": "Relevant beim Betrieb ohne oder beim Ausfall des Stromzählers. Solange es die sonstigen Bedinungen zulassen (insb. Batterieladung) wird dieses Limit am Wechselrichter eingestellt.", + "BaseLoadLimitHint": "Relevant beim Betrieb ohne oder beim Ausfall des Stromzählers. Solange es die sonstigen Bedinungen zulassen (insb. Batterieladung), wird diese Leistung auf die Wechselrichter verteilt.", + "TotalUpperPowerLimit": "Maximale Gesamtausgangsleistung", + "TotalUpperPowerLimitHint": "Die Wechselrichter werden so eingestellt, dass sie in Summe höchstens diese Leistung erbringen.", + "ManagedInverters": "Verwaltete Wechselrichter", + "AddInverter": "Wechselrichter Hinzufügen", + "NoManagedInverters": "Der Dynamic Power Limiter verwaltet zur Zeit keine Wechselrichter. Um Fortzufahren, wähle einen Wechselrichter aus der Auswahlliste oben und füge ihn hinzu.", + "InverterLabel": "Name (Typ)", + "PowerSource": "Energiequelle", + "PowerSourceBattery": "Batterie", + "PowerSourceSolarPanels": "Solarmodul(e)", + "EditInverter": "Wechselrichter Bearbeiten", + "EditInverterLabel": "Betrifft Wechselrichter", + "Apply": "Übernehmen", + "Cancel": "@:base.Cancel", + "Delete": "Entfernen", + "DeleteInverter": "Entfernen Bestätigen", + "DeleteInverterMsg": "Soll Wechselrichter {label} mit Seriennummer {serial} von der Liste vom Dynamic Power Limiter verwalteter Wechselrichter entfernt werden?", "UpperPowerLimit": "Maximales Leistungslimit", "UpperPowerLimitHint": "Der Wechselrichter wird stets so eingestellt, dass höchstens diese Ausgangsleistung erreicht wird. Dieser Wert muss so gewählt werden, dass die Strombelastbarkeit der AC-Anschlussleitungen eingehalten wird.", "SocThresholds": "Batterie State of Charge (SoC) Schwellwerte", - "IgnoreSoc": "Batterie SoC ignorieren", + "IgnoreSoc": "Nur Spannungs-Schwellwerte nutzen", + "IgnoreSocHint": "Falls aktiviert werden nur die Spannungs-Schwellwerte berücksichtigt. Deaktiviere diesen Schalter, um Batterie State of Charge (SoC) Schwellwerte zu konfigurieren (nicht empfohlen, da der SoC-Wert häufig ungenau ist).", "StartThreshold": "Batterienutzung Start-Schwellwert", "StopThreshold": "Batterienutzung Stop-Schwellwert", "FullSolarPassthroughStartThreshold": "Full-Solar-Passthrough Start-Schwellwert", "FullSolarPassthroughStartThresholdHint": "Oberhalb dieses Schwellwertes wird die Inverterleistung der Victron-MPPT-Leistung gleichgesetzt (abzüglich Effizienzkorrekturfaktor). Kann verwendet werden um überschüssige Solarleistung an das Netz zu liefern wenn die Batterie voll ist.", "VoltageSolarPassthroughStopThreshold": "Full-Solar-Passthrough Stop-Schwellwert", "VoltageLoadCorrectionFactor": "Lastkorrekturfaktor", - "BatterySocInfo": "Hinweis: Die Akku SoC (State of Charge) Werte werden nur benutzt, wenn die Batterie-Kommunikationsschnittstelle innerhalb der letzten Minute gültige Werte geschickt hat. Andernfalls werden als Fallback-Option die Spannungseinstellungen verwendet.", + "BatterySocInfo": "Hinweis: Die Batterie State of Charge (SoC) Schwellwerte werden bevorzugt herangezogen. Sie werden allerdings nur benutzt, wenn die Batterie-Kommunikationsschnittstelle innerhalb der letzten Minute gültige Werte verarbeitet hat. Andernfalls werden ersatzweise die Spannungs-Schwellwerte verwendet.", "InverterIsBehindPowerMeter": "Stromzählermessung beinhaltet Wechselrichter", "InverterIsBehindPowerMeterHint": "Aktivieren falls sich der Stromzähler-Messwert um die Ausgangsleistung des Wechselrichters verringert, wenn dieser Strom produziert. Normalerweise ist das zutreffend.", "InverterIsSolarPowered": "Wechselrichter wird von Solarmodulen gespeist", "UseOverscalingToCompensateShading": "Verschattung durch Überskalierung ausgleichen", - "UseOverscalingToCompensateShadingHint": "Erlaubt das Überskalieren des Wechselrichter-Limits, um Verschattung eines oder mehrerer Eingänge auszugleichen", + "UseOverscalingToCompensateShadingHint": "Erlaubt das Überskalieren des Wechselrichter-Limits, um Verschattung eines oder mehrerer Eingänge auszugleichen.", "VoltageThresholds": "Batterie Spannungs-Schwellwerte ", "VoltageLoadCorrectionInfo": "Hinweis: Wenn Leistung von der Batterie abgegeben wird, bricht ihre Spannung etwas ein. Der Spannungseinbruch skaliert mit dem Entladestrom. Damit nicht vorzeitig der Wechselrichter ausgeschaltet wird sobald der Stop-Schwellenwert unterschritten wurde, wird der hier angegebene Korrekturfaktor mit einberechnet um die Spannung zu errechnen die der Akku in Ruhe hätte. Korrigierte Spannung = DC Spannung + (Aktuelle Leistung (W) * Korrekturfaktor).", "InverterRestartHour": "Uhrzeit für geplanten Neustart", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 2ad7add2d..a798a9cfc 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -661,11 +661,12 @@ "ConfigHintsIntro": "The following notes regarding the Dynamic Power Limiter (DPL) configuration shall be considered:", "ConfigHintPowerMeterDisabled": "Without a power meter interface, the inverter limit the DPL will configure equals the configured base load (exception: (full) solar-passthrough).", "ConfigHintNoInverter": "At least one inverter must be configured prior to setting up the DPL.", - "ConfigHintInverterCommunication": "Polling data from and sending commands to the target inverter must be enabled.", + "ConfigHintInverterCommunication": "Polling data from and sending commands to all managed inverters must be enabled.", "ConfigHintNoChargeController": "The solar-passthrough feature can only be used if the VE.Direct interface is configured.", "ConfigHintNoBatteryInterface": "SoC-based thresholds can only be used if a battery communication interface is configured.", "General": "General", "Enable": "Enable", + "GovernInverter": "Govern Inverter \"{name}\"", "VerboseLogging": "@:base.VerboseLogging", "SolarPassthrough": "Solar-Passthrough", "EnableSolarPassthrough": "Enable Solar-Passthrough", @@ -673,34 +674,51 @@ "SolarPassthroughLossesInfo": "Hint: Line losses are to be expected when transferring energy from the solar charge controller to the inverter. These losses can be taken into account to prevent the battery from gradually discharging in (full) solar-passthrough mode. The power limit to be set on the inverter is additionally reduced by this factor after taking its efficiency into account.", "BatteryDischargeAtNight": "Use battery at night even if only partially charged", "SolarpassthroughInfo": "This feature allows to use the available current solar power directly. The solar power, as reported by the MPPT charge controller, is set as the inverter's limit, even if the battery is currently charging. This avoids storing energy unnecessarily, which would be lossy.", - "InverterSettings": "Inverter", - "Inverter": "Target Inverter", + "InverterSettings": "Inverter Settings", "SelectInverter": "Select an inverter...", + "InverterForDcVoltage": "Inverter used for voltage measurements", "InverterChannelId": "Input used for voltage measurements", "TargetPowerConsumption": "Target Grid Consumption", - "TargetPowerConsumptionHint": "Grid power consumption the limiter tries to achieve. Value may be negative.", + "TargetPowerConsumptionHint": "Grid power consumption the dynamic power limiter tries to achieve. Value may be negative.", "TargetPowerConsumptionHysteresis": "Hysteresis", - "TargetPowerConsumptionHysteresisHint": "Only send a newly calculated power limit to the inverter if the absolute difference to the last reported power limit exceeds this amount.", + "TargetPowerConsumptionHysteresisHint": "Only send a newly calculated power limit to the respective inverter if the absolute difference to the last reported power limit exceeds this amount.", "LowerPowerLimit": "Minimum Power Limit", "LowerPowerLimitHint": "This value must be selected so that stable operation is possible at this limit. If the inverter could only be operated with a lower limit, it is put into standby instead.", "BaseLoadLimit": "Base Load", - "BaseLoadLimitHint": "Relevant for operation without power meter or when the power meter fails. As long as the other conditions allow (in particular battery charge), this limit is set on the inverter.", + "BaseLoadLimitHint": "Relevant for operation without power meter or when the power meter fails. As long as the other conditions allow (battery charge in particular), the inverters are configured to output this amount of power in total.", + "TotalUpperPowerLimit": "Maximum Total Output", + "TotalUpperPowerLimitHint": "The inverters are configured to output this maximum amount of power in total.", + "ManagedInverters": "Managed Inverters", + "AddInverter": "Add Inverter", + "NoManagedInverters": "The Dynamic Power Limiter is currently not managing any inverters. To continue, select an inverter in the dropdown list above and add it.", + "InverterLabel": "Name (Type)", + "PowerSource": "Power Source", + "PowerSourceBattery": "Battery", + "PowerSourceSolarPanels": "Solar Panel(s)", + "EditInverter": "Edit Inverter", + "EditInverterLabel": "Concerns Inverter", + "Apply": "Apply", + "Cancel": "@:base.Cancel", + "Delete": "Delete", + "DeleteInverter": "Confirm Removal", + "DeleteInverterMsg": "Should inverter {label} with serial number {serial} be removed from the list of inverters managed by the Dynamic Power Limiter?", "UpperPowerLimit": "Maximum Power Limit", "UpperPowerLimitHint": "The inverter is always set such that no more than this output power is achieved. This value must be selected to comply with the current carrying capacity of the AC connection cables.", "SocThresholds": "Battery State of Charge (SoC) Thresholds", - "IgnoreSoc": "Ignore Battery SoC", + "IgnoreSoc": "Use Voltage Threshols Only", + "IgnoreSocHint": "When enabled, only voltage thresholds are considered. Disable this switch to configure and use battery State of Charge (SoC) thresholds (not recommended as the SoC value is often inaccurate).", "StartThreshold": "Start Threshold for Battery Discharging", "StopThreshold": "Stop Threshold for Battery Discharging", "FullSolarPassthroughStartThreshold": "Full Solar-Passthrough Start Threshold", "FullSolarPassthroughStartThresholdHint": "Inverter power is set equal to Victron MPPT power (minus efficiency factors) while above this threshold. Use this if you want to supply excess power to the grid when the battery is full.", "VoltageSolarPassthroughStopThreshold": "Full Solar-Passthrough Stop Threshold", "VoltageLoadCorrectionFactor": "Load correction factor", - "BatterySocInfo": "Hint: The battery SoC (State of Charge) values are only used if the battery communication interface reported SoC updates in the last minute. Otherwise the voltage thresholds will be used as fallback.", + "BatterySocInfo": "Hint: The use of battery State of Charge (SoC) thresholds is prioritized. However, SoC thresholds are only used if the battery communication interface has processed valid SoC values in the last minute. Otherwise, the voltage thresholds will be used as fallback.", "InverterIsBehindPowerMeter": "PowerMeter reading includes inverter output", "InverterIsBehindPowerMeterHint": "Enable this option if the power meter reading is reduced by the inverter's output when it produces power. This is typically true.", "InverterIsSolarPowered": "Inverter is powered by solar modules", "UseOverscalingToCompensateShading": "Compensate for shading", - "UseOverscalingToCompensateShadingHint": "Allow to overscale the inverter limit to compensate for shading of one or multiple inputs", + "UseOverscalingToCompensateShadingHint": "Allow to overscale the inverter limit to compensate for shading of one or multiple inputs.", "VoltageThresholds": "Battery Voltage Thresholds", "VoltageLoadCorrectionInfo": "Hint: When the battery is discharged, its voltage drops. The voltage drop scales with the discharge current. In order to not stop the inverter too early (stop threshold), this load correction factor can be specified to calculate the battery voltage if it was idle. Corrected voltage = DC Voltage + (Current power * correction factor).", "InverterRestartHour": "Automatic Restart Time", diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index ff19cd9d7..9a99f42e5 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -727,11 +727,12 @@ "ConfigHintsIntro": "The following notes regarding the Dynamic Power Limiter (DPL) configuration shall be considered:", "ConfigHintPowerMeterDisabled": "Without a power meter interface, the inverter limit the DPL will configure equals the configured base load (exception: (full) solar-passthrough).", "ConfigHintNoInverter": "At least one inverter must be configured prior to setting up the DPL.", - "ConfigHintInverterCommunication": "Polling data from and sending commands to the target inverter must be enabled.", + "ConfigHintInverterCommunication": "Polling data from and sending commands to all managed inverters must be enabled.", "ConfigHintNoChargeController": "The solar-passthrough feature can only be used if the VE.Direct interface is configured.", "ConfigHintNoBatteryInterface": "SoC-based thresholds can only be used if a battery communication interface is configured.", "General": "General", "Enable": "Enable", + "GovernInverter": "Govern Inverter \"{name}\"", "VerboseLogging": "@:base.VerboseLogging", "SolarPassthrough": "Solar-Passthrough", "EnableSolarPassthrough": "Enable Solar-Passthrough", @@ -740,8 +741,8 @@ "BatteryDischargeAtNight": "Use battery at night even if only partially charged", "SolarpassthroughInfo": "This feature allows to use the available current solar power directly. The solar power, as reported by the MPPT charge controller, is set as the inverter's limit, even if the battery is currently charging. This avoids storing energy unnecessarily, which would be lossy.", "InverterSettings": "Inverter", - "Inverter": "Target Inverter", "SelectInverter": "Select an inverter...", + "InverterForDcVoltage": "Inverter used for voltage measurements", "InverterChannelId": "Input used for voltage measurements", "TargetPowerConsumption": "Target Grid Consumption", "TargetPowerConsumptionHint": "Grid power consumption the limiter tries to achieve. Value may be negative.", @@ -751,22 +752,44 @@ "LowerPowerLimitHint": "This value must be selected so that stable operation is possible at this limit. If the inverter could only be operated with a lower limit, it is put into standby instead.", "BaseLoadLimit": "Base Load", "BaseLoadLimitHint": "Relevant for operation without power meter or when the power meter fails. As long as the other conditions allow (in particular battery charge), this limit is set on the inverter.", + "TotalUpperPowerLimit": "Maximum Total Output", + "TotalUpperPowerLimitHint": "The inverters are configured to output this maximum amount of power in total.", + "ManagedInverters": "Managed Inverters", + "AddInverter": "Add Inverter", + "NoManagedInverters": "The Dynamic Power Limiter is currently not managing any inverters. To continue, select an inverter in the dropdown list above and add it.", + "InverterLabel": "Name (Type)", + "PowerSource": "Power Source", + "PowerSourceBattery": "Battery", + "PowerSourceSolarPanels": "Solar Panel(s)", + "EditInverter": "Edit Inverter", + "EditInverterLabel": "Concerns Inverter", + "Apply": "Apply", + "Cancel": "@:base.Cancel", + "Delete": "Delete", + "DeleteInverter": "Confirm Removal", + "DeleteInverterMsg": "Should inverter {label} with serial number {serial} be removed from the list of inverters managed by the Dynamic Power Limiter?", "UpperPowerLimit": "Maximum Power Limit", "UpperPowerLimitHint": "The inverter is always set such that no more than this output power is achieved. This value must be selected to comply with the current carrying capacity of the AC connection cables.", "SocThresholds": "Battery State of Charge (SoC) Thresholds", - "IgnoreSoc": "Ignore Battery SoC", + "IgnoreSoc": "Use Voltage Threshols Only", + "IgnoreSocHint": "When enabled, only voltage thresholds are considered. Disable this switch to configure and use battery State of Charge (SoC) thresholds (not recommended as the SoC value is often inaccurate).", "StartThreshold": "Start Threshold for Battery Discharging", "StopThreshold": "Stop Threshold for Battery Discharging", "FullSolarPassthroughStartThreshold": "Full Solar-Passthrough Start Threshold", "FullSolarPassthroughStartThresholdHint": "Inverter power is set equal to Victron MPPT power (minus efficiency factors) while above this threshold. Use this if you want to supply excess power to the grid when the battery is full.", "VoltageSolarPassthroughStopThreshold": "Full Solar-Passthrough Stop Threshold", "VoltageLoadCorrectionFactor": "Load correction factor", - "BatterySocInfo": "Hint: The battery SoC (State of Charge) values are only used if the battery communication interface reported SoC updates in the last minute. Otherwise the voltage thresholds will be used as fallback.", + "BatterySocInfo": "Hint: The use of battery State of Charge (SoC) thresholds is prioritized. However, SoC thresholds are only used if the battery communication interface has processed valid SoC values in the last minute. Otherwise, the voltage thresholds will be used as fallback.", "InverterIsBehindPowerMeter": "PowerMeter reading includes inverter output", "InverterIsBehindPowerMeterHint": "Enable this option if the power meter reading is reduced by the inverter's output when it produces power. This is typically true.", "InverterIsSolarPowered": "Inverter is powered by solar modules", + "UseOverscalingToCompensateShading": "Compensate for shading", + "UseOverscalingToCompensateShadingHint": "Allow to overscale the inverter limit to compensate for shading of one or multiple inputs.", "VoltageThresholds": "Battery Voltage Thresholds", - "VoltageLoadCorrectionInfo": "Hint: When the battery is discharged, its voltage drops. The voltage drop scales with the discharge current. In order to not stop the inverter too early (stop threshold), this load correction factor can be specified to calculate the battery voltage if it was idle. Corrected voltage = DC Voltage + (Current power * correction factor)." + "VoltageLoadCorrectionInfo": "Hint: When the battery is discharged, its voltage drops. The voltage drop scales with the discharge current. In order to not stop the inverter too early (stop threshold), this load correction factor can be specified to calculate the battery voltage if it was idle. Corrected voltage = DC Voltage + (Current power * correction factor).", + "InverterRestartHour": "Automatic Restart Time", + "InverterRestartDisabled": "Do not execute automatic restart", + "InverterRestartHint": "The daily yield of the inverter is usually reset at night when the inverter turns off due to lack of light. To reset the daily yield even though the inverter is continuously powered by the battery, the inverter can be automatically restarted daily at the desired time." }, "login": { "Login": "Connexion", diff --git a/webapp/src/types/PowerLimiterConfig.ts b/webapp/src/types/PowerLimiterConfig.ts index f8220a7d5..a2189ec50 100644 --- a/webapp/src/types/PowerLimiterConfig.ts +++ b/webapp/src/types/PowerLimiterConfig.ts @@ -1,10 +1,12 @@ export interface PowerLimiterInverterInfo { + serial: string; pos: number; name: string; poll_enable: boolean; poll_enable_night: boolean; command_enable: boolean; command_enable_night: boolean; + max_power: number; type: string; channels: number; } @@ -15,7 +17,17 @@ export interface PowerLimiterMetaData { power_meter_enabled: boolean; battery_enabled: boolean; charge_controller_enabled: boolean; - inverters: { [key: string]: PowerLimiterInverterInfo }; + inverters: PowerLimiterInverterInfo[]; +} + +export interface PowerLimiterInverterConfig { + serial: string; + is_governed: boolean; + is_behind_power_meter: boolean; + is_solar_powered: boolean; + use_overscaling_to_compensate_shading: boolean; + lower_power_limit: number; + upper_power_limit: number; } export interface PowerLimiterConfig { @@ -24,16 +36,9 @@ export interface PowerLimiterConfig { solar_passthrough_enabled: boolean; solar_passthrough_losses: number; battery_always_use_at_night: boolean; - is_inverter_behind_powermeter: boolean; - is_inverter_solar_powered: boolean; - use_overscaling_to_compensate_shading: boolean; - inverter_serial: string; - inverter_channel_id: number; target_power_consumption: number; target_power_consumption_hysteresis: number; - lower_power_limit: number; base_load_limit: number; - upper_power_limit: number; ignore_soc: boolean; battery_soc_start_threshold: number; battery_soc_stop_threshold: number; @@ -44,4 +49,9 @@ export interface PowerLimiterConfig { full_solar_passthrough_soc: number; full_solar_passthrough_start_voltage: number; full_solar_passthrough_stop_voltage: number; + inverter_serial_for_dc_voltage: string; + inverter_channel_id_for_dc_voltage: number; + restart_hour: number; + total_upper_power_limit: number; + inverters: PowerLimiterInverterConfig[]; } diff --git a/webapp/src/views/PowerLimiterAdminView.vue b/webapp/src/views/PowerLimiterAdminView.vue index 639641a40..4c66c605b 100644 --- a/webapp/src/views/PowerLimiterAdminView.vue +++ b/webapp/src/views/PowerLimiterAdminView.vue @@ -30,7 +30,11 @@
- + - + - + + - + - + @@ -393,7 +425,12 @@ import CardElement from '@/components/CardElement.vue'; import FormFooter from '@/components/FormFooter.vue'; import InputElement from '@/components/InputElement.vue'; import { BIconInfoCircle } from 'bootstrap-icons-vue'; -import type { PowerLimiterConfig, PowerLimiterMetaData } from '@/types/PowerLimiterConfig'; +import type { + PowerLimiterConfig, + PowerLimiterInverterConfig, + PowerLimiterMetaData, + PowerLimiterInverterInfo, +} from '@/types/PowerLimiterConfig'; export default defineComponent({ components: { @@ -416,42 +453,70 @@ export default defineComponent({ }; }, created() { - this.getAllData(); + this.getMetaData(); }, watch: { - 'powerLimiterConfigList.inverter_serial'(newVal) { + governedInverters() { + if ( + !this.governedInverters.some( + (inv: PowerLimiterInverterConfig) => + inv.serial == this.powerLimiterConfigList.inverter_serial_for_dc_voltage + ) + ) { + // marks serial as invalid, selects placeholder option + this.powerLimiterConfigList.inverter_serial_for_dc_voltage = ''; + } + }, + }, + computed: { + governedInverters(): PowerLimiterInverterConfig[] { + const inverters = this.powerLimiterConfigList?.inverters || []; + return inverters.filter((inv: PowerLimiterInverterConfig) => inv.is_governed) || []; + }, + governedBatteryPoweredInverters(): PowerLimiterInverterConfig[] { + return this.governedInverters.filter((inv: PowerLimiterInverterConfig) => !inv.is_solar_powered); + }, + governingBatteryPoweredInverters(): boolean { + return this.governedBatteryPoweredInverters.length > 0; + }, + isEnabled(): boolean { const cfg = this.powerLimiterConfigList; + return cfg.enabled && this.governedInverters.length > 0; + }, + isSolarPassthroughEnabled(): boolean { + return ( + this.powerLimiterMetaData.charge_controller_enabled && + this.powerLimiterConfigList.solar_passthrough_enabled + ); + }, + hasPowerMeter(): boolean { + return this.powerLimiterMetaData.power_meter_enabled; + }, + canUseSolarPassthrough(): boolean { const meta = this.powerLimiterMetaData; - - if (newVal === '') { - return; - } // do not try to convert the placeholder value - - if (meta.inverters[newVal] !== undefined) { - return; - } - - for (const [serial, inverter] of Object.entries(meta.inverters)) { - // cfg.inverter_serial might be too large to parse as a 32 bit - // int, so we make sure to only try to parse two characters. if - // cfg.inverter_serial is indeed an old position based index, - // it is only one character. - if (inverter.pos == Number(cfg.inverter_serial.substr(0, 2))) { - // inverter_serial uses the old position-based - // value to identify the inverter. convert to serial. - cfg.inverter_serial = serial; - return; - } - } - - // previously selected inverter was deleted. marks serial as - // invalid, selects placeholder option. - cfg.inverter_serial = ''; + return meta.charge_controller_enabled && this.governingBatteryPoweredInverters; + }, + canUseVoltageThresholds(): boolean { + return this.governingBatteryPoweredInverters; + }, + canUseSoCThresholds(): boolean { + const cfg = this.powerLimiterConfigList; + const meta = this.powerLimiterMetaData; + return meta.battery_enabled && this.governingBatteryPoweredInverters && !cfg.ignore_soc; + }, + hasBatteryInterface(): boolean { + const meta = this.powerLimiterMetaData; + return meta.battery_enabled && this.governingBatteryPoweredInverters; }, }, methods: { - getConfigHints() { - const cfg = this.powerLimiterConfigList; + getInverterInfo(serial: string): PowerLimiterInverterInfo { + return ( + this.powerLimiterMetaData.inverters?.find((inv: PowerLimiterInverterInfo) => inv.serial === serial) || + ({} as PowerLimiterInverterInfo) + ); + }, + getConfigHints(): { severity: string; subject: string }[] { const meta = this.powerLimiterMetaData; const hints = []; @@ -459,20 +524,26 @@ export default defineComponent({ hints.push({ severity: 'optional', subject: 'PowerMeterDisabled' }); } - if (typeof meta.inverters === 'undefined' || Object.keys(meta.inverters).length == 0) { + if (typeof meta.inverters === 'undefined' || meta.inverters.length == 0) { hints.push({ severity: 'requirement', subject: 'NoInverter' }); this.configAlert = true; } else { - const inv = meta.inverters[cfg.inverter_serial]; - if ( - inv !== undefined && - !(inv.poll_enable && inv.command_enable && inv.poll_enable_night && inv.command_enable_night) - ) { - hints.push({ severity: 'requirement', subject: 'InverterCommunication' }); + for (const inv of this.powerLimiterMetaData.inverters) { + if ( + !this.powerLimiterConfigList.inverters.some( + (i: PowerLimiterInverterConfig) => i.serial == inv.serial + ) + ) { + continue; + } + if (!(inv.poll_enable && inv.command_enable && inv.poll_enable_night && inv.command_enable_night)) { + hints.push({ severity: 'requirement', subject: 'InverterCommunication' }); + break; + } } } - if (!cfg.is_inverter_solar_powered) { + if (this.governingBatteryPoweredInverters) { if (!meta.charge_controller_enabled) { hints.push({ severity: 'optional', subject: 'NoChargeController' }); } @@ -484,82 +555,104 @@ export default defineComponent({ return hints; }, - isEnabled() { - return this.powerLimiterConfigList.enabled; - }, - hasPowerMeter() { - return this.powerLimiterMetaData.power_meter_enabled; - }, - canUseOverscaling() { - const cfg = this.powerLimiterConfigList; - return cfg.is_inverter_solar_powered; - }, - canUseBatteryDischargeAtNight() { - const cfg = this.powerLimiterConfigList; - return !cfg.is_inverter_solar_powered; + range(end: number) { + return Array.from(Array(end).keys()); }, - canUseSolarPassthrough() { - const cfg = this.powerLimiterConfigList; + inverterName(serial: string) { + if (serial === undefined) { + return 'undefined'; + } const meta = this.powerLimiterMetaData; - return this.isEnabled() && meta.charge_controller_enabled && !cfg.is_inverter_solar_powered; + if (meta === undefined) { + return 'metadata pending'; + } + const inv = this.getInverterInfo(serial); + if (inv === undefined) { + return 'not found'; + } + return inv.name; }, - canUseSoCThresholds() { - const cfg = this.powerLimiterConfigList; + inverterLabel(serial: string) { + if (serial === undefined) { + return 'undefined'; + } const meta = this.powerLimiterMetaData; - return this.isEnabled() && meta.battery_enabled && !cfg.is_inverter_solar_powered; - }, - canUseVoltageThresholds() { - const cfg = this.powerLimiterConfigList; - return this.isEnabled() && !cfg.is_inverter_solar_powered; - }, - isSolarPassthroughEnabled() { - return this.powerLimiterConfigList.solar_passthrough_enabled; - }, - range(end: number) { - return Array.from(Array(end).keys()); + if (meta === undefined) { + return 'metadata pending'; + } + const inv = this.getInverterInfo(serial); + if (inv === undefined) { + return 'not found'; + } + return inv.name + ' (' + inv.type + ')'; }, needsChannelSelection() { const cfg = this.powerLimiterConfigList; - const meta = this.powerLimiterMetaData; const reset = function () { - cfg.inverter_channel_id = 0; + cfg.inverter_channel_id_for_dc_voltage = 0; return false; }; - if (cfg.inverter_serial === '') { - return reset(); - } - - if (cfg.is_inverter_solar_powered) { + if (!this.governingBatteryPoweredInverters) { return reset(); } - const inverter = meta.inverters[cfg.inverter_serial]; - if (inverter === undefined) { + if (cfg.inverter_serial_for_dc_voltage === '') { return reset(); } - if (cfg.inverter_channel_id >= inverter.channels) { - reset(); + const inverter = this.getInverterInfo(cfg.inverter_serial_for_dc_voltage); + if (cfg.inverter_channel_id_for_dc_voltage >= inverter.channels) { + cfg.inverter_channel_id_for_dc_voltage = 0; } return inverter.channels > 1; }, - getAllData() { + getMetaData() { this.dataLoading = true; fetch('/api/powerlimiter/metadata', { headers: authHeader() }) .then((response) => handleResponse(response, this.$emitter, this.$router)) .then((data) => { this.powerLimiterMetaData = data; - fetch('/api/powerlimiter/config', { headers: authHeader() }) - .then((response) => handleResponse(response, this.$emitter, this.$router)) - .then((data) => { - this.powerLimiterConfigList = data; - this.dataLoading = false; - }); + this.getConfigData(); }); }, + getConfigData() { + fetch('/api/powerlimiter/config', { headers: authHeader() }) + .then((response) => handleResponse(response, this.$emitter, this.$router)) + .then((data) => { + data.inverters = this.tidyUpInverterConfigs(data.inverters); + this.powerLimiterConfigList = data; + this.dataLoading = false; + }); + }, + tidyUpInverterConfigs(inverters: PowerLimiterInverterConfig[]): PowerLimiterInverterConfig[] { + const metaInverters = this.powerLimiterMetaData?.inverters || []; + + // remove power limiter inverter config if no such inverter exists + inverters = inverters.filter((cfgInv: PowerLimiterInverterConfig) => + metaInverters.some((metaInv) => metaInv.serial === cfgInv.serial) + ); + + // add default power limiter inverter config for new inverters + for (const metaInv of metaInverters) { + const known = inverters.some((cfgInv: PowerLimiterInverterConfig) => cfgInv.serial === metaInv.serial); + if (known) { + continue; + } + + const newInv = {} as PowerLimiterInverterConfig; + newInv.serial = metaInv.serial; + newInv.is_governed = false; + newInv.is_behind_power_meter = true; + newInv.lower_power_limit = 10 * metaInv.channels; + newInv.upper_power_limit = Math.max(metaInv.max_power, 300); + inverters.push(newInv); + } + + return inverters; + }, savePowerLimiterConfig(e: Event) { e.preventDefault(); @@ -576,6 +669,7 @@ export default defineComponent({ this.alertMessage = response.message; this.alertType = response.type; this.showAlert = true; + window.scrollTo(0, 0); }); }, },