Skip to content
3 changes: 3 additions & 0 deletions config/batcontrol_config_dummy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ battery_control_expert:
production_offset_percent: 1.0 # Adjust production forecast by a percentage (1.0 = 100%, 0.8 = 80%, etc.)
# Useful for winter mode when solar panels are covered with snow
preserve_min_grid_charge_soc: false # If true, also preserve min_grid_charge_soc as reserved energy during cheap/pre-expensive slots
# market_price_refresh_time: "12:30" # UTC time for a daily hard price refresh.
# Use this to pick up newly published next-day prices (e.g. EPEX Spot ~12:00 UTC).
# Format: "HH:MM". Default: "12:30".

#--------------------------
# Peak Shaving
Expand Down
41 changes: 40 additions & 1 deletion src/batcontrol/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ def __init__(self, configdict: dict):

self.round_price_digits = 4
self.production_offset_percent = 1.0 # Default: no offset
self.market_price_refresh_time = "12:30"

if self.config.get('battery_control_expert', None) is not None:
battery_control_expert = self.config.get(
Expand All @@ -262,6 +263,11 @@ def __init__(self, configdict: dict):
self.preserve_min_grid_charge_soc = battery_control_expert.get(
'preserve_min_grid_charge_soc',
self.preserve_min_grid_charge_soc)
raw_refresh_time = battery_control_expert.get(
'market_price_refresh_time',
self.market_price_refresh_time)
self._validate_market_price_refresh_time(raw_refresh_time)
self.market_price_refresh_time = raw_refresh_time

self.general_logic = CommonLogic.get_instance(
charge_rate_multiplier=self.batconfig.get(
Expand Down Expand Up @@ -381,6 +387,14 @@ def __init__(self, configdict: dict):
'hours',
self.fc_consumption.refresh_data,
'forecast-consumption-every')
# Hard refresh at market publish time (default 12:30 UTC) to pick up
# newly published next-day prices (e.g. EPEX spot).
self.scheduler.schedule_at(
self.market_price_refresh_time,
self._hard_refresh_prices,
'market-price-hard-refresh',
tz='UTC'
)
Comment thread
MaStr marked this conversation as resolved.
# Run initial data fetch
try:
self.fc_solar.refresh_data()
Expand All @@ -393,9 +407,11 @@ def shutdown(self):
""" Shutdown Batcontrol and dependent modules (inverter..) """
logger.info('Shutting down Batcontrol')
try:
# Stop scheduler thread
# Stop scheduler thread first, then clear jobs.
# Clearing before stop() would race with run_pending() in the thread.
if hasattr(self, 'scheduler') and self.scheduler is not None:
self.scheduler.stop()
self.scheduler.clear_jobs()
del self.scheduler

self.inverter.shutdown()
Expand All @@ -406,6 +422,29 @@ def shutdown(self):
except Exception as exc:
logger.exception("Error during Batcontrol shutdown: %s", exc)

@staticmethod
def _validate_market_price_refresh_time(value: str) -> None:
"""Raise ValueError if value is not a valid HH:MM time string."""
try:
datetime.datetime.strptime(value, "%H:%M")
except (ValueError, TypeError) as exc:
raise ValueError(
f"battery_control_expert.market_price_refresh_time must be "
f"a valid HH:MM time string (e.g. '12:30'), got: {value!r}"
) from exc

def _hard_refresh_prices(self) -> None:
"""Force a price refresh regardless of cache state.

Called at market_price_refresh_time (UTC) to pick up newly published
dynamic tariff data (e.g. EPEX next-day prices published ~12:00 UTC).
"""
logger.info(
'Hard price refresh at %s UTC - fetching fresh market prices',
self.market_price_refresh_time
)
self.dynamic_tariff.refresh_data(force=True)

def reset_forecast_error(self):
""" Reset the forecast error timer """
self.time_at_forecast_error = -1
Expand Down
15 changes: 11 additions & 4 deletions src/batcontrol/dynamictariff/baseclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,18 @@ def store_raw_data(self, data: dict) -> None:
"""Store raw data in cache."""
self.cache.store_new_entry(data)

def refresh_data(self) -> None:
"""Refresh data from provider if needed."""
def refresh_data(self, force: bool = False) -> None:
"""Refresh data from provider if needed.

Args:
force: When True, bypass next_update_ts and always fetch fresh data.
Comment thread
MaStr marked this conversation as resolved.
Use for scheduled market-publish refreshes (e.g. 12:30 UTC).
"""
with self._refresh_data_lock:
now = time.time()
if now > self.next_update_ts:
if force:
logger.info('%s: Forced price refresh triggered', self.__class__.__name__)
if force or now > self.next_update_ts:
Comment thread
MaStr marked this conversation as resolved.
# Not on initial call
Comment thread
MaStr marked this conversation as resolved.
if self.next_update_ts > 0 and self.delay_evaluation_by_seconds > 0:
sleeptime = random.randrange(0, self.delay_evaluation_by_seconds, 1)
Expand All @@ -99,7 +106,7 @@ def refresh_data(self) -> None:
time.sleep(sleeptime)
try:
self.store_raw_data(self.get_raw_data_from_provider())
self.next_update_ts = now + self.min_time_between_updates
self.next_update_ts = time.time() + self.min_time_between_updates
self.schedule_next_refresh()
except (ConnectionError, TimeoutError) as e:
logger.error('Error getting raw tariff data: %s', e)
Expand Down
8 changes: 6 additions & 2 deletions src/batcontrol/dynamictariff/dynamictariff_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,9 @@ def get_prices(self) -> dict[int, float]:
""" get prices in processable format with hours as keys """

@abstractmethod
def refresh_data(self) -> None:
""" Refresh data from provider """
def refresh_data(self, force: bool = False) -> None:
""" Refresh data from provider.

Args:
force: When True, bypass cache and always fetch fresh data.
"""
24 changes: 19 additions & 5 deletions src/batcontrol/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,20 +98,27 @@ def wrapped_job():
return obtained_unit.do(wrapped_job)


def schedule_at(time_str: str, job: Callable, job_name: str = ""):
def schedule_at(time_str: str, job: Callable, job_name: str = "",
tz: Optional[str] = None):
"""
Schedule a job to run at a specific time each day (globally accessible)

Args:
time_str: Time string in HH:MM format (e.g., "14:30")
job: The callable function to execute
job_name: Optional name for the job (for logging purposes)
tz: Optional timezone name (e.g., "UTC", "Europe/Berlin").
When None, the server's local time is used.

Returns:
The scheduled job object
"""
name = job_name or job.__name__
logger.info("Scheduling job '%s' to run daily at %s", name, time_str)
# Normalize empty/whitespace tz to None so log and schedule behaviour agree.
# Also strip surrounding whitespace so " UTC " is treated the same as "UTC".
effective_tz = tz.strip() if tz and tz.strip() else None
tz_label = effective_tz if effective_tz else "local"
logger.info("Scheduling job '%s' to run daily at %s %s", name, time_str, tz_label)

# Wrap the job to catch exceptions and add logging
def wrapped_job():
Expand All @@ -122,7 +129,11 @@ def wrapped_job():
except Exception as e:
logger.error("Error in scheduled job '%s': %s", name, e, exc_info=True)

return _get_job_registry().every().day.at(time_str).do(wrapped_job)
wrapped_job.__name__ = name
job_def = _get_job_registry().every().day
if effective_tz is not None:
return job_def.at(time_str, effective_tz).do(wrapped_job)
Comment thread
MaStr marked this conversation as resolved.
return job_def.at(time_str).do(wrapped_job)
Comment thread
MaStr marked this conversation as resolved.
Comment thread
MaStr marked this conversation as resolved.


def schedule_once(time: str, job: Callable, job_name: str = ""):
Expand Down Expand Up @@ -150,6 +161,7 @@ def wrapped_job():
except Exception as e:
logger.error("Error in scheduled one-time job '%s': %s", name, e, exc_info=True)

wrapped_job.__name__ = name
return (
_get_job_registry()
.every()
Expand Down Expand Up @@ -268,7 +280,8 @@ def schedule_every(self, interval: int, unit: str, job: Callable, job_name: str
"""
return schedule_every(interval, unit, job, job_name)

def schedule_at(self, time_str: str, job: Callable, job_name: str = ""):
def schedule_at(self, time_str: str, job: Callable, job_name: str = "",
tz: Optional[str] = None):
"""
Schedule a job to run at a specific time each day

Expand All @@ -279,11 +292,12 @@ def schedule_at(self, time_str: str, job: Callable, job_name: str = ""):
time_str: Time string in HH:MM format (e.g., "14:30")
job: The callable function to execute
job_name: Optional name for the job (for logging purposes)
tz: Optional timezone name (e.g., "UTC"). None uses server local time.

Returns:
The scheduled job object
"""
return schedule_at(time_str, job, job_name)
return schedule_at(time_str, job, job_name, tz)

def schedule_once(self, time: str, job: Callable, job_name: str = ""):
"""
Expand Down
46 changes: 45 additions & 1 deletion tests/batcontrol/dynamictariff/test_baseclass.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""
Test module for DynamicTariffBaseclass and providers
"""
import time
import pytest
import pytz
from datetime import datetime, timezone as dt_timezone
from unittest.mock import patch
from unittest.mock import patch, MagicMock
from batcontrol.dynamictariff.baseclass import DynamicTariffBaseclass


Expand Down Expand Up @@ -202,6 +203,49 @@ def test_replicate_hourly_to_15min_method(self, baseclass_15min_target):
idx = h * 4 + q
assert result[idx] == hourly[h]

def test_refresh_data_force_bypasses_next_update_ts(self, timezone):
"""force=True must fetch even when next_update_ts is in the future."""
instance = ConcreteTariffProvider(timezone)
instance.get_raw_data_from_provider = MagicMock(return_value={})
instance.delay_evaluation_by_seconds = 0

# Simulate data already fresh: next update far in the future
instance.next_update_ts = time.time() + 9999

instance.refresh_data(force=True)

instance.get_raw_data_from_provider.assert_called_once()

def test_refresh_data_no_force_respects_next_update_ts(self, timezone):
"""Without force, refresh_data must skip fetch when cache is still valid."""
instance = ConcreteTariffProvider(timezone)
instance.get_raw_data_from_provider = MagicMock(return_value={})

# Simulate data already fresh
instance.next_update_ts = time.time() + 9999

instance.refresh_data(force=False)

instance.get_raw_data_from_provider.assert_not_called()

def test_refresh_data_force_updates_next_update_ts(self, timezone):
"""After a forced refresh, next_update_ts must be pushed forward."""
instance = ConcreteTariffProvider(timezone)
instance.get_raw_data_from_provider = MagicMock(return_value={})
instance.delay_evaluation_by_seconds = 0

old_ts = time.time() + 9999
instance.next_update_ts = old_ts

before = time.time()
instance.refresh_data(force=True)
after = time.time()

# next_update_ts must be reset to now + min_time_between_updates
expected_min = before + instance.min_time_between_updates
expected_max = after + instance.min_time_between_updates
assert expected_min <= instance.next_update_ts <= expected_max


class TestAwattarProvider:
"""Tests for Awattar provider"""
Expand Down
104 changes: 104 additions & 0 deletions tests/batcontrol/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -957,5 +957,109 @@ def test_evcc_no_limit_active_no_change(
assert calc_params.peak_shaving.enabled is False


class TestMarketPriceRefresh:
"""Tests for the configurable market price hard refresh (issue #366)."""

BASE_CONFIG = {
'timezone': 'Europe/Berlin',
'time_resolution_minutes': 60,
'inverter': {
'type': 'dummy',
'max_grid_charge_rate': 5000,
'max_pv_charge_rate': 3000,
'min_pv_charge_rate': 0,
},
'utility': {'type': 'tibber', 'apikey': 'test_token'},
'pvinstallations': [],
'consumption_forecast': {'type': 'simple', 'value': 500},
'battery_control': {
'max_charging_from_grid_limit': 0.8,
'min_price_difference': 0.05,
},
'mqtt': {'enabled': False},
}

def _patch_core(self, mocker):
mock_inverter = mocker.MagicMock()
mock_inverter.max_pv_charge_rate = 3000
mock_inverter.get_max_capacity.return_value = 10000
mocker.patch('batcontrol.core.tariff_factory.create_tarif_provider',
autospec=True, return_value=mocker.MagicMock())
mocker.patch('batcontrol.core.inverter_factory.create_inverter',
autospec=True, return_value=mock_inverter)
mocker.patch('batcontrol.core.solar_factory.create_solar_provider',
autospec=True, return_value=mocker.MagicMock())
mocker.patch('batcontrol.core.consumption_factory.create_consumption',
autospec=True, return_value=mocker.MagicMock())

def test_default_market_price_refresh_time(self, mocker):
"""Without expert config, market_price_refresh_time defaults to 12:30."""
self._patch_core(mocker)
config = dict(self.BASE_CONFIG)
bc = Batcontrol(config)
assert bc.market_price_refresh_time == "12:30"
bc.shutdown()

def test_custom_market_price_refresh_time_from_expert_config(self, mocker):
"""market_price_refresh_time from battery_control_expert is used."""
self._patch_core(mocker)
config = dict(self.BASE_CONFIG)
config['battery_control_expert'] = {'market_price_refresh_time': '13:00'}
bc = Batcontrol(config)
assert bc.market_price_refresh_time == "13:00"
bc.shutdown()

def test_hard_refresh_prices_calls_force_refresh(self, mocker):
"""_hard_refresh_prices() must call dynamic_tariff.refresh_data(force=True)."""
self._patch_core(mocker)
config = dict(self.BASE_CONFIG)
bc = Batcontrol(config)

mock_refresh = mocker.MagicMock()
bc.dynamic_tariff.refresh_data = mock_refresh

bc._hard_refresh_prices()

mock_refresh.assert_called_once_with(force=True)
bc.shutdown()

@pytest.mark.parametrize("invalid_value", ["25:99", "noon", "12-30", "", 1230])
def test_invalid_market_price_refresh_time_raises(self, mocker, invalid_value):
"""Invalid market_price_refresh_time must raise ValueError at init."""
self._patch_core(mocker)
config = dict(self.BASE_CONFIG)
config['battery_control_expert'] = {
'market_price_refresh_time': invalid_value
}
with pytest.raises(ValueError, match="market_price_refresh_time"):
Batcontrol(config)

@pytest.mark.parametrize("valid_value", ["12:30", "00:00", "23:59", "13:00"])
def test_valid_market_price_refresh_time_accepted(self, mocker, valid_value):
"""Valid HH:MM values must be accepted without error."""
self._patch_core(mocker)
config = dict(self.BASE_CONFIG)
config['battery_control_expert'] = {
'market_price_refresh_time': valid_value
}
bc = Batcontrol(config)
assert bc.market_price_refresh_time == valid_value
bc.shutdown()

def test_shutdown_clears_scheduled_jobs(self, mocker):
"""shutdown() must clear the job registry so multiple instantiations do not accumulate jobs."""
from batcontrol import scheduler as sched_module
self._patch_core(mocker)
sched_module.reset_scheduler()

bc = Batcontrol(dict(self.BASE_CONFIG))
jobs_after_init = len(sched_module.get_jobs())
assert jobs_after_init > 0

bc.shutdown()

assert sched_module.get_jobs() == []


if __name__ == '__main__':
pytest.main([__file__, '-v'])
Loading