diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 1a1c04d..474a3c0 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -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 diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 2aba98e..26001bd 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -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( @@ -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( @@ -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' + ) # Run initial data fetch try: self.fc_solar.refresh_data() @@ -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() @@ -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 diff --git a/src/batcontrol/dynamictariff/baseclass.py b/src/batcontrol/dynamictariff/baseclass.py index fede30b..eb3f9da 100644 --- a/src/batcontrol/dynamictariff/baseclass.py +++ b/src/batcontrol/dynamictariff/baseclass.py @@ -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. + 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: # Not on initial call if self.next_update_ts > 0 and self.delay_evaluation_by_seconds > 0: sleeptime = random.randrange(0, self.delay_evaluation_by_seconds, 1) @@ -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) diff --git a/src/batcontrol/dynamictariff/dynamictariff_interface.py b/src/batcontrol/dynamictariff/dynamictariff_interface.py index 795e095..5a8849e 100644 --- a/src/batcontrol/dynamictariff/dynamictariff_interface.py +++ b/src/batcontrol/dynamictariff/dynamictariff_interface.py @@ -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 """ \ No newline at end of file + def refresh_data(self, force: bool = False) -> None: + """ Refresh data from provider. + + Args: + force: When True, bypass cache and always fetch fresh data. + """ \ No newline at end of file diff --git a/src/batcontrol/scheduler.py b/src/batcontrol/scheduler.py index 34c4da8..8ff8ba4 100644 --- a/src/batcontrol/scheduler.py +++ b/src/batcontrol/scheduler.py @@ -98,7 +98,8 @@ 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) @@ -106,12 +107,18 @@ def schedule_at(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", "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(): @@ -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) + return job_def.at(time_str).do(wrapped_job) def schedule_once(time: str, job: Callable, job_name: str = ""): @@ -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() @@ -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 @@ -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 = ""): """ diff --git a/tests/batcontrol/dynamictariff/test_baseclass.py b/tests/batcontrol/dynamictariff/test_baseclass.py index 56b4284..15476b2 100644 --- a/tests/batcontrol/dynamictariff/test_baseclass.py +++ b/tests/batcontrol/dynamictariff/test_baseclass.py @@ -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 @@ -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""" diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 4204db3..dc02ce1 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -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']) diff --git a/tests/batcontrol/test_scheduler.py b/tests/batcontrol/test_scheduler.py index 9072f3a..994f712 100644 --- a/tests/batcontrol/test_scheduler.py +++ b/tests/batcontrol/test_scheduler.py @@ -57,3 +57,53 @@ def test_schedule_every_rejects_invalid_unit(): with pytest.raises(ValueError): scheduler.schedule_every(1, "fortnights", _noop, "bad-unit") + + +def test_schedule_at_registers_daily_job(): + """schedule_at() must register exactly one job.""" + scheduler.reset_scheduler() + scheduler.schedule_at("12:30", _noop, "daily-job") + assert len(scheduler.get_jobs()) == 1 + scheduler.reset_scheduler() + + +def test_schedule_at_with_utc_timezone(): + """schedule_at() with tz='UTC' must register a job without raising.""" + scheduler.reset_scheduler() + scheduler.schedule_at("12:30", _noop, "utc-job", tz="UTC") + assert len(scheduler.get_jobs()) == 1 + scheduler.reset_scheduler() + + +def test_schedule_at_without_tz_uses_local_time(): + """schedule_at() without tz must register a job (uses server local time).""" + scheduler.reset_scheduler() + scheduler.schedule_at("14:00", _noop, "local-job") + assert len(scheduler.get_jobs()) == 1 + scheduler.reset_scheduler() + + +def test_schedule_at_empty_tz_treated_as_local(): + """schedule_at() with tz='' must not forward the empty string to schedule.""" + scheduler.reset_scheduler() + # Would raise if "" were passed through to schedule.Job.at() + scheduler.schedule_at("14:00", _noop, "empty-tz-job", tz="") + assert len(scheduler.get_jobs()) == 1 + scheduler.reset_scheduler() + + +def test_schedule_at_whitespace_tz_treated_as_local(): + """schedule_at() with tz containing only whitespace must be treated as None.""" + scheduler.reset_scheduler() + scheduler.schedule_at("14:00", _noop, "whitespace-tz-job", tz=" ") + assert len(scheduler.get_jobs()) == 1 + scheduler.reset_scheduler() + + +def test_schedule_at_tz_with_surrounding_whitespace_is_stripped(): + """schedule_at() with tz=' UTC ' must forward 'UTC', not ' UTC '.""" + scheduler.reset_scheduler() + # Would raise UnknownTimeZoneError if the spaces were not stripped. + scheduler.schedule_at("12:30", _noop, "padded-tz-job", tz=" UTC ") + assert len(scheduler.get_jobs()) == 1 + scheduler.reset_scheduler()