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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion config/batcontrol_config_dummy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ inverter:
# user: customer #customer or technician lowercase only!!
# password: YOUR-PASSWORD #
max_grid_charge_rate: 5000 # Watt, Upper limit for Grid to Battery charge rate.
max_pv_charge_rate: 0 # Watt, This allows to limit the PV to Battery charge rate. Set to 0 for unlimited charging.
max_pv_charge_rate: 0 # Watt, STATIC upper limit for PV to Battery charge rate. Set to 0 for unlimited charging.
# Applied in MODE_ALLOW_DISCHARGING (10) and as upper bound in MODE_LIMIT_BATTERY_CHARGE_RATE (8).
min_pv_charge_rate: 0 # Watt, STATIC lower limit for MODE_LIMIT_BATTERY_CHARGE_RATE (8).
# Set to 0 to allow complete charge blocking.
# Set to e.g. 100 to ensure minimum 100W charging when mode 8 is active.
# Only affects mode 8; ignored in other modes.
# fronius_inverter_id: '1' # Optional: ID of the inverter in Fronius API (default: '1')
# fronius_controller_id: '0' # Optional: ID of the controller in Fronius API (default: '0')
enable_resilient_wrapper: false # Enable resilient wrapper for graceful outage handling (default: false)
Expand Down
71 changes: 66 additions & 5 deletions src/batcontrol/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,14 @@

"""
# %%
from dataclasses import dataclass
import datetime
import time
import os
import logging
import platform
from typing import Callable

import pytz
import numpy as np
import platform

from .mqtt_api import MqttApi
from .evcc_api import EvccApi
Expand All @@ -46,6 +43,7 @@
FORECAST_TOLERANCE = 3 # Acceptable tolerance for forecast hours

MODE_ALLOW_DISCHARGING = 10
MODE_LIMIT_BATTERY_CHARGE_RATE = 8 # Limit PV charge, allow discharge
MODE_AVOID_DISCHARGING = 0
MODE_FORCE_CHARGING = -1

Expand All @@ -59,9 +57,10 @@ class Batcontrol:
def __init__(self, configdict: dict):
# For API
self.api_overwrite = False
# -1 = charge from grid , 0 = avoid discharge , 10 = discharge allowed
# -1 = charge from grid , 0 = avoid discharge , 8 = limit battery charge, 10 = discharge allowed
self.last_mode = None
self.last_charge_rate = 0
self._limit_battery_charge_rate = -1 # Dynamic battery charge rate limit (-1 = no limit)
self.last_prices = None
self.last_consumption = None
self.last_production = None
Expand Down Expand Up @@ -150,6 +149,10 @@ def __init__(self, configdict: dict):
self.inverter = inverter_factory.create_inverter(
config['inverter'])

# Get PV charge rate limits from inverter config (with defaults)
self.max_pv_charge_rate = getattr(self.inverter, 'max_pv_charge_rate', 0)
self.min_pv_charge_rate = config['inverter'].get('min_pv_charge_rate', 0)

self.pvsettings = config['pvinstallations']
self.fc_solar = solar_factory.create_solar_provider(
self.pvsettings,
Expand Down Expand Up @@ -219,6 +222,11 @@ def __init__(self, configdict: dict):
self.api_set_charge_rate,
int
)
self.mqtt_api.register_set_callback(
'limit_battery_charge_rate',
self.api_set_limit_battery_charge_rate,
int
)
self.mqtt_api.register_set_callback(
'always_allow_discharge_limit',
self.api_set_always_allow_discharge_limit,
Expand Down Expand Up @@ -513,7 +521,10 @@ def run(self):
inverter_settings.allow_discharge = False

if inverter_settings.allow_discharge:
self.allow_discharging()
if inverter_settings.limit_battery_charge_rate >= 0:
self.limit_battery_charge_rate(inverter_settings.limit_battery_charge_rate)
else:
self.allow_discharging()
elif inverter_settings.charge_from_grid:
self.force_charge(inverter_settings.charge_rate)
else:
Expand Down Expand Up @@ -555,6 +566,32 @@ def force_charge(self, charge_rate=500):
self.__set_mode(MODE_FORCE_CHARGING)
self.__set_charge_rate(charge_rate)

def limit_battery_charge_rate(self, limit_charge_rate: int = 0):
""" Limit PV charging rate while allowing battery discharge

