diff --git a/.github/memories.md b/.github/memories.md index 3cc76b7a..4f0e72e9 100644 --- a/.github/memories.md +++ b/.github/memories.md @@ -1517,8 +1517,23 @@ 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 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 +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 96ed2146..f8ae94fe 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 bd8cb755..1973b5b1 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._current_wait_mode_reserve: float | None = None self._next_update: str | None = None diff --git a/custom_components/hsem/coordinator_builder.py b/custom_components/hsem/coordinator_builder.py index 53d7fc33..e7ad001b 100644 --- a/custom_components/hsem/coordinator_builder.py +++ b/custom_components/hsem/coordinator_builder.py @@ -19,7 +19,6 @@ import math from datetime import datetime, 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, @@ -148,7 +146,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 @@ -224,15 +221,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) @@ -315,7 +303,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 afd43cb9..32b0eec2 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: @@ -298,7 +293,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": @@ -314,7 +309,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, @@ -602,10 +597,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, current_wait_mode_reserve=self._current_wait_mode_reserve, state=state, diff --git a/custom_components/hsem/coordinator_data.py b/custom_components/hsem/coordinator_data.py index 288ffa71..2d129e68 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), derived from ``calculate_required_battery_until_solar``. Used for excess-export scheduling and the EV discharge-cap SoC guard. @@ -55,8 +52,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 current_wait_mode_reserve: float | None = None state: str | None = None diff --git a/custom_components/hsem/coordinator_lifecycle.py b/custom_components/hsem/coordinator_lifecycle.py index cdbdb257..7fd2955e 100644 --- a/custom_components/hsem/coordinator_lifecycle.py +++ b/custom_components/hsem/coordinator_lifecycle.py @@ -32,7 +32,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 @@ -507,9 +506,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 63d24495..1e90c427 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 a23b2f10..6d5f89aa 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 9d79d89b..2a1faaf6 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 ( @@ -375,47 +371,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") @@ -646,33 +601,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, - ) - ) - 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 6f6ae439..1f39e111 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` @@ -27,7 +27,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 aeb9120a..a9305287 100644 --- a/custom_components/hsem/custom_sensors/working_mode_sensor.py +++ b/custom_components/hsem/custom_sensors/working_mode_sensor.py @@ -341,8 +341,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, @@ -563,7 +561,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 6c09e64f..00000000 --- a/custom_components/hsem/models/battery_schedule.py +++ /dev/null @@ -1,25 +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 and needed capacity) for one battery schedule - window. - """ - - enabled: bool - start: time - end: time - avg_import_price: float - needed_batteries_capacity: 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 6fbdb060..4225bac6 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: @@ -167,9 +164,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 c580f96c..07e32600 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`. @@ -131,10 +116,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 @@ -269,17 +250,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 @@ -393,14 +363,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 437a6bf9..7365c10e 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 @@ -438,9 +312,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 f64d13fc..a1064092 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_for_plan, @@ -79,7 +74,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, ) @@ -133,32 +127,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, @@ -168,17 +142,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 0d6bb8c4..f60f4006 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.", "invalid_wait_mode_behavior": "Ugyldig ventetilstand. Vælg Streng ventetilstand eller Selvforbrug med reserve.", "port_conflict": "Den anden OCPP-port skal være forskellig fra den første OCPP-port.", "ev_min_power_below_start_current": "Den konfigurerede minimumseffekt er for lav til den valgte fasetopologi — en rigtig lader kan ikke starte under 6 A. Øg minimumseffekten, eller skift fasetopologi." @@ -97,32 +94,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", @@ -393,48 +364,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", @@ -554,14 +483,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.", "invalid_wait_mode_behavior": "Ugyldig ventetilstand. Vælg Streng ventetilstand eller Selvforbrug med reserve.", "port_conflict": "Den anden OCPP-port skal være forskellig fra den første OCPP-port.", "ev_min_power_below_start_current": "Den konfigurerede minimumseffekt er for lav til den valgte fasetopologi — en rigtig lader kan ikke starte under 6 A. Øg minimumseffekten, eller skift fasetopologi." @@ -583,32 +509,6 @@ "description": "Konfigurer indstillinger for eksport af overskydende batterikapacitet til nettet, når det er økonomisk fordelagtigt.", "title": "Overskuds-eksport" }, - "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" - }, "ev_planned_load": { "data": { "hsem_ev_planned_load_enabled": "Aktiver EV-planlagt belastningsintegration", @@ -881,48 +781,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" - }, "battery_economics": { "data": { "hsem_batteries_purchase_price": "Batteri Købspris", @@ -1087,15 +945,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" }, @@ -1154,24 +1003,6 @@ "charge_rate_above_50": {} }, "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/de.json b/custom_components/hsem/translations/de.json index 0960c97b..720a07fd 100644 --- a/custom_components/hsem/translations/de.json +++ b/custom_components/hsem/translations/de.json @@ -16,14 +16,11 @@ "invalid_power_value": "Ungültiger Leistungswert — bitte eine Zahl eingeben.", "invalid_price_value": "Ungültiger Preiswert — bitte eine Zahl eingeben.", "invalid_sensor": "Ungültiger Eingabesensor. Bitte wähle einen gültigen Sensor.", - "invalid_time_format": "Ungültiges Zeitformat — erwartet wird HH:MM:SS.", "months_winter_empty": "Die Wintersaison muss mindestens einen Monat enthalten.", "only_one_entry_allowed": "Nur eine Konfiguration von HSEM ist zulässig.", "power_out_of_range": "Der Leistungswert liegt außerhalb des zulässigen Bereichs.", "price_out_of_range": "Der Preiswert liegt außerhalb des zulässigen Bereichs.", "required": "Dieses Feld ist erforderlich.", - "start_time_after_end_time": "Die Startzeit muss vor der Endzeit liegen.", - "start_time_equals_end_time": "Start- und Endzeit dürfen nicht identisch sein — das ergibt ein Zeitfenster der Länge null. Deaktiviere stattdessen den Zeitplan.", "invalid_wait_mode_behavior": "Ungültiges Wartemodus-Verhalten. Wähle Strenges Warten oder Eigenverbrauch mit Reserve.", "port_conflict": "Der zweite OCPP-Port muss sich vom ersten OCPP-Port unterscheiden.", "ev_min_power_below_start_current": "Die konfigurierte Mindestleistung ist für die gewählte Phasentopologie zu niedrig — ein echtes Ladegerät kann nicht unter 6 A starten. Erhöhe die Mindestleistung oder ändere die Phasentopologie." @@ -121,48 +118,6 @@ "description": "Konfiguriere den eingebetteten OCPP-1.6-Server für die reine LAN-Steuerung von EV-Ladegeräten. Wenn aktiviert, sendet HSEM SetChargingProfile-Befehle direkt an dein EV-Ladegerät auf Basis des Ladeplans.", "title": "OCPP-Server" }, - "batteries_schedule_1": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Batterie-Zeitplan 1 aktivieren", - "hsem_batteries_enable_batteries_schedule_1_end": "Batterie-Zeitplan 1 Endzeit", - "hsem_batteries_enable_batteries_schedule_1_start": "Batterie-Zeitplan 1 Startzeit" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Aktiviere oder deaktiviere den ersten Batterie-Entladezeitplan.", - "hsem_batteries_enable_batteries_schedule_1_end": "Lege die Endzeit für den ersten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_1_start": "Lege die Startzeit für den ersten Batterie-Entladezeitplan fest." - }, - "description": "Konfiguriere Batterie-Entladezeitplan 1, einschließlich seiner Aktivierungszeit.", - "title": "Batterie-Entladezeitplan 1" - }, - "batteries_schedule_2": { - "data": { - "hsem_batteries_enable_batteries_schedule_2": "Batterie-Zeitplan 2 aktivieren", - "hsem_batteries_enable_batteries_schedule_2_end": "Batterie-Zeitplan 2 Endzeit", - "hsem_batteries_enable_batteries_schedule_2_start": "Batterie-Zeitplan 2 Startzeit" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_2": "Aktiviere oder deaktiviere den zweiten Batterie-Entladezeitplan.", - "hsem_batteries_enable_batteries_schedule_2_end": "Lege die Endzeit für den zweiten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_2_start": "Lege die Startzeit für den zweiten Batterie-Entladezeitplan fest." - }, - "description": "Konfiguriere Batterie-Entladezeitplan 2, einschließlich seiner Aktivierungszeit.", - "title": "Batterie-Entladezeitplan 2" - }, - "batteries_schedule_3": { - "data": { - "hsem_batteries_enable_batteries_schedule_3": "Batterie-Zeitplan 3 aktivieren", - "hsem_batteries_enable_batteries_schedule_3_end": "Batterie-Zeitplan 3 Endzeit", - "hsem_batteries_enable_batteries_schedule_3_start": "Batterie-Zeitplan 3 Startzeit" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_3": "Aktiviere oder deaktiviere den dritten Batterie-Entladezeitplan.", - "hsem_batteries_enable_batteries_schedule_3_end": "Lege die Endzeit für den dritten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_3_start": "Lege die Startzeit für den dritten Batterie-Entladezeitplan fest." - }, - "description": "Konfiguriere Batterie-Entladezeitplan 3, einschließlich seiner Aktivierungszeit.", - "title": "Batterie-Entladezeitplan 3" - }, "prices": { "data": { "hsem_import_electricity_price_sensor": "Stromimportpreis-Sensor", @@ -345,32 +300,6 @@ "description": "Aktualisiere die Einstellungen für HSEM", "title": "Huawei Solar Energy Management aktualisieren" }, - "batteries_schedules": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Batterie-Zeitplan 1 aktivieren", - "hsem_batteries_enable_batteries_schedule_1_end": "Batterie-Zeitplan 1 Endzeit", - "hsem_batteries_enable_batteries_schedule_1_start": "Batterie-Zeitplan 1 Startzeit", - "hsem_batteries_enable_batteries_schedule_2": "Batterie-Zeitplan 2 aktivieren", - "hsem_batteries_enable_batteries_schedule_2_end": "Batterie-Zeitplan 2 Endzeit", - "hsem_batteries_enable_batteries_schedule_2_start": "Batterie-Zeitplan 2 Startzeit", - "hsem_batteries_enable_batteries_schedule_3": "Batterie-Zeitplan 3 aktivieren", - "hsem_batteries_enable_batteries_schedule_3_end": "Batterie-Zeitplan 3 Endzeit", - "hsem_batteries_enable_batteries_schedule_3_start": "Batterie-Zeitplan 3 Startzeit" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Aktiviere oder deaktiviere den ersten Batterie-Entladezeitplan.", - "hsem_batteries_enable_batteries_schedule_1_end": "Lege die Endzeit für den ersten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_1_start": "Lege die Startzeit für den ersten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_2": "Aktiviere oder deaktiviere den zweiten Batterie-Entladezeitplan.", - "hsem_batteries_enable_batteries_schedule_2_end": "Lege die Endzeit für den zweiten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_2_start": "Lege die Startzeit für den zweiten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_3": "Aktiviere oder deaktiviere den dritten Batterie-Entladezeitplan.", - "hsem_batteries_enable_batteries_schedule_3_end": "Lege die Endzeit für den dritten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_3_start": "Lege die Startzeit für den dritten Batterie-Entladezeitplan fest." - }, - "description": "Konfiguriere bis zu drei Batterie-Entladezeitplan-Fenster, jeweils mit Ein-/Aus-Schalter, Startzeit und Endzeit. Zeitpläne legen fest, wann die Batterie entladen darf, um Verbrauchsspitzen zu decken oder ins Netz einzuspeisen.", - "title": "Batterie-Entladezeitpläne" - }, "months": { "data": { "hsem_months_winter": "Als Winter/Frühling betrachtete Monate" @@ -554,14 +483,11 @@ "invalid_power_value": "Ungültiger Leistungswert — bitte eine Zahl eingeben.", "invalid_price_value": "Ungültiger Preiswert — bitte eine Zahl eingeben.", "invalid_sensor": "Ungültiger Eingabesensor. Bitte wähle einen gültigen Sensor.", - "invalid_time_format": "Ungültiges Zeitformat — erwartet wird HH:MM:SS.", "months_winter_empty": "Die Wintersaison muss mindestens einen Monat enthalten.", "only_one_entry_allowed": "Nur eine Konfiguration von HSEM ist zulässig.", "power_out_of_range": "Der Leistungswert liegt außerhalb des zulässigen Bereichs.", "price_out_of_range": "Der Preiswert liegt außerhalb des zulässigen Bereichs.", "required": "Dieses Feld ist erforderlich.", - "start_time_after_end_time": "Die Startzeit muss vor der Endzeit liegen.", - "start_time_equals_end_time": "Start- und Endzeit dürfen nicht identisch sein — das ergibt ein Zeitfenster der Länge null. Deaktiviere stattdessen den Zeitplan.", "invalid_wait_mode_behavior": "Ungültiges Wartemodus-Verhalten. Wähle Strenges Warten oder Eigenverbrauch mit Reserve.", "port_conflict": "Der zweite OCPP-Port muss sich vom ersten OCPP-Port unterscheiden.", "ev_min_power_below_start_current": "Die konfigurierte Mindestleistung ist für die gewählte Phasentopologie zu niedrig — ein echtes Ladegerät kann nicht unter 6 A starten. Erhöhe die Mindestleistung oder ändere die Phasentopologie." @@ -659,48 +585,6 @@ "description": "Konfiguriere den eingebetteten OCPP-1.6-Server für die reine LAN-Steuerung von EV-Ladegeräten. Wenn aktiviert, sendet HSEM SetChargingProfile-Befehle direkt an dein EV-Ladegerät auf Basis des Ladeplans.", "title": "OCPP-Server" }, - "batteries_schedule_1": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Batterie-Zeitplan 1 aktivieren", - "hsem_batteries_enable_batteries_schedule_1_end": "Batterie-Zeitplan 1 Endzeit", - "hsem_batteries_enable_batteries_schedule_1_start": "Batterie-Zeitplan 1 Startzeit" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Aktiviere oder deaktiviere den ersten Batterie-Entladezeitplan.", - "hsem_batteries_enable_batteries_schedule_1_end": "Lege die Endzeit für den ersten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_1_start": "Lege die Startzeit für den ersten Batterie-Entladezeitplan fest." - }, - "description": "Konfiguriere Batterie-Entladezeitplan 1, einschließlich seiner Aktivierungszeit.", - "title": "Batterie-Entladezeitplan 1" - }, - "batteries_schedule_2": { - "data": { - "hsem_batteries_enable_batteries_schedule_2": "Batterie-Zeitplan 2 aktivieren", - "hsem_batteries_enable_batteries_schedule_2_end": "Batterie-Zeitplan 2 Endzeit", - "hsem_batteries_enable_batteries_schedule_2_start": "Batterie-Zeitplan 2 Startzeit" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_2": "Aktiviere oder deaktiviere den zweiten Batterie-Entladezeitplan.", - "hsem_batteries_enable_batteries_schedule_2_end": "Lege die Endzeit für den zweiten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_2_start": "Lege die Startzeit für den zweiten Batterie-Entladezeitplan fest." - }, - "description": "Konfiguriere Batterie-Entladezeitplan 2, einschließlich seiner Aktivierungszeit.", - "title": "Batterie-Entladezeitplan 2" - }, - "batteries_schedule_3": { - "data": { - "hsem_batteries_enable_batteries_schedule_3": "Batterie-Zeitplan 3 aktivieren", - "hsem_batteries_enable_batteries_schedule_3_end": "Batterie-Zeitplan 3 Endzeit", - "hsem_batteries_enable_batteries_schedule_3_start": "Batterie-Zeitplan 3 Startzeit" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_3": "Aktiviere oder deaktiviere den dritten Batterie-Entladezeitplan.", - "hsem_batteries_enable_batteries_schedule_3_end": "Lege die Endzeit für den dritten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_3_start": "Lege die Startzeit für den dritten Batterie-Entladezeitplan fest." - }, - "description": "Konfiguriere Batterie-Entladezeitplan 3, einschließlich seiner Aktivierungszeit.", - "title": "Batterie-Entladezeitplan 3" - }, "prices": { "data": { "hsem_import_electricity_price_sensor": "Stromimportpreis-Sensor", @@ -883,32 +767,6 @@ "description": "Aktualisiere die Einstellungen für HSEM", "title": "Huawei Solar Energy Management aktualisieren" }, - "batteries_schedules": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Batterie-Zeitplan 1 aktivieren", - "hsem_batteries_enable_batteries_schedule_1_end": "Batterie-Zeitplan 1 Endzeit", - "hsem_batteries_enable_batteries_schedule_1_start": "Batterie-Zeitplan 1 Startzeit", - "hsem_batteries_enable_batteries_schedule_2": "Batterie-Zeitplan 2 aktivieren", - "hsem_batteries_enable_batteries_schedule_2_end": "Batterie-Zeitplan 2 Endzeit", - "hsem_batteries_enable_batteries_schedule_2_start": "Batterie-Zeitplan 2 Startzeit", - "hsem_batteries_enable_batteries_schedule_3": "Batterie-Zeitplan 3 aktivieren", - "hsem_batteries_enable_batteries_schedule_3_end": "Batterie-Zeitplan 3 Endzeit", - "hsem_batteries_enable_batteries_schedule_3_start": "Batterie-Zeitplan 3 Startzeit" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Aktiviere oder deaktiviere den ersten Batterie-Entladezeitplan.", - "hsem_batteries_enable_batteries_schedule_1_end": "Lege die Endzeit für den ersten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_1_start": "Lege die Startzeit für den ersten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_2": "Aktiviere oder deaktiviere den zweiten Batterie-Entladezeitplan.", - "hsem_batteries_enable_batteries_schedule_2_end": "Lege die Endzeit für den zweiten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_2_start": "Lege die Startzeit für den zweiten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_3": "Aktiviere oder deaktiviere den dritten Batterie-Entladezeitplan.", - "hsem_batteries_enable_batteries_schedule_3_end": "Lege die Endzeit für den dritten Batterie-Entladezeitplan fest.", - "hsem_batteries_enable_batteries_schedule_3_start": "Lege die Startzeit für den dritten Batterie-Entladezeitplan fest." - }, - "description": "Konfiguriere bis zu drei Batterie-Entladezeitplan-Fenster, jeweils mit Ein-/Aus-Schalter, Startzeit und Endzeit. Zeitpläne legen fest, wann die Batterie entladen darf, um Verbrauchsspitzen zu decken oder ins Netz einzuspeisen.", - "title": "Batterie-Entladezeitpläne" - }, "months": { "data": { "hsem_months_winter": "Als Winter/Frühling betrachtete Monate" @@ -1267,15 +1125,6 @@ "verbose_logging": { "name": "Ausführliche Protokollierung" }, - "batteries_schedule_1": { - "name": "Batterie-Zeitplan 1" - }, - "batteries_schedule_2": { - "name": "Batterie-Zeitplan 2" - }, - "batteries_schedule_3": { - "name": "Batterie-Zeitplan 3" - }, "ev_force_discharge": { "name": "EV maximale Entladeleistung erzwingen" }, @@ -1327,24 +1176,6 @@ } }, "time": { - "schedule_1_start": { - "name": "Batterie-Entladezeitplan 1 Start" - }, - "schedule_1_end": { - "name": "Batterie-Entladezeitplan 1 Ende" - }, - "schedule_2_start": { - "name": "Batterie-Entladezeitplan 2 Start" - }, - "schedule_2_end": { - "name": "Batterie-Entladezeitplan 2 Ende" - }, - "schedule_3_start": { - "name": "Batterie-Entladezeitplan 3 Start" - }, - "schedule_3_end": { - "name": "Batterie-Entladezeitplan 3 Ende" - }, "ev_deadline": { "name": "EV Ladefrist" }, diff --git a/custom_components/hsem/translations/en.json b/custom_components/hsem/translations/en.json index 05781de6..24138c34 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.", "ev_min_power_below_start_current": "The configured minimum power is too low for the selected phase topology — a real charger cannot start below 6 A. Increase the minimum power or switch phase topology." @@ -121,48 +118,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", @@ -345,32 +300,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" @@ -554,14 +483,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.", "ev_min_power_below_start_current": "The configured minimum power is too low for the selected phase topology — a real charger cannot start below 6 A. Increase the minimum power or switch phase topology." @@ -659,48 +585,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", @@ -883,32 +767,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" @@ -1267,15 +1125,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" }, @@ -1327,24 +1176,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/translations/es.json b/custom_components/hsem/translations/es.json index 1f5f6557..96a2022c 100644 --- a/custom_components/hsem/translations/es.json +++ b/custom_components/hsem/translations/es.json @@ -16,14 +16,11 @@ "invalid_power_value": "Valor de potencia no válido: introduce un número.", "invalid_price_value": "Valor de precio no válido: introduce un número.", "invalid_sensor": "Sensor de entrada no válido. Elige un sensor válido.", - "invalid_time_format": "Formato de hora no válido: se espera HH:MM:SS.", "months_winter_empty": "La temporada de invierno debe tener al menos un mes.", "only_one_entry_allowed": "Solo se permite una configuración de HSEM.", "power_out_of_range": "El valor de potencia está fuera del rango permitido.", "price_out_of_range": "El valor de precio está fuera del rango permitido.", "required": "Este campo es obligatorio.", - "start_time_after_end_time": "La hora de inicio debe ser anterior a la hora de fin.", - "start_time_equals_end_time": "La hora de inicio y la hora de fin no pueden ser iguales, ya que esto crea una ventana de duración cero. Desactiva la programación en su lugar.", "invalid_wait_mode_behavior": "Comportamiento de modo de espera no válido. Elige Espera estricta o Autoconsumo con reserva.", "port_conflict": "El segundo puerto OCPP debe ser diferente del primer puerto OCPP.", "ev_min_power_below_start_current": "La potencia mínima configurada es demasiado baja para la topología de fases seleccionada — un cargador real no puede arrancar por debajo de 6 A. Aumenta la potencia mínima o cambia la topología de fases." @@ -121,48 +118,6 @@ "description": "Configura el servidor OCPP 1.6 integrado para el control del cargador de VE únicamente en red local. Cuando está activado, HSEM envía órdenes SetChargingProfile directamente a tu cargador de VE según el plan de carga.", "title": "Servidor OCPP" }, - "batteries_schedule_1": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Activar programación de batería 1", - "hsem_batteries_enable_batteries_schedule_1_end": "Hora de fin de la programación de batería 1", - "hsem_batteries_enable_batteries_schedule_1_start": "Hora de inicio de la programación de batería 1" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Activa o desactiva la primera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_1_end": "Especifica la hora de fin de la primera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_1_start": "Especifica la hora de inicio de la primera programación de descarga de batería." - }, - "description": "Configura la programación de descarga de batería 1, incluida su hora de activación.", - "title": "Programación de descarga de batería 1" - }, - "batteries_schedule_2": { - "data": { - "hsem_batteries_enable_batteries_schedule_2": "Activar programación de batería 2", - "hsem_batteries_enable_batteries_schedule_2_end": "Hora de fin de la programación de batería 2", - "hsem_batteries_enable_batteries_schedule_2_start": "Hora de inicio de la programación de batería 2" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_2": "Activa o desactiva la segunda programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_2_end": "Especifica la hora de fin de la segunda programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_2_start": "Especifica la hora de inicio de la segunda programación de descarga de batería." - }, - "description": "Configura la programación de descarga de batería 2, incluida su hora de activación.", - "title": "Programación de descarga de batería 2" - }, - "batteries_schedule_3": { - "data": { - "hsem_batteries_enable_batteries_schedule_3": "Activar programación de batería 3", - "hsem_batteries_enable_batteries_schedule_3_end": "Hora de fin de la programación de batería 3", - "hsem_batteries_enable_batteries_schedule_3_start": "Hora de inicio de la programación de batería 3" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_3": "Activa o desactiva la tercera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_3_end": "Especifica la hora de fin de la tercera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_3_start": "Especifica la hora de inicio de la tercera programación de descarga de batería." - }, - "description": "Configura la programación de descarga de batería 3, incluida su hora de activación.", - "title": "Programación de descarga de batería 3" - }, "prices": { "data": { "hsem_import_electricity_price_sensor": "Sensor de precio de electricidad de importación", @@ -345,32 +300,6 @@ "description": "Actualiza los ajustes de HSEM", "title": "Actualizar Huawei Solar Energy Management" }, - "batteries_schedules": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Activar programación de batería 1", - "hsem_batteries_enable_batteries_schedule_1_end": "Hora de fin de la programación de batería 1", - "hsem_batteries_enable_batteries_schedule_1_start": "Hora de inicio de la programación de batería 1", - "hsem_batteries_enable_batteries_schedule_2": "Activar programación de batería 2", - "hsem_batteries_enable_batteries_schedule_2_end": "Hora de fin de la programación de batería 2", - "hsem_batteries_enable_batteries_schedule_2_start": "Hora de inicio de la programación de batería 2", - "hsem_batteries_enable_batteries_schedule_3": "Activar programación de batería 3", - "hsem_batteries_enable_batteries_schedule_3_end": "Hora de fin de la programación de batería 3", - "hsem_batteries_enable_batteries_schedule_3_start": "Hora de inicio de la programación de batería 3" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Activa o desactiva la primera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_1_end": "Especifica la hora de fin de la primera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_1_start": "Especifica la hora de inicio de la primera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_2": "Activa o desactiva la segunda programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_2_end": "Especifica la hora de fin de la segunda programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_2_start": "Especifica la hora de inicio de la segunda programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_3": "Activa o desactiva la tercera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_3_end": "Especifica la hora de fin de la tercera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_3_start": "Especifica la hora de inicio de la tercera programación de descarga de batería." - }, - "description": "Configura hasta tres ventanas de programación de descarga de batería, cada una con un interruptor de activación/desactivación, hora de inicio y hora de fin. Las programaciones definen cuándo puede descargarse la batería para cubrir picos de consumo o exportar a la red.", - "title": "Programaciones de descarga de batería" - }, "months": { "data": { "hsem_months_winter": "Meses considerados como invierno/primavera" @@ -554,14 +483,11 @@ "invalid_power_value": "Valor de potencia no válido: introduce un número.", "invalid_price_value": "Valor de precio no válido: introduce un número.", "invalid_sensor": "Sensor de entrada no válido. Elige un sensor válido.", - "invalid_time_format": "Formato de hora no válido: se espera HH:MM:SS.", "months_winter_empty": "La temporada de invierno debe tener al menos un mes.", "only_one_entry_allowed": "Solo se permite una configuración de HSEM.", "power_out_of_range": "El valor de potencia está fuera del rango permitido.", "price_out_of_range": "El valor de precio está fuera del rango permitido.", "required": "Este campo es obligatorio.", - "start_time_after_end_time": "La hora de inicio debe ser anterior a la hora de fin.", - "start_time_equals_end_time": "La hora de inicio y la hora de fin no pueden ser iguales, ya que esto crea una ventana de duración cero. Desactiva la programación en su lugar.", "invalid_wait_mode_behavior": "Comportamiento de modo de espera no válido. Elige Espera estricta o Autoconsumo con reserva.", "port_conflict": "El segundo puerto OCPP debe ser diferente del primer puerto OCPP.", "ev_min_power_below_start_current": "La potencia mínima configurada es demasiado baja para la topología de fases seleccionada — un cargador real no puede arrancar por debajo de 6 A. Aumenta la potencia mínima o cambia la topología de fases." @@ -659,48 +585,6 @@ "description": "Configura el servidor OCPP 1.6 integrado para el control del cargador de VE únicamente en red local. Cuando está activado, HSEM envía órdenes SetChargingProfile directamente a tu cargador de VE según el plan de carga.", "title": "Servidor OCPP" }, - "batteries_schedule_1": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Activar programación de batería 1", - "hsem_batteries_enable_batteries_schedule_1_end": "Hora de fin de la programación de batería 1", - "hsem_batteries_enable_batteries_schedule_1_start": "Hora de inicio de la programación de batería 1" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Activa o desactiva la primera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_1_end": "Especifica la hora de fin de la primera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_1_start": "Especifica la hora de inicio de la primera programación de descarga de batería." - }, - "description": "Configura la programación de descarga de batería 1, incluida su hora de activación.", - "title": "Programación de descarga de batería 1" - }, - "batteries_schedule_2": { - "data": { - "hsem_batteries_enable_batteries_schedule_2": "Activar programación de batería 2", - "hsem_batteries_enable_batteries_schedule_2_end": "Hora de fin de la programación de batería 2", - "hsem_batteries_enable_batteries_schedule_2_start": "Hora de inicio de la programación de batería 2" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_2": "Activa o desactiva la segunda programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_2_end": "Especifica la hora de fin de la segunda programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_2_start": "Especifica la hora de inicio de la segunda programación de descarga de batería." - }, - "description": "Configura la programación de descarga de batería 2, incluida su hora de activación.", - "title": "Programación de descarga de batería 2" - }, - "batteries_schedule_3": { - "data": { - "hsem_batteries_enable_batteries_schedule_3": "Activar programación de batería 3", - "hsem_batteries_enable_batteries_schedule_3_end": "Hora de fin de la programación de batería 3", - "hsem_batteries_enable_batteries_schedule_3_start": "Hora de inicio de la programación de batería 3" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_3": "Activa o desactiva la tercera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_3_end": "Especifica la hora de fin de la tercera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_3_start": "Especifica la hora de inicio de la tercera programación de descarga de batería." - }, - "description": "Configura la programación de descarga de batería 3, incluida su hora de activación.", - "title": "Programación de descarga de batería 3" - }, "prices": { "data": { "hsem_import_electricity_price_sensor": "Sensor de precio de electricidad de importación", @@ -883,32 +767,6 @@ "description": "Actualiza los ajustes de HSEM", "title": "Actualizar Huawei Solar Energy Management" }, - "batteries_schedules": { - "data": { - "hsem_batteries_enable_batteries_schedule_1": "Activar programación de batería 1", - "hsem_batteries_enable_batteries_schedule_1_end": "Hora de fin de la programación de batería 1", - "hsem_batteries_enable_batteries_schedule_1_start": "Hora de inicio de la programación de batería 1", - "hsem_batteries_enable_batteries_schedule_2": "Activar programación de batería 2", - "hsem_batteries_enable_batteries_schedule_2_end": "Hora de fin de la programación de batería 2", - "hsem_batteries_enable_batteries_schedule_2_start": "Hora de inicio de la programación de batería 2", - "hsem_batteries_enable_batteries_schedule_3": "Activar programación de batería 3", - "hsem_batteries_enable_batteries_schedule_3_end": "Hora de fin de la programación de batería 3", - "hsem_batteries_enable_batteries_schedule_3_start": "Hora de inicio de la programación de batería 3" - }, - "data_description": { - "hsem_batteries_enable_batteries_schedule_1": "Activa o desactiva la primera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_1_end": "Especifica la hora de fin de la primera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_1_start": "Especifica la hora de inicio de la primera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_2": "Activa o desactiva la segunda programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_2_end": "Especifica la hora de fin de la segunda programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_2_start": "Especifica la hora de inicio de la segunda programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_3": "Activa o desactiva la tercera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_3_end": "Especifica la hora de fin de la tercera programación de descarga de batería.", - "hsem_batteries_enable_batteries_schedule_3_start": "Especifica la hora de inicio de la tercera programación de descarga de batería." - }, - "description": "Configura hasta tres ventanas de programación de descarga de batería, cada una con un interruptor de activación/desactivación, hora de inicio y hora de fin. Las programaciones definen cuándo puede descargarse la batería para cubrir picos de consumo o exportar a la red.", - "title": "Programaciones de descarga de batería" - }, "months": { "data": { "hsem_months_winter": "Meses considerados como invierno/primavera" @@ -1267,15 +1125,6 @@ "verbose_logging": { "name": "Registro detallado" }, - "batteries_schedule_1": { - "name": "Programación de baterías 1" - }, - "batteries_schedule_2": { - "name": "Programación de baterías 2" - }, - "batteries_schedule_3": { - "name": "Programación de baterías 3" - }, "ev_force_discharge": { "name": "Forzar potencia máxima de descarga del VE" }, @@ -1327,24 +1176,6 @@ } }, "time": { - "schedule_1_start": { - "name": "Inicio de la programación de descarga de baterías 1" - }, - "schedule_1_end": { - "name": "Fin de la programación de descarga de baterías 1" - }, - "schedule_2_start": { - "name": "Inicio de la programación de descarga de baterías 2" - }, - "schedule_2_end": { - "name": "Fin de la programación de descarga de baterías 2" - }, - "schedule_3_start": { - "name": "Inicio de la programación de descarga de baterías 3" - }, - "schedule_3_end": { - "name": "Fin de la programación de descarga de baterías 3" - }, "ev_deadline": { "name": "Fecha límite de carga del VE" }, diff --git a/custom_components/hsem/utils/config_validator.py b/custom_components/hsem/utils/config_validator.py index 58bad1cf..6af93970 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 @@ -40,9 +39,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) @@ -235,69 +231,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 b8b3c277..c88ab36f 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 dd1a3392..21da9cad 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 ``` @@ -208,18 +208,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/ev-charge-plan-setup.md b/docs/ev-charge-plan-setup.md index 1857c2ab..6f12c022 100644 --- a/docs/ev-charge-plan-setup.md +++ b/docs/ev-charge-plan-setup.md @@ -82,7 +82,7 @@ init → prices → months → solcast → ev (force-discharge charger) → [ev_second] → ev_planned_load ← you are here → [ev_second_planned_load] - → batteries_schedule_1/2/3 → batteries_excess_export + → ocpp → batteries_wait_mode → batteries_excess_export → weighted_values ``` diff --git a/docs/planner-guide.md b/docs/planner-guide.md index 62b1f5fb..48708a8e 100644 --- a/docs/planner-guide.md +++ b/docs/planner-guide.md @@ -268,21 +268,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 | @@ -411,7 +396,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) | @@ -436,21 +421,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 | | -------- | -------------------------------------------------- | ----------------------- | @@ -1263,53 +1234,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:** @@ -1358,7 +1283,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:** @@ -1373,7 +1298,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:** @@ -1423,7 +1347,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:** @@ -1481,7 +1405,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:** @@ -1491,7 +1415,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:** @@ -1545,7 +1468,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:** @@ -1640,7 +1563,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 70e0c128..a9dbdc36 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 @@ -691,23 +689,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 | --- @@ -731,19 +726,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 b78ae913..a4933bf1 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, @@ -326,7 +294,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. @@ -343,7 +310,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: @@ -369,8 +335,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, @@ -433,7 +397,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], time_discount_rate=1.0, @@ -491,7 +454,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], time_discount_rate=1.0, diff --git a/tests/planner/test_48h_second_day.py b/tests/planner/test_48h_second_day.py deleted file mode 100644 index c9ca85e4..00000000 --- a/tests/planner/test_48h_second_day.py +++ /dev/null @@ -1,324 +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, - ) - - -# =========================================================================== -# 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 99820464..00000000 --- a/tests/planner/test_arbitrage_grid_charge.py +++ /dev/null @@ -1,448 +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. - 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 baddfb70..308f726d 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], weight_1d=25, @@ -212,7 +205,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) @@ -238,7 +231,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], weight_1d=25, 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 56d2ce14..b3c148e2 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, @@ -596,37 +593,43 @@ 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], ) result = run_planner(inp) charge_slots = [ @@ -635,12 +638,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): @@ -1251,7 +1255,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], ) result = run_planner(inp) @@ -1320,10 +1323,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. @@ -1551,10 +1551,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 b9e61968..8dc5773e 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 90f6d1b0..03af512b 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, @@ -389,7 +380,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 3d0b321b..b09fef37 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 4266c836..7dbb1028 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 79cf5018..14f9fb4c 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, # Primary EV @@ -586,7 +584,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 = ( @@ -610,7 +608,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 @@ -623,26 +621,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( @@ -653,7 +636,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 @@ -670,19 +653,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( @@ -690,13 +671,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 930e226a..15018d6f 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, @@ -509,8 +506,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, @@ -547,7 +542,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 @@ -562,7 +556,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: @@ -578,7 +571,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 @@ -626,7 +618,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 = [ @@ -646,7 +637,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: @@ -686,7 +676,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 6022d74f..9f2fefd8 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 aff6a638..d1930eb7 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 675bca40..f2fb241f 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -106,7 +106,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_coordinator_tracking_forecast.py b/tests/test_coordinator_tracking_forecast.py index 05edcfdb..c2800367 100644 --- a/tests/test_coordinator_tracking_forecast.py +++ b/tests/test_coordinator_tracking_forecast.py @@ -86,7 +86,6 @@ def _accumulate( forecast_tracker=tracker, last_accumulation_ts=last_accumulation_ts, solar_corrector=SolarForecastCorrector(), - solar_corrector_processed=set(), prediction_tracker=PredictionTracker(), last_planner_output=None, update_interval_minutes=1, diff --git a/tests/test_coordinator_tracking_solar_corrector.py b/tests/test_coordinator_tracking_solar_corrector.py index e579fda9..06283a91 100644 --- a/tests/test_coordinator_tracking_solar_corrector.py +++ b/tests/test_coordinator_tracking_solar_corrector.py @@ -95,6 +95,7 @@ def test_restored_solar_corrector_does_not_relearn_finalised_slot() -> None: solar_corrector=solar_corrector, prediction_tracker=prediction_tracker, last_planner_output=None, + update_interval_minutes=5, ) assert solar_corrector.hour_factors.get(9) == pytest.approx(0.75) @@ -124,6 +125,7 @@ def test_restored_solar_corrector_does_not_relearn_finalised_slot() -> None: solar_corrector=restored_corrector, prediction_tracker=prediction_tracker, last_planner_output=None, + update_interval_minutes=5, ) # Still exactly one sample -- the restored corrector did not re-learn 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 42cdfec8..3f14e776 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], ) 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 f099505b..e9940d27 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._current_wait_mode_reserve = None coord._live = None @@ -2202,7 +2200,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 ) @@ -2409,7 +2406,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)) @@ -2488,7 +2484,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)) @@ -2554,7 +2549,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) @@ -2622,7 +2616,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)) @@ -2853,7 +2846,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..3fe849cd 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() @@ -172,12 +165,12 @@ def test_switch_description_is_frozen(self) -> None: def test_time_description_has_description_field(self) -> None: desc = HSEMTimeEntityDescription( - key="hsem_batteries_enable_batteries_schedule_1_start", - name="S1 Start", - description="Start time for schedule 1.", + key="hsem_ev_deadline_time", + name="EV Deadline", + description="Deadline time for EV charging.", default_value="07:00:00", ) - assert desc.description == "Start time for schedule 1." + assert desc.description == "Deadline time for EV charging." assert desc.default_value == "07:00:00" def test_time_description_default_value_defaults_to_zero(self) -> None: @@ -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 7cf8b81b..d91f151d 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, @@ -151,80 +147,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 # =========================================================================== @@ -272,7 +195,7 @@ def test_high_consumption_assigned_discharge(self): # =========================================================================== -# 4. apply_optimization_strategy — solar charging loop threshold +# 3. apply_optimization_strategy — solar charging loop threshold # =========================================================================== @@ -325,17 +248,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 2cf2b56f..f73ec943 100644 --- a/tests/test_safety_gates.py +++ b/tests/test_safety_gates.py @@ -1023,7 +1023,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" - )