1288 lines
47 KiB
Python
1288 lines
47 KiB
Python
|
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)
|