Args:
limit_charge_rate: Maximum charge rate in W (0 = no charging, -1 = no limit)
"""
# If -1, use no limit (don't apply mode 8)
if limit_charge_rate < 0:
self.allow_discharging()
return

# Apply bounds from config
effective_limit = limit_charge_rate
if self.max_pv_charge_rate > 0:
effective_limit = min(effective_limit, self.max_pv_charge_rate)
if self.min_pv_charge_rate > 0 and limit_charge_rate > 0:
effective_limit = max(effective_limit, self.min_pv_charge_rate)

logger.info('Mode: Limit Battery Charge Rate to %d W, discharge allowed', effective_limit)
self.inverter.set_mode_limit_battery_charge(effective_limit)
self.__set_mode(MODE_LIMIT_BATTERY_CHARGE_RATE)

# Publish limit via MQTT
if self.mqtt_api is not None:
self.mqtt_api.publish_limit_battery_charge_rate(effective_limit)

def __save_run_data(
self,
production,
Expand Down Expand Up @@ -742,6 +779,7 @@ def api_set_mode(self, mode: int):
if mode not in [
MODE_FORCE_CHARGING,
MODE_AVOID_DISCHARGING,
MODE_LIMIT_BATTERY_CHARGE_RATE,
MODE_ALLOW_DISCHARGING]:
logger.warning('API: Invalid mode %s', mode)
return
Expand All @@ -754,6 +792,8 @@ def api_set_mode(self, mode: int):
self.force_charge()
elif mode == MODE_AVOID_DISCHARGING:
self.avoid_discharging()
elif mode == MODE_LIMIT_BATTERY_CHARGE_RATE:
self.limit_battery_charge_rate(self._limit_battery_charge_rate)
Comment on lines +795 to +796
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When api_set_mode is called with MODE_LIMIT_BATTERY_CHARGE_RATE (8), it uses the stored value in self._limit_battery_charge_rate. However, if this value is still at its default of -1 (no limit), the limit_battery_charge_rate method will fall back to allow_discharging mode (line 576-578). This means setting mode 8 via API without first setting a limit value will silently switch to mode 10 instead, which is confusing behavior. Consider adding a validation check or warning in api_set_mode to inform users they need to set a valid limit first, or consider using a different default value (e.g., max_pv_charge_rate).

Copilot uses AI. Check for mistakes.
elif mode == MODE_ALLOW_DISCHARGING:
self.allow_discharging()

Expand All @@ -768,6 +808,27 @@ def api_set_charge_rate(self, charge_rate: int):
if charge_rate != self.last_charge_rate:
self.force_charge(charge_rate)

def api_set_limit_battery_charge_rate(self, limit: int):
""" Set dynamic battery charge rate limit from external call

Args:
limit: Maximum battery charge rate in W (0 = no charging, -1 = no limit)
"""
if limit < -1:
logger.warning('API: Invalid limit_battery_charge_rate %d W', limit)
return

logger.info('API: Setting limit_battery_charge_rate to %d W', limit)
self._limit_battery_charge_rate = limit

# If currently in MODE_LIMIT_BATTERY_CHARGE_RATE, apply immediately
if self.last_mode == MODE_LIMIT_BATTERY_CHARGE_RATE:
self.limit_battery_charge_rate(limit)

def api_get_limit_battery_charge_rate(self) -> int:
""" Get current dynamic battery charge rate limit """
return self._limit_battery_charge_rate

def api_set_always_allow_discharge_limit(self, limit: float):
""" Set always allow discharge limit for battery control via external API request.
The change is temporary and will not be written to the config file.
Expand Down
15 changes: 9 additions & 6 deletions src/batcontrol/inverter/dummy.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def __init__(self, config):

def set_mode_force_charge(self, chargerate=500):
self.mode = 'force_charge'
logger.debug(f'Dummy inverter: Set to force charge mode (rate: {chargerate}W)')
logger.debug('Dummy inverter: Set to force charge mode (rate: %dW)', chargerate)

def set_mode_allow_discharge(self):
self.mode = 'allow_discharge'
Expand All @@ -35,6 +35,11 @@ def set_mode_avoid_discharge(self):
self.mode = 'avoid_discharge'
logger.debug('Dummy inverter: Set to avoid discharge mode')

def set_mode_limit_battery_charge(self, limit_charge_rate: int):
""" Dummy implementation for limit battery charge mode """
self.mode = 'limit_battery_charge'
logger.info('DUMMY: Limit battery charge rate to %d W', limit_charge_rate)

def get_capacity(self):
return self.installed_capacity

Expand All @@ -44,12 +49,10 @@ def get_SOC(self):
def activate_mqtt(self, api_mqtt_api):
# Dummy inverter doesn't support MQTT for simplicity
logger.debug('Dummy inverter: MQTT activation ignored (not supported)')
pass

def refresh_api_values(self):
# Call parent implementation for basic MQTT publishing if available
super().refresh_api_values()
# No-op for dummy inverter - no values to refresh
logger.debug('Dummy inverter: refresh_api_values called (no action needed)')

def shutdown(self):
logger.info('Dummy inverter: Shutdown called (no action needed)')
pass
logger.info('Dummy inverter: Shutdown called (no action needed)')
26 changes: 26 additions & 0 deletions src/batcontrol/inverter/fronius.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,32 @@ def set_mode_allow_discharge(self):

return response

def set_mode_limit_battery_charge(self, limit_charge_rate: int):
""" Limit PV charging rate while allowing battery discharge

Args:
limit_charge_rate: Maximum charge rate in W (0 = no charging)
"""
if limit_charge_rate < 0:
raise ValueError(f"limit_charge_rate must be >= 0, got {limit_charge_rate}")

