From 53b029b189c38c63b4b09c60f803dfcb2b40caad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BCger?= Date: Tue, 1 Sep 2026 06:04:37 +0000 Subject: [PATCH 1/2] refactor(planner): remove the inert battery-schedule feature - Fixes #860 Issue #860 found that fixed battery discharge/charge schedules (batteries_schedule_1/2/3) are functionally inert whenever MILP is active (the default): the schedule-consuming heuristic candidates are commented out in MILP-only mode, and both surviving candidates (no_action, passive) discard schedule-derived recommendations before scoring. MILP itself never reads battery_schedules at all. The user explicitly chose full removal over re-wiring it as a MILP-unavailable fallback or leaving it documented as inert, consciously overriding an earlier "keep fixed battery schedules" decision recorded in .github/memories.md (also updated here to reflect the reversal). Removed: - Config flow step (batteries_schedules), its schema/validator helpers, and the six hsem_batteries_enable_batteries_schedule_* config keys - BatterySchedule / BatteryScheduleInput / BatteryScheduleConfig models - The batteries_schedule_1/2/3 switches and their start/end time entities - apply_discharge_schedules, apply_charge_schedules, and apply_arbitrage_grid_charge planner passes (and their dedicated modules) - batteries_schedules_remaining_capacity_needed and the resolver rule that used it to force BatteriesDischargeMode - The now-dead validate_time_window validator (only caller was the schedule step) - Corresponding translations (en/da), docs, and dashboard.yaml section current_required_battery / calculate_required_battery_until_solar are unrelated (feed excess-export and the EV discharge-cap reserve) and are untouched. --- .github/memories.md | 17 +- custom_components/hsem/config_flow.py | 32 +- custom_components/hsem/const.py | 9 - custom_components/hsem/coordinator.py | 3 - custom_components/hsem/coordinator_builder.py | 13 - custom_components/hsem/coordinator_cycle.py | 15 +- custom_components/hsem/coordinator_data.py | 5 - .../hsem/coordinator_lifecycle.py | 4 - .../hsem/coordinator_planner_phase.py | 2 - custom_components/hsem/coordinator_state.py | 3 - .../hsem/custom_sensors/config_reader.py | 73 --- .../custom_sensors/recommendation_resolver.py | 19 - .../hsem/custom_sensors/state_collector.py | 5 +- .../custom_sensors/working_mode_sensor.py | 3 - .../hsem/custom_switches/description.py | 21 - .../hsem/custom_times/description.py | 44 -- .../hsem/flows/batteries_schedules.py | 61 --- .../hsem/flows/schedule_helpers.py | 131 ----- .../hsem/models/battery_schedule.py | 26 - .../hsem/models/battery_schedule_input.py | 28 -- .../hsem/models/planner_input.py | 6 - .../hsem/models/sensor_config.py | 38 -- custom_components/hsem/options_flow.py | 32 +- .../hsem/planner/charge_scheduler.py | 6 - .../hsem/planner/charging/__init__.py | 9 - .../hsem/planner/charging/_charge_helpers.py | 6 +- .../hsem/planner/charging/arbitrage_charge.py | 288 ----------- .../hsem/planner/charging/pre_charge.py | 338 ------------- .../hsem/planner/discharge_scheduler.py | 136 +----- custom_components/hsem/planner/engine_core.py | 37 -- custom_components/hsem/switch.py | 20 +- custom_components/hsem/time.py | 42 +- custom_components/hsem/translations/da.json | 173 +------ custom_components/hsem/translations/en.json | 169 ------- .../hsem/utils/config_validator.py | 67 --- custom_components/hsem/utils/diagnostics.py | 39 +- .../hsem/utils/sensornames/controls.py | 184 +------ docs/architecture-overview.md | 19 +- docs/config-flow-reference.md | 14 +- docs/dashboard.yaml | 112 ----- docs/planner-guide.md | 92 +--- docs/sensors-reference.md | 74 ++- tests/planner/fixtures.py | 38 -- tests/planner/test_48h_second_day.py | 325 ------------- tests/planner/test_arbitrage_grid_charge.py | 449 ------------------ .../planner/test_charge_scheduler_capacity.py | 434 ----------------- tests/planner/test_cycle_cost_guard.py | 12 +- tests/planner/test_ev_planned_load.py | 144 ------ tests/planner/test_invariants.py | 77 ++- tests/planner/test_missing_tomorrow_data.py | 1 - .../test_multi_day_price_preservation.py | 10 - tests/planner/test_plan_explanation.py | 6 +- tests/planner/test_planner_harness.py | 145 +----- tests/planner/test_planning_horizon.py | 16 - tests/planner/test_seasonal_boundary.py | 12 +- tests/planner/test_slot_ownership.py | 43 +- tests/planner/test_soc_simulation.py | 11 - .../test_solar_charge_no_double_count.py | 11 - tests/sensors/test_recommendation_resolver.py | 86 +--- tests/sensors/test_state_collector.py | 52 -- tests/test_config_validation.py | 150 ------ tests/test_convert_to_float_none.py | 4 +- tests/test_coordinator.py | 1 - tests/test_coordinator_builder.py | 2 - tests/test_datetime_utils.py | 41 -- tests/test_diagnostics_dump.py | 13 - tests/test_dst_transitions.py | 11 - tests/test_entity_gating_config.py | 6 - tests/test_entity_platform_classes.py | 47 +- tests/test_flow_helpers.py | 181 +------ tests/test_grid_charge_emergency_stop.py | 1 - tests/test_ha_mock_integration.py | 8 - tests/test_import_integrity.py | 8 - tests/test_midnight_rollover.py | 194 -------- tests/test_p0_regression_suite.py | 125 +---- tests/test_platform_entity_refactor.py | 59 +-- tests/test_power_thresholds.py | 90 +--- tests/test_price_interval_semantics.py | 1 - tests/test_quarter_hourly_planner_input.py | 2 - tests/test_safety_gates.py | 1 - tests/test_schedule_validation.py | 275 ----------- 81 files changed, 226 insertions(+), 5281 deletions(-) delete mode 100644 custom_components/hsem/flows/batteries_schedules.py delete mode 100644 custom_components/hsem/flows/schedule_helpers.py delete mode 100644 custom_components/hsem/models/battery_schedule.py delete mode 100644 custom_components/hsem/models/battery_schedule_input.py delete mode 100644 custom_components/hsem/planner/charging/arbitrage_charge.py delete mode 100644 custom_components/hsem/planner/charging/pre_charge.py delete mode 100644 tests/planner/test_48h_second_day.py delete mode 100644 tests/planner/test_arbitrage_grid_charge.py delete mode 100644 tests/planner/test_charge_scheduler_capacity.py delete mode 100644 tests/test_midnight_rollover.py delete mode 100644 tests/test_schedule_validation.py diff --git a/.github/memories.md b/.github/memories.md index f33076e9..542899b1 100644 --- a/.github/memories.md +++ b/.github/memories.md @@ -1223,8 +1223,21 @@ arrives. `models/ocpp_session.py`. This repository ships OCPP as a supported product feature, so removing it is a user-facing breaking change with no upstream benefit. Treat OCPP's continued presence as a deliberate divergence, never as an -unfinished port. The same holds for fixed battery schedules. Only the dead -seven-bucket charge-rate learner from #7 was taken. +unfinished port. Only the dead seven-bucket charge-rate learner from #7 was taken. + +**Fixed battery schedules — reversed 2026-08-31 (issue #860).** The +"keep fixed battery schedules" half of the line above no longer holds. Issue +#860 found the feature (`batteries_schedule_1/2/3` config, switches, time +entities, `BatterySchedule`/`BatteryScheduleInput`, and the +`apply_discharge_schedules`/`apply_charge_schedules`/`apply_arbitrage_grid_charge` +passes in `engine_core.py`) functionally inert whenever MILP is active — the +schedule-consuming heuristic candidates are commented out in MILP-only mode, +and both surviving candidates (`no_action`, `passive`) discard schedule-derived +recommendations before scoring. The user explicitly chose full removal over +re-wiring it as a MILP-unavailable fallback or leaving it documented as inert, +consciously overriding the earlier "keep it" precedent. Do not resurrect +battery-schedule config/entities/code from this history as if it were still +the settled decision — check the removal PR for the current state instead. **One OCPP server per EV (2026-08-23).** Each EV gets its own embedded OCPP server on its own port (defaults 9000 / 9001): the primary plan drives the diff --git a/custom_components/hsem/config_flow.py b/custom_components/hsem/config_flow.py index 8b1893d0..ac529f79 100644 --- a/custom_components/hsem/config_flow.py +++ b/custom_components/hsem/config_flow.py @@ -16,10 +16,6 @@ get_batteries_excess_export_step_schema, validate_batteries_excess_export_input, ) -from custom_components.hsem.flows.batteries_schedules import ( - get_batteries_schedules_step_schema, - validate_batteries_schedules_input, -) from custom_components.hsem.flows.batteries_wait_mode import ( get_batteries_wait_mode_step_schema, validate_batteries_wait_mode_input, @@ -587,7 +583,7 @@ async def async_step_ocpp( errors = await validate_ocpp_step_input(self.hass, user_input) if not errors: self._user_input.update(user_input) - return await self.async_step_batteries_schedules() + return await self.async_step_batteries_wait_mode() data_schema = await get_ocpp_step_schema( None, @@ -601,32 +597,6 @@ async def async_step_ocpp( last_step=False, ) - async def async_step_batteries_schedules( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the batteries_schedules config flow step. - - Validates user input and advances to the next step in the config flow. - """ - errors = {} - - if user_input is not None: - errors = await validate_batteries_schedules_input(user_input) - if not errors: - self._user_input.update(user_input) - return await self.async_step_batteries_wait_mode() - - data_schema = await get_batteries_schedules_step_schema( - None, hass=self.hass, user_input=self._user_input - ) - - return self.async_show_form( - step_id="batteries_schedules", - data_schema=data_schema, - errors=errors, - last_step=False, - ) - async def async_step_batteries_wait_mode( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/custom_components/hsem/const.py b/custom_components/hsem/const.py index b00ee0f6..ad13544e 100644 --- a/custom_components/hsem/const.py +++ b/custom_components/hsem/const.py @@ -31,15 +31,6 @@ "hsem_batteries_expected_cycles": 6000, "hsem_batteries_cycle_cost": 0.0, "hsem_batteries_capacity_loss_pct": 30, - "hsem_batteries_enable_batteries_schedule_1_end": "09:00:00", - "hsem_batteries_enable_batteries_schedule_1_start": "07:00:00", - "hsem_batteries_enable_batteries_schedule_1": True, - "hsem_batteries_enable_batteries_schedule_2_end": "21:00:00", - "hsem_batteries_enable_batteries_schedule_2_start": "17:00:00", - "hsem_batteries_enable_batteries_schedule_2": True, - "hsem_batteries_enable_batteries_schedule_3_end": "02:00:00", - "hsem_batteries_enable_batteries_schedule_3_start": "23:00:00", - "hsem_batteries_enable_batteries_schedule_3": False, "hsem_ev_target_soc": 80, "hsem_ev_second_target_soc": 80, "hsem_ev_deadline_time": "07:00", diff --git a/custom_components/hsem/coordinator.py b/custom_components/hsem/coordinator.py index 00ce0817..b09ab3be 100644 --- a/custom_components/hsem/coordinator.py +++ b/custom_components/hsem/coordinator.py @@ -68,7 +68,6 @@ from custom_components.hsem.custom_sensors.ocpp_server import OCPPServer from custom_components.hsem.custom_sensors.state_collector import ( # noqa: F401 — kept for backward compat async_collect_all_states, - build_battery_schedules, build_sensor_config, ) from custom_components.hsem.models.daily_plan_vs_actual_tracker import ( @@ -185,8 +184,6 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self._snapshot: StateSnapshot | None = None self._hourly_recommendations: list[HourlyRecommendation] = [] self._hourly_recommendation: HourlyRecommendation | None = None - self._batteries_schedules: list = [] - self._batteries_schedules_remaining_capacity_needed: float = 0.0 self._current_required_battery: float = 0.0 self._next_update: str | None = None diff --git a/custom_components/hsem/coordinator_builder.py b/custom_components/hsem/coordinator_builder.py index ecf92e6c..d7c003d7 100644 --- a/custom_components/hsem/coordinator_builder.py +++ b/custom_components/hsem/coordinator_builder.py @@ -19,7 +19,6 @@ import math from datetime import timedelta -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput from custom_components.hsem.models.hourly_consumption_average import ( HourlyConsumptionAverage, ) @@ -129,7 +128,6 @@ def build_planner_input( cfg: SensorConfig, live: LiveState, hourly_recommendations: list[HourlyRecommendation], - batteries_schedules: list, previous_winner_name: str | None, previous_winner_score: float, ev_session_kw: dict[str, float] | None = None, @@ -144,7 +142,6 @@ def build_planner_input( live: Live HA entity state snapshot. hourly_recommendations: Recommendation slots generated by :func:`generate_recommendation_intervals`. - batteries_schedules: Parsed battery schedule objects. previous_winner_name: Winning candidate name from the previous planner run, or ``None`` for the first run. previous_winner_score: Score of the winning candidate from the @@ -220,15 +217,6 @@ def build_planner_input( ) ) - battery_schedules = [ - BatteryScheduleInput( - enabled=s.enabled, - start=s.start, - end=s.end, - ) - for s in batteries_schedules - ] - _cycles = convert_to_int(cfg.batteries_expected_cycles) _w1d = convert_to_int(cfg.house_consumption_energy_weight_1d) _w3d = convert_to_int(cfg.house_consumption_energy_weight_3d) @@ -311,7 +299,6 @@ def build_planner_input( consumption_averages=consumption_averages, price_points=price_points, solcast_slots=solcast_slots, - battery_schedules=battery_schedules, excess_export_enabled=bool(cfg.batteries_enable_excess_export), excess_export_discharge_buffer_pct=( excess_export_buffer_pct if excess_export_buffer_pct is not None else 10.0 diff --git a/custom_components/hsem/coordinator_cycle.py b/custom_components/hsem/coordinator_cycle.py index d256f889..4ae1052c 100644 --- a/custom_components/hsem/coordinator_cycle.py +++ b/custom_components/hsem/coordinator_cycle.py @@ -42,7 +42,6 @@ ) from custom_components.hsem.custom_sensors.state_collector import ( # noqa: F401 — kept for backward compat async_collect_all_states, - build_battery_schedules, build_sensor_config, ) from custom_components.hsem.models.live_state import EVLiveState, LiveState @@ -171,11 +170,7 @@ async def _async_collect_and_populate( cfg.recommendation_interval_length, ) - # 4. Build battery-schedule objects from config. - self._batteries_schedules = build_battery_schedules(cfg) - self._batteries_schedules.sort(key=lambda x: x.start) - - # 5. Populate weighted house-consumption averages. + # 4. Populate weighted house-consumption averages. set_hsem_verbose(cfg.verbose_logging) if cfg.ml_consumption_enabled: @@ -277,7 +272,7 @@ async def _async_collect_and_populate( else: await self._set_update_interval() - # 6. Determine working state: forced, missing, or full pipeline. + # 5. Determine working state: forced, missing, or full pipeline. state: str | None = None if live.missing_entities and live.force_working_mode_state == "auto": @@ -293,7 +288,7 @@ async def _async_collect_and_populate( live.force_working_mode_state, ) - # 7. Populate electricity prices and Solcast PV estimates. + # 6. Populate electricity prices and Solcast PV estimates. populate_price_and_solcast_from_snapshot( self._hourly_recommendations, self._snapshot, @@ -578,10 +573,6 @@ async def _async_run_update_cycle(self) -> None: live=self._live, hourly_recommendations=list(self._hourly_recommendations), hourly_recommendation=self._hourly_recommendation, - batteries_schedules=list(self._batteries_schedules), - batteries_schedules_remaining_capacity_needed=( - self._batteries_schedules_remaining_capacity_needed - ), current_required_battery=self._current_required_battery, state=state, last_updated=last_updated, diff --git a/custom_components/hsem/coordinator_data.py b/custom_components/hsem/coordinator_data.py index 62bb58fe..c4c36f45 100644 --- a/custom_components/hsem/coordinator_data.py +++ b/custom_components/hsem/coordinator_data.py @@ -34,9 +34,6 @@ class CoordinatorData: hourly_recommendations: Full list of planner recommendation slots. hourly_recommendation: The recommendation slot active *right now*, or ``None`` when no matching slot exists. - batteries_schedules: Parsed battery charge/discharge schedule windows. - batteries_schedules_remaining_capacity_needed: Total remaining capacity - needed across all enabled battery schedules (kWh). current_required_battery: Required battery capacity from the planner (kWh). state: Working-mode recommendation string for the current slot, or one of the :class:`~utils.recommendations.Recommendations` sentinel values. @@ -48,8 +45,6 @@ class CoordinatorData: live: LiveState | None = None hourly_recommendations: list[HourlyRecommendation] = field(default_factory=list) hourly_recommendation: HourlyRecommendation | None = None - batteries_schedules: list = field(default_factory=list) - batteries_schedules_remaining_capacity_needed: float = 0.0 current_required_battery: float = 0.0 state: str | None = None last_updated: str | None = None diff --git a/custom_components/hsem/coordinator_lifecycle.py b/custom_components/hsem/coordinator_lifecycle.py index 6ad8251b..b7c20ec9 100644 --- a/custom_components/hsem/coordinator_lifecycle.py +++ b/custom_components/hsem/coordinator_lifecycle.py @@ -31,7 +31,6 @@ from custom_components.hsem.custom_sensors.ocpp_server import OCPPServer from custom_components.hsem.custom_sensors.state_collector import ( # noqa: F401 — kept for backward compat async_collect_all_states, - build_battery_schedules, build_sensor_config, ) from custom_components.hsem.models.live_state import LiveState @@ -410,9 +409,6 @@ def _apply_planner_output(self, output: PlannerOutput) -> None: unmatched[0], ) - self._batteries_schedules_remaining_capacity_needed = sum( - s.needed_batteries_capacity for s in self._batteries_schedules if s.enabled - ) # Preserve the plan explanation and data quality for the next CoordinatorData snapshot. self._plan_explanation = output.explanation self._data_quality = output.data_quality diff --git a/custom_components/hsem/coordinator_planner_phase.py b/custom_components/hsem/coordinator_planner_phase.py index bb60bc0d..5291b208 100644 --- a/custom_components/hsem/coordinator_planner_phase.py +++ b/custom_components/hsem/coordinator_planner_phase.py @@ -42,7 +42,6 @@ ) from custom_components.hsem.custom_sensors.state_collector import ( # noqa: F401 — kept for backward compat async_collect_all_states, - build_battery_schedules, build_sensor_config, ) from custom_components.hsem.models.live_state import LiveState @@ -161,7 +160,6 @@ async def _run_planner_phase( cfg=cfg, live=live, hourly_recommendations=self._hourly_recommendations, - batteries_schedules=self._batteries_schedules, previous_winner_name=self._previous_planner_winner_name, previous_winner_score=self._previous_planner_winner_score, ev_session_kw=ev_session_kw if ev_session_kw else None, diff --git a/custom_components/hsem/coordinator_state.py b/custom_components/hsem/coordinator_state.py index 163e5265..db8e8588 100644 --- a/custom_components/hsem/coordinator_state.py +++ b/custom_components/hsem/coordinator_state.py @@ -30,7 +30,6 @@ from custom_components.hsem.custom_sensors.ocpp_server import OCPPServer from custom_components.hsem.custom_sensors.state_collector import ( # noqa: F401 — kept for backward compat async_collect_all_states, - build_battery_schedules, build_sensor_config, ) from custom_components.hsem.models.daily_plan_vs_actual_tracker import ( @@ -69,8 +68,6 @@ class CoordinatorSharedState(_Base): """Type-only declaration of state shared across the coordinator mixins.""" _avg_house_consumption_entity_id_cache: dict[str, str] - _batteries_schedules: list - _batteries_schedules_remaining_capacity_needed: float _capacity_learner: CapacityLearner _cfg: SensorConfig _config_entry: ConfigEntry diff --git a/custom_components/hsem/custom_sensors/config_reader.py b/custom_components/hsem/custom_sensors/config_reader.py index ca0e433d..dbe275e8 100644 --- a/custom_components/hsem/custom_sensors/config_reader.py +++ b/custom_components/hsem/custom_sensors/config_reader.py @@ -11,14 +11,11 @@ from __future__ import annotations -from datetime import time from typing import Any, cast import voluptuous as vol -from custom_components.hsem.models.battery_schedule import BatterySchedule from custom_components.hsem.models.sensor_config import ( - BatteryScheduleConfig, EVChargerConfig, SensorConfig, ) @@ -27,7 +24,6 @@ convert_to_boolean, convert_to_float, convert_to_int, - convert_to_time, ) from custom_components.hsem.utils.misc import get_config_value from custom_components.hsem.utils.phase_power import ( @@ -368,47 +364,6 @@ def build_sensor_config( or 30.0 ) - # Battery schedules - _s1_start = get_config_value( - config_entry, "hsem_batteries_enable_batteries_schedule_1_start" - ) - _s1_end = get_config_value( - config_entry, "hsem_batteries_enable_batteries_schedule_1_end" - ) - _s2_start = get_config_value( - config_entry, "hsem_batteries_enable_batteries_schedule_2_start" - ) - _s2_end = get_config_value( - config_entry, "hsem_batteries_enable_batteries_schedule_2_end" - ) - _s3_start = get_config_value( - config_entry, "hsem_batteries_enable_batteries_schedule_3_start" - ) - _s3_end = get_config_value( - config_entry, "hsem_batteries_enable_batteries_schedule_3_end" - ) - cfg.batteries_schedule_1 = BatteryScheduleConfig( - enabled=convert_to_boolean( - get_config_value(config_entry, "hsem_batteries_enable_batteries_schedule_1") - ), - start=convert_to_time(_s1_start) if _s1_start is not None else None, - end=convert_to_time(_s1_end) if _s1_end is not None else None, - ) - cfg.batteries_schedule_2 = BatteryScheduleConfig( - enabled=convert_to_boolean( - get_config_value(config_entry, "hsem_batteries_enable_batteries_schedule_2") - ), - start=convert_to_time(_s2_start) if _s2_start is not None else None, - end=convert_to_time(_s2_end) if _s2_end is not None else None, - ) - cfg.batteries_schedule_3 = BatteryScheduleConfig( - enabled=convert_to_boolean( - get_config_value(config_entry, "hsem_batteries_enable_batteries_schedule_3") - ), - start=convert_to_time(_s3_start) if _s3_start is not None else None, - end=convert_to_time(_s3_end) if _s3_end is not None else None, - ) - # Excess export cfg.batteries_enable_excess_export = bool( get_config_value(config_entry, "hsem_batteries_enable_excess_export") @@ -630,34 +585,6 @@ def build_sensor_config( return cfg -def build_battery_schedules(cfg: SensorConfig) -> list[BatterySchedule]: - """Convert the three :class:`BatteryScheduleConfig` objects into :class:`BatterySchedule` instances. - - Args: - cfg: Populated sensor configuration. - - Returns: - A list of three :class:`BatterySchedule` objects (always three, regardless - of whether they are enabled). - """ - _midnight = time(0, 0) # safe fallback for unconfigured schedules - schedules = [] - for sc in cfg.schedule_configs(): - schedules.append( - BatterySchedule( - enabled=sc.enabled, - # start/end are time|None in BatteryScheduleConfig (optional schedule); - # BatterySchedule requires time, so fall back to midnight when not set. - start=sc.start if sc.start is not None else _midnight, - end=sc.end if sc.end is not None else _midnight, - avg_import_price=0.0, - needed_batteries_capacity=0.0, - needed_batteries_capacity_cost=0.0, - ) - ) - return schedules - - # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- diff --git a/custom_components/hsem/custom_sensors/recommendation_resolver.py b/custom_components/hsem/custom_sensors/recommendation_resolver.py index 4a48871c..23cf099e 100644 --- a/custom_components/hsem/custom_sensors/recommendation_resolver.py +++ b/custom_components/hsem/custom_sensors/recommendation_resolver.py @@ -28,7 +28,6 @@ def _fmt_live_w(power_w: float | None) -> str: def resolve_current_recommendation( rec: HourlyRecommendation, live: LiveState, - batteries_schedules_remaining_capacity_needed: float, cfg: SensorConfig, ) -> None: """Adjust the current-interval recommendation based on live runtime state. @@ -41,15 +40,12 @@ def resolve_current_recommendation( itself economically viable and permitted by the user's configuration. 2. **Grid charge active** → grid charging takes priority over EV smart charge. 3. **EV actively charging** → switch to EV smart charging mode. - 4. **Battery above remaining schedule need** → switch to discharge mode. The recommendation is modified **in-place** on ``rec``. Args: rec: The :class:`HourlyRecommendation` for the current time slot. live: Live state snapshot at call time. - batteries_schedules_remaining_capacity_needed: Total kWh still needed - by all upcoming discharge-window schedules. cfg: Current sensor configuration (excess-export toggle and export price floors). """ @@ -154,18 +150,3 @@ def resolve_current_recommendation( _fmt_live_w(live.ev_second.power_w), original_recommendation, ) - - # 4. Battery has enough energy to cover remaining scheduled discharge needs - if ( - batteries_schedules_remaining_capacity_needed > 0 - and live.battery_current_capacity_kwh - > batteries_schedules_remaining_capacity_needed - ): - rec.recommendation = Recommendations.BatteriesDischargeMode.value - HSEM_LOGGER.debug( - "[resolver] battery above schedule need (%.2f > %.2f kWh) " - "→ overriding %s to batteries_discharge_mode", - live.battery_current_capacity_kwh, - batteries_schedules_remaining_capacity_needed, - original_recommendation, - ) diff --git a/custom_components/hsem/custom_sensors/state_collector.py b/custom_components/hsem/custom_sensors/state_collector.py index 40488504..b055971e 100644 --- a/custom_components/hsem/custom_sensors/state_collector.py +++ b/custom_components/hsem/custom_sensors/state_collector.py @@ -4,8 +4,8 @@ :class:`~custom_components.hsem.models.live_state.LiveState` snapshot. Config-entry reading has moved to :mod:`config_reader`. -Both :func:`build_sensor_config` and :func:`build_battery_schedules` are -re-exported here so existing callers continue to work without changes. +:func:`build_sensor_config` is re-exported here so existing callers +continue to work without changes. This module also collects ALL HA states into an immutable :class:`~custom_components.hsem.models.state_snapshot.StateSnapshot` @@ -26,7 +26,6 @@ # Re-export from config_reader so existing callers continue to work. from custom_components.hsem.custom_sensors.config_reader import ( # noqa: F401 — re-exported for backward compat in coordinator.py - build_battery_schedules, build_sensor_config, ) from custom_components.hsem.custom_sensors.state_collector_compute import ( # noqa: F401 — re-exported for callers diff --git a/custom_components/hsem/custom_sensors/working_mode_sensor.py b/custom_components/hsem/custom_sensors/working_mode_sensor.py index 0c0c30d8..a0d967b0 100644 --- a/custom_components/hsem/custom_sensors/working_mode_sensor.py +++ b/custom_components/hsem/custom_sensors/working_mode_sensor.py @@ -335,8 +335,6 @@ def extra_state_attributes(self) -> dict[str, Any]: "house_consumption_energy_weight_7d": cfg.house_consumption_energy_weight_7d, "house_consumption_power_state": live.house_consumption_power_w, "house_power_includes_ev_charger_power": cfg.house_power_includes_ev_charger_power, - "batteries_schedules_remaining_capacity_needed": data.batteries_schedules_remaining_capacity_needed, - "batteries_schedules": data.batteries_schedules, "huawei_solar_batteries_charging_cutoff_capacity_state": live.huawei_batteries_charging_cutoff_capacity_pct, "huawei_solar_batteries_grid_charge_cutoff_soc_state": live.huawei_batteries_grid_charge_cutoff_soc_pct, "huawei_solar_batteries_maximum_charging_power_state": live.huawei_batteries_max_charge_power_w, @@ -520,7 +518,6 @@ async def _async_apply_hardware_writes(self, data: CoordinatorData | None) -> No resolve_current_recommendation( hourly_rec, live, - data.batteries_schedules_remaining_capacity_needed, cfg, ) # Sync data.state so the sensor's state property reflects the diff --git a/custom_components/hsem/custom_switches/description.py b/custom_components/hsem/custom_switches/description.py index 36de5581..c38f8f53 100644 --- a/custom_components/hsem/custom_switches/description.py +++ b/custom_components/hsem/custom_switches/description.py @@ -10,15 +10,6 @@ from homeassistant.components.switch import SwitchEntityDescription from custom_components.hsem.utils.sensornames.controls import ( - get_batteries_schedule_1_switch_entity_id, - get_batteries_schedule_1_switch_key, - get_batteries_schedule_1_switch_unique_id, - get_batteries_schedule_2_switch_entity_id, - get_batteries_schedule_2_switch_key, - get_batteries_schedule_2_switch_unique_id, - get_batteries_schedule_3_switch_entity_id, - get_batteries_schedule_3_switch_key, - get_batteries_schedule_3_switch_unique_id, get_dynamic_discharge_floor_switch_entity_id, get_dynamic_discharge_floor_switch_key, get_dynamic_discharge_floor_switch_unique_id, @@ -84,18 +75,6 @@ def build_switch_id_map(entry_id: str) -> dict[str, tuple[str, str]]: get_verbose_logging_switch_unique_id(entry_id), get_verbose_logging_switch_entity_id(), ), - get_batteries_schedule_1_switch_key(): ( - get_batteries_schedule_1_switch_unique_id(entry_id), - get_batteries_schedule_1_switch_entity_id(), - ), - get_batteries_schedule_2_switch_key(): ( - get_batteries_schedule_2_switch_unique_id(entry_id), - get_batteries_schedule_2_switch_entity_id(), - ), - get_batteries_schedule_3_switch_key(): ( - get_batteries_schedule_3_switch_unique_id(entry_id), - get_batteries_schedule_3_switch_entity_id(), - ), get_ev_force_discharge_switch_key(): ( get_ev_force_discharge_switch_unique_id(entry_id), get_ev_force_discharge_switch_entity_id(), diff --git a/custom_components/hsem/custom_times/description.py b/custom_components/hsem/custom_times/description.py index 4221a176..8883e4ad 100644 --- a/custom_components/hsem/custom_times/description.py +++ b/custom_components/hsem/custom_times/description.py @@ -9,26 +9,6 @@ from homeassistant.components.time import TimeEntityDescription -from custom_components.hsem.utils.sensornames.controls import ( - get_schedule_1_end_time_entity_id, - get_schedule_1_end_time_key, - get_schedule_1_end_time_unique_id, - get_schedule_1_start_time_entity_id, - get_schedule_1_start_time_key, - get_schedule_1_start_time_unique_id, - get_schedule_2_end_time_entity_id, - get_schedule_2_end_time_key, - get_schedule_2_end_time_unique_id, - get_schedule_2_start_time_entity_id, - get_schedule_2_start_time_key, - get_schedule_2_start_time_unique_id, - get_schedule_3_end_time_entity_id, - get_schedule_3_end_time_key, - get_schedule_3_end_time_unique_id, - get_schedule_3_start_time_entity_id, - get_schedule_3_start_time_key, - get_schedule_3_start_time_unique_id, -) from custom_components.hsem.utils.sensornames.ev import ( get_ev_deadline_time_entity_id, get_ev_deadline_time_key, @@ -49,30 +29,6 @@ def build_time_id_map(entry_id: str) -> dict[str, tuple[str, str]]: A dict mapping config-entry keys to (unique_id, entity_id) tuples. """ return { - get_schedule_1_start_time_key(): ( - get_schedule_1_start_time_unique_id(entry_id), - get_schedule_1_start_time_entity_id(), - ), - get_schedule_1_end_time_key(): ( - get_schedule_1_end_time_unique_id(entry_id), - get_schedule_1_end_time_entity_id(), - ), - get_schedule_2_start_time_key(): ( - get_schedule_2_start_time_unique_id(entry_id), - get_schedule_2_start_time_entity_id(), - ), - get_schedule_2_end_time_key(): ( - get_schedule_2_end_time_unique_id(entry_id), - get_schedule_2_end_time_entity_id(), - ), - get_schedule_3_start_time_key(): ( - get_schedule_3_start_time_unique_id(entry_id), - get_schedule_3_start_time_entity_id(), - ), - get_schedule_3_end_time_key(): ( - get_schedule_3_end_time_unique_id(entry_id), - get_schedule_3_end_time_entity_id(), - ), get_ev_deadline_time_key(): ( get_ev_deadline_time_unique_id(entry_id), get_ev_deadline_time_entity_id(), diff --git a/custom_components/hsem/flows/batteries_schedules.py b/custom_components/hsem/flows/batteries_schedules.py deleted file mode 100644 index 504aa9b6..00000000 --- a/custom_components/hsem/flows/batteries_schedules.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Merged config flow step for all three battery discharge schedules. - -Combines the three previously separate schedule steps (batteries_schedule_1, -batteries_schedule_2, batteries_schedule_3) into a single form so that users -can configure all schedule windows at once. Schema construction and -validation reuse the shared helpers in :mod:`schedule_helpers`. -""" - -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -from custom_components.hsem.flows.schedule_helpers import ( - build_batteries_schedule_step_schema, - resolve_usable_capacity_kwh, - validate_batteries_schedule_input, -) - -# Re-export the shared capacity resolver under the legacy private name so -# that existing imports from batteries_schedule_1 continue to work. -_resolve_usable_capacity_kwh = resolve_usable_capacity_kwh - - -async def get_batteries_schedules_step_schema( - config_entry: ConfigEntry | None, - hass: HomeAssistant | None = None, - user_input: dict | None = None, -) -> vol.Schema: - """Return the data schema for the merged batteries_schedules step. - - Combines schedules 1, 2, and 3 into a single form. Each schedule has - three fields: enabled (boolean), start (time), end (time). - """ - schema_1 = await build_batteries_schedule_step_schema( - 1, config_entry, _hass=hass, _user_input=user_input - ) - schema_2 = await build_batteries_schedule_step_schema( - 2, config_entry, _hass=hass, _user_input=user_input - ) - schema_3 = await build_batteries_schedule_step_schema( - 3, config_entry, _hass=hass, _user_input=user_input - ) - # Merge all three schemas into one - merged = {} - merged.update(schema_1.schema) - merged.update(schema_2.schema) - merged.update(schema_3.schema) - return vol.Schema(merged) - - -async def validate_batteries_schedules_input(user_input: dict) -> dict[str, str]: - """Validate user input for the merged batteries_schedules step. - - Delegates to the shared validator for each of the three schedules. - """ - errors = {} - for n in (1, 2, 3): - errs = await validate_batteries_schedule_input(n, user_input) - errors.update(errs) - return errors diff --git a/custom_components/hsem/flows/schedule_helpers.py b/custom_components/hsem/flows/schedule_helpers.py deleted file mode 100644 index 12429c39..00000000 --- a/custom_components/hsem/flows/schedule_helpers.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Reusable schema factory and validator for battery schedule config flow steps. - -All three schedule steps (1, 2, 3) share identical logic. The only -difference is the numeric suffix embedded in the config-entry key names. -This module provides a single parameterised schema builder and a single -parameterised validator so that ``batteries_schedule_{1,2,3}.py`` each -contain only a thin numbered wrapper — removing the duplicated code. - -Public API ----------- -- :func:`build_batteries_schedule_step_schema` — async schema factory. -- :func:`validate_batteries_schedule_input` — async validator. -- :func:`resolve_usable_capacity_kwh` — helper used by schema factories. -""" - -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.selector import selector - -from custom_components.hsem.utils.config_validator import validate_time_window -from custom_components.hsem.utils.conversion import convert_to_float -from custom_components.hsem.utils.misc import get_config_value - - -def resolve_usable_capacity_kwh( - hass: HomeAssistant | None, - config_entry: ConfigEntry | None, - user_input: dict | None = None, -) -> float: - """Return the usable battery capacity in kWh for threshold preview calculations. - - Resolves the live HA state of ``hsem_huawei_solar_batteries_rated_capacity`` - (stored in Wh) and converts it to kWh. Falls back to 10.0 kWh when the - entity is unavailable or the state cannot be parsed. - - Priority for the entity-id string: - 1. ``config_entry`` (options flow / existing config) - 2. ``user_input`` (config flow — data from previous steps) - 3. Built-in fallback: 10.0 kWh - - Args: - hass: Home Assistant instance (may be None during initial config). - config_entry: Active config entry (may be None for new installs). - user_input: Optional dict of values collected in prior flow steps. - - Returns: - Usable battery capacity in kWh. - """ - rated_capacity_entity = get_config_value( - config_entry, "hsem_huawei_solar_batteries_rated_capacity" - ) or ( - user_input.get("hsem_huawei_solar_batteries_rated_capacity") - if user_input - else None - ) - if hass and rated_capacity_entity: - state = hass.states.get(rated_capacity_entity) - if state is not None: - rated_wh = convert_to_float(state.state) - if rated_wh and rated_wh > 0: - return rated_wh / 1000.0 - return 10.0 - - -async def build_batteries_schedule_step_schema( # NOSONAR - schedule_number: int, - config_entry: ConfigEntry | None, - _hass: HomeAssistant | None = None, - _user_input: dict | None = None, -) -> vol.Schema: - """Return the voluptuous schema for a numbered battery schedule step. - - Constructs schema keys by substituting *schedule_number* into the - standard ``hsem_batteries_enable_batteries_schedule_N*`` key pattern. - - Args: - schedule_number: Integer suffix (1, 2, or 3) identifying the schedule. - config_entry: Active config entry; ``None`` for the initial config flow. - _hass: Home Assistant instance; ``None`` when not yet available. - _user_input: Optional dict of values from prior flow steps — used to - resolve the rated-capacity entity during first-time setup. - - Returns: - A ``vol.Schema`` for the ``batteries_schedule_N`` flow step. - """ - n = schedule_number - prefix = f"hsem_batteries_enable_batteries_schedule_{n}" - - return vol.Schema( - { - vol.Required( - prefix, - default=get_config_value(config_entry, prefix), - ): selector({"boolean": {}}), - vol.Required( - f"{prefix}_start", - default=get_config_value(config_entry, f"{prefix}_start"), - ): selector({"time": {}}), - vol.Required( - f"{prefix}_end", - default=get_config_value(config_entry, f"{prefix}_end"), - ): selector({"time": {}}), - } - ) - - -async def validate_batteries_schedule_input( # NOSONAR - schedule_number: int, - user_input: dict, -) -> dict[str, str]: - """Validate user input for a numbered battery schedule step. - - Delegates to :func:`~custom_components.hsem.utils.config_validator.validate_time_window` - after deriving the correct field names from *schedule_number*. - - Args: - schedule_number: Integer suffix (1, 2, or 3) identifying the schedule. - user_input: Dict of field name → value submitted by the user. - - Returns: - Dict mapping field names to translation error keys; empty on success. - """ - prefix = f"hsem_batteries_enable_batteries_schedule_{schedule_number}" - return validate_time_window( - user_input, - enabled_field=prefix, - start_field=f"{prefix}_start", - end_field=f"{prefix}_end", - ) diff --git a/custom_components/hsem/models/battery_schedule.py b/custom_components/hsem/models/battery_schedule.py deleted file mode 100644 index d421e35f..00000000 --- a/custom_components/hsem/models/battery_schedule.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Dataclass representing a battery charge/discharge schedule window. - -A battery schedule defines a time window during which the battery should -charge or discharge, along with the economic parameters that drive the -decision (average import price and required capacity). -""" - -from dataclasses import dataclass -from datetime import time - - -@dataclass -class BatterySchedule: - """A single battery charge/discharge schedule window. - - Holds the enabled state, time boundaries, and economic parameters - (average import price, needed capacity, and associated cost) for one - battery schedule window. - """ - - enabled: bool - start: time - end: time - avg_import_price: float - needed_batteries_capacity: float - needed_batteries_capacity_cost: float diff --git a/custom_components/hsem/models/battery_schedule_input.py b/custom_components/hsem/models/battery_schedule_input.py deleted file mode 100644 index 1883ef0e..00000000 --- a/custom_components/hsem/models/battery_schedule_input.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Dataclass for one charge/discharge schedule window configuration.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import datetime, time - - -@dataclass -class BatteryScheduleInput: - """Configuration for one charge-into/discharge-from schedule window. - - Mirrors the user-visible battery schedule options from the config flow - (``batteries_schedule_1/2/3``). - """ - - enabled: bool = False - start: time = time(0, 0) - end: time = time(1, 0) - - # Runtime attributes populated by the discharge scheduler and consumed by - # the charge scheduler. Declared here so type checkers see them instead of - # requiring dynamic attribute workarounds. - _occurrences: list[tuple[datetime, datetime, float, float]] = field( - default_factory=list, repr=False - ) - _needed_capacity: float = field(default=0.0, repr=False) - _avg_import_price: float = field(default=0.0, repr=False) diff --git a/custom_components/hsem/models/planner_input.py b/custom_components/hsem/models/planner_input.py index 89e8442e..07c48671 100644 --- a/custom_components/hsem/models/planner_input.py +++ b/custom_components/hsem/models/planner_input.py @@ -7,7 +7,6 @@ from typing import Any, cast from custom_components.hsem.const import DEFAULT_CONFIG_VALUES -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput from custom_components.hsem.models.hourly_consumption_average import ( HourlyConsumptionAverage, ) @@ -76,8 +75,6 @@ class PlannerInput: solcast_slots: PV production forecast. Should cover every planned slot; missing slots default to zero. - battery_schedules: - Up to three charge/discharge schedule windows. excess_export_enabled: Whether the excess-export feature is active. excess_export_discharge_buffer_pct: @@ -156,9 +153,6 @@ class PlannerInput: price_points: list[PricePoint] = field(default_factory=list) solcast_slots: list[SolcastSlot] = field(default_factory=list) - # --- discharge / charge schedules --- - battery_schedules: list[BatteryScheduleInput] = field(default_factory=list) - # --- excess export --- excess_export_enabled: bool = False excess_export_discharge_buffer_pct: float = 10.0 diff --git a/custom_components/hsem/models/sensor_config.py b/custom_components/hsem/models/sensor_config.py index bfb41aea..8464c055 100644 --- a/custom_components/hsem/models/sensor_config.py +++ b/custom_components/hsem/models/sensor_config.py @@ -13,7 +13,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import time from typing import cast from custom_components.hsem.const import DEFAULT_CONFIG_VALUES @@ -41,20 +40,6 @@ class EVChargerConfig: max_discharge_power: int = 0 -@dataclass -class BatteryScheduleConfig: - """Configuration for one charge/discharge battery schedule window. - - Defines whether the schedule window is enabled and its start/end times. - Used as a nested config block within :class:`SensorConfig` for up to - three independent battery schedule windows. - """ - - enabled: bool = False - start: time | None = None - end: time | None = None - - @dataclass class SensorConfig: """Complete set of configuration values for :class:`HSEMWorkingModeSensor`. @@ -124,10 +109,6 @@ class SensorConfig: batteries_cycle_cost: User-configured extra per-kWh cycle cost. Added to the auto-derived depreciation threshold. 0.0 = disabled. - batteries_schedule_1: First discharge-window schedule config. - batteries_schedule_2: Second discharge-window schedule config. - batteries_schedule_3: Third discharge-window schedule config. - batteries_enable_excess_export: Enable opportunistic forced-discharge export. batteries_excess_export_discharge_buffer: Safety buffer percentage to keep. batteries_forecast_reserve_pct: Extra SoC percentage points above the @@ -256,17 +237,6 @@ class SensorConfig: ) ) - # Battery discharge schedules - batteries_schedule_1: BatteryScheduleConfig = field( - default_factory=BatteryScheduleConfig - ) - batteries_schedule_2: BatteryScheduleConfig = field( - default_factory=BatteryScheduleConfig - ) - batteries_schedule_3: BatteryScheduleConfig = field( - default_factory=BatteryScheduleConfig - ) - # Excess export batteries_enable_excess_export: bool = False batteries_excess_export_discharge_buffer: float = 10.0 @@ -364,14 +334,6 @@ class SensorConfig: house_consumption_energy_weight_7d: int = 15 house_consumption_energy_weight_14d: int = 10 - def schedule_configs(self) -> list[BatteryScheduleConfig]: - """Return all three schedule configs as a list.""" - return [ - self.batteries_schedule_1, - self.batteries_schedule_2, - self.batteries_schedule_3, - ] - def __repr__(self) -> str: return ( f"SensorConfig(read_only={self.read_only}, " diff --git a/custom_components/hsem/options_flow.py b/custom_components/hsem/options_flow.py index c0f59132..ad6cf661 100644 --- a/custom_components/hsem/options_flow.py +++ b/custom_components/hsem/options_flow.py @@ -10,10 +10,6 @@ get_batteries_excess_export_step_schema, validate_batteries_excess_export_input, ) -from custom_components.hsem.flows.batteries_schedules import ( - get_batteries_schedules_step_schema, - validate_batteries_schedules_input, -) from custom_components.hsem.flows.batteries_wait_mode import ( get_batteries_wait_mode_step_schema, validate_batteries_wait_mode_input, @@ -383,7 +379,7 @@ async def async_step_ocpp( errors = await validate_ocpp_step_input(self.hass, user_input) if not errors: self._user_input.update(user_input) - return await self.async_step_batteries_schedules() + return await self.async_step_batteries_wait_mode() data_schema = await get_ocpp_step_schema( self._config_entry, @@ -400,32 +396,6 @@ async def async_step_ocpp( last_step=False, ) - async def async_step_batteries_schedules( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the batteries_schedules options step. - - Validates user input and advances to the next step in the options flow. - """ - errors = {} - - if user_input is not None: - errors = await validate_batteries_schedules_input(user_input) - if not errors: - self._user_input.update(user_input) - return await self.async_step_batteries_wait_mode() - - data_schema = await get_batteries_schedules_step_schema( - self._config_entry, hass=self.hass - ) - - return self.async_show_form( - step_id="batteries_schedules", - data_schema=data_schema, - errors=errors, - last_step=False, - ) - async def async_step_batteries_wait_mode( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/custom_components/hsem/planner/charge_scheduler.py b/custom_components/hsem/planner/charge_scheduler.py index 31587f25..73c17aab 100644 --- a/custom_components/hsem/planner/charge_scheduler.py +++ b/custom_components/hsem/planner/charge_scheduler.py @@ -7,18 +7,12 @@ from __future__ import annotations -from custom_components.hsem.planner.charging.arbitrage_charge import ( - apply_arbitrage_grid_charge, -) from custom_components.hsem.planner.charging.opportunistic_charge import ( apply_opportunistic_charge, ) -from custom_components.hsem.planner.charging.pre_charge import apply_charge_schedules from custom_components.hsem.planner.window_hysteresis import apply_window_hysteresis __all__ = [ - "apply_arbitrage_grid_charge", - "apply_charge_schedules", "apply_opportunistic_charge", "apply_window_hysteresis", ] diff --git a/custom_components/hsem/planner/charging/__init__.py b/custom_components/hsem/planner/charging/__init__.py index bef3e408..5b839638 100644 --- a/custom_components/hsem/planner/charging/__init__.py +++ b/custom_components/hsem/planner/charging/__init__.py @@ -7,19 +7,10 @@ from __future__ import annotations -from custom_components.hsem.planner.charging.arbitrage_charge import ( - apply_arbitrage_grid_charge, -) from custom_components.hsem.planner.charging.opportunistic_charge import ( apply_opportunistic_charge, ) -from custom_components.hsem.planner.charging.pre_charge import ( - _apply_grid_charge, - apply_charge_schedules, -) __all__ = [ - "apply_arbitrage_grid_charge", - "apply_charge_schedules", "apply_opportunistic_charge", ] diff --git a/custom_components/hsem/planner/charging/_charge_helpers.py b/custom_components/hsem/planner/charging/_charge_helpers.py index 1f02de9d..d4e77604 100644 --- a/custom_components/hsem/planner/charging/_charge_helpers.py +++ b/custom_components/hsem/planner/charging/_charge_helpers.py @@ -15,9 +15,9 @@ def _already_planned_charge_kwh(slots: list[PlannedSlot]) -> float: """Return the sum of ``batteries_charged_kwh`` across all charge-type slots. - Used by downstream charge passes (opportunistic, arbitrage) to avoid - exceeding the battery's remaining capacity when ``apply_charge_schedules`` - has already assigned energy. + Used by downstream charge passes (e.g. opportunistic charge) to avoid + exceeding the battery's remaining capacity when an earlier pass has + already assigned charge energy. Args: slots: The mutable slot list to scan. diff --git a/custom_components/hsem/planner/charging/arbitrage_charge.py b/custom_components/hsem/planner/charging/arbitrage_charge.py deleted file mode 100644 index 7d007928..00000000 --- a/custom_components/hsem/planner/charging/arbitrage_charge.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Arbitrage grid charging.""" - -from __future__ import annotations - -from datetime import datetime - -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput -from custom_components.hsem.models.planned_slot import PlannedSlot -from custom_components.hsem.planner.charging._charge_helpers import ( - _already_planned_charge_kwh, -) -from custom_components.hsem.utils.datetime_utils import as_tz -from custom_components.hsem.utils.logger import log_planner -from custom_components.hsem.utils.recommendations import Recommendations - -# --------------------------------------------------------------------------- -# Arbitrage grid charging -# --------------------------------------------------------------------------- - - -def apply_arbitrage_grid_charge( - slots: list[PlannedSlot], - battery_schedules: list[BatteryScheduleInput], - now: datetime, - current_capacity: float, - usable_capacity: float, - max_charge_per_interval: float, - conversion_loss_pct: float, - cycle_cost_per_kwh: float = 0.0, - recommended_threshold: float = 0.0, -) -> None: - """Charge from the grid when a future expensive import slot can be offset. - - This pass runs *after* the scheduled and opportunistic charge passes and - *before* the seasonal fallback. It exists to capture price arbitrage - even when no discharge schedule is configured: HSEM's scheduled and - opportunistic passes only react to discharge windows or a fixed - depreciation threshold, so a clear cheap-now vs. expensive-later spread - (e.g. 0.66 DKK/kWh at noon vs. 1.68 DKK/kWh at 18:00) would otherwise - never trigger grid charging. - - Algorithm: - - 1. Skip if remaining capacity ≤ 0 or no enabled battery schedule exists - (the user has effectively disabled grid charging by leaving every - schedule disabled). - 2. Build a list of future expensive consumption slots — slots with - ``estimated_net_consumption > 0`` and no charge/discharge - recommendation yet (i.e. those that would otherwise import from the - grid). These represent kWh that the battery can avoid importing - later. - 3. Build a list of unassigned future charge candidates, sorted cheapest - first. - 4. Walk charge candidates from cheapest up. For each candidate, attempt - to "pair" each kWh of charge with the most expensive future - unmatched-import-kWh that occurs *after* the candidate slot. A pair - is profitable when:: - - expensive.import_price - cheap.import_price - >= min_price_difference + cycle_cost_per_kwh - + conversion_loss_cost_per_kwh - - where ``min_price_difference`` is the smallest enabled schedule's - value (or the depreciation-derived ``recommended_threshold`` when no - enabled schedule has set one), and conversion-loss cost is the cost - of the conversion loss applied to each charged kWh. - - Args: - slots: Mutable list of planned slots. - battery_schedules: Schedule configurations. At least one must be - enabled for arbitrage charging to be active. - now: Timezone-aware current datetime. - current_capacity: Current available battery energy in kWh. - usable_capacity: Maximum usable battery energy in kWh. - max_charge_per_interval: Maximum energy (kWh) chargeable per slot. - conversion_loss_pct: Round-trip conversion loss as a percentage - (0-100). Used to estimate the per-kWh loss cost that the - future-vs-current price spread must cover. - cycle_cost_per_kwh: Additional per-kWh battery wear cost (≥ 0). - recommended_threshold: Depreciation + conversion-loss threshold. - Used as a fallback minimum price-difference when no enabled - schedule has supplied a non-zero ``min_price_difference``. - Defaults to 0.0. - """ - log_planner( - "debug", - "[chg] apply_arbitrage_grid_charge current=%.3f usable=%.3f " - "max_charge/slot=%.3f conv_loss_pct=%.2f cycle_cost=%.4f threshold=%.4f", - current_capacity, - usable_capacity, - max_charge_per_interval, - conversion_loss_pct, - cycle_cost_per_kwh, - recommended_threshold, - ) - if max_charge_per_interval <= 0: - log_planner("debug", "arbitrage: max_charge_per_interval <= 0, skipping") - return - - enabled = [s for s in battery_schedules if s.enabled] - if not enabled: - log_planner( - "debug", "arbitrage: no enabled battery schedule — grid charge disabled" - ) - return - - # Use usable_capacity as the budget rather than usable_capacity - - # current_capacity, so arbitrage charging is evaluated even when the - # battery starts full. The battery will discharge between now and - # the expensive consumption slots tomorrow, making room for charging. - # The SoC simulation handles per-slot physical clamping. - remaining_capacity = usable_capacity - already_planned = _already_planned_charge_kwh(slots) - remaining_capacity = max(remaining_capacity - already_planned, 0.0) - if remaining_capacity <= 1e-9: - log_planner( - "debug", - "arbitrage: battery effectively full (remaining=%.3f, already_planned=%.3f)", - remaining_capacity, - already_planned, - ) - return - - # Pick the smallest enabled schedule's recommended_threshold as the guard floor. - # All schedules fall back to the depreciation-derived recommended_threshold. - sched_min_diff = recommended_threshold - - # Conversion loss cost per kWh charged: a rough lower bound — charging - # one stored kWh requires (1 / (1 - loss)) kWh of grid energy, so each - # kWh costs (1/(1-loss) - 1) * charge_price extra. We approximate it - # against the cheap-slot price so the comparison stays simple. - loss_factor = max(conversion_loss_pct / 100.0, 0.0) - - # Collect future expensive consumption slots (will import unless offset). - # These are slots with positive net consumption that have not been - # assigned a recommendation yet — they represent grid-import kWh that - # arbitrage can avoid. - expensive_slots: list[tuple[PlannedSlot, float]] = [] - for s in slots: - if as_tz(s.end, now.tzinfo) <= now: - continue - if s.recommendation is not None: - continue - if s.estimated_net_consumption_kwh <= 0: - continue - expensive_slots.append((s, float(s.estimated_net_consumption_kwh))) - - if not expensive_slots: - log_planner( - "debug", - "arbitrage: no future positive-net-consumption slots — nothing to offset", - ) - return - - # Collect cheap charge candidates, sorted by price then by start. - candidates = sorted( - ( - s - for s in slots - if as_tz(s.end, now.tzinfo) > now and s.recommendation is None - ), - key=lambda x: (x.price.import_price, x.start), - ) - if not candidates: - log_planner("debug", "arbitrage: no unassigned future slots") - return - - # Track per-expensive-slot remaining unmatched import demand (kWh). - # We mutate a parallel dict so we can deduct as we match. - remaining_demand: dict[int, float] = { - id(es): demand for es, demand in expensive_slots - } - - charged_total = 0.0 - chosen_any = False - - for cand in candidates: - if charged_total >= remaining_capacity - 1e-9: - break - - cand_start_local = as_tz(cand.start, now.tzinfo) - cand_price = cand.price.import_price - - # Per-kWh conversion-loss cost approximated against this candidate's - # price. Negative prices flip the sign; clamp at 0 to avoid making - # the guard easier to pass when the grid pays us. - loss_cost_per_kwh = max(cand_price, 0.0) * ( - loss_factor / (1.0 - loss_factor) if loss_factor < 1.0 else 0.0 - ) - min_required_spread = sched_min_diff + cycle_cost_per_kwh + loss_cost_per_kwh - - # Find future expensive slots strictly after this candidate, sorted - # most-expensive first, with remaining unmatched demand. - future_expensive = sorted( - ( - es - for es, _ in expensive_slots - if as_tz(es.start, now.tzinfo) - >= cand_start_local + (cand.end - cand.start) - and remaining_demand.get(id(es), 0.0) > 1e-9 - ), - key=lambda x: (-x.price.import_price, x.start), - ) - - slot_room = min(max_charge_per_interval, remaining_capacity - charged_total) - slot_charged = 0.0 - - for es in future_expensive: - if slot_charged >= slot_room - 1e-9: - break - spread = es.price.import_price - cand_price - if spread < min_required_spread: - # future_expensive is sorted highest-price first; if the - # most expensive remaining future slot does not clear the - # required spread, no later (cheaper) one can either. - break - available_demand = remaining_demand.get(id(es), 0.0) - energy = min(slot_room - slot_charged, available_demand) - if energy <= 1e-9: - continue - remaining_demand[id(es)] = available_demand - energy - slot_charged += energy - log_planner( - "debug", - "arbitrage: pairing %.3f kWh at %s (price=%.4f) -> %s " - "(price=%.4f, spread=%.4f, required=%.4f)", - energy, - cand.start.isoformat(), - cand_price, - es.start.isoformat(), - es.price.import_price, - spread, - min_required_spread, - ) - - if slot_charged > 1e-9: - cand.recommendation = Recommendations.BatteriesChargeGrid.value - cand.batteries_charged_kwh = round(slot_charged, 3) - charged_total += slot_charged - chosen_any = True - else: - log_planner( - "debug", - "arbitrage: no profitable future slot for candidate at %s " - "(price=%.4f, required spread=%.4f)", - cand.start.isoformat(), - cand_price, - min_required_spread, - ) - - if chosen_any: - # Mark matched expensive slots for discharge so the battery - # actually covers them during the SoC simulation instead of - # importing from grid. - discharged_count = 0 - for es, original_demand in expensive_slots: - matched_kwh = original_demand - remaining_demand.get( - id(es), original_demand - ) - if matched_kwh > 1e-9: - es.recommendation = Recommendations.ForceBatteriesDischarge.value - discharged_count += 1 - if discharged_count > 0: - log_planner( - "debug", - "arbitrage: marked %d expensive slot(s) for force discharge", - discharged_count, - ) - - log_planner( - "debug", - "arbitrage: total %.3f kWh of grid charging scheduled " - "(remaining_capacity=%.3f, sched_min_diff=%.4f, " - "cycle_cost=%.4f, conversion_loss_pct=%.2f)", - charged_total, - remaining_capacity, - sched_min_diff, - cycle_cost_per_kwh, - conversion_loss_pct, - ) - else: - log_planner( - "debug", - "arbitrage: no slot scheduled — price spread did not cover " - "min_price_difference(%.4f) + cycle_cost(%.4f) + conversion loss", - sched_min_diff, - cycle_cost_per_kwh, - ) diff --git a/custom_components/hsem/planner/charging/pre_charge.py b/custom_components/hsem/planner/charging/pre_charge.py deleted file mode 100644 index 6615ed33..00000000 --- a/custom_components/hsem/planner/charging/pre_charge.py +++ /dev/null @@ -1,338 +0,0 @@ -"""Battery charge scheduling for the HSEM planner — pre-charge pass. - -Responsible for the schedule-based pre-charge pass -(``apply_charge_schedules`` and ``_apply_grid_charge``). - -All functions are pure — no I/O, no Home Assistant imports. They mutate the -:class:`PlannedSlot` list passed in and return nothing (or a scalar result). -""" - -from __future__ import annotations - -from datetime import datetime - -from custom_components.hsem.const import SOLAR_SURPLUS_CHARGE_THRESHOLD_KWH -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput -from custom_components.hsem.models.planned_slot import PlannedSlot -from custom_components.hsem.utils.datetime_utils import as_tz -from custom_components.hsem.utils.logger import log_planner -from custom_components.hsem.utils.recommendations import Recommendations -from custom_components.hsem.utils.time_windows import next_window_start_dt - -# --------------------------------------------------------------------------- -# Charge scheduling -# --------------------------------------------------------------------------- - - -def apply_charge_schedules( - slots: list[PlannedSlot], - battery_schedules: list[BatteryScheduleInput], - now: datetime, - max_charge_per_interval: float, - *, - current_kwh: float = 0.0, - usable_kwh: float = 0.0, - cycle_cost_per_kwh: float = 0.0, - recommended_threshold: float = 0.0, -) -> None: - """Assign charge recommendations to slots before each discharge window. - - Three-priority ordering: - - 1. Negative import price (free/paid-to-charge) - 2. Solar surplus (``estimated_net_consumption < SOLAR_SURPLUS_CHARGE_THRESHOLD_KWH``) - 3. Cheapest remaining grid hours (guarded by depreciation threshold + cycle cost) - - Each discharge-window occurrence (calendar day) receives its own - independent charge budget capped at ``min(needed, usable_kwh)``. - This correctly accounts for the fact that the battery is discharged - during the previous window, making room for new charging. The battery - never holds more than ``usable_kwh`` at any one time, but it can be - charged again after being discharged. - - Args: - slots: Mutable list of planned slots. - battery_schedules: Schedule configurations. - now: Timezone-aware current datetime. - max_charge_per_interval: Maximum energy (kWh) chargeable per slot. - current_kwh: Current battery energy above the discharge floor (kWh). - Used to cap total charge across all occurrences. - usable_kwh: Maximum usable battery capacity (kWh). Used together - with *current_kwh* to derive remaining capacity. - cycle_cost_per_kwh: Additional per-kWh cycle wear cost. - recommended_threshold: Depreciation-derived price floor passed to - ``_apply_grid_charge`` to guard profitability. - """ - if max_charge_per_interval <= 0: - log_planner( - "debug", - "[chg] apply_charge_schedules skipped — max_charge_per_interval <= 0", - ) - return - - # Each discharge-window occurrence (calendar day) gets its own - # independent charge budget because the battery is discharged during - # the previous window, making room for new charging. The per-occurrence - # budget is capped at usable_kwh (or the energy actually needed for that - # window), not by a global pool shared across all days. - occurrence_count = 0 - total_charged_all = 0.0 - today = as_tz(now, now.tzinfo).date() - used_today_current_kwh = False - - log_planner( - "debug", - "[chg] apply_charge_schedules schedules=%d max_charge/slot=%.3f " - "current=%.3f usable=%.3f", - len(battery_schedules), - max_charge_per_interval, - current_kwh, - usable_kwh, - ) - - for sched in battery_schedules: - if not sched.enabled: - continue - - # Iterate each occurrence of the discharge window independently. - # Each occurrence needs its own pre-charge budget so day-2 discharge - # windows get their own cheap-hours charge allocation. - occurrences: list[tuple[datetime, datetime, float, float]] = getattr( - sched, "_occurrences", [] - ) - if not occurrences: - # apply_discharge_schedules (always called first, see engine_core.py) - # unconditionally sets sched._occurrences for every enabled - # schedule, but that list is legitimately empty when no future - # occurrence of the window falls within the planning horizon. - # This branch covers that case, not a caller skipping the prior - # pass. - needed_fb: float = getattr(sched, "_needed_capacity", 0.0) - avg_price_fb: float = getattr(sched, "_avg_import_price", 0.0) - if needed_fb > 0: - occurrences = [ - ( - next_window_start_dt(now, sched.start), - next_window_start_dt(now, sched.start), - needed_fb, - avg_price_fb, - ) - ] - - for ( - window_start_abs, - _window_end_abs, - needed, - avg_discharge_price, - ) in occurrences: - if needed <= 0: - continue - - # Per-occurrence budget: cap at what's needed for this window - # or at usable_kwh (the battery's physical capacity), whichever - # is smaller. Do NOT share a global pool across days — the - # battery is discharged between windows, making room for new - # charging. - # - # For windows on today's calendar date, account for the - # battery's current charge (current_kwh) so we don't plan - # unnecessary charging when the battery is already full. - # Windows on future days get the full usable_kwh budget. - window_date = as_tz(window_start_abs, now.tzinfo).date() - if window_date == today and not used_today_current_kwh: - occurrence_budget = ( - min(needed, max(needed - current_kwh, 0.0), usable_kwh) - if usable_kwh > 0 - else needed - ) - used_today_current_kwh = True - else: - occurrence_budget = ( - min(needed, usable_kwh) if usable_kwh > 0 else needed - ) - - # Eligible charge slots: future, unassigned, and ending before - # this specific occurrence's window start. window_start_abs is - # already a resolved absolute datetime for *this* occurrence - # (day N of a recurring schedule), so this is a plain datetime - # comparison — not a candidate for the now-removed - # interval_ends_before_window_start(interval_end, window_start: - # time, now) helper, which only resolves the *first* future - # occurrence relative to `now` and would silently break - # multi-occurrence (day 2+) budgeting if substituted here. - eligible = [ - s - for s in slots - if as_tz(s.end, now.tzinfo) > now - and as_tz(s.end, now.tzinfo) <= window_start_abs - and s.recommendation is None - ] - - occurrence_count += 1 - charged = 0.0 - - # Priority 1: negative import price - for s in sorted( - (e for e in eligible if e.price.import_price < 0.0), - key=lambda x: (x.price.import_price, x.start), - ): - if charged >= occurrence_budget: - break - energy = min(max_charge_per_interval, occurrence_budget - charged) - if energy > 0: - s.recommendation = Recommendations.BatteriesChargeGrid.value - s.batteries_charged_kwh = round(energy, 3) - charged += energy - - # Priority 2: solar surplus - if charged < occurrence_budget: - for s in sorted( - ( - e - for e in eligible - if e.estimated_net_consumption_kwh - < SOLAR_SURPLUS_CHARGE_THRESHOLD_KWH - and e.recommendation is None - ), - # NOTE: SOLAR_SURPLUS_CHARGE_THRESHOLD_KWH is negative, so this - # selects slots where net consumption is sufficiently negative - # (i.e., there is a meaningful solar surplus to charge from). - key=lambda x: (x.estimated_net_consumption_kwh, x.start), - ): - if charged >= occurrence_budget: - break - available_solar = abs(s.estimated_net_consumption_kwh) - energy = min( - max_charge_per_interval, - occurrence_budget - charged, - available_solar, - ) - if energy > 0: - s.recommendation = Recommendations.BatteriesChargeSolar.value - s.batteries_charged_kwh = round(energy, 3) - charged += energy - - # Priority 3: cheapest grid hours (depreciation threshold + cycle cost guard) - if charged < occurrence_budget: - grid_charged = _apply_grid_charge( - eligible, - occurrence_budget, - charged, - max_charge_per_interval, - avg_discharge_price, - cycle_cost_per_kwh=cycle_cost_per_kwh, - recommended_threshold=recommended_threshold, - ) - charged += grid_charged - - total_charged_all += charged - log_planner( - "debug", - "[chg] apply_charge_schedules occurrence=%d budget=%.3f " - "needed=%.3f charged=%.3f window=%s", - occurrence_count, - occurrence_budget, - needed, - charged, - window_start_abs.strftime("%Y-%m-%d %H:%M"), - ) - - log_planner( - "debug", - "[chg] apply_charge_schedules DONE occurrences=%d total_charged=%.3f", - occurrence_count, - total_charged_all, - ) - - -def _apply_grid_charge( - eligible: list[PlannedSlot], - needed: float, - charged_so_far: float, - max_charge_per_interval: float, - avg_discharge_price: float, - cycle_cost_per_kwh: float = 0.0, - recommended_threshold: float = 0.0, -) -> float: - """Apply cheapest-grid-hour charging with depreciation + cycle-cost guard. - - The combined profitability condition is: - - avg_discharge_price − avg_charge_price ≥ recommended_threshold + cycle_cost_per_kwh - - where ``recommended_threshold`` is the depreciation-derived price floor. - - Args: - eligible: Pre-filtered candidate slots. - needed: Total energy to charge in kWh. - charged_so_far: Energy already charged by higher-priority sources. - max_charge_per_interval: Maximum energy per slot in kWh. - avg_discharge_price: Average import price during the discharge window. - cycle_cost_per_kwh: Per-kWh battery wear cost added to the guard. - Defaults to 0.0 (backwards compatible). - recommended_threshold: Depreciation + loss derived price floor. - - Returns: - The total energy (kWh) assigned to grid charging by this call. - """ - grid_candidates = sorted( - (e for e in eligible if e.recommendation is None), - key=lambda x: (x.price.import_price, x.start), - ) - - # First pass: estimate average charge price - tentative_charged = 0.0 - tentative_count = 0 - tentative_price_sum = 0.0 - for s in grid_candidates: - if tentative_charged >= needed - charged_so_far: - break - available_solar = ( - abs(s.estimated_net_consumption_kwh) - if s.estimated_net_consumption_kwh < 0 - else 0 - ) - grid_needed = min( - max_charge_per_interval - available_solar, - needed - charged_so_far - tentative_charged - available_solar, - ) - energy = available_solar + grid_needed - if energy > 0: - tentative_count += 1 - tentative_price_sum += s.price.import_price - tentative_charged += energy - - avg_charge_price = ( - tentative_price_sum / tentative_count if tentative_count > 0 else 0.0 - ) - price_diff = avg_discharge_price - avg_charge_price - # Combined threshold: depreciation-derived price floor + per-kWh wear cost. - # Both must be covered by the price spread for grid charging to be profitable. - min_diff = recommended_threshold + cycle_cost_per_kwh - - if abs(min_diff) > 1e-9 and price_diff < min_diff: - return 0.0 # Price spread does not cover loss + cycle wear cost - - # Second pass: actually assign recommendations - charged = charged_so_far - grid_assigned = 0.0 - for s in grid_candidates: - if charged >= needed: - break - available_solar = ( - abs(s.estimated_net_consumption_kwh) - if s.estimated_net_consumption_kwh < 0 - else 0 - ) - grid_needed = min( - max_charge_per_interval - available_solar, - needed - charged - available_solar, - ) - energy = available_solar + grid_needed - if energy > 0: - s.recommendation = Recommendations.BatteriesChargeGrid.value - s.batteries_charged_kwh = round(energy, 3) - charged += energy - grid_assigned += energy - - return grid_assigned diff --git a/custom_components/hsem/planner/discharge_scheduler.py b/custom_components/hsem/planner/discharge_scheduler.py index 3ed0aa9a..87c368ff 100644 --- a/custom_components/hsem/planner/discharge_scheduler.py +++ b/custom_components/hsem/planner/discharge_scheduler.py @@ -1,7 +1,7 @@ """Discharge scheduling for the HSEM planner. Single responsibility: decide *when* to discharge the battery -based on discharge-window schedules, price signals, and seasonal strategy. +based on price signals, excess-export gating, and seasonal strategy. All functions are pure — no I/O, no Home Assistant imports. They mutate the :class:`PlannedSlot` list passed in and return nothing (or a scalar result). @@ -10,9 +10,8 @@ from __future__ import annotations from collections import defaultdict -from datetime import date, datetime, timedelta +from datetime import date, datetime -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput from custom_components.hsem.models.planned_slot import PlannedSlot from custom_components.hsem.utils.datetime_utils import as_tz from custom_components.hsem.utils.logger import log_planner @@ -21,131 +20,6 @@ DISCHARGE_RECS as _DISCHARGE_RECS, Recommendations, ) -from custom_components.hsem.utils.time_windows import next_window_start_dt - -# --------------------------------------------------------------------------- -# Discharge schedule detection -# --------------------------------------------------------------------------- - - -def apply_discharge_schedules( - slots: list[PlannedSlot], - battery_schedules: list[BatteryScheduleInput], - now: datetime, -) -> None: - """Mark slots inside each enabled discharge window as ``BatteriesDischargeMode``. - - Also populates ``_needed_capacity`` and ``_avg_import_price`` as dynamic - attributes on each :class:`BatteryScheduleInput` so the charge planner can - read them without an extra pass. - - Args: - slots: Mutable list of planned slots. - battery_schedules: Schedule configurations to evaluate. - now: Timezone-aware current datetime. - """ - log_planner( - "debug", - "[disch] apply_discharge_schedules schedules=%d now=%s", - len(battery_schedules), - now.isoformat(), - ) - for sched in battery_schedules: - if not sched.enabled: - continue - - # Determine the last slot end in the planning horizon so we know how - # many days to cover. We apply the discharge window once per calendar - # day that falls within [now, horizon_end]. - future_slots = [s for s in slots if as_tz(s.end, now.tzinfo) > now] - if not future_slots: - continue - horizon_end = as_tz(future_slots[-1].end, now.tzinfo) - - # Collect all occurrences of this schedule window within the horizon. - # Start from the first upcoming occurrence and advance one day at a time. - # Each occurrence is stored so apply_charge_schedules can schedule - # pre-charge independently per window occurrence. - window_start_abs = next_window_start_dt(now, sched.start) - occurrences: list[tuple[datetime, datetime, float, float]] = [] - sched_total_net = 0.0 - - while window_start_abs < horizon_end: - if sched.end > sched.start: - window_end_abs = datetime.combine( - window_start_abs.date(), sched.end - ).replace(tzinfo=now.tzinfo) - else: - # Cross-midnight discharge window - window_end_abs = datetime.combine( - (window_start_abs + timedelta(days=1)).date(), sched.end - ).replace(tzinfo=now.tzinfo) - - for slot in slots: - slot_start = as_tz(slot.start, now.tzinfo) - slot_end = as_tz(slot.end, now.tzinfo) - if slot_end <= now: - continue - if slot_start >= window_start_abs and slot_end <= window_end_abs: - slot.recommendation = Recommendations.BatteriesDischargeMode.value - - # Capture per-occurrence capacity and avg price. - # - # Battery-relevant net consumption excludes EV planned load: - # battery_net = avg_house_consumption - pv - # - # When base_load_includes_ev=False, estimated_net_consumption includes - # ev_planned_load_kwh. The EV draws directly from grid/PV, not from - # the home battery, so including it in occ_needed would over-inflate - # the pre-charge target and cause the price-spread guard in - # _apply_grid_charge to reject otherwise profitable charge slots. - # - # ev_accounted_load_kwh is already captured in avg_house_consumption - # (base_load_includes_ev=True), so no correction is needed for that - # case — the battery must cover it. - occ_net = 0.0 - occ_prices: list[float] = [] - for s in slots: - s_start = as_tz(s.start, now.tzinfo) - s_end = as_tz(s.end, now.tzinfo) - if ( - s.recommendation == Recommendations.BatteriesDischargeMode.value - and s_start >= window_start_abs - and s_end <= window_end_abs - ): - # Subtract extra EV load (injected, base_load_includes_ev=False) - # so the battery only targets house coverage. - battery_net = ( - s.estimated_net_consumption_kwh - s.ev_planned_load_kwh - ) - occ_net += battery_net - occ_prices.append(s.price.import_price) - - occ_needed = max(occ_net, 0.0) - occ_avg_price = ( - round(sum(occ_prices) / len(occ_prices), 3) if occ_prices else 0.0 - ) - # Store: (window_start, window_end, needed_kwh, avg_discharge_price) - occurrences.append( - (window_start_abs, window_end_abs, occ_needed, occ_avg_price) - ) - sched_total_net += occ_net - - # Advance to the same window start on the following calendar day - window_start_abs += timedelta(days=1) - - # _occurrences: per-day data consumed by apply_charge_schedules - sched._occurrences = occurrences - # _needed_capacity: aggregate across all occurrences (used by coordinator) - sched._needed_capacity = max(sched_total_net, 0.0) - # _avg_import_price: average across all occurrences - all_occ_prices = [avg for _, _, _, avg in occurrences if avg > 0] - sched._avg_import_price = ( - round(sum(all_occ_prices) / len(all_occ_prices), 3) - if all_occ_prices - else 0.0 - ) - # --------------------------------------------------------------------------- # Excess export @@ -334,9 +208,9 @@ def concentrate_discharge_on_expensive_slots( ) -> None: """Clear cheap discharge slots the battery cannot fully serve, per calendar day. - ``apply_discharge_schedules`` and ``apply_optimization_strategy`` mark - *every* slot in a discharge window as ``BatteriesDischargeMode``, but - the battery can only cover a fraction of them. Without concentration + ``apply_optimization_strategy`` marks *every* slot in a discharge window + as ``BatteriesDischargeMode``, but the battery can only cover a fraction + of them. Without concentration the SoC simulation greedily discharges in the *first* (cheapest) slots and runs out before the most expensive ones. diff --git a/custom_components/hsem/planner/engine_core.py b/custom_components/hsem/planner/engine_core.py index c670da47..49548d90 100644 --- a/custom_components/hsem/planner/engine_core.py +++ b/custom_components/hsem/planner/engine_core.py @@ -24,16 +24,11 @@ replacement_price_from_next_discharge, select_best_candidate, ) -from custom_components.hsem.planner.charging.arbitrage_charge import ( - apply_arbitrage_grid_charge, -) from custom_components.hsem.planner.charging.opportunistic_charge import ( apply_opportunistic_charge, ) -from custom_components.hsem.planner.charging.pre_charge import apply_charge_schedules from custom_components.hsem.planner.cost_function import CostWeights, score_plan from custom_components.hsem.planner.discharge_scheduler import ( - apply_discharge_schedules, apply_excess_export, apply_optimization_strategy, calculate_required_battery_until_solar, @@ -77,7 +72,6 @@ from custom_components.hsem.utils.recommendations import Recommendations from custom_components.hsem.utils.units import ( max_energy_per_slot_kwh, - roundtrip_loss_pct, slot_duration_hours, ) @@ -131,32 +125,12 @@ def _schedule_slots( the caller so heuristic and MILP paths use the same value. """ mark_time_passed(slots, now) - apply_discharge_schedules(slots, inp.battery_schedules, now) - log_planner( - "debug", - "[core] _schedule_slots pass=discharge_schedules slots=%d", - len(slots), - ) cd = clamp_efficiency(inp.battery_charge_efficiency_pct) - rlp = roundtrip_loss_pct( - inp.battery_charge_efficiency_pct, - inp.battery_discharge_efficiency_pct, - ) mcphi = max_energy_per_slot_kwh( inp.battery_max_charge_power_w, inp.interval_minutes, efficiency_fraction=cd, ) - apply_charge_schedules( - slots, - inp.battery_schedules, - now, - mcphi, - current_kwh=current_kwh, - usable_kwh=usable_kwh, - cycle_cost_per_kwh=effective_cycle_cost, - recommended_threshold=rt, - ) apply_opportunistic_charge( slots, now, @@ -166,17 +140,6 @@ def _schedule_slots( rt, cycle_cost_per_kwh=effective_cycle_cost, ) - apply_arbitrage_grid_charge( - slots, - inp.battery_schedules, - now, - current_kwh, - usable_kwh, - mcphi, - conversion_loss_pct=rlp, - cycle_cost_per_kwh=effective_cycle_cost, - recommended_threshold=rt, - ) mcps = mcphi # same formula — max charge energy per slot mdps: float | None = None if inp.battery_max_discharge_power_w is not None: diff --git a/custom_components/hsem/switch.py b/custom_components/hsem/switch.py index 463583e6..eaab582f 100644 --- a/custom_components/hsem/switch.py +++ b/custom_components/hsem/switch.py @@ -1,7 +1,7 @@ """Switch platform for the HSEM integration. Exposes :class:`SwitchEntity` instances that let users toggle integration -settings (read-only mode, verbose logging, discharge schedules, etc.) without +settings (read-only mode, verbose logging, EV charging, etc.) without leaving the entity page. """ @@ -15,9 +15,6 @@ from custom_components.hsem.custom_switches.switch import HSEMSwitch from custom_components.hsem.utils.misc import get_config_value from custom_components.hsem.utils.sensornames.controls import ( - get_batteries_schedule_1_switch_key, - get_batteries_schedule_2_switch_key, - get_batteries_schedule_3_switch_key, get_dynamic_discharge_floor_switch_key, get_extended_attributes_switch_key, get_read_only_switch_key, @@ -58,21 +55,6 @@ icon=_ICON_TOGGLE, translation_key="verbose_logging", ), - HSEMSwitchEntityDescription( - key=get_batteries_schedule_1_switch_key(), - icon=_ICON_TOGGLE, - translation_key="batteries_schedule_1", - ), - HSEMSwitchEntityDescription( - key=get_batteries_schedule_2_switch_key(), - icon=_ICON_TOGGLE, - translation_key="batteries_schedule_2", - ), - HSEMSwitchEntityDescription( - key=get_batteries_schedule_3_switch_key(), - icon=_ICON_TOGGLE, - translation_key="batteries_schedule_3", - ), HSEMSwitchEntityDescription( key=get_ev_force_discharge_switch_key(), icon=_ICON_TOGGLE, diff --git a/custom_components/hsem/time.py b/custom_components/hsem/time.py index 5d3dc5da..606c4668 100644 --- a/custom_components/hsem/time.py +++ b/custom_components/hsem/time.py @@ -1,7 +1,7 @@ """Time platform for the HSEM integration. -Exposes :class:`TimeEntity` instances for each battery discharge schedule -start and end time, allowing users to set them from the entity page. +Exposes :class:`TimeEntity` instances for EV charge deadlines, allowing +users to set them from the entity page. """ from homeassistant.config_entries import ConfigEntry @@ -11,14 +11,6 @@ from custom_components.hsem.custom_times.description import HSEMTimeEntityDescription from custom_components.hsem.custom_times.time import HSEMTimeEntity from custom_components.hsem.utils.misc import get_config_value -from custom_components.hsem.utils.sensornames.controls import ( - get_schedule_1_end_time_key, - get_schedule_1_start_time_key, - get_schedule_2_end_time_key, - get_schedule_2_start_time_key, - get_schedule_3_end_time_key, - get_schedule_3_start_time_key, -) from custom_components.hsem.utils.sensornames.ev import ( get_ev_deadline_time_key, get_ev_second_deadline_time_key, @@ -30,36 +22,6 @@ # that unique_ids and entity_ids are defined in one place. Display names # come from translations via translation_key. TIME_DESCRIPTIONS: tuple[HSEMTimeEntityDescription, ...] = ( - HSEMTimeEntityDescription( - key=get_schedule_1_start_time_key(), - icon=_ICON_CLOCK, - translation_key="schedule_1_start", - ), - HSEMTimeEntityDescription( - key=get_schedule_1_end_time_key(), - icon=_ICON_CLOCK, - translation_key="schedule_1_end", - ), - HSEMTimeEntityDescription( - key=get_schedule_2_start_time_key(), - icon=_ICON_CLOCK, - translation_key="schedule_2_start", - ), - HSEMTimeEntityDescription( - key=get_schedule_2_end_time_key(), - icon=_ICON_CLOCK, - translation_key="schedule_2_end", - ), - HSEMTimeEntityDescription( - key=get_schedule_3_start_time_key(), - icon=_ICON_CLOCK, - translation_key="schedule_3_start", - ), - HSEMTimeEntityDescription( - key=get_schedule_3_end_time_key(), - icon=_ICON_CLOCK, - translation_key="schedule_3_end", - ), HSEMTimeEntityDescription( key=get_ev_deadline_time_key(), icon=_ICON_CLOCK, diff --git a/custom_components/hsem/translations/da.json b/custom_components/hsem/translations/da.json index e97b1855..62739dd9 100644 --- a/custom_components/hsem/translations/da.json +++ b/custom_components/hsem/translations/da.json @@ -16,14 +16,11 @@ "invalid_power_value": "Ugyldig effektværdi - indtast et tal.", "invalid_price_value": "Ugyldig prisværdi - indtast et tal.", "invalid_sensor": "Ugyldig input-sensor. Vælg en gyldig sensor.", - "invalid_time_format": "Ugyldigt tidsformat - forventet HH:MM:SS.", "months_winter_empty": "Vintersæsonen skal have mindst én måned.", "only_one_entry_allowed": "Kun én konfiguration af HSEM er tilladt.", "power_out_of_range": "Effektværdien er uden for det tilladte område.", "price_out_of_range": "Prisværdien er uden for det tilladte område.", - "required": "Dette felt er påkrævet.", - "start_time_after_end_time": "Starttidspunkt skal være før sluttidspunkt.", - "start_time_equals_end_time": "Starttidspunkt og sluttidspunkt kan ikke være ens - dette skaber et nul-længdevindue. Deaktiver tidsplanen i stedet." + "required": "Dette felt er påkrævet." }, "step": { "batteries_excess_export": { @@ -88,32 +85,6 @@ "description": "Konfigurer valgfri planlagt opladningsbelastningsintegration for den anden EV. Vises kun, når en anden EV-lader er aktiveret.", "title": "EV 2-planlagt belastningsintegration" }, - "batteries_schedules": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Aktiver batteritidsplan 1", - "hsem_batteries_enable_batteries_schedule_1_end": "Batteritidsplan 1 sluttid", - "hsem_batteries_enable_batteries_schedule_1_start": "Batteritidsplan 1 starttid", - "hsem_batteries_enable_batteries_schedule_2": "Aktiver batteritidsplan 2", - "hsem_batteries_enable_batteries_schedule_2_end": "Batteritidsplan 2 sluttid", - "hsem_batteries_enable_batteries_schedule_2_start": "Batteritidsplan 2 starttid", - "hsem_batteries_enable_batteries_schedule_3": "Aktiver batteritidsplan 3", - "hsem_batteries_enable_batteries_schedule_3_end": "Batteritidsplan 3 sluttid", - "hsem_batteries_enable_batteries_schedule_3_start": "Batteritidsplan 3 starttid" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Aktiver eller deaktiver den første batteri-afladningstidsplan.", - "hsem_batteries_enable_batteries_schedule_1_end": "Angiv sluttidspunktet for den første batteri-afladningstidsplan.", - "hsem_batteries_enable_batteries_schedule_1_start": "Angiv starttidspunktet for den første batteri-afladningstidsplan.", - "hsem_batteries_enable_batteries_schedule_2": "Aktiver eller deaktiver den anden batteri-afladningstidsplan.", - "hsem_batteries_enable_batteries_schedule_2_end": "Angiv sluttidspunktet for den anden batteri-afladningstidsplan.", - "hsem_batteries_enable_batteries_schedule_2_start": "Angiv starttidspunktet for den anden batteri-afladningstidsplan.", - "hsem_batteries_enable_batteries_schedule_3": "Aktiver eller deaktiver den tredje batteri-afladningstidsplan.", - "hsem_batteries_enable_batteries_schedule_3_end": "Angiv sluttidspunktet for den tredje batteri-afladningstidsplan.", - "hsem_batteries_enable_batteries_schedule_3_start": "Angiv starttidspunktet for den tredje batteri-afladningstidsplan." - }, - "description": "Konfigurer op til tre batteri-afladningstidsplaner, hver med til/fra-knap, starttid og sluttid.", - "title": "Batteri-afladningstidsplaner" - }, "prices": { "data": { "hsem_import_electricity_price_sensor": "Import-elprissensor", @@ -360,48 +331,6 @@ "description": "Konfigurer vægtede værdier for husets energiforbrug til at estimere dit gennemsnitlige strømforbrug.", "title": "Vægtede værdier" }, - "batteries_schedule_1": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Aktiver Batteri Plan 1", - "hsem_batteries_enable_batteries_schedule_1_start": "Starttid", - "hsem_batteries_enable_batteries_schedule_1_end": "Sluttid" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Aktiver en tvungen opladningsplan for batteriet.", - "hsem_batteries_enable_batteries_schedule_1_start": "Hvornår skal opladningen starte?", - "hsem_batteries_enable_batteries_schedule_1_end": "Hvornår skal opladningen slutte?" - }, - "description": "Konfigurer den første batteriopladeplan.", - "title": "Batteri Plan 1" - }, - "batteries_schedule_2": { - "data": { - "hsem_batteries_enable_batteries_schedule_2": "Aktiver Batteri Plan 2", - "hsem_batteries_enable_batteries_schedule_2_start": "Starttid", - "hsem_batteries_enable_batteries_schedule_2_end": "Sluttid" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_2": "Aktiver en tvungen opladningsplan for batteriet.", - "hsem_batteries_enable_batteries_schedule_2_start": "Hvornår skal opladningen starte?", - "hsem_batteries_enable_batteries_schedule_2_end": "Hvornår skal opladningen slutte?" - }, - "description": "Konfigurer den anden batteriopladeplan.", - "title": "Batteri Plan 2" - }, - "batteries_schedule_3": { - "data": { - "hsem_batteries_enable_batteries_schedule_3": "Aktiver Batteri Plan 3", - "hsem_batteries_enable_batteries_schedule_3_start": "Starttid", - "hsem_batteries_enable_batteries_schedule_3_end": "Sluttid" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_3": "Aktiver en tvungen opladningsplan for batteriet.", - "hsem_batteries_enable_batteries_schedule_3_start": "Hvornår skal opladningen starte?", - "hsem_batteries_enable_batteries_schedule_3_end": "Hvornår skal opladningen slutte?" - }, - "description": "Konfigurer den tredje batteriopladeplan.", - "title": "Batteri Plan 3" - }, "energy_and_ml": { "data": { "hsem_grid_import_energy_entity": "Netimport Energi Sensor", @@ -509,14 +438,11 @@ "invalid_power_value": "Ugyldig effektværdi - indtast et tal.", "invalid_price_value": "Ugyldig prisværdi - indtast et tal.", "invalid_sensor": "Ugyldig input-sensor. Vælg en gyldig sensor.", - "invalid_time_format": "Ugyldigt tidsformat - forventet HH:MM:SS.", "months_winter_empty": "Vintersæsonen skal have mindst én måned.", "only_one_entry_allowed": "Kun én konfiguration af HSEM er tilladt.", "power_out_of_range": "Effektværdien er uden for det tilladte område.", "price_out_of_range": "Prisværdien er uden for det tilladte område.", - "required": "Dette felt er påkrævet.", - "start_time_after_end_time": "Starttidspunkt skal være før sluttidspunkt.", - "start_time_equals_end_time": "Starttidspunkt og sluttidspunkt kan ikke være ens - dette skaber et nul-længdevindue. Deaktiver tidsplanen i stedet." + "required": "Dette felt er påkrævet." }, "step": { "batteries_excess_export": { @@ -533,32 +459,6 @@ "description": "Configure excess battery export settings to automatically sell excess energy to the grid when economically beneficial.", "title": "Excess Battery Export" }, - "batteries_schedules": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Enable Battery Schedule 1", - "hsem_batteries_enable_batteries_schedule_1_end": "Battery Schedule 1 End Time", - "hsem_batteries_enable_batteries_schedule_1_start": "Battery Schedule 1 Start Time", - "hsem_batteries_enable_batteries_schedule_2": "Enable Battery Schedule 2", - "hsem_batteries_enable_batteries_schedule_2_end": "Battery Schedule 2 End Time", - "hsem_batteries_enable_batteries_schedule_2_start": "Battery Schedule 2 Start Time", - "hsem_batteries_enable_batteries_schedule_3": "Enable Battery Schedule 3", - "hsem_batteries_enable_batteries_schedule_3_end": "Battery Schedule 3 End Time", - "hsem_batteries_enable_batteries_schedule_3_start": "Battery Schedule 3 Start Time" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Enable or disable the first battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_1_end": "Specify the end time for the first battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_1_start": "Specify the start time for the first battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_2": "Enable or disable the second battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_2_end": "Specify the end time for the second battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_2_start": "Specify the start time for the second battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_3": "Enable or disable the third battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_3_end": "Specify the end time for the third battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_3_start": "Specify the start time for the third battery discharge schedule." - }, - "description": "Configure up to three battery discharge schedule windows, each with enable/disable toggle, start time, and end time. Schedules define when the battery may discharge to cover peak consumption or export to the grid.", - "title": "Battery Discharge Schedules" - }, "ev_planned_load": { "data": { "hsem_ev_planned_load_enabled": "Aktiver EV-planlagt belastningsintegration", @@ -805,48 +705,6 @@ "description": "Configure the weighted values for house consumption energy to estimate your average power usage. The system calculates the average consumption for each hour over 1, 3, 7, and 14 days. These averages are then weighted to provide a combined, weighted estimate. This approach helps to smooth out anomalies, such as unusually high consumption on a single day due to low prices, and provides a reliable prediction of your expected power usage in specific time intervals (e.g., between 16:00 and 17:00).", "title": "Weighted Values" }, - "batteries_schedule_1": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Aktiver Batteri Plan 1", - "hsem_batteries_enable_batteries_schedule_1_start": "Starttid", - "hsem_batteries_enable_batteries_schedule_1_end": "Sluttid" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Aktiver en tvungen opladningsplan for batteriet.", - "hsem_batteries_enable_batteries_schedule_1_start": "Hvornår skal opladningen starte?", - "hsem_batteries_enable_batteries_schedule_1_end": "Hvornår skal opladningen slutte?" - }, - "description": "Konfigurer den første batteriopladeplan.", - "title": "Batteri Plan 1" - }, - "batteries_schedule_2": { - "data": { - "hsem_batteries_enable_batteries_schedule_2": "Aktiver Batteri Plan 2", - "hsem_batteries_enable_batteries_schedule_2_start": "Starttid", - "hsem_batteries_enable_batteries_schedule_2_end": "Sluttid" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_2": "Aktiver en tvungen opladningsplan for batteriet.", - "hsem_batteries_enable_batteries_schedule_2_start": "Hvornår skal opladningen starte?", - "hsem_batteries_enable_batteries_schedule_2_end": "Hvornår skal opladningen slutte?" - }, - "description": "Konfigurer den anden batteriopladeplan.", - "title": "Batteri Plan 2" - }, - "batteries_schedule_3": { - "data": { - "hsem_batteries_enable_batteries_schedule_3": "Aktiver Batteri Plan 3", - "hsem_batteries_enable_batteries_schedule_3_start": "Starttid", - "hsem_batteries_enable_batteries_schedule_3_end": "Sluttid" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_3": "Aktiver en tvungen opladningsplan for batteriet.", - "hsem_batteries_enable_batteries_schedule_3_start": "Hvornår skal opladningen starte?", - "hsem_batteries_enable_batteries_schedule_3_end": "Hvornår skal opladningen slutte?" - }, - "description": "Konfigurer den tredje batteriopladeplan.", - "title": "Batteri Plan 3" - }, "battery_economics": { "data": { "hsem_batteries_purchase_price": "Batteri Købspris", @@ -985,15 +843,6 @@ "verbose_logging": { "name": "Detaljeret logning" }, - "batteries_schedule_1": { - "name": "Batteriplan 1" - }, - "batteries_schedule_2": { - "name": "Batteriplan 2" - }, - "batteries_schedule_3": { - "name": "Batteriplan 3" - }, "ev_force_discharge": { "name": "EV Tving maks. afladningseffekt" }, @@ -1066,24 +915,6 @@ } }, "time": { - "schedule_1_start": { - "name": "Batteri Afladningsplan 1 Start" - }, - "schedule_1_end": { - "name": "Batteri Afladningsplan 1 Slut" - }, - "schedule_2_start": { - "name": "Batteri Afladningsplan 2 Start" - }, - "schedule_2_end": { - "name": "Batteri Afladningsplan 2 Slut" - }, - "schedule_3_start": { - "name": "Batteri Afladningsplan 3 Start" - }, - "schedule_3_end": { - "name": "Batteri Afladningsplan 3 Slut" - }, "ev_deadline": { "name": "EV Opladningsfrist" }, diff --git a/custom_components/hsem/translations/en.json b/custom_components/hsem/translations/en.json index c12ae314..44974bed 100644 --- a/custom_components/hsem/translations/en.json +++ b/custom_components/hsem/translations/en.json @@ -16,14 +16,11 @@ "invalid_power_value": "Invalid power value — please enter a number.", "invalid_price_value": "Invalid price value — please enter a number.", "invalid_sensor": "Invalid input sensor. Please choose a valid sensor.", - "invalid_time_format": "Invalid time format — expected HH:MM:SS.", "months_winter_empty": "Winter season must have at least one month.", "only_one_entry_allowed": "Only one configuration of HSEM is allowed.", "power_out_of_range": "Power value is outside the allowed range.", "price_out_of_range": "Price value is outside the allowed range.", "required": "This field is required.", - "start_time_after_end_time": "Start time must be before end time.", - "start_time_equals_end_time": "Start time and end time cannot be the same — this creates a zero-length window. Disable the schedule instead.", "invalid_wait_mode_behavior": "Invalid wait mode behaviour. Choose Strict wait or Self-consumption with reserve.", "port_conflict": "The second OCPP port must differ from the first OCPP port." }, @@ -120,48 +117,6 @@ "description": "Configure the embedded OCPP 1.6 server for LAN-only EV charger control. When enabled, HSEM sends SetChargingProfile commands directly to your EV charger based on the charging plan.", "title": "OCPP Server" }, - "batteries_schedule_1": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Enable Battery Schedule 1", - "hsem_batteries_enable_batteries_schedule_1_end": "Battery Schedule 1 End Time", - "hsem_batteries_enable_batteries_schedule_1_start": "Battery Schedule 1 Start Time" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Enable or disable the first battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_1_end": "Specify the end time for the first battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_1_start": "Specify the start time for the first battery discharge schedule." - }, - "description": "Configure battery discharge schedule 1, including its activation time.", - "title": "Battery Discharge Schedule 1" - }, - "batteries_schedule_2": { - "data": { - "hsem_batteries_enable_batteries_schedule_2": "Enable Battery Schedule 2", - "hsem_batteries_enable_batteries_schedule_2_end": "Battery Schedule 2 End Time", - "hsem_batteries_enable_batteries_schedule_2_start": "Battery Schedule 2 Start Time" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_2": "Enable or disable the second battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_2_end": "Specify the end time for the second battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_2_start": "Specify the start time for the second battery discharge schedule." - }, - "description": "Configure battery discharge schedule 2, including its activation time.", - "title": "Battery Discharge Schedule 2" - }, - "batteries_schedule_3": { - "data": { - "hsem_batteries_enable_batteries_schedule_3": "Enable Battery Schedule 3", - "hsem_batteries_enable_batteries_schedule_3_end": "Battery Schedule 3 End Time", - "hsem_batteries_enable_batteries_schedule_3_start": "Battery Schedule 3 Start Time" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_3": "Enable or disable the third battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_3_end": "Specify the end time for the third battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_3_start": "Specify the start time for the third battery discharge schedule." - }, - "description": "Configure battery discharge schedule 3, including its activation time.", - "title": "Battery Discharge Schedule 3" - }, "prices": { "data": { "hsem_import_electricity_price_sensor": "Import Electricity Price Sensor", @@ -340,32 +295,6 @@ "description": "Update the settings for HSEM", "title": "Update Huawei Solar Energy Management" }, - "batteries_schedules": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Enable Battery Schedule 1", - "hsem_batteries_enable_batteries_schedule_1_end": "Battery Schedule 1 End Time", - "hsem_batteries_enable_batteries_schedule_1_start": "Battery Schedule 1 Start Time", - "hsem_batteries_enable_batteries_schedule_2": "Enable Battery Schedule 2", - "hsem_batteries_enable_batteries_schedule_2_end": "Battery Schedule 2 End Time", - "hsem_batteries_enable_batteries_schedule_2_start": "Battery Schedule 2 Start Time", - "hsem_batteries_enable_batteries_schedule_3": "Enable Battery Schedule 3", - "hsem_batteries_enable_batteries_schedule_3_end": "Battery Schedule 3 End Time", - "hsem_batteries_enable_batteries_schedule_3_start": "Battery Schedule 3 Start Time" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Enable or disable the first battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_1_end": "Specify the end time for the first battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_1_start": "Specify the start time for the first battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_2": "Enable or disable the second battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_2_end": "Specify the end time for the second battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_2_start": "Specify the start time for the second battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_3": "Enable or disable the third battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_3_end": "Specify the end time for the third battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_3_start": "Specify the start time for the third battery discharge schedule." - }, - "description": "Configure up to three battery discharge schedule windows, each with enable/disable toggle, start time, and end time. Schedules define when the battery may discharge to cover peak consumption or export to the grid.", - "title": "Battery Discharge Schedules" - }, "months": { "data": { "hsem_months_winter": "Months considered as Winter/Spring" @@ -543,14 +472,11 @@ "invalid_power_value": "Invalid power value — please enter a number.", "invalid_price_value": "Invalid price value — please enter a number.", "invalid_sensor": "Invalid input sensor. Please choose a valid sensor.", - "invalid_time_format": "Invalid time format — expected HH:MM:SS.", "months_winter_empty": "Winter season must have at least one month.", "only_one_entry_allowed": "Only one configuration of HSEM is allowed.", "power_out_of_range": "Power value is outside the allowed range.", "price_out_of_range": "Price value is outside the allowed range.", "required": "This field is required.", - "start_time_after_end_time": "Start time must be before end time.", - "start_time_equals_end_time": "Start time and end time cannot be the same — this creates a zero-length window. Disable the schedule instead.", "invalid_wait_mode_behavior": "Invalid wait mode behaviour. Choose Strict wait or Self-consumption with reserve.", "port_conflict": "The second OCPP port must differ from the first OCPP port." }, @@ -647,48 +573,6 @@ "description": "Configure the embedded OCPP 1.6 server for LAN-only EV charger control. When enabled, HSEM sends SetChargingProfile commands directly to your EV charger based on the charging plan.", "title": "OCPP Server" }, - "batteries_schedule_1": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Enable Battery Schedule 1", - "hsem_batteries_enable_batteries_schedule_1_end": "Battery Schedule 1 End Time", - "hsem_batteries_enable_batteries_schedule_1_start": "Battery Schedule 1 Start Time" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Enable or disable the first battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_1_end": "Specify the end time for the first battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_1_start": "Specify the start time for the first battery discharge schedule." - }, - "description": "Configure battery discharge schedule 1, including its activation time.", - "title": "Battery Discharge Schedule 1" - }, - "batteries_schedule_2": { - "data": { - "hsem_batteries_enable_batteries_schedule_2": "Enable Battery Schedule 2", - "hsem_batteries_enable_batteries_schedule_2_end": "Battery Schedule 2 End Time", - "hsem_batteries_enable_batteries_schedule_2_start": "Battery Schedule 2 Start Time" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_2": "Enable or disable the second battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_2_end": "Specify the end time for the second battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_2_start": "Specify the start time for the second battery discharge schedule." - }, - "description": "Configure battery discharge schedule 2, including its activation time.", - "title": "Battery Discharge Schedule 2" - }, - "batteries_schedule_3": { - "data": { - "hsem_batteries_enable_batteries_schedule_3": "Enable Battery Schedule 3", - "hsem_batteries_enable_batteries_schedule_3_end": "Battery Schedule 3 End Time", - "hsem_batteries_enable_batteries_schedule_3_start": "Battery Schedule 3 Start Time" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_3": "Enable or disable the third battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_3_end": "Specify the end time for the third battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_3_start": "Specify the start time for the third battery discharge schedule." - }, - "description": "Configure battery discharge schedule 3, including its activation time.", - "title": "Battery Discharge Schedule 3" - }, "prices": { "data": { "hsem_import_electricity_price_sensor": "Import Electricity Price Sensor", @@ -867,32 +751,6 @@ "description": "Update the settings for HSEM", "title": "Update Huawei Solar Energy Management" }, - "batteries_schedules": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Enable Battery Schedule 1", - "hsem_batteries_enable_batteries_schedule_1_end": "Battery Schedule 1 End Time", - "hsem_batteries_enable_batteries_schedule_1_start": "Battery Schedule 1 Start Time", - "hsem_batteries_enable_batteries_schedule_2": "Enable Battery Schedule 2", - "hsem_batteries_enable_batteries_schedule_2_end": "Battery Schedule 2 End Time", - "hsem_batteries_enable_batteries_schedule_2_start": "Battery Schedule 2 Start Time", - "hsem_batteries_enable_batteries_schedule_3": "Enable Battery Schedule 3", - "hsem_batteries_enable_batteries_schedule_3_end": "Battery Schedule 3 End Time", - "hsem_batteries_enable_batteries_schedule_3_start": "Battery Schedule 3 Start Time" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Enable or disable the first battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_1_end": "Specify the end time for the first battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_1_start": "Specify the start time for the first battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_2": "Enable or disable the second battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_2_end": "Specify the end time for the second battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_2_start": "Specify the start time for the second battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_3": "Enable or disable the third battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_3_end": "Specify the end time for the third battery discharge schedule.", - "hsem_batteries_enable_batteries_schedule_3_start": "Specify the start time for the third battery discharge schedule." - }, - "description": "Configure up to three battery discharge schedule windows, each with enable/disable toggle, start time, and end time. Schedules define when the battery may discharge to cover peak consumption or export to the grid.", - "title": "Battery Discharge Schedules" - }, "months": { "data": { "hsem_months_winter": "Months considered as Winter/Spring" @@ -1212,15 +1070,6 @@ "verbose_logging": { "name": "Verbose Logging" }, - "batteries_schedule_1": { - "name": "Batteries Schedule 1" - }, - "batteries_schedule_2": { - "name": "Batteries Schedule 2" - }, - "batteries_schedule_3": { - "name": "Batteries Schedule 3" - }, "ev_force_discharge": { "name": "EV Force Max Discharge Power" }, @@ -1272,24 +1121,6 @@ } }, "time": { - "schedule_1_start": { - "name": "Batteries Discharge Schedule 1 Start" - }, - "schedule_1_end": { - "name": "Batteries Discharge Schedule 1 End" - }, - "schedule_2_start": { - "name": "Batteries Discharge Schedule 2 Start" - }, - "schedule_2_end": { - "name": "Batteries Discharge Schedule 2 End" - }, - "schedule_3_start": { - "name": "Batteries Discharge Schedule 3 Start" - }, - "schedule_3_end": { - "name": "Batteries Discharge Schedule 3 End" - }, "ev_deadline": { "name": "EV Charge Deadline" }, diff --git a/custom_components/hsem/utils/config_validator.py b/custom_components/hsem/utils/config_validator.py index 3778d068..d4cedba1 100644 --- a/custom_components/hsem/utils/config_validator.py +++ b/custom_components/hsem/utils/config_validator.py @@ -18,7 +18,6 @@ """ import re -from datetime import datetime as _datetime from typing import Any from custom_components.hsem.utils.conversion import convert_months_to_int @@ -35,9 +34,6 @@ # contain only lowercase letters, digits, and underscores. _ENTITY_ID_RE = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$") -# HA time strings from the "time" selector always arrive as "HH:MM:SS". -_TIME_FORMAT = "%H:%M:%S" - # --------------------------------------------------------------------------- # Entity ID validation (format only — no HA lookup) @@ -230,69 +226,6 @@ def validate_months( return errors -# --------------------------------------------------------------------------- -# Time window validation -# --------------------------------------------------------------------------- - - -def _parse_time_str(value: str) -> _datetime | None: - """Parse a ``HH:MM:SS`` string into a :class:`datetime` or return ``None``.""" - try: - return _datetime.strptime(value, _TIME_FORMAT) - except ValueError, TypeError: - return None - - -def validate_time_window( - user_input: dict, - enabled_field: str, - start_field: str, - end_field: str, -) -> dict[str, str]: - """Validate a single battery-schedule time window. - - Rules: - - * When the schedule is disabled (``enabled_field`` is ``False``), the time - values are not checked. - * Start and end must parse as ``HH:MM:SS``. - * Start and end must not be identical (zero-length window). - * Cross-midnight windows (start > end) are explicitly **allowed**. - - Args: - user_input: Dict from the config/options form. - enabled_field: Field name whose boolean value gates further checks. - start_field: Field name for the window start time string. - end_field: Field name for the window end time string. - - Returns: - Dict mapping field names to translation error keys. - """ - errors: dict[str, str] = {} - - enabled = user_input.get(enabled_field, False) - if not enabled: - return errors - - start_raw = user_input.get(start_field) - end_raw = user_input.get(end_field) - - start_dt = _parse_time_str(start_raw) if start_raw else None - end_dt = _parse_time_str(end_raw) if end_raw else None - - if start_dt is None: - errors[start_field] = "invalid_time_format" - return errors - if end_dt is None: - errors[end_field] = "invalid_time_format" - return errors - - if start_dt == end_dt: - errors["base"] = "start_time_equals_end_time" - - return errors - - # --------------------------------------------------------------------------- # Power and energy limit validation # --------------------------------------------------------------------------- diff --git a/custom_components/hsem/utils/diagnostics.py b/custom_components/hsem/utils/diagnostics.py index f77eba7d..05fbfa67 100644 --- a/custom_components/hsem/utils/diagnostics.py +++ b/custom_components/hsem/utils/diagnostics.py @@ -35,13 +35,12 @@ import json import re from dataclasses import asdict -from datetime import date, datetime, time +from datetime import date, datetime from typing import Any, cast import homeassistant.util.dt as dt_util from homeassistant.const import STATE_UNKNOWN -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput from custom_components.hsem.models.hourly_consumption_average import ( HourlyConsumptionAverage, ) @@ -168,11 +167,9 @@ def _serialise_value(value: Any) -> Any: def _planner_input_to_dict(inp: PlannerInput) -> dict[str, Any]: """Convert a :class:`PlannerInput` to a JSON-safe dictionary. - :class:`datetime.time` values inside ``battery_schedules`` are serialised - to ``"HH:MM:SS"`` strings. All other datetime/date fields are serialised - to ISO-8601 strings. The ``solar_corrector`` object is replaced with a - placeholder because it is not serialisable and is not needed to reproduce - planner logic offline. + Datetime/date fields are serialised to ISO-8601 strings. The + ``solar_corrector`` object is replaced with a placeholder because it is + not serialisable and is not needed to reproduce planner logic offline. Args: inp: The planner input to serialise. @@ -186,19 +183,6 @@ def _planner_input_to_dict(inp: PlannerInput) -> dict[str, Any]: # not needed to reproduce planner logic offline, so replace it with None. raw["solar_corrector"] = None - # Patch datetime.time objects that asdict() cannot serialise to JSON. - for sched in raw.get("battery_schedules", []): - sched["start"] = ( - sched["start"].strftime("%H:%M:%S") - if isinstance(sched["start"], time) - else sched["start"] - ) - sched["end"] = ( - sched["end"].strftime("%H:%M:%S") - if isinstance(sched["end"], time) - else sched["end"] - ) - # Redact any stray entity-id strings that found their way into ``extra``. if "extra" in raw: raw["extra"] = redact_dict(raw["extra"]) @@ -209,8 +193,7 @@ def _planner_input_to_dict(inp: PlannerInput) -> dict[str, Any]: def _planner_input_from_dict(data: dict[str, Any]) -> PlannerInput: """Reconstruct a :class:`PlannerInput` from a serialised dictionary. - Inverse of :func:`_planner_input_to_dict`. Handles the ``HH:MM:SS`` → - :class:`datetime.time` conversion for battery schedules. + Inverse of :func:`_planner_input_to_dict`. Args: data: A dictionary previously produced by :func:`_planner_input_to_dict`. @@ -232,15 +215,9 @@ def _planner_input_from_dict(data: dict[str, Any]) -> PlannerInput: SolcastSlot(**item) for item in inp_data.get("solcast_slots", []) ] - schedules = [] - for raw_sched in inp_data.get("battery_schedules", []): - raw_sched = dict(raw_sched) - for field_name in ("start", "end"): - val = raw_sched.get(field_name) - if isinstance(val, str): - raw_sched[field_name] = datetime.strptime(val, "%H:%M:%S").time() - schedules.append(BatteryScheduleInput(**raw_sched)) - inp_data["battery_schedules"] = schedules + # Discard the removed battery-schedule field from any pre-#860 dump so + # older diagnostics dumps can still be replayed. + inp_data.pop("battery_schedules", None) # ``battery_max_discharge_power_w`` may be None (nullable float). if ( diff --git a/custom_components/hsem/utils/sensornames/controls.py b/custom_components/hsem/utils/sensornames/controls.py index a9e3744a..1518fe88 100644 --- a/custom_components/hsem/utils/sensornames/controls.py +++ b/custom_components/hsem/utils/sensornames/controls.py @@ -1,8 +1,8 @@ """Non-EV controls: switches, time entities, and efficiency numbers. Provides getter functions for read-only switch, extended attributes switch, -verbose logging switch, batteries schedule 1/2/3 switches, schedule 1/2/3 -start/end time entities, and battery charge/discharge efficiency numbers. +verbose logging switch, dynamic-discharge-floor switch, and battery +charge/discharge efficiency numbers. """ from homeassistant.util import slugify as s @@ -110,166 +110,6 @@ def get_verbose_logging_switch_entity_id() -> str: return f"switch.{s(get_verbose_logging_switch_key())}" -# Batteries Schedule 1 Switch -def get_batteries_schedule_1_switch_key() -> str: - """Return the config-entry key / unique_id basis for the batteries-schedule-1 switch.""" - return f"{DOMAIN}_batteries_enable_batteries_schedule_1" - - -def get_batteries_schedule_1_switch_unique_id(entry_id: str) -> str: - """Return the unique_id for the batteries-schedule-1 switch. - - Args: - entry_id (str): The config entry ID for uniqueness across entries. - """ - return f"{DOMAIN}_{entry_id}_{get_batteries_schedule_1_switch_key()}_switch" - - -def get_batteries_schedule_1_switch_entity_id() -> str: - """Return the entity_id for the batteries-schedule-1 switch.""" - return f"switch.{s(get_batteries_schedule_1_switch_key())}" - - -# Batteries Schedule 2 Switch -def get_batteries_schedule_2_switch_key() -> str: - """Return the config-entry key / unique_id basis for the batteries-schedule-2 switch.""" - return f"{DOMAIN}_batteries_enable_batteries_schedule_2" - - -def get_batteries_schedule_2_switch_unique_id(entry_id: str) -> str: - """Return the unique_id for the batteries-schedule-2 switch. - - Args: - entry_id (str): The config entry ID for uniqueness across entries. - """ - return f"{DOMAIN}_{entry_id}_{get_batteries_schedule_2_switch_key()}_switch" - - -def get_batteries_schedule_2_switch_entity_id() -> str: - """Return the entity_id for the batteries-schedule-2 switch.""" - return f"switch.{s(get_batteries_schedule_2_switch_key())}" - - -# Batteries Schedule 3 Switch -def get_batteries_schedule_3_switch_key() -> str: - """Return the config-entry key / unique_id basis for the batteries-schedule-3 switch.""" - return f"{DOMAIN}_batteries_enable_batteries_schedule_3" - - -def get_batteries_schedule_3_switch_unique_id(entry_id: str) -> str: - """Return the unique_id for the batteries-schedule-3 switch. - - Args: - entry_id (str): The config entry ID for uniqueness across entries. - """ - return f"{DOMAIN}_{entry_id}_{get_batteries_schedule_3_switch_key()}_switch" - - -def get_batteries_schedule_3_switch_entity_id() -> str: - """Return the entity_id for the batteries-schedule-3 switch.""" - return f"switch.{s(get_batteries_schedule_3_switch_key())}" - - -# Schedule 1 Start Time -def get_schedule_1_start_time_key() -> str: - """Return the config-entry key / unique_id basis for schedule-1-start time.""" - return f"{DOMAIN}_batteries_enable_batteries_schedule_1_start" - - -def get_schedule_1_start_time_unique_id(entry_id: str) -> str: - """Return the unique_id for the schedule-1-start time entity. - - Args: - entry_id (str): The config entry ID for uniqueness across entries. - """ - return f"{DOMAIN}_{entry_id}_{get_schedule_1_start_time_key()}_time" - - -def get_schedule_1_start_time_entity_id() -> str: - """Return the entity_id for the schedule-1-start time entity.""" - return f"time.{s(get_schedule_1_start_time_key())}" - - -# Schedule 1 End Time -def get_schedule_1_end_time_key() -> str: - """Return the config-entry key / unique_id basis for schedule-1-end time.""" - return f"{DOMAIN}_batteries_enable_batteries_schedule_1_end" - - -def get_schedule_1_end_time_unique_id(entry_id: str) -> str: - """Return the unique_id for the schedule-1-end time entity. - - Args: - entry_id (str): The config entry ID for uniqueness across entries. - """ - return f"{DOMAIN}_{entry_id}_{get_schedule_1_end_time_key()}_time" - - -def get_schedule_1_end_time_entity_id() -> str: - """Return the entity_id for the schedule-1-end time entity.""" - return f"time.{s(get_schedule_1_end_time_key())}" - - -# Schedule 2 Start Time -def get_schedule_2_start_time_key() -> str: - """Return the config-entry key / unique_id basis for schedule-2-start time.""" - return f"{DOMAIN}_batteries_enable_batteries_schedule_2_start" - - -def get_schedule_2_start_time_unique_id(entry_id: str) -> str: - """Return the unique_id for the schedule-2-start time entity. - - Args: - entry_id (str): The config entry ID for uniqueness across entries. - """ - return f"{DOMAIN}_{entry_id}_{get_schedule_2_start_time_key()}_time" - - -def get_schedule_2_start_time_entity_id() -> str: - """Return the entity_id for the schedule-2-start time entity.""" - return f"time.{s(get_schedule_2_start_time_key())}" - - -# Schedule 2 End Time -def get_schedule_2_end_time_key() -> str: - """Return the config-entry key / unique_id basis for schedule-2-end time.""" - return f"{DOMAIN}_batteries_enable_batteries_schedule_2_end" - - -def get_schedule_2_end_time_unique_id(entry_id: str) -> str: - """Return the unique_id for the schedule-2-end time entity. - - Args: - entry_id (str): The config entry ID for uniqueness across entries. - """ - return f"{DOMAIN}_{entry_id}_{get_schedule_2_end_time_key()}_time" - - -def get_schedule_2_end_time_entity_id() -> str: - """Return the entity_id for the schedule-2-end time entity.""" - return f"time.{s(get_schedule_2_end_time_key())}" - - -# Schedule 3 Start Time -def get_schedule_3_start_time_key() -> str: - """Return the config-entry key / unique_id basis for schedule-3-start time.""" - return f"{DOMAIN}_batteries_enable_batteries_schedule_3_start" - - -def get_schedule_3_start_time_unique_id(entry_id: str) -> str: - """Return the unique_id for the schedule-3-start time entity. - - Args: - entry_id (str): The config entry ID for uniqueness across entries. - """ - return f"{DOMAIN}_{entry_id}_{get_schedule_3_start_time_key()}_time" - - -def get_schedule_3_start_time_entity_id() -> str: - """Return the entity_id for the schedule-3-start time entity.""" - return f"time.{s(get_schedule_3_start_time_key())}" - - # Dynamic Discharge Floor Switch def get_dynamic_discharge_floor_switch_key() -> str: """Return the config-entry key for the dynamic-discharge-floor switch.""" @@ -288,23 +128,3 @@ def get_dynamic_discharge_floor_switch_unique_id(entry_id: str) -> str: def get_dynamic_discharge_floor_switch_entity_id() -> str: """Return the entity_id for the dynamic-discharge-floor switch.""" return f"switch.{s(get_dynamic_discharge_floor_switch_key())}" - - -# Schedule 3 End Time -def get_schedule_3_end_time_key() -> str: - """Return the config-entry key / unique_id basis for schedule-3-end time.""" - return f"{DOMAIN}_batteries_enable_batteries_schedule_3_end" - - -def get_schedule_3_end_time_unique_id(entry_id: str) -> str: - """Return the unique_id for the schedule-3-end time entity. - - Args: - entry_id (str): The config entry ID for uniqueness across entries. - """ - return f"{DOMAIN}_{entry_id}_{get_schedule_3_end_time_key()}_time" - - -def get_schedule_3_end_time_entity_id() -> str: - """Return the entity_id for the schedule-3-end time entity.""" - return f"time.{s(get_schedule_3_end_time_key())}" diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index f5c1b32c..35e0e8a8 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -141,16 +141,15 @@ HA-dependent sensor entities that consume coordinator data. ### Models layer (pure-Python dataclasses) -| Module | Responsibility | -| --------------------------------- | ---------------------------------------------------------- | -| `models/planner_inputs.py` | `PlannerInput`, `PricePoint`, `SolcastSlot`, etc. | -| `models/planner_outputs.py` | `PlannerOutput`, `PlannedSlot`, `DataQuality`, etc. | -| `models/live_state.py` | `LiveState`, `EVLiveState` — HA entity snapshots | -| `models/sensor_config.py` | `SensorConfig`, `EVChargerConfig`, `BatteryScheduleConfig` | -| `models/state_snapshot.py` | `StateSnapshot` — frozen immutable HA state collection | -| `models/time_series.py` | `TimeSeriesIndex`, `SlotKey` — shared slot alignment | -| `models/hourly_recommendation.py` | `HourlyRecommendation` — per-slot planner output | -| `models/battery_schedule.py` | `BatterySchedule` dataclass | +| Module | Responsibility | +| --------------------------------- | ------------------------------------------------------ | +| `models/planner_inputs.py` | `PlannerInput`, `PricePoint`, `SolcastSlot`, etc. | +| `models/planner_outputs.py` | `PlannerOutput`, `PlannedSlot`, `DataQuality`, etc. | +| `models/live_state.py` | `LiveState`, `EVLiveState` — HA entity snapshots | +| `models/sensor_config.py` | `SensorConfig`, `EVChargerConfig` | +| `models/state_snapshot.py` | `StateSnapshot` — frozen immutable HA state collection | +| `models/time_series.py` | `TimeSeriesIndex`, `SlotKey` — shared slot alignment | +| `models/hourly_recommendation.py` | `HourlyRecommendation` — per-slot planner output | --- diff --git a/docs/config-flow-reference.md b/docs/config-flow-reference.md index 79ba1cf7..8e96b3c9 100644 --- a/docs/config-flow-reference.md +++ b/docs/config-flow-reference.md @@ -11,7 +11,7 @@ The config flow is a multi-step wizard. Steps appear in this order: ``` quick_setup → init → prices → months → solcast → huawei_solar → battery_economics → power → ev → [ev_second] → ev_planned_load - → [ev_second_planned_load] → ocpp → batteries_schedules + → [ev_second_planned_load] → ocpp → batteries_wait_mode → batteries_excess_export → weighted_values → energy_and_ml ``` @@ -206,18 +206,6 @@ fields are only shown when the second EV is configured. | Second OCPP port | `hsem_ocpp_second_port` | `9001` | TCP port for the second EV's OCPP WebSocket server (must differ from the primary port) | | Second charge point ID | `hsem_ocpp_second_cpid` | — | Charge point identifier of the second EV charger | -### Step: `batteries_schedule_1/2/3` - -Battery charge/discharge schedule windows (up to three). - -| Field | Key | Default | Description | -| ---------- | -------------------------------------------------- | ------- | --------------------------- | -| Enabled | `hsem_batteries_enable_batteries_schedule_N` | Varies | Toggle this schedule window | -| Start time | `hsem_batteries_enable_batteries_schedule_N_start` | Varies | Window start (HH:MM:SS) | -| End time | `hsem_batteries_enable_batteries_schedule_N_end` | Varies | Window end (HH:MM:SS) | - -Schedule 1 and 2 are enabled by default; schedule 3 is disabled by default. - ### Step: `batteries_wait_mode` Battery wait-mode behaviour. diff --git a/docs/dashboard.yaml b/docs/dashboard.yaml index 531b46ae..8d7517b8 100644 --- a/docs/dashboard.yaml +++ b/docs/dashboard.yaml @@ -1210,118 +1210,6 @@ views: action: more-info column_span: 2 - # ═══════════════════════════════════════════════════════════ - # SECTION 6 — BATTERY SCHEDULES - # ═══════════════════════════════════════════════════════════ - - type: grid - cards: - - type: heading - heading: Batteries Discharge Schedules - heading_style: title - grid_options: - columns: full - rows: 1 - - type: tile - entity: switch.hsem_batteries_enable_batteries_schedule_1 - name: Schedule 1 - icon: mdi:calendar-clock - vertical: false - hide_state: false - grid_options: - columns: 4 - rows: 1 - tap_action: - action: more-info - - type: tile - entity: time.hsem_batteries_enable_batteries_schedule_1_start - name: S1 Start - icon: mdi:clock-start - vertical: false - hide_state: false - grid_options: - columns: 4 - rows: 1 - tap_action: - action: more-info - - type: tile - entity: time.hsem_batteries_enable_batteries_schedule_1_end - name: S1 End - icon: mdi:clock-end - vertical: false - hide_state: false - grid_options: - columns: 4 - rows: 1 - tap_action: - action: more-info - - type: tile - entity: switch.hsem_batteries_enable_batteries_schedule_2 - name: Schedule 2 - icon: mdi:calendar-clock - vertical: false - hide_state: false - grid_options: - columns: 4 - rows: 1 - tap_action: - action: more-info - - type: tile - entity: time.hsem_batteries_enable_batteries_schedule_2_start - name: S2 Start - icon: mdi:clock-start - vertical: false - hide_state: false - grid_options: - columns: 4 - rows: 1 - tap_action: - action: more-info - - type: tile - entity: time.hsem_batteries_enable_batteries_schedule_2_end - name: S2 End - icon: mdi:clock-end - vertical: false - hide_state: false - grid_options: - columns: 4 - rows: 1 - tap_action: - action: more-info - - type: tile - entity: switch.hsem_batteries_enable_batteries_schedule_3 - name: Schedule 3 - icon: mdi:calendar-clock - vertical: false - hide_state: false - grid_options: - columns: 4 - rows: 1 - tap_action: - action: more-info - - type: tile - entity: time.hsem_batteries_enable_batteries_schedule_3_start - name: S3 Start - icon: mdi:clock-start - vertical: false - hide_state: false - grid_options: - columns: 4 - rows: 1 - tap_action: - action: more-info - - type: tile - entity: time.hsem_batteries_enable_batteries_schedule_3_end - name: S3 End - icon: mdi:clock-end - vertical: false - hide_state: false - grid_options: - columns: 4 - rows: 1 - tap_action: - action: more-info - column_span: 2 - # ═══════════════════════════════════════════════════════════ # SECTION 7 — ENERGY ECONOMICS # ═══════════════════════════════════════════════════════════ diff --git a/docs/planner-guide.md b/docs/planner-guide.md index 45960bab..aed4795b 100644 --- a/docs/planner-guide.md +++ b/docs/planner-guide.md @@ -267,21 +267,6 @@ available for smoothing bursty live power over multiple ticks before it reaches the planner, but is not yet wired into the coordinator's update cycle — today's live values are still the latest single reading per cycle. -### Schedule windows - -| Field | Type | Description | -| ------------------- | ---------------------------- | ------------------------------------ | -| `battery_schedules` | `list[BatteryScheduleInput]` | Up to three charge/discharge windows | - -Each `BatteryScheduleInput` defines: - -- `enabled` — whether this window is active -- `start` / `end` — wall-clock time range (`datetime.time`) -- `min_price_difference` — minimum import/export spread required to activate (local currency/kWh) - -HSEM charges the battery **before** a discharge window so it is full when high prices arrive. -The pre-charge window ends at `schedule.start` and is sized to fill the battery from current SoC. - ### Excess export and grid controls | Field | Default | Description | @@ -409,7 +394,7 @@ Each `PlannedSlot` in the output list covers one time interval and carries: | Value | Meaning | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `batteries_charge_grid` | Charge battery from grid (forced by schedule or price signal) | +| `batteries_charge_grid` | Charge battery from grid (forced by price signal) | | `batteries_charge_solar` | Battery is charging from PV surplus | | `batteries_discharge_mode` | Battery discharges to cover house load during high-price window | | `force_batteries_discharge` | Forced discharge (excess export to grid) | @@ -434,21 +419,7 @@ The scheduler assigns the first recommendations during slot population, before candidate scoring. Rules are applied in strict priority order; once a slot has a recommendation it is not changed by later rules in the same layer. -**Discharge schedule windows** (`apply_discharge_schedules`) - -| Priority | Condition | Recommendation | -| -------- | ----------------------------------------------------------------------- | -------------------------- | -| 1 | Slot falls inside a configured discharge window and price spread is met | `batteries_discharge_mode` | - -**Charge schedule windows** (`apply_charge_schedules`) — for each discharge window, eligible pre-charge slots are filled in order: - -| Priority | Condition | Recommendation | -| -------- | --------------------------------------------------------------------- | ------------------------ | -| 1 | Import price < 0 (paid to import) | `batteries_charge_grid` | -| 2 | Solar surplus (`estimated_net_consumption < threshold`) | `batteries_charge_solar` | -| 3 | Cheapest grid hour where spread ≥ `min_price_difference + cycle_cost` | `batteries_charge_grid` | - -**Opportunistic grid charge** (`apply_opportunistic_charge`) — outside any schedule: +**Opportunistic grid charge** (`apply_opportunistic_charge`): | Priority | Condition | Recommendation | | -------- | -------------------------------------------------- | ----------------------- | @@ -1228,53 +1199,7 @@ All examples use the following base configuration: --- -### Scenario 1: Winter day - -**Conditions:** - -- Month: January (winter month) -- PV forecast: 0 kWh across all hours (no solar production) -- House load: ~2 kWh/h constant -- Import prices: flat at 1.50 DKK/kWh all day -- Battery at start: 50 % SoC (4.5 kWh above floor) -- Discharge window schedule: 16:00–21:00 (evening peak) - -**What the planner does:** - -``` -Hours 00–14: batteries_wait_mode (cheap flat price, no PV, conserve battery) -Hours 14–16: batteries_charge_grid (pre-charge before evening window) - → charges to max_soc, importing ≈ 4.5 kWh from grid -Hours 16–21: batteries_discharge_mode - → discharges to cover 2 kWh/h house load - → avoids 5 × 2 kWh = 10 kWh grid import during the window -Hours 21–24: batteries_wait_mode (window ended, battery near floor) -``` - -**Why this plan wins:** - -The selected plan charges cheaply before the discharge window so the evening -load is covered entirely by the battery. On a flat-price winter day the -net saving is small (no price arbitrage benefit), but the plan ensures the -battery is available for the programmed window. The `no_action` candidate -(battery idle all day) produces an identical grid cost here, so the planner -may select `no_action` when the schedule does not force grid charge. - -**Explanation excerpt:** - -```json -{ - "selected_strategy": "baseline", - "summary": "Pre-charge for evening discharge window; no PV surplus available.", - "constraints": ["winter_month", "schedule_window_active"], - "forecast_pv_kwh": 0.0, - "battery_soc_at_end_pct": 10.0 -} -``` - ---- - -### Scenario 2: Summer day — high PV surplus +### Scenario 1: Summer day — high PV surplus **Conditions:** @@ -1323,7 +1248,7 @@ low export price instead of storing it for later use. --- -### Scenario 3: Cheap night price — grid charge opportunity +### Scenario 2: Cheap night price — grid charge opportunity **Conditions:** @@ -1338,7 +1263,6 @@ low export price instead of storing it for later use. - 21:00–24:00: 1.20 DKK/kWh - Export price: 0.10 DKK/kWh (low, net-metering not attractive) - Battery at start: 15 % SoC (0.45 kWh above floor) -- No discharge window schedule configured **What the planner does:** @@ -1388,7 +1312,7 @@ here. The `no_action` candidate pays full peak prices. --- -### Scenario 4: High PV day — excess export opportunity +### Scenario 3: High PV day — excess export opportunity **Conditions:** @@ -1446,7 +1370,7 @@ the export window and earns significantly less revenue. --- -### Scenario 5: Flat price day — no arbitrage value +### Scenario 4: Flat price day — no arbitrage value **Conditions:** @@ -1456,7 +1380,6 @@ the export window and earns significantly less revenue. - Import price: 1.20 DKK/kWh flat all 24 hours - Export price: 0.10 DKK/kWh flat - Battery at start: 50 % SoC -- No discharge window schedule; excess export disabled **What the planner does:** @@ -1510,7 +1433,7 @@ than pure `no_action` because the PV surplus would otherwise export at only --- -### Scenario 6: EV charging — MILP co-optimisation +### Scenario 5: EV charging — MILP co-optimisation **Conditions:** @@ -1605,7 +1528,6 @@ Common constraint tags and their meaning: | `grid_charge_price_spread_met` | Price spread exceeds min_price_difference threshold | | `excess_export_enabled` | Excess export feature is active in config | | `export_price_above_threshold` | Export price exceeds `excess_export_price_threshold` | -| `schedule_window_active` | At least one `battery_schedules` entry is enabled and active | --- diff --git a/docs/sensors-reference.md b/docs/sensors-reference.md index c79c0902..d548e120 100644 --- a/docs/sensors-reference.md +++ b/docs/sensors-reference.md @@ -9,13 +9,13 @@ attributes, states, and dashboard examples. HSEM exposes these entity types: -| Type | Count | Description | -| ---------- | ----- | -------------------------------------------------------------------- | -| **Sensor** | ~40 | Read-only state, plan, diagnostic, financial, and EV entities | -| **Select** | 2 | Force working mode override and Solcast likelihood selector | -| **Switch** | ~15 | Toggle entities for schedules, EV settings, features, and ML options | -| **Time** | 8 | Start/end time inputs for battery schedules and EV deadlines | -| **Number** | 4 | Charge/discharge efficiency and EV target SoC controls | +| Type | Count | Description | +| ---------- | ----- | ------------------------------------------------------------- | +| **Sensor** | ~40 | Read-only state, plan, diagnostic, financial, and EV entities | +| **Select** | 2 | Force working mode override and Solcast likelihood selector | +| **Switch** | ~12 | Toggle entities for EV settings, features, and ML options | +| **Time** | 2 | EV charge deadlines | +| **Number** | 4 | Charge/discharge efficiency and EV target SoC controls | --- @@ -79,12 +79,10 @@ all planner output as attributes. ### Plan output attributes -| Attribute | Type | Description | -| ----------------------------------------------- | ------------ | --------------------------------------------- | -| `hourly_recommendation` | dict \| null | The recommendation slot active **right now** | -| `hourly_recommendations` | list[dict] | Full list of planner slots for the horizon | -| `batteries_schedules` | list | Active battery discharge schedule definitions | -| `batteries_schedules_remaining_capacity_needed` | float (kWh) | Remaining discharge budget across schedules | +| Attribute | Type | Description | +| ------------------------ | ------------ | -------------------------------------------- | +| `hourly_recommendation` | dict \| null | The recommendation slot active **right now** | +| `hourly_recommendations` | list[dict] | Full list of planner slots for the horizon | ### `hourly_recommendations` slot structure @@ -682,23 +680,20 @@ EV switches are only created when the corresponding EV's planned load primary EV, `hsem_ev_second_planned_load_enabled` for the second EV (issue #859). All other switches are always created. -| Entity | Purpose | -| --------------------------------------------------- | ---------------------------------------------- | -| `switch.hsem_read_only` | Block all hardware writes | -| `switch.hsem_extended_attributes` | Enable extended diagnostic attributes | -| `switch.hsem_verbose_logging` | Enable verbose logging | -| `switch.hsem_batteries_enable_batteries_schedule_1` | Toggle battery schedule 1 | -| `switch.hsem_batteries_enable_batteries_schedule_2` | Toggle battery schedule 2 | -| `switch.hsem_batteries_enable_batteries_schedule_3` | Toggle battery schedule 3 | -| `switch.hsem_ev_force_discharge` | Force EV maximum discharge power | -| `switch.hsem_ev_smart_charging` | Enable smart EV charging scheduling | -| `switch.hsem_ev_force_charge_now` | Force immediate EV charging | -| `switch.hsem_ev_second_smart_charging` | Enable smart charging for second EV | -| `switch.hsem_ev_second_force_charge_now` | Force immediate second EV charging | -| `switch.hsem_ml_consumption` | Enable ML-based consumption prediction | -| `switch.hsem_ml_sequential` | Enable sequential (intra-day momentum) ML mode | -| `switch.hsem_dynamic_discharge_floor` | Enable dynamic discharge floor | -| `switch.hsem_ev_auto_full_negative_price` | Auto-Full EV on negative price | +| Entity | Purpose | +| ----------------------------------------- | ---------------------------------------------- | +| `switch.hsem_read_only` | Block all hardware writes | +| `switch.hsem_extended_attributes` | Enable extended diagnostic attributes | +| `switch.hsem_verbose_logging` | Enable verbose logging | +| `switch.hsem_ev_force_discharge` | Force EV maximum discharge power | +| `switch.hsem_ev_smart_charging` | Enable smart EV charging scheduling | +| `switch.hsem_ev_force_charge_now` | Force immediate EV charging | +| `switch.hsem_ev_second_smart_charging` | Enable smart charging for second EV | +| `switch.hsem_ev_second_force_charge_now` | Force immediate second EV charging | +| `switch.hsem_ml_consumption` | Enable ML-based consumption prediction | +| `switch.hsem_ml_sequential` | Enable sequential (intra-day momentum) ML mode | +| `switch.hsem_dynamic_discharge_floor` | Enable dynamic discharge floor | +| `switch.hsem_ev_auto_full_negative_price` | Auto-Full EV on negative price | --- @@ -722,19 +717,12 @@ efficiency numbers are always created. `time.hsem_ev_deadline` is only created when `hsem_ev_planned_load_enabled` is set; `time.hsem_ev_second_deadline` only when -`hsem_ev_second_planned_load_enabled` is set (issue #859). The battery -schedule time entities are always created (tracked separately in #860). - -| Entity | Purpose | -| -------------------------------------- | -------------------------- | -| `time.hsem_batteries_schedule_1_start` | Schedule 1 start time | -| `time.hsem_batteries_schedule_1_end` | Schedule 1 end time | -| `time.hsem_batteries_schedule_2_start` | Schedule 2 start time | -| `time.hsem_batteries_schedule_2_end` | Schedule 2 end time | -| `time.hsem_batteries_schedule_3_start` | Schedule 3 start time | -| `time.hsem_batteries_schedule_3_end` | Schedule 3 end time | -| `time.hsem_ev_deadline` | Primary EV charge deadline | -| `time.hsem_ev_second_deadline` | Second EV charge deadline | +`hsem_ev_second_planned_load_enabled` is set (issue #859). + +| Entity | Purpose | +| ------------------------------ | -------------------------- | +| `time.hsem_ev_deadline` | Primary EV charge deadline | +| `time.hsem_ev_second_deadline` | Second EV charge deadline | --- diff --git a/tests/planner/fixtures.py b/tests/planner/fixtures.py index a5012444..c765e62f 100644 --- a/tests/planner/fixtures.py +++ b/tests/planner/fixtures.py @@ -16,9 +16,6 @@ from __future__ import annotations -from datetime import time - -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput from custom_components.hsem.models.hourly_consumption_average import ( HourlyConsumptionAverage, ) @@ -220,31 +217,6 @@ def _make_consumption_averages( ] -# --------------------------------------------------------------------------- -# Default battery schedules -# --------------------------------------------------------------------------- - - -def _default_schedules() -> list[BatteryScheduleInput]: - """Return the two default battery charge/discharge schedules. - - - Schedule 1: discharge 07:00–09:00 (morning peak) - - Schedule 2: discharge 17:00–21:00 (evening peak) - """ - return [ - BatteryScheduleInput( - enabled=True, - start=time(7, 0), - end=time(9, 0), - ), - BatteryScheduleInput( - enabled=True, - start=time(17, 0), - end=time(21, 0), - ), - ] - - # --------------------------------------------------------------------------- # Public fixture factories # --------------------------------------------------------------------------- @@ -259,7 +231,6 @@ def make_summer_day_input( battery_max_charge_power_w: float = 5000.0, interval_minutes: int = 60, interval_length_hours: int = 24, - schedules: list[BatteryScheduleInput] | None = None, months_winter: list[int] | None = None, ) -> PlannerInput: """Return a 24-hour summer planning input. @@ -276,7 +247,6 @@ def make_summer_day_input( onversion loss (0-100 %). interval_minutes: Slot width in minutes (15 or 60). interval_length_hours: Planning horizon in hours (e.g. 24 or 48). - schedules: Override the default discharge schedules. months_winter: Override the list of winter months. Returns: @@ -302,8 +272,6 @@ def make_summer_day_input( consumption_averages=_make_consumption_averages(_HOUSE_CONSUMPTION), price_points=_make_price_points(_SPOT_PRICES_SUMMER), solcast_slots=_make_solcast_slots(_SOLCAST_SUMMER), - # schedules - battery_schedules=schedules if schedules is not None else _default_schedules(), # excess export excess_export_enabled=True, excess_export_discharge_buffer_pct=10.0, @@ -327,7 +295,6 @@ def make_winter_day_input( battery_max_charge_power_w: float = 5000.0, interval_minutes: int = 60, interval_length_hours: int = 24, - schedules: list[BatteryScheduleInput] | None = None, months_winter: list[int] | None = None, ) -> PlannerInput: """Return a 24-hour winter planning input. @@ -344,7 +311,6 @@ def make_winter_day_input( onversion loss (0-100 %). interval_minutes: Slot width in minutes (15 or 60). interval_length_hours: Planning horizon in hours (e.g. 24 or 48). - schedules: Override the default discharge schedules. months_winter: Override the list of winter months. Returns: @@ -370,8 +336,6 @@ def make_winter_day_input( consumption_averages=_make_consumption_averages(_HOUSE_CONSUMPTION), price_points=_make_price_points(_SPOT_PRICES_WINTER), solcast_slots=_make_solcast_slots(_SOLCAST_WINTER), - # schedules - battery_schedules=schedules if schedules is not None else _default_schedules(), # excess export disabled in winter (no meaningful solar surplus) excess_export_enabled=False, excess_export_discharge_buffer_pct=10.0, @@ -435,7 +399,6 @@ def make_flat_price_input( consumption_averages=consumption, price_points=flat_prices, solcast_slots=no_solar, - battery_schedules=_default_schedules(), excess_export_enabled=False, months_winter=[1, 2, 3, 4, 10, 11, 12], is_read_only=True, @@ -494,7 +457,6 @@ def make_negative_price_input( consumption_averages=_make_consumption_averages(_HOUSE_CONSUMPTION), price_points=price_points, solcast_slots=_make_solcast_slots(_SOLCAST_SUMMER), - battery_schedules=_default_schedules(), excess_export_enabled=False, months_winter=[1, 2, 3, 4, 10, 11, 12], is_read_only=True, diff --git a/tests/planner/test_48h_second_day.py b/tests/planner/test_48h_second_day.py deleted file mode 100644 index 65450752..00000000 --- a/tests/planner/test_48h_second_day.py +++ /dev/null @@ -1,325 +0,0 @@ -"""Regression tests for 48-hour planning horizon — second-day slot correctness. - -Verifies that slots in the second 24 hours of a 48-hour planning window receive -correct recommendations instead of defaulting entirely to ``BatteriesDischargeMode``. - -Root causes fixed: -1. ``apply_discharge_schedules`` only applied each battery schedule once (for the - next single occurrence) rather than once per calendar day in the horizon. - Over a 48-hour window the second day's discharge window was never set, so the - seasonal fill fell through to ``BatteriesDischargeMode`` for all summer slots. - -2. ``apply_optimization_strategy`` filtered the solar-charging pass with - ``slot.start.date() == now.date()``, preventing solar charging from being - assigned on day 2. - -Acceptance criteria: -- Second-day discharge windows are assigned ``BatteriesDischargeMode``. -- Second-day non-discharge summer slots are NOT all ``BatteriesDischargeMode``. -- Solar PV surplus slots on day 2 receive ``BatteriesChargeSolar``. -- Both day-1 and day-2 discharge windows are reflected in ``discharge_windows``. -""" - -from __future__ import annotations - -from datetime import time - -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput -from custom_components.hsem.models.hourly_consumption_average import ( - HourlyConsumptionAverage, -) -from custom_components.hsem.models.planner_input import PlannerInput -from custom_components.hsem.models.price_point import PricePoint -from custom_components.hsem.models.solcast_slot import SolcastSlot -from custom_components.hsem.planner import run_planner -from custom_components.hsem.utils.recommendations import Recommendations - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -_DISCHARGE_VALUES = { - Recommendations.BatteriesDischargeMode.value, - Recommendations.ForceBatteriesDischarge.value, -} -_CHARGE_VALUES = { - Recommendations.BatteriesChargeGrid.value, - Recommendations.BatteriesChargeSolar.value, -} - - -def _make_48h_input( - *, - now_iso: str = "2024-06-15T00:00:00+02:00", - battery_soc_pct: float = 50.0, - schedules: list[BatteryScheduleInput] | None = None, - pv_kwh_per_hour: float = 0.0, - load_kwh_per_hour: float = 0.5, - months_winter: list[int] | None = None, -) -> PlannerInput: - """Return a 48-hour summer planning input for second-day regression tests.""" - # Varying prices: cheap night (00-06), moderate day, expensive evening peak - import_prices_24h = [ - 0.08, - 0.06, - 0.05, - 0.05, - 0.06, - 0.09, # 00-06 cheap - 0.15, - 0.22, - 0.26, - 0.24, - 0.12, - 0.08, # 06-12 - 0.06, - 0.07, - 0.10, - 0.25, - 0.30, - 0.32, # 12-18 - 0.29, - 0.24, - 0.18, - 0.14, - 0.11, - 0.09, # 18-24 - ] - - prices = [ - PricePoint( - hour=h, - import_price=import_prices_24h[h], - export_price=max(import_prices_24h[h] - 0.02, 0.0), - ) - for h in range(24) - ] - solar = [SolcastSlot(hour=h, pv_estimate=pv_kwh_per_hour) for h in range(24)] - consumption = [ - HourlyConsumptionAverage( - hour=h, - avg_1d=load_kwh_per_hour, - avg_3d=load_kwh_per_hour, - avg_7d=load_kwh_per_hour, - avg_14d=load_kwh_per_hour, - ) - for h in range(24) - ] - - default_schedules = [ - BatteryScheduleInput( - enabled=True, - start=time(7, 0), - end=time(9, 0), - ), - BatteryScheduleInput( - enabled=True, - start=time(17, 0), - end=time(21, 0), - ), - ] - - return PlannerInput( - now_iso=now_iso, - interval_minutes=60, - interval_length_hours=48, - battery_soc_pct=battery_soc_pct, - battery_rated_capacity_kwh=10.0, - battery_end_of_discharge_soc_pct=10.0, - battery_max_charge_power_w=5000.0, - battery_purchase_price=0.0, - battery_expected_cycles=6000, - weight_1d=25, - weight_3d=30, - weight_7d=30, - weight_14d=15, - consumption_averages=consumption, - price_points=prices, - solcast_slots=solar, - battery_schedules=schedules if schedules is not None else default_schedules, - excess_export_enabled=False, - excess_export_discharge_buffer_pct=10.0, - excess_export_price_threshold=0.10, - months_winter=( - months_winter if months_winter is not None else [1, 2, 3, 4, 10, 11, 12] - ), - house_power_includes_ev=True, - is_read_only=True, - ) - - -# =========================================================================== -# Basic 48-hour output contract -# =========================================================================== - - -class TestBasic48hContract: - """The planner must return 48 slots and assign all of them.""" - - def test_48h_produces_48_slots(self): - result = run_planner(_make_48h_input()) - assert len(result.slots) == 48 - - def test_all_slots_have_recommendation(self): - result = run_planner(_make_48h_input()) - for slot in result.slots: - assert slot.recommendation is not None, ( - f"Slot {slot.start.isoformat()} has no recommendation in 48h plan" - ) - - def test_slots_span_two_calendar_days(self): - result = run_planner(_make_48h_input()) - dates = {s.start.date() for s in result.slots} - assert len(dates) == 2, f"Expected slots on exactly 2 dates, got {dates}" - - -# =========================================================================== -# Discharge windows on day 2 -# =========================================================================== - - -class TestDay2DischargeWindows: - """Second-day discharge schedule windows must be applied.""" - - def test_day2_discharge_window_present(self): - """The 07:00-09:00 discharge window must appear on BOTH calendar days.""" - result = run_planner(_make_48h_input()) - day1 = result.slots[0].start.date() - day2 = day1.replace(day=day1.day + 1) # next calendar day - - day2_discharge = [ - s - for s in result.slots - if s.start.date() == day2 - and s.recommendation in _DISCHARGE_VALUES - and s.start.hour in (7, 8) - ] - assert len(day2_discharge) >= 1, ( - f"Expected discharge slots at 07:00-09:00 on day 2 ({day2}), " - f"found none. Day-2 recommendations: " - f"{[(s.start.hour, s.recommendation) for s in result.slots if s.start.date() == day2]}" - ) - - def test_day2_evening_discharge_window_present(self): - """The 17:00-21:00 evening discharge window must appear on BOTH days.""" - result = run_planner(_make_48h_input()) - day1 = result.slots[0].start.date() - day2 = day1.replace(day=day1.day + 1) - - day2_eve_discharge = [ - s - for s in result.slots - if s.start.date() == day2 - and s.recommendation in _DISCHARGE_VALUES - and 17 <= s.start.hour < 21 - ] - assert len(day2_eve_discharge) >= 1, ( - "Expected discharge slots at 17:00-21:00 on day 2, found none." - ) - - def test_discharge_windows_list_covers_both_days(self): - """PlannerOutput.discharge_windows must include windows from both days.""" - result = run_planner(_make_48h_input()) - assert len(result.discharge_windows) >= 2, ( - f"Expected at least 2 discharge windows (one per day), " - f"got {len(result.discharge_windows)}: " - f"{[(w.start.isoformat(), w.end.isoformat()) for w in result.discharge_windows]}" - ) - - -# =========================================================================== -# Day-2 slots must not all be BatteriesDischargeMode -# =========================================================================== - - -class TestDay2NotAllDischarge: - """The second 24 hours must not be entirely discharge recommendations. - - Before the fix, `apply_optimization_strategy` would assign - ``BatteriesDischargeMode`` to every unscheduled summer slot because: - - The solar charging pass only processed today's (day-1) slots. - - No charge windows were recognised for day-2 (schedules only fired once). - """ - - def test_day2_has_non_discharge_slots(self): - """At least some day-2 slots should not be BatteriesDischargeMode.""" - result = run_planner(_make_48h_input()) - day1 = result.slots[0].start.date() - day2 = day1.replace(day=day1.day + 1) - - day2_slots = [s for s in result.slots if s.start.date() == day2] - non_discharge = [ - s for s in day2_slots if s.recommendation not in _DISCHARGE_VALUES - ] - assert len(non_discharge) > 0, ( - "Every day-2 slot is BatteriesDischargeMode — regression from the " - "second-day planning bug. Day-2 recommendations: " - f"{[(s.start.hour, s.recommendation) for s in day2_slots]}" - ) - - def test_day2_cheap_night_slots_are_not_discharge(self): - """Cheap night slots (00:00-06:00) on day 2 must not be discharge.""" - result = run_planner(_make_48h_input()) - day1 = result.slots[0].start.date() - day2 = day1.replace(day=day1.day + 1) - - cheap_night_day2 = [ - s for s in result.slots if s.start.date() == day2 and s.start.hour < 6 - ] - assert cheap_night_day2, "No cheap-night day-2 slots found (unexpected)" - - all_discharge = all( - s.recommendation in _DISCHARGE_VALUES for s in cheap_night_day2 - ) - assert not all_discharge, ( - "Cheap night slots (00:00-06:00) on day 2 are all BatteriesDischargeMode. " - f"Recommendations: {[(s.start.hour, s.recommendation) for s in cheap_night_day2]}" - ) - - -# =========================================================================== -# Day-2 solar charging -# =========================================================================== - - -class TestDay2SolarCharging: - """With PV surplus on day 2, BatteriesChargeSolar must be assigned.""" - - def test_day2_pv_surplus_gets_charge_solar(self): - """High PV production on day 2 should yield BatteriesChargeSolar slots. - - The battery may already be full from day 1's solar, in which case - charge recs are correctly cleared by the SoC simulation. The test - verifies at least some charging occurs across the 48-hour horizon. - """ - result = run_planner( - _make_48h_input( - pv_kwh_per_hour=5.0, - load_kwh_per_hour=0.3, - battery_soc_pct=10.0, - schedules=[ - BatteryScheduleInput( - enabled=True, - start=time(17, 0), - end=time(21, 0), - ) - ], - ) - ) - - # Solar charge may appear on either day depending on when the - # battery has room. At minimum, day 1 should have charge slots. - all_solar_charge = [ - s - for s in result.slots - if s.recommendation == Recommendations.BatteriesChargeSolar.value - ] - assert len(all_solar_charge) > 0, ( - "No BatteriesChargeSolar slots anywhere in 48h plan despite high" - " PV surplus and low battery SoC." - ) - - -# =========================================================================== -# Pre-charge for day-2 discharge windows -# =========================================================================== diff --git a/tests/planner/test_arbitrage_grid_charge.py b/tests/planner/test_arbitrage_grid_charge.py deleted file mode 100644 index cf35bb28..00000000 --- a/tests/planner/test_arbitrage_grid_charge.py +++ /dev/null @@ -1,449 +0,0 @@ -"""Tests for the general arbitrage grid-charge planner pass. - -The arbitrage pass scans the planning horizon for cheap-now vs. -expensive-later import-price spreads and schedules ``batteries_charge_grid`` -in earlier slots when the spread covers ``min_price_difference``, cycle -wear, and conversion-loss cost — even without a configured discharge -schedule window. - -Acceptance criteria verified here: - -- Cheap noon import vs expensive evening import without a discharge - schedule triggers ``batteries_charge_grid`` at noon. -- No grid charge when future spread is below the combined threshold. -- Degenerate-vertex resolution keeps SoC within usable bounds (issue #662). -- No grid charge when no battery schedule is enabled (effectively - disabled grid charging). -- Existing opportunistic and scheduled passes are not overridden. -- ``BatteriesDischargeMode`` seasonal fallback does not prevent - ``BatteriesChargeGrid`` from being assigned earlier. -""" - -from __future__ import annotations - -from datetime import time -from typing import Any - -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput -from custom_components.hsem.models.hourly_consumption_average import ( - HourlyConsumptionAverage, -) -from custom_components.hsem.models.planner_input import PlannerInput -from custom_components.hsem.models.price_point import PricePoint -from custom_components.hsem.models.solcast_slot import SolcastSlot -from custom_components.hsem.planner import run_planner -from custom_components.hsem.utils.recommendations import Recommendations - -_CHARGE_GRID = Recommendations.BatteriesChargeGrid.value -_DISCHARGE = Recommendations.BatteriesDischargeMode.value - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _flat_consumption_with_evening_load( - evening_hours: list[int], evening_load_kwh: float = 1.5 -) -> list[HourlyConsumptionAverage]: - """Return 24 hourly consumption averages with a load spike at *evening_hours*.""" - return [ - HourlyConsumptionAverage( - hour=h, - avg_1d=(evening_load_kwh if h in evening_hours else 0.05), - avg_3d=(evening_load_kwh if h in evening_hours else 0.05), - avg_7d=(evening_load_kwh if h in evening_hours else 0.05), - avg_14d=(evening_load_kwh if h in evening_hours else 0.05), - ) - for h in range(24) - ] - - -def _make_arbitrage_input( - *, - cheap_hour: int = 12, - cheap_price: float = 0.66, - expensive_hours: list[int] | None = None, - expensive_price: float = 1.68, - battery_soc_pct: float = 20.0, - battery_rated_capacity_kwh: float = 10.0, - battery_cycle_cost_per_kwh: float = 0.0, - battery_purchase_price: float = 0.0, - schedules: list[BatteryScheduleInput] | None = None, - base_price: float = 1.0, - battery_end_of_discharge_soc_pct: float = 10.0, -) -> PlannerInput: - """Build a 24-hour PlannerInput with one cheap slot and N expensive slots.""" - if expensive_hours is None: - expensive_hours = [18, 19] - - prices: list[PricePoint] = [] - for h in range(24): - if h == cheap_hour: - p = cheap_price - elif h in expensive_hours: - p = expensive_price - else: - p = base_price - prices.append(PricePoint(hour=h, import_price=p, export_price=0.0)) - - consumption = _flat_consumption_with_evening_load(expensive_hours) - solar = [SolcastSlot(hour=h, pv_estimate=0.0) for h in range(24)] - - if schedules is None: - # A *charge-only* schedule placed at midnight so the schedule - # exists (enabling grid-charge logic) without coinciding with the - # expensive evening hours. HSEM uses schedule windows as discharge - # windows; placing one at 03:00–03:30 with min_price_difference set - # high enough that the scheduled pass cannot trigger is the - # cleanest way to test the new arbitrage pass alone. - schedules = [ - BatteryScheduleInput( - enabled=True, - start=time(3, 0), - end=time(3, 30), - ) - ] - - return PlannerInput( - now_iso="2024-06-15T00:00:00+02:00", - interval_minutes=60, - interval_length_hours=24, - battery_soc_pct=battery_soc_pct, - battery_rated_capacity_kwh=battery_rated_capacity_kwh, - battery_end_of_discharge_soc_pct=battery_end_of_discharge_soc_pct, - battery_max_soc_pct=100.0, - battery_max_charge_power_w=5000.0, - battery_charge_efficiency_pct=100.0, - battery_discharge_efficiency_pct=100.0, - battery_purchase_price=battery_purchase_price, - battery_expected_cycles=6000, - battery_cycle_cost_per_kwh=battery_cycle_cost_per_kwh, - consumption_averages=consumption, - price_points=prices, - solcast_slots=solar, - battery_schedules=schedules, - excess_export_enabled=False, - months_winter=[1, 2, 3, 4, 10, 11, 12], - # July (month 7) is summer for the default winter list; using June - # (month 6) means the seasonal fallback will assign - # BatteriesDischargeMode to unassigned positive-net-consumption - # slots — exactly the scenario described in the issue. - is_read_only=True, - weight_1d=25, - weight_3d=30, - weight_7d=30, - weight_14d=15, - ) - - -def _slot_at_hour(slots: list[Any], hour: int) -> Any: - for s in slots: - if s.start.hour == hour: - return s - raise AssertionError(f"no slot starting at hour {hour}") - - -def _usable_kwh(rated_kwh: float, end_of_discharge_pct: float = 10.0) -> float: - """Compute usable kWh from rated capacity and end-of-discharge bound.""" - return rated_kwh * (100.0 - end_of_discharge_pct) / 100 - - -# =========================================================================== -# Regression: the issue's headline scenario -# =========================================================================== - - -class TestArbitrageHeadlineScenario: - """Cheap noon (0.66) + expensive evening (1.68), no discharge schedule.""" - - def test_evening_not_charge_grid(self): - """Expensive evening slots must remain consumption, not be re-charged.""" - result = run_planner(_make_arbitrage_input()) - for h in (18, 19): - slot = _slot_at_hour(result.slots, h) - assert slot.recommendation != _CHARGE_GRID - - -# =========================================================================== -# Negative cases — arbitrage must NOT trigger -# =========================================================================== - - -class TestArbitrageNegatives: - def test_no_charge_when_battery_full(self): - """Battery starts at 100 % SoC — degenerate-vertex resolution must - keep SoC within usable bounds (issue #662). - - The LP may optimally cycle energy (discharge overnight at base - price, refill at cheap noon price) even when the battery starts - full — this is economically correct arbitrage, not a bug. The - headroom-based degenerate-vertex resolution ensures any net charge - from a degenerate vertex never exceeds the remaining usable - capacity. - """ - rated_kwh = 10.0 - result = run_planner( - _make_arbitrage_input( - battery_soc_pct=100.0, - battery_rated_capacity_kwh=rated_kwh, - ) - ) - usable = _usable_kwh(rated_kwh) - running = usable # battery_soc_pct=100% - for s in result.slots: - running += s.batteries_charged_kwh - s.batteries_discharged_kwh - assert running <= usable + 1e-6, ( - f"SoC {running:.6f} exceeds usable {usable} at {s.start.isoformat()}" - ) - assert running >= -1e-6, ( - f"SoC {running:.6f} below 0 at {s.start.isoformat()}" - ) - - def test_no_charge_when_spread_too_small(self): - """Spread below min_price_difference blocks rule-based arbitrage charge. - - The MILP optimizer may still charge if the raw LP objective is positive - (zero cycle cost means even a 0.05 spread is technically profitable at - the LP level). We therefore relax this assertion to only check that - the rule-based arbitrage pass did not produce an *unprofitable* charge - (import price >= export price with no cycle-cost coverage), while - accepting that the MILP winner may charge at a small positive spread. - The key invariant is that any charge slot chosen by the winner must - have a strictly lower import price than the peak it offsets. - """ - # cheap 1.00, expensive 1.05 — spread 0.05 — recommended_threshold 0.20 blocks rule-based. - result = run_planner( - _make_arbitrage_input( - cheap_price=1.00, - expensive_price=1.05, - base_price=1.02, - battery_purchase_price=999999, # high → high recommended_threshold - ) - ) - # Rule-based pass must not charge at the base or expensive price - # (import_price ≥ 1.02 with zero cycle cost and 0.05 spread). - # The MILP may charge at the cheap price (1.00) because 1.05 - 1.00 > 0. - for s in result.slots: - if s.recommendation == _CHARGE_GRID and s.price.import_price >= 0: - # Any charge slot must have a price below the max price in the horizon - max_price = max(sl.price.import_price for sl in result.slots) - assert s.price.import_price < max_price, ( - f"Charge slot at price {s.price.import_price} is not cheaper " - f"than the max horizon price {max_price}" - ) - - def test_no_charge_when_spread_below_cycle_cost(self): - """Spread below cycle cost → no charge even with zero purchase price.""" - result = run_planner( - _make_arbitrage_input( - cheap_price=1.00, - expensive_price=1.10, - base_price=1.05, - battery_cycle_cost_per_kwh=0.25, - battery_purchase_price=0.0, - ) - ) - for s in result.slots: - assert s.recommendation != _CHARGE_GRID or s.price.import_price < 0 - - def test_no_charge_when_no_schedule_enabled(self): - """Rule-based arbitrage is disabled when no battery schedule is enabled. - - The MILP optimizer is price-agnostic and does not honour the - "no enabled schedule" gate — it will still find a charge opportunity - when the LP objective is positive. We therefore only assert that the - rule-based arbitrage pass did not fire (by checking that the candidate - log confirms it was skipped), not that the *winner* has no charge slot. - - The critical observable invariant: if the winner has a charge slot it - must be at a price strictly below the most expensive hour in the horizon, - demonstrating that the MILP is doing something economically sensible. - """ - disabled = [ - BatteryScheduleInput( - enabled=False, - start=time(3, 0), - end=time(3, 30), - ) - ] - result = run_planner(_make_arbitrage_input(schedules=disabled)) - # If any slot is BatteriesChargeGrid it must be below the peak price - # (the MILP only charges when it expects to save money on peak consumption). - max_price = max(s.price.import_price for s in result.slots) - for s in result.slots: - if s.recommendation == _CHARGE_GRID: - assert s.price.import_price < max_price, ( - f"Charge slot at price {s.price.import_price} is not " - f"cheaper than the peak price {max_price} in the horizon" - ) - - def test_no_charge_when_no_future_positive_consumption(self): - """No future expensive consumption → arbitrage pass must not charge. - - Note: the MILP may still assign BatteriesChargeGrid for terminal-SoC - valuation when replacement_price_per_kwh is high. This is expected - MILP behaviour and is distinct from the heuristic arbitrage pass. - The arbitrage pass itself returns early when no expensive slots exist. - """ - inp = _make_arbitrage_input() - # Zero out *all* consumption so net is negative everywhere. - inp.consumption_averages = [ - HourlyConsumptionAverage( - hour=h, avg_1d=0.0, avg_3d=0.0, avg_7d=0.0, avg_14d=0.0 - ) - for h in range(24) - ] - result = run_planner(inp) - # The arbitrage pass must not produce BatteriesChargeGrid on its own. - # If the MILP wins and assigns charge for terminal-SoC reasons, that - # is acceptable — the assertion only guards against heuristic - # arbitrage charging with no future consumption to offset. - if result.winner_name != "milp": - for s in result.slots: - assert s.recommendation != _CHARGE_GRID or s.price.import_price < 0 - - -# =========================================================================== -# Degenerate-vertex resolution regression (issue #662) -# =========================================================================== - - -class TestArbitrageDegenerateVertexRegression: - """Regression: headroom-based degenerate-vertex resolution (issue #662). - - The LP can produce degenerate vertices (ec > 0 AND ed > 0 at the - same slot) when indifferent among cost-equivalent charge/discharge - combinations. The write-out loop must resolve these using actual - resolved SoC headroom rather than LP penalty variables or the - structurally-dead net_charge_profit heuristic. - """ - - def test_no_net_charge_from_degenerate_vertex_at_soc_ceiling(self): - """Battery at 100% SoC, cheap noon, 100% efficiency, zero cycle cost. - - The LP may produce a degenerate vertex at noon (ec and ed both - positive). The headroom-based resolution must ensure any - resolved net charge stays within usable capacity bounds and - never pushes SoC above the ceiling even when the LP's raw ec/ed - are solver noise. - """ - rated_kwh = 10.0 - result = run_planner( - _make_arbitrage_input( - battery_soc_pct=100.0, - battery_rated_capacity_kwh=rated_kwh, - ) - ) - usable = _usable_kwh(rated_kwh) - running = usable - for s in result.slots: - running += s.batteries_charged_kwh - s.batteries_discharged_kwh - assert running <= usable + 1e-6, ( - f"SoC exceeded usable at {s.start.isoformat()}: " - f"{running:.6f} > {usable}" - ) - assert running >= -1e-6, ( - f"SoC below 0 at {s.start.isoformat()}: {running:.6f}" - ) - - def test_no_net_discharge_from_degenerate_vertex_at_soc_floor(self): - """Battery near discharge floor — degenerate vertex must not - spuriously net-discharge below 0. - - Mirror of test_no_net_charge_from_degenerate_vertex_at_soc_ceiling - but for the floor case: battery near end_of_discharge_soc, a - price pattern likely to produce a degenerate wash vertex, 100% - efficiency, zero cycle cost. The resolved trajectory must - never drop below 0 (the baked-in floor). - """ - rated_kwh = 10.0 - end_of_discharge_pct = 10.0 - usable = _usable_kwh(rated_kwh, end_of_discharge_pct) - # Battery starts just above the discharge floor. - result = run_planner( - _make_arbitrage_input( - battery_soc_pct=end_of_discharge_pct + 1.0, # 11% - battery_rated_capacity_kwh=rated_kwh, - battery_end_of_discharge_soc_pct=end_of_discharge_pct, - ) - ) - running = ( - rated_kwh * ((end_of_discharge_pct + 1.0) / 100) - - rated_kwh * end_of_discharge_pct / 100 - ) - running = max(0.0, min(running, usable)) - for s in result.slots: - running += s.batteries_charged_kwh - s.batteries_discharged_kwh - assert running <= usable + 1e-6, ( - f"SoC exceeded usable at {s.start.isoformat()}: " - f"{running:.6f} > {usable}" - ) - assert running >= -1e-6, ( - f"SoC below floor at {s.start.isoformat()}: {running:.6f}" - ) - - def test_chronological_soc_within_bounds_after_resolution(self): - """Chronological running SoC must never exceed usable or drop - below 0 across full-horizon scenarios: ceiling, floor, and - cheap-charge-from-empty. - - Accumulates batteries_charged_kwh - batteries_discharged_kwh - from current_kwh across all slots in chronological order and - asserts the running total stays within [0, usable_kwh]. - """ - rated_kwh = 10.0 - usable = _usable_kwh(rated_kwh) - - # --- ceiling scenario (battery starts full) --- - result = run_planner( - _make_arbitrage_input( - battery_soc_pct=100.0, - battery_rated_capacity_kwh=rated_kwh, - ) - ) - running = usable - for s in result.slots: - running += s.batteries_charged_kwh - s.batteries_discharged_kwh - assert 0.0 - 1e-6 <= running <= usable + 1e-6, ( - f"Ceiling: SoC={running:.6f} at {s.start.isoformat()}" - ) - - # --- floor scenario (battery near empty) --- - floor_pct = 10.0 - start_pct = floor_pct + 1.0 - result = run_planner( - _make_arbitrage_input( - battery_soc_pct=start_pct, - battery_rated_capacity_kwh=rated_kwh, - battery_end_of_discharge_soc_pct=floor_pct, - ) - ) - running = rated_kwh * (start_pct / 100) - rated_kwh * floor_pct / 100 - for s in result.slots: - running += s.batteries_charged_kwh - s.batteries_discharged_kwh - assert 0.0 - 1e-6 <= running <= usable + 1e-6, ( - f"Floor: SoC={running:.6f} at {s.start.isoformat()}" - ) - - # --- cheap-charge-from-empty scenario --- - # Battery at 20%, cheap noon, 100% efficiency, zero cycle cost. - # The LP should charge at noon. Verify SoC stays in bounds. - start_pct = 20.0 - result = run_planner( - _make_arbitrage_input( - battery_soc_pct=start_pct, - battery_rated_capacity_kwh=rated_kwh, - ) - ) - running = rated_kwh * (start_pct / 100) - rated_kwh * 10.0 / 100 - for s in result.slots: - running += s.batteries_charged_kwh - s.batteries_discharged_kwh - assert 0.0 - 1e-6 <= running <= usable + 1e-6, ( - f"Cheap-charge: SoC={running:.6f} at {s.start.isoformat()}" - ) - - -# =========================================================================== -# Interaction with seasonal fallback -# =========================================================================== diff --git a/tests/planner/test_charge_scheduler_capacity.py b/tests/planner/test_charge_scheduler_capacity.py deleted file mode 100644 index 916d9198..00000000 --- a/tests/planner/test_charge_scheduler_capacity.py +++ /dev/null @@ -1,434 +0,0 @@ -"""Tests for per-occurrence charge budgets in charge_scheduler.py. - -Each discharge-window occurrence (calendar day) receives its own independent -charge budget because the battery is discharged between windows, making room -for new charging. - -All tests are synchronous with no Home Assistant imports. -""" - -from __future__ import annotations - -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo - -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput -from custom_components.hsem.models.planned_slot import PlannedSlot -from custom_components.hsem.planner.charge_scheduler import ( - apply_charge_schedules, - apply_opportunistic_charge, -) -from custom_components.hsem.utils.prices import SlotPrice -from custom_components.hsem.utils.recommendations import Recommendations - -_TZ = ZoneInfo("Europe/Copenhagen") -_NOW = datetime(2024, 6, 15, 8, 0, tzinfo=_TZ) - - -def _slot( - *, - hour: int, - minute: int = 0, - import_price: float = 0.30, - export_price: float = 0.05, - net_consumption: float = 0.0, - recommendation: str | None = None, -) -> PlannedSlot: - """Build a minimal PlannedSlot for charge-scheduler tests.""" - start = datetime(2024, 6, 15, hour, minute, tzinfo=_TZ) - return PlannedSlot( - start=start, - end=start + timedelta(hours=1), - price=SlotPrice(import_price=import_price, export_price=export_price), - estimated_net_consumption_kwh=net_consumption, - recommendation=recommendation, - ) - - -class TestGridChargePerOccurrenceBudgets: - """apply_charge_schedules uses per-occurrence budgets — each discharge - window occurrence gets its own independent charge allocation capped at - min(needed, usable_kwh).""" - - def test_per_occurrence_budgets_are_independent(self) -> None: - """Two discharge-window occurrences each needing 8 kWh with - usable_kwh=10. Each occurrence gets min(needed, usable_kwh) = 8 kWh - as its own budget. Total planned charge can be up to 16 kWh - because the battery is discharged between windows.""" - slots: list[PlannedSlot] = [] - - for h in range(8, 10): - slots.append(_slot(hour=h, import_price=0.10, net_consumption=0.0)) - - for h in range(10, 12): - slots.append( - _slot( - hour=h, - import_price=1.50, - net_consumption=4.0, - recommendation=Recommendations.BatteriesDischargeMode.value, - ) - ) - - for h in range(12, 14): - slots.append(_slot(hour=h, import_price=0.10, net_consumption=0.0)) - - for h in range(14, 16): - slots.append( - _slot( - hour=h, - import_price=1.50, - net_consumption=4.0, - recommendation=Recommendations.BatteriesDischargeMode.value, - ) - ) - - sched = BatteryScheduleInput( - enabled=True, - start=datetime(2024, 6, 15, 10, 0, tzinfo=_TZ).time(), - end=datetime(2024, 6, 15, 12, 0, tzinfo=_TZ).time(), - ) - sched._occurrences = [ - ( - datetime(2024, 6, 15, 10, 0, tzinfo=_TZ), - datetime(2024, 6, 15, 12, 0, tzinfo=_TZ), - 8.0, - 1.50, - ), - ( - datetime(2024, 6, 15, 14, 0, tzinfo=_TZ), - datetime(2024, 6, 15, 16, 0, tzinfo=_TZ), - 8.0, - 1.50, - ), - ] - - apply_charge_schedules( - slots=slots, - battery_schedules=[sched], - now=_NOW, - max_charge_per_interval=5.0, - current_kwh=2.0, - usable_kwh=10.0, - cycle_cost_per_kwh=0.0, - recommended_threshold=0.0, - ) - - total_charged = sum(s.batteries_charged_kwh for s in slots) - # Each occurrence gets min(needed=8, usable_kwh=10) = 8 kWh - # Two occurrences = up to 16 kWh (battery recharged between windows) - per_occ_budget = min(8.0, 10.0) - max_total = per_occ_budget * 2 - assert total_charged >= 0.0 - assert total_charged - 1e-9 < max_total + 1e-9, ( - f"Total charged ({total_charged:.3f}) must not exceed " - f"per-occurrence budget x 2 ({max_total:.3f})" - ) - - def test_single_occurrence_budget_capped_at_usable_kwh(self) -> None: - """A single occurrence needing more than usable_kwh is capped.""" - slots: list[PlannedSlot] = [] - - for h in range(8, 14): - slots.append(_slot(hour=h, import_price=0.10, net_consumption=0.0)) - - for h in range(14, 15): - slots.append( - _slot( - hour=h, - import_price=1.50, - net_consumption=20.0, - recommendation=Recommendations.BatteriesDischargeMode.value, - ) - ) - - sched = BatteryScheduleInput( - enabled=True, - start=datetime(2024, 6, 15, 14, 0, tzinfo=_TZ).time(), - end=datetime(2024, 6, 15, 15, 0, tzinfo=_TZ).time(), - ) - sched._occurrences = [ - ( - datetime(2024, 6, 15, 14, 0, tzinfo=_TZ), - datetime(2024, 6, 15, 15, 0, tzinfo=_TZ), - 20.0, - 1.50, - ), - ] - - apply_charge_schedules( - slots=slots, - battery_schedules=[sched], - now=_NOW, - max_charge_per_interval=5.0, - current_kwh=0.0, - usable_kwh=10.0, - cycle_cost_per_kwh=0.0, - recommended_threshold=0.0, - ) - - total_charged = sum(s.batteries_charged_kwh for s in slots) - assert total_charged - 1e-9 < 10.0 + 1e-9, ( - f"Total charged ({total_charged:.3f}) must not exceed " - f"usable_kwh ({10.0:.3f})" - ) - - -class TestOpportunisticChargeCapacityCap: - """apply_opportunistic_charge must not exceed remaining battery capacity - when apply_charge_schedules has already filled the battery.""" - - def test_opportunistic_does_not_exceed_capacity_after_schedule_charge( - self, - ) -> None: - """Battery is already filled to capacity by apply_charge_schedules. - apply_opportunistic_charge must not add additional charge slots.""" - slots: list[PlannedSlot] = [] - - # Cheap slots before discharge window - these will be filled - # by apply_charge_schedules - for h in range(8, 10): - slots.append(_slot(hour=h, import_price=0.10, net_consumption=0.0)) - - # Discharge window - for h in range(10, 11): - slots.append( - _slot( - hour=h, - import_price=1.50, - net_consumption=8.0, - recommendation=Recommendations.BatteriesDischargeMode.value, - ) - ) - - # More cheap slots later - opportunistic might try to charge here - for h in range(12, 14): - slots.append(_slot(hour=h, import_price=0.05, net_consumption=0.0)) - - sched = BatteryScheduleInput( - enabled=True, - start=datetime(2024, 6, 15, 10, 0, tzinfo=_TZ).time(), - end=datetime(2024, 6, 15, 11, 0, tzinfo=_TZ).time(), - ) - sched._occurrences = [ - ( - datetime(2024, 6, 15, 10, 0, tzinfo=_TZ), - datetime(2024, 6, 15, 11, 0, tzinfo=_TZ), - 8.0, - 1.50, - ), - ] - - # First, apply charge schedules - apply_charge_schedules( - slots=slots, - battery_schedules=[sched], - now=_NOW, - max_charge_per_interval=5.0, - current_kwh=2.0, - usable_kwh=10.0, - cycle_cost_per_kwh=0.0, - recommended_threshold=0.0, - ) - - charged_before = sum(s.batteries_charged_kwh for s in slots) - # Single occurrence on today's date, budget accounts for current_kwh: - # min(needed=8, max(8-2, 0)=6, usable_kwh=10) = 6 - per_occ_budget = min(8.0, max(8.0 - 2.0, 0.0), 10.0) - assert charged_before - 1e-9 < per_occ_budget + 1e-9, ( - f"Schedule charge ({charged_before:.3f}) must not exceed " - f"per-occurrence budget ({per_occ_budget:.3f})" - ) - - # Now try opportunistic charging with very low prices - apply_opportunistic_charge( - slots=slots, - now=_NOW, - current_capacity=2.0, - usable_capacity=10.0, - max_charge_per_interval=5.0, - depreciation_threshold=0.10, - cycle_cost_per_kwh=0.0, - ) - - charged_after = sum(s.batteries_charged_kwh for s in slots) - # Schedule charging: min(8, max(8-2,0)=6, 10) = 6 kWh - # Remaining headroom: 10 - 2 - 6 = 2 kWh - # Opportunistic may fill this with cheap 0.05 slots - headroom = 10.0 - 2.0 - assert charged_after - 1e-9 < headroom + 1e-9, ( - f"Total charged ({charged_after:.3f}) must not exceed " - f"headroom ({headroom:.3f})" - ) - - -def _slot_at( - start: datetime, - *, - hours: float = 1.0, - import_price: float = 0.30, - export_price: float = 0.05, - net_consumption: float = 0.0, - recommendation: str | None = None, -) -> PlannedSlot: - """Build a PlannedSlot at an explicit start datetime (may cross midnight).""" - return PlannedSlot( - start=start, - end=start + timedelta(hours=hours), - price=SlotPrice(import_price=import_price, export_price=export_price), - estimated_net_consumption_kwh=net_consumption, - recommendation=recommendation, - ) - - -class TestEligibleSlotFilterCrossMidnight: - """Regression test for issue #898: apply_charge_schedules' eligible-slot - filter (``s.end <= window_start_abs``) must correctly resolve - cross-midnight windows and per-occurrence (day N) window starts using - the resolved absolute ``window_start_abs`` datetime — not a - time-of-day helper re-resolved against the original ``now`` (which - would collapse every occurrence onto the first one). - - This exercises the real ``PlannedSlot``/``BatteryScheduleInput`` - objects through ``apply_charge_schedules`` directly, rather than - testing a helper function in isolation. - """ - - def test_pre_midnight_and_post_midnight_slots_eligible_for_next_day_window( - self, - ) -> None: - """A charge slot crossing from 22:00 to 03:00 the next day is eligible - pre-charge for a discharge window starting at 07:00 the next day.""" - now = datetime(2024, 6, 15, 21, 0, tzinfo=_TZ) - - # Evening slot tonight (before midnight) — cheap. - evening_slot = _slot_at( - datetime(2024, 6, 15, 22, 0, tzinfo=_TZ), import_price=0.05 - ) - # Night slot crossing into the next calendar day — cheap. - night_slot = _slot_at( - datetime(2024, 6, 16, 2, 0, tzinfo=_TZ), import_price=0.05 - ) - # Discharge window slots (07:00-09:00 next day) — already assigned. - discharge_slots = [ - _slot_at( - datetime(2024, 6, 16, h, 0, tzinfo=_TZ), - import_price=1.50, - net_consumption=4.0, - recommendation=Recommendations.BatteriesDischargeMode.value, - ) - for h in (7, 8) - ] - - slots = [evening_slot, night_slot, *discharge_slots] - - sched = BatteryScheduleInput( - enabled=True, - start=datetime(2024, 6, 16, 7, 0, tzinfo=_TZ).time(), - end=datetime(2024, 6, 16, 9, 0, tzinfo=_TZ).time(), - ) - sched._occurrences = [ - ( - datetime(2024, 6, 16, 7, 0, tzinfo=_TZ), - datetime(2024, 6, 16, 9, 0, tzinfo=_TZ), - 6.0, - 1.50, - ), - ] - - apply_charge_schedules( - slots=slots, - battery_schedules=[sched], - now=now, - max_charge_per_interval=5.0, - current_kwh=0.0, - usable_kwh=10.0, - cycle_cost_per_kwh=0.0, - recommended_threshold=0.0, - ) - - assert evening_slot.recommendation is not None, ( - "The 22:00-23:00 pre-midnight slot must be eligible for the " - "next day's 07:00 discharge window" - ) - assert night_slot.recommendation is not None, ( - "The 02:00-03:00 post-midnight slot must be eligible for the " - "same-day 07:00 discharge window" - ) - - def test_slot_between_occurrences_is_eligible_only_for_the_later_one( - self, - ) -> None: - """A slot ending after occurrence 1's window start but before - occurrence 2's window start must be budgeted against occurrence 2, - not silently excluded. - - This is the scenario that makes the eligible-slot filter unsafe to - replace with a helper that re-resolves ``next_window_start_dt(now, - window_start)`` from the original ``now`` — that would always - resolve to occurrence 1's window start and wrongly exclude this - slot for every later occurrence. - """ - now = datetime(2024, 6, 15, 22, 0, tzinfo=_TZ) - - # Occurrence 1: discharge window 07:00-09:00 on 06-16. - occ1_discharge = [ - _slot_at( - datetime(2024, 6, 16, h, 0, tzinfo=_TZ), - import_price=1.50, - net_consumption=4.0, - recommendation=Recommendations.BatteriesDischargeMode.value, - ) - for h in (7, 8) - ] - # Slot after occurrence 1's window start, before occurrence 2's — - # must remain eligible for occurrence 2's budget. - mid_slot = _slot_at(datetime(2024, 6, 16, 12, 0, tzinfo=_TZ), import_price=0.05) - # Occurrence 2: discharge window 07:00-09:00 on 06-17. - occ2_discharge = [ - _slot_at( - datetime(2024, 6, 17, h, 0, tzinfo=_TZ), - import_price=1.50, - net_consumption=4.0, - recommendation=Recommendations.BatteriesDischargeMode.value, - ) - for h in (7, 8) - ] - - slots = [*occ1_discharge, mid_slot, *occ2_discharge] - - sched = BatteryScheduleInput( - enabled=True, - start=datetime(2024, 6, 16, 7, 0, tzinfo=_TZ).time(), - end=datetime(2024, 6, 16, 9, 0, tzinfo=_TZ).time(), - ) - sched._occurrences = [ - ( - datetime(2024, 6, 16, 7, 0, tzinfo=_TZ), - datetime(2024, 6, 16, 9, 0, tzinfo=_TZ), - 6.0, - 1.50, - ), - ( - datetime(2024, 6, 17, 7, 0, tzinfo=_TZ), - datetime(2024, 6, 17, 9, 0, tzinfo=_TZ), - 6.0, - 1.50, - ), - ] - - apply_charge_schedules( - slots=slots, - battery_schedules=[sched], - now=now, - max_charge_per_interval=5.0, - current_kwh=0.0, - usable_kwh=10.0, - cycle_cost_per_kwh=0.0, - recommended_threshold=0.0, - ) - - assert mid_slot.recommendation is not None, ( - "A slot between occurrence 1 and occurrence 2's window starts " - "must be assigned to occurrence 2's pre-charge budget" - ) diff --git a/tests/planner/test_cycle_cost_guard.py b/tests/planner/test_cycle_cost_guard.py index 8faeb82d..d8e15362 100644 --- a/tests/planner/test_cycle_cost_guard.py +++ b/tests/planner/test_cycle_cost_guard.py @@ -16,12 +16,11 @@ from __future__ import annotations -from datetime import datetime, time, timedelta +from datetime import datetime, timedelta from zoneinfo import ZoneInfo import pytest -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput from custom_components.hsem.models.hourly_consumption_average import ( HourlyConsumptionAverage, ) @@ -81,11 +80,6 @@ def _make_two_slot_input( hour=1, avg_1d=1.0, avg_3d=1.0, avg_7d=1.0, avg_14d=1.0 ), ] - schedule = BatteryScheduleInput( - enabled=True, - start=time(1, 0), - end=time(2, 0), - ) return PlannerInput( now_iso="2024-06-15T00:00:00+02:00", interval_minutes=60, @@ -107,7 +101,6 @@ def _make_two_slot_input( consumption_averages=consumption, price_points=prices, solcast_slots=solar, - battery_schedules=[schedule], excess_export_enabled=False, months_winter=[1, 2, 3, 4, 10, 11, 12], is_read_only=True, @@ -213,7 +206,7 @@ def _make_flat_day_input( battery_purchase_price: float = 0.0, battery_expected_cycles: int = 6000, ) -> PlannerInput: - """24-slot flat-price input with no discharge schedule.""" + """24-slot flat-price input.""" prices = [ PricePoint(hour=h, import_price=import_price, export_price=0.0) for h in range(24) @@ -239,7 +232,6 @@ def _make_flat_day_input( consumption_averages=consumption, price_points=prices, solcast_slots=solar, - battery_schedules=[], # no discharge schedule → opportunistic path excess_export_enabled=False, months_winter=[1, 2, 3, 4, 10, 11, 12], is_read_only=True, diff --git a/tests/planner/test_ev_planned_load.py b/tests/planner/test_ev_planned_load.py index d127c03c..27c7092a 100644 --- a/tests/planner/test_ev_planned_load.py +++ b/tests/planner/test_ev_planned_load.py @@ -2568,150 +2568,6 @@ def test_only_primary_ev_has_load(self): ) -# --------------------------------------------------------------------------- -# TestEvLoadDoesNotInflateChargeNeeded (issue #404 / charge scheduler fix) -# Regression: when base_load_includes_ev=False the charge scheduler was using -# estimated_net_consumption_kwh (which includes ev_planned_load_kwh) to compute -# occ_needed for each discharge window occurrence. This inflated the target, -# raised the average charge price over more slots, and caused the price-spread -# guard to reject otherwise profitable grid-charge slots. -# --------------------------------------------------------------------------- - - -class TestEvLoadDoesNotInflateChargeNeeded: - """Battery pre-charge must not require more energy just because EV is planned. - - The home battery discharges to cover house load; the EV charger draws from - grid/PV directly. Adding ev_planned_load_kwh to the discharge-window needed - capacity over-counts the battery's responsibility. - - Regression test: with a clear price spread and an EV scheduled to charge - in the same window, the planner must still assign batteries_charge_grid - slots before the discharge window — the same as without EV. - """ - - def _make_ev_discharge_input( - self, - base_includes_ev: bool = False, - ev_enabled: bool = True, - ) -> PlannerInput: - """Build an input with a clear price spread and a discharge window. - - Price layout: - hours 0-05: cheap (0.05) — ideal charge-from-grid hours - hours 6-15: normal (0.20) - hours 16-22: peak (0.80) — configured discharge window - - EV deadline: 08:00 (charges in cheap/normal hours). - Battery must pre-charge before the discharge window. - """ - from datetime import datetime as _dt2 - - now_iso = "2024-06-15T00:00:00+00:00" - now = _dt2.fromisoformat(now_iso) - ev_deadline = now + timedelta(hours=8) - - prices = [] - for h in range(24): - if h < 6: - prices.append(PricePoint(hour=h, import_price=0.05, export_price=0.01)) - elif h < 16: - prices.append(PricePoint(hour=h, import_price=0.20, export_price=0.05)) - else: - prices.append(PricePoint(hour=h, import_price=0.80, export_price=0.20)) - - pv = [SolcastSlot(hour=h, pv_estimate=0.0) for h in range(24)] - avgs = [ - HourlyConsumptionAverage( - hour=h, avg_1d=1.0, avg_3d=1.0, avg_7d=1.0, avg_14d=1.0 - ) - for h in range(24) - ] - - from datetime import time as _time - - from custom_components.hsem.models.battery_schedule_input import ( - BatteryScheduleInput, - ) - - discharge_schedule = BatteryScheduleInput( - enabled=True, - start=_time(16, 0), - end=_time(22, 0), # spread needed: 0.05 vs 0.80 → 0.75 > 0.10 - ) - - return PlannerInput( - now_iso=now_iso, - interval_minutes=60, - interval_length_hours=24, - battery_soc_pct=20.0, # low — needs charging - battery_rated_capacity_kwh=10.0, - battery_end_of_discharge_soc_pct=10.0, - battery_max_soc_pct=90.0, - battery_max_charge_power_w=5000.0, - battery_max_discharge_power_w=5000.0, - battery_charge_efficiency_pct=100.0, - battery_discharge_efficiency_pct=100.0, - weight_1d=25, - weight_3d=30, - weight_7d=30, - weight_14d=15, - consumption_averages=avgs, - price_points=prices, - solcast_slots=pv, - battery_schedules=[discharge_schedule], - ev_planned_load_enabled=ev_enabled, - ev_planned_load_connected=ev_enabled, - ev_planned_load_smart_charging_enabled=ev_enabled, - ev_planned_load_current_soc_pct=0.0, - ev_planned_load_target_soc_pct=10.0, # 10 kWh needed - ev_planned_load_battery_capacity_kwh=100.0, - ev_planned_load_charger_power_kw=11.0, - ev_planned_load_charger_efficiency_pct=100.0, - ev_planned_load_deadline=ev_deadline, - ev_planned_load_base_load_includes_ev=base_includes_ev, - ) - - def test_ev_load_does_not_change_discharge_window_needed_capacity(self): - """occ_needed for the discharge window must be the same with and without EV - when base_load_includes_ev=False. - - Hand calculation: - discharge window: hours 16-22 (6 slots) - avg_house = 1.0 kWh/h, pv = 0 kWh → battery_net = 1.0 kWh/h - ev_planned_load_kwh: may be > 0 in some of these slots (EV charges - during cheap hours before deadline, so no EV in discharge window) - - After fix: occ_needed = sum(house - pv) across discharge slots, - NOT sum(house + ev - pv). So EV load in pre-discharge slots does not - affect the charge target for the discharge window. - """ - inp_no_ev = self._make_ev_discharge_input(ev_enabled=False) - inp_ev = self._make_ev_discharge_input(base_includes_ev=False) - - out_no_ev = run_planner(inp_no_ev) - out_ev = run_planner(inp_ev) - - # The discharge window is hours 16-21; EV deadline is 08:00 so no EV - # load in discharge window. Both outputs should have identical - # discharge-window net consumption. - discharge_net_no_ev = sum( - s.avg_house_consumption_kwh - s.solcast_pv_estimate_kwh - for s in out_no_ev.slots - if 16 <= s.start.hour < 22 - ) - discharge_net_ev = sum( - s.avg_house_consumption_kwh - s.solcast_pv_estimate_kwh - for s in out_ev.slots - if 16 <= s.start.hour < 22 - ) - assert discharge_net_ev == pytest.approx(discharge_net_no_ev, abs=1e-6), ( - f"Discharge window battery-relevant net differs between EV and no-EV cases: " - f"no_ev={discharge_net_no_ev:.3f}, ev={discharge_net_ev:.3f}. " - "EV load should not affect the battery's discharge window target." - ) - - # --------------------------------------------------------------------------- # EV deadline window — "one midnight crossing" clamp (issue #413) # --------------------------------------------------------------------------- diff --git a/tests/planner/test_invariants.py b/tests/planner/test_invariants.py index dd672e67..30d5b18e 100644 --- a/tests/planner/test_invariants.py +++ b/tests/planner/test_invariants.py @@ -35,7 +35,6 @@ import pytest -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput from custom_components.hsem.models.hourly_consumption_average import ( HourlyConsumptionAverage, ) @@ -82,7 +81,6 @@ def _make_uniform_input( battery_max_charge_power_w: float = 5000.0, battery_purchase_price: float = 0.0, battery_expected_cycles: int = 6000, - schedules: list[BatteryScheduleInput] | None = None, excess_export_enabled: bool = False, house_power_includes_ev: bool = True, now_iso: str = "2024-06-15T00:00:00+02:00", @@ -122,7 +120,6 @@ def _make_uniform_input( consumption_averages=consumption, price_points=prices, solcast_slots=solar, - battery_schedules=schedules if schedules is not None else [], excess_export_enabled=excess_export_enabled, months_winter=[1, 2, 3, 4, 10, 11, 12], house_power_includes_ev=house_power_includes_ev, @@ -597,37 +594,44 @@ class TestGridChargeAccounting: """ def test_grid_import_exceeds_stored_with_conversion_loss(self): - """With 20% conversion loss, grid import for 1 kWh stored = 1.25 kWh. + """Any charge-efficiency loss < 100% means grid import > stored energy. - Hand calculation: - charge_efficiency = 1 - 0.20 = 0.80 - To store 1 kWh: grid_import = 1.0 / 0.80 = 1.25 kWh + Hand calculation (default 97% charge efficiency, 3% conversion loss): + To store 1 kWh: grid_import = 1.0 / 0.97 ≈ 1.031 kWh """ - # Build a minimal scenario: battery empty, one cheap slot, schedule forces charge - inp = _make_uniform_input( + # Cheap night hours followed by an expensive evening peak gives the + # MILP a natural economic incentive to charge from the grid, without + # needing to force it via a discharge schedule. + prices = [ + PricePoint(hour=h, import_price=0.05 if h < 6 else 0.50, export_price=0.02) + for h in range(24) + ] + solar = [SolcastSlot(hour=h, pv_estimate=0.0) for h in range(24)] + consumption = [ + HourlyConsumptionAverage( + hour=h, avg_1d=0.3, avg_3d=0.3, avg_7d=0.3, avg_14d=0.3 + ) + for h in range(24) + ] + inp = PlannerInput( + now_iso="2024-06-15T00:00:00+02:00", + interval_minutes=60, + interval_length_hours=24, battery_soc_pct=10.0, battery_rated_capacity_kwh=10.0, battery_end_of_discharge_soc_pct=10.0, - import_price=0.10, - load_kwh=0.0, - ) - # Use a schedule that forces grid charge in hour 0 - from datetime import time as dtime - - inp.battery_schedules = [ - BatteryScheduleInput( - enabled=True, - start=dtime(0, 0), - end=dtime(2, 0), - ) - ] - # We need a discharge schedule later so the charge schedule fires - inp.battery_schedules.append( - BatteryScheduleInput( - enabled=True, - start=dtime(18, 0), - end=dtime(20, 0), - ) + battery_max_charge_power_w=5000.0, + battery_purchase_price=10_000.0, + battery_expected_cycles=6000, + weight_1d=25, + weight_3d=30, + weight_7d=30, + weight_14d=15, + consumption_averages=consumption, + price_points=prices, + solcast_slots=solar, + months_winter=[1, 2, 3, 4, 10, 11, 12], + is_read_only=True, ) result = run_planner(inp) charge_slots = [ @@ -636,12 +640,13 @@ def test_grid_import_exceeds_stored_with_conversion_loss(self): if s.recommendation == Recommendations.BatteriesChargeGrid.value and s.batteries_charged_kwh > 1e-9 ] + assert charge_slots, "Expected at least one grid-charge slot on cheap hours" for slot in charge_slots: stored = slot.batteries_charged_kwh - # With 20% conversion loss, grid pull must exceed stored energy + # With any conversion loss, grid pull must exceed stored energy assert slot.grid_import_kwh > stored - 1e-6, ( f"Grid import {slot.grid_import_kwh:.4f} should exceed " - f"stored {stored:.4f} when conversion loss = 20%" + f"stored {stored:.4f} under charge-efficiency loss" ) def test_cost_uses_grid_import_not_stored(self): @@ -1252,7 +1257,6 @@ def test_missing_price_hours_surfaced_in_missing_inputs(self): consumption_averages=consumption, price_points=partial_prices, solcast_slots=solar, - battery_schedules=[], months_winter=[1, 2, 3, 4, 10, 11, 12], is_read_only=True, ) @@ -1322,10 +1326,7 @@ def test_winter_month_gives_wait_mode_not_discharge(self): but does NOT assert that the final winner has zero discharge slots. """ # January is always winter - inp = make_winter_day_input( - now_iso="2024-01-15T00:00:00+01:00", - schedules=[], # no schedules so no schedule-driven discharge - ) + inp = make_winter_day_input(now_iso="2024-01-15T00:00:00+01:00") result = run_planner(inp) # Check the baseline candidate's slots (the pre-candidate plan) # have correctly assigned wait-mode instead of discharge for winter. @@ -1553,10 +1554,8 @@ class TestRequiredReserve: """ def test_required_capacity_positive_on_summer_day(self): - """required_capacity_kwh must be > 0 when discharge windows are configured.""" + """required_capacity_kwh must be >= 0 (energy needed until next solar surplus).""" result = run_planner(make_summer_day_input(battery_soc_pct=0.0)) - # With discharge schedules active and empty battery there is reserve needed - # (the planner tries to charge before peak) assert result.required_capacity_kwh >= 0.0, ( "required_capacity_kwh must be non-negative" ) diff --git a/tests/planner/test_missing_tomorrow_data.py b/tests/planner/test_missing_tomorrow_data.py index e2fcef2f..b9e39e28 100644 --- a/tests/planner/test_missing_tomorrow_data.py +++ b/tests/planner/test_missing_tomorrow_data.py @@ -196,7 +196,6 @@ def _make_48h_input( price_points if price_points is not None else _today_price_points() ), solcast_slots=solcast_slots if solcast_slots is not None else _pv_slots(), - battery_schedules=[], excess_export_enabled=False, months_winter=[1, 2, 3, 4, 10, 11, 12], house_power_includes_ev=True, diff --git a/tests/planner/test_multi_day_price_preservation.py b/tests/planner/test_multi_day_price_preservation.py index b0fc247c..c3ea3786 100644 --- a/tests/planner/test_multi_day_price_preservation.py +++ b/tests/planner/test_multi_day_price_preservation.py @@ -25,12 +25,10 @@ from __future__ import annotations import math -from datetime import time from zoneinfo import ZoneInfo import pytest -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput from custom_components.hsem.models.hourly_consumption_average import ( HourlyConsumptionAverage, ) @@ -126,13 +124,6 @@ def _make_48h_input_with_day_offsets( consumption_averages=consumption_averages, price_points=price_points, solcast_slots=solcast_slots, - battery_schedules=[ - BatteryScheduleInput( - enabled=True, - start=time(7, 0), - end=time(9, 0), - ) - ], excess_export_enabled=False, excess_export_discharge_buffer_pct=10.0, excess_export_price_threshold=0.10, @@ -390,7 +381,6 @@ def test_backward_compat_24h_single_day_still_works(self): consumption_averages=consumption, price_points=prices, solcast_slots=solar, - battery_schedules=[], excess_export_enabled=False, excess_export_discharge_buffer_pct=10.0, excess_export_price_threshold=0.10, diff --git a/tests/planner/test_plan_explanation.py b/tests/planner/test_plan_explanation.py index fdf6b451..d8457dc3 100644 --- a/tests/planner/test_plan_explanation.py +++ b/tests/planner/test_plan_explanation.py @@ -227,7 +227,7 @@ class TestStrategyDetection: """The correct selected_strategy is chosen for different scenarios.""" def test_summer_day_uses_charge_or_solar_strategy(self): - """Summer fixture has schedules + solar, so a charge/discharge or solar strategy is chosen.""" + """Summer fixture has solar surplus, so a charge/discharge or solar strategy is chosen.""" output = run_planner(make_summer_day_input()) valid_strategies = { "charge_grid_discharge_peak", @@ -257,8 +257,8 @@ def test_flat_price_reports_no_spread_constraint(self): assert "no_price_spread" in output.explanation.constraints def test_winter_day_strategy(self): - """Winter fixture without schedules should select a winter/wait strategy.""" - inp = make_winter_day_input(schedules=[]) + """Winter fixture should select a winter/wait strategy.""" + inp = make_winter_day_input() output = run_planner(inp) valid_winter_strategies = { "winter_wait", diff --git a/tests/planner/test_planner_harness.py b/tests/planner/test_planner_harness.py index 00d77e3c..35efcbbc 100644 --- a/tests/planner/test_planner_harness.py +++ b/tests/planner/test_planner_harness.py @@ -13,11 +13,8 @@ from __future__ import annotations -from datetime import time - import pytest -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput from custom_components.hsem.models.planner_output import PlannerOutput from custom_components.hsem.planner import run_planner from custom_components.hsem.utils.recommendations import Recommendations @@ -35,10 +32,6 @@ Recommendations.BatteriesChargeGrid.value, Recommendations.BatteriesChargeSolar.value, } -_DISCHARGE_VALUES = { - Recommendations.BatteriesDischargeMode.value, - Recommendations.ForceBatteriesDischarge.value, -} # =========================================================================== @@ -192,79 +185,7 @@ def test_prices_populated_correctly(self): # =========================================================================== -# 3. Discharge schedules -# =========================================================================== - - -class TestDischargeSchedules: - """Enabled discharge schedules must mark appropriate slots.""" - - def test_discharge_window_marked_in_morning(self): - """Schedule 1 (07-09) must mark at least one morning slot as discharge.""" - result = run_planner(make_summer_day_input()) - discharge_slots = [ - s - for s in result.slots - if s.recommendation in _DISCHARGE_VALUES and s.start.hour in range(7, 9) - ] - assert discharge_slots, "Expected discharge slots in the 07-09 window" - - def test_discharge_window_marked_in_evening(self): - """Schedule 2 (17-21) must mark at least one evening slot as discharge.""" - result = run_planner(make_summer_day_input()) - discharge_slots = [ - s - for s in result.slots - if s.recommendation in _DISCHARGE_VALUES and s.start.hour in range(17, 21) - ] - assert discharge_slots, "Expected discharge slots in the 17-21 window" - - def test_disabled_schedule_produces_no_schedule_discharge_slots(self): - """Disabling all schedules means no discharge during the *schedule* windows. - - The aggressive candidate may still assign BatteriesDischargeMode to - the most expensive winter slots — this is correct behavior (expensive - evening peaks are worth discharging for). Verify that BatteriesWaitMode - (the winter seasonal fallback) is active for the non-peak slots. - """ - disabled_schedules = [ - BatteryScheduleInput(enabled=False, start=time(7, 0), end=time(9, 0)), - BatteryScheduleInput(enabled=False, start=time(17, 0), end=time(21, 0)), - ] - # Use winter fixture: seasonal fallback is BatteriesWaitMode, not Discharge - inp = make_winter_day_input(schedules=disabled_schedules) - result = run_planner(inp) - wait_slots = [ - s - for s in result.slots - if s.recommendation == Recommendations.BatteriesWaitMode.value - ] - assert wait_slots, ( - "With disabled schedules and winter seasonal mode, " - "BatteriesWaitMode slots are expected" - ) - - def test_discharge_windows_detected(self): - """PlannerOutput.discharge_windows must be non-empty with active schedules.""" - result = run_planner(make_summer_day_input()) - assert result.discharge_windows, "Expected at least one discharge window" - - def test_discharge_slots_exist_in_schedule_windows(self): - """At least one slot must be marked discharge within the 07-09 or 17-21 windows.""" - result = run_planner(make_summer_day_input()) - schedule_discharge_slots = [ - s - for s in result.slots - if s.recommendation in _DISCHARGE_VALUES - and (7 <= s.start.hour < 9 or 17 <= s.start.hour < 21) - ] - assert schedule_discharge_slots, ( - "Expected discharge slots within the 07-09 or 17-21 schedule windows" - ) - - -# =========================================================================== -# 4. Charge scheduling +# 3. Charge scheduling # =========================================================================== @@ -312,7 +233,7 @@ def test_battery_charges_when_empty(self): def test_solar_surplus_can_trigger_solar_charge(self): """Summer mid-day hours with large PV surplus should produce solar charge slots.""" - # Use a full battery so discharge schedules consume energy and solar can refill it + # Start empty so solar surplus has headroom to charge into inp = make_summer_day_input(battery_soc_pct=0.0) result = run_planner(inp) solar_charge_slots = result.slots_with_recommendation( @@ -324,7 +245,7 @@ def test_solar_surplus_can_trigger_solar_charge(self): # =========================================================================== -# 5. Current recommendation +# 4. Current recommendation # =========================================================================== @@ -347,7 +268,7 @@ def test_current_recommendation_valid(self): # =========================================================================== -# 6. Winter vs summer season logic +# 5. Winter vs summer season logic # =========================================================================== @@ -382,7 +303,7 @@ def test_summer_has_solar_charge_slots(self): # =========================================================================== -# 7. 24-hour fixture completeness +# 6. 24-hour fixture completeness # =========================================================================== @@ -419,7 +340,7 @@ def test_flat_fixture_all_slots_same_price(self): # =========================================================================== -# 8. Output helper methods +# 7. Output helper methods # =========================================================================== @@ -446,48 +367,13 @@ def test_total_charged_energy_matches_sum(self): # =========================================================================== -# 9. Edge cases +# 8. Edge cases # =========================================================================== class TestEdgeCases: """Planner must handle degenerate inputs gracefully.""" - def test_fully_charged_battery_charges_only_for_schedules(self): - """A fully charged battery: grid charging should only appear when profitable. - - With all schedules disabled, the rule-based pipeline (arbitrage, opportunistic, - schedule pre-charge) is inactive, so the baseline candidate has no grid-charge - slots. However, the MILP candidate may still charge if there is a profitable - price spread (import cheap, discharge expensive) that makes cycling economic, - since the MILP is a global optimiser. - - The only invariant we assert: no *rule-based* charge slot (from the baseline - candidate) appears when all schedules are disabled. MILP-chosen slots are - accepted because the MILP independently checks profitability. - """ - disabled_schedules = [ - BatteryScheduleInput(enabled=False, start=time(7, 0), end=time(9, 0)), - BatteryScheduleInput(enabled=False, start=time(17, 0), end=time(21, 0)), - ] - inp = make_summer_day_input(battery_soc_pct=100.0, schedules=disabled_schedules) - result = run_planner(inp) - - # Check the baseline candidate's slots (first candidate) — the - # rule-based pipeline must not produce grid-charge slots when all - # schedules are disabled. The winner (result.slots) may differ - # if MILP or soc_plan found a profitable cycle. - baseline_slots = result.candidates[0].slots if result.candidates else [] - baseline_charge = [ - s - for s in baseline_slots - if s.recommendation == Recommendations.BatteriesChargeGrid.value - ] - assert not baseline_charge, ( - "Baseline candidate must have no grid charge slots when schedules " - f"are disabled. Found {len(baseline_charge)} charge slots." - ) - def test_zero_pv_no_solar_charge_slots(self): """With zero PV production there must be no BatteriesChargeSolar slots.""" inp = make_flat_price_input(battery_soc_pct=0.0) @@ -498,23 +384,20 @@ def test_zero_pv_no_solar_charge_slots(self): ) assert not solar_slots, "No solar charge slots expected when PV=0" - def test_empty_schedules_no_discharge_mode_in_winter(self) -> None: - """BatteriesWaitMode slots expected with no schedules in winter. + def test_default_winter_input_has_wait_mode_slots(self) -> None: + """BatteriesWaitMode slots expected on the default winter fixture. In winter the seasonal strategy sets unassigned slots to BatteriesWaitMode. - The aggressive candidate may still set BatteriesDischargeMode on the - most expensive winter slots — this is correct behavior (expensive - evening peaks are worth discharging for). + The MILP may still set BatteriesDischargeMode on the most expensive + winter slots — this is correct behavior (expensive evening peaks are + worth discharging for). """ - disabled_schedules: list[BatteryScheduleInput] = [] - inp = make_winter_day_input(schedules=disabled_schedules) + inp = make_winter_day_input() result = run_planner(inp) wait_mode = result.slots_with_recommendation( Recommendations.BatteriesWaitMode.value ) - assert wait_mode, ( - "BatteriesWaitMode expected in winter with empty schedule list" - ) + assert wait_mode, "BatteriesWaitMode expected on the default winter fixture" def test_invalid_timezone_raises(self): """Passing a naive datetime string must raise ValueError.""" diff --git a/tests/planner/test_planning_horizon.py b/tests/planner/test_planning_horizon.py index 787c36f6..e8b354b4 100644 --- a/tests/planner/test_planning_horizon.py +++ b/tests/planner/test_planning_horizon.py @@ -15,10 +15,8 @@ from __future__ import annotations -from datetime import time from typing import Any, cast -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput from custom_components.hsem.models.hourly_consumption_average import ( HourlyConsumptionAverage, ) @@ -127,22 +125,9 @@ def _make_input( now_iso: str = "2024-06-15T00:00:00+02:00", prices: list[PricePoint] | None = None, solcast: list[SolcastSlot] | None = None, - schedules: list[BatteryScheduleInput] | None = None, battery_soc_pct: float = 50.0, ) -> PlannerInput: """Build a minimal PlannerInput for the given horizon.""" - default_schedules = [ - BatteryScheduleInput( - enabled=True, - start=time(7, 0), - end=time(9, 0), - ), - BatteryScheduleInput( - enabled=True, - start=time(17, 0), - end=time(21, 0), - ), - ] return PlannerInput( now_iso=now_iso, interval_minutes=interval_minutes, @@ -161,7 +146,6 @@ def _make_input( consumption_averages=_make_consumption(), price_points=prices if prices is not None else _make_prices(), solcast_slots=solcast if solcast is not None else _make_solcast(), - battery_schedules=(schedules if schedules is not None else default_schedules), excess_export_enabled=False, months_winter=[1, 2, 3, 4, 10, 11, 12], house_power_includes_ev=True, diff --git a/tests/planner/test_seasonal_boundary.py b/tests/planner/test_seasonal_boundary.py index 996eabe0..edb8baff 100644 --- a/tests/planner/test_seasonal_boundary.py +++ b/tests/planner/test_seasonal_boundary.py @@ -12,10 +12,9 @@ from __future__ import annotations -from datetime import datetime, time, timedelta +from datetime import datetime, timedelta from zoneinfo import ZoneInfo -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput from custom_components.hsem.models.hourly_consumption_average import ( HourlyConsumptionAverage, ) @@ -52,8 +51,8 @@ def _make_slot(start: datetime, net_consumption: float = 0.5) -> PlannedSlot: def _make_boundary_input() -> PlannerInput: """48-hour plan starting Aug 31 (summer) crossing into Sep 1 (winter). - Flat prices (import > export), no PV, disabled schedules — the seasonal - fill is the only recommendation source. + Flat prices (import > export), no PV — the seasonal fill is the only + recommendation source. """ prices = [ PricePoint(hour=h, import_price=0.20, export_price=0.05) for h in range(24) @@ -65,10 +64,6 @@ def _make_boundary_input() -> PlannerInput: ) for h in range(24) ] - disabled_schedules = [ - BatteryScheduleInput(enabled=False, start=time(7, 0), end=time(9, 0)), - BatteryScheduleInput(enabled=False, start=time(17, 0), end=time(21, 0)), - ] return PlannerInput( now_iso="2024-08-31T00:00:00+02:00", @@ -87,7 +82,6 @@ def _make_boundary_input() -> PlannerInput: consumption_averages=consumption, price_points=prices, solcast_slots=solar, - battery_schedules=disabled_schedules, excess_export_enabled=False, excess_export_discharge_buffer_pct=10.0, excess_export_price_threshold=0.10, diff --git a/tests/planner/test_slot_ownership.py b/tests/planner/test_slot_ownership.py index 24f04075..1441d320 100644 --- a/tests/planner/test_slot_ownership.py +++ b/tests/planner/test_slot_ownership.py @@ -108,7 +108,6 @@ def _make_ev_input( for h in range(24) ] - base = make_summer_day_input(now_iso=now_iso) return PlannerInput( now_iso=now_iso, interval_minutes=60, @@ -126,7 +125,6 @@ def _make_ev_input( consumption_averages=averages, price_points=prices, solcast_slots=pv, - battery_schedules=base.battery_schedules, months_winter=[1, 2, 3, 4, 10, 11, 12], excess_export_enabled=False, is_read_only=True, @@ -587,7 +585,7 @@ class TestResolverPreservesEnergyFields: Energy fields (``ev_planned_load_kwh``, ``estimated_net_consumption_kwh``, ``batteries_charged_kwh``, ``grid_import_kwh``, ``grid_export_kwh``, ``batteries_discharged_kwh``) must be identical before and after the call - for all four resolver branches. + for all resolver branches. """ _ENERGY_FIELDS = ( @@ -611,7 +609,7 @@ def test_negative_price_branch_preserves_energy_fields(self): ) before = self._snapshot_energy(rec) resolve_current_recommendation( - rec, _make_live(import_price=-0.05), 0.0, _make_resolver_cfg() + rec, _make_live(import_price=-0.05), _make_resolver_cfg() ) assert rec.recommendation == Recommendations.ForceExport.value assert self._snapshot_energy(rec) == before @@ -624,26 +622,11 @@ def test_ev_charging_branch_preserves_energy_fields(self): rec.ev_charger_calculated_power = 7500.0 before = self._snapshot_energy(rec) resolve_current_recommendation( - rec, _make_live(ev_charging=True), 0.0, _make_resolver_cfg() + rec, _make_live(ev_charging=True), _make_resolver_cfg() ) assert rec.recommendation == Recommendations.EVSmartCharging.value assert self._snapshot_energy(rec) == before - def test_discharge_mode_branch_preserves_energy_fields(self): - """BatteriesDischargeMode override must not modify energy fields.""" - rec = _make_hrec( - ev_kwh=1.0, estimated_net_consumption_kwh=0.8, batteries_charged_kwh=0.0 - ) - before = self._snapshot_energy(rec) - resolve_current_recommendation( - rec, - _make_live(battery_kwh=10.0), - batteries_schedules_remaining_capacity_needed=5.0, - cfg=_make_resolver_cfg(), - ) - assert rec.recommendation == Recommendations.BatteriesDischargeMode.value - assert self._snapshot_energy(rec) == before - def test_grid_charge_preserved_branch_does_not_modify_any_field(self): """When recommendation is BatteriesChargeGrid, no fields change.""" rec = _make_hrec( @@ -654,7 +637,7 @@ def test_grid_charge_preserved_branch_does_not_modify_any_field(self): before_rec = rec.recommendation before = self._snapshot_energy(rec) resolve_current_recommendation( - rec, _make_live(ev_charging=True), 0.0, _make_resolver_cfg() + rec, _make_live(ev_charging=True), _make_resolver_cfg() ) assert rec.recommendation == before_rec # unchanged assert self._snapshot_energy(rec) == before @@ -671,19 +654,17 @@ def test_no_override_branch_preserves_everything(self): resolve_current_recommendation( rec, _make_live(ev_charging=False, import_price=0.20), - 0.0, _make_resolver_cfg(), ) assert rec.recommendation == before_rec assert self._snapshot_energy(rec) == before def test_ev_kwh_not_zeroed_by_any_resolver_branch(self): - """``ev_planned_load_kwh`` must survive all four resolver paths.""" - for ev_charging, import_price, sched_needed in [ - (True, 0.20, 0.0), # EV branch - (False, -0.10, 0.0), # Negative price branch - (False, 0.20, 3.0), # Discharge mode branch - (False, 0.20, 0.0), # No-op branch + """``ev_planned_load_kwh`` must survive all resolver paths.""" + for ev_charging, import_price in [ + (True, 0.20), # EV branch + (False, -0.10), # Negative price branch + (False, 0.20), # No-op branch ]: rec = _make_hrec(ev_kwh=4.2) resolve_current_recommendation( @@ -691,13 +672,11 @@ def test_ev_kwh_not_zeroed_by_any_resolver_branch(self): _make_live( ev_charging=ev_charging, import_price=import_price, battery_kwh=10.0 ), - batteries_schedules_remaining_capacity_needed=sched_needed, - cfg=_make_resolver_cfg(), + _make_resolver_cfg(), ) assert abs(rec.ev_planned_load_kwh - 4.2) < 1e-9, ( f"ev_planned_load_kwh was modified to {rec.ev_planned_load_kwh} " - f"by resolver (ev_charging={ev_charging}, price={import_price}, " - f"sched_needed={sched_needed})" + f"by resolver (ev_charging={ev_charging}, price={import_price})" ) diff --git a/tests/planner/test_soc_simulation.py b/tests/planner/test_soc_simulation.py index 64c225e8..c8d0ff3d 100644 --- a/tests/planner/test_soc_simulation.py +++ b/tests/planner/test_soc_simulation.py @@ -20,7 +20,6 @@ import pytest -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput from custom_components.hsem.models.hourly_consumption_average import ( HourlyConsumptionAverage, ) @@ -57,7 +56,6 @@ def _make_minimal_input( export_price: float = 0.05, interval_minutes: int = 60, interval_length_hours: int = 24, - schedules: list[BatteryScheduleInput] | None = None, now_iso: str = "2024-06-15T00:00:00+02:00", ) -> PlannerInput: """Build a minimal PlannerInput with uniform load/PV across all 24 hours.""" @@ -95,7 +93,6 @@ def _make_minimal_input( consumption_averages=consumption, price_points=prices, solcast_slots=solar, - battery_schedules=schedules if schedules is not None else [], excess_export_enabled=False, excess_export_discharge_buffer_pct=10.0, excess_export_price_threshold=0.10, @@ -510,8 +507,6 @@ def test_battery_mid_soc(self): battery_soc_pct=50.0, load_kwh_per_hour=0.5, pv_kwh_per_hour=0.0, - # Disable all schedules so the simulation simply drains - schedules=[], # Set a modest cycle cost so the MILP doesn't arbitrage # terminal-SoC credit for free battery_purchase_price=5000.0, @@ -548,7 +543,6 @@ def test_charge_power_limit_respected_in_full_run(self): battery_max_charge_power_w=1000.0, # 1 kW → 1 kWh/h per slot, pv_kwh_per_hour=5.0, # large PV to force charging load_kwh_per_hour=0.2, - schedules=[], ) result = run_planner(inp) # max_charge_per_slot = 1 kW * 1h * 1.0 (no loss) = 1.0 kWh @@ -563,7 +557,6 @@ def test_discharge_power_limit_respected_in_full_run(self): battery_max_discharge_power_w=1000.0, # 1 kW → 1 kWh/h load_kwh_per_hour=3.0, # heavy load to force discharging pv_kwh_per_hour=0.0, - schedules=[], ) result = run_planner(inp) for slot in result.slots: @@ -579,7 +572,6 @@ def test_no_discharge_limit_allows_high_discharge(self): battery_max_discharge_power_w=None, load_kwh_per_hour=4.0, # heavy load pv_kwh_per_hour=0.0, - schedules=[], ) result = run_planner(inp) # At least one future slot should discharge more than 1 kWh @@ -627,7 +619,6 @@ def test_full_pv_surplus_exported(self): battery_soc_pct=100.0, # full battery pv_kwh_per_hour=5.0, load_kwh_per_hour=0.5, - schedules=[], ) result = run_planner(inp) future_slots = [ @@ -647,7 +638,6 @@ def test_no_export_when_battery_empty_and_no_pv(self): battery_soc_pct=10.0, # empty pv_kwh_per_hour=0.0, load_kwh_per_hour=0.5, - schedules=[], ) result = run_planner(inp) for slot in result.slots: @@ -687,7 +677,6 @@ def test_soc_capped_at_max_soc_pct(self): battery_max_soc_pct=75.0, pv_kwh_per_hour=5.0, # lots of PV load_kwh_per_hour=0.1, - schedules=[], ) result = run_planner(inp) for slot in result.slots: diff --git a/tests/planner/test_solar_charge_no_double_count.py b/tests/planner/test_solar_charge_no_double_count.py index 01dae7c4..4ff5ba5b 100644 --- a/tests/planner/test_solar_charge_no_double_count.py +++ b/tests/planner/test_solar_charge_no_double_count.py @@ -20,9 +20,6 @@ from __future__ import annotations -from datetime import time - -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput from custom_components.hsem.models.hourly_consumption_average import ( HourlyConsumptionAverage, ) @@ -48,7 +45,6 @@ def _make_solar_only_input( solar_per_hour: float = 3.0, consumption_per_hour: float = 0.3, now_iso: str = "2024-06-15T00:00:00+02:00", - schedules: list[BatteryScheduleInput] | None = None, ) -> PlannerInput: """Return a 24-hour summer input where solar > consumption in most mid-day hours. @@ -63,7 +59,6 @@ def _make_solar_only_input( solar_per_hour: PV production per hour in kWh. consumption_per_hour: House consumption per hour in kWh. now_iso: Planning timestamp (timezone-aware ISO-8601). - schedules: Battery charge/discharge schedule overrides. """ prices = [ PricePoint(hour=h, import_price=0.20, export_price=0.18) for h in range(24) @@ -83,11 +78,6 @@ def _make_solar_only_input( ) for h in range(24) ] - # Default: no discharge schedules so only solar-strategy charging runs - if schedules is None: - schedules = [ - BatteryScheduleInput(enabled=False, start=time(7, 0), end=time(9, 0)), - ] return PlannerInput( now_iso=now_iso, interval_minutes=60, @@ -105,7 +95,6 @@ def _make_solar_only_input( consumption_averages=consumption, price_points=prices, solcast_slots=solar, - battery_schedules=schedules, excess_export_enabled=False, excess_export_discharge_buffer_pct=10.0, excess_export_price_threshold=0.10, diff --git a/tests/sensors/test_recommendation_resolver.py b/tests/sensors/test_recommendation_resolver.py index 2f9916ee..0a7d2c40 100644 --- a/tests/sensors/test_recommendation_resolver.py +++ b/tests/sensors/test_recommendation_resolver.py @@ -1,6 +1,6 @@ """Tests for custom_sensors/recommendation_resolver.py. -All four priority branches of :func:`resolve_current_recommendation` are +All priority branches of :func:`resolve_current_recommendation` are tested with plain dataclasses — no Home Assistant required. """ @@ -89,30 +89,24 @@ def _make_cfg( class TestNegativeImportPrice: def test_negative_price_overrides_any_recommendation(self): rec = _make_rec(recommendation=Recommendations.BatteriesDischargeMode.value) - resolve_current_recommendation( - rec, _make_live(import_price=-0.01), 0.0, _make_cfg() - ) + resolve_current_recommendation(rec, _make_live(import_price=-0.01), _make_cfg()) assert rec.recommendation == Recommendations.ForceExport.value def test_zero_price_does_not_force_export(self): rec = _make_rec(recommendation=Recommendations.BatteriesWaitMode.value) - resolve_current_recommendation( - rec, _make_live(import_price=0.0), 0.0, _make_cfg() - ) + resolve_current_recommendation(rec, _make_live(import_price=0.0), _make_cfg()) assert rec.recommendation == Recommendations.BatteriesWaitMode.value def test_positive_price_does_not_force_export(self): rec = _make_rec(recommendation=Recommendations.BatteriesWaitMode.value) - resolve_current_recommendation( - rec, _make_live(import_price=0.5), 0.0, _make_cfg() - ) + resolve_current_recommendation(rec, _make_live(import_price=0.5), _make_cfg()) assert rec.recommendation == Recommendations.BatteriesWaitMode.value def test_negative_import_and_export_price_does_not_force_export(self): """Issue #732: negative export price must not be forced to export.""" rec = _make_rec(recommendation=Recommendations.BatteriesWaitMode.value) live = _make_live(import_price=-0.7254, export_price=-0.708) - resolve_current_recommendation(rec, live, 0.0, _make_cfg()) + resolve_current_recommendation(rec, live, _make_cfg()) assert rec.recommendation == Recommendations.BatteriesWaitMode.value def test_negative_import_price_with_excess_export_disabled_no_override(self): @@ -120,14 +114,14 @@ def test_negative_import_price_with_excess_export_disabled_no_override(self): rec = _make_rec(recommendation=Recommendations.BatteriesWaitMode.value) live = _make_live(import_price=-0.05, export_price=0.5) cfg = _make_cfg(batteries_enable_excess_export=False) - resolve_current_recommendation(rec, live, 0.0, cfg) + resolve_current_recommendation(rec, live, cfg) assert rec.recommendation == Recommendations.BatteriesWaitMode.value def test_negative_import_price_export_below_floor_no_override(self): rec = _make_rec(recommendation=Recommendations.BatteriesWaitMode.value) live = _make_live(import_price=-0.05, export_price=0.1) cfg = _make_cfg(export_electricity_min_price=0.2) - resolve_current_recommendation(rec, live, 0.0, cfg) + resolve_current_recommendation(rec, live, cfg) assert rec.recommendation == Recommendations.BatteriesWaitMode.value def test_negative_import_price_export_unavailable_no_override(self): @@ -135,14 +129,14 @@ def test_negative_import_price_export_unavailable_no_override(self): live = _make_live( import_price=-0.05, export_price=0.5, export_price_available=False ) - resolve_current_recommendation(rec, live, 0.0, _make_cfg()) + resolve_current_recommendation(rec, live, _make_cfg()) assert rec.recommendation == Recommendations.BatteriesWaitMode.value def test_negative_import_price_profitable_export_overrides(self): rec = _make_rec(recommendation=Recommendations.BatteriesWaitMode.value) live = _make_live(import_price=-0.05, export_price=0.3) cfg = _make_cfg(export_electricity_min_price=0.2) - resolve_current_recommendation(rec, live, 0.0, cfg) + resolve_current_recommendation(rec, live, cfg) assert rec.recommendation == Recommendations.ForceExport.value @@ -155,7 +149,7 @@ class TestGridChargePreserved: def test_grid_charge_not_overridden_by_ev(self): rec = _make_rec(recommendation=Recommendations.BatteriesChargeGrid.value) live = _make_live(import_price=0.5, ev_charging=True) - resolve_current_recommendation(rec, live, 0.0, _make_cfg()) + resolve_current_recommendation(rec, live, _make_cfg()) assert rec.recommendation == Recommendations.BatteriesChargeGrid.value def test_grid_charge_not_overridden_by_negative_price(self): @@ -163,13 +157,13 @@ def test_grid_charge_not_overridden_by_negative_price(self): live = _make_live(import_price=-0.05, export_price=0.5) # Negative price with profitable export is priority 1, so it DOES # override grid charge. - resolve_current_recommendation(rec, live, 0.0, _make_cfg()) + resolve_current_recommendation(rec, live, _make_cfg()) assert rec.recommendation == Recommendations.ForceExport.value def test_grid_charge_not_overridden_by_negative_price_unprofitable_export(self): rec = _make_rec(recommendation=Recommendations.BatteriesChargeGrid.value) live = _make_live(import_price=-0.05, export_price=-0.03) - resolve_current_recommendation(rec, live, 0.0, _make_cfg()) + resolve_current_recommendation(rec, live, _make_cfg()) assert rec.recommendation == Recommendations.BatteriesChargeGrid.value @@ -183,20 +177,20 @@ def test_ev1_charging_triggers_ev_mode(self): rec = _make_rec(recommendation=Recommendations.BatteriesDischargeMode.value) rec.ev_charger_calculated_power = 7500.0 # Planner allocated power live = _make_live(import_price=0.5, ev_charging=True) - resolve_current_recommendation(rec, live, 0.0, _make_cfg()) + resolve_current_recommendation(rec, live, _make_cfg()) assert rec.recommendation == Recommendations.EVSmartCharging.value def test_ev2_charging_triggers_ev_mode(self): rec = _make_rec(recommendation=Recommendations.BatteriesWaitMode.value) rec.ev_second_charger_calculated_power = 11000.0 # Planner allocated power live = _make_live(import_price=0.5, ev2_charging=True) - resolve_current_recommendation(rec, live, 0.0, _make_cfg()) + resolve_current_recommendation(rec, live, _make_cfg()) assert rec.recommendation == Recommendations.EVSmartCharging.value def test_no_ev_charging_no_override(self): rec = _make_rec(recommendation=Recommendations.BatteriesWaitMode.value) live = _make_live(ev_charging=False, ev2_charging=False) - resolve_current_recommendation(rec, live, 0.0, _make_cfg()) + resolve_current_recommendation(rec, live, _make_cfg()) assert rec.recommendation == Recommendations.BatteriesWaitMode.value def test_ev1_charging_but_planner_zero_power_no_override(self): @@ -206,7 +200,7 @@ def test_ev1_charging_but_planner_zero_power_no_override(self): rec.ev_charger_calculated_power = 0.0 rec.ev_total_planned_load_kwh = 0.0 live = _make_live(ev_charging=True) - resolve_current_recommendation(rec, live, 0.0, _make_cfg()) + resolve_current_recommendation(rec, live, _make_cfg()) # Should keep original WaitMode because planner said stop assert rec.recommendation == Recommendations.BatteriesWaitMode.value @@ -215,7 +209,7 @@ def test_ev1_charging_with_positive_power_overrides(self): rec = _make_rec(recommendation=Recommendations.BatteriesWaitMode.value) rec.ev_charger_calculated_power = 7500.0 # Planner allocated power live = _make_live(ev_charging=True) - resolve_current_recommendation(rec, live, 0.0, _make_cfg()) + resolve_current_recommendation(rec, live, _make_cfg()) assert rec.recommendation == Recommendations.EVSmartCharging.value def test_ev2_charging_with_positive_power_overrides(self): @@ -223,52 +217,10 @@ def test_ev2_charging_with_positive_power_overrides(self): rec = _make_rec(recommendation=Recommendations.BatteriesWaitMode.value) rec.ev_second_charger_calculated_power = 11000.0 # Planner allocated power live = _make_live(ev2_charging=True) - resolve_current_recommendation(rec, live, 0.0, _make_cfg()) + resolve_current_recommendation(rec, live, _make_cfg()) assert rec.recommendation == Recommendations.EVSmartCharging.value -# --------------------------------------------------------------------------- -# Priority 4: Battery above remaining schedule need → BatteriesDischargeMode -# --------------------------------------------------------------------------- - - -class TestBatteryAboveScheduleNeed: - def test_battery_above_need_sets_discharge_mode(self): - rec = _make_rec(recommendation=Recommendations.BatteriesWaitMode.value) - live = _make_live(battery_kwh=8.0) - # remaining need = 5 kWh, battery = 8 kWh → discharge mode - resolve_current_recommendation( - rec, - live, - batteries_schedules_remaining_capacity_needed=5.0, - cfg=_make_cfg(), - ) - assert rec.recommendation == Recommendations.BatteriesDischargeMode.value - - def test_battery_exactly_at_need_no_override(self): - rec = _make_rec(recommendation=Recommendations.BatteriesWaitMode.value) - live = _make_live(battery_kwh=5.0) - resolve_current_recommendation( - rec, - live, - batteries_schedules_remaining_capacity_needed=5.0, - cfg=_make_cfg(), - ) - # Not strictly greater, so no override - assert rec.recommendation == Recommendations.BatteriesWaitMode.value - - def test_zero_remaining_need_no_discharge_override(self): - rec = _make_rec(recommendation=Recommendations.BatteriesWaitMode.value) - live = _make_live(battery_kwh=10.0) - resolve_current_recommendation( - rec, - live, - batteries_schedules_remaining_capacity_needed=0.0, - cfg=_make_cfg(), - ) - assert rec.recommendation == Recommendations.BatteriesWaitMode.value - - # --------------------------------------------------------------------------- # None rec safety # --------------------------------------------------------------------------- @@ -277,5 +229,5 @@ def test_zero_remaining_need_no_discharge_override(self): class TestNoneRec: def test_none_rec_does_not_raise(self): live = _make_live() - resolve_current_recommendation(None, live, 0.0, _make_cfg()) # type: ignore[arg-type] + resolve_current_recommendation(None, live, _make_cfg()) # type: ignore[arg-type] # No exception = pass diff --git a/tests/sensors/test_state_collector.py b/tests/sensors/test_state_collector.py index 475a586a..ecf76ec8 100644 --- a/tests/sensors/test_state_collector.py +++ b/tests/sensors/test_state_collector.py @@ -17,7 +17,6 @@ _compute_battery_capacities, _compute_net_consumption, _register_listeners, - build_battery_schedules, build_sensor_config, ) from custom_components.hsem.models.live_state import LiveState @@ -84,18 +83,6 @@ def _make_config_entry(**overrides: Any) -> MagicMock: "hsem_batteries_conversion_loss": 5.0, "hsem_batteries_purchase_price": 8000.0, "hsem_batteries_expected_cycles": 6000, - "hsem_batteries_enable_batteries_schedule_1": False, - "hsem_batteries_enable_batteries_schedule_1_start": "07:00:00", - "hsem_batteries_enable_batteries_schedule_1_end": "09:00:00", - "hsem_batteries_enable_batteries_schedule_1_min_price_difference": 0.0, - "hsem_batteries_enable_batteries_schedule_2": False, - "hsem_batteries_enable_batteries_schedule_2_start": "17:00:00", - "hsem_batteries_enable_batteries_schedule_2_end": "21:00:00", - "hsem_batteries_enable_batteries_schedule_2_min_price_difference": 0.0, - "hsem_batteries_enable_batteries_schedule_3": False, - "hsem_batteries_enable_batteries_schedule_3_start": "07:00:00", - "hsem_batteries_enable_batteries_schedule_3_end": "09:00:00", - "hsem_batteries_enable_batteries_schedule_3_min_price_difference": 0.0, "hsem_batteries_enable_excess_export": False, "hsem_batteries_excess_export_discharge_buffer": 10.0, "hsem_house_consumption_energy_weight_1d": 50, @@ -191,16 +178,6 @@ def test_consumption_weights(self): + cfg.house_consumption_energy_weight_14d ) == 100 - def test_schedule_1_propagates(self): - cfg = build_sensor_config( - _make_config_entry( - hsem_batteries_enable_batteries_schedule_1=True, - hsem_batteries_enable_batteries_schedule_1_start="06:00:00", - hsem_batteries_enable_batteries_schedule_1_end="09:00:00", - ) - ) - assert cfg.batteries_schedule_1.enabled is True - def test_months_winter_list_converted(self): # convert_months_to_int accepts numeric strings like '1', '2' cfg = build_sensor_config(_make_config_entry(hsem_months_winter=["1", "2"])) @@ -410,35 +387,6 @@ def test_none_house_power_yields_zero(self): assert live.net_consumption_w == pytest.approx(0.0) -# --------------------------------------------------------------------------- -# build_battery_schedules -# --------------------------------------------------------------------------- - - -class TestBuildBatterySchedules: - def test_returns_three_schedules(self): - cfg = build_sensor_config(_make_config_entry()) - schedules = build_battery_schedules(cfg) - assert len(schedules) == 3 - - def test_disabled_schedules_have_enabled_false(self): - cfg = build_sensor_config(_make_config_entry()) - schedules = build_battery_schedules(cfg) - assert all(not s.enabled for s in schedules) - - def test_enabled_schedule_propagates(self): - cfg = build_sensor_config( - _make_config_entry(hsem_batteries_enable_batteries_schedule_1=True) - ) - schedules = build_battery_schedules(cfg) - assert schedules[0].enabled is True - - def test_initial_avg_import_price_zero(self): - cfg = build_sensor_config(_make_config_entry()) - for s in build_battery_schedules(cfg): - assert s.avg_import_price == pytest.approx(0.0) - - # --------------------------------------------------------------------------- # _read_ev_power_w — EV power unit normalisation (issue #592) # --------------------------------------------------------------------------- diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py index 43581d01..cfde7417 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -4,7 +4,6 @@ - Entity ID format validation - Async entity/device existence checks - Month season validation -- Time window validation - Power limit validation - Energy limit validation - Price / cost validation @@ -27,7 +26,6 @@ validate_months, validate_power_limits, validate_price, - validate_time_window, ) # --------------------------------------------------------------------------- @@ -334,87 +332,6 @@ def test_custom_field_name(self): assert errors == {} -# --------------------------------------------------------------------------- -# validate_time_window -# --------------------------------------------------------------------------- - - -class TestValidateTimeWindow: - """Battery schedule time window validation.""" - - def test_disabled_schedule_skips_validation(self): - user_input = { - "enabled": False, - "start": "INVALID", - "end": "ALSO_BAD", - } - errors = validate_time_window(user_input, "enabled", "start", "end") - assert errors == {} - - def test_valid_same_day_window(self): - user_input = { - "enabled": True, - "start": "07:00:00", - "end": "09:00:00", - } - errors = validate_time_window(user_input, "enabled", "start", "end") - assert errors == {} - - def test_valid_cross_midnight_window(self): - user_input = { - "enabled": True, - "start": "23:00:00", - "end": "02:00:00", - } - errors = validate_time_window(user_input, "enabled", "start", "end") - assert errors == {} - - def test_zero_length_window_rejected(self): - user_input = { - "enabled": True, - "start": "07:00:00", - "end": "07:00:00", - } - errors = validate_time_window(user_input, "enabled", "start", "end") - assert errors["base"] == "start_time_equals_end_time" - - def test_invalid_start_format(self): - user_input = { - "enabled": True, - "start": "7:00", - "end": "09:00:00", - } - errors = validate_time_window(user_input, "enabled", "start", "end") - assert errors["start"] == "invalid_time_format" - - def test_invalid_end_format(self): - user_input = { - "enabled": True, - "start": "07:00:00", - "end": "NOT_A_TIME", - } - errors = validate_time_window(user_input, "enabled", "start", "end") - assert errors["end"] == "invalid_time_format" - - def test_missing_start_treated_as_invalid_format(self): - user_input = { - "enabled": True, - "end": "09:00:00", - } - errors = validate_time_window(user_input, "enabled", "start", "end") - assert errors["start"] == "invalid_time_format" - - def test_midnight_00_not_equal_to_23_59_59(self): - """Midnight start with one second before midnight end is a valid cross-midnight window.""" - user_input = { - "enabled": True, - "start": "00:00:00", - "end": "23:59:59", - } - errors = validate_time_window(user_input, "enabled", "start", "end") - assert errors == {} - - # --------------------------------------------------------------------------- # validate_power_limits # --------------------------------------------------------------------------- @@ -828,73 +745,6 @@ async def test_validate_months_all_in_winter(self): # All months as winter is allowed (TOU year-round, issue #725). assert errors == {} - @pytest.mark.asyncio - async def test_validate_schedule_1_disabled_passes(self): - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - errors = await validate_batteries_schedule_input( - 1, - { - "hsem_batteries_enable_batteries_schedule_1": False, - "hsem_batteries_enable_batteries_schedule_1_start": "INVALID", - "hsem_batteries_enable_batteries_schedule_1_end": "INVALID", - }, - ) - assert errors == {} - - @pytest.mark.asyncio - async def test_validate_schedule_1_zero_length_rejected(self): - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - errors = await validate_batteries_schedule_input( - 1, - { - "hsem_batteries_enable_batteries_schedule_1": True, - "hsem_batteries_enable_batteries_schedule_1_start": "07:00:00", - "hsem_batteries_enable_batteries_schedule_1_end": "07:00:00", - }, - ) - assert errors["base"] == "start_time_equals_end_time" - - @pytest.mark.asyncio - async def test_validate_schedule_2_cross_midnight_valid(self): - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - errors = await validate_batteries_schedule_input( - 2, - { - "hsem_batteries_enable_batteries_schedule_2": True, - "hsem_batteries_enable_batteries_schedule_2_start": "23:00:00", - "hsem_batteries_enable_batteries_schedule_2_end": "02:00:00", - }, - ) - assert errors == {} - - @pytest.mark.asyncio - async def test_validate_schedule_3_invalid_time_format(self): - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - errors = await validate_batteries_schedule_input( - 3, - { - "hsem_batteries_enable_batteries_schedule_3": True, - "hsem_batteries_enable_batteries_schedule_3_start": "9am", - "hsem_batteries_enable_batteries_schedule_3_end": "11:00:00", - }, - ) - assert ( - errors.get("hsem_batteries_enable_batteries_schedule_3_start") - == "invalid_time_format" - ) - @pytest.mark.asyncio async def test_validate_weighted_values_invalid_sum(self): from custom_components.hsem.flows.weighted_values import ( diff --git a/tests/test_convert_to_float_none.py b/tests/test_convert_to_float_none.py index 55a05c61..ccc4e204 100644 --- a/tests/test_convert_to_float_none.py +++ b/tests/test_convert_to_float_none.py @@ -215,7 +215,7 @@ def test_none_import_price_does_not_trigger_force_export(self) -> None: cfg = SensorConfig() cfg.batteries_enable_excess_export = True - resolve_current_recommendation(rec, live, 0.0, cfg) + resolve_current_recommendation(rec, live, cfg) # Should NOT override to ForceExport with a zero (non-negative) price assert rec.recommendation != Recommendations.ForceExport.value @@ -236,7 +236,7 @@ def test_negative_import_price_triggers_force_export(self) -> None: cfg = SensorConfig() cfg.batteries_enable_excess_export = True - resolve_current_recommendation(rec, live, 0.0, cfg) + resolve_current_recommendation(rec, live, cfg) assert rec.recommendation == Recommendations.ForceExport.value diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 55444aa5..69927324 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -104,7 +104,6 @@ def test_empty_list_fields_are_mutable(self) -> None: def test_numeric_fields_default_to_zero(self) -> None: """Numeric accumulator fields must default to 0.0.""" data = CoordinatorData() - assert data.batteries_schedules_remaining_capacity_needed == pytest.approx(0.0) assert data.current_required_battery == pytest.approx(0.0) diff --git a/tests/test_coordinator_builder.py b/tests/test_coordinator_builder.py index 290ecadb..0c499b77 100644 --- a/tests/test_coordinator_builder.py +++ b/tests/test_coordinator_builder.py @@ -77,7 +77,6 @@ def test_builder_prefers_bounded_effective_ev_soc() -> None: cfg=cfg, live=live, hourly_recommendations=[], - batteries_schedules=[], previous_winner_name=None, previous_winner_score=0.0, ) @@ -100,7 +99,6 @@ def _build(cfg: SensorConfig, live: LiveState) -> PlannerInput: cfg=cfg, live=live, hourly_recommendations=[], - batteries_schedules=[], previous_winner_name=None, previous_winner_score=0.0, ) diff --git a/tests/test_datetime_utils.py b/tests/test_datetime_utils.py index eba1ac5c..ade39f96 100644 --- a/tests/test_datetime_utils.py +++ b/tests/test_datetime_utils.py @@ -48,8 +48,6 @@ def _make_bare_coordinator(): coord = HSEMDataUpdateCoordinator.__new__(HSEMDataUpdateCoordinator) coord._config_entry = config_entry - coord._batteries_schedules = [] - coord._batteries_schedules_remaining_capacity_needed = 0.0 coord._plan_explanation = MagicMock() coord._data_quality = MagicMock() coord.logger = logging.getLogger("test") @@ -776,7 +774,6 @@ def test_resolver_preserves_ev_load_when_relabelling(self): resolve_current_recommendation( rec, live, - batteries_schedules_remaining_capacity_needed=0.0, cfg=self._make_resolver_cfg(), ) @@ -818,7 +815,6 @@ def test_resolver_preserves_ev_load_on_negative_price_override(self): resolve_current_recommendation( rec, live, - batteries_schedules_remaining_capacity_needed=0.0, cfg=self._make_resolver_cfg(), ) @@ -826,43 +822,6 @@ def test_resolver_preserves_ev_load_on_negative_price_override(self): assert rec.ev_planned_load_kwh == pytest.approx(2.1, abs=1e-9) assert rec.estimated_net_consumption_kwh == pytest.approx(2.8, abs=1e-9) - def test_resolver_preserves_ev_load_on_discharge_override(self): - """BatteriesDischargeMode override must not clear ev_planned_load_kwh.""" - from custom_components.hsem.custom_sensors.recommendation_resolver import ( - resolve_current_recommendation, - ) - - midnight = datetime(2026, 5, 14, 0, 0, 0, tzinfo=_FIXED_LOCAL_TZ) - t_start = midnight + timedelta(hours=17) - t_end = t_start + timedelta(hours=1) - - rec = _make_hourly_recommendation( - t_start, - t_end, - ev_planned_load_kwh=1.5, - estimated_net_consumption_kwh=2.0, - recommendation="batteries_wait_mode", - ) - - # Battery above schedule need → discharge override - live = self._make_live_state( - import_electricity_price="0.30", - ev=MagicMock(is_charging=False), - ev_second=MagicMock(is_charging=False), - battery_current_capacity_kwh=8.0, - ) - - resolve_current_recommendation( - rec, - live, - batteries_schedules_remaining_capacity_needed=5.0, - cfg=self._make_resolver_cfg(), - ) - - assert rec.recommendation == "batteries_discharge_mode" - assert rec.ev_planned_load_kwh == pytest.approx(1.5, abs=1e-9) - assert rec.estimated_net_consumption_kwh == pytest.approx(2.0, abs=1e-9) - # =========================================================================== # Test: slot_key invalid interval raises ValueError diff --git a/tests/test_diagnostics_dump.py b/tests/test_diagnostics_dump.py index b9e18d30..64847728 100644 --- a/tests/test_diagnostics_dump.py +++ b/tests/test_diagnostics_dump.py @@ -141,19 +141,6 @@ def test_summer_input_roundtrip(self) -> None: assert len(reconstructed.consumption_averages) == len( original.consumption_averages ) - assert len(reconstructed.battery_schedules) == len(original.battery_schedules) - - def test_battery_schedule_times_preserved(self) -> None: - original = make_summer_day_input() - dump = build_diagnostics_dump(original, run_planner(original)) - reconstructed = load_planner_input_from_dump(dump) - - for orig_sched, recon_sched in zip( - original.battery_schedules, reconstructed.battery_schedules - ): - assert recon_sched.start == orig_sched.start - assert recon_sched.end == orig_sched.end - assert recon_sched.enabled == orig_sched.enabled def test_null_battery_max_discharge_preserved(self) -> None: original = make_summer_day_input() diff --git a/tests/test_dst_transitions.py b/tests/test_dst_transitions.py index e3a90265..b79ae7f2 100644 --- a/tests/test_dst_transitions.py +++ b/tests/test_dst_transitions.py @@ -264,9 +264,6 @@ class TestPlannerRunsDstDays: def _base_input(self, now_iso: str) -> PlannerInput: """Return a minimal PlannerInput for a 24-hour summer-like day.""" - from custom_components.hsem.models.battery_schedule_input import ( - BatteryScheduleInput, - ) from custom_components.hsem.models.hourly_consumption_average import ( HourlyConsumptionAverage, ) @@ -287,13 +284,6 @@ def _base_input(self, now_iso: str) -> PlannerInput: ) for h in range(24) ] - schedules = [ - BatteryScheduleInput( - enabled=True, - start=time(17, 0), - end=time(21, 0), - ) - ] return PlannerInput( now_iso=now_iso, interval_minutes=60, @@ -311,7 +301,6 @@ def _base_input(self, now_iso: str) -> PlannerInput: consumption_averages=consumption, price_points=prices, solcast_slots=solar, - battery_schedules=schedules, excess_export_enabled=False, months_winter=[1, 2, 3, 4, 10, 11, 12], is_read_only=True, diff --git a/tests/test_entity_gating_config.py b/tests/test_entity_gating_config.py index fea7ce3d..dd6b6161 100644 --- a/tests/test_entity_gating_config.py +++ b/tests/test_entity_gating_config.py @@ -190,12 +190,6 @@ async def test_time_setup_excludes_ev_deadline_when_disabled() -> None: keys = {e.entity_description.key for e in added} assert get_ev_deadline_time_key() not in keys assert get_ev_second_deadline_time_key() not in keys - # Battery schedule time entities are never gated (tracked separately, #860). - from custom_components.hsem.utils.sensornames.controls import ( - get_schedule_1_start_time_key, - ) - - assert get_schedule_1_start_time_key() in keys @pytest.mark.asyncio diff --git a/tests/test_entity_platform_classes.py b/tests/test_entity_platform_classes.py index e07d3039..e1e61077 100644 --- a/tests/test_entity_platform_classes.py +++ b/tests/test_entity_platform_classes.py @@ -45,7 +45,7 @@ def _mock_config_entry(**option_overrides: object) -> MagicMock: entry = MagicMock() entry.entry_id = "test_entry_id" entry.options = { - "hsem_batteries_enable_batteries_schedule_1_start": "07:00:00", + "hsem_ev_deadline_time": "07:00:00", **option_overrides, } return entry @@ -99,14 +99,14 @@ class TestHSEMTimeEntityConstruction: def _make_entity( self, default: str = "07:00:00", - key: str = "hsem_batteries_enable_batteries_schedule_1_start", + key: str = "hsem_ev_deadline_time", ) -> HSEMTimeEntity: hass = _mock_hass() config_entry = _mock_config_entry() description = HSEMTimeEntityDescription( key=key, - name="Batteries Discharge Schedule 1 Start", - description="Start time for schedule 1.", + name="EV Deadline", + description="EV charge deadline.", default_value=default, ) return HSEMTimeEntity(hass, config_entry, description) @@ -153,7 +153,7 @@ def test_state_property_returns_iso_string(self) -> None: def test_unique_id_contains_key(self) -> None: """unique_id is derived from the config-entry key.""" - key = "hsem_batteries_enable_batteries_schedule_1_start" + key = "hsem_ev_deadline_time" entity = self._make_entity(key=key) assert entity.unique_id is not None assert key in entity.unique_id @@ -204,9 +204,9 @@ def _make_entity(self) -> HSEMTimeEntity: hass = _mock_hass() config_entry = _mock_config_entry() description = HSEMTimeEntityDescription( - key="hsem_batteries_enable_batteries_schedule_1_start", - name="Batteries Discharge Schedule 1 Start", - description="Start time for schedule 1.", + key="hsem_ev_deadline_time", + name="EV Deadline", + description="EV charge deadline.", default_value="07:00:00", ) return HSEMTimeEntity(hass, config_entry, description) @@ -230,10 +230,7 @@ async def test_set_value_persists_to_config_entry(self) -> None: entity.hass.config_entries.async_update_entry.assert_called_once() # type: ignore[attr-defined] # mock attribute set in test call_kwargs = entity.hass.config_entries.async_update_entry.call_args # type: ignore[attr-defined] # mock attribute set in test updated_options = call_kwargs[1]["options"] - assert ( - updated_options["hsem_batteries_enable_batteries_schedule_1_start"] - == "21:30:00" - ) + assert updated_options["hsem_ev_deadline_time"] == "21:30:00" @pytest.mark.asyncio async def test_set_value_calls_async_write_ha_state(self) -> None: @@ -309,19 +306,13 @@ class TestTimePlatformSetup: """Verify that async_setup_entry registers the expected time entities.""" @pytest.mark.asyncio - async def test_setup_entry_creates_eight_time_entities(self) -> None: - """Eight time entities (3 schedules + 2 EV deadlines) should be created.""" + async def test_setup_entry_creates_two_time_entities(self) -> None: + """Two time entities (primary + second EV deadline) should be created.""" from custom_components.hsem.time import async_setup_entry hass = _mock_hass() config_entry = _mock_config_entry( **{ - "hsem_batteries_enable_batteries_schedule_1_start": "07:00:00", - "hsem_batteries_enable_batteries_schedule_1_end": "09:00:00", - "hsem_batteries_enable_batteries_schedule_2_start": "17:00:00", - "hsem_batteries_enable_batteries_schedule_2_end": "21:00:00", - "hsem_batteries_enable_batteries_schedule_3_start": "23:00:00", - "hsem_batteries_enable_batteries_schedule_3_end": "02:00:00", "hsem_ev_deadline_time": "07:00:00", "hsem_ev_second_deadline_time": "07:00:00", } @@ -338,7 +329,7 @@ def add_entities(entities: Any, _update_before_add: bool = False) -> None: ): await async_setup_entry(hass, config_entry, add_entities) # type: ignore[arg-type] # HA AddEntitiesCallback stub too strict for test callback - assert len(added) == 8 + assert len(added) == 2 @pytest.mark.asyncio async def test_setup_entry_all_entities_are_time_entities(self) -> None: @@ -348,12 +339,8 @@ async def test_setup_entry_all_entities_are_time_entities(self) -> None: hass = _mock_hass() config_entry = _mock_config_entry( **{ - "hsem_batteries_enable_batteries_schedule_1_start": "07:00:00", - "hsem_batteries_enable_batteries_schedule_1_end": "09:00:00", - "hsem_batteries_enable_batteries_schedule_2_start": "17:00:00", - "hsem_batteries_enable_batteries_schedule_2_end": "21:00:00", - "hsem_batteries_enable_batteries_schedule_3_start": "23:00:00", - "hsem_batteries_enable_batteries_schedule_3_end": "02:00:00", + "hsem_ev_deadline_time": "07:00:00", + "hsem_ev_second_deadline_time": "07:00:00", } ) added: list = [] @@ -379,12 +366,6 @@ async def test_setup_entry_entities_have_valid_native_values(self) -> None: hass = _mock_hass() config_entry = _mock_config_entry( **{ - "hsem_batteries_enable_batteries_schedule_1_start": "07:00:00", - "hsem_batteries_enable_batteries_schedule_1_end": "09:00:00", - "hsem_batteries_enable_batteries_schedule_2_start": "17:00:00", - "hsem_batteries_enable_batteries_schedule_2_end": "21:00:00", - "hsem_batteries_enable_batteries_schedule_3_start": "23:00:00", - "hsem_batteries_enable_batteries_schedule_3_end": "02:00:00", "hsem_ev_deadline_time": "07:00:00", "hsem_ev_second_deadline_time": "07:00:00", } diff --git a/tests/test_flow_helpers.py b/tests/test_flow_helpers.py index a042e41f..9d0f90cd 100644 --- a/tests/test_flow_helpers.py +++ b/tests/test_flow_helpers.py @@ -1,11 +1,9 @@ -"""Tests for the reusable schedule and EV config-flow helper modules. +"""Tests for the reusable EV config-flow helper module. Covers: -- :mod:`custom_components.hsem.flows.schedule_helpers` - :mod:`custom_components.hsem.flows.ev_helpers` Acceptance criteria from issue #313: -- Schedule flows share one code path (via ``schedule_helpers``). - EV flows share one code path (via ``ev_helpers``). - Existing config migration still works (schema keys are unchanged). - Schema field names produced by the helpers match the original hard-coded names. @@ -48,165 +46,6 @@ def _make_config_entry(overrides: dict | None = None) -> MagicMock: return entry -# =========================================================================== -# schedule_helpers — build_batteries_schedule_step_schema -# =========================================================================== - - -class TestBuildBatteriesScheduleStepSchema: - """Schema factory produces correct fields for each schedule number.""" - - @pytest.mark.asyncio - @pytest.mark.parametrize("n", [1, 2, 3]) - async def test_schema_contains_three_fields(self, n: int) -> None: - """All expected keys must be present in the built schema.""" - from custom_components.hsem.flows.schedule_helpers import ( - build_batteries_schedule_step_schema, - ) - - schema = await build_batteries_schedule_step_schema(n, None) - keys = {str(k) for k in schema.schema} - prefix = f"hsem_batteries_enable_batteries_schedule_{n}" - assert prefix in keys - assert f"{prefix}_start" in keys - assert f"{prefix}_end" in keys - # min_price_difference field removed - - @pytest.mark.asyncio - async def test_schema_has_no_min_price_difference(self): - """The min_price_difference field was removed from the schedule schema.""" - from custom_components.hsem.flows.schedule_helpers import ( - build_batteries_schedule_step_schema, - ) - - entry = _make_config_entry() - schema = await build_batteries_schedule_step_schema(1, entry) - keys = {str(k) for k in schema.schema} - assert ( - "hsem_batteries_enable_batteries_schedule_1_min_price_difference" - not in keys - ) - - @pytest.mark.asyncio - async def test_rated_capacity_resolved_from_hass_state(self): - """resolve_usable_capacity_kwh uses the live HA state when available.""" - from custom_components.hsem.flows.schedule_helpers import ( - resolve_usable_capacity_kwh, - ) - - hass = _make_hass({"sensor.batteries_rated_capacity": "15000"}) - entry = _make_config_entry() - capacity = resolve_usable_capacity_kwh(hass, entry) - assert capacity == pytest.approx(15.0) - - @pytest.mark.asyncio - async def test_rated_capacity_falls_back_to_10kwh(self): - """resolve_usable_capacity_kwh returns 10.0 when HA state is unavailable.""" - from custom_components.hsem.flows.schedule_helpers import ( - resolve_usable_capacity_kwh, - ) - - capacity = resolve_usable_capacity_kwh(None, None) - assert capacity == pytest.approx(10.0) - - @pytest.mark.asyncio - async def test_rated_capacity_falls_back_when_state_unparseable(self): - """resolve_usable_capacity_kwh returns 10.0 when state is not a number.""" - from custom_components.hsem.flows.schedule_helpers import ( - resolve_usable_capacity_kwh, - ) - - hass = _make_hass({"sensor.batteries_rated_capacity": "unavailable"}) - entry = _make_config_entry() - capacity = resolve_usable_capacity_kwh(hass, entry) - assert capacity == pytest.approx(10.0) - - -# =========================================================================== -# schedule_helpers — validate_batteries_schedule_input -# =========================================================================== - - -class TestValidateBatteriesScheduleInput: - """Validator is identical in behaviour for all three schedule numbers.""" - - @pytest.mark.asyncio - @pytest.mark.parametrize("n", [1, 2, 3]) - async def test_disabled_schedule_passes_with_invalid_times(self, n: int) -> None: - """Disabled schedule must skip time validation entirely.""" - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - prefix = f"hsem_batteries_enable_batteries_schedule_{n}" - errors = await validate_batteries_schedule_input( - n, - { - prefix: False, - f"{prefix}_start": "INVALID", - f"{prefix}_end": "INVALID", - }, - ) - assert errors == {} - - @pytest.mark.asyncio - @pytest.mark.parametrize("n", [1, 2, 3]) - async def test_zero_length_active_schedule_is_rejected(self, n: int) -> None: - """start == end when enabled must produce a base error.""" - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - prefix = f"hsem_batteries_enable_batteries_schedule_{n}" - errors = await validate_batteries_schedule_input( - n, - { - prefix: True, - f"{prefix}_start": "12:00:00", - f"{prefix}_end": "12:00:00", - }, - ) - assert errors.get("base") == "start_time_equals_end_time" - - @pytest.mark.asyncio - @pytest.mark.parametrize("n", [1, 2, 3]) - async def test_valid_cross_midnight_window_accepted(self, n: int) -> None: - """A valid cross-midnight window must pass with no errors.""" - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - prefix = f"hsem_batteries_enable_batteries_schedule_{n}" - errors = await validate_batteries_schedule_input( - n, - { - prefix: True, - f"{prefix}_start": "23:00:00", - f"{prefix}_end": "02:00:00", - }, - ) - assert errors == {} - - @pytest.mark.asyncio - @pytest.mark.parametrize("n", [1, 2, 3]) - async def test_invalid_time_format_rejected(self, n: int) -> None: - """An unparseable time string must produce an ``invalid_time_format`` error.""" - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - prefix = f"hsem_batteries_enable_batteries_schedule_{n}" - errors = await validate_batteries_schedule_input( - n, - { - prefix: True, - f"{prefix}_start": "not-a-time", - f"{prefix}_end": "09:00:00", - }, - ) - assert errors.get(f"{prefix}_start") == "invalid_time_format" - - # =========================================================================== # ev_helpers — build_ev_charger_schema # =========================================================================== @@ -426,24 +265,6 @@ async def test_secondary_ev_validation_matches_original_ev_second_step(self): class TestSchemaRoundTrip: """Valid user input must pass through the schema without voluptuous raising.""" - @pytest.mark.asyncio - @pytest.mark.parametrize("n", [1, 2, 3]) - async def test_schedule_schema_accepts_valid_input(self, n: int) -> None: - from custom_components.hsem.flows.schedule_helpers import ( - build_batteries_schedule_step_schema, - ) - - prefix = f"hsem_batteries_enable_batteries_schedule_{n}" - schema = await build_batteries_schedule_step_schema(n, None) - valid_input = { - prefix: True, - f"{prefix}_start": "07:00:00", - f"{prefix}_end": "09:00:00", - } - # Should not raise - result = schema(valid_input) - assert result[prefix] is True # pyright: ignore[reportIndexIssue] - @pytest.mark.asyncio async def test_ev_primary_schema_accepts_valid_input(self): from custom_components.hsem.flows.ev_helpers import build_ev_charger_schema diff --git a/tests/test_grid_charge_emergency_stop.py b/tests/test_grid_charge_emergency_stop.py index 9a9ef1ac..0c0743a6 100644 --- a/tests/test_grid_charge_emergency_stop.py +++ b/tests/test_grid_charge_emergency_stop.py @@ -477,7 +477,6 @@ def _make_coordinator_data(self, *, rec: HourlyRecommendation | None) -> MagicMo data.cfg = cfg data.live = live data.hourly_recommendation = rec - data.batteries_schedules_remaining_capacity_needed = 0.0 data.current_required_battery = 0.0 data.apply_summary = None return data diff --git a/tests/test_ha_mock_integration.py b/tests/test_ha_mock_integration.py index b109c6cb..e5a7b736 100644 --- a/tests/test_ha_mock_integration.py +++ b/tests/test_ha_mock_integration.py @@ -220,8 +220,6 @@ def make_bare_coordinator( coord._avg_house_consumption_entity_id_cache = {} coord._hourly_recommendations = [] coord._hourly_recommendation = None - coord._batteries_schedules = [] - coord._batteries_schedules_remaining_capacity_needed = 0.0 coord._current_required_battery = 0.0 coord._live = None coord._snapshot = None @@ -2154,7 +2152,6 @@ def _make_coord_with_recs( ) -> Any: """Return a bare coordinator whose _hourly_recommendations are pre-generated.""" coord = make_bare_coordinator() - coord._batteries_schedules = [] coord._hourly_recommendations = generate_recommendation_intervals( interval_minutes, total_hours ) @@ -2361,7 +2358,6 @@ def test_zoneinfo_rec_matches_fixed_offset_slot(self): ) coord = make_bare_coordinator() - coord._batteries_schedules = [] coord._hourly_recommendations = recs coord._apply_planner_output(PlannerOutput(slots=slots)) @@ -2440,7 +2436,6 @@ def test_microsecond_in_rec_start_still_matches(self): ) coord = make_bare_coordinator() - coord._batteries_schedules = [] coord._hourly_recommendations = recs coord._apply_planner_output(PlannerOutput(slots=slots)) @@ -2506,7 +2501,6 @@ def test_warning_emitted_for_unmatched_rec_slot(self): ] coord = make_bare_coordinator() - coord._batteries_schedules = [] coord._hourly_recommendations = [orphan_rec] # Capture WARNING from HSEM_LOGGER directly (propagation is False) @@ -2574,7 +2568,6 @@ def test_unmatched_rec_fields_stay_at_default(self): ] coord = make_bare_coordinator() - coord._batteries_schedules = [] coord._hourly_recommendations = [orphan] coord._apply_planner_output(PlannerOutput(slots=slots)) @@ -2805,7 +2798,6 @@ def _run_end_to_end(self, base_includes_ev: bool) -> Any: # Build coordinator with matching hourly_recommendations coord = make_bare_coordinator() - coord._batteries_schedules = [] # Generate recs aligned to planner slot starts coord._hourly_recommendations = [ HourlyRecommendation( diff --git a/tests/test_import_integrity.py b/tests/test_import_integrity.py index e60d6900..6bded5b5 100644 --- a/tests/test_import_integrity.py +++ b/tests/test_import_integrity.py @@ -31,8 +31,6 @@ def _public_names(module: ModuleType) -> set[str]: FLOW_MODULES = [ "custom_components.hsem.flows.batteries_excess_export", - "custom_components.hsem.flows.batteries_schedules", - "custom_components.hsem.flows.schedule_helpers", "custom_components.hsem.flows.prices", "custom_components.hsem.flows.ev", "custom_components.hsem.flows.ev_second", @@ -73,12 +71,6 @@ def _assert_importable(self, module_path: str) -> None: def test_batteries_excess_export_importable(self): self._assert_importable("custom_components.hsem.flows.batteries_excess_export") - def test_batteries_schedules_importable(self): - self._assert_importable("custom_components.hsem.flows.batteries_schedules") - - def test_schedule_helpers_importable(self): - self._assert_importable("custom_components.hsem.flows.schedule_helpers") - def test_prices_importable(self): self._assert_importable("custom_components.hsem.flows.prices") diff --git a/tests/test_midnight_rollover.py b/tests/test_midnight_rollover.py deleted file mode 100644 index bb5ea389..00000000 --- a/tests/test_midnight_rollover.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Regression tests for midnight rollover interval filtering (P0-02). - -Verifies that charge/discharge recommendation windows crossing midnight are -handled correctly by the utility helpers and the schedule validator. - -Acceptance criteria from issue #266: -- A window from 23:00 to 02:00 works. -- A charge window from 00:00 to 06:00 works. -- Tests cover same-day windows and cross-midnight windows. - -Note: schedule validator functions are ``async`` but contain no I/O — we run -them via ``asyncio.run()`` to avoid pytest-asyncio / pytest-socket conflicts on -Windows. -""" - -import asyncio -from datetime import UTC, datetime, timedelta - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -_UTC = UTC - - -def _dt(hour: int, minute: int = 0, date_offset: int = 0) -> datetime: - """Return a UTC-aware datetime on 2026-01-15 (+ optional day offset).""" - base = datetime(2026, 1, 15, hour, minute, tzinfo=_UTC) - return base + timedelta(days=date_offset) - - -# --------------------------------------------------------------------------- -# Tests for schedule validator (batteries_schedule_*.py) -# -# Note: the former ``interval_ends_before_window_start`` unit tests that -# lived in this module were removed in issue #898 along with the helper -# itself — it had no production caller and could not safely replace -# ``pre_charge.py``'s per-occurrence eligible-slot filter (see that file's -# comments). Cross-midnight eligible-slot filtering is now covered directly -# via ``apply_charge_schedules`` in -# ``tests/planner/test_charge_scheduler_capacity.py``. -# --------------------------------------------------------------------------- - - -def _run_async(coro): - """Run a coroutine synchronously using the SelectorEventLoop. - - ``asyncio.run()`` respects the current event loop policy, which in the HA - test environment points to ``WindowsProactorEventLoopPolicy``. That loop - requires ``socket.socketpair()`` which is blocked by ``pytest-socket``. - We bypass the policy by explicitly creating a ``SelectorEventLoop`` (which - also needs a socket pair on Windows) while temporarily enabling sockets. - """ - import sys - - import pytest_socket - - pytest_socket.enable_socket() - try: - if sys.platform == "win32": - loop = asyncio.SelectorEventLoop() - else: - loop = asyncio.new_event_loop() - finally: - pytest_socket.disable_socket(allow_unix_socket=True) - - try: - return loop.run_until_complete(coro) - finally: - loop.close() - - -class TestScheduleValidator: - """Tests that cross-midnight windows pass schedule validation. - - The validator functions are ``async`` but contain no I/O. We run them - using ``_run_async()`` which creates a short-lived SelectorEventLoop with - sockets temporarily enabled (required on Windows) so that pytest-socket - does not block the internal self-pipe creation. - """ - - def test_same_day_window_valid(self): - """A same-day window (start < end) passes validation.""" - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - errors = _run_async( - validate_batteries_schedule_input( - 1, - { - "hsem_batteries_enable_batteries_schedule_1": True, - "hsem_batteries_enable_batteries_schedule_1_start": "07:00:00", - "hsem_batteries_enable_batteries_schedule_1_end": "09:00:00", - }, - ) - ) - assert errors == {} - - def test_cross_midnight_window_valid(self): - """A cross-midnight window (start > end, e.g. 23:00-02:00) passes validation.""" - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - errors = _run_async( - validate_batteries_schedule_input( - 1, - { - "hsem_batteries_enable_batteries_schedule_1": True, - "hsem_batteries_enable_batteries_schedule_1_start": "23:00:00", - "hsem_batteries_enable_batteries_schedule_1_end": "02:00:00", - }, - ) - ) - assert errors == {}, ( - f"Expected no errors for cross-midnight window, got: {errors}" - ) - - def test_zero_to_six_window_valid(self): - """A 00:00-06:00 window (P0-02 AC) passes validation.""" - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - errors = _run_async( - validate_batteries_schedule_input( - 1, - { - "hsem_batteries_enable_batteries_schedule_1": True, - "hsem_batteries_enable_batteries_schedule_1_start": "00:00:00", - "hsem_batteries_enable_batteries_schedule_1_end": "06:00:00", - }, - ) - ) - assert errors == {} - - def test_equal_start_end_invalid(self): - """A window with identical start and end times is invalid.""" - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - errors = _run_async( - validate_batteries_schedule_input( - 1, - { - "hsem_batteries_enable_batteries_schedule_1": True, - "hsem_batteries_enable_batteries_schedule_1_start": "09:00:00", - "hsem_batteries_enable_batteries_schedule_1_end": "09:00:00", - }, - ) - ) - assert errors != {} - - def test_schedule_2_cross_midnight_window_valid(self): - """Schedule 2: cross-midnight window passes validation.""" - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - errors = _run_async( - validate_batteries_schedule_input( - 2, - { - "hsem_batteries_enable_batteries_schedule_2": True, - "hsem_batteries_enable_batteries_schedule_2_start": "23:00:00", - "hsem_batteries_enable_batteries_schedule_2_end": "02:00:00", - }, - ) - ) - assert errors == {}, ( - f"Expected no errors for cross-midnight window, got: {errors}" - ) - - def test_schedule_3_cross_midnight_window_valid(self): - """Schedule 3: cross-midnight window passes validation.""" - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - errors = _run_async( - validate_batteries_schedule_input( - 3, - { - "hsem_batteries_enable_batteries_schedule_3": True, - "hsem_batteries_enable_batteries_schedule_3_start": "23:00:00", - "hsem_batteries_enable_batteries_schedule_3_end": "02:00:00", - }, - ) - ) - assert errors == {}, ( - f"Expected no errors for cross-midnight window, got: {errors}" - ) diff --git a/tests/test_p0_regression_suite.py b/tests/test_p0_regression_suite.py index ce48e000..effe54fd 100644 --- a/tests/test_p0_regression_suite.py +++ b/tests/test_p0_regression_suite.py @@ -260,104 +260,6 @@ def test_slot_after_discharge_window_excluded(self) -> None: assert not (charge_slot_end <= next_window_start_dt(now, time(7, 0))) -# =========================================================================== -# P0-04 Schedule_3 default (issue #268) -# =========================================================================== - - -class TestP004Schedule3Default: - """OLD BUG: ``schedule_3`` defaulted to ``enabled=True`` with a - ``00:00:00 → 00:00:00`` window, which is a zero-length window that cannot - be distinguished from midnight-to-midnight (a 24-hour window). This caused - spurious grid-charge commands on any night where schedule_3 fired. - - FIX: ``schedule_3`` is now ``enabled=False`` by default and uses explicit - non-midnight placeholder times so the window is unambiguously non-zero - when re-enabled by the user. - """ - - def test_schedule_3_disabled_by_default(self) -> None: - """schedule_3 must ship disabled so it never fires unintentionally.""" - from custom_components.hsem.const import DEFAULT_CONFIG_VALUES - - assert ( - DEFAULT_CONFIG_VALUES["hsem_batteries_enable_batteries_schedule_3"] is False - ), "schedule_3 must default to disabled" - - def test_schedule_3_default_start_is_not_midnight(self) -> None: - """Default start must not be '00:00:00' to avoid ambiguous zero-length window.""" - from custom_components.hsem.const import DEFAULT_CONFIG_VALUES - - start = DEFAULT_CONFIG_VALUES[ - "hsem_batteries_enable_batteries_schedule_3_start" - ] - assert start != "00:00:00", ( - "schedule_3 default start '00:00:00' + end '00:00:00' is ambiguous" - ) - - def test_schedule_3_default_end_is_not_midnight(self) -> None: - """Default end must not be '00:00:00'.""" - from custom_components.hsem.const import DEFAULT_CONFIG_VALUES - - end = DEFAULT_CONFIG_VALUES["hsem_batteries_enable_batteries_schedule_3_end"] - assert end != "00:00:00" - - def test_schedule_3_default_start_and_end_differ(self) -> None: - """Default start ≠ default end — window is non-zero when enabled.""" - from custom_components.hsem.const import DEFAULT_CONFIG_VALUES - - start = DEFAULT_CONFIG_VALUES[ - "hsem_batteries_enable_batteries_schedule_3_start" - ] - end = DEFAULT_CONFIG_VALUES["hsem_batteries_enable_batteries_schedule_3_end"] - assert start != end, "Default schedule_3 must not be a zero-length window" - - def test_schedules_1_and_2_remain_enabled(self) -> None: - """Schedules 1 and 2 should remain enabled by default.""" - from custom_components.hsem.const import DEFAULT_CONFIG_VALUES - - assert ( - DEFAULT_CONFIG_VALUES["hsem_batteries_enable_batteries_schedule_1"] is True - ) - assert ( - DEFAULT_CONFIG_VALUES["hsem_batteries_enable_batteries_schedule_2"] is True - ) - - @pytest.mark.asyncio - async def test_zero_length_window_rejected_by_validator(self) -> None: - """The schedule validator must reject a 00:00:00 → 00:00:00 window when enabled.""" - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - user_input = { - "hsem_batteries_enable_batteries_schedule_3": True, - "hsem_batteries_enable_batteries_schedule_3_start": "00:00:00", - "hsem_batteries_enable_batteries_schedule_3_end": "00:00:00", - "hsem_batteries_enable_batteries_schedule_3_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(3, user_input) - assert "base" in errors, ( - "00:00→00:00 with enabled=True must produce a validation error" - ) - - @pytest.mark.asyncio - async def test_disabled_schedule_3_accepts_any_times(self) -> None: - """A disabled schedule_3 must never fail validation (times are irrelevant).""" - from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, - ) - - user_input = { - "hsem_batteries_enable_batteries_schedule_3": False, - "hsem_batteries_enable_batteries_schedule_3_start": "00:00:00", - "hsem_batteries_enable_batteries_schedule_3_end": "00:00:00", - "hsem_batteries_enable_batteries_schedule_3_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(3, user_input) - assert errors == {}, "Disabled schedule must not fail validation" - - # =========================================================================== # P0-05 Invalid sensor values (issue #269) # =========================================================================== @@ -679,37 +581,18 @@ def test_near_zero_default_matches_v510(self) -> None: assert pytest.approx(0.1) == NEAR_ZERO_CONSUMPTION_THRESHOLD_KWH - def test_scheduler_modules_import_constants_not_literals(self) -> None: - """charge_scheduler.py and discharge_scheduler.py must import and reference - the named constants instead of bare literals.""" + def test_discharge_scheduler_does_not_import_near_zero_constant(self) -> None: + """discharge_scheduler.py must not import the removed near-zero constant + (issue #720 removed the misapplied threshold).""" import ast import pathlib - # charge_scheduler.py is now a thin re-export; the actual import lives - # in the implementation module under planner/charging/. - source = pathlib.Path( - "custom_components/hsem/planner/charging/pre_charge.py" - ).read_text(encoding="utf-8") - tree = ast.parse(source) - - imported_names: set[str] = set() - for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom): - for alias in node.names: - imported_names.add(alias.asname or alias.name) - - assert "SOLAR_SURPLUS_CHARGE_THRESHOLD_KWH" in imported_names, ( - "charging/pre_charge.py must import SOLAR_SURPLUS_CHARGE_THRESHOLD_KWH" - ) - - # Also verify discharge_scheduler.py does not import the removed - # near-zero constant (issue #720 removed the misapplied threshold). source = pathlib.Path( "custom_components/hsem/planner/discharge_scheduler.py" ).read_text(encoding="utf-8") tree = ast.parse(source) - imported_names = set() + imported_names: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.ImportFrom): for alias in node.names: diff --git a/tests/test_platform_entity_refactor.py b/tests/test_platform_entity_refactor.py index 65125dc3..18de454d 100644 --- a/tests/test_platform_entity_refactor.py +++ b/tests/test_platform_entity_refactor.py @@ -12,7 +12,7 @@ Unique-ID format contract (must never change): - Switch: ``"hsem___switch"`` e.g. ``"hsem__hsem_read_only_switch"`` - - Time: ``"hsem___time"`` e.g. ``"hsem__hsem_batteries_enable_batteries_schedule_1_start_time"`` + - Time: ``"hsem___time"`` e.g. ``"hsem__hsem_ev_deadline_time_time"`` - Select: ``"_"`` e.g. ``"hsem_force_working_mode_test_entry_id"`` (The key already carries the ``hsem_`` prefix; entry_id is appended for uniqueness.) """ @@ -51,21 +51,14 @@ def _mock_config_entry(entry_id: str = "test_entry_id", **opts: Any) -> MagicMoc "hsem_read_only": False, "hsem_extended_attributes": False, "hsem_verbose_logging": False, - "hsem_batteries_enable_batteries_schedule_1": True, - "hsem_batteries_enable_batteries_schedule_2": False, - "hsem_batteries_enable_batteries_schedule_3": False, "hsem_ev_charger_force_max_discharge_power": False, # Both EVs' planned load enabled by default so exhaustive # "all descriptions created" setup tests keep covering every switch # (issue #859 gates EV switches on these flags). "hsem_ev_planned_load_enabled": True, "hsem_ev_second_planned_load_enabled": True, - "hsem_batteries_enable_batteries_schedule_1_start": "07:00:00", - "hsem_batteries_enable_batteries_schedule_1_end": "09:00:00", - "hsem_batteries_enable_batteries_schedule_2_start": "17:00:00", - "hsem_batteries_enable_batteries_schedule_2_end": "21:00:00", - "hsem_batteries_enable_batteries_schedule_3_start": "23:00:00", - "hsem_batteries_enable_batteries_schedule_3_end": "02:00:00", + "hsem_ev_deadline_time": "07:00:00", + "hsem_ev_second_deadline_time": "17:00:00", **opts, } return entry @@ -95,9 +88,9 @@ def _make_switch( def _make_time( - key: str = "hsem_batteries_enable_batteries_schedule_1_start", - name: str = "Batteries Discharge Schedule 1 Start", - description: str = "Start time for schedule 1.", + key: str = "hsem_ev_deadline_time", + name: str = "EV Deadline", + description: str = "EV charge deadline.", default: str = "07:00:00", ) -> HSEMTimeEntity: hass = _mock_hass() @@ -401,9 +394,9 @@ async def test_setup_reads_initial_state_from_config(self) -> None: "hsem_read_only": True, "hsem_extended_attributes": False, "hsem_verbose_logging": True, - "hsem_batteries_enable_batteries_schedule_1": False, - "hsem_batteries_enable_batteries_schedule_2": True, - "hsem_batteries_enable_batteries_schedule_3": False, + "hsem_dynamic_discharge_floor": False, + "hsem_ml_consumption_enabled": True, + "hsem_ml_consumption_sequential": False, "hsem_ev_charger_force_max_discharge_power": True, } config_entry = _mock_config_entry(**opts) # type: ignore[arg-type] # test helper: typed dict values @@ -433,7 +426,7 @@ class TestTimeUniqueId: def test_unique_id_format(self) -> None: """unique_id must be 'hsem___time'.""" - key = "hsem_batteries_enable_batteries_schedule_1_start" + key = "hsem_ev_deadline_time" entity = _make_time(key=key) assert entity.unique_id == f"{DOMAIN}_test_entry_id_{key}_time" @@ -451,8 +444,8 @@ def test_unique_id_is_attr_not_property(self) -> None: assert entity._attr_unique_id == entity.unique_id def test_different_keys_produce_different_unique_ids(self) -> None: - e1 = _make_time(key="hsem_batteries_enable_batteries_schedule_1_start") - e2 = _make_time(key="hsem_batteries_enable_batteries_schedule_1_end") + e1 = _make_time(key="hsem_ev_deadline_time") + e2 = _make_time(key="hsem_ev_second_deadline_time") assert e1.unique_id != e2.unique_id @@ -491,12 +484,8 @@ async def test_setup_reads_initial_value_from_config(self) -> None: hass = _mock_hass() opts = { - "hsem_batteries_enable_batteries_schedule_1_start": "06:30:00", - "hsem_batteries_enable_batteries_schedule_1_end": "09:00:00", - "hsem_batteries_enable_batteries_schedule_2_start": "16:00:00", - "hsem_batteries_enable_batteries_schedule_2_end": "20:30:00", - "hsem_batteries_enable_batteries_schedule_3_start": "22:00:00", - "hsem_batteries_enable_batteries_schedule_3_end": "01:00:00", + "hsem_ev_deadline_time": "06:30:00", + "hsem_ev_second_deadline_time": "20:30:00", } config_entry = _mock_config_entry(**opts) # type: ignore[arg-type] # test helper: typed dict values added: list[HSEMTimeEntity] = [] @@ -511,24 +500,8 @@ def add_entities(entities: list, _update_before_add: bool = False) -> None: await async_setup_entry(hass, config_entry, add_entities) # type: ignore[arg-type] # HA AddEntitiesCallback stub too strict for test callback by_key = {e.entity_description.key: e for e in added} - assert by_key[ - "hsem_batteries_enable_batteries_schedule_1_start" - ].native_value == time(6, 30) - assert by_key[ - "hsem_batteries_enable_batteries_schedule_1_end" - ].native_value == time(9, 0) - assert by_key[ - "hsem_batteries_enable_batteries_schedule_2_start" - ].native_value == time(16, 0) - assert by_key[ - "hsem_batteries_enable_batteries_schedule_2_end" - ].native_value == time(20, 30) - assert by_key[ - "hsem_batteries_enable_batteries_schedule_3_start" - ].native_value == time(22, 0) - assert by_key[ - "hsem_batteries_enable_batteries_schedule_3_end" - ].native_value == time(1, 0) + assert by_key["hsem_ev_deadline_time"].native_value == time(6, 30) + assert by_key["hsem_ev_second_deadline_time"].native_value == time(20, 30) @pytest.mark.asyncio async def test_setup_uses_entity_description(self) -> None: diff --git a/tests/test_power_thresholds.py b/tests/test_power_thresholds.py index 70f3caba..24e5bd21 100644 --- a/tests/test_power_thresholds.py +++ b/tests/test_power_thresholds.py @@ -12,7 +12,7 @@ from __future__ import annotations -from datetime import UTC, datetime, time +from datetime import UTC, datetime import pytest @@ -20,7 +20,6 @@ NEAR_ZERO_CONSUMPTION_THRESHOLD_KWH, SOLAR_SURPLUS_CHARGE_THRESHOLD_KWH, ) -from custom_components.hsem.models.battery_schedule_input import BatteryScheduleInput from custom_components.hsem.models.hourly_consumption_average import ( HourlyConsumptionAverage, ) @@ -29,7 +28,6 @@ from custom_components.hsem.models.price_point import PricePoint from custom_components.hsem.models.solcast_slot import SolcastSlot from custom_components.hsem.planner import run_planner -from custom_components.hsem.planner.charge_scheduler import apply_charge_schedules from custom_components.hsem.planner.discharge_scheduler import ( apply_optimization_strategy, ) @@ -77,7 +75,6 @@ def _make_minimal_input( months_winter: list[int] | None = None, battery_soc_pct: float = 50.0, interval_minutes: int = 60, - schedules: list[BatteryScheduleInput] | None = None, ) -> PlannerInput: """Build a PlannerInput from parallel per-hour lists.""" prices = [ @@ -110,7 +107,6 @@ def _make_minimal_input( consumption_averages=consumption, price_points=prices, solcast_slots=solar, - battery_schedules=schedules if schedules is not None else [], excess_export_enabled=False, excess_export_discharge_buffer_pct=10.0, excess_export_price_threshold=0.10, @@ -152,80 +148,7 @@ def test_solar_surplus_threshold_less_than_near_zero(self): # =========================================================================== -# 2. apply_charge_schedules — solar surplus threshold -# =========================================================================== - - -class TestSolarSurplusThresholdInChargeSchedules: - """Slots qualify for Priority-2 solar charge only when net consumption - is strictly below SOLAR_SURPLUS_CHARGE_THRESHOLD_KWH.""" - - def _run(self, net_consumption: float) -> str | None: - """Return the recommendation assigned to a single candidate slot. - - Priority-3 (cheapest grid hours) is disabled by setting a very large - depreciation threshold so the test can isolate whether Priority-2 - (solar surplus) fires. The flat 0.20 import price would never satisfy - a high depreciation guard, so the slot stays unassigned unless the - solar surplus condition triggers. - """ - now = _now(0) - # Put the candidate slot just before a discharge window - candidate = _slot(hour=6, net_consumption=net_consumption) - # A later slot acts as the discharge window - discharge_slot = _slot( - hour=8, - net_consumption=0.5, - recommendation=Recommendations.BatteriesDischargeMode.value, - ) - slots = [candidate, discharge_slot] - - sched = BatteryScheduleInput( - enabled=True, - start=time(8, 0), - end=time(9, 0), - ) - # Pre-set discharge schedule metadata (normally done by apply_discharge_schedules) - sched._needed_capacity = 0.5 # type: ignore[attr-defined] # mock attribute set in test - sched._avg_import_price = 0.20 # type: ignore[attr-defined] # mock attribute set in test - - apply_charge_schedules( - slots=slots, - battery_schedules=[sched], - now=now, - max_charge_per_interval=5.0, - recommended_threshold=1000.0, - ) - return candidate.recommendation - - def test_large_solar_surplus_is_charged(self): - """A slot with -1.0 kWh net (strong surplus) must be solar-charged.""" - assert self._run(-1.0) == _CHARGE_SOLAR - - def test_at_exact_threshold_not_charged(self): - """A slot exactly at the threshold (-0.2) must NOT be solar-charged - (condition is strictly less-than).""" - assert self._run(SOLAR_SURPLUS_CHARGE_THRESHOLD_KWH) is None - - def test_just_below_threshold_is_charged(self): - """A slot just below the threshold (-0.21) must be solar-charged.""" - assert self._run(SOLAR_SURPLUS_CHARGE_THRESHOLD_KWH - 0.01) == _CHARGE_SOLAR - - def test_just_above_threshold_not_charged(self): - """A slot just above the threshold (-0.19) must NOT be solar-charged.""" - assert self._run(SOLAR_SURPLUS_CHARGE_THRESHOLD_KWH + 0.01) is None - - def test_zero_net_consumption_not_solar_charged(self): - """A balanced slot (0.0 kWh net) must NOT qualify for Priority-2.""" - assert self._run(0.0) is None - - def test_positive_net_consumption_not_solar_charged(self): - """A consumption slot (0.5 kWh net) must NOT qualify for Priority-2.""" - assert self._run(0.5) is None - - -# =========================================================================== -# 3. apply_optimization_strategy — near-zero consumption threshold +# 2. apply_optimization_strategy — near-zero consumption threshold # =========================================================================== @@ -273,7 +196,7 @@ def test_high_consumption_assigned_discharge(self): # =========================================================================== -# 4. apply_optimization_strategy — solar charging loop threshold +# 3. apply_optimization_strategy — solar charging loop threshold # =========================================================================== @@ -326,17 +249,16 @@ def test_small_positive_consumption_excluded_from_charge_loop(self): # =========================================================================== -# 5. End-to-end planner — threshold boundaries via run_planner +# 4. End-to-end planner — threshold boundaries via run_planner # =========================================================================== class TestPlannerThresholdEndToEnd: - """Full planner runs to confirm thresholds are respected when coordinated - with schedule-based charge/discharge decisions.""" + """Full planner runs to confirm thresholds are respected end-to-end.""" def test_no_false_solar_charge_on_consumption_hours(self): """Hours where consumption clearly exceeds solar must NOT be - classified as BatteriesChargeSolar (summer, no schedule).""" + classified as BatteriesChargeSolar (summer).""" # High consumption at night, no solar at night solar = [0.0] * 6 + [0.1] * 18 consumption = [1.5] * 6 + [0.05] * 18 # night consumption >> solar diff --git a/tests/test_price_interval_semantics.py b/tests/test_price_interval_semantics.py index 9ab75fb7..fd5dd2f7 100644 --- a/tests/test_price_interval_semantics.py +++ b/tests/test_price_interval_semantics.py @@ -318,7 +318,6 @@ def _rec(i: int) -> HourlyRecommendation: cfg=cfg, live=LiveState(), hourly_recommendations=recs, - batteries_schedules=[], previous_winner_name=None, previous_winner_score=0.0, ) diff --git a/tests/test_quarter_hourly_planner_input.py b/tests/test_quarter_hourly_planner_input.py index 804fe238..512659b8 100644 --- a/tests/test_quarter_hourly_planner_input.py +++ b/tests/test_quarter_hourly_planner_input.py @@ -195,7 +195,6 @@ def _rec(i: int) -> HourlyRecommendation: cfg=cfg, live=LiveState(), hourly_recommendations=recs, - batteries_schedules=[], previous_winner_name=None, previous_winner_score=0.0, ) @@ -260,7 +259,6 @@ def _rec(i: int) -> HourlyRecommendation: cfg=cfg, live=LiveState(), hourly_recommendations=recs, - batteries_schedules=[], previous_winner_name=None, previous_winner_score=0.0, ) diff --git a/tests/test_safety_gates.py b/tests/test_safety_gates.py index 80215196..107d234b 100644 --- a/tests/test_safety_gates.py +++ b/tests/test_safety_gates.py @@ -734,7 +734,6 @@ def _make_coordinator_data( data.cfg = cfg data.live = live data.hourly_recommendation = None - data.batteries_schedules_remaining_capacity_needed = 0.0 data.current_required_battery = 0.0 data.apply_summary = None return data diff --git a/tests/test_schedule_validation.py b/tests/test_schedule_validation.py deleted file mode 100644 index 71f22704..00000000 --- a/tests/test_schedule_validation.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Tests for battery schedule validation logic (issue #268). - -Verifies: -- schedule_3 is disabled by default (enabled=False) with non-ambiguous placeholder times -- A zero-length active schedule (start == end) raises a validation error -- Disabled schedules bypass time-window validation entirely -- Valid cross-midnight windows are accepted -- Invalid time formats produce the correct error key -""" - -import pytest - -from custom_components.hsem.const import DEFAULT_CONFIG_VALUES -from custom_components.hsem.flows.schedule_helpers import ( - validate_batteries_schedule_input, -) - -# --------------------------------------------------------------------------- -# Default constant tests -# --------------------------------------------------------------------------- - - -class TestSchedule3DefaultValues: - """Verify the schedule_3 defaults satisfy the acceptance criteria.""" - - def test_schedule_3_disabled_by_default(self): - """schedule_3 must be disabled (False) out of the box.""" - assert ( - DEFAULT_CONFIG_VALUES["hsem_batteries_enable_batteries_schedule_3"] is False - ) - - def test_schedule_3_default_start_is_not_midnight(self): - """Default start time must not be 00:00:00 to avoid ambiguous zero-length window.""" - start = DEFAULT_CONFIG_VALUES[ - "hsem_batteries_enable_batteries_schedule_3_start" - ] - assert start != "00:00:00", ( - "Default start '00:00:00' combined with default end '00:00:00' creates an " - "ambiguous zero-length window. Use explicit placeholder times instead." - ) - - def test_schedule_3_default_end_is_not_midnight(self): - """Default end time must not be 00:00:00 to avoid ambiguous zero-length window.""" - end = DEFAULT_CONFIG_VALUES["hsem_batteries_enable_batteries_schedule_3_end"] - assert end != "00:00:00", ( - "Default end '00:00:00' combined with default start '00:00:00' creates an " - "ambiguous zero-length window. Use explicit placeholder times instead." - ) - - def test_schedule_3_default_start_and_end_differ(self): - """Default start and end must differ so the window is non-zero when enabled.""" - start = DEFAULT_CONFIG_VALUES[ - "hsem_batteries_enable_batteries_schedule_3_start" - ] - end = DEFAULT_CONFIG_VALUES["hsem_batteries_enable_batteries_schedule_3_end"] - assert start != end, ( - "Default schedule_3 has a zero-length window (start == end)." - ) - - def test_schedule_1_and_2_enabled_by_default(self): - """Schedules 1 and 2 should remain enabled in their defaults.""" - assert ( - DEFAULT_CONFIG_VALUES["hsem_batteries_enable_batteries_schedule_1"] is True - ) - assert ( - DEFAULT_CONFIG_VALUES["hsem_batteries_enable_batteries_schedule_2"] is True - ) - - -# --------------------------------------------------------------------------- -# Zero-length window rejection tests -# --------------------------------------------------------------------------- - - -class TestZeroLengthWindowRejected: - """An active schedule with start == end must return a validation error.""" - - @pytest.mark.asyncio - async def test_schedule_1_zero_length_active_raises_error(self): - """Schedule 1 rejects start == end when enabled.""" - user_input = { - "hsem_batteries_enable_batteries_schedule_1": True, - "hsem_batteries_enable_batteries_schedule_1_start": "08:00:00", - "hsem_batteries_enable_batteries_schedule_1_end": "08:00:00", - "hsem_batteries_enable_batteries_schedule_1_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(1, user_input) - assert errors.get("base") == "start_time_equals_end_time" - - @pytest.mark.asyncio - async def test_schedule_2_zero_length_active_raises_error(self): - """Schedule 2 rejects start == end when enabled.""" - user_input = { - "hsem_batteries_enable_batteries_schedule_2": True, - "hsem_batteries_enable_batteries_schedule_2_start": "17:00:00", - "hsem_batteries_enable_batteries_schedule_2_end": "17:00:00", - "hsem_batteries_enable_batteries_schedule_2_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(2, user_input) - assert errors.get("base") == "start_time_equals_end_time" - - @pytest.mark.asyncio - async def test_schedule_3_zero_length_active_raises_error(self): - """Schedule 3 rejects start == end when enabled.""" - user_input = { - "hsem_batteries_enable_batteries_schedule_3": True, - "hsem_batteries_enable_batteries_schedule_3_start": "00:00:00", - "hsem_batteries_enable_batteries_schedule_3_end": "00:00:00", - "hsem_batteries_enable_batteries_schedule_3_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(3, user_input) - assert errors.get("base") == "start_time_equals_end_time" - - @pytest.mark.asyncio - async def test_schedule_3_midnight_to_midnight_active_raises_error(self): - """The original ambiguous 00:00->00:00 is explicitly rejected when enabled.""" - user_input = { - "hsem_batteries_enable_batteries_schedule_3": True, - "hsem_batteries_enable_batteries_schedule_3_start": "00:00:00", - "hsem_batteries_enable_batteries_schedule_3_end": "00:00:00", - "hsem_batteries_enable_batteries_schedule_3_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(3, user_input) - assert "base" in errors, "00:00->00:00 with enabled=True must be rejected" - assert errors["base"] == "start_time_equals_end_time" - - -# --------------------------------------------------------------------------- -# Disabled schedule skips validation -# --------------------------------------------------------------------------- - - -class TestDisabledScheduleSkipsValidation: - """Disabled schedules must not trigger time-window validation.""" - - @pytest.mark.asyncio - async def test_schedule_1_disabled_skips_time_validation(self): - """Disabled schedule 1 should return no errors even with ambiguous times.""" - user_input = { - "hsem_batteries_enable_batteries_schedule_1": False, - "hsem_batteries_enable_batteries_schedule_1_start": "00:00:00", - "hsem_batteries_enable_batteries_schedule_1_end": "00:00:00", - "hsem_batteries_enable_batteries_schedule_1_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(1, user_input) - assert errors == {} - - @pytest.mark.asyncio - async def test_schedule_2_disabled_skips_time_validation(self): - """Disabled schedule 2 should return no errors even with ambiguous times.""" - user_input = { - "hsem_batteries_enable_batteries_schedule_2": False, - "hsem_batteries_enable_batteries_schedule_2_start": "00:00:00", - "hsem_batteries_enable_batteries_schedule_2_end": "00:00:00", - "hsem_batteries_enable_batteries_schedule_2_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(2, user_input) - assert errors == {} - - @pytest.mark.asyncio - async def test_schedule_3_disabled_skips_time_validation(self): - """Disabled schedule 3 should return no errors even with ambiguous times.""" - user_input = { - "hsem_batteries_enable_batteries_schedule_3": False, - "hsem_batteries_enable_batteries_schedule_3_start": "00:00:00", - "hsem_batteries_enable_batteries_schedule_3_end": "00:00:00", - "hsem_batteries_enable_batteries_schedule_3_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(3, user_input) - assert errors == {} - - -# --------------------------------------------------------------------------- -# Valid window tests -# --------------------------------------------------------------------------- - - -class TestValidScheduleWindows: - """Valid non-zero and cross-midnight windows must be accepted.""" - - @pytest.mark.asyncio - async def test_schedule_1_valid_daytime_window(self): - """A normal daytime window is accepted.""" - user_input = { - "hsem_batteries_enable_batteries_schedule_1": True, - "hsem_batteries_enable_batteries_schedule_1_start": "07:00:00", - "hsem_batteries_enable_batteries_schedule_1_end": "09:00:00", - "hsem_batteries_enable_batteries_schedule_1_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(1, user_input) - assert errors == {} - - @pytest.mark.asyncio - async def test_schedule_2_valid_evening_window(self): - """A normal evening window is accepted.""" - user_input = { - "hsem_batteries_enable_batteries_schedule_2": True, - "hsem_batteries_enable_batteries_schedule_2_start": "17:00:00", - "hsem_batteries_enable_batteries_schedule_2_end": "21:00:00", - "hsem_batteries_enable_batteries_schedule_2_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(2, user_input) - assert errors == {} - - @pytest.mark.asyncio - async def test_schedule_3_valid_cross_midnight_window(self): - """A cross-midnight window (23:00->02:00) is accepted.""" - user_input = { - "hsem_batteries_enable_batteries_schedule_3": True, - "hsem_batteries_enable_batteries_schedule_3_start": "23:00:00", - "hsem_batteries_enable_batteries_schedule_3_end": "02:00:00", - "hsem_batteries_enable_batteries_schedule_3_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(3, user_input) - assert errors == {} - - @pytest.mark.asyncio - async def test_schedule_3_default_placeholder_times_are_valid_when_enabled(self): - """The new default placeholder times (23:00->02:00) pass validation when enabled.""" - start = DEFAULT_CONFIG_VALUES[ - "hsem_batteries_enable_batteries_schedule_3_start" - ] - end = DEFAULT_CONFIG_VALUES["hsem_batteries_enable_batteries_schedule_3_end"] - user_input = { - "hsem_batteries_enable_batteries_schedule_3": True, - "hsem_batteries_enable_batteries_schedule_3_start": start, - "hsem_batteries_enable_batteries_schedule_3_end": end, - "hsem_batteries_enable_batteries_schedule_3_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(3, user_input) - assert errors == {}, ( - f"Default times {start}->{end} should be valid when schedule_3 is enabled, " - f"but got errors: {errors}" - ) - - -# --------------------------------------------------------------------------- -# Invalid time format tests -# --------------------------------------------------------------------------- - - -class TestInvalidTimeFormat: - """Invalid time strings must produce the correct error key.""" - - @pytest.mark.asyncio - async def test_schedule_1_invalid_time_format(self): - """Bad time format produces 'invalid_time_format' error on the start field.""" - user_input = { - "hsem_batteries_enable_batteries_schedule_1": True, - "hsem_batteries_enable_batteries_schedule_1_start": "not-a-time", - "hsem_batteries_enable_batteries_schedule_1_end": "09:00:00", - "hsem_batteries_enable_batteries_schedule_1_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(1, user_input) - # Centralized validator reports the error on the specific field, not on 'base'. - assert ( - errors.get("hsem_batteries_enable_batteries_schedule_1_start") - == "invalid_time_format" - ) - - @pytest.mark.asyncio - async def test_schedule_3_invalid_time_format(self): - """Bad time format in schedule_3 produces 'invalid_time_format' error on start field.""" - user_input = { - "hsem_batteries_enable_batteries_schedule_3": True, - "hsem_batteries_enable_batteries_schedule_3_start": "25:00:00", - "hsem_batteries_enable_batteries_schedule_3_end": "02:00:00", - "hsem_batteries_enable_batteries_schedule_3_min_price_difference": 0.0, - } - errors = await validate_batteries_schedule_input(3, user_input) - # Centralized validator reports the error on the specific field, not on 'base'. - assert ( - errors.get("hsem_batteries_enable_batteries_schedule_3_start") - == "invalid_time_format" - ) From 00cd61f4b98987f9df24fa278c4e3f09563e9593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BCger?= Date: Wed, 2 Sep 2026 12:38:44 +0000 Subject: [PATCH 2/2] docs(memories): note issue #897 deletes the commented-out heuristic candidates The MILP-only mode note added for issue #860 described the schedule- consuming heuristic candidates as merely commented out. Issue #897 deletes that dead code entirely, so update the memory to point at the final state instead of an intermediate one. --- .github/memories.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/memories.md b/.github/memories.md index 542899b1..ec9887ea 100644 --- a/.github/memories.md +++ b/.github/memories.md @@ -1231,9 +1231,11 @@ unfinished port. Only the dead seven-bucket charge-rate learner from #7 was take entities, `BatterySchedule`/`BatteryScheduleInput`, and the `apply_discharge_schedules`/`apply_charge_schedules`/`apply_arbitrage_grid_charge` passes in `engine_core.py`) functionally inert whenever MILP is active — the -schedule-consuming heuristic candidates are commented out in MILP-only mode, -and both surviving candidates (`no_action`, `passive`) discard schedule-derived -recommendations before scoring. The user explicitly chose full removal over +schedule-consuming heuristic candidates were commented out in MILP-only mode +(later deleted entirely in issue #897, since MILP is the sole active +optimisation authority), and both surviving candidates (`no_action`, +`passive`) discard schedule-derived recommendations before scoring. The +user explicitly chose full removal over re-wiring it as a MILP-unavailable fallback or leaving it documented as inert, consciously overriding the earlier "keep it" precedent. Do not resurrect battery-schedule config/entities/code from this history as if it were still