Innovenergy_trunk/NodeRed/dvcc.py

1288 lines
47 KiB
Python
Raw Normal View History

2024-05-28 09:20:02 +00:00
from dbus.exceptions import DBusException
from gi.repository import GLib
import logging
from math import pi, floor, ceil
import traceback
from itertools import count, chain
from functools import partial, wraps
# Victron packages
from sc_utils import safeadd, copy_dbus_value, reify, ExpiringValue
from ve_utils import exit_on_error
from delegates.base import SystemCalcDelegate
from delegates.batteryservice import BatteryService
from delegates.multi import Multi as MultiService
# Adjust things this often (in seconds)
# solar chargers has to switch to HEX mode each time we write a value to its
# D-Bus service. Writing too often may block text messages. In MPPT firmware
# v1.23 and later, all relevant values will be transmitted as asynchronous
# message, so the update rate could be increased. For now, keep this at 3 and
# above.
ADJUST = 3
VEBUS_FIRMWARE_REQUIRED = 0x422
VEDIRECT_FIRMWARE_REQUIRED = 0x129
VECAN_FIRMWARE_REQUIRED = 0x10200 # 1.02, 24-bit version
# This is a place to account for some BMS quirks where we may have to ignore
# the BMS value and substitute our own.
def _byd_quirk(dvcc, bms, charge_voltage, charge_current, feedback_allowed):
""" Quirk for the BYD batteries. When the battery sends CCL=0, float it at
55V. """
if charge_current == 0:
return (55, 40, feedback_allowed, False)
return (charge_voltage, charge_current, feedback_allowed, False)
def _lg_quirk(dvcc, bms, charge_voltage, charge_current, feedback_allowed):
""" Quirk for LG batteries. The hard limit is 58V. Above that you risk
tripping on high voltage. The batteries publish a charge voltage of 57.7V
but we need to make room for an 0.4V overvoltage when feed-in is enabled.
"""
# Make room for a potential 0.4V at the top
return (min(charge_voltage, 57.3), charge_current, feedback_allowed, False)
def _pylontech_quirk(dvcc, bms, charge_voltage, charge_current, feedback_allowed):
""" Quirk for Pylontech. Make a bit of room at the top. Pylontech says that
at 51.8V the battery is 95% full, and that balancing starts at 90%.
53.2V is normally considered 100% full, and 54V raises an alarm. By
running the battery at 52.4V it will be 99%-100% full, balancing should
be active, and we should avoid high voltage alarms.
Identify 24-V batteries by the lower charge voltage, and do the same
thing with an 8-to-15 cell ratio, +-3.48V per cell.
"""
# Use 3.48V per cell plus a little, 52.4V for 15 cell 48V batteries.
# Use 3.46V per cell plus a little, 27.8V for 24V batteries testing shows that's 100% SOC.
# That leaves 1.6V margin for 48V batteries and 1.0V for 24V.
# See https://github.com/victronenergy/venus/issues/536
if charge_voltage > 55:
# 48V battery (16 cells.) Assume BMS knows what it's doing.
return (charge_voltage, charge_current, feedback_allowed, False)
if charge_voltage > 30:
# 48V battery (15 cells)
return (min(charge_voltage, 52.4), charge_current, feedback_allowed, False)
if charge_voltage > 20:
# 24V battery (8 cells). 24V batteries send CCL=0 when they are full,
# whereas the 48V batteries reduce CCL by 50% when the battery is full.
# Do the same for 24V batteries. The normal limit is C/2, so put the
# limit to C/4. Note that this is just a nicety, the important part is
# to clip the charge voltage to 27.8 volts. That fixes the sawtooth
# issue.
capacity = bms.capacity or 55
return (min(charge_voltage, 27.8), max(charge_current, round(capacity/4.0)), feedback_allowed, False)
# Not known, probably a 12V battery.
return (charge_voltage, charge_current, feedback_allowed, False)
def _pylontech_pelio_quirk(dvcc, bms, charge_voltage, charge_current, feedback_allowed):
""" Quirk for Pelio-L batteries. This is a 16-cell battery. 56V is 3.5V per
cell which is where this battery registers 100% SOC. Battery sends
CCL=0 at 3.55V per cell, to ensure good feed-in of excess DC coupled
PV, set the lower limit to 20% of capacity, which is what the battery
itself imposes at around 98% SOC.
"""
capacity = bms.capacity or 100.0
return (min(charge_voltage, 56.0), max(charge_current, round(capacity/5.0)), feedback_allowed, False)
def _lynx_smart_bms_quirk(dvcc, bms, charge_voltage, charge_current, feedback_allowed):
""" When the Lynx Smart BMS sends CCL=0, it wants all chargers to stop. """
return (charge_voltage, charge_current, feedback_allowed, True)
QUIRKS = {
0xB004: _lg_quirk,
0xB009: _pylontech_quirk,
0xB00A: _byd_quirk,
0xB015: _byd_quirk,
0xB019: _byd_quirk,
0xB029: _pylontech_pelio_quirk,
0xA3E5: _lynx_smart_bms_quirk,
0xA3E6: _lynx_smart_bms_quirk,
}
def distribute(current_values, max_values, increment):
""" current_values and max_values are lists of equal size containing the
current limits, and the maximum they can be increased to. increment
contains the amount by which we want to increase the total, ie the sum
of the values in current_values, while staying below max_values.
This is done simply by first attempting to spread the increment
equally. If a value exceeds the max in that process, the remainder is
thrown back into the pot and distributed equally among the rest.
Negative values are also handled, and zero is assumed to be the
implicit lower limit. """
n = cn = len(current_values)
new_values = [-1] * n
for j in range(0, n):
for i, mv, av in zip(count(), max_values, current_values):
assert mv >= 0
if new_values[i] == mv or new_values[i] == 0:
continue
nv = av + float(increment) / cn
if nv >= mv:
increment += av - mv
cn -= 1
new_values[i] = mv
break
elif nv < 0:
increment += av
cn -= 1
new_values[i] = 0
break
new_values[i] = nv
else:
break
continue
return new_values
class LowPassFilter(object):
""" Low pass filter, with a cap. """
def __init__(self, omega, value):
self.omega = omega
self._value = value
def update(self, newvalue):
self._value += (newvalue - self._value) * self.omega
return self._value
@property
def value(self):
return self._value
class BaseCharger(object):
def __init__(self, monitor, service):
self.monitor = monitor
self.service = service
def _get_path(self, path):
return self.monitor.get_value(self.service, path)
def _set_path(self, path, v):
if self.monitor.seen(self.service, path):
self.monitor.set_value_async(self.service, path, v)
@property
def firmwareversion(self):
return self.monitor.get_value(self.service, '/FirmwareVersion')
@property
def product_id(self):
return self.monitor.get_value(self.service, '/ProductId') or 0
@property
def chargecurrent(self):
return self._get_path('/Dc/0/Current')
@property
def n2k_device_instance(self):
return self.monitor.get_value(self.service, '/N2kDeviceInstance')
@property
def connection(self):
return self._get_path('/Mgmt/Connection')
@property
def active(self):
return self._get_path('/State') != 0
@property
def has_externalcontrol_support(self):
""" Override this to implement detection of external control support.
"""
return False
@property
def want_bms(self):
""" Indicates whether this solar charger was previously
controlled by a BMS and therefore expects one to
be present. """
return 0
@property
def maxchargecurrent(self):
v = self._get_path('/Link/ChargeCurrent')
return v if v is not None else self.currentlimit
@maxchargecurrent.setter
def maxchargecurrent(self, v):
v = max(0, min(v, self.currentlimit))
self._set_path('/Link/ChargeCurrent', v)
@property
def chargevoltage(self):
return self._get_path('/Link/ChargeVoltage')
@chargevoltage.setter
def chargevoltage(self, v):
self._set_path('/Link/ChargeVoltage', v)
@property
def currentlimit(self):
return self._get_path('/Settings/ChargeCurrentLimit')
def maximize_charge_current(self):
""" Max out the charge current of this solar charger by setting
ChargeCurrent to the configured limit in settings. """
if self.monitor.seen(self.service, '/Link/ChargeCurrent'):
copy_dbus_value(self.monitor,
self.service, '/Settings/ChargeCurrentLimit',
self.service, '/Link/ChargeCurrent')
@property
def smoothed_current(self):
# For chargers that are not solar-chargers, the generated current
# should be fairly stable already
return self.chargecurrent or 0
class Networkable(object):
""" Mix into BaseCharger to support network paths. """
@property
def networkmode(self):
return self._get_path('/Link/NetworkMode')
@networkmode.setter
def networkmode(self, v):
self._set_path('/Link/NetworkMode', 0)
self._set_path('/Settings/BmsPresent',0)
class SolarCharger(BaseCharger, Networkable):
""" Encapsulates a solar charger on dbus. Exposes dbus paths as convenient
attributes. """
def __init__(self, monitor, service):
super().__init__(monitor, service)
self._smoothed_current = LowPassFilter((2 * pi)/20, self.chargecurrent or 0)
self._has_externalcontrol_support = False
@property
def has_externalcontrol_support(self):
# If we have previously determined that there is support, re-use that.
# If the firmware is ever to be downgraded, the solarcharger must necessarily
# disconnect and reconnect, so this is completely safe.
if self._has_externalcontrol_support:
return True
# These products are known to have support, but may have older firmware
# See https://github.com/victronenergy/venus/issues/655
if 0xA102 <= self.product_id <= 0xA10E:
self._has_externalcontrol_support = True
return True
v = self.firmwareversion
# If the firmware version is not known, don't raise a false
# warning.
if v is None:
return True
# New VE.Can controllers have 24-bit version strings. One would
# hope that any future VE.Direct controllers with 24-bit firmware
# versions will 1) have a version larger than 1.02 and 2) support
# external control.
if v & 0xFF0000:
self._has_externalcontrol_support = (v >= VECAN_FIRMWARE_REQUIRED)
else:
self._has_externalcontrol_support = (v >= VEDIRECT_FIRMWARE_REQUIRED)
return self._has_externalcontrol_support
@property
def smoothed_current(self):
""" Returns the internal low-pass filtered current value. """
return self._smoothed_current.value
def update_values(self):
# This is called periodically from a timer to maintain
# a smooth current value.
v = self.monitor.get_value(self.service, '/Dc/0/Current')
if v is not None:
self._smoothed_current.update(v)
class Alternator(BaseCharger, Networkable):
""" This also includes other DC/DC converters. """
@property
def has_externalcontrol_support(self):
# If it has the ChargeCurrent path, we assume it has
# external control support
return self.monitor.seen(self.service, '/Link/ChargeCurrent')
class InverterCharger(SolarCharger):
""" Encapsulates an inverter/charger object, currently the inverter RS,
which has a solar input and can charge the battery like a solar
charger, but is also an inverter.
"""
def __init__(self, monitor, service):
super(InverterCharger, self).__init__(monitor, service)
@property
def has_externalcontrol_support(self):
# Inverter RS always had support
return True
@property
def maxdischargecurrent(self):
""" Returns discharge current setting. This does nothing except
return the previously set value. """
return self.monitor.get_value(self.service, '/Link/DischargeCurrent')
@maxdischargecurrent.setter
def maxdischargecurrent(self, limit):
self.monitor.set_value_async(self.service, '/Link/DischargeCurrent', limit)
def set_maxdischargecurrent(self, limit):
""" Write the maximum discharge limit across. The firmware
already handles a zero by turning off. """
self.maxdischargecurrent = limit
@property
def active(self):
# The charger part is active, as long as the maximum charging
# power value is more than zero.
return (self.monitor.get_value(self.service,
'/Settings/ChargeCurrentLimit') or 0) > 0
class InverterSubsystem(object):
""" Encapsulate collection of inverters. """
def __init__(self, monitor):
self.monitor = monitor
self._inverters = {}
def _add_inverter(self, ob):
self._inverters[ob.service] = ob
return ob
def remove_inverter(self, service):
del self._inverters[service]
def __iter__(self):
return iter(self._inverters.values())
def __len__(self):
return len(self._inverters)
def __contains__(self, k):
return k in self._inverters
def set_maxdischargecurrent(self, limit):
# Inverters only care about limit=0, so simply send
# it to all.
for inverter in self:
inverter.set_maxdischargecurrent(limit)
class ChargerSubsystem(object):
""" Encapsulates a collection of chargers or devices that incorporate a
charger, to collectively make up a charging system (sans Multi).
Properties related to the whole system or some combination of the
individual chargers are exposed here as attributes. """
def __init__(self, monitor):
self.monitor = monitor
self._solarchargers = {}
self._otherchargers = {}
def add_solar_charger(self, service):
self._solarchargers[service] = charger = SolarCharger(self.monitor, service)
return charger
def add_alternator(self, service):
self._otherchargers[service] = charger = Alternator(self.monitor, service)
return charger
def add_invertercharger(self, service):
self._solarchargers[service] = inverter = InverterCharger(self.monitor, service)
return inverter
def remove_charger(self, service):
for di in (self._solarchargers, self._otherchargers):
try:
del di[service]
except KeyError:
pass
def __iter__(self):
return iter(chain(self._solarchargers.values(), self._otherchargers.values()))
def __len__(self):
return len(self._solarchargers) + len(self._otherchargers)
def __contains__(self, k):
return k in self._solarchargers or k in self._otherchargers
@property
def has_externalcontrol_support(self):
# Only consider solarchargers. This is used for firmware warning
# above, and we only care about the solar chargers there.
return all(s.has_externalcontrol_support for s in self._solarchargers.values())
@property
def has_vecan_chargers(self):
""" Returns true if we have any VE.Can chargers in the system. This is
used elsewhere to enable broadcasting charge voltages on the relevant
can device. """
return any((s.connection == 'VE.Can' for s in self))
@property
def want_bms(self):
""" Return true if any of our solar chargers expect a BMS to
be present. """
return any((s.want_bms for s in self))
@property
def totalcapacity(self):
""" Total capacity if all chargers are running at full power. """
return safeadd(*(c.currentlimit for c in self))
@property
def smoothed_current(self):
""" Total smoothed current, calculated by adding the smoothed current
of the individual chargers. """
return safeadd(*(c.smoothed_current for c in self)) or 0
@property
def solar_current(self):
return safeadd(*(c.smoothed_current for c in self._solarchargers.values())) or 0
def maximize_charge_current(self):
""" Max out all chargers. """
for charger in self:
charger.maximize_charge_current()
def shutdown_chargers(self):
""" Shut down all chargers. """
for charger in self:
charger.maxchargecurrent = 0
def set_networked(self, has_bms, bms_charge_voltage, charge_voltage, max_charge_current, feedback_allowed, stop_on_mcc0):
""" This is the main entry-point into the solar charger subsystem. This
sets all chargers to the same charge_voltage, and distributes
max_charge_current between the chargers. If feedback_allowed, then
we simply max out the chargers. We also don't bother with
distribution if there's only one charger in the system or if
it exceeds our total capacity.
"""
# Network mode:
# bit 0: Operated in network environment
# bit 2: Remote Hub-1 control (MPPT will accept charge voltage and max charge current)
# bit 3: Remote BMS control (MPPT enter BMS mode)
network_mode = 1 | (0 if charge_voltage is None and max_charge_current is None else 4) | (8 if has_bms else 0)
network_mode_written = False
for charger in self:
charger.networkmode = network_mode
network_mode_written = True
# Distribute the voltage setpoint to all solar chargers.
# Non-solar chargers are controlled elsewhere.
voltage_written = 0
if charge_voltage is not None:
voltage_written = int(len(self)>0)
for charger in self._solarchargers.values():
charger.chargevoltage = charge_voltage
# Distribute the original BMS voltage setpoint, if there is one,
# to the other chargers
if bms_charge_voltage is not None:
for charger in self._otherchargers.values():
charger.chargevoltage = bms_charge_voltage
# Do not limit max charge current when feedback is allowed. The
# rationale behind this is that MPPT charge power should match the
# capabilities of the battery. If the default charge algorithm is used
# by the MPPTs, the charge current should stay within limits. This
# avoids a problem that we do not know if extra MPPT power will be fed
# back to the grid when we decide to increase the MPPT max charge
# current.
#
# Additionally, don't bother with chargers that are disconnected.
chargers = [x for x in self._solarchargers.values() if x.active and x.maxchargecurrent is not None and x.n2k_device_instance in (0, None)]
if len(chargers) > 0:
if stop_on_mcc0 and max_charge_current == 0:
self.shutdown_chargers()
elif feedback_allowed:
self.maximize_charge_current()
elif max_charge_current is not None:
if len(chargers) == 1:
# The simple case: Only one charger. Simply assign the
# limit to the charger
sc = chargers[0]
cc = min(ceil(max_charge_current), sc.currentlimit)
sc.maxchargecurrent = cc
elif max_charge_current > self.totalcapacity * 0.95:
# Another simple case, we're asking for more than our
# combined capacity (with a 5% margin)
self.maximize_charge_current()
else:
# The hard case, we have more than one CC and we want
# less than our capacity.
self._distribute_current(chargers, max_charge_current)
# Split remainder over other chargers, according to individual
# capacity. Only consider controllable devices.
if max_charge_current is not None:
remainder = max(0.0, max_charge_current - self.solar_current)
controllable = [c for c in self._otherchargers.values() if c.has_externalcontrol_support]
capacity = safeadd(*(c.currentlimit for c in controllable)) or 0
if capacity > 0:
for c in controllable:
c.maxchargecurrent = remainder * c.currentlimit / capacity
# Return flags of what we did
return voltage_written, int(network_mode_written and max_charge_current is not None), network_mode
# The math for the below is as follows. Let c be the total capacity of the
# charger, l be the current limit, a the actual current it produces, k the
# total current limit for the two chargers, and m the margin (l - a)
# between the limit and what is produced.
#
# We want m/c to be the same for all our chargers.
#
# Expression 1: (l1-a1)/c1 == (l2-a2)/c2
# Expression 2: l1 + l2 == k
#
# Solving that yields the expression below.
@staticmethod
def _balance_chargers(charger1, charger2, l1, l2):
c1, c2 = charger1.currentlimit, charger2.currentlimit
a1 = min(charger1.smoothed_current, c1)
a2 = min(charger2.smoothed_current, c2)
k = l1 + l2
try:
l1 = round((c2 * a1 - c1 * a2 + k * c1)/(c1 + c2), 1)
except ArithmeticError:
return l1, l2 # unchanged
else:
l1 = max(min(l1, c1), 0)
return l1, k - l1
@staticmethod
def _distribute_current(chargers, max_charge_current):
""" This is called if there are two or more solar chargers. It
distributes the charge current over all of them. """
# Take the difference between the values and spread it over all
# the chargers. The maxchargecurrents of the chargers should ideally
# always add up to the whole.
limits = [c.maxchargecurrent for c in chargers]
ceilings = [c.currentlimit for c in chargers]
# We cannot have a max_charge_current higher than the sum of the
# ceilings.
max_charge_current = min(sum(ceilings), max_charge_current)
# Check how far we have to move our adjustment. If it doesn't have to
# move much (or at all), then just balance the charge limits. Our
# threshold for doing an additional distribution of charge is relative
# to the number of chargers, as it makes no sense to attempt a
# distribution if there is too little to be gained. The chosen value
# here is 100mA per charger.
delta = max_charge_current - sum(limits)
if abs(delta) > 0.1 * len(chargers):
limits = distribute(limits, ceilings, delta)
for charger, limit in zip(chargers, limits):
charger.maxchargecurrent = limit
else:
# Balance the limits so they have the same headroom at the top.
# Each charger is balanced against its neighbour, the one at the
# end is paired with the one at the start.
limits = []
r = chargers[0].maxchargecurrent
for c1, c2 in zip(chargers, chargers[1:]):
l, r = ChargerSubsystem._balance_chargers(c1, c2, r, c2.maxchargecurrent)
limits.append(l)
l, limits[0] = ChargerSubsystem._balance_chargers(c2, chargers[0], r, limits[0])
limits.append(l)
for charger, limit in zip(chargers, limits):
charger.maxchargecurrent = limit
def update_values(self):
# This is called periodically from a timer to update contained
# solar chargers with values that they track.
for charger in self:
try:
charger.update_values()
except AttributeError:
pass
class BatteryOperationalLimits(object):
""" Only used to encapsulate this part of the Multi's functionality.
"""
def __init__(self, multi):
self._multi = multi
def _property(path, self):
# Due to the use of partial, path and self is reversed.
return self._multi.monitor.get_value(self._multi.service, path)
def _set_property(path, self, v):
# None of these values can be negative
if v is not None:
v = max(0, v)
self._multi.monitor.set_value_async(self._multi.service, path, v)
chargevoltage = property(
partial(_property, '/BatteryOperationalLimits/MaxChargeVoltage'),
partial(_set_property, '/BatteryOperationalLimits/MaxChargeVoltage'))
maxchargecurrent = property(
partial(_property, '/BatteryOperationalLimits/MaxChargeCurrent'),
partial(_set_property, '/BatteryOperationalLimits/MaxChargeCurrent'))
maxdischargecurrent = property(
partial(_property, '/BatteryOperationalLimits/MaxDischargeCurrent'),
partial(_set_property, '/BatteryOperationalLimits/MaxDischargeCurrent'))
batterylowvoltage = property(
partial(_property, '/BatteryOperationalLimits/BatteryLowVoltage'),
partial(_set_property, '/BatteryOperationalLimits/BatteryLowVoltage'))
class Multi(object):
""" Encapsulates the multi. Makes access to dbus paths a bit neater by
exposing them as attributes. """
def __init__(self, monitor, service):
self.monitor = monitor
self._service = service
self.bol = BatteryOperationalLimits(self)
self._dc_current = LowPassFilter((2 * pi)/30, 0)
self._v = object()
@property
def service(self):
return getattr(MultiService.instance.vebus_service, 'service', None)
@property
def active(self):
return self.service is not None
@property
def ac_connected(self):
return self.monitor.get_value(self.service, '/Ac/ActiveIn/Connected') == 1
@property
def has_bolframe(self):
return self.monitor.get_value(self.service, '/FirmwareFeatures/BolFrame') == 1
@property
def has_ess_assistant(self):
# We do not analyse the content of /Devices/0/Assistants, because that
# would require us to keep a list of ESS assistant version numbers (see
# VebusSocWriter._hub2_assistant_ids). Because that list is expected to
# change (unlike the list of hub-2 assistants), we use
# /Hub4/AssistantId to check the presence. It is guaranteed that
# /Hub4/AssistantId will be published before /Devices/0/Assistants.
assistants = self.monitor.get_value(self.service, '/Devices/0/Assistants')
return assistants is not None and \
self.monitor.get_value(self.service, '/Hub4/AssistantId') == 5
@property
def dc_current(self):
""" Return a low-pass smoothed current. """
return self._dc_current.value
@property
def hub_voltage(self):
return self.monitor.get_value(self.service, '/Hub/ChargeVoltage')
@property
def maxchargecurrent(self):
return self.monitor.get_value(self.service, '/Dc/0/MaxChargeCurrent')
@maxchargecurrent.setter
def maxchargecurrent(self, v):
# If the Multi is not ready, don't write to it just yet
if self.active and self.maxchargecurrent is not None and v != self._v:
# The maximum present charge current is 6-parallel 12V 5kva units, 6*220 = 1320A.
# We will consider 10000A to be impossibly high.
self.monitor.set_value_async(self.service, '/Dc/0/MaxChargeCurrent', 10000 if v is None else v)
self._v = v
@property
def state(self):
return self.monitor.get_value(self.service, '/State')
@property
def feedin_enabled(self):
return self.monitor.get_value(self.service,
'/Hub4/L1/DoNotFeedInOvervoltage') == 0
@property
def firmwareversion(self):
return self.monitor.get_value(self.service, '/FirmwareVersion')
@property
def allow_to_charge(self):
return self.monitor.get_value(self.service, '/Bms/AllowToCharge') != 0
@property
def has_vebus_bms(self):
""" This checks that we have a VE.Bus BMS. """
return self.monitor.get_value(self.service, '/Bms/BmsType') == 2
@property
def has_vebus_bmsv2(self):
""" Checks that we have v2 of the VE.Bus BMS, but also that we can
properly use it, that is we also have an mk3. """
version = self.monitor.get_value(self.service, '/Devices/Bms/Version')
atc = self.monitor.get_value(self.service, '/Bms/AllowToCharge')
# If AllowToCharge is defined, but we have no version, then the Multi
# is off, but we still have a v2 BMS. V1 goes invalid if the multi
# is off. Yes, this is kludgy, but it is less kludgy than the
# fix the other end would require.
if self.has_vebus_bms and atc is not None and version is None:
return True
# Otherwise, if the Multi is on, check the version to see if we should
# enable v2 functionality.
return (version or 0) >= 1146100 and \
self.monitor.get_value(self.service, '/Interfaces/Mk2/ProductName') == 'MK3'
def update_values(self, limit):
c = self.monitor.get_value(self.service, '/Dc/0/Current', 0)
if c is not None:
# Cap the filter at a limit. If we don't do this, dc currents
# in excess of our capacity causes a kind of wind-up that delays
# backing-off when the load drops suddenly.
if limit is not None:
c = max(c, -limit)
self._dc_current.update(c)
class Dvcc(SystemCalcDelegate):
""" This is the main DVCC delegate object. """
def __init__(self, sc):
super(Dvcc, self).__init__()
self.systemcalc = sc
self._chargesystem = None
self._vecan_services = []
self._timer = None
self._tickcount = ADJUST
self._dcsyscurrent = LowPassFilter((2 * pi)/20, 0.0)
self._internal_mcp = ExpiringValue(3, None) # Max charging power
def get_input(self):
return [
('com.victronenergy.vebus', [
'/Ac/ActiveIn/Connected',
'/Hub/ChargeVoltage',
'/Dc/0/Current',
'/Dc/0/MaxChargeCurrent',
'/State',
'/BatteryOperationalLimits/BatteryLowVoltage',
'/BatteryOperationalLimits/MaxChargeCurrent',
'/BatteryOperationalLimits/MaxChargeVoltage',
'/BatteryOperationalLimits/MaxDischargeCurrent',
'/Bms/AllowToCharge',
'/Bms/BmsType',
'/Devices/Bms/Version',
'/FirmwareFeatures/BolFrame',
'/Hub4/L1/DoNotFeedInOvervoltage',
'/FirmwareVersion',
'/Interfaces/Mk2/ProductName']),
('com.victronenergy.solarcharger', [
'/ProductId',
'/Dc/0/Current',
'/Link/NetworkMode',
'/Link/ChargeVoltage',
'/Link/ChargeCurrent',
'/Settings/ChargeCurrentLimit',
'/State',
'/FirmwareVersion',
'/N2kDeviceInstance',
'/Mgmt/Connection',
'/Settings/BmsPresent']),
('com.victronenergy.alternator', [
'/ProductId',
'/Dc/0/Voltage',
'/Dc/0/Current',
'/Link/NetworkMode',
'/Link/ChargeVoltage',
'/Link/ChargeCurrent',
'/Settings/ChargeCurrentLimit',
'/State',
'/FirmwareVersion',
'/N2kDeviceInstance',
'/Mgmt/Connection',
'/Settings/BmsPresent']),
('com.victronenergy.inverter', [
'/ProductId',
'/Dc/0/Current',
'/IsInverterCharger',
'/Link/NetworkMode',
'/Link/ChargeVoltage',
'/Link/ChargeCurrent',
'/Link/DischargeCurrent',
'/Settings/ChargeCurrentLimit',
'/State',
'/N2kDeviceInstance',
'/Mgmt/Connection',
'/Settings/BmsPresent']),
('com.victronenergy.multi', [
'/ProductId',
'/Dc/0/Current',
'/IsInverterCharger',
'/Link/ChargeCurrent',
'/Link/DischargeCurrent',
'/Settings/ChargeCurrentLimit',
'/State',
'/N2kDeviceInstance',
'/Mgmt/Connection',
'/Settings/BmsPresent']),
('com.victronenergy.vecan', [
'/Link/ChargeVoltage',
'/Link/NetworkMode']),
('com.victronenergy.settings', [
'/Settings/CGwacs/OvervoltageFeedIn',
'/Settings/Services/Bol'])]
def get_settings(self):
return [
('maxchargecurrent', '/Settings/SystemSetup/MaxChargeCurrent', -1, -1, 10000),
('maxchargevoltage', '/Settings/SystemSetup/MaxChargeVoltage', 0.0, 0.0, 80.0),
('bol', '/Settings/Services/Bol', 0, 0, 7)
]
def set_sources(self, dbusmonitor, settings, dbusservice):
SystemCalcDelegate.set_sources(self, dbusmonitor, settings, dbusservice)
self._chargesystem = ChargerSubsystem(dbusmonitor)
self._inverters = InverterSubsystem(dbusmonitor)
self._multi = Multi(dbusmonitor, dbusservice)
self._dbusservice.add_path('/Control/SolarChargeVoltage', value=0)
self._dbusservice.add_path('/Control/SolarChargeCurrent', value=0)
self._dbusservice.add_path('/Control/EffectiveChargeVoltage', value=None)
self._dbusservice.add_path('/Control/BmsParameters', value=0)
self._dbusservice.add_path('/Control/MaxChargeCurrent', value=0)
self._dbusservice.add_path('/Control/Dvcc', value=self.has_dvcc)
self._dbusservice.add_path('/Debug/BatteryOperationalLimits/SolarVoltageOffset', value=0, writeable=True)
self._dbusservice.add_path('/Debug/BatteryOperationalLimits/VebusVoltageOffset', value=0, writeable=True)
self._dbusservice.add_path('/Debug/BatteryOperationalLimits/CurrentOffset', value=0, writeable=True)
self._dbusservice.add_path('/Dvcc/Alarms/FirmwareInsufficient', value=0)
self._dbusservice.add_path('/Dvcc/Alarms/MultipleBatteries', value=0)
def device_added(self, service, instance, do_service_change=True):
service_type = service.split('.')[2]
if service_type == 'solarcharger':
self._chargesystem.add_solar_charger(service)
elif service_type in ('inverter', 'multi'):
if self._dbusmonitor.get_value(service, '/IsInverterCharger') == 1:
# Add to both the solarcharger and inverter collections.
# add_invertercharger returns an object that can be directly
# added to the inverter collection.
self._inverters._add_inverter(
self._chargesystem.add_invertercharger(service))
elif service_type == 'vecan':
self._vecan_services.append(service)
elif service_type == 'alternator':
self._chargesystem.add_alternator(service)
elif service_type == 'battery':
pass # install timer below
else:
# Skip timer code below
return
if self._timer is None:
self._timer = GLib.timeout_add(1000, exit_on_error, self._on_timer)
def device_removed(self, service, instance):
if service in self._chargesystem:
self._chargesystem.remove_charger(service)
# Some solar chargers are inside an inverter
if service in self._inverters:
self._inverters.remove_inverter(service)
elif service in self._vecan_services:
self._vecan_services.remove(service)
elif service in self._inverters:
self._inverters.remove_inverter(service)
if len(self._chargesystem) == 0 and len(self._vecan_services) == 0 and \
len(BatteryService.instance.batteries) == 0 and self._timer is not None:
GLib.source_remove(self._timer)
self._timer = None
def _property(path, self):
# Due to the use of partial, path and self is reversed.
try:
return float(self._dbusservice[path])
except ValueError:
return None
solarvoltageoffset = property(partial(_property, '/Debug/BatteryOperationalLimits/SolarVoltageOffset'))
invertervoltageoffset = property(partial(_property, '/Debug/BatteryOperationalLimits/VebusVoltageOffset'))
currentoffset = property(partial(_property, '/Debug/BatteryOperationalLimits/CurrentOffset'))
@property
def internal_maxchargepower(self):
return self._internal_mcp.get()
@internal_maxchargepower.setter
def internal_maxchargepower(self, v):
self._internal_mcp.set(v)
@property
def dcsyscurrent(self):
""" Return non-zero DC system current, if it is based on
a real measurement. If an estimate/calculation, we cannot use it.
"""
if self._dbusservice['/Dc/System/MeasurementType'] == 1:
try:
v = self._dbusservice['/Dc/Battery/Voltage']
return self._dcsyscurrent.update(
float(self._dbusservice['/Dc/System/Power'])/v)
except (TypeError, ZeroDivisionError):
pass
return 0.0
@property
def has_ess_assistant(self):
return self._multi.active and self._multi.has_ess_assistant
@property
def has_dvcc(self):
# 0b00 = Off
# 0b01 = On
# 0b10 = Forced off
# 0b11 = Forced on
v = self._settings['bol']
return bool(v & 1)
@property
def bms(self):
return BatteryService.instance.bms
@property
def bms_seen(self):
return self._chargesystem.want_bms
def _on_timer(self):
def update_solarcharger_control_flags(voltage_written, current_written, chargevoltage):
self._dbusservice['/Control/SolarChargeVoltage'] = voltage_written
self._dbusservice['/Control/SolarChargeCurrent'] = current_written
self._dbusservice['/Control/EffectiveChargeVoltage'] = chargevoltage
bol_support = self.has_dvcc
self._tickcount -= 1; self._tickcount %= ADJUST
if not bol_support:
if self._tickcount > 0: return True
voltage_written, current_written = self._legacy_update_solarchargers()
update_solarcharger_control_flags(voltage_written, current_written, None) # Not tracking for non-DVCC case
self._dbusservice['/Control/BmsParameters'] = 0
self._dbusservice['/Control/MaxChargeCurrent'] = 0
self._dbusservice['/Control/Dvcc'] = 0
self._dbusservice['/Dvcc/Alarms/FirmwareInsufficient'] = 0
self._dbusservice['/Dvcc/Alarms/MultipleBatteries'] = 0
return True
# BOL/DVCC support below
self._dbusservice['/Dvcc/Alarms/FirmwareInsufficient'] = int(
not self._chargesystem.has_externalcontrol_support or (
self._multi.firmwareversion is not None and self._multi.firmwareversion < VEBUS_FIRMWARE_REQUIRED))
self._dbusservice['/Dvcc/Alarms/MultipleBatteries'] = int(
len(BatteryService.instance.bmses) > 1)
# Update subsystems
self._chargesystem.update_values()
self._multi.update_values(self._chargesystem.totalcapacity)
# Below are things we only do every ADJUST seconds
if self._tickcount > 0: return True
# Signal Dvcc support to other processes
self._dbusservice['/Control/Dvcc'] = 1
# Check that we have not lost the BMS, if we ever had one. If the BMS
# is lost, stop passing information to the solar chargers so that they
# might time out.
bms_service = self.bms
if self.bms_seen and bms_service is None and not self._multi.has_vebus_bmsv2:
# BMS is lost
update_solarcharger_control_flags(0, 0, None)
return True
# Get the user current limit, if set
user_max_charge_current = self._settings['maxchargecurrent']
if user_max_charge_current < 0: user_max_charge_current = None
# If there is a BMS, get the charge voltage and current from it
max_charge_current = None
charge_voltage = None
feedback_allowed = self.feedback_allowed
stop_on_mcc0 = False
has_bms = bms_service is not None
if has_bms:
charge_voltage, max_charge_current, feedback_allowed, stop_on_mcc0 = \
self._adjust_battery_operational_limits(bms_service, feedback_allowed)
# Check /Bms/AllowToCharge on the VE.Bus service, and set
# max_charge_current to zero if charging is not allowed. Skip this if
# ESS is involved, then the Multi controls this through the charge
# voltage. If it is BMS v2, then also set BMS bit so that solarchargers
# go into #67 if we lose it.
if self._multi.has_vebus_bms:
stop_on_mcc0 = True
has_bms = has_bms or self._multi.has_vebus_bmsv2
max_charge_current = 10000 if self._multi.allow_to_charge else 0
# Take the lesser of the BMS and user current limits, wherever they exist
maximae = [x for x in (user_max_charge_current, max_charge_current) if x is not None]
max_charge_current = min(maximae) if maximae else None
# Override the battery charge voltage by taking the lesser of the
# voltage limits. Only override if the battery supplies one, to prevent
# a voltage being sent to a Multi in a system without a managed battery.
# Otherwise the Multi will go into passthru if the user disables this.
if charge_voltage is not None:
user_charge_voltage = self._settings['maxchargevoltage']
if user_charge_voltage > 0:
charge_voltage = min(charge_voltage, user_charge_voltage)
# @todo EV What if ESS + OvervoltageFeedIn? In that case there is no
# charge current control on the MPPTs, but we'll still indicate that
# the control is active here. Should we?
self._dbusservice['/Control/MaxChargeCurrent'] = \
not self._multi.active or self._multi.has_bolframe
# If there is a measured DC system, the Multi and solarchargers
# should add extra current for that. Round this to nearest 100mA.
if max_charge_current is not None and max_charge_current > 0 and not stop_on_mcc0:
max_charge_current = round(max_charge_current + self.dcsyscurrent, 1)
# We need to keep a copy of the original value for later. We will be
# modifying one of them to compensate for vebus current.
_max_charge_current = max_charge_current
# If we have vebus current, we have to compensate for it. But if we must
# stop on MCC=0, then only if the max charge current is above zero.
# Otherwise leave it unmodified so that the solarchargers are also
# stopped.
vebus_dc_current = self._multi.dc_current
if _max_charge_current is not None and vebus_dc_current is not None and \
(not stop_on_mcc0 or _max_charge_current > 0) and vebus_dc_current < 0:
_max_charge_current = ceil(_max_charge_current - vebus_dc_current)
# Try to push the solar chargers to the vebus-compensated value
voltage_written, current_written, effective_charge_voltage = \
self._update_solarchargers_and_vecan(has_bms, charge_voltage,
_max_charge_current, feedback_allowed, stop_on_mcc0)
update_solarcharger_control_flags(voltage_written, current_written, effective_charge_voltage)
# The Multi gets the remainder after subtracting what the solar
# chargers made. If there is a maximum charge power from another
# delegate (dynamicess), apply that here.
if max_charge_current is not None:
max_charge_current = max(0.0, round(max_charge_current - self._chargesystem.smoothed_current))
try:
internal_mcc = self.internal_maxchargepower / self._dbusservice['/Dc/Battery/Voltage']
except (TypeError, ZeroDivisionError, ValueError):
pass
else:
try:
max_charge_current = min(x for x in (ceil(internal_mcc), max_charge_current) if x is not None)
except ValueError:
pass
# Write the remainder to the Multi.
# There are two ways to limit the charge current of a VE.Bus system. If we have a BMS,
# the BOL parameter is used.
# If not, then the BOL parameters are not available, and the /Dc/0/MaxChargeCurrent path is
# used instead. This path relates to the MaxChargeCurrent setting as also available in
# VEConfigure, except that writing to it only changes the value in RAM in the Multi.
# Unlike VEConfigure it's not necessary to take the number of units in a system into account.
#
# Venus OS v2.30 fixes in mk2-dbus related to /Dc/0/MaxChargeCurrent:
# 1) Fix charge current too high in systems with multiple units per phase. mk2-bus was dividing
# the received current only by the number of phases in the system instead of dividing by the
# number of units in the system.
# 2) Fix setted charge current still active after disabling the "Limit charge current" setting.
# It used to be necessary to set a high current; and only then disable the setting or reset
# the VE.Bus system to re-initialise from the stored setting as per VEConfigure.
bms_parameters_written = 0
if bms_service is None:
if max_charge_current is None:
self._multi.maxchargecurrent = None
else:
# Don't bother setting a charge current at 1A or less
self._multi.maxchargecurrent = max_charge_current if max_charge_current > 1 else 0
else:
bms_parameters_written = self._update_battery_operational_limits(bms_service, charge_voltage, max_charge_current)
self._dbusservice['/Control/BmsParameters'] = int(bms_parameters_written or (bms_service is not None and voltage_written))
return True
def _adjust_battery_operational_limits(self, bms_service, feedback_allowed):
""" Take the charge voltage and maximum charge current from the BMS
and adjust it as necessary. For now we only implement quirks
for batteries known to have them.
"""
cv = bms_service.chargevoltage
mcc = bms_service.maxchargecurrent
quirk = QUIRKS.get(bms_service.product_id)
stop_on_mcc0 = False
if quirk is not None:
# If any quirks are registered for this battery, use that
# instead.
cv, mcc, feedback_allowed, stop_on_mcc0 = quirk(self, bms_service, cv, mcc, feedback_allowed)
# Add debug offsets
if cv is not None:
cv = safeadd(cv, self.invertervoltageoffset)
if mcc is not None:
mcc = safeadd(mcc, self.currentoffset)
return cv, mcc, feedback_allowed, stop_on_mcc0
def _update_battery_operational_limits(self, bms_service, cv, mcc):
""" This function writes the bms parameters across to the Multi
if it exists. Also communicate DCL=0 to inverters. """
written = 0
if self._multi.active:
if cv is not None:
self._multi.bol.chargevoltage = cv
if mcc is not None:
self._multi.bol.maxchargecurrent = mcc
# Also set the maxchargecurrent, to ensure this is not stuck
# at some lower value that overrides the intent here.
try:
self._multi.maxchargecurrent = max(self._multi.maxchargecurrent, mcc)
except TypeError:
pass
# Copy the rest unmodified
self._multi.bol.maxdischargecurrent = bms_service.maxdischargecurrent
self._multi.bol.batterylowvoltage = bms_service.batterylowvoltage
written = 1
# Also control inverters if BMS stops discharge
if len(self._inverters):
self._inverters.set_maxdischargecurrent(bms_service.maxdischargecurrent)
written = 1
return written
@property
def feedback_allowed(self):
# Feedback allowed is defined as 'ESS present and FeedInOvervoltage is
# enabled'. This ignores other setups which allow feedback: hub-1.
return self.has_ess_assistant and self._multi.ac_connected and \
self._dbusmonitor.get_value('com.victronenergy.settings',
'/Settings/CGwacs/OvervoltageFeedIn') == 1
def _update_solarchargers_and_vecan(self, has_bms, bms_charge_voltage, max_charge_current, feedback_allowed, stop_on_mcc0):
""" This function updates the solar chargers only. Parameters
related to the Multi are handled elsewhere. """
# If the vebus service does not provide a charge voltage setpoint (so
# no ESS/Hub-1/Hub-4), we use the max charge voltage provided by the
# BMS (if any). This will probably prevent feedback, but that is
# probably not allowed anyway.
charge_voltage = None
if self._multi.active:
charge_voltage = self._multi.hub_voltage
if charge_voltage is None and bms_charge_voltage is not None:
charge_voltage = bms_charge_voltage
if charge_voltage is not None:
try:
charge_voltage += self.solarvoltageoffset
except (ValueError, TypeError):
pass
if charge_voltage is None and max_charge_current is None:
return 0, 0, None
voltage_written, current_written, network_mode = self._chargesystem.set_networked(
has_bms, bms_charge_voltage, charge_voltage,
max_charge_current, feedback_allowed, stop_on_mcc0)
# Write the voltage to VE.Can. Also update the networkmode.
if charge_voltage is not None:
for service in self._vecan_services:
try:
# In case there is no path at all, the set_value below will
# raise an DBusException which we will ignore cheerfully. If we
# cannot set the NetworkMode there is no point in setting the
# ChargeVoltage.
self._dbusmonitor.set_value_async(service, '/Link/NetworkMode', network_mode)
self._dbusmonitor.set_value_async(service, '/Link/ChargeVoltage', charge_voltage)
voltage_written = 1
except DBusException:
pass
return voltage_written, current_written, charge_voltage
def _legacy_update_solarchargers(self):
""" This is the old implementation we used before DVCC. It is kept
here so we can fall back to it where DVCC is not fully supported,
and to avoid maintaining two copies of systemcalc. """
max_charge_current = None
for battery in BatteryService.instance.batteries:
max_charge_current = safeadd(max_charge_current, battery.maxchargecurrent)
# Workaround: copying the max charge current from BMS batteries to the solarcharger leads to problems:
# excess PV power is not fed back to the grid any more, and loads on AC-out are not fed with PV power.
# PV power is used for charging the batteries only.
# So we removed this feature, until we have a complete solution for solar charger support. Until then
# we set a 'high' max charge current to avoid 'BMS connection lost' alarms from the solarcharger.
if max_charge_current is not None:
max_charge_current = 1000
vebus_path = self._multi.service if self._multi.active else None
charge_voltage = None if vebus_path is None else \
self._dbusmonitor.get_value(vebus_path, '/Hub/ChargeVoltage')
if charge_voltage is None and max_charge_current is None:
return (0, 0)
# Network mode:
# bit 0: Operated in network environment
# bit 2: Remote Hub-1 control
# bit 3: Remote BMS control
network_mode = 1 | (0 if charge_voltage is None else 4) | (0 if max_charge_current is None else 8)
voltage_written = 0
current_written = 0
for charger in self._chargesystem:
try:
# We use /Link/NetworkMode to detect Hub support in the solarcharger. Existence of this item
# implies existence of the other /Link/* fields.
if charger.networkmode is None:
continue
charger.networkmode = network_mode
if charge_voltage is not None:
charger.chargevoltage = charge_voltage
voltage_written = 1
if max_charge_current is not None:
charger.maxchargecurrent = max_charge_current
current_written = 1
except DBusException:
# If the charger for whatever reason doesn't have the /Link
# path, ignore it. This is the legacy implementation and
# better to keep it for the moment.
pass
# The below is different to the non-legacy case above, where the voltage
# the com.victronenergy.vecan.* service instead.
if charge_voltage is not None and self._chargesystem.has_vecan_chargers:
# Charge voltage cannot by written directly to the CAN-bus solar chargers, we have to use
# the com.victronenergy.vecan.* service instead.
# Writing charge current to CAN-bus solar charger is not supported yet.
for service in self._vecan_services:
try:
# Note: we don't check the value of charge_voltage_item because it may be invalid,
# for example if the D-Bus path has not been written for more than 60 (?) seconds.
# In case there is no path at all, the set_value below will raise an DBusException
# which we will ignore cheerfully.
self._dbusmonitor.set_value_async(service, '/Link/ChargeVoltage', charge_voltage)
voltage_written = 1
except DBusException:
pass
return (voltage_written, current_written)