# Always set TimeOfUse rule for mode 8 (even if 0 = no charging)
timeofuselist = [{'Active': True,
'Power': int(limit_charge_rate),
'ScheduleType': 'CHARGE_MAX',
"TimeTable": {"Start": "00:00", "End": "23:59"},
"Weekdays":
{"Mon": True,
"Tue": True,
"Wed": True,
"Thu": True,
"Fri": True,
"Sat": True,
"Sun": True}
}]
response = self.set_time_of_use(timeofuselist)
return response

def set_mode_force_charge(self, chargerate=500):
""" Set the inverter to charge the battery with a specific power from GRID."""
# activate timeofuse rules
Expand Down
8 changes: 8 additions & 0 deletions src/batcontrol/inverter/inverter_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ def set_mode_avoid_discharge(self):
def set_mode_allow_discharge(self):
""" Set the inverter to avoid discharge mode """

@abstractmethod
def set_mode_limit_battery_charge(self, limit_charge_rate: int):
""" Set the inverter to limit PV charging while allowing discharge

Args:
limit_charge_rate: Maximum charge rate in W (0 = no charging)
"""

@abstractmethod
def get_stored_energy(self) -> float:
""" Get the stored energy in the inverter.
Expand Down
28 changes: 28 additions & 0 deletions src/batcontrol/inverter/mqtt_inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,34 @@ def set_mode_avoid_discharge(self):
retain=False
)

def set_mode_limit_battery_charge(self, limit_charge_rate: int):
"""
Set inverter to limit battery charge rate mode.

Publishes mode and max charge rate to MQTT command topics (non-retained).

Args:
limit_charge_rate: Maximum charge rate in W (0 = no charging)
"""
self.last_mode = 'limit_battery_charge'
logger.info('Setting mode to limit_battery_charge with max rate %sW', limit_charge_rate)

# Publish mode command (QoS 1, not retained)
self.mqtt_client.publish(
f'{self.inverter_topic}/command/mode',
'limit_battery_charge',
qos=1,
retain=False
)

# Publish max charge rate command (QoS 1, not retained)
self.mqtt_client.publish(
f'{self.inverter_topic}/command/limit_battery_charge_rate',
str(limit_charge_rate),
qos=1,
retain=False
)

def get_capacity(self):
"""
Get battery capacity in Wh.
Expand Down
10 changes: 10 additions & 0 deletions src/batcontrol/inverter/resilient_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,16 @@ def set_mode_allow_discharge(self):
mark_initialized=True
)

def set_mode_limit_battery_charge(self, limit_charge_rate: int):
"""Set limit battery charge mode with resilience handling."""
return self._call_with_resilience(
self._inverter.set_mode_limit_battery_charge,
"set_mode_limit_battery_charge",
None, None,
method_args=(limit_charge_rate,),
mark_initialized=True
)

# =========================================================================
# InverterInterface Implementation - Other Methods
# =========================================================================
Expand Down
17 changes: 15 additions & 2 deletions src/batcontrol/mqtt_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
- /status: online/offline status of batcontrol
- /evaluation_intervall: interval in seconds
- /last_evaluation: timestamp of last evaluation
- /mode: operational mode (-1 = charge from grid, 0 = avoid discharge, 10 = discharge allowed)
- /mode: operational mode (-1 = charge from grid, 0 = avoid discharge, 8 = limit battery charge, 10 = discharge allowed)
- /max_charging_from_grid_limit: charge limit in 0.1-1
- /max_charging_from_grid_limit_percent: charge limit in %
- /always_allow_discharge_limit: always discharge limit in 0.1-1
- /always_allow_discharge_limit_percent: always discharge limit in %
- /always_allow_discharge_limit_capacity: always discharge limit in Wh
- /charge_rate: charge rate in W
- /limit_battery_charge_rate: dynamic battery charge rate limit in W
- /max_energy_capacity: maximum capacity of battery in Wh
- /stored_energy_capacity: energy stored in battery in Wh
- /stored_usable_energy_capacity: energy stored in battery in Wh and usable (min SOC considered)
Expand All @@ -29,8 +30,9 @@
- /FCST/net_consumption: forecasted net consumption in W

Implemented Input-API:
- /mode/set: set mode (-1 = charge from grid, 0 = avoid discharge, 10 = discharge allowed)
- /mode/set: set mode (-1 = charge from grid, 0 = avoid discharge, 8 = limit battery charge, 10 = discharge allowed)
- /charge_rate/set: set charge rate in W, sets mode to -1
- /limit_battery_charge_rate/set: set dynamic battery charge rate limit in W
- /always_allow_discharge_limit/set: set always discharge limit in 0.1-1
- /max_charging_from_grid_limit/set: set charge limit in 0-1
- /min_price_difference/set: set minimum price difference in EUR
Expand Down Expand Up @@ -191,6 +193,17 @@ def publish_charge_rate(self, rate: float) -> None:
if self.client.is_connected():
self.client.publish(self.base_topic + '/charge_rate', rate)

def publish_limit_battery_charge_rate(self, limit: int) -> None:
""" Publish dynamic battery charge rate limit to MQTT
/limit_battery_charge_rate
"""
if self.client.is_connected():
self.client.publish(
self.base_topic + '/limit_battery_charge_rate',
limit,
retain=True
)

def publish_production(
self,
production: np.ndarray,
Expand Down
Loading
Loading