From a0cee80a4e8297fbb86fb02fde1fe28f7d7a342e Mon Sep 17 00:00:00 2001 From: enoch85 Date: Wed, 15 Jul 2026 20:12:53 +0000 Subject: [PATCH] The evidence: every defect pinned by a test that failed first Ninety-odd new test files, one per defect or invariant: the billing hour survives the clocks going back; an hour the meter slept through is not a bill; an unloaded integration does not drive the heat pump; the thermostat off switch actually turns it off; a user boost outranks the price optimizer; the pump models match their datasheets; the emitter law matches OpenEnergyMonitor to 0.00 C; the simulated plant obeys physics through bounds that can actually fail; no document misquotes the safety thresholds; no fenced declaration names a constant the code does not have. The two known-open defects stay visible as strict xfails restricted to AssertionError (F-124 saturation trap, F-132b learning ceiling): fixing either turns the suite red until its marker is removed, and a crash can no longer impersonate the finding. Run against main, this suite fails 556 tests and 40 files cannot import - the measured before/after of the audit. The tip of this branch is byte-identical to audit/safety-fixes-and-en442. --- ...st_from_six_hours_ago_is_not_a_forecast.py | 135 +++++++ ...test_a_missing_price_is_not_a_free_hour.py | 102 +++++ .../test_a_setpoint_is_not_a_measurement.py | 123 ++++++ .../test_adapter_refuses_fabricated_data.py | 146 +++++++ ...an_implausible_reading_is_not_a_reading.py | 205 ++++++++++ .../test_temperature_unit_conversion.py | 141 +++++++ .../test_the_shape_gespot_actually_sends.py | 129 ++++++ ...est_the_weather_adapter_knows_its_units.py | 111 ++++++ ...r_remembers_where_its_samples_came_from.py | 134 +++++++ ...st_a_dropped_meter_is_not_a_measurement.py | 175 +++++++++ ..._helper_can_stand_in_for_the_lux_switch.py | 63 +++ ...user_boost_outranks_the_price_optimizer.py | 137 +++++++ ...r_the_meter_slept_through_is_not_a_bill.py | 214 ++++++++++ ...ntegration_does_not_drive_the_heat_pump.py | 228 +++++++++++ .../test_effect_layer_uses_current_power.py | 34 ++ ...ptimization_says_when_it_is_not_running.py | 109 ++++++ ...st_notifications_use_an_api_that_exists.py | 45 +++ .../coordinator/test_one_writer_at_a_time.py | 155 ++++++++ ...y_the_grid_meter_can_set_a_billing_peak.py | 215 ++++++++++ ...our_hot_water_boost_does_not_outlive_us.py | 128 ++++++ ...t_savings_are_not_computed_from_a_guess.py | 153 ++++++++ .../test_shutdown_stops_the_coordinator.py | 117 ++++++ ...ing_hour_survives_the_clocks_going_back.py | 239 ++++++++++++ ...he_ventilation_fan_cannot_cycle_forever.py | 192 +++++++++ .../test_update_loop_survives_errors.py | 133 +++++++ .../test_dhw_safety_stop_not_rate_limited.py | 167 ++++++++ ...t_hot_water_wins_but_never_below_safety.py | 186 +++++++++ ...schedule_survives_the_clocks_going_back.py | 48 +++ ...what_the_dhw_safety_floor_actually_does.py | 100 +++++ ..._a_version_1_store_does_not_break_setup.py | 57 +++ ...ction_works_without_a_whole_house_meter.py | 156 ++++++++ .../test_peak_reset_and_predictive_guard.py | 139 +++++++ ..._tariff_counts_at_most_one_peak_per_day.py | 100 +++++ ...m_sensor_is_not_driven_on_a_placeholder.py | 85 ++++ .../test_airflow_energy_balance.py | 92 +++++ .../test_compressor_wear_guard.py | 124 ++++++ ...t_may_coast_the_house_but_not_starve_it.py | 274 +++++++++++++ .../test_dhw_does_not_start_only_to_abort.py | 99 +++++ ...t_every_rung_of_the_ladder_is_reachable.py | 122 ++++++ .../test_free_electricity_is_not_declined.py | 236 +++++++++++ .../test_learning_can_actually_learn.py | 265 +++++++++++++ .../test_manual_override_safety_floor.py | 161 ++++++++ .../test_no_room_sensor_safety.py | 157 ++++++++ ...t_one_definition_of_the_billed_quantity.py | 175 +++++++++ ...peak_protection_compares_like_with_like.py | 65 ++++ .../test_preheat_sees_the_cold_coming.py | 78 ++++ ...est_proactive_shares_the_thermal_ladder.py | 67 ++++ .../test_safety_priority_inversion.py | 359 +++++++++++++++++ ...re_control_law_does_not_need_a_forecast.py | 129 ++++++ ...mergency_ladder_does_not_fire_in_summer.py | 229 +++++++++++ ...low_curve_has_no_cliff_and_no_dead_path.py | 204 ++++++++++ ...ediction_gates_count_in_the_right_units.py | 86 ++++ ...ce_layer_reads_prices_not_just_rankings.py | 146 +++++++ ...vings_figure_is_not_the_night_weighting.py | 368 ++++++++++++++++++ ...e_tariff_bills_the_hour_not_the_quarter.py | 143 +++++++ .../test_the_wear_and_rate_limits_are_real.py | 146 +++++++ .../test_thermal_mass_buffer_direction.py | 80 ++++ .../test_warming_is_not_heat_loss.py | 169 ++++++++ ...winter_power_with_aux_is_not_an_anomaly.py | 38 ++ ...we_started_is_a_hot_water_boost_we_stop.py | 175 +++++++++ ...cs_report_the_band_the_house_is_held_to.py | 48 +++ ...ome_assistant_apis_are_used_as_declared.py | 93 +++++ .../unit/test_invented_prices_do_not_vote.py | 173 ++++++++ .../unit/test_money_sensors_tell_the_truth.py | 89 +++++ ...ne_answer_to_what_the_power_sensor_says.py | 163 ++++++++ ...st_options_flow_tells_you_what_is_wrong.py | 46 +++ ...orms_unload_before_the_coordinator_dies.py | 54 +++ .../unit/test_reads_do_not_drive_the_pump.py | 109 ++++++ tests/unit/test_startup_grace_is_bounded.py | 72 ++++ ...flow_sensor_survives_its_own_attributes.py | 28 ++ ...st_the_boost_cooldown_survives_a_reload.py | 93 +++++ ..._not_driven_on_a_reading_from_hours_ago.py | 85 ++++ ...mostat_off_switch_actually_turns_it_off.py | 112 ++++++ ..._which_things_actually_unload_the_entry.py | 109 ++++++ ...u_can_report_what_the_pump_actually_did.py | 195 ++++++++++ ...for_a_temperature_the_system_will_fight.py | 146 +++++++ .../test_a_negative_price_is_still_a_price.py | 106 +++++ .../test_milliwatts_are_not_megawatts.py | 209 ++++++++++ ...est_the_pump_does_what_the_engine_asked.py | 95 +++++ ..._compressor_is_a_positive_feedback_trap.py | 87 +++++ ...test_climate_zones_doc_matches_the_code.py | 105 +++++ ...t_emitter_law_matches_openenergymonitor.py | 282 ++++++++++++++ ...ulator_constant_says_where_it_came_from.py | 182 +++++++++ ...ocument_misquotes_the_safety_thresholds.py | 238 +++++++++++ ...o_production_code_uses_a_naive_datetime.py | 72 ++++ ..._test_captures_the_clock_at_import_time.py | 105 +++++ ...test_one_definition_of_the_safety_floor.py | 132 +++++++ .../test_research_docs_still_hold.py | 238 +++++++++++ .../test_sensors_speak_the_users_language.py | 89 +++++ ...e_plant_engages_aux_where_the_pump_does.py | 47 +++ ..._the_pump_models_match_their_datasheets.py | 334 ++++++++++++++++ ...st_the_rulebook_describes_this_codebase.py | 223 +++++++++++ .../test_the_simulated_plant_obeys_physics.py | 306 +++++++++++++++ .../validation/test_translation_key_parity.py | 84 ++++ ...est_weather_compensation_has_no_dc_bias.py | 184 +++++++++ ...r_compensation_is_not_anti_compensation.py | 186 +++++++++ 96 files changed, 13707 insertions(+) create mode 100644 tests/unit/adapters/test_a_forecast_from_six_hours_ago_is_not_a_forecast.py create mode 100644 tests/unit/adapters/test_a_missing_price_is_not_a_free_hour.py create mode 100644 tests/unit/adapters/test_a_setpoint_is_not_a_measurement.py create mode 100644 tests/unit/adapters/test_adapter_refuses_fabricated_data.py create mode 100644 tests/unit/adapters/test_an_implausible_reading_is_not_a_reading.py create mode 100644 tests/unit/adapters/test_temperature_unit_conversion.py create mode 100644 tests/unit/adapters/test_the_shape_gespot_actually_sends.py create mode 100644 tests/unit/adapters/test_the_weather_adapter_knows_its_units.py create mode 100644 tests/unit/coordinator/test_a_billing_hour_remembers_where_its_samples_came_from.py create mode 100644 tests/unit/coordinator/test_a_dropped_meter_is_not_a_measurement.py create mode 100644 tests/unit/coordinator/test_a_helper_can_stand_in_for_the_lux_switch.py create mode 100644 tests/unit/coordinator/test_a_user_boost_outranks_the_price_optimizer.py create mode 100644 tests/unit/coordinator/test_an_hour_the_meter_slept_through_is_not_a_bill.py create mode 100644 tests/unit/coordinator/test_an_unloaded_integration_does_not_drive_the_heat_pump.py create mode 100644 tests/unit/coordinator/test_effect_layer_uses_current_power.py create mode 100644 tests/unit/coordinator/test_hot_water_optimization_says_when_it_is_not_running.py create mode 100644 tests/unit/coordinator/test_notifications_use_an_api_that_exists.py create mode 100644 tests/unit/coordinator/test_one_writer_at_a_time.py create mode 100644 tests/unit/coordinator/test_only_the_grid_meter_can_set_a_billing_peak.py create mode 100644 tests/unit/coordinator/test_our_hot_water_boost_does_not_outlive_us.py create mode 100644 tests/unit/coordinator/test_savings_are_not_computed_from_a_guess.py create mode 100644 tests/unit/coordinator/test_shutdown_stops_the_coordinator.py create mode 100644 tests/unit/coordinator/test_the_billing_hour_survives_the_clocks_going_back.py create mode 100644 tests/unit/coordinator/test_the_ventilation_fan_cannot_cycle_forever.py create mode 100644 tests/unit/coordinator/test_update_loop_survives_errors.py create mode 100644 tests/unit/dhw/test_dhw_safety_stop_not_rate_limited.py create mode 100644 tests/unit/dhw/test_hot_water_wins_but_never_below_safety.py create mode 100644 tests/unit/dhw/test_the_dhw_schedule_survives_the_clocks_going_back.py create mode 100644 tests/unit/dhw/test_what_the_dhw_safety_floor_actually_does.py create mode 100644 tests/unit/effect/test_a_version_1_store_does_not_break_setup.py create mode 100644 tests/unit/effect/test_peak_protection_works_without_a_whole_house_meter.py create mode 100644 tests/unit/effect/test_peak_reset_and_predictive_guard.py create mode 100644 tests/unit/effect/test_the_tariff_counts_at_most_one_peak_per_day.py create mode 100644 tests/unit/optimization/test_a_pump_with_no_room_sensor_is_not_driven_on_a_placeholder.py create mode 100644 tests/unit/optimization/test_airflow_energy_balance.py create mode 100644 tests/unit/optimization/test_compressor_wear_guard.py create mode 100644 tests/unit/optimization/test_cost_may_coast_the_house_but_not_starve_it.py create mode 100644 tests/unit/optimization/test_dhw_does_not_start_only_to_abort.py create mode 100644 tests/unit/optimization/test_every_rung_of_the_ladder_is_reachable.py create mode 100644 tests/unit/optimization/test_free_electricity_is_not_declined.py create mode 100644 tests/unit/optimization/test_learning_can_actually_learn.py create mode 100644 tests/unit/optimization/test_manual_override_safety_floor.py create mode 100644 tests/unit/optimization/test_no_room_sensor_safety.py create mode 100644 tests/unit/optimization/test_one_definition_of_the_billed_quantity.py create mode 100644 tests/unit/optimization/test_peak_protection_compares_like_with_like.py create mode 100644 tests/unit/optimization/test_preheat_sees_the_cold_coming.py create mode 100644 tests/unit/optimization/test_proactive_shares_the_thermal_ladder.py create mode 100644 tests/unit/optimization/test_safety_priority_inversion.py create mode 100644 tests/unit/optimization/test_the_core_control_law_does_not_need_a_forecast.py create mode 100644 tests/unit/optimization/test_the_emergency_ladder_does_not_fire_in_summer.py create mode 100644 tests/unit/optimization/test_the_flow_curve_has_no_cliff_and_no_dead_path.py create mode 100644 tests/unit/optimization/test_the_prediction_gates_count_in_the_right_units.py create mode 100644 tests/unit/optimization/test_the_price_layer_reads_prices_not_just_rankings.py create mode 100644 tests/unit/optimization/test_the_savings_figure_is_not_the_night_weighting.py create mode 100644 tests/unit/optimization/test_the_tariff_bills_the_hour_not_the_quarter.py create mode 100644 tests/unit/optimization/test_the_wear_and_rate_limits_are_real.py create mode 100644 tests/unit/optimization/test_thermal_mass_buffer_direction.py create mode 100644 tests/unit/optimization/test_warming_is_not_heat_loss.py create mode 100644 tests/unit/optimization/test_winter_power_with_aux_is_not_an_anomaly.py create mode 100644 tests/unit/test_a_hot_water_boost_we_started_is_a_hot_water_boost_we_stop.py create mode 100644 tests/unit/test_diagnostics_report_the_band_the_house_is_held_to.py create mode 100644 tests/unit/test_home_assistant_apis_are_used_as_declared.py create mode 100644 tests/unit/test_invented_prices_do_not_vote.py create mode 100644 tests/unit/test_money_sensors_tell_the_truth.py create mode 100644 tests/unit/test_one_answer_to_what_the_power_sensor_says.py create mode 100644 tests/unit/test_options_flow_tells_you_what_is_wrong.py create mode 100644 tests/unit/test_platforms_unload_before_the_coordinator_dies.py create mode 100644 tests/unit/test_reads_do_not_drive_the_pump.py create mode 100644 tests/unit/test_startup_grace_is_bounded.py create mode 100644 tests/unit/test_the_airflow_sensor_survives_its_own_attributes.py create mode 100644 tests/unit/test_the_boost_cooldown_survives_a_reload.py create mode 100644 tests/unit/test_the_pump_is_not_driven_on_a_reading_from_hours_ago.py create mode 100644 tests/unit/test_the_thermostat_off_switch_actually_turns_it_off.py create mode 100644 tests/unit/test_which_things_actually_unload_the_entry.py create mode 100644 tests/unit/test_you_can_report_what_the_pump_actually_did.py create mode 100644 tests/unit/test_you_cannot_ask_for_a_temperature_the_system_will_fight.py create mode 100644 tests/unit/utils/test_a_negative_price_is_still_a_price.py create mode 100644 tests/unit/utils/test_milliwatts_are_not_megawatts.py create mode 100644 tests/unit/utils/test_the_pump_does_what_the_engine_asked.py create mode 100644 tests/validation/test_a_saturated_compressor_is_a_positive_feedback_trap.py create mode 100644 tests/validation/test_climate_zones_doc_matches_the_code.py create mode 100644 tests/validation/test_emitter_law_matches_openenergymonitor.py create mode 100644 tests/validation/test_every_simulator_constant_says_where_it_came_from.py create mode 100644 tests/validation/test_no_document_misquotes_the_safety_thresholds.py create mode 100644 tests/validation/test_no_production_code_uses_a_naive_datetime.py create mode 100644 tests/validation/test_no_test_captures_the_clock_at_import_time.py create mode 100644 tests/validation/test_one_definition_of_the_safety_floor.py create mode 100644 tests/validation/test_research_docs_still_hold.py create mode 100644 tests/validation/test_sensors_speak_the_users_language.py create mode 100644 tests/validation/test_the_plant_engages_aux_where_the_pump_does.py create mode 100644 tests/validation/test_the_pump_models_match_their_datasheets.py create mode 100644 tests/validation/test_the_rulebook_describes_this_codebase.py create mode 100644 tests/validation/test_the_simulated_plant_obeys_physics.py create mode 100644 tests/validation/test_translation_key_parity.py create mode 100644 tests/validation/test_weather_compensation_has_no_dc_bias.py create mode 100644 tests/validation/test_weather_compensation_is_not_anti_compensation.py diff --git a/tests/unit/adapters/test_a_forecast_from_six_hours_ago_is_not_a_forecast.py b/tests/unit/adapters/test_a_forecast_from_six_hours_ago_is_not_a_forecast.py new file mode 100644 index 00000000..6169531b --- /dev/null +++ b/tests/unit/adapters/test_a_forecast_from_six_hours_ago_is_not_a_forecast.py @@ -0,0 +1,135 @@ +"""forecast_hours[N] must mean "N hours from now"; the adapter must make that true. + +Every consumer slices WeatherData.forecast_hours positionally (thermal_layer +forecast_hours[:3] is the cold-snap trigger; weather_layer [:24]; prediction_layer +[:horizon]). The adapter used to append every entry the weather entity published, in +its published order, including hours already past - so a stalled-but-"available" +integration (unavailable never trips) could hold stale weather at index 0 and push a +real cold snap outside every horizon. + +get_forecast() now drops hours that have already ended and sorts the rest. A forecast +entirely in the past becomes empty, and the layers abstain when there is no forecast. +""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import MagicMock + +import pytest +from homeassistant.util import dt as dt_util + +from custom_components.effektguard.adapters.weather_adapter import WeatherAdapter +from custom_components.effektguard.const import CONF_WEATHER_ENTITY + +# The clock is read INSIDE each test (fixture below), never at module import: a module-level NOW +# captured at collection time would diverge from the adapter's run-time clock under a frozen clock. + + +@pytest.fixture +def now(): + return dt_util.utcnow() + + +# A cold snap arriving within the hour, behind six hours of stale mild weather. +STALE_LEADING_HOURS = [(-6, 5.0), (-5, 4.0), (-4, 3.0), (-3, 2.0), (-2, 1.0), (-1, 0.0)] +THE_COLD_SNAP = [(0, -1.0), (1, -8.0), (2, -14.0), (3, -18.0)] + + +def _adapter(now, hours: list[tuple[int, float]]) -> WeatherAdapter: + state = MagicMock() + state.state = "cloudy" + state.attributes = { + "temperature": -1.0, + "temperature_unit": "°C", + "forecast": [ + { + "datetime": (now + timedelta(hours=offset)).isoformat(), + "temperature": temp, + "condition": "cloudy", + } + for offset, temp in hours + ], + } + hass = MagicMock() + hass.states.get.return_value = state + return WeatherAdapter(hass, {CONF_WEATHER_ENTITY: "weather.home"}) + + +@pytest.mark.asyncio +async def test_the_first_forecast_hour_is_actually_in_the_future(now): + data = await _adapter(now, STALE_LEADING_HOURS + THE_COLD_SNAP).get_forecast() + + first = data.forecast_hours[0] + hours_away = (first.datetime - now).total_seconds() / 3600 + + assert hours_away > -1.0, ( + f"forecast_hours[0] is {hours_away:+.0f} hours from now, and reads {first.temperature:+.1f} " + f"C. Every layer slices this list positionally and treats index 0 as the next hour - so the " + f"cold-snap trigger was reading the weather from this morning." + ) + + +@pytest.mark.asyncio +async def test_the_cold_snap_is_inside_the_three_hour_trigger_window(now): + """The whole point. thermal_layer reads forecast_hours[:3] to decide whether cold is coming.""" + data = await _adapter(now, STALE_LEADING_HOURS + THE_COLD_SNAP).get_forecast() + + next_three = [hour.temperature for hour in data.forecast_hours[:3]] + + assert min(next_three) < -5.0, ( + f"The next three forecast hours read {next_three} C, and a cold snap reaching -18 C arrives " + f"within the hour. Six hours of already-past weather were sitting at the front of the list, " + f"pushing the snap out of every horizon the layers look at." + ) + + +@pytest.mark.asyncio +async def test_the_past_hours_are_dropped_entirely(now): + data = await _adapter(now, STALE_LEADING_HOURS + THE_COLD_SNAP).get_forecast() + + assert len(data.forecast_hours) == len(THE_COLD_SNAP) + assert all((hour.datetime - now).total_seconds() / 3600 > -1.0 for hour in data.forecast_hours) + + +@pytest.mark.asyncio +async def test_the_hours_come_back_in_order(now): + """A positional read is meaningless on an unsorted list, and nothing guaranteed the order.""" + shuffled = [THE_COLD_SNAP[2], THE_COLD_SNAP[0], THE_COLD_SNAP[3], THE_COLD_SNAP[1]] + + data = await _adapter(now, shuffled).get_forecast() + times = [hour.datetime for hour in data.forecast_hours] + + assert times == sorted(times) + assert data.forecast_hours[0].temperature == pytest.approx(-1.0) + + +@pytest.mark.asyncio +async def test_a_forecast_entirely_in_the_past_is_no_forecast_at_all(now): + """A stalled weather integration stays 'available' forever. It must not drive the pre-heat.""" + data = await _adapter(now, STALE_LEADING_HOURS).get_forecast() + + assert data is None, ( + "Every hour this weather entity published has already passed - it has stalled, and its " + "entity is still 'available', so the existing unavailable-check never trips. Driving the " + "pre-heat on it means pre-heating for weather that has already happened. The layers already " + "abstain when there is no forecast, which is the correct behaviour here." + ) + + +@pytest.mark.asyncio +async def test_a_healthy_forecast_is_untouched(now): + """The regression guard.""" + data = await _adapter(now, THE_COLD_SNAP).get_forecast() + + assert [hour.temperature for hour in data.forecast_hours] == pytest.approx( + [temp for _, temp in THE_COLD_SNAP] + ) + + +@pytest.mark.asyncio +async def test_the_current_hour_is_kept(now): + """A period that began forty minutes ago is still the weather now, not a memory.""" + data = await _adapter(now, [(0, -1.0), (1, -8.0)]).get_forecast() + + assert len(data.forecast_hours) == 2 diff --git a/tests/unit/adapters/test_a_missing_price_is_not_a_free_hour.py b/tests/unit/adapters/test_a_missing_price_is_not_a_free_hour.py new file mode 100644 index 00000000..005dd954 --- /dev/null +++ b/tests/unit/adapters/test_a_missing_price_is_not_a_free_hour.py @@ -0,0 +1,102 @@ +"""A GE-Spot entry with no `value` must be dropped, never defaulted to a price. + +`_parse_periods` reads `float(item["value"])`: a missing `value` raises KeyError and the +interval is dropped. It must never become `.get("value", 0.0)` - 0.0 is the cheapest +possible price, so a data-less quarter would rank best of the day, classify VERY_CHEAP +(PRICE_OFFSET_VERY_CHEAP is +4.0 C, aggressive pre-heating), and drive the pump hardest +in the interval nobody sent a price for. Zero is also a real Nordic price, so a fabricated +0.0 is indistinguishable from a genuinely free quarter after the fact. + +Dropped intervals are located by timestamp, so a gap means that quarter has no price and +the price layer abstains; a wholly empty day trips the no-price-source path. +""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import MagicMock + +import pytest +from homeassistant.util import dt as dt_util + +from custom_components.effektguard.adapters.gespot_adapter import GESpotAdapter +from custom_components.effektguard.optimization.price_layer import ( + PriceAnalyzer, + QuarterClassification, +) + + +def _adapter() -> GESpotAdapter: + return GESpotAdapter(MagicMock(), {"gespot_entity": "sensor.gespot"}) + + +def _raw_day(broken_at: int | None = None) -> list[dict[str, object]]: + """A realistic SE4 day. One entry may arrive without its `value` key.""" + base = dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) + day: list[dict[str, object]] = [] + + for i in range(96): + item: dict[str, object] = {"time": (base + timedelta(minutes=15 * i)).isoformat()} + if i != broken_at: + item["value"] = 40.0 + 50.0 * (i % 24) / 24.0 + day.append(item) + + return day + + +def test_a_good_day_parses(): + """The precondition. If this fails, the parser is rejecting everything.""" + periods = _adapter()._parse_periods(_raw_day()) + + assert len(periods) == 96 + assert min(p.price for p in periods) >= 40.0 + + +def test_an_entry_with_no_price_is_dropped_not_invented(): + """The interval has no price. That is not the same as a price of zero.""" + periods = _adapter()._parse_periods(_raw_day(broken_at=50)) + + assert len(periods) == 95, ( + "A GE-Spot entry with no `value` key was still turned into a price period. " + f"`.get('value', 0.0)` invented 0.0 for it - and 0.0 is the cheapest possible price." + ) + assert all(p.price != 0.0 for p in periods), ( + "A fabricated 0.0 öre survived into the parsed day. Zero is a REAL Nordic price (~100 h a " + "year per SE zone), so nothing downstream can ever tell it apart from a genuinely free " + "quarter." + ) + + +def test_a_quarter_with_no_data_is_not_the_best_quarter_of_the_day(): + """The consequence, end to end: no data ranks as the cheapest hour there is.""" + periods = _adapter()._parse_periods(_raw_day(broken_at=50)) + + classes = PriceAnalyzer().classify_quarterly_periods(periods) + + assert QuarterClassification.VERY_CHEAP not in set(classes.values()) or all( + periods[i].price > 0.0 for i, c in classes.items() if c is QuarterClassification.VERY_CHEAP + ), ( + "A quarter the adapter had no price for was classified VERY_CHEAP - the best quarter of the " + "day - because it was invented as 0.0. PRICE_OFFSET_VERY_CHEAP is +4.0 °C, 'aggressive " + "pre-heating'. The heat pump would be driven hardest in the interval nobody sent us a price " + "for." + ) + + +def test_a_day_where_every_price_is_missing_yields_no_day_at_all(): + """The schema-change case: GE-Spot renames the key and every entry breaks. + + All 96 intervals drop, `today` comes back empty, and the coordinator's no-price-source path + takes over: the price layer abstains entirely and a repair issue tells the user (F-123). That is + the correct outcome. The wrong one is 96 quarters of invented 0.0, every one of them VERY_CHEAP, + with the pump pre-heating aggressively around the clock. + """ + broken = [{"time": item["time"]} for item in _raw_day()] + + periods = _adapter()._parse_periods(broken) + + assert periods == [], ( + f"Every entry was missing its price and {len(periods)} periods came back anyway. If they " + f"are all invented zeros, every quarter of the day classifies VERY_CHEAP and the pump " + f"pre-heats aggressively, around the clock, on a day nobody sent us a single price for." + ) diff --git a/tests/unit/adapters/test_a_setpoint_is_not_a_measurement.py b/tests/unit/adapters/test_a_setpoint_is_not_a_measurement.py new file mode 100644 index 00000000..bfc812ed --- /dev/null +++ b/tests/unit/adapters/test_a_setpoint_is_not_a_measurement.py @@ -0,0 +1,123 @@ +"""A NIBE room-temperature SETPOINT must not be discovered as the indoor MEASUREMENT. + +Discovery must reject `number.` entities for temperature keys (`_consider_candidate` +requires `sensor.`). A `number.` is something the owner sets; a NIBE room setpoint is a +`number.` with device_class temperature and unit C and can match the `room_temperature` +pattern. Bound as the measurement it is silent and catastrophic: the target is read as +the measurement with indoor_temp_valid=True, so the deviation from target is 0.0 forever, +the comfort layer never corrects, and the 18 C safety floor (MIN_TEMP_LIMIT) never fires +because it reads the same setpoint. Manual overrides bypass discovery, so a reading truly +exposed as a `number.` can still be configured explicitly. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from custom_components.effektguard.adapters.nibe_adapter import NibeAdapter +from custom_components.effektguard.const import ( + CONF_NIBE_ENTITY, + NIBE_DISCOVERY_PATTERNS, + NIBE_TEMPERATURE_KEYS, +) + + +def _adapter() -> NibeAdapter: + return NibeAdapter(MagicMock(), {CONF_NIBE_ENTITY: "number.offset"}) + + +def _consider(adapter: NibeAdapter, entity_id: str) -> dict[str, str]: + """Run the real discovery candidate check against one entity.""" + adapter._entity_cache = {} + adapter._consider_candidate( + entity_id=entity_id, + device_class="temperature", + unit="°C", + rank=0, + ranks={}, + claimed=set(), + ) + return adapter._entity_cache + + +def test_the_pattern_that_makes_this_reachable_is_still_there(): + """The premise. `room_temperature` matches a setpoint's entity id just as well as a sensor's.""" + assert "room_temperature" in NIBE_DISCOVERY_PATTERNS["indoor_temp"] + assert "indoor_temp" in NIBE_TEMPERATURE_KEYS + + +@pytest.mark.parametrize( + "setpoint", + [ + "number.nibe_room_temperature_setpoint_s1", + "number.f750_room_temperature_s1_47398", + "number.heatpump_room_temperature", + ], +) +def test_a_writable_setpoint_is_never_bound_as_the_indoor_measurement(setpoint): + cache = _consider(_adapter(), setpoint) + + assert "indoor_temp" not in cache, ( + f"Discovery bound {setpoint} - a WRITABLE setpoint, something the owner sets - as the " + f"indoor temperature MEASUREMENT. The target is then read as the measurement with " + f"indoor_temp_valid=True, so the deviation from target is exactly 0.0 forever, the comfort " + f"layer never corrects, and the 18 C safety floor can never fire because it is reading the " + f"same setpoint. A house at 12 C in January would report itself perfectly on target." + ) + + +@pytest.mark.parametrize( + ("entity_id", "key"), + [ + ("sensor.nibe_bt50_room_temperature", "indoor_temp"), + ("sensor.nibe_bt1_outdoor_temperature", "outdoor_temp"), + ("sensor.nibe_bt25_supply_temperature", "supply_temp"), + ], +) +def test_a_real_sensor_is_still_discovered(entity_id, key): + """The regression guard. Do not break discovery while hardening it.""" + cache = _consider(_adapter(), entity_id) + + assert cache.get(key) == entity_id, ( + f"{entity_id} is an ordinary temperature sensor and discovery no longer finds it as " + f"{key}. The domain rule must reject setpoints, not measurements." + ) + + +def test_every_temperature_key_is_protected_not_just_the_indoor_one(): + """A setpoint bound as the SUPPLY temperature would drive weather compensation on a target.""" + adapter = _adapter() + + for key in NIBE_TEMPERATURE_KEYS: + patterns = NIBE_DISCOVERY_PATTERNS.get(key, []) + if not patterns: + continue + entity_id = f"number.nibe{patterns[0]}_setpoint" + cache = _consider(adapter, entity_id) + + assert key not in cache, ( + f"A `number.` entity matching the {key} pattern was bound as a {key} MEASUREMENT. " + f"Every temperature key reads a value the pump reports; none of them is something the " + f"owner sets." + ) + + +def test_the_write_target_still_has_to_be_a_number(): + """The mirror-image rule, which this file already had. It must survive.""" + adapter = _adapter() + adapter._entity_cache = {} + adapter._consider_candidate( + entity_id="sensor.nibe_heat_offset_s1_47011", + device_class=None, + unit=None, + rank=0, + ranks={}, + claimed=set(), + ) + + assert "offset" not in adapter._entity_cache, ( + "A `sensor.` was bound as the OFFSET write target. The write path calls number.set_value; " + "a sensor can never work." + ) diff --git a/tests/unit/adapters/test_adapter_refuses_fabricated_data.py b/tests/unit/adapters/test_adapter_refuses_fabricated_data.py new file mode 100644 index 00000000..2dec5bcd --- /dev/null +++ b/tests/unit/adapters/test_adapter_refuses_fabricated_data.py @@ -0,0 +1,146 @@ +"""The adapter must refuse to fabricate the inputs that drive heat-pump control. + +Two contracts are pinned: + +1. REQUIRED readings (outdoor, supply, degree minutes) missing/unavailable -> raise + UpdateFailed rather than substitute a plausible constant. A fabricated full NibeState + makes a broken install indistinguishable from a healthy one and still writes an offset. + Degree minutes is never estimated (no `_estimate_degree_minutes`): it is the primary + thermal-debt safety signal. +2. OPTIONAL indoor reading missing -> a NIBE with no BT50 is legitimate, so do not fail, + but set indoor_temp_valid=False so comfort layers abstain instead of trusting the + DEFAULT_INDOOR_TEMP placeholder, which equals the target (deviation of exactly 0.0). +""" + +from unittest.mock import MagicMock + +import pytest +from homeassistant.helpers.update_coordinator import UpdateFailed + +from custom_components.effektguard.adapters.nibe_adapter import NibeAdapter +from custom_components.effektguard.const import DEFAULT_INDOOR_TEMP + +OUTDOOR = "sensor.nibe_bt1_outdoor" +SUPPLY = "sensor.nibe_bt25_supply" +INDOOR = "sensor.nibe_bt50_room" +DEGREE_MINUTES = "sensor.nibe_degree_minutes" +OFFSET = "number.nibe_heat_offset_s1_47011" + +FULL_CACHE = { + "outdoor_temp": OUTDOOR, + "supply_temp": SUPPLY, + "indoor_temp": INDOOR, + "degree_minutes": DEGREE_MINUTES, + "offset": OFFSET, +} + +READINGS = { + OUTDOOR: "-8.4", + SUPPLY: "38.2", + INDOOR: "20.6", + DEGREE_MINUTES: "-420", + OFFSET: "0", +} + + +def build_adapter(cache: dict[str, str], readings: dict[str, str]) -> NibeAdapter: + """NibeAdapter wired to a fake state machine, with discovery pinned to `cache`.""" + hass = MagicMock() + + def get_state(entity_id: str): + if entity_id not in readings: + return None + state = MagicMock() + state.state = readings[entity_id] + state.attributes = {"unit_of_measurement": "°C"} + return state + + hass.states.get.side_effect = get_state + + adapter = NibeAdapter(hass, {"nibe_entity": OFFSET}) + adapter._entity_cache = dict(cache) + # Pin discovery: the cache above IS the discovered set for this test. + adapter._discover_nibe_entities = _noop + return adapter + + +async def _noop() -> None: + return None + + +class TestRequiredReadingsRefuseToBeFabricated: + @pytest.mark.asyncio + async def test_missing_degree_minutes_raises_instead_of_estimating(self): + """DM is the primary safety signal. It must never be invented.""" + cache = {k: v for k, v in FULL_CACHE.items() if k != "degree_minutes"} + adapter = build_adapter(cache, READINGS) + + with pytest.raises(UpdateFailed, match="degree minutes"): + await adapter.get_current_state() + + @pytest.mark.asyncio + async def test_missing_outdoor_temp_raises_instead_of_defaulting_to_zero(self): + """Outdoor 0.0 drives the climate-aware DM thresholds and weather compensation. + + A Swedish user at -20 C read as 0 C gets the wrong DM band AND under-heating. + """ + cache = {k: v for k, v in FULL_CACHE.items() if k != "outdoor_temp"} + adapter = build_adapter(cache, READINGS) + + with pytest.raises(UpdateFailed, match="outdoor"): + await adapter.get_current_state() + + @pytest.mark.asyncio + async def test_missing_supply_temp_raises_instead_of_defaulting_to_35(self): + cache = {k: v for k, v in FULL_CACHE.items() if k != "supply_temp"} + adapter = build_adapter(cache, READINGS) + + with pytest.raises(UpdateFailed, match="supply"): + await adapter.get_current_state() + + @pytest.mark.asyncio + async def test_unavailable_entity_is_treated_as_missing(self): + """A discovered entity reporting `unavailable` must not fall back to a constant.""" + readings = dict(READINGS) + readings[DEGREE_MINUTES] = "unavailable" + adapter = build_adapter(FULL_CACHE, readings) + + with pytest.raises(UpdateFailed, match="degree minutes"): + await adapter.get_current_state() + + @pytest.mark.asyncio + async def test_the_estimator_is_gone(self): + """No back-door: the DM estimator must not exist at all (repo rule: no aliases).""" + assert not hasattr(NibeAdapter, "_estimate_degree_minutes"), ( + "_estimate_degree_minutes still exists. Degree minutes must never be " + "fabricated from a heating-curve guess." + ) + + +class TestIndoorSensorIsOptionalButMarkedInvalid: + @pytest.mark.asyncio + async def test_no_room_sensor_still_works_but_marks_indoor_invalid(self): + """A NIBE without BT50 is a legitimate setup - it must not fail, but must not lie.""" + cache = {k: v for k, v in FULL_CACHE.items() if k != "indoor_temp"} + adapter = build_adapter(cache, READINGS) + + state = await adapter.get_current_state() + + assert state.indoor_temp_valid is False, ( + "Indoor reading is a placeholder but is flagged as a measurement. Comfort " + "layers would trust DEFAULT_INDOOR_TEMP, which equals the target and yields a " + "deviation of exactly 0.0." + ) + assert state.indoor_temp == pytest.approx(DEFAULT_INDOOR_TEMP) + # The rest of the state is real and usable. + assert state.degree_minutes == pytest.approx(-420.0) + assert state.outdoor_temp == pytest.approx(-8.4) + + @pytest.mark.asyncio + async def test_present_room_sensor_is_marked_valid(self): + adapter = build_adapter(FULL_CACHE, READINGS) + + state = await adapter.get_current_state() + + assert state.indoor_temp_valid is True + assert state.indoor_temp == pytest.approx(20.6) diff --git a/tests/unit/adapters/test_an_implausible_reading_is_not_a_reading.py b/tests/unit/adapters/test_an_implausible_reading_is_not_a_reading.py new file mode 100644 index 00000000..e716f41b --- /dev/null +++ b/tests/unit/adapters/test_an_implausible_reading_is_not_a_reading.py @@ -0,0 +1,205 @@ +"""An implausible temperature reading is not a reading - _plausible must return None. + +NIBE's Modbus registers hold deci-degrees, so a hand-written YAML that omits `scale: 0.1` +reports BT50's 21.3 C as 213.0 C, BT1's -3.2 as -32.0, BT2's 35.8 as 358.0. The +plausibility band must cover the sensor the HEAT PUMP sends (BT50), not only the +user-added room sensors originally checked. An implausible required sensor (outdoor, +supply) raises UpdateFailed; an implausible BT50 degrades to "no room sensor" (comfort +layers abstain, 18 C floor unaffected). The placeholder must never seed the multi-sensor +median - DEFAULT_INDOOR_TEMP would drag a cold house toward the target and mask the +deviation, which _calculate_multi_sensor_temperature's own docstring forbids. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from homeassistant.helpers.update_coordinator import UpdateFailed +from homeassistant.util import dt as dt_util + +from custom_components.effektguard.adapters.nibe_adapter import NibeAdapter +from custom_components.effektguard.const import ( + DEFAULT_INDOOR_TEMP, + INDOOR_SENSOR_PLAUSIBLE_MAX, + INDOOR_SENSOR_PLAUSIBLE_MIN, + NIBE_OUTDOOR_PLAUSIBLE_MAX, + NIBE_OUTDOOR_PLAUSIBLE_MIN, + NIBE_WATER_PLAUSIBLE_MAX, + NIBE_WATER_PLAUSIBLE_MIN, +) + + +def _adapter(states: dict[str, str]) -> NibeAdapter: + hass = MagicMock() + + def get(entity_id): + if entity_id not in states: + return None + state = MagicMock() + state.state = states[entity_id] + state.attributes = {"unit_of_measurement": "°C"} + state.last_reported = dt_util.utcnow() + state.last_updated = state.last_reported + return state + + hass.states.get.side_effect = get + + adapter = NibeAdapter(hass, {"nibe_entity": "number.offset"}) + adapter._entity_cache = { + "outdoor_temp": "sensor.bt1", + "supply_temp": "sensor.bt2", + "indoor_temp": "sensor.bt50", + "degree_minutes": "sensor.dm", + } + return adapter + + +HEALTHY = { + "sensor.bt1": "-3.2", + "sensor.bt2": "35.8", + "sensor.bt50": "21.3", + "sensor.dm": "-150", +} + + +class TestTheRoomSensorTheHeatPumpSends: + """BT50 is the one exposed to the typo, and it was the one not being checked.""" + + @pytest.mark.asyncio + async def test_a_bt50_reading_213_degrees_is_not_a_room_temperature(self): + adapter = _adapter({**HEALTHY, "sensor.bt50": "213.0"}) + + state = await adapter.get_current_state() + + assert state.indoor_temp_valid is False, ( + f"BT50 reported 213.0 C - a missing `scale: 0.1` on a deci-degree register - and it " + f"was accepted as a room temperature with indoor_temp_valid=True. The comfort layer " + f"then reads a 192 C overshoot and commands -10.0 C at critical weight, forever, and " + f"the 18 C safety floor never fires because it is reading the same 213 C." + ) + assert state.indoor_temp == DEFAULT_INDOOR_TEMP, ( + "An implausible BT50 must degrade to 'no room sensor' - a configuration this " + "integration already handles, by having the comfort-reasoning layers abstain." + ) + + @pytest.mark.asyncio + async def test_a_healthy_bt50_is_still_trusted(self): + state = await _adapter(HEALTHY).get_current_state() + + assert state.indoor_temp_valid is True + assert state.indoor_temp == pytest.approx(21.3) + + @pytest.mark.parametrize("reading", [15.0, 21.3, 30.0]) + @pytest.mark.asyncio + async def test_the_whole_habitable_band_is_accepted(self, reading): + """The band's job is to catch a value that cannot be a temperature, not to second-guess.""" + state = await _adapter({**HEALTHY, "sensor.bt50": str(reading)}).get_current_state() + + assert state.indoor_temp_valid is True + assert INDOOR_SENSOR_PLAUSIBLE_MIN <= state.indoor_temp <= INDOOR_SENSOR_PLAUSIBLE_MAX + + +class TestTheRequiredSensors: + """Outdoor and supply drive every decision. An impossible one must stop the integration.""" + + @pytest.mark.asyncio + async def test_a_bt1_reading_105_below_zero_stops_the_integration(self): + """-105 C demands a 96.8 C flow and pushes the DM warning to within 50 of the aux limit.""" + adapter = _adapter({**HEALTHY, "sensor.bt1": "-105.0"}) + + with pytest.raises(UpdateFailed, match="outdoor temperature"): + await adapter.get_current_state() + + @pytest.mark.asyncio + async def test_a_supply_temperature_of_358_degrees_stops_the_integration(self): + """A missing scale on BT2: 358 deci-degrees is 35.8 C. Water cannot be at 358 C.""" + adapter = _adapter({**HEALTHY, "sensor.bt2": "358.0"}) + + with pytest.raises(UpdateFailed, match="supply"): + await adapter.get_current_state() + + @pytest.mark.asyncio + async def test_a_healthy_pump_still_reads(self): + state = await _adapter(HEALTHY).get_current_state() + + assert state.outdoor_temp == pytest.approx(-3.2) + assert state.supply_temp == pytest.approx(35.8) + assert state.degree_minutes == pytest.approx(-150.0) + + @pytest.mark.parametrize( + ("outdoor", "ok"), + [(-45.0, True), (-50.0, True), (-51.0, False), (40.0, True), (60.0, False)], + ) + @pytest.mark.asyncio + async def test_the_outdoor_band_reaches_below_kiruna(self, outdoor, ok): + """Kiruna reaches -40 C. The band must not reject a real Nordic winter.""" + assert NIBE_OUTDOOR_PLAUSIBLE_MIN <= -45.0, "the band must accommodate Kiruna" + adapter = _adapter({**HEALTHY, "sensor.bt1": str(outdoor)}) + + if ok: + state = await adapter.get_current_state() + assert state.outdoor_temp == pytest.approx(outdoor) + else: + with pytest.raises(UpdateFailed): + await adapter.get_current_state() + + def test_water_cannot_freeze_or_boil(self): + assert NIBE_WATER_PLAUSIBLE_MIN == 0.0 + assert NIBE_WATER_PLAUSIBLE_MAX == 100.0 + assert NIBE_OUTDOOR_PLAUSIBLE_MAX < NIBE_WATER_PLAUSIBLE_MAX + + +class TestThePlaceholderNeverSeedsTheMedian: + """`_calculate_multi_sensor_temperature`'s own docstring forbids exactly what was happening.""" + + @pytest.mark.asyncio + async def test_a_sensorless_pump_with_one_added_sensor_reports_that_sensor(self): + """median([21.0 placeholder, 17.0 real]) is 19.0. The house is at 17.0.""" + adapter = _adapter({**HEALTHY, "sensor.hall": "17.0"}) + del adapter._entity_cache["indoor_temp"] # no BT50 + adapter._additional_indoor_sensors = ["sensor.hall"] + + state = await adapter.get_current_state() + + assert state.indoor_temp == pytest.approx(17.0), ( + f"A sensorless NIBE with one added room sensor reading 17.0 C reported " + f"{state.indoor_temp:.1f} C. DEFAULT_INDOOR_TEMP ({DEFAULT_INDOOR_TEMP}) was seeded " + f"into the median, so the combined reading is dragged TOWARD the target and a cold " + f"house looks two degrees warmer than it is. The function's own docstring forbids it." + ) + assert state.indoor_temp_valid is True + + @pytest.mark.asyncio + async def test_the_placeholder_does_not_bias_a_two_sensor_median_either(self): + adapter = _adapter({**HEALTHY, "sensor.hall": "18.0", "sensor.living": "18.4"}) + del adapter._entity_cache["indoor_temp"] + adapter._additional_indoor_sensors = ["sensor.hall", "sensor.living"] + + state = await adapter.get_current_state() + + assert state.indoor_temp == pytest.approx(18.2), ( + f"Two sensors at 18.0 and 18.4 have a median of 18.2. Got {state.indoor_temp:.2f} - " + f"the 21.0 placeholder was seeded in, biasing the reading toward the target." + ) + + @pytest.mark.asyncio + async def test_a_real_bt50_is_still_combined_with_the_added_sensors(self): + """The regression guard: a pump WITH a room sensor must still use it.""" + adapter = _adapter({**HEALTHY, "sensor.hall": "20.0", "sensor.living": "22.0"}) + adapter._additional_indoor_sensors = ["sensor.hall", "sensor.living"] + + state = await adapter.get_current_state() + + # median of [21.3 (BT50), 20.0, 22.0] + assert state.indoor_temp == pytest.approx(21.3) + assert state.indoor_temp_valid is True + + +def test_the_helper_returns_none_rather_than_clamping(): + """Clamping would invent a reading. The whole point is that we do not have one.""" + adapter = _adapter(HEALTHY) + + assert adapter._plausible(213.0, 15.0, 30.0, "BT50") is None + assert adapter._plausible(None, 15.0, 30.0, "BT50") is None + assert adapter._plausible(21.3, 15.0, 30.0, "BT50") == pytest.approx(21.3) diff --git a/tests/unit/adapters/test_temperature_unit_conversion.py b/tests/unit/adapters/test_temperature_unit_conversion.py new file mode 100644 index 00000000..3af5b1c0 --- /dev/null +++ b/tests/unit/adapters/test_temperature_unit_conversion.py @@ -0,0 +1,141 @@ +"""NIBE temperature readings must be normalised to °C (`_read_temperature`). + +NibeState documents every temperature as °C and the optimization stack assumes it. +Discovery accepts °F entities, and HA presents a temperature sensor in the user's +preferred unit, so on an imperial install BT1 reading 32 (= 0 °C) and BT25 reading 95 +(= 35 °C) would be taken as +32 °C and a 95 °C flow if passed through as bare floats - +driving weather compensation to minimum offset in winter. Conversion must happen after +the unknown-value marker check, so a raw -32768 marker is dropped, not converted. +""" + +from unittest.mock import MagicMock + +import pytest + +from custom_components.effektguard.adapters.nibe_adapter import NibeAdapter + +OUTDOOR = "sensor.nibe_bt1_outdoor" +SUPPLY = "sensor.nibe_bt25_supply" +INDOOR = "sensor.nibe_bt50_room" +DEGREE_MINUTES = "sensor.nibe_degree_minutes" +OFFSET = "number.nibe_heat_offset" + +CACHE = { + "outdoor_temp": OUTDOOR, + "supply_temp": SUPPLY, + "indoor_temp": INDOOR, + "degree_minutes": DEGREE_MINUTES, + "offset": OFFSET, +} + + +async def _noop() -> None: + return None + + +def build_adapter(readings: dict[str, tuple[str, str | None]]) -> NibeAdapter: + """readings maps entity_id -> (state_value, unit_of_measurement).""" + hass = MagicMock() + + def get_state(entity_id: str): + if entity_id not in readings: + return None + value, unit = readings[entity_id] + state = MagicMock() + state.state = value + state.attributes = {"unit_of_measurement": unit} if unit else {} + return state + + hass.states.get.side_effect = get_state + + adapter = NibeAdapter(hass, {"nibe_entity": OFFSET}) + adapter._entity_cache = dict(CACHE) + adapter._discover_nibe_entities = _noop + return adapter + + +class TestFahrenheitIsConvertedToCelsius: + @pytest.mark.asyncio + async def test_fahrenheit_sensors_are_converted(self): + """A pump reported entirely in °F must arrive as °C.""" + adapter = build_adapter( + { + OUTDOOR: ("32", "°F"), # 0 °C - freezing + SUPPLY: ("95", "°F"), # 35 °C - a normal flow temp + INDOOR: ("68", "°F"), # 20 °C + DEGREE_MINUTES: ("-420", None), + OFFSET: ("0", None), + } + ) + + state = await adapter.get_current_state() + + assert state.outdoor_temp == pytest.approx(0.0), ( + f"BT1 at 32 °F is FREEZING, but was read as {state.outdoor_temp:.1f} °C. " + "Weather compensation would think it is a mild day." + ) + assert state.supply_temp == pytest.approx(35.0), ( + f"BT25 at 95 °F is a normal 35 °C flow, but was read as " + f"{state.supply_temp:.1f} °C - an impossible flow temperature." + ) + assert state.indoor_temp == pytest.approx(20.0) + assert state.indoor_temp_valid is True + + @pytest.mark.asyncio + async def test_celsius_sensors_pass_through_unchanged(self): + """Do not over-correct: °C must not be touched.""" + adapter = build_adapter( + { + OUTDOOR: ("-8.4", "°C"), + SUPPLY: ("38.2", "°C"), + INDOOR: ("20.6", "°C"), + DEGREE_MINUTES: ("-420", None), + OFFSET: ("0", None), + } + ) + + state = await adapter.get_current_state() + + assert state.outdoor_temp == pytest.approx(-8.4) + assert state.supply_temp == pytest.approx(38.2) + assert state.indoor_temp == pytest.approx(20.6) + + @pytest.mark.asyncio + async def test_missing_unit_is_assumed_celsius(self): + """Modbus/template sensors often carry no unit. Celsius is the right assumption.""" + adapter = build_adapter( + { + OUTDOOR: ("-8.4", None), + SUPPLY: ("38.2", None), + INDOOR: ("20.6", None), + DEGREE_MINUTES: ("-420", None), + OFFSET: ("0", None), + } + ) + + state = await adapter.get_current_state() + + assert state.outdoor_temp == pytest.approx(-8.4) + assert state.supply_temp == pytest.approx(38.2) + + @pytest.mark.asyncio + async def test_unknown_value_marker_is_still_rejected_before_conversion(self): + """-32768 is a raw s16 'no reading' marker - it must not be converted, it must be dropped. + + Converting it from °F would yield -18204 °C, a plausible-looking float. + """ + adapter = build_adapter( + { + OUTDOOR: ("-8.4", "°C"), + SUPPLY: ("38.2", "°C"), + INDOOR: ("-32768", "°F"), # disconnected sensor, reported in °F + DEGREE_MINUTES: ("-420", None), + OFFSET: ("0", None), + } + ) + + state = await adapter.get_current_state() + + # The marker must be treated as "no reading", not converted into a temperature. + assert state.indoor_temp_valid is False + assert state.indoor_temp > 0 # the placeholder, not -18204 diff --git a/tests/unit/adapters/test_the_shape_gespot_actually_sends.py b/tests/unit/adapters/test_the_shape_gespot_actually_sends.py new file mode 100644 index 00000000..47f79459 --- /dev/null +++ b/tests/unit/adapters/test_the_shape_gespot_actually_sends.py @@ -0,0 +1,129 @@ +"""Pin the price parser to the shape GE-Spot actually publishes: datetime objects. + +`_parse_periods` accepts `time` as either an ISO string or a timezone-aware datetime. +GE-Spot sends the datetime-object form (its sensor/base.py builds `{"time": dt, ...}`), +but the other tests all build fixtures with `.isoformat()`, exercising only the string +branch. This file exercises the datetime branch: a full day parses and stays tz-aware and +time-ordered; `value` (the billed price) is used, not `raw_value` (pre-VAT/tariff); a +missing `value` is still dropped, not defaulted to 0.0; and a naive datetime does not +crash the parser but resolves to None at the timestamp-containment lookup. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock +from zoneinfo import ZoneInfo + +from custom_components.effektguard.adapters.gespot_adapter import GESpotAdapter + +STOCKHOLM = ZoneInfo("Europe/Stockholm") + + +def _adapter() -> GESpotAdapter: + return GESpotAdapter(MagicMock(), {"gespot_entity": "sensor.gespot"}) + + +def _live_day(broken_at: int | None = None) -> list[dict[str, object]]: + """A day exactly as GE-Spot builds it: datetime objects, and a pre-VAT `raw_value`. + + Mirrors ge_spot/sensor/base.py - `datetime(y, m, d, hour, minute, 0, tzinfo=target_tz)`, + `value` rounded to 4 places, `raw_value` added only when GE-Spot has it. + """ + midnight = datetime(2026, 1, 15, 0, 0, tzinfo=STOCKHOLM) + day: list[dict[str, object]] = [] + + for quarter in range(96): + item: dict[str, object] = {"time": midnight + timedelta(minutes=15 * quarter)} + if quarter != broken_at: + item["value"] = round(40.0 + quarter * 0.5, 4) + item["raw_value"] = round((40.0 + quarter * 0.5) * 0.6, 4) # before VAT and tariffs + day.append(item) + + return day + + +def test_the_shape_gespot_actually_publishes_parses(): + """A datetime object in `time`, not an ISO string. The production path, finally exercised.""" + periods = _adapter()._parse_periods(_live_day()) + + assert len(periods) == 96, ( + "GE-Spot's real output - datetime objects in `time` - did not parse into a full day. " + "This is the shape the adapter receives in production." + ) + assert all(period.start_time.tzinfo is not None for period in periods), ( + "A timezone-aware datetime from GE-Spot came back naive. Every downstream comparison is " + "against dt_util.now(), which is aware; mixing the two raises TypeError, and PriceData " + "swallows it and returns None - silently pricing every quarter as unknown." + ) + + +def test_the_instant_gespot_sent_is_the_instant_we_store(): + """No round-trip through a string, so no chance to lose the offset.""" + day = _live_day() + periods = _adapter()._parse_periods(day) + + assert periods[0].start_time == day[0]["time"] + assert periods[40].start_time == day[40]["time"] + + +def test_the_pre_vat_price_is_not_mistaken_for_the_price_the_owner_pays(): + """`raw_value` is the market price before VAT and tariffs. It is not what anything costs. + + GE-Spot publishes both. `value` is what the owner is billed; `raw_value` is roughly 60 % of + it. They differ by enough that optimising against the wrong one would rank quarters by a + number nobody pays - and, worse, would look entirely plausible in every log and every chart. + """ + periods = _adapter()._parse_periods(_live_day()) + + assert periods[0].price == 40.0, ( + f"The parser took {periods[0].price} for the first quarter. `value` (40.0) is the price " + f"the owner pays; `raw_value` (24.0) is the market price before VAT and tariffs. " + f"Optimising against the pre-tax price ranks quarters by a number nobody is billed for." + ) + + +def test_a_missing_price_is_still_dropped_on_the_path_that_actually_runs(): + """A missing `value` is dropped on the datetime path too, not defaulted to 0.0.""" + periods = _adapter()._parse_periods(_live_day(broken_at=50)) + + assert len(periods) == 95, ( + "A GE-Spot entry with a real datetime but no `value` key was still turned into a price " + "period. On this path - the production path - the missing price is invented as 0.0, the " + "cheapest possible price, and that quarter is ranked the best of the day and answered " + "with the most aggressive pre-heating the price layer can command." + ) + assert all(period.price >= 40.0 for period in periods), "a fabricated 0.0 survived" + + +def test_a_live_day_is_ordered_by_instant_without_ever_seeing_a_string(): + """The sort key is `.timestamp()`, which needs the datetime path to be right.""" + shuffled = _live_day() + shuffled.reverse() + + periods = _adapter()._parse_periods(shuffled) + + instants = [period.start_time.timestamp() for period in periods] + assert instants == sorted(instants), "GE-Spot's intervals did not come back in time order" + assert periods[0].start_time.hour == 0 + assert periods[-1].start_time.hour == 23 + + +def test_a_naive_datetime_from_a_foreign_price_integration_is_not_silently_accepted(): + """A naive datetime parses but must resolve to None at the containment lookup. + + A naive timestamp compared against an aware dt_util.now() raises TypeError, which + _index_containing catches and answers with None - it must never raise into pump control. + """ + from custom_components.effektguard.adapters.gespot_adapter import PriceData + + naive = [ + {"time": datetime(2026, 1, 15, 0, 0) + timedelta(minutes=15 * q), "value": 40.0 + q} + for q in range(4) + ] + + periods = _adapter()._parse_periods(naive) + price_data = PriceData(today=periods, tomorrow=[], has_tomorrow=False) + + # The lookup refuses rather than raising into pump control. + assert price_data.get_period_index(datetime(2026, 1, 15, 0, 7, tzinfo=timezone.utc)) is None diff --git a/tests/unit/adapters/test_the_weather_adapter_knows_its_units.py b/tests/unit/adapters/test_the_weather_adapter_knows_its_units.py new file mode 100644 index 00000000..3594df4d --- /dev/null +++ b/tests/unit/adapters/test_the_weather_adapter_knows_its_units.py @@ -0,0 +1,111 @@ +"""The weather adapter must convert forecast temperatures to °C, like nibe_adapter. + +A HA weather entity reports temperatures in the user's unit and declares it in +`temperature_unit`; get_forecast() must convert via TemperatureConverter. Without it, on an +imperial install a -5 C cold snap arrives as "23" (F) and is read as +23 C - a 28-degree +error that withdraws the pre-heat exactly when it is needed and disagrees with nibe_adapter, +which does convert. Both current_temp and every forecast hour must be converted; a missing +unit is assumed Celsius (HA's default). +""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import MagicMock + +import pytest +from homeassistant.const import UnitOfTemperature +from homeassistant.util import dt as dt_util + +from custom_components.effektguard.adapters.weather_adapter import WeatherAdapter +from custom_components.effektguard.const import CONF_WEATHER_ENTITY + +# The clock is read INSIDE each test (fixture below), never at module import: a module-level NOW +# captured at collection time would diverge from the adapter's run-time clock under a frozen clock. + + +@pytest.fixture +def now(): + return dt_util.utcnow() + + +# -5 C, -10 C, -15 C: a Nordic cold snap, spelled in each unit system. +COLD_SNAP_C = [-5.0, -10.0, -15.0] +COLD_SNAP_F = [23.0, 14.0, 5.0] + + +def _weather_entity(now, current: float, forecast: list[float], unit: str) -> MagicMock: + state = MagicMock() + state.state = "cloudy" + state.attributes = { + "temperature": current, + "temperature_unit": unit, + "forecast": [ + { + "datetime": (now + timedelta(hours=i)).isoformat(), + "temperature": t, + "condition": "cloudy", + } + for i, t in enumerate(forecast) + ], + } + return state + + +def _adapter(state: MagicMock) -> WeatherAdapter: + hass = MagicMock() + hass.states.get.return_value = state + return WeatherAdapter(hass, {CONF_WEATHER_ENTITY: "weather.home"}) + + +@pytest.mark.asyncio +async def test_a_fahrenheit_cold_snap_is_not_read_as_a_warm_spell(now): + """23 F is -5 C. Read as Celsius it is a mild spring day, and the pre-heat stands down.""" + adapter = _adapter(_weather_entity(now, 23.0, COLD_SNAP_F, UnitOfTemperature.FAHRENHEIT)) + + data = await adapter.get_forecast() + + assert data is not None + assert data.current_temp == pytest.approx(-5.0, abs=0.1), ( + f"A weather entity reporting 23 degrees FAHRENHEIT (-5 C) was read as " + f"{data.current_temp:.1f} C. That is a 28-degree error, in the direction of 'the house does " + f"not need heat' - so the pre-heat is withdrawn at exactly the moment it is needed, while " + f"nibe_adapter reports the outdoor sensor correctly as -5 C." + ) + + +@pytest.mark.asyncio +async def test_the_whole_fahrenheit_forecast_is_converted_not_just_the_current_reading(now): + """The forecast drives the cold-snap trigger. It is the half that matters most.""" + adapter = _adapter(_weather_entity(now, 23.0, COLD_SNAP_F, UnitOfTemperature.FAHRENHEIT)) + + data = await adapter.get_forecast() + + got = [round(h.temperature, 1) for h in data.forecast_hours[: len(COLD_SNAP_C)]] + assert got == pytest.approx(COLD_SNAP_C, abs=0.1), ( + f"The forecast came back as {got} C from a Fahrenheit entity; it should be {COLD_SNAP_C}. " + f"The cold-snap trigger reads the FORECAST - a slab must start charging days ahead - so an " + f"unconverted forecast means the pre-heat never fires for an imperial user." + ) + + +@pytest.mark.asyncio +async def test_celsius_is_untouched(now): + """The regression guard: every existing (metric) install must be bit-for-bit unchanged.""" + adapter = _adapter(_weather_entity(now, -5.0, COLD_SNAP_C, UnitOfTemperature.CELSIUS)) + + data = await adapter.get_forecast() + + assert data.current_temp == pytest.approx(-5.0) + assert [round(h.temperature, 1) for h in data.forecast_hours[:3]] == pytest.approx(COLD_SNAP_C) + + +@pytest.mark.asyncio +async def test_an_entity_that_declares_no_unit_is_assumed_celsius(now): + """Home Assistant's own default. Do not refuse to work with a sparse weather integration.""" + state = _weather_entity(now, -5.0, COLD_SNAP_C, UnitOfTemperature.CELSIUS) + del state.attributes["temperature_unit"] + + data = await _adapter(state).get_forecast() + + assert data.current_temp == pytest.approx(-5.0) diff --git a/tests/unit/coordinator/test_a_billing_hour_remembers_where_its_samples_came_from.py b/tests/unit/coordinator/test_a_billing_hour_remembers_where_its_samples_came_from.py new file mode 100644 index 00000000..7954285a --- /dev/null +++ b/tests/unit/coordinator/test_a_billing_hour_remembers_where_its_samples_came_from.py @@ -0,0 +1,134 @@ +"""A billing hour's provenance is decided by every sample in it, not by the closing cycle. + +The accumulator stamps a completed hour with the WEAKEST source among its samples. So an hour +whose middle fell back to pump phase currents (the grid meter dropped out) is control-grade, +even if the meter answered again at the hour boundary - the tariff bills whole-house grid +import, and fifty minutes of pump-only samples are not that. A pure grid-meter hour stays +billable; anything weaker in the mix degrades it. +""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock +from zoneinfo import ZoneInfo + +import pytest +from homeassistant.util import dt as dt_util + +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.const import ( + POWER_SOURCE_EXTERNAL_METER, + POWER_SOURCE_NIBE_CURRENTS, + UPDATE_INTERVAL_MINUTES, +) +from custom_components.effektguard.coordinator import EffektGuardCoordinator +from custom_components.effektguard.optimization.billing_period import BillingPeriodAccumulator +from custom_components.effektguard.optimization.effect_layer import EffectManager + +STOCKHOLM = ZoneInfo("Europe/Stockholm") + + +def _hour(minute: int, hour: int = 10) -> datetime: + return datetime(2026, 1, 15, hour, minute, tzinfo=STOCKHOLM) + + +class TestTheAccumulatorTracksSources: + def test_a_pure_meter_hour_stays_a_meter_hour(self): + acc = BillingPeriodAccumulator() + for minute in range(0, 60, 5): + acc.add(_hour(minute), 4.0, POWER_SOURCE_EXTERNAL_METER) + completed = acc.add(_hour(0, hour=11), 2.0, POWER_SOURCE_EXTERNAL_METER) + + assert completed is not None + assert completed.source == POWER_SOURCE_EXTERNAL_METER + + def test_one_pump_only_sample_degrades_the_hour_to_control_grade(self): + acc = BillingPeriodAccumulator() + for minute in range(0, 60, 5): + source = POWER_SOURCE_NIBE_CURRENTS if minute == 30 else POWER_SOURCE_EXTERNAL_METER + acc.add(_hour(minute), 4.0, source) + completed = acc.add(_hour(0, hour=11), 2.0, POWER_SOURCE_EXTERNAL_METER) + + assert completed is not None + assert completed.source == POWER_SOURCE_NIBE_CURRENTS + + +def _coordinator() -> EffektGuardCoordinator: + hass = MagicMock() + hass.config.latitude = 59.33 + hass.config.longitude = 18.07 + + nibe = MagicMock() + nibe._power_sensor_entity = "sensor.house_power" + nibe.power_sensor_entity = "sensor.house_power" + nibe.calculate_power_from_currents = MagicMock(return_value=9.0) + + entry = MagicMock() + entry.data = {} + entry.options = {} + + coordinator = EffektGuardCoordinator( + hass, nibe, MagicMock(), MagicMock(), MagicMock(), EffectManager(hass), entry + ) + coordinator.peak_today = 0.0 + coordinator.peak_this_month = 0.0 + coordinator._power_sensor_available = True + coordinator.effect.record_period_measurement = AsyncMock(return_value=None) + return coordinator + + +def _pump(with_currents: bool) -> NibeState: + return NibeState( + outdoor_temp=-5.0, + indoor_temp=21.0, + supply_temp=42.0, + return_temp=37.0, + degree_minutes=-150.0, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 10, 0, tzinfo=STOCKHOLM), + phase1_current=8.0 if with_currents else None, + phase2_current=8.0 if with_currents else None, + phase3_current=8.0 if with_currents else None, + ) + + +def _meter(hass, kw: float | None) -> None: + state = MagicMock() + if kw is None: + state.state = "unavailable" + state.attributes = {} + else: + state.state = str(kw) + state.attributes = {"unit_of_measurement": "kW"} + hass.states.get.return_value = state + + +@pytest.mark.asyncio +async def test_a_meter_dropout_hour_is_not_billed_as_a_meter_hour(monkeypatch): + """Meter for the first half, pump currents for the second, meter again at the boundary. + + The boundary cycle's source is the METER - and the old stamping would have recorded the + whole hour as a billable meter measurement. Half of it never saw the house. + """ + coordinator = _coordinator() + + for minute in range(0, 60, UPDATE_INTERVAL_MINUTES): + monkeypatch.setattr(dt_util, "now", lambda tz=None, _m=minute: _hour(_m)) + meter_alive = minute < 30 + _meter(coordinator.hass, 4.0 if meter_alive else None) + await coordinator._update_peak_tracking(_pump(with_currents=not meter_alive)) + + monkeypatch.setattr(dt_util, "now", lambda tz=None: _hour(0, hour=11)) + _meter(coordinator.hass, 2.0) + await coordinator._update_peak_tracking(_pump(with_currents=False)) + + calls = coordinator.effect.record_period_measurement.await_args_list + assert len(calls) == 1, "the 10:00 hour was continuously sampled and must be recorded" + assert calls[0].kwargs["source"] == POWER_SOURCE_NIBE_CURRENTS, ( + f"The hour was recorded with source {calls[0].kwargs['source']!r}. Fifty-five minutes " + f"of it are fine, but 25 minutes were measured at the PUMP, not the grid connection - " + f"the tariff bills whole-house import, so this hour is control-grade, not billable." + ) diff --git a/tests/unit/coordinator/test_a_dropped_meter_is_not_a_measurement.py b/tests/unit/coordinator/test_a_dropped_meter_is_not_a_measurement.py new file mode 100644 index 00000000..52282300 --- /dev/null +++ b/tests/unit/coordinator/test_a_dropped_meter_is_not_a_measurement.py @@ -0,0 +1,175 @@ +"""A configured power meter that drops out must not have its estimate billed as a meter reading. + +A meter goes `unavailable` routinely (a Zigbee plug loses its router, an MQTT bridge restarts). +The old billing guard asked whether a power sensor was CONFIGURED, not whether one had just +MEASURED anything - so once the meter dropped out, the compressor-Hz estimate that replaced it +was recorded as a tariff peak and stamped with source "external_meter". Provenance was falsified, +and effect tariffs bill the top-3 hours of the month, so a phantom peak stands for weeks. + +The fix: a measurement carries where it came from, and the billing guard asks that (via +PEAK_CONTROL_POWER_SOURCES), not the config entry. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.util import dt as dt_util + +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.coordinator import EffektGuardCoordinator +from custom_components.effektguard.optimization.effect_layer import EffectManager + + +@pytest.fixture +def coordinator_with_external_meter(): + """A coordinator whose owner has configured a whole-house power meter.""" + hass = MagicMock() + hass.config.latitude = 59.33 + hass.config.longitude = 18.07 + + nibe = MagicMock() + nibe._power_sensor_entity = "sensor.house_power" + nibe.power_sensor_entity = "sensor.house_power" + + entry = MagicMock() + entry.data = {} + entry.options = {} + + coordinator = EffektGuardCoordinator( + hass, nibe, MagicMock(), MagicMock(), MagicMock(), EffectManager(hass), entry + ) + coordinator.peak_today = 0.0 + coordinator.peak_this_month = 0.0 + coordinator.effect.record_period_measurement = AsyncMock(return_value=None) + return coordinator + + +def _pump_running_but_unmetered() -> NibeState: + """The compressor is working. No phase-current sensors, so Hz is all that is left.""" + return NibeState( + outdoor_temp=-5.0, + indoor_temp=21.0, + supply_temp=42.0, + return_temp=37.0, + degree_minutes=-150.0, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc), + phase1_current=None, + phase2_current=None, + phase3_current=None, + compressor_hz=60, + ) + + +async def _run_a_complete_billing_hour(coordinator, nibe_data, monkeypatch) -> None: + """Samples from 10:00 through 11:00, so the HOUR is observed whole and recorded. + + The Swedish effect tariff bills the HOURLY mean, so only a full hour completes a billing period. + """ + for hour, minute in [(10, m) for m in range(0, 60, 5)] + [(11, 0)]: + monkeypatch.setattr( + dt_util, + "now", + lambda tz=None, hour=hour, minute=minute: datetime( + 2026, 1, 15, hour, minute, tzinfo=timezone.utc + ), + ) + await coordinator._update_peak_tracking(nibe_data) + + +@pytest.mark.asyncio +async def test_a_meter_that_drops_out_does_not_keep_billing( + coordinator_with_external_meter, monkeypatch +): + """The meter answered once, hours ago. It is not answering now.""" + coordinator = coordinator_with_external_meter + + # It worked at startup. That is what latches the flag - and unsubscribes the listener. + coordinator._power_sensor_available = True + + dropped_out = MagicMock() + dropped_out.state = "unavailable" + dropped_out.attributes = {} + coordinator.hass.states.get.return_value = dropped_out + + await _run_a_complete_billing_hour(coordinator, _pump_running_but_unmetered(), monkeypatch) + + coordinator.effect.record_period_measurement.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_a_meter_reporting_garbage_does_not_keep_billing( + coordinator_with_external_meter, monkeypatch +): + """The other way in: the state is present and unparseable. + + `except (ValueError, TypeError)` warns and leaves `current_power` as None - and then the very same + fall-through to the estimate happens, with the very same "the entity is configured, so this must be + a real measurement" conclusion at the end. + """ + coordinator = coordinator_with_external_meter + coordinator._power_sensor_available = True + + garbage = MagicMock() + garbage.state = "n/a" + garbage.attributes = {"unit_of_measurement": "W"} + coordinator.hass.states.get.return_value = garbage + + await _run_a_complete_billing_hour(coordinator, _pump_running_but_unmetered(), monkeypatch) + + coordinator.effect.record_period_measurement.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_an_estimate_is_never_stamped_as_a_meter_reading( + coordinator_with_external_meter, monkeypatch +): + """Whatever else happens, the record must not LIE about where the number came from. + + The daily peak is allowed to hold an estimate - it is a display value. What it must never do is + claim the estimate came from the external meter, because that is the one field anyone would consult + to find out whether a peak can be trusted. + """ + coordinator = coordinator_with_external_meter + coordinator._power_sensor_available = True + + dropped_out = MagicMock() + dropped_out.state = "unavailable" + dropped_out.attributes = {} + coordinator.hass.states.get.return_value = dropped_out + + await _run_a_complete_billing_hour(coordinator, _pump_running_but_unmetered(), monkeypatch) + + assert coordinator.peak_today_source != "external_meter", ( + f"A peak of {coordinator.peak_today:.2f} kW, estimated from compressor Hz because the meter " + f"was unavailable, was recorded with source 'external_meter'. Nothing downstream - and nobody " + f"reading the logs - can now tell it apart from a real reading." + ) + + +@pytest.mark.asyncio +async def test_a_working_meter_still_bills(coordinator_with_external_meter, monkeypatch): + """The precondition, and the thing that must not regress. + + A guard that refuses real measurements is worse than the bug it fixes: it would silently stop peak + tracking for every owner whose meter works. This is the test that says the fix costs them nothing. + """ + coordinator = coordinator_with_external_meter + coordinator._power_sensor_available = True + + working = MagicMock() + working.state = "4200" + working.attributes = {"unit_of_measurement": "W"} + coordinator.hass.states.get.return_value = working + + await _run_a_complete_billing_hour(coordinator, _pump_running_but_unmetered(), monkeypatch) + + coordinator.effect.record_period_measurement.assert_awaited_once() + recorded = coordinator.effect.record_period_measurement.await_args.kwargs + assert recorded["power_kw"] == pytest.approx(4.2) + assert coordinator.peak_today_source == "external_meter" diff --git a/tests/unit/coordinator/test_a_helper_can_stand_in_for_the_lux_switch.py b/tests/unit/coordinator/test_a_helper_can_stand_in_for_the_lux_switch.py new file mode 100644 index 00000000..b6dcf45c --- /dev/null +++ b/tests/unit/coordinator/test_a_helper_can_stand_in_for_the_lux_switch.py @@ -0,0 +1,63 @@ +"""A Modbus user's input_boolean helper is a valid temporary-lux actuator (issue #18). + +MyUplink exposes temporary lux as a `switch`; nibe_heatpump and generic Modbus do not, so +those users bridge it with a helper + automation. The lux door hardcoded the `switch` +service domain, and the config flow only accepted `switch` entities - locking every +non-MyUplink install out of hot-water optimization for no reason: `homeassistant.turn_on` +/`turn_off` drive both domains through the same one door. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.effektguard.coordinator import EffektGuardCoordinator + + +def _coordinator(lux_entity: str) -> EffektGuardCoordinator: + coordinator = EffektGuardCoordinator.__new__(EffektGuardCoordinator) + coordinator.hass = MagicMock() + coordinator.hass.services.async_call = AsyncMock() + coordinator.temp_lux_entity = lux_entity + coordinator._shutdown_requested = False + coordinator._lux_boost_is_ours = False + return coordinator + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "lux_entity", + ["switch.temporary_lux_50004", "input_boolean.nibe_temp_lux_bridge"], +) +async def test_the_lux_door_drives_any_toggleable_entity(lux_entity): + coordinator = _coordinator(lux_entity) + + assert await coordinator._set_temporary_lux(True) is True + + call = coordinator.hass.services.async_call.await_args + assert call.args[0] == "homeassistant", ( + f"The lux door called the {call.args[0]!r} service domain for {lux_entity}. An " + f"input_boolean helper - the only bridge a Modbus/nibe_heatpump user has - does not " + f"answer switch.turn_on; homeassistant.turn_on drives both." + ) + assert call.args[1] == "turn_on" + assert call.args[2] == {"entity_id": lux_entity} + + +def test_the_config_flow_accepts_a_helper_for_temporary_lux(): + import re + from pathlib import Path + + source = Path("custom_components/effektguard/config_flow.py").read_text(encoding="utf-8") + lux_selectors = re.findall( + r"CONF_NIBE_TEMP_LUX_ENTITY[^)]*?EntitySelectorConfig\(domain=(\[[^\]]*\]|\"[a-z_]+\")", + source, + flags=re.DOTALL, + ) + assert lux_selectors, "could not find the temp-lux entity selector in the config flow" + for domains in lux_selectors: + assert "input_boolean" in domains and "switch" in domains, ( + f"The temporary-lux selector accepts only {domains}. A nibe_heatpump/Modbus user " + f"has no lux switch - their bridge is an input_boolean helper, and the selector " + f"must let them pick it (issue #18)." + ) diff --git a/tests/unit/coordinator/test_a_user_boost_outranks_the_price_optimizer.py b/tests/unit/coordinator/test_a_user_boost_outranks_the_price_optimizer.py new file mode 100644 index 00000000..640a292e --- /dev/null +++ b/tests/unit/coordinator/test_a_user_boost_outranks_the_price_optimizer.py @@ -0,0 +1,137 @@ +"""A hot-water boost the USER commanded is not the price optimizer's to cancel. + +`boost_dhw` records HOW LONG the user asked for, and while that window is open: +- the ordinary price-based stop path defers to it, +- the thermal-debt SAFETY abort still stops it (and closes the window), +- expiry stops it through the same owned door the unload cleanup uses, +- and `duration` therefore does something real, instead of being validated and discarded. + +Only safety outranks the user; cost optimization does not. +""" + +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.exceptions import HomeAssistantError + +from custom_components.effektguard.coordinator import EffektGuardCoordinator + +NOW = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc) + + +def _coordinator(lux_state: str = "off") -> EffektGuardCoordinator: + coordinator = EffektGuardCoordinator.__new__(EffektGuardCoordinator) + coordinator.hass = MagicMock() + coordinator.hass.services.async_call = AsyncMock() + lux = MagicMock() + lux.state = lux_state + coordinator.hass.states.get = MagicMock(return_value=lux) + coordinator.entry = MagicMock() + coordinator.entry.data = {"target_indoor_temp": 21.0} + coordinator.entry.options = {} + coordinator.data = {} + coordinator.last_update_success = True + coordinator.temp_lux_entity = "switch.temporary_lux_50004" + coordinator._shutdown_requested = False + coordinator._lux_boost_is_ours = False + coordinator._service_boost_until = None + coordinator._last_dhw_control_time = NOW - timedelta(hours=2) + coordinator.dhw_optimizer = MagicMock() + coordinator._raise_dhw_control_issue = MagicMock() + coordinator._clear_dhw_control_issue = MagicMock() + return coordinator + + +def _stop_decision(): + """What the optimizer says when prices are high: stop heating water.""" + return SimpleNamespace(should_heat=False, abort_conditions=[], priority_reason="EXPENSIVE") + + +@pytest.mark.asyncio +async def test_the_price_stop_does_not_cancel_a_user_boost(): + coordinator = _coordinator(lux_state="off") + await coordinator.async_start_dhw_boost(duration_minutes=60, now_time=NOW) + assert coordinator._lux_boost_is_ours is True + + # Next cycle: lux is on, prices are high, the optimizer wants it off. + coordinator.hass.states.get.return_value.state = "on" + coordinator.hass.services.async_call.reset_mock() + + await coordinator._apply_dhw_control(_stop_decision(), 45.0, NOW + timedelta(minutes=5)) + + coordinator.hass.services.async_call.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_the_boost_ends_when_its_duration_expires(): + coordinator = _coordinator(lux_state="off") + await coordinator.async_start_dhw_boost(duration_minutes=60, now_time=NOW) + + coordinator.hass.states.get.return_value.state = "on" + coordinator.hass.services.async_call.reset_mock() + + await coordinator._apply_dhw_control(_stop_decision(), 45.0, NOW + timedelta(minutes=61)) + + coordinator.hass.services.async_call.assert_awaited_once() + assert coordinator.hass.services.async_call.await_args.args[1] == "turn_off" + assert coordinator._service_boost_until is None + + +@pytest.mark.asyncio +async def test_the_safety_abort_still_stops_a_user_boost(): + """Only safety outranks the user: deep thermal debt ends the boost, window and all.""" + coordinator = _coordinator(lux_state="on") + coordinator._service_boost_until = NOW + timedelta(minutes=60) + coordinator._lux_boost_is_ours = True + coordinator.dhw_optimizer.check_abort_conditions = MagicMock( + return_value=(True, "thermal debt DM -800") + ) + + decision = SimpleNamespace( + should_heat=True, abort_conditions=["dm"], priority_reason="USER_BOOST" + ) + await coordinator._apply_dhw_control(decision, 45.0, NOW + timedelta(minutes=5)) + + coordinator.hass.services.async_call.assert_awaited_once() + assert coordinator.hass.services.async_call.await_args.args[1] == "turn_off" + assert coordinator._service_boost_until is None + + +@pytest.mark.asyncio +async def test_a_boost_is_refused_while_optimization_is_off(): + """OFF means safety monitoring only - it does not fire the immersion heater on request.""" + coordinator = _coordinator() + coordinator.entry.data = {"enable_optimization": False} + + with pytest.raises(HomeAssistantError): + await coordinator.async_start_dhw_boost(duration_minutes=60, now_time=NOW) + + coordinator.hass.services.async_call.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_unload_cleanup_closes_the_window_too(): + coordinator = _coordinator(lux_state="on") + await coordinator.async_start_dhw_boost(duration_minutes=60, now_time=NOW) + + await coordinator._cancel_our_dhw_boost() + + assert coordinator._service_boost_until is None + assert coordinator._lux_boost_is_ours is False + + +def test_the_service_no_longer_advertises_a_temperature_it_cannot_set(): + """Temporary lux is a switch: the pump owns the temperature. services.yaml must not lie.""" + from pathlib import Path + + import yaml + + services = yaml.safe_load( + Path("custom_components/effektguard/services.yaml").read_text(encoding="utf-8") + ) + fields = services["boost_dhw"].get("fields", {}) + assert "target_temp" not in fields + assert "duration" in fields diff --git a/tests/unit/coordinator/test_an_hour_the_meter_slept_through_is_not_a_bill.py b/tests/unit/coordinator/test_an_hour_the_meter_slept_through_is_not_a_bill.py new file mode 100644 index 00000000..8e818b79 --- /dev/null +++ b/tests/unit/coordinator/test_an_hour_the_meter_slept_through_is_not_a_bill.py @@ -0,0 +1,214 @@ +"""An hour the meter mostly did not see must not be billed at all. + +When the meter goes `unavailable`, nothing is billed FROM the estimate - but the billing HOUR +used to carry on and, at close, bill whatever the meter last said before it went quiet, +stretched across the silence. A 9 kW reading at 10:00 followed by a blackout until 10:55 +became a fabricated 8.33 kW hour ((9*55 + 1*5)/60), which stands for the rest of the month +because the effect tariff bills the three highest hours - throttling the pump to defend a +number that happened in no hour. + +The guard: an hour containing a silence longer than MAX_BILLING_OBSERVATION_GAP_MINUTES is +refused. Missing a real peak is recoverable; inventing one is not. +""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock +from zoneinfo import ZoneInfo + +import pytest +from homeassistant.util import dt as dt_util + +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.const import ( + MAX_BILLING_OBSERVATION_GAP_MINUTES, + UPDATE_INTERVAL_MINUTES, +) +from custom_components.effektguard.coordinator import EffektGuardCoordinator +from custom_components.effektguard.optimization.effect_layer import EffectManager + +STOCKHOLM = ZoneInfo("Europe/Stockholm") + + +def _coordinator() -> EffektGuardCoordinator: + hass = MagicMock() + hass.config.latitude = 59.33 + hass.config.longitude = 18.07 + + nibe = MagicMock() + nibe._power_sensor_entity = "sensor.house_power" + nibe.power_sensor_entity = "sensor.house_power" + + entry = MagicMock() + entry.data = {} + entry.options = {} + + coordinator = EffektGuardCoordinator( + hass, nibe, MagicMock(), MagicMock(), MagicMock(), EffectManager(hass), entry + ) + coordinator.peak_today = 0.0 + coordinator.peak_this_month = 0.0 + coordinator._power_sensor_available = True # it HAS answered before + coordinator.effect.record_period_measurement = AsyncMock(return_value=None) + return coordinator + + +def _pump() -> NibeState: + return NibeState( + outdoor_temp=-5.0, + indoor_temp=21.0, + supply_temp=42.0, + return_temp=37.0, + degree_minutes=-150.0, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 10, 0, tzinfo=STOCKHOLM), + phase1_current=None, + ) + + +def _meter(hass, kw: float | None) -> None: + """`None` is a meter that has gone `unavailable` - which real meters do, routinely.""" + state = MagicMock() + if kw is None: + state.state = "unavailable" + state.attributes = {} + else: + state.state = str(kw) + state.attributes = {"unit_of_measurement": "kW"} + hass.states.get.return_value = state + + +async def _run_the_hour(coordinator, monkeypatch, reading_at) -> None: + """10:00 through 11:00, on the coordinator's real update cadence.""" + for minute in range(0, 60, UPDATE_INTERVAL_MINUTES): + monkeypatch.setattr( + dt_util, + "now", + lambda tz=None, _m=minute: datetime(2026, 1, 15, 10, _m, tzinfo=STOCKHOLM), + ) + _meter(coordinator.hass, reading_at(minute)) + await coordinator._update_peak_tracking(_pump()) + + # The first sample of the next hour is what closes this one. + monkeypatch.setattr( + dt_util, "now", lambda tz=None: datetime(2026, 1, 15, 11, 0, tzinfo=STOCKHOLM) + ) + _meter(coordinator.hass, 2.0) + await coordinator._update_peak_tracking(_pump()) + + +def _billed(coordinator) -> list[float]: + return [ + round(call.kwargs["power_kw"], 2) + for call in coordinator.effect.record_period_measurement.await_args_list + ] + + +@pytest.mark.asyncio +async def test_an_hour_the_meter_slept_through_is_not_billed(monkeypatch): + """The bug: 8.33 kW billed from two readings, fifty minutes of it unobserved.""" + coordinator = _coordinator() + + # 9 kW at the top of the hour. Then the meter dies until 10:55, and returns reading 1 kW. + def reading_at(minute: int) -> float | None: + if minute == 0: + return 9.0 + if minute == 55: + return 1.0 + return None + + await _run_the_hour(coordinator, monkeypatch, reading_at) + + assert _billed(coordinator) == [], ( + f"the coordinator billed {_billed(coordinator)} kW for an hour in which the meter answered " + f"twice and was `unavailable` for fifty of the sixty minutes. That figure is the 9 kW " + f"reading taken at 10:00, stretched across a blackout nobody watched. It becomes one of the " + f"month's three billed peaks, and the pump is throttled for the rest of the month to defend " + f"it. The code logs 'Peak billing is suspended until it does' ten times while doing this." + ) + + +@pytest.mark.asyncio +async def test_a_fully_observed_hour_is_still_billed(monkeypatch): + """The control. The guard must refuse blackouts, not customers.""" + coordinator = _coordinator() + + await _run_the_hour(coordinator, monkeypatch, lambda minute: 6.0) + + assert _billed(coordinator) == [6.0], ( + f"a meter that answered on every one of the twelve cycles of the hour billed " + f"{_billed(coordinator)}. A fully observed 6 kW hour is a 6 kW bill." + ) + + +@pytest.mark.asyncio +async def test_a_brief_dropout_is_tolerated(monkeypatch): + """Sensors miss a beat. That is jitter, not a blackout, and the hour was still measured. + + One missed cycle leaves a gap of 2 x UPDATE_INTERVAL_MINUTES between readings, which is inside + MAX_BILLING_OBSERVATION_GAP_MINUTES. Refusing this would throw away most real hours and buy + nothing: the reading either side of a five-minute blink is the same reading. + """ + coordinator = _coordinator() + + await _run_the_hour(coordinator, monkeypatch, lambda minute: None if minute == 25 else 6.0) + + assert _billed(coordinator) == [6.0], ( + f"a single missed update cycle threw the whole hour away ({_billed(coordinator)}). Home " + f"Assistant misses cycles routinely; a guard that discards an hour for one blink discards " + f"most of them, and the tariff record goes empty." + ) + + +@pytest.mark.asyncio +async def test_the_gap_that_is_tolerated_is_bounded_by_the_update_interval(monkeypatch): + """The threshold is a judgement, so it is pinned where it can be argued with.""" + assert ( + MAX_BILLING_OBSERVATION_GAP_MINUTES > UPDATE_INTERVAL_MINUTES + ), "the tolerated gap must exceed one update interval, or every ordinary hour is discarded" + assert ( + MAX_BILLING_OBSERVATION_GAP_MINUTES < 60 + ), "a tolerated gap of an hour or more means no hour can ever be refused, which is the bug" + + +@pytest.mark.asyncio +async def test_a_meter_that_dies_and_never_returns_does_not_bill_the_rest_of_the_hour(monkeypatch): + """The silence that runs from the last reading to the hour boundary is a gap too. + + The meter answers at 10:00 and 10:05, then stays `unavailable`. Every gap BETWEEN readings is a + healthy five minutes, so a guard that only inspects those gaps would see a well-observed hour - + but the last reading is carried across fifty-five minutes of silence to the boundary, and that + trailing span must be measured as a gap. + """ + coordinator = _coordinator() + + await _run_the_hour(coordinator, monkeypatch, lambda minute: 9.0 if minute <= 5 else None) + + assert _billed(coordinator) == [], ( + f"billed {_billed(coordinator)} for an hour whose meter answered twice - at 10:00 and 10:05 " + f"- and was `unavailable` for the remaining fifty-five minutes. The 9 kW reading was carried " + f"to the boundary and billed as though it had been watched the whole way." + ) + + +@pytest.mark.asyncio +async def test_a_long_blackout_is_refused_even_when_the_power_was_low(monkeypatch): + """It is not about the magnitude. An unobserved hour is unobserved, whatever it reads. + + A LOW reading stretched across a blackout is just as false as a high one - it simply fails + quietly, by under-recording a peak that did happen, and leaving the month unprotected. + """ + coordinator = _coordinator() + + def reading_at(minute: int) -> float | None: + return 1.0 if minute in (0, 55) else None + + await _run_the_hour(coordinator, monkeypatch, reading_at) + + assert _billed(coordinator) == [], ( + f"billed {_billed(coordinator)} for an hour the meter slept through. The house may have " + f"drawn 9 kW for fifty unwatched minutes; a 1 kW bill would leave the month undefended." + ) diff --git a/tests/unit/coordinator/test_an_unloaded_integration_does_not_drive_the_heat_pump.py b/tests/unit/coordinator/test_an_unloaded_integration_does_not_drive_the_heat_pump.py new file mode 100644 index 00000000..8bc7ae44 --- /dev/null +++ b/tests/unit/coordinator/test_an_unloaded_integration_does_not_drive_the_heat_pump.py @@ -0,0 +1,228 @@ +"""A coordinator whose entry has unloaded must not write to the heat pump. + +`_do_aligned_refresh` runs on `hass.async_create_task`, so HA cannot cancel it on unload, and it +is mid-flight for seconds while `_read_and_decide` awaits the weather forecast, the price adapter +and the learning modules. `_shutdown_requested` guarded the timer RE-ARM but not the WRITE, so an +in-flight refresh drove the pump after unload. The entry unloads on the reconfigure flow, a manual +reload, a removal or a restart (NOT on an options change, which hot-reloads) - and this stray write +can land after the reload's new coordinator, or be a deleted integration getting the last word. + +The write path now has one guarded door per actuator, and each refuses once the entry is gone. +""" + +from __future__ import annotations + +import ast +import asyncio +import pathlib +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from custom_components.effektguard.coordinator import EffektGuardCoordinator +from custom_components.effektguard.optimization.effect_layer import EffectManager + + +def _coordinator() -> EffektGuardCoordinator: + hass = MagicMock() + hass.config.latitude = 59.33 + hass.config.longitude = 18.07 + hass.config_entries.async_update_entry = MagicMock() + + nibe = MagicMock() + nibe.set_curve_offset = AsyncMock(return_value=2) + nibe.set_enhanced_ventilation = AsyncMock(return_value=True) + nibe.is_enhanced_ventilation_active = AsyncMock(return_value=False) + nibe.has_ventilation_control = False + + entry = MagicMock() + entry.data = {} + entry.options = {} + + coordinator = EffektGuardCoordinator( + hass, nibe, MagicMock(), MagicMock(), MagicMock(), EffectManager(hass), entry + ) + # Storage is not what is under test here, and HA's Store wants a real event loop executor. + coordinator.learning_store = MagicMock() + coordinator.learning_store.async_save = AsyncMock() + coordinator.effect.async_save = AsyncMock() + return coordinator + + +@pytest.mark.asyncio +async def test_a_refresh_in_flight_when_the_entry_unloads_does_not_write(): + """THE RACE, run for real: unload lands while the refresh is awaiting the weather forecast.""" + coordinator = _coordinator() + + reached_the_awaits = asyncio.Event() + let_it_finish = asyncio.Event() + + async def slow_read_and_decide(apply: bool = False, explicit_command: bool = False): + # Stands in for the real one, which awaits the weather service call, the price adapter and + # the learning modules. Seconds of awaits - and the unload lands in the middle of them. + # + # It reaches the pump the way the real one does, through `_write_curve_offset`. Calling + # `nibe.set_curve_offset` directly here would be testing a code path production no longer + # has - and the assertion is on the ADAPTER, so nothing about the guard is assumed: the + # question is only whether the heat pump was touched. + reached_the_awaits.set() + await let_it_finish.wait() + if apply: + await coordinator._write_curve_offset(2.0) + return {} + + with patch.object(coordinator, "_read_and_decide", slow_read_and_decide): + refresh = asyncio.create_task(coordinator._do_aligned_refresh()) + await reached_the_awaits.wait() + + # The user swaps the power meter in the reconfigure flow. The entry unloads. + await coordinator.async_shutdown() + assert coordinator._shutdown_requested is True + + # Home Assistant cannot cancel this task - the coordinator's own comment says so. + let_it_finish.set() + await refresh + + assert coordinator.nibe.set_curve_offset.await_count == 0, ( + f"the coordinator wrote to the heat pump {coordinator.nibe.set_curve_offset.await_count} " + f"time(s) AFTER the entry was unloaded: " + f"{coordinator.nibe.set_curve_offset.await_args_list}. The entry unloads on the reconfigure " + f"flow, a manual reload, a removal or a restart - and this write can land after the NEW " + f"coordinator's, leaving the pump on a decision computed by a coordinator built from the " + f"entities the user has just replaced. On a removal, it is the deleted integration getting " + f"the last word on the heat pump." + ) + + +@pytest.mark.asyncio +async def test_a_live_coordinator_still_writes(): + """The control. The guard must refuse dead coordinators, not working ones.""" + coordinator = _coordinator() + + async def read_and_decide(apply: bool = False, explicit_command: bool = False): + if apply: + await coordinator._write_curve_offset(2.0) + return {} + + with patch.object(coordinator, "_read_and_decide", read_and_decide): + await coordinator._do_aligned_refresh() + + assert coordinator.nibe.set_curve_offset.await_count == 1, ( + "a running coordinator must drive the pump - that is the whole job. The shutdown guard " + "must not be reachable while the entry is loaded." + ) + + +@pytest.mark.asyncio +async def test_switching_optimization_off_after_unload_does_not_write(): + """The other write path. `set_optimization_enabled(False)` resets the offset to neutral. + + It is a user command and perfectly legitimate while the entry is loaded - but if it is in flight + when the entry unloads, it reaches the pump from a dead coordinator exactly as the control loop + does. One guarded way to the pump, not two. + """ + coordinator = _coordinator() + await coordinator.async_shutdown() + + await coordinator.set_optimization_enabled(False) + + assert coordinator.nibe.set_curve_offset.await_count == 0, ( + f"a shut-down coordinator reset the pump's offset to neutral " + f"({coordinator.nibe.set_curve_offset.await_args_list}). The entry is gone; it has no " + f"business writing anything." + ) + + +@pytest.mark.asyncio +async def test_switching_optimization_off_forces_neutral_through_cooldown(): + """OFF is a safety transition, not an ordinary rate-limited adjustment.""" + coordinator = _coordinator() + coordinator.nibe.set_curve_offset = AsyncMock(return_value=0) + + await coordinator.set_optimization_enabled(False) + + coordinator.nibe.set_curve_offset.assert_awaited_once_with(0.0, force_write=True) + assert coordinator.last_applied_offset == 0.0 + disabled_data = coordinator.hass.config_entries.async_update_entry.call_args.kwargs["data"] + assert disabled_data["enable_optimization"] is False + + +@pytest.mark.asyncio +async def test_an_unloaded_coordinator_does_not_command_the_fan_either(): + """The heating curve is not the only thing this integration writes to the pump. + + `set_enhanced_ventilation` raises the exhaust fan on an F750/F730 from the control loop, so it + rides the same in-flight refresh and the same race. On a reload the old coordinator can switch + the fan ON while the new one starts up believing it is off, leaving it running with nothing left + to turn it off. + """ + coordinator = _coordinator() + coordinator.nibe.set_enhanced_ventilation = AsyncMock(return_value=True) + await coordinator.async_shutdown() + + wrote = await coordinator._write_enhanced_ventilation(True) + + assert wrote is False + assert coordinator.nibe.set_enhanced_ventilation.await_count == 0, ( + "a shut-down coordinator switched enhanced ventilation on. The entry is unloaded; the fan " + "is not its to command, and nothing is left to switch it off again." + ) + + +def test_there_is_exactly_one_door_to_each_thing_the_pump_can_be_told(): + """A structural guard, and it is the one that keeps the others honest. + + The tests above prove the guarded doors refuse a dead coordinator; they cannot prove nobody has + cut a NEW door beside them. So: every `self.nibe.set_*` call in the coordinator must live inside + a `_write_*` method - the only places that ask whether the entry is still loaded. A new way to + command the pump either routes through one of them or changes this test in a reviewed diff. + """ + source = pathlib.Path("custom_components/effektguard/coordinator.py").read_text() + tree = ast.parse(source) + + # A LIST of (command, the method that issues it), not a dict keyed by the command. + # + # The first version of this collected `doors[command] = enclosing_method`, and a mutation test + # walked straight through it: a second `self.nibe.set_enhanced_ventilation(...)` in the airflow + # loop simply OVERWROTE the dict entry, so two doors looked exactly like one. A container that + # silently collapses duplicates cannot count duplicates - which is the whole job here. + doors: list[tuple[str, str]] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.AsyncFunctionDef): + continue + for inner in ast.walk(node): + if ( + isinstance(inner, ast.Call) + and isinstance(inner.func, ast.Attribute) + and isinstance(inner.func.value, ast.Attribute) + and inner.func.value.attr == "nibe" + and inner.func.attr.startswith("set_") + ): + doors.append((inner.func.attr, node.name)) + + assert sorted(doors) == [ + ("set_curve_offset", "_write_curve_offset"), + ("set_enhanced_ventilation", "_write_enhanced_ventilation"), + ], ( + f"the heat pump is commanded from {sorted(doors)}. Every write must go through a `_write_*` " + f"method - exactly once - because those are the only ones that ask whether the entry is " + f"still loaded. A door that bypasses them is how an unloaded integration gets the last word " + f"on somebody's heating." + ) + + +@pytest.mark.asyncio +async def test_the_shutdown_flag_is_actually_consulted_on_the_write_path(): + """A structural guard, because the flag existed and was simply never read here. + + `_shutdown_requested` was checked in two places - the code that re-arms the timer, and the code + that sets it - and in neither of the two places that drive the heat pump. The bug was not a wrong + value; it was a value nobody asked for. + """ + coordinator = _coordinator() + await coordinator.async_shutdown() + + written = await coordinator._write_curve_offset(3.0) + + assert written is None + assert coordinator.nibe.set_curve_offset.await_count == 0 diff --git a/tests/unit/coordinator/test_effect_layer_uses_current_power.py b/tests/unit/coordinator/test_effect_layer_uses_current_power.py new file mode 100644 index 00000000..60302b60 --- /dev/null +++ b/tests/unit/coordinator/test_effect_layer_uses_current_power.py @@ -0,0 +1,34 @@ +"""The coordinator must feed the decision engine live power, never the daily peak. + +`peak_today` is a daily high-water mark that only ratchets up until the midnight reset. +Feeding it to the engine as "current power" let one unrelated household spike (an oven, a +kettle, an EV charger) pin the effect layer to CRITICAL (weight 1.0, offset -3.0 C) for the +rest of the day, regardless of what the heat pump was drawing. The engine must instead +receive the live reading PROJECTED over the billing hour, because the monthly record it is +compared against is an hourly mean. +""" + +import inspect + + +class TestCoordinatorPowerContract: + """The coordinator must feed the engine live power, not the daily maximum.""" + + def test_decision_path_does_not_consume_peak_today(self): + """`peak_today` (a daily maximum) and `current_power_kw` (the live reading the effect + layer consumes) are different quantities and must not be aliased. + """ + from custom_components.effektguard.coordinator import EffektGuardCoordinator + + update_src = inspect.getsource(EffektGuardCoordinator._read_and_decide) + + assert "current_power_for_decision = self.peak_today" not in update_src, ( + "The decision engine is being fed peak_today (a daily MAXIMUM) as current power. " + "One morning spike would pin the effect layer to CRITICAL until midnight." + ) + assert "projected_hour_mean" in update_src and "self.current_power_kw" in update_src, ( + "The decision engine must be fed the live reading PROJECTED over the billing hour " + "- the monthly record it is compared against is an hourly mean, so an instantaneous " + "spike is not the same quantity. See " + "tests/unit/optimization/test_peak_protection_compares_like_with_like.py." + ) diff --git a/tests/unit/coordinator/test_hot_water_optimization_says_when_it_is_not_running.py b/tests/unit/coordinator/test_hot_water_optimization_says_when_it_is_not_running.py new file mode 100644 index 00000000..7e1b49c3 --- /dev/null +++ b/tests/unit/coordinator/test_hot_water_optimization_says_when_it_is_not_running.py @@ -0,0 +1,109 @@ +"""On an S-series pump, hot-water optimisation must raise a repair issue, not fail in a debug line. + +EffektGuard drives DHW via NIBE's temporary-lux switch (register 50004), which Home Assistant's +NIBE integration maps for the F-SERIES ONLY. On an S-series pump no such entity exists, so the +whole DHW feature silently does nothing while the UI still shows a hot-water status, a +recommendation and a scheduled start time that can never fire. A _LOGGER.debug is not telling +anyone - so this now raises the same kind of repair issue the missing price source does (F-123). +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from custom_components.effektguard.const import DHW_CONTROL_ISSUE_ID, DOMAIN +from custom_components.effektguard.coordinator import EffektGuardCoordinator + +NOW = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc) + + +def _coordinator(lux_entity: str | None) -> EffektGuardCoordinator: + coordinator = EffektGuardCoordinator.__new__(EffektGuardCoordinator) + coordinator.hass = MagicMock() + coordinator.hass.services.async_call = AsyncMock() + coordinator.temp_lux_entity = lux_entity + coordinator._dhw_issue_active = False + coordinator._lux_boost_is_ours = False + coordinator._service_boost_until = None + coordinator._last_dhw_control_time = None + # `__new__` skips `__init__`, so anything the real object always carries has to be set here or + # the fake is not the object. Home Assistant's DataUpdateCoordinator.__init__ sets this, and the + # hot-water switch door reads it: a coordinator whose entry has unloaded does not start a boost. + coordinator._shutdown_requested = False + coordinator.last_update_success = True + coordinator.data = {} + coordinator.entry = MagicMock() + coordinator.entry.data = {"enable_hot_water_optimization": True} + coordinator.entry.options = {} + + state = MagicMock() + state.state = "off" + coordinator.hass.states.get.return_value = state + return coordinator + + +def _decision(): + decision = MagicMock() + decision.should_heat = True + decision.priority_reason = "cheap window" + return decision + + +@pytest.mark.asyncio +async def test_an_s_series_pump_raises_a_repair_issue(): + coordinator = _coordinator(lux_entity=None) + + with patch("custom_components.effektguard.coordinator.async_create_issue") as create_issue: + await coordinator._apply_dhw_control(_decision(), current_dhw_temp=45.0, now_time=NOW) + + create_issue.assert_called_once() + args, kwargs = create_issue.call_args + assert args[1] == DOMAIN + assert args[2] == DHW_CONTROL_ISSUE_ID + assert kwargs["translation_key"] == DHW_CONTROL_ISSUE_ID + + +@pytest.mark.asyncio +async def test_the_issue_is_raised_once_not_on_every_cycle(): + """The coordinator ticks every five minutes. Do not re-raise it 288 times a day.""" + coordinator = _coordinator(lux_entity=None) + + with patch("custom_components.effektguard.coordinator.async_create_issue") as create_issue: + for _ in range(5): + await coordinator._apply_dhw_control(_decision(), current_dhw_temp=45.0, now_time=NOW) + + assert create_issue.call_count == 1 + + +@pytest.mark.asyncio +async def test_a_pump_that_has_the_switch_clears_the_issue(): + """An F-series pump must not be nagged - and a stale issue from a restart must be cleared.""" + coordinator = _coordinator(lux_entity="switch.temporary_lux_50004") + + with ( + patch("custom_components.effektguard.coordinator.async_delete_issue") as delete_issue, + patch("custom_components.effektguard.coordinator.async_create_issue") as create_issue, + ): + await coordinator._apply_dhw_control(_decision(), current_dhw_temp=45.0, now_time=NOW) + + create_issue.assert_not_called() + delete_issue.assert_called_once() + + +@pytest.mark.asyncio +async def test_the_f_series_pump_still_actually_controls_hot_water(): + """The regression guard: raising an issue must not break the pumps that work.""" + coordinator = _coordinator(lux_entity="switch.temporary_lux_50004") + + with patch("custom_components.effektguard.coordinator.async_delete_issue"): + await coordinator._apply_dhw_control(_decision(), current_dhw_temp=45.0, now_time=NOW) + + turn_ons = [ + call + for call in coordinator.hass.services.async_call.await_args_list + if call.args[:2] == ("homeassistant", "turn_on") + ] + assert turn_ons, "an F-series pump with a cheap window must still get its hot-water boost" diff --git a/tests/unit/coordinator/test_notifications_use_an_api_that_exists.py b/tests/unit/coordinator/test_notifications_use_an_api_that_exists.py new file mode 100644 index 00000000..5ca15781 --- /dev/null +++ b/tests/unit/coordinator/test_notifications_use_an_api_that_exists.py @@ -0,0 +1,45 @@ +"""`hass.components` was removed from Home Assistant; the coordinator must not call it. + +The removed API raises AttributeError, and the `# type: ignore[attr-defined]` on the old call +claimed - falsely - that it was a type-stubs gap. The supported replacement is +`homeassistant.components.persistent_notification.async_create(hass, ...)`, imported at module +top, and these tests read the coordinator source to hold that fix in place. A MagicMock `hass` +answers `hass.components...` cheerfully, so the unit suite could never catch this by mocking. +""" + +from __future__ import annotations + +import inspect +from pathlib import Path + +from custom_components.effektguard.coordinator import EffektGuardCoordinator + +COORDINATOR_SOURCE = Path(inspect.getfile(EffektGuardCoordinator)).read_text(encoding="utf-8") + +# The source with comments stripped. Checked against the CODE, not against prose about the code - +# otherwise a comment explaining the removed API would trip the very test that forbids it. +CODE_ONLY = "\n".join( + line.split("#", 1)[0] + for line in COORDINATOR_SOURCE.splitlines() + if not line.lstrip().startswith("#") +) + + +def test_the_coordinator_does_not_call_a_removed_api(): + """The defect, read straight out of the source.""" + assert "hass.components" not in CODE_ONLY, ( + "coordinator.py calls `hass.components`, which Home Assistant has removed. It raises " + "AttributeError, and the `# type: ignore[attr-defined]` on that line hides a real error " + "behind a comment claiming it is a type-stubs gap. It is not." + ) + + +def test_persistent_notification_is_imported_at_module_top(): + """The project's own rule, and the fix: import the real API, at the top, like everything else.""" + assert "from homeassistant.components.persistent_notification import async_create" in ( + COORDINATOR_SOURCE + ), ( + "The supported way to raise a notification is " + "`homeassistant.components.persistent_notification.async_create(hass, ...)`, imported at " + "module top." + ) diff --git a/tests/unit/coordinator/test_one_writer_at_a_time.py b/tests/unit/coordinator/test_one_writer_at_a_time.py new file mode 100644 index 00000000..09c35fa4 --- /dev/null +++ b/tests/unit/coordinator/test_one_writer_at_a_time.py @@ -0,0 +1,155 @@ +"""Two things may drive the heat pump. They must never drive it at once. + +The write path has two entry points - the aligned control loop, and a service that explicitly +commands the pump - and both are long coroutines that await at every step, so asyncio interleaves +them freely. Without a lock, an aligned refresh that snapshotted the engine before a concurrent +force_offset(+3) can finish afterwards and overwrite it with a stale +0.5; the same interleaving +corrupts _apply_offset's rate limiting, which reads last_offset_timestamp and then writes it. + +One writer at a time, via the control lock. Reads are unaffected: they are free to overlap, and do. +""" + +from __future__ import annotations + +import asyncio +import ast +import inspect +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.effektguard.coordinator import EffektGuardCoordinator + + +def _make_minimal_hass() -> MagicMock: + hass = MagicMock() + hass.data = {} + hass.config = MagicMock() + hass.config.latitude = 59.3 + hass.config.config_dir = "/tmp/test" + hass.loop = MagicMock() + hass.loop.call_soon_threadsafe = MagicMock() + hass.async_add_executor_job = AsyncMock(side_effect=lambda func, *args: func(*args)) + hass.async_create_task = MagicMock() + return hass + + +def _make_minimal_entry() -> MagicMock: + entry = MagicMock() + entry.data = MagicMock() + entry.data.get.side_effect = lambda key, default=None: default + entry.options = MagicMock() + entry.options.get.side_effect = lambda key, default=None: default + return entry + + +def _make_coordinator() -> EffektGuardCoordinator: + return EffektGuardCoordinator( + hass=_make_minimal_hass(), + nibe_adapter=MagicMock(), + gespot_adapter=MagicMock(), + weather_adapter=MagicMock(), + decision_engine=MagicMock(), + effect_manager=MagicMock(), + entry=_make_minimal_entry(), + ) + + +@pytest.mark.asyncio +async def test_the_control_loop_and_a_service_never_write_together(monkeypatch): + """The two writers, launched together. They must take the pump in turns.""" + coordinator = _make_coordinator() + + in_flight = 0 + overlapped = False + + async def slow_cycle( + apply: bool, + explicit_command: bool = False, + ) -> dict[str, object]: + """Stand-in for the real read-decide-write cycle, which awaits at every step.""" + nonlocal in_flight, overlapped + in_flight += 1 + if in_flight > 1: + overlapped = True + await asyncio.sleep(0) # asyncio's chance to interleave, exactly as the real body gives it + in_flight -= 1 + return {"applied": apply} + + monkeypatch.setattr(coordinator, "_read_and_decide", slow_cycle) + monkeypatch.setattr(coordinator, "async_set_updated_data", MagicMock()) + monkeypatch.setattr(coordinator, "_schedule_aligned_refresh", MagicMock()) + + await asyncio.gather( + coordinator._do_aligned_refresh(), # the control loop + coordinator.async_refresh_and_apply(), # a service commanding the pump + ) + + assert not overlapped, ( + "The aligned control loop and a service were both driving the heat pump at the same " + "moment. Whichever decision finishes last wins - and that may be the OLDER one, computed " + "before the user's force_offset override even existed. The forced offset is silently " + "overwritten, and _apply_offset's rate limiting reads state another writer is changing." + ) + + +@pytest.mark.asyncio +async def test_reads_are_still_free_to_overlap(monkeypatch): + """The lock guards the pump, not the sensors. Serialising reads would be a needless stall.""" + coordinator = _make_coordinator() + + started = asyncio.Event() + release = asyncio.Event() + + async def blocking_cycle( + apply: bool, + explicit_command: bool = False, + ) -> dict[str, object]: + started.set() + await release.wait() + return {} + + monkeypatch.setattr(coordinator, "_read_and_decide", blocking_cycle) + monkeypatch.setattr(coordinator, "async_set_updated_data", MagicMock()) + monkeypatch.setattr(coordinator, "_schedule_aligned_refresh", MagicMock()) + + writer = asyncio.create_task(coordinator.async_refresh_and_apply()) + await started.wait() # the writer now holds whatever it holds + + # A read must not be stuck behind it. HA calls this hook on its own schedule and on reload; + # blocking it on a write in progress would stall the entities for no reason. + monkeypatch.setattr(coordinator, "_read_and_decide", AsyncMock(return_value={})) + await asyncio.wait_for(coordinator._async_update_data(), timeout=1.0) + + release.set() + await writer + + +def test_nothing_can_write_without_taking_the_lock(): + """Structural: `apply=True` exists in exactly one place, and that place holds the lock. + + The behavioural test above proves the two callers we have today serialise. This one keeps the + next caller honest - a third `_read_and_decide(apply=True)` added elsewhere would reintroduce + the race in a way no existing test would notice. + """ + source = inspect.getsource(EffektGuardCoordinator) + tree = ast.parse(source) + writers = sum( + 1 + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "_read_and_decide" + and any( + keyword.arg == "apply" + and isinstance(keyword.value, ast.Constant) + and keyword.value.value is True + for keyword in node.keywords + ) + ) + + assert writers == 1, ( + f"`_read_and_decide(apply=True)` is called from {writers} places. The write path must have " + f"exactly one owner, and that owner must hold the control lock. Route new writers through " + f"it rather than calling the cycle directly." + ) diff --git a/tests/unit/coordinator/test_only_the_grid_meter_can_set_a_billing_peak.py b/tests/unit/coordinator/test_only_the_grid_meter_can_set_a_billing_peak.py new file mode 100644 index 00000000..0522c5dc --- /dev/null +++ b/tests/unit/coordinator/test_only_the_grid_meter_can_set_a_billing_peak.py @@ -0,0 +1,215 @@ +"""Only a whole-house meter reading can become a billing peak. + +Two things were once recorded against the tariff that the grid did not deliver: + +- NIBE phase currents (BE1/BE2/BE3) measure the heat pump only - not the oven, EV charger or kettle + - yet were accepted as a whole-house billing measurement. They are now control-grade, not billable: + available to the decision layers (which want a magnitude), never reported as the month's bill. + +- A solar "smart fallback" substituted an ESTIMATED compressor power when a grid-import meter read + under 0.5 kW while the compressor ran hard, then billed the estimate (~5.5 kW where the grid + imported 0.3 kW). The operator bills grid import, which is exactly what the meter saw. The fallback + is gone: the meter reading is the truth. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.util import dt as dt_util + +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.const import ( + BILLABLE_POWER_SOURCES, + POWER_SOURCE_EXTERNAL_METER, + POWER_SOURCE_NIBE_CURRENTS, +) +from custom_components.effektguard.coordinator import EffektGuardCoordinator +from custom_components.effektguard.optimization.effect_layer import EffectManager + + +def _coordinator(power_entity: str | None) -> EffektGuardCoordinator: + hass = MagicMock() + hass.config.latitude = 59.33 + hass.config.longitude = 18.07 + + nibe = MagicMock() + nibe._power_sensor_entity = power_entity + nibe.power_sensor_entity = power_entity + nibe.calculate_power_from_currents.side_effect = lambda p1, p2, p3: ( + 240 * (p1 + (p2 or 0) + (p3 or 0)) * 0.95 / 1000 if p1 is not None else None + ) + + entry = MagicMock() + entry.data = {} + entry.options = {} + + coordinator = EffektGuardCoordinator( + hass, nibe, MagicMock(), MagicMock(), MagicMock(), EffectManager(hass), entry + ) + coordinator.peak_today = 0.0 + coordinator.peak_this_month = 0.0 + coordinator._power_sensor_available = True + coordinator.effect.record_period_measurement = AsyncMock(return_value=None) + return coordinator + + +def _meter(hass, value: str, unit: str = "W") -> None: + state = MagicMock() + state.state = value + state.attributes = {"unit_of_measurement": unit} + hass.states.get.return_value = state + + +def _pump(compressor_hz: int = 0, currents: float | None = None) -> NibeState: + return NibeState( + outdoor_temp=-5.0, + indoor_temp=21.0, + supply_temp=42.0, + return_temp=37.0, + degree_minutes=-150.0, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc), + phase1_current=currents, + phase2_current=currents, + phase3_current=currents, + compressor_hz=compressor_hz, + ) + + +async def _run_a_complete_billing_hour(coordinator, nibe_data, monkeypatch) -> None: + """Samples through a whole HOUR, because that is the tariff's billing period.""" + for hour, minute in [(10, m) for m in range(0, 60, 5)] + [(11, 0)]: + monkeypatch.setattr( + dt_util, + "now", + lambda tz=None, hour=hour, minute=minute: datetime( + 2026, 1, 15, hour, minute, tzinfo=timezone.utc + ), + ) + await coordinator._update_peak_tracking(nibe_data) + + +def test_only_a_whole_house_meter_is_billable(): + """The rule, stated once, where both the recorder and the reporting read it.""" + assert BILLABLE_POWER_SOURCES == frozenset({POWER_SOURCE_EXTERNAL_METER}), ( + f"BILLABLE_POWER_SOURCES is {sorted(BILLABLE_POWER_SOURCES)}. The Swedish effect tariff bills " + f"whole-house grid import. Only a whole-house meter measures that." + ) + + +@pytest.mark.asyncio +async def test_nibe_phase_currents_still_drive_peak_protection(monkeypatch): + """NOT BILLABLE and NOT RECORDED are different things; conflating them would break the feature. + + A house without a whole-house meter must still record NIBE-currents peaks - gating recording on + billability would leave `should_limit_power` with an empty history, and peak protection would + never fire. `should_limit_power` compares this quarter against the month's own recorded peaks, so + a NIBE-only history against NIBE-only power is self-consistent and still throttles the pump. That + number must never be reported to the owner as the month's BILLING peak. + """ + coordinator = _coordinator(power_entity=None) # no whole-house meter, only NIBE currents + + await _run_a_complete_billing_hour( + coordinator, _pump(compressor_hz=60, currents=10.0), monkeypatch + ) + + coordinator.effect.record_period_measurement.assert_awaited_once() + recorded = coordinator.effect.record_period_measurement.await_args.kwargs + + assert recorded["source"] == POWER_SOURCE_NIBE_CURRENTS, ( + f"The peak was recorded as {recorded['source']!r}. It must carry its provenance, because " + f"that is the only thing standing between a pump-only measurement and a billing figure." + ) + assert coordinator.peak_today_source == POWER_SOURCE_NIBE_CURRENTS + assert coordinator.peak_today > 0.0 + + +@pytest.mark.asyncio +async def test_a_nibe_currents_peak_is_never_billable(monkeypatch): + """It drives control. It is not the bill. The PeakEvent itself has to know the difference.""" + from custom_components.effektguard.optimization.effect_layer import PeakEvent + + from_currents = PeakEvent( + timestamp=datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc), + period_of_day=40, + actual_power=6.8, + effective_power=6.8, + is_daytime=True, + source=POWER_SOURCE_NIBE_CURRENTS, + ) + from_meter = PeakEvent( + timestamp=datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc), + period_of_day=40, + actual_power=6.8, + effective_power=6.8, + is_daytime=True, + source=POWER_SOURCE_EXTERNAL_METER, + ) + + assert not from_currents.is_billable, ( + "A peak measured from the pump's own phase currents was marked billable. BE1/BE2/BE3 " + "measure the heat pump - not the oven, not the EV charger. The tariff bills the house." + ) + assert from_meter.is_billable + + # And it must survive a round-trip through storage, or the distinction is lost on the next + # Home Assistant restart - which is exactly when nobody is watching. + assert PeakEvent.from_dict(from_currents.to_dict()).is_billable is False + assert PeakEvent.from_dict(from_meter.to_dict()).is_billable is True + + +@pytest.mark.asyncio +async def test_an_estimate_drives_nothing_at_all(monkeypatch): + """Compressor-Hz estimates are excluded from BOTH. A guess must not throttle a house.""" + coordinator = _coordinator(power_entity=None) + + # No meter, no phase currents: PRIORITY 3 falls through to a compressor-Hz estimate. + await _run_a_complete_billing_hour( + coordinator, _pump(compressor_hz=60, currents=None), monkeypatch + ) + + coordinator.effect.record_period_measurement.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_a_meter_masked_by_solar_bills_what_the_grid_actually_delivered(monkeypatch): + """The owner's rule: if solar covers everything but 0.5 kW, count 0.5 kW. + + Compressor running hard at 60 Hz, meter reading 500 W because the panels are covering the rest. + The import was 0.5 kW. The bill will be for 0.5 kW. So the record must say 0.5 kW. + """ + coordinator = _coordinator(power_entity="sensor.house_power") + _meter(coordinator.hass, "500") # 500 W of grid import behind solar + + await _run_a_complete_billing_hour(coordinator, _pump(compressor_hz=60), monkeypatch) + + coordinator.effect.record_period_measurement.assert_awaited_once() + recorded = coordinator.effect.record_period_measurement.await_args.kwargs + + assert recorded["power_kw"] == pytest.approx(0.5), ( + f"The grid delivered 0.5 kW and {recorded['power_kw']:.2f} kW was recorded against the " + f"tariff. The old 'smart fallback' replaced the meter reading with an ESTIMATE of what the " + f"compressor was drawing (~5.5 kW) on the theory that solar was masking the meter. But the " + f"operator bills grid import, and the import is exactly what the meter saw. The substitution " + f"inflated the month's peak by an order of magnitude, in the owner's disfavour." + ) + assert coordinator.peak_today == pytest.approx(0.5) + assert coordinator.peak_today_source == POWER_SOURCE_EXTERNAL_METER + + +@pytest.mark.asyncio +async def test_a_working_meter_still_bills(monkeypatch): + """The regression guard. Whole-house meter, ordinary reading, must still be recorded.""" + coordinator = _coordinator(power_entity="sensor.house_power") + _meter(coordinator.hass, "4200") + + await _run_a_complete_billing_hour(coordinator, _pump(compressor_hz=60), monkeypatch) + + coordinator.effect.record_period_measurement.assert_awaited_once() + recorded = coordinator.effect.record_period_measurement.await_args.kwargs + assert recorded["power_kw"] == pytest.approx(4.2) diff --git a/tests/unit/coordinator/test_our_hot_water_boost_does_not_outlive_us.py b/tests/unit/coordinator/test_our_hot_water_boost_does_not_outlive_us.py new file mode 100644 index 00000000..90dd62aa --- /dev/null +++ b/tests/unit/coordinator/test_our_hot_water_boost_does_not_outlive_us.py @@ -0,0 +1,128 @@ +"""A hot-water boost EffektGuard started must be cancelled on unload, not left running. + +EffektGuard drives DHW by turning NIBE's temporary-lux switch ON, and turns it OFF on the tick +that decides the cycle is done - but nothing turned it off on UNLOAD. A reload or restart mid-boost +left the pump running to NIBE's own timeout with nothing alive to stop it: a full high-temperature +cycle nobody asked for. Only OUR boost is cancelled; one the owner started from the pump panel or +their own automation is left alone. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from custom_components.effektguard.coordinator import EffektGuardCoordinator + +LUX = "switch.temporary_lux_50004" + + +def _coordinator(lux_state: str | None, boost_is_ours: bool) -> EffektGuardCoordinator: + """A real coordinator with __init__ bypassed - only what the shutdown path touches.""" + coordinator = EffektGuardCoordinator.__new__(EffektGuardCoordinator) + coordinator._shutdown_requested = False + coordinator._unsub_aligned_refresh = None + coordinator._power_sensor_listener = None + coordinator.adaptive_learning = None + coordinator.thermal_predictor = None + coordinator.weather_learner = None + coordinator.effect = MagicMock() + coordinator.effect.async_save = AsyncMock() + coordinator._save_learned_data = AsyncMock() + coordinator._clock_aligned = True + + coordinator.temp_lux_entity = LUX + coordinator._lux_boost_is_ours = boost_is_ours + + coordinator.hass = MagicMock() + coordinator.hass.services.async_call = AsyncMock() + if lux_state is None: + coordinator.hass.states.get.return_value = None + else: + state = MagicMock() + state.state = lux_state + coordinator.hass.states.get.return_value = state + + return coordinator + + +async def _unload(coordinator, monkeypatch) -> None: + async def fake_base_shutdown(self) -> None: + self._shutdown_requested = True + + monkeypatch.setattr(DataUpdateCoordinator, "async_shutdown", fake_base_shutdown) + await coordinator.async_shutdown() + + +def _turn_off_calls(coordinator) -> list: + return [ + call + for call in coordinator.hass.services.async_call.await_args_list + if call.args[:2] == ("homeassistant", "turn_off") + ] + + +@pytest.mark.asyncio +async def test_our_own_boost_is_cancelled_on_unload(monkeypatch): + coordinator = _coordinator(lux_state=STATE_ON, boost_is_ours=True) + + await _unload(coordinator, monkeypatch) + + calls = _turn_off_calls(coordinator) + assert calls, ( + "EffektGuard unloaded while a hot-water boost IT had started was still running, and did " + "not turn it off. The pump runs that boost to NIBE's own timeout with nothing left alive " + "to stop it - a full high-temperature DHW cycle nobody asked for, heated at the top of the " + "tank where the immersion heater does the work." + ) + assert calls[0].args[2] == {"entity_id": LUX} + + +@pytest.mark.asyncio +async def test_a_boost_the_owner_started_is_left_alone(monkeypatch): + """The switch is ON, but it was not us. Turning it off would be overriding the owner.""" + coordinator = _coordinator(lux_state=STATE_ON, boost_is_ours=False) + + await _unload(coordinator, monkeypatch) + + assert not _turn_off_calls(coordinator), ( + "EffektGuard turned off a temporary-lux boost it did not start. The owner may run one from " + "the heat pump's own panel or from their own automation, and unloading EffektGuard must " + "not cancel their hot water." + ) + + +@pytest.mark.asyncio +async def test_nothing_is_written_when_the_boost_has_already_finished(monkeypatch): + """Ours, but NIBE already timed it out. Do not write for the sake of writing.""" + coordinator = _coordinator(lux_state=STATE_OFF, boost_is_ours=True) + + await _unload(coordinator, monkeypatch) + + assert not _turn_off_calls(coordinator) + assert coordinator._lux_boost_is_ours is False + + +@pytest.mark.asyncio +async def test_a_pump_with_no_lux_switch_unloads_cleanly(monkeypatch): + """An S1155 exposes no temporary-lux entity at all. Unload must not raise.""" + coordinator = _coordinator(lux_state=None, boost_is_ours=False) + coordinator.temp_lux_entity = None + + await _unload(coordinator, monkeypatch) + + assert not _turn_off_calls(coordinator) + + +@pytest.mark.asyncio +async def test_the_rest_of_shutdown_still_runs(monkeypatch): + """The regression guard: cancelling the boost must not skip saving state.""" + coordinator = _coordinator(lux_state=STATE_ON, boost_is_ours=True) + + await _unload(coordinator, monkeypatch) + + coordinator.effect.async_save.assert_awaited_once() + assert coordinator._shutdown_requested is True diff --git a/tests/unit/coordinator/test_savings_are_not_computed_from_a_guess.py b/tests/unit/coordinator/test_savings_are_not_computed_from_a_guess.py new file mode 100644 index 00000000..34e239d9 --- /dev/null +++ b/tests/unit/coordinator/test_savings_are_not_computed_from_a_guess.py @@ -0,0 +1,153 @@ +"""A savings figure is money, and must not be computed from an estimated power reading. + +`NibeState.power_kw` is filled by `get_power_consumption()`, which falls back to a temperature +curve fit when no power sensor is configured - a guess in the same field as a measurement, clamped +to never read below 1.0 kW even with the compressor off. The coordinator fed that into +`_daily_spot_savings`, which the owner reads as kronor: a savings report every day derived from a +formula that never saw a watt. + +The estimate stays available to layers that want a rough magnitude, but it must now carry a +`power_is_estimated` flag, and anything that reports or bills money must ask it first. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock + +import pytest +from homeassistant.util import dt as dt_util + +from custom_components.effektguard.adapters.gespot_adapter import PriceData, QuarterPeriod +from custom_components.effektguard.adapters.nibe_adapter import NibeAdapter, NibeState + + +def _nibe(power_kw: float, estimated: bool) -> NibeState: + return NibeState( + outdoor_temp=-5.0, + indoor_temp=21.0, + supply_temp=42.0, + return_temp=37.0, + degree_minutes=-150.0, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=dt_util.utcnow(), + power_kw=power_kw, + power_is_estimated=estimated, + ) + + +@pytest.fixture +def coordinator_for_savings(): + """A coordinator with a real savings calculator and a SEK price unit.""" + from custom_components.effektguard.coordinator import EffektGuardCoordinator + from custom_components.effektguard.optimization.effect_layer import EffectManager + + hass = MagicMock() + hass.config.latitude = 59.33 + hass.config.longitude = 18.07 + + gespot = MagicMock() + gespot.price_unit = "SEK/kWh" + + entry = MagicMock() + entry.data = {} + entry.options = {} + + coordinator = EffektGuardCoordinator( + hass, MagicMock(), gespot, MagicMock(), MagicMock(), EffectManager(hass), entry + ) + coordinator._daily_spot_savings = 0.0 + return coordinator + + +def _a_day_of_prices() -> PriceData: + """A day with a genuinely cheap current quarter, so savings would be non-zero if computed.""" + midnight = dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) + today = [ + QuarterPeriod(start_time=midnight + timedelta(minutes=15 * q), price=100.0) + for q in range(96) + ] + now_index = PriceData(today=today, tomorrow=[], has_tomorrow=False).get_period_index( + dt_util.now() + ) + assert now_index is not None, "precondition: some quarter must contain 'now'" + today[now_index] = QuarterPeriod(start_time=today[now_index].start_time, price=1.0) + return PriceData(today=today, tomorrow=[], has_tomorrow=False) + + +@pytest.mark.asyncio +async def test_an_estimate_is_marked_as_one(): + """The adapter must say which it gave you. + + No power sensor is configured, so the only thing left is the temperature curve fit. + """ + state = MagicMock() + state.state = "40.0" + state.last_reported = dt_util.utcnow() + state.last_updated = dt_util.utcnow() + + hass = MagicMock() + hass.states.get.return_value = state + + adapter = NibeAdapter(hass, {"nibe_entity": "number.offset"}) + adapter._entity_cache = {"supply_temp": "sensor.supply", "outdoor_temp": "sensor.outdoor"} + + power, estimated = await adapter.get_power_consumption() + + assert power is not None, "precondition: the temperature fallback should produce a number" + assert estimated is True, ( + f"get_power_consumption() returned {power:.2f} kW derived from supply and outdoor " + f"temperature and reported it as a measurement. Nothing downstream can now tell it from a " + f"reading off a real meter." + ) + + +@pytest.mark.asyncio +async def test_a_measurement_is_not_marked_as_an_estimate(): + """The precondition in the other direction: a real meter must not be dismissed as a guess.""" + state = MagicMock() + state.state = "4200" + state.attributes = {"unit_of_measurement": "W"} + state.last_reported = dt_util.utcnow() + state.last_updated = dt_util.utcnow() + + hass = MagicMock() + hass.states.get.return_value = state + + adapter = NibeAdapter( + hass, {"nibe_entity": "number.offset", "power_sensor_entity": "sensor.house_power"} + ) + + power, estimated = await adapter.get_power_consumption() + + assert power == pytest.approx(4.2) + assert estimated is False + + +@pytest.mark.asyncio +async def test_no_savings_are_reported_from_estimated_power(coordinator_for_savings): + """The whole point. No power sensor means no savings figure - not a plausible one.""" + coordinator = coordinator_for_savings + + coordinator._accumulate_spot_savings(_nibe(power_kw=4.4, estimated=True), _a_day_of_prices()) + + assert coordinator._daily_spot_savings == 0.0, ( + f"{coordinator._daily_spot_savings:.2f} kr of savings were accumulated from a power figure " + f"that was estimated from supply and outdoor temperature. The owner reads that number as " + f"money saved." + ) + + +@pytest.mark.asyncio +async def test_savings_are_still_reported_from_measured_power(coordinator_for_savings): + """And the regression guard: an owner WITH a power meter must not lose their savings report.""" + coordinator = coordinator_for_savings + + coordinator._accumulate_spot_savings(_nibe(power_kw=4.4, estimated=False), _a_day_of_prices()) + + assert coordinator._daily_spot_savings != 0.0, ( + "A measured power reading produced no savings figure at all. The guard against estimated " + "power has been drawn too wide and now refuses real measurements." + ) diff --git a/tests/unit/coordinator/test_shutdown_stops_the_coordinator.py b/tests/unit/coordinator/test_shutdown_stops_the_coordinator.py new file mode 100644 index 00000000..287dbcd8 --- /dev/null +++ b/tests/unit/coordinator/test_shutdown_stops_the_coordinator.py @@ -0,0 +1,117 @@ +"""Unload must actually stop the coordinator. Two writers on one heat pump is unacceptable. + +EffektGuard drives itself from a clock-aligned timer, re-armed in `_do_aligned_refresh`'s +`finally`. That refresh runs on `hass.async_create_task`, so HA cannot cancel it on unload - and +if it re-arms after the entry unloads, the reload's new coordinator becomes a second writer, and +every reload adds another. The guard is `_shutdown_requested`, which is only set if +`async_shutdown()` calls `super().async_shutdown()` (which also cancels the refresh handle and the +debouncer). That super() call also makes shutdown idempotent: it runs twice per unload (the base +auto-registers it AND `async_unload_entry` calls it), and without the guard it double-saved state. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from custom_components.effektguard.coordinator import EffektGuardCoordinator + + +def make_coordinator() -> EffektGuardCoordinator: + """A REAL EffektGuardCoordinator with __init__ bypassed. + + It must be a real instance: `super()` inside async_shutdown requires + `isinstance(self, EffektGuardCoordinator)`, so a MagicMock cannot stand in here. + Only the attributes the shutdown/scheduling paths touch are populated. + """ + coordinator = EffektGuardCoordinator.__new__(EffektGuardCoordinator) + coordinator._shutdown_requested = False + coordinator._unsub_aligned_refresh = None + coordinator._power_sensor_listener = None + coordinator.adaptive_learning = None + coordinator.thermal_predictor = None + coordinator.weather_learner = None + coordinator.effect = MagicMock() + coordinator.effect.async_save = AsyncMock() + coordinator._save_learned_data = AsyncMock() + coordinator.hass = MagicMock() + coordinator._clock_aligned = True + # Shutdown now also cancels an EffektGuard-initiated hot-water boost, so it touches these. + coordinator.temp_lux_entity = None + coordinator._lux_boost_is_ours = False + return coordinator + + +async def shutdown(coordinator, base_shutdown_calls: list, monkeypatch) -> None: + """Run the real async_shutdown, with super().async_shutdown() faithfully emulated.""" + + async def fake_base_shutdown(self) -> None: + base_shutdown_calls.append(True) + # Exactly what the real base class does, and it is the whole point of the fix: + self._shutdown_requested = True + + monkeypatch.setattr(DataUpdateCoordinator, "async_shutdown", fake_base_shutdown) + await coordinator.async_shutdown() + + +class TestShutdownActuallyStopsIt: + @pytest.mark.asyncio + async def test_shutdown_calls_super(self, monkeypatch): + """Without super(), `_shutdown_requested` is never set and nothing below works.""" + coordinator = make_coordinator() + base_calls: list = [] + + await shutdown(coordinator, base_calls, monkeypatch) + + assert base_calls, ( + "async_shutdown() did not call super().async_shutdown(). The base sets " + "_shutdown_requested, cancels the refresh handle and shuts down the debouncer. " + "Without it, unload does not actually stop the coordinator." + ) + assert coordinator._shutdown_requested is True + + @pytest.mark.asyncio + async def test_an_inflight_refresh_cannot_rearm_a_dead_coordinator(self, monkeypatch): + """THE ORPHAN-TIMER RACE. This is the one that puts two writers on one pump.""" + coordinator = make_coordinator() + await shutdown(coordinator, [], monkeypatch) + + # A refresh task was already in flight when the entry unloaded. Its `finally` + # block now runs and tries to re-arm the timer. + coordinator._schedule_aligned_refresh() + + assert coordinator._unsub_aligned_refresh is None, ( + "A shut-down coordinator re-armed its aligned-refresh timer. The reloaded entry " + "creates a second coordinator, and BOTH will write curve offsets to the same " + "heat pump - fighting each other, and adding another writer on every reload." + ) + + @pytest.mark.asyncio + async def test_shutdown_is_idempotent(self, monkeypatch): + """It runs twice per unload: once via async_on_unload, once from async_unload_entry.""" + coordinator = make_coordinator() + base_calls: list = [] + + await shutdown(coordinator, base_calls, monkeypatch) + await shutdown(coordinator, base_calls, monkeypatch) + + assert coordinator._save_learned_data.await_count == 0 # no learning modules here + assert coordinator.effect.async_save.await_count == 1, ( + "Effect peaks were saved twice on a single unload. async_shutdown runs twice " + "(the base auto-registers it AND async_unload_entry calls it) and must be " + "idempotent." + ) + assert len(base_calls) == 1, "super().async_shutdown() must not run twice either." + + +class TestTheUpdateLoopStillRearmsWhenAlive: + """Do not over-correct: a LIVE coordinator must still re-arm, or the loop dies.""" + + def test_a_live_coordinator_rearms(self): + coordinator = make_coordinator() + coordinator._calculate_next_aligned_time = MagicMock() + + coordinator._schedule_aligned_refresh() + + # It reached the scheduling call rather than returning early. + coordinator._calculate_next_aligned_time.assert_called_once() diff --git a/tests/unit/coordinator/test_the_billing_hour_survives_the_clocks_going_back.py b/tests/unit/coordinator/test_the_billing_hour_survives_the_clocks_going_back.py new file mode 100644 index 00000000..d115b481 --- /dev/null +++ b/tests/unit/coordinator/test_the_billing_hour_survives_the_clocks_going_back.py @@ -0,0 +1,239 @@ +"""The billing hour must survive DST: the autumn fold must not delete a month's peak. + +When the clocks go back, wall-clock hour 02 happens twice (02:00 CEST, then 02:00 CET). PEP 495 +ignores `fold` when comparing two aware datetimes with the same tzinfo, so an hour-boundary check +that compares local wall-clock times sees 02:00 CEST == 02:00 CET: the rollover never fires, the +two hours merge, and sample deltas across the fold run backwards - subtracting the earlier hour's +energy instead of recording it. 02:00 is exactly where the optimiser puts its load (cheap night +power), so this deletes the hour most likely to be the month's peak. + +The fix keeps the accumulator arithmetic on the absolute (UTC) time line, where the two 02:00 hours +are an hour apart, while the LABEL stays local (the night discount and the month a peak belongs to +are local-clock facts). The spring gap is tested too: wall-clock 02:00 never happens and must not +be invented. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock +from zoneinfo import ZoneInfo + +import pytest +from homeassistant.util import dt as dt_util + +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.const import POWER_SOURCE_EXTERNAL_METER +from custom_components.effektguard.coordinator import EffektGuardCoordinator +from custom_components.effektguard.optimization.effect_layer import EffectManager + +STOCKHOLM = ZoneInfo("Europe/Stockholm") +UTC = ZoneInfo("UTC") + +# The real transitions, from the tz database. +AUTUMN_FALL_BACK = datetime(2026, 10, 25, 0, 0, tzinfo=UTC) # 02:00 CEST; 02:xx runs twice +SPRING_FORWARD = datetime(2026, 3, 29, 0, 0, tzinfo=UTC) # 01:00 CET; 02:xx never happens + + +@contextmanager +def a_swedish_installation(): + """HA's `dt_util.as_local` resolves against the timezone HA is CONFIGURED with. + + The test harness leaves that at UTC, and the coordinator asks `as_local` which month a completed + billing hour belongs to. A test that does not set it is not testing a Swedish install - it is + testing a UTC one, where the month boundary cannot go wrong and the assertion would pass for the + wrong reason. (It is set here rather than in a fixture because + pytest-homeassistant-custom-component asserts at teardown that nobody has left the default zone + moved, and a fixture's undo loses that race.) + """ + previous = dt_util.DEFAULT_TIME_ZONE + dt_util.DEFAULT_TIME_ZONE = STOCKHOLM + try: + yield + finally: + dt_util.DEFAULT_TIME_ZONE = previous + + +def _coordinator() -> EffektGuardCoordinator: + hass = MagicMock() + hass.config.latitude = 59.33 + hass.config.longitude = 18.07 + + nibe = MagicMock() + nibe._power_sensor_entity = "sensor.house_power" + nibe.power_sensor_entity = "sensor.house_power" + + entry = MagicMock() + entry.data = {} + entry.options = {} + + coordinator = EffektGuardCoordinator( + hass, nibe, MagicMock(), MagicMock(), MagicMock(), EffectManager(hass), entry + ) + coordinator.peak_today = 0.0 + coordinator.peak_this_month = 0.0 + coordinator._power_sensor_available = True + coordinator.effect.record_period_measurement = AsyncMock(return_value=None) + return coordinator + + +def _pump() -> NibeState: + return NibeState( + outdoor_temp=-5.0, + indoor_temp=21.0, + supply_temp=42.0, + return_temp=37.0, + degree_minutes=-150.0, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 10, 25, 2, 0, tzinfo=UTC), + ) + + +def _meter(hass, kw: float) -> None: + state = MagicMock() + state.state = str(kw) + state.attributes = {"unit_of_measurement": "kW"} + hass.states.get.return_value = state + + +async def _drive(coordinator, monkeypatch, start_utc, minutes, power_at) -> None: + """Step real (absolute) time in 5-minute coordinator cycles, as HA actually would. + + Time is advanced on the UTC line and handed to the coordinator as LOCAL time - which is exactly + what dt_util.now() gives it, fold and all. Nothing here fakes the transition; the tz database + does it. + """ + for step in range(0, minutes, 5): + instant = start_utc + timedelta(minutes=step) + local = instant.astimezone(STOCKHOLM) + monkeypatch.setattr(dt_util, "now", lambda tz=None, _local=local: _local) + _meter(coordinator.hass, power_at(instant)) + await coordinator._update_peak_tracking(_pump()) + + +def _recorded(coordinator) -> list[tuple[int, float]]: + """(billing hour, mean kW) for every hour the coordinator actually recorded.""" + return [ + (call.kwargs["period"], round(call.kwargs["power_kw"], 2)) + for call in coordinator.effect.record_period_measurement.await_args_list + ] + + +@pytest.mark.asyncio +async def test_the_repeated_hour_does_not_delete_the_months_peak(monkeypatch): + """9 kW through the first 02:00, 1 kW through the second. Both are real, billable hours.""" + coordinator = _coordinator() + + # 9 kW for the first 02:00-03:00 (CEST, i.e. 00:00-01:00 UTC), 1 kW for the second. + def power_at(instant: datetime) -> float: + return 9.0 if instant < AUTUMN_FALL_BACK + timedelta(hours=1) else 1.0 + + # Three real hours: 02:00 CEST, 02:00 CET, 03:00 CET. + await _drive(coordinator, monkeypatch, AUTUMN_FALL_BACK, 180, power_at) + + recorded = _recorded(coordinator) + means = [mean for _, mean in recorded] + + assert 9.0 in means, ( + f"the coordinator recorded {recorded}. A full hour at 9 kW - the highest of the month, and " + f"the hour the optimiser itself chose to load, because night power is cheap - was never " + f"recorded. On the night the clocks go back, wall-clock 02:00 occurs twice, and PEP 495 " + f"makes 02:00 CEST == 02:00 CET for an aware-datetime comparison with the same tzinfo. So " + f"the hour never rolls over, the two hours merge, and the sample deltas across the fold run " + f"backwards - which subtracts the 9 kW hour instead of recording it. The effect tariff bills " + f"the mean of the month's three highest hours: a peak that is never recorded is never " + f"defended, for the rest of the month." + ) + + +@pytest.mark.asyncio +async def test_both_halves_of_the_repeated_hour_are_recorded(monkeypatch): + """Two real hours went by. Two hours must be billed - not one, and not three.""" + coordinator = _coordinator() + await _drive(coordinator, monkeypatch, AUTUMN_FALL_BACK, 180, lambda i: 5.0) + + recorded = _recorded(coordinator) + + assert len(recorded) == 2, ( + f"three real hours elapsed (02:00 CEST, 02:00 CET, 03:00 CET) and the coordinator completed " + f"{len(recorded)} of the first two: {recorded}. Each repeated hour is separately metered and " + f"separately billable." + ) + assert [period for period, _ in recorded] == [2, 2], ( + f"both completed hours are the local hour 2 - that is the point, they print the same digits. " + f"Got {recorded}." + ) + for _, mean in recorded: + assert mean == pytest.approx(5.0, abs=0.01), ( + f"a flat 5 kW through a whole hour has an hourly mean of 5 kW. Got {recorded}. A mean " + f"that is not 5 means the window it was divided by was not one hour." + ) + + +@pytest.mark.asyncio +async def test_the_spring_gap_does_not_invent_an_hour(monkeypatch): + """The other transition. Wall-clock 02:00 never happens - it must not be billed.""" + coordinator = _coordinator() + + # 01:00 CET -> 03:00 CEST. Two real hours: 01:00 and 03:00. There is no 02:00. + await _drive(coordinator, monkeypatch, SPRING_FORWARD, 120, lambda i: 4.0) + + recorded = _recorded(coordinator) + hours = [period for period, _ in recorded] + + assert 2 not in hours, ( + f"the coordinator billed an hour 2 on the spring-forward day: {recorded}. Wall-clock 02:00 " + f"does not exist that night - no meter recorded it, and no bill will contain it." + ) + for _, mean in recorded: + assert mean == pytest.approx( + 4.0, abs=0.01 + ), f"a flat 4 kW hour has a mean of 4 kW. Got {recorded} - the divisor was not an hour." + + +@pytest.mark.asyncio +async def test_the_first_hour_of_a_month_is_billed_to_that_month(monkeypatch): + """The completed hour is stamped local, so it is bucketed into the right calendar month. + + The accumulator runs on the UTC time line, but the effect layer buckets peaks by calendar month + (`peak.timestamp.year, peak.timestamp.month`), a local-clock fact. In Stockholm the billing hour + 00:00-01:00 on 1 November is 23:00-00:00 on 31 October in UTC - hand the layer the raw UTC stamp + and a November peak is filed against an already-billed October, while November loses its first + hour. + """ + coordinator = _coordinator() + # 23:00 UTC on 31 Oct == 00:00 local on 1 Nov (CET, +01:00). Two whole local hours. + november_first = datetime(2026, 10, 31, 23, 0, tzinfo=UTC) + + with a_swedish_installation(): + await _drive(coordinator, monkeypatch, november_first, 120, lambda i: 7.0) + + stamps = [ + call.kwargs["timestamp"] + for call in coordinator.effect.record_period_measurement.await_args_list + ] + assert stamps, "no hour was recorded at all" + for stamp in stamps: + assert (stamp.year, stamp.month) == (2026, 11), ( + f"an hour of 1 November was handed to the effect layer stamped {stamp.isoformat()}, " + f"which is month {stamp.month}. The layer files peaks by calendar month, so this peak " + f"lands in October - a month already billed - and November loses its first hour." + ) + + +@pytest.mark.asyncio +async def test_an_ordinary_hour_is_unchanged(monkeypatch): + """The control. Whatever the fix does to DST, a January hour must still bill exactly as before.""" + coordinator = _coordinator() + january = datetime(2026, 1, 15, 10, 0, tzinfo=UTC) + + await _drive(coordinator, monkeypatch, january, 120, lambda i: 6.0) + + recorded = _recorded(coordinator) + + assert len(recorded) == 1 and recorded[0][1] == pytest.approx( + 6.0, abs=0.01 + ), f"a flat 6 kW hour on an ordinary day must record exactly one hour at 6.0 kW. Got {recorded}." diff --git a/tests/unit/coordinator/test_the_ventilation_fan_cannot_cycle_forever.py b/tests/unit/coordinator/test_the_ventilation_fan_cannot_cycle_forever.py new file mode 100644 index 00000000..0e6aadf6 --- /dev/null +++ b/tests/unit/coordinator/test_the_ventilation_fan_cannot_cycle_forever.py @@ -0,0 +1,192 @@ +"""The ventilation fan must not cycle every tick when a decision oscillates around its threshold. + +The old anti-cycle guard was 5 minutes - exactly one coordinator tick - so a turn-off was permitted +on the very next cycle and it prevented nothing. It also only guarded the turn-OFF, with no rest +period before re-enhancing, so an oscillating decision (what a marginal COP gain produces) flipped +the fan twelve times an hour, each flip perturbing the source air an exhaust-air F750 draws from. + +The optimizer's own `duration_minutes` (15-60 min by deficit), previously logged and discarded, is +now the minimum run time, and NIBE_VENTILATION_MIN_REST_DURATION bounds the other direction. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.util import dt as dt_util + +from custom_components.effektguard.const import ( + AIRFLOW_DURATION_SMALL_DEFICIT, + NIBE_VENTILATION_MIN_ENHANCED_DURATION, + NIBE_VENTILATION_MIN_REST_DURATION, + UPDATE_INTERVAL_MINUTES, +) +from custom_components.effektguard.coordinator import EffektGuardCoordinator +from custom_components.effektguard.optimization.airflow_optimizer import FlowDecision, FlowMode + +START = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc) + + +def _decision(should_enhance: bool, duration: int = AIRFLOW_DURATION_SMALL_DEFICIT) -> FlowDecision: + return FlowDecision( + mode=FlowMode.ENHANCED if should_enhance else FlowMode.STANDARD, + duration_minutes=duration if should_enhance else 0, + expected_gain_kw=0.4 if should_enhance else 0.0, + reason="marginal COP gain, oscillating around the threshold", + timestamp=START, + ) + + +class _Fan: + """A NIBE whose ventilation switch actually remembers what it was told.""" + + def __init__(self) -> None: + self.enhanced = False + self.changes = 0 + + async def is_enhanced_ventilation_active(self) -> bool: + return self.enhanced + + async def set_enhanced_ventilation(self, on: bool, *, force_write: bool = False) -> bool: + if on != self.enhanced: + self.changes += 1 + self.enhanced = on + return True + + +def _coordinator(fan: _Fan) -> EffektGuardCoordinator: + coordinator = EffektGuardCoordinator.__new__(EffektGuardCoordinator) + coordinator.nibe = fan + coordinator._airflow_enhance_start = None + coordinator._airflow_enhance_minutes = NIBE_VENTILATION_MIN_ENHANCED_DURATION + coordinator._airflow_normal_since = None + # `__new__` skips `__init__`, so every attribute the real object always has must be set here or + # the fake is not the object. Home Assistant's DataUpdateCoordinator.__init__ sets this one, and + # the fan write now consults it: a coordinator whose entry has unloaded does not command the fan. + coordinator._shutdown_requested = False + return coordinator + + +async def _run_an_oscillating_hour(coordinator, monkeypatch) -> None: + """Twelve ticks, the decision flipping on every one of them.""" + for step in range(12): + now = START + timedelta(minutes=UPDATE_INTERVAL_MINUTES * step) + monkeypatch.setattr(dt_util, "utcnow", lambda _n=now: _n) + await coordinator._apply_airflow_decision(_decision(should_enhance=step % 2 == 0)) + + +def test_the_old_guard_was_exactly_one_tick_long(): + """The premise. A minimum that equals the sampling interval constrains nothing.""" + assert NIBE_VENTILATION_MIN_ENHANCED_DURATION > UPDATE_INTERVAL_MINUTES, ( + f"The minimum enhanced duration ({NIBE_VENTILATION_MIN_ENHANCED_DURATION} min) is not " + f"longer than one coordinator tick ({UPDATE_INTERVAL_MINUTES} min), so a turn-off is " + f"permitted on the very next cycle and the guard prevents nothing." + ) + + +def test_the_minimum_is_at_least_the_shortest_enhancement_ever_recommended(): + assert NIBE_VENTILATION_MIN_ENHANCED_DURATION >= AIRFLOW_DURATION_SMALL_DEFICIT, ( + f"The minimum run time ({NIBE_VENTILATION_MIN_ENHANCED_DURATION} min) is shorter than the " + f"shortest duration the optimizer ever asks for ({AIRFLOW_DURATION_SMALL_DEFICIT} min), so " + f"it could never enforce even the mildest of its own recommendations." + ) + + +@pytest.mark.asyncio +async def test_an_oscillating_decision_does_not_cycle_the_fan(monkeypatch): + """Twelve state changes an hour, before. The bound is now set by the constants, not the tick.""" + fan = _Fan() + + await _run_an_oscillating_hour(_coordinator(fan), monkeypatch) + + # A full cycle cannot be shorter than one minimum run plus one minimum rest, so an hour + # permits at most that many cycles, and each cycle is two state changes. + period = NIBE_VENTILATION_MIN_ENHANCED_DURATION + NIBE_VENTILATION_MIN_REST_DURATION + allowed = 2 * (60 // period) + + assert fan.changes <= allowed, ( + f"The ventilation fan changed state {fan.changes} times in one hour while the decision " + f"oscillated around its threshold; the constants bound it to {allowed}. The old guard was " + f"five minutes and a tick is five minutes, so a turn-off was allowed on the very next " + f"cycle - and nothing guarded the turn-on at all, which produced twelve. On an exhaust-air " + f"F750 every change perturbs the source air the compressor is drawing from." + ) + assert fan.changes < 12, "the unbounded behaviour was twelve changes an hour" + + +@pytest.mark.asyncio +async def test_the_enhancement_runs_for_the_duration_the_optimizer_asked_for(monkeypatch): + """`duration_minutes` was computed on every decision, logged, and thrown away.""" + fan = _Fan() + coordinator = _coordinator(fan) + + monkeypatch.setattr(dt_util, "utcnow", lambda: START) + await coordinator._apply_airflow_decision(_decision(True, duration=45)) + assert fan.enhanced is True + + # The decision flips immediately. It must not be obeyed until the 45 minutes are up. + for minutes in (5, 20, 44): + moment = START + timedelta(minutes=minutes) + monkeypatch.setattr(dt_util, "utcnow", lambda _m=moment: _m) + await coordinator._apply_airflow_decision(_decision(False)) + assert fan.enhanced is True, ( + f"The optimizer asked for 45 minutes of enhanced ventilation and the fan was switched " + f"off after {minutes}. That number was being logged and discarded." + ) + + moment = START + timedelta(minutes=46) + monkeypatch.setattr(dt_util, "utcnow", lambda _m=moment: _m) + await coordinator._apply_airflow_decision(_decision(False)) + assert fan.enhanced is False, "after the recommended duration it must be free to stop" + + +@pytest.mark.asyncio +async def test_the_fan_rests_before_it_can_be_enhanced_again(monkeypatch): + """The guard that never existed. Without it, the run time only sets the oscillation period.""" + fan = _Fan() + coordinator = _coordinator(fan) + coordinator._airflow_normal_since = START + + monkeypatch.setattr(dt_util, "utcnow", lambda: START + timedelta(minutes=1)) + await coordinator._apply_airflow_decision(_decision(True)) + + assert fan.enhanced is False, ( + f"The fan was re-enhanced one minute after returning to normal. It must rest for " + f"{NIBE_VENTILATION_MIN_REST_DURATION} min first." + ) + + rested = START + timedelta(minutes=NIBE_VENTILATION_MIN_REST_DURATION + 1) + monkeypatch.setattr(dt_util, "utcnow", lambda _m=rested: _m) + await coordinator._apply_airflow_decision(_decision(True)) + + assert fan.enhanced is True, "once rested, a real gain must still be taken" + + +@pytest.mark.asyncio +async def test_a_steady_beneficial_decision_still_enhances(monkeypatch): + """The regression guard: do not switch the feature off while bounding it.""" + fan = _Fan() + coordinator = _coordinator(fan) + + monkeypatch.setattr(dt_util, "utcnow", lambda: START) + await coordinator._apply_airflow_decision(_decision(True)) + + assert fan.enhanced is True + assert fan.changes == 1 + + +@pytest.mark.asyncio +async def test_a_pump_with_no_ventilation_switch_is_left_alone(monkeypatch): + """A ground-source pump has no exhaust-air fan to enhance.""" + nibe = MagicMock() + nibe.is_enhanced_ventilation_active = AsyncMock(return_value=None) + nibe.set_enhanced_ventilation = AsyncMock() + coordinator = _coordinator(_Fan()) + coordinator.nibe = nibe + + monkeypatch.setattr(dt_util, "utcnow", lambda: START) + await coordinator._apply_airflow_decision(_decision(True)) + + nibe.set_enhanced_ventilation.assert_not_awaited() diff --git a/tests/unit/coordinator/test_update_loop_survives_errors.py b/tests/unit/coordinator/test_update_loop_survives_errors.py new file mode 100644 index 00000000..13c36e8e --- /dev/null +++ b/tests/unit/coordinator/test_update_loop_survives_errors.py @@ -0,0 +1,133 @@ +"""The coordinator's update loop must survive any single bad cycle and always re-arm. + +The base scheduler is disabled (`update_interval=None`), so `_do_aligned_refresh` is the sole +owner of the timer: if it returns without calling `_schedule_aligned_refresh()`, nothing re-arms +it and the coordinator is permanently dead - silently, since `last_update_success` stays True and +entities keep serving stale values. So the refresh catches a broad `except Exception` and re-arms +in a `finally`. The update path can raise HomeAssistantError (weather with no hourly forecast), +IndexError (DST price lookup), ZeroDivisionError (savings), RuntimeError/numpy (learning) - and a +daily-only weather entity raises on every cycle, so the first such raise must not kill the loop. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.exceptions import HomeAssistantError, ServiceNotFound +from homeassistant.helpers.update_coordinator import UpdateFailed + +from custom_components.effektguard.adapters.weather_adapter import WeatherAdapter +from custom_components.effektguard.coordinator import EffektGuardCoordinator + +WEATHER_ENTITY = "weather.daily_only_provider" + + +def make_coordinator(update_error: Exception | None): + """Duck-typed stand-in exposing only what _do_aligned_refresh touches. + + It drives the pump through `_drive_the_pump` - the sole owner of the write path - not through + Home Assistant's read hook. Stubbing the wrong one is not a harmless mismatch: the real + `_drive_the_pump` would be reached on a MagicMock, fail to await, and be swallowed by the very + `except Exception` under test. The error cases would then pass on a TypeError instead of on the + error they name. + """ + coordinator = MagicMock() + coordinator.last_update_success = True + + if update_error is None: + coordinator._drive_the_pump = AsyncMock(return_value={"ok": True}) + else: + coordinator._drive_the_pump = AsyncMock(side_effect=update_error) + + coordinator._schedule_aligned_refresh = MagicMock() + coordinator.async_set_updated_data = MagicMock() + return coordinator + + +async def run_refresh(coordinator) -> None: + await EffektGuardCoordinator._do_aligned_refresh(coordinator) + + +class TestUpdateLoopAlwaysRearms: + """Whatever happens, the next update must be scheduled.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "error", + [ + HomeAssistantError("Weather entity does not support 'hourly' forecast"), + ServiceNotFound("weather", "get_forecasts"), + IndexError("list index out of range"), # DST 92/100-quarter day + ZeroDivisionError("float division by zero"), # savings maths + RuntimeError("something unexpected"), + UpdateFailed("required NIBE sensors unreadable"), + ], + ids=[ + "HomeAssistantError", + "ServiceNotFound", + "IndexError_dst", + "ZeroDivisionError_savings", + "RuntimeError", + "UpdateFailed_expected", + ], + ) + async def test_timer_is_rearmed_after_any_error(self, error): + coordinator = make_coordinator(update_error=error) + + # The loop must not propagate - a dead task means a dead coordinator. + await run_refresh(coordinator) + + coordinator._schedule_aligned_refresh.assert_called_once(), ( + f"{type(error).__name__} left the aligned-refresh timer un-armed. With " + "update_interval=None, the coordinator is now permanently dead." + ) + + @pytest.mark.asyncio + async def test_failure_marks_the_update_unsuccessful(self): + """Entities must go unavailable rather than serving stale data as if healthy.""" + coordinator = make_coordinator(update_error=HomeAssistantError("boom")) + + await run_refresh(coordinator) + + assert coordinator.last_update_success is False, ( + "The coordinator reported success after a failed update. Entities would keep " + "serving their last value and look healthy while control had stopped." + ) + + @pytest.mark.asyncio + async def test_timer_is_rearmed_on_success_too(self): + coordinator = make_coordinator(update_error=None) + + await run_refresh(coordinator) + + coordinator._schedule_aligned_refresh.assert_called_once() + assert coordinator.last_update_success is True + coordinator.async_set_updated_data.assert_called_once() + + +class TestWeatherAdapterSurvivesUnsupportedForecast: + """A daily-only weather entity must degrade, not take the integration down.""" + + @pytest.mark.asyncio + async def test_unsupported_forecast_returns_none_instead_of_raising(self): + hass = MagicMock() + + state = MagicMock() + state.state = "cloudy" + # Current HA weather entities publish no `forecast` state attribute, so the + # service-call path is always taken. + state.attributes = {"temperature": 4.2} + hass.states.get.return_value = state + + hass.services.async_call = AsyncMock( + side_effect=HomeAssistantError( + f"Weather entity '{WEATHER_ENTITY}' does not support 'hourly' forecast" + ) + ) + + adapter = WeatherAdapter(hass, {"weather_entity": WEATHER_ENTITY}) + + result = await adapter.get_forecast() + + assert result is None, "Weather is optional - it must degrade to None, not raise." + # And it must back off rather than hammering the service every cycle. + assert adapter._next_random_attempt is not None diff --git a/tests/unit/dhw/test_dhw_safety_stop_not_rate_limited.py b/tests/unit/dhw/test_dhw_safety_stop_not_rate_limited.py new file mode 100644 index 00000000..f880ad88 --- /dev/null +++ b/tests/unit/dhw/test_dhw_safety_stop_not_rate_limited.py @@ -0,0 +1,167 @@ +"""A DHW stop must never be deferred by the rate limiter. + +The rate limiter (DHW_CONTROL_MIN_INTERVAL_MINUTES = 60) once guarded BOTH directions, and its +clock is stamped by the turn-ON - so a boost started at 03:00 could not be stopped until 04:00. If +a cold front then crashes DM into the CRITICAL_THERMAL_DEBT block at 03:05, DHW keeps the compressor +off space heating while thermal debt deepens ("DHW during heating demand"). The abort branch cannot +rescue it either: every should_heat=False return carries an empty abort_conditions list. + +Stopping an EffektGuard lux boost cannot harm the pump (NIBE's own schedule is untouched), so STARTS +stay rate-limited to bound oscillation while STOPS are always allowed. +""" + +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.effektguard.const import DHW_CONTROL_MIN_INTERVAL_MINUTES +from custom_components.effektguard.coordinator import EffektGuardCoordinator + +LUX_ENTITY = "switch.temporary_lux_50004" +NOW = datetime(2026, 1, 15, 3, 5) + +# The lux boost was started 5 minutes ago - deep inside the 60-minute rate-limit window. +STARTED_5_MIN_AGO = NOW - timedelta(minutes=5) + + +@dataclass +class FakeDHWDecision: + """Mirrors the shape of DHWScheduleDecision on the paths under test.""" + + should_heat: bool + priority_reason: str + # Every should_heat=False return in should_start_dhw() sets this to []. That is + # precisely why the abort branch cannot rescue us and the limiter had to be fixed. + abort_conditions: list[str] = field(default_factory=list) + + +def make_coordinator(lux_is_on: bool, last_control_time: datetime | None): + """Duck-typed stand-in exposing only what _apply_dhw_control touches. + + Calling the unbound method with this avoids standing up a full HA config entry, and + keeps the test deterministic. + """ + coordinator = MagicMock() + coordinator.temp_lux_entity = LUX_ENTITY + coordinator._last_dhw_control_time = last_control_time + coordinator.last_update_success = True + coordinator.data = {"dhw_planning": {"thermal_debt": -1100.0, "indoor_temperature": 20.4}} + coordinator.entry.options = {} + coordinator.entry.data = {"target_indoor_temp": 21.0} + + lux_state = MagicMock() + lux_state.state = "on" if lux_is_on else "off" + coordinator.hass.states.get.return_value = lux_state + coordinator.hass.services.async_call = AsyncMock() + + # Bind the real rate-limit helper so the test exercises production logic. + coordinator._is_dhw_start_rate_limited = ( + lambda now: EffektGuardCoordinator._is_dhw_start_rate_limited(coordinator, now) + ) + # And the real switch door, for the same reason: it is the only place that records whether a + # running hot-water boost is EffektGuard's to cancel, and `_apply_dhw_control` now goes through + # it. A MagicMock would answer the call cheerfully and record nothing. + # + # `_shutdown_requested` must be a real False, not an auto-mock: the door refuses to START a boost + # when it is set, and every MagicMock attribute is truthy. The fake has to be the object. + coordinator._shutdown_requested = False + # Same for the user-boost window: None means "no service boost", an auto-mock means chaos. + coordinator._service_boost_until = None + coordinator._set_temporary_lux = lambda on: EffektGuardCoordinator._set_temporary_lux( + coordinator, on + ) + return coordinator + + +async def apply(coordinator, decision) -> None: + await EffektGuardCoordinator._apply_dhw_control(coordinator, decision, 45.0, NOW) + + +def switch_calls(coordinator) -> list[str]: + """The switch services actually invoked, e.g. ['turn_off'].""" + return [ + call.args[1] + for call in coordinator.hass.services.async_call.call_args_list + if call.args and call.args[0] == "homeassistant" + ] + + +class TestSafetyStopIsNotRateLimited: + @pytest.mark.asyncio + async def test_critical_thermal_debt_stops_dhw_inside_the_rate_limit_window(self): + """Stop must happen at 03:05, not be deferred to 04:00.""" + coordinator = make_coordinator(lux_is_on=True, last_control_time=STARTED_5_MIN_AGO) + + await apply( + coordinator, + FakeDHWDecision(should_heat=False, priority_reason="CRITICAL_THERMAL_DEBT"), + ) + + assert "turn_off" in switch_calls(coordinator), ( + "DHW was NOT stopped despite CRITICAL_THERMAL_DEBT, because the rate limiter " + f"deferred it ({DHW_CONTROL_MIN_INTERVAL_MINUTES} min window, boost started " + "5 min ago). DHW keeps stealing the compressor from space heating while " + "thermal debt deepens." + ) + + @pytest.mark.asyncio + async def test_stop_works_with_empty_abort_conditions(self): + """The abort branch cannot rescue us: should_heat=False always sets []. + + This pins the reason the limiter had to change rather than the abort path. + """ + coordinator = make_coordinator(lux_is_on=True, last_control_time=STARTED_5_MIN_AGO) + + decision = FakeDHWDecision( + should_heat=False, + priority_reason="SPACE_HEATING_EMERGENCY", + abort_conditions=[], + ) + await apply(coordinator, decision) + + assert "turn_off" in switch_calls(coordinator) + + +class TestStartsRemainRateLimited: + """Bounding oscillation is what the limiter is for - that must still hold.""" + + @pytest.mark.asyncio + async def test_start_is_still_rate_limited(self): + coordinator = make_coordinator(lux_is_on=False, last_control_time=STARTED_5_MIN_AGO) + + await apply( + coordinator, + FakeDHWDecision(should_heat=True, priority_reason="DHW_SCHEDULED"), + ) + + assert switch_calls(coordinator) == [], ( + "A DHW start inside the rate-limit window must still be deferred - otherwise " + "the pump can be cycled every coordinator tick." + ) + + @pytest.mark.asyncio + async def test_start_proceeds_once_the_window_has_passed(self): + coordinator = make_coordinator( + lux_is_on=False, + last_control_time=NOW - timedelta(minutes=DHW_CONTROL_MIN_INTERVAL_MINUTES + 1), + ) + + await apply( + coordinator, + FakeDHWDecision(should_heat=True, priority_reason="DHW_SCHEDULED"), + ) + + assert "turn_on" in switch_calls(coordinator) + + @pytest.mark.asyncio + async def test_first_ever_start_is_not_rate_limited(self): + coordinator = make_coordinator(lux_is_on=False, last_control_time=None) + + await apply( + coordinator, + FakeDHWDecision(should_heat=True, priority_reason="DHW_SCHEDULED"), + ) + + assert "turn_on" in switch_calls(coordinator) diff --git a/tests/unit/dhw/test_hot_water_wins_but_never_below_safety.py b/tests/unit/dhw/test_hot_water_wins_but_never_below_safety.py new file mode 100644 index 00000000..eb9f2fde --- /dev/null +++ b/tests/unit/dhw/test_hot_water_wins_but_never_below_safety.py @@ -0,0 +1,186 @@ +"""A scheduled shower outranks thermal debt and space-heating demand. It never outranks safety. + +Owner rule: "DHW wins, but never below safety." RULE 0 (two-lane scheduling) returns early, before +the thermal-debt block (RULE 1) and space-heating emergency (RULE 2), so a scheduled window heats +hot water through the debt block - but not below the MIN_TEMP_LIMIT indoor floor, and not at the +DM_THRESHOLD_AUX_LIMIT degree-minute limit. + +And if it may start, it may run: the scheduled path's abort conditions are the SAME two safety +thresholds, so a cycle permitted to begin cannot be aborted by the state it began in (it once +started at DM -1400 while carrying `thermal_debt < -1100` as an abort, cycling once an hour forever). + +A window refused for safety is OWED, not cancelled: it resumes the moment the house is safe again, +then clears once the water reaches target. +""" + +from __future__ import annotations + +from datetime import datetime +from zoneinfo import ZoneInfo + +import pytest + +from custom_components.effektguard.const import DM_THRESHOLD_AUX_LIMIT, MIN_TEMP_LIMIT +from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector +from custom_components.effektguard.optimization.dhw_optimizer import ( + DHWDemandPeriod, + IntelligentDHWScheduler, +) +from custom_components.effektguard.optimization.thermal_layer import EmergencyLayer + +STOCKHOLM = ZoneInfo("Europe/Stockholm") +IN_THE_RUN_UP = datetime(2026, 1, 15, 6, 0, tzinfo=STOCKHOLM) # hot water wanted at 07:00 +LONG_AFTER = datetime(2026, 1, 15, 11, 0, tzinfo=STOCKHOLM) # window long gone + + +def _scheduler() -> IntelligentDHWScheduler: + detector = ClimateZoneDetector(latitude=59.33) + return IntelligentDHWScheduler( + demand_periods=[ + DHWDemandPeriod( + availability_hour=7, target_temp=50.0, duration_hours=2, min_amount_minutes=5 + ) + ], + climate_detector=detector, + emergency_layer=EmergencyLayer(detector, heating_type="radiator"), + user_target_temp=50.0, + ) + + +def _ask(scheduler, thermal_debt: float, indoor: float, when=IN_THE_RUN_UP, dhw_temp: float = 35.0): + return scheduler.should_start_dhw( + current_dhw_temp=dhw_temp, + space_heating_demand_kw=5.0, + thermal_debt_dm=thermal_debt, + indoor_temp=indoor, + target_indoor_temp=21.0, + outdoor_temp=-10.0, + price_classification="expensive", + current_time=when, + price_periods=None, + hours_since_last_dhw=8.0, + ) + + +def test_a_scheduled_shower_beats_thermal_debt(): + """The priority itself. This is what the owner asked for and it must not regress. + + DM -1400 is deep in the T3 recovery tier and `should_block_dhw()` refuses it. The scheduled window + overrules that, because a shower the owner scheduled is a shower the owner wants. + """ + scheduler = _scheduler() + emergency = scheduler.emergency_layer + + assert emergency.should_block_dhw(-1400.0, -10.0), "precondition: debt this deep blocks DHW" + + decision = _ask(scheduler, thermal_debt=-1400.0, indoor=21.0) + + assert decision.should_heat, ( + "A scheduled hot-water window was refused because of thermal debt. The owner's rule is that " + "the shower wins: DHW beats the debt block and beats space-heating demand." + ) + + +def test_a_scheduled_shower_does_not_beat_the_safety_floor(): + """The house is below the temperature at which the safety layer commands maximum heat. + + Running hot water here takes the compressor away from a house that is already in trouble. + """ + decision = _ask(_scheduler(), thermal_debt=-400.0, indoor=MIN_TEMP_LIMIT - 0.5) + + assert not decision.should_heat, ( + f"DHW was started with the house at {MIN_TEMP_LIMIT - 0.5} C - below the {MIN_TEMP_LIMIT} C " + f"floor, where the safety layer is already commanding maximum heat. Hot water takes the " + f"compressor away from exactly that." + ) + + +def test_a_scheduled_shower_does_not_beat_the_absolute_degree_minute_limit(): + """At the aux limit the immersion heater is engaging. DHW must not compete with recovery.""" + decision = _ask(_scheduler(), thermal_debt=DM_THRESHOLD_AUX_LIMIT - 50, indoor=21.0) + + assert not decision.should_heat, ( + f"DHW was started at DM {DM_THRESHOLD_AUX_LIMIT - 50}, past the absolute limit " + f"{DM_THRESHOLD_AUX_LIMIT} where the emergency layer owns the pump." + ) + + +def test_if_it_may_start_it_may_run(): + """The heart of it. Nothing that permits the start may be a reason to abort. + + The scheduled path used to start at DM -1400 while handing back `thermal_debt < -1100` as an abort + condition - true before the cycle even began. It started and aborted, once an hour, forever. + """ + scheduler = _scheduler() + decision = _ask(scheduler, thermal_debt=-1400.0, indoor=20.0) + + assert decision.should_heat, "precondition: this cycle is permitted to start" + + should_abort, reason = scheduler.check_abort_conditions( + decision.abort_conditions, + thermal_debt=-1400.0, # the very state it was started in + indoor_temp=20.0, + target_indoor=21.0, + ) + + assert not should_abort, ( + f"DHW was permitted to start in this exact state and its own abort conditions " + f"{decision.abort_conditions} fire on it immediately: {reason}. It starts, aborts, is " + f"rate-limited for an hour, starts again, and never heats any water." + ) + + +def test_it_does_abort_when_the_house_actually_becomes_unsafe(): + """The other half. The priority is not a licence to freeze the house.""" + scheduler = _scheduler() + decision = _ask(scheduler, thermal_debt=-1400.0, indoor=20.0) + assert decision.should_heat, "precondition" + + should_abort, reason = scheduler.check_abort_conditions( + decision.abort_conditions, + thermal_debt=-1400.0, + indoor_temp=MIN_TEMP_LIMIT - 0.5, # the house has fallen below the floor while heating + target_indoor=21.0, + ) + + assert should_abort, ( + f"The house fell below the {MIN_TEMP_LIMIT} C safety floor while hot water was being heated, " + f"and nothing stopped it. Abort conditions were {decision.abort_conditions}." + ) + + +def test_a_window_refused_for_safety_is_resumed_when_the_house_recovers(): + """Owner decision: "retry as soon as it is safe". Hot water late, not hot water never. + + The 07:00 window is refused because the house is below the floor. By 11:00 the house has recovered + and the window is long gone - but the shower was still wanted, so it is heated now. + """ + scheduler = _scheduler() + + refused = _ask(scheduler, thermal_debt=-400.0, indoor=MIN_TEMP_LIMIT - 0.5) + assert not refused.should_heat, "precondition: safety refused the window" + + recovered = _ask(scheduler, thermal_debt=-200.0, indoor=21.0, when=LONG_AFTER) + + assert recovered.should_heat, ( + "The scheduled window was refused for safety and then simply forgotten. The house has " + "recovered and the hot water the owner asked for has still not been heated." + ) + + +def test_the_retry_does_not_fire_forever_once_the_water_is_hot(): + """It is a debt to be settled, not a standing order.""" + scheduler = _scheduler() + + refused = _ask(scheduler, thermal_debt=-400.0, indoor=MIN_TEMP_LIMIT - 0.5) + assert not refused.should_heat, "precondition" + + # The water reached target (by the retry, or by the pump's own schedule - it does not matter). + satisfied = _ask(scheduler, thermal_debt=-200.0, indoor=21.0, when=LONG_AFTER, dhw_temp=50.0) + assert not satisfied.should_heat, "the water is at target; there is nothing left to settle" + + # And it stays settled. + again = _ask(scheduler, thermal_debt=-200.0, indoor=21.0, when=LONG_AFTER, dhw_temp=49.0) + assert ( + again.priority_reason != "DHW_SCHEDULED_RETRY_AFTER_SAFETY" + ), "the missed-window debt was settled and must not resurrect itself" diff --git a/tests/unit/dhw/test_the_dhw_schedule_survives_the_clocks_going_back.py b/tests/unit/dhw/test_the_dhw_schedule_survives_the_clocks_going_back.py new file mode 100644 index 00000000..43099777 --- /dev/null +++ b/tests/unit/dhw/test_the_dhw_schedule_survives_the_clocks_going_back.py @@ -0,0 +1,48 @@ +"""The hours until a DHW demand period are REAL hours, not wall-clock arithmetic. + +`_check_upcoming_demand_period` measured the distance to the next scheduled shower with +naive datetime subtraction. On the night the clocks go back, wall-clock arithmetic loses the +repeated hour: 00:30 CEST to 06:00 CET is 5.5 wall-clock hours but 6.5 REAL hours - and the +planner would heat water against that figure. Production now subtracts on the UTC timeline. +""" + +from datetime import datetime +from zoneinfo import ZoneInfo + +from custom_components.effektguard.optimization.dhw_optimizer import ( + DHWDemandPeriod, + IntelligentDHWScheduler, +) + +STOCKHOLM = ZoneInfo("Europe/Stockholm") + + +def _scheduler_with_morning_period() -> IntelligentDHWScheduler: + scheduler = IntelligentDHWScheduler.__new__(IntelligentDHWScheduler) + scheduler.demand_periods = [ + DHWDemandPeriod(availability_hour=6, target_temp=50.0, duration_hours=2) + ] + return scheduler + + +def test_the_fall_back_night_counts_its_extra_hour(): + # 00:30 CEST on fall-back night: 06:00 CET is 6.5 REAL hours away (02:00 happens twice). + current = datetime(2026, 10, 25, 0, 30, tzinfo=STOCKHOLM) + + info = _scheduler_with_morning_period()._check_upcoming_demand_period(current) + + assert info is not None + assert info["hours_until"] == 6.5, ( + f"Reported {info['hours_until']} h to the 06:00 demand period. The clocks " + f"go back at 03:00 CEST, so the pump has 6.5 real hours to heat water, not 5.5 - " + f"wall-clock subtraction plans the heating an hour short." + ) + + +def test_an_ordinary_night_is_unchanged(): + current = datetime(2026, 1, 15, 0, 30, tzinfo=STOCKHOLM) + + info = _scheduler_with_morning_period()._check_upcoming_demand_period(current) + + assert info is not None + assert info["hours_until"] == 5.5 diff --git a/tests/unit/dhw/test_what_the_dhw_safety_floor_actually_does.py b/tests/unit/dhw/test_what_the_dhw_safety_floor_actually_does.py new file mode 100644 index 00000000..eaa9ba34 --- /dev/null +++ b/tests/unit/dhw/test_what_the_dhw_safety_floor_actually_does.py @@ -0,0 +1,100 @@ +"""What DHW_SAFETY_CRITICAL (20 C) actually does, versus its old "always heat below this" comment. + +Below 20 C the optimizer stops WAITING FOR A CHEAPER PRICE. It does NOT heat unconditionally, and +must not, because two things still outrank the hot water - both deliberate: + + * CRITICAL THERMAL DEBT - a DHW cycle takes the compressor from space heating; doing that in deep + degree-minute debt turns a recoverable debt into an immersion-heater one. + * THE HOUSE BELOW ITS OWN SAFETY FLOOR (MIN_TEMP_LIMIT) - "DHW wins, but never below safety." + +The code was right; the comment was the lie. These tests pin the real behaviour. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from custom_components.effektguard.const import ( + DHW_SAFETY_CRITICAL, + DHW_SAFETY_MIN, + MIN_TEMP_LIMIT, +) +from custom_components.effektguard.optimization.dhw_optimizer import IntelligentDHWScheduler + +NOW = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc) + +# Five degrees UNDER the "hard floor". Every case below uses it. +FREEZING_TANK = DHW_SAFETY_CRITICAL - 5.0 + + +def _decide(dhw: float, dm: float, indoor: float): + return IntelligentDHWScheduler().should_start_dhw( + current_dhw_temp=dhw, + space_heating_demand_kw=3.0, + thermal_debt_dm=dm, + indoor_temp=indoor, + target_indoor_temp=21.0, + outdoor_temp=-5.0, + price_classification="normal", + current_time=NOW, + price_periods=[], + hours_since_last_dhw=6.0, + ) + + +def test_the_tank_used_in_these_tests_really_is_below_the_floor(): + """The premise.""" + assert FREEZING_TANK < DHW_SAFETY_CRITICAL < DHW_SAFETY_MIN + + +def test_below_the_floor_price_stops_being_a_reason_to_wait(): + """What the constant DOES do. A healthy house heats its water, whatever the price is doing.""" + decision = _decide(dhw=FREEZING_TANK, dm=-150.0, indoor=21.0) + + assert decision.should_heat is True, ( + f"The tank is at {FREEZING_TANK} C, below DHW_SAFETY_CRITICAL ({DHW_SAFETY_CRITICAL}), the " + f"house is warm and the degree minutes are healthy - and the optimizer still declined to " + f"heat: {decision.priority_reason}." + ) + + +def test_a_house_in_deep_thermal_debt_still_outranks_the_hot_water(): + """NOT "always heat". Taking the compressor now is how a recoverable debt becomes aux heat.""" + decision = _decide(dhw=FREEZING_TANK, dm=-1400.0, indoor=21.0) + + assert decision.should_heat is False, ( + f"The house is in deep thermal debt (DM -1400) and the optimizer started a hot-water cycle " + f"anyway, because the tank was below DHW_SAFETY_CRITICAL. That takes the compressor away " + f"from space heating at the worst possible moment. The constant's old comment - 'Hard " + f"floor, always heat below this' - says to do exactly this, and it is wrong." + ) + assert "THERMAL_DEBT" in decision.priority_reason + + +def test_a_house_below_its_own_safety_floor_still_outranks_the_hot_water(): + """The owner's rule: DHW wins, but never below safety.""" + decision = _decide(dhw=FREEZING_TANK, dm=-150.0, indoor=MIN_TEMP_LIMIT - 1.0) + + assert decision.should_heat is False, ( + f"The house is at {MIN_TEMP_LIMIT - 1.0} C - below its {MIN_TEMP_LIMIT} C safety floor - " + f"and the optimizer started a hot-water cycle because the tank was cold. Space heating " + f"outranks hot water when the house itself is unsafe. Nobody wants a hot shower in a " + f"freezing house." + ) + assert "SPACE_HEATING" in decision.priority_reason + + +def test_an_adequate_tank_in_a_healthy_house_still_waits_for_a_better_price(): + """The regression guard: none of this may switch the optimisation off.""" + decision = _decide(dhw=45.0, dm=-150.0, indoor=21.0) + + assert decision.should_heat is False + assert "ADEQUATE" in decision.priority_reason + + +@pytest.mark.parametrize("dm", [-1400.0, -2000.0]) +def test_the_precedence_does_not_depend_on_how_cold_the_tank_is(dm): + """A tank at 5 C does not buy its way past a house in danger either.""" + assert _decide(dhw=5.0, dm=dm, indoor=21.0).should_heat is False diff --git a/tests/unit/effect/test_a_version_1_store_does_not_break_setup.py b/tests/unit/effect/test_a_version_1_store_does_not_break_setup.py new file mode 100644 index 00000000..f3fd3034 --- /dev/null +++ b/tests/unit/effect/test_a_version_1_store_does_not_break_setup.py @@ -0,0 +1,57 @@ +"""An upgrade must not break setup: version-1 peak records are migrated, not parsed. + +Version 1 recorded 15-minute quarter peaks (``quarter_of_day``). This branch bills the HOURLY mean +(``period_of_day``), and the two are different billed quantities - so migration DISCARDS the old +records and the month's top-3 restarts from live measurement. Parsing them instead raised +``KeyError: 'period_of_day'`` in ``PeakEvent.from_dict`` inside ``async_setup_entry``, failing setup +for every upgrading install. Losing at most a month of partial history is recoverable; that was not. +""" + +from unittest.mock import MagicMock + +import pytest + +from custom_components.effektguard.const import EFFECT_STORAGE_VERSION, STORAGE_KEY +from custom_components.effektguard.optimization.effect_layer import EffectManager, EffectStore + +# A record exactly as main's PeakEvent.to_dict() wrote it: quarter_of_day, no source. +V1_QUARTER_RECORD = { + "timestamp": "2026-06-15T08:00:00+02:00", + "quarter_of_day": 32, + "actual_power": 6.0, + "effective_power": 6.0, + "is_daytime": True, +} + + +@pytest.mark.asyncio +async def test_migration_discards_quarter_hour_records(): + """A v1 payload comes out of migration with its quarter-era peaks discarded, not crashed on.""" + store = EffectStore(MagicMock(), EFFECT_STORAGE_VERSION, STORAGE_KEY) + + migrated = await store._async_migrate_func(1, 1, {"peaks": [V1_QUARTER_RECORD]}) + + assert migrated == {"peaks": []} + + +@pytest.mark.asyncio +async def test_migration_survives_a_malformed_v1_payload(): + """A corrupt or hand-edited v1 file must migrate to an empty history, not raise.""" + store = EffectStore(MagicMock(), EFFECT_STORAGE_VERSION, STORAGE_KEY) + + assert await store._async_migrate_func(1, 1, None) == {"peaks": []} + assert await store._async_migrate_func(1, 1, {"junk": 1}) == {"peaks": []} + + +def test_the_manager_wires_the_migrating_store_above_the_quarter_era(): + """The migration only runs if the store is an EffectStore AND declares a version above 1. + + Home Assistant's Store calls ``_async_migrate_func`` only when the stored version is lower + than the declared one. Declaring version 1 - what this integration did - hands v1 data to + the parser unmigrated, which is the setup crash this file exists to prevent. + """ + manager = EffectManager(MagicMock()) + + assert isinstance(manager._store, EffectStore) + assert manager._store.version == EFFECT_STORAGE_VERSION + assert EFFECT_STORAGE_VERSION > 1 diff --git a/tests/unit/effect/test_peak_protection_works_without_a_whole_house_meter.py b/tests/unit/effect/test_peak_protection_works_without_a_whole_house_meter.py new file mode 100644 index 00000000..5460e38c --- /dev/null +++ b/tests/unit/effect/test_peak_protection_works_without_a_whole_house_meter.py @@ -0,0 +1,156 @@ +"""The whole-house meter is optional; peak protection is not. + +`should_limit_power` returns "OK, no peaks recorded yet" on an empty history, and the history is +filled only by the peak recorder. Gating that recorder on BILLABILITY - as a first billing fix did - +leaves a house with no whole-house meter recording nothing, so peak protection never fires. + +BILLABLE and USABLE-AS-A-CONTROL-THRESHOLD are different questions. NIBE phase currents are a valid +control threshold (the pump is the dominant controllable load, compared against its own recorded +history) but are not whole-house grid import - so the PeakEvent carries provenance and is never +reported as a bill. Estimates drive neither. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest +from unittest.mock import MagicMock + +from custom_components.effektguard.const import ( + BILLABLE_POWER_SOURCES, + PEAK_CONTROL_POWER_SOURCES, + POWER_SOURCE_ESTIMATE, + POWER_SOURCE_EXTERNAL_METER, + POWER_SOURCE_NIBE_CURRENTS, + POWER_SOURCE_NONE, +) +from custom_components.effektguard.optimization.effect_layer import EffectManager + +JANUARY = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc) +MIDDAY_HOUR = 10 # inside DAYTIME, so no night weighting confuses the arithmetic + + +def _manager() -> EffectManager: + manager = EffectManager(MagicMock()) + manager._monthly_peaks = [] + return manager + + +def test_a_guess_is_not_a_control_threshold(): + """Estimates drive nothing. This is the line the billing fix was right about.""" + assert POWER_SOURCE_ESTIMATE not in PEAK_CONTROL_POWER_SOURCES + assert POWER_SOURCE_ESTIMATE not in BILLABLE_POWER_SOURCES + assert POWER_SOURCE_NONE not in PEAK_CONTROL_POWER_SOURCES + + +def test_phase_currents_control_but_do_not_bill(): + """The distinction the whole fix turns on, stated once.""" + assert POWER_SOURCE_NIBE_CURRENTS in PEAK_CONTROL_POWER_SOURCES, ( + "NIBE phase currents were excluded from peak RECORDING because they are not billable. But " + "an empty peak history makes should_limit_power return OK forever, so every user without a " + "whole-house meter - and the meter is optional - lost peak protection entirely." + ) + assert POWER_SOURCE_NIBE_CURRENTS not in BILLABLE_POWER_SOURCES + assert BILLABLE_POWER_SOURCES < PEAK_CONTROL_POWER_SOURCES, ( + "Everything billable must also be usable for control. If these sets ever cross, a reading " + "could bill the owner without being allowed to protect them from the bill." + ) + + +@pytest.mark.asyncio +async def test_peak_protection_actually_fires_for_a_house_with_no_meter(): + """The regression, end to end: record from phase currents, then demand a limit.""" + manager = _manager() + + # A cold January stretch. One counted hour per day - the tariff's own rule - fills the top 3. + for day_offset, kw in enumerate((6.0, 5.5, 5.0)): + await manager.record_period_measurement( + power_kw=kw, + period=MIDDAY_HOUR, + timestamp=JANUARY + timedelta(days=day_offset), + source=POWER_SOURCE_NIBE_CURRENTS, + ) + + assert len(manager._monthly_peaks) == 3, ( + "Nothing was recorded. A house whose only power measurement is the pump's own phase " + "currents has no monthly peak history at all, and should_limit_power short-circuits to " + "'OK - no peaks recorded yet' on an empty history." + ) + + # Now the pump goes past the lowest of the top three. Protection must engage. + decision = manager.should_limit_power(current_power=7.0, current_period=MIDDAY_HOUR) + + assert decision.should_limit, ( + f"The house is drawing 7.0 kW against a recorded monthly peak of 5.0 kW and peak " + f"protection said {decision.severity!r}: {decision.reason!r}. This is the integration's " + f"headline feature, and for every user without a whole-house meter it never fired." + ) + assert decision.severity == "CRITICAL" + assert decision.recommended_offset < 0.0, "protection must REDUCE heat, not add it" + + +@pytest.mark.asyncio +async def test_the_resulting_peak_is_flagged_as_not_a_bill(): + """It controls the pump. It must never be shown to the owner as money.""" + manager = _manager() + + await manager.record_period_measurement( + power_kw=6.0, + period=MIDDAY_HOUR, + timestamp=JANUARY, + source=POWER_SOURCE_NIBE_CURRENTS, + ) + summary = manager.get_monthly_peak_summary() + + assert summary["highest"] == pytest.approx(6.0) + assert summary["billable"] is False, ( + "A monthly peak built from the pump's own phase currents was reported as billable. BE1/BE2/" + "BE3 measure the heat pump - not the oven, not the EV charger - and the Swedish effect " + "tariff bills whole-house grid import." + ) + assert summary["peaks"][0]["source"] == POWER_SOURCE_NIBE_CURRENTS + + +@pytest.mark.asyncio +async def test_one_unmetered_quarter_taints_the_whole_billing_figure(): + """The tariff charges the top THREE quarters together, so the set is billable or it is not.""" + manager = _manager() + + await manager.record_period_measurement( + power_kw=6.0, period=MIDDAY_HOUR, timestamp=JANUARY, source=POWER_SOURCE_EXTERNAL_METER + ) + await manager.record_period_measurement( + power_kw=5.0, + period=MIDDAY_HOUR, + timestamp=JANUARY + timedelta(days=1), + source=POWER_SOURCE_NIBE_CURRENTS, + ) + + summary = manager.get_monthly_peak_summary() + + assert summary["count"] == 2 + assert summary["billable"] is False, ( + "Two of the month's top quarters, one measured at the meter and one at the pump, were " + "reported together as a billing figure. The tariff is charged on the three together; one " + "pump-only quarter in the set means the total is not what the grid delivered." + ) + + +@pytest.mark.asyncio +async def test_a_metered_house_is_unaffected(): + """The regression guard on the guard: none of this may change a properly metered install.""" + manager = _manager() + + for kw in (6.0, 5.5, 5.0): + await manager.record_period_measurement( + power_kw=kw, + period=MIDDAY_HOUR, + timestamp=JANUARY, + source=POWER_SOURCE_EXTERNAL_METER, + ) + + summary = manager.get_monthly_peak_summary() + assert summary["billable"] is True + assert summary["highest"] == pytest.approx(6.0) + assert manager.should_limit_power(7.0, MIDDAY_HOUR).should_limit diff --git a/tests/unit/effect/test_peak_reset_and_predictive_guard.py b/tests/unit/effect/test_peak_reset_and_predictive_guard.py new file mode 100644 index 00000000..96eaf1e8 --- /dev/null +++ b/tests/unit/effect/test_peak_reset_and_predictive_guard.py @@ -0,0 +1,139 @@ +"""Three effect-tariff invariants, each a case of acting on a number that did not mean what it said. + +* Monthly peaks must reset on a month boundary, not only at startup: `_clean_old_peaks()` was + reachable only from `async_load()`, so an instance up across 1 November carried October's top-3 + into November (protection threshold and sensors a month stale). +* `peak_this_month` must track the HIGHEST peak, not the latest: `record_period_measurement()` + returns a PeakEvent for any entry while the top-3 fills, so assigning its power dropped a 6.0 kW + peak to a later 2.0 kW one. +* The predictive branch must ABSTAIN with no peak history: current_peak 0.0 makes + `predicted_margin` always negative, which voted -1.5 C at weight 0.85 - above T1 (0.65) and + T2 (0.81) thermal-debt recovery. +""" + +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +import pytest + +from custom_components.effektguard.const import ( + DAYTIME_START_HOUR, + EFFECT_OFFSET_PREDICTIVE, + EFFECT_WEIGHT_PREDICTIVE, +) +from custom_components.effektguard.optimization.effect_layer import EffectManager + +DAYTIME_HOUR = DAYTIME_START_HOUR + 1 # 07:00 - avoids the 50% night weighting + +OCTOBER = datetime(2025, 10, 20, 7, 0) +NOVEMBER = datetime(2025, 11, 3, 7, 0) + +# A house cooling fast enough to trigger the predictive power-increase branch. +COOLING_TREND = {"trend": "cooling", "rate_per_hour": -0.5, "confidence": 1.0} + + +class TestMonthlyPeaksReset: + @pytest.mark.asyncio + async def test_last_months_peaks_do_not_survive_into_this_month(self, hass, monkeypatch): + """An instance up across a month boundary carried October into November.""" + effect = EffectManager(hass) + await effect.record_period_measurement(6.0, DAYTIME_HOUR, OCTOBER) + assert effect.get_monthly_peak_summary()["count"] == 1 + + # Time moves into November. This is what the coordinator now calls on month change. + monkeypatch.setattr( + "custom_components.effektguard.optimization.effect_layer.dt_util.now", + lambda: NOVEMBER, + ) + effect.prune_peaks_for_current_month() + + summary = effect.get_monthly_peak_summary() + assert summary["count"] == 0, ( + "October's peaks survived into November. The effect tariff bills a MONTHLY peak, " + "so the protection threshold and the peak sensor would be a month stale." + ) + assert summary["highest"] == 0.0 + + @pytest.mark.asyncio + async def test_this_months_peaks_are_kept(self, hass, monkeypatch): + """Do not over-correct: pruning must not eat the current month.""" + effect = EffectManager(hass) + await effect.record_period_measurement(6.0, DAYTIME_HOUR, NOVEMBER) + + monkeypatch.setattr( + "custom_components.effektguard.optimization.effect_layer.dt_util.now", + lambda: NOVEMBER, + ) + effect.prune_peaks_for_current_month() + + assert effect.get_monthly_peak_summary()["count"] == 1 + + +class TestMonthlyPeakIsTheHighest: + @pytest.mark.asyncio + async def test_summary_reports_the_highest_not_the_latest(self, hass): + """The coordinator must read `highest`, not the returned PeakEvent.""" + effect = EffectManager(hass) + + await effect.record_period_measurement(6.0, DAYTIME_HOUR, OCTOBER) + event = await effect.record_period_measurement( + 2.0, DAYTIME_HOUR + 4, OCTOBER + timedelta(days=1) + ) + + # The second, SMALLER hour (on its own day) still returns a PeakEvent (top-3 not full). + assert event is not None + assert event.effective_power == pytest.approx(2.0) + + # Which is exactly why assigning it to peak_this_month was wrong. + assert effect.get_monthly_peak_summary()["highest"] == pytest.approx(6.0) + + def test_coordinator_reads_the_summary_not_the_event(self): + """Regression guard on the coordinator's assignment.""" + import inspect + + from custom_components.effektguard.coordinator import EffektGuardCoordinator + + src = inspect.getsource(EffektGuardCoordinator._update_peak_tracking) + + assert "self.peak_this_month = peak_event.effective_power" not in src, ( + "peak_this_month is being set to the LATEST peak. A 6.0 kW peak followed by a " + "2.0 kW quarter would silently drop the monthly peak to 2.0 kW." + ) + assert 'get_monthly_peak_summary()["highest"]' in src + + +class TestPredictiveBranchNeedsAPeakHistory: + def test_no_peak_history_means_no_heat_reducing_vote(self, hass): + """On a fresh install the layer must ABSTAIN, not vote -1.5 @ 0.85.""" + effect = EffectManager(hass) # no peaks recorded at all + + decision = effect.evaluate_layer( + current_peak=0.0, + current_power=2.0, + thermal_trend=COOLING_TREND, + enable_peak_protection=True, + ) + + assert decision.offset != pytest.approx(EFFECT_OFFSET_PREDICTIVE), ( + f"With no peak history the effect layer voted {decision.offset:+.1f} C at weight " + f"{decision.weight} - because current_peak is 0.0, so predicted_margin is always " + "negative. Weight 0.85 outranks T1 (0.65) and T2 (0.81) thermal-debt recovery." + ) + assert decision.weight < EFFECT_WEIGHT_PREDICTIVE + assert decision.offset >= 0.0, "Missing input must never produce a heat-reducing vote" + + @pytest.mark.asyncio + async def test_predictive_still_fires_once_a_peak_exists(self, hass): + """Do not over-correct: with real history the predictive branch must still work.""" + effect = EffectManager(hass) + await effect.record_period_measurement(3.0, DAYTIME_HOUR, OCTOBER) + + decision = effect.evaluate_layer( + current_peak=3.0, + current_power=2.5, # +1.5 kW predicted increase -> margin < 1.0 kW + thermal_trend=COOLING_TREND, + enable_peak_protection=True, + ) + + assert decision.offset == pytest.approx(EFFECT_OFFSET_PREDICTIVE) + assert decision.weight == pytest.approx(EFFECT_WEIGHT_PREDICTIVE) diff --git a/tests/unit/effect/test_the_tariff_counts_at_most_one_peak_per_day.py b/tests/unit/effect/test_the_tariff_counts_at_most_one_peak_per_day.py new file mode 100644 index 00000000..3c0feaf5 --- /dev/null +++ b/tests/unit/effect/test_the_tariff_counts_at_most_one_peak_per_day.py @@ -0,0 +1,100 @@ +"""The effect tariff counts at most ONE peak per day - the three must come from THREE days. + +Ellevio, "Så fungerar effektavgiften": the monthly charge is the mean of the three highest hourly +peaks, and "only one power peak per day is counted, so the three peaks must come from three +different days." https://www.ellevio.se/abonnemang/elnatspriser/ny-prismodell-baserad-pa-effekt/ + +Date-blind top-3 let one cold day fill all three slots. That overstates the bill and understates +the margin the pump is then throttled against (9/8/7 from one Saturday vs a real third day of 4 kW). +""" + +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from custom_components.effektguard.const import POWER_SOURCE_EXTERNAL_METER +from custom_components.effektguard.optimization.effect_layer import EffectManager + + +@pytest.fixture +def manager(): + mgr = EffectManager(MagicMock()) + mgr.async_save = AsyncMock() + return mgr + + +async def _record(mgr, power_kw, day, hour): + return await mgr.record_period_measurement( + power_kw=power_kw, + period=hour, + timestamp=datetime(2026, 1, day, hour, 0), + source=POWER_SOURCE_EXTERNAL_METER, + ) + + +@pytest.mark.asyncio +async def test_three_hours_on_one_day_count_as_one_peak(manager): + """9, 8 and 7 kW on the same date must yield ONE tracked peak, not three.""" + await _record(manager, 9.0, day=10, hour=7) + await _record(manager, 8.0, day=10, hour=18) + await _record(manager, 7.0, day=10, hour=20) + + assert len(manager._monthly_peaks) == 1 + assert manager._monthly_peaks[0].actual_power == 9.0 + + +@pytest.mark.asyncio +async def test_a_higher_hour_replaces_its_own_day(manager): + """The day's counted peak is its highest hour - a later, higher hour takes the slot over.""" + await _record(manager, 6.0, day=10, hour=8) + event = await _record(manager, 9.0, day=10, hour=17) + + assert event is not None + assert len(manager._monthly_peaks) == 1 + assert manager._monthly_peaks[0].actual_power == 9.0 + + +@pytest.mark.asyncio +async def test_a_lower_hour_on_an_already_counted_day_cannot_evict_another_day(manager): + """The trap the date-blind top-3 walks into. + + Day 10 peaked at 9 kW, day 11 at 5, day 12 at 4. A 6 kW hour on day 10 beats day 12's + 4 kW - but day 10 is already counted at 9, so the 6 must not evict day 12. Without the + one-per-day rule the bill gains a second day-10 entry and loses a real billing day. + """ + await _record(manager, 9.0, day=10, hour=7) + await _record(manager, 5.0, day=11, hour=7) + await _record(manager, 4.0, day=12, hour=7) + + event = await _record(manager, 6.0, day=10, hour=19) + + assert event is None + days = sorted(p.timestamp.day for p in manager._monthly_peaks) + assert days == [10, 11, 12] + assert sorted(p.actual_power for p in manager._monthly_peaks) == [4.0, 5.0, 9.0] + + +@pytest.mark.asyncio +async def test_three_days_fill_three_slots_and_a_fourth_evicts_the_lowest_day(manager): + await _record(manager, 9.0, day=10, hour=7) + await _record(manager, 8.0, day=11, hour=7) + await _record(manager, 7.0, day=12, hour=7) + + event = await _record(manager, 8.5, day=13, hour=7) + + assert event is not None + assert len(manager._monthly_peaks) == 3 + days = sorted(p.timestamp.day for p in manager._monthly_peaks) + assert days == [10, 11, 13] + + +@pytest.mark.asyncio +async def test_replacement_within_a_day_compares_effective_power(manager): + """A 9 kW night hour bills as 4.5 - a later 5 kW day hour outbills it and takes the day.""" + await _record(manager, 9.0, day=10, hour=2) # night: effective 4.5 + event = await _record(manager, 5.0, day=10, hour=12) # day: effective 5.0 + + assert event is not None + assert len(manager._monthly_peaks) == 1 + assert manager._monthly_peaks[0].effective_power == 5.0 diff --git a/tests/unit/optimization/test_a_pump_with_no_room_sensor_is_not_driven_on_a_placeholder.py b/tests/unit/optimization/test_a_pump_with_no_room_sensor_is_not_driven_on_a_placeholder.py new file mode 100644 index 00000000..f73652d7 --- /dev/null +++ b/tests/unit/optimization/test_a_pump_with_no_room_sensor_is_not_driven_on_a_placeholder.py @@ -0,0 +1,85 @@ +"""A NIBE with no room sensor must not be driven on the placeholder indoor temperature. + +A pump with no BT50 is a supported configuration: it runs on degree minutes and the heating curve. +The adapter substitutes DEFAULT_INDOOR_TEMP (21.0) for display and sets indoor_temp_valid=False so +comfort-reasoning layers abstain. The comfort layer must honour that flag: any target below the +placeholder would otherwise read as a permanent, uncorrectable overshoot and coast the pump to +minimum output all winter, on a house nobody is measuring. + +Invariant: with indoor_temp_valid=False the comfort layer abstains (weight 0, offset 0); with a +real reading it still corrects a genuine overshoot or a genuinely cold house. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.const import DEFAULT_INDOOR_TEMP, MIN_TEMP_LIMIT +from custom_components.effektguard.optimization.comfort_layer import ComfortLayer + +# Targets a real owner can set. All of them sit BELOW the placeholder, which is the whole problem. +COOL_TARGETS = [20.5, 20.0, 19.0, 18.5] + + +def _sensorless_pump() -> NibeState: + """Exactly what the adapter builds when there is no BT50: the placeholder, flagged invalid.""" + return NibeState( + outdoor_temp=-5.0, + indoor_temp=DEFAULT_INDOOR_TEMP, + supply_temp=42.0, + return_temp=37.0, + degree_minutes=-150.0, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc), + indoor_temp_valid=False, + ) + + +def _pump_with_a_real_sensor(indoor: float) -> NibeState: + state = _sensorless_pump() + state.indoor_temp = indoor + state.indoor_temp_valid = True + return state + + +def test_the_placeholder_is_above_every_cool_target_which_is_why_this_bites(): + """The precondition. If DEFAULT_INDOOR_TEMP ever drops, re-derive these numbers.""" + assert DEFAULT_INDOOR_TEMP == 21.0 + assert all(t < DEFAULT_INDOOR_TEMP for t in COOL_TARGETS) + assert min(COOL_TARGETS) >= MIN_TEMP_LIMIT, "an allowed target must be above the safety floor" + + +@pytest.mark.parametrize("target", COOL_TARGETS) +def test_the_comfort_layer_abstains_with_no_room_sensor(target): + """No measurement, no comfort opinion. Not a small one - none.""" + decision = ComfortLayer(target_temp=target).evaluate_layer(_sensorless_pump()) + + assert decision.weight == 0.0, ( + f"With no room sensor and a target of {target} C, the comfort layer commanded " + f"{decision.offset:+.2f} C at weight {decision.weight:.2f} - '{decision.reason}'. That " + f"deviation is measured against DEFAULT_INDOOR_TEMP ({DEFAULT_INDOOR_TEMP} C), a " + f"placeholder. Nothing is measuring this house, so the 'overshoot' can never be corrected " + f"and the pump stays coasted for the whole winter." + ) + assert decision.offset == 0.0 + + +class TestTheLayerStillWorksWhenItCanSee: + """The regression guard on the guard: abstaining must not break a normal house.""" + + def test_a_real_overshoot_is_still_corrected(self): + decision = ComfortLayer(target_temp=21.0).evaluate_layer(_pump_with_a_real_sensor(23.0)) + + assert decision.weight > 0.0 + assert decision.offset < 0.0, "a house that is genuinely 2 C too warm must still coast" + + def test_a_real_cold_house_is_still_heated(self): + decision = ComfortLayer(target_temp=21.0).evaluate_layer(_pump_with_a_real_sensor(19.5)) + + assert decision.weight > 0.0 + assert decision.offset > 0.0, "a house that is genuinely 1.5 C too cold must still heat" diff --git a/tests/unit/optimization/test_airflow_energy_balance.py b/tests/unit/optimization/test_airflow_energy_balance.py new file mode 100644 index 00000000..a817b126 --- /dev/null +++ b/tests/unit/optimization/test_airflow_energy_balance.py @@ -0,0 +1,92 @@ +"""Enhanced airflow must obey the energy balance, and it does not pay in a Swedish winter. + +The extra heat an exhaust-air pump extracts and its "improved COP" are the same joules: in steady +state Q_cond = P_el + Q_evap, so at constant electrical input d(Q_cond) = d(Q_evap) = P_el*d(COP). +`calculate_net_thermal_gain` must count that heat once - extra extraction minus the ventilation +penalty - never adding a separate COP term. + +Consequence: enhancement pays only above an outdoor temperature of +(indoor - AIRFLOW_EVAPORATOR_TEMP_DROP), around +9 C. Across the whole heating season it is a net +thermal LOSS, because the evaporator recovers only that drop while the building reheats every extra +cubic metre from outdoor all the way to indoor. +""" + +import pytest + +from custom_components.effektguard.const import ( + AIRFLOW_DEFAULT_ENHANCED, + AIRFLOW_DEFAULT_STANDARD, + AIRFLOW_EVAPORATOR_TEMP_DROP, +) +from custom_components.effektguard.optimization.airflow_optimizer import ( + calculate_net_thermal_gain, + evaporator_heat_extraction, + ventilation_heat_loss, +) + +INDOOR = 21.0 + +# Above this outdoor temperature the building has to reheat the extra air by less than the +# evaporator takes out of it, so enhancing pays. Below it, it cannot. +BREAK_EVEN_OUTDOOR = INDOOR - AIRFLOW_EVAPORATOR_TEMP_DROP + + +def _net(outdoor: float) -> float: + return calculate_net_thermal_gain( + flow_standard=AIRFLOW_DEFAULT_STANDARD, + flow_enhanced=AIRFLOW_DEFAULT_ENHANCED, + temp_indoor=INDOOR, + temp_outdoor=outdoor, + ) + + +def test_the_extra_heat_is_not_counted_twice(): + """Net gain must be the extra extraction minus the ventilation penalty. Nothing else. + + Both extra terms describe the same joules: heat that entered the refrigerant at the + evaporator and left it at the condenser. + """ + outdoor = 0.0 + + extraction = evaporator_heat_extraction(AIRFLOW_DEFAULT_ENHANCED) - evaporator_heat_extraction( + AIRFLOW_DEFAULT_STANDARD + ) + penalty = ventilation_heat_loss( + AIRFLOW_DEFAULT_ENHANCED, INDOOR, outdoor + ) - ventilation_heat_loss(AIRFLOW_DEFAULT_STANDARD, INDOOR, outdoor) + + assert _net(outdoor) == pytest.approx(extraction - penalty, abs=0.01), ( + f"Net gain at {outdoor:.0f} C is {_net(outdoor):.3f} kW, but the energy balance allows " + f"only extraction ({extraction:.3f}) minus penalty ({penalty:.3f}) = " + f"{extraction - penalty:.3f} kW. The COP term is the extraction term again." + ) + + +@pytest.mark.parametrize("outdoor", [8.0, 5.0, 0.0, -5.0, -10.0, -15.0]) +def test_enhancing_is_a_thermal_loss_all_winter(outdoor): + """Across the whole Swedish heating season, pulling more air through the house costs heat. + + Break-even is +9 C. Every one of these is a heating-season temperature and every one of them + is below it. + """ + net = _net(outdoor) + + assert net < 0.0, ( + f"At {outdoor:+.0f} C outdoor the model says enhanced airflow GAINS {net:.3f} kW. The " + f"evaporator takes only {AIRFLOW_EVAPORATOR_TEMP_DROP:.0f} C out of the extra air while " + f"the building must reheat it from {outdoor:+.0f} C to {INDOOR:.0f} C." + ) + + +def test_break_even_is_where_the_physics_puts_it(): + """Break-even is indoor minus the evaporator's temperature drop - about +9 C, not -15 C.""" + assert _net(BREAK_EVEN_OUTDOOR + 2.0) > 0.0, "above break-even, enhancing should pay" + assert _net(BREAK_EVEN_OUTDOOR - 2.0) < 0.0, "below break-even, it cannot" + + +def test_the_loss_deepens_as_it_gets_colder(): + """Colder outdoor air means a bigger reheat bill for the same extra cubic metres.""" + losses = [_net(t) for t in (10.0, 0.0, -10.0, -20.0)] + + for warmer, colder in zip(losses, losses[1:]): + assert colder < warmer, f"net gain must fall as it gets colder, got {losses}" diff --git a/tests/unit/optimization/test_compressor_wear_guard.py b/tests/unit/optimization/test_compressor_wear_guard.py new file mode 100644 index 00000000..1364e4a1 --- /dev/null +++ b/tests/unit/optimization/test_compressor_wear_guard.py @@ -0,0 +1,124 @@ +"""When the compressor is saturated, a higher offset buys no heat - only wear and DM deficit. + +The offset raises the pump's supply setpoint S1. Once the compressor is at maximum frequency a +higher S1 produces no extra heat; it only holds the machine flat out longer and deepens the +degree-minute deficit (DM = integral(BT25 - S1)), which the auxiliary heater exists to absorb +(F-124). So when CompressorHealthMonitor reports COMPRESSOR_RISK_HIGH, the decision engine +declines to ask for MORE: `_apply_compressor_wear_guard` HOLDS the offset. It never CUTS the +offset - the boost was producing no heat, so declining it costs no comfort - and it never +overrides the absolute safety floor. +""" + +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +import pytest + +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.const import COMPRESSOR_RISK_HIGH +from custom_components.effektguard.models.nibe import NibeF750Profile +from custom_components.effektguard.optimization.decision_engine import DecisionEngine +from custom_components.effektguard.optimization.effect_layer import EffectManager +from custom_components.effektguard.optimization.price_layer import PriceAnalyzer +from custom_components.effektguard.optimization.thermal_layer import ThermalModel + +NOW = datetime(2026, 1, 15, 12, 0) + + +def _engine() -> DecisionEngine: + config = { + "target_indoor_temp": 21.0, + "tolerance": 0.5, + "optimization_mode": "balanced", + "latitude": 59.33, + "heating_type": "radiator", + "heat_loss_coefficient": 150.0, + "thermal_mass": 0.7, + "insulation_quality": 1.0, + } + return DecisionEngine( + price_analyzer=PriceAnalyzer(), + effect_manager=EffectManager(MagicMock()), + thermal_model=ThermalModel(0.7, 1.0), + config=config, + heat_pump_model=NibeF750Profile(), + ) + + +def _state(degree_minutes: float, indoor: float = 20.0, hz: int = 115) -> NibeState: + """A house in thermal debt with the compressor already flat out.""" + return NibeState( + outdoor_temp=-12.0, + indoor_temp=indoor, + supply_temp=45.0, + return_temp=40.0, + degree_minutes=degree_minutes, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=NOW, + compressor_hz=hz, + power_kw=3.0, + ) + + +def _decide(engine: DecisionEngine, state: NibeState, risk: str | None): + return engine.calculate_decision( + nibe_state=state, + price_data=None, + weather_data=None, + current_peak=0.0, + current_power=3.0, + compressor_risk=risk, + ) + + +def test_a_saturated_compressor_is_not_asked_for_more(): + """At HIGH risk the boost cannot produce heat, so it must not be demanded.""" + engine = _engine() + state = _state(degree_minutes=-600.0) + + unguarded = _decide(engine, state, risk=None).offset + guarded = _decide(engine, state, risk=COMPRESSOR_RISK_HIGH).offset + + assert unguarded > 0.5, "precondition: without the guard the engine wants to boost" + assert guarded < unguarded, ( + f"The compressor has been above 100 Hz for over fifteen minutes - it is at maximum and " + f"has nothing left to give. The engine still demanded {guarded:+.2f} (unguarded: " + f"{unguarded:+.2f}). That offset buys no heat, only wear and a deeper DM deficit." + ) + + +def test_the_guard_holds_heat_it_never_cuts_it(): + """A wear guard that cools the house is not a wear guard, it is a fault.""" + engine = _engine() + state = _state(degree_minutes=-600.0) + + guarded = _decide(engine, state, risk=COMPRESSOR_RISK_HIGH).offset + + assert guarded >= 0.0, ( + f"The guard reduced the offset to {guarded:+.2f}, taking heat AWAY from a house that is " + f"already in thermal debt. It may decline to ask for MORE; it may never ask for less." + ) + + +def test_the_absolute_safety_floor_still_wins(): + """A house below the hard minimum gets everything, whatever the compressor is doing.""" + engine = _engine() + freezing = _state(degree_minutes=-600.0, indoor=17.0) # below MIN_TEMP_LIMIT + + decision = _decide(engine, freezing, risk=COMPRESSOR_RISK_HIGH) + + assert decision.is_emergency, "an indoor temperature below the floor is not negotiable" + assert decision.offset > 5.0, ( + f"The house is at 17 C. The wear guard must not stand between it and the heat: got " + f"{decision.offset:+.2f}." + ) + + +def test_a_healthy_compressor_is_left_alone(): + """The guard must be silent when the compressor has headroom.""" + engine = _engine() + state = _state(degree_minutes=-600.0, hz=60) + + assert _decide(engine, state, risk=None).offset == _decide(engine, state, risk="OK").offset diff --git a/tests/unit/optimization/test_cost_may_coast_the_house_but_not_starve_it.py b/tests/unit/optimization/test_cost_may_coast_the_house_but_not_starve_it.py new file mode 100644 index 00000000..182993b0 --- /dev/null +++ b/tests/unit/optimization/test_cost_may_coast_the_house_but_not_starve_it.py @@ -0,0 +1,274 @@ +"""A cost layer may coast the house within its comfort band. It may not coast it out. + +Using the band is the thermal battery - the point of the integration. But step 4 of +`_aggregate_layers` takes the critical layer's vote alone, so a price layer at PEAK (weight 1.0, +offset -10) is both max and min and the comfort layer never enters the sum. Cost then keeps cutting +heat into an already-cold house, and nothing else objects: degree minutes are blind by construction +(DM = integral(BT25 - S1), so lowering the curve lowers S1 and DM *improves* as the house cools). + +Invariant: once the house is below its comfort band, a cost layer's reduction is floored at the +comfort layer's own (graduated) demand. The floor is ramped in via `starvation`, not switched at a +threshold, so a dithering indoor sensor cannot chatter the curve between extremes. It never weakens a +safety or physics vote, and it never becomes a heat source of its own. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from custom_components.effektguard.const import ( + LAYER_WEIGHT_SAFETY, + PRICE_OFFSET_PEAK, + SAFETY_EMERGENCY_OFFSET, +) +from custom_components.effektguard.optimization.decision_engine import ( + COMFORT_LAYER_NAME, + DecisionEngine, + LayerDecision, + SAFETY_LAYER_NAME, +) + + +def _engine() -> DecisionEngine: + from unittest.mock import MagicMock + + return DecisionEngine( + price_analyzer=MagicMock(), + effect_manager=MagicMock(), + thermal_model=MagicMock(), + config={"target_indoor_temp": 21.0, "tolerance": 0.5}, + ) + + +def _price_at_peak() -> LayerDecision: + return LayerDecision( + name="Spot Price", + offset=PRICE_OFFSET_PEAK, + weight=LAYER_WEIGHT_SAFETY, + reason="PEAK quarter", + is_cost_layer=True, + ) + + +def _comfort_wanting_heat(offset: float = 0.9) -> LayerDecision: + return LayerDecision( + name=COMFORT_LAYER_NAME, + offset=offset, + weight=0.5, + reason="Too cold", + ) + + +class TestInsideTheBandCostIsFree: + """The thermal battery. Do not break it while fixing the starvation.""" + + def test_a_peak_quarter_may_coast_a_house_that_is_at_target(self): + engine = _engine() + layers = [ + _price_at_peak(), + LayerDecision(name=COMFORT_LAYER_NAME, offset=0.0, weight=0.0, reason="At target"), + ] + + offset = engine._aggregate_layers(layers, starvation=0.0) + + assert offset == pytest.approx(PRICE_OFFSET_PEAK), ( + f"A PEAK quarter with the house at target commanded {offset:+.2f} instead of " + f"{PRICE_OFFSET_PEAK:+.2f}. Coasting a house that is AT target is the entire point of " + f"the integration - the fix for starvation must not disable it." + ) + + def test_a_peak_quarter_may_coast_a_house_drifting_inside_the_band(self): + """0.3 C below target with a 0.5 C tolerance: still inside the band. Cost may use it.""" + engine = _engine() + layers = [_price_at_peak(), _comfort_wanting_heat(offset=0.2)] + + offset = engine._aggregate_layers(layers, starvation=0.0) + + assert offset == pytest.approx(PRICE_OFFSET_PEAK) + + +class TestOutsideTheBandCostMustYield: + """The house is colder than the owner asked for. Money stops being the priority.""" + + def test_a_peak_quarter_may_not_starve_a_house_below_its_band(self): + engine = _engine() + comfort = _comfort_wanting_heat(offset=0.9) + layers = [_price_at_peak(), comfort] + + offset = engine._aggregate_layers(layers, starvation=1.0) + + assert offset >= comfort.offset, ( + f"The house is below its comfort band and the price layer commanded {offset:+.2f} C - " + f"maximum heat reduction - while the comfort layer asked for {comfort.offset:+.2f} C. " + f"Comfort never entered the sum: step 4 takes the critical layer's vote alone. Degree " + f"minutes cannot object either, because lowering the curve makes DM look BETTER as the " + f"house gets colder. Nothing would have stopped this until the 18 C floor." + ) + + @pytest.mark.parametrize("comfort_demand", [0.3, 0.9, 1.5, 3.0]) + def test_the_floor_is_the_comfort_layers_own_graduated_demand(self, comfort_demand): + """Not a fixed number: the colder the house, the higher the floor.""" + engine = _engine() + layers = [_price_at_peak(), _comfort_wanting_heat(offset=comfort_demand)] + + offset = engine._aggregate_layers(layers, starvation=1.0) + + assert offset == pytest.approx(comfort_demand) + + def test_cost_is_still_allowed_to_reduce_heat_below_what_comfort_asked_for_it_just_cannot_cut( + self, + ): + """The floor never ADDS heat beyond comfort's request - it only stops the cut.""" + engine = _engine() + layers = [_price_at_peak(), _comfort_wanting_heat(offset=0.9)] + + offset = engine._aggregate_layers(layers, starvation=1.0) + + assert offset <= 0.9, "the floor must not become a heat SOURCE" + + +class TestTheFloorNeverWeakensSafety: + """It exists to bound COST. It must not touch a safety or physics vote.""" + + def test_a_critical_safety_vote_is_untouched(self): + """Safety at +10 must still win outright - the floor must not reduce it to comfort's ask.""" + engine = _engine() + layers = [ + LayerDecision( + name=SAFETY_LAYER_NAME, + offset=SAFETY_EMERGENCY_OFFSET, + weight=LAYER_WEIGHT_SAFETY, + reason="Below floor", + ), + _comfort_wanting_heat(offset=0.9), + ] + + offset = engine._aggregate_layers(layers, starvation=1.0) + + assert offset == pytest.approx(SAFETY_EMERGENCY_OFFSET) + + def test_a_safety_vote_alongside_a_cost_vote_still_wins_the_tie_break(self): + """Safety +10 vs price -10 ties by construction; the safety-biased tie-break must hold.""" + engine = _engine() + layers = [ + LayerDecision( + name=SAFETY_LAYER_NAME, + offset=SAFETY_EMERGENCY_OFFSET, + weight=LAYER_WEIGHT_SAFETY, + reason="Below floor", + ), + _price_at_peak(), + _comfort_wanting_heat(offset=0.9), + ] + + offset = engine._aggregate_layers(layers, starvation=1.0) + + assert offset == pytest.approx(SAFETY_EMERGENCY_OFFSET), ( + "With a non-cost layer also voting at critical weight, the tie-break already had a " + "safety opinion to weigh and the comfort floor must not interfere with it." + ) + + def test_the_floor_only_engages_when_every_critical_layer_is_a_cost_layer(self): + engine = _engine() + assert engine._all_critical_are_cost([_price_at_peak()]) is True + assert ( + engine._all_critical_are_cost( + [ + _price_at_peak(), + LayerDecision( + name=SAFETY_LAYER_NAME, + offset=10.0, + weight=LAYER_WEIGHT_SAFETY, + reason="", + ), + ] + ) + is False + ) + + +class TestTheFloorIsRampedNotSwitched: + """A boolean floor on a temperature threshold is a bang-bang controller. + + A boolean at `target - tolerance_range` jumps the command by up to 10 C on a hundredth of a + degree - and a real indoor sensor dithers by more, chattering a Modbus write every cycle. The + ramp fixes it by construction: at the inner edge the floor IS the cost layer's own vote (nothing + moves), climbing monotonically to the comfort layer's demand as the house leaves the band. + """ + + def _sweep(self, engine, comfort_offset: float = 0.2): + """The final offset as the house cools through the band, driving the real engine.""" + results = [] + for indoor in [21.00 - i * 0.01 for i in range(0, 61)]: + nibe = MagicMock() + nibe.indoor_temp = indoor + nibe.indoor_temp_valid = True + layers = [_price_at_peak(), _comfort_wanting_heat(offset=comfort_offset)] + starvation = engine._starvation_fraction(nibe) + results.append((indoor, engine._aggregate_layers(layers, starvation=starvation))) + return results + + def test_no_hundredth_of_a_degree_moves_the_command_by_more_than_a_degree(self): + """The defect, stated as the invariant it violates. It moved it by ten.""" + engine = _engine() + sweep = self._sweep(engine) + + jumps = [ + (a_temp, b_temp, abs(b_off - a_off)) + for (a_temp, a_off), (b_temp, b_off) in zip(sweep, sweep[1:]) + if abs(b_off - a_off) > 1.0 + ] + + assert not jumps, ( + "The control law is discontinuous. A 0.01 C step in indoor temperature moves the " + "commanded curve offset by: " + + ", ".join(f"{d:.2f} C between {a:.2f} and {b:.2f}" for a, b, d in jumps) + + ". A real indoor sensor dithers by more than 0.01 C, so the house sits on that " + "boundary flipping the curve between its extremes, writing to the pump every cycle." + ) + + def test_at_the_inner_edge_the_cost_layer_is_still_free(self): + """The thermal battery must not be narrowed by the ramp. Above the inner band, nothing.""" + engine = _engine() + nibe = MagicMock() + nibe.indoor_temp = engine.target_temp - engine.tolerance_range # exactly the inner edge + nibe.indoor_temp_valid = True + + assert engine._starvation_fraction(nibe) == 0.0 + assert engine._aggregate_layers( + [_price_at_peak(), _comfort_wanting_heat(offset=0.2)], + starvation=engine._starvation_fraction(nibe), + ) == pytest.approx(PRICE_OFFSET_PEAK) + + def test_at_the_band_the_owner_asked_for_the_comfort_layer_has_the_floor(self): + """The other end of the ramp. `tolerance` is the owner's own limit and it is honoured.""" + engine = _engine() + nibe = MagicMock() + nibe.indoor_temp = engine.target_temp - engine.tolerance # 20.5 at the defaults + nibe.indoor_temp_valid = True + + assert engine._starvation_fraction(nibe) == 1.0 + assert engine._aggregate_layers( + [_price_at_peak(), _comfort_wanting_heat(offset=0.4)], + starvation=engine._starvation_fraction(nibe), + ) == pytest.approx(0.4) + + def test_the_ramp_is_monotone(self): + """Colder house, higher floor. Never the reverse.""" + offsets = [offset for _, offset in self._sweep(_engine())] + + assert offsets == sorted(offsets), ( + "The floor must rise monotonically as the house cools. It does not: " + f"{[round(o, 2) for o in offsets]}" + ) + + def test_an_invalid_indoor_reading_abstains(self): + """Without a reading this cannot be measured, and nothing else can see it either.""" + engine = _engine() + nibe = MagicMock() + nibe.indoor_temp = 15.0 # would be deeply starved, if it were believable + nibe.indoor_temp_valid = False + + assert engine._starvation_fraction(nibe) == 0.0 diff --git a/tests/unit/optimization/test_dhw_does_not_start_only_to_abort.py b/tests/unit/optimization/test_dhw_does_not_start_only_to_abort.py new file mode 100644 index 00000000..1a89fe94 --- /dev/null +++ b/tests/unit/optimization/test_dhw_does_not_start_only_to_abort.py @@ -0,0 +1,99 @@ +"""DHW must not be allowed to start at a degree-minute value that aborts it on the next tick. + +Two thresholds govern hot water under thermal debt: `block` (do not START below this DM) and `abort` +(STOP a running cycle below this DM). Abort must be the DEEPER of the two: heating hot water steals +the compressor from space heating, so degree minutes always sink during a cycle, and an abort +shallower than block means every cycle that starts near the block threshold trips abort immediately - +the pump starts, stops, starts, stops. The fallback constants have the relationship right +(block -340, abort -500; abort 160 DM deeper). + +Invariants: in every climate zone abort < block; the reported block equals what EmergencyLayer +actually enforces (`warning - DM_CRITICAL_T2_MARGIN`); and abort never sinks past the absolute limit. +""" + +from __future__ import annotations + +import pytest + +from custom_components.effektguard.const import ( + DM_CRITICAL_T2_MARGIN, + DM_DHW_ABORT_FALLBACK, + DM_DHW_BLOCK_FALLBACK, + DM_THRESHOLD_AUX_LIMIT, +) +from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector +from custom_components.effektguard.optimization.dhw_optimizer import IntelligentDHWScheduler +from custom_components.effektguard.optimization.thermal_layer import EmergencyLayer + +LATITUDES = [59.33, 67.86, 55.60] # Stockholm, Kiruna, Malmo +OUTDOOR = [-20.0, -15.0, -10.0, 0.0, 5.0] + + +def _thresholds(latitude: float, outdoor: float) -> tuple[float, float]: + """(block, abort) as the running system computes them, for this zone and temperature.""" + detector = ClimateZoneDetector(latitude=latitude) + emergency = EmergencyLayer(detector, heating_type="radiator") + + optimizer = IntelligentDHWScheduler(emergency_layer=emergency, climate_detector=detector) + return optimizer.get_dm_block_and_abort_thresholds(outdoor) + + +def test_the_fallback_constants_say_which_way_round_it_goes(): + """The precondition, and the specification. Abort is DEEPER than block.""" + assert DM_DHW_ABORT_FALLBACK < DM_DHW_BLOCK_FALLBACK, ( + f"Even the fallback pair is inverted: block {DM_DHW_BLOCK_FALLBACK}, " + f"abort {DM_DHW_ABORT_FALLBACK}." + ) + + +@pytest.mark.parametrize("latitude", LATITUDES) +@pytest.mark.parametrize("outdoor", OUTDOOR) +def test_dhw_never_starts_at_a_degree_minute_that_aborts_it(latitude, outdoor): + """The whole finding, in one assertion, in every zone and at every temperature.""" + block, abort = _thresholds(latitude, outdoor) + + assert abort < block, ( + f"At latitude {latitude}, {outdoor} °C: DHW is BLOCKED from starting below {block:.0f} DM, " + f"but a running cycle ABORTS below {abort:.0f} DM - which is {block - abort:.0f} DM " + f"SHALLOWER. Every degree-minute value between {block:.0f} and {abort:.0f} is one where the " + f"pump is allowed to start hot water and then told to stop it on the next tick. Heating hot " + f"water always sinks degree minutes, so it starts, aborts, starts, aborts." + ) + + +@pytest.mark.parametrize("latitude", LATITUDES) +@pytest.mark.parametrize("outdoor", OUTDOOR) +def test_the_block_threshold_is_the_one_that_is_actually_enforced(latitude, outdoor): + """What the optimizer reports as the block must be what EmergencyLayer enforces. + + `should_block_dhw` blocks at `warning - DM_CRITICAL_T2_MARGIN`. The optimizer published plain + `warning` as `thermal_debt_threshold_block`, so the diagnostic named a threshold that blocks + nothing - 200 DM shallower than the one that does. + """ + detector = ClimateZoneDetector(latitude=latitude) + emergency = EmergencyLayer(detector, heating_type="radiator") + enforced = emergency.get_adjusted_dm_thresholds(outdoor)["warning"] - DM_CRITICAL_T2_MARGIN + + block, _ = _thresholds(latitude, outdoor) + + assert block == pytest.approx(enforced), ( + f"The optimizer reports a DHW block threshold of {block:.0f} DM, but EmergencyLayer actually " + f"blocks at {enforced:.0f}. The published number blocks nothing." + ) + + +@pytest.mark.parametrize("latitude", LATITUDES) +@pytest.mark.parametrize("outdoor", OUTDOOR) +def test_abort_never_sinks_past_the_absolute_limit(latitude, outdoor): + """The absolute limit is the floor. Below it the emergency layer owns the pump outright. + + Clamping at the limit ITSELF is deliberate: clamping at `limit + buffer` would push abort back + ABOVE block in the coldest zone, where block already sits at -1400, re-creating the inversion + this file exists to prevent. An abort exactly at the limit is the hardest possible stop. + """ + _, abort = _thresholds(latitude, outdoor) + + assert abort >= DM_THRESHOLD_AUX_LIMIT, ( + f"Abort threshold {abort:.0f} is deeper than the absolute limit " + f"{DM_THRESHOLD_AUX_LIMIT:.0f}, past which DHW cannot run at all." + ) diff --git a/tests/unit/optimization/test_every_rung_of_the_ladder_is_reachable.py b/tests/unit/optimization/test_every_rung_of_the_ladder_is_reachable.py new file mode 100644 index 00000000..29ef8b6f --- /dev/null +++ b/tests/unit/optimization/test_every_rung_of_the_ladder_is_reachable.py @@ -0,0 +1,122 @@ +"""Every rung of the proactive ladder (Z1-Z5) must be reachable by some degree-minute value. + +Zone 5's band is `warning < DM <= zone5_threshold`, and `zone5_threshold` is +`normal_max * PROACTIVE_ZONE5_THRESHOLD_PERCENT`. When that percent was 1.00, zone5_threshold equalled +normal_max - and every climate zone also sets its warning threshold to normal_max - so both ends of +the band were the same number and Z5 could never fire. It is now 0.875, strictly below the warning +threshold, restoring the +3.0 rung. + +Two thresholds coinciding deletes a rung silently, so every zone is swept across the whole DM range +and required to expose all five rungs, plus a monotone-escalation check across both layers. +""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector +from custom_components.effektguard.optimization.thermal_layer import EmergencyLayer, ProactiveLayer + +# Stockholm, Kiruna, Malmo - three zones with different normal ranges. +LATITUDES = [59.33, 67.86, 55.60] +OUTDOOR = [-20.0, -10.0, 0.0, 5.0] + + +def _state(degree_minutes: float, outdoor: float) -> NibeState: + return NibeState( + outdoor_temp=outdoor, + indoor_temp=20.5, # below target, so nothing abstains on comfort grounds + supply_temp=40.0, + return_temp=35.0, + degree_minutes=degree_minutes, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 12, 0), + compressor_hz=50, + ) + + +def _zones_reachable(latitude: float, outdoor: float) -> dict[str, float]: + """Sweep DM and collect every proactive zone that actually fires, with its offset.""" + layer = ProactiveLayer(ClimateZoneDetector(latitude=latitude), heating_type="radiator") + + seen: dict[str, float] = {} + for dm_tenths in range(0, -16000, -10): # 1 DM resolution, 0 to -1600 + decision = layer.evaluate_layer( + _state(dm_tenths / 10.0, outdoor), None, 21.0, is_volatile=False + ) + if decision.zone and decision.zone not in seen: + seen[decision.zone] = decision.offset + + return seen + + +@pytest.mark.parametrize("latitude", LATITUDES) +@pytest.mark.parametrize("outdoor", OUTDOOR) +def test_zone_5_is_reachable(latitude, outdoor): + """The bridging rung between Z4 and the first critical tier.""" + reachable = _zones_reachable(latitude, outdoor) + + assert "Z5" in reachable, ( + f"At latitude {latitude} and {outdoor} °C, no degree-minute value anywhere between 0 and " + f"-1600 lands in Zone 5. Its band is `warning < DM <= zone5_threshold`, and " + f"PROACTIVE_ZONE5_THRESHOLD_PERCENT = 1.00 makes zone5_threshold equal to normal_max - which " + f"every climate zone also uses as its warning threshold. Both ends of the band are the same " + f"number. The ladder steps 2.5 -> 4.0 where it was built to step 2.5 -> 3.0 -> 4.0. " + f"Zones that DO fire: {sorted(reachable)}" + ) + + +@pytest.mark.parametrize("latitude", LATITUDES) +@pytest.mark.parametrize("outdoor", OUTDOOR) +def test_the_whole_proactive_ladder_is_reachable(latitude, outdoor): + """Not just Z5. Every rung the code declares must have a step to stand on. + + The zone bands are computed as percentages of one threshold and bounded by another. Two of them + coinciding deletes a rung in silence - which is exactly what happened - so this checks all five + rather than the one we know about. + """ + reachable = _zones_reachable(latitude, outdoor) + + missing = [zone for zone in ("Z1", "Z2", "Z3", "Z4", "Z5") if zone not in reachable] + + assert not missing, ( + f"At latitude {latitude} and {outdoor} °C the proactive ladder has rungs with no step: " + f"{missing}. Every zone must be reachable by some degree-minute value, or it is dead code " + f"that reads like a working safety feature. Reachable: {sorted(reachable)}" + ) + + +@pytest.mark.parametrize("latitude", LATITUDES) +def test_the_ladder_escalates_monotonically(latitude): + """A ladder that goes DOWN a rung as the house gets colder is not a ladder. + + The ladder spans two layers (proactive Z1-Z5, then emergency T1-T3), and the proactive layer + correctly stands down to zero at the handover. So the invariant is on the strongest boost EITHER + layer asks for: it must never weaken as the house falls further into debt. + """ + proactive = ProactiveLayer(ClimateZoneDetector(latitude=latitude), heating_type="radiator") + emergency = EmergencyLayer(ClimateZoneDetector(latitude=latitude), heating_type="radiator") + + strongest_so_far = 0.0 + previous = (0.0, "start") + for dm in range(0, -1600, -5): + state = _state(float(dm), -10.0) + p = proactive.evaluate_layer(state, None, 21.0, is_volatile=False) + e = emergency.evaluate_layer(state, None, None, 21.0, 1.0, is_volatile=False) + + asked = max(p.offset, e.offset) + rung = e.tier if e.offset >= p.offset else p.zone + + assert asked >= strongest_so_far, ( + f"At latitude {latitude}, degree minutes fell to {dm} - the house is deeper in thermal " + f"debt than at {previous[1]} - and the strongest boost any layer asked for DROPPED from " + f"{strongest_so_far:+.1f} to {asked:+.1f} (now {rung}). The ladder has a rung that steps " + f"DOWN as the house gets colder." + ) + strongest_so_far = asked + previous = (asked, f"DM {dm}") diff --git a/tests/unit/optimization/test_free_electricity_is_not_declined.py b/tests/unit/optimization/test_free_electricity_is_not_declined.py new file mode 100644 index 00000000..cf5f75ca --- /dev/null +++ b/tests/unit/optimization/test_free_electricity_is_not_declined.py @@ -0,0 +1,236 @@ +"""Free electricity must be bought, and the dear plateau of a high-wind day must not be. + +On a high-wind day the distribution is a step, not a curve: 83 quarters at 120 ore and 13 at -10, so +p25 == p75 == p90 == 120 and the 83 dearest quarters all satisfy `price <= p25`. Rank alone therefore +calls them CHEAP and commands +4 C at the most expensive moment. The mirror image is more common - a +long free run into a short expensive one - where the free plateau IS the median. + +The fix is one guard on one band: `price <= p25 and price < p90` for CHEAP. `price < p90` earns its +place only there (p25 can equal p90 - the dear plateau), and every other band is already implied by +the upstream spread check that guarantees p90 > p10. The dear side keeps its strict `>`: an +inescapable plateau is the price of the day, not a PEAK to coast through. +""" + +from __future__ import annotations + +from collections import Counter +from datetime import datetime, timedelta, timezone + +import pytest + +from custom_components.effektguard.optimization.price_layer import ( + PriceAnalyzer, + QuarterClassification, +) + + +class _Period: + """A quarter-hour period, as the price adapter hands them over.""" + + def __init__(self, index: int, price: float): + self.price = price + self.start = datetime(2026, 1, 15, 0, 0, tzinfo=timezone.utc) + timedelta( + minutes=15 * index + ) + self.end = self.start + timedelta(minutes=15) + + +def _classify(prices: list[float]) -> list[QuarterClassification]: + periods = [_Period(index, price) for index, price in enumerate(prices)] + result = PriceAnalyzer().classify_quarterly_periods(periods) + return [result[index] for index in range(len(prices))] + + +# A windy night into a calm evening. Fourteen hours of free power, ten hours at 80 ore. +FREE_HOURS = 14 +COSTLY_HOURS = 10 +A_FREE_DAY = [0.0] * (FREE_HOURS * 4) + [80.0] * (COSTLY_HOURS * 4) + +# The high-wind day the median guard was written for: a short negative run, a long dear plateau. +A_NEGATIVE_PRICE_DAY = [-10.0] * 13 + [120.0] * 83 + + +class TestFreeElectricityIsBought: + """The bug. Fourteen hours of free power, and the optimiser would not touch it.""" + + def test_the_free_quarters_are_not_called_normal(self): + classifications = _classify(A_FREE_DAY) + free = classifications[: FREE_HOURS * 4] + + assert all(c == QuarterClassification.VERY_CHEAP for c in free), ( + f"{FREE_HOURS} hours at exactly 0.00 ore classified as {Counter(c.name for c in free)}. " + f"The electricity is FREE. It is more than half the day, so it is also the median - and " + f"the cheap bands demanded `price < median`, which 0.0 is not. The one thing this " + f"integration exists to do is move heat into hours like these." + ) + + def test_the_expensive_quarters_are_not_called_cheap(self): + """The other half. Fixing the floor must not tell the house to heat at 80 ore.""" + costly = _classify(A_FREE_DAY)[FREE_HOURS * 4 :] + + assert not any( + c in (QuarterClassification.VERY_CHEAP, QuarterClassification.CHEAP) for c in costly + ), ( + f"The 80 ore quarters classified as {Counter(c.name for c in costly)}. They are the " + f"most expensive power available today and must never be a reason to add heat." + ) + + def test_the_day_is_not_uniformly_normal(self): + """The symptom, stated plainly: an 80 ore spread produced no signal whatsoever.""" + classifications = _classify(A_FREE_DAY) + + assert len(set(classifications)) > 1, ( + "Every quarter of a day with an 80 ore spread classified NORMAL. The price layer is " + "blind: it will not pre-heat on free power and it will not coast at 80 ore." + ) + + +class TestTheCaseTheGuardWasWrittenFor: + """The regression guard, and it is the more dangerous of the two failures.""" + + def test_negative_prices_are_still_very_cheap(self): + negative = _classify(A_NEGATIVE_PRICE_DAY)[:13] + + assert all(c == QuarterClassification.VERY_CHEAP for c in negative), ( + f"Quarters at MINUS 10 ore - the grid is paying the house to take the power - " + f"classified as {Counter(c.name for c in negative)}." + ) + + def test_the_dear_plateau_is_never_called_cheap(self): + """THE bug the median guard exists to prevent: +4.0 C at the day's highest price.""" + plateau = _classify(A_NEGATIVE_PRICE_DAY)[13:] + + assert not any( + c in (QuarterClassification.VERY_CHEAP, QuarterClassification.CHEAP) for c in plateau + ), ( + f"The 83 quarters at the day's HIGHEST price (120 ore) classified as " + f"{Counter(c.name for c in plateau)}. They satisfy `price <= p25` because the plateau " + f"IS the 25th percentile, and classifying them cheap commands +4.0 C of extra heat at " + f"the most expensive moment of the day." + ) + + def test_an_inescapable_plateau_is_not_a_peak_either(self): + """A plateau you cannot escape is not a peak, it is just the price of the day. + + Loosening the dear side to `>=` would fix nothing and would make 83 of the day's 96 + quarters PEAK - telling the house to coast for twenty hours, with three hours of cheap + power to charge in. The strict `>` stays. + """ + plateau = _classify(A_NEGATIVE_PRICE_DAY)[13:] + + assert not any(c == QuarterClassification.PEAK for c in plateau), ( + f"{sum(c == QuarterClassification.PEAK for c in plateau)} of the day's 96 quarters " + f"classified PEAK. There is nowhere to shift the load to." + ) + + +class TestAnOrdinaryDayIsUntouched: + """The bands only move where the median IS the plateau. Everywhere else, nothing changes.""" + + def test_a_normal_price_curve_still_classifies_every_band(self): + """A textbook Nordic day: cheap at night, a morning peak, an evening peak.""" + prices = [20.0 + 60.0 * ((index % 48) / 48.0) for index in range(96)] + + classifications = _classify(prices) + seen = Counter(c.name for c in classifications) + + for band in ("VERY_CHEAP", "CHEAP", "NORMAL", "EXPENSIVE", "PEAK"): + assert seen[band] > 0, ( + f"An ordinary day with a 60 ore range produced no {band} quarters at all: {seen}. " + f"The fix was meant to be inert on days where the median is not a plateau." + ) + + def test_the_cheapest_quarters_of_an_ordinary_day_are_the_cheap_ones(self): + prices = [20.0 + 60.0 * ((index % 48) / 48.0) for index in range(96)] + classifications = _classify(prices) + + cheapest = min(range(96), key=lambda i: prices[i]) + dearest = max(range(96), key=lambda i: prices[i]) + + assert classifications[cheapest] == QuarterClassification.VERY_CHEAP + assert classifications[dearest] == QuarterClassification.PEAK + + +@pytest.mark.parametrize("free_fraction", [0.55, 0.60, 0.75, 0.90]) +def test_free_power_is_bought_however_much_of_the_day_it_covers(free_fraction): + """The plateau only has to exceed half the day to become the median. Beyond that it is worse. + + price_math's own docstring puts exactly-zero prices at "roughly a hundred hours a year per SE + bidding zone", and they arrive in long contiguous runs - which is exactly the shape that makes + the plateau the median. + """ + free_quarters = int(96 * free_fraction) + prices = [0.0] * free_quarters + [80.0] * (96 - free_quarters) + + classifications = _classify(prices)[:free_quarters] + + assert all(c == QuarterClassification.VERY_CHEAP for c in classifications), ( + f"With {free_fraction:.0%} of the day at exactly 0.00 ore, the free quarters classified as " + f"{Counter(c.name for c in classifications)}." + ) + + +class TestTheOneGuardThatEarnsItsPlace: + """`price < p90` on the CHEAP band. Every other band is already implied by the spread check.""" + + # Three levels, with the DEAR one spanning p25 through p90. This is the shape that needs the + # guard: without it the 60 ore quarters - which are p25, p75 AND p90 - classify CHEAP. + A_DEAR_PLATEAU_AT_THE_QUARTILE = [5.0] * 20 + [60.0] * 76 + + def test_a_dear_plateau_sitting_on_p25_is_not_cheap(self): + prices = self.A_DEAR_PLATEAU_AT_THE_QUARTILE + plateau = _classify(prices)[20:] + + assert not any( + c in (QuarterClassification.VERY_CHEAP, QuarterClassification.CHEAP) for c in plateau + ), ( + f"76 quarters at the day's HIGHEST price classified {Counter(c.name for c in plateau)}. " + f"They are p25, p75 and p90 all at once, so rank alone calls them cheap. This is the " + f"one case the guard exists for." + ) + + def test_the_cheap_quarters_of_that_day_are_still_found(self): + cheap = _classify(self.A_DEAR_PLATEAU_AT_THE_QUARTILE)[:20] + + assert all( + c in (QuarterClassification.VERY_CHEAP, QuarterClassification.CHEAP) for c in cheap + ), f"The 5 ore quarters classified {Counter(c.name for c in cheap)}." + + +class TestAMidLevelPlateauThatIsAlsoTheMedian: + """The third shape, and the one that proves `p90` is the right question and `median` is not. + + p25 is never above the median, so on the CHEAP band `price <= p25` already implies + `price <= median`. The two spellings can therefore only disagree when p25 IS the median - a + plateau covering the whole lower half of the day - and that plateau is still meaningfully + cheaper than the evening: + + 12 quarters at 0 ore, 40 at 30 ore, 44 at 90 ore + p10 = 0 p25 = 30 median = 30 p75 = 90 p90 = 90 + + Heating at 30 rather than at 90 is a third of the price. The band exists to say so. Asking + `price < median` says 30 is not below 30 and calls twenty hours of cheap power NORMAL. + """ + + A_MID_PLATEAU_DAY = [0.0] * 12 + [30.0] * 40 + [90.0] * 44 + + def test_the_mid_plateau_is_cheap_because_it_is_cheaper_than_the_evening(self): + plateau = _classify(self.A_MID_PLATEAU_DAY)[12:52] + + assert all(c == QuarterClassification.CHEAP for c in plateau), ( + f"40 quarters at 30 ore - against an evening at 90 - classified " + f"{Counter(c.name for c in plateau)}. They are the 25th percentile AND the median, so " + f"`price < median` rejects them. They are a third of the evening price." + ) + + def test_the_free_quarters_are_still_the_very_cheap_ones(self): + assert all( + c == QuarterClassification.VERY_CHEAP for c in _classify(self.A_MID_PLATEAU_DAY)[:12] + ) + + def test_the_evening_is_still_the_expensive_one(self): + evening = _classify(self.A_MID_PLATEAU_DAY)[52:] + + assert not any( + c in (QuarterClassification.VERY_CHEAP, QuarterClassification.CHEAP) for c in evening + ), f"The 90 ore evening classified {Counter(c.name for c in evening)}." diff --git a/tests/unit/optimization/test_learning_can_actually_learn.py b/tests/unit/optimization/test_learning_can_actually_learn.py new file mode 100644 index 00000000..269b2e91 --- /dev/null +++ b/tests/unit/optimization/test_learning_can_actually_learn.py @@ -0,0 +1,265 @@ +"""Learning must be able to engage on a real house - and today it cannot, for two reasons. + +Learning observes hourly (LEARNING_OBSERVATION_INTERVAL_MINUTES), not at the 5-minute control +cadence: a 0.1 C sensor sampled every five minutes reports quantisation, not the house. The +672-entry deque is therefore a 28-day memory. The window/cadence tests hold that. + +Even so, learning never engages (F-132b, the strict xfail below): the confidence metric caps at +0.600 on any real house, and - independently - the confidence gate reads a dict key that nothing +writes, so it is False forever. The remaining tests hold that the pre-heat never sizes itself from +the quarantined heat-loss index. Enabling learning is the owner's call. +""" + +from __future__ import annotations + +import inspect +import math +from datetime import datetime, timedelta + +import pytest + +from custom_components.effektguard.const import ( + LEARNING_CONFIDENCE_THRESHOLD, + LEARNING_OBSERVATION_INTERVAL_MINUTES, + LEARNING_OBSERVATION_WINDOW, + UPDATE_INTERVAL_MINUTES, +) +from custom_components.effektguard.optimization import decision_engine +from custom_components.effektguard.optimization.adaptive_learning import AdaptiveThermalModel + +SENSOR_QUANTUM = 0.1 # °C - what a NIBE BT1 can actually report + + +def _observe_a_real_house(cadence_minutes: int, days: int = 30) -> AdaptiveThermalModel: + """A house with an honest thermal response, watched at `cadence_minutes`. + + Indoor temperature follows the outdoor swing with lag and is nudged by the heating offset. The + crucial detail is the last line: the sensor is READ THROUGH ITS QUANTUM, so what the model sees is + what a NIBE actually reports, not the true continuous temperature. + """ + model = AdaptiveThermalModel(initial_thermal_mass=1.0) + + start = datetime(2026, 1, 1, 0, 0) + indoor_true = 21.0 + + for step in range(int(days * 24 * 60 / cadence_minutes)): + now = start + timedelta(minutes=cadence_minutes * step) + hours = step * cadence_minutes / 60.0 + + # Outdoor: a -5 °C winter mean with a 5 °C diurnal swing. + outdoor = -5.0 + 5.0 * math.sin(2 * math.pi * hours / 24.0) + + # Heating: the curve pushes harder when it is colder. + offset = 2.0 if outdoor < -5.0 else 0.0 + + # First-order building response toward an equilibrium set by outdoor + heating. + equilibrium = 21.0 + 0.15 * (outdoor + 5.0) + 0.8 * offset + tau_hours = 12.0 + dt_hours = cadence_minutes / 60.0 + indoor_true += (equilibrium - indoor_true) * (dt_hours / tau_hours) + + # The sensor can only say what a sensor can say. + model.record_observation( + timestamp=now, + indoor_temp=round(indoor_true / SENSOR_QUANTUM) * SENSOR_QUANTUM, + outdoor_temp=outdoor, + heating_offset=offset, + ) + + model.update_learned_parameters() + return model + + +def test_the_observation_window_spans_the_timescale_a_building_is_learned_on(): + """672 observations at the recording cadence must be a MEMORY, not a weekend.""" + span_hours = LEARNING_OBSERVATION_WINDOW * LEARNING_OBSERVATION_INTERVAL_MINUTES / 60.0 + + assert span_hours >= 7 * 24, ( + f"The observation deque holds {LEARNING_OBSERVATION_WINDOW} entries recorded every " + f"{LEARNING_OBSERVATION_INTERVAL_MINUTES} minutes, so it remembers {span_hours:.0f} hours - " + f"{span_hours / 24:.1f} days. It is a ROLLING window, so the model on day 90 sees exactly " + f"what it saw on day {span_hours / 24:.1f}. A building cannot be learned from a memory " + f"shorter than the promise made about it." + ) + + +def test_the_observation_cadence_is_slower_than_the_control_cadence(): + """Learning and control are different questions on different timescales. + + Control runs every 5 minutes because the pump needs steering. Learning must not: a 0.1 °C sensor + sampled every 5 minutes reports the quantisation, not the house. + """ + assert LEARNING_OBSERVATION_INTERVAL_MINUTES > UPDATE_INTERVAL_MINUTES, ( + f"Learning observes every {LEARNING_OBSERVATION_INTERVAL_MINUTES} min, the same as the " + f"control loop ({UPDATE_INTERVAL_MINUTES} min). A house warming at 0.6 °C/h moves 0.05 °C in " + f"five minutes - half a sensor tick - so every rate quantises to 0.0 or 1.2 °C/h and the " + f"scatter is pure sampling artefact." + ) + + +@pytest.mark.xfail( + strict=True, + reason=( + "F-132b: learning cannot engage on ANY house, at ANY cadence, and the cadence was only half " + "the story. `consistency = 1 - std/mean` is computed over EVERY heating observation, and a " + "house at equilibrium contributes a rate of exactly zero - so the mean is dragged under the " + "0.1 C/h floor by the samples where the house was doing nothing, and consistency is pinned " + "to 0.0. Confidence then caps at obs(0.4) + time(0.2) = 0.600, under a 0.7 gate, forever. " + "Measured: wooden 0.415, brick 0.415, concrete 0.415 - every house, every cadence. " + "The metric is not repairable by tuning: filtering to the samples that DO move makes the " + "5-minute cadence score a PERFECT 1.000, because at that cadence the only rates above the " + "floor are exactly one sensor quantum and therefore all identical - std collapses to zero " + "and the quantisation artefact reads as certainty. std/mean rewards data for being " + "degenerate. Confidence has to be measured by PREDICTION ERROR against held-out " + "observations, which is a redesign of a control-path metric at weight 0.65. OWNER DECISION." + ), +) +def test_learning_engages_on_a_house_that_behaves_like_a_house(): + """The whole point. A real building, watched properly, must become knowable.""" + model = _observe_a_real_house(LEARNING_OBSERVATION_INTERVAL_MINUTES, days=30) + params = model.get_parameters() + + assert params is not None, "no parameters were learned at all" + assert params.confidence >= LEARNING_CONFIDENCE_THRESHOLD, ( + f"After 30 days of hourly observation of a house with an entirely ordinary thermal response, " + f"confidence reached {params.confidence:.3f} against a gate of {LEARNING_CONFIDENCE_THRESHOLD}. " + f"Learning never engages, so the adaptive model is decoration." + ) + assert model.should_use_learned_parameters() + + +def test_the_production_cadence_could_not_learn_this_same_house(): + """The control, so nobody has to take the docstring on trust. + + Identical house, identical physics, identical sensor - only the sampling interval differs. + """ + model = _observe_a_real_house(UPDATE_INTERVAL_MINUTES, days=30) + params = model.get_parameters() + + confidence = params.confidence if params else 0.0 + assert confidence < LEARNING_CONFIDENCE_THRESHOLD, ( + "precondition failed: the 5-minute cadence now DOES learn this house, which means the " + "premise of this change is wrong and it should be revisited rather than kept." + ) + + +def test_a_flatlined_sensor_still_teaches_us_nothing(): + """A dead indoor sensor - one value forever - must score below the gate. + + std/mean reads zero scatter as certainty, so a flat line could earn perfect consistency. + Whatever replaces the confidence metric must keep this case at zero. + """ + model = AdaptiveThermalModel(initial_thermal_mass=1.0) + start = datetime(2026, 1, 1, 0, 0) + + for step in range(LEARNING_OBSERVATION_WINDOW): + model.record_observation( + timestamp=start + timedelta(minutes=LEARNING_OBSERVATION_INTERVAL_MINUTES * step), + indoor_temp=21.0, # the sensor died; it says 21.0 and will say 21.0 forever + outdoor_temp=-5.0 + 5.0 * math.sin(2 * math.pi * step / 24.0), + heating_offset=2.0, + ) + + model.update_learned_parameters() + params = model.get_parameters() + confidence = params.confidence if params else 0.0 + + assert confidence < LEARNING_CONFIDENCE_THRESHOLD, ( + f"A flatlined indoor sensor scored {confidence:.3f} confidence and would drive the pump " + f"through the pre-heating layer at weight 0.65 on parameters derived from a dead sensor." + ) + assert not model.should_use_learned_parameters() + + +def test_the_heat_loss_coefficient_is_never_used_as_a_control_input(): + """The learned heat-loss coefficient is a relative index, not W/K, and must never reach control. + + `_calculate_heat_loss_coefficient` cannot yield a physical W/K value from decay alone; it + lands clamped in a plausible 100-300 range. The decision engine takes the coefficient from + configuration instead, and this test holds that the learned index stays out of the source. + """ + source = inspect.getsource(decision_engine) + + assert ( + "learned" not in source or "heat_loss_coefficient" not in source.split("learned")[1][:200] + ) + + model = _observe_a_real_house(LEARNING_OBSERVATION_INTERVAL_MINUTES, days=30) + params = model.get_parameters() + assert params is not None + + # It is pinned to its clamp, which is the tell: this is not a measurement of anything. + assert ( + params.heat_loss_coefficient in (100.0, 180.0, 300.0) + or 100.0 <= params.heat_loss_coefficient <= 300.0 + ) + + +class TestTwoDefectsWereCancellingEachOther: + """A second, independent reason learning is inert - and the trap disarming it revealed. + + The gate `should_use_learned_parameters()` reads `learned_parameters["confidence"]`, a key + only the `insulation_quality` setter ever writes (and it writes `heat_loss_coefficient`, not + confidence), so the gate is False forever. That dead gate once masked a unit error: + `calculate_preheating_target` would have fed the learned relative index into + `heat_loss_coef / 1000.0` as if it were W/K. Production now always uses the configured + coefficient; these two tests hold the gate closed and the pre-heat off the learned index. + """ + + def test_the_gate_can_never_open_however_much_the_model_learns(self): + """The second, independent reason. Recorded, not fixed - opening it is F-132b.""" + model = _observe_a_real_house(LEARNING_OBSERVATION_INTERVAL_MINUTES, days=30) + params = model.update_learned_parameters() + + assert params is not None, "precondition: the model must have learned something at all" + assert "confidence" not in model.learned_parameters, ( + f"`learned_parameters` now holds {sorted(model.learned_parameters)}. If a confidence " + f"has appeared there, someone has repaired the gate - check first that " + f"calculate_preheating_target still takes its heat-loss coefficient from CONFIGURATION " + f"and not from the quarantined relative index, or the pre-heat is now sized with a " + f"dimensionless number divided by 1000 as if it were watts." + ) + assert not model.should_use_learned_parameters(), ( + "the gate reads a confidence that nothing writes, so it is False forever - a SECOND " + "reason learning never engages, independent of the confidence metric that the xfail " + "above records" + ) + + def test_the_preheat_never_sizes_itself_with_the_quarantined_index(self): + """The control path must not touch the learned coefficient at all. + + The pre-heat must size identically whether the learned index reads 180 or 3000, because + production takes the coefficient from configuration. Decay is pinned to a positive value + so the deficit is non-zero and a re-armed trap could actually move the answer. + """ + model = _observe_a_real_house(LEARNING_OBSERVATION_INTERVAL_MINUTES, days=30) + model.learned_parameters = {"confidence": 1.0} # force the gate wide open + model._calculate_thermal_decay_rate = lambda: 0.15 # a house that actually cools + + call = dict( + current_temp=21.0, + desired_temp=21.0, + hours_until_peak=6, + outdoor_temp=-5.0, + forecast_min_temp=-10.0, + ) + + model._calculate_heat_loss_coefficient = lambda: 180.0 + baseline = model.calculate_preheating_target(**call) + + assert baseline > call["desired_temp"], ( + f"PRECONDITION: the pre-heat must actually be sizing something ({baseline:.2f} C vs a " + f"target of {call['desired_temp']:.2f}), or a change in the coefficient could not move " + f"it and this test would prove nothing." + ) + + model._calculate_heat_loss_coefficient = lambda: 3000.0 # an absurd relative index + with_absurd_index = model.calculate_preheating_target(**call) + + assert with_absurd_index == pytest.approx(baseline), ( + f"Multiplying the QUARANTINED relative cooling index by 17 moved the pre-heat target " + f"from {baseline:.2f} C to {with_absurd_index:.2f} C. That index is dimensionless - " + f"its own estimator says it 'MUST NOT be used as an absolute W/°C coefficient anywhere " + f"in the control path' - and this is the control path, dividing it by 1000 as if it " + f"were watts." + ) diff --git a/tests/unit/optimization/test_manual_override_safety_floor.py b/tests/unit/optimization/test_manual_override_safety_floor.py new file mode 100644 index 00000000..731ccf4d --- /dev/null +++ b/tests/unit/optimization/test_manual_override_safety_floor.py @@ -0,0 +1,161 @@ +"""A user command is authoritative - but not below the absolute safety floor. + +`force_offset` and `boost_heating` previously returned from calculate_decision BEFORE the +safety layer, the emergency thermal-debt layer, and the anti-windup flag were computed, and +the coordinator explicitly bypassed the offset-volatility blocker for manual decisions. So +`force_offset(-10)` for 6 hours would hold maximum heat REDUCTION while the house fell below +MIN_TEMP_LIMIT, or while DM sat past DM_THRESHOLD_AUX_LIMIT with the immersion heater running. + +The fix applies the floor as a FLOOR, not a replacement: a user asking for MORE heat than +safety requires is passed through untouched (boost_heating(+10) still boosts); a command that +would leave the system below the safety floor is raised to it. +""" + +from datetime import datetime +from unittest.mock import MagicMock + +import pytest + +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.const import ( + DM_THRESHOLD_AUX_LIMIT, + MIN_OFFSET, + MIN_TEMP_LIMIT, + SAFETY_EMERGENCY_OFFSET, +) +from custom_components.effektguard.optimization.decision_engine import DecisionEngine +from custom_components.effektguard.optimization.effect_layer import EffectManager +from custom_components.effektguard.optimization.price_layer import PriceAnalyzer +from custom_components.effektguard.optimization.thermal_layer import ThermalModel + +STOCKHOLM_LATITUDE = 59.33 + + +@pytest.fixture +def engine(): + return DecisionEngine( + price_analyzer=PriceAnalyzer(), + effect_manager=EffectManager(MagicMock()), + thermal_model=ThermalModel(thermal_mass=1.0, insulation_quality=1.0), + config={ + "target_indoor_temp": 21.0, + "tolerance": 0.5, + "latitude": STOCKHOLM_LATITUDE, + }, + ) + + +def state(indoor_temp: float = 21.0, degree_minutes: float = -100.0) -> NibeState: + return NibeState( + outdoor_temp=-10.0, + indoor_temp=indoor_temp, + supply_temp=35.0, + return_temp=30.0, + degree_minutes=degree_minutes, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 6, 0), + ) + + +def decide(engine: DecisionEngine, nibe_state: NibeState): + """calculate_decision on the manual-override path. + + The override branch returns before any price/weather layer runs, so None inputs are + safe here and keep the test deterministic. + """ + return engine.calculate_decision( + nibe_state=nibe_state, + price_data=None, + weather_data=None, + current_peak=0.0, + current_power=0.0, + ) + + +class TestManualOverrideRespectsAbsoluteSafetyFloor: + def test_force_offset_cannot_hold_the_house_below_min_temp_limit(self, engine): + """force_offset(-10) while indoor is below MIN_TEMP_LIMIT must be raised.""" + engine.set_manual_override(MIN_OFFSET, duration_minutes=360) + + decision = decide(engine, state(indoor_temp=MIN_TEMP_LIMIT - 1.0)) + + assert decision.offset == pytest.approx(SAFETY_EMERGENCY_OFFSET), ( + f"Manual override held {decision.offset:+.1f}°C while indoor was below " + f"{MIN_TEMP_LIMIT}°C. The safety floor must outrank a user command." + ) + assert decision.is_emergency is True + + def test_force_offset_cannot_hold_dm_past_the_aux_limit(self, engine): + """force_offset(-10) while DM is past the aux limit must be raised.""" + engine.set_manual_override(MIN_OFFSET, duration_minutes=360) + + decision = decide(engine, state(degree_minutes=DM_THRESHOLD_AUX_LIMIT - 20)) + + assert decision.offset == pytest.approx(SAFETY_EMERGENCY_OFFSET), ( + f"Manual override held {decision.offset:+.1f}°C at DM " + f"{DM_THRESHOLD_AUX_LIMIT - 20}. Past the aux limit the immersion heater is " + "running; reducing heat deepens the debt." + ) + assert decision.is_emergency is True + + def test_boost_heating_is_passed_through_untouched(self, engine): + """The floor only ever RAISES. A user asking for more heat still gets it.""" + engine.set_manual_override(SAFETY_EMERGENCY_OFFSET, duration_minutes=360) + + decision = decide(engine, state()) + + assert decision.offset == pytest.approx(SAFETY_EMERGENCY_OFFSET) + assert decision.is_manual_override is True + assert decision.is_emergency is False + + def test_normal_manual_reduction_is_honoured_when_safe(self, engine): + """With the house warm and DM healthy, a user reduction is a preference, not a fault.""" + engine.set_manual_override(-3.0, duration_minutes=60) + + decision = decide(engine, state(indoor_temp=22.0, degree_minutes=-100.0)) + + assert decision.offset == pytest.approx(-3.0) + assert decision.is_manual_override is True + assert decision.is_emergency is False + + +class TestAbsoluteSafetyFloor: + def test_floor_is_none_under_normal_conditions(self, engine): + assert engine._absolute_safety_floor(state()) is None + + def test_floor_engages_below_min_temp_limit(self, engine): + floor = engine._absolute_safety_floor(state(indoor_temp=MIN_TEMP_LIMIT - 0.1)) + assert floor == pytest.approx(SAFETY_EMERGENCY_OFFSET) + + def test_floor_engages_at_the_aux_limit(self, engine): + floor = engine._absolute_safety_floor(state(degree_minutes=DM_THRESHOLD_AUX_LIMIT)) + assert floor == pytest.approx(SAFETY_EMERGENCY_OFFSET) + + +class TestVolatilityBlockerBypassesEmergency: + """The coordinator must not defer an emergency for 45 minutes.""" + + def test_coordinator_bypasses_volatile_check_for_emergency(self): + """Regression guard for the offset-volatility blocker. + + Pre-fix the blocker bypassed only `is_manual_override` and `anti_windup_active`. + An aux-limit emergency sets neither, so a +10.0 recovery following a -6.0 PEAK + offset was a "volatile reversal" and got deferred for up to 45 minutes while DM + kept falling. + """ + import inspect + + from custom_components.effektguard.coordinator import EffektGuardCoordinator + + src = inspect.getsource(EffektGuardCoordinator._read_and_decide) + + assert "elif decision.is_emergency:" in src, ( + "The offset-volatility blocker does not bypass emergency decisions. It would " + "defer an aux-limit recovery for up to 45 minutes." + ) + # The emergency bypass must be evaluated BEFORE the volatile-reversal branch. + assert src.index("elif decision.is_emergency:") < src.index( + "is_reversal_volatile" + ), "The emergency bypass must precede the volatile-reversal check." diff --git a/tests/unit/optimization/test_no_room_sensor_safety.py b/tests/unit/optimization/test_no_room_sensor_safety.py new file mode 100644 index 00000000..2bf0bbbc --- /dev/null +++ b/tests/unit/optimization/test_no_room_sensor_safety.py @@ -0,0 +1,157 @@ +"""A system with no room sensor must still get thermal-debt protection. + +With no BT50 the adapter reports DEFAULT_INDOOR_TEMP (21.0) as a placeholder. It equals the +usual target, so `temp_deviation` is exactly 0.0 - which two gates in the emergency layer read +as "at target": `temp_deviation > tolerance_range` is False, and `temp_deviation >= 0` is always +True, returning weight 0.0 unless the price is cheap. That disabled the whole thermal-debt layer +on exactly the sensorless systems that depend on degree minutes most. The safety layer had the +mirror failure: it fires below MIN_TEMP_LIMIT (18.0), which the placeholder 21.0 sits above. + +Correct behaviour: comfort-reasoning layers ABSTAIN when the indoor reading is not a +measurement, and the degree-minute tiers run normally, as NIBE runs without a sensor. +""" + +from datetime import datetime +from unittest.mock import MagicMock + +import pytest + +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.const import ( + DEFAULT_INDOOR_TEMP, + DM_RECOVERY_TIERS, + LAYER_WEIGHT_SAFETY, +) +from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector +from custom_components.effektguard.optimization.decision_engine import DecisionEngine +from custom_components.effektguard.optimization.effect_layer import EffectManager +from custom_components.effektguard.optimization.price_layer import PriceAnalyzer +from custom_components.effektguard.optimization.thermal_layer import EmergencyLayer, ThermalModel + +STOCKHOLM_LATITUDE = 59.33 + +# Deep thermal debt, well past the climate-aware warning threshold for Stockholm at -15 C. +DEEP_DEBT_DM = -1200.0 + + +def sensorless_state(degree_minutes: float = DEEP_DEBT_DM) -> NibeState: + """Exactly what the adapter produces when there is no room sensor.""" + return NibeState( + outdoor_temp=-15.0, + indoor_temp=DEFAULT_INDOOR_TEMP, # placeholder, not a measurement + supply_temp=35.0, + return_temp=30.0, + degree_minutes=degree_minutes, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 6, 0), + indoor_temp_valid=False, + ) + + +class TestEmergencyLayerStillProtectsSensorlessSystems: + @staticmethod + def _layer() -> EmergencyLayer: + return EmergencyLayer( + climate_detector=ClimateZoneDetector(STOCKHOLM_LATITUDE), + heating_type="radiator", + ) + + def test_deep_thermal_debt_still_triggers_recovery_without_a_room_sensor(self): + """Case 2 saw deviation 0.0, called it "at target", and abstained without a sensor.""" + decision = self._layer().evaluate_layer( + nibe_state=sensorless_state(), + weather_data=None, + price_data=None, # price not cheap -> the old Case 2 would return weight 0.0 + target_temp=21.0, + tolerance_range=0.2, + ) + + assert decision.tier in DM_RECOVERY_TIERS, ( + f"Thermal-debt recovery did not engage at DM {DEEP_DEBT_DM} on a system with no " + f"room sensor - got tier={decision.tier!r}, weight={decision.weight}. The " + "placeholder indoor temperature made the layer believe it was at target." + ) + assert decision.weight > 0.0 + assert decision.offset > 0.0 + + def test_a_real_room_sensor_at_target_still_suppresses_recovery(self): + """Do not over-correct: with a MEASURED reading at target, Case 2 must still work.""" + measured_at_target = NibeState( + outdoor_temp=-15.0, + indoor_temp=21.0, + supply_temp=35.0, + return_temp=30.0, + degree_minutes=DEEP_DEBT_DM, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 6, 0), + indoor_temp_valid=True, + ) + + decision = self._layer().evaluate_layer( + nibe_state=measured_at_target, + weather_data=None, + price_data=None, + target_temp=21.0, + tolerance_range=0.2, + ) + + assert decision.tier == "OK" + assert decision.weight == 0.0 + + +class TestSafetyLayerAbstainsWithoutAMeasurement: + @pytest.fixture + def engine(self): + return DecisionEngine( + price_analyzer=PriceAnalyzer(), + effect_manager=EffectManager(MagicMock()), + thermal_model=ThermalModel(thermal_mass=1.0, insulation_quality=1.0), + config={ + "target_indoor_temp": 21.0, + "tolerance": 0.5, + "latitude": STOCKHOLM_LATITUDE, + }, + ) + + def test_safety_layer_abstains_rather_than_reporting_ok(self, engine): + """A placeholder of 21.0 must not be read as "comfortably above 18.0".""" + decision = engine._safety_layer(sensorless_state()) + + assert decision.weight == 0.0 + assert "abstain" in decision.reason.lower() + + def test_absolute_safety_floor_ignores_a_placeholder_indoor_reading(self, engine): + """The floor must not be driven by a value that was never measured.""" + healthy_dm = sensorless_state(degree_minutes=-100.0) + + assert engine._absolute_safety_floor(healthy_dm) is None + + def test_absolute_safety_floor_still_engages_on_degree_minutes(self, engine): + """Sensorless systems are protected by DM, and that path must remain live.""" + at_aux_limit = sensorless_state(degree_minutes=-1600.0) + + floor = engine._absolute_safety_floor(at_aux_limit) + assert floor is not None + + def test_safety_layer_still_fires_on_a_real_cold_reading(self, engine): + """Do not over-correct: a MEASURED 17 C must still trigger the floor.""" + cold = NibeState( + outdoor_temp=-15.0, + indoor_temp=17.0, + supply_temp=35.0, + return_temp=30.0, + degree_minutes=-100.0, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 6, 0), + indoor_temp_valid=True, + ) + + decision = engine._safety_layer(cold) + assert decision.weight == pytest.approx(LAYER_WEIGHT_SAFETY) + assert decision.offset > 0.0 diff --git a/tests/unit/optimization/test_one_definition_of_the_billed_quantity.py b/tests/unit/optimization/test_one_definition_of_the_billed_quantity.py new file mode 100644 index 00000000..a08c2d6f --- /dev/null +++ b/tests/unit/optimization/test_one_definition_of_the_billed_quantity.py @@ -0,0 +1,175 @@ +"""One definition of the billed quantity: the time-weighted mean power over a billing hour. + +That number decides whether the pump is throttled for the rest of the month, so `BillingPeriodAccumulator` +must compute it exactly. These tests pin the arithmetic the tariff pays for: + * the time-weighted mean, which is NOT the arithmetic sample mean when Home Assistant's update + cycle jitters or a restart drops samples; + * the hour counted on the absolute time line, so the repeated DST fall-back hour is two hours; + * the local hour label and local start stamp, because the night discount and the calendar month a + peak belongs to are both wall-clock facts; + * an hour begun before observation, or cut short by shutdown, is not billed. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +import pytest + +from custom_components.effektguard.const import ( + BILLING_PERIOD_MINUTES, + POWER_SOURCE_EXTERNAL_METER, +) +from custom_components.effektguard.optimization.billing_period import BillingPeriodAccumulator + +STOCKHOLM = ZoneInfo("Europe/Stockholm") +UTC = ZoneInfo("UTC") + + +def _local(*args) -> datetime: + return datetime(*args, tzinfo=STOCKHOLM) + + +def test_a_flat_hour_is_billed_at_its_flat_power(): + """The simplest case, and the one everything else is measured against.""" + accumulator = BillingPeriodAccumulator() + completed = None + + for minute in range(0, 60, 5): + completed = ( + accumulator.add(_local(2026, 1, 15, 10, minute), 6.0, POWER_SOURCE_EXTERNAL_METER) + or completed + ) + # The first sample of the NEXT hour is what closes this one. + completed = ( + accumulator.add(_local(2026, 1, 15, 11, 0), 6.0, POWER_SOURCE_EXTERNAL_METER) or completed + ) + + assert completed is not None, "a whole hour went by and no billing period completed" + assert completed.mean_power_kw == pytest.approx(6.0) + assert completed.billing_hour == 10 + assert completed.started_at == _local(2026, 1, 15, 10, 0) + + +def test_the_mean_is_time_weighted_not_sample_counted(): + """The time-weighted mean is not the arithmetic sample mean when samples are unevenly spaced. + + readings 1 kW at :00, :15, :30, then 9 kW at :45 and :55 + time-weighted (what the grid bills): (1*45 + 9*15) / 60 = 3.0 kW + arithmetic mean of the samples: (1+1+1+9+9) / 5 = 4.2 kW (40% high) + + Home Assistant's update cycle jitters, so the samples in an hour are not evenly spaced. The gaps + here stay within MAX_BILLING_OBSERVATION_GAP_MINUTES, so the hour is actually observed and billed. + """ + accumulator = BillingPeriodAccumulator() + + for minute, power in ((0, 1.0), (15, 1.0), (30, 1.0), (45, 9.0), (55, 9.0)): + accumulator.add(_local(2026, 1, 15, 10, minute), power, POWER_SOURCE_EXTERNAL_METER) + completed = accumulator.add(_local(2026, 1, 15, 11, 0), 1.0, POWER_SOURCE_EXTERNAL_METER) + + assert completed is not None + assert completed.mean_power_kw == pytest.approx((1.0 * 45 + 9.0 * 15) / 60), ( + f"the hour was billed at {completed.mean_power_kw:.2f} kW. 1 kW stood for 45 minutes and " + f"9 kW for fifteen; the grid bills the time-weighted mean, 3.0 kW. Counting samples instead " + f"gives 4.2 kW - 40% high, persisted as the month's peak." + ) + + +def test_the_hour_is_counted_on_the_absolute_time_line(): + """The DST fall-back: wall-clock 02:00 happens twice, and both hours are billable. + + PEP 495 - for two aware datetimes with the same tzinfo, `fold` is IGNORED in comparisons - is why + the naive version of this merged them and deleted a peak. + """ + accumulator = BillingPeriodAccumulator() + completed = [] + + # Step REAL time across the transition; the tz database does the rest. + start = datetime(2026, 10, 25, 0, 0, tzinfo=UTC) # 02:00 CEST + for step in range(0, 150, 5): + instant = (start + timedelta(minutes=step)).astimezone(STOCKHOLM) + power = 9.0 if step < 60 else 1.0 # 9 kW through the FIRST 02:00, 1 kW through the second + event = accumulator.add(instant, power, POWER_SOURCE_EXTERNAL_METER) + if event is not None: + completed.append(event) + + means = [round(event.mean_power_kw, 2) for event in completed] + hours = [event.billing_hour for event in completed] + + assert hours == [ + 2, + 2, + ], f"two separately-metered hours both labelled 02 must both complete. Got hours {hours}." + assert means == [9.0, 1.0], ( + f"the two 02:00 hours billed {means}. They are an hour apart and both real. Merging them " + f"deletes the 9 kW hour - which is what the coordinator did until 37f2fef." + ) + + +def test_the_start_stamp_is_local_so_the_month_is_right(): + """The effect layer buckets peaks by calendar month, and that is a wall-clock fact. + + The billing hour 00:00-01:00 on 1 November IS 23:00-00:00 on 31 October in UTC. Stamping it in + UTC files a November peak against a month that is already billed. + """ + accumulator = BillingPeriodAccumulator() + completed = None + + start = datetime(2026, 10, 31, 23, 0, tzinfo=UTC) # 00:00 local, 1 November + for step in range(0, 65, 5): + instant = (start + timedelta(minutes=step)).astimezone(STOCKHOLM) + completed = accumulator.add(instant, 7.0, POWER_SOURCE_EXTERNAL_METER) or completed + + assert completed is not None + assert (completed.started_at.year, completed.started_at.month) == (2026, 11), ( + f"the hour was stamped {completed.started_at.isoformat()} - month " + f"{completed.started_at.month}. It is the first hour of November." + ) + assert completed.billing_hour == 0 + + +def test_an_hour_that_began_before_observation_is_not_billed(): + """Home Assistant starts mid-hour. That hour was never fully measured, so it is not a bill.""" + accumulator = BillingPeriodAccumulator() + + accumulator.add( + _local(2026, 1, 15, 10, 23), 5.0, POWER_SOURCE_EXTERNAL_METER + ) # first ever sample: mid-hour + accumulator.add(_local(2026, 1, 15, 10, 55), 5.0, POWER_SOURCE_EXTERNAL_METER) + completed = accumulator.add(_local(2026, 1, 15, 11, 0), 5.0, POWER_SOURCE_EXTERNAL_METER) + + assert completed is None, ( + f"the 10:00 hour was billed at {completed.mean_power_kw if completed else None} kW, but it " + f"was only observed from 10:23. A partial hour is not a measurement of an hour." + ) + + # ...and the NEXT, fully-observed hour is billed normally. + for minute in range(5, 60, 5): + accumulator.add(_local(2026, 1, 15, 11, minute), 5.0, POWER_SOURCE_EXTERNAL_METER) + completed = accumulator.add(_local(2026, 1, 15, 12, 0), 5.0, POWER_SOURCE_EXTERNAL_METER) + + assert completed is not None and completed.mean_power_kw == pytest.approx(5.0) + assert completed.billing_hour == 11 + + +def test_flush_closes_the_hour_in_progress(): + """The simulator's run ends. The hour it ends on is complete in sim-time and must be billed. + + Production never calls this - Home Assistant keeps running, and an hour cut short by a shutdown + is not a bill. It exists so the harness does not silently drop its final hour. + """ + accumulator = BillingPeriodAccumulator() + for minute in range(0, 60, 5): + accumulator.add(_local(2026, 1, 15, 10, minute), 4.0, POWER_SOURCE_EXTERNAL_METER) + + completed = accumulator.flush() + + assert completed is not None and completed.mean_power_kw == pytest.approx(4.0) + assert completed.billing_hour == 10 + assert accumulator.flush() is None, "flushing twice must not bill the same hour twice" + + +def test_the_billing_period_is_the_hour_the_tariff_actually_uses(): + """The accumulator must not carry its own private idea of how long an hour is.""" + assert BILLING_PERIOD_MINUTES == 60 diff --git a/tests/unit/optimization/test_peak_protection_compares_like_with_like.py b/tests/unit/optimization/test_peak_protection_compares_like_with_like.py new file mode 100644 index 00000000..6f02bde8 --- /dev/null +++ b/tests/unit/optimization/test_peak_protection_compares_like_with_like.py @@ -0,0 +1,65 @@ +"""Peak protection must compare an HOURLY MEAN against an hourly-mean record. + +The monthly record is the mean power of a whole billing hour - that is what Ellevio bills. +The effect layer was handed the instantaneous reading of the last cycle and compared it +against that record: a five-minute oven spike read as if it were a whole hour of it, and +the pump was throttled to defend a peak the meter would have averaged away. + +The like-for-like quantity is the PROJECTED hour mean: what this billing hour becomes if +the current draw persists to the boundary. Early in the hour a spike projects to almost +nothing; the closer the boundary, the more the accumulated hour dominates and the less +anyone can pretend the spike away. +""" + +from datetime import datetime +from zoneinfo import ZoneInfo + +from custom_components.effektguard.const import POWER_SOURCE_EXTERNAL_METER +from custom_components.effektguard.optimization.billing_period import BillingPeriodAccumulator + +STOCKHOLM = ZoneInfo("Europe/Stockholm") + + +def _t(minute: int, hour: int = 10) -> datetime: + return datetime(2026, 1, 15, hour, minute, tzinfo=STOCKHOLM) + + +def test_half_an_hour_of_low_draw_halves_a_spike(): + acc = BillingPeriodAccumulator() + for minute in range(0, 35, 5): + acc.add(_t(minute), 2.0, POWER_SOURCE_EXTERNAL_METER) + + # 9 kW starting at 10:30: the hour's mean, if it persists, is (2*30 + 9*30)/60. + projected = acc.projected_hour_mean(_t(30), 9.0) + + assert projected == (2.0 * 30 + 9.0 * 30) / 60 + + +def test_an_empty_hour_projects_the_draw_itself(): + acc = BillingPeriodAccumulator() + + assert acc.projected_hour_mean(_t(0), 9.0) == 9.0 + + +def test_a_spike_in_the_last_five_minutes_barely_moves_the_hour(): + acc = BillingPeriodAccumulator() + for minute in range(0, 60, 5): + acc.add(_t(minute), 1.0, POWER_SOURCE_EXTERNAL_METER) + + projected = acc.projected_hour_mean(_t(55), 9.0) + + assert projected == (1.0 * 55 + 9.0 * 5) / 60 + + +def test_the_coordinator_feeds_the_projection_to_the_engine(): + """The wiring contract: the decision path consumes the like-for-like quantity.""" + import inspect + + from custom_components.effektguard.coordinator import EffektGuardCoordinator + + src = inspect.getsource(EffektGuardCoordinator._read_and_decide) + assert "projected_hour_mean" in src, ( + "The decision path no longer projects the billing hour. Handing the effect layer an " + "instantaneous reading compares a five-minute spike against an HOURLY-MEAN record - " + "the layer throttles the pump to defend a peak the meter would average away." + ) diff --git a/tests/unit/optimization/test_preheat_sees_the_cold_coming.py b/tests/unit/optimization/test_preheat_sees_the_cold_coming.py new file mode 100644 index 00000000..7ea8459e --- /dev/null +++ b/tests/unit/optimization/test_preheat_sees_the_cold_coming.py @@ -0,0 +1,78 @@ +"""A slow house must be allowed to look further ahead than a fast one. + +The pre-heat layer fires on a forecast drop of at least WEATHER_FORECAST_DROP_THRESHOLD within the +prediction horizon. A concrete slab gets into thermal debt not from a sudden plunge (the pump's own +curve catches that) but from a slow, deep, multi-day slide - and a fixed 12 h horizon cannot see one: +a 15 C fall over two days shows only 3.8 C in any twelve hours, under the trigger, so the pre-heat +never fires. + +Invariant: ThermalModel.get_prediction_horizon() must scale with thermal mass +(UFH_CONCRETE > UFH_TIMBER > UFH_RADIATOR), not return a single fixed value for every house. +""" + +import pytest + +from custom_components.effektguard.const import ( + UFH_CONCRETE_PREDICTION_HORIZON, + UFH_RADIATOR_PREDICTION_HORIZON, + UFH_TIMBER_PREDICTION_HORIZON, +) +from custom_components.effektguard.optimization.thermal_layer import ThermalModel + +# The engine's own classification (decision_engine.py): >= 1.5 concrete, >= 1.2 timber, else +# radiator. The horizon must be derived from the SAME thresholds, or a house is one type for the +# heating curve and another for the forecast. +CONCRETE_SLAB = 1.8 +TIMBER_UFH = 1.3 +RADIATORS = 0.7 + + +@pytest.mark.parametrize( + "thermal_mass,expected,what", + [ + (CONCRETE_SLAB, UFH_CONCRETE_PREDICTION_HORIZON, "a concrete slab"), + (TIMBER_UFH, UFH_TIMBER_PREDICTION_HORIZON, "timber underfloor"), + (RADIATORS, UFH_RADIATOR_PREDICTION_HORIZON, "radiators"), + ], +) +def test_the_horizon_follows_the_thermal_mass(thermal_mass, expected, what): + """The heavier the house, the further ahead it has to look. That is the whole point.""" + horizon = ThermalModel(thermal_mass, 1.0).get_prediction_horizon() + + assert horizon == expected, ( + f"{what} (thermal mass {thermal_mass}) needs a {expected:.0f} h horizon and got " + f"{horizon:.0f} h. The horizon must scale with thermal mass, not collapse to one fixed " + f"value - this is the model the engine actually uses." + ) + + +def test_a_slab_looks_further_ahead_than_a_radiator(): + """Ordering, not just values: mass buys lag, and lag must buy look-ahead.""" + slab = ThermalModel(CONCRETE_SLAB, 1.0).get_prediction_horizon() + timber = ThermalModel(TIMBER_UFH, 1.0).get_prediction_horizon() + radiator = ThermalModel(RADIATORS, 1.0).get_prediction_horizon() + + assert slab > timber > radiator, ( + f"Horizons must be ordered by thermal lag: concrete {slab:.0f} h > timber {timber:.0f} h " + f"> radiators {radiator:.0f} h." + ) + + +def test_a_two_day_slide_is_visible_to_a_slab(): + """The case that actually drains a slab: 15 C over 48 h. + + Within twelve hours it falls only 3.8 C - under the trigger. Within twenty-four it falls + 7.5 C, and the pre-heat can start while there is still time to charge the slab. + """ + from custom_components.effektguard.const import WEATHER_FORECAST_DROP_THRESHOLD + + total_drop, over_hours = 15.0, 48.0 + slab_horizon = ThermalModel(CONCRETE_SLAB, 1.0).get_prediction_horizon() + + drop_seen = total_drop * min(slab_horizon, over_hours) / over_hours + + assert drop_seen >= abs(WEATHER_FORECAST_DROP_THRESHOLD), ( + f"A 15 C slide over two days shows only {drop_seen:.1f} C inside a {slab_horizon:.0f} h " + f"window, under the {abs(WEATHER_FORECAST_DROP_THRESHOLD):.0f} C trigger. The pre-heat " + f"never fires, and the slab is drained over days with nothing watching." + ) diff --git a/tests/unit/optimization/test_proactive_shares_the_thermal_ladder.py b/tests/unit/optimization/test_proactive_shares_the_thermal_ladder.py new file mode 100644 index 00000000..abe7a6c4 --- /dev/null +++ b/tests/unit/optimization/test_proactive_shares_the_thermal_ladder.py @@ -0,0 +1,67 @@ +"""Both thermal-debt layers must read the same (thermal-mass-buffered) warning threshold. + +EmergencyLayer applies the thermal-mass buffer; ProactiveLayer must too. When it read the raw range +instead, the two layers used different thresholds for the same house, leaving a band of degree +minutes where the proactive layer had handed over but the emergency layer had not yet picked up - +worst for the concrete slab, the house that can least afford to fall behind. + +Invariant: for every heating type the two layers warn at the same DM, and the slab has no silent band. +""" + +import pytest + +from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector +from custom_components.effektguard.optimization.thermal_layer import ( + EmergencyLayer, + ProactiveLayer, +) + +STOCKHOLM = 59.33 +OUTDOOR = 0.0 + +HEATING_TYPES = ["concrete_ufh", "timber", "radiator"] + + +@pytest.fixture +def detector() -> ClimateZoneDetector: + return ClimateZoneDetector(latitude=STOCKHOLM) + + +@pytest.mark.parametrize("heating_type", HEATING_TYPES) +def test_both_layers_use_the_same_warning_threshold(detector, heating_type): + """A threshold is a property of the house, not of the layer that happens to read it.""" + emergency = EmergencyLayer(climate_detector=detector, heating_type=heating_type) + proactive = ProactiveLayer(climate_detector=detector, heating_type=heating_type) + + emergency_warning = emergency._get_thermal_mass_adjusted_thresholds( + detector.get_expected_dm_range(OUTDOOR) + )["warning"] + proactive_warning = proactive._calculate_expected_dm_for_temperature(OUTDOOR)["warning"] + + assert proactive_warning == pytest.approx(emergency_warning), ( + f"For {heating_type!r} the proactive layer warns at DM {proactive_warning:.0f} while the " + f"emergency layer warns at DM {emergency_warning:.0f}. Between the two lies a band in " + f"which neither layer responds." + ) + + +def test_the_concrete_slab_has_no_silent_band(detector): + """No degree-minute value may leave both layers idle while the debt is real. + + The slab is the case that matters: its debt does not reach the room for hours, so a band where + nothing acts is a band of deficit that can never be recovered. + """ + emergency = EmergencyLayer(climate_detector=detector, heating_type="concrete_ufh") + proactive = ProactiveLayer(climate_detector=detector, heating_type="concrete_ufh") + + emergency_warning = emergency._get_thermal_mass_adjusted_thresholds( + detector.get_expected_dm_range(OUTDOOR) + )["warning"] + proactive_warning = proactive._calculate_expected_dm_for_temperature(OUTDOOR)["warning"] + + # The proactive layer must not hand over LATER than the emergency layer picks up. + assert proactive_warning >= emergency_warning, ( + f"The proactive layer stays silent until DM {proactive_warning:.0f}, but the emergency " + f"layer does not engage until DM {emergency_warning:.0f}. Every degree minute between " + f"them is unattended." + ) diff --git a/tests/unit/optimization/test_safety_priority_inversion.py b/tests/unit/optimization/test_safety_priority_inversion.py new file mode 100644 index 00000000..ce054f5b --- /dev/null +++ b/tests/unit/optimization/test_safety_priority_inversion.py @@ -0,0 +1,359 @@ +"""A cost layer must never reduce heating while the thermal-debt layer is recovering. + +The aggregator must select the emergency tier by reading the `tier` field, never by inferring it +from layer weights or offset magnitudes. That inference broke in four ways, each letting cost win: + + 1. the aux-limit EMERGENCY tier fell through to the peak-aware compromise or the tie-break; + 2. the tie-break `abs(max) > abs(min)` returns `min` on the exact +10/-10 tie -> max heat cut; + 3. the peak-aware gate hardcoded `weight >= 0.85`, but DM_CRITICAL_T2_WEIGHT is 0.81; + 4. the tier was inferred from the POST-damping offset, so a damped T3 was misread as T1. + +Also: the DM_THRESHOLD_AUX_LIMIT hard limit must be enforced BEFORE the anti-windup and "too warm" +early returns - past it NIBE engages the aux heater, so throttling recovery guarantees a larger peak. +""" + +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +import pytest + +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.const import ( + DM_CRITICAL_T1_PEAK_AWARE_OFFSET, + DM_CRITICAL_T2_OFFSET, + DM_CRITICAL_T2_PEAK_AWARE_OFFSET, + DM_CRITICAL_T2_WEIGHT, + DM_CRITICAL_T3_OFFSET, + DM_CRITICAL_T3_PEAK_AWARE_OFFSET, + DM_CRITICAL_T3_WEIGHT, + DM_THRESHOLD_AUX_LIMIT, + EFFECT_OFFSET_CRITICAL, + EFFECT_WEIGHT_CRITICAL, + LAYER_WEIGHT_SAFETY, + MAX_OFFSET, + MIN_OFFSET, + PRICE_OFFSET_PEAK, + SAFETY_EMERGENCY_OFFSET, + THERMAL_RECOVERY_T3_MIN_OFFSET, +) +from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector +from custom_components.effektguard.optimization.decision_engine import ( + DecisionEngine, + LayerDecision, +) +from custom_components.effektguard.optimization.effect_layer import EffectManager +from custom_components.effektguard.optimization.price_layer import PriceAnalyzer +from custom_components.effektguard.optimization.thermal_layer import ( + EmergencyLayer, + EmergencyLayerDecision, + ThermalModel, +) + +# Stockholm - the reference climate zone used throughout the project docs. +STOCKHOLM_LATITUDE = 59.33 + + +@pytest.fixture +def engine(): + """DecisionEngine with the CONFIG KEYS THE ENGINE ACTUALLY READS. + + Note `target_indoor_temp` (not `target_temperature`) and the production default + tolerance of 0.5. Several existing test fixtures pass `target_temperature` and + `tolerance: 5.0`; the engine reads neither, which widens the emergency layer's + "too warm" gate by 10x and hides real defects. + """ + return DecisionEngine( + price_analyzer=PriceAnalyzer(), + effect_manager=EffectManager(MagicMock()), + thermal_model=ThermalModel(thermal_mass=1.0, insulation_quality=1.0), + config={ + "target_indoor_temp": 21.0, + "tolerance": 0.5, + "latitude": STOCKHOLM_LATITUDE, + }, + ) + + +def build_layers( + emergency: EmergencyLayerDecision, + effect: LayerDecision | None = None, + price: LayerDecision | None = None, +) -> list[LayerDecision]: + """Build the 9-layer list in the exact order DecisionEngine.calculate_decision uses. + + Layers not under test are neutral (weight 0.0) so they cannot influence the result. + """ + neutral = lambda name: LayerDecision(name=name, offset=0.0, weight=0.0, reason="n/a") + return [ + neutral("Safety"), + emergency, + neutral("Proactive"), + effect or neutral("Peak Protection"), + neutral("Learned Pre-heat"), + neutral("Math WC"), + neutral("Weather"), + price or neutral("Spot Price"), + neutral("Comfort"), + ] + + +def emergency_at_aux_limit() -> EmergencyLayerDecision: + """The EMERGENCY tier exactly as thermal_layer emits it at DM <= -1500.""" + return EmergencyLayerDecision( + name="Thermal Debt", + offset=SAFETY_EMERGENCY_OFFSET, + weight=LAYER_WEIGHT_SAFETY, + reason="EMERGENCY: DM at aux limit", + tier="EMERGENCY", + degree_minutes=DM_THRESHOLD_AUX_LIMIT - 20, + ) + + +def critical_effect_peak() -> LayerDecision: + """Effect layer at CRITICAL: already at/above the monthly peak. + + `is_cost_layer` mirrors how DecisionEngine.calculate_decision wraps the effect + layer - the effect tariff optimizes cost, not comfort or safety. + """ + return LayerDecision( + name="Peak Protection", + offset=EFFECT_OFFSET_CRITICAL, + weight=EFFECT_WEIGHT_CRITICAL, + reason="At monthly peak", + is_cost_layer=True, + ) + + +def price_peak() -> LayerDecision: + """Price layer at PEAK. price_layer.py promotes itself to weight 1.0 here.""" + return LayerDecision( + name="Spot Price", + offset=PRICE_OFFSET_PEAK, + weight=LAYER_WEIGHT_SAFETY, + reason="PEAK quarter", + is_cost_layer=True, + ) + + +class TestAuxLimitIsAbsolute: + """DM <= DM_THRESHOLD_AUX_LIMIT must dominate every cost layer, unconditionally.""" + + def test_price_peak_cannot_override_aux_limit_emergency(self, engine): + """Price PEAK (-10.0 @ 1.0) must NOT beat the aux-limit emergency (+10.0 @ 1.0). + + Pre-fix: the tie-break `abs(max) > abs(min)` is False on the exact 10.0/-10.0 tie, + so it returned min_offset = -10.0 - MAXIMUM HEAT REDUCTION at the aux-heat limit. + """ + offset = engine._aggregate_layers( + build_layers(emergency_at_aux_limit(), price=price_peak()) + ) + + assert offset == pytest.approx(SAFETY_EMERGENCY_OFFSET), ( + f"Cost overrode the absolute DM safety limit: got {offset:+.1f}. " + f"At DM <= {DM_THRESHOLD_AUX_LIMIT} the aux immersion heater engages; " + f"reducing heat here deepens the debt AND creates a larger peak." + ) + + def test_critical_effect_peak_cannot_throttle_aux_limit_emergency(self, engine): + """A critical effect peak must not throttle the aux-limit emergency to +1.0. + + Pre-fix: the peak-aware compromise fired for the EMERGENCY tier and replaced + +10.0 with DM_CRITICAL_T3_PEAK_AWARE_OFFSET (+1.0). + """ + offset = engine._aggregate_layers( + build_layers(emergency_at_aux_limit(), effect=critical_effect_peak()) + ) + + assert offset == pytest.approx(SAFETY_EMERGENCY_OFFSET), ( + f"Effect-tariff protection throttled the absolute DM limit to {offset:+.1f}. " + "Peak protection must never suppress aux-limit recovery." + ) + + def test_both_cost_layers_together_cannot_override_aux_limit(self, engine): + """Price PEAK and a critical effect peak together still must not win.""" + offset = engine._aggregate_layers( + build_layers( + emergency_at_aux_limit(), + effect=critical_effect_peak(), + price=price_peak(), + ) + ) + + assert offset == pytest.approx(SAFETY_EMERGENCY_OFFSET) + + +class TestRecoveryTiersSurviveCostLayers: + """T1/T2/T3 recovery must never be driven NEGATIVE by a cost layer.""" + + def test_t2_recovery_is_not_crushed_by_critical_effect_peak(self, engine): + """T2 (weight 0.81) + critical effect peak must not yield a heat REDUCTION. + + Pre-fix: the peak-aware gate was a hardcoded `weight >= 0.85`, but + DM_CRITICAL_T2_WEIGHT is 0.81, so T2 fell through to the critical-override + branch and returned the effect layer's -3.0 while in deep thermal debt. + """ + t2 = EmergencyLayerDecision( + name="T2", + offset=DM_CRITICAL_T2_OFFSET, + weight=DM_CRITICAL_T2_WEIGHT, + reason="T2 recovery", + tier="T2", + degree_minutes=-900, + ) + + offset = engine._aggregate_layers(build_layers(t2, effect=critical_effect_peak())) + + assert offset == pytest.approx(DM_CRITICAL_T2_PEAK_AWARE_OFFSET), ( + f"T2 thermal-debt recovery returned {offset:+.1f}. A negative offset here " + "actively deepens the debt toward the aux limit." + ) + assert offset > 0, "Recovery must never be negative while in thermal debt" + + def test_price_peak_cannot_crush_t3_recovery(self, engine): + """Price PEAK (weight 1.0) must not outvote a T3 recovery (weight 0.91). + + Pre-fix: price_layer promotes itself to weight 1.0 on any PEAK quarter, entering + the critical-override branch that emergency tiers (max 0.91) cannot reach. + Result: -10.0 while DM is ~50 from the aux limit. + """ + t3 = EmergencyLayerDecision( + name="T3", + offset=DM_CRITICAL_T3_OFFSET, + weight=DM_CRITICAL_T3_WEIGHT, + reason="T3 recovery", + tier="T3", + degree_minutes=-1400, + ) + + offset = engine._aggregate_layers(build_layers(t3, price=price_peak())) + + assert offset > 0, ( + f"Spot price outvoted T3 emergency recovery: got {offset:+.1f}. " + "A cost layer must never reduce heat during thermal-debt recovery." + ) + + def test_damped_t3_still_gets_the_t3_peak_aware_offset(self, engine): + """A DAMPED T3 must be treated as T3, not misread as T1. + + Pre-fix: the tier was inferred by comparing the emergency layer's offset against + DM_CRITICAL_T3_OFFSET (8.5) / DM_CRITICAL_T2_OFFSET (7.0). But that offset has + already been through thermal-recovery damping and bottoms out at + THERMAL_RECOVERY_T3_MIN_OFFSET (2.0), so it fell through to the T1 branch and a + genuine T3 emergency received T1's minimal offset. + """ + damped_t3 = EmergencyLayerDecision( + name="T3", + offset=THERMAL_RECOVERY_T3_MIN_OFFSET, # damped from 8.5 by solar gain + weight=DM_CRITICAL_T3_WEIGHT, + reason="T3 recovery [damped: warming]", + tier="T3", + degree_minutes=-1400, + ) + + offset = engine._aggregate_layers(build_layers(damped_t3, effect=critical_effect_peak())) + + assert offset == pytest.approx(DM_CRITICAL_T3_PEAK_AWARE_OFFSET), ( + f"Damped T3 got {offset:+.1f}; expected the T3 peak-aware offset " + f"({DM_CRITICAL_T3_PEAK_AWARE_OFFSET}). Tier must come from the `tier` field, " + "not from the post-damping offset magnitude." + ) + assert offset != pytest.approx( + DM_CRITICAL_T1_PEAK_AWARE_OFFSET + ), "Damped T3 was misclassified as T1" + + +class TestAggregateOutputIsBounded: + """The aggregator must never emit an offset outside the pump's valid range.""" + + def test_aggregate_never_exceeds_offset_bounds(self, engine): + """Even with extreme layer votes, the result stays within [MIN_OFFSET, MAX_OFFSET].""" + extreme = EmergencyLayerDecision( + name="T3", + offset=999.0, + weight=DM_CRITICAL_T3_WEIGHT, + reason="pathological", + tier="T3", + degree_minutes=-1400, + ) + + offset = engine._aggregate_layers(build_layers(extreme)) + + assert MIN_OFFSET <= offset <= MAX_OFFSET + + +class TestAuxLimitEnforcedBeforeEarlyReturns: + """thermal_layer must check the aux limit BEFORE its early-return branches.""" + + @staticmethod + def _layer() -> EmergencyLayer: + return EmergencyLayer( + climate_detector=ClimateZoneDetector(STOCKHOLM_LATITUDE), + heating_type="radiator", + ) + + @staticmethod + def _state(degree_minutes: float, indoor_temp: float, current_offset: float = 0.0): + return NibeState( + outdoor_temp=-15.0, + indoor_temp=indoor_temp, + supply_temp=35.0, + return_temp=30.0, + degree_minutes=degree_minutes, + current_offset=current_offset, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 6, 0), + ) + + def test_aux_limit_enforced_even_when_house_is_too_warm(self): + """DM past the aux limit must fire EMERGENCY even if indoor is above tolerance. + + Pre-fix: Case 1 ("too warm") returned weight 0.0 with no aux-limit guard, while the + neighbouring Case 2 guarded on `dm > DM_THRESHOLD_AUX_LIMIT` - so a solar-gain morning + during a debt spiral silently disabled the hard limit. With the production default + tolerance (0.5 -> tolerance_range 0.2 C), 0.3 C over target triggers Case 1. + """ + decision = self._layer().evaluate_layer( + nibe_state=self._state(degree_minutes=DM_THRESHOLD_AUX_LIMIT - 50, indoor_temp=21.3), + weather_data=None, + price_data=None, + target_temp=21.0, + tolerance_range=0.2, # production default: tolerance 0.5 * 0.4 + ) + + assert decision.tier == "EMERGENCY", ( + f"Aux limit not enforced when too warm - got tier={decision.tier!r}, " + f"offset={decision.offset:+.1f}, weight={decision.weight}. " + "The DM -1500 hard limit must outrank the 'too warm' early return." + ) + assert decision.weight == pytest.approx(LAYER_WEIGHT_SAFETY) + assert decision.offset == pytest.approx(SAFETY_EMERGENCY_OFFSET) + + def test_aux_limit_enforced_during_anti_windup_cooldown(self): + """DM past the aux limit must fire EMERGENCY even inside the anti-windup cooldown. + + Pre-fix: the cooldown branch returned early with weight 0.7 and the pump's current + offset, so for up to ANTI_WINDUP_COOLDOWN_MINUTES the aux limit was not enforced + at all. + """ + layer = self._layer() + now = datetime(2026, 1, 15, 6, 0) + layer._anti_windup_cooldown_until = now + timedelta(minutes=20) + + decision = layer.evaluate_layer( + nibe_state=self._state( + degree_minutes=DM_THRESHOLD_AUX_LIMIT - 50, + indoor_temp=20.5, + current_offset=1.0, + ), + weather_data=None, + price_data=None, + target_temp=21.0, + tolerance_range=0.2, + ) + + assert decision.tier == "EMERGENCY", ( + f"Aux limit not enforced during anti-windup cooldown - got tier={decision.tier!r}, " + f"offset={decision.offset:+.1f}. The hard limit must outrank the cooldown." + ) + assert decision.offset == pytest.approx(SAFETY_EMERGENCY_OFFSET) diff --git a/tests/unit/optimization/test_the_core_control_law_does_not_need_a_forecast.py b/tests/unit/optimization/test_the_core_control_law_does_not_need_a_forecast.py new file mode 100644 index 00000000..8d675774 --- /dev/null +++ b/tests/unit/optimization/test_the_core_control_law_does_not_need_a_forecast.py @@ -0,0 +1,129 @@ +"""The weather entity is optional; the weather-compensation CONTROL LAW is not. + +Math WC is the EN 442 emitter law: given the outdoor temperature and indoor setpoint, what flow +temperature do the emitters need? Its inputs are the pump's OWN sensors (nibe_state.outdoor_temp and +flow_temp), which are always present; it does not read the forecast. So `evaluate_layer` must NOT +early-return when weather_data is None - a blank optional weather dropdown would otherwise silently +switch off the layer that votes on every cycle. + +Invariant: with weather_data=None the Math WC layer still votes (weight > 0) and computes the SAME +offset it would with a forecast, keeping its sign across the whole winter; the pre-heat layer, which +genuinely needs a forecast, still abstains without one. +""" + +from __future__ import annotations + +import pytest + +from custom_components.effektguard.optimization.weather_layer import ( + AdaptiveClimateSystem, + WeatherCompensationCalculator, + WeatherCompensationLayer, + WeatherPredictionLayer, +) + + +class _NibeState: + """Only the fields the emitter law reads. All of them come from the pump itself.""" + + def __init__(self, outdoor_temp: float, flow_temp: float, degree_minutes: float = -30.0): + self.outdoor_temp = outdoor_temp + self.flow_temp = flow_temp + self.degree_minutes = degree_minutes + + +def _layer() -> WeatherCompensationLayer: + return WeatherCompensationLayer( + weather_comp=WeatherCompensationCalculator(), + climate_system=AdaptiveClimateSystem(latitude=59.3), # Stockholm + weather_learner=None, + ) + + +def test_math_wc_still_votes_when_no_weather_entity_is_configured(): + """The bug. A blank optional dropdown silently switched off the primary control law.""" + decision = _layer().evaluate_layer( + nibe_state=_NibeState(outdoor_temp=-5.0, flow_temp=30.0), + weather_data=None, # exactly what WeatherAdapter returns with no entity configured + target_temp=21.0, + ) + + assert decision.weight > 0.0, ( + f"Math WC returned weight={decision.weight} reason={decision.reason!r} because no weather " + f"entity is configured. Math WC is the EN 442 emitter law over the pump's OWN outdoor and " + f"flow sensors - it does not read the forecast. Switching it off leaves the air-source " + f"F2040 pinned at maximum offset against a saturated compressor: 13x more immersion heat " + f"than its capacity deficit forced, and 1265 minutes above the comfort ceiling." + ) + + +def test_the_offset_is_the_same_with_and_without_a_forecast(): + """It must not merely vote - it must compute the SAME answer. The forecast is not an input.""" + nibe_state = _NibeState(outdoor_temp=-5.0, flow_temp=30.0) + + without = _layer().evaluate_layer(nibe_state=nibe_state, weather_data=None, target_temp=21.0) + with_forecast = _layer().evaluate_layer( + nibe_state=nibe_state, + weather_data=_FORECAST_THAT_CHANGES_NOTHING, + target_temp=21.0, + ) + + assert without.offset == pytest.approx(with_forecast.offset), ( + f"the emitter law returned {without.offset} without a forecast and {with_forecast.offset} " + f"with one. Its inputs are the outdoor temperature, the flow temperature and the setpoint. " + f"A forecast that changes the answer means the forecast leaked into a calculation that is " + f"defined not to use it." + ) + assert without.weight == pytest.approx(with_forecast.weight) + + +@pytest.mark.parametrize("outdoor_temp", [-20.0, -10.0, -5.0, 0.0, 5.0, 10.0]) +def test_a_cold_house_is_still_told_to_add_heat_with_no_forecast(outdoor_temp): + """The law must keep its sign across the whole winter, not just at one temperature. + + Flow is held far below what the emitters need, so the correct answer is always "add heat". + Before the fix this returned a flat 0.0 at every outdoor temperature - the DM ran away, the + other layers pinned the offset at maximum, and the immersion heater picked up the difference. + """ + decision = _layer().evaluate_layer( + nibe_state=_NibeState(outdoor_temp=outdoor_temp, flow_temp=22.0), + weather_data=None, + target_temp=21.0, + ) + + assert decision.offset > 0.0 and decision.weight > 0.0, ( + f"at {outdoor_temp}C with the flow 22C - well under what the radiators need - Math WC " + f"proposed offset={decision.offset} weight={decision.weight}. With no forecast the law " + f"went quiet and the house was left to the layers that cannot see a heating curve." + ) + + +def test_the_forecast_layer_itself_still_stands_down_without_a_forecast(): + """The other half. Pre-heat genuinely needs a forecast, and must NOT invent one.""" + preheat = WeatherPredictionLayer(thermal_mass=1.0, forecast_horizon=12) + + decision = preheat.evaluate_layer( + nibe_state=_NibeState(outdoor_temp=-5.0, flow_temp=30.0), + weather_data=None, + thermal_trend={}, + ) + + assert decision.weight == 0.0, ( + "weather PRE-HEAT is forecast-driven by definition - with no forecast it must abstain, not " + "guess. Fixing Math WC must not drag it along." + ) + + +class _Hour: + def __init__(self, temperature: float): + self.temperature = temperature + + +class _Forecast: + def __init__(self): + self.current_temp = -5.0 + self.forecast_hours = [_Hour(-5.0) for _ in range(48)] + self.source_entity = "test" + + +_FORECAST_THAT_CHANGES_NOTHING = _Forecast() diff --git a/tests/unit/optimization/test_the_emergency_ladder_does_not_fire_in_summer.py b/tests/unit/optimization/test_the_emergency_ladder_does_not_fire_in_summer.py new file mode 100644 index 00000000..5faf2009 --- /dev/null +++ b/tests/unit/optimization/test_the_emergency_ladder_does_not_fire_in_summer.py @@ -0,0 +1,229 @@ +"""No degree-minute warning threshold may reach into the compressor's own cycling band. + +The zone thresholds are shifted shallower as it warms (`adjustment = temp_delta * 20`). NIBE starts +the compressor at DM_THRESHOLD_START (-60) and stops it at 0, so degree minutes traverse that band on +every normal cycle. Unbounded above, the Stockholm warning threshold climbed to -40 at +25 C and +60 +at +30 C - so in summer a healthy pump's ordinary compressor start armed the emergency ladder. + +The clamp must hold on the number the layers actually READ - AFTER `apply_thermal_mass_buffer`, which +divides by up to 1.3 (a clamp at -110 becomes -85, back inside the band). So these tests drive the +real layers with every heating_type, and check the warm-side ceiling never touches a winter threshold. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from custom_components.effektguard.const import ( + DM_THERMAL_MASS_BUFFER_CONCRETE, + DM_THERMAL_MASS_BUFFER_RADIATOR, + DM_THERMAL_MASS_BUFFER_TIMBER, + DM_THRESHOLD_START, + DM_WARNING_BUFFER, +) +from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector +from custom_components.effektguard.optimization.thermal_layer import ( + EmergencyLayer, + apply_thermal_mass_buffer, +) + +# Every zone the detector can land in. +LATITUDES = [ + (67.85, "Kiruna"), + (63.83, "Umea"), + (59.33, "Stockholm"), + (55.6, "Malmo"), + (48.86, "Paris"), +] +SUMMER = [15.0, 20.0, 25.0, 30.0, 35.0] + +# Every emitter the buffer knows about. The multiplier is what makes them differ, and it is the +# multiplier that undid the clamp - so a test that does not vary this cannot see the bug. +MULTIPLIERS = { + "radiator": DM_THERMAL_MASS_BUFFER_RADIATOR, + "concrete_ufh": DM_THERMAL_MASS_BUFFER_CONCRETE, + "concrete_slab": DM_THERMAL_MASS_BUFFER_CONCRETE, + "timber": DM_THERMAL_MASS_BUFFER_TIMBER, + "timber_ufh": DM_THERMAL_MASS_BUFFER_TIMBER, +} +HEATING_TYPES = list(MULTIPLIERS) + +TARGET = 22.0 +TOLERANCE = 1.0 + + +class _HealthyPumpOnASummerMorning: + """Nothing wrong here. The compressor has just started, so DM has dipped past its start point. + + Indoor is a fraction under target - which is ordinary, and is what stops the layer abstaining + outright - and the pump is answering it. This is the state that must NOT be called an emergency. + """ + + supply_temp = 30.0 + return_temp = 27.0 + current_offset = 0.0 + is_heating = True + is_hot_water = False + compressor_frequency = 40.0 + hot_water_temp = 50.0 + + def __init__(self, outdoor: float, degree_minutes: float): + self.outdoor_temp = outdoor + self.indoor_temp = TARGET - 0.2 + self.degree_minutes = degree_minutes + + +def _thresholds_the_layers_actually_read(latitude: float, outdoor: float, heating_type: str): + """The full production path: zone -> weather shift -> clamp -> thermal-mass buffer.""" + base = ClimateZoneDetector(latitude=latitude).get_expected_dm_range(outdoor) + return apply_thermal_mass_buffer(base, heating_type) + + +def test_the_compressor_really_does_cycle_through_this_band(): + """The precondition the whole file rests on.""" + assert DM_THRESHOLD_START == -60, ( + "NIBE starts the compressor at -60 DM and stops it at 0, so degree minutes traverse that " + "band on every normal cycle. If that changes, the ceiling below must move with it." + ) + + +@pytest.mark.parametrize(("latitude", "city"), LATITUDES) +@pytest.mark.parametrize("outdoor", SUMMER) +@pytest.mark.parametrize("heating_type", HEATING_TYPES) +def test_the_warning_threshold_never_reaches_into_the_compressors_own_cycling_band( + latitude, city, outdoor, heating_type +): + """The real bound, on the real number. A threshold inside -60..0 fires on normal operation. + + This is asserted AFTER the thermal-mass buffer, because that is the last thing that changes it + and it is what every layer reads. Asserting it before the divide is what let a concrete slab + warn at -85 while this file was green. + """ + warning = _thresholds_the_layers_actually_read(latitude, outdoor, heating_type)["warning"] + + assert warning <= DM_THRESHOLD_START - DM_WARNING_BUFFER, ( + f"{city}, {heating_type}, at {outdoor:+.0f} C outdoor warns at {warning:+.0f} DM. NIBE " + f"starts the compressor at {DM_THRESHOLD_START} DM and stops it at 0, and degree minutes " + f"undershoot the start point while the pump ramps - so a perfectly healthy pump passes " + f"through {warning:+.0f} on every cycle, all summer, and is told it is in thermal debt." + ) + + +@pytest.mark.parametrize(("latitude", "city"), LATITUDES) +@pytest.mark.parametrize("outdoor", SUMMER) +@pytest.mark.parametrize("heating_type", HEATING_TYPES) +def test_the_normal_band_does_not_end_inside_it_either(latitude, city, outdoor, heating_type): + """`normal_max` is the deep end of "normal", and the proactive tiers trigger off it too.""" + normal_max = _thresholds_the_layers_actually_read(latitude, outdoor, heating_type)["normal_max"] + + assert normal_max <= DM_THRESHOLD_START - DM_WARNING_BUFFER, ( + f"{city}, {heating_type}, at {outdoor:+.0f} C outdoor calls DM {normal_max:+.0f} the deep " + f"end of normal, which is inside the band the compressor cycles through by itself." + ) + + +@pytest.mark.parametrize("heating_type", HEATING_TYPES) +def test_a_healthy_pump_in_july_is_not_given_a_curve_boost(heating_type): + """A healthy summer compressor start must draw no emergency curve boost from any emitter. + + The layer is driven for real, with the heating_type set, at the degree minutes an ordinary + summer compressor start produces. + """ + layer = EmergencyLayer( + climate_detector=ClimateZoneDetector(latitude=59.33), heating_type=heating_type + ) + pump = _HealthyPumpOnASummerMorning(outdoor=25.0, degree_minutes=-85.0) + now = datetime(2026, 7, 13, 6, 0, tzinfo=timezone.utc) + + decision = layer.evaluate_layer(pump, None, None, TARGET, TOLERANCE, lambda: now, False) + + assert decision.weight == 0.0 and decision.offset == 0.0, ( + f"A {heating_type} house at {pump.indoor_temp} C ({TARGET - pump.indoor_temp:.1f} C under " + f"target) on a +{pump.outdoor_temp:.0f} C July morning, with degree minutes at " + f"{pump.degree_minutes:+.0f} because the compressor has just started, is commanded " + f"{decision.offset:+.1f} C of curve offset at weight {decision.weight:.2f}. Reason: " + f"{decision.reason!r}. There is nothing wrong with this heat pump." + ) + + +@pytest.mark.parametrize("heating_type", HEATING_TYPES) +def test_a_pump_in_real_thermal_debt_in_winter_still_gets_help(heating_type): + """The regression guard, and the more important half. The ceiling must not sedate the ladder. + + -30 C, the house losing ground, degree minutes far past anything the zone calls normal. Every + emitter must still answer, or the clamp has traded a July false alarm for a January failure. + """ + layer = EmergencyLayer( + climate_detector=ClimateZoneDetector(latitude=59.33), heating_type=heating_type + ) + pump = _HealthyPumpOnASummerMorning(outdoor=-30.0, degree_minutes=-1300.0) + pump.indoor_temp = 19.5 # well below the comfort band + pump.supply_temp = 55.0 + now = datetime(2026, 1, 13, 6, 0, tzinfo=timezone.utc) + + decision = layer.evaluate_layer(pump, None, None, TARGET, TOLERANCE, lambda: now, False) + + assert decision.offset > 0 and decision.weight > 0, ( + f"A {heating_type} house at 19.5 C in a -30 C snap, {pump.degree_minutes:+.0f} degree " + f"minutes in debt, is offered {decision.offset:+.1f} C at weight {decision.weight:.2f}. " + f"The warm-side ceiling is a ceiling on mild days, never a floor on cold ones." + ) + + +@pytest.mark.parametrize(("latitude", "city"), LATITUDES) +@pytest.mark.parametrize("heating_type", HEATING_TYPES) +def test_the_ceiling_is_inert_in_winter(latitude, city, heating_type): + """The warm-side ceiling must not touch a single winter threshold. + + In winter the final warning must be exactly base / multiplier, unclamped - the ceiling is a + no-op there. + """ + detector = ClimateZoneDetector(latitude=latitude) + multiplier = MULTIPLIERS[heating_type] + + for outdoor in (-30.0, -20.0, -10.0, 0.0): + base = detector.get_expected_dm_range(outdoor) + buffered = apply_thermal_mass_buffer(base, heating_type) + + assert buffered["warning"] == pytest.approx(base["warning"] / multiplier), ( + f"{city}, {heating_type}, at {outdoor:+.0f} C: the warm-side ceiling has reached into " + f"winter. The warning threshold should be {base['warning'] / multiplier:.0f} " + f"(base {base['warning']:.0f} / {multiplier}), but the ceiling pulled it to " + f"{buffered['warning']:.0f} and made the emergency ladder less sensitive in a cold " + f"snap. It is a ceiling on mild days, never a floor on cold ones." + ) + + +@pytest.mark.parametrize("heating_type", HEATING_TYPES) +def test_the_thresholds_still_deepen_as_it_gets_colder(heating_type): + """The whole mechanism must survive the fix.""" + detector = ClimateZoneDetector(latitude=59.33) + warnings = [ + apply_thermal_mass_buffer(detector.get_expected_dm_range(t), heating_type)["warning"] + for t in (-20.0, -10.0, 0.0, 10.0) + ] + + assert warnings == sorted(warnings), ( + f"The warning threshold for a {heating_type} house must get DEEPER as it gets colder. Got " + f"{[round(w) for w in warnings]} for -20/-10/0/+10 C." + ) + + +def test_a_slow_house_still_reacts_sooner_than_a_fast_one(): + """The buffer's actual purpose, which the clamp must not flatten. + + In winter - where the buffer is meant to act - a concrete slab must still warn EARLIER (at a + shallower DM) than a radiator system, because heat put into a slab arrives hours later. If the + ceiling made every emitter equal, it would have deleted the feature instead of bounding it. + """ + base = ClimateZoneDetector(latitude=59.33).get_expected_dm_range(-10.0) + + radiator = apply_thermal_mass_buffer(base, "radiator")["warning"] + slab = apply_thermal_mass_buffer(base, "concrete_slab")["warning"] + + assert slab > radiator, ( + f"At -10 C a concrete slab warns at {slab:.0f} DM and a radiator at {radiator:.0f}. The " + f"slab must warn SOONER (shallower), or the thermal-mass buffer is doing nothing." + ) diff --git a/tests/unit/optimization/test_the_flow_curve_has_no_cliff_and_no_dead_path.py b/tests/unit/optimization/test_the_flow_curve_has_no_cliff_and_no_dead_path.py new file mode 100644 index 00000000..42925109 --- /dev/null +++ b/tests/unit/optimization/test_the_flow_curve_has_no_cliff_and_no_dead_path.py @@ -0,0 +1,204 @@ +"""Three invariants of the EN 442 emitter law, each guarding a real defect in the flow curve. + +1. No STEP at the balance point: `return indoor_setpoint` above it leaves a 2.5 C jump (spread/2), + and the shoulder season crosses the balance point (~17 C) repeatedly. +2. Both anchors see internal gains: `calculate_rated_output_flow_temp` is the PREFERRED anchor + (confidence 0.95), so wiring gains only into the design-point anchor is a no-op for installers + who configured their emitters, and the two anchors of one law then disagree. +3. Internal gains are WATTS over the house's own W/K, not a fixed offset in degrees - the balance + point is derived (INTERNAL_GAINS_W / heat_loss_coefficient), bounded, and follows the setpoint. +""" + +from __future__ import annotations + +import pytest + +from custom_components.effektguard.const import ( + BALANCE_POINT_MAX_OFFSET, + BALANCE_POINT_MIN_OFFSET, + DEFAULT_DESIGN_SPREAD, + DEFAULT_HEAT_LOSS_COEFFICIENT, + INTERNAL_GAINS_W, +) +from custom_components.effektguard.optimization.weather_layer import ( + WeatherCompensationCalculator, +) + +TARGET = 21.0 + + +def test_the_flow_curve_has_no_step_at_the_balance_point(): + """Sweep the curve across its own discontinuity and demand that there isn't one. + + Below the balance point the emitters need no excess over the room. The naive `return + indoor_setpoint` puts a step of spread/2 (2.5 C on the defaults) right at the balance point + (~17-18 C), because the other side tends to `indoor_setpoint + spread/2` as load goes to zero. + The shoulder season crosses that boundary repeatedly, so a step there is the pump chattering. + """ + calc = WeatherCompensationCalculator(heat_loss_coefficient=DEFAULT_HEAT_LOSS_COEFFICIENT) + balance = calc.balance_point_temp(TARGET) + cliff_if_broken = DEFAULT_DESIGN_SPREAD / 2.0 # 2.5 C - what the naive `return setpoint` costs + + # Straddle the balance point finely enough that a step cannot hide between samples. + outdoors = [balance - 1.0 + i * 0.01 for i in range(201)] + flows = [calc.calculate_design_point_flow_temp(TARGET, t) for t in outdoors] + + steps = [abs(b - a) for a, b in zip(flows, flows[1:])] + worst = max(steps) + where = outdoors[steps.index(worst)] + + # The law is continuous but STEEP at zero load: dT ~ phi^(1/n) has an infinite derivative at + # phi = 0, so the curve genuinely does move a few hundredths over the last 0.01 C. That is the + # emitter law, not a defect. A missing spread term is a 2.5 C JUMP - fifty times larger. + assert worst < cliff_if_broken / 10.0, ( + f"The flow curve jumps {worst:.2f} C between {where:.2f} C and {where + 0.01:.2f} C " + f"outdoor - a cliff at the balance point ({balance:.2f} C). The shoulder season sits on " + f"top of this boundary and the outdoor temperature crosses it repeatedly, so the pump " + f"would be commanded up and down by {worst:.2f} C all day. Returning a bare " + f"`indoor_setpoint` above the balance point costs exactly {cliff_if_broken:.1f} C here." + ) + + +def test_the_curve_is_flat_and_continuous_above_the_balance_point(): + """Above the balance point the house needs no heat, and the two sides must meet.""" + calc = WeatherCompensationCalculator(heat_loss_coefficient=DEFAULT_HEAT_LOSS_COEFFICIENT) + balance = calc.balance_point_temp(TARGET) + no_heat_needed = TARGET + DEFAULT_DESIGN_SPREAD / 2.0 + + just_above = calc.calculate_design_point_flow_temp(TARGET, balance + 0.5) + far_above = calc.calculate_design_point_flow_temp(TARGET, balance + 10.0) + + assert just_above == pytest.approx(no_heat_needed, abs=0.01) + assert far_above == pytest.approx(no_heat_needed, abs=0.01) + + # Approach the boundary from below. The excess over the room must tend to zero, so the two + # sides meet - that is what makes the curve continuous rather than merely close. + from_below = calc.calculate_design_point_flow_temp(TARGET, balance - 1e-9) + assert from_below == pytest.approx(no_heat_needed, abs=0.01), ( + f"Approaching the balance point from below, the curve converges on {from_below:.3f} C but " + f"holds {no_heat_needed:.3f} C above it. The two sides do not meet: there is a step of " + f"{abs(from_below - no_heat_needed):.2f} C at the balance point." + ) + + +def test_the_preferred_anchor_is_not_left_out_of_the_gains_fix(): + """`calculate_rated_output_flow_temp` is chosen at confidence 0.95. It must see the gains too. + + The layer prefers the rated-output anchor whenever an installer supplies their emitters' + nameplate figure, so gains wired into the design-point anchor only would do nothing for them. + A gains-aware curve stops needing heat at the balance point, so at an outdoor temperature + above the balance point but below the setpoint it is already flat while a gains-blind one + still asks for heat. + """ + calc = WeatherCompensationCalculator( + heat_loss_coefficient=DEFAULT_HEAT_LOSS_COEFFICIENT, + radiator_rated_output=9000.0, + ) + balance = calc.balance_point_temp(TARGET) + assert balance < TARGET - 1.0, "precondition: gains must move the balance point at all" + + # Between the balance point and the setpoint: no heat is needed, and both anchors must say so. + outdoor = (balance + TARGET) / 2.0 + rated = calc.calculate_rated_output_flow_temp(TARGET, outdoor, DEFAULT_DESIGN_SPREAD) + flat = TARGET + DEFAULT_DESIGN_SPREAD / 2.0 + + assert rated == pytest.approx(flat, abs=0.01), ( + f"At {outdoor:.1f} C outdoor - above the {balance:.1f} C balance point - the house is " + f"heating itself, yet the PREFERRED anchor still asks for {rated:.1f} C of flow. It is " + f"computing its load as (setpoint - outdoor) and has never been told about internal gains. " + f"Every installer who filled in their emitters' rated output gets this path." + ) + + +def test_both_anchors_agree_when_the_house_is_described_consistently(): + """One law, two anchors - so given a self-consistent house they must give the SAME curve. + + The five inputs (heat loss, design flow, design outdoor, spread, rated output) are + over-determined: any four fix the fifth, but nothing enforces consistency and the layer + silently prefers the rated-output anchor. This pins the invariant: when the inputs agree, + the anchors agree exactly. + """ + room, dot, spread, hlc = TARGET, -15.0, DEFAULT_DESIGN_SPREAD, DEFAULT_HEAT_LOSS_COEFFICIENT + design_flow = 50.0 + + probe = WeatherCompensationCalculator(heat_loss_coefficient=hlc) + balance = probe.balance_point_temp(room) + + # The rated output this house's design point implies, by the same EN 442 law. + design_load_w = hlc * (balance - dot) + mean_dt = design_flow - spread / 2.0 - room + consistent_rated = design_load_w / ((mean_dt / 50.0) ** 1.3) + + calc = WeatherCompensationCalculator( + heat_loss_coefficient=hlc, + radiator_rated_output=consistent_rated, + design_outdoor_temp=dot, + design_flow_temp=design_flow, + design_spread=spread, + ) + + for outdoor in (-20.0, -15.0, -5.0, 0.0, 5.0, 10.0, 15.0): + by_design = calc.calculate_design_point_flow_temp(room, outdoor) + by_rating = calc.calculate_rated_output_flow_temp(room, outdoor, spread) + assert by_rating == pytest.approx(by_design, abs=0.05), ( + f"At {outdoor:+.1f} C the two anchors of the same law disagree: design point says " + f"{by_design:.2f} C, rated output says {by_rating:.2f} C. They were given a house whose " + f"description is self-consistent, so they must produce the same curve." + ) + + +class TestGainsAreWattsNotDegrees: + """The balance point must be DERIVED from the house, not stamped on as a constant.""" + + def test_an_insulated_house_gets_more_degrees_from_the_same_free_heat(self): + """600 W of bodies and appliances is worth more degrees in a house that loses heat slowly. + + This is the whole reason the constant is watts. A fixed offset in degrees would hand a + draughty 300 W/K house the same 4 K of free heat as a 100 W/K passive house - crediting the + leaky one with three times the internal gains it actually has. + """ + leaky = WeatherCompensationCalculator(heat_loss_coefficient=300.0) + typical = WeatherCompensationCalculator(heat_loss_coefficient=180.0) + tight = WeatherCompensationCalculator(heat_loss_coefficient=100.0) + + leaky_offset = TARGET - leaky.balance_point_temp(TARGET) + typical_offset = TARGET - typical.balance_point_temp(TARGET) + tight_offset = TARGET - tight.balance_point_temp(TARGET) + + assert leaky_offset < typical_offset < tight_offset, ( + f"The balance-point offset must shrink as a house gets leakier: got {leaky_offset:.2f} " + f"K at 300 W/K, {typical_offset:.2f} K at 180 W/K, {tight_offset:.2f} K at 100 W/K. If " + f"these are equal, the gains have been re-frozen into a constant number of degrees and " + f"the same fridge is heating a draughty house as much as a sealed one." + ) + + def test_the_offset_is_the_gains_divided_by_the_heat_loss(self): + """Not approximately. Exactly - it is a definition, not a tuning.""" + for hlc in (120.0, 180.0, 250.0): + calc = WeatherCompensationCalculator(heat_loss_coefficient=hlc) + expected = TARGET - INTERNAL_GAINS_W / hlc + assert calc.balance_point_temp(TARGET) == pytest.approx(expected, abs=0.001) + + def test_the_balance_point_follows_the_setpoint_the_owner_chose(self): + """A 19 C house balances 2 C lower than a 21 C house. The gains do not change.""" + calc = WeatherCompensationCalculator(heat_loss_coefficient=DEFAULT_HEAT_LOSS_COEFFICIENT) + assert calc.balance_point_temp(19.0) == pytest.approx(calc.balance_point_temp(21.0) - 2.0) + + def test_an_absurd_heat_loss_cannot_switch_the_heating_off(self): + """A mis-typed 20 W/K would put the balance point 30 K below the setpoint. + + That is a house that never asks for heat. The bound is not cosmetic: `heat_loss_coefficient` + is not validated anywhere in the config flow today, so it is exactly the kind of number that + arrives wrong. + """ + absurdly_tight = WeatherCompensationCalculator(heat_loss_coefficient=20.0) + absurdly_leaky = WeatherCompensationCalculator(heat_loss_coefficient=5000.0) + + tight_offset = TARGET - absurdly_tight.balance_point_temp(TARGET) + leaky_offset = TARGET - absurdly_leaky.balance_point_temp(TARGET) + + assert tight_offset == pytest.approx(BALANCE_POINT_MAX_OFFSET), ( + f"A 20 W/K heat loss puts the balance point {tight_offset:.1f} K below the setpoint. " + f"The house would stop asking for heat at {TARGET - tight_offset:.1f} C outdoor." + ) + assert leaky_offset == pytest.approx(BALANCE_POINT_MIN_OFFSET) diff --git a/tests/unit/optimization/test_the_prediction_gates_count_in_the_right_units.py b/tests/unit/optimization/test_the_prediction_gates_count_in_the_right_units.py new file mode 100644 index 00000000..85cea015 --- /dev/null +++ b/tests/unit/optimization/test_the_prediction_gates_count_in_the_right_units.py @@ -0,0 +1,86 @@ +"""The prediction gates must count in SAMPLES_PER_HOUR, not a remembered sample count. + +The coordinator records one sample every UPDATE_INTERVAL_MINUTES - twelve an hour, not four. Gates +that hardcoded 96 samples "for 24 hours" actually opened at 8 hours, so the learned pre-heat layer +engaged on a third of the data it believed it had. + +Invariant: every gate is `hours * SAMPLES_PER_HOUR` (24 h -> 288 samples), the predictor's deque can +hold what the gate asks for, and the learning-progress reason string uses the same denominator. +""" + +from __future__ import annotations + +from custom_components.effektguard.const import ( + PREDICTION_LEARNED_PREHEAT_MIN_HOURS, + PREDICTION_MIN_HISTORY_HOURS, + PREDICTION_RESPONSIVENESS_MIN_HOURS, + SAMPLES_PER_HOUR, + UPDATE_INTERVAL_MINUTES, +) +from custom_components.effektguard.optimization.prediction_layer import ThermalStatePredictor + + +def test_the_coordinator_really_does_record_twelve_samples_an_hour(): + """The premise. Every count below is meaningless without it.""" + assert SAMPLES_PER_HOUR == 60 // UPDATE_INTERVAL_MINUTES + assert SAMPLES_PER_HOUR == 12, ( + f"The coordinator ticks every {UPDATE_INTERVAL_MINUTES} min, so it records " + f"{SAMPLES_PER_HOUR} samples an hour. The old gates were written believing it was 4." + ) + + +def test_a_full_day_of_history_is_a_full_day_of_history(): + """The gate that mattered: 96 samples is eight hours, not twenty-four.""" + required = PREDICTION_LEARNED_PREHEAT_MIN_HOURS * SAMPLES_PER_HOUR + + assert required == 288, ( + f"The learned pre-heat gate needs {required} samples for " + f"{PREDICTION_LEARNED_PREHEAT_MIN_HOURS} hours. It used to hardcode 96 - which at a " + f"{UPDATE_INTERVAL_MINUTES}-minute tick is {96 / SAMPLES_PER_HOUR:.0f} hours, so the layer " + f"acted on a third of the data it thought it had." + ) + assert required / SAMPLES_PER_HOUR == PREDICTION_LEARNED_PREHEAT_MIN_HOURS + + +def test_the_predictors_own_deque_can_hold_what_the_gate_asks_for(): + """A gate that can never open is worse than one that opens early.""" + predictor = ThermalStatePredictor() + required = PREDICTION_LEARNED_PREHEAT_MIN_HOURS * SAMPLES_PER_HOUR + + assert predictor.state_history.maxlen >= required, ( + f"The learned pre-heat gate wants {required} samples and the history deque holds only " + f"{predictor.state_history.maxlen}. It could never engage at all." + ) + + +def test_the_learning_progress_message_counts_in_the_same_units_as_the_gate(): + """The reason string hardcoded 96 too, so it told the owner the wrong denominator.""" + from unittest.mock import MagicMock + + predictor = ThermalStatePredictor() + required = PREDICTION_LEARNED_PREHEAT_MIN_HOURS * SAMPLES_PER_HOUR + + decision = predictor.evaluate_layer( + nibe_state=MagicMock(), + weather_data=MagicMock(), + target_temp=21.0, + thermal_model=MagicMock(), + ) + + assert f"0/{required}" in decision.reason, ( + f"The layer reports its learning progress as {decision.reason!r}. The denominator must be " + f"the number of samples the gate actually waits for ({required}), not the 96 it used to " + f"print." + ) + + +def test_every_gate_is_expressed_in_hours_not_in_a_remembered_sample_count(): + """All three, so the next one to be added cannot quietly reintroduce the belief.""" + for hours in ( + PREDICTION_MIN_HISTORY_HOURS, + PREDICTION_RESPONSIVENESS_MIN_HOURS, + PREDICTION_LEARNED_PREHEAT_MIN_HOURS, + ): + samples = hours * SAMPLES_PER_HOUR + assert samples % SAMPLES_PER_HOUR == 0 + assert samples / SAMPLES_PER_HOUR == hours diff --git a/tests/unit/optimization/test_the_price_layer_reads_prices_not_just_rankings.py b/tests/unit/optimization/test_the_price_layer_reads_prices_not_just_rankings.py new file mode 100644 index 00000000..fd84e1a4 --- /dev/null +++ b/tests/unit/optimization/test_the_price_layer_reads_prices_not_just_rankings.py @@ -0,0 +1,146 @@ +"""Percentile RANK is scale-invariant, so on its own it cannot see a price at all. + +Banding purely by rank has two consequences a ranking cannot notice, both pinned here: + + * a FLAT day (39.80-40.20 ore) earns the full VERY_CHEAP..PEAK banding - a 14 C swing to chase + four tenths of an ore. The fix requires the day's spread to be material against the day's own + price SCALE (PRICE_FLAT_DAY_SPREAD_FRACTION), which is relative and so survives the fact that + PriceData carries no unit (an absolute ore threshold would be 100x wrong in SEK/kWh); + * on a high-wind day the plateau IS the median (p25 == p75 == p90 == 120), so free electricity + went NORMAL while the dear plateau, if the guard is removed naively, goes CHEAP. Both must be + resolved without the naive fix that turned an ordinary day into PEAK quarters and was reverted. +""" + +from __future__ import annotations + +import collections +from datetime import datetime, timedelta, timezone + +import numpy as np +import pytest + +from custom_components.effektguard.adapters.gespot_adapter import QuarterPeriod +from custom_components.effektguard.const import ( + PRICE_FLAT_DAY_SPREAD_FRACTION, + QuarterClassification, +) +from custom_components.effektguard.optimization.price_layer import PriceAnalyzer + +MIDNIGHT = datetime(2026, 1, 15, 0, 0, tzinfo=timezone.utc) + + +def _day(prices: list[float]) -> list[QuarterPeriod]: + return [ + QuarterPeriod(start_time=MIDNIGHT + timedelta(minutes=15 * i), price=float(p)) + for i, p in enumerate(prices) + ] + + +def _bands(prices: list[float]) -> collections.Counter: + result = PriceAnalyzer().classify_quarterly_periods(_day(prices)) + return collections.Counter(c.value if hasattr(c, "value") else c for c in result.values()) + + +HIGH_WIND = [120.0] * 83 + [-10.0] * 13 +FLAT = list(np.linspace(39.8, 40.2, 96)) +ORDINARY = [28.0] * 32 + [40.0] * 32 + [52.0] * 32 +VOLATILE = list(np.linspace(20.0, 250.0, 96)) + + +class TestFreeElectricityIsBought: + """The grid is PAYING you. This is the single cheapest power of the year.""" + + def test_the_negative_quarters_are_classified_very_cheap(self): + bands = _bands(HIGH_WIND) + + assert bands[QuarterClassification.VERY_CHEAP] == 13, ( + f"On a day with 13 quarters at MINUS 10 ore - the grid paying you to take the power - " + f"the classification came out {dict(bands)}. The middle of the distribution is a " + f"plateau (p25 == p75 == p90 == 120), and the old guard tested exactly that and gave " + f"up, marking the whole day NORMAL." + ) + + def test_the_expensive_plateau_is_not_classified_cheap(self): + """THE TRAP. Deleting the plateau guard naively is WORSE than leaving the bug in.""" + bands = _bands(HIGH_WIND) + + assert bands[QuarterClassification.CHEAP] == 0, ( + f"The 83 quarters at the day's HIGHEST price (120 ore) were classified CHEAP - which " + f"commands +4.0 C of EXTRA HEAT at the most expensive moment of the day. They satisfy " + f"`price <= p25` because p25 sits on the plateau. Got {dict(bands)}." + ) + assert bands[QuarterClassification.NORMAL] == 83 + + +class TestAFlatDayIsNotOptimised: + """Ranking noise is not a price signal.""" + + def test_four_tenths_of_an_ore_does_not_earn_a_fourteen_degree_swing(self): + bands = _bands(FLAT) + + assert set(bands) == {QuarterClassification.NORMAL}, ( + f"A day spanning 39.80 to 40.20 ore - a spread of 0.4 ore - was classified " + f"{dict(bands)}. VERY_CHEAP commands +4.0 C and PEAK commands -10.0 C, so this is a " + f"14 C swing in commanded offset, and a heat pump thrown around all day, to chase four " + f"tenths of an ore." + ) + + def test_the_test_is_relative_because_nothing_here_knows_its_unit(self): + """The same flat day in SEK/kWh instead of ore. An absolute threshold would be 100x wrong. + + PriceData carries no unit. GE-Spot publishes whatever the owner configured. A threshold + expressed in ore would silently misbehave for every user reporting SEK/kWh - and because + percentile ranking is scale-invariant, nothing would ever have flagged it. + """ + in_sek = [p / 100.0 for p in FLAT] + + assert _bands(in_sek) == _bands(FLAT), ( + "The same day, priced in SEK/kWh rather than ore/kWh, classified differently. The " + "flat-day test must be scale-invariant - the layer does not know its own unit." + ) + + def test_a_genuinely_volatile_day_is_still_optimised(self): + """The regression guard on the guard: do not switch the product off.""" + bands = _bands(VOLATILE) + + assert bands[QuarterClassification.PEAK] > 0 + assert bands[QuarterClassification.VERY_CHEAP] > 0 + + def test_the_threshold_is_a_fraction_of_the_days_own_scale(self): + assert 0.0 < PRICE_FLAT_DAY_SPREAD_FRACTION < 0.5 + + +class TestTheRegressionThatGotTheLastAttemptReverted: + """An ordinary day must not suddenly sprout critical PEAK quarters.""" + + def test_an_ordinary_day_produces_no_peak_quarters(self): + """Flipping `> p90` to `>= p90` turns a THIRD of an ordinary day into PEAK quarters at + weight 1.0 and PRICE_OFFSET_PEAK (-10.0). The strict `>` must hold. + """ + bands = _bands(ORDINARY) + + assert bands[QuarterClassification.PEAK] == 0, ( + f"An ordinary 28/40/52 ore day produced {bands[QuarterClassification.PEAK]} PEAK " + f"quarters. PEAK commands -10.0 C at critical weight. A third of an ordinary day " + f"spent at maximum heat reduction is how the last attempt at this was reverted." + ) + + def test_an_ordinary_day_is_unchanged_by_this_fix(self): + bands = _bands(ORDINARY) + assert bands[QuarterClassification.VERY_CHEAP] == 32 + assert bands[QuarterClassification.NORMAL] == 64 + + +class TestTheOldBehaviourThatWasCorrect: + def test_the_uniform_fallback_day_is_still_all_normal(self): + assert set(_bands([1.0] * 96)) == {QuarterClassification.NORMAL} + + def test_an_all_negative_day_is_still_ranked(self): + """Prices below zero happen routinely in SE1-SE4. Relative differences still matter.""" + bands = _bands(list(np.linspace(-50.0, -5.0, 96))) + + assert bands[QuarterClassification.VERY_CHEAP] > 0 + assert bands[QuarterClassification.PEAK] > 0 + + def test_an_empty_day_does_not_raise(self): + assert PriceAnalyzer().classify_quarterly_periods([]) == {} diff --git a/tests/unit/optimization/test_the_savings_figure_is_not_the_night_weighting.py b/tests/unit/optimization/test_the_savings_figure_is_not_the_night_weighting.py new file mode 100644 index 00000000..b1897370 --- /dev/null +++ b/tests/unit/optimization/test_the_savings_figure_is_not_the_night_weighting.py @@ -0,0 +1,368 @@ +"""The effect-tariff saving must compare like with like, from a billable source. + +The Swedish tariff halves night quarters, so the effect layer carries both `actual_power` (6.0 kW) +and `effective_power` (3.0 kW at 02:00). `peak_this_month` is the effective figure, so the baseline +the coordinator feeds must be weighted the same way; feeding it `actual_power` compares the same +quarter against itself and reports the night weighting as a saving, flagged MEASURED. + +Two invariants, driven through the coordinator: the baseline is the same quantity as +peak_this_month, and it is built only from BILLABLE_POWER_SOURCES (the external meter) - a +NIBE-currents peak may throttle the pump but must never become a figure in kronor. The dashboard +sensors weight both sides the same way, and an unmeasured baseline says so rather than reading 0 SEK. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.util import dt as dt_util + +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.coordinator import EffektGuardCoordinator +from custom_components.effektguard.optimization.effect_layer import EffectManager + +# 02:00: the night weighting halves this quarter. The whole bug lives in that halving. +NIGHT_HOUR = 2 +DAY_HOUR = 10 +SIX_KW_OF_CURRENT = 8.7 # amps per phase, 3-phase 230 V -> ~6.0 kW + + +@pytest.fixture +def coordinator(): + """The owner has a whole-house meter, and optimisation is switched OFF. + + That is the state in which the baseline is measured: the coordinator holds the curve offset at + 0.0, so the quarters recorded now are what this house does WITHOUT EffektGuard. + """ + hass = MagicMock() + hass.config.latitude = 59.33 + hass.config.longitude = 18.07 + + nibe = MagicMock() + nibe._power_sensor_entity = "sensor.house_power" + nibe.power_sensor_entity = "sensor.house_power" + + entry = MagicMock() + entry.data = {"enable_optimization": False} + entry.options = {} + + coord = EffektGuardCoordinator( + hass, nibe, MagicMock(), MagicMock(), MagicMock(), EffectManager(hass), entry + ) + coord.peak_today = 0.0 + coord.peak_this_month = 0.0 + coord.effect._store = MagicMock() + coord.effect._store.async_save = AsyncMock() + coord.effect._monthly_peaks = [] + return coord + + +def _metered_house(hour: int, power_kw: float) -> NibeState: + """A NibeState timestamped in the given hour. Power comes from the external meter.""" + return NibeState( + outdoor_temp=-5.0, + indoor_temp=21.0, + supply_temp=42.0, + return_temp=37.0, + degree_minutes=-150.0, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, hour, 0, tzinfo=timezone.utc), + phase1_current=None, + phase2_current=None, + phase3_current=None, + compressor_hz=60, + ) + + +async def _observe_a_whole_quarter(coord, monkeypatch, hour: int, power_kw: float) -> None: + """Drive a whole billing hour so it completes and is recorded as a tariff peak. + + The meter must actually READ: a bare MagicMock state is refused by `power_kw_from_state` + (its unit is a MagicMock and the integration will not guess a power unit), which records no + peak and sets no baseline. The callers assert a precondition that the peak was really recorded, + so a vacuously-green run cannot hide the bug. + """ + state = MagicMock() + state.entity_id = "sensor.house_power" + state.state = str(power_kw) + state.attributes = {"unit_of_measurement": "kW"} + coord.hass.states.get = MagicMock(return_value=state) + + nibe_data = _metered_house(hour, power_kw) + + # A whole BILLING HOUR, because that is what the tariff bills. + for h, m in [(hour, mm) for mm in range(0, 60, 5)] + [(hour + 1, 0)]: + monkeypatch.setattr( + dt_util, + "now", + lambda tz=None, m=m, h=h: datetime(2026, 1, 15, h, m, tzinfo=timezone.utc), + ) + await coord._update_peak_tracking(nibe_data) + + assert coord.effect._monthly_peaks, ( + "PRECONDITION FAILED: no billing hour was recorded, so nothing downstream of here means " + "anything. The meter did not read." + ) + + +def _savings(coord): + return coord.savings_calculator.estimate_monthly_savings( + current_peak_kw=coord.peak_this_month, + baseline_peak_kw=coord.savings_calculator._baseline_monthly_peak, + average_spot_savings_per_day=0.0, + ) + + +@pytest.mark.asyncio +async def test_a_single_night_quarter_is_not_a_saving(coordinator, monkeypatch): + """The bug, through the coordinator. The optimiser is OFF and does nothing at all.""" + await _observe_a_whole_quarter(coordinator, monkeypatch, NIGHT_HOUR, 6.0) + + baseline = coordinator.savings_calculator._baseline_monthly_peak + estimate = _savings(coordinator) + + assert estimate.effect_savings == 0.0, ( + f"One 6.0 kW quarter at 02:00, with optimisation switched OFF, reports " + f"{estimate.effect_savings:.0f} SEK/month of effect-tariff savings. The baseline was fed " + f"{baseline:.2f} kW (actual_power) while peak_this_month is " + f"{coordinator.peak_this_month:.2f} kW (effective_power, halved by the night tariff). It " + f"is the same quarter compared against itself, and the difference IS the weighting." + ) + + +@pytest.mark.asyncio +async def test_the_baseline_is_the_same_quantity_peak_this_month_is(coordinator, monkeypatch): + """The invariant that would have stopped this being written: compare like with like.""" + await _observe_a_whole_quarter(coordinator, monkeypatch, NIGHT_HOUR, 6.0) + + assert coordinator.peak_this_month == pytest.approx(3.0), ( + "precondition: peak_this_month must be the EFFECTIVE peak, halved at 02:00. If the night " + "weighting did not bite, this test proves nothing." + ) + assert coordinator.savings_calculator._baseline_monthly_peak == pytest.approx( + coordinator.peak_this_month + ), ( + f"The baseline is {coordinator.savings_calculator._baseline_monthly_peak:.2f} kW and " + f"peak_this_month is {coordinator.peak_this_month:.2f} kW - the same quarter, expressed " + f"two different ways. Whatever feeds the baseline must be weighted exactly as " + f"peak_this_month is, or their difference is an artefact of the weighting." + ) + + +@pytest.mark.asyncio +async def test_the_hour_of_the_day_is_not_a_saving(coordinator, monkeypatch): + """An unchanged 6 kW peak reports nothing, whether it happened at 02:00 or at 10:00.""" + for hour in (NIGHT_HOUR, DAY_HOUR): + coordinator.effect._monthly_peaks = [] + coordinator.peak_this_month = 0.0 + coordinator.savings_calculator._baseline_monthly_peak = None + coordinator._quarter_power_start = None + + await _observe_a_whole_quarter(coordinator, monkeypatch, hour, 6.0) + + assert _savings(coordinator).effect_savings == 0.0, ( + f"An unchanged 6.0 kW peak at {hour:02d}:00 reports " + f"{_savings(coordinator).effect_savings:.0f} SEK/month of savings. The hour of the day " + f"is not a saving." + ) + + +@pytest.mark.asyncio +async def test_a_real_reduction_is_still_reported(coordinator, monkeypatch): + """The regression guard. Killing the fabrication must not silence a genuine saving.""" + await _observe_a_whole_quarter(coordinator, monkeypatch, DAY_HOUR, 8.0) + baseline = coordinator.savings_calculator._baseline_monthly_peak + + # Now the optimiser is on, and it holds the house to 5 kW in the same daytime quarter. + optimised = EffectManager(MagicMock()) + optimised._store = MagicMock() + optimised._store.async_save = AsyncMock() + optimised._monthly_peaks = [] + await optimised.record_period_measurement( + power_kw=5.0, + period=DAY_HOUR, + timestamp=datetime(2026, 1, 20, DAY_HOUR, 0, tzinfo=timezone.utc), + source="external_meter", + ) + + estimate = coordinator.savings_calculator.estimate_monthly_savings( + current_peak_kw=optimised.get_monthly_peak_summary()["highest"], + baseline_peak_kw=baseline, + average_spot_savings_per_day=0.0, + ) + + assert estimate.effect_savings > 0, ( + f"The house drew 8.0 kW unoptimised and 5.0 kW optimised, both in DAYTIME quarters where " + f"the weighting is 1.0 on each side. That is a real 3 kW cut in the billed peak, and it " + f"reported {estimate.effect_savings:.0f} SEK." + ) + + +@pytest.mark.asyncio +async def test_the_heat_pumps_own_current_sensors_are_not_a_billing_baseline(monkeypatch): + """A NIBE-only peak may throttle the pump. It may not become a figure in kronor. + + Peak RECORDING deliberately accepts nibe_currents: the pump is the dominant controllable load, + and this month's NIBE quarters compared against this month's NIBE peaks is a coherent basis for + deciding whether to back off. `PEAK_CONTROL_POWER_SOURCES` says exactly that. + + But the effect tariff bills WHOLE-HOUSE grid import, and `BILLABLE_POWER_SOURCES` is the + external meter alone. A baseline built from a sensor that cannot see the oven, the EV or the + water heater is not a baseline for anything the owner is charged - and the number it feeds is + denominated in SEK on a dashboard. + """ + hass = MagicMock() + hass.config.latitude = 59.33 + hass.config.longitude = 18.07 + hass.states.get = MagicMock(return_value=None) # no external meter at all + + nibe = MagicMock() + nibe._power_sensor_entity = None + nibe.power_sensor_entity = None + # 3 x 8.7 A at 230 V is about 6 kW - of HEAT PUMP, not of house. + nibe.calculate_power_from_currents = MagicMock(return_value=6.0) + + entry = MagicMock() + entry.data = {"enable_optimization": False} + entry.options = {} + + coord = EffektGuardCoordinator( + hass, nibe, MagicMock(), MagicMock(), MagicMock(), EffectManager(hass), entry + ) + coord.peak_today = 0.0 + coord.peak_this_month = 0.0 + coord.effect._store = MagicMock() + coord.effect._store.async_save = AsyncMock() + coord.effect._monthly_peaks = [] + + pump_only = _metered_house(DAY_HOUR, 6.0) + pump_only.phase1_current = SIX_KW_OF_CURRENT + pump_only.phase2_current = SIX_KW_OF_CURRENT + pump_only.phase3_current = SIX_KW_OF_CURRENT + + # A whole billing HOUR, because that is what the tariff bills. + for h, m in [(DAY_HOUR, mm) for mm in range(0, 60, 5)] + [(DAY_HOUR + 1, 0)]: + monkeypatch.setattr( + dt_util, + "now", + lambda tz=None, m=m, h=h: datetime(2026, 1, 15, h, m, tzinfo=timezone.utc), + ) + await coord._update_peak_tracking(pump_only) + + assert coord.effect._monthly_peaks, ( + "PRECONDITION: the NIBE-currents hour must still be RECORDED - peak control depends on " + "it, and refusing to record it would break throttling. The point is what it must not FEED." + ) + assert coord.savings_calculator._baseline_monthly_peak is None, ( + f"A peak measured from the heat pump's own current sensors " + f"({coord.savings_calculator._baseline_monthly_peak} kW) became the baseline for a savings " + f"figure in SEK. That sensor cannot see the oven, the EV or the water heater, and the " + f"effect tariff bills whole-house grid import. Money comes from the meter, or not at all." + ) + + +class TestWhatTheOwnerIsTold: + """The same mismatch, on the dashboard. Both sides must be weighted the way the tariff is.""" + + def _peak_today_sensor(self, coord): + from custom_components.effektguard.sensor import SENSORS, EffektGuardSensor + + description = next(d for d in SENSORS if d.key == "peak_today") + entry = MagicMock() + entry.entry_id = "test" + entry.data = {} + return EffektGuardSensor(coord, entry, description) + + def _coordinator(self, peak_today, period, peak_this_month): + coord = MagicMock() + # `extra_state_attributes` returns early on a falsy `data`, so an empty dict here would + # make every assertion below a KeyError rather than a judgement about the attribute. + coord.data = {"nibe": MagicMock()} + coord.peak_today = peak_today + coord.peak_today_period = period + coord.peak_today_source = "external_meter" + coord.peak_today_time = None + coord.peak_this_month = peak_this_month + coord.yesterday_peak = 0.0 + return coord + + def test_a_night_blip_is_not_announced_as_a_new_monthly_peak(self): + """3.1 kW at 02:00 is billed as 1.55 kW. It cannot beat a 3.0 kW effective monthly peak.""" + coord = self._coordinator(peak_today=3.1, period=NIGHT_HOUR, peak_this_month=3.0) + + attrs = self._peak_today_sensor(coord).extra_state_attributes + + assert attrs["will_affect_billing"] is False, ( + "The house drew 3.1 kW at 02:00 and the owner was told it set a new monthly peak " + "against 3.0 kW. peak_this_month is the EFFECTIVE peak and the night tariff halves " + "this quarter to 1.55 kW - it is not close. The night weighting is not a peak." + ) + + def test_a_daytime_peak_that_really_does_beat_the_month_is_still_announced(self): + """The regression guard. Weighting both sides must not silence a genuine new peak.""" + coord = self._coordinator(peak_today=6.0, period=DAY_HOUR, peak_this_month=3.0) + + attrs = self._peak_today_sensor(coord).extra_state_attributes + + assert ( + attrs["will_affect_billing"] is True + ), "6.0 kW at 10:00 is billed in full and beats a 3.0 kW monthly peak. It IS a new peak." + + def test_a_night_peak_big_enough_to_win_on_its_billed_value_is_announced(self): + """8.0 kW at 02:00 is billed as 4.0 kW, which does beat 3.0. The weighting cuts both ways.""" + coord = self._coordinator(peak_today=8.0, period=NIGHT_HOUR, peak_this_month=3.0) + + attrs = self._peak_today_sensor(coord).extra_state_attributes + + assert attrs["will_affect_billing"] is True + assert "4.00 kW" in attrs["billing_impact"], ( + f"The owner must be shown what the tariff will BILL - 4.00 kW - not the 8.0 kW the " + f"meter saw. Got: {attrs['billing_impact']!r}" + ) + + +class TestZeroSavingsMeansTwoDifferentThings: + """`effect_baseline_measured` was computed and never surfaced. Counted, and ignored.""" + + def _savings_sensor(self, measured: bool): + from custom_components.effektguard.sensor import SENSORS, EffektGuardSensor + from custom_components.effektguard.optimization.savings_calculator import SavingsEstimate + + coord = MagicMock() + coord.data = { + "savings": SavingsEstimate( + monthly_estimate=0.0, + effect_savings=0.0, + spot_savings=0.0, + baseline_cost=0.0, + optimized_cost=0.0, + effect_baseline_measured=measured, + ) + } + description = next(d for d in SENSORS if d.key == "savings_estimate") + entry = MagicMock() + entry.entry_id = "test" + entry.data = {} + return EffektGuardSensor(coord, entry, description) + + def test_an_unmeasured_baseline_says_so(self): + """0 SEK because we have never seen this house unoptimised - not because we are failing.""" + attrs = self._savings_sensor(measured=False).extra_state_attributes + + assert attrs["effect_baseline_measured"] is False + assert "effect_savings_note" in attrs, ( + "The savings sensor reads 0 SEK and the owner has no way to tell whether that means " + "'we have never measured your unoptimised house' or 'we are saving you nothing'. The " + "flag that distinguishes them was computed and never shown." + ) + + def test_a_measured_baseline_does_not_apologise(self): + """Once it IS measured, zero means zero and there is nothing to explain.""" + attrs = self._savings_sensor(measured=True).extra_state_attributes + + assert attrs["effect_baseline_measured"] is True + assert "effect_savings_note" not in attrs diff --git a/tests/unit/optimization/test_the_tariff_bills_the_hour_not_the_quarter.py b/tests/unit/optimization/test_the_tariff_bills_the_hour_not_the_quarter.py new file mode 100644 index 00000000..fabb2890 --- /dev/null +++ b/tests/unit/optimization/test_the_tariff_bills_the_hour_not_the_quarter.py @@ -0,0 +1,143 @@ +"""The Swedish effect tariff bills the mean power of a billing HOUR, not a 15-minute quarter. + +Ellevio (whose model this implements) bills the average of the three highest hourly peaks of the +month, one per day, with 22:00-06:00 counted at half. An hourly mean averages the quiet 45 minutes +around a spike, so a 15-minute hot-water cycle recorded as a quarter-hour peak reads at up to three +times its billed value - and the effect layer throttles the pump to defend a peak on no bill. + +Invariants: BILLING_PERIOD_MINUTES is 60; the tariff rate and night weight match the published +figures (SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH 81.25, NIGHT_TARIFF_WEIGHT 0.5); a full hour is +billed at its mean, the night discount halves it, and only the top three hours are kept. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.effektguard.const import ( + BILLING_PERIOD_MINUTES, + NIGHT_TARIFF_WEIGHT, + POWER_SOURCE_EXTERNAL_METER, + SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH, +) +from custom_components.effektguard.optimization.effect_layer import EffectManager + +JANUARY = datetime(2026, 1, 15, tzinfo=timezone.utc) + + +def _manager() -> EffectManager: + manager = EffectManager(MagicMock()) + manager._store = MagicMock() + manager._store.async_save = AsyncMock() + manager._monthly_peaks = [] + return manager + + +def test_the_rate_is_the_one_a_real_company_publishes(): + """The tariff rate is Ellevio's published 81,25 kr/kW/month, and the night weight is a half. + + Every SEK figure the owner is shown is denominated in this number, so it must be one somebody + actually charges. + """ + assert SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH == 81.25, ( + f"The effect tariff is {SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH} SEK/kW/month. Ellevio " + f"publishes 81,25 kr per kilowatt per manad. Every SEK figure the owner is shown is " + f"denominated in this number, so it had better be one somebody actually charges." + ) + assert ( + NIGHT_TARIFF_WEIGHT == 0.5 + ), "Ellevio: between 22:00 and 06:00 'raknas bara halva effekttoppen' - half the peak counts." + + +def test_the_billing_period_is_an_hour(): + """The constant said 15 and called itself "Swedish Effektavgift measurement period".""" + assert BILLING_PERIOD_MINUTES == 60, ( + f"The billing period is {BILLING_PERIOD_MINUTES} minutes. Ellevio: 'the measurement uses " + f"hourly averages'. Energimarknadsinspektionen: 'elnatsforetagen mater din elanvandning per " + f"timme'. A quarter-hour mean is not a quantity anyone is billed on." + ) + + +@pytest.mark.asyncio +async def test_a_hot_water_cycle_is_not_a_billing_peak(): + """THE BUG. One 15-minute cycle inside an otherwise quiet hour, recorded at three times its + billed value - and the effect layer throttles the pump to defend it. + """ + manager = _manager() + + # The hour, as the meter sees it: a hot-water cycle, then the house idling. + await manager.record_period_measurement( + power_kw=(9.0 + 1.0 + 1.0 + 1.0) / 4, # the HOUR's mean, which is what the tariff bills + period=10, + timestamp=JANUARY.replace(hour=10), + source=POWER_SOURCE_EXTERNAL_METER, + ) + + recorded = manager.get_monthly_peak_summary()["highest"] + + assert recorded == pytest.approx(3.0), ( + f"EffektGuard recorded a billing peak of {recorded:.2f} kW for an hour whose mean power was " + f"3.00 kW. The 9 kW quarter is a hot-water cycle, and the tariff averages it with the " + f"quiet 45 minutes around it. At 81.25 SEK/kW the difference is a phantom " + f"{(recorded - 3.0) * 81.25:.0f} SEK a month - and the effect layer throttles the heat pump " + f"to protect it." + ) + + +@pytest.mark.asyncio +async def test_the_night_discount_runs_from_22_to_06(): + """Ellevio: between 22:00 and 06:00 "raknas bara halva effekttoppen". Hours, not quarters.""" + manager = _manager() + + await manager.record_period_measurement( + power_kw=6.0, + period=2, + timestamp=JANUARY.replace(hour=2), + source=POWER_SOURCE_EXTERNAL_METER, + ) + + assert manager.get_monthly_peak_summary()["highest"] == pytest.approx(3.0), ( + "A 6 kW hour at 02:00 is billed as 3 kW - half - and that is the whole reason the " + "distinction between actual and effective power exists." + ) + + +@pytest.mark.asyncio +async def test_a_daytime_hour_is_billed_in_full(): + manager = _manager() + + await manager.record_period_measurement( + power_kw=6.0, + period=10, + timestamp=JANUARY.replace(hour=10), + source=POWER_SOURCE_EXTERNAL_METER, + ) + + assert manager.get_monthly_peak_summary()["highest"] == pytest.approx(6.0) + + +@pytest.mark.asyncio +async def test_only_the_top_three_hours_are_billed_and_one_per_day(): + """Ellevio: "the average of the three highest peaks", one per day, on three different days.""" + manager = _manager() + + for day, kw in ((10, 5.0), (11, 6.0), (12, 5.5), (13, 2.0)): + await manager.record_period_measurement( + power_kw=kw, + period=10, + timestamp=JANUARY.replace(day=day, hour=10), + source=POWER_SOURCE_EXTERNAL_METER, + ) + + peaks = sorted((p.effective_power for p in manager._monthly_peaks), reverse=True) + + assert len(peaks) == 3, ( + f"The tariff bills the mean of the THREE highest hours of the month, so only three are " + f"kept. {len(peaks)} are: {peaks}" + ) + assert peaks == pytest.approx( + [6.0, 5.5, 5.0] + ), "and they must be the three highest - the 2.0 kW hour is not billed at all" diff --git a/tests/unit/optimization/test_the_wear_and_rate_limits_are_real.py b/tests/unit/optimization/test_the_wear_and_rate_limits_are_real.py new file mode 100644 index 00000000..12a6a3ba --- /dev/null +++ b/tests/unit/optimization/test_the_wear_and_rate_limits_are_real.py @@ -0,0 +1,146 @@ +"""The register bounds, the write rate limit, and the emergency exemption - the real limits. + +There is deliberately NO per-update magnitude limit on the offset. One would rate-limit the +emergency response, which must go from 0 to +10 in a single cycle when degree minutes reach the +auxiliary-heat limit - deferring that even one cycle is the death spiral the anti-windup work +prevents. What bounds the offset is the NIBE register range [MIN_OFFSET, MAX_OFFSET]; what +protects the controller from wear is the write rate limit, not a magnitude cap. These tests drive +that production code directly. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.effektguard.adapters.nibe_adapter import NibeAdapter +from custom_components.effektguard.const import ( + LAYER_WEIGHT_SAFETY, + MAX_OFFSET, + MIN_OFFSET, + SAFETY_EMERGENCY_OFFSET, + SERVICE_RATE_LIMIT_MINUTES, + UPDATE_INTERVAL_MINUTES, + WEATHER_FORECAST_HORIZON, +) +from custom_components.effektguard.optimization.decision_engine import ( + DecisionEngine, + LayerDecision, + SAFETY_LAYER_NAME, +) + + +def _adapter() -> NibeAdapter: + hass = MagicMock() + state = MagicMock() + state.state = "0" + state.attributes = {} + hass.states.get.return_value = state + hass.services.async_call = AsyncMock() + + adapter = NibeAdapter(hass, {"nibe_entity": "number.offset"}) + adapter._entity_cache = {"offset": "number.offset"} + return adapter + + +def _engine() -> DecisionEngine: + return DecisionEngine( + price_analyzer=MagicMock(), + effect_manager=MagicMock(), + thermal_model=MagicMock(), + config={"target_indoor_temp": 21.0, "tolerance": 0.5}, + ) + + +class TestTheWriteRateLimitActuallyRefuses: + """A second write inside the cooldown must be refused, so the NIBE controller is not + rewritten every cycle.""" + + @pytest.mark.asyncio + async def test_a_second_write_inside_the_cooldown_is_refused(self): + adapter = _adapter() + + first = await adapter.set_curve_offset(-3.0) + immediately_after = await adapter.set_curve_offset(3.0) + + assert first == -3, "precondition: the first write must land" + assert immediately_after is None, ( + f"A second write was accepted immediately after the first. The cooldown is " + f"SERVICE_RATE_LIMIT_MINUTES ({SERVICE_RATE_LIMIT_MINUTES} min), and it exists to stop " + f"the NIBE controller being rewritten every cycle. The test that used to guard this " + f"asserted `300 >= 300` and never called the adapter." + ) + + @pytest.mark.asyncio + async def test_a_write_after_the_cooldown_is_accepted(self): + """The regression guard on the guard: the rate limit must not become a permanent block.""" + adapter = _adapter() + + assert await adapter.set_curve_offset(-3.0) == -3 + + adapter._last_write = adapter._last_write - timedelta( + minutes=SERVICE_RATE_LIMIT_MINUTES + 1 + ) + + assert await adapter.set_curve_offset(3.0) == 3 + + def test_the_cooldown_is_at_least_one_update_cycle(self): + """A cooldown shorter than the update interval would not rate-limit anything.""" + assert SERVICE_RATE_LIMIT_MINUTES >= UPDATE_INTERVAL_MINUTES, ( + f"The write cooldown ({SERVICE_RATE_LIMIT_MINUTES} min) is shorter than the coordinator's " + f"own update interval ({UPDATE_INTERVAL_MINUTES} min), so it can never actually refuse a " + f"scheduled write and the wear protection is decorative." + ) + + +class TestTheRegisterBoundsAreTheRealLimit: + """There is no per-update magnitude limit, and there must not be. This is what bounds it.""" + + @pytest.mark.parametrize("wild", [-99.0, -10.5, 10.5, 99.0]) + def test_no_layer_can_drive_the_offset_outside_the_register(self, wild): + engine = _engine() + layers = [LayerDecision(name="Rogue", offset=wild, weight=1.0, reason="")] + + offset = engine._aggregate_layers(layers) + + assert MIN_OFFSET <= offset <= MAX_OFFSET, ( + f"A layer voting {wild:+.1f} produced a final offset of {offset:+.1f}, outside the " + f"[{MIN_OFFSET}, {MAX_OFFSET}] the NIBE register can hold." + ) + + +class TestTheEmergencyPathIsDeliberatelyExemptFromSmoothing: + """Why no per-update magnitude limit exists. Do not add one. + + A per-update magnitude limit would throttle the emergency response, and degree minutes at the + auxiliary-heat limit cannot wait several cycles for full heat. + """ + + def test_the_safety_layer_reaches_full_heat_in_a_single_update(self): + engine = _engine() + layers = [ + LayerDecision( + name=SAFETY_LAYER_NAME, + offset=SAFETY_EMERGENCY_OFFSET, + weight=LAYER_WEIGHT_SAFETY, + reason="Indoor below the floor", + ), + ] + + offset = engine._aggregate_layers(layers) + + assert offset == pytest.approx(SAFETY_EMERGENCY_OFFSET), ( + f"The safety layer asked for {SAFETY_EMERGENCY_OFFSET:+.1f} and the engine emitted " + f"{offset:+.1f}. A per-update magnitude limit would throttle exactly this - the house " + f"is below its absolute floor, and it cannot wait three cycles for full heat." + ) + + +def test_the_forecast_horizon_is_long_enough_to_see_the_cold_coming(): + """Pre-heat decisions need at least 12 h of look-ahead.""" + assert WEATHER_FORECAST_HORIZON >= 12.0, ( + f"The forecast horizon is {WEATHER_FORECAST_HORIZON} h. Pre-heating decisions need at " + f"least 12 h of look-ahead; below that the pre-heat cannot see the cold coming." + ) diff --git a/tests/unit/optimization/test_thermal_mass_buffer_direction.py b/tests/unit/optimization/test_thermal_mass_buffer_direction.py new file mode 100644 index 00000000..d074592f --- /dev/null +++ b/tests/unit/optimization/test_thermal_mass_buffer_direction.py @@ -0,0 +1,80 @@ +"""A slab that takes six hours to respond must be helped SOONER, not later. + +Degree-minute thresholds are NEGATIVE, so the thermal-mass buffer must DIVIDE, not multiply: for a +concrete slab, -540 / 1.3 = -415 fires earlier, while -540 * 1.3 = -702 would make the slowest +system the LAST to intervene and the radiator the first. Heat put into a slab arrives hours later, so +it must start recovering while the debt is still shallow. + +Invariant: warning thresholds order concrete > timber > radiator (shallower = sooner), the radiator +(buffer 1.0) is unmodified, and the absolute limit is never buffered. +""" + +import pytest + +from custom_components.effektguard.const import ( + DM_THERMAL_MASS_BUFFER_CONCRETE, + DM_THERMAL_MASS_BUFFER_RADIATOR, + DM_THERMAL_MASS_BUFFER_TIMBER, +) +from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector +from custom_components.effektguard.optimization.thermal_layer import EmergencyLayer + +STOCKHOLM = 59.33 +OUTDOOR = 0.0 + + +def _thresholds(heating_type: str) -> dict: + detector = ClimateZoneDetector(latitude=STOCKHOLM) + layer = EmergencyLayer(climate_detector=detector, heating_type=heating_type) + base = detector.get_expected_dm_range(OUTDOOR) + return layer._get_thermal_mass_adjusted_thresholds(base) + + +def test_the_buffers_are_ordered_by_thermal_lag(): + """Sanity: the constants themselves say concrete lags most.""" + assert DM_THERMAL_MASS_BUFFER_CONCRETE > DM_THERMAL_MASS_BUFFER_TIMBER + assert DM_THERMAL_MASS_BUFFER_TIMBER > DM_THERMAL_MASS_BUFFER_RADIATOR + + +def test_concrete_intervenes_earlier_than_a_radiator(): + """A six-hour lag must start recovering while the debt is still shallow.""" + concrete = _thresholds("concrete_ufh")["warning"] + radiator = _thresholds("radiator")["warning"] + + assert concrete > radiator, ( + f"Concrete warns at DM {concrete:.0f} and a radiator system at DM {radiator:.0f}. " + f"Degree minutes are NEGATIVE, so the concrete slab - six hours of thermal lag - is being " + f"made to wait {abs(concrete - radiator):.0f} DM LONGER for help than a radiator system " + f"that recovers in under an hour." + ) + + +def test_timber_sits_between_them(): + """Timber lags 2-4 hours: later than concrete, earlier than radiators.""" + concrete = _thresholds("concrete_ufh")["warning"] + timber = _thresholds("timber")["warning"] + radiator = _thresholds("radiator")["warning"] + + assert concrete > timber > radiator, ( + f"Ordered by lag, the warning thresholds must be concrete > timber > radiator. " + f"Got concrete {concrete:.0f}, timber {timber:.0f}, radiator {radiator:.0f}." + ) + + +def test_a_radiator_system_is_left_exactly_where_it_was(): + """The radiator buffer is 1.0: it must be the unmodified baseline, whatever the operation.""" + detector = ClimateZoneDetector(latitude=STOCKHOLM) + base = detector.get_expected_dm_range(OUTDOOR) + + adjusted = _thresholds("radiator") + + assert adjusted["warning"] == pytest.approx(base["warning"]) + assert adjusted["normal_min"] == pytest.approx(base["normal_min"]) + + +def test_the_absolute_maximum_is_never_buffered(): + """The aux limit is hardware, not a tuning knob. It is the same for every emitter.""" + concrete = _thresholds("concrete_ufh")["critical"] + radiator = _thresholds("radiator")["critical"] + + assert concrete == radiator diff --git a/tests/unit/optimization/test_warming_is_not_heat_loss.py b/tests/unit/optimization/test_warming_is_not_heat_loss.py new file mode 100644 index 00000000..d32a4851 --- /dev/null +++ b/tests/unit/optimization/test_warming_is_not_heat_loss.py @@ -0,0 +1,169 @@ +"""Solar gain is not heat loss, and corrupt stored state must not poison the scheduler. + +Comfort layer: `indoor_rate` is a SIGNED °C/h trend. The effective heat-loss rate must be +`max(-indoor_rate, 0.0)`, not `max(abs(indoor_rate), ...)` - taking the absolute value reads a warming +house as losing heat fast, shrinking buffer_hours and triggering a pre-heat while it overheats. + +DHW heating rate: the rate is used as a divisor in `estimate_heating_time`, so a rate restored from +storage must pass the same plausibility band (DHW_HEATING_RATE_MIN..MAX) as a learned one - a +truncated or hand-edited .storage file could otherwise load 0.0 or 0.1 and make the scheduler +panic-heat forever. +""" + +from datetime import datetime +from unittest.mock import MagicMock + +import pytest + +from custom_components.effektguard.const import ( + DHW_DEFAULT_HEATING_RATE, + DHW_HEATING_RATE_MAX, + DHW_HEATING_RATE_MIN, + MODE_CONFIGS, + OPTIMIZATION_MODE_BALANCED, +) +from custom_components.effektguard.optimization.comfort_layer import ComfortLayer +from custom_components.effektguard.optimization.thermal_layer import ThermalModel + + +class TestWarmingIsNotHeatLoss: + """The thermal buffer grows when the house warms. It must not read as draining.""" + + @staticmethod + def _layer(indoor_rate: float) -> ComfortLayer: + return ComfortLayer( + get_thermal_trend=lambda: { + "trend": "warming" if indoor_rate > 0 else "cooling", + "rate_per_hour": indoor_rate, + "confidence": 1.0, + }, + thermal_model=ThermalModel(thermal_mass=1.0, insulation_quality=1.0), + mode_config=MODE_CONFIGS[OPTIMIZATION_MODE_BALANCED], + tolerance_range=0.2, + target_temp=21.0, + ) + + @staticmethod + def _state(indoor_temp: float) -> MagicMock: + state = MagicMock() + state.indoor_temp = indoor_temp + state.outdoor_temp = -4.0 + state.supply_temp = 35.0 + state.degree_minutes = -100.0 + state.current_offset = 0.0 + state.timestamp = datetime(2026, 1, 15, 9, 0) + state.indoor_temp_valid = True + return state + + def _effective_heat_loss(self, indoor_rate: float) -> float: + """Extract the loss rate the layer computed, from its own reason string. + + The layer reports `... @ {effective_heat_loss:.2f}°C/h ...`, which is the value + under test. `_analyze_expensive_periods` is stubbed so the arithmetic under test is + isolated from price-data plumbing: an upcoming spike 2 h out, lasting 2 h. + """ + layer = self._layer(indoor_rate) + layer._analyze_expensive_periods = lambda price_data, thermal_mass: (2.0, 2.0, 60.0) + + decision = layer._evaluate_thermal_aware_overshoot( + nibe_state=self._state(21.9), + weather_data=None, + price_data=MagicMock(), + overshoot=0.9, + temp_deviation=0.9, + ) + assert decision is not None, "Expected the thermal-aware branch to engage" + # "... = 1.5h @ 0.60°C/h loss | ..." + tail = decision.reason.split("@ ", 1)[1] + return float(tail.split("°C/h", 1)[0]) + + def test_a_warming_house_is_not_counted_as_losing_heat(self): + """+0.6 °C/h of solar gain must NOT be read as 0.6 °C/h of heat loss.""" + warming = self._effective_heat_loss(indoor_rate=+0.6) + still = self._effective_heat_loss(indoor_rate=0.0) + + assert warming == pytest.approx(still), ( + f"A house warming at +0.6 °C/h reported {warming:.2f} °C/h of heat loss, versus " + f"{still:.2f} °C/h when static. abs() turned solar gain into heat loss, shrinking " + "the thermal buffer and triggering a pre-heat while the house was OVERHEATING." + ) + + def test_a_cooling_house_still_counts_as_losing_heat(self): + """Do not over-correct: real cooling must still drive the loss rate.""" + cooling = self._effective_heat_loss(indoor_rate=-0.6) + still = self._effective_heat_loss(indoor_rate=0.0) + + assert cooling > still, ( + "A house cooling at -0.6 °C/h must report a HIGHER heat-loss rate than a static " + "one - that is the case the `max()` exists for." + ) + assert cooling == pytest.approx(0.6, abs=0.01) + + +class TestCorruptStoredHeatingRateIsRejected: + """Storage is untrusted input. It must not become a divisor.""" + + @staticmethod + def _optimizer(): + from custom_components.effektguard.optimization.dhw_optimizer import ( + IntelligentDHWScheduler, + ) + + return IntelligentDHWScheduler() + + @pytest.mark.parametrize( + "corrupt", + [0.0, 0.1, -5.0, 900.0, "fourteen", None, True], + ids=["zero", "near_zero", "negative", "absurd", "string", "none", "bool"], + ) + def test_implausible_stored_rate_is_ignored(self, corrupt): + optimizer = self._optimizer() + before = optimizer.learned_heating_rate + + optimizer.restore_from_persistence({"learned_heating_rate": corrupt}) + + assert optimizer.learned_heating_rate == before, ( + f"A stored heating rate of {corrupt!r} was accepted. It is used as a divisor in " + "estimate_heating_time: 0.0 raises ZeroDivisionError, and 0.1 yields a 200-hour " + "heat-up estimate that makes the scheduler panic-heat forever." + ) + + # Whatever it falls back to must itself be usable as a divisor. + effective = optimizer.learned_heating_rate or DHW_DEFAULT_HEATING_RATE + assert DHW_HEATING_RATE_MIN <= effective <= DHW_HEATING_RATE_MAX + + def test_a_plausible_stored_rate_is_still_restored(self): + """Do not over-correct: a legitimate learned rate must survive a restart.""" + optimizer = self._optimizer() + + optimizer.restore_from_persistence( + {"learned_heating_rate": 18.0, "heating_rate_observations": 7} + ) + + assert optimizer.learned_heating_rate == pytest.approx(18.0) + assert optimizer.heating_rate_observations == 7 + + def test_corrupt_legionella_timestamp_does_not_abort_the_restore(self): + """A bad timestamp used to raise and abort the rest of learning initialization.""" + optimizer = self._optimizer() + + optimizer.restore_from_persistence( + {"last_legionella_boost": "not-a-timestamp", "learned_heating_rate": 18.0} + ) + + # The heating rate after it in the same method must still have been restored. + assert optimizer.learned_heating_rate == pytest.approx(18.0) + + def test_estimate_heating_time_never_divides_by_a_bad_rate(self): + """Defence in depth: the divisor itself is guarded.""" + optimizer = self._optimizer() + + hours = optimizer.estimate_heating_time( + current_temp=30.0, target_temp=50.0, heating_rate=0.0 + ) + + expected = 20.0 / DHW_DEFAULT_HEATING_RATE + assert hours == pytest.approx(expected), ( + "estimate_heating_time must fall back to the default rate rather than dividing " + "by zero." + ) diff --git a/tests/unit/optimization/test_winter_power_with_aux_is_not_an_anomaly.py b/tests/unit/optimization/test_winter_power_with_aux_is_not_an_anomaly.py new file mode 100644 index 00000000..6f466260 --- /dev/null +++ b/tests/unit/optimization/test_winter_power_with_aux_is_not_an_anomaly.py @@ -0,0 +1,38 @@ +"""A winter reading with the elpatron running is normal, not an every-cycle warning. + +typical_electrical_range_kw is the compressor draw alone (0.27-2.06 kW). The validator compared +the whole-machine reading against it and flagged "exceeds max" on every cold cycle where the +immersion heater was doing its job. The machine's plausible ceiling is compressor + immersion +heater: below it, aux-range draw is normal; above it, the reading is implausible for the +hardware and worth a warning. +""" + +from unittest.mock import MagicMock + +from custom_components.effektguard.models.nibe.f750 import NibeF750Profile +from custom_components.effektguard.optimization.decision_engine import DecisionEngine + + +def _engine() -> DecisionEngine: + engine = DecisionEngine.__new__(DecisionEngine) + engine.heat_pump_model = NibeF750Profile() + return engine + + +def test_compressor_plus_elpatron_draw_is_valid_and_quiet(): + # 2.0 kW compressor + 3.5 kW delivery-setting immersion: a cold January morning. + result = _engine()._validate_power_consumption(5.5, outdoor_temp=-10.0) + + assert result["valid"] is True + assert result["warning"] is None, ( + f"A draw the machine's own immersion heater fully explains was flagged: " + f"{result['warning']!r}. This fired every cycle, all winter." + ) + + +def test_a_draw_no_f750_can_produce_is_flagged(): + # Ceiling is (compressor max 2.06 + immersion 3.5) x margin = 6.67 kW; 12 kW is not this machine. + result = _engine()._validate_power_consumption(12.0, outdoor_temp=-10.0) + + assert result["valid"] is False + assert result["warning"] is not None diff --git a/tests/unit/test_a_hot_water_boost_we_started_is_a_hot_water_boost_we_stop.py b/tests/unit/test_a_hot_water_boost_we_started_is_a_hot_water_boost_we_stop.py new file mode 100644 index 00000000..d449fae3 --- /dev/null +++ b/tests/unit/test_a_hot_water_boost_we_started_is_a_hot_water_boost_we_stop.py @@ -0,0 +1,175 @@ +"""A hot-water boost EffektGuard's own service started must be recognised as ours on unload. + +`_cancel_our_dhw_boost` turns off, on unload, only a temporary-lux boost EffektGuard started - told +apart from the owner's by `_lux_boost_is_ours`, which is set in exactly one place: +`_set_temporary_lux`. The `boost_dhw` service must reach the switch through that method (via the +coordinator), or the flag is never set and the boost it started is left running to NIBE's lux +timeout on the immersion heater after the entry unloads (reconfigure, reload, removal, restart). + +The structural test pins the one-door invariant: every `switch.turn_on/off` on the lux entity goes +through `_set_temporary_lux`, the only place that records who started the boost. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.const import STATE_ON + +from custom_components.effektguard import _async_register_services +from custom_components.effektguard.const import CONF_NIBE_TEMP_LUX_ENTITY, DOMAIN +from custom_components.effektguard.coordinator import EffektGuardCoordinator +from custom_components.effektguard.optimization.effect_layer import EffectManager + +LUX = "switch.temporary_lux_50004" + + +def _hass_and_coordinator() -> tuple[MagicMock, EffektGuardCoordinator, dict]: + hass = MagicMock() + hass.config.latitude = 59.33 + hass.config.longitude = 18.07 + hass.services.async_call = AsyncMock() + hass.services.has_service = MagicMock(return_value=False) + + registered: dict = {} + hass.services.async_register = MagicMock( + side_effect=lambda domain, service, handler, **kw: registered.__setitem__(service, handler) + ) + + entry = MagicMock() + entry.data = {CONF_NIBE_TEMP_LUX_ENTITY: LUX} + entry.options = {} + + coordinator = EffektGuardCoordinator( + hass, MagicMock(), MagicMock(), MagicMock(), MagicMock(), EffectManager(hass), entry + ) + coordinator.data = {} + coordinator.temp_lux_entity = LUX + coordinator.learning_store = MagicMock() + coordinator.learning_store.async_save = AsyncMock() + coordinator.effect.async_save = AsyncMock() + + hass.data = {DOMAIN: {"entry_1": coordinator}} + + # The lux switch reads ON once a boost is running. + lux_state = MagicMock() + lux_state.state = STATE_ON + hass.states.get = MagicMock(return_value=lux_state) + + return hass, coordinator, registered + + +def _turn_offs(hass) -> list: + return [ + call + for call in hass.services.async_call.await_args_list + if call.args[0] == "homeassistant" and call.args[1] == "turn_off" + ] + + +@pytest.mark.asyncio +async def test_a_boost_our_own_service_started_is_cancelled_on_unload(): + """The service starts the boost; the cleanup must recognise it as ours on unload.""" + hass, coordinator, registered = _hass_and_coordinator() + await _async_register_services(hass) + + call = MagicMock() + call.data = {} + with patch.object(coordinator, "async_request_refresh", AsyncMock()): + await registered["boost_dhw"](call) + + assert coordinator._lux_boost_is_ours is True, ( + "the effektguard.boost_dhw service turned the temporary-lux switch on and did not record " + "that EffektGuard is the one who did it. `_cancel_our_dhw_boost` reads exactly that flag." + ) + + hass.services.async_call.reset_mock() + await coordinator.async_shutdown() # reconfigure / manual reload / removal / restart + + assert len(_turn_offs(hass)) == 1, ( + f"the integration unloaded and left a hot-water boost running that IT had started " + f"({len(_turn_offs(hass))} turn_off calls). Nothing is left to stop it, so it runs to NIBE's " + f"own temporary-lux timeout on the immersion heater at COP 1.0. The cleanup for this exists " + f"and was simply never told the boost was ours." + ) + + +@pytest.mark.asyncio +async def test_a_boost_the_owner_started_is_left_alone(): + """The other half, and it is why the flag exists at all. + + A boost the HOUSEHOLD started - somebody pressed temporary lux on the pump, or in MyUplink - + is not EffektGuard's to cancel. Unloading the integration must not switch off somebody's shower. + """ + hass, coordinator, _ = _hass_and_coordinator() + # Nobody called our service and the optimizer never ran: the switch is on, but not by us. + assert coordinator._lux_boost_is_ours is False + + await coordinator.async_shutdown() + + assert _turn_offs(hass) == [], ( + "unloading EffektGuard cancelled a hot-water boost it did not start. That is the owner's " + "boost, and taking it away is worse than leaving ours running." + ) + + +@pytest.mark.asyncio +async def test_a_shut_down_coordinator_cannot_start_a_boost(): + """The same race as the curve offset and the fan: an unloaded entry does not command the pump. + + Turning a boost OFF during shutdown must still work - that is the cleanup itself - so the guard + can only refuse to START one. + """ + hass, coordinator, _ = _hass_and_coordinator() + await coordinator.async_shutdown() + hass.services.async_call.reset_mock() + + started = await coordinator._set_temporary_lux(True) + + assert started is False + assert hass.services.async_call.await_count == 0, ( + "a shut-down coordinator started a hot-water boost. The entry is unloaded, and nothing is " + "left that would ever switch it off again." + ) + + +def test_there_is_exactly_one_door_to_the_hot_water_switch(): + """Every `switch.turn_on/off` on the temporary-lux entity must go through `_set_temporary_lux` - + the only place that records who started the boost, which is what lets the unload cleanup tell + ours from the owner's. + """ + import ast + import pathlib + + def commands_the_lux_switch(call: ast.Call) -> bool: + """A `switch.turn_on/off` aimed at the TEMPORARY-LUX entity specifically. + + Scoped to the lux entity because the NIBE adapter also drives a different `switch` (the + enhanced-ventilation one), which has its own guard. + """ + if not ( + isinstance(call.func, ast.Attribute) + and call.func.attr == "async_call" + and len(call.args) >= 3 + and isinstance(call.args[0], ast.Constant) + and call.args[0].value == "homeassistant" + ): + return False + return "temp_lux_entity" in ast.dump(call.args[2]) + + doors: list[tuple[str, str]] = [] + for path in sorted(pathlib.Path("custom_components/effektguard").rglob("*.py")): + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if not isinstance(node, ast.AsyncFunctionDef): + continue + for inner in ast.walk(node): + if isinstance(inner, ast.Call) and commands_the_lux_switch(inner): + doors.append((path.name, node.name)) + + assert doors == [("coordinator.py", "_set_temporary_lux")], ( + f"the hot-water switch is commanded from {doors}. Every call must go through " + f"`_set_temporary_lux`, which is the only place that records whether the boost is ours - " + f"the fact the unload cleanup reads." + ) diff --git a/tests/unit/test_diagnostics_report_the_band_the_house_is_held_to.py b/tests/unit/test_diagnostics_report_the_band_the_house_is_held_to.py new file mode 100644 index 00000000..e730c7b6 --- /dev/null +++ b/tests/unit/test_diagnostics_report_the_band_the_house_is_held_to.py @@ -0,0 +1,48 @@ +"""Diagnostics must report the DM band production ENFORCES, not the raw zone table. + +The production path runs every zone range through apply_thermal_mass_buffer (a concrete slab +is helped ~1.3x sooner), so a dump quoting the unadjusted range disagrees with the decision +it exists to explain - worse than none, since it sends the reader hunting for a discrepancy +that is the dump's own. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from custom_components.effektguard.diagnostics import _dm_thresholds +from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector +from custom_components.effektguard.optimization.thermal_layer import apply_thermal_mass_buffer + + +def _coordinator(heating_type: str) -> MagicMock: + coordinator = MagicMock() + coordinator.engine.climate_detector = ClimateZoneDetector(latitude=59.33) + coordinator.engine.emergency_layer.heating_type = heating_type + return coordinator + + +def test_the_reported_range_is_the_thermal_mass_adjusted_one(): + coordinator = _coordinator("concrete_ufh") + nibe = SimpleNamespace(outdoor_temp=0.0) + + report = _dm_thresholds(coordinator, nibe) + + detector = coordinator.engine.climate_detector + enforced = apply_thermal_mass_buffer(detector.get_expected_dm_range(0.0), "concrete_ufh") + assert report["range"] == enforced, ( + f"Diagnostics report {report['range']} but production holds this house to {enforced}. " + f"The dump exists to explain the decision; it must quote the band the decision used." + ) + assert report["heating_type"] == "concrete_ufh" + + +def test_a_radiator_house_is_unchanged_by_the_adjustment(): + coordinator = _coordinator("radiator") + nibe = SimpleNamespace(outdoor_temp=0.0) + + report = _dm_thresholds(coordinator, nibe) + + detector = coordinator.engine.climate_detector + assert report["range"] == apply_thermal_mass_buffer( + detector.get_expected_dm_range(0.0), "radiator" + ) diff --git a/tests/unit/test_home_assistant_apis_are_used_as_declared.py b/tests/unit/test_home_assistant_apis_are_used_as_declared.py new file mode 100644 index 00000000..3f1699bc --- /dev/null +++ b/tests/unit/test_home_assistant_apis_are_used_as_declared.py @@ -0,0 +1,93 @@ +"""Two Home Assistant APIs must be handed the exact types they check for. + +`calculate_optimal_schedule` must register with SupportsResponse.OPTIONAL, not a bare `True`: HA +compares the value by identity, so `True` passes `is not SupportsResponse.NONE` but fails +`is SupportsResponse.OPTIONAL`, advertising the service as response-REQUIRED. + +`EffektGuardCoordinator` must pass `config_entry=` to DataUpdateCoordinator.__init__. Omitting it +falls back to a ContextVar HA removes in 2026.8, leaving `coordinator.config_entry` None for any +coordinator built outside async_setup_entry. +""" + +from __future__ import annotations + +import inspect +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.core import SupportsResponse + +from custom_components.effektguard import _async_register_services +from custom_components.effektguard.coordinator import EffektGuardCoordinator + + +async def test_the_service_declares_an_optional_response_not_a_required_one(): + """`supports_response=True` advertises calculate_optimal_schedule as response-REQUIRED.""" + hass = MagicMock() + hass.services.has_service.return_value = False + hass.services.async_register = MagicMock() + + await _async_register_services(hass) + + responses = { + call.args[1] if len(call.args) > 1 else call.kwargs.get("service"): call.kwargs[ + "supports_response" + ] + for call in hass.services.async_register.call_args_list + if "supports_response" in call.kwargs + } + + assert responses, "no service registered a supports_response at all" + + for service, response in responses.items(): + assert isinstance(response, SupportsResponse), ( + f"{service} passed {response!r} as supports_response. Home Assistant expects a " + f"SupportsResponse enum and compares it by identity: a bare True satisfies " + f"`is not SupportsResponse.NONE` but fails `is SupportsResponse.OPTIONAL`, so the " + f"service is advertised as response-REQUIRED." + ) + assert response is SupportsResponse.OPTIONAL, ( + f"{service} returns a dict when it can and nothing when it cannot, so its response is " + f"OPTIONAL. It declares {response!r}." + ) + + +def test_the_coordinator_hands_home_assistant_its_config_entry(): + """Omitting it falls back to a ContextVar that Home Assistant removes in 2026.8.""" + source = inspect.getsource(EffektGuardCoordinator.__init__) + + assert "config_entry=" in source, ( + "EffektGuardCoordinator does not pass `config_entry=` to DataUpdateCoordinator.__init__. " + "Home Assistant falls back to a deprecated ContextVar for it - breaks_in_ha_version " + '"2026.8" - and coordinator.config_entry is None for any coordinator constructed outside ' + "async_setup_entry, which several call sites read without checking." + ) + + +def test_the_config_entry_actually_arrives(): + """Behavioural, not just structural: build one and read it back.""" + hass = MagicMock() + hass.data = {} + hass.config = MagicMock(latitude=59.3, config_dir="/tmp/test") + hass.async_add_executor_job = AsyncMock(side_effect=lambda f, *a: f(*a)) + + entry = MagicMock() + entry.data = MagicMock() + entry.data.get.side_effect = lambda key, default=None: default + entry.options = MagicMock() + entry.options.get.side_effect = lambda key, default=None: default + + coordinator = EffektGuardCoordinator( + hass=hass, + nibe_adapter=MagicMock(), + gespot_adapter=MagicMock(), + weather_adapter=MagicMock(), + decision_engine=MagicMock(), + effect_manager=MagicMock(), + entry=entry, + ) + + assert coordinator.config_entry is entry, ( + "coordinator.config_entry is not the entry it was constructed with. Home Assistant sets it " + "from the `config_entry=` argument; without it, it is whatever the deprecated ContextVar " + "happened to hold - None, outside async_setup_entry." + ) diff --git a/tests/unit/test_invented_prices_do_not_vote.py b/tests/unit/test_invented_prices_do_not_vote.py new file mode 100644 index 00000000..e4c9019d --- /dev/null +++ b/tests/unit/test_invented_prices_do_not_vote.py @@ -0,0 +1,173 @@ +"""With no price source, the coordinator must NOT invent 96 identical prices and let them vote. + +The adapter raises when there is no GE-Spot entity. The coordinator must not catch that and +fabricate a flat price curve: the invented quarters classify NORMAL, the price layer casts a real +weighted vote, and the aggregate is dragged down - so the fabrication takes heat away from the house +on a number nobody measured, while the reasoning string claims a price was analysed. + +`price_data=None` is the honest answer, and the engine handles it: the price layer abstains and the +thermal, comfort and safety layers decide. The user is told through a Home Assistant repair issue, +raised when the source is missing and cleared unconditionally (the in-memory flag does not survive a +restart, but the repair issue does). +""" + +from __future__ import annotations + +import inspect +from datetime import datetime +from unittest.mock import MagicMock + +import pytest + +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.coordinator import EffektGuardCoordinator +from custom_components.effektguard.models.nibe import NibeF750Profile +from custom_components.effektguard.optimization.decision_engine import DecisionEngine +from custom_components.effektguard.optimization.effect_layer import EffectManager +from custom_components.effektguard.optimization.price_layer import PriceAnalyzer +from custom_components.effektguard.optimization.thermal_layer import ThermalModel + +CONFIG = { + "target_indoor_temp": 21.0, + "tolerance": 0.5, + "optimization_mode": "balanced", + "latitude": 59.33, + "heating_type": "radiator", + "heat_loss_coefficient": 150.0, + "thermal_mass": 0.7, + "insulation_quality": 1.0, +} + + +@pytest.fixture +def engine() -> DecisionEngine: + return DecisionEngine( + price_analyzer=PriceAnalyzer(), + effect_manager=EffectManager(MagicMock()), + thermal_model=ThermalModel(0.7, 1.0), + config=CONFIG, + heat_pump_model=NibeF750Profile(), + ) + + +@pytest.fixture +def state() -> NibeState: + """A house mildly in debt, on a cold-ish day. Nothing dramatic.""" + return NibeState( + outdoor_temp=-5.0, + indoor_temp=21.0, + supply_temp=38.0, + return_temp=33.0, + degree_minutes=-150.0, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 12, 0), + compressor_hz=50, + power_kw=2.0, + ) + + +def test_the_coordinator_does_not_invent_prices(): + """The adapter raises honestly. The coordinator must not undo that.""" + source = inspect.getsource(EffektGuardCoordinator) + + assert "get_fallback_prices" not in source, ( + "The coordinator calls get_fallback_prices() when the price source is missing or fails. " + "That returns 96 quarters all priced 1.0 - a number nobody measured - and the decision " + "engine then WEIGHS it. The adapter was fixed to raise rather than fabricate (F-013/F-014); " + "catching that and fabricating one layer up puts the defect straight back." + ) + + +def test_the_fabrication_is_gone_entirely(): + """No dead code, no second way back in.""" + from custom_components.effektguard.optimization import price_layer + + assert not hasattr(price_layer, "get_fallback_prices"), ( + "get_fallback_prices() still exists. Nothing may invent a price: if it is there, someone " + "will call it." + ) + + +def test_a_missing_price_source_is_raised_as_a_repair_issue(): + """A warning in the log is not telling the user. A repair issue is.""" + source = inspect.getsource(EffektGuardCoordinator) + + assert "async_create_issue" in source, ( + "When there is no electricity price source, price optimisation does not run - and the user " + "has `enable_price_optimization` switched on and believes it does. They are told by a " + "_LOGGER.warning, which nobody reads. Home Assistant has a repair-issue registry for " + "exactly this." + ) + + +def test_abstaining_heats_the_house_more_than_inventing_a_price(engine, state): + """The reason this matters, in one number. + + The invented prices are not neutral. They classify as NORMAL, the price layer casts a real + weighted vote, and the aggregate is pulled down - so the fabrication takes heat AWAY from the + house on the strength of a price that does not exist. + """ + honest = engine.calculate_decision( + nibe_state=state, + price_data=None, + weather_data=None, + current_peak=0.0, + current_power=2.0, + ) + + # Reproduce what the fallback used to be: 96 identical quarters, for TODAY. The date must be + # today - get_period_index(now) looks up the CURRENT quarter, so a differently-stamped day + # matches nothing, the price layer abstains, and the fabricated case would look identical. + from homeassistant.util import dt as dt_util + + from custom_components.effektguard.adapters.gespot_adapter import PriceData, QuarterPeriod + + base = dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) + invented = PriceData( + today=[ + QuarterPeriod( + start_time=base.replace(hour=q // 4, minute=(q % 4) * 15), + price=1.0, + ) + for q in range(96) + ], + tomorrow=[], + has_tomorrow=False, + ) + + fabricated = engine.calculate_decision( + nibe_state=state, + price_data=invented, + weather_data=None, + current_peak=0.0, + current_power=2.0, + ) + + assert honest.offset > fabricated.offset, ( + f"Abstaining commands {honest.offset:+.2f} °C; the invented prices command " + f"{fabricated.offset:+.2f} °C. The fabrication is not neutral - it votes, and it votes the " + f"house colder." + ) + assert "Spot Price" not in honest.reasoning, ( + "With no price data the reasoning must not mention a spot price at all. It said: " + f"{honest.reasoning!r}" + ) + + +def test_the_repair_issue_can_be_cleared_after_a_restart(): + """The `_price_issue_active` flag is reset by a restart; the repair issue HA persists is not. + + If the delete is guarded on that flag, an issue raised before a restart can never be cleared + after one - the flag is False again, the delete returns early, and the user is nagged forever. + async_delete_issue is a no-op when there is nothing to delete, so the clear must be unconditional. + """ + source = inspect.getsource(EffektGuardCoordinator._clear_price_source_issue) + + assert "if not self._price_issue_active" not in source, ( + "_clear_price_source_issue() returns early when the in-memory flag is False. That flag is " + "reset by every restart; the repair issue is not. So an issue raised before a restart can " + "never be cleared after one, and the user is told to fix something they already fixed." + ) + assert "async_delete_issue" in source, "the clear path must actually delete the issue" diff --git a/tests/unit/test_money_sensors_tell_the_truth.py b/tests/unit/test_money_sensors_tell_the_truth.py new file mode 100644 index 00000000..1b207a9e --- /dev/null +++ b/tests/unit/test_money_sensors_tell_the_truth.py @@ -0,0 +1,89 @@ +"""A projection is not a meter reading, and a price is not a sum of money. + +MONETARY permits exactly one state class - TOTAL - which makes the recorder keep a running SUM. + +`savings_estimate` must NOT be MONETARY: its value is a forward-looking monthly projection, and +summing it in the Energy dashboard is meaningless. Its unit stays hardcoded "SEK" (the effect-tariff +component is a Swedish tariff and the spot component is dropped unless already SEK-compatible, so the +value really is kronor - deriving the label from the öre/kWh price feed would be a 100x error). + +`current_price` must NOT be MONETARY either: its unit is typically "öre/kWh", a rate, not currency. +It is MEASUREMENT, which is what gives a price long-term statistics (min/max/mean) at all. +""" + +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass + +from custom_components.effektguard.sensor import SENSORS + + +def _by_key(key: str): + match = [d for d in SENSORS if d.key == key] + assert match, f"no sensor description with key {key!r}" + return match[0] + + +def test_a_projection_is_not_accumulated_into_the_energy_dashboard(): + """savings_estimate is a forecast. TOTAL makes the recorder sum it.""" + savings = _by_key("savings_estimate") + + assert savings.state_class != SensorStateClass.TOTAL, ( + "savings_estimate is state_class=TOTAL, so Home Assistant's recorder keeps a SUM of it - " + "but the value is a forward-looking monthly PROJECTION that rises and falls with the " + "forecast. The Energy and Statistics graphs accumulate it as if it were a meter." + ) + + +def test_the_savings_label_matches_the_unit_the_value_is_computed_in(): + """SEK is the RIGHT label: `monthly_estimate` is kronor (a Swedish effect tariff plus a spot + component dropped unless already SEK-compatible). Deriving the unit from the öre/kWh price feed + would print "öre" on a SEK value - a 100x error. The Norwegian-user problem is the tariff MODEL, + not the label (F-107, open with the owner). + """ + savings = _by_key("savings_estimate") + + assert savings.native_unit_of_measurement == "SEK", ( + "savings_estimate must be labelled SEK, because that is the unit its value is computed in: " + "a Swedish effect tariff, plus a spot component that is dropped unless it is already " + "SEK-compatible. Any other label misstates the magnitude." + ) + + +def test_a_price_per_kwh_is_not_a_sum_of_money(): + """MONETARY means an amount of currency. 'öre/kWh' is a rate.""" + price = _by_key("current_price") + + assert price.device_class != SensorDeviceClass.MONETARY, ( + "current_price is device_class=MONETARY, but its unit is read off the spot-price entity " + "and is typically 'öre/kWh' - not a currency. A price per kilowatt-hour is a rate, not an " + "amount of money." + ) + + +def test_the_price_sensor_produces_statistics(): + """The sensor a user most wants to plot recorded nothing at all. + + MONETARY permits only TOTAL, and TOTAL is wrong for a price, so the sensor was left with no + state class - and a sensor with no state class gets no long-term statistics. MEASUREMENT is + what a price is: the recorder keeps min, max and mean. + """ + price = _by_key("current_price") + + assert price.state_class == SensorStateClass.MEASUREMENT, ( + "current_price has no state class, so Home Assistant records no long-term statistics for " + "it. A price is a MEASUREMENT - min/max/mean over time is exactly what you want from it." + ) + + +def test_no_sensor_claims_monetary_without_earning_it(): + """Whatever else changes, MONETARY must come with the only state class HA allows for it.""" + for description in SENSORS: + if description.device_class != SensorDeviceClass.MONETARY: + continue + + assert description.state_class == SensorStateClass.TOTAL, ( + f"{description.key} declares device_class=MONETARY. Home Assistant permits exactly one " + f"state class with it - TOTAL - and TOTAL means the recorder keeps a running sum. If " + f"that is not what this sensor is, it is not MONETARY." + ) diff --git a/tests/unit/test_one_answer_to_what_the_power_sensor_says.py b/tests/unit/test_one_answer_to_what_the_power_sensor_says.py new file mode 100644 index 00000000..af6de17c --- /dev/null +++ b/tests/unit/test_one_answer_to_what_the_power_sensor_says.py @@ -0,0 +1,163 @@ +"""One power sensor, one answer - the adapter and the coordinator must not disagree by a factor of a +thousand over what a unit means. + +Both read the owner's whole-house meter through the shared `power_kw_from_state` helper now: one +feeds savings and model validation, the other feeds peak protection and the tariff record. A sensor +with no declared unit must be refused by both (a unit-less 6000 is otherwise 6 MW to one reader and +6 kW to the other; a unit-less 6.0 kW meter divided by 1000 becomes 0.006 kW and silently disables +peak protection for the month). A cumulative kWh energy sensor - one dropdown entry away - must be +refused too: read as power it reports the meter's lifetime total as an instantaneous peak. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.util import dt as dt_util + +from custom_components.effektguard.adapters.nibe_adapter import NibeAdapter, NibeState +from custom_components.effektguard.const import POWER_SOURCE_EXTERNAL_METER +from custom_components.effektguard.coordinator import EffektGuardCoordinator +from custom_components.effektguard.optimization.effect_layer import EffectManager + +POWER_ENTITY = "sensor.house_power" + + +def _hass_with_power_sensor(value: str, unit: str | None) -> MagicMock: + state = MagicMock() + state.state = value + state.attributes = {} if unit is None else {"unit_of_measurement": unit} + state.last_reported = dt_util.utcnow() + state.last_updated = dt_util.utcnow() + + hass = MagicMock() + hass.config.latitude = 59.33 + hass.config.longitude = 18.07 + hass.states.get.return_value = state + return hass + + +async def _adapter_says(value: str, unit: str | None) -> float | None: + """What the adapter reports as a MEASUREMENT. None when it declines to accept the sensor. + + A refused sensor falls through to `_estimate_power_from_temps`, which is a legitimate thing for + the adapter to do - the estimate is flagged, and layers that only need a magnitude may use it. + It is not a reading of this sensor, so it is not what this file is about. + """ + hass = _hass_with_power_sensor(value, unit) + adapter = NibeAdapter( + hass, {"nibe_entity": "number.offset", "power_sensor_entity": POWER_ENTITY} + ) + power, estimated = await adapter.get_power_consumption() + return None if estimated else power + + +async def _coordinator_says(value: str, unit: str | None) -> float | None: + """What the coordinator took FROM THE METER. None when it declined to accept the sensor. + + Same distinction as `_adapter_says`: a refused sensor still leaves the coordinator estimating a + power figure for the decision layers, but that estimate is not billable and is not a reading of + this sensor. `peak_today_source` is how the coordinator records which it was. + """ + hass = _hass_with_power_sensor(value, unit) + + nibe = MagicMock() + nibe._power_sensor_entity = POWER_ENTITY + nibe.power_sensor_entity = POWER_ENTITY + + entry = MagicMock() + entry.data = {} + entry.options = {} + + coordinator = EffektGuardCoordinator( + hass, nibe, MagicMock(), MagicMock(), MagicMock(), EffectManager(hass), entry + ) + coordinator.peak_today = 0.0 + coordinator.peak_this_month = 0.0 + coordinator._power_sensor_available = True + coordinator.effect.record_period_measurement = AsyncMock(return_value=None) + + await coordinator._update_peak_tracking( + NibeState( + outdoor_temp=-5.0, + indoor_temp=21.0, + supply_temp=42.0, + return_temp=37.0, + degree_minutes=-150.0, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc), + ) + ) + if coordinator.peak_today_source != POWER_SOURCE_EXTERNAL_METER: + return None + return coordinator.current_power_kw + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("value", "unit"), [("6000", "W"), ("6.0", "kW"), ("6000", "MW")]) +async def test_both_readers_of_the_same_sensor_give_the_same_answer(value, unit): + """Whatever the right answer is, there cannot be two of them.""" + adapter = await _adapter_says(value, unit) + coordinator = await _coordinator_says(value, unit) + + assert adapter == coordinator, ( + f"A power sensor reporting {value!r} with unit {unit!r} is read as {adapter} kW by the NIBE " + f"adapter and {coordinator} kW by the coordinator - the same entity, the same instant. One " + f"drives savings and model validation; the other drives peak protection and the tariff " + f"record." + ) + + +@pytest.mark.asyncio +async def test_a_sensor_with_no_unit_is_refused_by_both_readers(): + """The 1000x split. Neither reader may take the number, and neither may take a different one. + + The adapter used to keep `6000` as 6000 kW; the coordinator divided the same 6000 down to 6.0 kW. + Six megawatts and six kilowatts, from one sensor, in one cycle. There is no answer that makes both + right, so neither is allowed to invent one. + """ + assert await _adapter_says("6000", None) is None, ( + "The NIBE adapter accepted a power sensor with no declared unit, keeping 6000 verbatim as " + "6000 kW - six megawatts, fed to savings and model validation." + ) + assert await _coordinator_says("6000", None) is None, ( + "The coordinator accepted a power sensor with no declared unit, assuming watts and dividing " + "by 1000. The adapter, reading the SAME entity in the SAME cycle, assumed kilowatts." + ) + + +@pytest.mark.asyncio +async def test_a_kilowatt_meter_with_no_unit_does_not_become_six_watts(): + """The failure the coordinator's own comment warns about, which its default still creates. + + A 6.0 kW whole-house meter that carries no unit is divided by 1000 into 0.006 kW. Peak protection + then sees a house drawing six watts and never fires - all month, silently. + """ + coordinator = await _coordinator_says("6.0", None) + + assert coordinator is None or coordinator > 0.5, ( + f"A meter reading 6.0 with no declared unit was taken as {coordinator} kW. If it is a " + f"kilowatt meter - and 6.0 is a kilowatt-shaped number; a watt meter would say 6000 - then " + f"peak protection has just been told the house is drawing six watts, and it will not fire " + f"again this month." + ) + + +@pytest.mark.asyncio +async def test_an_energy_sensor_is_not_a_power_sensor(): + """kWh is cumulative. It only ever climbs, and it is one dropdown entry away from the right one. + + Picked by mistake, it is read as if it were instantaneous power: a house that has consumed 4300 kWh + this year reports a 4300 kW peak, and every subsequent decision is made against it. + """ + assert await _adapter_says("4300", "kWh") is None, ( + "A cumulative ENERGY sensor (kWh) was accepted as instantaneous power. It never falls, so the " + "recorded peak becomes the meter's lifetime total and stays there." + ) + assert ( + await _coordinator_says("4300", "kWh") is None + ), "A cumulative ENERGY sensor (kWh) was accepted as instantaneous power for peak billing." diff --git a/tests/unit/test_options_flow_tells_you_what_is_wrong.py b/tests/unit/test_options_flow_tells_you_what_is_wrong.py new file mode 100644 index 00000000..efe11b92 --- /dev/null +++ b/tests/unit/test_options_flow_tells_you_what_is_wrong.py @@ -0,0 +1,46 @@ +"""The options flow must surface its own validation message, not throw it away. + +`_validate_and_convert_dhw_config` raises `vol.Invalid` with a message naming the field and its +permitted range. `async_step_init` must catch it and re-show the form with the message in an +`errors` dict - an exception left to escape a config-flow step renders as HA's generic "Unknown +error occurred", so the user is told that something failed but not what, and their input is gone. +""" + +from __future__ import annotations + +import inspect + +import pytest +import voluptuous as vol + +from custom_components.effektguard.options import EffektGuardOptionsFlow + + +def test_the_validator_still_rejects_an_out_of_range_target(): + """The precondition. If this stops raising, the rest of the file is about nothing.""" + flow = EffektGuardOptionsFlow() + + with pytest.raises(vol.Invalid): + flow._validate_and_convert_dhw_config({"dhw_target_temp": 95.0}) + + +def test_the_step_does_not_let_the_error_escape_as_unknown_error(): + """An unhandled exception in a flow step renders as "Unknown error occurred".""" + source = inspect.getsource(EffektGuardOptionsFlow.async_step_init) + + assert "vol.Invalid" in source, ( + "async_step_init calls _validate_and_convert_dhw_config, which raises vol.Invalid with a " + "message naming the field and the permitted range - and does not catch it. Home Assistant " + "turns an escaped exception into 'Unknown error occurred', so the message is never seen " + "and the user's input is discarded." + ) + + +def test_the_step_re_shows_the_form_with_the_message_on_it(): + """Catching it is only half the job: the user has to be told, on the field.""" + source = inspect.getsource(EffektGuardOptionsFlow.async_step_init) + + assert "errors" in source, ( + "async_step_init must collect the validation failure into an `errors` dict and pass it to " + "async_show_form, so the message lands on the form the user is looking at." + ) diff --git a/tests/unit/test_platforms_unload_before_the_coordinator_dies.py b/tests/unit/test_platforms_unload_before_the_coordinator_dies.py new file mode 100644 index 00000000..f6a7a074 --- /dev/null +++ b/tests/unit/test_platforms_unload_before_the_coordinator_dies.py @@ -0,0 +1,54 @@ +"""Platforms unload FIRST; the coordinator dies only after they actually did. + +async_unload_entry must unload the platforms before shutting the coordinator down. If it +shuts down first and a platform then refuses to unload (HA returns False and keeps the entry +loaded), the entry is left with live entities served by a dead coordinator - sensors frozen, +control loop gone, nothing saying so. HA's own order is: unload platforms, and only on +success tear down what they were reading from. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.effektguard import async_unload_entry +from custom_components.effektguard.const import DOMAIN + + +def _env(unload_ok: bool): + hass = MagicMock() + hass.config_entries.async_unload_platforms = AsyncMock(return_value=unload_ok) + hass.services.has_service = MagicMock(return_value=False) + + entry = MagicMock() + entry.entry_id = "test-entry" + + coordinator = MagicMock() + coordinator.async_shutdown = AsyncMock() + hass.data = {DOMAIN: {"test-entry": coordinator}} + return hass, entry, coordinator + + +@pytest.mark.asyncio +async def test_a_refused_platform_unload_leaves_the_coordinator_alive(): + hass, entry, coordinator = _env(unload_ok=False) + + result = await async_unload_entry(hass, entry) + + assert result is False + coordinator.async_shutdown.assert_not_awaited() + assert hass.data[DOMAIN]["test-entry"] is coordinator, ( + "The entry is still loaded - HA keeps serving its entities - so the coordinator " + "must still be the live object behind them." + ) + + +@pytest.mark.asyncio +async def test_a_successful_unload_shuts_the_coordinator_down_after(): + hass, entry, coordinator = _env(unload_ok=True) + + result = await async_unload_entry(hass, entry) + + assert result is True + coordinator.async_shutdown.assert_awaited_once() + assert "test-entry" not in hass.data[DOMAIN] diff --git a/tests/unit/test_reads_do_not_drive_the_pump.py b/tests/unit/test_reads_do_not_drive_the_pump.py new file mode 100644 index 00000000..ac4957e2 --- /dev/null +++ b/tests/unit/test_reads_do_not_drive_the_pump.py @@ -0,0 +1,109 @@ +"""Reading the state of the world must not command the heat pump. + +`_async_update_data` is HA's READ hook, and `async_request_refresh()` is public, debounced, and +called from reloads, options changes and bookkeeping services (reset_peak_tracking clears a +counter). So the read path must contain no write. Writes belong to `_do_aligned_refresh`, the one +scheduled owner of the control loop; services that genuinely command the pump (force_offset, +boost_heating) go through the explicit `async_refresh_and_apply` path and take effect at once. +""" + +import inspect +from pathlib import Path + +import pytest + +from custom_components.effektguard.coordinator import EffektGuardCoordinator + +# Everything that reaches the heat pump. +WRITES = ( + "set_curve_offset", + "set_enhanced_ventilation", + "_apply_dhw_control", + "_apply_airflow_decision", +) + + +def test_the_read_hook_contains_no_write(): + """Checked structurally, not by execution. + + `_async_update_data` gathers state from half a dozen adapters; a test that stubbed all of them + would prove only that the stubs were right. What matters is that the source of the READ path + contains no call that reaches the pump. + """ + source = inspect.getsource(EffektGuardCoordinator._async_update_data) + + found = [call for call in WRITES if call in source] + + assert not found, ( + f"_async_update_data is Home Assistant's READ hook and it writes to the heat pump: " + f"{', '.join(found)}. Everything that calls async_request_refresh() therefore drives the " + f"pump - including reset_peak_tracking, which only clears a counter." + ) + + +def test_the_control_loop_is_the_one_that_writes(): + """If the scheduled loop does not drive the pump, nothing ever will. + + With `update_interval=None`, `_do_aligned_refresh` is the only thing on a clock. Taking the + writes out of the read hook without putting them here would leave the pump on whatever offset + it last held, forever, and every entity would still look healthy. + """ + source = inspect.getsource(EffektGuardCoordinator._do_aligned_refresh) + + assert "_drive_the_pump" in source, ( + "_do_aligned_refresh is the scheduled owner of the write path, and with update_interval " + "None it is the only thing on a clock. If it does not drive the pump, nothing does." + ) + + +def test_a_service_can_still_command_the_pump_at_once(): + """Splitting read from write must not make force_offset wait for the next aligned tick.""" + assert hasattr(EffektGuardCoordinator, "async_refresh_and_apply"), ( + "Services that genuinely command the pump need an explicit way to read, decide and apply " + "immediately - otherwise force_offset would take up to a full update interval to land." + ) + + source = inspect.getsource(EffektGuardCoordinator.async_refresh_and_apply) + assert "_drive_the_pump" in source, ( + "async_refresh_and_apply exists to reach the pump, and must do so through the one owner of " + "the write path - which is what holds the control lock." + ) + + +def _service_handler(marker: str) -> str: + """The source of the service handler containing `marker`, to its closing boundary. + + Sliced at the next `async def`, not at a byte count: a fixed window silently stops covering + the handler the moment anyone adds a line to it, and the test then passes for the wrong reason. + """ + source = ( + Path(__file__).resolve().parents[2] / "custom_components" / "effektguard" / "__init__.py" + ).read_text(encoding="utf-8") + + start = source.index(marker) + end = source.find("\n async def ", start) + return source[start:end] if end != -1 else source[start:] + + +def test_bookkeeping_services_do_not_touch_the_pump(): + """reset_peak_tracking clears a counter. That is all it may do.""" + handler = _service_handler("Reset peak tracking service called") + + assert "async_refresh_and_apply" not in handler, ( + "reset_peak_tracking clears a stored counter and must not drive the heat pump. It may ask " + "for a refresh so the entities catch up; it may not ask for an apply." + ) + + +@pytest.mark.parametrize( + "marker", + ["Force offset service called", "Boost heating service called"], +) +def test_the_services_that_command_the_pump_do_apply(marker): + """force_offset and boost_heating mean what they say, and must land at once.""" + handler = _service_handler(marker) + + assert "async_apply_manual_override" in handler, ( + f"{marker!r} exists to drive the heat pump. With the read path no longer writing, it must " + f"use the shared explicit-command path, or it does nothing until the next aligned tick." + ) diff --git a/tests/unit/test_startup_grace_is_bounded.py b/tests/unit/test_startup_grace_is_bounded.py new file mode 100644 index 00000000..a40e5a5c --- /dev/null +++ b/tests/unit/test_startup_grace_is_bounded.py @@ -0,0 +1,72 @@ +"""A heat pump that never appears must eventually be reported as missing, not "still starting". + +The coordinator tolerates a missing NIBE at startup (MyUplink is slow to publish entities) by +returning `startup_pending: True` while `_first_successful_update` is False. That grace must be +BOUNDED by STARTUP_MAX_GRACE_ATTEMPTS: past it, a missing pump becomes UpdateFailed rather than a +permanently green entry that reads nothing and controls nothing. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.helpers.update_coordinator import UpdateFailed + +from custom_components.effektguard.const import STARTUP_MAX_GRACE_ATTEMPTS +from custom_components.effektguard.coordinator import EffektGuardCoordinator + + +@pytest.fixture +def coordinator() -> EffektGuardCoordinator: + """A coordinator whose NIBE never answers.""" + coord = EffektGuardCoordinator.__new__(EffektGuardCoordinator) + coord.nibe = MagicMock() + coord.nibe.get_current_state = AsyncMock(side_effect=UpdateFailed("no such entity")) + coord._first_successful_update = False + coord._startup_grace_attempts = 0 + coord._schedule_aligned_refresh = MagicMock() + coord.hass = MagicMock() + coord.entry = MagicMock() + coord.entry.data = {} + return coord + + +async def test_it_waits_before_giving_up(coordinator): + """The grace period must still exist: MyUplink is genuinely slow to start.""" + result = await coordinator._async_update_data() + + assert result["startup_pending"] is True, "the first attempt must be tolerated, not fatal" + assert result["nibe"] is None + + +async def test_it_does_not_wait_forever(coordinator): + """After the grace period, a missing heat pump is an error, not a pending state.""" + for _ in range(STARTUP_MAX_GRACE_ATTEMPTS): + await coordinator._async_update_data() + + with pytest.raises(UpdateFailed) as err: + await coordinator._async_update_data() + + assert "NIBE" in str(err.value) + + +async def test_the_entry_never_reports_itself_healthy_while_blind(coordinator): + """`startup_pending` must not be returnable indefinitely. + + A config entry that stays loaded, green, and pending forever tells the user nothing is wrong + while the integration reads nothing and controls nothing. + """ + pending = 0 + for _ in range(STARTUP_MAX_GRACE_ATTEMPTS + 5): + try: + result = await coordinator._async_update_data() + except UpdateFailed: + break + if result.get("startup_pending"): + pending += 1 + else: # pragma: no cover - only reached if it never gives up + pytest.fail( + f"The coordinator returned startup_pending {pending} times and never once " + f"reported failure. A user with no NIBE at all gets a permanently green integration." + ) + + assert pending <= STARTUP_MAX_GRACE_ATTEMPTS diff --git a/tests/unit/test_the_airflow_sensor_survives_its_own_attributes.py b/tests/unit/test_the_airflow_sensor_survives_its_own_attributes.py new file mode 100644 index 00000000..b6ccb971 --- /dev/null +++ b/tests/unit/test_the_airflow_sensor_survives_its_own_attributes.py @@ -0,0 +1,28 @@ +"""The airflow_thermal_gain sensor must render its attributes against a REAL AirflowOptimizer. + +``get_enhancement_stats()`` was deleted (bookkeeping nothing consumed), but the attribute block once +still called it, raising AttributeError on every update. A MagicMock coordinator answers any method +cheerfully and hides that, so this test wires the real optimizer and renders the real sensor. +""" + +from unittest.mock import MagicMock, Mock + +from custom_components.effektguard.optimization.airflow_optimizer import AirflowOptimizer +from custom_components.effektguard.sensor import SENSORS, EffektGuardSensor + + +def test_attribute_render_calls_only_methods_the_real_optimizer_has(): + description = next(s for s in SENSORS if s.key == "airflow_thermal_gain") + + coordinator = MagicMock() + coordinator.airflow_optimizer = AirflowOptimizer() # the real thing - no auto-attributes + coordinator.data = {"airflow_decision": None} + + entry = Mock() + entry.entry_id = "test-entry" + + sensor = EffektGuardSensor(coordinator, entry, description) + + attrs = sensor.extra_state_attributes # must not raise + + assert isinstance(attrs, dict) diff --git a/tests/unit/test_the_boost_cooldown_survives_a_reload.py b/tests/unit/test_the_boost_cooldown_survives_a_reload.py new file mode 100644 index 00000000..5b8f91e0 --- /dev/null +++ b/tests/unit/test_the_boost_cooldown_survives_a_reload.py @@ -0,0 +1,93 @@ +"""The `_service_last_called` cooldown dict is deliberately at MODULE scope, not on the coordinator. + +It rate-limits the two services that can hurt the machine (boost_heating commands MAX_OFFSET; +boost_dhw fires the immersion heater via temporary lux). On the coordinator it would die with the +coordinator, so HA's reload button - which re-creates it - would reset the rate limiter: boost to ++10 °C, reload, boost again. `single_config_entry` is true, so a module global cannot leak across +entries. This file pins that the state stays at module scope and no reload path clears it. +""" + +from __future__ import annotations + + +import inspect + +from custom_components.effektguard import ( + _check_service_cooldown, + _service_last_called, + _update_service_timestamp, +) +from custom_components.effektguard.const import ( + DHW_BOOST_COOLDOWN_MINUTES, + HEATING_BOOST_COOLDOWN_MINUTES, + MAX_OFFSET, +) + + +def test_the_cooldown_actually_blocks_a_second_boost(): + """Precondition: the rate limiter rate-limits.""" + _service_last_called.clear() + + allowed, _ = _check_service_cooldown("boost_heating", HEATING_BOOST_COOLDOWN_MINUTES) + assert allowed, "the first boost must be allowed" + + _update_service_timestamp("boost_heating") + + allowed, remaining = _check_service_cooldown("boost_heating", HEATING_BOOST_COOLDOWN_MINUTES) + assert not allowed, ( + f"A second boost_heating was allowed immediately after the first. It commands " + f"{MAX_OFFSET:+.0f} °C." + ) + assert remaining > 0 + + +def test_the_cooldown_state_is_not_held_on_the_coordinator(): + """Structural, and the whole point of the file. + + Anything the coordinator owns is destroyed when the entry is unloaded. Home Assistant's reload + button unloads and re-sets-up the entry, so a cooldown living there is cleared by a reload - + and the two services it guards are the two that can drive the pump to +10 °C and light the + immersion heater. + """ + from custom_components.effektguard.coordinator import EffektGuardCoordinator + + coordinator_source = inspect.getsource(EffektGuardCoordinator) + + assert "_service_last_called" not in coordinator_source, ( + "The service-cooldown state has been moved onto the coordinator. The coordinator is " + "destroyed on unload, so reloading the integration now RESETS the cooldown on " + "boost_heating (+10 °C) and boost_dhw (the immersion heater). A rate limiter that a reload " + "clears is not a rate limiter. It belongs at module scope, and deliberately so." + ) + + +def test_no_reload_path_clears_the_cooldown(): + """A config-entry reload must not forget that a boost just happened. + + HA's reload does not re-import the module; it calls `async_unload_entry` then `async_setup_entry` + on the module already in `sys.modules`, so module-scope state survives unless a path clears it. + That is what is checked (rather than importlib.reload, which re-executes the body and would reset + the dict - the opposite of a config-entry reload). + """ + import custom_components.effektguard as integration + + for name in ("async_unload_entry", "_async_unregister_services", "async_setup_entry"): + source = inspect.getsource(getattr(integration, name)) + + assert "_service_last_called" not in source, ( + f"{name} touches _service_last_called. Clearing the service cooldowns on unload or " + f"setup makes Home Assistant's reload button a one-click reset for the rate limiter on " + f"boost_heating ({MAX_OFFSET:+.0f} °C) and boost_dhw (the immersion heater)." + ) + + +def test_the_dhw_cooldown_is_long_enough_to_matter(): + """The two guarded services are the two that can hurt the machine.""" + assert HEATING_BOOST_COOLDOWN_MINUTES >= 30, ( + f"boost_heating commands {MAX_OFFSET:+.0f} °C. A {HEATING_BOOST_COOLDOWN_MINUTES}-minute " + f"cooldown is not a meaningful limit on that." + ) + assert DHW_BOOST_COOLDOWN_MINUTES >= 30, ( + f"boost_dhw fires the immersion heater. A {DHW_BOOST_COOLDOWN_MINUTES}-minute cooldown is " + f"not a meaningful limit on that." + ) diff --git a/tests/unit/test_the_pump_is_not_driven_on_a_reading_from_hours_ago.py b/tests/unit/test_the_pump_is_not_driven_on_a_reading_from_hours_ago.py new file mode 100644 index 00000000..c461b9f6 --- /dev/null +++ b/tests/unit/test_the_pump_is_not_driven_on_a_reading_from_hours_ago.py @@ -0,0 +1,85 @@ +"""A required NIBE reading nobody has confirmed for hours is not a reading; it must not drive the pump. + +An MQTT/modbus sensor (both listed as NIBE sources in manifest.json) holds its last retained value +indefinitely and is never marked unavailable, so if its publisher stops the adapter's other checks +all pass while the number goes stale. Age is the only thing that separates a reading from a memory: +`_read_entity_float` rejects a value older than NIBE_READING_MAX_AGE_MINUTES, and a required sensor +that comes back None raises UpdateFailed - the pump is left on its last offset, the safe thing to do +with a heat pump you can no longer see. The threshold stays generous enough not to break a slow but +working NIBE integration. +""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import MagicMock + +import pytest +from homeassistant.util import dt as dt_util + +from custom_components.effektguard.adapters.nibe_adapter import NibeAdapter +from custom_components.effektguard.const import NIBE_READING_MAX_AGE_MINUTES + + +def _adapter_with(entity_id: str, value: str, age: timedelta) -> NibeAdapter: + """An adapter whose sensor last said anything `age` ago.""" + state = MagicMock() + state.state = value + state.last_reported = dt_util.utcnow() - age + state.last_updated = dt_util.utcnow() - age + + hass = MagicMock() + hass.states.get.return_value = state + + return NibeAdapter(hass, {"nibe_entity": "number.offset", "degree_minutes_entity": entity_id}) + + +def test_the_max_age_is_generous_enough_not_to_break_a_working_setup(): + """A guard that rejects healthy data is worse than the bug it was meant to fix. + + The coordinator runs every five minutes. Any NIBE source that reports less often than this + threshold cannot support five-minute heat-pump control anyway, so nothing that works today can + be broken by it. + """ + assert NIBE_READING_MAX_AGE_MINUTES >= 15, ( + f"A max age of {NIBE_READING_MAX_AGE_MINUTES} minutes is tight enough to reject a healthy " + f"but slow NIBE integration, and refusing to control a working heat pump is a worse failure " + f"than the one this guards against." + ) + + +@pytest.mark.asyncio +async def test_a_fresh_reading_is_used(): + """The precondition. If this fails, the guard is rejecting everything.""" + adapter = _adapter_with("sensor.dm", "-150", age=timedelta(minutes=1)) + + value = await adapter._read_entity_float("sensor.dm", default=None) + + assert value == -150.0 + + +@pytest.mark.asyncio +async def test_a_reading_nobody_has_confirmed_for_hours_is_not_a_reading(): + """The MQTT case: available, unchanged, and hours old.""" + stale = timedelta(minutes=NIBE_READING_MAX_AGE_MINUTES + 60) + adapter = _adapter_with("sensor.dm", "-150", age=stale) + + value = await adapter._read_entity_float("sensor.dm", default=None) + + assert value is None, ( + f"A degree-minute sensor that last reported {stale} ago was read as -150.0 and used to " + f"drive the heat pump. Nothing has confirmed that number since. The real degree minutes " + f"could be anywhere - including past the auxiliary-heat limit - and the integration would " + f"go on trimming the curve for price, because as far as it can tell the pump is coping." + ) + + +@pytest.mark.asyncio +async def test_a_stale_required_reading_stops_the_integration_controlling(): + """It must take the same path as a missing one: refuse to drive on data we do not have.""" + from homeassistant.helpers.update_coordinator import UpdateFailed + + adapter = _adapter_with("sensor.dm", "-150", age=timedelta(hours=6)) + + with pytest.raises(UpdateFailed): + await adapter.get_current_state() diff --git a/tests/unit/test_the_thermostat_off_switch_actually_turns_it_off.py b/tests/unit/test_the_thermostat_off_switch_actually_turns_it_off.py new file mode 100644 index 00000000..c9265b4b --- /dev/null +++ b/tests/unit/test_the_thermostat_off_switch_actually_turns_it_off.py @@ -0,0 +1,112 @@ +"""Setting the thermostat to OFF must actually disable the optimiser, not just read OFF. + +The coordinator's master gate is `entry.data["enable_optimization"]`. Setting HVACMode.OFF must +write that key (via `set_optimization_enabled`), or the optimiser goes quiet for one cycle and then +resumes driving the pump while the thermostat still displays OFF. There is ONE piece of state - +`entry.data["enable_optimization"]`, which the `enable_optimization` switch writes too - and the +thermostat's `hvac_mode` is a VIEW of it, so the two controls cannot disagree and the mode survives a +restart from the entry (no RestoreEntity shadowing it). +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.components.climate.const import HVACMode + +from custom_components.effektguard.climate import EffektGuardClimate +from custom_components.effektguard.const import CONF_ENABLE_OPTIMIZATION + + +def _climate(optimization_enabled: bool = True) -> tuple[EffektGuardClimate, MagicMock]: + entry = MagicMock() + entry.entry_id = "entry_1" + entry.data = {CONF_ENABLE_OPTIMIZATION: optimization_enabled} + entry.options = {} + + coordinator = MagicMock() + + async def _set_optimization_enabled(enabled: bool) -> None: + new_data = dict(entry.data) + new_data[CONF_ENABLE_OPTIMIZATION] = enabled + entry.data = new_data + + coordinator.set_optimization_enabled = AsyncMock(side_effect=_set_optimization_enabled) + coordinator.data = {} + + climate = EffektGuardClimate(coordinator, entry) + climate.hass = MagicMock() + climate.async_write_ha_state = MagicMock() + + # Home Assistant writes the new entry through and the entry object reflects it. + def _update_entry(target_entry, data=None, options=None, **kwargs): + if data is not None: + target_entry.data = data + if options is not None: + target_entry.options = options + + climate.hass.config_entries.async_update_entry = MagicMock(side_effect=_update_entry) + return climate, entry + + +@pytest.mark.asyncio +async def test_setting_the_thermostat_to_off_disables_the_master_gate(): + """THE BUG. OFF reset the offset once and left the optimiser enabled.""" + climate, entry = _climate(optimization_enabled=True) + + await climate.async_set_hvac_mode(HVACMode.OFF) + + assert entry.data[CONF_ENABLE_OPTIMIZATION] is False, ( + "the thermostat was set to OFF and `enable_optimization` is still " + f"{entry.data[CONF_ENABLE_OPTIMIZATION]}. That key is the ONLY thing the coordinator's " + "decision gate consults. So the optimiser goes quiet for a single cycle - the offset is " + "reset to 0.0 - and then the next aligned refresh, five minutes later, decides an offset " + "and writes it to the heat pump, while the thermostat still reads OFF." + ) + + +@pytest.mark.asyncio +async def test_setting_it_back_to_heat_re_enables_the_gate(): + """The other direction, or OFF becomes a trap you cannot leave.""" + climate, entry = _climate(optimization_enabled=False) + + await climate.async_set_hvac_mode(HVACMode.HEAT) + + assert entry.data[CONF_ENABLE_OPTIMIZATION] is True + climate.coordinator.set_optimization_enabled.assert_awaited_with(True) + + +def test_the_thermostat_shows_what_the_optimiser_is_actually_doing(): + """The display must be a VIEW of the master gate, not a second copy of it. + + The `enable_optimization` SWITCH writes the same key. With two independent pieces of state, the + switch could be off and the thermostat could read HEAT - one fact, two answers. + """ + off_climate, _ = _climate(optimization_enabled=False) + on_climate, _ = _climate(optimization_enabled=True) + + assert off_climate.hvac_mode == HVACMode.OFF, ( + "the master switch is off - the coordinator is holding a neutral offset and optimising " + "nothing - and the thermostat says it is HEATing. The switch entity and the thermostat " + "write the same fact and must read the same fact." + ) + assert on_climate.hvac_mode == HVACMode.HEAT + + +def test_the_mode_survives_a_restart_because_it_lives_in_the_entry(): + """And it is the TRUTH that survives, not a display of it. + + The mode used to be restored by RestoreEntity from the entity's own last state - a copy of a copy. + It restored OFF perfectly while the optimiser, whose gate had never been told anything, resumed + driving the pump. The entry survives restarts on its own, and it is what the coordinator reads. + """ + climate, entry = _climate(optimization_enabled=False) + + # A fresh entity, as after a restart: same entry, no restored entity state anywhere. + reborn = EffektGuardClimate(climate.coordinator, entry) + + assert reborn.hvac_mode == HVACMode.OFF, ( + "after a restart the thermostat does not reflect the optimiser's actual state. It must be " + "read from the config entry, which is the thing the coordinator's gate reads too." + ) diff --git a/tests/unit/test_which_things_actually_unload_the_entry.py b/tests/unit/test_which_things_actually_unload_the_entry.py new file mode 100644 index 00000000..ea99e1fa --- /dev/null +++ b/tests/unit/test_which_things_actually_unload_the_entry.py @@ -0,0 +1,109 @@ +"""Which user actions actually tear the entry down - the two facts the shutdown guards depend on. + +An options change calls the update listener, which HOT-RELOADS (`async_update_config`): the entry +stays loaded, so the shutdown guards must NOT fire on it and the entities must be re-rendered +(`async_update_listeners`) since they are views of the entry. + +What DOES unload the entry: the reconfigure flow (changing entity selections - power meter, weather, +pump model), which ends in `async_update_reload_and_abort` and schedules a full reload; plus manual +reload, removal, and restart. The reconfigure case is exactly when a stray write from the old +coordinator would land, which is the defect the shutdown guards close. +""" + +from __future__ import annotations + +import ast +import pathlib +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.effektguard import async_reload_entry +from custom_components.effektguard.const import DOMAIN + + +@pytest.mark.asyncio +async def test_changing_an_option_hot_reloads_and_does_not_unload(): + """The listener that fires on an options change must not tear the entry down.""" + hass = MagicMock() + hass.config_entries.async_reload = AsyncMock() + hass.config_entries.async_unload = AsyncMock() + + coordinator = MagicMock() + coordinator.async_update_config = AsyncMock() + + entry = MagicMock() + entry.entry_id = "entry_1" + entry.data = {"target_indoor_temp": 21.0} + entry.options = {"thermal_mass": 1.8} + hass.data = {DOMAIN: {"entry_1": coordinator}} + + await async_reload_entry(hass, entry) + + coordinator.async_update_config.assert_awaited_once() + applied = coordinator.async_update_config.await_args.args[0] + assert applied["thermal_mass"] == 1.8, "the changed option must actually reach the coordinator" + + assert hass.config_entries.async_reload.await_count == 0, ( + "changing an option tore the entry down. This integration hot-reloads on purpose - it is " + "what preserves the startup grace period, the entities, and the accumulated learning state. " + "If this ever becomes a real reload, every comment that says an options change does NOT " + "unload becomes wrong, and the shutdown guards start firing on an ordinary settings change." + ) + assert hass.config_entries.async_unload.await_count == 0 + + +@pytest.mark.asyncio +async def test_the_entities_are_told_when_the_entry_changes(): + """Hot-reloading the config must re-render the entities that are VIEWS of it. + + Switches read `entry.data` in `is_on` and the thermostat's `hvac_mode` reads the same + `enable_optimization` key, but a view only updates when told to. Without + `async_update_listeners`, the switch kept displaying its old value until the next aligned refresh. + """ + hass = MagicMock() + coordinator = MagicMock() + coordinator.async_update_config = AsyncMock() + + entry = MagicMock() + entry.entry_id = "entry_1" + entry.data = {"enable_optimization": False} + entry.options = {} + hass.data = {DOMAIN: {"entry_1": coordinator}} + + await async_reload_entry(hass, entry) + + coordinator.async_update_listeners.assert_called_once_with() + + +def test_the_reconfigure_flow_is_the_one_that_reloads(): + """And it is a real user action: swapping the power meter or the weather entity. + + Checked structurally: the reconfigure step ends in `async_update_reload_and_abort`, Home + Assistant's "apply these entity selections and reload the entry" - the FULL teardown the + shutdown guards exist for. + """ + source = pathlib.Path("custom_components/effektguard/config_flow.py").read_text() + tree = ast.parse(source) + + reloaders = [ + node.name + for node in ast.walk(tree) + if isinstance(node, ast.AsyncFunctionDef) + and any( + isinstance(inner, ast.Call) + and isinstance(inner.func, ast.Attribute) + and inner.func.attr == "async_update_reload_and_abort" + for inner in ast.walk(node) + ) + ] + + assert reloaders, ( + "no step in the config flow calls `async_update_reload_and_abort`. Something must force a " + "full reload when the entity selections change - the adapters are built from entry.data at " + "setup and would otherwise keep pointing at the old entities." + ) + assert all("reconfigure" in name for name in reloaders), ( + f"{reloaders} force a full entry reload. Only the reconfigure step should: it is the one " + f"that changes which entities the adapters are built from." + ) diff --git a/tests/unit/test_you_can_report_what_the_pump_actually_did.py b/tests/unit/test_you_can_report_what_the_pump_actually_did.py new file mode 100644 index 00000000..30680301 --- /dev/null +++ b/tests/unit/test_you_can_report_what_the_pump_actually_did.py @@ -0,0 +1,195 @@ +"""The diagnostics hook must hand over what the DECISION saw, and must be downloadable. + +`async_get_config_entry_diagnostics` carries the offset it commanded and every layer's vote, the +NIBE state it read, the degree-minute thresholds actually in force (computed per climate zone AND +thermal mass, so the constants prove nothing), and whether the price and weather sources were live +(a missing price source silently withdraws the whole price layer, F-123). It must NOT carry the +home's latitude - the dump is pasted into public issues - but keeps the climate ZONE, which +identifies nobody. The whole dump must JSON-serialise, or the download button 500s. + +Separately: each platform declares PARALLEL_UPDATES (HA defaults a coordinator platform to 0, +unlimited); climate is 1 because `set_hvac_mode` reaches `_drive_the_pump`. +""" + +from __future__ import annotations + +import importlib +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +def test_the_integration_can_produce_diagnostics(): + """The hook Home Assistant looks for.""" + diagnostics = pytest.importorskip( + "custom_components.effektguard.diagnostics", + reason="custom_components/effektguard/diagnostics.py does not exist", + ) + + assert hasattr(diagnostics, "async_get_config_entry_diagnostics"), ( + "diagnostics.py exists but does not define async_get_config_entry_diagnostics, which is " + "the entry point Home Assistant calls." + ) + + +@pytest.mark.asyncio +async def test_the_dump_carries_what_the_decision_actually_saw(): + """A bug report about a heat pump has to contain the pump's state and the layer votes.""" + from custom_components.effektguard.diagnostics import async_get_config_entry_diagnostics + + hass, entry = _hass_and_entry() + + dump = await async_get_config_entry_diagnostics(hass, entry) + + decision = dump.get("decision", {}) + assert decision.get("offset") == 1.5, "the offset it commanded must be in the dump" + assert decision.get("reasoning"), "the reasoning must be in the dump" + assert decision.get("layers"), ( + "every layer's vote and weight must be in the dump. An offset without the votes behind it " + "cannot be argued with." + ) + + nibe = dump.get("nibe", {}) + for field in ("degree_minutes", "indoor_temp", "outdoor_temp", "supply_temp", "current_offset"): + assert field in nibe, f"the NIBE state the decision was made from is missing {field!r}" + + assert "dm_thresholds" in dump, ( + "the degree-minute thresholds actually in force must be in the dump. They are computed per " + "climate zone AND per thermal mass, so quoting the constants proves nothing about what " + "this house was being held to." + ) + + sources = dump.get("sources", {}) + assert "price" in sources and "weather" in sources, ( + "whether the price and weather sources were live must be in the dump: a missing price " + "source silently withdraws the entire price layer (F-123), and the offset looks " + "inexplicable without it." + ) + + +@pytest.mark.asyncio +async def test_the_dump_does_not_leak_the_home_location(): + """A diagnostics file is something the owner pastes into a public issue.""" + from custom_components.effektguard.diagnostics import async_get_config_entry_diagnostics + + hass, entry = _hass_and_entry() + hass.config.latitude = 59.3293 + hass.config.longitude = 18.0686 + + dump = await async_get_config_entry_diagnostics(hass, entry) + + flat = repr(dump) + assert "59.3293" not in flat and "18.0686" not in flat, ( + "The diagnostics dump contains the home's latitude/longitude. The decision engine holds " + "the latitude because that is how the climate zone is detected - and this file gets pasted " + "into public issue trackers." + ) + assert "climate_zone" in flat, ( + "Redacting the coordinates must not throw away the useful part: the climate ZONE (Cold, " + "Very Cold...) is what the thresholds derive from, and it identifies nobody." + ) + + +@pytest.mark.parametrize("platform", ["climate", "sensor", "switch"]) +def test_every_platform_declares_how_many_calls_it_will_take_at_once(platform): + """PARALLEL_UPDATES is unset, and climate.set_hvac_mode drives the heat pump.""" + module = importlib.import_module(f"custom_components.effektguard.{platform}") + + assert hasattr(module, "PARALLEL_UPDATES"), ( + f"{platform}.py does not declare PARALLEL_UPDATES. Home Assistant defaults a " + f"coordinator-based integration to 0 - unlimited concurrent entity service calls - and " + f"climate.set_hvac_mode reaches set_optimization_enabled(), which calls " + f"async_refresh_and_apply() and DRIVES THE PUMP." + ) + + +def test_the_entity_that_drives_the_pump_takes_one_call_at_a_time(): + """Belt and braces with the control lock, and honest about what the entity does.""" + from custom_components.effektguard import climate + + assert climate.PARALLEL_UPDATES == 1, ( + "climate.set_hvac_mode drives the heat pump (set_optimization_enabled -> " + "async_refresh_and_apply -> _drive_the_pump). PARALLEL_UPDATES must be 1 so Home Assistant " + "serialises the service calls, rather than 0 (unlimited) which is the coordinator default." + ) + + +def _hass_and_entry() -> tuple[MagicMock, MagicMock]: + """A coordinator that has just made a decision, wired the way the integration wires it.""" + from custom_components.effektguard.adapters.nibe_adapter import NibeState + from custom_components.effektguard.const import DOMAIN + from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector + from custom_components.effektguard.optimization.decision_engine import ( + LayerDecision, + OptimizationDecision, + ) + + nibe = NibeState( + outdoor_temp=-5.0, + indoor_temp=20.8, + supply_temp=38.0, + return_temp=33.0, + degree_minutes=-320.0, + current_offset=1.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 12, 0), + compressor_hz=62, + power_kw=2.4, + ) + + # REAL objects, not mocks: MagicMocks are unserialisable by construction, so the + # serialisability check below needs the types production actually hands the hook. + decision = OptimizationDecision( + offset=1.5, + reasoning="[Z2] DM -320, boost recovery speed | [Comfort] within band", + layers=[LayerDecision(name="Emergency", offset=4.0, weight=0.65, reason="T1 recovery")], + is_emergency=False, + ) + + coordinator = MagicMock() + coordinator.data = { + "nibe": nibe, + "decision": decision, + "price": None, # F-123: no price source -> the whole price layer withdrew + "weather": MagicMock(current_temp=-5.0), + } + coordinator.compressor_risk = "OK" + # The real detector: Stockholm's latitude, so the zone and the band are the ones a real house + # would be held to - and so the redaction has something genuine to redact. + coordinator.engine.climate_detector = ClimateZoneDetector(latitude=59.3293) + # A real string, as the real EmergencyLayer carries: the dump reports the band AFTER the + # thermal-mass adjustment, so it reads this. An auto-MagicMock here would be unserialisable. + coordinator.engine.emergency_layer.heating_type = "radiator" + coordinator.effect.get_monthly_peak_summary.return_value = {"highest": 4.2} + + hass = MagicMock() + hass.config = MagicMock(latitude=59.3293, longitude=18.0686) + + entry = MagicMock() + entry.entry_id = "abc" + entry.data = {"nibe_entity": "number.nibe_offset", "gespot_entity": None} + entry.options = {"target_indoor_temp": 21.0} + + hass.data = {DOMAIN: {entry.entry_id: coordinator}} + return hass, entry + + +@pytest.mark.asyncio +async def test_the_dump_can_actually_be_downloaded(): + """Home Assistant serialises the dump to JSON. If it cannot, the download button 500s. + + A datetime, an enum or a dataclass - anything json.dumps refuses - breaks the download. + NibeState carries a `timestamp` and the degree-minute range comes back from a detector, + so the risk is real. + """ + import json + + from custom_components.effektguard.diagnostics import async_get_config_entry_diagnostics + + hass, entry = _hass_and_entry() + + dump = await async_get_config_entry_diagnostics(hass, entry) + + json.dumps(dump) # raises TypeError on anything Home Assistant could not serve diff --git a/tests/unit/test_you_cannot_ask_for_a_temperature_the_system_will_fight.py b/tests/unit/test_you_cannot_ask_for_a_temperature_the_system_will_fight.py new file mode 100644 index 00000000..ec910803 --- /dev/null +++ b/tests/unit/test_you_cannot_ask_for_a_temperature_the_system_will_fight.py @@ -0,0 +1,146 @@ +"""The thermostat must not offer a setpoint the safety layer will fight. + +MIN_TEMP_LIMIT (18.0 °C) is the absolute floor: below it the safety layer fires MAX_OFFSET as an +emergency. A settable minimum below the floor produces a limit cycle - above 18 °C the comfort +layer reads an overshoot and cuts to MIN_OFFSET, below it safety commands MAX_OFFSET, and every +safety boost is is_emergency=True so it bypasses the volatility blocker. + +The floor is MIN_TARGET_TEMP (one default tolerance above the safety floor), and the engine clamps +any stored target below it up to it - stored options, migration or a hand-edited entry alike. To +move the floor, change MIN_TEMP_LIMIT, not the slider. +""" + +from __future__ import annotations + +import inspect +from datetime import datetime +from unittest.mock import MagicMock + +import pytest + +from custom_components.effektguard import climate as climate_module +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.const import ( + DEFAULT_TOLERANCE, + MAX_OFFSET, + MIN_OFFSET, + MIN_TARGET_TEMP, + MIN_TEMP_LIMIT, +) +from custom_components.effektguard.models.nibe import NibeF750Profile +from custom_components.effektguard.optimization.decision_engine import DecisionEngine +from custom_components.effektguard.optimization.effect_layer import EffectManager +from custom_components.effektguard.optimization.price_layer import PriceAnalyzer +from custom_components.effektguard.optimization.thermal_layer import ThermalModel + + +def _engine(target: float) -> DecisionEngine: + return DecisionEngine( + price_analyzer=PriceAnalyzer(), + effect_manager=EffectManager(MagicMock()), + thermal_model=ThermalModel(0.7, 1.0), + config={ + "target_indoor_temp": target, + "tolerance": 0.5, + "optimization_mode": "balanced", + "latitude": 59.33, + "heating_type": "radiator", + "heat_loss_coefficient": 150.0, + "thermal_mass": 0.7, + "insulation_quality": 1.0, + }, + heat_pump_model=NibeF750Profile(), + ) + + +def _state(indoor: float) -> NibeState: + return NibeState( + outdoor_temp=-5.0, + indoor_temp=indoor, + supply_temp=38.0, + return_temp=33.0, + degree_minutes=-100.0, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=datetime(2026, 1, 15, 12, 0), + compressor_hz=50, + power_kw=2.0, + ) + + +def test_the_thermostat_does_not_offer_a_setpoint_below_the_safety_floor(): + """The slider and the safety layer must agree on the lowest permitted temperature. + + Checked in the source: HA's CachedProperties metaclass turns `_attr_min_temp` into a descriptor, + so reading the class attribute would compare a property to a float, not fail honestly. + """ + # The invariant, not the constant's name: the lowest target the thermostat offers must sit far + # enough above the safety floor that the comfort band around it clears the floor entirely. + assert MIN_TARGET_TEMP >= MIN_TEMP_LIMIT + DEFAULT_TOLERANCE, ( + f"The lowest offered target ({MIN_TARGET_TEMP} °C) does not clear the safety floor " + f"({MIN_TEMP_LIMIT} °C) by a tolerance ({DEFAULT_TOLERANCE} °C). A target sitting AT the " + f"floor puts the lower half of its own comfort band inside the emergency zone: ordinary " + f"control noise then trips a full MAX_OFFSET boost that bypasses the volatility blocker." + ) + + source = inspect.getsource(climate_module) + assert "_attr_min_temp = MIN_TARGET_TEMP" in source, ( + "The climate entity's minimum target must be MIN_TARGET_TEMP - the lowest temperature this " + "system can actually HOLD - rather than a number the safety layer will fight." + ) + + +@pytest.mark.parametrize("indoor", [17.9, 16.0, 15.0]) +def test_a_setpoint_below_the_floor_is_answered_with_an_emergency(indoor): + """Below the safety floor the engine commands MAX_OFFSET, whatever the stored target.""" + decision = _engine(target=15.0).calculate_decision( + nibe_state=_state(indoor), + price_data=None, + weather_data=None, + current_peak=0.0, + current_power=2.0, + ) + + assert decision.is_emergency, f"precondition: {indoor} °C is below MIN_TEMP_LIMIT" + assert decision.offset == MAX_OFFSET, ( + f"With a target of 15 °C and the house at {indoor} °C, the engine commands " + f"{decision.offset:+.2f} - maximum heat - against the user's own setpoint." + ) + + +def test_the_house_is_not_driven_between_the_two_extremes(): + """The limit cycle, in one assertion, exercised against a stored target of 15 °C. + + With a 15 °C target the comfort layer read 19.0 °C as an overshoot and cut to MIN_OFFSET while + safety read 17.9 °C as an emergency and commanded MAX_OFFSET. HA keeps the stored value across + the upgrade, so the ENGINE must clamp the target - not just the slider - to protect existing + owners. + """ + engine = _engine(target=15.0) + + hot = engine.calculate_decision( + nibe_state=_state(19.0), + price_data=None, + weather_data=None, + current_peak=0.0, + current_power=2.0, + ) + cold = engine.calculate_decision( + nibe_state=_state(17.9), + price_data=None, + weather_data=None, + current_peak=0.0, + current_power=2.0, + ) + + # The defect is the COMFORT end, not the span: safety legitimately commands +10 below 18 °C, so a + # span measured from a quiet baseline is ~10 whether healthy or not. Assert the thing that broke: + # the engine must not slam the heat off in a house its own safety layer is about to call cold. + assert hot.offset > MIN_OFFSET / 2, ( + f"With a stored target of 15 °C and the house at 19.0 °C, the engine commands " + f"{hot.offset:+.2f} - it reads the house as badly overheated and slams the heat off. One " + f"degree lower, at 17.9 °C, it commands {cold.offset:+.2f}: the safety layer calls the same " + f"house an emergency. The pump is driven between the extremes for as long as the setpoint " + f"stands. A target the safety layer will fight is not a target." + ) diff --git a/tests/unit/utils/test_a_negative_price_is_still_a_price.py b/tests/unit/utils/test_a_negative_price_is_still_a_price.py new file mode 100644 index 00000000..9dc5ccf2 --- /dev/null +++ b/tests/unit/utils/test_a_negative_price_is_still_a_price.py @@ -0,0 +1,106 @@ +"""`price_savings_fraction` must handle Nordic prices at zero and below. + +The DHW optimizer decides whether to heat now or defer to a cheaper window, and the old arithmetic +broke on both edge cases: `if current_quarter_price` is False at exactly 0.00 (a real price, ~100 +hours/year per SE zone), skipping the whole branch; and dividing by the SIGNED price inverts the +fraction when the current price is negative, so a genuinely cheaper (deeper-negative) window comes +out negative and is declined. The fix divides by the MAGNITUDE, returns 1.0 when current is zero and +a cheaper window exists, and returns None (not 0) when there is no current price - shared by both +call sites that had drifted apart. +""" + +from __future__ import annotations + +import pytest + +from custom_components.effektguard.const import DHW_OPTIMAL_WINDOW_MIN_SAVINGS +from custom_components.effektguard.utils.price_math import price_savings_fraction + + +class TestPricesAtExactlyZero: + """0.00 ore is a real Nordic price, and `if price:` says it is not a price at all.""" + + def test_a_zero_price_is_not_the_same_as_no_price(self): + savings = price_savings_fraction(current=0.0, candidate=-40.0) + + assert savings is not None, ( + "A current price of exactly 0.00 ore was treated as 'no price' - the truthiness test " + "`if current_quarter_price` is False on 0.0 - so the optimizer never even considered " + "deferring the hot water to a window where the grid PAYS 40 ore/kWh to take it. " + "Exactly-zero prices occur about a hundred hours a year per SE bidding zone." + ) + assert savings >= DHW_OPTIMAL_WINDOW_MIN_SAVINGS + + def test_free_now_and_paid_later_is_a_total_saving(self): + """Nothing to divide by. It is still unambiguously worth waiting.""" + assert price_savings_fraction(current=0.0, candidate=-1.0) == 1.0 + + def test_free_now_and_dearer_later_is_no_saving(self): + assert price_savings_fraction(current=0.0, candidate=10.0) is None + + def test_absent_is_not_zero(self): + """`None` means we do not have a price. It must not be read as 'free'.""" + assert price_savings_fraction(current=None, candidate=-40.0) is None + + +class TestNegativePrices: + """The grid pays you. A window that pays MORE is cheaper, and the sign must not flip.""" + + @pytest.mark.parametrize( + ("current", "candidate"), + [ + (-10.0, -60.0), # gave -5.00 + (-50.0, -60.0), # gave -0.20 + (-1.0, -100.0), + ], + ) + def test_a_window_that_pays_more_is_a_saving_not_a_loss(self, current, candidate): + savings = price_savings_fraction(current, candidate) + + assert savings is not None and savings > 0.0, ( + f"With the price at {current} ore and a window at {candidate} ore - where the grid pays " + f"MORE to take the power - the saving came out as {savings}. Dividing by the SIGNED " + f"price inverts the fraction, so a genuinely better window fails the 15 % test and the " + f"hot water is heated now instead." + ) + + def test_the_deeper_negative_window_wins(self): + assert price_savings_fraction(-10.0, -60.0) > price_savings_fraction(-50.0, -60.0) + + def test_a_shallower_negative_window_is_not_a_saving(self): + """current -50, window -10: the grid pays LESS there. Do not defer to it.""" + assert price_savings_fraction(current=-50.0, candidate=-10.0) is None + + def test_crossing_zero_downwards_is_a_saving(self): + assert price_savings_fraction(current=5.0, candidate=-20.0) > 0.0 + + +class TestOrdinaryPositivePrices: + """The regression guard. None of this may change the common case.""" + + def test_a_cheaper_window_is_the_fraction_it_always_was(self): + assert price_savings_fraction(current=50.0, candidate=30.0) == pytest.approx(0.4) + + def test_a_dearer_window_is_never_a_saving(self): + assert price_savings_fraction(current=30.0, candidate=50.0) is None + + def test_an_identical_window_is_never_a_saving(self): + assert price_savings_fraction(current=30.0, candidate=30.0) is None + + +def test_the_sign_of_the_result_only_ever_reflects_which_price_is_lower(): + """The property the signed divisor destroyed, stated once.""" + prices = [-100.0, -50.0, -10.0, 0.0, 10.0, 50.0, 100.0] + + for current in prices: + for candidate in prices: + savings = price_savings_fraction(current, candidate) + if candidate < current: + assert ( + savings is not None and savings > 0.0 + ), f"{candidate} is cheaper than {current} and the saving came out {savings}." + else: + assert savings is None, ( + f"{candidate} is not cheaper than {current}, yet a saving of {savings} was " + f"reported." + ) diff --git a/tests/unit/utils/test_milliwatts_are_not_megawatts.py b/tests/unit/utils/test_milliwatts_are_not_megawatts.py new file mode 100644 index 00000000..b7bbccac --- /dev/null +++ b/tests/unit/utils/test_milliwatts_are_not_megawatts.py @@ -0,0 +1,209 @@ +"""`mW` and `MW` differ only in case, and one is 10^9 times the other, so the unit must NOT be folded. + +HA ships both `UnitOfPower.MILLIWATT` ("mW") and `UnitOfPower.MEGA_WATT` ("MW"); case-folding +collapses them, and the table mapped that key to MEGAWATTS - so a 5000 mW (5 W) sensor read as +5 000 000 kW, persisted as the month's tariff peak. That does not throttle the house: every real +quarter then looks safe against the astronomical threshold, so peak protection is silently disabled +until the month rolls over. `power_kw_from_state` keys the table case-SENSITIVELY, and a second line +of defence (TestTheSecondLineOfDefence) refuses any peak above what a domestic supply can deliver. + +The ambiguity is derived from `UnitOfPower` itself, so a future case-colliding pair fails here. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.const import UnitOfPower + +from custom_components.effektguard.const import POWER_SOURCE_EXTERNAL_METER +from custom_components.effektguard.optimization.effect_layer import EffectManager +from custom_components.effektguard.utils.power import ( + POWER_UNIT_FACTORS_KW, + power_kw_from_state, +) + + +def _sensor(value: str, unit: str | None) -> MagicMock: + state = MagicMock() + state.entity_id = "sensor.house_power" + state.state = value + state.attributes = {"unit_of_measurement": unit} if unit is not None else {} + return state + + +def test_home_assistant_really_does_ship_two_units_that_differ_only_in_case(): + """The precondition. If this ever stops being true, the guard below is guarding nothing.""" + folded = [unit.value.lower() for unit in UnitOfPower] + collisions = {f for f in folded if folded.count(f) > 1} + + assert collisions == {"mw"}, ( + f"Home Assistant's UnitOfPower now case-collides on {collisions or 'nothing'}, not just " + f"{{'mw'}}. Every colliding pair is a silent unit-conversion bug in any code that folds " + f"case before looking a unit up. Check power.py handles each one." + ) + assert UnitOfPower.MILLIWATT.value == "mW" + assert UnitOfPower.MEGA_WATT.value == "MW" + + +def test_a_milliwatt_sensor_is_not_read_as_megawatts(): + """The bug: 5000 mW read as 5 000 000 kW. A factor of 10^9, straight into the billing peak.""" + reading = power_kw_from_state(_sensor("5000", UnitOfPower.MILLIWATT)) + + assert reading == pytest.approx(0.005), ( + f"5000 mW is 5 watts, i.e. 0.005 kW. It was read as {reading} kW. Case-folding the unit " + f"collapses 'mW' onto 'MW' and applies the MEGAWATT factor - a factor of 10^9 - and the " + f"result is classified billable and persisted as the month's tariff peak." + ) + + +def test_a_megawatt_sensor_is_still_read_as_megawatts(): + """The other half of the pair must not be broken by fixing the first.""" + assert power_kw_from_state(_sensor("2", UnitOfPower.MEGA_WATT)) == pytest.approx(2000.0) + + +@pytest.mark.parametrize( + ("value", "unit", "expected_kw"), + [ + ("1500", UnitOfPower.WATT, 1.5), + ("1.5", UnitOfPower.KILO_WATT, 1.5), + ("1500000", UnitOfPower.MILLIWATT, 1.5), + ("0.0015", UnitOfPower.MEGA_WATT, 1.5), + ], +) +def test_every_power_unit_converts_to_the_same_kilowatts(value, unit, expected_kw): + """The same 1.5 kW, spelled four ways. All four must agree.""" + assert power_kw_from_state(_sensor(value, unit)) == pytest.approx(expected_kw) + + +class TestTheSecondLineOfDefence: + """A number persisted for a month gets a plausibility CEILING, the symmetric partner of the + PEAK_RECORDING_MINIMUM floor. Peaks above what a domestic supply can deliver are refused, so a + mis-scaled unit is contained even before the table is fixed. + """ + + @pytest.mark.asyncio + async def test_an_impossible_reading_never_becomes_a_tariff_peak(self): + """5 000 000 kW is not a peak. It is a broken sensor, and it costs a month of protection.""" + manager = EffectManager(MagicMock()) + manager._store = MagicMock() + manager._store.async_save = AsyncMock() + manager._monthly_peaks = [] + + what_the_old_code_produced = 5_000_000.0 # 5000 mW, read as megawatts + + event = await manager.record_period_measurement( + power_kw=what_the_old_code_produced, + period=10, + timestamp=datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc), + source=POWER_SOURCE_EXTERNAL_METER, + ) + + assert event is None and not manager._monthly_peaks, ( + f"{what_the_old_code_produced:,.0f} kW was recorded as this month's tariff peak. No " + f"domestic main fuse can pass it. Once it is in the record, every real quarter looks " + f"safe against it - the effect layer reports 'Safe margin: 4999994 kW below peak' on a " + f"6 kW January cold snap - so peak protection goes quiet until the month rolls over " + f"and the owner blows the real peak the feature exists to prevent." + ) + + @pytest.mark.asyncio + async def test_peak_protection_still_works_after_the_refusal(self): + """The point of refusing it: the month is not written off.""" + manager = EffectManager(MagicMock()) + manager._store = MagicMock() + manager._store.async_save = AsyncMock() + manager._monthly_peaks = [] + + await manager.record_period_measurement( + power_kw=5_000_000.0, + period=10, + timestamp=datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc), + source=POWER_SOURCE_EXTERNAL_METER, + ) + # A real quarter, after the bad one. + await manager.record_period_measurement( + power_kw=6.0, + period=10, + timestamp=datetime(2026, 1, 15, 10, 15, tzinfo=timezone.utc), + source=POWER_SOURCE_EXTERNAL_METER, + ) + + assert manager.get_monthly_peak_summary()["highest"] == pytest.approx(6.0), ( + "the real 6 kW quarter must be the month's peak - the impossible one was refused, so " + "it cannot be sitting above it making everything else look safe" + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("power_kw", [6.0, 17.0, 24.0, 99.0]) + async def test_every_power_a_real_house_can_draw_is_still_recorded(self, power_kw): + """The ceiling must never refuse a real house. 25 A three-phase is 17 kW; 35 A is 24 kW.""" + manager = EffectManager(MagicMock()) + manager._store = MagicMock() + manager._store.async_save = AsyncMock() + manager._monthly_peaks = [] + + event = await manager.record_period_measurement( + power_kw=power_kw, + period=10, + timestamp=datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc), + source=POWER_SOURCE_EXTERNAL_METER, + ) + + assert event is not None, ( + f"{power_kw} kW was refused as implausible. A large Swedish villa on a 35 A service " + f"with an EV charging draws 24 kW, and the ceiling exists to catch unit errors, not " + f"customers." + ) + + +def test_the_canonical_units_are_keyed_case_sensitively(): + """A regression guard on the TABLE, not just its outputs. + + If someone re-lowercases these keys the conversions above still pass for exact-cased units - the + bug only bites the ambiguous pair. So the table's own shape is pinned. + """ + assert "mW" in POWER_UNIT_FACTORS_KW and "MW" in POWER_UNIT_FACTORS_KW + assert POWER_UNIT_FACTORS_KW["mW"] < POWER_UNIT_FACTORS_KW["MW"] + assert POWER_UNIT_FACTORS_KW["MW"] / POWER_UNIT_FACTORS_KW["mW"] == pytest.approx(1e9) + + +class TestForgivingWhereItIsSafeToBe: + """A hand-written template sensor may not match HA's capitalisation. That much is fine.""" + + @pytest.mark.parametrize("unit", ["w", "W", "kw", "kW", "KW"]) + def test_unambiguous_case_variants_are_accepted(self, unit): + """Refusing "kw" would break working installations and buy no safety.""" + assert power_kw_from_state(_sensor("1000", unit)) is not None + + @pytest.mark.parametrize("unit", ["mw", "Mw", "MW ", " mW"]) + def test_an_ambiguous_spelling_is_refused_rather_than_guessed(self, unit): + """`mw` is BOTH milliwatts and megawatts. There is no safe guess, so there is no guess. + + Note ' mW' and 'MW ' are stripped first and then match exactly - those are fine. The ones + that must be refused are the ones whose case does not identify the unit. + """ + result = power_kw_from_state(_sensor("1000", unit)) + + if unit.strip() in POWER_UNIT_FACTORS_KW: + assert result is not None, "an exactly-spelled unit must still work after stripping" + else: + assert result is None, ( + f"A sensor reporting {unit!r} was converted to {result} kW. That spelling is both " + f"milliwatts and megawatts - a factor of 10^9 - and this reading decides whether " + f"the house is about to set a monthly billing peak. Refuse, do not guess." + ) + + +class TestRefusalIsStillRefusal: + """The original contract must survive the fix.""" + + @pytest.mark.parametrize("unit", [None, "", "kWh", "Wh", "%", "°C", "A"]) + def test_a_non_power_unit_is_refused(self, unit): + assert power_kw_from_state(_sensor("1234", unit)) is None + + def test_a_non_numeric_reading_is_refused(self): + assert power_kw_from_state(_sensor("unavailable", UnitOfPower.WATT)) is None + assert power_kw_from_state(_sensor("banana", UnitOfPower.WATT)) is None diff --git a/tests/unit/utils/test_the_pump_does_what_the_engine_asked.py b/tests/unit/utils/test_the_pump_does_what_the_engine_asked.py new file mode 100644 index 00000000..783eda17 --- /dev/null +++ b/tests/unit/utils/test_the_pump_does_what_the_engine_asked.py @@ -0,0 +1,95 @@ +"""`integer_offset_for` must ROUND, not truncate, when bridging fractional offsets to NIBE's +integer register. + +`int(-1.9)` is `-1`: truncation toward zero made every offset come out smaller than the engine +asked, always in the same direction, permanently (the residual was never re-applied). Since this is +the last thing to touch the number before the pump, that bias silently attenuates every decision and +every tuned constant. Rounding bounds the error at 0.5 C and makes it unbiased. The 1 C deadband is +deliberate hysteresis (MyUplink is rate-limited), and the value is clamped to the register range. +Shared with the simulation harness so the plant model and the code cannot drift. +""" + +from __future__ import annotations + +import pytest + +from custom_components.effektguard.const import ( + MAX_OFFSET, + MIN_OFFSET, + NIBE_FRACTIONAL_ACCUMULATOR_THRESHOLD, +) +from custom_components.effektguard.utils.offset import integer_offset_for + + +class TestTheBiasIsGone: + """The whole point: the error must not always point the same way.""" + + @pytest.mark.parametrize( + ("demand", "expected"), + [ + (-1.9, -2), # int() gave -1 + (-2.7, -3), # int() gave -2 + (+1.9, +2), # int() gave +1 + (+2.7, +3), # int() gave +2 + (-1.4, -1), + (+1.4, +1), + ], + ) + def test_the_offset_is_rounded_not_truncated(self, demand, expected): + applied = integer_offset_for(demand, current=0) + + assert applied == expected, ( + f"The engine asked for {demand:+.1f} C and the pump was given {applied:+d} C. " + f"int({demand}) is {int(demand)} - Python truncates toward zero - so the pump always " + f"did LESS than it was told, in the same direction, permanently." + ) + + def test_the_error_is_symmetric_around_zero(self): + """A biased quantiser silently retunes every constant in const.py.""" + for magnitude in (1.1, 1.5, 1.9, 2.3, 2.5, 2.9, 3.4): + up = integer_offset_for(+magnitude, current=0) + down = integer_offset_for(-magnitude, current=0) + assert up == -down, ( + f"A demand of +{magnitude} became {up:+d} but -{magnitude} became {down:+d}. " + f"The quantiser must not prefer one direction." + ) + + def test_the_residual_error_never_exceeds_half_a_degree(self): + """The best an integer register can do. Truncation gave up to a full degree.""" + for demand in [x / 10 for x in range(-100, 101)]: + applied = integer_offset_for(demand, current=0) + if applied != 0: # outside the deadband + assert abs(demand - applied) <= 0.5 + 1e-9, ( + f"demand {demand:+.1f} -> {applied:+d}, an error of " + f"{abs(demand - applied):.2f} C" + ) + + +class TestTheDeadbandIsDeliberate: + """Hysteresis, not arithmetic. It stops the register churning; do not remove it by accident.""" + + def test_a_demand_that_has_barely_moved_does_not_rewrite_the_register(self): + assert integer_offset_for(-2.4, current=-2) == -2 + assert integer_offset_for(+0.9, current=0) == 0 + + def test_the_threshold_is_a_whole_degree(self): + assert NIBE_FRACTIONAL_ACCUMULATOR_THRESHOLD == 1.0 + assert integer_offset_for(-0.99, current=0) == 0 + assert integer_offset_for(-1.0, current=0) == -1 + + def test_it_settles_rather_than_oscillating(self): + """Apply the same demand repeatedly: the register must reach a value and stay there.""" + demand = -1.9 + current = 0 + seen = [] + for _ in range(10): + current = integer_offset_for(demand, current) + seen.append(current) + + assert seen[-3:] == [-2, -2, -2], f"the register never settled: {seen}" + + +class TestTheRegisterCannotBeOverrun: + def test_the_offset_is_clamped_to_what_the_register_can_hold(self): + assert integer_offset_for(-50.0, current=0) == MIN_OFFSET + assert integer_offset_for(+50.0, current=0) == MAX_OFFSET diff --git a/tests/validation/test_a_saturated_compressor_is_a_positive_feedback_trap.py b/tests/validation/test_a_saturated_compressor_is_a_positive_feedback_trap.py new file mode 100644 index 00000000..f9c9ff75 --- /dev/null +++ b/tests/validation/test_a_saturated_compressor_is_a_positive_feedback_trap.py @@ -0,0 +1,87 @@ +"""KNOWN DEFECT, RECORDED NOT FIXED. F-124 is BLOCKED-ON-OWNER in the audit. + +`DM = integral(BT25 - S1)`. Raising the curve offset raises S1 instantly; BT25 - the water the +pump actually makes - can only follow if the compressor has headroom. A SATURATED compressor has +none, so raising the offset widens the gap, DM falls FASTER, the emergency layer sees them falling +and raises the offset again: a positive feedback loop. The unit test below proves the mechanism - +handed a pump at maximum flow, a house ABOVE target and DM at the integrator floor, the emergency +layer still commands +10. + +On every machine that saturates, the optimiser buys MORE resistive heat than the capacity deficit +forces (1.2-1.7x on datasheet-sized systems). It does NOT cook a correctly-sized house: the pump's +own start addition arms the elpatron first (F750 -700, S-series -460, VVM 320 -760, see +test_the_plant_engages_aux_where_the_pump_does.py) and holds the house, while the controller wastes +money fighting a wall. + +WHY NOT FIXED HERE: the EMERGENCY tier deliberately bypasses the anti-windup written for this +failure mode. Changing it means deciding what a pump should do when it physically cannot meet its +own curve - a heat-pump decision, not a code cleanup. The xfail is STRICT: fix the defect and the +suite goes RED, forcing whoever fixes it to come here and delete the marker. +""" + +from __future__ import annotations + +import pytest + +from custom_components.effektguard.const import ( + DM_THRESHOLD_AUX_LIMIT, + MAX_OFFSET, + SAFETY_EMERGENCY_OFFSET, +) +from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector +from custom_components.effektguard.optimization.thermal_layer import EmergencyLayer + + +def test_the_emergency_tier_asks_for_maximum_heat_at_the_aux_limit(): + """The precondition, and it is not itself wrong - it is what a healthy pump needs.""" + assert SAFETY_EMERGENCY_OFFSET == MAX_OFFSET + + +@pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason=( + "F-124, BLOCKED-ON-OWNER. A saturated compressor cannot raise BT25, so raising S1 makes " + "DM = integral(BT25 - S1) fall FASTER. The emergency layer answers by raising it again and " + "latches at +10. Every machine that saturates is made worse by it: the optimiser burns " + "1.2-1.7x the resistive heat the capacity deficit physically forces. Fixing it means " + "deciding what a pump should do when it physically cannot meet its own curve - a heat-pump " + "decision, not a code-cleanup one." + ), +) +def test_the_emergency_layer_does_not_keep_raising_a_pump_that_has_nothing_left(): + """When the pump is saturated, MORE offset is not more heat - it is only more debt. + + The pump has been at maximum flow for hours and degree minutes are still collapsing. That is + the signature of saturation: the offset is not being converted into heat. Commanding more of it + cannot help, and it demonstrably harms. + """ + layer = EmergencyLayer(climate_detector=ClimateZoneDetector(latitude=59.33)) + + class _SaturatedPump: + outdoor_temp = -25.0 + indoor_temp = 22.9 # already ABOVE target - the immersion heater is cooking the house + supply_temp = 63.0 # the pump is flat out and cannot go higher + degree_minutes = -3000.0 # the integrator floor + current_offset = float(MAX_OFFSET) # already asking for everything it can ask for + is_heating = True + is_hot_water = False + + # raises=AssertionError on the marker ensures this xfails on the ASSERTION below, not on some + # unrelated TypeError that would silently impersonate the expected failure. + decision = layer.evaluate_layer( + _SaturatedPump(), + weather_data=None, + price_data=None, + target_temp=21.0, + tolerance_range=1.0, + ) + + assert decision.offset < SAFETY_EMERGENCY_OFFSET, ( + f"The pump is at maximum flow ({_SaturatedPump.supply_temp} C), already commanded to " + f"{_SaturatedPump.current_offset:+.0f}, the house is at {_SaturatedPump.indoor_temp} C - " + f"ABOVE target, on immersion heat - and degree minutes are at the integrator floor. The " + f"emergency layer still asks for {decision.offset:+.1f}. Raising the offset raises S1, " + f"which a saturated pump cannot follow, so DM falls faster still. This is the spiral, and " + f"the aux limit ({DM_THRESHOLD_AUX_LIMIT}) is long behind us." + ) diff --git a/tests/validation/test_climate_zones_doc_matches_the_code.py b/tests/validation/test_climate_zones_doc_matches_the_code.py new file mode 100644 index 00000000..64339851 --- /dev/null +++ b/tests/validation/test_climate_zones_doc_matches_the_code.py @@ -0,0 +1,105 @@ +"""The document a maintainer opens to ask "what DM is normal here?" must match the code. + +`docs/CLIMATE_ZONES.md` is the reference for the most safety-critical question in the project: at a +given zone and outdoor temperature, what degree-minute range is normal. Every one of its DM rows +must be exactly what ClimateZoneDetector computes - a good number attached to the wrong outdoor +temperature is still a wrong claim, and that is how the tables drifted (a Cold-zone winter average +of -8.0 C, once quoted as -10.0, moves every threshold derived from it). + +So this parses the DM tables straight out of the markdown and asks the real detector what it would +say. A maintainer who tunes a threshold in const.py and leaves the document behind gets a failing +test naming the row. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector + +DOC = Path(__file__).resolve().parents[2] / "docs" / "CLIMATE_ZONES.md" + +# A latitude that lands squarely inside each zone, to ask the detector with. +ZONE_LATITUDE = { + "Extreme Cold": 67.86, # Kiruna + "Very Cold": 65.58, # Luleå + "Cold": 59.33, # Stockholm + "Moderate Cold": 55.60, # Malmö + "Standard": 48.86, # Paris +} + +ROW = re.compile(r"^\|\s*(-?\d+)°C\s*\|\s*(-?\d+)\s+to\s+(-?\d+)\s*\|\s*(-?\d+)\s*\|", re.M) + + +def _documented_rows() -> list[tuple[str, int, int, int, int]]: + """Every DM row in the document, tagged with the zone whose section it sits in.""" + text = DOC.read_text(encoding="utf-8") + rows: list[tuple[str, int, int, int, int]] = [] + zone: str | None = None + + for line in text.splitlines(): + heading = re.match(r"^###\s+\S*\s*(.+?)\s+Zone\b", line) + if heading: + zone = heading.group(1).strip() + continue + match = ROW.match(line) + if match and zone in ZONE_LATITUDE: + outdoor, low, high, warning = (int(g) for g in match.groups()) + rows.append((zone, outdoor, low, high, warning)) + + return rows + + +def test_the_document_actually_has_tables_to_check(): + """A parser that silently matches nothing would make every assertion below vacuous.""" + rows = _documented_rows() + + assert len(rows) >= 17, ( + f"Only {len(rows)} DM rows were parsed out of {DOC.name}. The tables were reformatted or " + f"removed, and this test has quietly stopped checking anything." + ) + assert {zone for zone, *_ in rows} == set( + ZONE_LATITUDE + ), "Every climate zone must have a DM table in the document." + + +@pytest.mark.parametrize("zone,outdoor,low,high,warning", _documented_rows()) +def test_each_documented_dm_row_is_what_the_code_computes(zone, outdoor, low, high, warning): + """The number a maintainer reads must be the number the heat pump gets.""" + detector = ClimateZoneDetector(latitude=ZONE_LATITUDE[zone]) + actual = detector.get_expected_dm_range(float(outdoor)) + + documented = (low, high, warning) + computed = ( + round(actual["normal_min"]), + round(actual["normal_max"]), + round(actual["warning"]), + ) + + assert documented == computed, ( + f"{zone} at {outdoor}°C: the document says normal {low} to {high}, warning {warning}. " + f"ClimateZoneDetector actually gives normal {computed[0]} to {computed[1]}, warning " + f"{computed[2]}. This is the table a maintainer consults to decide whether a degree-minute " + f"reading is safe." + ) + + +def test_the_adjustment_formula_is_stated_with_the_right_sign(): + """The document must state the adjustment in the direction the code computes it. + + `adjustment = (outdoor_temp - zone_avg_winter_low) x 20`: colder than the zone average is a + NEGATIVE delta and a DEEPER threshold. The inverted form yields the opposite sign. + """ + text = DOC.read_text(encoding="utf-8") + + assert "(outdoor_temp - zone_avg_winter_low)" in text, ( + "CLIMATE_ZONES.md must state the adjustment formula in the direction the code computes it: " + "adjustment = (outdoor_temp - zone_avg_winter_low) × 20. Colder than the zone average is a " + "NEGATIVE delta and a DEEPER threshold." + ) + assert ( + "(zone_avg_winter_low - outdoor_temp)" not in text + ), "The inverted form of the formula is back in the document." diff --git a/tests/validation/test_emitter_law_matches_openenergymonitor.py b/tests/validation/test_emitter_law_matches_openenergymonitor.py new file mode 100644 index 00000000..19278bfc --- /dev/null +++ b/tests/validation/test_emitter_law_matches_openenergymonitor.py @@ -0,0 +1,282 @@ +"""Our flow-temperature curve is checked against OpenEnergyMonitor's, not against our own opinion. + +The emitter law decides how hot the water must be at every outdoor temperature, forever; if it is +wrong, everything downstream quietly holds the house at the wrong temperature and calls it +optimisation. So it is pinned to a published, independent implementation - OpenEnergyMonitor's +weather-compensation tool (github.com/openenergymonitor/tools, www/tools/weathercomp/weathercomp.js): + + let HTC = heat_loss / (room_temperature - design_outsideT); + let heat_demand = HTC * (room_temperature - outsideT); + let DT = Math.pow((heat_demand / rated_emitter_output_dt50), 1 / 1.3) * 50; + let flowT = room_temperature + DT + (systemDT * 0.5); + +Two facts the tests below pin, because this project got one wrong and worried needlessly about the +other: + + * The spread is CONSTANT (`systemDT * 0.5`, never `* phi`). A heat pump modulates its circulator + to hold the commissioned spread; scaling it pivots the curve invisibly on the design point. + * Internal gains are WATTS over W/K, never fitted to a curve. WeatherComp has no gains term and is + the outlier - its authors' own SCOP tool carries the naive formula commented out. So WeatherComp + checks the EMITTER LAW only (the `^(1/1.3)` part), demand held identical on both sides, gains off. + +The gains term is NOT checked against NIBE's curve 9, because it cannot be: the constant-spread and +balance-point terms are the same basis function with opposite signs (any assumed spread manufactures +a matching "gains" figure, even from a curve with zero gains), and curve 9 is a straight line to +0.19 C, which cannot resolve curvature. Two tests below prove both. +""" + +from __future__ import annotations + +import pytest + +from custom_components.effektguard.const import ( + DEFAULT_DESIGN_SPREAD, + DEFAULT_HEAT_LOSS_COEFFICIENT, + INTERNAL_GAINS_W, +) +from custom_components.effektguard.utils.emitter import en442_flow_temp + +# OpenEnergyMonitor weathercomp.js defaults, verbatim from the source. +OEM_HEAT_LOSS_KW = 3.0 +OEM_RATED_EMITTER_DT50_KW = 15.0 +OEM_ROOM_TEMP = 20.0 +OEM_DESIGN_OUTDOOR = -3.0 +OEM_SYSTEM_DT = 5.0 +OEM_EXPONENT = 1.3 + + +def oem_weathercomp_flow_temp(outdoor: float) -> float: + """weathercomp.js, transliterated line for line. This is the reference, not our code.""" + htc = OEM_HEAT_LOSS_KW / (OEM_ROOM_TEMP - OEM_DESIGN_OUTDOOR) + heat_demand = htc * (OEM_ROOM_TEMP - outdoor) + delta_t = (heat_demand / OEM_RATED_EMITTER_DT50_KW) ** (1 / OEM_EXPONENT) * 50 + mean_water_temp = OEM_ROOM_TEMP + delta_t + return mean_water_temp + (OEM_SYSTEM_DT * 0.5) + + +OEM_DESIGN_FLOW = oem_weathercomp_flow_temp(OEM_DESIGN_OUTDOOR) + + +def ours(outdoor: float) -> float: + """Our law with gains switched OFF, matching weathercomp.js, which has no gains term. + + WeatherComp checks the EMITTER LAW only; the demand model is held identical on both sides. + """ + return en442_flow_temp( + indoor_setpoint=OEM_ROOM_TEMP, + outdoor_temp=outdoor, + design_outdoor_temp=OEM_DESIGN_OUTDOOR, + design_flow_temp=OEM_DESIGN_FLOW, + design_spread=OEM_SYSTEM_DT, + emitter_exponent=OEM_EXPONENT, + balance_point_temp=OEM_ROOM_TEMP, # no gains, matching weathercomp.js + ) + + +@pytest.mark.parametrize( + "outdoor", [15.0, 12.0, 8.0, 5.0, 2.0, 0.0, -3.0, -6.0, -10.0, -15.0, -20.0] +) +def test_our_curve_is_openenergymonitors_curve(outdoor): + """Across the whole Nordic range, to a hundredth of a degree.""" + reference = oem_weathercomp_flow_temp(outdoor) + mine = ours(outdoor) + + assert mine == pytest.approx(reference, abs=0.01), ( + f"At {outdoor:+.1f} C outdoor, OpenEnergyMonitor's weather-compensation tool asks for " + f"{reference:.2f} C of flow and we ask for {mine:.2f} C - a gap of {mine - reference:+.2f} C. " + f"Their tool is public, published and independently used; ours drives a real heat pump. " + f"Where they disagree, the burden is on us." + ) + + +def test_the_error_a_scaled_spread_produces_is_not_symmetric(): + """Why the old bug hid: it was zero exactly where anyone would have checked it. + + Scaling the spread with load pivots the whole curve about the design point. At the design point + the error is exactly zero, which is where a sanity check naturally looks - and it grows in both + directions from there, cooling the house in mild weather and cooking it in cold. + """ + room, design_out, spread = OEM_ROOM_TEMP, OEM_DESIGN_OUTDOOR, OEM_SYSTEM_DT + + def with_scaled_spread(outdoor: float) -> float: + phi = (room - outdoor) / (room - design_out) + excess = (OEM_DESIGN_FLOW - spread / 2 - room) * phi ** (1 / OEM_EXPONENT) + return room + excess + (spread * phi) / 2 + + assert with_scaled_spread(design_out) == pytest.approx( + ours(design_out), abs=0.01 + ), "precondition: at the design point the old bug is invisible" + assert ( + with_scaled_spread(12.0) < ours(12.0) - 1.0 + ), "mild weather: the old model ran the house cool" + assert with_scaled_spread(-12.0) > ours(-12.0) + 0.5, "cold weather: the old model ran it hot" + + +def test_the_vaillant_heat_curve_is_the_same_law(): + """Kuhne's formula and ours are one model. Neither is a rival to the other. + + HC is not a heat loss coefficient - it is Vaillant's dimensionless curve number, 0.1 to 4.0, + defaulting to 0.6 for a heat pump. It is obtained by INVERTING the formula at the design point, + which is the same information our design_flow_temp carries. Protons for Breakfast works the + example: 45 C of flow needed at -5 C outdoor for a 20 C room gives heat curve 0.75. + """ + room, design_out, design_flow = 20.0, -5.0, 45.0 + + hc = ((design_flow - room) / 2.55) ** (1 / 0.78) / (room - design_out) + assert hc == pytest.approx(0.75, abs=0.01), ( + f"Inverting Kuhne at the published worked example gives HC {hc:.3f}, not the 0.75 that " + f"Protons for Breakfast reports. If this fails, our reading of the formula is wrong." + ) + + def kuhne(outdoor: float) -> float: + return 2.55 * (hc * (room - outdoor)) ** 0.78 + room + + for outdoor in (10.0, 5.0, 0.0, -5.0, -10.0, -15.0): + theirs = kuhne(outdoor) + mine = en442_flow_temp( + indoor_setpoint=room, + outdoor_temp=outdoor, + design_outdoor_temp=design_out, + design_flow_temp=design_flow, + design_spread=5.0, + emitter_exponent=1.3, + ) + assert mine == pytest.approx(theirs, abs=2.0), ( + f"At {outdoor:+.1f} C, Vaillant's curve (via Kuhne) wants {theirs:.1f} C and we want " + f"{mine:.1f} C. These are supposed to be the same physics; a real divergence here means " + f"one of us has the emitter law wrong." + ) + + +# NIBE's own published heating curve 9, digitised. Room 21 C, operating spread 5 K. +NIBE_CURVE_9 = {-15.0: 52.6, -10.0: 48.6, -5.0: 44.9, 0.0: 41.0, 5.0: 36.9, 10.0: 32.5} + + +def _rms_against_nibe(balance_point: float, spread: float) -> float: + """RMS error of our curve against NIBE's curve 9, anchored at its -15 C end.""" + room, dut = 21.0, -15.0 + errors = [ + en442_flow_temp( + indoor_setpoint=room, + outdoor_temp=outdoor, + design_outdoor_temp=dut, + design_flow_temp=NIBE_CURVE_9[dut], + design_spread=spread, + emitter_exponent=1.3, + balance_point_temp=balance_point, + ) + - nibe + for outdoor, nibe in NIBE_CURVE_9.items() + ] + return (sum(e * e for e in errors) / len(errors)) ** 0.5 + + +def test_nibes_published_curve_is_a_straight_line_and_validates_nothing(): + """NIBE's curve cannot be used as evidence for our law, and this is why. + + Fit a straight line to its six digitised points and the residual is 0.19 C: they ARE a straight + line. Their successive slopes even wobble non-monotonically, steepening toward WARM in the middle + of the range - digitisation noise, larger than the curvature anyone was trying to detect. + Collinear points confirm every model fitted to them, so curve 9 cannot tell the emitter law from + a ruler, nor resolve a balance point. NIBE interpolates its curves linearly; we follow EN 442, + and the gap between them is THE TRIM - the whole reason this layer exists. + """ + ts = sorted(NIBE_CURVE_9) + n = len(ts) + sx, sy = sum(ts), sum(NIBE_CURVE_9[t] for t in ts) + sxx = sum(t * t for t in ts) + sxy = sum(t * NIBE_CURVE_9[t] for t in ts) + slope = (n * sxy - sx * sy) / (n * sxx - sx * sx) + intercept = (sy - sx * slope) / n + linear_rms = (sum((slope * t + intercept - NIBE_CURVE_9[t]) ** 2 for t in ts) / n) ** 0.5 + + assert linear_rms < 0.25, ( + f"NIBE's published curve 9 now departs from a straight line by {linear_rms:.2f} C RMS. If " + f"it has become genuinely curved, it could finally discriminate between emitter models - " + f"and this whole test, plus the reasoning in const.py about why gains cannot be fitted to " + f"it, would want revisiting." + ) + + step_slopes = [(NIBE_CURVE_9[b] - NIBE_CURVE_9[a]) / (b - a) for a, b in zip(ts, ts[1:])] + assert step_slopes != sorted(step_slopes, reverse=True), ( + "Curve 9's slopes have become monotonic in the direction a real emitter law predicts. That " + "would make it evidence rather than noise; re-examine this test before trusting it." + ) + + +def test_our_curve_stays_within_sight_of_nibes(): + """A sanity BOUND, not a validation. We trim NIBE's curve; we must not fight it. + + The emitter law and NIBE's linear interpolation genuinely disagree - that disagreement is the + correction this layer is for. But a trim that wandered degrees away from the pump's own curve + would mean one of the two is broken, and `WEATHER_COMP_MAX_OFFSET` (3.0 C) would then be + clipping every decision. This keeps us honest without pretending curve 9 proves anything. + """ + balance = 21.0 - INTERNAL_GAINS_W / DEFAULT_HEAT_LOSS_COEFFICIENT + rms = _rms_against_nibe(balance, DEFAULT_DESIGN_SPREAD) + + assert rms < 1.0, ( + f"Our flow-temperature curve now sits {rms:.2f} C RMS from NIBE's own published curve 9. " + f"We are supposed to be trimming that curve, not replacing it. A gap this size means the " + f"design point, the spread or the gains are misconfigured - and every offset we emit would " + f"be a correction toward our own error." + ) + + +def test_a_curve_fit_cannot_measure_internal_gains(): + """The trap that produced the wrong constant, nailed down so nobody walks into it again. + + Fitting the balance point against a heating curve is DEGENERATE: + + a constant spread LIFTS the curve by (spread / 2) * (1 - phi ** (1/n)) + a balance point DROPS the curve by a term of the same shape, opposite sign + + Both are zero at the design point and grow in mild weather - the same basis function - so + whatever spread you assume, the fit hands you a "gains" figure that absorbs it, even when the + curve contains no gains AT ALL. Proof, run here: fit our law to Kuhne's Vaillant curve (a pure + power law with PROVABLY ZERO gains) and a balance point appears anyway, tracking the assumed + spread. Gains are WATTS over the house's W/K, never degrees off a fit. + """ + room, dut = 20.0, -15.0 + hc = 0.75 # Vaillant curve number, Protons for Breakfast's worked example + + def kuhne(outdoor: float) -> float: + return 2.55 * (hc * (room - outdoor)) ** 0.78 + room + + def best_fit_offset(assumed_spread: float) -> float: + """The balance-point offset a fitter would 'discover' in a curve that has none.""" + probes = [-15.0, -10.0, -5.0, 0.0, 5.0, 10.0] + + def rms(offset: float) -> float: + errs = [ + en442_flow_temp( + indoor_setpoint=room, + outdoor_temp=t, + design_outdoor_temp=dut, + design_flow_temp=kuhne(dut), + design_spread=assumed_spread, + emitter_exponent=1.3, + balance_point_temp=room - offset, + ) + - kuhne(t) + for t in probes + ] + return (sum(e * e for e in errs) / len(errs)) ** 0.5 + + return min((n / 10.0 for n in range(0, 90)), key=rms) + + near_zero = best_fit_offset(0.01) + at_five = best_fit_offset(5.0) + at_ten = best_fit_offset(10.0) + + assert near_zero < 1.0, ( + f"With no spread to absorb, fitting a zero-gains curve should recover ~zero gains; it " + f"recovered {near_zero:.1f} K. If this fails the degeneracy argument itself is wrong." + ) + assert at_five > near_zero + 1.5 and at_ten > at_five + 1.5, ( + f"The 'gains' a curve fit reports must track the spread it was given - that is what makes " + f"the fit worthless as evidence. Got {near_zero:.1f} K / {at_five:.1f} K / {at_ten:.1f} K " + f"for spreads of 0 / 5 / 10 K. If they no longer diverge, the two terms have stopped being " + f"degenerate and the balance point could legitimately be fitted after all - which would be " + f"news, and would want a very careful look before anyone acts on it." + ) diff --git a/tests/validation/test_every_simulator_constant_says_where_it_came_from.py b/tests/validation/test_every_simulator_constant_says_where_it_came_from.py new file mode 100644 index 00000000..59d130e2 --- /dev/null +++ b/tests/validation/test_every_simulator_constant_says_where_it_came_from.py @@ -0,0 +1,182 @@ +"""Every number in the plant model must say where it came from. + +The pump profiles once carried an outdoor-keyed COP curve labelled "Real-world COP curve (tested +and validated)" and sourced to "NIBE F750 datasheet, Swedish NIBE forum validation" - a template +with the digits nudged, whose numbers were in neither. A plain number with a confident comment is +indistinguishable from a measurement until someone checks, and for a year nobody did. + +So every physical constant in the harness is declared as exactly one of two things: + + SOURCED - a document, quoted, that a reader can go and open. + ASSUMED - no published source exists; then the sensitivity MUST be measured and stated. If + the conclusions move when the number moves, the number is load-bearing and the + conclusions are not trustworthy. + +An ASSUMED constant is not a sin. An UNDECLARED one is. Loop counters, unit conversions and the +harness's own reporting budgets are not physical claims and are listed in NOT_A_PHYSICAL_CLAIM. +""" + +from __future__ import annotations + +import ast +import pathlib +import re + +import pytest + +HARNESS = pathlib.Path("scripts/simulation/sim_harness.py") + +# Names that are not physical claims: loop counters, unit conversions, and the harness's own +# reporting budgets. They do not describe a heat pump, a house or a tariff, so there is nothing to +# source. Anything else must be in PROVENANCE. +NOT_A_PHYSICAL_CLAIM = frozenset( + { + "STEP_MIN", + "SIM_DAYS", + "DST_SIM_DAYS", + "QUARTER_MINUTES", + "J_PER_KWH", + "KELVIN", + "ORE_PER_KWH_FROM_SEK_PER_MWH", + "EXERGY_FIT_PARAMETERS", + # The harness's own pass/fail budgets. They are what the SIMULATION demands of the + # controller, not claims about hardware, and each is argued where it is defined. + "WATER_NODE_LEAK_BUDGET_KWH", + "COP_ENVELOPE_TOLERANCE", + "AUX_OVER_PHYSICS_TOLERANCE", + "AUX_SLACK_KWH", + "DM_AUX_MARGIN", + "MAX_COMFORT_MINUTES_BELOW", + "MAX_COMFORT_MINUTES_ABOVE", + "INDOOR_CEILING", + "COMFORT_TOLERANCE", + "OVERSHOOT_TOLERANCE", + "DM_INTEGRATOR_FLOOR", + "DM_INTEGRATOR_CEILING", + "MIN_EXERGY_EFFICIENCY", + "MAX_EXERGY_EFFICIENCY", + "MIN_LIFT_K", + # The reference battery controller: a comparison strategy, not a model of anything. + "BATTERY_BAND", + "BATTERY_CHARGE_OFFSET", + "BATTERY_COAST_OFFSET", + "BATTERY_CHEAP_PERCENTILE", + "BATTERY_DEAR_PERCENTILE", + "TARGET_INDOOR", + "TOMORROW_VISIBLE_HOUR", + } +) + + +def _module_constants() -> dict[str, float]: + """Every module-level numeric constant the harness defines.""" + tree = ast.parse(HARNESS.read_text(encoding="utf-8")) + found: dict[str, float] = {} + for node in tree.body: + if not isinstance(node, ast.Assign) or len(node.targets) != 1: + continue + target = node.targets[0] + if not isinstance(target, ast.Name) or not target.id.isupper(): + continue + value = node.value + if isinstance(value, ast.Constant) and isinstance(value.value, (int, float)): + found[target.id] = float(value.value) + elif ( + isinstance(value, ast.UnaryOp) + and isinstance(value.op, ast.USub) + and isinstance(value.operand, ast.Constant) + ): + found[target.id] = -float(value.operand.value) + return found + + +def _provenance() -> dict[str, str]: + """The PROVENANCE table the harness declares.""" + tree = ast.parse(HARNESS.read_text(encoding="utf-8")) + for node in tree.body: + # `PROVENANCE: dict[str, str] = {...}` is an AnnAssign, not an Assign - handle both, or a + # walker that looks only for Assign finds nothing and reports every constant as undeclared. + if isinstance(node, ast.AnnAssign) and getattr(node.target, "id", "") == "PROVENANCE": + target = node.value + elif isinstance(node, ast.Assign) and getattr(node.targets[0], "id", "") == "PROVENANCE": + target = node.value + else: + continue + if isinstance(target, ast.Dict): + return { + key.value: value.value + for key, value in zip(target.keys, target.values) + if isinstance(key, ast.Constant) and isinstance(value, ast.Constant) + } + return {} + + +def test_the_harness_declares_a_provenance_table(): + assert _provenance(), ( + "scripts/simulation/sim_harness.py has no PROVENANCE table. Every number that describes a " + "heat pump, a house or a tariff must say where it came from - a document, or an explicit " + "admission that there is none and a measurement of what the answer costs if it is wrong." + ) + + +@pytest.mark.parametrize("name", sorted(set(_module_constants()) - NOT_A_PHYSICAL_CLAIM)) +def test_every_physical_constant_says_where_it_came_from(name): + """A number with a confident comment and no source is indistinguishable from a measurement.""" + provenance = _provenance() + + assert name in provenance, ( + f"{name} is a physical claim in the plant model and it does not say where it came from. " + f"Add it to PROVENANCE with either a document you can quote, or the word ASSUMED and the " + f"measured sensitivity of the conclusions to it. The last time a number like this went " + f"unchecked, the simulator derated a heat pump in the wrong direction and cited EN 14511 " + f"for it, and every finding built on that was wrong. If {name} is not a physical claim, " + f"say so by listing it in NOT_A_PHYSICAL_CLAIM - deliberately, in a diff someone reviews." + ) + + +@pytest.mark.parametrize("name", sorted(_provenance())) +def test_a_sourced_constant_quotes_a_document_and_an_assumed_one_admits_it(name): + """The two are not interchangeable, and the difference is the whole point of the table.""" + claim = _provenance()[name] + + if claim.startswith("ASSUMED"): + assert "sensitivity" in claim.lower(), ( + f"{name} is ASSUMED, which is allowed - not every number has a published source. But " + f"then the conclusions must be shown NOT to depend on it: state the measured " + f"sensitivity. An unsourced number that moves the answer is a finding about the " + f"modeller, not about the heat pump." + ) + return + + assert claim.startswith("SOURCED"), ( + f"{name}'s provenance reads {claim!r}. It must begin with SOURCED (and quote the document) " + f"or ASSUMED (and state the measured sensitivity). There is no third kind." + ) + # A SOURCED claim must name a REFERENCE, not merely use the word "datasheet" - a bare word can + # sit in a sentence that says the opposite ("No datasheet publishes it"). A reference is a URL, + # a numbered standard, a NIBE document code, a part number, or a docs/research file. + references = ( + r"https?://", + r"\bEN \d{3,5}\b", # EN 442, EN 1264, EN 14511, EN 14825 + r"\bISO \d{3,5}\b", + r"\b(IHB|UHB)\b", # NIBE installer / user handbook codes + r"part no", + r"docs/research/", + ) + + assert any(re.search(pattern, claim) for pattern in references), ( + f"{name} claims to be SOURCED but names no reference: {claim!r}. A reference is a URL, a " + f"numbered standard, a NIBE document code, a part number, or a docs/research note. " + f"'Swedish NIBE forum validation' was the last thing that passed for a source here, and " + f"the numbers it justified were in no forum and no datasheet." + ) + + +def test_no_constant_is_declared_that_does_not_exist(): + """A provenance table that outlives its constants is a table nobody is reading.""" + stale = sorted(set(_provenance()) - set(_module_constants())) + + assert not stale, ( + f"PROVENANCE declares {stale}, which the harness no longer defines. A stale entry is worse " + f"than none: it says a number was checked when the number is gone." + ) diff --git a/tests/validation/test_no_document_misquotes_the_safety_thresholds.py b/tests/validation/test_no_document_misquotes_the_safety_thresholds.py new file mode 100644 index 00000000..40ada118 --- /dev/null +++ b/tests/validation/test_no_document_misquotes_the_safety_thresholds.py @@ -0,0 +1,238 @@ +"""One test for every document, because the wrong number kept turning up in the prose. + +`docs/CLIMATE_ZONES.md` has a test that parses its TABLE rows, so the PROSE in the other documents +went on being wrong. The trap: "-450 to -700" is a real Stockholm range - at -8 C, the Cold zone's +actual winter average - but documents assert it at -10 C, where the code gives -490 to -740. You +cannot catch that by looking for a bad number; it is a good number attached to the wrong +temperature, and the root is one constant (Cold `winter_avg_low` = -8.0). + +So this checks the CLAIM, not the digits, across every markdown file: wherever a document names a +zone or city, gives an outdoor temperature, and prints a degree-minute range, that range must be +the one ClimateZoneDetector computes at that temperature. The removed flow-temperature model +(Kuhne) and the scaled-spread bug are guarded here too, for the same reason - a guard scoped to one +file has a hole the shape of every other file. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from custom_components.effektguard.optimization.climate_zones import ( + HEATING_CLIMATE_ZONES, + ClimateZoneDetector, +) + +ROOT = Path(__file__).resolve().parents[2] + +# A latitude squarely inside each zone, and the names a document might use for it. +ZONES = { + "extreme_cold": (67.86, ("Extreme Cold", "Kiruna", "Tromsø", "Tromso")), + "very_cold": (65.58, ("Very Cold", "Luleå", "Lulea", "Umeå", "Umea")), + "cold": (59.33, ("Cold", "Stockholm", "Oslo", "Göteborg", "Goteborg", "Helsinki")), + "moderate_cold": (55.60, ("Moderate Cold", "Malmö", "Malmo", "Copenhagen")), + "standard": (48.86, ("Standard", "Paris", "London", "Berlin")), +} + +# A degree-minute range: "-450 to -700". +DM_RANGE = re.compile(r"(-\d{2,4})\s*(?:to|–|-)\s*(-\d{2,4})") +# An outdoor temperature: "-10°C", "-10.0°C", "at -10 C". +OUTDOOR = re.compile(r"(-?\d{1,2}(?:\.\d)?)\s*°?\s*C\b") +# Every way this repository writes a zone's winter average - including the underscore form +# `winter_avg_low: -10.0°C`, the constant's own name as quoted in the code blocks people copy: +# "Winter avg: -10.0°C" prose and mermaid labels +# "Average winter low: -8°C" +# "winter_avg_low: -10.0°C" +WINTER_AVG = re.compile( + r"[Ww]inter[\s_](?:avg|average)(?:[\s_]low)?[:\s]+(-?\d{1,2}(?:\.\d)?)" + r"|[Aa]verage\s+winter\s+low[:\s]+(-?\d{1,2}(?:\.\d)?)" +) + + +def _markdown_files() -> list[Path]: + files = [ROOT / "README.md"] + files += sorted((ROOT / "docs").rglob("*.md")) + files += sorted((ROOT / ".github").rglob("*.md")) + return [f for f in files if f.exists()] + + +def _zone_named_in(line: str) -> str | None: + """Which climate zone, if any, this line is talking about. + + The most specific match wins: a line naming "Extreme Cold" is not a "Cold" line. + """ + best: tuple[int, str] | None = None + for key, (_lat, names) in ZONES.items(): + for name in names: + if re.search(rf"\b{re.escape(name)}\b", line): + if best is None or len(name) > best[0]: + best = (len(name), key) + return best[1] if best else None + + +def _claims() -> list[tuple[Path, int, str, str, float, int, int]]: + """Every (file, line, zone, outdoor_temp, dm_low, dm_high) a document asserts.""" + found = [] + for path in _markdown_files(): + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + dm = DM_RANGE.search(line) + if not dm: + continue + zone = _zone_named_in(line) + if zone is None: + continue + temps = [float(t) for t in OUTDOOR.findall(line)] + # The outdoor temperature is the one that is not a degree-minute figure. + temps = [t for t in temps if -40.0 <= t <= 20.0] + if not temps: + continue + found.append( + (path, lineno, zone, line.strip(), temps[0], int(dm.group(1)), int(dm.group(2))) + ) + return found + + +def test_the_scanner_actually_finds_the_claims_it_is_checking(): + """A parser that silently matches nothing makes every assertion below vacuous.""" + claims = _claims() + + assert len(claims) >= 5, ( + f"Only {len(claims)} degree-minute claims were found across every markdown file in the " + f"repository. The scanner has stopped matching, and this test now proves nothing." + ) + + +@pytest.mark.parametrize( + "path,lineno,zone,line,outdoor,low,high", + _claims(), + ids=lambda v: f"{v.name}" if isinstance(v, Path) else str(v), +) +def test_every_documented_dm_range_is_what_the_code_computes( + path, lineno, zone, line, outdoor, low, high +): + """A good number attached to the wrong temperature is still a wrong claim.""" + latitude = ZONES[zone][0] + actual = ClimateZoneDetector(latitude=latitude).get_expected_dm_range(outdoor) + expected = (round(actual["normal_min"]), round(actual["normal_max"])) + + assert (low, high) == expected, ( + f"{path.relative_to(ROOT)}:{lineno} says the {zone} zone at {outdoor:g}°C expects DM " + f"{low} to {high}. ClimateZoneDetector computes {expected[0]} to {expected[1]}.\n" + f" {line}\n" + f"Note {low} to {high} may well be a REAL range for this zone - at a different outdoor " + f"temperature. The Cold zone's winter average is " + f"{HEATING_CLIMATE_ZONES['cold']['winter_avg_low']}°C, not -10°C, and four documents " + f"derive their thresholds from the wrong one." + ) + + +@pytest.mark.parametrize("zone_key", sorted(ZONES)) +def test_no_document_misstates_a_zones_winter_average(zone_key): + """One constant, wrong in four places, and every threshold derived from it is wrong.""" + real = float(HEATING_CLIMATE_ZONES[zone_key]["winter_avg_low"]) + names = ZONES[zone_key][1] + + wrong = [] + for path in _markdown_files(): + lines = path.read_text(encoding="utf-8").splitlines() + for lineno, line in enumerate(lines, 1): + match = WINTER_AVG.search(line) + if not match: + continue + # Attribute the claim to a zone named on this line, or on the nearest heading above it. + zone = _zone_named_in(line) + if zone is None: + context = "\n".join(lines[max(0, lineno - 8) : lineno]) + zone = _zone_named_in(context) + if zone != zone_key: + continue + claimed = match.group(1) or match.group(2) + if float(claimed) != real: + wrong.append(f"{path.relative_to(ROOT)}:{lineno} says {claimed} — {line.strip()}") + + assert not wrong, ( + f"The {zone_key} zone's winter average is {real}°C in const.py. These documents say " + f"otherwise, and every degree-minute threshold they derive from it is wrong:\n " + + "\n ".join(wrong) + ) + + +# ── The removed flow-temperature model, across EVERY document ──────────────────────────────── +# +# The rulebook was cleaned of Kühne and given a test. The test read the rulebook. So the README +# went on advertising "André Kühne + Timbones formulas" to users, docs/architecture/10 went on +# deriving four worked examples from it, and docs/CLIMATE_ZONES went on naming it as the weather +# compensation model. Three documents, teaching a model that appears ZERO times in the codebase. +# +# A guard scoped to one file is a guard with a hole the shape of every other file. + +DENIALS = ( + "used to ", + "no longer", + "was removed", + "Do not reintroduce", + "does not exist", + "not sourced", + "has never existed", +) + + +def _paragraphs_that_assert(path: Path) -> str: + """A document's claims, minus the paragraphs that exist to warn you off something. + + Whitespace is normalised BEFORE the markers are looked for: markdown wraps prose, so a denial + can read "**was\nremoved**" in the file and a naive substring check for "was removed" would + miss it. + """ + paragraphs = path.read_text(encoding="utf-8").split("\n\n") + return "\n\n".join(p for p in paragraphs if not any(d in " ".join(p.split()) for d in DENIALS)) + + +@pytest.mark.parametrize("path", _markdown_files(), ids=lambda p: str(p.name)) +def test_no_document_teaches_the_flow_temperature_model_that_was_removed(path): + """Kühne drove the flow temperature of a real heat pump, and was taken out for being wrong. + + It was fed a heat-loss coefficient where the derivation requires a dimensionless relative load + (audit F-119/F-121), and it is gone: the flow temperature comes from the EN 442 emitter law in + `utils/emitter.py`. + + A document may explain what Kühne WAS, why it went, or use its curve as a REFERENCE - it is a + pure power law with provably zero internal gains, which makes it the cleanest way to demonstrate + that a balance point cannot be fitted to a heating curve. `docs/research/02_emitter_law.md` does + exactly that, and that is the point of it. What a document may not do is present Kühne's formula + as the model this project uses to set a flow temperature. + """ + claims = _paragraphs_that_assert(path) + + assert "TFlow = 2.55" not in claims and "2.55 * (HC" not in claims, ( + f"{path.relative_to(ROOT)} presents André Kühne's flow-temperature formula as a live model. " + f"It appears ZERO times in the codebase - it was replaced by the EN 442 emitter law. A " + f"reader following this document builds the model this project deliberately removed. " + f"See docs/research/02_emitter_law.md." + ) + + +@pytest.mark.parametrize("path", _markdown_files(), ids=lambda p: str(p.name)) +def test_no_document_teaches_the_scaled_spread(path): + """The bug the docs kept teaching for a whole commit after the code stopped doing it. + + `utils/emitter.py` holds the flow-return spread CONSTANT, because a heat pump modulates its + circulator to maintain the commissioned spread and varies the flow rate. Scaling the spread by + load - `spread_design * phi` - models a fixed-speed pump on a wet boiler. + + The commit that fixed the code left `docs/research/02_emitter_law.md` printing the scaled form + in its HEADLINE equation, so anyone implementing from the research note would have rebuilt the + bug on the spot. The error is invisible at the design point and grows in both directions from + it, which is exactly why it needs a guard rather than a careful reader. + """ + claims = " ".join(_paragraphs_that_assert(path).split()) + + for scaled in ("spread_design · φ", "spread_design * phi", "systemDT * phi", "spread * phi"): + assert scaled not in claims, ( + f"{path.relative_to(ROOT)} still teaches the SCALED spread ('{scaled}'). The code holds " + f"the spread constant - a heat pump modulates its circulator. Scaling it makes the flow " + f"temperature too cool in mild weather and too hot in cold, pivoting invisibly on the " + f"design point. See utils/emitter.py." + ) diff --git a/tests/validation/test_no_production_code_uses_a_naive_datetime.py b/tests/validation/test_no_production_code_uses_a_naive_datetime.py new file mode 100644 index 00000000..a5af37ca --- /dev/null +++ b/tests/validation/test_no_production_code_uses_a_naive_datetime.py @@ -0,0 +1,72 @@ +"""Home Assistant works in aware UTC. `datetime.now()` returns a naive local time. + +Mix the two and Python does not quietly do the wrong thing - it refuses: + + aware - naive -> TypeError: can't subtract offset-naive and offset-aware datetimes + +And if it did not refuse, it would be worse: the box runs UTC while `datetime.now()` returns local +time, so every interval would be wrong by the UTC offset - two hours in a Swedish summer. + +A grep is the right shape of test here: the rule is categorical, it costs nothing to hold, and the +next naive datetime someone adds will be in a file nobody has thought about. +""" + +from __future__ import annotations + +import ast +import pathlib + +import pytest + +PRODUCTION = pathlib.Path("custom_components/effektguard") + +# `dt_util.now()` and `dt_util.utcnow()` are the correct calls and are NOT what this looks for - +# only a bare `datetime.now()` / `datetime.utcnow()`. +NAIVE = {"now", "utcnow"} + + +def _naive_calls(path: pathlib.Path) -> list[tuple[int, str]]: + tree = ast.parse(path.read_text(encoding="utf-8")) + found = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + if node.func.attr not in NAIVE: + continue + value = node.func.value + # `datetime.now()` - the class, not dt_util + if isinstance(value, ast.Name) and value.id == "datetime": + found.append((node.lineno, f"datetime.{node.func.attr}()")) + return found + + +@pytest.mark.parametrize( + "path", sorted(PRODUCTION.rglob("*.py")), ids=lambda p: str(p.relative_to(PRODUCTION)) +) +def test_no_production_file_calls_datetime_now(path): + naive = _naive_calls(path) + + assert not naive, ( + f"{path} calls " + + ", ".join(f"{call} at line {line}" for line, call in naive) + + ". Home Assistant works in aware UTC: a naive datetime cannot be compared with an aware " + "one at all (TypeError), and if it could, this box runs UTC while datetime.now() returns " + "local time - so the interval would be wrong by the UTC offset, two hours in a Swedish " + "summer. Use `dt_util.utcnow()`." + ) + + +def test_the_rule_can_actually_catch_something(tmp_path): + """The guard on the guard: an AST walker that matches nothing is not a test.""" + offender = tmp_path / "offender.py" + offender.write_text("from datetime import datetime\n\nx = datetime.now()\n") + + assert _naive_calls(offender) == [(3, "datetime.now()")] + + +def test_dt_util_is_not_mistaken_for_the_naive_call(tmp_path): + """`dt_util.utcnow()` is the CORRECT call and must never be flagged.""" + good = tmp_path / "good.py" + good.write_text("from homeassistant.util import dt as dt_util\n\nx = dt_util.utcnow()\n") + + assert _naive_calls(good) == [] diff --git a/tests/validation/test_no_test_captures_the_clock_at_import_time.py b/tests/validation/test_no_test_captures_the_clock_at_import_time.py new file mode 100644 index 00000000..e7289b17 --- /dev/null +++ b/tests/validation/test_no_test_captures_the_clock_at_import_time.py @@ -0,0 +1,105 @@ +"""A test that reads the clock when pytest COLLECTS it is measuring the gap between two clocks. + + NOW = dt_util.utcnow() # <- evaluated at import, i.e. at collection + + async def test_something(...): + entity = _weather_entity_with_forecast_from(NOW) # built against the collection clock + data = await adapter.get_forecast() # adapter reads the clock again, NOW + +Those two clocks agree only while nothing moves the clock between collection and the test running. +Freeze the wall clock at a daylight-saving transition, or collect at 23:59:58, and they diverge - +so the fragility is invisible on an ordinary run. + +The rule is narrow on purpose: read the clock INSIDE the test (a fixture is the tidy way), never at +module scope. Constants that are plain literals - a fixed January date used as a label, say - are +fine and are not what this looks for. +""" + +from __future__ import annotations + +import ast +import pathlib + +import pytest + +TESTS = pathlib.Path("tests") + +# The calls that read the real clock. `datetime.now()` is already banned in production by +# test_no_production_code_uses_a_naive_datetime; here it is banned at test-module SCOPE too. +CLOCK_READS = { + ("dt_util", "now"), + ("dt_util", "utcnow"), + ("datetime", "now"), + ("datetime", "utcnow"), +} + + +def _module_level_clock_reads(path: pathlib.Path) -> list[tuple[int, str]]: + """Clock reads evaluated when the module is imported, not when a test runs.""" + tree = ast.parse(path.read_text(encoding="utf-8")) + + # Only statements that RUN AT IMPORT. A def or a class is not one of them - its body runs when + # the test runs, which is exactly where reading the clock is correct, so we do not descend into + # them. + at_import = [ + node + for node in tree.body + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + ] + + found = [] + for node in at_import: + for child in ast.walk(node): + if not isinstance(child, ast.Call) or not isinstance(child.func, ast.Attribute): + continue + value = child.func.value + if not isinstance(value, ast.Name): + continue + if (value.id, child.func.attr) in CLOCK_READS: + found.append((child.lineno, f"{value.id}.{child.func.attr}()")) + return found + + +@pytest.mark.parametrize( + "path", sorted(TESTS.rglob("test_*.py")), ids=lambda p: str(p.relative_to(TESTS)) +) +def test_the_clock_is_read_when_the_test_runs_not_when_it_is_collected(path): + reads = _module_level_clock_reads(path) + + assert not reads, ( + f"{path} reads the clock at module scope: " + + ", ".join(f"{call} on line {line}" for line, call in reads) + + ". That value is captured when pytest COLLECTS the file, while the code under test reads " + "the clock when the test RUNS. The two agree only while nothing moves the clock - freeze it " + "at a daylight-saving transition, or collect at 23:59:58, and they diverge, and the test is " + "then measuring the gap between two clocks rather than the behaviour it is named for. Read " + "the clock inside the test; a fixture is the tidy way." + ) + + +class TestTheRuleCanActuallyCatchSomething: + """A walker that matches nothing is not a guard.""" + + def test_a_module_level_capture_is_caught(self, tmp_path): + bad = tmp_path / "test_bad.py" + bad.write_text("from homeassistant.util import dt as dt_util\n\nNOW = dt_util.utcnow()\n") + + assert _module_level_clock_reads(bad) == [(3, "dt_util.utcnow()")] + + def test_a_read_inside_a_test_is_allowed(self, tmp_path): + good = tmp_path / "test_good.py" + good.write_text( + "from homeassistant.util import dt as dt_util\n\n\n" + "def test_thing():\n now = dt_util.utcnow()\n assert now\n" + ) + + assert _module_level_clock_reads(good) == [] + + def test_a_read_inside_a_fixture_is_allowed(self, tmp_path): + good = tmp_path / "test_fixture.py" + good.write_text( + "import pytest\nfrom homeassistant.util import dt as dt_util\n\n\n" + "@pytest.fixture\ndef now():\n return dt_util.utcnow()\n" + ) + + assert _module_level_clock_reads(good) == [] diff --git a/tests/validation/test_one_definition_of_the_safety_floor.py b/tests/validation/test_one_definition_of_the_safety_floor.py new file mode 100644 index 00000000..7c0b95f6 --- /dev/null +++ b/tests/validation/test_one_definition_of_the_safety_floor.py @@ -0,0 +1,132 @@ +"""One definition of the most safety-critical number in the project. + +DM -1500 is the absolute degree-minute floor: the reading at which an absolute emergency is +declared. It must have a SINGLE source, or its copies drift apart and disagree about when the +house is in danger. This guard holds four things together: + + - const.py defines DM_THRESHOLD_AUX_LIMIT = -1500 exactly once (the only literal permitted); + - climate_zones publishes it as `critical`, and get_expected_dm_range()["critical"] must be the + SAME object as the emergency tier's DM_THRESHOLD_AUX_LIMIT, not merely equal to it; + - the simulator reads the aux limit from the pump profile, so the profile must REFERENCE the + constant, not restate a literal - else a change to the constant leaves the plant validating + against the old threshold; + - there is one latitude-to-climate classification, not two. + +The number itself may yet change - F-112 is open with the owner: on an F750 the pump's own "start +addition" fires at -700 and works DM back up, so -1500 describes a regime a healthy pump never +enters. When it changes, everything above must move with it. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from custom_components.effektguard import const +from custom_components.effektguard.optimization import climate_zones + +COMPONENT = Path(__file__).resolve().parents[2] / "custom_components" / "effektguard" + +# A literal -1500 assigned to a name, anywhere in production code. +LITERAL = re.compile(r"^\s*(\w+)\s*(?::[^=]+)?=\s*-1500\b", re.M) + + +def _definitions() -> list[tuple[Path, str]]: + found = [] + for path in sorted(COMPONENT.rglob("*.py")): + for name in LITERAL.findall(path.read_text(encoding="utf-8")): + found.append((path, name)) + return found + + +def test_the_absolute_degree_minute_floor_is_defined_exactly_once(): + """Two live definitions of the same number cannot be kept equal by hoping.""" + definitions = _definitions() + + assert len(definitions) == 1, ( + "The absolute degree-minute floor (-1500) is defined " + f"{len(definitions)} times:\n " + + "\n ".join(f"{p.relative_to(COMPONENT)}: {name} = -1500" for p, name in definitions) + + "\n\nIt is one physical quantity: the DM at which an absolute emergency is declared. " + "thermal_layer tests against DM_THRESHOLD_AUX_LIMIT; get_expected_dm_range() publishes " + "DM_ABSOLUTE_MAXIMUM as `critical`. Change one - as F-112 may require - and the other " + "silently disagrees about when the house is in danger." + ) + + +def test_the_published_critical_threshold_is_the_emergency_trigger_itself(): + """Not merely equal today. The same object. + + `get_expected_dm_range()` publishes a `critical` threshold to every consumer, and + `thermal_layer` fires the EMERGENCY tier on `DM_THRESHOLD_AUX_LIMIT`. These are one quantity. + Asserting identity, not equality, is the point: two constants holding -1500 are equal today and + that is exactly the state this test exists to forbid. + """ + published = climate_zones.ClimateZoneDetector(latitude=59.33).get_expected_dm_range(-10.0) + + assert published["critical"] is const.DM_THRESHOLD_AUX_LIMIT, ( + f"get_expected_dm_range() publishes critical={published['critical']!r}, which is not the " + f"same object as const.DM_THRESHOLD_AUX_LIMIT={const.DM_THRESHOLD_AUX_LIMIT!r}. The " + f"emergency tier and the published critical threshold must move together, or they will " + f"disagree about when the house is in danger." + ) + + +def test_the_simulator_validates_against_the_threshold_production_actually_uses(): + """The simulator reads the profile. The profile must not restate the number. + + This is the one that would bite hardest. The simulator is what validates a change to the aux + limit - and it takes the limit from the heat-pump profile, deliberately, so that "the plant + model tracks whatever the integration believes". If the profile carries its own literal, the + plant does NOT track the integration: change the constant, and the simulator goes on modelling + the old threshold and pronounces the new behaviour safe against a plant that never sees it. + """ + from custom_components.effektguard.models.nibe import NibeF750Profile + + profile = NibeF750Profile() + + # Value equality is NOT the assertion. Both are -1500 today, and a test that checks only that + # passes by coincidence - which is the entire defect. It has to REFERENCE the constant. + for module in ("models/base.py", "models/nibe/f750.py"): + source = (COMPONENT / module).read_text(encoding="utf-8") + declaration = next( + (ln for ln in source.splitlines() if "dm_threshold_aux_swedish" in ln and "=" in ln), + None, + ) + if declaration is None: + continue + + assert "DM_THRESHOLD_AUX_LIMIT" in declaration, ( + f"{module} declares dm_threshold_aux_swedish with a literal:\n" + f" {declaration.strip()}\n" + f"The simulator reads this field so the plant tracks what the integration believes. " + f"A literal cannot track anything. It must reference DM_THRESHOLD_AUX_LIMIT." + ) + + assert profile.dm_threshold_aux_swedish == const.DM_THRESHOLD_AUX_LIMIT, ( + f"The F750 profile's aux threshold ({profile.dm_threshold_aux_swedish}) is not " + f"DM_THRESHOLD_AUX_LIMIT ({const.DM_THRESHOLD_AUX_LIMIT})." + ) + + +def test_there_is_one_latitude_to_climate_classification_not_two(): + """The coordinator has its own latitude bands, and nothing reads the result. + + `_detect_climate_region()` maps latitude to CLIMATE_SOUTHERN_SWEDEN / CENTRAL / MID_NORTHERN / + NORTHERN / LAPLAND on boundaries of 58 / 62 / 65 / 67. `ClimateZoneDetector` maps the SAME + latitude to a climate zone on boundaries of 54.5 / 56 / 60.5 / 66.5, and that one actually + drives the degree-minute thresholds. + + Two answers to "what climate is this house in", from one latitude, with different boundaries - + and the dead one has eleven tests, which test only each other. + """ + coordinator_source = (COMPONENT / "coordinator.py").read_text(encoding="utf-8") + + assert "_detect_climate_region" not in coordinator_source, ( + "coordinator._detect_climate_region() is a SECOND latitude-to-climate classification, with " + "different boundaries from ClimateZoneDetector, and its result (self.climate_region) is " + "read by nothing in production. A maintainer could wire it up believing it is the real " + "one. There must be one answer to what climate a house is in." + ) diff --git a/tests/validation/test_research_docs_still_hold.py b/tests/validation/test_research_docs_still_hold.py new file mode 100644 index 00000000..d67316cc --- /dev/null +++ b/tests/validation/test_research_docs_still_hold.py @@ -0,0 +1,238 @@ +"""The research must stay true, or it becomes what it replaced. + +`docs/research/` exists because the code cited fifteen research documents that were all absent from +the repository, so the binding rule "never guess NIBE behaviour, verify against research" could not +be obeyed by anyone who cloned it. Sourced citations only help if they stay true: a research note +that has drifted from the code is worse than none, because it looks settled. + +So the documents are PARSED, not remembered. `NAME = value` in the prose, the net-gain table in 04, +the worked example in 02 - all read out of the markdown and checked against the code that runs. A +digit changed in either place fails here, which is the only arrangement under which "the research +still holds" means anything. These are not the derivations - those live in the documents, with +their sources; this is the part a machine can hold you to. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from custom_components.effektguard import const +from custom_components.effektguard.const import DEFAULT_HEAT_LOSS_COEFFICIENT, INTERNAL_GAINS_W +from custom_components.effektguard.optimization.airflow_optimizer import calculate_net_thermal_gain +from custom_components.effektguard.utils.emitter import en442_flow_temp + +RESEARCH = Path(__file__).resolve().parents[2] / "docs" / "research" + +# The documents are typeset, not code: they use the Unicode MINUS SIGN and bold the numbers they +# want you to look at. A parser that does not know that reads "−0.31" as a string and quietly +# matches nothing, which is the same failure as not reading them at all. +MINUS_SIGNS = str.maketrans({"−": "-", "–": "-", "—": "-"}) + + +def _text(name: str) -> str: + return (RESEARCH / name).read_text(encoding="utf-8").translate(MINUS_SIGNS) + + +def _all_research_text() -> list[tuple[str, str]]: + return [(p.name, _text(p.name)) for p in sorted(RESEARCH.glob("*.md"))] + + +def _constants_cited_in_the_research() -> list[tuple[str, str, float]]: + """Every `NAME = value` in the prose, where NAME is a real constant. Read, not remembered.""" + cited = [] + for filename, text in _all_research_text(): + for name, value in re.findall( + r"\b([A-Z][A-Z0-9_]{3,})\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)", text + ): + if hasattr(const, name): + cited.append((filename, name, float(value))) + return sorted(set(cited)) + + +def _constants_declared_in_code_fences() -> list[tuple[str, str, float]]: + """Every `NAME = value` inside a fenced code block - a DECLARATION, not a mention. + + Prose may lawfully name a constant that no longer exists ("DEFAULT_BALANCE_POINT_OFFSET ... is + gone"). A fenced ```python block reads as the code the document is deriving, so a name there + that const.py does not have is a promise the codebase is not keeping - and the hasattr filter + used elsewhere would silently skip it. + """ + declared = [] + for filename, text in _all_research_text(): + for fence in re.findall(r"```[a-z]*\n(.*?)```", text, flags=re.DOTALL): + for name, value in re.findall( + r"^\s*([A-Z][A-Z0-9_]{3,})\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)", fence, flags=re.M + ): + declared.append((filename, name, float(value))) + return sorted(set(declared)) + + +def test_no_fenced_declaration_names_a_constant_the_code_does_not_have(): + declared = _constants_declared_in_code_fences() + assert declared, "the fence parser matched nothing - it can no longer catch anything either" + + phantoms = [(f, n, v) for f, n, v in declared if not hasattr(const, n)] + assert not phantoms, ( + f"docs/research declares constants the code does not have: {phantoms}. A reader takes a " + f"fenced declaration as fact; if the constant was renamed or the change never landed, " + f"the document must say so in prose instead of declaring it." + ) + + +def _net_gain_table() -> list[tuple[float, float]]: + """The `| outdoor | net gain |` table in 04. `| +10 °C | **+0.03 kW** |` -> (10.0, 0.03).""" + rows = re.findall( + r"\|\s*\*{0,2}([+-]?[0-9.]+)\s*°C\*{0,2}\s*\|\s*\*{0,2}([+-]?[0-9.]+)\s*kW\*{0,2}\s*\|", + _text("04_exhaust_air_recovery.md"), + ) + return [(float(outdoor), float(gain)) for outdoor, gain in rows] + + +class TestTheDocumentsAreActuallyRead: + """A parser that matches nothing is indistinguishable from the dict it replaced.""" + + def test_the_research_really_does_cite_constants_by_name(self): + cited = _constants_cited_in_the_research() + + assert len(cited) >= 6, ( + f"Only {len(cited)} constants were parsed out of docs/research/: " + f"{[c[1] for c in cited]}. Every test below is parametrised over this list, so if the " + f"parser stops matching, the whole file silently passes and checks nothing." + ) + + def test_the_net_gain_table_is_really_parsed(self): + """The net-gain table uses a Unicode minus AND a leading `+` on positive rows. + + A regex that handles neither reads only a subset - and the rows it drops are the positive + ones, the only rows where the feature looks GOOD - so the row count is asserted, not assumed. + """ + table = _net_gain_table() + + assert len(table) == 6, ( + f"Parsed {len(table)} rows from the net-gain table in 04: {table}. The document prints " + f"six. A parser that quietly matches a subset is the same failure as not reading the " + f"document at all." + ) + assert any(gain > 0 for _, gain in table), "the +10 C row is the one that shows a gain" + assert sum(1 for _, gain in table if gain < 0) == 5, "and five rows show a LOSS" + + def test_a_corrupted_document_would_be_caught(self, tmp_path): + """The guard on the guard. Prove the parser can see a wrong number, on a fake document.""" + doc = tmp_path / "fake.md" + doc.write_text("`DM_THRESHOLD_START = -99` is this number.\n", encoding="utf-8") + + found = re.findall( + r"\b([A-Z][A-Z0-9_]{3,})\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)", + doc.read_text(encoding="utf-8"), + ) + + assert found == [("DM_THRESHOLD_START", "-99")] + assert ( + float(found[0][1]) != const.DM_THRESHOLD_START + ), "a document quoting the wrong value must not compare equal to the code" + + +@pytest.mark.parametrize( + "filename,name,quoted", + _constants_cited_in_the_research(), + ids=lambda v: str(v) if not isinstance(v, float) else f"{v:g}", +) +def test_research_quotes_the_constant_the_code_actually_holds(filename, name, quoted): + """A citation that no longer matches the code is a citation that misleads. + + The value is read from the markdown. Change the digit in the document OR retune the constant + without revisiting the evidence, and this fails - which is the whole point of the directory. + """ + actual = getattr(const, name) + + assert float(actual) == quoted, ( + f"docs/research/{filename} quotes {name} = {quoted!r}; const.py holds {actual!r}. Either " + f"the constant was retuned without revisiting the evidence for it, or the note is wrong. " + f"Both matter: this directory exists so that these numbers can be checked." + ) + + +@pytest.mark.parametrize("outdoor,quoted_gain", _net_gain_table()) +def test_the_airflow_gain_table_is_what_the_code_computes(outdoor, quoted_gain): + """04_exhaust_air_recovery.md prints a net-gain table. It must be the real one. + + The whole point of that page is that the gain is NEGATIVE once the double-counted COP term is + removed. If someone restores the COP term, this table goes positive and the page becomes a lie + that argues for a feature that loses heat. + """ + gain = calculate_net_thermal_gain( + const.AIRFLOW_DEFAULT_STANDARD, const.AIRFLOW_DEFAULT_ENHANCED, 21.0, float(outdoor) + ) + + assert gain == pytest.approx(quoted_gain, abs=0.005), ( + f"docs/research/04 says enhanced airflow nets {quoted_gain:+.2f} kW at {outdoor}°C; " + f"calculate_net_thermal_gain gives {gain:+.2f} kW." + ) + + +def test_enhanced_airflow_still_loses_heat_in_the_cold(): + """The claim the page is actually making, stated as a property rather than a table.""" + for outdoor in (5, 0, -5, -10, -15): + gain = calculate_net_thermal_gain( + const.AIRFLOW_DEFAULT_STANDARD, const.AIRFLOW_DEFAULT_ENHANCED, 21.0, float(outdoor) + ) + assert gain < 0, ( + f"Enhanced airflow shows a POSITIVE net gain of {gain:+.2f} kW at {outdoor}°C. The " + f"research (docs/research/04) says it cannot: extracting more heat from more air and " + f"'improving the COP' are the same joules, and NIBE's own S735 data confirms it. If " + f"this now passes, someone has re-added the double-counted term." + ) + + +def test_the_en442_worked_example_in_the_docs_reproduces(): + """02_emitter_law.md shows a code block and prints its result. Run it, against ITS number. + + This anchors the whole flow-temperature model: NIBE's published curve 9 reads 41.0 C at 0 C + outdoor, our law lands ~0.64 C above it, and that gap is the TRIM, not an error - NIBE + interpolates its curves linearly, we follow EN 442. The expected value is read OUT of the doc's + comparison table rather than copied from it. + """ + table = _text("02_emitter_law.md") + row = re.search(r"\|\s*EN 442[^|]*\|\s*([0-9.]+)\s*°C\s*\|", table) + + assert row, ( + "02_emitter_law.md no longer prints an 'EN 442 + derived gains' row in its comparison " + "table. This test reads its expected value from that row, so without it the test is " + "checking nothing." + ) + doc_says = float(row.group(1)) + + flow = en442_flow_temp( + indoor_setpoint=21.0, + outdoor_temp=0.0, + design_outdoor_temp=-15.0, + design_flow_temp=52.6, + design_spread=5.0, + emitter_exponent=1.3, + balance_point_temp=21.0 - INTERNAL_GAINS_W / DEFAULT_HEAT_LOSS_COEFFICIENT, + ) + + assert flow == pytest.approx(doc_says, abs=0.01), ( + f"The worked example in 02_emitter_law.md says this call returns {doc_says}; it returns " + f"{flow:.2f}. A research note whose own code block does not run is exactly the kind of " + f"citation this directory was created to replace." + ) + assert abs(flow - 41.0) < 1.0, ( + f"The emitter law gives {flow:.2f} C where NIBE's own published curve 9 gives 41.0 C. We " + f"TRIM that curve, so a gap is expected - but a large one would mean the design point, the " + f"spread or the gains are misconfigured, and every offset we emit would be a correction " + f"toward our own error." + ) + + +def test_every_research_note_is_indexed(): + """A note nobody can find is a note nobody will maintain.""" + index = (RESEARCH / "README.md").read_text(encoding="utf-8") + notes = sorted(p.name for p in RESEARCH.glob("*.md") if p.name != "README.md") + + missing = [n for n in notes if n not in index] + + assert not missing, f"docs/research/README.md does not link: {', '.join(missing)}" diff --git a/tests/validation/test_sensors_speak_the_users_language.py b/tests/validation/test_sensors_speak_the_users_language.py new file mode 100644 index 00000000..e296b390 --- /dev/null +++ b/tests/validation/test_sensors_speak_the_users_language.py @@ -0,0 +1,89 @@ +"""Every sensor must be translatable, so the Swedish user does not read the dial in English. + +Home Assistant resolves an entity's name by `translation_key`. A sensor that sets a hardcoded +English `name=` instead - as all twenty-four once did - stays English in sv, no, da and fi whatever +language HA runs in, and the primary audience for this integration is Swedish. + +The fix mirrors the six switches: `translation_key="..."` plus an `entity.sensor` entry in +strings.json, present in every locale (test_translation_key_parity.py keeps them in lockstep). +Nothing here touches the heat pump - it is the label on the dial, not the dial. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from homeassistant.helpers.typing import UNDEFINED + +from custom_components.effektguard.sensor import SENSORS + +COMPONENT = Path(__file__).resolve().parents[2] / "custom_components" / "effektguard" +STRINGS = json.loads((COMPONENT / "strings.json").read_text(encoding="utf-8")) +LOCALES = ("en", "sv", "no", "da", "fi") + + +@pytest.mark.parametrize("description", SENSORS, ids=lambda d: d.key) +def test_every_sensor_has_a_translation_key(description): + """Without one, Home Assistant has nothing to look the name up by.""" + assert description.translation_key, ( + f"Sensor {description.key!r} has no translation_key, so its name is permanently " + f"{description.name!r} - in Swedish, Norwegian, Danish and Finnish too. The switches set " + f"one; the sensors do not." + ) + + +@pytest.mark.parametrize("description", SENSORS, ids=lambda d: d.key) +def test_every_sensor_name_is_declared_in_strings_json(description): + """A translation_key with nothing behind it renders as a raw key, or as nothing at all.""" + sensors = STRINGS.get("entity", {}).get("sensor", {}) + + assert description.translation_key in sensors, ( + f"Sensor {description.key!r} declares translation_key=" + f"{description.translation_key!r}, and strings.json has no entity.sensor entry for it. " + f"Home Assistant will fall back to the raw key." + ) + assert sensors[description.translation_key].get( + "name" + ), f"entity.sensor.{description.translation_key} has no name in strings.json." + + +@pytest.mark.parametrize("locale", LOCALES) +def test_every_locale_carries_every_sensor_name(locale): + """The parity test guards the file as a whole; this names the sensor that is missing.""" + path = COMPONENT / "translations" / f"{locale}.json" + translated = json.loads(path.read_text(encoding="utf-8")).get("entity", {}).get("sensor", {}) + + missing = [ + d.translation_key + for d in SENSORS + if d.translation_key and not translated.get(d.translation_key, {}).get("name") + ] + + assert not missing, ( + f"{locale}.json is missing a name for {len(missing)} sensor(s): {', '.join(sorted(missing))}. " + f"A user reading Home Assistant in this language sees the raw key, or a blank label." + ) + + +def test_the_hardcoded_english_name_is_gone(): + """Two sources for one string is one too many; they diverge, and the silent one wins. + + Home Assistant resolves the name from the translation when a translation_key is set, and only + falls back to `name=` when the lookup fails. Keeping both means the English string sits there + doing nothing until someone edits it, and then goes on doing nothing - which is exactly how the + switch descriptions ended up carrying a dead `name=` that no longer matched their translation. + """ + # EntityDescription.name defaults to the UNDEFINED sentinel, which is TRUTHY - a `getattr(d, + # "name", None)` check silently passes on every sensor whether or not it has a name. + with_both = [ + d.key for d in SENSORS if d.translation_key and d.name not in (UNDEFINED, None, "") + ] + + assert not with_both, ( + f"{len(with_both)} sensor(s) carry BOTH a translation_key and a hardcoded name=: " + f"{', '.join(sorted(with_both))}. The translation always wins, so the name is dead weight " + f"that will silently diverge from what the user actually sees." + ) diff --git a/tests/validation/test_the_plant_engages_aux_where_the_pump_does.py b/tests/validation/test_the_plant_engages_aux_where_the_pump_does.py new file mode 100644 index 00000000..233841cf --- /dev/null +++ b/tests/validation/test_the_plant_engages_aux_where_the_pump_does.py @@ -0,0 +1,47 @@ +"""The simulated pump engages additive heat where the REAL pump does - not at -1500. + +NIBE ships every supported machine with its additive heat armed far above EffektGuard's +absolute floor: the F750/F730 "start addition" defaults to -700 (IHB GB 1301-1, menu 4.9.3), +the S1155/F1155 controllers to about -460, the VVM 320 that pairs with an F2040 to about +-760. On a healthy pump DM asymptotes AT the start-addition value, because the elpatron +engages there and works it back up. + +Waiting for EffektGuard's own -1500 floor instead under-fires the elpatron - 800 degree-minutes +late for an F750 - so cold-snap aux and overshoot get computed against a machine no factory ships. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parents[2] / "scripts" / "simulation")) + +from sim_harness import HOUSES # noqa: E402 + +from custom_components.effektguard.const import DM_THRESHOLD_AUX_LIMIT # noqa: E402 + + +def test_every_house_fires_aux_at_its_pumps_own_start_addition(): + for house in HOUSES: + assert house.aux_start_dm == house.profile.aux_start_dm, house.name + + +def test_the_hardware_start_addition_is_not_effektguards_floor(): + """The two numbers are different FACTS: confusing them is audit finding F-112.""" + for house in HOUSES: + assert house.aux_start_dm > DM_THRESHOLD_AUX_LIMIT, ( + f"{house.name}: the plant arms additive heat at {house.aux_start_dm}, at or below " + f"EffektGuard's absolute floor ({DM_THRESHOLD_AUX_LIMIT}). No factory-default NIBE " + f"waits that long - the elpatron is part of the machine being simulated." + ) + + +def test_the_factory_defaults_match_the_installer_manuals(): + expected = { + "wooden_f750": -700.0, + "apartment_f730": -700.0, + "concrete_f1155": -460.0, + "villa_s1155": -460.0, + "airsource_f2040": -760.0, + } + for house in HOUSES: + assert house.aux_start_dm == expected[house.name], house.name diff --git a/tests/validation/test_the_pump_models_match_their_datasheets.py b/tests/validation/test_the_pump_models_match_their_datasheets.py new file mode 100644 index 00000000..7c7b9b72 --- /dev/null +++ b/tests/validation/test_the_pump_models_match_their_datasheets.py @@ -0,0 +1,334 @@ +"""The heat-pump models must come from the datasheets, not from an invented curve. + +Every profile in `models/nibe/` once carried an outdoor-keyed `cop_curve` whose docstring called it +"Real-world COP curve (tested and validated)" and sourced it to "NIBE F750 datasheet, Swedish NIBE +forum validation". It was neither: the F750 and F730 shipped BYTE-IDENTICAL curves despite different +published outputs, and the F750's said COP 5.0 at +7 C outdoor - a figure in no NIBE document, at a +condition an EXHAUST-AIR pump is never rated at (its points are A20(12), 20 C extract air; outdoor +air never touches its evaporator). + +What the datasheets say, and what this file checks the model against: + + NIBE F750, "Output data according to EN 14 511", part no. 066 063: + 4.994 kW / COP 2.43 A20(12)W45, 252 m3/h, MAX compressor frequency + F2040 (air source): capacity RISES as it cools - 3.86 -> 6.60 kW from +7 to -7 C - because an + inverter throttles back at its mild rating point; the COP falls instead, 4.65 -> 2.68 at W35. + +The old curve gave the F750 an 8 kW compressor (it makes 4.994), derated the F2040 the wrong way, +and dropped a ground-source F1155's COP because the outdoor AIR got cold - though its heat source is +0 C borehole brine. Each profile now carries its EN 14511 rating points VERBATIM, and the +simulator's COP is + + COP = eta_exergy(load, flow) x Carnot(source, flow) + +with eta fitted to each machine's own published points - a claim that CAN be falsified, which the +curve it replaced could not be, and this file falsifies it or fails. +""" + +from __future__ import annotations + +import importlib.util +import pathlib + +import pytest + +from custom_components.effektguard.models.nibe import ( + NibeF730Profile, + NibeF750Profile, + NibeF1155Profile, + NibeF2040Profile, + NibeS1155Profile, +) + +_SPEC = importlib.util.spec_from_file_location( + "sim_harness", pathlib.Path("scripts/simulation/sim_harness.py") +) +sim = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(sim) + +PROFILES = [ + NibeF750Profile, + NibeF730Profile, + NibeF1155Profile, + NibeS1155Profile, + NibeF2040Profile, +] + +# The model must reproduce every point it was fitted on to within this. Measured: 0.82 % worst. +DATASHEET_TOLERANCE_PCT = 2.0 + +# And it must predict points it was NEVER fitted on to within this. Measured: 5.7 % worst, on the +# F2040's W45 rows when the fit only ever saw W35. That is the number that makes this a model +# rather than a curve-fit, and it is why the tolerance here is looser and still meaningful. +HELD_OUT_TOLERANCE_PCT = 8.0 + + +@pytest.fixture(params=PROFILES, ids=lambda p: p().model_name) +def profile(request): + return request.param() + + +def _house_for(profile): + """A HouseConfig wrapping this profile, so the real simulator physics is exercised.""" + return next(h for h in sim.HOUSES if h.profile.model_name == profile.model_name) + + +class TestEveryNumberHasASource: + """No source, no number. That is the whole rule, and it was not being followed.""" + + def test_the_profile_carries_its_datasheet(self, profile): + assert profile.datasheet_points, ( + f"{profile.model_name} has no EN 14511 rating points. Every performance figure in this " + f"package is now derived from the manufacturer's published measurements, because the " + f"ones that were not turned out to be a template with the digits nudged." + ) + assert profile.datasheet_source, ( + f"{profile.model_name} does not say where its numbers came from. The last time this " + f"field said 'NIBE F750 datasheet, Swedish NIBE forum validation', the numbers were in " + f"neither." + ) + + def test_every_rating_point_names_its_condition(self, profile): + """`A20(12)W35, 252 m3/h, min compressor frequency` is the datasheet's own string. + + Without it a rating point is just four floats, and four floats are what got invented. + """ + for point in profile.datasheet_points: + assert len(point.condition) > 8 and any( + c.isdigit() for c in point.condition + ), f"{profile.model_name} has a rating point with no condition: {point.condition!r}" + + def test_the_two_exhaust_air_pumps_no_longer_share_one_curve(self): + """The tell. Different machines, byte-identical COP curves, for a year.""" + f750, f730 = NibeF750Profile(), NibeF730Profile() + + assert f750.datasheet_points != f730.datasheet_points, ( + "The F750 and F730 carry identical performance data. They are different machines: " + "NIBE publishes 4.994 kW / COP 2.43 for one and 5.35 kW / COP 2.43 for the other." + ) + + def test_the_f1155_is_not_set_slightly_below_the_s1155(self): + """Its docstring said it was. The datasheets say they are the same machine.""" + assert NibeF1155Profile().datasheet_points == NibeS1155Profile().datasheet_points, ( + "The F1155 and S1155 publish IDENTICAL EN 14511 data at every size. The old profile " + "'set' the F1155's COP curve slightly below the S1155's - which was not merely " + "unsourced, it was wrong." + ) + + +class TestTheModelReproducesTheDatasheet: + """The claim that can be falsified. It is the difference between a model and a decoration.""" + + def test_it_reproduces_every_point_it_was_fitted_on(self, profile): + house = _house_for(profile) + rated_airflow = max( + (p.airflow_m3h for p in profile.datasheet_points if p.airflow_m3h), default=None + ) + + for point in profile.datasheet_points: + if rated_airflow is not None and point.airflow_m3h != rated_airflow: + continue # a different source condition - see exergy_fit + + load = point.heat_output_kw / profile.max_heat_output_kw + eta = house.exergy_efficiency(load, point.flow_temp_c) + modelled = eta * house.carnot_at(point.source_temp_c, point.flow_temp_c) + error = abs(modelled - point.cop) / point.cop * 100 + + assert error < DATASHEET_TOLERANCE_PCT, ( + f"{profile.model_name} at '{point.condition}': NIBE measured COP {point.cop:.2f}, " + f"the model says {modelled:.2f} ({error:.1f}% out). The model exists to reproduce " + f"this machine's own published measurements; if it cannot, it is not a model of " + f"this machine." + ) + + def test_it_predicts_the_points_it_never_saw(self): + """THE REAL TEST. Fit the F2040 on its W35 rows only, then predict its W45 rows. + + The F2040 is the only machine whose datasheet is rich enough to hold points back: five + rating points, three at 35 C flow and two at 45 C. A curve can be drawn through anything. + A model has to work on data it has not seen. + """ + f2040 = NibeF2040Profile() + house = _house_for(f2040) + + held_out = [p for p in f2040.datasheet_points if p.flow_temp_c == 45.0] + assert len(held_out) == 2, "precondition: the F2040 must publish W45 rows to hold back" + + for point in held_out: + load = point.heat_output_kw / f2040.max_heat_output_kw + eta = house.exergy_efficiency(load, point.flow_temp_c) + modelled = eta * house.carnot_at(point.source_temp_c, point.flow_temp_c) + error = abs(modelled - point.cop) / point.cop * 100 + + assert error < HELD_OUT_TOLERANCE_PCT, ( + f"F2040 at '{point.condition}': NIBE measured COP {point.cop:.2f}, the model " + f"predicts {modelled:.2f} ({error:.1f}% out) from a fit that only ever saw 35 C " + f"flow temperatures. Predicting held-out data is the only thing that separates " + f"this from the invented curve it replaced." + ) + + +class TestThePhysicsIsTheRightWayUp: + """A sign error here is invisible to the Carnot guard, so it is pinned directly.""" + + def test_efficiency_falls_as_the_compressor_is_pushed(self, profile): + """An inverter gets LESS efficient the harder it runs. + + A fit with efficiency RISING with load extrapolates to COP 9.86 at full load and 35 C flow, + under Carnot's ceiling of 12.5 there - so the second-law guard cannot catch it. (The trap: + the F750's two minimum-frequency points differ by AIRFLOW, 108 vs 252 m3/h, not by load; + treating them as a load pair turns the physics upside down.) + """ + _, load_slope, _ = _house_for(profile).exergy_fit + + assert load_slope < 0, ( + f"{profile.model_name}'s exergy efficiency RISES with compressor load " + f"(slope {load_slope:+.3f}). A heat pump does not get more efficient by working " + f"harder. This is the sign error that produced COP 9.86, and the Carnot guard cannot " + f"catch it." + ) + + def test_hotter_water_costs_efficiency_beyond_carnot(self, profile): + """A real machine loses MORE than Carnot predicts when you raise the flow temperature.""" + _, _, flow_slope = _house_for(profile).exergy_fit + + assert flow_slope < 0, ( + f"{profile.model_name}'s exergy efficiency RISES with flow temperature " + f"(slope {flow_slope:+.4f}). Running hotter water is not free, and running COOLER " + f"water is the entire mechanism by which weather compensation saves money." + ) + + def test_no_machine_beats_carnot_anywhere_the_simulator_goes(self, profile): + house = _house_for(profile) + + for outdoor in (-20.0, -10.0, 0.0, 10.0): + for flow in (25.0, 35.0, 45.0, 55.0): + for load in (0.1, 0.5, 1.0): + cop = house.cop_at(outdoor, flow, load) + ceiling = house.carnot_cop(outdoor, flow) + assert cop <= ceiling, ( + f"{profile.model_name} at {outdoor:+.0f} C, {flow:.0f} C flow, " + f"{load:.0%} load: COP {cop:.2f} beats the Carnot limit {ceiling:.2f}." + ) + + +class TestTheHeatSourceIsNotTheWeather: + """Four of these five machines do not know what the weather is doing, and now nor does the model.""" + + @pytest.mark.parametrize("model", ["F750", "F730", "F1155", "S1155"]) + def test_a_pump_that_does_not_breathe_outdoor_air_has_a_flat_cop(self, model): + """The one that mattered most. An F1155's COP fell from 5.3 to 3.3 because of the WEATHER. + + Its heat source is brine from a borehole. NIBE's capacity chart plots its output against + "Incoming brine temp, C" and there is no air-temperature rating point in its datasheet at + all. An exhaust-air pump breathes 20 C house air. Neither cares about the sky. + """ + house = next(h for h in sim.HOUSES if h.profile.model_name == model) + + warm = house.cop_at(7.0, 40.0, 0.6) + freezing = house.cop_at(-20.0, 40.0, 0.6) + + assert warm == pytest.approx(freezing), ( + f"{model}'s COP moves from {warm:.2f} to {freezing:.2f} when the outdoor air goes from " + f"+7 C to -20 C, at the same flow temperature and the same load. Its heat source did " + f"not move. The old curve did exactly this, and the simulator priced a month of " + f"electricity with it." + ) + + def test_the_air_source_pump_is_the_only_one_that_does_care(self): + """And for the F2040 it is real, measured, and in the datasheet: COP 4.65 -> 2.68.""" + house = next(h for h in sim.HOUSES if h.profile.model_name == "F2040") + + assert house.cop_at(7.0, 35.0, 0.6) > house.cop_at(-7.0, 35.0, 0.6) * 1.2, ( + "The F2040's source IS the outdoor air. Its COP must fall with the weather - NIBE " + "publishes 4.65 at 7/35 and 2.68 at -7/35 - and it is the ONLY machine here for which " + "an outdoor-keyed curve was ever meaningful." + ) + + +class TestCapacityComesFromTheDatasheetToo: + """The 8 kW compressor that does not exist.""" + + def test_no_machine_can_make_more_than_it_is_published_to_make(self, profile): + house = _house_for(profile) + + for outdoor in (-20.0, -10.0, 0.0, 7.0, 15.0): + capacity = house.capacity_kw_at(outdoor) + assert capacity <= profile.max_heat_output_kw + 1e-9, ( + f"{profile.model_name} is modelled as making {capacity:.2f} kW at {outdoor:+.0f} C, " + f"above its published maximum of {profile.max_heat_output_kw:.2f} kW. The F750 was " + f"given 8.0 kW against a published 4.994, and it is the reason no exhaust-air pump " + f"has ever saturated in this simulator." + ) + + def test_the_exhaust_air_pumps_are_bounded_by_the_air_they_breathe(self): + """~5 kW, and it does not depend on the weather. It depends on the ventilation rate.""" + for model, published in (("F750", 4.994), ("F730", 5.35)): + house = next(h for h in sim.HOUSES if h.profile.model_name == model) + + assert house.capacity_kw_at(-20.0) == pytest.approx(published), ( + f"{model} must make {published} kW whatever the weather - its evaporator is fed by " + f"the house's own ventilation air at 20 C, and its output is set by the airflow." + ) + + def test_the_air_source_pumps_capacity_rises_as_it_gets_colder(self): + """It does not derate. It ramps up - an inverter throttled back at its mild rating point.""" + house = next(h for h in sim.HOUSES if h.profile.model_name == "F2040") + + mild, cold = house.capacity_kw_at(7.0), house.capacity_kw_at(-7.0) + + assert cold > mild, ( + f"The F2040 is modelled as making {cold:.2f} kW at -7 C and {mild:.2f} kW at +7 C. " + f"NIBE publishes 6.60 and 3.86: an inverter is throttled back at its mild rating point " + f"and ramps UP as the weather cools. The old model derated it 2.5 %/C and blamed the " + f"EN 14511 rating points, which say the opposite." + ) + + +class TestTheImmersionHeaterIsAlsoFromTheDatasheet: + """It was ONE invented number, applied to five machines, matching none of them. + + The simulator gave every house the same `AUX_STEP_KW = 3.0`. NIBE ships the F750 and F730 with a + 6.5 kW heater set to 3.5 kW at delivery, the F1155-12 and S1155-12 with a 7 kW heater in seven + automatic steps, and the F2040 with NO HEATER AT ALL - it is an outdoor monobloc, and the + electric addition belongs to the indoor module it is paired with. + """ + + def test_each_machine_carries_its_own_published_heater(self, profile): + published = { + "F750": 3.5, # "6.5 (3.5) kW" - max 6.5, delivery setting 3.5 + "F730": 3.5, + "F1155": 7.0, # additional power 1/2/3/4/5/6/7 kW + "S1155": 7.0, + "F2040": 0.0, # it has none + } + + assert profile.immersion_heater_kw == published[profile.model_name], ( + f"{profile.model_name}'s immersion heater is " + f"{profile.immersion_heater_kw} kW; its datasheet says " + f"{published[profile.model_name]} kW. One invented constant used to stand for all five." + ) + + def test_the_f2040_has_no_immersion_heater_at_all(self): + """Not "0 kW by default". The machine physically does not have one.""" + f2040 = NibeF2040Profile() + + assert f2040.immersion_heater_kw == 0.0 and not f2040.supports_aux_heating, ( + "The F2040 is an outdoor monobloc. Its technical-specifications table has no " + "immersion-heater row. The profile used to claim 'True # Larger immersion heaters'." + ) + + def test_the_simulator_falls_back_only_for_the_machine_that_has_none(self): + """And it names that fallback an ASSUMPTION, because it is one.""" + for house in sim.HOUSES: + published = house.profile.immersion_heater_kw + if published > 0: + assert house.immersion_heater_kw == published, ( + f"{house.name} is simulated with a {house.immersion_heater_kw} kW heater while " + f"its datasheet publishes {published} kW." + ) + else: + assert house.immersion_heater_kw == sim.ASSUMED_INDOOR_MODULE_HEATER_KW, ( + "the F2040's backup heat is an assumption about the paired indoor module, and " + "the constant that supplies it must say so in its name" + ) diff --git a/tests/validation/test_the_rulebook_describes_this_codebase.py b/tests/validation/test_the_rulebook_describes_this_codebase.py new file mode 100644 index 00000000..38703360 --- /dev/null +++ b/tests/validation/test_the_rulebook_describes_this_codebase.py @@ -0,0 +1,223 @@ +"""The document every contributor is told to read first must not describe a codebase that is gone. + +`CLAUDE.md` sends every contributor to `.github/copilot-instructions.md` as "the single source of +truth ... to be read at the start of every session", so a false claim there is an instruction, not +a documentation nit. This test reads the rulebook and holds it to the code: + + - it must not teach the removed Kuhne flow-temperature formula (F-119/F-121), nor a second, linear + flow rule, as live models - both were replaced by the EN 442 emitter law; + - every climate DM table and UFH prediction horizon it prints must be what const.py computes (the + table appears more than once, and an earlier fix corrected only one copy); + - every module it tells you to import must exist, and every research document it cites must be in + the repository (`docs/research/`), not one of the gitignored, absent ones (F-106). + +The docs here drifted because no test ever read one. Now one does. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from custom_components.effektguard import const +from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector + +ROOT = Path(__file__).resolve().parents[2] +RULEBOOK = ROOT / ".github" / "copilot-instructions.md" +DOC = RULEBOOK.read_text(encoding="utf-8") + +# What the rulebook ASSERTS, as opposed to what it warns you against. A document that says +# "do not reintroduce X" necessarily contains X, and must not trip the test that forbids X - the +# same trap that made an earlier pass of this file flag its own corrections. +DENIALS = ( + "has never existed", + "neither of which exists", + "There is **no", + "does not exist", + "does not have", + "not sourced", + # "used to X" - any past-tense correction; matching the phrase "used to " covers every verb, so + # a new correction ("used to offer") does not trip its own test. + "used to ", + "Do not reintroduce", + "no longer", +) + + +def _claims() -> str: + """What the rulebook ASSERTS, minus the paragraphs that warn you against something removed. + + PARAGRAPH-wise, not line-wise: a warning spans several lines ("This example used to show X ... + Do not reintroduce it.") and only one carries the marker, so a line filter would keep the rest + and trip the test on the very correction it is meant to protect. + """ + kept = [p for p in DOC.split("\n\n") if not any(d in p for d in DENIALS)] + return _strip_corrections("\n\n".join(kept)) + + +def _strip_corrections(text: str) -> str: + """ "(NOT -700)" is a correction, not a claim that the number is -700.""" + return re.sub(r"\(NOT\s*-?\d+\)", "", text) + + +# For "this thing was REMOVED, do not bring it back" checks. A warning must be allowed to name the +# thing it forbids, so the paragraphs that carry a denial are dropped. +CLAIMS = _claims() + +# For NUMBERS. Nothing is dropped, because a number is never legitimately wrong - not even inside a +# warning. Filtering denials here would hide a drifting second copy of the degree-minute table +# whose paragraph happens to contain "not sourced" (about DM -1500): the filter that protects the +# Kuhne check must not blind the climate check. +EVERY_WORD = _strip_corrections(DOC) + + +def test_the_rulebook_does_not_teach_a_formula_that_was_removed(): + """Kühne drove the flow temperature of a real heat pump, and was taken out for being wrong. + + Checked against everything the document ASSERTS - prose as much as code. Naming the formula in + a warning ("do not reintroduce this") is exactly what the file should do; crediting it under + "Research-Based", or copying it into a "✅ Do this" example, is what it must not. + """ + assert "Kühne" not in CLAIMS and "Kuhne" not in CLAIMS, ( + "The rulebook still credits André Kühne's flow-temperature formula. It appears ZERO times " + "in the codebase: it was removed (F-119/F-121) and replaced by the EN 442 emitter law, " + "because it was fed a heat-loss coefficient where the derivation needs a dimensionless " + "relative load. A contributor following the rulebook reintroduces it. " + "See docs/research/02_emitter_law.md." + ) + assert "2.55" not in CLAIMS, ( + "The Kühne coefficient 2.55 is still asserted somewhere in the rulebook. The flow " + "temperature comes from the EN 442 emitter law now - see utils/emitter.py and " + "docs/research/02_emitter_law.md." + ) + + +def test_the_rulebook_does_not_offer_a_second_flow_temperature_model(): + """A fixed "Flow = Outdoor + 27 °C" rule must not sit beside the emitter law as advice. + + It is offered in the rulebook as "OEM Research", in the document that tells contributors how to + implement - inviting someone to build a model this project does not have (there are no + OPTIMAL_FLOW_DELTA_SPF_* constants). The flow temperature comes from the EN 442 emitter law, + anchored on the house's own design point, not a fixed offset from the outdoor temperature. + """ + assert "Flow = Outdoor +" not in CLAIMS, ( + "The rulebook offers a linear flow-temperature rule (Flow = Outdoor + 27 °C) as OEM " + "research. The flow temperature comes from the EN 442 emitter law, anchored on the house's " + "own design point. There are no OPTIMAL_FLOW_DELTA_SPF_* constants; this describes a model " + "the code does not have." + ) + + +@pytest.mark.parametrize( + "city,latitude,outdoor", + [("Stockholm", 59.33, -10.0), ("Kiruna", 67.86, -30.0), ("Paris", 48.86, 5.0)], +) +def test_every_climate_number_in_the_rulebook_is_the_number_the_code_computes( + city, latitude, outdoor +): + """The same table appears twice in this file. An earlier fix corrected only one copy.""" + dm_range = ClimateZoneDetector(latitude=latitude).get_expected_dm_range(outdoor) + real = {round(v) for v in dm_range.values()} + + # Every degree-minute figure the rulebook prints on a line that names this city, wherever in + # the file that line appears. All of them must be numbers the code actually produces. + quoted = { + int(n) + for line in EVERY_WORD.splitlines() + if city in line + for n in re.findall(r"(-\d{3,4})\b", line) + } + + assert quoted, f"the rulebook no longer quotes a DM threshold for {city} at all" + + invented = quoted - real + assert not invented, ( + f"On a line naming {city}, the rulebook prints {sorted(invented)}. At {outdoor:.0f}°C the " + f"code produces {sorted(real)} (normal_min, normal_max, warning, critical). These are the " + f"numbers a maintainer reads to decide whether a degree-minute reading is safe - and this " + f"table appears more than once in the file, so correct EVERY copy." + ) + + +@pytest.mark.parametrize( + "emitter,constant", + [ + ("Concrete slab", "UFH_CONCRETE_PREDICTION_HORIZON"), + ("Timber", "UFH_TIMBER_PREDICTION_HORIZON"), + ("Radiators", "UFH_RADIATOR_PREDICTION_HORIZON"), + ], +) +def test_the_prediction_horizons_match_the_constants(emitter, constant): + """A slab plans over 24 hours, not 12. Six hours is its LAG, not its horizon.""" + real = int(getattr(const, constant)) + + line = next((ln for ln in EVERY_WORD.splitlines() if f"**{emitter}**" in ln), None) + assert line, f"the rulebook no longer describes {emitter}" + + quoted = re.findall(r"\*{0,2}(\d+)h\*{0,2} prediction horizon", line) + assert quoted, f"no prediction horizon quoted for {emitter}: {line.strip()!r}" + + assert int(quoted[0]) == real, ( + f"The rulebook says {emitter} uses a {quoted[0]}h prediction horizon; {constant} is " + f"{real}.0. For a concrete slab this is the difference between seeing a two-day cold slide " + f"and being blind to it (F-130)." + ) + + +def test_every_module_the_rulebook_tells_you_to_import_exists(): + """The "verify your work" snippet imports a module that has never existed.""" + imports = re.findall(r"from (custom_components\.effektguard[\w.]*) import", CLAIMS) + imports += re.findall(r"import (custom_components\.effektguard[\w.]*)", CLAIMS) + + missing = [] + for dotted in set(imports): + path = ROOT / (dotted.replace(".", "/") + ".py") + if not path.exists() and not (ROOT / dotted.replace(".", "/")).is_dir(): + missing.append(dotted) + + assert not missing, ( + f"The rulebook tells you to import {', '.join(sorted(missing))}, which does not exist. " + f"The thermal model lives in `optimization/thermal_layer.py` - every module in that " + f"package is `*_layer.py`." + ) + + +def test_the_research_pointers_point_at_research_that_is_in_the_repository(): + """ "Never guess NIBE behaviour, verify with research docs" - and then names absent documents.""" + absent = [ + name + for name in re.findall(r"`?([\w/]+\.md)`?", CLAIMS) + if "IMPLEMENTATION_PLAN" in name or "COMPLETED" in name + ] + absent += [ + name + for name in ( + "Forum_Summary.md", + "Swedish_NIBE_Forum_Findings.md", + "Setpoint_Optimizing_Algorithm.md", + "MyUplink_Complete_Guide.md", + "Mathematical_Enhancement_Summary.md", + "Enhancement_Proposals.md", + ) + if name in CLAIMS and not list(ROOT.rglob(name)) + ] + + assert not absent, ( + f"The rulebook's binding rule is 'never guess NIBE behaviour, verify with research docs', " + f"and it then cites {', '.join(sorted(set(absent)))} - none of which is in this repository " + f"(they are gitignored; audit F-106). The rule cannot be obeyed. `docs/research/` holds " + f"the sourced evidence: point at that." + ) + + +def test_the_rulebook_sends_you_to_the_research_that_does_exist(): + """Having removed the dangling citations, it has to name the real ones.""" + assert (ROOT / "docs" / "research").is_dir(), "docs/research/ is missing" + + assert "docs/research" in DOC, ( + "docs/research/ holds the sourced evidence for the safety limits - EN 442-1, EN 1264, the " + "F750 manual's menu 4.9.3, NIBE's own S735 tables - and the rulebook does not mention it. " + "That directory exists precisely so the 'verify with research' rule can be obeyed." + ) diff --git a/tests/validation/test_the_simulated_plant_obeys_physics.py b/tests/validation/test_the_simulated_plant_obeys_physics.py new file mode 100644 index 00000000..ca3d2fb6 --- /dev/null +++ b/tests/validation/test_the_simulated_plant_obeys_physics.py @@ -0,0 +1,306 @@ +"""The simulator is the instrument. An instrument that flatters what it measures produces numbers +people quote, so the harness `scripts/simulation/sim_harness.py` needs guards of its own. It once +shipped three defects, all reported as PASS, which this file now pins: + +1. THE ENERGY "AUDITS" WERE ALGEBRAIC IDENTITIES. `metered = power - aux - standby` against + `owed = q/cop`, where power was DEFINED as `q/cop + aux + standby` - x - y + y = x. Doubling the + compressor's COP left the audit reporting 0.00 % error and PASS. There is no exact energy audit + to be had inside a closed ODE plant; what CAN fail is a physical BOUND or a LEAK, and those are + what the harness asserts now. + +2. THE PLANT DESTROYED ENERGY IT HAD CHARGED FOR. The water node was force-clamped to the pump's + maximum AFTER the ODE integrated it, so joules vanished with no residual noticing - the immersion + heater pouring into a node already at its ceiling, the clamp deleting it. Real immersion heaters + have thermostats. + +3. THE PLANT INTEGRATED DEGREE MINUTES AGAINST A SETPOINT THE PUMP WAS FORBIDDEN TO REACH. `flow` + was clamped to max_flow_temp; `flow_target` was not. DM is the integral of (flow - flow_target), + so DM fell no matter what any controller did and the harness blamed the recovery ladder. A NIBE + limits its calculated supply temperature to the configured maximum; it does not chase water it + cannot make. This one inflated the evidence for F-124; the honest numbers now live in + test_a_saturated_compressor_is_a_positive_feedback_trap. +""" + +from __future__ import annotations + +import asyncio +import functools +import importlib.util +import pathlib +from dataclasses import replace + +import pytest + +from custom_components.effektguard.const import MAX_OFFSET + +_SPEC = importlib.util.spec_from_file_location( + "sim_harness", pathlib.Path("scripts/simulation/sim_harness.py") +) +sim = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(sim) + + +@pytest.fixture(params=[h.name for h in sim.HOUSES]) +def house(request): + return next(h for h in sim.HOUSES if h.name == request.param) + + +@functools.lru_cache(maxsize=1) +def _weather_and_prices(): + """The harness's own two-day self-test data. Enough to exercise the plant loop, and fast. + + A plain cache rather than a module-scoped fixture: pytest-homeassistant-custom-component + installs an autouse function-scoped event loop, and a module-scoped fixture in the same file + drags every test in it into a scope mismatch. + """ + times, temps, price_days, unit = sim.load_data(selftest=True) + return times, temps, sim.PriceSource(price_days, unit) + + +_SATURATING_HOUSE = "airsource_f2040" + + +@functools.lru_cache(maxsize=1) +def _the_only_run_that_reaches_the_immersion_heater() -> dict: + """An UNDERSIZED F2040 through a cold-snap month, cached: the run that saturates a pump. + + It is an outdoor-air machine, so it is the only one whose capacity collapses as the weather + does. The other four sail through a Swedish January without ever touching resistive heat, which + means a leak test run on them proves nothing about a plant that mishandles the heater - and the + first version of that test was run on exactly those, and passed on a broken plant. + + UNDERSIZED, because since the plant fires its elpatron at the pump's own start-addition + (menu 4.9.3) rather than EffektGuard's -1500 floor, a correctly-sized F2040 no longer + latches the emergency ladder - the hardware catches DM at about -760 and holds the house. + The saturation clamp this class tests only engages when the machine is genuinely beyond + its envelope, which is what average-climate sizing against a Swedish winter produces. + """ + times, temps, price_days, unit = sim.load_data(selftest=False) + house = next(h for h in sim.HOUSES if h.name == _SATURATING_HOUSE) + house = replace(house, hlc_w_per_k=house.hlc_w_per_k * sim.UNDERSIZED_PUMP_FACTOR) + try: + stats, _violations, _trace = sim.simulate( + house, + times, + sim.apply_coldsnap(times, temps), + sim.PriceSource(price_days, unit), + days=sim.SIM_DAYS, + ) + finally: + asyncio.set_event_loop(asyncio.new_event_loop()) + return stats + + +def _short_run(house, coldsnap: bool = False) -> dict: + """Drive the real plant for two days. + + The harness is a script: it drives the async engine with `asyncio.run`, which closes the loop + and leaves the thread without one. pytest-homeassistant-custom-component has an autouse fixture + that calls `asyncio.get_event_loop()`, so without putting a loop back every LATER test in the + run errors out in setup. Hand it a fresh one. + """ + times, temps, prices = _weather_and_prices() + if coldsnap: + temps = sim.apply_coldsnap(times, temps) + try: + stats, _violations, _trace = sim.simulate(house, times, temps, prices, days=2) + finally: + asyncio.set_event_loop(asyncio.new_event_loop()) + return stats + + +class TestTheCopModelIsBoundedByPhysicsAndByTheDatasheet: + """The two statements about efficiency that are NOT rearrangements of the plant's own books.""" + + @pytest.mark.parametrize("outdoor", [-30.0, -20.0, -10.0, 0.0, 7.0, 15.0]) + @pytest.mark.parametrize("flow", [25.0, 35.0, 45.0, 55.0, 65.0]) + def test_no_pump_beats_carnot(self, house, outdoor, flow): + """The second law. An external bound, so it can disagree with the model - and it must not.""" + cop = house.cop_at(outdoor, flow) + ceiling = house.carnot_cop(outdoor, flow) + + assert cop <= ceiling, ( + f"{house.name} at {outdoor:+.0f} C outdoor making {flow:.0f} C water has COP {cop:.2f}, " + f"above the Carnot limit of {ceiling:.2f} between those temperatures. No machine can do " + f"this, so the plant is inventing energy and every cost it reports is fiction." + ) + + def test_hotter_water_costs_efficiency(self, house): + """The mechanism weather compensation exists to exploit. A flow-blind COP cannot see it.""" + assert house.cop_at(-5.0, 55.0) < house.cop_at(-5.0, 35.0), ( + f"{house.name} makes 55 C water as efficiently as 35 C water. Running cooler water IS " + f"how weather compensation saves money - with a flow-blind COP the optimiser can only " + f"ever look like a loss, and it duly did." + ) + + def test_the_datasheet_check_lives_where_the_datasheet_does(self): + """Two tests used to live here, and BOTH rested on a COP model that was invented. + + They compared the plant against `profile.get_cop_at_temperature(outdoor)` - an outdoor-keyed + curve which, for four of the five machines, described a heat source that does not exist. The + F750's said COP 5.0 at +7 C outdoor. NIBE's datasheet has no such figure, and the outdoor + air never touches that machine's evaporator: its rating points are A20(12), twenty-degree + extract air from inside the house. + + They are replaced by tests/validation/test_the_pump_models_match_their_datasheets.py, which + checks something strictly stronger, against real data: the model reproduces every published + EN 14511 rating point to within 2 %, and PREDICTS the F2040's W45 rows - which the fit never + saw - to within 8 %. + + What stays in this file is the part that is a property of the PLANT rather than of the pump: + the second law, and the fact that hotter water costs efficiency. + """ + source = pathlib.Path( + "tests/validation/test_the_pump_models_match_their_datasheets.py" + ).read_text(encoding="utf-8") + + assert "def test_it_reproduces_every_point_it_was_fitted_on" in source, ( + "the datasheet reproduction test is gone, and this file no longer checks the COP model " + "against anything the manufacturer published" + ) + assert "def test_it_predicts_the_points_it_never_saw" in source, ( + "the held-out prediction test is gone. Reproducing a fit is not evidence; predicting " + "data the fit never saw is." + ) + + +class TestThePlantDoesNotDestroyEnergyItChargedFor: + """The clamp overwrites a state variable after the ODE integrated it. Nothing else can leak. + + Each test needs a PRECONDITION proving the mechanism it guards actually engaged (a leak test on + mild weather, where no pump reaches its immersion heater, passes on a plant with the thermostat + torn out), and it reads the real plant's output rather than recomputing the headroom formula and + asserting the result equals itself. + """ + + def test_the_pump_that_actually_reaches_its_immersion_heater_leaks_nothing(self): + """The F2040 in a deep cold snap: the ONE case that pins the water node at its ceiling. + + It is an outdoor-air pump, so it is the only one whose capacity collapses with the weather, + the only one that saturates, and the only one that falls back on resistive heat. + """ + stats = _the_only_run_that_reaches_the_immersion_heater() + + assert stats["aux_kwh"] > 0, ( + "PRECONDITION FAILED, and this is the important half: if the immersion heater never " + "ran, this test proves nothing about a plant that mishandles it - a leak test on mild " + "weather passes happily against a plant with the heater's thermostat removed." + ) + assert abs(stats["water_node_leak_kwh"]) <= sim.WATER_NODE_LEAK_BUDGET_KWH, ( + f"The F2040 burned {stats['aux_kwh']:.1f} kWh of immersion heat and the flow clamp " + f"destroyed {abs(stats['water_node_leak_kwh']):.1f} kWh of it: energy the meter charged " + f"for and the room never received. No energy residual in this harness can see that, " + f"because they are all rearrangements of the ODE that runs BEFORE the clamp." + ) + + @pytest.mark.parametrize("coldsnap", [False, True], ids=["mild", "coldsnap"]) + def test_no_house_leaks_in_ordinary_operation(self, house, coldsnap): + """The broad regression guard, across every pump. Cheap, and it covers the compressor side. + + It is NOT the test above: none of these runs reaches the immersion heater, which is why + that one exists and says so. + """ + stats = _short_run(house, coldsnap) + + assert abs(stats["water_node_leak_kwh"]) <= sim.WATER_NODE_LEAK_BUDGET_KWH, ( + f"{house.name} destroyed {abs(stats['water_node_leak_kwh']):.1f} kWh in the flow clamp " + f"without even reaching its immersion heater." + ) + + +class TestThePumpIsNeverAskedForWaterItCannotMake: + """The artifact that inflated the evidence for F-124. + + It reads `flow_target_max` off a real run - what the plant ACTUALLY asked the pump for - rather + than recomputing `min(uncapped, max_flow)` in the test body and asserting the result is <= + max_flow, which is true of arithmetic and never touches the plant's own (unclamped) S1. + """ + + def test_the_saturated_pump_is_never_asked_for_water_above_its_maximum(self): + """The F2040 in a cold snap, where the curve plus a +10 emergency offset overshoots. + + Degree minutes are the integral of (BT25 - S1). `flow` was clamped to max_flow_temp and + `flow_target` was not, so the plant integrated against a setpoint the pump was physically + forbidden to reach: DM fell regardless of the controller and floored on its own, and the + harness called it a control failure. + """ + stats = _the_only_run_that_reaches_the_immersion_heater() + house = next(h for h in sim.HOUSES if h.name == _SATURATING_HOUSE) + max_flow = float(house.profile.max_flow_temp) + + assert stats["offset_max"] >= MAX_OFFSET, ( + "PRECONDITION: this only bites when the emergency tier commands its maximum offset on " + "top of an already-steep curve. If the ladder never latched, the overshoot never " + "happened and this test is not exercising anything." + ) + assert stats["flow_target_max"] <= max_flow + 1e-6, ( + f"The plant asked the pump for {stats['flow_target_max']:.1f} C water, " + f"{stats['flow_target_max'] - max_flow:.1f} C above the {max_flow:.0f} C maximum it is " + f"allowed to make. Degree minutes integrate (BT25 - S1) and BT25 is capped, so DM then " + f"falls at {stats['flow_target_max'] - max_flow:.1f} per minute FOREVER - no controller " + f"can escape it, the integrator floors on its own, and the harness blames the recovery " + f"ladder for a defect in the plant." + ) + + def test_degree_minutes_only_run_away_when_the_pump_IS_saturated(self): + """They used to run away because the PLANT was chasing water the pump could not make. + + That was an artefact: `flow` was clamped to max_flow_temp and `flow_target` was not, so DM + integrated against an unreachable setpoint and floored on its own, whatever the controller + did. The test that stood here asserted DM never reaches the integrator floor again. + + IT DOES NOW, AND FOR A REAL REASON. With the pump models taken from the datasheets, the + F2040 genuinely cannot make the heat its house needs in a cold snap - NIBE declares it + bivalent below -9 C, with 1.1 kW of supplementary heat - so BT25 really does sit below S1 + and degree minutes really do collapse. That is the physics, not a plant bug. + + The invariant that distinguishes the two is the one above: the plant must never ASK for water + the pump cannot make. So this pins the artefact's cause, and lets the real symptom through. + """ + stats = _the_only_run_that_reaches_the_immersion_heater() + house = next(h for h in sim.HOUSES if h.name == _SATURATING_HOUSE) + + assert stats["flow_target_max"] <= float(house.profile.max_flow_temp) + 1e-6, ( + "the plant is asking for water the pump cannot make, which floors the integrator on its " + "own regardless of the controller - that is the artefact, and it is what this guards" + ) + assert stats["unavoidable_aux_kwh"] > 0, ( + "PRECONDITION: this pump must be genuinely saturated in this run, or the degree-minute " + "collapse below would be an artefact rather than a symptom" + ) + + +class TestTheHarnessCannotGoBackToBeingUnfalsifiable: + """A guard on the guards. Every one of these was, at some point, a number nobody asserted.""" + + def test_the_identity_audits_are_gone_and_stay_gone(self): + """They reported 0.00 % error on a plant that had doubled its own COP.""" + source = pathlib.Path("scripts/simulation/sim_harness.py").read_text(encoding="utf-8") + + for banned in ("compressor_elec_metered_kwh", "compressor_elec_owed_kwh"): + assert banned not in source, ( + f"`{banned}` is back. It is one half of `metered = power - aux - standby` against " + f"`owed = q/cop`, where power was DEFINED as q/cop + aux + standby - an identity " + f"dressed up as an audit. It cannot fail, so it cannot detect, and it spent several " + f"commits being quoted as evidence that the plant was sound." + ) + + def test_the_checks_that_can_fail_are_all_asserted(self): + """Counted-and-never-asserted is how this harness failed the first three times.""" + source = pathlib.Path("scripts/simulation/sim_harness.py").read_text(encoding="utf-8") + checked = source.split("def check_invariants")[1] + + for metric in ("water_node_leak_kwh", "datasheet_cop", "aux_kwh", "comfort_minutes_above"): + assert metric in checked, ( + f"`{metric}` is computed by the harness and never asserted in check_invariants. " + f"A number that is tracked and ignored is decoration: aux_kwh and the comfort " + f"minutes were both tracked and ignored while the optimiser overheated a house and " + f"burned resistive heat, and every run still printed PASS." + ) + + def test_carnot_is_asserted_during_the_run_not_merely_available(self): + source = pathlib.Path("scripts/simulation/sim_harness.py").read_text(encoding="utf-8") + + assert "cop_beats_carnot" in source and "cop_beats_carnot" in str( + sim.FATAL_VIOLATIONS + ), "the Carnot bound must be a FATAL violation raised per step, not a helper nobody calls" diff --git a/tests/validation/test_translation_key_parity.py b/tests/validation/test_translation_key_parity.py new file mode 100644 index 00000000..fe17a329 --- /dev/null +++ b/tests/validation/test_translation_key_parity.py @@ -0,0 +1,84 @@ +"""Every locale must carry exactly the keys strings.json declares. + +Home Assistant resolves a translation by key. A MISSING key falls back to the raw key or an empty +label; a STALE key is dead weight that quietly diverges. Neither is visible in a test run, in CI, +or in the UI of whoever wrote the change - only to the user in that language, and the primary +audience for this integration is Swedish. + +This has drifted before: options.py renamed sections and added fields, strings.json and en.json +were updated, and sv/no/da/fi were not - leaving Swedish users raw keys for the DHW target and +schedule fields that directly drive the heat pump. An empty-string value counts as a failure too: +it renders as a blank label, indistinguishable from a missing translation. +""" + +import json +from pathlib import Path + +import pytest + +COMPONENT = Path(__file__).resolve().parent.parent.parent / "custom_components" / "effektguard" +STRINGS = COMPONENT / "strings.json" +TRANSLATIONS = COMPONENT / "translations" + +LOCALES = ["en", "sv", "no", "da", "fi"] + + +def _leaf_keys(data: dict, prefix: str = "") -> dict[str, str]: + """Flatten a translation dict to {dotted.key: value}.""" + out: dict[str, str] = {} + for key, value in data.items(): + path = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + out.update(_leaf_keys(value, path)) + else: + out[path] = value + return out + + +def _load(path: Path) -> dict[str, str]: + return _leaf_keys(json.loads(path.read_text(encoding="utf-8"))) + + +@pytest.fixture(scope="module") +def reference() -> dict[str, str]: + return _load(STRINGS) + + +@pytest.mark.parametrize("locale", LOCALES) +def test_locale_has_no_missing_keys(locale, reference): + """A missing key renders as a raw key or a blank label in that language's UI.""" + translated = _load(TRANSLATIONS / f"{locale}.json") + + missing = sorted(set(reference) - set(translated)) + + assert not missing, ( + f"{locale}.json is missing {len(missing)} key(s) declared in strings.json. " + f"Users in this language see raw keys instead of labels.\n " + "\n ".join(missing) + ) + + +@pytest.mark.parametrize("locale", LOCALES) +def test_locale_has_no_stale_keys(locale, reference): + """A stale key is dead weight and a sign the file was not migrated with the code.""" + translated = _load(TRANSLATIONS / f"{locale}.json") + + stale = sorted(set(translated) - set(reference)) + + assert not stale, ( + f"{locale}.json carries {len(stale)} key(s) that no longer exist in strings.json. " + f"They are dead, and their presence means the file missed a rename.\n " + + "\n ".join(stale) + ) + + +@pytest.mark.parametrize("locale", LOCALES) +def test_locale_has_no_empty_values(locale): + """An empty string renders as a blank label - indistinguishable from a missing one.""" + translated = _load(TRANSLATIONS / f"{locale}.json") + + empty = sorted(key for key, value in translated.items() if not str(value).strip()) + + assert not empty, ( + f"{locale}.json has {len(empty)} empty translation value(s), which render as blank " + f"labels:\n " + "\n ".join(empty) + ) diff --git a/tests/validation/test_weather_compensation_has_no_dc_bias.py b/tests/validation/test_weather_compensation_has_no_dc_bias.py new file mode 100644 index 00000000..a4b61119 --- /dev/null +++ b/tests/validation/test_weather_compensation_has_no_dc_bias.py @@ -0,0 +1,184 @@ +"""Weather compensation must command ~zero on a curve that is already correct. + +A layer that adds a constant to every decision is not a controller, it is a bias - the removed +Kuehne model carried a persistent negative one that under-heated the house while presenting the +shortfall as savings. The direction is not what made it a bug; being a bias is. + +So this asserts the property the failure shared with its replacement: with the house exactly on +target, degree minutes healthy, a steady forecast, and the pump's own curve already delivering what +the emitter law asks for, there is nothing to correct and the offset must be ~0. The climate-zone +safety margin is what breaks this - it exists to pull up a curve running COLD in a hard winter, but +adding it unconditionally tells a perfectly-tuned curve to add heat too. A margin is permission to +run warm, not an instruction to. +""" + +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +import pytest + +from custom_components.effektguard.const import DEFAULT_HEAT_LOSS_COEFFICIENT, INTERNAL_GAINS_W +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.adapters.weather_adapter import ( + WeatherData, + WeatherForecastHour, +) +from custom_components.effektguard.models.nibe import NibeF750Profile +from custom_components.effektguard.optimization.decision_engine import DecisionEngine +from custom_components.effektguard.optimization.effect_layer import EffectManager +from custom_components.effektguard.optimization.price_layer import PriceAnalyzer +from custom_components.effektguard.optimization.thermal_layer import ThermalModel + +TARGET_INDOOR = 22.0 +DESIGN_OUTDOOR = -15.0 +DESIGN_FLOW = 50.0 +DESIGN_SPREAD = 5.0 +EMITTER_EXPONENT = 1.3 + +# The correction that remains when there is genuinely nothing to correct. Not zero, because the +# pump's curve is quantised and the emitter law is continuous - but a fraction of one offset step. +NO_CORRECTION_NEEDED = 0.35 + +NOW = datetime(2026, 1, 15, 12, 0) + + +def _emitter_law_flow(outdoor: float) -> float: + """The flow a PERFECTLY tuned curve delivers - taken from OpenEnergyMonitor, not from us. + + A reference has to come from outside, or it reproduces the code's own bug. This is + OpenEnergyMonitor's weather-compensation tool (weathercomp.js): + + DT = (heat_demand / rated_emitter_output_dt50) ** (1/1.3) * 50 + flowT = room_temperature + DT + systemDT * 0.5 <- systemDT, NOT systemDT * phi + + The flow-return spread is CONSTANT (a heat pump modulates its circulator). Anchored on our + design point rather than theirs, which is the same equation rewritten. + """ + balance = TARGET_INDOOR - INTERNAL_GAINS_W / DEFAULT_HEAT_LOSS_COEFFICIENT + load = balance - outdoor + design_load = balance - DESIGN_OUTDOOR + design_excess = DESIGN_FLOW - DESIGN_SPREAD / 2 - TARGET_INDOOR + phi = load / design_load + return TARGET_INDOOR + design_excess * phi ** (1 / EMITTER_EXPONENT) + DESIGN_SPREAD / 2 + + +@pytest.fixture +def engine() -> DecisionEngine: + config = { + "target_indoor_temp": TARGET_INDOOR, + "tolerance": 0.5, + "optimization_mode": "balanced", + "enable_weather_compensation": True, + "enable_peak_protection": True, + "enable_price_optimization": True, + "latitude": 59.33, + "heating_type": "radiator", + "heat_loss_coefficient": 150.0, + "thermal_mass": 0.7, + "insulation_quality": 1.0, + } + return DecisionEngine( + price_analyzer=PriceAnalyzer(), + effect_manager=EffectManager(MagicMock()), + thermal_model=ThermalModel(0.7, 1.0), + config=config, + heat_pump_model=NibeF750Profile(), + ) + + +def _offset_on_a_perfect_curve(engine: DecisionEngine, outdoor: float) -> float: + flow = _emitter_law_flow(outdoor) + forecast = [ + WeatherForecastHour(datetime=NOW + timedelta(hours=h), temperature=outdoor) + for h in range(1, 49) + ] + state = NibeState( + outdoor_temp=outdoor, + indoor_temp=TARGET_INDOOR, + supply_temp=round(flow, 1), + return_temp=round(flow - DESIGN_SPREAD, 1), + degree_minutes=-30.0, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=NOW, + compressor_hz=50, + power_kw=2.0, + ) + return engine.weather_comp_layer.evaluate_layer( + nibe_state=state, + weather_data=WeatherData( + current_temp=outdoor, forecast_hours=forecast, source_entity="test" + ), + target_temp=TARGET_INDOOR, + ).offset + + +def test_no_dc_bias_when_the_curve_is_already_perfect(engine): + """The pump is delivering exactly the emitter law's answer. Ask for nothing.""" + biased = [] + for outdoor in (10.0, 5.0, 0.0, -5.0, -10.0, -15.0, -20.0): + offset = _offset_on_a_perfect_curve(engine, outdoor) + if abs(offset) > NO_CORRECTION_NEEDED: + biased.append( + f"{outdoor:+.0f} C: curve delivers {_emitter_law_flow(outdoor):.2f} C, exactly " + f"what the emitter law asks - yet the layer commands {offset:+.2f}" + ) + + assert not biased, ( + "Weather compensation carries a DC bias: it corrects a curve that needs no correction " + f"(tolerance +/-{NO_CORRECTION_NEEDED}):\n " + "\n ".join(biased) + ) + + +def test_the_bias_does_not_merely_average_out(engine): + """A bias that cancels across the range would be noise; one that does not is a setback. + + The sign is irrelevant - a persistent +1.5 C over-heats the house and raises the bill just as + reliably as a negative bias under-heats it and lowers it. + """ + walk = [10.0, 5.0, 0.0, -5.0, -10.0, -15.0, -20.0] + offsets = [_offset_on_a_perfect_curve(engine, t) for t in walk] + mean = sum(offsets) / len(offsets) + + assert abs(mean) <= NO_CORRECTION_NEEDED, ( + f"Mean offset {mean:+.2f} C across the operating range on a perfectly tuned curve. " + f"This is a permanent setback, not a correction. Offsets: " + + ", ".join(f"{t:+.0f}C:{o:+.2f}" for t, o in zip(walk, offsets)) + ) + + +def test_a_cold_curve_is_still_pulled_up(engine): + """The margin's safety purpose must survive: an under-supplying curve gets corrected.""" + outdoor = -15.0 + short_by = 4.0 + flow = _emitter_law_flow(outdoor) - short_by + forecast = [ + WeatherForecastHour(datetime=NOW + timedelta(hours=h), temperature=outdoor) + for h in range(1, 49) + ] + state = NibeState( + outdoor_temp=outdoor, + indoor_temp=TARGET_INDOOR, + supply_temp=round(flow, 1), + return_temp=round(flow - DESIGN_SPREAD, 1), + degree_minutes=-30.0, + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=NOW, + compressor_hz=50, + power_kw=2.0, + ) + offset = engine.weather_comp_layer.evaluate_layer( + nibe_state=state, + weather_data=WeatherData( + current_temp=outdoor, forecast_hours=forecast, source_entity="test" + ), + target_temp=TARGET_INDOOR, + ).offset + + assert offset > 1.0, ( + f"A curve running {short_by:.0f} C COLD at the design temperature must be pulled up. " + f"The layer commands {offset:+.2f}." + ) diff --git a/tests/validation/test_weather_compensation_is_not_anti_compensation.py b/tests/validation/test_weather_compensation_is_not_anti_compensation.py new file mode 100644 index 00000000..54caa92d --- /dev/null +++ b/tests/validation/test_weather_compensation_is_not_anti_compensation.py @@ -0,0 +1,186 @@ +"""Weather compensation must ask for a flow temperature that can actually heat the house. + +The "Math WC" layer is enabled on every installation (decision_engine reads +`config.get("enable_weather_compensation", True)`; no config-flow option switches it off). It can +still take the early exit `if not weather_data or not weather_data.forecast_hours: weight=0.0`, so +an installation with a blank weather entity runs with it silently disabled - see +tests/unit/optimization/test_the_core_control_law_does_not_need_a_forecast.py. + +The test house is the standard Swedish low-temperature radiator design: 22 C indoor, 150 W/K, 50 C +supply at the -15 C design outdoor. At -15 C the emitters MUST run at 50 C or the house cannot hold +22 C - a matter of the emitter law, not opinion. The removed Kuehne model targeted far less (its +curve rose only ~0.22 C of supply per -1 C outdoor where this house needs 0.76), cutting hardest +exactly when the house needs heat most; and nothing downstream catches it, because lowering the +offset lowers S1 and DM = integral(BT25 - S1), so degree minutes IMPROVE as the house cools (F-120). + +These tests assert properties ANY correct model has, so they outlive the model that satisfies them: + ADEQUACY - at the design outdoor temperature the flow target must be able to heat the house. + NO CUTS - with the house on target and the curve already correct, the layer must not take heat + away. It is a trim, not a replacement curve. + BOUNDED - the correction stays inside WEATHER_COMP_MAX_OFFSET in both directions. +""" + +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +import pytest + +from custom_components.effektguard.adapters.nibe_adapter import NibeState +from custom_components.effektguard.const import WEATHER_COMP_MAX_OFFSET +from custom_components.effektguard.adapters.weather_adapter import ( + WeatherData, + WeatherForecastHour, +) +from custom_components.effektguard.models.nibe import NibeF750Profile +from custom_components.effektguard.optimization.decision_engine import DecisionEngine +from custom_components.effektguard.optimization.effect_layer import EffectManager +from custom_components.effektguard.optimization.price_layer import PriceAnalyzer +from custom_components.effektguard.optimization.thermal_layer import ThermalModel + +# The test house: standard Swedish low-temperature radiator design. +TARGET_INDOOR = 22.0 +DESIGN_OUTDOOR = -15.0 +DESIGN_FLOW = 50.0 # supply needed at DESIGN_OUTDOOR to hold TARGET_INDOOR +HEAT_LOSS_COEFFICIENT = 150.0 + +# A correctly tuned curve for that house: flow(-15) == 50, and flow == room when no heat is +# needed (outdoor == room). Slope = (50 - 22) / (22 - -15) = 0.757 C of supply per C outdoor. +CURVE_SLOPE = (DESIGN_FLOW - TARGET_INDOOR) / (TARGET_INDOOR - DESIGN_OUTDOOR) + +# How far below the design flow the model may fall at the design point before the house can no +# longer be heated. Generous: the true shortfall under Kuehne is 18.3 C. +DESIGN_FLOW_SHORTFALL_ALLOWED = 2.0 + +# The deepest heat CUT that can be justified while the house sits exactly on target and the curve +# already delivers what the house needs - which is to say, almost none. A small POSITIVE trim is +# not bounded here: adding heat is the safe direction, and WEATHER_COMP_MAX_OFFSET already caps +# the magnitude in both directions. What must never happen is the layer taking heat AWAY from a +# house that is exactly where it should be. +MAX_DEFENSIBLE_CUT = -1.0 + +NOW = datetime(2026, 1, 15, 12, 0) + + +def _correct_curve_flow(outdoor: float) -> float: + """Supply temperature the correctly tuned curve delivers at this outdoor temperature.""" + return TARGET_INDOOR + CURVE_SLOPE * (TARGET_INDOOR - outdoor) + + +@pytest.fixture +def engine() -> DecisionEngine: + config = { + "target_indoor_temp": TARGET_INDOOR, + "tolerance": 0.5, + "optimization_mode": "balanced", + "enable_weather_compensation": True, + "enable_peak_protection": True, + "enable_price_optimization": True, + "latitude": 59.33, + "heating_type": "radiator", + "heat_loss_coefficient": HEAT_LOSS_COEFFICIENT, + "thermal_mass": 0.7, + "insulation_quality": 1.0, + } + return DecisionEngine( + price_analyzer=PriceAnalyzer(), + effect_manager=EffectManager(MagicMock()), + thermal_model=ThermalModel(0.7, 1.0), + config=config, + heat_pump_model=NibeF750Profile(), + ) + + +def _evaluate(engine: DecisionEngine, outdoor: float): + """Math WC's decision with the house on target and the curve already correct. + + The layer is evaluated directly rather than fished out of `decision.layers`, because the + aggregate flattens each layer into a `LayerDecision` that carries only name/offset/weight and + drops `optimal_flow_temp` - the flow target is exactly what these tests need to see. + + The outdoor temperature is steady (flat forecast), so nothing the layer does here can be a + legitimate response to weather that is about to change. + """ + forecast = [ + WeatherForecastHour(datetime=NOW + timedelta(hours=h), temperature=outdoor) + for h in range(1, 49) + ] + flow = _correct_curve_flow(outdoor) + state = NibeState( + outdoor_temp=outdoor, + indoor_temp=TARGET_INDOOR, # exactly on target + supply_temp=round(flow, 1), + return_temp=round(flow - 5.0, 1), + degree_minutes=-30.0, # healthy + current_offset=0.0, + is_heating=True, + is_hot_water=False, + timestamp=NOW, + compressor_hz=50, + power_kw=2.0, + ) + return engine.weather_comp_layer.evaluate_layer( + nibe_state=state, + weather_data=WeatherData( + current_temp=outdoor, forecast_hours=forecast, source_entity="test" + ), + target_temp=TARGET_INDOOR, + ) + + +def test_flow_target_at_design_temperature_can_actually_heat_the_house(engine): + """At the design outdoor temperature the flow target must be able to heat the house. + + This is the decisive invariant and it needs no arbitrary threshold: at -15 C this house + requires 50 C of supply to hold 22 C. A weather-compensation model that targets less than + that is asking the emitters to deliver the design heat load at below the design temperature, + which the emitter law forbids. Whatever model is used, it must clear its own design point. + """ + layer = _evaluate(engine, DESIGN_OUTDOOR) + target_flow = layer.optimal_flow_temp + + assert target_flow >= DESIGN_FLOW - DESIGN_FLOW_SHORTFALL_ALLOWED, ( + f"At the {DESIGN_OUTDOOR:.0f} C design temperature this house needs {DESIGN_FLOW:.1f} C " + f"of supply to hold {TARGET_INDOOR:.0f} C indoor. Weather compensation targets " + f"{target_flow:.1f} C - a {DESIGN_FLOW - target_flow:.1f} C shortfall - and so commands " + f"{layer.offset:+.2f} C of curve offset at the coldest hour of the winter." + ) + + +def test_compensation_never_cuts_heat_from_a_house_that_is_already_correct(engine): + """The layer must not take heat AWAY from a house on target with a correct curve. + + This is the defect itself, stated as an invariant. Across the whole operating range the pump + is already delivering exactly what the house needs, so there is nothing to cut - and the + colder it gets, the less defensible a cut becomes. Kuehne cut deeper and deeper: -2.71 at + +10 C, -6.09 at 0 C, -11.06 at -15 C. + + A small POSITIVE trim is fine and is not failed here; adding heat is the safe direction, and + WEATHER_COMP_MAX_OFFSET bounds the magnitude both ways. + """ + cuts = [] + for outdoor in (10.0, 5.0, 0.0, -5.0, -10.0, -15.0, -20.0): + layer = _evaluate(engine, outdoor) + if layer.offset < MAX_DEFENSIBLE_CUT: + cuts.append( + f"{outdoor:+.0f} C: curve delivers {_correct_curve_flow(outdoor):.1f} C, " + f"layer wants only {layer.optimal_flow_temp:.1f} C, commands {layer.offset:+.2f}" + ) + + assert not cuts, ( + "Weather compensation cuts heat from a house that is exactly on target with a correctly " + f"tuned curve (deepest defensible cut {MAX_DEFENSIBLE_CUT:+.1f} C):\n " + "\n ".join(cuts) + ) + + +def test_compensation_offset_is_bounded(engine): + """The correction is a trim and stays inside its declared bound, in both directions. + + An unbounded offset is how a mis-configured design point turns into a large swing at the + pump. The old implementation had no clamp at all and could return -11.4. + """ + for outdoor in (15.0, 10.0, 0.0, -10.0, -20.0, -30.0): + offset = _evaluate(engine, outdoor).offset + assert abs(offset) <= WEATHER_COMP_MAX_OFFSET + 1e-9, ( + f"At {outdoor:+.0f} C weather compensation commands {offset:+.2f} C, outside its " + f"declared bound of +/-{WEATHER_COMP_MAX_OFFSET:.1f} C." + )