diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 318ebbfe..438fde55 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -77,10 +77,15 @@ climate_detector = ClimateZoneDetector(latitude=59.33) # Stockholm example outdoor_temp = -10.0 dm_range = climate_detector.get_expected_dm_range(outdoor_temp) -# Thresholds automatically adapt to location and conditions: -# Stockholm at -10°C: normal_min=-450, normal_max=-700, warning=-700, critical=-1500 -# Kiruna at -30°C: normal_min=-800, normal_max=-1200, warning=-1200, critical=-1500 -# Paris at 5°C: normal_min=-200, normal_max=-350, warning=-350, critical=-1500 +# Thresholds automatically adapt to location and conditions. +# VERIFIED against ClimateZoneDetector.get_expected_dm_range(), 2026-07-12: +# Stockholm (59.33) at -10°C: normal_min=-490, normal_max=-740, warning=-740, critical=-1500 +# Kiruna (67.86) at -30°C: normal_min=-1000, normal_max=-1400, warning=-1400, critical=-1500 +# Paris (48.86) at +5°C: normal_min=-100, normal_max=-250, warning=-250, critical=-1500 +# +# NOTE: normal_max == warning in every zone (DM_CRITICAL_T1_MARGIN is 0), so the WARNING band +# has ZERO WIDTH and is unreachable. That is a known open finding, not a typo - do not "fix" +# the table to hide it. if degree_minutes < dm_range["critical"]: # Always -1500 (absolute maximum) # Emergency recovery mode @@ -91,32 +96,42 @@ elif degree_minutes < dm_range["warning"]: # Zone-specific warning threshold if degree_minutes < -500: # DANGEROUS - ignores climate context! ``` -**Key Safety Constants:** +**Key Safety Constants:** (these two are the only DM thresholds that are constants) ```python from .const import ( - DM_THRESHOLD_START, # -60 (normal compressor start) - DM_THRESHOLD_EXTENDED, # -240 (extended runs, acceptable) - DM_THRESHOLD_AUX_LIMIT, # -1500 (auxiliary heat limit, avoid exceeding) + DM_THRESHOLD_START, # -60 (normal compressor start, NIBE menu 4.9.3) + DM_THRESHOLD_AUX_LIMIT, # -1500 (auxiliary heat limit) ) +``` + +There is **no `DM_THRESHOLD_WARNING` and no `DM_THRESHOLD_EXTENDED`.** The warning threshold is +**computed per climate zone and outdoor temperature**, not stored: -# Climate zone system provides context-aware thresholds: -# - DM_THRESHOLD_WARNING: Climate/temp specific (e.g., -700 for Stockholm at -10°C) -# - DM_THRESHOLD_AUX_LIMIT: Always -1500 (validated in Swedish forums) +```python +dm_range = climate_detector.get_expected_dm_range(outdoor_temp) +dm_range["warning"] # Stockholm at -10°C: -740 (NOT -700) +dm_range["critical"] # always -1500 ``` +⚠️ On the owner's F750 the pump's own **"start addition"** (menu 4.9.3) fires at **-700** by +default - *before* EffektGuard's Stockholm warning threshold of -740 is ever reached. The +auxiliary heater engages first and works DM back up. Do not reason about DM -1500 as if the +elpatron were not there; see F-112/F-129 in the audit. + ### Four-Layer Structure 1. **Integration Layer** (`custom_components/effektguard/`): Home Assistant-specific - Config flow for user setup (`config_flow.py`) - - Entity creation (`climate.py`, `sensor.py`, `number.py`, etc.) + - Entity creation (`climate.py`, `sensor.py`, `switch.py` - those three platforms + only; Number and Select were deliberately removed, see PLATFORMS in `__init__.py`) - Coordinator for data updates (`coordinator.py`) - Service registration (`services.yaml`) 2. **Optimization Engine** (`optimization/`): Pure Python logic - - Energy budget management (`effect_manager.py`) - - Thermal modeling (`thermal_model.py`) + - Energy budget management (`effect_layer.py`) + - Thermal modeling (`thermal_layer.py`) - Multi-layer decision engine (`decision_engine.py`) - - Price analysis (`price_analyzer.py`) + - Price analysis (`price_layer.py`) - All return domain objects, no HA dependencies 3. **Data Adapters** (`adapters/`): External integration interfaces @@ -125,11 +140,14 @@ from .const import ( - Weather forecast reader (`weather_adapter.py`) - All read from existing HA entities, no direct API calls -4. **Validation Layer** (`utils/validators.py`): Configuration safety - - Pump configuration detection (open-loop requires Auto mode) - - System type identification (open-loop vs buffered vs mixed) - - UFH type detection (concrete slab vs timber vs radiator) - - Critical warnings that block activation +4. **Utilities** (`utils/`): `compressor_monitor.py` (frequency/wear risk), `emitter.py` + (EN 442 / EN 1264 emitter law), `time_utils.py`, `volatile_helpers.py` + + There is **no validation layer**. Config-time validation lives in `config_flow.py`; + run-time safety lives in `DecisionEngine._safety_layer()` (the comfort floor/ceiling, a + method - not a file) and in `EmergencyLayer` in `optimization/thermal_layer.py` (degree-minute + thermal debt, tiered recovery). Do not go looking for `utils/validators.py` - it has never + existed. **Data Flow:** ``` @@ -138,7 +156,9 @@ Optimization Engine → Decision → Climate Entity → NIBE Offset Control ``` **Cross-Cutting:** -- **Safety** (`utils/safety.py`): Thermal debt tracking, emergency recovery +- **Safety**: `DecisionEngine._safety_layer()` (comfort floor/ceiling) and `EmergencyLayer` in + `optimization/thermal_layer.py` (degree-minute thermal debt, tiered recovery). + There is no `utils/safety.py` and no `optimization/safety_layer.py`. - **Configuration** (`config_flow.py`): Validation, warnings, guided setup - **Constants** (`const.py`): All thresholds, defaults, entity IDs patterns @@ -155,19 +175,25 @@ Before editing, use `read_file` for entire file. Understand heat pump context, i ```python # ✅ Do this from .const import ( - DM_THRESHOLD_CRITICAL, + DM_THRESHOLD_AUX_LIMIT, + DEFAULT_CURVE_SENSITIVITY, UFH_CONCRETE_PREDICTION_HORIZON, - OPTIMAL_FLOW_DELTA_SPF_4, ) if ufh_type == "concrete_slab": - horizon = UFH_CONCRETE_PREDICTION_HORIZON # 12.0 hours + horizon = UFH_CONCRETE_PREDICTION_HORIZON # 24.0 hours # ❌ Never this if ufh_type == "concrete_slab": - horizon = 12.0 # Hardcoded! + horizon = 24.0 # Hardcoded! ``` +Every name above is real; import them and see. This example used to name +`DM_THRESHOLD_CRITICAL` and `OPTIMAL_FLOW_DELTA_SPF_4`, **neither of which exists**, and +annotated the horizon as 12.0 when the constant is 24.0 - so the "correct" branch produced +the same wrong number as the "hardcoded" one it was warning against. Check names against +`const.py` before repeating them here. + ### Safety Thresholds Are Non-Negotiable ```python @@ -226,8 +252,8 @@ grep -rE "\* -?[0-9]+\.[0-9]+" custom_components/effektguard/ | grep -v "const.p grep -r "0.4" custom_components/effektguard/ # Example: tolerance multiplier grep -r "WARNING_DEVIATION_THRESHOLD" custom_components/effektguard/ # Should be imported -# Test imports -python3 -c "from custom_components.effektguard.optimization.thermal_model import ThermalModel" +# Test imports (every module in optimization/ is *_layer.py - there is no thermal_model.py) +python3 -c "from custom_components.effektguard.optimization.thermal_layer import ThermalModel" # Run Black formatting black custom_components/effektguard/ --check --line-length 100 @@ -268,7 +294,7 @@ def check_deviation(deviation: float) -> str: ``` **Constant Reuse Across Files:** -- Production code (`decision_engine.py`, `thermal_model.py`, etc.) imports from `const.py` +- Production code (`decision_engine.py`, `thermal_layer.py`, etc.) imports from `const.py` - Test code (`tests/`, `scripts/test_decision_scenarios.py`) imports SAME constants - NO duplicate definitions - single source of truth - If constant exists, use it everywhere applicable @@ -307,7 +333,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator # Local (relative imports) -from .const import DOMAIN, DM_THRESHOLD_CRITICAL +from .const import DOMAIN, DM_THRESHOLD_AUX_LIMIT from .optimization.decision_engine import DecisionEngine ``` @@ -394,12 +420,14 @@ def calculate_preheating_target( Target indoor temperature for pre-heating phase (°C) Notes: - Based on research showing load shifting without battery is ineffective. - Uses moderate pre-heating to prevent thermal debt (DM < -500 catastrophic). - + Six hours is the slab's LAG, not its horizon: it is only ~19% charged at 14 h and + ~29% at 24 h (slow time constant ~70 h), so a cold snap has to be seen at least a + day out, not six hours out. + References: - - Forum_Summary.md: stevedvo's thermal debt case study - - Enhancement_Proposals.md: Thermal model mathematics + - docs/research/03_concrete_slab_response.md: the two-node transient, why the + horizon is 24 h, and the OPEN pre-heat sizing question (owner decision) + - docs/research/01_degree_minutes.md: why DM cannot see under-heating we cause """ ``` @@ -623,11 +651,15 @@ plt.savefig("docs/dev/debug_issue_description.png", dpi=150) - `const.py` - ALL constants and thresholds - `climate.py` - Main climate entity -**Optimization:** -- `optimization/decision_engine.py` - Multi-layer decision logic -- `optimization/thermal_model.py` - Thermal calculations -- `optimization/effect_manager.py` - Peak tracking (15-minute windows) -- `optimization/price_analyzer.py` - Spot price classification +**Optimization:** (every module here is `*_layer.py`, not `*_model/_manager/_analyzer.py`) +- `optimization/decision_engine.py` - Multi-layer decision logic; also holds `_safety_layer()` +- `optimization/thermal_layer.py` - `ThermalModel`, `EmergencyLayer` (DM debt), `ProactiveLayer` +- `optimization/effect_layer.py` - Peak tracking (15-minute windows) +- `optimization/price_layer.py` - Spot price classification +- `optimization/comfort_layer.py`, `weather_layer.py`, `prediction_layer.py` +- `optimization/adaptive_learning.py` - Observing only; does not drive control (see F-132) +- `optimization/dhw_optimizer.py`, `airflow_optimizer.py`, `climate_zones.py`, + `savings_calculator.py`, `weather_learning.py` **Adapters:** - `adapters/nibe_adapter.py` - Read NIBE MyUplink entities @@ -635,8 +667,13 @@ plt.savefig("docs/dev/debug_issue_description.png", dpi=150) - `adapters/weather_adapter.py` - Read weather forecast **Safety:** -- `utils/validators.py` - Configuration validation -- `utils/safety.py` - Thermal debt tracking +- `DecisionEngine._safety_layer()` in `optimization/decision_engine.py` - comfort floor/ceiling +- `EmergencyLayer` in `optimization/thermal_layer.py` - degree-minute thermal debt, tiered recovery +- `utils/compressor_monitor.py` - compressor frequency and wear risk + +**Utilities:** +- `utils/emitter.py` - EN 442 / EN 1264 emitter law +- `utils/time_utils.py`, `utils/volatile_helpers.py` --- @@ -682,7 +719,7 @@ Format: black applied" ❌ **Editing without full NIBE context** - Read research docs first ❌ **Hardcoding safety thresholds** - Use constants from `const.py` -❌ **Guessing NIBE behavior** - Verify with Forum_Summary.md or similar +❌ **Guessing NIBE behavior** - Verify against `docs/research/` (or the NIBE manual it cites) ❌ **Incomplete refactoring** - Update ALL callers including tests ❌ **Forgetting safety validation** - All optimization must respect thermal debt ❌ **Skipping Black formatting** - Always format before commit @@ -711,66 +748,82 @@ DM_CRITICAL = -500 # Line 18 DM_CRITICAL = -400 # Line 26 (different value!) # ✅ One definition in const.py -DM_THRESHOLD_CRITICAL = -500 # stevedvo case study +DM_THRESHOLD_AUX_LIMIT = -1500 # auxiliary heat limit ``` ### Document NIBE-Specific Calculations ```python -# ✅ Show NIBE research basis -# Open-loop UFH prediction horizon (glyn.hudson case study) -# Concrete slab: 6+ hours thermal lag observed -# Requires 12-hour prediction for proper pre-heating -UFH_CONCRETE_PREDICTION_HORIZON = 12.0 # hours +# ✅ Show the research basis +# Concrete slab: 6+ hours of conduction lag from pipe to floor surface, but the slab is only +# ~19% charged at 14 h and ~29% at 24 h (slow time constant ~70 h). Six hours is the LAG; +# twenty-four is the MINIMUM horizon. Confirmed on the owner's slab (2-node, 100 mm + 60 mm). +UFH_CONCRETE_PREDICTION_HORIZON = 24.0 # hours # ❌ No context -HORIZON = 12.0 +HORIZON = 24.0 ``` +(This example used to say `12.0`, which is simply not the value - a doc that teaches you to +document your reasoning while getting its own number wrong. Check `const.py`.) + ### Reference Research Documents ```python # ✅ Good - traceable to research -# Thermal debt threshold based on stevedvo's F2040 real-world failure -# DM -500 caused: 15kW spikes, 10°K overshoot, catastrophic inefficiency -# Swedish term: Gradminuter (GM), NIBE Menu 4.9.3 -# Swedish auxiliary optimization: -1000 to -1500 (prevents excessive aux heat) -# Source: Forum_Summary.md, Swedish_NIBE_Forum_Findings.md -DM_THRESHOLD_CRITICAL = -500 -DM_THRESHOLD_AUX_SWEDISH = -1500 +# Degree minutes: DM = integral(BT25 - S1) dt. Swedish: Gradminuter (GM), NIBE menu 4.9.3. +# "start addition" (the immersion heater) defaults to -700 on the F750, range -2000..-30; +# it engages there deliberately, to spare the compressor, and works DM back UP. +# Source: NIBE F750 Installer Manual IHB GB 1301-1 (231236), menu 4.9.3 +DM_THRESHOLD_AUX_LIMIT = -1500 # absolute floor, never to be reached in normal operation # ❌ Bad - no justification -DM_THRESHOLD_CRITICAL = -500 # Don't go below this +DM_THRESHOLD_AUX_LIMIT = -1500 # Don't go below this ``` ### Black Formatting in Docstrings ```python -# ✅ Formatted with Black -def calculate_optimal_flow_temp( - self, +# ✅ Formatted with Black. This is the real signature - see utils/emitter.py. +def en442_flow_temp( indoor_setpoint: float, outdoor_temp: float, - heat_loss_coefficient: float = 180.0, + design_outdoor_temp: float, + design_flow_temp: float, + design_spread: float, + emitter_exponent: float, ) -> float: - """Calculate optimal flow temperature using André Kühne's formula. - - Validated across manufacturers: Vaillant, Daikin, Mitsubishi, NIBE. - + """Flow temperature the emitters need to hold ``indoor_setpoint`` at ``outdoor_temp``. + Args: - indoor_setpoint: Target indoor temperature (°C) - outdoor_temp: Current outdoor temperature (°C) - heat_loss_coefficient: Building heat loss (W/°C), default 180.0 - + indoor_setpoint: Target indoor temperature (C). + outdoor_temp: Current outdoor temperature (C). + design_outdoor_temp: Dimensioning outdoor temperature the emitters were sized for (C). + design_flow_temp: Supply temperature the system needs at ``design_outdoor_temp`` (C). + design_spread: Flow-return spread at the design load (C). + emitter_exponent: EN 442 exponent n (1.3 radiators, 1.1 underfloor). + Returns: - Optimal flow temperature (°C) - + Required flow temperature (C). Never below ``indoor_setpoint``: water colder than the + room removes heat from it. + References: - Mathematical_Enhancement_Summary.md: André Kühne's formula - Formula: TFlow = 2.55 × (HC × (Tset - Tout))^0.78 + Tset + docs/research/02_emitter_law.md - EN 442-1 §3.31, EN 12831, EN 1264, and the + validation against NIBE's own published curve 9. """ ``` +⚠️ **This example used to show "André Kühne's formula", +`TFlow = 2.55 × (HC × (Tset − Tout))^0.78 + Tset`, and cite a research document that is not in +this repository.** That model is **gone** (audit F-119/F-121) — it was being fed a **heat-loss +coefficient** where the derivation requires a **dimensionless relative load**, so a dimensionally +inconsistent input went into a structurally correct law and produced numbers that looked plausible +and were not. It drove the flow temperature of a real heat pump. **Do not reintroduce it.** The +flow temperature comes from the EN 442 emitter law, in `utils/emitter.py`. + +(The `0.78` was not wrong, incidentally — it is `1/n` for `n = 1.3`, the inverse emitter exponent. +The structure was right; the input was not. `docs/research/02_emitter_law.md` shows the derivation.) + --- ## NIBE Heat Pump Specifics @@ -784,27 +837,69 @@ def calculate_optimal_flow_temp( - S1 = target flow temperature - Standard compressor start: DM -60 - Extended runs: DM -240 (stevedvo custom setting, acceptable) -- **CLIMATE-AWARE WARNING**: Varies by zone and outdoor temp - - Stockholm at -10°C: DM -700 warning threshold - - Kiruna at -30°C: DM -1200 warning threshold - - Paris at 5°C: DM -350 warning threshold -- **AUXILIARY LIMIT: DM -1500** (validated in Swedish forums, avoid exceeding to prevent expensive aux heat) -- Use `ClimateZoneDetector.get_expected_dm_range(outdoor_temp)` for context-aware thresholds +- **CLIMATE-AWARE WARNING**: Varies by zone and outdoor temp. **Computed, never stored** — always + call `ClimateZoneDetector.get_expected_dm_range(outdoor_temp)`. Verified 2026-07-12: + - Stockholm at -10°C: DM **-740** warning threshold (normal -490 to -740) + - Kiruna at -30°C: DM **-1400** warning threshold (normal -1000 to -1400) + - Paris at 5°C: DM **-250** warning threshold (normal -100 to -250) +- **AUXILIARY LIMIT: DM -1500** — the absolute floor. ⚠️ **It is NOT the number that governs a real + F750.** The pump's own "start addition" (menu 4.9.3) defaults to **-700**: the immersion heater + engages there, deliberately, to spare the compressor, and works DM back UP. So on the owner's pump + the elpatron fires *before* Stockholm's -740 warning is even reached, and DM -1500 describes a + régime a healthy F750 never enters. The -1500 figure itself is attributed to "Swedish forums" and + **is not sourced in this repository** — see `docs/research/01_degree_minutes.md`. **Pump Configuration:** - **Open-loop UFH**: MUST use Auto mode, 10% (ASHP) or 20% (GSHP) idle - **Buffered systems**: Intermittent mode acceptable - Wrong setting = 8-hour off periods (glyn.hudson case) -**UFH Types:** -- **Concrete slab**: 6+ hours thermal lag, 12h prediction horizon -- **Timber**: 2-3 hours lag, 6h prediction horizon -- **Radiators**: <1 hour lag, 2h prediction horizon +**UFH Types:** (the horizons are `UFH_*_PREDICTION_HORIZON` in `const.py` — check them there) +- **Concrete slab**: 6+ hours thermal lag, **24h** prediction horizon +- **Timber**: 2-3 hours lag, **12h** prediction horizon +- **Radiators**: <1 hour lag, **6h** prediction horizon + +⚠️ **Six hours is the LAG, not the horizon.** The slab is only ~19 % charged at fourteen hours (slow time constant ~70 h), so a +12 h window cannot see the thing that actually drains it: a slow, deep, multi-day slide shows less +than 4 °C of drop in any twelve hours and never triggers the pre-heat at all, while the sudden plunge +that *does* trigger it is the case the pump's own curve already handles. +See `docs/research/03_concrete_slab_response.md` and audit F-130. + +**Flow Temperature — the EN 442 emitter law (`utils/emitter.py`):** -**Flow Temperature Targets (OEM Research):** -- SPF 4.0+ systems: Flow = Outdoor + 27°C ±3°C -- SPF 3.5+ systems: Flow = Outdoor + 30°C ±4°C -- Most systems run 5-15°C below optimal (huge opportunity) +The flow temperature is **not** a linear offset from outdoor temperature. It follows the emitter's +own characteristic curve: + +``` +balance = T_room − INTERNAL_GAINS_W / heat_loss_coefficient demand reaches zero HERE, not at T_room +φ = (balance − T_out) / (balance − T_out_design) dimensionless relative load +T_flow = T_room + ΔT_design · φ^(1/n) + spread_design / 2 n = 1.3 radiators, 1.1 UFH +``` + +Two things in that equation are easy to get wrong, and they are easy to get wrong **in cancelling +ways** — which is how both errors survived in this repository for months: + +1. **The spread is CONSTANT — never scale it by φ.** A fixed-speed circulator gives constant mass + flow and a load-proportional spread; that is a wet boiler. A heat pump *modulates* its circulator + to hold the commissioned spread. Scaling it runs the curve too cool in mild weather and too hot + in cold, pivoting **invisibly on the design point**. +2. **Internal gains are WATTS, never degrees, and NEVER fitted to a curve.** Divide them by the + house's own W/K. A fixed offset in degrees would make a leaky house get *more* credit for the + same fridge, which is backwards. + +⚠️ **Do not "validate" this against NIBE's published curve.** That was tried, and it produced a +wrong constant twice. NIBE's curve 9 is **a straight line to within 0.19 °C** (its slopes even +wobble the wrong way) so it cannot resolve curvature at all; and the constant-spread and +balance-point terms are **the same basis function with opposite signs**, so any assumed spread +manufactures a matching "gains" figure — even out of a curve with provably zero gains. NIBE +interpolates its curves linearly; we follow EN 442. **The gap between them is the trim, not an +error.** See `docs/research/02_emitter_law.md`, and the tests that prove both claims in +`tests/validation/test_emitter_law_matches_openenergymonitor.py`. + +⚠️ **This section used to offer "SPF 4.0+ systems: Flow = Outdoor + 27 °C ±3 °C" as OEM research.** +That is a LINEAR rule — the very thing the emitter law was chosen over — sitting in the document +that tells contributors how to implement. There are no `OPTIMAL_FLOW_DELTA_SPF_*` constants: it +described a model this codebase does not have. **MyUplink API:** - Update interval: ~60 seconds @@ -813,11 +908,30 @@ def calculate_optimal_flow_temp( ### Always Check Research Before Implementing -When implementing NIBE-specific features, reference: -1. `IMPLEMENTATION_PLAN/02_Research/Forum_Summary.md` - Real F2040 cases -2. `IMPLEMENTATION_PLAN/02_Research/Swedish_NIBE_Forum_Findings.md` - F750 optimizations -3. `IMPLEMENTATION_PLAN/01_Algorithm/Setpoint_Optimizing_Algorithm.md` - Algorithm spec -4. `IMPLEMENTATION_PLAN/03_API/MyUplink_Complete_Guide.md` - API details +**`docs/research/` — in this repository, and citable:** + +1. `docs/research/01_degree_minutes.md` — what DM is; NIBE menu 4.9.3 (F750 Installer Manual + IHB GB 1301-1); why the **elpatron at -700**, not -1500, is the number that governs a real F750; + and why DM is structurally **blind to under-heating EffektGuard itself causes** (lowering the + offset lowers S1, so DM *improves* while the house cools). +2. `docs/research/02_emitter_law.md` — EN 442-1 §3.23/§3.31, EN 12831, EN 1264. The flow-temperature + model, checked against NIBE's own published curve 9 (EN 442 with derived gains lands + +0.64 °C from it — and curve 9 is itself a straight line to within 0.19 °C, so it cannot + resolve curvature; the gap is the trim, not an error. See docs/research/02_emitter_law.md). +3. `docs/research/03_concrete_slab_response.md` — the two-node transient. Why the horizon is 24 h and + the pre-heat is +2.0 °C. +4. `docs/research/04_exhaust_air_recovery.md` — why "extra heat extracted" and "improved COP" are the + same joules, proven from NIBE's own S735 EN 14511 tables. + +Each note states plainly **what is not sourced**, so that nothing gets laundered into fact by being +filed next to a standard. + +⚠️ **This list used to name four documents under `IMPLEMENTATION_PLAN/`. All four are gitignored and +absent from the repository** — as are the other eleven research documents cited across the code +(audit F-106). **Rule 13 above — "never guess NIBE behaviour, verify with research docs" — was +therefore impossible to obey by anyone who cloned this repo.** A number with a confident citation to +a document nobody has is worse than a number with no citation at all: it looks settled. If you add a +constant, cite something a reader can actually open. --- @@ -830,16 +944,31 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator class EffektGuardCoordinator(DataUpdateCoordinator): """Coordinate data updates for EffektGuard.""" - - def __init__(self, hass: HomeAssistant, ...): + + def __init__(self, hass: HomeAssistant, ..., entry: ConfigEntry): super().__init__( hass, _LOGGER, + config_entry=entry, # required: HA removes the ContextVar fallback in 2026.8 name=DOMAIN, - update_interval=timedelta(minutes=5), + update_interval=None, # NOT timedelta(minutes=5) - see below ) ``` +⚠️ **`update_interval` is `None` on purpose.** This integration disables HA's own scheduler and +drives itself from a clock-aligned timer (`_schedule_aligned_refresh`, every 5 min at :XX:10) so +that updates land just after the sensors refresh and in step with the 15-minute price quarters. +Two consequences you must not forget: + +- `_do_aligned_refresh` is the **sole owner** of that timer. If it ever returns without re-arming, + the coordinator is dead **permanently and silently** — `last_update_success` stays True and every + entity keeps serving its last value while the pump sits on the last offset written. That is why it + has a broad `except Exception` and a `finally`. +- **`_async_update_data` is HA's READ hook and must NEVER write to the pump.** It is public, + debounced, and called by reloads, options changes and services. Writes belong to + `_drive_the_pump()`, which holds the control lock. (Audit F-063/F-131: `reset_peak_tracking` — a + service whose entire job is to clear a counter — used to drive the heat pump.) + ### Config Flow for Setup ```python @@ -929,10 +1058,15 @@ When creating release notes via `gh release create`, use this exact format: - Comfort over cost (moderate optimization, not aggressive) - Real homes depend on this (heat pump health matters) -**Research-Based:** -- All thresholds from real NIBE failures and Swedish forum validation -- Climate zone system: Adapts DM thresholds from Arctic (-30°C) to Mediterranean (5°C) -- Mathematical formulas from OEM research (André Kühne, Timbones) +**Research-Based** — and see `docs/research/`, which states plainly what is *not* sourced: +- Flow temperature: the **EN 442 emitter law** (EN 442-1 §3.31, EN 12831, EN 1264), validated + against NIBE's own published curve. **Not** a fitted or linear formula. +- Degree minutes and the auxiliary heater: the **NIBE F750 Installer Manual, IHB GB 1301-1**, + menu 4.9.3. +- Climate zone system: adapts DM thresholds from Arctic (−30 °C) to Mediterranean (5 °C). +- ⚠️ **Not everything is sourced.** The DM −1500 figure, the "20 % airflow COP" and the + `stevedvo` / `glyn.hudson` case studies rest on forum anecdote and on documents that are **not in + this repository**. They are marked as such where they appear. Do not launder them into fact. **Swedish-Specific:** - 15-minute effect tariff windows (quarterly measurement) @@ -940,7 +1074,10 @@ When creating release notes via `gh release create`, use this exact format: - F750/F2040 focus with S-series support **Known Critical Issues:** -- Climate-aware thermal debt thresholds (DM -1500 auxiliary limit, validated in Swedish forums) +- Climate-aware thermal debt thresholds. ⚠️ **DM −1500 is the absolute floor, not the number that + governs a real F750** — the pump's own "start addition" fires at **−700** and works DM back up. + The −1500 figure is attributed to "Swedish forums" and is **not sourced in this repository**; + see `docs/research/01_degree_minutes.md`. - Open-loop pump Intermittent = 8-hour off periods - BT50 indoor sensor + UFH = instability (not recommended) - DHW during heating demand = thermal debt accumulation diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 9e30e164..81c570a8 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -38,11 +38,10 @@ jobs: run: | pytest tests/ -v --tb=short - # Commented out until ready for HACS publication - # - name: HACS validation - # uses: hacs/action@main - # with: - # category: integration + - name: HACS validation + uses: hacs/action@main + with: + category: integration - name: Hassfest validation uses: home-assistant/actions/hassfest@master diff --git a/README.md b/README.md index b9c50f9b..e179e34b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ EffektGuard Logo [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration) -![Version](https://img.shields.io/badge/version-0.5.0-beta.1-blue) +![Version](https://img.shields.io/badge/version-0.5.0-blue) ![HA](https://img.shields.io/badge/Home%20Assistant-2025.10%2B-blue) [![Sponsor on GitHub](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-1f425f?logo=github&style=for-the-badge)](https://github.com/sponsors/enoch85) @@ -40,30 +40,50 @@ Currently supports NIBE heat pumps via MyUplink integration, with plans to add s - **Proactive debt prevention** - trend-based future DM prediction - **Effect tariff** (peak avoidance) - predictive 15-min protection - **Prediction/Learning** (self-tuning) - learned thermal model for pre-heating -- **Weather compensation** (mathematical flow temp) - André Kühne + Timbones formulas +- **Weather compensation** (mathematical flow temp) - the **EN 442 emitter law** - **Weather prediction** (pre-heating) - time-aware cold snap protection - **Spot price** (cost reduction) - forward-looking optimization with adaptive horizon - **Comfort** (tolerance) - reactive temperature correction ### 🌍 Global Climate Adaptation -Automatic latitude-based zone detection (Arctic to Mediterranean): -- **Extreme Cold** (66.5°N+): Kiruna, Tromsø - DM -800 to -1200 normal -- **Very Cold** (60.5-66.5°N): Luleå, Umeå - DM -600 to -1000 normal -- **Cold** (56-60.5°N): Stockholm, Oslo, Helsinki - DM -450 to -700 normal -- **Moderate Cold** (54.5-56°N): Copenhagen, Malmö - DM -300 to -500 normal -- **Standard** (<54.5°N): Paris, London - DM -200 to -350 normal - -**No configuration needed** - uses Home Assistant latitude. DM -1500 absolute maximum enforced globally. - -### 🧠 Self-Learning Capability -Learns your building over 7-14 days: +Automatic latitude-based zone detection (Arctic to Mediterranean). The ranges below are each zone's +**base** range — what is normal **at that zone's own winter average**. They shift with the outdoor +temperature, by 20 DM per degree: + +| Zone | Latitude | Winter avg | Base normal DM | +|---|---|---|---| +| **Extreme Cold** | 66.5°N+ | −20 °C | −800 to −1200 | +| **Very Cold** | 60.5–66.5°N | −12 °C | −600 to −1000 | +| **Cold** | 56–60.5°N | −8 °C | −450 to −700 | +| **Moderate Cold** | 54.5–56°N | −1 °C | −300 to −500 | +| **Standard** | <54.5°N | 0 °C | −200 to −350 | + +So Stockholm's normal range is −450 to −700 **at −8 °C**, and −490 to −740 at −10 °C. Quoting the +base range as if it applied at every temperature is how four of this project's own documents came +to be wrong; see `docs/CLIMATE_ZONES.md` for the full tables. + +**No configuration needed** — uses Home Assistant's latitude. + +**DM −1500 is the absolute floor**, enforced globally. ⚠️ It is *not* the number that governs a real +F750: the pump's own "start addition" (menu 4.9.3) fires at **−700**, so the immersion heater engages +and works DM back up long before −1500 is approached. See `docs/research/01_degree_minutes.md`. + +### 🧠 Self-Learning Capability (observing only — not yet driving your pump) +EffektGuard continuously observes your building and estimates: - **UFH type detection** - concrete slab (6h lag) vs timber (2-3h lag) vs radiators (<1h lag) - **Thermal mass** - building heat storage capacity (kWh/°C) - **Heat loss coefficient** - envelope performance (W/°C) - **Heating efficiency** - system response to offset changes (°C/°C) - **Weather patterns** - seasonal adaptation with unusual weather detection -Predictive pre-heating uses learned parameters for intelligent load shifting. +**These estimates do not currently influence control.** Learned parameters are used for +pre-heating only once the model's confidence exceeds 70%, and confidence cannot presently +reach that: the indoor temperature is read to 0.1 °C every 5 minutes, and a building's +response over 5 minutes is smaller than the sensor's own resolution. Nothing reliable can be +learned from that, so nothing is claimed from it. + +Control today is physics-based and deterministic — the heating curve, the EN 442 emitter law, +weather compensation and the degree-minute safety net — none of which depends on learning. ### ⚡ Effect Tariff Optimization Native 15-minute (quarterly) integration: @@ -82,7 +102,8 @@ Multi-factor forward-looking optimization combining: ### 🌡️ Weather Compensation (Mathematical) Physics-based flow temperature optimization: -- **André Kühne formula** - validated across manufacturers (Vaillant, Daikin, NIBE, etc.) +- **EN 442 emitter law** (EN 442-1 §3.31, EN 12831, EN 1264) - validated against NIBE's own + published curve 9: it lands **0.20 °C** from it, where a straight line is out by **2.37 °C** - **Timbones method** - radiator-specific calculations (BS EN442) - **UFH adjustments** - concrete slab (-8°C), timber (-5°C) - **Climate-aware margins** - automatic safety headroom by zone @@ -272,22 +293,36 @@ Native quarterly (15-min) price periods: - **Auto-discovery** - finds price entity automatically ### Weather Compensation Math + +The flow temperature follows the **EN 442 emitter law** (`utils/emitter.py`) — the emitter's own +characteristic curve, not a linear offset from outdoor temperature: + ```python -# André Kühne formula (universal) -TFlow = 2.55 × (HC × (Tset - Tout))^0.78 + Tset +# Demand reaches zero at the BALANCE POINT, not at room temperature: +# bodies, appliances and the sun cover the losses until several degrees below the setpoint. +balance = T_room - INTERNAL_GAINS_W / heat_loss_coefficient -# Timbones method (radiator-specific) -TFlow = ((Pin / Pout)^(1/1.3) × (DTout / DTin)) × (Tset - Tout) + Tset +phi = (balance - T_out) / (balance - T_out_design) # dimensionless relative load +T_flow = T_room + dT_design * phi**(1/n) + spread/2 # n = 1.3 radiators, 1.1 UFH ``` -Combined with climate-aware safety margins (0.0-2.5°C by zone). -### Self-Learning Timeline -- **Day 1-3**: Low confidence (0.0-0.3), conservative defaults -- **Day 4-7**: Medium confidence (0.3-0.7), starts using learned params -- **Day 8-14**: High confidence (0.7-1.0), fully optimized -- **Ongoing**: Continuous refinement, seasonal adaptation +The spread is held **constant** — a heat pump modulates its circulator, it does not run a +fixed-speed pump. Combined with climate-aware safety margins (0.0–2.5 °C by zone). + +### Self-Learning Status + +Observations are recorded every 5 minutes into a rolling 672-entry window — **56 hours**, not +the week the window was sized for. Confidence is scored from observation count, data +consistency and time span, and learned parameters are used for pre-heating only above 70%. + +At the present observation cadence that threshold is not reachable, and the integration says so +rather than pretending otherwise: a 0.1 °C indoor sensor sampled every 5 minutes quantises the +building's response into steps of 1.2 °C/h, which is larger than any real heating rate. The +consistency term is therefore honestly zero and confidence settles around 0.47. -672 observations (1 week @ 15-min) minimum for reliable learning. +**Learning does not drive your heat pump today.** Making it do so means sampling slowly enough +for the signal to exceed the sensor's resolution — a deliberate change, not a tuning tweak, +because it would put a learned model in the control path of real heating equipment. ## Documentation diff --git a/custom_components/effektguard/__init__.py b/custom_components/effektguard/__init__.py index 81f8fffb..8c6fa43a 100644 --- a/custom_components/effektguard/__init__.py +++ b/custom_components/effektguard/__init__.py @@ -12,27 +12,54 @@ import logging from datetime import datetime, timedelta +import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.core import HomeAssistant, SupportsResponse +from homeassistant.exceptions import ( + ConfigEntryNotReady, + HomeAssistantError, + ServiceValidationError, +) from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util import dt as dt_util +from .adapters.gespot_adapter import GESpotAdapter +from .adapters.nibe_adapter import NibeAdapter +from .adapters.weather_adapter import WeatherAdapter from .const import ( + ATTR_DURATION, + ATTR_OFFSET, + CONF_NIBE_TEMP_LUX_ENTITY, + DHW_BOOST_COOLDOWN_MINUTES, + DHW_BOOST_DEFAULT_DURATION_MINUTES, + DHW_BOOST_MAX_DURATION_MINUTES, + DHW_BOOST_MIN_DURATION_MINUTES, DOMAIN, HEATING_BOOST_COOLDOWN_MINUTES, - DHW_BOOST_COOLDOWN_MINUTES, + MAX_OFFSET, + MIN_OFFSET, + SERVICE_BOOST_DHW, + SERVICE_BOOST_HEATING, + SERVICE_CALCULATE_OPTIMAL_SCHEDULE, + SERVICE_FORCE_OFFSET, SERVICE_RATE_LIMIT_MINUTES, - CONF_NIBE_TEMP_LUX_ENTITY, - DHW_MIN_TEMP, - DHW_MAX_TEMP, + SERVICE_RESET_PEAK_TRACKING, ) from .coordinator import EffektGuardCoordinator +from .optimization.decision_engine import DecisionEngine +from .optimization.effect_layer import EffectManager +from .optimization.price_layer import PriceAnalyzer +from .optimization.thermal_layer import ThermalModel _LOGGER = logging.getLogger(__name__) -# Service call cooldown tracking (per hass instance) +# Service-call cooldowns at MODULE scope, deliberately. They rate-limit 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 they would die with it, and HA's reload re-creates the +# coordinator - so a user could boost to +10 C, reload to clear the cooldown, and boost again. +# single_config_entry is true, so a module global cannot leak across entries. +# See tests/unit/test_the_boost_cooldown_survives_a_reload.py. _service_last_called: dict[str, datetime] = {} @@ -127,13 +154,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" + """Unload a config entry. + + Platforms unload FIRST. If one refuses, HA keeps the entry loaded and its entities + alive - so the coordinator behind them must stay alive too. Shutting it down first + left a loaded entry served by a dead coordinator: every sensor frozen on its last + value, the control loop gone, nothing saying so. + """ _LOGGER.info("Unloading EffektGuard integration") - # Get coordinator before removing from hass.data - coordinator: EffektGuardCoordinator = hass.data[DOMAIN].get(entry.entry_id) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if not unload_ok: + return False - # Save persistent state before unloading + coordinator: EffektGuardCoordinator = hass.data[DOMAIN].get(entry.entry_id) if coordinator: try: await coordinator.async_shutdown() @@ -141,30 +175,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (OSError, RuntimeError, ValueError) as err: _LOGGER.warning("Failed to shutdown coordinator cleanly: %s", err) - # Unload platforms - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - # Remove coordinator - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id, None) + hass.data[DOMAIN].pop(entry.entry_id, None) - # Unregister services if this is the last config entry - if not hass.data[DOMAIN]: - _async_unregister_services(hass) + # Unregister services if this is the last config entry + if not hass.data[DOMAIN]: + _async_unregister_services(hass) - return unload_ok + return True def _async_unregister_services(hass: HomeAssistant) -> None: """Unregister integration services when last config entry is removed.""" - from .const import ( - SERVICE_BOOST_DHW, - SERVICE_BOOST_HEATING, - SERVICE_CALCULATE_OPTIMAL_SCHEDULE, - SERVICE_FORCE_OFFSET, - SERVICE_RESET_PEAK_TRACKING, - ) - services = [ SERVICE_FORCE_OFFSET, SERVICE_RESET_PEAK_TRACKING, @@ -182,18 +203,16 @@ def _async_unregister_services(hass: HomeAssistant) -> None: async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update. + """Handle a config-entry update by hot-reloading runtime settings. - Called when entry.options changes (from options flow UI). - Entity selections are in entry.data and don't trigger this listener. + Hot-reloading (not tearing the entry down) preserves the startup grace period whose reset + would block offset application, the entities that would otherwise flicker, and accumulated + state (compressor stats, trends, thermal predictor). - Since our options flow only contains runtime settings (target temp, tolerance, - thermal mass, DHW schedules, etc.), we can always hot-reload without restart. - - This prevents: - - Startup grace period reset (which blocks offset application) - - Screen flickering from entity recreation - - Lost state (compressor stats, trends, thermal predictor) + HA fires this listener on ANY entry change - data or options - and switch.py writes feature + flags into entry.data. Handling only runtime settings is still correct: switch flags are read + from entry.data at the point of use, and entity selections go through the reconfigure flow, + which schedules a FULL reload that rebuilds the adapters. """ coordinator: EffektGuardCoordinator = hass.data[DOMAIN].get(entry.entry_id) if not coordinator: @@ -208,6 +227,11 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: _LOGGER.info("Options updated, applying changes (no restart)") await coordinator.async_update_config(merged_config) + # Tell the entities: the switches and the thermostat are VIEWS of this entry, and a view only + # updates when asked. Without this the master switch kept displaying "on" for five minutes after + # the thermostat wrote OFF, until the next coordinator refresh happened to re-render it. + coordinator.async_update_listeners() + async def _create_coordinator( hass: HomeAssistant, @@ -218,23 +242,13 @@ async def _create_coordinator( This factory function creates all dependencies and injects them into the coordinator following clean architecture principles. """ - from .adapters.gespot_adapter import GESpotAdapter - from .adapters.nibe_adapter import NibeAdapter - from .adapters.weather_adapter import WeatherAdapter - from .optimization.decision_engine import DecisionEngine - from .optimization.effect_layer import EffectManager - from .optimization.price_layer import PriceAnalyzer - from .optimization.thermal_layer import ThermalModel - # Create data adapters nibe_adapter = NibeAdapter(hass, entry.data) gespot_adapter = GESpotAdapter(hass, entry.data) - # Weather adapter: check options first, fall back to data - weather_config = dict(entry.data) - if "weather_entity" in entry.options: - weather_config["weather_entity"] = entry.options["weather_entity"] - weather_adapter = WeatherAdapter(hass, weather_config) + # weather_entity is written only to entry.data (config and reconfigure flows), never to + # entry.options, so the adapter is built from entry.data alone. + weather_adapter = WeatherAdapter(hass, dict(entry.data)) # Create optimization components price_analyzer = PriceAnalyzer() @@ -302,22 +316,6 @@ async def _async_register_services(hass: HomeAssistant) -> None: - boost_dhw: Manual DHW heating boost - calculate_optimal_schedule: Preview 24h optimization """ - import voluptuous as vol - from homeassistant.exceptions import ServiceValidationError - from homeassistant.helpers import config_validation as cv - - from .const import ( - ATTR_DURATION, - ATTR_OFFSET, - ATTR_TARGET_TEMP, - MAX_OFFSET, - MIN_OFFSET, - SERVICE_BOOST_DHW, - SERVICE_BOOST_HEATING, - SERVICE_CALCULATE_OPTIMAL_SCHEDULE, - SERVICE_FORCE_OFFSET, - SERVICE_RESET_PEAK_TRACKING, - ) def get_coordinator(hass: HomeAssistant) -> EffektGuardCoordinator | None: """Get first available coordinator from domain data.""" @@ -357,11 +355,12 @@ async def force_offset_handler(call) -> None: f"Offset {offset} outside valid range [{MIN_OFFSET}, {MAX_OFFSET}]" ) - # Set override in decision engine - coordinator.engine.set_manual_override(offset, duration) + if not coordinator.optimization_enabled: + raise ServiceValidationError( + "EffektGuard is OFF. Turn optimization on before forcing an offset." + ) - # Request immediate update - await coordinator.async_request_refresh() + await coordinator.async_apply_manual_override(offset, duration) # Update last called timestamp _update_service_timestamp("force_offset") @@ -417,12 +416,13 @@ async def boost_heating_handler(call) -> None: _LOGGER.info("Boost heating service called: duration=%s minutes", duration) - # Set maximum positive offset for boost duration boost_offset = MAX_OFFSET # +10°C - coordinator.engine.set_manual_override(boost_offset, duration) + if not coordinator.optimization_enabled: + raise ServiceValidationError( + "EffektGuard is OFF. Turn optimization on before boosting heating." + ) - # Request immediate update - await coordinator.async_request_refresh() + await coordinator.async_apply_manual_override(boost_offset, duration) # Update last called timestamp _update_service_timestamp("boost_heating") @@ -449,20 +449,11 @@ async def boost_dhw_handler(call) -> None: if not coordinator: raise ServiceValidationError("No EffektGuard coordinator found") - target_temp = call.data.get(ATTR_TARGET_TEMP, DHW_MAX_TEMP) - duration = call.data.get(ATTR_DURATION, 180) # 3 hours default (NIBE temporary lux) + duration = call.data.get(ATTR_DURATION, DHW_BOOST_DEFAULT_DURATION_MINUTES) - _LOGGER.info( - "Boost DHW service called: target_temp=%s°C, duration=%s minutes", - target_temp, - duration, - ) - - # Validate target temperature - if not DHW_MIN_TEMP <= target_temp <= 70.0: - raise ServiceValidationError( - f"Target temperature {target_temp} outside safe range [{DHW_MIN_TEMP}, 70.0]°C" - ) + # No target_temp: temporary lux is a switch and the pump heats to its own lux temperature, + # so there is nothing to set. duration is enforced here - the coordinator ends the boost. + _LOGGER.info("Boost DHW service called: duration=%s minutes", duration) # Get temporary lux entity from config temp_lux_entity = coordinator.config_entry.data.get(CONF_NIBE_TEMP_LUX_ENTITY) @@ -475,32 +466,25 @@ async def boost_dhw_handler(call) -> None: "DHW boost requires temporary lux entity (switch.temporary_lux_50004)" ) - # Turn on temporary lux switch (NIBE will handle 3-hour DHW priority) - _LOGGER.info("Activating NIBE temporary lux via %s", temp_lux_entity) - await hass.services.async_call( - "switch", - "turn_on", - {"entity_id": temp_lux_entity}, - blocking=True, - ) - - # Store DHW boost request in coordinator for tracking - coordinator.data["dhw_boost"] = { - "target_temp": target_temp, - "duration_minutes": duration, - "requested_at": dt_util.now(), - "method": "temporary_lux", - } + # Go through the coordinator, not straight at the switch: that records the boost as OURS + # (so unload stops it) and opens the user window (so the price optimizer does not cancel it + # on the next cycle). + try: + await coordinator.async_start_dhw_boost(duration, dt_util.utcnow()) + except HomeAssistantError as err: + raise ServiceValidationError(str(err)) from err - # Request immediate update to track status + # The lux switch is on - the entities just need to catch up. await coordinator.async_request_refresh() # Update last called timestamp _update_service_timestamp("boost_dhw") _LOGGER.info( - "DHW boost activated via temporary lux: %s°C target for %s minutes", - target_temp, + "DHW boost activated via NIBE temporary lux on %s for %s minutes. The pump decides " + "the lux temperature; EffektGuard ends the boost when the window closes, unless " + "NIBE's own lux timeout or the thermal-debt safety stop ends it first.", + temp_lux_entity, duration, ) @@ -605,11 +589,9 @@ async def calculate_optimal_schedule_handler(call): boost_dhw_schema = vol.Schema( { - vol.Optional(ATTR_TARGET_TEMP, default=55.0): vol.All( - vol.Coerce(float), vol.Range(min=40.0, max=70.0) - ), - vol.Optional(ATTR_DURATION, default=90): vol.All( - vol.Coerce(int), vol.Range(min=30, max=180) + vol.Optional(ATTR_DURATION, default=DHW_BOOST_DEFAULT_DURATION_MINUTES): vol.All( + vol.Coerce(int), + vol.Range(min=DHW_BOOST_MIN_DURATION_MINUTES, max=DHW_BOOST_MAX_DURATION_MINUTES), ), } ) @@ -659,7 +641,10 @@ async def calculate_optimal_schedule_handler(call): SERVICE_CALCULATE_OPTIMAL_SCHEDULE, calculate_optimal_schedule_handler, schema=calculate_optimal_schedule_schema, - supports_response=True, + # SupportsResponse.OPTIONAL, not a bare True: HA compares this by identity, so True + # passes `is not SupportsResponse.NONE` but fails `is SupportsResponse.OPTIONAL`, + # advertising the service as response-REQUIRED. The handler returns a dict or nothing. + supports_response=SupportsResponse.OPTIONAL, ) _LOGGER.debug("Registered service: %s", SERVICE_CALCULATE_OPTIMAL_SCHEDULE) diff --git a/custom_components/effektguard/adapters/gespot_adapter.py b/custom_components/effektguard/adapters/gespot_adapter.py index ac0d3629..29799815 100644 --- a/custom_components/effektguard/adapters/gespot_adapter.py +++ b/custom_components/effektguard/adapters/gespot_adapter.py @@ -20,7 +20,7 @@ import logging from dataclasses import dataclass from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Final, NotRequired, TypedDict from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -30,7 +30,7 @@ DAYTIME_END_HOUR, DAYTIME_START_HOUR, NATIVE_DAY_QUARTER_COUNTS, - QUARTER_INTERVAL_MINUTES, + MINUTES_PER_QUARTER, ) from ..utils.time_utils import QUARTERS_PER_HOUR @@ -39,7 +39,23 @@ _LOGGER = logging.getLogger(__name__) -QUARTER_DURATION: Final = timedelta(minutes=QUARTER_INTERVAL_MINUTES) +QUARTER_DURATION: Final = timedelta(minutes=MINUTES_PER_QUARTER) + + +class RawPricePeriod(TypedDict): + """One interval as GE-Spot publishes it in `today_interval_prices`. + + `time` is a timezone-aware datetime on a live GE-Spot and an ISO string only when Home + Assistant has restored the attribute from JSON across a restart; `_parse_periods` handles + both. `value` is the billed price; `raw_value` (NotRequired) is the pre-VAT market price, + which nothing reads and which must never be used as the price. The TypedDict enforces + nothing at runtime - `_parse_periods` validates every field and drops the interval if it + cannot. + """ + + time: datetime | str + value: float + raw_value: NotRequired[float] @dataclass @@ -62,7 +78,7 @@ def quarter_of_day(self) -> int: number only for display and tariff bookkeeping. """ return (self.start_time.hour * QUARTERS_PER_HOUR) + ( - self.start_time.minute // QUARTER_INTERVAL_MINUTES + self.start_time.minute // MINUTES_PER_QUARTER ) @property @@ -222,11 +238,13 @@ async def get_prices(self) -> PriceData: has_tomorrow=len(tomorrow_periods) > 0, ) - def _parse_periods(self, raw_prices: list[dict[str, Any]]) -> list[QuarterPeriod]: + def _parse_periods(self, raw_prices: list[RawPricePeriod]) -> list[QuarterPeriod]: """Parse raw price data into QuarterPeriod objects. Args: - raw_prices: List of dicts with 'time' (datetime string) and 'value' (float) + raw_prices: GE-Spot's published intervals. See RawPricePeriod: `time` is a + timezone-aware datetime object on a live GE-Spot, and an ISO string when + Home Assistant has restored the attribute from JSON across a restart. Returns: The day's native intervals sorted by absolute instant: 96 on a @@ -256,14 +274,21 @@ def _parse_periods(self, raw_prices: list[dict[str, Any]]) -> list[QuarterPeriod else: continue - # Parse price - use exactly as provided by GE-Spot (no conversion) - price = float(item.get("value", 0.0)) + # Used exactly as GE-Spot provides it (no conversion). Deliberately NOT + # `.get("value", 0.0)`: a missing price is not a price of zero. Zero is a real + # Nordic price and the cheapest one possible, so a defaulted interval outranks + # every genuine quarter of the day and wins the most aggressive pre-heating the + # price layer can command. KeyError below drops the interval instead. + price = float(item["value"]) # Create period with just datetime and price (all else derived) periods.append(QuarterPeriod(start_time=start_time, price=price)) except (ValueError, TypeError, KeyError) as err: - _LOGGER.warning("Failed to parse price period: %s", err) + # Drop it rather than substitute anything. Lookups are by timestamp, so a gap + # means that quarter has no price and the price layer abstains for it; if every + # interval drops, the empty day trips the no-price-source repair issue. + _LOGGER.warning("Dropping unparseable price period (%s): %s", err, item) continue # Sort by absolute instant. A local wall-clock sort cannot distinguish diff --git a/custom_components/effektguard/adapters/nibe_adapter.py b/custom_components/effektguard/adapters/nibe_adapter.py index 2002095b..3ecf49bb 100644 --- a/custom_components/effektguard/adapters/nibe_adapter.py +++ b/custom_components/effektguard/adapters/nibe_adapter.py @@ -27,12 +27,16 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util import dt as dt_util +from homeassistant.util.unit_conversion import TemperatureConverter from ..const import ( + NIBE_READING_MAX_AGE_MINUTES, CONF_ADDITIONAL_INDOOR_SENSORS, CONF_DEGREE_MINUTES_ENTITY, CONF_INDOOR_TEMP_METHOD, @@ -42,10 +46,13 @@ DEFAULT_INDOOR_TEMP, DEFAULT_INDOOR_TEMP_METHOD, DOMAIN, - MAX_OFFSET, - MIN_OFFSET, + INDOOR_SENSOR_PLAUSIBLE_MAX, + INDOOR_SENSOR_PLAUSIBLE_MIN, + NIBE_OUTDOOR_PLAUSIBLE_MAX, + NIBE_OUTDOOR_PLAUSIBLE_MIN, + NIBE_WATER_PLAUSIBLE_MAX, + NIBE_WATER_PLAUSIBLE_MIN, NIBE_COMPRESSOR_ACTIVE_HZ_THRESHOLD, - NIBE_DEFAULT_SUPPLY_TEMP, NIBE_DISCOVERY_CORE_KEYS, NIBE_DISCOVERY_EXCLUDE, NIBE_DISCOVERY_MAX_ATTEMPTS, @@ -55,7 +62,6 @@ NIBE_DISCOVERY_RANK_MANUAL, NIBE_DISCOVERY_RANK_REGISTRY_ONLY, NIBE_DISCOVERY_SLOW_RETRY_CYCLES, - NIBE_FRACTIONAL_ACCUMULATOR_THRESHOLD, NIBE_MANUAL_OVERRIDE_KEYS, NIBE_OFFSET_RESYNC_MINUTES, NIBE_POWER_FACTOR, @@ -70,6 +76,8 @@ TEMP_FACTOR_MAX, TEMP_FACTOR_MIN, ) +from ..utils.offset import integer_offset_for +from ..utils.power import power_kw_from_state if TYPE_CHECKING: from ..models.types import AdapterConfigDict @@ -101,6 +109,15 @@ class NibeState: phase3_current: float | None = None # BE3 - Phase 3 current (43081) - optional compressor_hz: int | None = None # Compressor frequency - optional power_kw: float | None = None # Total power consumption in kW - optional + # True when power_kw is a temperature-derived estimate, not a measurement. It rides in the same + # field as a real reading, so anything that reports or bills consumption must check this first + # or it presents a guess as actual consumption. + power_is_estimated: bool = False + # False when no indoor sensor could be read and indoor_temp is the DEFAULT_INDOOR_TEMP + # placeholder. No room sensor (no BT50) is a legitimate NIBE setup - it runs on degree minutes + # and the heating curve - but comfort-reasoning layers must abstain rather than trust the + # placeholder, which equals the usual target and yields a deviation of exactly 0.0. + indoor_temp_valid: bool = True @property def flow_temp(self) -> float: @@ -166,7 +183,6 @@ def __init__(self, hass: HomeAssistant, config: "AdapterConfigDict"): # Fractional accumulator for precise offset tracking # NIBE only accepts integers, so we accumulate fractional parts # and apply them when they sum to ±1°C - self._fractional_accumulator: float = 0.0 # Track last integer offset sent to NIBE (to avoid redundant writes) self._last_nibe_offset: int | None = None @@ -216,28 +232,28 @@ async def get_current_state(self) -> NibeState: ): await self._discover_nibe_entities() - # Read temperature sensors - outdoor_temp = await self._read_entity_float( - self._entity_cache.get("outdoor_temp"), default=0.0 + # --- REQUIRED readings ------------------------------------------------------- + # These three drive every control decision. Never substitute a plausible constant for a + # missing OR implausible one - that makes a broken install indistinguishable from a healthy + # one and still writes an offset. (A Modbus sensor missing `scale: 0.1` reports deci-degrees + # raw: BT1's -3.2 C arrives as -32.0 C.) Refuse instead, and let the coordinator degrade - + # startup_pending before the first success, UpdateFailed after - so nothing is written. + outdoor_temp = self._plausible( + await self._read_temperature(self._entity_cache.get("outdoor_temp")), + NIBE_OUTDOOR_PLAUSIBLE_MIN, + NIBE_OUTDOOR_PLAUSIBLE_MAX, + "outdoor temperature (BT1)", ) - - indoor_temp = await self._read_entity_float( - self._entity_cache.get("indoor_temp"), default=DEFAULT_INDOOR_TEMP + supply_temp = self._plausible( + await self._read_temperature(self._entity_cache.get("supply_temp")), + NIBE_WATER_PLAUSIBLE_MIN, + NIBE_WATER_PLAUSIBLE_MAX, + "supply temperature (BT25/BT63)", ) - # Multi-sensor indoor temperature calculation - if self._additional_indoor_sensors: - indoor_temp = await self._calculate_multi_sensor_temperature(indoor_temp) - - supply_temp = await self._read_entity_float( - self._entity_cache.get("supply_temp"), default=NIBE_DEFAULT_SUPPLY_TEMP - ) - return_temp = await self._read_entity_float( - self._entity_cache.get("return_temp"), default=None - ) - - # Read degree minutes (GM/DM) - # First try optional configured sensor, then fall back to auto-discovery + # Degree minutes: configured sensor first, then auto-discovery. NEVER estimated - + # DM is the primary thermal-debt safety signal and every NIBE exposes it + # (register 40940 / 43005). Guessing it would drive the emergency layer on fiction. degree_minutes = None if self._degree_minutes_entity: degree_minutes = await self._read_entity_float( @@ -250,7 +266,6 @@ async def get_current_state(self) -> NibeState: degree_minutes, ) - # Fall back to auto-discovered sensor if degree_minutes is None: degree_minutes = await self._read_entity_float( self._entity_cache.get("degree_minutes"), default=None @@ -258,10 +273,56 @@ async def get_current_state(self) -> NibeState: if degree_minutes is not None: _LOGGER.debug("Using auto-discovered degree minutes sensor: %.1f", degree_minutes) - # If still None, estimate from thermal model (will be implemented in thermal_model.py) - if degree_minutes is None: - degree_minutes = self._estimate_degree_minutes(indoor_temp, supply_temp, outdoor_temp) - _LOGGER.debug("Estimating degree minutes from thermal model: %.1f", degree_minutes) + missing = [ + name + for name, value in ( + ("outdoor temperature (BT1)", outdoor_temp), + ("supply/flow temperature (BT25/BT63)", supply_temp), + ("degree minutes", degree_minutes), + ) + if value is None + ] + if missing: + raise UpdateFailed( + "Cannot read required NIBE sensors: " + + ", ".join(missing) + + ". EffektGuard will not control the heat pump on incomplete data. " + "Check that the source integration (myuplink / nibe_heatpump / modbus) is " + "loaded and its entities are available, or set the matching manual entity " + "overrides in the EffektGuard options." + ) + + # --- OPTIONAL readings ------------------------------------------------------- + # Indoor temperature: no room sensor (no BT50) is legitimate - the pump runs on degree + # minutes and the heating curve. Keep the placeholder for display but mark it invalid so + # comfort layers abstain instead of reading a 0.0 deviation from a value that IS the target. + # Apply the plausibility band to the pump's own BT50 too, not just the user's extra sensors: + # a BT50 reporting 213.0 C (a scale typo for 21.3) once drove a -10.0 C command at critical + # weight. An implausible BT50 is treated as no room sensor, which is handled correctly. + measured_indoor = self._plausible( + await self._read_temperature(self._entity_cache.get("indoor_temp")), + INDOOR_SENSOR_PLAUSIBLE_MIN, + INDOOR_SENSOR_PLAUSIBLE_MAX, + "indoor temperature (BT50)", + ) + indoor_temp_valid = measured_indoor is not None + indoor_temp = measured_indoor if indoor_temp_valid else DEFAULT_INDOOR_TEMP + + # Pass the MEASURED value, never the placeholder: _calculate_multi_sensor_temperature's + # docstring forbids seeding the median with DEFAULT_INDOOR_TEMP, which would drag the + # combined reading toward the target and mask a real deviation. + if self._additional_indoor_sensors: + combined = await self._calculate_multi_sensor_temperature(measured_indoor) + if combined is not None: + indoor_temp = combined + indoor_temp_valid = True + + return_temp = self._plausible( + await self._read_temperature(self._entity_cache.get("return_temp")), + NIBE_WATER_PLAUSIBLE_MIN, + NIBE_WATER_PLAUSIBLE_MAX, + "return temperature (BT3)", + ) # Read current offset current_offset = await self._read_entity_float( @@ -289,11 +350,9 @@ async def get_current_state(self) -> NibeState: is_hot_water = False # Read DHW temperatures (optional - BT7 top, BT6 charging/bottom) - dhw_top_temp = await self._read_entity_float( - self._entity_cache.get("dhw_top_temp"), default=None - ) - dhw_charging_temp = await self._read_entity_float( - self._entity_cache.get("dhw_charging_temp"), default=None + dhw_top_temp = await self._read_temperature(self._entity_cache.get("dhw_top_temp")) + dhw_charging_temp = await self._read_temperature( + self._entity_cache.get("dhw_charging_temp") ) # Read DHW amount (hot water minutes available) - NIBE calculates this @@ -344,10 +403,16 @@ async def get_current_state(self) -> NibeState: phase3_current or 0.0, ) - # Read actual power consumption (if available) - power_kw = await self.get_power_consumption() + # Read power consumption. It may be a measurement or a temperature-derived estimate, and + # the two are not interchangeable - the flag travels with the number so nothing has to + # guess later. + power_kw, power_is_estimated = await self.get_power_consumption() if power_kw is not None: - _LOGGER.debug("Power consumption: %.2f kW", power_kw) + _LOGGER.debug( + "Power consumption: %.2f kW (%s)", + power_kw, + "estimated from temperatures" if power_is_estimated else "measured", + ) return NibeState( outdoor_temp=outdoor_temp, @@ -367,30 +432,25 @@ async def get_current_state(self) -> NibeState: phase3_current=phase3_current, compressor_hz=int(compressor_hz) if compressor_hz is not None else None, power_kw=power_kw, + power_is_estimated=power_is_estimated, + indoor_temp_valid=indoor_temp_valid, ) - async def set_curve_offset(self, offset: float) -> bool: - """Set heating curve offset via NIBE entity with fractional accumulation. - - The NIBE offset register (47011 on F-series) is integer-only, but the - optimization engine calculates precise fractional offsets (e.g., 0.35°C, - -1.24°C). - - Solution: Accumulate fractional parts and apply when they sum to ±1°C. - This preserves the precision of gentle optimization while respecting - NIBE's integer-only constraint. + async def set_curve_offset(self, offset: float, *, force_write: bool = False) -> int | None: + """Set the heating curve offset via the NIBE offset entity. - Example: - Cycle 1: Calculate 0.35°C → Send 0, accumulate +0.35 - Cycle 2: Calculate 0.42°C → Delta +0.07, accumulator = +0.42 - Cycle 3: Calculate 0.89°C → Delta +0.47, accumulator = +0.89 - Cycle 4: Calculate 1.12°C → Delta +0.23, accumulator = +1.12 → Send +1 + The NIBE offset register (47011 on the F-series) is integer-only, while the engine + calculates fractional offsets. The offset is ROUNDED to the nearest integer and written + only once it differs from what the register holds by a whole degree - a deadband, so the + register is not rewritten every five minutes as demand wanders across a rounding boundary. + See utils/offset.py. Args: offset: Calculated offset value in °C (e.g., -1.24, +0.87) + force_write: Bypass cooldown and deadband for a safety transition such as OFF. Returns: - True if offset was written to NIBE, False if deferred/accumulated + Integer written to NIBE, or None if the write was skipped or failed. Note: The target must be a writable number entity: a MyUplink offset @@ -400,17 +460,19 @@ async def set_curve_offset(self, offset: float) -> bool: """ # Rate limiting - minimum time between writes now = dt_util.utcnow() - if self._last_write and now - self._last_write < timedelta( - minutes=SERVICE_RATE_LIMIT_MINUTES + if ( + not force_write + and self._last_write + and now - self._last_write < timedelta(minutes=SERVICE_RATE_LIMIT_MINUTES) ): _LOGGER.debug("Skipping offset write, too soon since last write") - return False + return None # Get offset entity offset_entity = self._entity_cache.get("offset") if not offset_entity: _LOGGER.error("No offset entity found") - return False + return None # Check entity is available state = self.hass.states.get(offset_entity) @@ -420,7 +482,7 @@ async def set_curve_offset(self, offset: float) -> bool: offset_entity, state.state if state else "None", ) - return False + return None # Sync with the entity's actual value on first call, and re-sync when # the entity disagrees with our bookkeeping and we have not written @@ -440,66 +502,33 @@ async def set_curve_offset(self, offset: float) -> bool: or (entity_offset != self._last_nibe_offset and resync_window_passed) ): self._last_nibe_offset = entity_offset - self._fractional_accumulator = offset - self._last_nibe_offset _LOGGER.info( - "✓ Synced with NIBE: current offset %d°C (calculated: %.2f°C, accumulator: %.2f°C)", + "✓ Synced with NIBE: register holds %d°C (engine asked for %.2f°C)", self._last_nibe_offset, offset, - self._fractional_accumulator, ) elif self._last_nibe_offset is None: self._last_nibe_offset = 0 - self._fractional_accumulator = offset - # Fractional accumulation logic - # The accumulator tracks the total difference between what we've calculated - # and what NIBE actually has. - self._fractional_accumulator = offset - self._last_nibe_offset + # The integer the register should hold; the rounding, deadband and clamping live in + # utils/offset.py, shared with the simulation harness. + offset_to_apply = integer_offset_for(offset, self._last_nibe_offset) _LOGGER.debug( - "Offset calculation: calculated=%.2f°C, NIBE_current=%d°C, accumulator=%.2f°C", + "Offset: engine asked for %.2f°C, register holds %d°C -> writing %d°C", offset, self._last_nibe_offset, - self._fractional_accumulator, + offset_to_apply, ) - # Determine what integer value to apply to NIBE - # Only write when accumulator crosses threshold - offset_to_apply = self._last_nibe_offset # Start with current NIBE value - - if abs(self._fractional_accumulator) >= NIBE_FRACTIONAL_ACCUMULATOR_THRESHOLD: - # Accumulator has crossed threshold, apply the integer part - accumulated_adjustment = int(self._fractional_accumulator) - offset_to_apply = self._last_nibe_offset + accumulated_adjustment - - # Clamp to NIBE's valid range (MIN_OFFSET to MAX_OFFSET) - offset_to_apply = int(max(MIN_OFFSET, min(offset_to_apply, MAX_OFFSET))) - - _LOGGER.info( - "✓ Accumulated fractional offset reached threshold: " - "applying %+d°C adjustment (accumulator: %.2f°C, new_offset: %d°C)", - accumulated_adjustment, - self._fractional_accumulator, - offset_to_apply, - ) - else: - # Accumulator hasn't crossed threshold, keep current NIBE offset - _LOGGER.debug( - "Accumulator below threshold (%.2f°C), keeping NIBE at %d°C", - self._fractional_accumulator, - offset_to_apply, - ) - # Only write if integer part changed from last written value - if offset_to_apply == self._last_nibe_offset: + if not force_write and offset_to_apply == self._last_nibe_offset: _LOGGER.debug( - "Offset unchanged: %.2f°C → int(%d°C) = NIBE already at %d°C (accumulator: %.2f°C)", + "Offset unchanged: engine asked for %.2f°C, register already holds %d°C", offset, - offset_to_apply, self._last_nibe_offset, - self._fractional_accumulator, ) - return False + return None # Respect the target entity's own limits when it exposes them # (a template number left at default min 0/max 100 would otherwise @@ -523,8 +552,8 @@ async def set_curve_offset(self, offset: float) -> bool: clamped, ) offset_to_apply = clamped - if offset_to_apply == self._last_nibe_offset: - return False + if not force_write and offset_to_apply == self._last_nibe_offset: + return None # Store old value for logging before updating old_offset = self._last_nibe_offset @@ -546,19 +575,18 @@ async def set_curve_offset(self, offset: float) -> bool: self._last_nibe_offset = offset_to_apply _LOGGER.info( - "✓ Applied offset to NIBE: %d°C → %d°C (calculated: %.2f°C, accumulator: %.2f°C)", + "✓ Applied offset to NIBE: %d°C → %d°C (engine asked for %.2f°C)", old_offset, offset_to_apply, offset, - self._fractional_accumulator, ) - return True + return offset_to_apply except (HomeAssistantError, AttributeError, OSError, ValueError, TypeError) as err: _LOGGER.error("Failed to set NIBE offset: %s", err) - return False + return None - async def set_enhanced_ventilation(self, enabled: bool) -> bool: + async def set_enhanced_ventilation(self, enabled: bool, *, force_write: bool = False) -> bool: """Enable or disable enhanced ventilation for exhaust air heat pumps. NIBE F750/F730 "Increased Ventilation" is a switch entity that toggles @@ -571,6 +599,7 @@ async def set_enhanced_ventilation(self, enabled: bool) -> bool: Args: enabled: True to enable enhanced ventilation, False for normal + force_write: Bypass cooldown when disabling all EffektGuard control. Returns: True if ventilation was set, False if skipped/failed @@ -602,12 +631,14 @@ async def set_enhanced_ventilation(self, enabled: bool) -> bool: "Ventilation already %s, skipping redundant call", "ENHANCED" if enabled else "NORMAL", ) - return False + return True # Rate limiting - minimum time between writes now = dt_util.utcnow() - if self._last_ventilation_write and now - self._last_ventilation_write < timedelta( - minutes=SERVICE_RATE_LIMIT_MINUTES + if ( + not force_write + and self._last_ventilation_write + and now - self._last_ventilation_write < timedelta(minutes=SERVICE_RATE_LIMIT_MINUTES) ): remaining = ( timedelta(minutes=SERVICE_RATE_LIMIT_MINUTES) - (now - self._last_ventilation_write) @@ -636,6 +667,15 @@ async def set_enhanced_ventilation(self, enabled: bool) -> bool: _LOGGER.error("Failed to set enhanced ventilation: %s", err) return False + @property + def has_ventilation_control(self) -> bool: + """Whether an increased-ventilation switch is configured or was discovered. + + Distinguishes "this pump has no fan to neutralize" from "the fan's switch is + momentarily unavailable" - the OFF transition must refuse the latter, not skip it. + """ + return self._entity_cache.get("increased_ventilation") is not None + async def is_enhanced_ventilation_active(self) -> bool | None: """Check if enhanced ventilation is currently active. @@ -761,17 +801,33 @@ async def _discover_nibe_entities(self) -> None: unit = state.attributes.get("unit_of_measurement", "") if state else "" _LOGGER.info(" %s: %s = %s %s", key, entity_id, state_value, unit) - # Warn if critical sensors are missing + # Warn if sensors are missing. + # Indoor is OPTIONAL (a system without a room sensor runs on DM + the heating + # curve); the others are REQUIRED and get_current_state() refuses to run without + # them rather than substituting a plausible constant. if "indoor_temp" not in self._entity_cache: - _LOGGER.warning( - "No indoor temperature sensor (BT50) found! " - "Looking for entities with: bt50, room_temperature, or 40033. " - "Will use default fallback temperature (21°C)." + _LOGGER.info( + "No indoor temperature sensor (BT50) found. Comfort-based layers will " + "abstain; optimization continues on degree minutes and the heating curve. " + "Set the indoor temperature override in options if you do have one." ) if "outdoor_temp" not in self._entity_cache: - _LOGGER.warning("No outdoor temperature sensor (BT1) found!") - if "degree_minutes" not in self._entity_cache: - _LOGGER.warning("No degree minutes sensor found, will estimate from thermal model") + _LOGGER.error( + "No outdoor temperature sensor (BT1) found - EffektGuard cannot optimize " + "without it. Looking for entities with: bt1, outdoor_temp, or 40004." + ) + if "supply_temp" not in self._entity_cache: + _LOGGER.error( + "No supply/flow temperature sensor (BT25/BT63) found - EffektGuard cannot " + "optimize without it. Looking for: bt25, bt63, supply_temp, 40008, 40071." + ) + if "degree_minutes" not in self._entity_cache and not self._degree_minutes_entity: + _LOGGER.error( + "No degree minutes sensor found - EffektGuard cannot protect against " + "thermal debt without it and will not control the pump. Looking for: " + "degree_minutes, gradminuter, 40940, 43005. Set the degree-minutes entity " + "override in options if your sensor is named differently." + ) def _consider_candidate( self, @@ -811,6 +867,23 @@ def _consider_candidate( continue if key in NIBE_TEMPERATURE_KEYS: + # A measurement must come from a `sensor.`, not a `number.` (a setpoint the owner + # writes). A NIBE room-temperature setpoint is a `number.` with device_class + # temperature in °C - matching every attribute this gate checks and the + # `room_temperature` pattern - and binding it as the indoor measurement is silent + # and catastrophic: the reading equals the target, so the deviation is 0.0 forever + # and neither the comfort layer nor the 18 C safety floor ever fires. (Manual + # overrides seed the cache directly and bypass this.) + if not entity_id.startswith("sensor."): + _LOGGER.debug( + "Skipping %s candidate %s: a measurement must come from a sensor, and a " + "`number.` entity is a setpoint - something the owner writes, not " + "something the pump reports", + key, + entity_id, + ) + continue + # Accept if device_class is temperature OR unit is °C/°F # (handles Modbus sensors where only the unit is configured) if device_class != "temperature" and unit not in ["°C", "°F", "C", "F"]: @@ -863,6 +936,27 @@ async def _read_entity_float( if not state or state.state in ["unknown", "unavailable"]: return default + # Reject a reading older than NIBE_READING_MAX_AGE_MINUTES: an available-but-stale sensor + # (e.g. an MQTT publisher that has stopped) is unchanged and worthless, and every other + # check here passes it (F-015). A stale reading is a reading we do not have - it returns the + # default, and a REQUIRED sensor coming back None raises UpdateFailed, leaving the pump on + # its last offset. Prefer `last_reported` (bumped on every write) over `last_updated` (moves + # only when the value changes, which a steady pump's does not). If neither is a datetime the + # age is unknowable, so fail OPEN and use the reading - this is an extra guard, and raising + # from the read path would be worse than the staleness it catches. + reported = getattr(state, "last_reported", None) or getattr(state, "last_updated", None) + if isinstance(reported, datetime): + age = dt_util.utcnow() - reported + if age > timedelta(minutes=NIBE_READING_MAX_AGE_MINUTES): + _LOGGER.warning( + "%s last reported %.0f minutes ago (limit %d) - treating it as unread. Nothing " + "has confirmed this value since, and the heat pump will not be driven on it.", + entity_id, + age.total_seconds() / 60, + NIBE_READING_MAX_AGE_MINUTES, + ) + return default + try: value = float(state.state) except (ValueError, TypeError): @@ -876,6 +970,114 @@ async def _read_entity_float( return value + def _plausible( + self, + value: float | None, + minimum: float, + maximum: float, + description: str, + ) -> float | None: + """A reading outside the physically possible is not a reading. Return None. + + A value that cannot be a temperature is a missing reading wearing a number: NIBE's Modbus + registers hold DECI-degrees, so a sensor missing `scale: 0.1` turns 21.3 C into 213.0 C. + Returning None routes it exactly like a missing sensor - a required one raises UpdateFailed + and nothing is written; an optional BT50 degrades to "no room sensor". + + Args: + value: The reading, already converted to °C, or None. + minimum: Lowest value this sensor could physically report. + maximum: Highest value this sensor could physically report. + description: Human-readable sensor name, for the log line. + + Returns: + The value, or None when it is outside the band. + """ + if value is None: + return None + + if minimum <= value <= maximum: + return value + + _LOGGER.warning( + "%s reported %.1f°C, which is outside the possible range %.0f to %.0f°C. Treating it " + "as unread rather than controlling the heat pump on it. NIBE's Modbus registers hold " + "DECI-degrees: if you configured this sensor by hand, check it has `scale: 0.1`.", + description, + value, + minimum, + maximum, + ) + return None + + async def _read_temperature( + self, + entity_id: str | None, + default: float | None = None, + ) -> float | None: + """Read a temperature entity and normalise it to °C. + + Every temperature in NibeState is °C, but discovery accepts a °F entity (see + _consider_candidate) and Home Assistant presents a temperature sensor in the user's own + unit. Without conversion, BT1 reading 32 (0 °C) was taken as +32 °C and BT25's 95 (35 °C) as + a 95 °C flow temperature, driving weather compensation to minimum offset in midwinter. + + Args: + entity_id: Entity to read + default: Value to return when the entity is missing or unreadable + + Returns: + Temperature in °C, or `default` + """ + if not entity_id: + return default + + state = self.hass.states.get(entity_id) + if not state or state.state in ["unknown", "unavailable"]: + return default + + # Staleness guard, as in _read_entity_float: reject a reading older than + # NIBE_READING_MAX_AGE_MINUTES (prefer last_reported, fall back to last_updated), and fail + # OPEN when the age is unknowable. See _read_entity_float for the full rationale (F-015). + reported = getattr(state, "last_reported", None) or getattr(state, "last_updated", None) + if isinstance(reported, datetime): + age = dt_util.utcnow() - reported + if age > timedelta(minutes=NIBE_READING_MAX_AGE_MINUTES): + _LOGGER.warning( + "%s last reported %.0f minutes ago (limit %d) - treating it as unread. Nothing " + "has confirmed this value since, and the heat pump will not be driven on it.", + entity_id, + age.total_seconds() / 60, + NIBE_READING_MAX_AGE_MINUTES, + ) + return default + + try: + value = float(state.state) + except (ValueError, TypeError): + _LOGGER.warning("Cannot parse temperature from %s: %s", entity_id, state.state) + return default + + # The unknown-value marker is a RAW sensor value - check it before converting. + if value in NIBE_UNKNOWN_VALUE_MARKERS: + _LOGGER.debug("Ignoring unknown-value marker %s from %s", value, entity_id) + return default + + unit = state.attributes.get("unit_of_measurement") + if unit is None or unit == UnitOfTemperature.CELSIUS: + return value + + try: + return TemperatureConverter.convert(value, unit, UnitOfTemperature.CELSIUS) + except (HomeAssistantError, ValueError, TypeError): + _LOGGER.warning( + "Unrecognised temperature unit %r on %s - treating %.1f as °C", + unit, + entity_id, + value, + ) + return value + async def _read_entity_bool( self, entity_id: str | None, @@ -927,50 +1129,7 @@ def _read_prio_state(self) -> str | None: return "heating" return "other" - def _estimate_degree_minutes( - self, indoor_temp: float, supply_temp: float, outdoor_temp: float - ) -> float: - """Estimate degree minutes from temperatures when sensor unavailable. - - Uses simplified thermal balance model: - DM ≈ (actual_flow - target_flow) × time_factor - - This is a rough estimation. Real DM tracking from NIBE is much more accurate. - - Args: - indoor_temp: Current indoor temperature (°C) - supply_temp: Current supply/flow temperature (°C) - outdoor_temp: Current outdoor temperature (°C) - - Returns: - Estimated degree minutes (typically -500 to +500) - - Note: - Negative DM = compressor needs to run (heat deficit) - Positive DM = recent heating surplus - """ - # Calculate target flow temp using simplified heating curve - # Typical NIBE curve: Flow ≈ 20 + 1.5 × (20 - Outdoor) - target_flow = 20.0 + 1.5 * (20.0 - outdoor_temp) - - # Calculate thermal imbalance - flow_error = supply_temp - target_flow - - # Estimate DM based on flow error and indoor temp error - target_indoor = DEFAULT_INDOOR_TEMP # Assumed target when not configured - indoor_error = indoor_temp - target_indoor - - # Simplified estimation - # If too cold inside and flow too low → negative DM (needs heating) - # If warm enough and flow adequate → near zero DM - estimated_dm = flow_error * 10.0 + indoor_error * 50.0 - - # Clamp to reasonable range - estimated_dm = max(-800.0, min(estimated_dm, 500.0)) - - return estimated_dm - - async def get_power_consumption(self) -> float | None: + async def get_power_consumption(self) -> tuple[float | None, bool]: """Get current power consumption of heat pump. Tries in order: @@ -978,24 +1137,21 @@ async def get_power_consumption(self) -> float | None: 2. Estimation from supply temperature (least accurate) Returns: - Power consumption in kW, or None if unavailable + (power in kW or None, whether that number was estimated). The estimate is useful for + layers that only need a magnitude, and useless to anything that reports or bills + consumption - so callers are made to see which one they got. """ - # Try configured power sensor + # Read via power_kw_from_state, the one shared helper the coordinator also uses, so both + # agree on what an absent unit means instead of answering the same sensor 1000x apart (W/kW). if self._power_sensor_entity: - power = await self._read_entity_float(self._power_sensor_entity, default=None) + power = power_kw_from_state(self.hass.states.get(self._power_sensor_entity)) if power is not None: - # Convert W to kW if needed - state = self.hass.states.get(self._power_sensor_entity) - if state: - unit = state.attributes.get("unit_of_measurement", "").lower() - if unit == "w": - power = power / 1000.0 _LOGGER.debug( "Using configured power sensor: %s = %.2f kW", self._power_sensor_entity, power, ) - return power + return power, False # Fall back to estimation from supply temperature supply_temp = await self._read_entity_float( @@ -1008,9 +1164,9 @@ async def get_power_consumption(self) -> float | None: if supply_temp is not None and outdoor_temp is not None: estimated_power = self._estimate_power_from_temps(supply_temp, outdoor_temp) _LOGGER.debug("Estimating power from temperatures: %.2f kW", estimated_power) - return estimated_power + return estimated_power, True - return None + return None, False def calculate_power_from_currents( self, @@ -1062,7 +1218,7 @@ def calculate_power_from_currents( return power_kw - async def _calculate_multi_sensor_temperature(self, nibe_temp: float) -> float: + async def _calculate_multi_sensor_temperature(self, nibe_temp: float | None) -> float | None: """Calculate indoor temperature from NIBE sensor + additional sensors. Combines NIBE BT50 with additional room sensors for more accurate @@ -1074,36 +1230,44 @@ async def _calculate_multi_sensor_temperature(self, nibe_temp: float) -> float: - Logs when all sensors become available Args: - nibe_temp: Temperature from NIBE BT50 sensor + nibe_temp: Temperature from NIBE BT50, or None when there is no room sensor. + A placeholder must NEVER be passed here - seeding the median with + DEFAULT_INDOOR_TEMP would drag the combined reading toward the target and + mask a real deviation. Returns: - Combined temperature using configured method (median/average) + Combined temperature using the configured method (median/average), or None + when no sensor produced a usable reading. """ - # Start with NIBE sensor - temps = [nibe_temp] + # Start with the NIBE sensor, if there is one + temps = [nibe_temp] if nibe_temp is not None else [] - # Read additional sensors + # Read additional sensors. _read_temperature normalises each to °C first - these + # are arbitrary user-chosen room sensors, so a °F one is entirely plausible, and + # the plausibility band below would otherwise reject every Fahrenheit reading. for entity_id in self._additional_indoor_sensors: - state = self.hass.states.get(entity_id) - if state and state.state not in ["unknown", "unavailable"]: - try: - temp = float(state.state) - # Sanity check (15-30°C range) - if 15.0 <= temp <= 30.0: - temps.append(temp) - else: - _LOGGER.warning( - "Ignoring out-of-range temperature from %s: %.1f°C", - entity_id, - temp, - ) - except (ValueError, TypeError) as err: - _LOGGER.debug("Failed to read sensor %s: %s", entity_id, err) + temp = await self._read_temperature(entity_id) + if temp is None: + continue + + if INDOOR_SENSOR_PLAUSIBLE_MIN <= temp <= INDOOR_SENSOR_PLAUSIBLE_MAX: + temps.append(temp) + else: + _LOGGER.warning( + "Ignoring out-of-range temperature from %s: %.1f°C (expected %.0f-%.0f°C)", + entity_id, + temp, + INDOOR_SENSOR_PLAUSIBLE_MIN, + INDOOR_SENSOR_PLAUSIBLE_MAX, + ) # Calculate combined temperature + if not temps: + # Neither BT50 nor any additional sensor produced a reading + return None + if len(temps) == 1: - # Only NIBE sensor available - return nibe_temp + return temps[0] if self._indoor_temp_method == "median": # Median is more robust to outliers (recommended) diff --git a/custom_components/effektguard/adapters/weather_adapter.py b/custom_components/effektguard/adapters/weather_adapter.py index ae7133ce..1d857635 100644 --- a/custom_components/effektguard/adapters/weather_adapter.py +++ b/custom_components/effektguard/adapters/weather_adapter.py @@ -15,10 +15,13 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt as dt_util +from homeassistant.util.unit_conversion import TemperatureConverter -from ..const import CONF_WEATHER_ENTITY +from ..const import CONF_WEATHER_ENTITY, WEATHER_FORECAST_PERIOD_HOURS if TYPE_CHECKING: from ..models.types import AdapterConfigDict @@ -114,6 +117,17 @@ async def get_forecast(self) -> WeatherData | None: self._schedule_next_random_attempt() return None + # A weather entity reports temperatures in the user's configured unit and declares which in + # `temperature_unit`; convert to Celsius. Without this, an imperial install feeds +23 where + # -5 C was meant (a 28-degree error) and withdraws the pre-heat exactly when it is needed, + # disagreeing with nibe_adapter, which does convert via the same TemperatureConverter. + source_unit = state.attributes.get("temperature_unit") or UnitOfTemperature.CELSIUS + + def to_celsius(value: float) -> float: + return TemperatureConverter.convert( + float(value), source_unit, UnitOfTemperature.CELSIUS + ) + # Get current temperature current_temp = state.attributes.get("temperature") if current_temp is None: @@ -192,15 +206,27 @@ async def get_forecast(self) -> WeatherData | None: ) # Schedule next random attempt self._schedule_next_random_attempt() - except (AttributeError, KeyError, ValueError, TypeError, OSError) as err: + except ( + HomeAssistantError, + AttributeError, + KeyError, + ValueError, + TypeError, + OSError, + ) as err: + # weather.get_forecasts raises HomeAssistantError (via raise_unsupported_forecast, + # plus its ServiceNotFound/ServiceValidationError subclasses) for any entity that + # does not implement hourly forecasts. This path runs on every update for entities + # with no `forecast` attribute, so an uncaught error here escapes the coordinator + # and kills its refresh task permanently. _LOGGER.warning( "Failed to get forecast via service call from %s: %s. " - "Weather-based optimization disabled. " - "This is normal for OpenWeatherMap free tier (no OneCall 3.0 access). " - "Consider switching to Met.no for free forecast access.", + "Weather-based optimization disabled; the rest of the optimization " + "continues. Common causes: the entity provides no hourly forecast, or " + "OpenWeatherMap free tier (no OneCall 3.0 access). Met.no provides a " + "free hourly forecast.", self._weather_entity, err, - exc_info=True, ) # Schedule next random attempt after error self._schedule_next_random_attempt() @@ -236,7 +262,7 @@ async def get_forecast(self) -> WeatherData | None: forecast_hours.append( WeatherForecastHour( datetime=dt, - temperature=float(temp), + temperature=to_celsius(temp), condition=item.get("condition"), ) ) @@ -244,8 +270,27 @@ async def get_forecast(self) -> WeatherData | None: _LOGGER.debug("Skipping invalid forecast entry: %s", err) continue + # FUTURE ONLY, AND IN ORDER. Layers slice this list positionally (`[:3]`, `[:24]`) and read + # index N as "N hours from now", but a weather entity's entries are not guaranteed future or + # sorted - a stalled integration can stay "available" while every forecast hour is in the + # past. Drop past hours and sort so index N is again N hours ahead. + cutoff = dt_util.utcnow() - timedelta(hours=WEATHER_FORECAST_PERIOD_HOURS) + stale = len(forecast_hours) + forecast_hours = sorted( + (hour for hour in forecast_hours if hour.datetime > cutoff), + key=lambda hour: hour.datetime, + ) + dropped = stale - len(forecast_hours) + if dropped: + _LOGGER.debug("Dropped %d forecast hours that had already passed", dropped) + if not forecast_hours: - _LOGGER.warning("No valid forecast hours parsed") + _LOGGER.warning( + "Weather entity %s has no forecast hours left in the future - every period it " + "published has already passed. Treating it as no forecast at all rather than " + "pre-heating on weather from hours ago.", + self._weather_entity, + ) return None # Validate forecast length @@ -273,7 +318,7 @@ async def get_forecast(self) -> WeatherData | None: self._next_random_attempt = None return WeatherData( - current_temp=float(current_temp), + current_temp=to_celsius(current_temp), forecast_hours=forecast_hours, source_entity=self._weather_entity, source_method=source_method, diff --git a/custom_components/effektguard/climate.py b/custom_components/effektguard/climate.py index 17024373..ba0a5016 100644 --- a/custom_components/effektguard/climate.py +++ b/custom_components/effektguard/climate.py @@ -21,16 +21,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( + CONF_ENABLE_OPTIMIZATION, CONF_OPTIMIZATION_MODE, CONF_TARGET_INDOOR_TEMP, DEFAULT_INDOOR_TEMP, DOMAIN, MAX_INDOOR_TEMP, - MIN_INDOOR_TEMP, + MIN_TARGET_TEMP, OPTIMIZATION_MODE_BALANCED, OPTIMIZATION_MODE_COMFORT, OPTIMIZATION_MODE_SAVINGS, @@ -40,6 +40,12 @@ _LOGGER = logging.getLogger(__name__) +# This entity DRIVES THE HEAT PUMP: async_set_hvac_mode -> set_optimization_enabled() -> +# async_refresh_and_apply() -> _drive_the_pump(). HA defaults a coordinator-based platform to 0 +# (unlimited concurrent calls); 1 serialises them. The control lock in _drive_the_pump already +# serialises the write, so this is belt-and-braces and flags that this entity touches hardware. +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, @@ -52,7 +58,9 @@ async def async_setup_entry( async_add_entities([EffektGuardClimate(coordinator, entry)]) -class EffektGuardClimate(CoordinatorEntity[EffektGuardCoordinator], RestoreEntity, ClimateEntity): +# No RestoreEntity: the mode lives in the config entry, which survives restart on its own and is +# what the coordinator reads. Restoring the entity's own last state just shadowed that source. +class EffektGuardClimate(CoordinatorEntity[EffektGuardCoordinator], ClimateEntity): """Climate entity for EffektGuard. Main user interface displaying current optimization status and allowing @@ -68,7 +76,12 @@ class EffektGuardClimate(CoordinatorEntity[EffektGuardCoordinator], RestoreEntit _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) - _attr_min_temp = MIN_INDOOR_TEMP + # The settable floor must not sit below the safety floor MIN_TEMP_LIMIT (18 C). A settable 15 C + # (the former MIN_INDOOR_TEMP) put the setpoint three degrees under it, so the comfort layer cut + # the offset to MIN_OFFSET above 18 C while the safety layer boosted MAX_OFFSET below it - a + # limit cycle on a real compressor. To move the floor, change MIN_TEMP_LIMIT, not this. + # See tests/unit/test_you_cannot_ask_for_a_temperature_the_system_will_fight.py. + _attr_min_temp = MIN_TARGET_TEMP _attr_max_temp = MAX_INDOOR_TEMP _attr_target_temperature_step = TEMP_STEP @@ -87,34 +100,20 @@ def __init__( model="Heat Pump Optimizer", ) self._entry = entry - self._attr_hvac_mode = HVACMode.HEAT self._attr_preset_mode = PRESET_NONE - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass. + @property + def hvac_mode(self) -> HVACMode: + """HEAT when the optimiser is running, OFF when it is not - a VIEW of the config entry. + + The master gate is entry.data["enable_optimization"], which the switch entity writes too. + Storing hvac_mode privately instead let the thermostat keep displaying OFF while the + optimiser resumed at the next tick, so both entities read the gate directly now. - Restore previous state to maintain HVAC mode across restarts. + tests/unit/test_the_thermostat_off_switch_actually_turns_it_off.py """ - await super().async_added_to_hass() - - # Restore previous state if available - if (last_state := await self.async_get_last_state()) is not None: - # Restore HVAC mode if valid - if last_state.state in [mode.value for mode in self._attr_hvac_modes]: - try: - self._attr_hvac_mode = HVACMode(last_state.state) - _LOGGER.debug( - "Restored HVAC mode: %s from previous state", self._attr_hvac_mode - ) - except ValueError: - _LOGGER.warning( - "Invalid HVAC mode '%s' in restored state, using default HEAT", - last_state.state, - ) - self._attr_hvac_mode = HVACMode.HEAT - else: - _LOGGER.debug("No valid HVAC mode to restore, using default HEAT") - self._attr_hvac_mode = HVACMode.HEAT + enabled = self._entry.data.get(CONF_ENABLE_OPTIMIZATION, True) + return HVACMode.HEAT if enabled else HVACMode.OFF @property def current_temperature(self) -> float | None: @@ -188,14 +187,11 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode | str) -> None: hvac_mode = HVACMode(hvac_mode) _LOGGER.info("Setting HVAC mode to %s", hvac_mode) - self._attr_hvac_mode = hvac_mode + enabled = hvac_mode != HVACMode.OFF - if hvac_mode == HVACMode.OFF: - # Disable optimization, reset offset to neutral - await self.coordinator.set_optimization_enabled(False) - else: - # Enable optimization - await self.coordinator.set_optimization_enabled(True) + # The coordinator changes the persisted gate only after the hardware transition succeeds, + # so the entity cannot display OFF while NIBE still holds a non-neutral offset. + await self.coordinator.set_optimization_enabled(enabled) self.async_write_ha_state() @@ -280,7 +276,7 @@ def extra_state_attributes(self) -> dict[str, Any]: # Current offset applied if "decision" in self.coordinator.data: decision = self.coordinator.data["decision"] - attrs["current_offset"] = decision.offset + attrs["current_offset"] = self.coordinator.current_offset attrs["optimization_reasoning"] = decision.reasoning # NIBE state diff --git a/custom_components/effektguard/config_flow.py b/custom_components/effektguard/config_flow.py index 5eeb5921..143c5a14 100644 --- a/custom_components/effektguard/config_flow.py +++ b/custom_components/effektguard/config_flow.py @@ -338,7 +338,9 @@ async def async_step_optional_sensors( _optional_entity_field( schema_dict, CONF_NIBE_TEMP_LUX_ENTITY, - selector.EntitySelector(selector.EntitySelectorConfig(domain="switch")), + selector.EntitySelector( + selector.EntitySelectorConfig(domain=["switch", "input_boolean"]) + ), auto_detected_temp_lux, ) @@ -698,7 +700,9 @@ async def async_step_reconfigure( _optional_entity_field( schema_dict, CONF_NIBE_TEMP_LUX_ENTITY, - selector.EntitySelector(selector.EntitySelectorConfig(domain="switch")), + selector.EntitySelector( + selector.EntitySelectorConfig(domain=["switch", "input_boolean"]) + ), entry.data.get(CONF_NIBE_TEMP_LUX_ENTITY), ) _optional_entity_field( diff --git a/custom_components/effektguard/const.py b/custom_components/effektguard/const.py index 765c29d0..b6a00ae2 100644 --- a/custom_components/effektguard/const.py +++ b/custom_components/effektguard/const.py @@ -23,9 +23,6 @@ CONF_RETURN_TEMP_ENTITY: Final = "return_temp_entity" # Optional: BT3 override CONF_DHW_CHARGING_TEMP_ENTITY: Final = "dhw_charging_temp_entity" # Optional: BT6 override CONF_NIBE_TEMP_LUX_ENTITY: Final = "nibe_temp_lux_entity" # Optional: switch.temporary_lux_50004 -CONF_ENABLE_DHW_OPTIMIZATION: Final = "enable_dhw_optimization" # Enable intelligent DHW scheduling -CONF_DHW_DEMAND_PERIODS: Final = "dhw_demand_periods" # High DHW demand periods (JSON list) -CONF_DHW_TARGET_TEMP: Final = "dhw_target_temp" # User-configurable DHW target temperature (°C) CONF_ADDITIONAL_INDOOR_SENSORS: Final = ( "additional_indoor_sensors" # Optional: List of extra temp sensors ) @@ -36,13 +33,11 @@ CONF_ENABLE_HOT_WATER_OPTIMIZATION: Final = "enable_hot_water_optimization" CONF_ENABLE_WEATHER_COMPENSATION: Final = "enable_weather_compensation" # Universal formulas CONF_ENABLE_OPTIMIZATION: Final = "enable_optimization" # Master enable switch -CONF_TARGET_TEMPERATURE: Final = "target_temperature" CONF_TOLERANCE: Final = "tolerance" CONF_OPTIMIZATION_MODE: Final = "optimization_mode" CONF_THERMAL_MASS: Final = "thermal_mass" CONF_INSULATION_QUALITY: Final = "insulation_quality" CONF_HEAT_PUMP_MODEL: Final = "heat_pump_model" -CONF_WEATHER_COMPENSATION_WEIGHT: Final = "weather_compensation_weight" # 0.0-1.0 # Defaults DEFAULT_TOLERANCE: Final = 0.5 @@ -58,8 +53,9 @@ DEFAULT_DHW_MORNING_HOUR: Final = 7 # Default morning DHW availability hour (07:00) DEFAULT_DHW_EVENING_HOUR: Final = 18 # Default evening DHW availability hour (18:00) -# Climate entity temperature limits (displayed in UI) -MIN_INDOOR_TEMP: Final = 15.0 # °C - minimum settable temperature +# Climate entity temperature limits (displayed in UI). +# The MINIMUM is MIN_TEMP_LIMIT - the safety floor - not a separate number: a setpoint the +# safety layer answers with an emergency is not a setpoint (audit F-085). MAX_INDOOR_TEMP: Final = 25.0 # °C - maximum settable temperature TEMP_STEP: Final = 0.5 # °C - temperature adjustment step @@ -73,6 +69,13 @@ DAYTIME_START_QUARTER: Final = 24 # Quarter 24 = 06:00 DAYTIME_END_QUARTER: Final = 87 # Quarter 87 = 21:45 (last daytime quarter) +# Swedish effect tariffs weight night quarters at half. This was a bare 0.5 in two places in +# effect_layer, and the fact that it is a WEIGHTING rather than a power was easy to lose sight of: +# the coordinator fed the savings baseline an unweighted peak and compared it against a weighted +# one, so a single night quarter reported 150 SEK/month of "savings" that were nothing but this +# number. Anything compared against a monthly peak has to be put through effective_tariff_power_kw. +NIGHT_TARIFF_WEIGHT: Final = 0.5 + # Optimization modes for climate entity presets OPTIMIZATION_MODE_COMFORT: Final = "comfort" # Minimize deviation, accept higher costs OPTIMIZATION_MODE_BALANCED: Final = "balanced" # Balance comfort and savings @@ -135,9 +138,31 @@ class OptimizationModeConfig: MAX_OFFSET: Final = 10.0 MIN_TEMP_LIMIT: Final = 18.0 +# The lowest target the system can actually HOLD, as opposed to the lowest it will not treat as an +# emergency. The safety layer fires below MIN_TEMP_LIMIT, and the comfort band is target ± tolerance +# - so a target sitting AT the floor puts the lower half of its own comfort band inside the +# emergency zone, and ordinary control noise trips a full MAX_OFFSET boost that carries +# is_emergency=True and bypasses the volatility blocker. A target must sit at least one tolerance +# above the floor (audit F-085). +# +# This is the slider's minimum, computed at the DEFAULT tolerance. The engine enforces the real +# bound - MIN_TEMP_LIMIT + the tolerance actually configured - and says so if it has to. +MIN_TARGET_TEMP: Final = MIN_TEMP_LIMIT + DEFAULT_TOLERANCE # 18.5 °C at the default ±0.5 + +# Power-reading plausibility. The margin covers startup/defrost transients above the +# machine's compressor-plus-immersion ceiling before a reading is called implausible. +POWER_VALIDATION_MARGIN: Final = 1.2 + # Service call rate limiting (boost, DHW, general) HEATING_BOOST_COOLDOWN_MINUTES: Final = 45 # Space heating boost cooldown DHW_BOOST_COOLDOWN_MINUTES: Final = 60 # DHW boost cooldown + +# boost_dhw duration window. EffektGuard itself ends the boost when it expires; NIBE's own +# temporary-lux timeout (~3 h one-shot on the F-series) still applies underneath, which is why +# the ceiling matches it. +DHW_BOOST_DEFAULT_DURATION_MINUTES: Final = 90 +DHW_BOOST_MIN_DURATION_MINUTES: Final = 30 +DHW_BOOST_MAX_DURATION_MINUTES: Final = 180 DHW_CONTROL_MIN_INTERVAL_MINUTES: Final = 60 # Automatic DHW control rate limit (1 hour) SERVICE_RATE_LIMIT_MINUTES: Final = 5 # General service call cooldown @@ -171,23 +196,20 @@ class OptimizationModeConfig: LAYER_WEIGHT_PROACTIVE_MIN: Final = 0.3 # Minimum proactive weight LAYER_WEIGHT_PREDICTION: Final = 0.65 # Prediction layer weight (Phase 6) LAYER_WEIGHT_COMFORT_MIN: Final = 0.2 # Minimum comfort weight -LAYER_WEIGHT_COMFORT_MAX: Final = 0.5 # Maximum comfort weight (legacy - unused after Phase 2) +LAYER_WEIGHT_COMFORT_MAX: Final = ( + 0.5 # Ceiling of the comfort layer's urgency ramp (comfort_layer.py) +) # Graduated comfort layer weights (Phase 2: Temperature Control Fixes) # Provides dynamic response to temperature overshoot severity # Dec 2, 2025: Lowered thresholds - comfort layer triggers at 0.5°C and 1.0°C -COMFORT_OVERSHOOT_SEVERE: Final = 0.5 # °C over tolerance for severe response -COMFORT_OVERSHOOT_CRITICAL: Final = 1.0 # °C over tolerance for critical response LAYER_WEIGHT_COMFORT_HIGH: Final = 0.7 # High priority: 0-0.5°C over tolerance -LAYER_WEIGHT_COMFORT_SEVERE: Final = 0.9 # Very high priority: 0.5-1°C over tolerance LAYER_WEIGHT_COMFORT_CRITICAL: Final = ( 1.0 # Critical priority: 1°C+ over tolerance (same as safety) ) # Graduated comfort layer correction multipliers (Phase 2) COMFORT_CORRECTION_MILD: Final = 1.0 # 0-1°C over tolerance: standard correction -COMFORT_CORRECTION_STRONG: Final = 1.2 # 1-2°C over tolerance: strong correction -COMFORT_CORRECTION_CRITICAL: Final = 1.5 # 2°C+ over tolerance: emergency correction # Effect tariff / Peak protection layer weights and offsets # Oct 19, 2025: Increased weights to make peak protection more decisive @@ -201,8 +223,6 @@ class OptimizationModeConfig: EFFECT_OFFSET_WARNING_RISING: Final = -1.0 # Gentle reduction near peak EFFECT_OFFSET_WARNING_STABLE: Final = -0.5 # Light reduction near peak EFFECT_MARGIN_PREDICTIVE: Final = 1.0 # kW margin threshold for predictive action -EFFECT_MARGIN_WARNING: Final = 1.5 # kW margin threshold for warning -EFFECT_MARGIN_WATCH: Final = 2.5 # kW margin threshold for watch # Effect layer predictive power thresholds (Oct 19, 2025) # Predicts future power demand based on thermal trend rate @@ -255,6 +275,11 @@ class OptimizationModeConfig: DM_THRESHOLD_START: Final = -60 # Normal compressor start (NIBE standard) DM_THRESHOLD_AUX_LIMIT: Final = -1500 # Auxiliary heat threshold (prevent expensive elpatron) +# How far the EXPECTED degree-minute band must stay clear of the absolute floor above: a house whose +# "normal" range reached the emergency trigger would be normal and in danger at once (audit F-076). +DM_NORMAL_MIN_BUFFER: Final = 100 # DM - the shallow end of "normal" stays this clear of the floor +DM_WARNING_BUFFER: Final = 50 # DM - normal_max and warning stay this clear of it + # Multi-tier CRITICAL thermal debt intervention (Oct 19, 2025 - Climate-Aware) # Philosophy: Progressive escalation based on climate-aware WARNING threshold # Tiers calculated dynamically as: WARNING + margin (adapts to climate zone) @@ -285,8 +310,30 @@ class OptimizationModeConfig: DM_CRITICAL_T1_PEAK_AWARE_OFFSET: Final = 0.5 # T1 minimal: Just enough to stabilize DM_CRITICAL_T2_PEAK_AWARE_OFFSET: Final = 0.75 # T2 minimal: Moderate escalation DM_CRITICAL_T3_PEAK_AWARE_OFFSET: Final = 1.0 # T3 minimal: Aggressive recovery needed -PEAK_AWARE_EFFECT_THRESHOLD: Final = -1.0 # Effect offset threshold for peak detection -PEAK_AWARE_EFFECT_WEIGHT_MIN: Final = 0.5 # Minimum effect weight for peak detection + +# Emergency tier identifiers (see thermal_layer.EmergencyLayerDecision.tier) +# +# SAFETY DISPATCH MUST KEY ON THESE NAMES - never on a layer's weight or on the +# magnitude of its offset. Both are unreliable discriminators: +# - The offset returned by a tier has already passed through thermal-recovery +# damping, the anti-windup cap, and the volatile-boost skip, so a damped T3 can +# emerge smaller than an undamped T1. +# - A weight is a tuning parameter. Retuning DM_CRITICAL_T2_WEIGHT (0.85 -> 0.81) +# silently moved T2 below a hardcoded `weight >= 0.85` gate, which dropped T2 +# recovery into the cost-layer override path and produced a -3.0 C heat REDUCTION +# at deep thermal debt. +DM_TIER_EMERGENCY: Final = "EMERGENCY" # DM <= DM_THRESHOLD_AUX_LIMIT (absolute priority) +DM_RECOVERY_TIERS: Final[frozenset[str]] = frozenset({"T1", "T2", "T3"}) + +# Minimal offsets applied when a recovery tier coincides with a CRITICAL cost layer +# (effect tariff at the monthly peak, or a PEAK spot-price quarter). Large enough to +# stop DM worsening, small enough not to grow the monthly peak. Keyed by tier name so +# that damping cannot change which tier's compromise is selected. +DM_CRITICAL_PEAK_AWARE_OFFSETS: Final[dict[str, float]] = { + "T1": DM_CRITICAL_T1_PEAK_AWARE_OFFSET, + "T2": DM_CRITICAL_T2_PEAK_AWARE_OFFSET, + "T3": DM_CRITICAL_T3_PEAK_AWARE_OFFSET, +} # Thermal Recovery Damping - General (Oct 20, 2025) # Prevent concrete slab thermal overshoot when solar gain naturally warms house during recovery @@ -513,9 +560,13 @@ class OptimizationModeConfig: THERMAL_MASS_TIMBER_UFH_THRESHOLD: Final = 1.2 # >= 1.2 = timber underfloor heating # Below 1.2 defaults to radiator heating -# Tolerance range multiplier (Oct 19, 2025) -# Scales user tolerance setting (1-10) to actual temperature range -TOLERANCE_RANGE_MULTIPLIER: Final = 0.4 # Scale: 1-10 -> 0.4-4.0°C +# THE INNER BAND: how much of the owner's tolerance (in DEGREES) a cost layer may spend freely. +# +# An owner who asks for +/-0.5 C gets an INNER band of +/-0.2 C inside which the spot and effect +# layers may coast the house around for free - the thermal battery - and a ramp out to the +/-0.5 C +# they actually asked for, across which the comfort layer progressively takes the floor back. +# See DecisionEngine._starvation_fraction. +TOLERANCE_RANGE_MULTIPLIER: Final = 0.4 # of the owner's tolerance: 0.5 C -> a 0.2 C free band # Safety layer emergency offsets (Oct 19, 2025) # Used for extreme temperature deviations and absolute DM maximum @@ -545,11 +596,26 @@ class OptimizationModeConfig: # DESIGN: All proactive zones trigger BEFORE warning threshold! # Z1-Z5 are PREVENTION layers. T1-T3 are RECOVERY layers (after warning). # +# WHY ZONE 5 WAS UNREACHABLE (kept as a comment: it was a module-level string bound to a +# name nothing read, which is dead code however useful the words are). +# Zone 5 was unreachable for the whole life of this project, in every release and every climate zone. +# +# Its band is `warning < DM <= zone5_threshold`, and the percent below was 1.00 - so zone5_threshold +# came out at exactly `normal_max`. Every climate zone ALSO sets `dm_warning_threshold` to exactly the +# deep end of `dm_normal_range`, so `warning == normal_max == zone5_threshold` and the band read +# `-740 < DM <= -740`: the empty set. Both ends were the same number, and the same temperature +# adjustment is added to both, so they could never separate. +# +# 1.00 is the one value that gives this rung no step to stand on, and it contradicts the DESIGN note +# directly above: Z1-Z5 trigger BEFORE the warning threshold, so Z5's boundary must sit strictly before +# it. 0.875 splits the old Z4 band in half and hands the deeper half to Z5 - the ladder regains the +# +3.0 rung it was built with, and no threshold that governs when RECOVERY starts moves at all. + PROACTIVE_ZONE1_THRESHOLD_PERCENT: Final = 0.02 # 2% of normal max (ultra-early warning, Jan 2026) PROACTIVE_ZONE2_THRESHOLD_PERCENT: Final = 0.30 # 30% of normal max (moderate) PROACTIVE_ZONE3_THRESHOLD_PERCENT: Final = 0.50 # 50% of normal max (significant) PROACTIVE_ZONE4_THRESHOLD_PERCENT: Final = 0.75 # 75% of normal max (strong) -PROACTIVE_ZONE5_THRESHOLD_PERCENT: Final = 1.00 # 100% of normal max (at warning boundary) +PROACTIVE_ZONE5_THRESHOLD_PERCENT: Final = 0.875 # 87.5% - strictly BEFORE warning (see note above) # Proactive layer zone offsets and weights (Oct 19, 2025) # Progressive escalation as DM approaches warning threshold @@ -601,10 +667,6 @@ class OptimizationModeConfig: OVERSHOOT_PROTECTION_FULL: Final = 1.5 # °C above target for full response OVERSHOOT_PROTECTION_OFFSET_MIN: Final = -7.0 # Offset at start threshold (coast gently) OVERSHOOT_PROTECTION_OFFSET_MAX: Final = MIN_OFFSET # Offset at full threshold (full coast) -OVERSHOOT_PROTECTION_WEIGHT_MIN: Final = 0.5 # Weight at start threshold -OVERSHOOT_PROTECTION_WEIGHT_MAX: Final = 1.0 # Weight at full threshold (full override) -OVERSHOOT_PROTECTION_FORECAST_HORIZON: Final = 12 # Hours to check forecast stability -OVERSHOOT_PROTECTION_COLD_SNAP_THRESHOLD: Final = 3.0 # °C drop that qualifies as cold snap # Price-aware overshoot protection (Dec 4, 2025) # Rapid cooling detection (Oct 19, 2025) @@ -646,8 +708,35 @@ class OptimizationModeConfig: # - Let SAFETY, COMFORT, EFFECT layers moderate naturally via weighted aggregation # # Real-world validation: Prevents 20:00→04:00 emergency cycles and 16:00 overshoot +# The unit shown on the price sensor when the spot-price integration has not (yet) reported one. +# The sensor prefers the user's own integration's unit; this is only the gap-filler during startup +# or a brief outage. It is öre because that is what GE-Spot reports for SE4 (audit F-070). +# Home Assistant repair-issue id raised when there is no electricity price source at all. +# Without prices the price layer abstains entirely - which is correct - but the user has +# `enable_price_optimization` switched on and believes it is trading. A log line does not tell +# them; a repair issue does. (Audit F-123.) +PRICE_SOURCE_ISSUE_ID: Final = "no_price_source" + +# EffektGuard drives hot water by toggling NIBE's temporary-lux switch. Home Assistant's own NIBE +# integration only maps that register (50004) for the F-series, so an S-series pump exposes no such +# entity at all - and the DHW half of EffektGuard then does nothing whatsoever. It said so in a +# _LOGGER.debug, while the UI carried on showing a hot-water status, a recommendation and a +# scheduled start time that could never fire. Same rule as the price source: a debug line is not +# telling anyone. +DHW_CONTROL_ISSUE_ID: Final = "no_dhw_control_entity" + +PRICE_UNIT_FALLBACK: Final = "öre/kWh" + WEATHER_FORECAST_DROP_THRESHOLD: Final = -4.0 # °C drop in forecast (was -5.0, lowered Jan 2026) WEATHER_FORECAST_HORIZON: Final = 12.0 # Hours to scan forecast (matches thermal lag) + +# `WeatherData.forecast_hours` MUST be filtered to the future, because every consumer slices it +# POSITIONALLY (`[:3]`, `[:24]`, `[:horizon]`). A stalled weather integration holds a forecast that +# starts hours ago, so an unfiltered `[:3]` reads weather from the past and a cold snap an hour away +# falls outside every horizon - exactly the case pre-heat exists for. Entries whose hour has ENDED +# are dropped; the current hour is kept (current_temp carries the present reading anyway), and an +# all-past forecast becomes EMPTY, which the layers already treat as "no forecast". +WEATHER_FORECAST_PERIOD_HOURS: Final = 1.0 # each forecast entry covers one hour WEATHER_GENTLE_OFFSET: Final = 0.83 # °C - gentle pre-heat (tuned Oct 20, was 0.5→0.6→0.7→0.77) WEATHER_INDOOR_COOLING_CONFIRMATION: Final = -0.5 # °C/h - confirms forecast accuracy LAYER_WEIGHT_WEATHER_PREDICTION: Final = 0.85 # Base weight (scaled by thermal mass) @@ -731,6 +820,17 @@ class OptimizationModeConfig: PRICE_PERCENTILE_CHEAP: Final = 25 # 10-25% = CHEAP PRICE_PERCENTILE_NORMAL: Final = 75 # 25-75% = NORMAL PRICE_PERCENTILE_EXPENSIVE: Final = 90 # 75-90% = EXPENSIVE +PRICE_PERCENTILE_MEDIAN: Final = 50 # the day's midpoint; guards every band against a plateau + +# A day whose prices barely move carries no signal, and percentile RANK cannot see that: it is +# scale-invariant, so it cannot tell a 130 ore spread from a 0.4 ore one. A day that ran from +# 39.80 to 40.20 ore used to earn the full VERY_CHEAP..PEAK banding - a 14 C swing in commanded +# offset - to chase four tenths of an ore. +# +# The test is RELATIVE, not an absolute number of ore, because nothing in the price layer knows +# its unit: PriceData carries none, and GE-Spot publishes whatever the owner configured. An +# absolute threshold would be a hundred times wrong for anyone reporting SEK/kWh. +PRICE_FLAT_DAY_SPREAD_FRACTION: Final = 0.05 # p90-p10 must exceed 5 % of the day's price scale # Above 90% = PEAK # Price classification base offsets (Dec 3, 2025, updated Dec 8, 2025) @@ -752,9 +852,6 @@ class OptimizationModeConfig: # Comfort layer constants (Oct 19, 2025) COMFORT_DEAD_ZONE: Final = 0.2 # ±0.2°C dead zone (no action) COMFORT_CORRECTION_MULT: Final = 0.3 # Gentle correction multiplier -COMFORT_DM_COOLING_THRESHOLD: Final = ( - -200 # Block cooling corrections when DM < -200 (thermal debt accumulating) -) # Comfort layer thermal-aware calculations (Dec 8, 2025) # Heat loss rate calculation: base_heat_loss = temp_diff / (insulation * HEAT_LOSS_DIVISOR) @@ -827,21 +924,73 @@ class OptimizationModeConfig: DEFAULT_CURVE_SENSITIVITY: Final = 1.5 # NIBE curve sensitivity (~1.5°C flow change per 1°C offset) -# Weather compensation mathematical constants -KUEHNE_COEFFICIENT: Final = 2.55 # Universal coefficient for flow temperature calculation -KUEHNE_POWER: Final = 0.78 # Power coefficient for heat transfer physics -RADIATOR_POWER_COEFFICIENT: Final = 1.3 # BS EN442 standard radiator output -RADIATOR_RATED_DT: Final = 50.0 # Standard test DT (75°C flow, 65°C return, 20°C room) - -# UFH flow temperature adjustments -UFH_FLOW_REDUCTION_CONCRETE: Final = 8.0 # °C reduction for concrete slab UFH -UFH_FLOW_REDUCTION_TIMBER: Final = 5.0 # °C reduction for timber/lightweight UFH -UFH_MIN_FLOW_TEMP_CONCRETE: Final = 25.0 # Minimum effective concrete slab temp -UFH_MIN_FLOW_TEMP_TIMBER: Final = 22.0 # Minimum effective timber UFH temp +# Weather compensation - EN 442 emitter law (see utils/emitter.py) +# +# EN 442-1:2014 3.31 emitter output: Phi / Phi_N = (dT / dT_N) ** n +# EN 442-1:2014 3.23 rated point 75/65/20 => dT_N = 50 K, ARITHMETIC mean. +# Never pair this reference with a log-mean dT. +# EN 1264 underfloor: q = 8.92 * dT ** 1.1 => n ~ 1.1 +RADIATOR_POWER_COEFFICIENT: Final = 1.3 # EN 442 exponent n, panel/sectional radiators +RADIATOR_RATED_DT: Final = 50.0 # EN 442 rated excess temperature (75/65/20) +UFH_POWER_COEFFICIENT: Final = 1.1 # EN 1264 exponent n, underfloor (NOT 1.3) + +# Design point: the outdoor temperature the emitters were sized for, and the supply they need at +# it. Defaults describe a standard Swedish low-temperature radiator system, erring WARM: a +# too-warm design point over-supplies slightly, a too-cold one silently under-heats, and degree +# minutes cannot detect under-heating that a negative offset causes (they improve as it worsens). +# INTERNAL GAINS. Bodies, appliances and the sun heat a house for free. Demand is NOT linear in +# (indoor - outdoor); it is linear in (balance_point - outdoor), and it reaches zero well before +# the outdoor air reaches room temperature. Ignoring that asks the pump to run hot in mild weather +# - where most of the season's kWh are delivered, and where OEM's measured fleet puts the COP +# penalty at 2.5-3 % per degree of excess flow. +# +# GAINS ARE WATTS, NOT DEGREES. The balance point is DERIVED, never fitted: +# +# balance_point = indoor_setpoint - INTERNAL_GAINS_W / heat_loss_coefficient +# +# A fixed offset in degrees would make the gains scale with the house's heat loss - a leaky house +# would be credited with MORE free heat than a well-insulated one, from the same bodies and the +# same fridge. Backwards. Watts divided by W/K is the only form that gets this right, and it is +# the form both measured sources below are expressed in. +# +# Measured sources: +# * heatpumpmonitor.org, 383 monitored systems with a fitted heat-demand line: median implied +# gains 583 W (750 W among the systems where it fitted non-zero). +# * OpenEnergyMonitor's SCOP tool: baseTemp 15.5 C against a 19.3 C room. Its source carries the +# naive no-gains formula COMMENTED OUT, with the note "This approach would need to take into +# account gains, hence use of degree days approach". +# +# NOT a source: fitting this against NIBE's published heating curve. That fit is DEGENERATE - a +# constant spread lifts the curve by (spread/2)(1 - phi^(1/n)) and the balance point drops it by +# the same basis function, so the two are not separately identifiable and any assumed spread +# manufactures a matching "gains" figure. Fitting the post-fix law to Kuhne's Vaillant curve - +# which contains PROVABLY ZERO gains - still yields 0.3 K at spread 0, 2.6 K at spread 5 and 4.9 K +# at spread 10. A curve fit cannot see gains. Only a wattage can. +INTERNAL_GAINS_W: Final = 600.0 # W of free heat; fleet median 583 W + +# A mis-configured heat loss must not drive the balance point somewhere absurd (a 20 W/K entry +# would put it 30 K below the setpoint and switch heating off entirely). +BALANCE_POINT_MIN_OFFSET: Final = 1.0 # C below setpoint, floor +BALANCE_POINT_MAX_OFFSET: Final = 6.0 # C below setpoint, ceiling + +DEFAULT_DESIGN_OUTDOOR_TEMP: Final = -15.0 # °C, dimensioning outdoor temperature (DUT/DVUT) +DEFAULT_DESIGN_FLOW_TEMP_RADIATOR: Final = 50.0 # °C supply at DUT for radiators +DEFAULT_DESIGN_FLOW_TEMP_UFH: Final = 35.0 # °C supply at DUT for UFH (NIBE: normally 35-45) +DEFAULT_DESIGN_SPREAD: Final = 5.0 # °C flow-return spread at design load + +# Weather compensation TRIMS the pump's own curve, it does not replace it: a correctly tuned curve +# needs a near-zero correction. This bound stops a mis-configured design point from ever +# commanding a large swing in either direction. +WEATHER_COMP_MAX_OFFSET: Final = 3.0 # °C, absolute cap on the compensation offset # Heat loss coefficient defaults (W/°C) DEFAULT_HEAT_LOSS_COEFFICIENT: Final = 180.0 # W/°C typical value +# Fallback decay rate for the pre-heat sizing, used until learning is trusted. It was a bare 0.15 +# in adaptive_learning next to a bare 180.0 - and the 180.0 was a second copy of the constant +# directly above this line. +DEFAULT_THERMAL_DECAY_RATE: Final = 0.15 # °C per hour, per °C of indoor-outdoor difference + # Power estimation defaults (kW) # Used when actual power sensor unavailable - fallback values DEFAULT_BASE_POWER: Final = 3.0 # kW typical NIBE heat pump average @@ -856,34 +1005,117 @@ class OptimizationModeConfig: PEAK_RECORDING_MINIMUM: Final = 0.5 # kW - lowered from 1.0 for better learning # Typical NIBE consumption: standby 0.05-0.1 kW, heating 2.5-6.0 kW +# And a CEILING, for the same reason the floor exists: a reading this far outside what a house can +# physically draw is not a peak, it is a broken sensor. +# +# A Swedish domestic service is 16-25 A three-phase (11-17 kW); 35 A (24 kW) is a large villa with +# an EV charger. 100 kW cannot happen behind a domestic main fuse, so nothing real is ever refused +# here - but a mis-scaled reading is, and one of those got all the way into the tariff record: +# `power_kw_from_state` case-folded its unit and read 5000 mW (five watts) as 5 000 000 kW. The +# unit table is fixed, but a number that is persisted for a month and silently governs whether the +# house is throttled deserves a plausibility bound of its own. An astronomical recorded peak does +# not throttle the house - it makes every real quarter look safe against it, and peak protection +# goes quiet for the rest of the month. +PEAK_RECORDING_MAXIMUM: Final = 100.0 # kW - beyond any domestic main fuse; a sensor fault + # Update intervals UPDATE_INTERVAL_MINUTES: Final = ( 5 # Coordinator update frequency + thermal predictor save throttle interval ) -QUARTER_INTERVAL_MINUTES: Final = 15 # Swedish Effektavgift measurement period +# The SPOT PRICE interval. Nordpool settles in quarter-hours, and this is that. It is a DIFFERENT +# concern from the effect tariff's measurement window (BILLING_PERIOD_MINUTES) even though the +# owner's tariff measures over the same 15-minute quarter: one is what electricity costs, the other +# is what the grid peak is billed on. They are kept as separate constants so a change to one model +# cannot silently move the other. QUARTERS_PER_DAY: Final = 96 # Quarters in a normal (non-DST-transition) day # Native interval counts a day can have: 92 (spring DST), 96 (normal), # 100 (autumn DST). Anything else means the source delivered a data gap. NATIVE_DAY_QUARTER_COUNTS: Final = (92, 96, 100) +# How many consecutive update cycles the coordinator will wait for the heat pump to appear before +# it reports failure. MyUplink can take 45-50 seconds to publish its entities, so waiting is right; +# waiting FOREVER is a silent failure. Unbounded, a user who picked the wrong entity - or who has +# no NIBE at all - keeps a config entry that stays loaded and green indefinitely while the +# integration reads nothing and controls nothing. +# +# At UPDATE_INTERVAL_MINUTES this is a generous margin over any plausible MyUplink start-up. +# How old a NIBE reading may be before it stops being a reading and becomes a memory. +# +# The adapter rejects `unavailable` and `unknown`, so an upstream integration that DIES is caught - +# MyUplink and nibe_heatpump use a coordinator, and their entities go unavailable when polling +# fails. But `manifest.json` also lists mqtt and modbus, and an MQTT sensor holds its last retained +# value indefinitely: if the bridge publishing degree minutes stops, the sensor goes on reporting +# the number it was given hours ago and every other check passes. The pump keeps being driven on it, +# and the real degree minutes could be anywhere - including past the auxiliary-heat limit. +# +# 30 minutes is deliberately generous: the control loop runs every 5, so any NIBE source reporting +# less often than this cannot support five-minute heat-pump control anyway, and nothing that works +# today can be broken by the guard. (Audit F-015.) +NIBE_READING_MAX_AGE_MINUTES: Final = 30 + +STARTUP_MAX_GRACE_ATTEMPTS: Final = 12 # cycles (~1 hour) before a missing pump is an error + STARTUP_GRACE_UPDATES: Final = 1 # Number of full cycles to observe before active control STARTUP_GRACE_MIN_INTERVAL: Final = 120 # Seconds - minimum lockout before observation cycles # Thermal predictor history constants (derived from UPDATE_INTERVAL_MINUTES) SAMPLES_PER_HOUR: Final = 60 // UPDATE_INTERVAL_MINUTES # 12 samples/hour with 5-min intervals +# THE PREDICTION GATES COUNTED SAMPLES AND SPOKE IN HOURS, AND THE TWO DISAGREED BY 3x. +# +# SAMPLES_PER_HOUR is derived correctly above, and the predictor's own deque is sized with it. The +# gates were not: they hardcoded 4, 96 and 8, with comments claiming "1 hour", "24 hours" and +# "2+ hours". At a five-minute coordinator tick those are 20 minutes, EIGHT hours, and 40 minutes. +# +# The learned pre-heating layer therefore engaged on a THIRD of the data it believed it had, and +# eight hours of a Swedish winter night is not a representative day. The hours are named here and +# the sample counts derived from them, so the two can no longer drift apart. +PREDICTION_MIN_HISTORY_HOURS: Final = 1 # before projecting forward at all +PREDICTION_RESPONSIVENESS_MIN_HOURS: Final = 2 # before estimating how fast the house responds +PREDICTION_LEARNED_PREHEAT_MIN_HOURS: Final = 24 # before acting on learned pre-heating + # Adaptive learning parameters # Source: POST_PHASE_5_ROADMAP.md Phase 6 - Self-Learning Capability -LEARNING_OBSERVATION_WINDOW: Final = 672 # 1 week of 15-minute observations -LEARNING_MIN_OBSERVATIONS: Final = 96 # 24 hours minimum for basic learning +# +# LEARNING OBSERVES ON A DIFFERENT CLOCK FROM CONTROL, and it has to. +# +# Control runs every UPDATE_INTERVAL_MINUTES because the pump needs steering that often. Learning +# used to piggy-back on the same tick, and could therefore never learn anything: the room +# sensor (NIBE BT50) reports to 0.1 C, and a house warming at a brisk 0.6 C/h moves 0.05 C in five minutes - half +# a sensor tick. Every observed rate quantised to 0.0 or 1.2 C/h with nothing in between, so the +# scatter that `_calculate_confidence` scores was a measurement of the SAMPLING INTERVAL, not of the +# building. Confidence sat at 0.467 against a 0.7 gate, on any house, forever (F-132). +# +# The same house, identical physics, identical sensor, watched more slowly: +# 5 min quantum 1.20 C/h confidence 0.467 never engages +# 15 min quantum 0.40 C/h confidence 0.600 never engages +# 30 min quantum 0.20 C/h confidence 0.707 engages, barely +# 60 min quantum 0.10 C/h confidence 0.811 engages, comfortably +# +# Hourly is also the honest timescale for the question being asked. A building's thermal time +# constant is hours; the concrete slab in UFH_CONCRETE_* lags six of them. Sampling that every five +# minutes is sampling noise. +# +# It fixes the memory as well. The 672-entry deque was commented "1 week" but spanned 56 HOURS at the +# 5-minute cadence - and it is a rolling window, so the model on day 90 saw exactly what it saw on +# day 3, and the README's "Day 8-14: high confidence" was unreachable by construction. At one +# observation an hour the same deque remembers 28 days. +LEARNING_OBSERVATION_INTERVAL_MINUTES: Final = ( + 60 # Learning observes hourly, not every control tick +) +LEARNING_OBSERVATION_WINDOW: Final = 672 # 672 hourly observations = 28 days of memory +LEARNING_MIN_OBSERVATIONS: Final = 96 # 96 hourly observations = 4 days before learning may engage LEARNING_CONFIDENCE_THRESHOLD: Final = 0.7 # 70% confidence to use learned params +# What it takes for the heating observations to carry any information at all. +# The room sensor (NIBE BT50) reports to 0.1 C. A house that moved less than one sensor tick per +# hour WHILE ACTIVELY HEATING has told us nothing measurable about itself: the signal is below the +# instrument's resolution. Such a run must score ZERO confidence, not perfect confidence - which is +# what a std/mean ratio does when every reading is identical and std collapses to 0 (F-132). +LEARNING_MIN_HEATING_RATE: Final = 0.1 # °C/h - one sensor tick per hour; below this, no signal +LEARNING_MIN_HEATING_SAMPLES: Final = 10 # observations under heating before consistency means much + # Swedish climate regions - SMHI historical data (1961-1990) # Source: Swedish_Climate_Adaptations.md -CLIMATE_SOUTHERN_SWEDEN: Final = "southern_sweden" # Malmö/Gothenburg (0°C Jan avg) -CLIMATE_CENTRAL_SWEDEN: Final = "central_sweden" # Stockholm (-4°C Jan avg) -CLIMATE_MID_NORTHERN_SWEDEN: Final = "mid_northern_sweden" # Umeå/Östersund (-8°C Jan avg) -CLIMATE_NORTHERN_SWEDEN: Final = "northern_sweden" # Luleå (-11°C Jan avg) -CLIMATE_NORTHERN_LAPLAND: Final = "northern_lapland" # Kiruna (-13°C Jan avg) # Climate zones - Import from dedicated module # Source: optimization/climate_zones.py @@ -898,15 +1130,18 @@ class OptimizationModeConfig: # # Import is done at runtime to avoid circular dependencies - use the climate_zones module directly. -# Storage -STORAGE_VERSION: Final = 1 +# Storage. Two stores, two schemas, two lifecycles - so two versions. +# Effect store v1 recorded 15-minute quarter peaks (`quarter_of_day`) with no per-sample source. +# The owner's tariff measures the same 15-minute quarter (see effect_layer.py), so a v1 record is +# the SAME billed quantity - migration to v2 CONVERTS it (`quarter_of_day` -> `period_of_day`, +# `source` -> POWER_SOURCE_NONE) rather than discarding it. The only field it cannot recover is +# provenance, so a converted peak is treated as unbillable until fresh measurement replaces it. +EFFECT_STORAGE_VERSION: Final = 2 +LEARNING_STORAGE_VERSION: Final = 1 STORAGE_KEY: Final = f"{DOMAIN}_state" STORAGE_KEY_LEARNING: Final = f"{DOMAIN}_learned_data" # Services -SERVICE_SET_OPTIMIZATION_MODE: Final = "set_optimization_mode" -SERVICE_FORCE_UPDATE: Final = "force_update" -SERVICE_RESET_PEAKS: Final = "reset_peaks" SERVICE_FORCE_OFFSET: Final = "force_offset" SERVICE_RESET_PEAK_TRACKING: Final = "reset_peak_tracking" SERVICE_BOOST_HEATING: Final = "boost_heating" @@ -919,20 +1154,12 @@ class OptimizationModeConfig: ATTR_TARGET_TEMP: Final = "target_temp" # Attributes -ATTR_CURRENT_OFFSET: Final = "current_offset" -ATTR_DECISION_REASONING: Final = "decision_reasoning" -ATTR_LAYER_VOTES: Final = "layer_votes" -ATTR_PEAK_TODAY: Final = "peak_today" -ATTR_PEAK_THIS_MONTH: Final = "peak_this_month" -ATTR_THERMAL_DEBT: Final = "thermal_debt" -ATTR_QUARTER_OF_DAY: Final = "quarter_of_day" -ATTR_OPTIONAL_FEATURES: Final = "optional_features_status" # DHW (Domestic Hot Water) Optimization Constants # Based on DHW_RESEARCH_FINDINGS.md and DHW_IMPLEMENTATION_CORRECTIONS.md # # Temperature hierarchy: -# - 20°C (DHW_SAFETY_CRITICAL): Hard floor, always heat (emergency) +# - 20°C (DHW_SAFETY_CRITICAL): below this, price is no longer a reason to wait # - 30°C (DHW_SAFETY_MIN): Price optimization minimum (allows tank to cool for better price-based heating) # - 40°C (DHW_MIN_TEMP): User-configurable minimum (validation) # - 45°C (MIN_DHW_TARGET_TEMP): Minimum user target / NIBE start threshold @@ -950,27 +1177,49 @@ class OptimizationModeConfig: 52.0 # °C - DHW at normal target (50°C + 2°C buffer for "ready" status) ) -# Legionella Prevention (requires DHW tank immersion heater, Swedish: elpatron) -# Boverket.se official guidelines: -# - Legionella bacteria grow at 20-45°C (our new low-temp optimization range!) -# - Legionella dormant below 20°C -# - Killed at high temperatures (≥60°C) -# - Water heaters should maintain ≥60°C to prevent bacterial growth -# -# Heat pump limitation: -# - Compressor can only reach ~50-55°C max (COP/efficiency limits) -# - NIBE automatically engages DHW tank immersion heater to reach 60°C -# - This is standard operation for all NIBE Legionella protection -# - Our hygiene boost schedules this during cheapest electricity periods -# -# NOTE: DHW immersion heater (elpatron) is separate from space heating auxiliary heater. -# They are different electrical heating systems with different purposes. -DHW_LEGIONELLA_DETECT: Final = ( - 55.0 # °C - BT7 temp indicating Legionella boost complete (observed 55.3°C in production) -) -DHW_LEGIONELLA_PREVENT_TEMP: Final = ( - 56.0 # °C - Target temp for hygiene boost (kills bacteria, requires immersion heater) -) +# Legionella / hygiene. +# +# ⚠️ EFFEKTGUARD DOES NOT PROVIDE LEGIONELLA PROTECTION. NIBE DOES. +# +# NIBE's built-in "periodic increase" function (menu 2.9.1 on F-series, 2.4 on S-series - +# NOT 4.9.5, which is schedule blocking) is ACTIVATED FROM THE FACTORY, runs every 14 days +# (7 on S-series), targets a stop temperature of 55 C (settable 55-70, never lower), and +# explicitly uses "the compressor AND the immersion heater". EffektGuard cannot block it, +# and cannot even observe it: Home Assistant's myuplink integration excludes parameters +# 47050 (enable) and 47051 (interval) from the entities it creates. +# Source: NIBE F750 / F730 / F1155 installer manuals, menus 2.9.1 and 5.1.1. +# +# Why EffektGuard's own boost CANNOT perform a Legionella cycle: +# Temporary lux is not a setpoint - it switches the hot-water comfort mode to LUXURY for +# 3/6/12 h, so the tank is driven to the pump's configured LUXURY STOP temperature. Factory +# values, measured on BT6 (the CONTROL sensor): F750 54 C, F730 53 C, F1155 53 C - all BELOW +# the 55 C floor NIBE enforces for its Legionella function. Those setpoints are +# installer-adjustable, so their true value is UNKNOWN to us at runtime. Never hard-code it. +# +# NOTE: the DHW immersion heater (elpatron) is separate from the space-heating auxiliary +# heater. Different electrical systems, different purposes. +DHW_LEGIONELLA_DETECT: Final = 55.0 +"""°C on BT7 taken as evidence that a high-temperature cycle occurred. + +Unsound as proof that OUR boost completed; kept only as a best-effort observation of NIBE's +own periodic increase: + - BT7 is "hot water, DISPLAY"; BT6 is "hot water, CONTROL". Every setpoint acts on BT6. + - On F1155 / S1155, BT7 is OPTIONAL and may not physically exist. + - Temporary lux stops at 53-54 C on BT6, so it will not reach this threshold. +""" + +DHW_LEGIONELLA_PREVENT_TEMP: Final = 56.0 +"""°C - target requested for the opportunistic high-temperature top-up. + +NEVER ACTUALLY WRITTEN TO NIBE: the only DHW actuator is the temporary-lux switch, and the +pump heats to ITS OWN configured lux stop temperature, not to this value. +""" + +# Days without any observed high-temperature cycle after which EffektGuard warns the user. +# NIBE's periodic increase runs every DHW_LEGIONELLA_MAX_DAYS (14) from the factory, so +# going well beyond that suggests it has been switched off on the pump. DIAGNOSTIC ONLY - +# we warn, we do not attempt to substitute for the function (we cannot: see above). +DHW_LEGIONELLA_OVERDUE_DAYS: Final = 21.0 DHW_LEGIONELLA_MAX_DAYS: Final = 14.0 # Days - Max time without high-temp cycle (hygiene) DHW_HEATING_TIME_HOURS: Final = 1.5 # Hours to heat DHW tank (typically 1-2h) DHW_SCHEDULING_WINDOW_MAX: Final = 24 # Max hours ahead for DHW scheduling @@ -990,6 +1239,14 @@ class OptimizationModeConfig: # Used as fallback when insufficient history for dynamic calculation # The dhw_optimizer uses calculate_heating_rate() for dynamic estimation from BT7 history DHW_DEFAULT_HEATING_RATE: Final = 14.0 # °C/hour (measured from debug log) + +# Plausible band for the DHW tank heating rate (°C/hour). Applied BOTH when a rate is learned +# from BT7 history AND when one is restored from storage: an unchecked restore can load 0.0 +# (ZeroDivisionError in estimate_heating_time) or 0.1 (a 200-hour heat-up estimate, which makes +# the scheduler panic-heat immediately at any price, forever). +DHW_HEATING_RATE_MIN: Final = 5.0 +DHW_HEATING_RATE_MAX: Final = 25.0 + DHW_AMOUNT_HEATING_BUFFER: Final = 0.5 # Hours buffer for scheduling (arrive early, not late) # DHW optimal window price optimization (Phase 1 fix - Jan 2026) @@ -1005,6 +1262,13 @@ class OptimizationModeConfig: DM_DHW_BLOCK_FALLBACK: Final = -340.0 # Fallback: Never start DHW below this DM DM_DHW_ABORT_FALLBACK: Final = -500.0 # Fallback: Abort DHW if reached during run +# How far BELOW the block threshold a running DHW cycle is allowed to sink before it gives up. +# Heating hot water takes the compressor away from space heating, so degree minutes ALWAYS fall +# during a cycle. Abort must therefore sit deeper than block, or every cycle that starts near the +# block threshold is aborted on the next tick and the pump cycles (audit F-030). This 160 DM is the +# gap the fallback pair above already encodes: -340 block, -500 abort. +DM_DHW_ABORT_BUFFER: Final = 160.0 + # DHW runtime safeguards (monitoring only - NIBE controls actual completion) DHW_SAFETY_RUNTIME_MINUTES: Final = 30 # Safety minimum heating (emergency) DHW_NORMAL_RUNTIME_MINUTES: Final = 45 # Normal DHW heating window @@ -1012,10 +1276,17 @@ class OptimizationModeConfig: DHW_URGENT_RUNTIME_MINUTES: Final = 90 # Urgent pre-demand heating # NIBE Power Calculation Constants (Swedish 3-phase standard) -# All NIBE heat pumps in Sweden are 3-phase systems -NIBE_VOLTAGE_PER_PHASE: Final = ( - 240.0 # V - Swedish 3-phase: 400V between phases, 240V phase-to-neutral -) +# All NIBE heat pumps in Sweden are 3-phase systems. +# +# This was 240.0 V, and its own comment gave the reason it could not be: "400V between phases, +# 240V phase-to-neutral". Those two numbers contradict each other by a factor of sqrt(3) - +# 400 / sqrt(3) is 230.94, not 240. 240 V is the legacy UK/US figure. +# +# IEC 60038 and EN 50160 declare the European low-voltage supply as 230/400 V, and Sweden is +# 230 V phase-to-neutral. At 240 V every power figure derived from NIBE's BE1/BE2/BE3 phase +# currents came out 4.3 % HIGH - and those figures now feed the monthly peak history that drives +# peak protection, so the bias is not cosmetic. +NIBE_VOLTAGE_PER_PHASE: Final = 230.0 # V - IEC 60038: 230/400 V, phase-to-neutral NIBE_POWER_FACTOR: Final = 0.95 # Conservative for inverter compressor (real likely 0.96-0.98) # ============================================================================ @@ -1056,14 +1327,11 @@ class OptimizationModeConfig: AIRFLOW_DEFAULT_STANDARD: Final = 150.0 # m³/h - Normal ventilation AIRFLOW_DEFAULT_ENHANCED: Final = 252.0 # m³/h - Maximum ventilation -# COP improvement from enhanced airflow -# More air → warmer evaporator → better COP -# Empirically ~20% improvement at enhanced flow -AIRFLOW_COP_IMPROVEMENT_FACTOR: Final = 1.20 # 20% COP improvement -AIRFLOW_BASE_COP: Final = 3.3 # Typical NIBE F750 COP - -# Compressor input power for benefit calculations -AIRFLOW_COMPRESSOR_INPUT_KW: Final = 2.0 # kW typical compressor electrical input +# The airflow COP-improvement constants that lived here are gone with the term that used them. +# Extracting more heat from more air and "improving the COP" are the same joules: at constant +# electrical input the first law gives d(Q_cond) = d(Q_evap) = P_el * d(COP). Adding both counted +# the heat twice, and made a net thermal LOSS look like a gain across the whole heating season. +# See optimization/airflow_optimizer.py. # Temperature thresholds AIRFLOW_OUTDOOR_TEMP_MIN: Final = -15.0 # °C - Never enhance below this (penalty exceeds gains) @@ -1107,17 +1375,119 @@ class OptimizationModeConfig: # NIBE Enhanced Ventilation (F750/F730) # Controls the "Increased Ventilation" switch on exhaust air heat pumps # Entity pattern: switch.{device}_increased_ventilation -NIBE_VENTILATION_MIN_ENHANCED_DURATION: Final = 5 # Minimum minutes to run enhanced - -DHW_SAFETY_CRITICAL: Final = 20.0 # °C - Hard floor, always heat below this (emergency) +# THE VENTILATION FAN COULD CYCLE FOREVER, AND THE GUARD MEANT TO STOP IT GUARDED NOTHING. +# +# NIBE_VENTILATION_MIN_ENHANCED_DURATION was 5 minutes - exactly UPDATE_INTERVAL_MINUTES - so a +# turn-off was permitted on the very next coordinator tick. And nothing at all guarded the turn-ON. +# A decision that oscillates around its threshold, which is what a marginal COP gain does, produced +# twelve fan state changes an hour, indefinitely. On an exhaust-air F750 every one of them perturbs +# the source air the compressor is drawing from. +# +# It was also shorter than the SHORTEST duration the airflow optimizer ever recommends +# (AIRFLOW_DURATION_SMALL_DEFICIT, 15 min) - so it could never enforce even the mildest of them. +# The optimizer's own `duration_minutes` is now the minimum run time; this is only a floor under it, +# for the case where a decision arrives with no duration at all. +NIBE_VENTILATION_MIN_ENHANCED_DURATION: Final = 15 # Minimum minutes to run enhanced + +# And a minimum REST before enhancing again, which did not exist. Without it the fan can go +# ON -> hold -> OFF -> ON on the next tick, and the minimum run time above just sets the period of +# the oscillation rather than preventing it. +NIBE_VENTILATION_MIN_REST_DURATION: Final = 15 # Minimum minutes at normal before re-enhancing + +# "HARD FLOOR, ALWAYS HEAT BELOW THIS" IS WHAT THIS SAID, AND IT IS NOT TRUE. +# +# Below 20 C the optimizer stops WAITING FOR A CHEAPER PRICE. It does not heat unconditionally, and +# it must not: two things still outrank the hot water, and both are deliberate. +# +# * CRITICAL THERMAL DEBT. Running a DHW cycle takes the compressor away from space heating, and +# doing that while the house is already in deep degree-minute debt is how a recoverable debt +# becomes an immersion-heater one. +# * THE HOUSE ITSELF BEING BELOW ITS SAFETY FLOOR. The owner's rule, in his own words: "DHW wins, +# but never below safety." +# +# Verified by driving the real scheduler at 15 C - five degrees under this "hard floor": +# +# DHW 15 C, DM -150, indoor 21.0 -> heats (DHW_SAFETY_MINIMUM) +# DHW 15 C, DM -1400, indoor 21.0 -> does NOT heat (CRITICAL_THERMAL_DEBT) +# DHW 15 C, DM -150, indoor 17.0 -> does NOT heat (SPACE_HEATING_EMERGENCY) +# +# The code is right. The comment was the lie, and it is the kind of lie that gets a safety rule +# "restored" by the next reader who trusts it. +DHW_SAFETY_CRITICAL: Final = 20.0 # °C - below this, price stops being a reason to defer DHW_SAFETY_MIN: Final = 30.0 # °C - Safety minimum (can defer if 20-30°C during expensive periods) DHW_COOLING_RATE: Final = 0.5 # °C/hour - Conservative DHW tank cooling estimate # Unit conversion WATTS_PER_KILOWATT: Final = 1000.0 +KILOWATTS_PER_MEGAWATT: Final = 1000.0 +MILLIWATTS_PER_KILOWATT: Final = 1_000_000.0 + +# Where a power reading came from. The monthly effect tariff may only be billed against a real +# measurement, so the value has to carry its own provenance - asking whether a power ENTITY is +# configured tells you nothing about whether it answered this cycle. +POWER_SOURCE_EXTERNAL_METER: Final = "external_meter" +POWER_SOURCE_NIBE_CURRENTS: Final = "nibe_currents" +POWER_SOURCE_ESTIMATE: Final = "estimate" +POWER_SOURCE_NONE: Final = "none" + +# BILLABLE and USABLE-AS-A-CONTROL-THRESHOLD are two different questions, and conflating them broke +# the integration's headline feature for everyone without a whole-house meter. +# +# BILLABLE: the Swedish effect tariff bills whole-house grid IMPORT, so only a whole-house meter can +# produce a number that belongs in a billing figure. NIBE phase currents (BE1/BE2/BE3) measure the +# heat pump and nothing else - not the oven, not the EV charger. Estimates are fabricated. Neither +# may be reported to the owner as "your monthly peak". +BILLABLE_POWER_SOURCES: Final = frozenset({POWER_SOURCE_EXTERNAL_METER}) + +# PEAK CONTROL: a reading does not have to be the billed quantity to be worth acting on. The heat +# pump is the dominant CONTROLLABLE load in the house, and `should_limit_power` compares this +# quarter against the month's own recorded peaks - so a NIBE-only history compared against NIBE-only +# power is self-consistent, and it still throttles the pump when the pump is the thing spiking. +# +# The whole-house meter is OPTIONAL in the config flow. Gating peak RECORDING on billability alone +# left `_monthly_peaks` permanently empty for those users, and `should_limit_power` returns +# "OK - no peaks recorded yet" on an empty history: peak protection silently never fired at all. +# `main` allowed phase currents here (`has_external_power_sensor or phase1_current is not None`) and +# ran a winter that way. +# +# Estimates stay out of BOTH. A number derived from compressor Hz is not a measurement, and driving +# peak protection from it would throttle the house on the strength of a guess. +PEAK_CONTROL_POWER_SOURCES: Final = frozenset( + {POWER_SOURCE_EXTERNAL_METER, POWER_SOURCE_NIBE_CURRENTS} +) # NIBE Adapter Constants NIBE_DEFAULT_SUPPLY_TEMP: Final = 35.0 # °C - Default supply/flow temp when sensor unavailable + +# AN IMPLAUSIBLE READING IS NOT A READING. +# +# `get_current_state` already says this about a MISSING sensor: "Never substitute a plausible +# constant for a missing one: that makes a broken installation indistinguishable from a healthy +# one and still writes a curve offset to the pump." A reading that is physically impossible is the +# same thing wearing a number, and the mechanism that produces one is mundane: NIBE's Modbus +# registers hold temperatures in DECI-degrees, so a hand-written YAML that omits `scale: 0.1` +# reports BT50's 213 as 213.0 C rather than 21.3 C. The repo's own Modbus simulator documents +# exactly that register, with exactly that value. +# +# 213 C indoor is not a rounding error. The comfort layer reads a 192 C overshoot and commands +# -10.0 C at critical weight, forever - maximum heat reduction in a Swedish January - and the +# 18 C safety floor never fires, because the safety layer is reading the same 213 C. +# +# These bands are applied AFTER unit conversion to °C. They are deliberately wide: their job is to +# catch a value that cannot be a temperature at all, not to second-guess a working sensor. +INDOOR_SENSOR_PLAUSIBLE_MIN: Final = 15.0 +INDOOR_SENSOR_PLAUSIBLE_MAX: Final = 30.0 + +# Outdoor air (BT1). Kiruna reaches -40 C; the band leaves room below it and well above any +# habitable summer. A mis-scaled BT1 reading -105 C demands a 96.8 C flow temperature and pushes +# the degree-minute warning threshold to -1450 - fifty short of the aux limit - so the immersion +# heater engages with no warning at all. +NIBE_OUTDOOR_PLAUSIBLE_MIN: Final = -50.0 +NIBE_OUTDOOR_PLAUSIBLE_MAX: Final = 50.0 + +# Heating water (BT25/BT63 supply, BT3 return). It cannot freeze and it cannot boil. +NIBE_WATER_PLAUSIBLE_MIN: Final = 0.0 +NIBE_WATER_PLAUSIBLE_MAX: Final = 100.0 NIBE_FRACTIONAL_ACCUMULATOR_THRESHOLD: Final = ( 1.0 # °C - Write to NIBE when accumulator crosses ±1.0 ) @@ -1291,14 +1661,67 @@ class OptimizationModeConfig: SPACE_HEATING_DEMAND_LOW_THRESHOLD: Final = 0.5 # kW - Display threshold SPACE_HEATING_DEMAND_DROP_HOURS: Final = 2.0 # Conservative estimate for demand to drop -# Savings Calculation Constants (Swedish electricity market) -# Swedish effect tariff - typical cost per kW of monthly peak -# Based on common Swedish grid operators (Ellevio ~55, Vattenfall/E.ON ~50 SEK/kW/month) -SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH: Final = 50.0 # Conservative average - -# Baseline peak estimation - assumes optimization reduces peak by ~15% -# If no baseline observed, estimate unoptimized peak from current optimized peak -BASELINE_PEAK_MULTIPLIER: Final = 1.176 # Inverse of 0.85 (15% reduction) +# THE EFFECT-TARIFF RATE THE SIMULATOR PRICES AGAINST - ILLUSTRATIVE, AND SOURCED. +# +# 81.25 is a real published figure (Ellevio, kr/kW/month) used so the simulation's SEK numbers are +# in a plausible range. It is an EXAMPLE, not a claim that this is what any given owner pays: effect +# charges are set per grid company, and there are thousands of DSOs. +# +# OPERATOR MODELS VARY, AND THAT VARIABILITY IS FINDING F-107 (owner-gated). Every DSO built its own +# model - different measurement windows, different hours, different prices, different rules about how +# many peaks per day count. On 13 March 2026 the government instructed Energimarknadsinspektionen to +# repeal the requirement to levy effect charges at all, precisely because the models had diverged; +# EIFS 2022:1 was repealed in June 2026 and Ei must propose a uniform model by 12 April 2027. Effect +# charges are NOT prohibited and several DSOs still levy them, so the feature is not dead - but no +# single operator's rules are baked in as fact here. +# https://www.regeringen.se/pressmeddelanden/2026/03/krav-pa-inforande-av-effektavgifter-stoppas/ +# https://ei.se/konsument/anvand-el-smartare/elnatsavtal-med-effektavgift +SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH: Final = 81.25 # illustrative example rate, kr/kW/month + +# THE OWNER'S TARIFF MODEL: A 15-MINUTE MEASUREMENT WINDOW. +# +# This is a CONFIGURATION, not a universal fact. The owner runs 15-minute intervals, and that is +# what this integration measures peaks over: the mean power across each quarter-hour. Operator +# models differ (F-107) - some bill the hour, some the quarter - so nothing here should be read as +# "the tariff bills the quarter" in general; it is "the owner's tariff bills the quarter". +# +# The quantity is the QUARTER-HOUR MEAN, not the instantaneous draw and not a sample count: HA's +# update cycle jitters, so a quarter's samples are unevenly spaced and their time-weighted mean is +# the quarter's mean power. +# The outdoor temperatures the DISPLAY COP curve is tabulated at, and the span it interpolates over. +# Nothing computes from that curve - the simulator takes COP from the datasheet rating points - it is +# a dashboard proxy: in a colder month the house asks for hotter water, which costs efficiency. The +# span runs from the warmest tabulated point (+7 C) to the coldest (-20 C), i.e. 27 K. +DISPLAY_COP_CURVE_TEMPS: Final = (7, 5, 0, -5, -10, -15, -20) +DISPLAY_COP_CURVE_COLD_C: Final = -20.0 +DISPLAY_COP_CURVE_SPAN_K: Final = 27.0 + +BILLING_PERIOD_MINUTES: Final = 15 +BILLING_PERIODS_PER_DAY: Final = 96 +# The longest silence between two meter readings that still leaves a billing period MEASURED. +# +# The period mean extrapolates each reading forward until the next one arrives - right at the +# five-minute cadence, absurd across a blackout (a 9 kW reading stretched over most of an unwatched +# quarter-hour bills a peak that happened in no observed quarter). +# +# A LABELLED JUDGEMENT, NOT A CITATION. No standard says how much of a quarter must be seen. What is +# defensible is the direction: missing a real peak costs some protection, inventing one costs a +# month of throttling to defend a fiction - and the utility bills from ITS meter, not ours. +# +# At the 5-minute control cadence a fully observed 15-minute period holds three samples (the +# boundary reading, :05 and :10), so its longest internal gap is one update interval. Ten minutes - +# two update intervals - refuses a period that has lost MORE THAN HALF its samples (a single reading +# left standing across two-thirds of the quarter or more) while still tolerating one dropped cycle. +# It must stay strictly below BILLING_PERIOD_MINUTES, or a single-sample quarter (a 15-minute rest +# to the boundary) could never be refused and the rule would not bite. +MAX_BILLING_OBSERVATION_GAP_MINUTES: Final = 10 + +# BASELINE_PEAK_MULTIPLIER (1.176) was deleted. It manufactured an unoptimised baseline from the +# CURRENT peak - `baseline = peak * 1.176` - so the reported effect-tariff saving reduced to +# `0.176 * peak * tariff`: a higher peak reported MORE "savings", and the sensor could never read +# zero however badly the optimiser was doing. A baseline is now either MEASURED (from the quarters +# recorded while the optimisation switch is off, when the offset is held at 0.0) or absent, and an +# absent one reports no effect saving at all. # Price-unit handling for savings math. GE-Spot preserves whatever display # unit the user configured; savings must convert to the MAIN currency unit @@ -1337,6 +1760,23 @@ class OptimizationModeConfig: # - 50 Hz (mid): ~3.5-4.5 kW # - 80 Hz (normal max): ~6.0-7.0 kW # - 100-120 Hz (emergency/max): Higher output, reduced efficiency +# Compressor stress levels, as reported by CompressorHealthMonitor.assess_risk(). +# +# HIGH means the compressor has been above 100 Hz for more than fifteen minutes: it is at maximum +# capacity and has nothing left to give. Asking it for more heat by raising the curve offset +# produces NONE - the offset raises the calculated supply setpoint S1, and the compressor cannot +# follow it. What it does produce is wear, and a DEEPER degree-minute deficit, because +# DM = integral(BT25 - S1) and S1 just went up while BT25 could not. +# +# The auxiliary heater exists for exactly this moment: NIBE places "start addition" where it does +# (menu 4.9.3) so the compressor need not grind at full frequency for hours. Declining its help by +# demanding more from a saturated compressor trades cheap kWh for expensive compressor life. +COMPRESSOR_RISK_HIGH: Final = "HIGH" # >100 Hz for >15 min - at maximum, nothing left to give +COMPRESSOR_RISK_ELEVATED: Final = "ELEVATED" # >80 Hz for >2 h +COMPRESSOR_RISK_NOTABLE: Final = "NOTABLE" +COMPRESSOR_RISK_WATCH: Final = "WATCH" +COMPRESSOR_RISK_OK: Final = "OK" + COMPRESSOR_HZ_MIN: Final = 20 # Minimum operating frequency COMPRESSOR_HZ_MAX: Final = 120 # Maximum operating frequency (NIBE F-series inverter) COMPRESSOR_HZ_RANGE: Final = 100 # 120-20 = operating range diff --git a/custom_components/effektguard/coordinator.py b/custom_components/effektguard/coordinator.py index c36b433f..f490d25e 100644 --- a/custom_components/effektguard/coordinator.py +++ b/custom_components/effektguard/coordinator.py @@ -2,31 +2,36 @@ from __future__ import annotations +import asyncio import logging from datetime import datetime, timedelta from typing import TYPE_CHECKING +from homeassistant.components.persistent_notification import async_create from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.storage import Store +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import ( + DHW_CONTROL_ISSUE_ID, + PRICE_SOURCE_ISSUE_ID, AIRFLOW_DEFAULT_ENHANCED, AIRFLOW_DEFAULT_STANDARD, - CLIMATE_CENTRAL_SWEDEN, - CLIMATE_MID_NORTHERN_SWEDEN, - CLIMATE_NORTHERN_LAPLAND, - CLIMATE_NORTHERN_SWEDEN, - CLIMATE_SOUTHERN_SWEDEN, CONF_AIRFLOW_ENHANCED_RATE, CONF_AIRFLOW_STANDARD_RATE, CONF_DHW_MIN_AMOUNT, CONF_ENABLE_AIRFLOW_OPTIMIZATION, + CONF_ENABLE_OPTIMIZATION, CONF_HEAT_PUMP_MODEL, CONF_NIBE_TEMP_LUX_ENTITY, DEFAULT_DHW_EVENING_HOUR, @@ -41,20 +46,29 @@ DHW_WEATHER_COOLDOWN_MINUTES, DM_THRESHOLD_START, DOMAIN, + MAX_BILLING_OBSERVATION_GAP_MINUTES, + PEAK_CONTROL_POWER_SOURCES, + LEARNING_OBSERVATION_INTERVAL_MINUTES, MIN_DHW_TARGET_TEMP, NIBE_VENTILATION_MIN_ENHANCED_DURATION, - QUARTER_INTERVAL_MINUTES, + NIBE_VENTILATION_MIN_REST_DURATION, + POWER_SOURCE_ESTIMATE, + POWER_SOURCE_EXTERNAL_METER, + POWER_SOURCE_NIBE_CURRENTS, + POWER_SOURCE_NONE, STORAGE_KEY_LEARNING, - STORAGE_VERSION, + LEARNING_STORAGE_VERSION, + TOLERANCE_RANGE_MULTIPLIER, STARTUP_GRACE_MIN_INTERVAL, + STARTUP_MAX_GRACE_ATTEMPTS, STARTUP_GRACE_UPDATES, UPDATE_INTERVAL_MINUTES, - WATTS_PER_KILOWATT, ) from .models.nibe import NibeF750Profile from .models.registry import HeatPumpModelRegistry from .optimization.adaptive_learning import AdaptiveThermalModel from .optimization.airflow_optimizer import AirflowOptimizer +from .optimization.billing_period import BillingPeriodAccumulator from .optimization.decision_engine import ( OptimizationDecision, get_safe_default_decision, @@ -65,11 +79,11 @@ IntelligentDHWScheduler, ) from .optimization.prediction_layer import ThermalStatePredictor -from .optimization.price_layer import get_fallback_prices from .optimization.savings_calculator import SavingsCalculator from .optimization.weather_learning import WeatherPatternLearner from .utils.compressor_monitor import CompressorHealthMonitor -from .utils.time_utils import get_current_quarter +from .utils.power import power_kw_from_state +from .utils.time_utils import get_current_billing_period from .utils.volatile_helpers import OffsetVolatilityTracker if TYPE_CHECKING: @@ -105,6 +119,10 @@ def __init__( super().__init__( hass, _LOGGER, + # Hand HA the entry. Without it HA falls back to a deprecated ContextVar + # (breaks_in_ha_version 2026.8), which is only set inside async_setup_entry - so + # `self.config_entry` is None for a coordinator built anywhere else (audit F-073). + config_entry=entry, name=DOMAIN, # Disable base class automatic scheduling - we use clock-aligned scheduling instead # This prevents drift from startup time and ensures updates at :00:10, :05:10, etc. @@ -143,10 +161,12 @@ def __init__( # Pass climate zone info for seasonal-aware defaults climate_zone_info = decision_engine.climate_detector.zone_info self.weather_learner = WeatherPatternLearner(climate_zone_info=climate_zone_info) - self.climate_region = self._detect_climate_region(hass) # Compressor health monitoring (Oct 19, 2025) self.compressor_monitor = CompressorHealthMonitor(max_history_hours=24) + # The monitor's verdict, fed to the decision engine so it will not demand more heat from a + # compressor already at maximum frequency. + self.compressor_risk: str | None = None self.compressor_stats = None # Latest CompressorStats from monitor # DHW temporary lux entity (stored once, reused everywhere) @@ -233,6 +253,11 @@ def __init__( # Track airflow enhancement state for minimum duration enforcement self._airflow_enhance_start: datetime | None = None + # How long the airflow optimizer asked this enhancement to run (15-60 min, by deficit). + # This bounds the fan's minimum run time so a marginal decision cannot cycle it every tick. + self._airflow_enhance_minutes: int = NIBE_VENTILATION_MIN_ENHANCED_DURATION + # When the fan last returned to normal, so it cannot be re-enhanced on the very next tick. + self._airflow_normal_since: datetime | None = None if demand_periods: try: @@ -259,25 +284,25 @@ def __init__( _LOGGER.debug("Could not format DHW periods: %s", err) # Learning storage - self.learning_store = Store(hass, STORAGE_VERSION, STORAGE_KEY_LEARNING) + self.learning_store = Store(hass, LEARNING_STORAGE_VERSION, STORAGE_KEY_LEARNING) # State tracking self.current_offset: float = 0.0 self.last_applied_offset: float | None = None # Last offset written to NIBE self.last_offset_timestamp: datetime | None = None # When offset was last applied + # Daily high-water mark (display + diagnostics). Monotonically non-decreasing + # until the midnight reset. NEVER pass this to the decision engine as + # "current power" - see current_power_kw. self.peak_today: float = 0.0 self.peak_this_month: float = 0.0 - # Swedish quarter-hour tariffs bill the 15-minute MEAN power, not an - # instantaneous sample: accumulate real measurements within the - # quarter and record the mean when the quarter completes. The quarter - # is identified by its start instant (fold-preserving aware local - # time), so autumn's repeated wall-clock hour yields two distinct - # quarters. The first quarter after startup is observed but never - # recorded - it began before we could watch it. - self._quarter_power_samples: list[tuple[datetime, float]] = [] - self._quarter_power_start: datetime | None = None - self._quarter_power_number: int = 0 - self._quarter_power_partial: bool = False + # Instantaneous whole-house power (kW), refreshed every cycle by + # _update_peak_tracking. None until the first successful measurement, in which + # case peak protection stays disabled rather than acting on a guess. + self.current_power_kw: float | None = None + # What the effect tariff bills: the time-weighted mean power over a billing HOUR (a + # quarter-hour mean overstates the billed peak up to fourfold). The arithmetic lives in + # billing_period.py, once, so the simulator runs the same object rather than a second copy. + self._billing_period = BillingPeriodAccumulator() self.last_decision_time = None self._learned_data_changed = False # Track if learning data needs saving self._last_learning_save: datetime | None = None # Track last learned data save time @@ -297,7 +322,7 @@ def __init__( # Peak tracking metadata (for sensor attributes) self.peak_today_time: datetime | None = None # When today's peak occurred self.peak_today_source: str = "unknown" # external_meter, nibe_currents, estimate - self.peak_today_quarter: int | None = None # 15-min quarter (0-95) for effect tariff + self.peak_today_period: int | None = None # the billing HOUR (0-23) for the effect tariff self.yesterday_peak: float = 0.0 # Yesterday's peak for comparison # DHW tracking (unified: is_hot_water OR temp_lux active) @@ -306,6 +331,13 @@ def __init__( self.dhw_heating_start = None # When current/last DHW cycle started self.dhw_heating_end = None # When last DHW cycle ended self.dhw_was_active = False # Track DHW state (is_hot_water OR temp_lux) + # True while a temporary-lux boost that EFFEKTGUARD started is still running. The owner may + # also start one from the heat pump's own panel or their own automation, and that one is + # none of our business - so shutdown only cancels a boost we are responsible for. + self._lux_boost_is_ours = False + # While set, a boost_dhw SERVICE call owns the lux switch: ordinary price optimization + # may not cancel it before this instant. Safety still may - see _apply_dhw_control. + self._service_boost_until: datetime | None = None # Spot price savings tracking (per-cycle accumulation) self._daily_spot_savings: float = 0.0 # Accumulates during day, recorded at midnight @@ -313,6 +345,10 @@ def __init__( # Startup tracking - gracefully handle missing entities during HA startup # MyUplink integration can take 45-50 seconds to initialize entities self._first_successful_update = False + # Consecutive cycles spent waiting for the heat pump to appear. Bounded: see + # STARTUP_MAX_GRACE_ATTEMPTS. Distinct from _startup_update_count below, which counts + # observation cycles AFTER the pump is already answering. + self._startup_grace_attempts = 0 self._startup_update_count = 0 # Count updates before ending grace period self._startup_grace_updates = ( STARTUP_GRACE_UPDATES # Require N updates before active control @@ -322,67 +358,22 @@ def __init__( # Startup grace: timeout after which observation cycles begin self._startup_grace_timeout = dt_util.now() + timedelta(seconds=STARTUP_GRACE_MIN_INTERVAL) + # Whether the "no price source" repair issue is currently raised. + self._price_issue_active = False + self._dhw_issue_active = False + + # One writer at a time. See _drive_the_pump: the aligned control loop and a service that + # commands the pump are both long coroutines, and asyncio interleaves them freely. + self._control_lock = asyncio.Lock() + # Power sensor availability tracking (event-driven) # Event listener detects when external power sensor becomes available during startup # Listener unsubscribes after detection to avoid overhead self._power_sensor_available = False + # Learning observes hourly, not per control cycle - see _record_learning_observations + self._last_learning_observation: datetime | None = None self._power_sensor_listener = None - def _detect_climate_region(self, hass: HomeAssistant) -> str: - """Detect Swedish climate region based on Home Assistant location. - - Uses latitude to determine climate region for adaptive learning thresholds. - - Swedish climate regions (based on SMHI climate data): - - Southern Sweden (55-58°N): Malmö, Gothenburg - Jan avg 0.1°C - - Central Sweden (58-62°N): Stockholm, Gävle - Jan avg -3.7°C - - Mid-Northern Sweden (62-65°N): Östersund, Umeå - Jan avg -7.9°C - - Northern Sweden (65-67°N): Luleå, Boden - Jan avg -11.0°C - - Northern Lapland (67-70°N): Kiruna, Gällivare - Jan avg -12.5°C - - Args: - hass: HomeAssistant instance - - Returns: - Climate region constant (southern_sweden, central_sweden, etc.) - """ - try: - # Get Home Assistant latitude - latitude = hass.config.latitude - - if latitude is None: - _LOGGER.warning("Latitude not configured, defaulting to central Sweden") - return CLIMATE_CENTRAL_SWEDEN - - # Detect region based on latitude bands - if latitude < 58.0: - region = CLIMATE_SOUTHERN_SWEDEN - region_name = "Southern Sweden (Malmö/Gothenburg)" - elif latitude < 62.0: - region = CLIMATE_CENTRAL_SWEDEN - region_name = "Central Sweden (Stockholm/Gävle)" - elif latitude < 65.0: - region = CLIMATE_MID_NORTHERN_SWEDEN - region_name = "Mid-Northern Sweden (Östersund/Umeå)" - elif latitude < 67.0: - region = CLIMATE_NORTHERN_SWEDEN - region_name = "Northern Sweden (Luleå/Boden)" - else: - region = CLIMATE_NORTHERN_LAPLAND - region_name = "Northern Lapland (Kiruna)" - - _LOGGER.info( - "Detected climate region: %s (latitude: %.2f°N)", - region_name, - latitude, - ) - - return region - - except (AttributeError, KeyError, ValueError) as err: - _LOGGER.warning("Failed to detect climate region: %s, defaulting to central", err) - return CLIMATE_CENTRAL_SWEDEN - def _calculate_next_aligned_time(self) -> datetime: """Calculate next 5-minute boundary + 10 seconds. @@ -415,6 +406,13 @@ def _schedule_aligned_refresh(self) -> None: This gives sensors time to update before we read them, and aligns with 15-minute spot price intervals. """ + # Never re-arm a coordinator that has been shut down. _do_aligned_refresh calls this from + # a `finally`, so an update in flight when the entry unloads would otherwise arm a fresh + # timer on a dead object - two coordinators driving one pump with conflicting offsets. + if self._shutdown_requested: + _LOGGER.debug("Coordinator shut down - not re-arming the aligned refresh") + return + # Cancel any existing schedule if self._unsub_aligned_refresh: self._unsub_aligned_refresh() @@ -438,19 +436,33 @@ def _on_refresh(_now: datetime) -> None: _LOGGER.debug("Next update at %s", next_time.strftime("%H:%M:%S")) async def _do_aligned_refresh(self) -> None: - """Perform refresh and schedule next aligned update. + """Perform one refresh and ALWAYS re-arm the next aligned update. - Note: _async_update_data() now handles scheduling at the end of every update, - so we don't need to schedule here. This method is called by the timer callback. + Sole owner of the retry timer: the base scheduler is disabled (update_interval=None), so + nothing else re-arms it. That is why the broad `except Exception` is correct - anything the + update path raises would otherwise kill the task silently and permanently + (`last_update_success` stays True while the pump holds its last offset until HA restarts). + The `finally` re-arms so the loop survives any single bad cycle. """ try: - self.data = await self._async_update_data() + # The one place the pump is driven on a schedule. `_drive_the_pump` holds the control + # lock, so a service commanding the pump at the same moment waits its turn. + self.data = await self._drive_the_pump() self.last_update_success = True self.async_set_updated_data(self.data) - except (UpdateFailed, OSError, ValueError, TypeError, KeyError, AttributeError) as err: + except UpdateFailed as err: + # Expected degradation (e.g. required NIBE sensors unreadable). self.last_update_success = False _LOGGER.error("Update failed: %s", err) - # Still need to schedule next update even on failure + except Exception: # noqa: BLE001 - supervisory loop; see docstring + self.last_update_success = False + _LOGGER.exception( + "Unexpected error during EffektGuard update. The update loop will continue; " + "entities are marked unavailable for this cycle and no offset was written." + ) + finally: + # ALWAYS re-arm. Without this the coordinator dies permanently on any + # unhandled exception, because update_interval is None. self._schedule_aligned_refresh() async def async_initialize_learning(self) -> None: @@ -636,6 +648,66 @@ def power_sensor_state_changed(event): power_state.state if power_state else "None", ) + async def _set_temporary_lux(self, on: bool) -> bool: + """Command the hot-water boost. The ONE place that records WHO STARTED IT. + + That record is what lets `_cancel_our_dhw_boost` tell ours from the household's; reaching + the switch directly disowns a boost we started, leaving it to run to NIBE's lux timeout. + Starting from a shut-down coordinator is refused; STOPPING is not - it is the cleanup, and + runs during shutdown. + + tests/unit/test_a_hot_water_boost_we_started_is_a_hot_water_boost_we_stop.py + """ + if not self.temp_lux_entity: + return False + + if on and self._shutdown_requested: + _LOGGER.debug( + "Coordinator is shut down - refusing to start a hot-water boost. The entry is " + "unloaded; nothing would be left to stop it." + ) + return False + + try: + # The generic service, not switch.*: a nibe_heatpump/Modbus user has no lux + # switch - their bridge is an input_boolean helper - and homeassistant.turn_on + # drives both domains through this same one door (issue #18). + await self.hass.services.async_call( + "homeassistant", + "turn_on" if on else "turn_off", + {"entity_id": self.temp_lux_entity}, + blocking=True, + ) + except (HomeAssistantError, AttributeError, OSError, ValueError) as err: + _LOGGER.error("Failed to set temporary lux to %s: %s", on, err) + return False + + # Ours if we switched it on; not ours once it is off, whoever asked for that. + self._lux_boost_is_ours = on + return True + + async def _cancel_our_dhw_boost(self) -> None: + """Turn off a temporary-lux boost that EffektGuard started, if one is still running. + + Called on unload. A boost the OWNER started is left alone. + """ + # Whatever happens below, the entry is going away - no service window survives it. + self._service_boost_until = None + if not (self._lux_boost_is_ours and self.temp_lux_entity): + return + + state = self.hass.states.get(self.temp_lux_entity) + if state is None or state.state != STATE_ON: + self._lux_boost_is_ours = False + return + + _LOGGER.info( + "Cancelling the EffektGuard hot-water boost on %s before unload - it would otherwise " + "run to NIBE's own timeout with nothing left to stop it", + self.temp_lux_entity, + ) + await self._set_temporary_lux(False) + async def async_shutdown(self) -> None: """Clean shutdown of coordinator. @@ -644,9 +716,24 @@ async def async_shutdown(self) -> None: - Effect tracking state (monthly peaks) Called during integration unload or reload. + + IDEMPOTENT BY DESIGN. This runs TWICE per unload: the base DataUpdateCoordinator + registers `config_entry.async_on_unload(self.async_shutdown)` in its __init__, and + async_unload_entry also calls it explicitly. Without the guard below, every unload + saved the learning data and the effect peaks twice. """ + if self._shutdown_requested: + _LOGGER.debug("Coordinator already shut down - ignoring repeat call") + return + _LOGGER.debug("Shutting down EffektGuard coordinator") + # Base shutdown FIRST: it sets `_shutdown_requested` (which stops an in-flight + # _do_aligned_refresh from re-arming its timer, making the reload's coordinator the only + # writer) and shuts down the debouncer (a trailing debounced refresh could otherwise fire + # after unload and write an offset). + await super().async_shutdown() + try: # Unsubscribe aligned refresh timer (if active) unsub = getattr(self, "_unsub_aligned_refresh", None) @@ -660,6 +747,12 @@ async def async_shutdown(self) -> None: self._power_sensor_listener = None _LOGGER.debug("Power sensor availability listener unsubscribed") + # Cancel OUR OWN DHW boost on unload. Nothing else does: a reload or restart mid-boost + # otherwise leaves the temporary-lux switch ON until NIBE's timeout, running a + # high-temperature cycle nobody asked for and nobody is left to stop. Only ours - a + # boost the owner started from the pump panel is none of our business. + await self._cancel_our_dhw_boost() + # Save learning state if self.adaptive_learning or self.thermal_predictor or self.weather_learner: await self._save_learned_data( @@ -679,8 +772,116 @@ async def async_shutdown(self) -> None: # Don't raise - allow shutdown to complete async def _async_update_data(self) -> dict[str, object]: + """Home Assistant's READ hook. Reads the world and decides. It NEVER writes. + + Public and debounced, so anything may refresh it (a reload, an options change, a + bookkeeping service). Writes belong to the control loop `_do_aligned_refresh`; services + that mean to command the pump call `async_refresh_and_apply`. + """ + return await self._read_and_decide(apply=False) + + async def async_refresh_and_apply(self, *, explicit_command: bool = False) -> None: + """Read, decide, and DRIVE THE PUMP. For services that genuinely command it. + + force_offset and boost_heating mean what they say and must land immediately, not at the + next aligned tick. Bookkeeping services must NOT use this - they call + `async_request_refresh()`, which reads and decides but writes nothing. + """ + self.data = await self._drive_the_pump(explicit_command=explicit_command) + self.async_set_updated_data(self.data) + + async def _drive_the_pump(self, *, explicit_command: bool = False) -> dict[str, object]: + """The write path. Its sole owner, and the only place `apply=True` is passed. + + The aligned control loop and an explicit-command service both reach the pump, and both + await freely; without _control_lock a forced offset and an in-flight aligned decision + race, and the older decision can overwrite the newer write (and corrupt _apply_offset's + read-then-write rate limiting). Reads are NOT serialised - they touch no hardware. + + tests/unit/coordinator/test_one_writer_at_a_time.py + """ + async with self._control_lock: + return await self._read_and_decide( + apply=True, + explicit_command=explicit_command, + ) + + def _report_no_price_source(self, reason: str) -> None: + """Tell the user, in the UI, that price optimisation is not running. + + A _LOGGER.warning is not telling anyone. The user has `enable_price_optimization` switched + on and believes the integration is trading on price; without a price source it simply is + not, and nothing on screen says so (audit F-123). + """ + if self._price_issue_active: + return + + _LOGGER.warning( + "No electricity price data (%s) - price optimization is NOT running", reason + ) + async_create_issue( + self.hass, + DOMAIN, + PRICE_SOURCE_ISSUE_ID, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=PRICE_SOURCE_ISSUE_ID, + ) + self._price_issue_active = True + + def _raise_dhw_control_issue(self) -> None: + """Tell the user, in the UI, that hot-water optimisation is not running. + + They have `enable_hot_water_optimization` switched on, the DHW sensors are populated, and + one of them is showing the time of a boost that will never happen. + """ + if self._dhw_issue_active: + return + + _LOGGER.warning( + "No temporary-lux switch found (register 50004) - hot-water optimization is NOT " + "running. Home Assistant's NIBE integration exposes this switch for F-series pumps " + "only; an S-series pump has no equivalent entity." + ) + async_create_issue( + self.hass, + DOMAIN, + DHW_CONTROL_ISSUE_ID, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=DHW_CONTROL_ISSUE_ID, + ) + self._dhw_issue_active = True + + def _clear_dhw_control_issue(self) -> None: + """A lux switch has appeared. Deliberately NOT guarded on the flag - see below.""" + async_delete_issue(self.hass, DOMAIN, DHW_CONTROL_ISSUE_ID) + self._dhw_issue_active = False + + def _clear_price_source_issue(self) -> None: + """Prices are flowing again. + + Deliberately NOT guarded on `_price_issue_active`: that flag resets to False on restart + while the repair issue, which HA persists in its own registry, stays raised - so guarding + the delete would leave the issue up forever after a restart. `async_delete_issue` is a + no-op when nothing is raised. + """ + async_delete_issue(self.hass, DOMAIN, PRICE_SOURCE_ISSUE_ID) + self._price_issue_active = False + + async def _read_and_decide( + self, + apply: bool, + explicit_command: bool = False, + ) -> dict[str, object]: """Fetch data and calculate optimal offset. + Args: + apply: Whether to drive the heat pump with the resulting decision. Only the control + loop and the services that explicitly command the pump may pass True. + explicit_command: A user command that bypasses startup observation and ordinary write + cooldown. The decision engine's absolute safety floor still applies. + This method: 1. Gathers data from all sources (with graceful degradation) 2. Runs optimization algorithm @@ -701,6 +902,7 @@ async def _async_update_data(self) -> dict[str, object]: # Gather core data (NIBE - required, but allow startup grace period) try: nibe_data = await self.nibe.get_current_state() + self.current_offset = float(nibe_data.current_offset) _LOGGER.debug( "NIBE data retrieved: indoor %.1f°C, outdoor %.1f°C, flow %.1f°C, DM %.0f", nibe_data.indoor_temp, @@ -712,6 +914,7 @@ async def _async_update_data(self) -> dict[str, object]: # Mark first successful update if not self._first_successful_update: self._first_successful_update = True + self._startup_grace_attempts = 0 _LOGGER.info("EffektGuard fully initialized - NIBE entities available") # Update compressor health monitoring (Oct 19, 2025) @@ -728,11 +931,14 @@ async def _async_update_data(self) -> dict[str, object]: self.compressor_stats = self.compressor_monitor.update( nibe_data.compressor_hz, nibe_data.timestamp, heating_mode ) - # Log compressor diagnostics at debug level if self.compressor_stats: + # This is a CONTROL INPUT, not a log line. A compressor that has been at + # maximum frequency for a quarter of an hour has nothing left to give, and + # asking it for more heat buys only wear and a deeper DM deficit. risk_level, risk_reason = self.compressor_monitor.assess_risk( self.compressor_stats ) + self.compressor_risk = risk_level _LOGGER.debug( "Compressor: %d Hz (1h avg: %.0f, 6h avg: %.0f, mode: %s) - %s: %s", self.compressor_stats.current_hz, @@ -749,10 +955,33 @@ async def _async_update_data(self) -> dict[str, object]: # During startup, MyUplink entities may not be ready yet (takes ~45-50 seconds) # Gracefully handle this by returning minimal data until entities are available if not self._first_successful_update: + self._startup_grace_attempts += 1 + + if self._startup_grace_attempts > STARTUP_MAX_GRACE_ATTEMPTS: + # The grace period is over. A heat pump that has not appeared by now is not + # slow, it is missing - a wrong entity, or none configured at all - and saying + # "still starting up" forever leaves the entry green while nothing is read and + # nothing is controlled. + _LOGGER.error( + "NIBE entities never became available after %d attempts (~%d minutes). " + "Check that the configured entities exist and their integration is " + "loaded. Last error: %s", + self._startup_grace_attempts - 1, + (self._startup_grace_attempts - 1) * UPDATE_INTERVAL_MINUTES, + err, + ) + raise UpdateFailed( + f"NIBE entities unavailable after " + f"{self._startup_grace_attempts - 1} attempts: {err}" + ) from err + _LOGGER.info( "Waiting for NIBE entities to become available: %s " - "(this is normal during HA startup, will retry in %d minutes)", + "(this is normal during HA startup, attempt %d of %d, " + "will retry in %d minutes)", err, + self._startup_grace_attempts, + STARTUP_MAX_GRACE_ATTEMPTS, UPDATE_INTERVAL_MINUTES, ) # Important: even though we return early, we must keep clock-aligned scheduling @@ -800,12 +1029,18 @@ async def _async_update_data(self) -> dict[str, object]: current_price, unit, ) + self._clear_price_source_issue() else: - _LOGGER.debug("Spot price data empty, using fallback prices") - price_data = get_fallback_prices() + self._report_no_price_source("the price entity returned no quarters") + price_data = None except (AttributeError, KeyError, ValueError, TypeError) as err: - _LOGGER.warning("Price data unavailable, using fallback: %s", err) - price_data = get_fallback_prices() + # Do NOT fabricate. A flat fallback (the old one returned 96 quarters priced 1.0) + # classifies NORMAL and casts a real price vote, dragging the aggregate offset down on + # a number nobody measured - +1.00 °C became +0.27 °C (F-123, same class as + # F-013/F-014). None is honest: the price layer abstains and the thermal, comfort and + # safety layers decide on their own. + self._report_no_price_source(str(err)) + price_data = None # Weather forecast try: @@ -818,7 +1053,10 @@ async def _async_update_data(self) -> dict[str, object]: ) else: _LOGGER.debug("Weather forecast not available (optional feature disabled)") - except (AttributeError, KeyError, ValueError, TypeError) as err: + except (HomeAssistantError, AttributeError, KeyError, ValueError, TypeError) as err: + # Weather is OPTIONAL - never let it take the whole update down. + # HomeAssistantError was missing here: weather.get_forecasts raises it for any + # entity without an hourly forecast, and it is not a subclass of the others. _LOGGER.info("Weather forecast unavailable: %s", err) weather_data = None @@ -826,7 +1064,8 @@ async def _async_update_data(self) -> dict[str, object]: is_grace_period = False # Check if optimization is enabled (master switch) - if not self.entry.data.get("enable_optimization", True): + optimization_enabled = self.entry.data.get("enable_optimization", True) + if not optimization_enabled: _LOGGER.info("Optimization disabled by user - maintaining neutral offset") decision = OptimizationDecision( offset=0.0, @@ -835,19 +1074,27 @@ async def _async_update_data(self) -> dict[str, object]: ) else: try: - # Validate peak tracking data - if self.peak_today is None or self.peak_today < 0: - _LOGGER.error( - "Peak tracking error: peak_today is %s " - "(should have actual power measurement). " - "This indicates power sensor is not " - "configured or unavailable. Peak protection " - "will be disabled until sensors are available.", - self.peak_today, + # The effect layer needs INSTANTANEOUS power to judge how close the current + # quarter is to the monthly peak. Passing peak_today here (a daily maximum + # that only ratchets upward until midnight) made a single morning spike pin + # the effect layer to CRITICAL - weight 1.0, offset -3.0 - for the rest of + # the day, even with the compressor idle. + if self.current_power_kw is None or self.current_power_kw < 0: + _LOGGER.warning( + "No valid power measurement (%s) - disabling peak protection this " + "cycle rather than acting on a guess. Configure a power sensor or " + "NIBE phase currents for effect-tariff protection.", + self.current_power_kw, ) current_power_for_decision = 0.0 # Disable peak protection else: - current_power_for_decision = self.peak_today + # LIKE FOR LIKE: the monthly record is a PERIOD MEAN, so the layer is + # compared against the 15-minute period this cycle projects to, not the + # instant. A five-minute oven spike early in the period projects small; + # the same spike at :12 has already committed most of the period. + current_power_for_decision = self._billing_period.projected_period_mean( + dt_util.now(), self.current_power_kw + ) # Check if DHW is active (EITHER is_hot_water sensor OR temp_lux switch) # When NIBE heats DHW, flow temp reads charging temp (45-60°C), not space heating @@ -889,12 +1136,15 @@ async def _async_update_data(self) -> dict[str, object]: current_power_for_decision, # Current whole-house power consumption dhw_is_active, # DHW heating active - skip weather comp self.dhw_heating_end, # When DHW last stopped - for cooldown + self.compressor_risk, # Do not ask a saturated compressor for more heat ) # Startup grace period: lockout + observation cycles now = dt_util.now() - if now < self._startup_grace_timeout: + if explicit_command and decision.is_manual_override: + _LOGGER.info("Explicit user command bypasses startup observation") + elif now < self._startup_grace_timeout: # Phase 1: Time-based lockout secs_left = int((self._startup_grace_timeout - now).total_seconds()) is_grace_period = True @@ -965,6 +1215,22 @@ async def _async_update_data(self) -> dict[str, object]: decision.offset, ) self._offset_volatility_tracker.record_change(decision.offset, decision.reasoning) + elif decision.is_emergency: + # Absolute safety path: indoor below MIN_TEMP_LIMIT, or DM past the aux limit. + # The volatile blocker damps price-driven flip-flopping; it must never defer a safety + # recovery. Blocking here would hold the previous (often negative) offset for up to + # 45 minutes while DM keeps falling and the immersion heater runs. + _LOGGER.warning( + "Emergency decision: bypassing volatile check (offset %.1f°C → %.1f°C) - %s", + ( + self._offset_volatility_tracker.last_offset + if self._offset_volatility_tracker.last_offset is not None + else 0.0 + ), + decision.offset, + decision.reasoning, + ) + self._offset_volatility_tracker.record_change(decision.offset, decision.reasoning) elif decision.anti_windup_active: # Anti-windup is a safety mechanism — always apply immediately # Record the change so volatile tracker knows the new baseline @@ -995,8 +1261,6 @@ async def _async_update_data(self) -> dict[str, object]: # Record the new offset for volatility tracking self._offset_volatility_tracker.record_change(decision.offset, decision.reasoning) - # Update current state - self.current_offset = decision.offset self.last_decision_time = dt_util.utcnow() # Apply offset to the NIBE heating curve offset number entity @@ -1006,66 +1270,48 @@ async def _async_update_data(self) -> dict[str, object]: # Accumulation logic: We track fractional offsets but only write to NIBE when # the integer part changes. This prevents oscillation when calculated offsets # hover around boundaries (e.g., 0.48 ↔ 0.52 both stay at 0°C in NIBE). - if is_grace_period: + if not apply: + # A read, not a control cycle. Decide, publish, write nothing. + _LOGGER.debug("Read-only refresh: decided %.2f°C, not applying", decision.offset) + elif is_grace_period: _LOGGER.info("Skipping offset application during startup grace period") - elif self.last_applied_offset is not None and int(decision.offset) == int( - self.last_applied_offset - ): - _LOGGER.debug( - "Offset %.2f°C → int(%d°C) matches last " - "applied int(%d°C), skipping adapter call", - decision.offset, - int(decision.offset), - int(self.last_applied_offset), - ) else: try: - was_applied = await self.nibe.set_curve_offset(decision.offset) - if was_applied: - _LOGGER.info("Applied offset %.2f°C to NIBE", decision.offset) - # Track what NIBE actually has (integer) - synced from entity on restart - self.last_applied_offset = float(int(decision.offset)) + # Through the one guarded door: a coordinator whose entry has unloaded mid-refresh + # must not get the last word on the pump. See _write_curve_offset. + applied_offset = await self._write_curve_offset( + decision.offset, + force_write=explicit_command, + ) + if applied_offset is not None: + _LOGGER.info( + "Applied offset %.2f°C as %d°C on NIBE", + decision.offset, + applied_offset, + ) + self.last_applied_offset = float(applied_offset) + self.current_offset = float(applied_offset) + nibe_data.current_offset = float(applied_offset) self.last_offset_timestamp = dt_util.utcnow() self._learned_data_changed = True # Trigger save on shutdown + if decision.is_manual_override: + self.engine.consume_manual_override() else: _LOGGER.debug( "Offset %.2f°C unchanged (NIBE offset not changed)", decision.offset, ) + if explicit_command: + raise HomeAssistantError( + "The explicit heating offset did not reach the NIBE register" + ) except (HomeAssistantError, AttributeError, OSError, ValueError) as err: _LOGGER.error("Failed to apply offset to NIBE: %s", err) - # Continue anyway - next cycle will retry + if explicit_command: + raise + # Automatic control retries on the next aligned cycle. - # Calculate actual spot savings for this cycle using real NIBE power - now_time = dt_util.now() - current_quarter = price_data.get_period_index(now_time) if price_data else None - if ( - price_data - and hasattr(price_data, "today") - and price_data.today - and nibe_data - and nibe_data.power_kw is not None - ): - prices_today = [q.price for q in price_data.today] - if prices_today and current_quarter is not None: - average_price = sum(prices_today) / len(prices_today) - current_price = price_data.today[current_quarter].price - - # Calculate savings using ACTUAL power consumption - self.savings_calculator.price_unit = getattr(self.gespot, "price_unit", None) - cycle_savings = self.savings_calculator.calculate_spot_savings_per_cycle( - actual_power_kw=nibe_data.power_kw, - current_price=current_price, - average_price_today=average_price, - cycle_minutes=UPDATE_INTERVAL_MINUTES, - ) - if self.savings_calculator.is_sek_price_unit: - self._daily_spot_savings += cycle_savings - else: - _LOGGER.debug( - "Skipping non-SEK spot-savings aggregation for price unit %s", - self.savings_calculator.price_unit, - ) + self._accumulate_spot_savings(nibe_data, price_data) # Check for day change and save yesterday's peak now = dt_util.now() @@ -1089,18 +1335,34 @@ async def _async_update_data(self) -> dict[str, object]: ) self._daily_spot_savings = 0.0 # Reset for new day + # A new day may also be a new MONTH. The effect tariff bills a monthly peak, so + # last month's peaks must not carry over into this one - an instance that stays up + # across a month boundary would otherwise bill against a stale threshold. + month_changed = (now.year, now.month) != ( + self._last_update_date.year, + self._last_update_date.month, + ) + if month_changed: + self.effect.prune_peaks_for_current_month() + self.peak_this_month = self.effect.get_monthly_peak_summary()["highest"] + _LOGGER.info( + "Month change detected: pruned previous month's peaks, " + "monthly peak reset to %.2f kW", + self.peak_this_month, + ) + # Reset daily peak for new day self.peak_today = 0.0 self.peak_today_time = None self.peak_today_source = "unknown" - self.peak_today_quarter = None + self.peak_today_period = None self._last_update_date = now.date() # Update peak tracking await self._update_peak_tracking(nibe_data) # Record observations for learning (Phase 6) - await self._record_learning_observations(nibe_data, weather_data, decision.offset) + await self._record_learning_observations(nibe_data, weather_data, self.current_offset) # Save state periodically await self.effect.async_save() @@ -1250,11 +1512,14 @@ async def _async_update_data(self) -> dict[str, object]: # Apply DHW control based on optimizer decision (if hot water optimization enabled) if ( - self.entry.data.get("enable_hot_water_optimization", False) + optimization_enabled + and self.entry.data.get("enable_hot_water_optimization", False) and dhw_result is not None and dhw_result.decision is not None ): - if is_grace_period: + if not apply: + _LOGGER.debug("Read-only refresh: not applying DHW control") + elif is_grace_period: _LOGGER.info("Skipping DHW control during startup grace period") else: await self._apply_dhw_control( @@ -1317,7 +1582,11 @@ async def _async_update_data(self) -> dict[str, object]: # Apply control only if airflow optimization is enabled (like DHW) airflow_enabled = self.entry.data.get(CONF_ENABLE_AIRFLOW_OPTIMIZATION, False) if airflow_enabled: - if is_grace_period: + if not optimization_enabled: + _LOGGER.debug("Optimization disabled - not applying airflow control") + elif not apply: + _LOGGER.debug("Read-only refresh: not applying airflow control") + elif is_grace_period: _LOGGER.info("Skipping airflow control during startup grace period") else: await self._apply_airflow_decision(airflow_decision) @@ -1341,7 +1610,7 @@ async def _async_update_data(self) -> dict[str, object]: "thermal_trend": temperature_trend_data, # Temperature trend from predictor "outdoor_trend": outdoor_trend_data, # Outdoor temperature trend "decision": decision, - "offset": decision.offset, + "offset": self.current_offset, "peak_today": self.peak_today, "peak_this_month": self.peak_this_month, "current_quarter": current_quarter, @@ -1415,9 +1684,10 @@ async def _calculate_dhw_recommendation( if self.engine.climate_detector: climate_zone_name = self.engine.climate_detector.zone_info.name else: - # Show HA notification for missing climate detector - # HA dynamic component access (not in type stubs) - self.hass.components.persistent_notification.async_create( # type: ignore[attr-defined] + # `hass.components` was removed from Home Assistant; the type: ignore that used to sit + # here claimed a stubs gap and was hiding a real AttributeError (audit F-068). + async_create( + self.hass, "EffektGuard could not detect your climate zone. " "Using balanced thermal debt thresholds. " "Configure latitude in integration settings for optimal climate-aware operation.", @@ -1578,53 +1848,88 @@ async def _apply_airflow_decision(self, decision) -> None: _LOGGER.debug("Ventilation control unavailable - no ventilation switch found") return - # Determine target state based on decision + now = dt_util.utcnow() + + # Bound the fan in BOTH directions or it cycles forever: a decision oscillating around its + # threshold (what a marginal COP gain does) otherwise flips it every tick, and on an + # exhaust-air F750 each flip perturbs the source air the compressor draws from. The + # optimizer's own `duration_minutes` (15-60 min by deficit) is the minimum run time, and a + # minimum rest at normal speed bounds the other direction. if decision.should_enhance: - # Only turn on if not already enhanced - if not is_enhanced: - success = await self.nibe.set_enhanced_ventilation(True) - if success: - # Track when we started enhanced mode - self._airflow_enhance_start = dt_util.utcnow() - _LOGGER.info( - "🌀 Ventilation ENHANCED: ON for %d min (+%.2f kW gain) - %s", - decision.duration_minutes, - decision.expected_gain_kw, - decision.reason, + if is_enhanced: + _LOGGER.debug("Ventilation already enhanced - %s", decision.reason) + return + + resting_since = self._airflow_normal_since + if resting_since is not None: + rested = (now - resting_since).total_seconds() / 60 + if rested < NIBE_VENTILATION_MIN_REST_DURATION: + _LOGGER.debug( + "Ventilation: at normal for only %d of %d min - not re-enhancing yet", + int(rested), + NIBE_VENTILATION_MIN_REST_DURATION, ) - else: - _LOGGER.debug( - "Ventilation already enhanced - %s", + return + + if await self._write_enhanced_ventilation(True): + self._airflow_enhance_start = now + self._airflow_normal_since = None + # The optimizer's own recommendation, floored so a decision that carries no + # duration still cannot produce a five-minute burst. + self._airflow_enhance_minutes = max( + decision.duration_minutes, NIBE_VENTILATION_MIN_ENHANCED_DURATION + ) + _LOGGER.info( + "🌀 Ventilation ENHANCED: ON for at least %d min (+%.2f kW gain) - %s", + self._airflow_enhance_minutes, + decision.expected_gain_kw, decision.reason, ) - else: - # Only reduce if currently enhanced and minimum duration passed - if is_enhanced: - # Check minimum enhanced duration to prevent rapid cycling - if hasattr(self, "_airflow_enhance_start") and self._airflow_enhance_start: - elapsed_minutes = ( - dt_util.utcnow() - self._airflow_enhance_start - ).total_seconds() / 60 - - if elapsed_minutes < NIBE_VENTILATION_MIN_ENHANCED_DURATION: - _LOGGER.debug( - "Ventilation: keeping enhanced for %d more min (min duration)", - int(NIBE_VENTILATION_MIN_ENHANCED_DURATION - elapsed_minutes), - ) - return + return - success = await self.nibe.set_enhanced_ventilation(False) - if success: - self._airflow_enhance_start = None - _LOGGER.info( - "🌀 Ventilation NORMAL: OFF - %s", - decision.reason, - ) - else: + if not is_enhanced: + _LOGGER.debug("Ventilation at normal - %s", decision.reason) + return + + if self._airflow_enhance_start is not None: + elapsed = (now - self._airflow_enhance_start).total_seconds() / 60 + if elapsed < self._airflow_enhance_minutes: _LOGGER.debug( - "Ventilation at normal - %s", - decision.reason, + "Ventilation: keeping enhanced for %d more min (the optimizer asked for %d)", + int(self._airflow_enhance_minutes - elapsed), + self._airflow_enhance_minutes, ) + return + + if await self._write_enhanced_ventilation(False): + self._airflow_enhance_start = None + self._airflow_normal_since = now + _LOGGER.info("🌀 Ventilation NORMAL: OFF - %s", decision.reason) + + def _is_dhw_start_rate_limited(self, now_time: datetime) -> bool: + """True if a DHW boost was started or stopped too recently to start another. + + Guards STARTS only. A stop must never be deferred - see _apply_dhw_control. + + Args: + now_time: Current datetime + + Returns: + True when a new DHW boost must not be started yet + """ + if self._last_dhw_control_time is None: + return False + + minutes_since_last = (now_time - self._last_dhw_control_time).total_seconds() / 60 + if minutes_since_last < DHW_CONTROL_MIN_INTERVAL_MINUTES: + _LOGGER.debug( + "DHW start rate limited: %.1f min since last change (min %d min)", + minutes_since_last, + DHW_CONTROL_MIN_INTERVAL_MINUTES, + ) + return True + + return False async def _apply_dhw_control( self, decision, current_dhw_temp: float, now_time: datetime @@ -1641,12 +1946,16 @@ async def _apply_dhw_control( """ # Check temporary lux entity if not self.temp_lux_entity: - _LOGGER.debug( - "DHW control disabled: No temporary lux entity " - "configured (switch.temporary_lux_50004)" - ) + # A _LOGGER.debug is not telling anyone, and this is a whole feature silently doing + # nothing. Home Assistant's NIBE integration maps the temporary-lux register (50004) + # for the F-series only, so an S-series pump exposes no such entity - while the UI goes + # on showing a hot-water status, a recommendation and a SCHEDULED START TIME that can + # never fire. Exactly the case the price-source repair issue was created for. + self._raise_dhw_control_issue() return + self._clear_dhw_control_issue() + # Get current state of temporary lux switch temp_lux_state = self.hass.states.get(self.temp_lux_entity) if not temp_lux_state: @@ -1655,6 +1964,20 @@ async def _apply_dhw_control( is_lux_on = temp_lux_state.state == "on" + # A user-commanded boost (boost_dhw) opens a window ordinary optimization must not + # close. Expiry closes it here, through the owned door; the safety abort below closes + # it too - only safety outranks the user. + if self._service_boost_until is not None: + if not is_lux_on: + # NIBE's own lux timeout, or the household, ended it first. + self._service_boost_until = None + elif now_time >= self._service_boost_until: + _LOGGER.info("User DHW boost window ended - returning temporary lux to normal") + if await self._set_temporary_lux(False): + self._last_dhw_control_time = now_time + self._service_boost_until = None + return + # Use pre-calculated decision from _calculate_dhw_recommendation() # This avoids duplicate optimizer calls and log spam # Get thermal_debt and indoor_temp from coordinator data for abort conditions @@ -1690,31 +2013,24 @@ async def _apply_dhw_control( "DHW heating aborted early: %s. Stopping DHW to prioritize space heating.", abort_reason, ) - try: - await self.hass.services.async_call( - "switch", - "turn_off", - {"entity_id": self.temp_lux_entity}, - blocking=True, - ) + # Stop through the owned door so `_lux_boost_is_ours` is cleared. Safety also + # outranks a user boost - the window closes with the switch. + self._service_boost_until = None + if await self._set_temporary_lux(False): self._last_dhw_control_time = now_time - except (HomeAssistantError, AttributeError, OSError, ValueError) as err: - _LOGGER.error("Failed to abort DHW heating: %s", err) return # Exit early - abort handled - # Rate limiting: Don't change lux state too frequently (minimum 1 hour) - if self._last_dhw_control_time is not None: - time_since_last = (now_time - self._last_dhw_control_time).total_seconds() / 60 - if time_since_last < DHW_CONTROL_MIN_INTERVAL_MINUTES: - _LOGGER.debug( - "DHW control rate limited: %.1f min since last change (min %d min)", - time_since_last, - DHW_CONTROL_MIN_INTERVAL_MINUTES, - ) + # Apply control decision. + # + # RATE LIMITING APPLIES TO STARTS ONLY - never to stops. Rate-limiting a stop would strand + # an in-progress DHW cycle (the limiter's clock is started by the turn-ON, so the interval + # runs from the start of the very cycle being stopped), holding the compressor away from + # space heating while thermal debt deepened. Stopping the boost cannot harm the pump, and + # oscillation stays bounded because the next START is still rate limited. + if decision.should_heat and not is_lux_on: + if self._is_dhw_start_rate_limited(now_time): return - # Apply control decision - if decision.should_heat and not is_lux_on: # Turn ON temporary lux to boost DHW _LOGGER.info( "DHW control: Activating temporary lux - %s (DHW: %.1f°C, DM: %.0f)", @@ -1722,18 +2038,18 @@ async def _apply_dhw_control( current_dhw_temp, thermal_debt, ) - try: - await self.hass.services.async_call( - "switch", - "turn_on", - {"entity_id": self.temp_lux_entity}, - blocking=True, - ) + # Through the one door, which is what records that this boost is ours - and therefore + # what lets the unload cleanup find it again. See _set_temporary_lux. + if await self._set_temporary_lux(True): self._last_dhw_control_time = now_time - except (HomeAssistantError, AttributeError, OSError, ValueError) as err: - _LOGGER.error("Failed to turn on temporary lux: %s", err) elif not decision.should_heat and is_lux_on: + if self._service_boost_until is not None: + _LOGGER.debug( + "User DHW boost active until %s - price optimization does not cancel it", + self._service_boost_until, + ) + return # Turn OFF temporary lux to block/stop DHW _LOGGER.info( "DHW control: Deactivating temporary lux - %s (DHW: %.1f°C, DM: %.0f)", @@ -1741,16 +2057,8 @@ async def _apply_dhw_control( current_dhw_temp, thermal_debt, ) - try: - await self.hass.services.async_call( - "switch", - "turn_off", - {"entity_id": self.temp_lux_entity}, - blocking=True, - ) + if await self._set_temporary_lux(False): self._last_dhw_control_time = now_time - except (HomeAssistantError, AttributeError, OSError, ValueError) as err: - _LOGGER.error("Failed to turn off temporary lux: %s", err) else: # No change needed _LOGGER.debug( @@ -1760,6 +2068,47 @@ async def _apply_dhw_control( decision.priority_reason, ) + def _accumulate_spot_savings(self, nibe_data, price_data) -> None: + """Add this cycle's spot-price savings to the running daily total. + + Savings are reported to the owner as money, so they may only be computed from power that was + MEASURED. Without a power sensor, `power_kw` holds a curve fit floored at 1.0 kW even with + the compressor off, and it arrives in the same field as a real reading - so an estimate here + turns into kronor indistinguishable from earned ones. Same rule as the peak: never bill a guess. + """ + if ( + not price_data + or not getattr(price_data, "today", None) + or not nibe_data + or nibe_data.power_kw is None + or nibe_data.power_is_estimated + ): + return + + current_quarter = price_data.get_period_index(dt_util.now()) + if current_quarter is None: + return + + prices_today = [quarter.price for quarter in price_data.today] + average_price = sum(prices_today) / len(prices_today) + current_price = price_data.today[current_quarter].price + + self.savings_calculator.price_unit = getattr(self.gespot, "price_unit", None) + cycle_savings = self.savings_calculator.calculate_spot_savings_per_cycle( + actual_power_kw=nibe_data.power_kw, + current_price=current_price, + average_price_today=average_price, + cycle_minutes=UPDATE_INTERVAL_MINUTES, + ) + + if self.savings_calculator.is_sek_price_unit: + self._daily_spot_savings += cycle_savings + else: + _LOGGER.debug( + "Skipping non-SEK spot-savings aggregation for price unit %s", + self.savings_calculator.price_unit, + ) + async def _update_peak_tracking(self, nibe_data) -> None: """Update peak power tracking for effect tariff optimization. @@ -1776,6 +2125,11 @@ async def _update_peak_tracking(self, nibe_data) -> None: self.nibe.power_sensor_entity ) + # Where this cycle's power reading came from. The billing guard at the end asks THIS, + # and nothing else: keying on whether a power entity is configured says nothing about + # whether it answered, and let a dropped-out meter's estimate be billed as a meter peak. + power_source = POWER_SOURCE_NONE + # PRIORITY 1: External power meter (whole house including NIBE) # This is MOST IMPORTANT for peak billing - measures total house consumption # Used for: Monthly peak tracking (effect tariff billing) @@ -1784,39 +2138,23 @@ async def _update_peak_tracking(self, nibe_data) -> None: power_entity_id = self.nibe.power_sensor_entity power_state = self.hass.states.get(power_entity_id) - # Only attempt to use external sensor if it's available - # Availability is tracked via event listener for fast startup detection - if power_state and power_state.state not in ["unknown", "unavailable"]: - try: - # Convert to kW only when the meter reports watts - - # a kW meter must not be divided a second time - # (a 6.0 kW whole-house meter would become 0.006 kW, - # invalidating peak protection and peak records) - power_unit = str( - power_state.attributes.get("unit_of_measurement", "W") - ).lower() - current_power = float(power_state.state) - if power_unit == "w": - current_power = current_power / WATTS_PER_KILOWATT - _LOGGER.debug( - "📊 External power meter (whole house): %.3f kW from %s", - current_power, - power_entity_id, - ) - # Mark sensor as available (in case event listener hasn't fired yet) - if not self._power_sensor_available: - self._power_sensor_available = True - _LOGGER.debug("External power sensor marked as available") - except (ValueError, TypeError) as e: - _LOGGER.warning( - "Failed to read power sensor %s (state: %s): %s", - power_entity_id, - power_state.state, - e, - ) + # Unit handling lives in one place, shared with the NIBE adapter, which reads the + # same entity: an unrecognised or absent unit is refused rather than guessed. + current_power = power_kw_from_state(power_state) + + if current_power is not None: + power_source = POWER_SOURCE_EXTERNAL_METER + _LOGGER.debug( + "📊 External power meter (whole house): %.3f kW from %s", + current_power, + power_entity_id, + ) + # Mark sensor as available (in case event listener hasn't fired yet) + if not self._power_sensor_available: + self._power_sensor_available = True + _LOGGER.debug("External power sensor marked as available") elif not self._power_sensor_available: - # Sensor still not available - skip peak tracking this cycle - # Event listener will trigger refresh when sensor becomes available + # Never seen alive - wait for the listener rather than tracking peaks on nothing _LOGGER.debug( "External power sensor %s not yet available (state: %s) - " "skipping peak tracking (listener active: %s)", @@ -1825,6 +2163,20 @@ async def _update_peak_tracking(self, nibe_data) -> None: self._power_sensor_listener is not None, ) return # Exit early, event listener will trigger refresh when ready + else: + # It has answered before and is not answering now. Everything below still runs - + # the decision layers need SOME power figure - but the source stays unbillable, + # so nothing invented here reaches the tariff record, and a silence longer than + # MAX_BILLING_OBSERVATION_GAP_MINUTES refuses the whole hour rather than billing + # a stale reading stretched across it. + _LOGGER.warning( + "External power meter %s did not yield a reading (state: %s). This cycle is " + "not billable, and if the silence exceeds %d minutes the whole hour is " + "refused rather than billed on a stale reading.", + power_entity_id, + power_state.state if power_state else "None", + MAX_BILLING_OBSERVATION_GAP_MINUTES, + ) # PRIORITY 2: NIBE phase currents (NIBE heat pump only - for reference/debugging) # Calculates real NIBE power from BE1/BE2/BE3 current sensors @@ -1837,6 +2189,7 @@ async def _update_peak_tracking(self, nibe_data) -> None: nibe_data.phase3_current, ) if current_power is not None: + power_source = POWER_SOURCE_NIBE_CURRENTS _LOGGER.debug( "⚡ NIBE power from phase currents: %.3f kW " "(L1=%.1fA, L2=%.1fA, L3=%.1fA)", @@ -1853,6 +2206,7 @@ async def _update_peak_tracking(self, nibe_data) -> None: current_power = self.effect.estimate_power_from_compressor( nibe_data.compressor_hz, nibe_data.outdoor_temp ) + power_source = POWER_SOURCE_ESTIMATE _LOGGER.debug( "⚙️ Power estimated from compressor: %.2f kW (%d Hz, %.1f°C outdoor) " "[ESTIMATE ONLY - not used for peak billing]", @@ -1868,169 +2222,163 @@ async def _update_peak_tracking(self, nibe_data) -> None: is_heating = getattr(nibe_data, "is_heating", False) outdoor_temp = getattr(nibe_data, "outdoor_temp", 0.0) current_power = self.effect.estimate_power_consumption(is_heating, outdoor_temp) + power_source = POWER_SOURCE_ESTIMATE _LOGGER.warning( "⚠️ Power estimation fallback: %.2f kW (no real data available) " "[ESTIMATE ONLY - not used for peak billing]", current_power, ) - # SMART FALLBACK for grid import meters with solar/battery - # If meter shows unexpectedly low reading but compressor running significantly, - # the meter likely shows NET import (actual consumption minus solar export) - # In this case, use calculated/estimated heat pump power for peak tracking - if has_external_power_sensor and current_power < 0.5: - # Check if heat pump is actually working hard - compressor_hz = getattr(nibe_data, "compressor_hz", 0) or 0 - is_heating = getattr(nibe_data, "is_heating", False) + # Do NOT override a low meter reading with a compressor-draw estimate on the assumption + # that solar is masking grid import. The grid bills grid IMPORT, which is exactly what + # the meter saw: if solar covers 4.7 kW of a 5.0 kW compressor, 0.3 kW was imported and + # 0.3 kW is charged. Substituting ~5.5 kW inflated the month's peak tenfold, and the + # effect tariff bills the top three quarters, so it would stand for weeks. - if is_heating and compressor_hz > 20: - # Compressor running significantly but meter shows low reading - # This indicates solar/battery offsetting grid import - estimated_power = self.effect.estimate_power_from_compressor( - compressor_hz, getattr(nibe_data, "outdoor_temp", 0.0) - ) + # Publish the instantaneous reading for the effect layer (see the decision site for why + # it must be current power and never peak_today). This runs after the decision, so the + # engine reads the previous cycle's value - at most UPDATE_INTERVAL_MINUTES old. + self.current_power_kw = current_power - if estimated_power > 1.0: # Estimated power seems reasonable - _LOGGER.info( - "Smart fallback: Using estimated power %.2f kW " - "(meter shows %.2f kW - likely solar/" - "battery offset, compressor: %d Hz)", - estimated_power, - current_power, - compressor_hz, - ) - current_power = estimated_power - - # Determine measurement source for metadata - measurement_source = "unknown" - if has_external_power_sensor and current_power is not None: - # Check if this was from external meter or smart fallback - if has_external_power_sensor and current_power >= 0.5: - measurement_source = "external_meter" - elif nibe_data.phase1_current is not None: - measurement_source = "nibe_currents" - else: - measurement_source = "estimate" - elif nibe_data.phase1_current is not None: - measurement_source = "nibe_currents" - else: - measurement_source = "estimate" + # The source is recorded where the value was produced, never reconstructed here from the + # config entry and the number's magnitude - which once filed a compressor estimate as + # "external_meter", making an invented peak look measured. + measurement_source = power_source # Get current timestamp for peak tracking now = dt_util.now() - quarter_of_day = get_current_quarter(now) - quarter_start = now.replace( - minute=now.minute - now.minute % QUARTER_INTERVAL_MINUTES, - second=0, - microsecond=0, - ) + billing_period = get_current_billing_period(now) # Update daily peak (always track for display, even if estimated) if current_power > self.peak_today: self.peak_today = current_power self.peak_today_time = now self.peak_today_source = measurement_source - self.peak_today_quarter = quarter_of_day + # The billing PERIOD this peak fell in - an hour. The sensor weights it through + # effective_tariff_power_kw, which now takes an hour, so it must be given one. + self.peak_today_period = billing_period _LOGGER.info( - "New daily peak: %.2f kW at %s (quarter %d, source: %s)", + "New daily peak: %.2f kW at %s (billing hour %d, source: %s)", current_power, now.strftime("%H:%M:%S"), - quarter_of_day, + billing_period, measurement_source, ) - # CRITICAL: Only record monthly peaks with REAL measurements - # Monthly peak billing requires accurate whole-house power measurement - # We MUST have either: - # 1. External whole-house power meter (best for billing) OR - # 2. NIBE phase currents (accurate NIBE-only, but missing other house loads) - # Estimates are NEVER used for monthly peak tracking - billing must be accurate - has_real_measurement = has_external_power_sensor or nibe_data.phase1_current is not None - if not has_real_measurement: + # Only record monthly peaks from REAL measurements: billing must be accurate. This asks + # where THIS cycle's number came from, not whether a power entity is configured - a + # meter that has gone unavailable still satisfies the latter, so the estimate that + # replaced it would be billed in the very cycle the log says it must not. + if power_source not in PEAK_CONTROL_POWER_SOURCES: _LOGGER.debug( - "Skipping monthly peak recording: No real power measurement available. " - "Current reading %.2f kW is estimated (not suitable for billing). " - "Configure external power meter for accurate peak tracking.", + "Skipping monthly peak recording: %.2f kW came from %s, which is not a " + "measurement. Peak protection must not be driven by a guess.", current_power, + power_source, ) return - # Swedish effect tariffs bill the 15-minute MEAN power. Recording - # each instantaneous sample would register a short spike (e.g. a - # 9 kW start among 1 kW readings) as a full quarter peak. Instead, - # accumulate this quarter's samples and record the mean when the - # quarter completes. - peak_event = None - if quarter_start != self._quarter_power_start: - if ( - self._quarter_power_start is not None - and self._quarter_power_samples - and not self._quarter_power_partial - ): - completed_start, previous_power = self._quarter_power_samples[0] - quarter_end = completed_start + timedelta(minutes=QUARTER_INTERVAL_MINUTES) - weighted_power = 0.0 - previous_time = completed_start - for sample_time, sample_power in self._quarter_power_samples[1:]: - weighted_power += ( - previous_power * (sample_time - previous_time).total_seconds() - ) - previous_time = sample_time - previous_power = sample_power - weighted_power += previous_power * (quarter_end - previous_time).total_seconds() - quarter_mean = weighted_power / (quarter_end - completed_start).total_seconds() - # Stamp the event with the quarter it measures, not the - # boundary-crossing time: at a month boundary "now" would - # attribute the old month's last quarter to the new month - peak_event = await self.effect.record_quarter_measurement( - power_kw=quarter_mean, - quarter=self._quarter_power_number, - timestamp=completed_start, - ) - elif self._quarter_power_start is not None: - _LOGGER.debug( - "Discarding partial effect-tariff quarter %d (observation " - "began mid-quarter)", - self._quarter_power_number, - ) + # The billed quantity - the time-weighted mean over a billing hour - is defined once, in + # billing_period.py, so the coordinator and the simulator cannot diverge. Two copies + # once did, both wrong on the DST fall-back, and merged the repeated hour into one, + # deleting a 9 kW billing peak. + completed = self._billing_period.add(now, current_power, power_source) - # Only the first quarter after startup can be partial: it began - # before observation started (unless the first sample landed in - # the quarter's first minute). Later quarters anchor their first - # sample at the quarter boundary - the reading backfills at most - # one update cycle, mirroring the forward extrapolation to the - # boundary at the end of the quarter. - self._quarter_power_partial = self._quarter_power_start is None and bool( - now.minute % QUARTER_INTERVAL_MINUTES + peak_event = None + if completed is not None: + peak_event = await self.effect.record_period_measurement( + power_kw=completed.mean_power_kw, + period=completed.billing_period, + timestamp=completed.started_at, + # The period's OWN provenance - every sample votes, not the closing cycle. + source=completed.source, ) - self._quarter_power_start = quarter_start - self._quarter_power_number = quarter_of_day - anchor = now if self._quarter_power_partial else quarter_start - self._quarter_power_samples = [(anchor, current_power)] - else: - self._quarter_power_samples.append((now, current_power)) + + if ( + peak_event + and not self.entry.data.get("enable_optimization", True) + and peak_event.is_billable + ): + # The unoptimised baseline, MEASURED: with optimization off the offset is held at + # 0.0 and the pump runs its own curve, so this is what the house does without + # EffektGuard. Nothing else calls update_baseline_peak; without it the savings + # calculator assumes baseline = peak * 1.176 and can never read zero. Must be + # `effective_power` (tariff-weighted, like the peak_this_month it is compared + # against) and billable - an unweighted or NIBE-only baseline reports weighting or + # unseen load as savings. + self.savings_calculator.update_baseline_peak(peak_event.effective_power) if peak_event: - self.peak_this_month = peak_event.effective_power + # The HIGHEST of the tracked peaks, never peak_event.effective_power: + # record_period_measurement returns an event for ANY new entry while the top-3 + # list is still filling, so a 6.0 kW peak followed by a 2.0 kW hour would drop the + # monthly peak to 2.0 and weaken the threshold for the rest of the month. + self.peak_this_month = self.effect.get_monthly_peak_summary()["highest"] _LOGGER.info("New monthly peak: %.2f kW", self.peak_this_month) except (AttributeError, KeyError, ValueError, TypeError) as err: _LOGGER.warning("Failed to update peak tracking: %s", err) - async def async_set_offset(self, offset: float) -> None: + async def _write_curve_offset(self, offset: float, *, force_write: bool = False) -> int | None: + """The ONE way this integration reaches the heat pump. Return the applied integer. + + A coordinator that has been shut down is not a writer. `_do_aligned_refresh` runs on + `hass.async_create_task`, so HA cannot cancel it on unload and it stays mid-flight for + seconds awaiting the weather forecast; without this check it would drive the pump after + unload, leaving the reload's new coordinator a second writer. + + tests/unit/coordinator/test_an_unloaded_integration_does_not_drive_the_heat_pump.py + """ + if self._shutdown_requested: + _LOGGER.debug( + "Coordinator is shut down - refusing to write offset %.2f°C to the pump. The entry " + "is unloaded; an in-flight refresh does not get the last word.", + offset, + ) + return None + + return await self.nibe.set_curve_offset(offset, force_write=force_write) + + async def _write_enhanced_ventilation( + self, enabled: bool, *, force_write: bool = False + ) -> bool: + """The ONE way this integration commands the exhaust fan. Returns whether it wrote. + + Same race as the curve offset: written from the control loop, so it rides the same in-flight + refresh. Worse on a reload - the dead coordinator can switch the fan ON while the new one + starts up believing it is off, and nothing is left that will ever turn it off again. + """ + if self._shutdown_requested: + _LOGGER.debug( + "Coordinator is shut down - refusing to set enhanced ventilation to %s. The entry " + "is unloaded; the fan is not its to command.", + enabled, + ) + return False + + return await self.nibe.set_enhanced_ventilation(enabled, force_write=force_write) + + async def async_set_offset(self, offset: float, *, force_write: bool = False) -> int | None: """Apply heating curve offset to NIBE system. Args: offset: Offset value in °C (-10 to +10) + force_write: Bypass ordinary write suppression for a safety transition. + + Returns: + Integer applied to NIBE, or None when no write reached the pump. """ try: - await self.nibe.set_curve_offset(offset) - self.current_offset = offset - self.last_applied_offset = offset + applied_offset = await self._write_curve_offset(offset, force_write=force_write) + if applied_offset is None: + return None + self.current_offset = float(applied_offset) + self.last_applied_offset = float(applied_offset) self.last_offset_timestamp = dt_util.utcnow() self._learned_data_changed = True # Trigger save on shutdown - _LOGGER.info("Applied offset: %.2f°C", offset) + _LOGGER.info("Applied offset: %d°C", applied_offset) + return applied_offset except (HomeAssistantError, AttributeError, OSError, ValueError) as err: _LOGGER.error("Failed to apply offset: %s", err) raise @@ -2041,17 +2389,86 @@ async def set_optimization_enabled(self, enabled: bool) -> None: Args: enabled: True to enable optimization, False to disable """ + if self._shutdown_requested: + _LOGGER.debug("Coordinator is shut down - ignoring optimization mode change") + return + if enabled: _LOGGER.info("Optimization enabled") - # Resume normal optimization - await self.async_request_refresh() + previous_data = dict(self.entry.data) + enabled_data = dict(previous_data) + enabled_data[CONF_ENABLE_OPTIMIZATION] = True + self.hass.config_entries.async_update_entry(self.entry, data=enabled_data) + try: + # Resume normal optimization now rather than waiting for the next aligned tick. + await self.async_refresh_and_apply() + except Exception: + self.hass.config_entries.async_update_entry(self.entry, data=previous_data) + raise else: _LOGGER.info("Optimization disabled - resetting offset to neutral") - # Reset offset to neutral (0.0) - try: - await self.async_set_offset(0.0) - except (HomeAssistantError, AttributeError, OSError, ValueError) as err: - _LOGGER.error("Failed to reset offset: %s", err) + async with self._control_lock: + applied_offset = await self.async_set_offset(0.0, force_write=True) + await self._cancel_our_dhw_boost() + is_enhanced = await self.nibe.is_enhanced_ventilation_active() + if is_enhanced is None and self.nibe.has_ventilation_control: + # The fan may be enhanced and the switch cannot say. OFF must not be + # displayed until every owned actuator is KNOWN neutral. + raise HomeAssistantError( + "Cannot confirm the NIBE ventilation is back to normal - " + "the ventilation switch is unavailable" + ) + if is_enhanced: + ventilation_stopped = await self._write_enhanced_ventilation( + False, + force_write=True, + ) + if not ventilation_stopped: + raise HomeAssistantError( + "Could not return NIBE ventilation to its normal setting" + ) + self._airflow_enhance_start = None + self._airflow_normal_since = dt_util.utcnow() + if applied_offset != 0: + raise HomeAssistantError("Could not reset the NIBE heating offset to neutral") + + disabled_data = dict(self.entry.data) + disabled_data[CONF_ENABLE_OPTIMIZATION] = False + self.hass.config_entries.async_update_entry(self.entry, data=disabled_data) + + async def async_start_dhw_boost(self, duration_minutes: int, now_time: datetime) -> None: + """Start a user-commanded hot-water boost that price optimization may not cancel. + + Only safety outranks the user: the thermal-debt abort still stops it, and so does + unload. The duration is real - EffektGuard turns the switch back off when it expires, + through the same owned door the cleanup uses - so the service argument means what it + says instead of being validated and discarded. NIBE's own lux timeout still applies + underneath; whichever ends first wins. + """ + if not self.optimization_enabled: + raise HomeAssistantError( + "EffektGuard is OFF. Turn optimization on before boosting hot water." + ) + + if not await self._set_temporary_lux(True): + raise HomeAssistantError( + f"Could not start the hot-water boost on {self.temp_lux_entity}" + ) + self._service_boost_until = now_time + timedelta(minutes=duration_minutes) + + async def async_apply_manual_override(self, offset: float, duration_minutes: int) -> None: + """Apply one explicit user heating command through the locked control path.""" + if not self.entry.data.get("enable_optimization", True): + raise HomeAssistantError( + "EffektGuard is OFF. Turn optimization on before commanding a heating offset." + ) + + self.engine.set_manual_override(offset, duration_minutes) + try: + await self.async_refresh_and_apply(explicit_command=True) + except Exception: + self.engine.clear_manual_override() + raise async def async_update_config(self, options: "EffektGuardConfigDict") -> None: """Update configuration without full reload. @@ -2072,7 +2489,9 @@ async def async_update_config(self, options: "EffektGuardConfigDict") -> None: if "tolerance" in options: self.engine.tolerance = float(options["tolerance"]) - self.engine.tolerance_range = self.engine.tolerance * 0.4 # Recalculate range + # The constant, not a second copy of 0.4. A number that lives in two places is a + # number that will eventually disagree with itself. + self.engine.tolerance_range = self.engine.tolerance * TOLERANCE_RANGE_MULTIPLIER _LOGGER.debug( "Updated tolerance: %.1f (range: %.1f°C)", self.engine.tolerance, @@ -2180,7 +2599,7 @@ async def async_update_config(self, options: "EffektGuardConfigDict") -> None: self._airflow_enhance_start = None try: if await self.nibe.is_enhanced_ventilation_active(): - await self.nibe.set_enhanced_ventilation(False) + await self._write_enhanced_ventilation(False) _LOGGER.info("Disabled enhanced ventilation on airflow optimizer disable") except (AttributeError, ValueError, OSError) as err: _LOGGER.warning("Failed to disable enhanced ventilation: %s", err) @@ -2213,6 +2632,11 @@ def current_peak(self) -> float: """ return self.peak_this_month + @property + def optimization_enabled(self) -> bool: + """Whether the master control gate permits explicit and automatic writes.""" + return self.entry.data.get(CONF_ENABLE_OPTIMIZATION, True) + @property def model_profile(self): """Get heat pump model profile. @@ -2238,13 +2662,25 @@ async def _record_learning_observations( try: now = dt_util.utcnow() - # Record adaptive thermal observation - self.adaptive_learning.record_observation( - timestamp=now, - indoor_temp=nibe_data.indoor_temp, - outdoor_temp=nibe_data.outdoor_temp, - heating_offset=current_offset, + # Learning observes on its own clock (LEARNING_OBSERVATION_INTERVAL_MINUTES), not the + # control loop's: per-cycle indoor readings capture sensor quantisation, not the + # building's hours-long time constant. The thermal PREDICTOR below still wants every + # cycle - it tracks short-term trend, where five-minute resolution is the point (F-132). + since_last = ( + None + if self._last_learning_observation is None + else now - self._last_learning_observation ) + if since_last is None or since_last >= timedelta( + minutes=LEARNING_OBSERVATION_INTERVAL_MINUTES + ): + self.adaptive_learning.record_observation( + timestamp=now, + indoor_temp=nibe_data.indoor_temp, + outdoor_temp=nibe_data.outdoor_temp, + heating_offset=current_offset, + ) + self._last_learning_observation = now # Record thermal state for predictor self.thermal_predictor.record_state( @@ -2300,7 +2736,7 @@ async def _save_learned_data( """ try: learned_data = { - "version": STORAGE_VERSION, + "version": LEARNING_STORAGE_VERSION, "last_updated": dt_util.utcnow().isoformat(), } @@ -2398,7 +2834,7 @@ async def _save_thermal_predictor_immediate(self) -> None: if self.thermal_predictor: existing_data["thermal_predictor"] = self.thermal_predictor.to_dict() existing_data["last_updated"] = now.isoformat() - existing_data["version"] = existing_data.get("version", STORAGE_VERSION) + existing_data["version"] = existing_data.get("version", LEARNING_STORAGE_VERSION) await self.learning_store.async_save(existing_data) self._last_predictor_save = now @@ -2431,7 +2867,7 @@ async def _save_dhw_state_immediate(self) -> None: "last_legionella_boost": self.dhw_optimizer.last_legionella_boost.isoformat() } existing_data["last_updated"] = dt_util.utcnow().isoformat() - existing_data["version"] = existing_data.get("version", STORAGE_VERSION) + existing_data["version"] = existing_data.get("version", LEARNING_STORAGE_VERSION) await self.learning_store.async_save(existing_data) diff --git a/custom_components/effektguard/diagnostics.py b/custom_components/effektguard/diagnostics.py new file mode 100644 index 00000000..cfe44874 --- /dev/null +++ b/custom_components/effektguard/diagnostics.py @@ -0,0 +1,151 @@ +"""Diagnostics: what the decision actually saw. + +The dump carries the DECISION, not just entity states: the commanded offset, every layer's vote +and weight, the NIBE state behind it, the degree-minute band actually in force, and whether the +price and weather sources were live - a missing price source silently withdraws the whole price +layer (F-123), leaving the offset inexplicable without that fact. + +It must NOT carry the home's coordinates: this file gets pasted into public issue trackers. The +climate ZONE identifies nobody and is what the thresholds derive from, so the zone goes in and the +latitude does not. +""" + +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .models.types import DiagnosticsDict +from .optimization.thermal_layer import apply_thermal_mass_buffer + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> DiagnosticsDict: + """Return everything needed to argue with a decision this integration made.""" + coordinator = hass.data.get(DOMAIN, {}).get(entry.entry_id) + if coordinator is None: + return {"error": "coordinator not loaded"} + + data = coordinator.data or {} + nibe = data.get("nibe") + decision = data.get("decision") + + return { + "config": _config(entry), + "sources": _sources(data), + "nibe": _nibe(nibe), + "decision": _decision(decision), + "dm_thresholds": _dm_thresholds(coordinator, nibe), + "compressor_risk": getattr(coordinator, "compressor_risk", None), + "peaks": _peaks(coordinator), + } + + +def _config(entry: ConfigEntry) -> dict[str, object]: + """The user's configuration. Entity ids are not secrets; coordinates are, and are not here.""" + return { + "data": dict(entry.data), + "options": dict(entry.options), + } + + +def _sources(data: dict[str, object]) -> dict[str, str]: + """Which inputs were actually available. + + Price data of None is not a missing field - the price layer abstained entirely and every + price-driven vote is absent from the decision below (F-123). Read the offset knowing that. + """ + return { + "price": "live" if data.get("price") is not None else "ABSENT - price layer abstained", + "weather": "live" if data.get("weather") is not None else "ABSENT - no forecast layers", + "nibe": "live" if data.get("nibe") is not None else "ABSENT", + } + + +def _nibe(nibe: object) -> dict[str, object]: + """The state the decision was made from.""" + if nibe is None: + return {} + + return { + field: getattr(nibe, field, None) + for field in ( + "degree_minutes", + "indoor_temp", + "outdoor_temp", + "supply_temp", + "return_temp", + "current_offset", + "compressor_hz", + "power_kw", + "is_heating", + "is_hot_water", + ) + } + + +def _decision(decision: object) -> dict[str, object]: + """The offset, and the layer votes behind it - without which the offset cannot be argued with.""" + if decision is None: + return {} + + return { + "offset": getattr(decision, "offset", None), + "reasoning": getattr(decision, "reasoning", None), + "is_emergency": getattr(decision, "is_emergency", None), + "is_manual_override": getattr(decision, "is_manual_override", None), + "anti_windup_active": getattr(decision, "anti_windup_active", None), + "layers": [ + { + "name": getattr(layer, "name", None), + "offset": getattr(layer, "offset", None), + "weight": getattr(layer, "weight", None), + "reason": getattr(layer, "reason", None), + } + for layer in getattr(decision, "layers", []) or [] + ], + } + + +def _dm_thresholds(coordinator: object, nibe: object) -> dict[str, object]: + """The degree-minute band this house was actually held to. + + Computed from the climate zone AND outdoor temperature, so the DM constants say nothing about + what governed this decision. The zone name goes in; the latitude it derived from does not. + """ + try: + detector = coordinator.engine.climate_detector + outdoor = getattr(nibe, "outdoor_temp", None) + if detector is None or outdoor is None: + return {} + + # The enforced band is the zone range run through apply_thermal_mass_buffer (a high-mass + # slab is helped sooner); the raw zone table alone understates where the code intervenes. + heating_type = getattr(coordinator.engine.emergency_layer, "heating_type", "radiator") + zone_range = detector.get_expected_dm_range(float(outdoor)) + return { + "climate_zone": detector.zone_info.name, + "outdoor_temp": outdoor, + "heating_type": heating_type, + "range": apply_thermal_mass_buffer(zone_range, heating_type), + "zone_range_before_thermal_mass": zone_range, + } + except (AttributeError, TypeError, ValueError) as err: + _LOGGER.debug("Could not resolve DM thresholds for diagnostics: %s", err) + return {} + + +def _peaks(coordinator: object) -> dict[str, object]: + """Effect-tariff state: what the peak protection was defending.""" + try: + summary = coordinator.effect.get_monthly_peak_summary() + return {"month_highest_kw": summary.get("highest")} + except (AttributeError, TypeError) as err: + _LOGGER.debug("Could not resolve peaks for diagnostics: %s", err) + return {} diff --git a/custom_components/effektguard/icons.json b/custom_components/effektguard/icons.json index 59362b7e..1397e669 100644 --- a/custom_components/effektguard/icons.json +++ b/custom_components/effektguard/icons.json @@ -5,7 +5,8 @@ "sections": { "optimization_settings": "mdi:tune", "building_characteristics": "mdi:home-thermometer", - "domestic_hot_water": "mdi:water-boiler" + "domestic_hot_water": "mdi:water-boiler", + "airflow_optimization": "mdi:fan" } } } diff --git a/custom_components/effektguard/manifest.json b/custom_components/effektguard/manifest.json index 3ed9a7aa..17175df7 100644 --- a/custom_components/effektguard/manifest.json +++ b/custom_components/effektguard/manifest.json @@ -9,5 +9,6 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/enoch85/EffektGuard/issues", "requirements": ["numpy>=1.21.0"], - "version": "v0.5.0-beta.1" + "single_config_entry": true, + "version": "v0.5.0" } diff --git a/custom_components/effektguard/models/base.py b/custom_components/effektguard/models/base.py index 627e5555..f4abcfcc 100644 --- a/custom_components/effektguard/models/base.py +++ b/custom_components/effektguard/models/base.py @@ -6,7 +6,15 @@ """ from abc import ABC, abstractmethod -from dataclasses import dataclass +from collections.abc import Sequence +from dataclasses import dataclass, field + +from ..const import ( + DISPLAY_COP_CURVE_COLD_C, + DISPLAY_COP_CURVE_SPAN_K, + DISPLAY_COP_CURVE_TEMPS, + DM_THRESHOLD_AUX_LIMIT, +) @dataclass @@ -19,6 +27,56 @@ class ValidationResult: suggestions: list[str] +@dataclass(frozen=True) +class RatingPoint: + """One EN 14511 rating point, verbatim from the manufacturer - a measurement at a stated + condition, not a fitted curve. `condition` is the datasheet's own string, and + test_the_pump_models_match_their_datasheets reproduces the COP at every point. + + NOTE `source_temp_c` is the HEAT SOURCE temperature, which is NOT outdoor air for four of the + five machines: A20(12) is 20 C extract air (exhaust-air pump), B0 is 0 C brine (ground-source), + A7 is 7 C outdoor. Only an air/water pump like the F2040 has outdoor air as its source, so only + for it is an outdoor-keyed curve meaningful. + """ + + condition: str # verbatim from the datasheet, e.g. "A20(12)W35, 252 m3/h, min compressor freq" + source_temp_c: float # the HEAT SOURCE temperature at this point (A20 -> 20, B0 -> 0, A7 -> 7) + flow_temp_c: float # W35 -> 35.0 + heat_output_kw: float # PH, the specified heating output + cop: float + # Ventilation rate is part of an exhaust-air pump's source condition, not a detail. The F750's + # two minimum-frequency points differ only in airflow (108 vs 252 m3/h): more air -> higher + # output AND higher COP. Treated as a load pair, efficiency appears to RISE with load (backwards) + # and the fit extrapolates to COP 9.86 at full load, so the two must be told apart. + airflow_m3h: float | None = None + + +def seasonal_cop_proxy( + points: Sequence["RatingPoint"], source_temp_c: float | None = None +) -> dict[int, float]: + """A DISPLAY-ONLY seasonal COP curve, interpolated between a machine's published extremes. + + NOTHING COMPUTES FROM THIS - it is a dashboard proxy. The simulator takes COP from + `datasheet_points` via the exergy model, which uses SOURCE and FLOW temperatures, never the + weather (four of the five machines do not have outdoor air as their heat source). + + `source_temp_c` filters to one source condition (how a brine machine is anchored on its two + published W35/W45 COPs at 0 C). Left None, every published point is used. + """ + cops = [ + point.cop + for point in points + if source_temp_c is None or point.source_temp_c == source_temp_c + ] + best, worst = max(cops), min(cops) + return { + temp: round( + worst + (best - worst) * (temp - DISPLAY_COP_CURVE_COLD_C) / DISPLAY_COP_CURVE_SPAN_K, 2 + ) + for temp in DISPLAY_COP_CURVE_TEMPS + } + + @dataclass class HeatPumpProfile(ABC): """Abstract base class for heat pump model profiles. @@ -28,6 +86,11 @@ class HeatPumpProfile(ABC): - Efficiency curves (COP vs outdoor/flow temp) - Optimization parameters (DM thresholds, cycling protection) - Validation logic (verify power consumption is normal) + + A profile deliberately does NOT calculate the flow temperature the house needs: that is a + property of the HOUSE's emitters (type, sizing, design point), which a profile describing the + PUMP cannot know. Flow temperature belongs to optimization/weather_layer.py, via the EN 442 + emitter law in utils/emitter.py. """ # Identity @@ -53,16 +116,16 @@ class HeatPumpProfile(ABC): max_flow_temp: float min_flow_temp: float - # Optimization parameters (Swedish NIBE research) - dm_threshold_start: float = -60 # Normal compressor start - dm_threshold_extended: float = -240 # Extended runs acceptable - dm_threshold_warning: float = -400 # Approaching danger - dm_threshold_critical: float = -500 # Emergency recovery - dm_threshold_aux_swedish: float = -1500 # Swedish aux optimization + # The simulator reads this so the plant model tracks what the integration believes. + # It cannot do that while the profile restates the number, so it references it (F-076). + dm_threshold_aux_swedish: float = DM_THRESHOLD_AUX_LIMIT - # Cycling protection - min_runtime_minutes: int = 30 - min_rest_minutes: int = 10 + # The DM at which THE PUMP ITSELF engages its additive heat, at factory settings - NIBE menu + # 4.9.3 "start addition" (F-series) or "start diff additional heat" summed with the compressor + # start (S-series/F11xx). A HARDWARE fact, distinct from DM_THRESHOLD_AUX_LIMIT (EffektGuard's + # emergency floor, F-112): a real pump's elpatron fires HERE and works DM back up, so a plant + # model that waits for -1500 misreports both aux energy and overshoot. Overridden per model. + aux_start_dm: float = -700.0 # Exhaust air heat pump features # Only EAHP models (F730, F750) support airflow optimization @@ -70,24 +133,84 @@ class HeatPumpProfile(ABC): standard_airflow_m3h: float = 0.0 # Normal ventilation rate enhanced_airflow_m3h: float = 0.0 # Maximum ventilation rate - @abstractmethod - def calculate_optimal_flow_temp( - self, - outdoor_temp: float, - indoor_target: float, - heat_demand_kw: float, - ) -> float: - """Calculate optimal flow temperature for conditions. - - Args: - outdoor_temp: Current outdoor temperature (°C) - indoor_target: Target indoor temperature (°C) - heat_demand_kw: Required heat output (kW) - - Returns: - Optimal flow temperature (°C) for maximum efficiency + # THE MANUFACTURER'S OWN MEASUREMENTS. See RatingPoint. Everything the simulator believes about + # this machine's efficiency and its capacity is derived from these and from nothing else. + datasheet_points: tuple[RatingPoint, ...] = field(default_factory=tuple) + datasheet_source: str = "" # the document these came out of. No source, no number. + + # The datasheet's own "Heating capacity (PH)" row, e.g. "3 - 12 kW". THIS IS NOT THE SAME THING + # as the output at a rating point: NIBE's EN 14511 figures for the inverter machines are taken + # at NOMINAL compressor frequency (50 Hz for the ground-source pumps), while this row is the + # modulation envelope. An F1155-12 makes 5.06 kW at its 0/35 rating point and can reach 12. + heating_capacity_range_kw: tuple[float, float] = (0.0, 0.0) + + # The immersion heater's DELIVERY SETTING, from the datasheet. 0.0 means the machine has none. + # NIBE ships the F750/F730 with a 6.5 kW heater set to 3.5 kW at delivery, the F1155-12/S1155-12 + # with a 7 kW heater in seven automatic steps, and the F2040 with none (outdoor monobloc - its + # electric addition lives in the paired indoor module, which this package does not model). + immersion_heater_kw: float = 0.0 + # The machine's published minimum OUTDOOR operating temperature, or None where the + # manufacturer publishes none. Only meaningful for a machine whose heat source IS the outdoor + # air: the F2040 manual gives "Min. / Max. air temp: -20 / 43 C" (IHB EN 1848-8/231846 p.65), + # and below that floor the unit does not run - a hard edge, not a derating. NIBE publishes no + # outdoor floor for the brine or exhaust-air machines (their sources are 0 C brine and 20 C + # house air; their only compressor blocks are source-side, e.g. F730 exhaust < 6 C), so + # None here means NONE PUBLISHED, and no model may invent one. + min_operating_outdoor_c: float | None = None + + # Pdesignh - the DESIGN HEAT LOAD this machine is certified for, from its own ErP declaration. + # The only sourced way to size a simulated building: an oversized pump (e.g. a 12 kW GSHP on a + # 6 kW house) cannot saturate, and a simulation that cannot saturate cannot test emergency + # behaviour. + design_heat_load_kw: float = 0.0 + + # Pdesignh for the EN 14825 AVERAGE climate (design temperature -10 C). Tbiv and Psup below are + # declared FOR THAT climate, so any statement that combines them must use this figure, not the + # cold-climate Pdesignh. + design_heat_load_average_kw: float = 0.0 + + # Tbiv - the BIVALENT TEMPERATURE, from the ErP declaration. Below this outdoor temperature the + # pump cannot meet the design heat load alone and supplementary heat is REQUIRED. This is the + # design, not a defect: a correctly-sized Swedish ASHP is bivalent (F2040-8: Tbiv -9 C, 1.1 kW + # supplementary). 0.0 = not declared (exhaust-air and ground-source machines are not bivalent in + # the same sense - their heat source does not weaken with the weather). + bivalent_temp_c: float = 0.0 + + # Psup - the supplementary heat the ErP AVERAGE-climate declaration says this machine needs at + # that climate's design point (-10 C). For the F2040-8: Pdesignh(avg) 8.2 kW with Psup 1.1 kW, + # so the COMPRESSOR delivers 7.1 kW at -10 C - the only published statement about its capacity + # below -7 C, and one COMPLETE declaration, not a splice of two. + supplementary_heat_kw: float = 0.0 + + @property + def max_heat_output_kw(self) -> float: + """The most heat this machine can make, from its own datasheet. + + NOT `rated_power_kw[1]`: the F750 carried 8.0 kW against a published maximum of 4.994. An + exhaust-air pump's output is bounded by the ventilation air it breathes. """ - raise NotImplementedError + if self.heating_capacity_range_kw[1] > 0.0: + return self.heating_capacity_range_kw[1] + + # The ErP declaration is also a published statement about the maximum. For the F2040-8, the + # AVERAGE declaration (Pdesignh 8.2 kW, Psup 1.1 kW at -10 C) puts the COMPRESSOR at 7.1 kW - + # above its coldest tabulated rating point (6.60 kW at -7 C), because inverter capacity rises + # as the weather cools. Tbiv and Psup belong to the average climate, so it is the only + # Pdesignh they may be combined with. + published = max(point.heat_output_kw for point in self.datasheet_points) + if self.design_heat_load_average_kw > 0.0 and self.supplementary_heat_kw > 0.0: + published = max( + published, self.design_heat_load_average_kw - self.supplementary_heat_kw + ) + return published + + def rating_point_at(self, flow_temp_c: float) -> RatingPoint: + """The published point closest to this flow temperature, at the highest output.""" + candidates = sorted( + self.datasheet_points, + key=lambda p: (abs(p.flow_temp_c - flow_temp_c), -p.heat_output_kw), + ) + return candidates[0] @abstractmethod def validate_power_consumption( @@ -134,23 +257,3 @@ def get_cop_at_temperature(self, outdoor_temp: float) -> float: return cop1 + (cop2 - cop1) * ratio return 3.0 # Conservative fallback - - def estimate_electrical_consumption( - self, - heat_demand_kw: float, - outdoor_temp: float, - ) -> float: - """Estimate electrical consumption for heat demand. - - Args: - heat_demand_kw: Required heat output (kW) - outdoor_temp: Outdoor temperature (°C) - - Returns: - Estimated electrical consumption (kW) - """ - cop = self.get_cop_at_temperature(outdoor_temp) - electrical_kw = heat_demand_kw / cop - - # Cap at max electrical power - return min(electrical_kw, self.typical_electrical_range_kw[1]) diff --git a/custom_components/effektguard/models/nibe/f1155.py b/custom_components/effektguard/models/nibe/f1155.py index 9471ee6c..67bd8dac 100644 --- a/custom_components/effektguard/models/nibe/f1155.py +++ b/custom_components/effektguard/models/nibe/f1155.py @@ -3,18 +3,59 @@ Inverter-controlled GSHP - F-series ground source, predecessor of the S1155. Available in 3 sizes: 1.5-6 kW, 4-12 kW, 4-16 kW. -Added for issue #18: F1155 owners connect via local Modbus (nibe_heatpump -integration / MODBUS40) and previously had to pick the S1155 profile. +F1155 owners connect via local Modbus (nibe_heatpump integration / MODBUS40). -Source: https://www.nibe.eu/en-eu/products/heat-pumps/ground-source-heat-pumps -Physics inherit from the S1155 (same GSHP family, stable 5-8°C ground -temperature); COP curve set slightly below the S1155 (older inverter -platform, SCOP ~5.0). +Heat source is brine from a borehole, so performance is keyed on INCOMING BRINE temperature, not +outdoor air. The F1155 and S1155 publish IDENTICAL EN 14511 data at every size (below, verbatim). """ from dataclasses import dataclass +from ..base import HeatPumpProfile, RatingPoint, ValidationResult, seasonal_cop_proxy from ..registry import HeatPumpModelRegistry + +# F1155-12. EN 14511 rating points, VERBATIM. +# +# Keyed on INCOMING BRINE temperature, not outdoor air (datasheet capacity chart x-axis is +# "Incoming brine temp, C"). All four points are at NOMINAL (50 Hz) frequency; NIBE publishes no +# min-/max-frequency COP for these pumps, only the modulation envelope ("Heating capacity (PH)"), +# so load dependence of efficiency is NOT measurable here - see HouseConfig.exergy_efficiency. +F1155_12_DATASHEET = ( + RatingPoint( + "0/35 nominal (50 Hz), incoming brine 0 C", + source_temp_c=0.0, + flow_temp_c=35.0, + heat_output_kw=5.06, + cop=4.87, + ), + RatingPoint( + "0/45 nominal (50 Hz), incoming brine 0 C", + source_temp_c=0.0, + flow_temp_c=45.0, + heat_output_kw=4.78, + cop=3.75, + ), + RatingPoint( + "10/35 nominal (50 Hz), incoming brine 10 C", + source_temp_c=10.0, + flow_temp_c=35.0, + heat_output_kw=6.33, + cop=6.12, + ), + RatingPoint( + "10/45 nominal (50 Hz), incoming brine 10 C", + source_temp_c=10.0, + flow_temp_c=45.0, + heat_output_kw=5.98, + cop=4.59, + ), +) +F1155_12_SOURCE = ( + "NIBE F1155 installer manual, Output data according to EN 14511, " + "F1155_12 column. https://installer.nibe.eu/ " + "(F1155: IHB EN 2008-5/331379 p.69; S1155: IHB EN 2001-1/531210 p.70. The two " + "publish IDENTICAL EN 14511 data at every size - same platform.)" +) from .s1155 import NibeS1155Profile @@ -37,23 +78,28 @@ class NibeF1155Profile(NibeS1155Profile): model_type: str = "F-series GSHP" # Mid-range variant (4-12 kW) - rated_power_kw: tuple[float, float] = (4.0, 12.0) # Heat output - typical_electrical_range_kw: tuple[float, float] = (0.6, 2.8) # Estimated from SCOP ~5.0 + datasheet_points: tuple[RatingPoint, ...] = F1155_12_DATASHEET + datasheet_source: str = F1155_12_SOURCE + design_heat_load_kw: float = ( + 12.0 # Pdesignh - installer manual "Nominal heating output (Pdesignh) 12 kW" (F1155-12) + ) + immersion_heater_kw: float = 7.0 # datasheet: additional power 1/2/3/4/5/6/7 kW + heating_capacity_range_kw: tuple[float, float] = ( + 3.0, + 12.0, + ) # datasheet "Heating capacity (PH)" + + rated_power_kw: tuple[float, float] = (3.0, 12.0) # the PH modulation envelope, EN 14511 + typical_electrical_range_kw: tuple[float, float] = (1.04, 2.5) # PE at the rating points modulation_range: tuple[int, int] = (20, 120) - typical_cop_range: tuple[float, float] = (3.3, 5.3) # GSHP, slightly below S1155 + typical_cop_range: tuple[float, float] = (3.75, 6.12) # published COPs, 0/45 .. 10/35 def __post_init__(self): - """Initialize COP curve - S1155 shape shifted slightly down for the - older F-series inverter platform.""" - self.cop_curve = { - 7: 5.3, - 5: 5.1, - 0: 4.8, - -5: 4.6, # Ground temp stable - -10: 4.3, # Ground temp stable - -15: 4.1, - -20: 3.8, - -25: 3.6, - -30: 3.3, # Much better than ASHP at extreme temps - } + """Initialize display-only COP proxy. + + Nothing computes from it: COP is a function of brine + flow temperature, not the weather, + and the simulator takes it from `datasheet_points`. The proxy is a dashboard seasonal curve + anchored on the two published W35/W45 COPs at 0 C brine. + """ + self.cop_curve = seasonal_cop_proxy(self.datasheet_points, source_temp_c=0.0) diff --git a/custom_components/effektguard/models/nibe/f2040.py b/custom_components/effektguard/models/nibe/f2040.py index c6f40365..ae77b4d3 100644 --- a/custom_components/effektguard/models/nibe/f2040.py +++ b/custom_components/effektguard/models/nibe/f2040.py @@ -1,105 +1,171 @@ """NIBE F2040 heat pump profile. -12-16kW ASHP - Large model for 180-250m² houses or poorly insulated properties. +AIR/WATER heat pump (outdoor monobloc). The ONLY machine in this package whose heat source is the +outdoor air, so the only one for which an outdoor-keyed COP curve is meaningful. + +NIBE ships the F2040 in sizes 6/8/12/16 whose outputs differ ~3x (A7/W35: 2.67/3.86/5.21/7.03 kW). +This profile carries the F2040-8; offering the size in the config flow is an owner decision made +elsewhere. """ from dataclasses import dataclass -from ...const import KUEHNE_COEFFICIENT, KUEHNE_POWER, WATTS_PER_KILOWATT -from ..base import HeatPumpProfile, ValidationResult +from ..base import HeatPumpProfile, RatingPoint, ValidationResult from ..registry import HeatPumpModelRegistry +# NIBE F2040-8, installer manual IHB EN 1848-8 / 231846, p.65: +# "Output data according to EN 14511 dT5K - Capacity / power input / COP (kW/kW/-) at nominal flow" +# VERBATIM. +F2040_8_DATASHEET = ( + RatingPoint( + "A7/W35, EN 14511 dT5K at nominal flow (floor heating)", + source_temp_c=7.0, + flow_temp_c=35.0, + heat_output_kw=3.86, + cop=4.65, + ), + RatingPoint( + "A2/W35, EN 14511 dT5K at nominal flow (floor heating)", + source_temp_c=2.0, + flow_temp_c=35.0, + heat_output_kw=5.11, + cop=3.76, + ), + RatingPoint( + "A-7/W35, EN 14511 dT5K at nominal flow (floor heating)", + source_temp_c=-7.0, + flow_temp_c=35.0, + heat_output_kw=6.60, + cop=2.68, + ), + RatingPoint( + "A7/W45, EN 14511 dT5K at nominal flow", + source_temp_c=7.0, + flow_temp_c=45.0, + heat_output_kw=3.70, + cop=3.70, + ), + RatingPoint( + "A2/W45, EN 14511 dT5K at nominal flow", + source_temp_c=2.0, + flow_temp_c=45.0, + heat_output_kw=5.03, + cop=2.96, + ), +) +F2040_SOURCE = ( + "NIBE F2040 installer manual IHB EN 1848-8/231846 p.65, 'Output data according to EN 14511' " + "(F2040-8 column); cross-checked against the F2040 Specification Sheet. " + "https://installer.nibe.eu/download/18.69d23679185eaf109d92e20/1676544181832/" + "F2040-IHB-231846-8.pdf" +) + +# CAPACITY RISES AS IT GETS COLDER; it does not fall. This is an INVERTER: at +7/W35 it is throttled +# back to part load and ramps the compressor UP as the weather cools (3.86 -> 5.11 -> 6.60 kW from +7 +# to -7 C). What collapses with the cold is the COP (4.65 -> 3.76 -> 2.68), not the capacity - there +# is no derating table in the datasheet. NIBE tabulates no capacity below -7 C (only a "Max specified +# output" graph), so the model holds capacity flat at the -7 C figure below that point. Operating +# limits, same manual: "Min. / Max. air temp: -20 / 43 C". +MIN_AIR_TEMP_C = -20.0 +MAX_AIR_TEMP_C = 43.0 + @HeatPumpModelRegistry.register("nibe_f2040") @dataclass class NibeF2040Profile(HeatPumpProfile): - """NIBE F2040 12-16kW Air Source Heat Pump. + """NIBE F2040-8 air/water heat pump. + + Datasheet: makes 3.86 kW at A7/W35 and 6.60 kW at -7/W35; supplies at most 58 C ("Min. / Max. HM + temp continuous operation: 25 / 58 C"); has NO immersion heater - it is an outdoor monobloc, and + the electric backup lives in the paired indoor module (VVM/SMO), which this package does not + model. - **Target Market**: 180-250m² houses or older/poorly insulated - **Electrical**: 3-phase 16A/20A (higher loads) - **Power**: 2.5-6.5kW electrical typical (can spike to 10kW+ in extreme cold) + SCOP(EN 14825) cold climate 35 C: 3.55, Pdesignh 9 kW. """ model_name: str = "F2040" manufacturer: str = "NIBE" - model_type: str = "F-series ASHP" + model_type: str = "F-series air/water" - rated_power_kw: tuple[float, float] = (3.0, 16.0) - typical_electrical_range_kw: tuple[float, float] = (2.5, 10.0) + # SOURCED: the F2040 is an outdoor monobloc with no additive heat of its own; the indoor + # controller owns it. VVM 320 (its standard pairing) ships "start diff additional heat" 700 + # below the compressor start (-60): additive heat engages at about -760. + aux_start_dm: float = -760.0 + + datasheet_points: tuple[RatingPoint, ...] = F2040_8_DATASHEET + datasheet_source: str = F2040_SOURCE + + rated_power_kw: tuple[float, float] = (3.86, 6.60) # PH at A7/W35 .. A-7/W35, EN 14511 + typical_electrical_range_kw: tuple[float, float] = (0.83, 2.46) # Pin at those same points modulation_range: tuple[int, int] = (70, 120) modulation_type: str = "inverter" - typical_cop_range: tuple[float, float] = (1.8, 4.8) - optimal_flow_delta: float = 30.0 # Slightly higher for larger systems + typical_cop_range: tuple[float, float] = (2.68, 4.65) # the published COPs at W35 + optimal_flow_delta: float = 30.0 cop_curve: dict[float, float] = None - supports_aux_heating: bool = True # Larger immersion heaters + # NO IMMERSION HEATER - the F2040 is an outdoor monobloc; its technical-specifications table has + # no immersion-heater row, and electric addition belongs to the paired indoor module (VVM/SMO). + # ErP declaration, F2040-8: "Tbiv Bivalent temperature -9 C", "TOL Min. outdoor air + # temperature -10 C", "Psup Rated heat output 1.1 kW", "Pdh Tj = biv 6.6 kW". + # Below -9 C this machine is DESIGNED to need supplementary heat. + bivalent_temp_c: float = -9.0 + supplementary_heat_kw: float = 1.1 # ErP: "Psup Rated heat output 1.1 kW" + # Pdesignh at the EN 14825 COLD climate, 35 C application (spec sheet): 9.0 kW - the harness sizes + # houses at the cold design temperature. The AVERAGE-climate declaration is carried separately + # below; Tbiv and Psup above belong to IT, so the cold Pdesignh must not be spliced onto them. + design_heat_load_kw: float = 9.0 + design_heat_load_average_kw: float = 8.2 # spec sheet, average/35 + immersion_heater_kw: float = 0.0 + # The published operating floor (module constant above, manual p.65). Below it the plant + # model makes NO compressor heat: 28% of a real Kiruna January is below this line. + min_operating_outdoor_c: float | None = MIN_AIR_TEMP_C + supports_aux_heating: bool = False supports_modulation: bool = True supports_weather_compensation: bool = True - max_flow_temp: float = 63.0 - min_flow_temp: float = 20.0 - - min_runtime_minutes: int = 35 - min_rest_minutes: int = 12 + max_flow_temp: float = 58.0 # "Min. / Max. HM temp continuous operation: 25 / 58 C" + min_flow_temp: float = 25.0 def __post_init__(self): - """Initialize COP curve - slightly lower than F750 (larger unit).""" - self.cop_curve = { - 7: 4.8, - 5: 4.3, - 0: 3.8, - -5: 3.3, - -10: 2.8, - -15: 2.5, - -20: 2.1, - -25: 1.9, - -30: 1.7, - } + """The outdoor-keyed COP curve, and for THIS machine it is a real measurement. - def calculate_optimal_flow_temp( - self, outdoor_temp: float, indoor_target: float, heat_demand_kw: float - ) -> float: - """Calculate optimal flow temp for F2040.""" - heat_loss_coefficient = 250.0 # W/°C large/poorly insulated house - temp_diff = indoor_target - outdoor_temp - - flow_from_formula = ( - KUEHNE_COEFFICIENT - * (heat_loss_coefficient / WATTS_PER_KILOWATT * temp_diff) ** KUEHNE_POWER - + indoor_target - ) - flow_from_efficiency = outdoor_temp + self.optimal_flow_delta + The F2040's heat source IS the outdoor air, so its COP genuinely is a function of outdoor + temperature - unlike the four other profiles in this package, which shipped outdoor-keyed + curves for machines that breathe 20 C house air or 0 C brine. - optimal = min(flow_from_formula, flow_from_efficiency + 4.0) - return max(self.min_flow_temp, min(optimal, self.max_flow_temp)) + These are the datasheet's own W35 COPs. Below -7 C NIBE tabulates nothing, so the curve + stops where the evidence stops. + """ + self.cop_curve = { + int(point.source_temp_c): point.cop + for point in self.datasheet_points + if point.flow_temp_c == 35.0 + } def validate_power_consumption( self, current_power_kw: float, outdoor_temp: float, flow_temp: float ) -> ValidationResult: - """Validate F2040 power consumption.""" - cop = self.get_cop_at_temperature(outdoor_temp) - indoor_target = 21.0 - temp_diff = indoor_target - outdoor_temp - heat_loss_coefficient = 250.0 # Large house - expected_heat_demand_kw = heat_loss_coefficient * temp_diff / 1000 - expected_power_kw = expected_heat_demand_kw / cop - - if current_power_kw > self.typical_electrical_range_kw[1]: + """Validate F2040 power consumption against the published electrical input.""" + max_electrical = self.typical_electrical_range_kw[1] + + if current_power_kw > max_electrical: return ValidationResult( valid=False, severity="warning", - message=f"Very high power: {current_power_kw:.1f}kW", + message=( + f"Power {current_power_kw:.1f} kW exceeds the F2040-8's published input " + f"({max_electrical:.2f} kW at -7/35)" + ), suggestions=[ - "F2040 running at high load - check house insulation", - "Auxiliary heating likely active", - "Consider insulation upgrades to reduce demand", - f"At {outdoor_temp:.1f}°C, this is extreme", + "Electric addition in the indoor module is probably running", + "The F2040 itself has no immersion heater", + f"At {outdoor_temp:.1f} C with {flow_temp:.1f} C flow, check the curve", ], ) return ValidationResult( valid=True, severity="info", - message=f"Power consumption OK: {current_power_kw:.1f}kW (COP {cop:.1f})", + message=f"Power consumption OK: {current_power_kw:.1f} kW", suggestions=[], ) diff --git a/custom_components/effektguard/models/nibe/f730.py b/custom_components/effektguard/models/nibe/f730.py index 302c37e5..9ada3765 100644 --- a/custom_components/effektguard/models/nibe/f730.py +++ b/custom_components/effektguard/models/nibe/f730.py @@ -5,10 +5,44 @@ from dataclasses import dataclass -from ...const import KUEHNE_COEFFICIENT, KUEHNE_POWER, WATTS_PER_KILOWATT -from ..base import HeatPumpProfile, ValidationResult +from ..base import HeatPumpProfile, RatingPoint, ValidationResult, seasonal_cop_proxy from ..registry import HeatPumpModelRegistry +# NIBE F730 product data sheet, "Output data according to EN 14511". VERBATIM. +# The only three performance figures NIBE publishes for this machine. +F730_DATASHEET = ( + RatingPoint( + "A20(12)W35, exhaust air flow 90 m3/h (25 l/s) min compressor frequency", + source_temp_c=20.0, + flow_temp_c=35.0, + heat_output_kw=1.27, + cop=4.79, + airflow_m3h=90.0, + ), + RatingPoint( + "A20(12)W35, exhaust air flow 252 m3/h (70 l/s) min compressor frequency", + source_temp_c=20.0, + flow_temp_c=35.0, + heat_output_kw=1.53, + cop=5.32, + airflow_m3h=252.0, + ), + RatingPoint( + "A20(12)W45, exhaust air flow 252 m3/h (70 l/s) max compressor frequency", + source_temp_c=20.0, + flow_temp_c=45.0, + heat_output_kw=5.35, + cop=2.43, + airflow_m3h=252.0, + ), +) +F730_SOURCE = ( + "NIBE F730 product data sheet 639853 CIL EN 1904-1, 'Output data according to EN 14511'. " + "https://assetstore.nibe.se/hcms/v2.3/entity/document/22472/storage/MDIyNDcyLzAvbWFzdGVy " + "(NOTE: the 1x230 V variant, IHB EN 2003-3/531384, publishes DIFFERENT points and a 3.5 kW " + "immersion heater. This profile is the 3x400 V machine.)" +) + @HeatPumpModelRegistry.register("nibe_f730") @dataclass @@ -24,12 +58,25 @@ class NibeF730Profile(HeatPumpProfile): manufacturer: str = "NIBE" model_type: str = "F-series ASHP" - rated_power_kw: tuple[float, float] = (1.5, 6.0) + # SOURCED: F730 Installer Manual, menu 4.9.3 "start addition" factory default -700, + # same additive-heat control as the F750. + aux_start_dm: float = -700.0 + + datasheet_points: tuple[RatingPoint, ...] = F730_DATASHEET + datasheet_source: str = F730_SOURCE + + design_heat_load_kw: float = 5.0 # Pdesignh - datasheet "Nominal heating output (Pdesign) kW 5" + immersion_heater_kw: float = 3.5 # datasheet: "6.5 (3.5) kW" - max 6.5, delivery setting 3.5 + heating_capacity_range_kw: tuple[float, float] = ( + 1.27, + 5.35, + ) # the max-compressor-frequency point IS the maximum + rated_power_kw: tuple[float, float] = (1.27, 5.35) # PH min..max, EN 14511 typical_electrical_range_kw: tuple[float, float] = (1.0, 4.5) modulation_range: tuple[int, int] = (70, 120) modulation_type: str = "inverter" - typical_cop_range: tuple[float, float] = (2.0, 5.0) + typical_cop_range: tuple[float, float] = (2.43, 5.32) # the published COPs optimal_flow_delta: float = 27.0 cop_curve: dict[float, float] = None @@ -39,9 +86,6 @@ class NibeF730Profile(HeatPumpProfile): max_flow_temp: float = 58.0 min_flow_temp: float = 20.0 - min_runtime_minutes: int = 30 - min_rest_minutes: int = 10 - # Exhaust air heat pump features # F730 is an EAHP - supports airflow optimization for heat extraction supports_exhaust_airflow: bool = True @@ -54,36 +98,8 @@ class NibeF730Profile(HeatPumpProfile): # an indirect approximation - adequate for relative decisions, NOT # validated for absolute energy/savings claims. def __post_init__(self): - """Initialize COP curve.""" - # Same curve as F750 (same technology, different size) - self.cop_curve = { - 7: 5.0, - 5: 4.5, - 0: 4.0, - -5: 3.5, - -10: 3.0, - -15: 2.7, - -20: 2.3, - -25: 2.0, - -30: 1.8, - } - - def calculate_optimal_flow_temp( - self, outdoor_temp: float, indoor_target: float, heat_demand_kw: float - ) -> float: - """Calculate optimal flow temp for F730.""" - heat_loss_coefficient = 150.0 # W/°C smaller house - temp_diff = indoor_target - outdoor_temp - - flow_from_formula = ( - KUEHNE_COEFFICIENT - * (heat_loss_coefficient / WATTS_PER_KILOWATT * temp_diff) ** KUEHNE_POWER - + indoor_target - ) - flow_from_efficiency = outdoor_temp + self.optimal_flow_delta - - optimal = min(flow_from_formula, flow_from_efficiency + 3.0) - return max(self.min_flow_temp, min(optimal, self.max_flow_temp)) + """Initialize display-only COP proxy. Nothing computes from it; see f750.py __post_init__.""" + self.cop_curve = seasonal_cop_proxy(self.datasheet_points) def validate_power_consumption( self, current_power_kw: float, outdoor_temp: float, flow_temp: float diff --git a/custom_components/effektguard/models/nibe/f750.py b/custom_components/effektguard/models/nibe/f750.py index f8344520..ea85e471 100644 --- a/custom_components/effektguard/models/nibe/f750.py +++ b/custom_components/effektguard/models/nibe/f750.py @@ -1,38 +1,60 @@ """NIBE F750 heat pump profile. -8kW ASHP - Most common model for 100-150m² houses in Sweden. -Based on NIBE official specifications and Swedish forum validation. +EXHAUST-AIR heat pump. Its heat source is the house's own ventilation air, not the outdoor air, and +its output is bounded by the airflow it breathes - it is not an ASHP and cannot make 8 kW. +Performance derives from the EN 14511 rating points below; see RatingPoint in models/base.py. """ -from dataclasses import dataclass +from dataclasses import dataclass, field -from ...const import KUEHNE_COEFFICIENT, KUEHNE_POWER, WATTS_PER_KILOWATT -from ..base import HeatPumpProfile, ValidationResult +from ..base import HeatPumpProfile, RatingPoint, ValidationResult, seasonal_cop_proxy from ..registry import HeatPumpModelRegistry +from ...const import DM_THRESHOLD_AUX_LIMIT + +# NIBE F750, "Output data according to EN 14 511", part no. 066 063 / 066 061. VERBATIM. +# The only three performance figures NIBE publishes for this machine. +F750_DATASHEET = ( + RatingPoint( + "A20(12)W35, exhaust air flow 108 m3/h (30 l/s) min compressor frequency", + source_temp_c=20.0, + flow_temp_c=35.0, + heat_output_kw=1.144, + cop=4.20, + airflow_m3h=108.0, + ), + RatingPoint( + "A20(12)W35, exhaust air flow 252 m3/h (70 l/s) min compressor frequency", + source_temp_c=20.0, + flow_temp_c=35.0, + heat_output_kw=1.498, + cop=4.72, + airflow_m3h=252.0, + ), + RatingPoint( + "A20(12)W45, exhaust air flow 252 m3/h (70 l/s) max compressor frequency", + source_temp_c=20.0, + flow_temp_c=45.0, + heat_output_kw=4.994, + cop=2.43, + airflow_m3h=252.0, + ), +) +F750_SOURCE = ( + "NIBE F750 product data sheet, 'Output data according to EN 14 511', part no. 066 063/066 061. " + "https://www.teplounion.com/doc/NIBE-F750-information.pdf" +) @HeatPumpModelRegistry.register("nibe_f750") @dataclass class NibeF750Profile(HeatPumpProfile): - """NIBE F750 8kW Air Source Heat Pump. + """NIBE F750 EXHAUST-AIR heat pump. - **Target Market**: 100-150m² standard insulation houses - **Typical Application**: Single-family homes, floor heating + radiators - **Electrical**: 3-phase 16A (11kW) or 3-phase 20A (13.8kW) - **Heating Medium**: Optimized for UFH (25-35°C flow), OK for radiators (45-55°C) + NIBE publishes three EN 14511 points (F750_DATASHEET) and no others. Maximum specified heating + output is 4.994 kW; the heat source is 20 C extract air (A20(12)), so outdoor air never touches + the evaporator. Everything the simulator believes derives from those points and nothing else. - **Power Characteristics**: - - Rated: 8kW heat at 7°C outdoor, 45°C flow - - Modulation: 1.2-6.5kW electrical (3-phase) - - Typical: 1.5-2.5kW electrical for well-matched system - - **COP Performance**: - - Best: 5.0 at 7°C outdoor (mild Swedish winter) - - Good: 4.0 at 0°C (Malmö/Gothenburg average) - - Acceptable: 3.0 at -10°C (Stockholm cold spell) - - Survival: 2.0 at -25°C (Kiruna extreme) - - **Source**: NIBE F750 datasheet, Swedish NIBE forum validation + Pdesign 5 kW. Immersion heater 0.5-6.5 kW. SCOP(EN 14825) 4.5/4.7 average/cold at 35 C. """ # Identity @@ -40,14 +62,23 @@ class NibeF750Profile(HeatPumpProfile): manufacturer: str = "NIBE" model_type: str = "F-series ASHP" - # Power characteristics - rated_power_kw: tuple[float, float] = (2.0, 8.0) # Heat output range - typical_electrical_range_kw: tuple[float, float] = (1.2, 6.5) # 3-phase + # THE DATASHEET. Everything below that is a number is derived from it in __post_init__. + datasheet_points: tuple[RatingPoint, ...] = F750_DATASHEET + datasheet_source: str = F750_SOURCE + + # Power characteristics - DERIVED from the rating points, not restated. + design_heat_load_kw: float = 5.0 # Pdesignh - datasheet "Nominal heating output (Pdesign) 5 kW" + immersion_heater_kw: float = 3.5 # datasheet: "6.5 (3.5) kW" - max 6.5, delivery setting 3.5 + heating_capacity_range_kw: tuple[float, float] = ( + 1.144, + 4.994, + ) # the max-compressor-frequency point IS the maximum + rated_power_kw: tuple[float, float] = (1.144, 4.994) # PH min..max, EN 14511 + typical_electrical_range_kw: tuple[float, float] = (0.27, 2.06) # PH/COP at those same points modulation_range: tuple[int, int] = (70, 120) # Hz (inverter compressor) modulation_type: str = "inverter" - # Efficiency - Real-world COP curve for F750 - typical_cop_range: tuple[float, float] = (2.0, 5.0) + typical_cop_range: tuple[float, float] = (2.43, 4.72) # the published COPs, min..max optimal_flow_delta: float = 27.0 # SPF 4.0+ target (outdoor + 27°C) cop_curve: dict[float, float] = None # Set in __post_init__ @@ -58,16 +89,12 @@ class NibeF750Profile(HeatPumpProfile): max_flow_temp: float = 60.0 min_flow_temp: float = 20.0 - # Swedish optimization parameters (validated in Swedish NIBE forum) - dm_threshold_start: float = -60 # Standard NIBE compressor start - dm_threshold_extended: float = -240 # Extended runs (custom stevedvo setting) - dm_threshold_warning: float = -400 # Approaching thermal debt danger - dm_threshold_critical: float = -500 # Emergency recovery needed - dm_threshold_aux_swedish: float = -1500 # Swedish aux delay optimization - - # Cycling protection (prevents compressor wear) - min_runtime_minutes: int = 30 # NIBE recommendation - min_rest_minutes: int = 10 # Minimum off time between cycles + # The simulator reads this so the plant model tracks what the integration believes. + # It cannot do that while the profile restates the number, so it references it (F-076). + dm_threshold_aux_swedish: float = DM_THRESHOLD_AUX_LIMIT + # SOURCED: F750 Installer Manual IHB GB 1301-1 (231236), menu 4.9.3 "start addition", + # setting range -2000..-30, factory default -700. The pump's own elpatron fires here. + aux_start_dm: float = -700.0 # Exhaust air heat pump features # F750 is an EAHP - supports airflow optimization for heat extraction @@ -75,64 +102,15 @@ class NibeF750Profile(HeatPumpProfile): standard_airflow_m3h: float = 150.0 # Normal ventilation rate enhanced_airflow_m3h: float = 252.0 # Maximum ventilation rate - # MODELING LIMITATION (review 2026-07): this is an exhaust-air heat pump; - # its COP depends primarily on exhaust-air (source) and flow (sink) - # temperatures, not outdoor temperature. The outdoor-keyed curve below is - # an indirect approximation - adequate for relative decisions, NOT - # validated for absolute energy/savings claims. def __post_init__(self): - """Initialize COP curve after dataclass creation.""" - # Real-world F750 COP curve (tested and validated) - self.cop_curve = { - 7: 5.0, # Rated conditions (mild winter) - 5: 4.5, # Mild - 0: 4.0, # Average Swedish winter (Malmö, Gothenburg) - -5: 3.5, # Common cold (Stockholm, Uppsala) - -10: 3.0, # Cold winter (most of Sweden) - -15: 2.7, # Design temperature (Northern Sweden) - -20: 2.3, # Very cold (Luleå, Umeå) - -25: 2.0, # Extreme cold (Kiruna) - -30: 1.8, # Survival mode (rare extreme) - } - - def calculate_optimal_flow_temp( - self, - outdoor_temp: float, - indoor_target: float, - heat_demand_kw: float, - ) -> float: - """Calculate optimal flow temperature for F750. - - Uses André Kühne's universal formula validated across manufacturers - combined with F750-specific efficiency targets. - - Args: - outdoor_temp: Current outdoor temperature (°C) - indoor_target: Target indoor temperature (°C) - heat_demand_kw: Required heat output (kW) + """DISPLAY-ONLY seasonal COP proxy for the dashboard. Nothing computes from it. - Returns: - Optimal flow temperature (°C) for maximum efficiency + The simulator takes COP from `datasheet_points` via the exergy model + (scripts/simulation/sim_harness.py), which uses the SOURCE temperature (a constant 20 C for + this machine) and flow temperature, never the weather. The proxy is anchored on the two + published endpoints (COP 4.72 min-freq/W35, 2.43 max-freq/W45). """ - # André Kühne formula (validated universal formula) - # Source: Mathematical_Enhancement_Summary.md - heat_loss_coefficient = 180.0 # W/°C typical Swedish house - temp_diff = indoor_target - outdoor_temp - - flow_from_formula = ( - KUEHNE_COEFFICIENT - * (heat_loss_coefficient / WATTS_PER_KILOWATT * temp_diff) ** KUEHNE_POWER - + indoor_target - ) - - # F750 efficiency target: outdoor + 27°C for SPF 4.0+ - flow_from_efficiency = outdoor_temp + self.optimal_flow_delta - - # Return lower value (more efficient while meeting demand) - optimal = min(flow_from_formula, flow_from_efficiency + 3.0) - - # Clamp to F750 limits - return max(self.min_flow_temp, min(optimal, self.max_flow_temp)) + self.cop_curve = seasonal_cop_proxy(self.datasheet_points) def validate_power_consumption( self, diff --git a/custom_components/effektguard/models/nibe/s1155.py b/custom_components/effektguard/models/nibe/s1155.py index 2686afa8..28dafcdc 100644 --- a/custom_components/effektguard/models/nibe/s1155.py +++ b/custom_components/effektguard/models/nibe/s1155.py @@ -9,10 +9,52 @@ from dataclasses import dataclass -from ...const import KUEHNE_COEFFICIENT, KUEHNE_POWER, WATTS_PER_KILOWATT -from ..base import HeatPumpProfile, ValidationResult +from ..base import HeatPumpProfile, RatingPoint, ValidationResult, seasonal_cop_proxy from ..registry import HeatPumpModelRegistry +# S1155-12. EN 14511 rating points, VERBATIM. +# +# Keyed on INCOMING BRINE temperature, not outdoor air (datasheet capacity chart x-axis is +# "Incoming brine temp, C"). All four points are at NOMINAL (50 Hz) frequency; NIBE publishes no +# min-/max-frequency COP for these pumps, only the modulation envelope ("Heating capacity (PH)"), +# so load dependence of efficiency is NOT measurable here - see HouseConfig.exergy_efficiency. +S1155_12_DATASHEET = ( + RatingPoint( + "0/35 nominal (50 Hz), incoming brine 0 C", + source_temp_c=0.0, + flow_temp_c=35.0, + heat_output_kw=5.06, + cop=4.87, + ), + RatingPoint( + "0/45 nominal (50 Hz), incoming brine 0 C", + source_temp_c=0.0, + flow_temp_c=45.0, + heat_output_kw=4.78, + cop=3.75, + ), + RatingPoint( + "10/35 nominal (50 Hz), incoming brine 10 C", + source_temp_c=10.0, + flow_temp_c=35.0, + heat_output_kw=6.33, + cop=6.12, + ), + RatingPoint( + "10/45 nominal (50 Hz), incoming brine 10 C", + source_temp_c=10.0, + flow_temp_c=45.0, + heat_output_kw=5.98, + cop=4.59, + ), +) +S1155_12_SOURCE = ( + "NIBE S1155 installer manual, Output data according to EN 14511, " + "S1155_12 column. https://installer.nibe.eu/ " + "(F1155: IHB EN 2008-5/331379 p.69; S1155: IHB EN 2001-1/531210 p.70. The two " + "publish IDENTICAL EN 14511 data at every size - same platform.)" +) + @HeatPumpModelRegistry.register("nibe_s1155") @dataclass @@ -37,25 +79,39 @@ class NibeS1155Profile(HeatPumpProfile): manufacturer: str = "NIBE" model_type: str = "S-series GSHP" + # SOURCED: S1155 register 40680 "start diff additional heat" factory default 400, applied + # below the compressor start (-60): the controller engages additive heat at about -460. + aux_start_dm: float = -460.0 + # Mid-range variant (3-12 kW) - VERIFIED from NIBE website - rated_power_kw: tuple[float, float] = (3.0, 12.0) # Heat output - typical_electrical_range_kw: tuple[float, float] = (0.6, 2.5) # Estimated from SCOP ~5.0 + datasheet_points: tuple[RatingPoint, ...] = S1155_12_DATASHEET + datasheet_source: str = S1155_12_SOURCE + design_heat_load_kw: float = ( + 12.0 # Pdesignh - installer manual, S1155-12: "Rated heating output (Pdesignh) 12 kW" + ) + immersion_heater_kw: float = ( + 7.0 # datasheet: 7 kW integrated electric heater, seven automatic steps + ) + heating_capacity_range_kw: tuple[float, float] = ( + 3.0, + 12.0, + ) # datasheet "Heating capacity (PH)" + + rated_power_kw: tuple[float, float] = (3.0, 12.0) # the PH modulation envelope, EN 14511 + typical_electrical_range_kw: tuple[float, float] = (1.04, 2.5) # PE at the rating points modulation_range: tuple[int, int] = (70, 120) modulation_type: str = "inverter" - typical_cop_range: tuple[float, float] = (3.5, 5.5) # Higher than ASHP! + typical_cop_range: tuple[float, float] = (3.75, 6.12) # published COPs, 0/45 .. 10/35 optimal_flow_delta: float = 25.0 # Can run lower flow temps cop_curve: dict[float, float] = None supports_aux_heating: bool = True supports_modulation: bool = True supports_weather_compensation: bool = True - max_flow_temp: float = 58.0 + max_flow_temp: float = 65.0 # "compressor provides a supply temperature up to 65 C" min_flow_temp: float = 18.0 # Can go lower with ground source - min_runtime_minutes: int = 30 - min_rest_minutes: int = 10 - def __post_init__(self): """Initialize COP curve - GSHP has much better COP than ASHP. @@ -65,36 +121,10 @@ def __post_init__(self): VERIFIED: S1155 has high seasonal performance factor (SCOP). Source: NIBE official website """ - self.cop_curve = { - 7: 5.5, # Excellent in mild weather - 5: 5.3, - 0: 5.0, # Still excellent in winter - -5: 4.8, # Ground temp stable - -10: 4.5, # Ground temp stable - -15: 4.3, - -20: 4.0, # Still good in extreme cold - -25: 3.8, - -30: 3.5, # Much better than ASHP at extreme temps - } - - def calculate_optimal_flow_temp( - self, outdoor_temp: float, indoor_target: float, heat_demand_kw: float - ) -> float: - """Calculate optimal flow temp for S1155 GSHP.""" - heat_loss_coefficient = 180.0 # W/°C typical house - temp_diff = indoor_target - outdoor_temp - - flow_from_formula = ( - KUEHNE_COEFFICIENT - * (heat_loss_coefficient / WATTS_PER_KILOWATT * temp_diff) ** KUEHNE_POWER - + indoor_target - ) - - # GSHP can run lower flow temps for better COP - flow_from_efficiency = outdoor_temp + self.optimal_flow_delta - - optimal = min(flow_from_formula, flow_from_efficiency + 3.0) - return max(self.min_flow_temp, min(optimal, self.max_flow_temp)) + # DISPLAY PROXY ONLY. Nothing computes from it: COP is a function of brine + flow + # temperature, not the weather, and the simulator takes it from `datasheet_points`. This is + # a dashboard seasonal curve anchored on the two published W35/W45 COPs at 0 C brine. + self.cop_curve = seasonal_cop_proxy(self.datasheet_points, source_temp_c=0.0) def validate_power_consumption( self, current_power_kw: float, outdoor_temp: float, flow_temp: float diff --git a/custom_components/effektguard/models/types.py b/custom_components/effektguard/models/types.py index 04ff9c76..5b079b33 100644 --- a/custom_components/effektguard/models/types.py +++ b/custom_components/effektguard/models/types.py @@ -86,3 +86,43 @@ class AdapterConfigDict(TypedDict, total=False): return_temp_entity: str dhw_temp_entity: str dhw_charging_temp_entity: str + + +class DiagnosticsLayerDict(TypedDict): + """One layer's vote in the decision that was made.""" + + name: str | None + offset: float | None + weight: float | None + reason: str | None + + +class DiagnosticsDecisionDict(TypedDict, total=False): + """The offset commanded, and the votes behind it.""" + + offset: float | None + reasoning: str | None + is_emergency: bool | None + is_manual_override: bool | None + anti_windup_active: bool | None + layers: list[DiagnosticsLayerDict] + + +class DiagnosticsDict(TypedDict, total=False): + """What a bug report about this heat pump needs to contain. + + The decision, the state it was made from, the degree-minute band actually in force, and whether + the price and weather sources were even live. NOT the home's coordinates: the decision engine + holds the latitude (it is how the climate zone is detected) and this file gets pasted into + public issue trackers. The climate ZONE goes in instead - it is what the thresholds derive + from, and it identifies nobody. + """ + + error: str + config: dict[str, object] + sources: dict[str, str] + nibe: dict[str, object] + decision: DiagnosticsDecisionDict + dm_thresholds: dict[str, object] + compressor_risk: str | None + peaks: dict[str, object] diff --git a/custom_components/effektguard/optimization/adaptive_learning.py b/custom_components/effektguard/optimization/adaptive_learning.py index 84754045..7409779a 100644 --- a/custom_components/effektguard/optimization/adaptive_learning.py +++ b/custom_components/effektguard/optimization/adaptive_learning.py @@ -19,7 +19,11 @@ from homeassistant.util import dt as dt_util from ..const import ( + DEFAULT_HEAT_LOSS_COEFFICIENT, + DEFAULT_THERMAL_DECAY_RATE, LEARNING_CONFIDENCE_THRESHOLD, + LEARNING_MIN_HEATING_RATE, + LEARNING_MIN_HEATING_SAMPLES, LEARNING_MIN_OBSERVATIONS, LEARNING_OBSERVATION_WINDOW, UFH_CONCRETE_PREDICTION_HORIZON, @@ -483,10 +487,18 @@ def _calculate_confidence(self) -> float: rate = obs.temp_change / obs.time_delta_hours heating_rates.append(rate) - if len(heating_rates) > 10: - consistency = 1.0 - min(np.std(heating_rates) / max(np.mean(heating_rates), 0.1), 1.0) + # The std/mean consistency ratio LIES when there is no signal: an identical (flatlined) + # reading collapses std to 0 and scores perfect consistency 1.0, above a house genuinely + # heating (F-132). So the weak cases are answered before the ratio is taken - both give + # ZERO, the honest answer to "how well do we know this building". + mean_rate = float(np.mean(heating_rates)) if heating_rates else 0.0 + + if len(heating_rates) <= LEARNING_MIN_HEATING_SAMPLES: + consistency = 0.0 # too little evidence is not half the evidence + elif mean_rate < LEARNING_MIN_HEATING_RATE: + consistency = 0.0 # the house moved less than one sensor tick per hour while heating else: - consistency = 0.5 + consistency = 1.0 - min(float(np.std(heating_rates)) / mean_rate, 1.0) # Time span (prefer observations over longer period) if len(self.observations) > 1: @@ -583,18 +595,25 @@ def calculate_preheating_target( Target indoor temperature for pre-heating phase (°C) References: - - Forum_Summary.md: stevedvo's thermal debt case study - - Enhancement_Proposals.md: Thermal model mathematics + docs/research/01_degree_minutes.md - the forum case studies this method's tuning + descends from are marked UNSOURCED there (anecdote, not documents in this repository). """ - # Get learned parameters or use defaults + # THE HEAT-LOSS COEFFICIENT IS NEVER TAKEN FROM LEARNING: the learning estimator produces a + # RELATIVE cooling index, not a physical W/°C value, and this is the control path. The old + # code carried that index and a literal 180.0 W/°C on the two branches of one variable (two + # units), both fed into `/ 1000.0` as if watts per kelvin. It never fired only because the + # gate is dead (should_use_learned_parameters reads a "confidence" key never stored), so + # repairing that gate would have silently armed the unit error. See + # test_learning_can_actually_learn.py; enabling learning is the owner's call (F-132b), making + # it SAFE to enable is this. params = self.update_learned_parameters() + + heat_loss_coef = DEFAULT_HEAT_LOSS_COEFFICIENT + if params and self.should_use_learned_parameters(): - heat_loss_coef = params.heat_loss_coefficient decay_rate = params.thermal_decay_rate else: - # Conservative defaults - heat_loss_coef = 180.0 # W/°C typical house - decay_rate = 0.15 # °C per hour per °C difference + decay_rate = DEFAULT_THERMAL_DECAY_RATE # Account for thermal decay during forecast period temp_diff = current_temp - forecast_min_temp diff --git a/custom_components/effektguard/optimization/airflow_optimizer.py b/custom_components/effektguard/optimization/airflow_optimizer.py index 14d1fcae..c2710f90 100644 --- a/custom_components/effektguard/optimization/airflow_optimizer.py +++ b/custom_components/effektguard/optimization/airflow_optimizer.py @@ -5,18 +5,27 @@ Calculates optimal airflow rates for exhaust air heat pump systems. Physics basis: -- Heat extraction: Q = ṁ × cp × ΔT -- COP relationship: COP ∝ T_evap / (T_cond - T_evap) -- Energy balance: Net gain = Heat gain - Ventilation penalty - -When Enhanced Airflow Helps: -| Outdoor °C | Min Compressor % | Expected Gain | -|------------|-----------------|---------------| -| +10 | 50% | +1.3 kW | -| 0 | 50% | +0.9 kW | -| -5 | 62% | +0.7 kW | -| -10 | 75% | +0.4 kW | -| < -15 | Don't enhance | Negative | +- Heat extraction: Q = ṁ × cp × ΔT +- Ventilation cost: the building must reheat every extra cubic metre from outdoor to indoor +- Energy balance: Q_cond = P_el + Q_evap, so d(Q_cond) = d(Q_evap) = P_el × d(COP) at constant + electrical input. The extra extraction and the "COP improvement" are the same + joules; only one of them may be counted. + +Net gain = (extra heat extracted) - (extra air to reheat), and it turns negative below an outdoor +temperature of (indoor - AIRFLOW_EVAPORATOR_TEMP_DROP): + +| Outdoor °C | Net gain | +|------------|----------| +| +15 | +0.22 kW | +| +12 | +0.12 kW | +| +9 | 0.00 | <- break-even +| +5 | -0.14 kW | +| 0 | -0.29 kW | +| -10 | -0.65 kW | + +Enhancement therefore pays only in mild weather, and is a net thermal LOSS across the Swedish +heating season. The evaporator recovers only AIRFLOW_EVAPORATOR_TEMP_DROP from the extra air while +the building has to warm all of it the whole way. Author: Original work License: MIT @@ -29,13 +38,12 @@ from enum import Enum from typing import TYPE_CHECKING, NamedTuple +from homeassistant.util import dt as dt_util + from ..const import ( AIRFLOW_AIR_DENSITY, - AIRFLOW_BASE_COP, AIRFLOW_COMPRESSOR_BASE_THRESHOLD, - AIRFLOW_COMPRESSOR_INPUT_KW, AIRFLOW_COMPRESSOR_SLOPE, - AIRFLOW_COP_IMPROVEMENT_FACTOR, AIRFLOW_DEFAULT_ENHANCED, AIRFLOW_DEFAULT_STANDARD, AIRFLOW_DEFICIT_LARGE_THRESHOLD, @@ -99,9 +107,15 @@ class FlowDecision: timestamp: datetime | None = None def __post_init__(self): - """Set timestamp if not provided.""" + """Set timestamp if not provided. + + `dt_util.utcnow()`, never `datetime.now()`. Home Assistant works in aware UTC; a naive + datetime cannot be compared with an aware one at all (TypeError), and if it could, the box + runs UTC while `datetime.now()` returns local time - so the arithmetic would be wrong by the + UTC offset, which in a Swedish summer is two hours. + """ if self.timestamp is None: - self.timestamp = datetime.now() + self.timestamp = dt_util.utcnow() @property def should_enhance(self) -> bool: @@ -155,60 +169,45 @@ def evaporator_heat_extraction( return m_dot * AIRFLOW_SPECIFIC_HEAT * temp_drop -def estimate_cop_improvement(base_cop: float = AIRFLOW_BASE_COP) -> float: - """Estimate COP with enhanced airflow. - - More air → warmer evaporator → better COP. - Empirically ~20% improvement at enhanced flow. - - Args: - base_cop: Base COP without enhancement - - Returns: - Enhanced COP - """ - return base_cop * AIRFLOW_COP_IMPROVEMENT_FACTOR - - def calculate_net_thermal_gain( flow_standard: float, flow_enhanced: float, temp_indoor: float, temp_outdoor: float, - compressor_input_kw: float = AIRFLOW_COMPRESSOR_INPUT_KW, ) -> float: """Calculate net thermal gain from enhanced airflow (kW). - Net Benefit = (Extra heat extracted) + (COP improvement) - (Ventilation penalty) + Net gain = (extra heat extracted at the evaporator) - (extra air the building must reheat). + There is no third term: "improved COP" is the same joules as the extra evaporator heat, not a + separate benefit. In steady state Q_cond = P_el + Q_evap, so at constant electrical input + d(Q_cond) = d(Q_evap) = P_el * d(COP) - an identity; adding P_el * d(COP) counts the heat twice. + NIBE's S735 EN 14511 table confirms it: over the 90 -> 252 m³/h step the heat-output rise, + dQ_evap and P_el*dCOP agree within 0.02 kW. See docs/research/04_exhaust_air_recovery.md. + + Consequence: enhancement pays only above an outdoor temperature of + (indoor - AIRFLOW_EVAPORATOR_TEMP_DROP), around +9 °C - the evaporator recovers only + AIRFLOW_EVAPORATOR_TEMP_DROP from the extra air while the building reheats every cubic metre of + it from outdoor to indoor. Below break-even (the whole Swedish heating season) this returns + negative, a net thermal LOSS. Args: flow_standard: Standard airflow rate in m³/h flow_enhanced: Enhanced airflow rate in m³/h temp_indoor: Indoor temperature in °C temp_outdoor: Outdoor temperature in °C - compressor_input_kw: Compressor electrical input in kW Returns: Net thermal gain in kW (positive = beneficial to enhance) """ - # Additional heat extraction q_extract_std = evaporator_heat_extraction(flow_standard) q_extract_enh = evaporator_heat_extraction(flow_enhanced) delta_extraction = q_extract_enh - q_extract_std - # COP improvement benefit - cop_std = AIRFLOW_BASE_COP - cop_enh = estimate_cop_improvement(cop_std) - heat_output_std = compressor_input_kw * cop_std - heat_output_enh = compressor_input_kw * cop_enh - delta_cop_benefit = heat_output_enh - heat_output_std - - # Ventilation penalty loss_std = ventilation_heat_loss(flow_standard, temp_indoor, temp_outdoor) loss_enh = ventilation_heat_loss(flow_enhanced, temp_indoor, temp_outdoor) delta_penalty = loss_enh - loss_std - return delta_extraction + delta_cop_benefit - delta_penalty + return delta_extraction - delta_penalty def minimum_compressor_threshold(temp_outdoor: float) -> float: @@ -410,8 +409,6 @@ class AirflowOptimizer: flow_standard: Standard airflow rate in m³/h flow_enhanced: Enhanced airflow rate in m³/h current_decision: Most recent flow decision - enhancement_active: Whether enhanced mode is currently active - enhancement_end_time: When current enhancement should end """ def __init__( @@ -428,9 +425,6 @@ def __init__( self.flow_standard = flow_standard self.flow_enhanced = flow_enhanced self.current_decision: FlowDecision | None = None - self.enhancement_active = False - self.enhancement_end_time: datetime | None = None - self._decision_history: list[FlowDecision] = [] def evaluate( self, @@ -465,11 +459,6 @@ def evaluate( # Update state self.current_decision = decision - # Maintain history (keep last 24 hours worth at 5-min intervals = 288 entries) - self._decision_history.append(decision) - if len(self._decision_history) > 288: - self._decision_history = self._decision_history[-288:] - return decision def evaluate_from_nibe( @@ -511,32 +500,3 @@ def evaluate_from_nibe( compressor_pct=compressor_pct, trend_indoor=trend_indoor, ) - - def get_enhancement_stats(self) -> dict: - """Get statistics about enhancement recommendations. - - Returns: - Dictionary with enhancement statistics - """ - if not self._decision_history: - return { - "total_decisions": 0, - "enhance_recommendations": 0, - "enhance_percentage": 0.0, - "average_gain_kw": 0.0, - } - - enhance_decisions = [d for d in self._decision_history if d.should_enhance] - enhance_count = len(enhance_decisions) - total = len(self._decision_history) - - return { - "total_decisions": total, - "enhance_recommendations": enhance_count, - "enhance_percentage": (enhance_count / total) * 100 if total > 0 else 0.0, - "average_gain_kw": ( - sum(d.expected_gain_kw for d in enhance_decisions) / enhance_count - if enhance_count > 0 - else 0.0 - ), - } diff --git a/custom_components/effektguard/optimization/billing_period.py b/custom_components/effektguard/optimization/billing_period.py new file mode 100644 index 00000000..11ce2808 --- /dev/null +++ b/custom_components/effektguard/optimization/billing_period.py @@ -0,0 +1,197 @@ +"""The billed quantity, defined once: the time-weighted mean power over a billing period. + +The owner's effect tariff measures a 15-minute period (BILLING_PERIOD_MINUTES). The coordinator and +the simulator both call this. They used to compute it separately, with different formulas, and both +were wrong on the DST fall-back - so neither could catch the other. + +Three things the arithmetic must not lose: + + * TIME-WEIGHTED, not sample-counted. HA's update cycle jitters, so the samples in a period are not + evenly spaced and their arithmetic mean is not the period's mean power. + * THE PERIOD IS ABSOLUTE. Wall-clock 02:00-03:00 happens twice on the last Sunday of October (its + four quarter-periods run twice), and PEP 495 ignores `fold` when comparing two aware datetimes + with the same tzinfo - so a local-datetime boundary check merges two separately-billable periods + into one. + * THE LABEL AND STAMP STAY LOCAL. The night discount is a wall-clock window and peaks are bucketed + by calendar month; 00:00 on 1 Nov local is 23:00 on 31 Oct in UTC. + +No Home Assistant imports, so the simulator runs this rather than a lookalike. + +tests/unit/optimization/test_one_definition_of_the_billed_quantity.py +tests/unit/coordinator/test_the_billing_period_survives_the_clocks_going_back.py +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from ..const import ( + BILLING_PERIOD_MINUTES, + MAX_BILLING_OBSERVATION_GAP_MINUTES, + PEAK_CONTROL_POWER_SOURCES, + POWER_SOURCE_EXTERNAL_METER, + POWER_SOURCE_NIBE_CURRENTS, + POWER_SOURCE_NONE, +) + +BILLING_PERIOD = timedelta(minutes=BILLING_PERIOD_MINUTES) +MAX_BILLING_OBSERVATION_GAP_SECONDS = MAX_BILLING_OBSERVATION_GAP_MINUTES * 60 +PERIODS_PER_HOUR = 60 // BILLING_PERIOD_MINUTES # 4 quarter-periods per hour + + +def _period_of_day(now: datetime) -> int: + """The billing period index (0-95) for `now` - the quarter of the day the tariff measures.""" + return now.hour * PERIODS_PER_HOUR + now.minute // BILLING_PERIOD_MINUTES + + +def _period_start(now: datetime) -> datetime: + """The local start of the 15-minute period containing `now`, minute floored to the quarter.""" + floored = (now.minute // BILLING_PERIOD_MINUTES) * BILLING_PERIOD_MINUTES + return now.replace(minute=floored, second=0, microsecond=0) + + +@dataclass(frozen=True) +class CompletedBillingPeriod: + """One whole billing period, measured. This is the thing the grid charges for.""" + + mean_power_kw: float + billing_period: int # the LOCAL quarter of the day, 0-95 - what the night discount reads + started_at: datetime # LOCAL and aware - what the calendar month is taken from + sample_sources: frozenset[str] # every source that contributed a sample to this period + + @property + def source(self) -> str: + """What this period may be recorded AS - decided by every sample, not the closing one. + + The coordinator used to stamp the period with the CURRENT cycle's source, so a period + whose middle was measured at the pump's phase currents became a billable meter period + the moment the meter answered again at the boundary. The tariff bills whole-house + grid import; a period is a meter measurement only if the meter measured all of it. + """ + if self.sample_sources == {POWER_SOURCE_EXTERNAL_METER}: + return POWER_SOURCE_EXTERNAL_METER + if self.sample_sources <= PEAK_CONTROL_POWER_SOURCES: + return POWER_SOURCE_NIBE_CURRENTS + return POWER_SOURCE_NONE + + +class BillingPeriodAccumulator: + """Accumulates power samples into completed billing periods.""" + + def __init__(self) -> None: + self._absolute_start: datetime | None = None + self._local_start: datetime | None = None + self._billing_period: int = 0 + # True when the current period began before observation did - it was never fully measured, + # so it is not a bill. Only the first period after startup can be partial. + self._partial: bool = False + self._samples: list[tuple[datetime, float]] = [] + self._sources: set[str] = set() + + def add(self, now: datetime, power_kw: float, source: str) -> CompletedBillingPeriod | None: + """Record a sample. Returns the previous period if this sample closed it. + + `now` is local and aware, as `dt_util.now()` gives it - `fold` included, which is the only + thing distinguishing the two 02:00s on the night the clocks go back. `source` is where the + reading came from; the completed period's provenance is the set of them. + """ + local_start = _period_start(now) + # Converting the local period boundary to UTC IS fold-aware, so the two 02:00 quarters + # resolve to two instants an hour apart. Comparing the local datetimes directly would not - + # see PEP 495. + absolute_start = local_start.astimezone(timezone.utc) + absolute_now = now.astimezone(timezone.utc) + + if absolute_start == self._absolute_start: + self._samples.append((absolute_now, power_kw)) + self._sources.add(source) + return None + + completed = self._close() + + # A period is partial only if the very first sample ever seen arrives after its boundary. + self._partial = self._absolute_start is None and absolute_now != absolute_start + self._absolute_start = absolute_start + self._local_start = local_start + self._billing_period = _period_of_day(now) + self._samples = [(absolute_start, power_kw)] + self._sources = {source} + return completed + + def projected_period_mean(self, now: datetime, power_kw: float) -> float: + """What this billing period's mean becomes if ``power_kw`` persists to the boundary. + + Peak PROTECTION must compare like with like: the monthly record is a period mean, + and an instantaneous reading is not. Early in the period a spike projects to almost + nothing; near the boundary the accumulated period dominates. The current cycle's + reading is not yet in the samples when the decision runs, which is why it is passed + in rather than read. + """ + local_start = _period_start(now) + absolute_start = local_start.astimezone(timezone.utc) + absolute_now = now.astimezone(timezone.utc) + + if absolute_start != self._absolute_start or not self._samples: + # A fresh or unobserved period: the only information is the draw itself. + return power_kw + + period_end = self._absolute_start + BILLING_PERIOD + previous_time, previous_power = self._samples[0] + weighted = 0.0 + for sample_time, sample_power in self._samples[1:]: + weighted += previous_power * (sample_time - previous_time).total_seconds() + previous_time = sample_time + previous_power = sample_power + weighted += previous_power * (absolute_now - previous_time).total_seconds() + weighted += power_kw * (period_end - absolute_now).total_seconds() + return weighted / (period_end - self._absolute_start).total_seconds() + + def flush(self) -> CompletedBillingPeriod | None: + """Close the period in progress and return it. + + The SIMULATOR calls this; production does not. A period cut short by a shutdown was never + measured and is not a bill. + """ + completed = self._close() + self._absolute_start = None + self._local_start = None + self._samples = [] + self._sources = set() + return completed + + def _close(self) -> CompletedBillingPeriod | None: + """The time-weighted mean of the period just ended, or None if there is nothing to bill.""" + if self._absolute_start is None or not self._samples or self._partial: + return None + + period_end = self._absolute_start + BILLING_PERIOD + previous_time, previous_power = self._samples[0] + weighted = 0.0 + longest_gap = 0.0 + for sample_time, sample_power in self._samples[1:]: + span = (sample_time - previous_time).total_seconds() + longest_gap = max(longest_gap, span) + weighted += previous_power * span + previous_time = sample_time + previous_power = sample_power + # The last reading stands until the boundary. It counts as a gap too: a meter that dies + # early in the quarter and never returns leaves the rest of it resting on one reading, which + # is as unmeasured as a hole in the middle - and that is the ORDINARY shape of a dropout. + final_span = (period_end - previous_time).total_seconds() + longest_gap = max(longest_gap, final_span) + weighted += previous_power * final_span + + # A period the meter slept through is not a measurement of it. Weighting a reading by how + # long it stood extrapolates it, which is right at the five-minute cadence and absurd across + # a blackout - it invents a peak, and the tariff defends the month's top three for weeks. + # tests/unit/coordinator/test_an_hour_the_meter_slept_through_is_not_a_bill.py + if longest_gap > MAX_BILLING_OBSERVATION_GAP_SECONDS: + return None + + return CompletedBillingPeriod( + mean_power_kw=weighted / (period_end - self._absolute_start).total_seconds(), + billing_period=self._billing_period, + started_at=self._local_start, + sample_sources=frozenset(self._sources), + ) diff --git a/custom_components/effektguard/optimization/climate_zones.py b/custom_components/effektguard/optimization/climate_zones.py index 2bb5b375..df03183f 100644 --- a/custom_components/effektguard/optimization/climate_zones.py +++ b/custom_components/effektguard/optimization/climate_zones.py @@ -25,6 +25,10 @@ from typing import Final from ..const import ( + DM_NORMAL_MIN_BUFFER, + DM_THRESHOLD_AUX_LIMIT, + DM_THRESHOLD_START, + DM_WARNING_BUFFER, CLIMATE_ZONE_EXTREME_COLD_WINTER_AVG, CLIMATE_ZONE_VERY_COLD_WINTER_AVG, CLIMATE_ZONE_COLD_WINTER_AVG, @@ -35,6 +39,47 @@ _LOGGER = logging.getLogger(__name__) +def keep_triggers_clear_of_the_compressor_band( + thresholds: dict[str, float], +) -> dict[str, float]: + """No degree-minute TRIGGER may reach into the band the compressor cycles through anyway. + + NIBE starts the compressor at DM_THRESHOLD_START (-60) and stops it at 0, so degree minutes + traverse that band on EVERY NORMAL CYCLE, in every season, on every heat pump. A threshold + inside it fires on healthy operation rather than on trouble. DM_WARNING_BUFFER keeps a margin + below the band for the ordinary undershoot that follows a compressor start. + + THE THRESHOLDS ARE BUILT IN TWO STEPS AND BOTH CAN BREACH IT, which is why this is a function + and not two lines inside one of them: + + 1. `get_expected_dm_range` shifts the zone thresholds by `temp_delta * 20` and that shift was + unbounded above. In Stockholm the warning threshold climbed to -40 at +25 C - inside the + band - and to +60 at +30 C, i.e. POSITIVE, where every possible reading is a warning. + + 2. `apply_thermal_mass_buffer` then DIVIDES by up to 1.3. Clamping only in step 1 left the + invariant true of a number production never uses: -110 / 1.3 = -85, back inside the band, + and a concrete-slab house 0.2 C under target on a +25 C July morning was commanded +4.0 C + of curve offset by the T1 recovery tier. Clamping in step 1 alone is the bug I shipped and + then wrote a test for that could not see it, because the test called step 1. + + Winter is untouched: a slab's warning threshold of -415 is far below the ceiling and `min` + leaves it exactly where it is. This is a ceiling on mild days, never a floor on cold ones. + + `normal_min` is deliberately NOT clamped. Mind the naming - it is the SHALLOW end of the + normal band (`normal_min` > `normal_max`, both negative) and it describes where degree minutes + are ALLOWED to sit, rather than triggering anything. In mild weather they really do reach 0, + because that is where NIBE stops the compressor. `critical` is the hardware auxiliary-heat + limit and is nowhere near the band. + """ + warm_ceiling = DM_THRESHOLD_START - DM_WARNING_BUFFER + + return { + **thresholds, + "normal_max": min(thresholds["normal_max"], warm_ceiling), + "warning": min(thresholds["warning"], warm_ceiling), + } + + # Climate zones focused on heating needs (coldest to mildest) HEATING_CLIMATE_ZONES: Final = { "extreme_cold": { @@ -92,9 +137,11 @@ # Zone order for detection (coldest to mildest) ZONE_ORDER: Final = ["extreme_cold", "very_cold", "cold", "moderate_cold", "standard"] -# Absolute safety limit - NEVER EXCEED regardless of climate -# Source: Swedish NIBE forums - validated in real-world Nordic conditions -DM_ABSOLUTE_MAXIMUM: Final = -1500 +# The absolute safety limit is imported from const.py (DM_THRESHOLD_AUX_LIMIT), never restated here: +# a second copy could drift from the EMERGENCY tier and the `critical` threshold published here, +# which must agree on when the house is in danger (F-112 is open because the number itself may be +# wrong; audit F-076). The buffers below keep the expected band clear of that floor, so the edge of +# "normal" is not also the emergency trigger. @dataclass @@ -211,14 +258,18 @@ def get_expected_dm_range(self, outdoor_temp: float) -> dict[str, float]: Base DM expectations come from climate zone, then adjust based on how much colder/warmer current temperature is compared to zone's winter average. - EXAMPLES: - - Kiruna (Extreme Cold, winter avg -30°C): - * At -30°C: DM -800 to -1200 is normal - * At -20°C: DM -600 to -1000 is normal (10°C warmer = shallower) + EXAMPLES (computed, not remembered - check them against the code if you change it): + - Kiruna (Extreme Cold, winter avg -20°C): + * At -30°C: DM -1000 to -1400 is normal + * At -20°C: DM -800 to -1200 is normal (at the zone average, so the base range) + + - Stockholm (Cold, winter avg -8°C): + * At -10°C: DM -490 to -740 is normal + * At 0°C: DM -290 to -540 is normal (8°C warmer than average = shallower) - - Stockholm (Cold, winter avg -10°C): - * At -10°C: DM -450 to -700 is normal - * At 0°C: DM -250 to -450 is normal (10°C warmer = shallower) + The examples are the ADJUSTED ranges this method returns, not the base ranges before the + temperature adjustment; docs/CLIMATE_ZONES.md must match them row for row (checked by + tests/validation/test_climate_zones_doc_matches_the_code.py). Args: outdoor_temp: Current outdoor temperature (°C) @@ -240,20 +291,20 @@ def get_expected_dm_range(self, outdoor_temp: float) -> dict[str, float]: normal_max = self.zone_info.dm_normal_max + adjustment warning = self.zone_info.dm_warning_threshold + adjustment - # Ensure we never expect DM beyond absolute maximum - # Leave 100 DM buffer before critical limit - normal_min = max(normal_min, DM_ABSOLUTE_MAXIMUM + 100) - normal_max = max(normal_max, DM_ABSOLUTE_MAXIMUM + 50) - warning = max(warning, DM_ABSOLUTE_MAXIMUM + 50) - - # Debug logging removed to reduce spam - this is called multiple times per update - - return { - "normal_min": normal_min, - "normal_max": normal_max, - "warning": warning, - "critical": DM_ABSOLUTE_MAXIMUM, # Always -1500 - } + # COLD SIDE: never expect degree minutes beyond the absolute limit. + normal_min = max(normal_min, DM_THRESHOLD_AUX_LIMIT + DM_NORMAL_MIN_BUFFER) + normal_max = max(normal_max, DM_THRESHOLD_AUX_LIMIT + DM_WARNING_BUFFER) + warning = max(warning, DM_THRESHOLD_AUX_LIMIT + DM_WARNING_BUFFER) + + # WARM SIDE: see keep_triggers_clear_of_the_compressor_band. There was no clamp at all. + return keep_triggers_clear_of_the_compressor_band( + { + "normal_min": normal_min, + "normal_max": normal_max, + "warning": warning, + "critical": DM_THRESHOLD_AUX_LIMIT, + } + ) def get_safety_margin(self) -> float: """Get flow temperature safety margin for this climate zone. diff --git a/custom_components/effektguard/optimization/comfort_layer.py b/custom_components/effektguard/optimization/comfort_layer.py index 5ab861ce..6f0eca54 100644 --- a/custom_components/effektguard/optimization/comfort_layer.py +++ b/custom_components/effektguard/optimization/comfort_layer.py @@ -126,6 +126,28 @@ def evaluate_layer( Returns: ComfortLayerDecision with comfort correction """ + # A NIBE with no room sensor (no BT50) is a legitimate configuration: it runs on degree + # minutes and the heating curve. The adapter substitutes DEFAULT_INDOOR_TEMP (21.0) so the + # display has something to show, and sets `indoor_temp_valid=False` - in its own words, "so + # comfort-reasoning layers abstain". This is the comfort-reasoning layer, and it did not. + # + # Every deviation computed from that placeholder is fiction. With a target BELOW 21.0 - and + # 18.5 is now an allowed target - the layer reads a permanent overshoot that no amount of + # heating can correct, because nothing is measuring the house: + # + # target 19.0 -> offset -10.00 at weight 1.00, "Overshoot: 2.0C above target" + # + # A full coast at critical weight, forever, on a house nobody is measuring. Only the + # degree-minute emergency path stands between that and a cold house, and it would be + # fighting this layer on every cycle for the whole winter. + if not getattr(nibe_state, "indoor_temp_valid", True): + return ComfortLayerDecision( + name="Comfort", + offset=0.0, + weight=0.0, + reason="No indoor sensor - abstaining (degree minutes protect this system)", + ) + temp_deviation = nibe_state.indoor_temp - self.target_temp tolerance = self.tolerance_range dead_zone = self.mode_config.dead_zone @@ -247,11 +269,19 @@ def _evaluate_thermal_aware_overshoot( future_temp_diff = indoor_temp - forecast_min_outdoor forecast_heat_loss = future_temp_diff / (insulation * HEAT_LOSS_DIVISOR) - # Use the WORSE of current trend or forecast-based loss - effective_heat_loss = max(abs(indoor_rate), forecast_heat_loss) - - # Safety: minimum loss rate - if effective_heat_loss <= 0.01: + # Use the WORSE of the observed cooling rate and the forecast-based loss. + # + # Only COOLING is heat loss. `indoor_rate` is a SIGNED °C/h trend, and taking its + # absolute value here treated a WARMING house as if it were losing heat fastest: + # on a sunny morning, solar gain of +0.6 °C/h became an apparent 0.6 °C/h loss, + # beating the modelled loss (~0.2), which shrank buffer_hours and made the layer + # conclude "buffer insufficient - pre-heat!" at the exact moment the house was + # overheating. The thermal buffer is GROWING then, not draining. + observed_cooling = max(-indoor_rate, 0.0) + effective_heat_loss = max(observed_cooling, forecast_heat_loss) + + # Safety: never divide by a vanishing loss rate (buffer_hours would explode). + if effective_heat_loss <= COMFORT_HEAT_LOSS_FLOOR: effective_heat_loss = COMFORT_HEAT_LOSS_FLOOR # Calculate buffer duration diff --git a/custom_components/effektguard/optimization/decision_engine.py b/custom_components/effektguard/optimization/decision_engine.py index 02ff8f00..48fdf158 100644 --- a/custom_components/effektguard/optimization/decision_engine.py +++ b/custom_components/effektguard/optimization/decision_engine.py @@ -17,25 +17,30 @@ import logging from dataclasses import dataclass, field from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Optional, TypedDict +from typing import TYPE_CHECKING, Final, Optional, TypedDict from homeassistant.util import dt as dt_util from ..const import ( + WEATHER_FORECAST_HORIZON, + COMPRESSOR_RISK_HIGH, DEFAULT_HEAT_LOSS_COEFFICIENT, DEFAULT_TARGET_TEMP, DEFAULT_THERMAL_MASS, DEFAULT_TOLERANCE, DEFAULT_WEATHER_COMPENSATION_WEIGHT, - DM_CRITICAL_T1_PEAK_AWARE_OFFSET, - DM_CRITICAL_T2_OFFSET, - DM_CRITICAL_T2_PEAK_AWARE_OFFSET, - DM_CRITICAL_T3_OFFSET, - DM_CRITICAL_T3_PEAK_AWARE_OFFSET, + DM_CRITICAL_PEAK_AWARE_OFFSETS, + DM_RECOVERY_TIERS, + DM_THRESHOLD_AUX_LIMIT, + DM_TIER_EMERGENCY, THERMAL_MASS_CONCRETE_UFH_THRESHOLD, THERMAL_MASS_TIMBER_UFH_THRESHOLD, LAYER_WEIGHT_SAFETY, + MAX_OFFSET, + MIN_OFFSET, + MIN_TARGET_TEMP, MIN_TEMP_LIMIT, + POWER_VALIDATION_MARGIN, SAFETY_EMERGENCY_OFFSET, TOLERANCE_RANGE_MULTIPLIER, TREND_BOOST_OFFSET_LIMIT, @@ -50,6 +55,7 @@ from .comfort_layer import ComfortLayer from .thermal_layer import ( EmergencyLayer, + EmergencyLayerDecision, ProactiveLayer, is_cooling_rapidly, is_warming_rapidly, @@ -83,6 +89,17 @@ class PowerValidationDict(TypedDict, total=False): severity: str +# Display name of the safety layer. _aggregate_layers looks the layer up by name rather +# than by list position, so reordering the layers cannot silently re-target safety logic. +SAFETY_LAYER_NAME: Final = "Safety" + +# Display name of the comfort layer. It is the ONLY layer that can see under-heating caused by a +# negative curve offset: degree minutes are DM = integral(BT25 - S1), so lowering the curve lowers +# S1 and DM improves as the house gets colder. _aggregate_layers looks it up by name to floor a +# cost layer that is coasting the house out of its comfort band. +COMFORT_LAYER_NAME: Final = "Comfort" + + @dataclass class LayerDecision: """Decision from a single optimization layer. @@ -94,6 +111,10 @@ class LayerDecision: offset: float # Proposed heating curve offset (°C) weight: float # Layer weight/priority (0.0-1.0) reason: str # Human-readable explanation + # True for layers that optimize for COST (spot price, effect tariff) rather than for + # comfort, safety, or physics. Cost layers are barred from reducing heat while the + # thermal-debt layer is recovering - see DecisionEngine._aggregate_layers. + is_cost_layer: bool = False @dataclass @@ -108,6 +129,12 @@ class OptimizationDecision: reasoning: str = "" anti_windup_active: bool = False # True when anti-windup is driving the decision is_manual_override: bool = False # True for user-commanded offsets (force_offset/boost) + # True when an ABSOLUTE safety path produced this offset: indoor below MIN_TEMP_LIMIT, + # or degree minutes past DM_THRESHOLD_AUX_LIMIT. The coordinator's offset-volatility + # blocker must never defer such a decision - it exists to damp price-driven + # flip-flopping, and deferring an aux-limit recovery for 45 minutes lets DM plunge + # further while the immersion heater runs. + is_emergency: bool = False def get_safe_default_decision() -> OptimizationDecision: @@ -250,7 +277,8 @@ def __init__( # Weather prediction layer for proactive pre-heating self.weather_prediction = WeatherPredictionLayer( - thermal_mass=thermal_model.thermal_mass if thermal_model else 1.0 + thermal_mass=thermal_model.thermal_mass if thermal_model else 1.0, + forecast_horizon=self._forecast_horizon_for(thermal_model), ) # Weather compensation layer for mathematical flow temp optimization @@ -274,6 +302,7 @@ def __init__( self.proactive_layer = ProactiveLayer( climate_detector=self.climate_detector, get_thermal_trend=self._get_thermal_trend, + heating_type=heating_type, # Must match EmergencyLayer: one ladder, not two ) # Comfort layer for reactive temperature adjustments @@ -288,6 +317,7 @@ def __init__( # Manual override state (Phase 5 service support) self._manual_override_offset: float | None = None self._manual_override_until: Optional[datetime] = None + self._manual_override_one_shot = False def update_mode_config(self) -> None: """Update cached mode configuration from current optimization mode. @@ -317,6 +347,7 @@ def set_manual_override(self, offset: float, duration_minutes: int = 0) -> None: self._manual_override_offset = offset if duration_minutes > 0: + self._manual_override_one_shot = False self._manual_override_until = dt_util.now() + timedelta(minutes=duration_minutes) _LOGGER.info( "Manual override set: %s°C until %s", @@ -324,6 +355,7 @@ def set_manual_override(self, offset: float, duration_minutes: int = 0) -> None: self._manual_override_until.strftime("%Y-%m-%d %H:%M"), ) else: + self._manual_override_one_shot = True self._manual_override_until = None _LOGGER.info("Manual override set: %s°C until next cycle", offset) @@ -331,8 +363,18 @@ def clear_manual_override(self) -> None: """Clear manual override, return to automatic optimization.""" self._manual_override_offset = None self._manual_override_until = None + self._manual_override_one_shot = False _LOGGER.info("Manual override cleared") + def consume_manual_override(self) -> None: + """Consume an override whose public duration was zero. + + A zero-duration override means one applied control cycle, not forever. Reads and startup + observation do not consume it because neither reaches the pump. + """ + if self._manual_override_one_shot: + self.clear_manual_override() + def _check_manual_override(self) -> float | None: """Check if manual override is active and still valid. @@ -351,6 +393,35 @@ def _check_manual_override(self) -> float | None: return self._manual_override_offset + @staticmethod + def _absolute_safety_floor(nibe_state) -> float | None: + """Lowest offset the system may apply regardless of user intent or cost. + + These are the two conditions the project treats as non-negotiable: + - indoor below MIN_TEMP_LIMIT: the house is getting dangerously cold. + - degree minutes at or past DM_THRESHOLD_AUX_LIMIT: NIBE engages the auxiliary + immersion heater here. Declining to recover does not avoid that - it + guarantees it, while the debt keeps deepening. + + Applied as a FLOOR, not a replacement: a user asking for MORE heat than safety + requires still gets what they asked for. Only a command that would leave the + system below the safety floor is raised to it. + + The indoor check is skipped when the reading is not a measurement (no room + sensor): DEFAULT_INDOOR_TEMP sits above MIN_TEMP_LIMIT, so trusting it would + mean the floor could never engage on such a system. DM still protects it. + + Returns: + The minimum permitted offset (°C), or None when neither condition applies. + """ + if getattr(nibe_state, "indoor_temp_valid", True) and ( + nibe_state.indoor_temp < MIN_TEMP_LIMIT + ): + return SAFETY_EMERGENCY_OFFSET + if nibe_state.degree_minutes <= DM_THRESHOLD_AUX_LIMIT: + return SAFETY_EMERGENCY_OFFSET + return None + def _get_thermal_trend(self) -> dict: """Get current indoor temperature trend data. @@ -381,6 +452,43 @@ def _get_outdoor_trend(self) -> OutdoorTrendDict: } return {"trend": "unknown", "rate_per_hour": 0.0, "confidence": 0.0} + @property + def target_temp(self) -> float: + """The indoor temperature being aimed for.""" + return self._target_temp + + @target_temp.setter + def target_temp(self, target: float) -> None: + """Refuse a target the safety layer would answer with an emergency. + + Below MIN_TEMP_LIMIT the safety layer commands MAX_OFFSET. The comfort layer drives TOWARD + the target - so a target below the floor means comfort pulls the house down into the + emergency zone and safety hauls it back out, MIN_OFFSET to MAX_OFFSET, on a real compressor, + for as long as the setpoint stands. And the safety boost carries is_emergency=True, so it + bypasses the volatility blocker that exists to stop precisely that thrashing. Measured, with + a target of 15 °C: -10.00 at 19.0 °C, +10.00 at 17.9 °C. (Audit F-085.) + + Even AT the floor it is wrong: the house would sit at its target with the emergency trigger + 0.0 °C below it, and ordinary control noise would fire a full boost. So the lowest target + this system can hold is MIN_TARGET_TEMP, one default tolerance clear of the floor. + + Enforced in the setter, not in the callers. The climate entity no longer OFFERS a lower + target - and that does nothing for the owner who set one before this landed, because Home + Assistant keeps the stored value across the upgrade. Stored options, a hot reload, a + migration, a hand-edited entry: they all assign this attribute, and they are all refused. + """ + if target < MIN_TARGET_TEMP: + _LOGGER.warning( + "Target %.1f°C cannot be held - the safety layer treats anything below %.1f°C as an " + "emergency, so this target could only be met by fighting it. Holding %.1f°C.", + target, + MIN_TEMP_LIMIT, + MIN_TARGET_TEMP, + ) + target = MIN_TARGET_TEMP + + self._target_temp = target + def calculate_decision( self, nibe_state, @@ -390,6 +498,7 @@ def calculate_decision( current_power: float, temp_lux_active: bool = False, dhw_heating_end: datetime | None = None, + compressor_risk: str | None = None, ) -> OptimizationDecision: """Calculate optimal heating offset using multi-layer approach. @@ -414,12 +523,51 @@ def calculate_decision( """ _LOGGER.debug("Calculating optimization decision") - # Check for manual override first (Phase 5 service support) + # Check for manual override first (Phase 5 service support). + # + # A user command is authoritative, but it is NOT permitted to hold the system + # below the absolute safety floor. force_offset(-10) held for hours while the + # house drops below MIN_TEMP_LIMIT, or while DM sits past the aux limit, is not a + # preference - it is a fault. The floor only ever raises the offset, so a user + # asking for MORE heat (e.g. boost_heating) is passed through untouched. manual_override = self._check_manual_override() if manual_override is not None: + safety_floor = self._absolute_safety_floor(nibe_state) + + if safety_floor is not None and manual_override < safety_floor: + _LOGGER.warning( + "Manual override %.1f°C raised to %.1f°C: absolute safety floor active " + "(indoor %.1f°C, DM %.0f)", + manual_override, + safety_floor, + nibe_state.indoor_temp, + nibe_state.degree_minutes, + ) + return OptimizationDecision( + offset=self._clamp_offset(safety_floor), + layers=[ + LayerDecision( + name=SAFETY_LAYER_NAME, + offset=safety_floor, + weight=LAYER_WEIGHT_SAFETY, + reason=( + f"Safety floor overrides manual {manual_override:.1f}°C " + f"(indoor {nibe_state.indoor_temp:.1f}°C, " + f"DM {nibe_state.degree_minutes:.0f})" + ), + ) + ], + reasoning=( + f"Manual override {manual_override:.1f}°C raised to " + f"{safety_floor:.1f}°C by absolute safety floor" + ), + is_manual_override=True, + is_emergency=True, + ) + _LOGGER.info("Using manual override: %.2f°C", manual_override) return OptimizationDecision( - offset=manual_override, + offset=self._clamp_offset(manual_override), layers=[ LayerDecision( name="Manual Override", @@ -491,6 +639,7 @@ def calculate_decision( offset=effect_result.offset, weight=effect_result.weight, reason=effect_result.reason, + is_cost_layer=True, # Effect tariff optimizes cost, not comfort or safety ) # 5. Prediction Layer @@ -558,6 +707,7 @@ def calculate_decision( offset=price_result.offset, weight=price_result.weight, reason=price_result.reason, + is_cost_layer=True, # Spot price optimizes cost, not comfort or safety ) # 9. Comfort Layer @@ -585,8 +735,13 @@ def calculate_decision( comfort_decision, ] + # HOW FAR OUT OF ITS COMFORT BAND IS THE HOUSE? A cost layer is allowed to coast it around + # inside that band - that is the thermal battery - but not out of it, and degree minutes + # cannot tell the difference (see _aggregate_layers step 4). + starvation = self._starvation_fraction(nibe_state) + # Aggregate layers with priority weighting - raw_offset = self._aggregate_layers(layers) + raw_offset = self._aggregate_layers(layers, starvation=starvation) # NEW: Trend-aware damping to prevent overshoot/undershoot thermal_trend = self._get_thermal_trend() @@ -627,8 +782,20 @@ def calculate_decision( final_offset = raw_offset * damping_factor + # An ABSOLUTE safety path outranks the wear guard: a house below MIN_TEMP_LIMIT, or degree + # minutes past the pump's aux-start, gets everything the machine has. Wear is a cost; + # a cold house is a failure. + is_emergency = ( + safety_decision.weight >= LAYER_WEIGHT_SAFETY + or getattr(emergency_decision, "tier", "") == DM_TIER_EMERGENCY + ) + + final_offset, wear_note = self._limit_for_compressor_wear( + final_offset, nibe_state, compressor_risk, is_emergency + ) + # Generate human-readable reasoning - reasoning = self._generate_reasoning(layers) + reason_suffix + reasoning = self._generate_reasoning(layers) + reason_suffix + wear_note _LOGGER.info("Decision: offset %.2f°C - %s", final_offset, reasoning) @@ -639,11 +806,17 @@ def calculate_decision( # The volatile blocker must not block this safety-critical reduction. anti_windup = getattr(emergency_decision, "anti_windup_active", False) + # `is_emergency` was computed above, before the wear guard, so that the guard could stand + # aside for it. It also tells the coordinator's offset-volatility blocker not to defer this + # decision: that blocker damps price-driven flip-flopping, and deferring an aux-limit + # recovery for 45 minutes lets DM keep falling while the immersion heater runs. + return OptimizationDecision( offset=final_offset, layers=layers, reasoning=reasoning, anti_windup_active=anti_windup, + is_emergency=is_emergency, ) def _safety_layer(self, nibe_state) -> LayerDecision: @@ -666,11 +839,23 @@ def _safety_layer(self, nibe_state) -> LayerDecision: """ indoor_temp = nibe_state.indoor_temp + # Abstain when the indoor reading is a placeholder rather than a measurement. + # DEFAULT_INDOOR_TEMP (21.0) is above MIN_TEMP_LIMIT (18.0), so a system with no + # room sensor would otherwise report "OK" forever and this layer could never fire. + # Such systems are protected by the degree-minute path instead. + if not getattr(nibe_state, "indoor_temp_valid", True): + return LayerDecision( + name=SAFETY_LAYER_NAME, + offset=0.0, + weight=0.0, + reason="No indoor sensor - abstaining (degree minutes protect this system)", + ) + if indoor_temp < MIN_TEMP_LIMIT: # Too cold - emergency heating offset = SAFETY_EMERGENCY_OFFSET return LayerDecision( - name="Safety", + name=SAFETY_LAYER_NAME, offset=offset, weight=LAYER_WEIGHT_SAFETY, reason=f"Too cold ({indoor_temp:.1f}°C < {MIN_TEMP_LIMIT}°C)", @@ -678,109 +863,275 @@ def _safety_layer(self, nibe_state) -> LayerDecision: else: # Within safe limits (no fixed upper limit - comfort layer handles dynamically) return LayerDecision( - name="Safety", + name=SAFETY_LAYER_NAME, offset=0.0, weight=0.0, reason="OK", ) - def _aggregate_layers(self, layers: list[LayerDecision]) -> float: - """Aggregate layer decisions into final offset. + @staticmethod + def _limit_for_compressor_wear( + offset: float, + nibe_state, + compressor_risk: str | None, + is_emergency: bool, + ) -> tuple[float, str]: + """Decline to ask a saturated compressor for more heat. Never ask it for less. + + The offset works by raising the pump's calculated supply setpoint, S1. That only produces + heat while the compressor has frequency left to give. Above 100 Hz for a quarter of an hour + it has none: the setpoint goes up, the compressor cannot follow, and all that is bought is + + * wear, from holding the machine at full frequency longer than it needs to be, and + * a DEEPER degree-minute deficit, because DM = integral(BT25 - S1) and S1 just rose while + BT25 could not (audit F-124). + + The auxiliary heater exists for precisely this moment - NIBE sets "start addition" where it + does (menu 4.9.3) so the compressor need not grind at maximum for hours. Refusing its help + by demanding more from a saturated compressor trades cheap kWh for expensive compressor. + + This costs no comfort, and that is forced rather than argued: the extra offset was not + producing heat, so declining to ask for it cannot take any away. It HOLDS the offset at what + the pump is already being asked for; it never reduces it, and the absolute safety paths + (indoor below MIN_TEMP_LIMIT, the aux-limit emergency) return before this is ever reached. + """ + if is_emergency or compressor_risk != COMPRESSOR_RISK_HIGH: + return offset, "" + + held = min(offset, nibe_state.current_offset) + if held >= offset: + return offset, "" + + _LOGGER.info( + "Compressor saturated (%s): holding offset at %.2f°C instead of %.2f°C. A higher " + "setpoint buys no heat from a compressor already at maximum - only wear and a deeper " + "DM deficit. The auxiliary heater is what adds heat here.", + compressor_risk, + held, + offset, + ) + return held, f" | Compressor at maximum: holding {held:+.1f}°C (asked {offset:+.1f}°C)" + + @staticmethod + def _forecast_horizon_for(thermal_model) -> float: + """How far ahead the pre-heat layer must scan for this house. - Uses weighted average with special handling for high-priority layers. - Layer priority order (highest to lowest): - 1. Safety layer (absolute limits) - 2. Emergency layer (thermal debt) - ALWAYS overrides peak protection - 3. Effect layer (peak protection) - 4. Other layers + WEATHER_FORECAST_HORIZON is the FLOOR that every house gets. A thermal model may only + EXTEND it, never shrink it: seeing further ahead costs a little early pre-heat, while + seeing less far can cost the cold snap entirely. - Oct 19, 2025: Enhanced peak-aware emergency mode - When emergency layer is critical AND effect/peak layers are strongly negative, - apply minimal offset to prevent DM worsening without creating new peaks. + The layer scanned that fixed floor whatever the house was built of, and a concrete slab + cannot see a slow slide inside it. A 15 C fall spread over two days shows only 3.8 C in any + twelve hours - under WEATHER_FORECAST_DROP_THRESHOLD - so the pre-heat never fired, while + the sudden plunge that DID trigger it is the case the pump's own curve already handles. + UFH_CONCRETE_PREDICTION_HORIZON has said 24 hours all along; nothing could reach it. - Nov 29, 2025: Updated for weighted mixing (T3=0.95) - Allows Emergency T3 to mix with Price/Weather in normal conditions, - but protects it from being overridden by Critical Peak (1.0). + A model that cannot state a horizon gets the floor. + """ + if thermal_model is None: + return WEATHER_FORECAST_HORIZON + try: + horizon = float(thermal_model.get_prediction_horizon()) + except (AttributeError, TypeError, ValueError): + return WEATHER_FORECAST_HORIZON + return max(WEATHER_FORECAST_HORIZON, horizon) + + @staticmethod + def _clamp_offset(offset: float) -> float: + """Clamp an offset to the pump's valid range. + + This is the engine's single, unconditional bound. The adapter clamps again at + write time as defence in depth, but it only does so inside its fractional + accumulator branch - so before this existed, the unclamped float still reached + the coordinator, the sensors, and the learning recorder. + """ + return max(MIN_OFFSET, min(offset, MAX_OFFSET)) + + def _aggregate_layers(self, layers: list[LayerDecision], starvation: float = 0.0) -> float: + """Aggregate layer decisions into the final offset. + + SAFETY CONTRACT - the invariant this method exists to enforce: + + A cost layer (spot price, effect tariff) must NEVER reduce heating while + the thermal-debt layer is actively recovering. + + Priority order: + 1. Safety layer - indoor below MIN_TEMP_LIMIT. Absolute. + 2. EMERGENCY tier - DM past DM_THRESHOLD_AUX_LIMIT. Absolute. + 3. Recovery tiers T1/T2/T3 - cost layers may MODERATE the response down to + the tier's peak-aware offset, never reverse it. + 4. Remaining critical - weight >= LAYER_WEIGHT_SAFETY, safety-biased tie-break. + 5. Weighted average - everything else. + + Tiers are read from `EmergencyLayerDecision.tier`, never inferred from weights or + offset magnitudes: damping mutates the offset, and a weight is a tuning knob, so + inferring from either lets a retuned or damped tier fall through into the cost-layer + override path. Args: - layers: List of layer decisions + layers: Layer decisions, as built by calculate_decision Returns: - Final offset value + Final offset (°C), always within [MIN_OFFSET, MAX_OFFSET] """ - # 1. Safety Layer (Absolute Priority) - # Always enforced if critical (weight >= 1.0) - if len(layers) > 0 and layers[0].weight >= 1.0: - return layers[0].offset - - # 2. Emergency vs Peak Conflict Resolution - # If Emergency is strong (T2=0.85, T3=0.95) AND Peak is Critical (1.0), - # we need a compromise. We don't want Peak to crush Emergency (unsafe), - # nor Emergency to ignore Peak (expensive). - emergency_layer = layers[1] if len(layers) > 1 else None - effect_layer = layers[3] if len(layers) > 3 else None - - if ( - emergency_layer - and emergency_layer.weight >= 0.85 # T2 or T3 active - and effect_layer - and effect_layer.weight >= 1.0 # Peak Critical active - ): - emergency_offset = emergency_layer.offset - - # Apply Peak-Aware Compromise Logic - # Scale minimal offset based on emergency severity - if emergency_offset >= DM_CRITICAL_T3_OFFSET: # T3 - minimal_offset = DM_CRITICAL_T3_PEAK_AWARE_OFFSET - elif emergency_offset >= DM_CRITICAL_T2_OFFSET: # T2 - minimal_offset = DM_CRITICAL_T2_PEAK_AWARE_OFFSET - else: # T1 - minimal_offset = DM_CRITICAL_T1_PEAK_AWARE_OFFSET + safety_layer = next((layer for layer in layers if layer.name == SAFETY_LAYER_NAME), None) + emergency_layer = next( + (layer for layer in layers if isinstance(layer, EmergencyLayerDecision)), None + ) - _LOGGER.info( - "Peak-aware emergency mode: reducing offset from %.2f to %.2f (Critical Peak protection active)", - emergency_offset, - minimal_offset, + # 1. Safety layer: indoor temperature below the absolute floor. + if safety_layer is not None and safety_layer.weight >= LAYER_WEIGHT_SAFETY: + return self._clamp_offset(safety_layer.offset) + + # 2. EMERGENCY tier: DM past the auxiliary-heat limit. + # Nothing may throttle this. Past DM_THRESHOLD_AUX_LIMIT the immersion heater + # engages; suppressing recovery to protect the effect tariff does not avoid the + # peak, it guarantees a bigger one from the aux heater while the debt deepens. + if emergency_layer is not None and emergency_layer.tier == DM_TIER_EMERGENCY: + _LOGGER.warning( + "Aux-limit emergency: DM %.0f - applying %.1f°C, overriding all cost layers", + emergency_layer.degree_minutes, + emergency_layer.offset, ) - return minimal_offset + return self._clamp_offset(emergency_layer.offset) + + # 3. Recovery tiers (T1/T2/T3): thermal debt beyond the climate-aware warning + # threshold. The emergency layer only reaches a recovery tier when the house is + # NOT above tolerance (its "too warm" case returns tier OK first), so removing + # heat here always deepens the debt. + if emergency_layer is not None and emergency_layer.tier in DM_RECOVERY_TIERS: + peak_aware_floor = DM_CRITICAL_PEAK_AWARE_OFFSETS[emergency_layer.tier] + + if self._has_critical_cost_layer(layers): + # Peak-aware compromise: enough to stop DM worsening, small enough not to + # grow the monthly peak. Selected by TIER, so a damped T3 still gets T3's + # compromise rather than T1's. + _LOGGER.info( + "Peak-aware %s recovery: %.2f°C (critical cost layer active, DM %.0f)", + emergency_layer.tier, + peak_aware_floor, + emergency_layer.degree_minutes, + ) + return self._clamp_offset(peak_aware_floor) - # 3. Critical Overrides (Standard) - # Any remaining layer with weight >= 1.0 overrides weighted average - # (e.g., Critical Peak when Emergency is not strong) - critical_layers = [layer for layer in layers if layer.weight >= 1.0] + # No critical cost layer: let the tier mix with the other layers, but never + # below the tier's minimum recovery offset. + weighted = self._weighted_average(layers) + return self._clamp_offset(max(weighted, peak_aware_floor)) + # 4. Remaining critical layers (no thermal-debt recovery in progress). + critical_layers = [layer for layer in layers if layer.weight >= LAYER_WEIGHT_SAFETY] if critical_layers: - # For critical layers, take the strongest vote max_offset = max(layer.offset for layer in critical_layers) min_offset = min(layer.offset for layer in critical_layers) + # Safety-biased tie-break: on equal magnitude prefer the HEATING vote. + # (`>` here returned the negative vote on an exact tie, and + # SAFETY_EMERGENCY_OFFSET/+10 vs PRICE_OFFSET_PEAK/-10 tie by construction.) + chosen = max_offset if abs(max_offset) >= abs(min_offset) else min_offset + + # A COST LAYER MAY COAST THE HOUSE WITHIN ITS COMFORT BAND. IT MAY NOT COAST IT OUT. + # This step takes the critical layer's vote ALONE, so with a price layer at PEAK the + # comfort layer never enters the sum and cost kept cutting heat into an already-cold + # house until the hard floor fired three degrees later. Nothing else catches it: DM is + # blind by construction (DM = integral(BT25 - S1), so lowering the curve lowers S1 and + # DM *improves* as the house cools). + # + # So floor a cost reduction at the comfort layer's own demand, which is graduated by how + # far out the house is. The floor is RAMPED in via `starvation`, not switched at a + # threshold - a boolean there is bang-bang and chatters a Modbus write on every dither + # of the indoor sensor; see _starvation_fraction. + if chosen < 0 and starvation > 0.0 and self._all_critical_are_cost(critical_layers): + comfort = next( + (layer for layer in layers if layer.name == COMFORT_LAYER_NAME), None + ) + if comfort is not None and comfort.offset > chosen: + # `min` states the invariant AND keeps it exact: the floor stops the cut, it + # never becomes a heat source of its own. Without it, the blend at starvation + # 1.0 lands on 0.9000000000000004 rather than comfort's 0.9. + floored = min(comfort.offset, chosen + (comfort.offset - chosen) * starvation) + _LOGGER.debug( + "Cost layer asked for %.2f°C with the house %.0f%% of the way out of its " + "comfort band; floored at %.2f°C (comfort wants %.2f°C)", + chosen, + starvation * 100.0, + floored, + comfort.offset, + ) + chosen = floored - # If conflicting critical votes, take the more conservative (lower magnitude? No, safer) - # Actually, if we have multiple criticals (e.g. Peak vs Comfort Critical), - # we should probably prioritize Safety/Peak. - # But Safety is handled in step 1. - # So this is likely Peak vs Comfort Critical. - # Peak (-3.0) vs Comfort Critical (-3.0). Same. - # Peak (-3.0) vs Comfort Critical (+3.0 - too cold). - # If too cold (Comfort Critical) and Peak Critical (-3.0). - # Comfort Critical is 1.0. Peak is 1.0. - # We should probably respect Peak to avoid fees, unless Safety triggers. - # Current logic: abs(max) > abs(min). - # If max=+3, min=-3. Returns +3. - # If max=+1, min=-3. Returns -3. - # This logic favors the "stronger" intervention. - if abs(max_offset) > abs(min_offset): - return max_offset - else: - return min_offset + return self._clamp_offset(chosen) + + # 5. Weighted average of everything else. + return self._clamp_offset(self._weighted_average(layers)) + + def _starvation_fraction(self, nibe_state) -> float: + """How far the house has been coasted out of its comfort band, from 0.0 to 1.0. + + THERE ARE TWO BANDS HERE, AND THE DIFFERENCE BETWEEN THEM IS THE RAMP. - # 4. Weighted Average - # Mixes all layers (including T3=0.95, Price=0.8, Weather=0.85) + inner = target - tolerance_range the cost layers' playground. Above this the + thermal battery runs free, which is the entire + point of the integration. + outer = target - tolerance the band the OWNER actually asked for. At this + point a cost layer has spent everything it was + lent and the comfort layer's demand is the floor. + + Between them the floor is blended, so the control law is continuous - NOT a boolean at + `inner`, which is bang-bang on a temperature threshold: indoor 20.80 C giving -10.00 and + 20.79 C giving +0.01. A real indoor sensor dithers by more than a hundredth of a degree, so + the house would sit on that boundary flipping the curve between its extremes, every flip a + write to the pump. + + Abstains (0.0) when there is no valid indoor reading: without one this cannot be measured, + and degree minutes are structurally blind to it - DM = integral(BT25 - S1), so lowering the + curve lowers S1 and DM *improves* as the house gets colder. + """ + if not getattr(nibe_state, "indoor_temp_valid", True): + return 0.0 + + inner = self.target_temp - self.tolerance_range + outer = self.target_temp - self.tolerance + + if nibe_state.indoor_temp >= inner: + return 0.0 + if nibe_state.indoor_temp <= outer or inner <= outer: + return 1.0 + + return (inner - nibe_state.indoor_temp) / (inner - outer) + + @staticmethod + def _all_critical_are_cost(critical_layers: list[LayerDecision]) -> bool: + """True when EVERY layer voting at critical weight is a cost layer. + + The floor in step 4 must not weaken a critical SAFETY or physics vote - only a vote that + exists to save money. If anything other than cost is also critical, the tie-break already + had a non-cost opinion to weigh and there is nothing to protect the house from. + """ + return bool(critical_layers) and all( + getattr(layer, "is_cost_layer", False) for layer in critical_layers + ) + + @staticmethod + def _has_critical_cost_layer(layers: list[LayerDecision]) -> bool: + """True if a cost layer (spot price or effect tariff) is voting at critical weight. + + Both the price layer (PEAK quarters) and the effect layer (at the monthly peak) + promote themselves to LAYER_WEIGHT_SAFETY. Non-cost layers never set the flag, so + `getattr` defaults them to False - the emergency and proactive layers use their own + decision dataclasses and do not carry this field. + """ + return any( + getattr(layer, "is_cost_layer", False) and layer.weight >= LAYER_WEIGHT_SAFETY + for layer in layers + ) + + @staticmethod + def _weighted_average(layers: list[LayerDecision]) -> float: + """Weighted average of all layer votes (0.0 when no layer is voting).""" total_weight = sum(layer.weight for layer in layers) if total_weight == 0: return 0.0 - - weighted_sum = sum(layer.offset * layer.weight for layer in layers) - return weighted_sum / total_weight + return sum(layer.offset * layer.weight for layer in layers) / total_weight def _generate_reasoning( self, @@ -844,14 +1195,24 @@ def _validate_power_consumption( min_power, max_power = self.heat_pump_model.typical_electrical_range_kw - # Allow 20% margin for startup/defrost - max_with_margin = max_power * 1.2 + # The compressor range plus the machine's own immersion heater is the machine's + # plausible ceiling. Winter draw above the compressor range alone is the elpatron + # doing its job - flagging that fired every five minutes all January, which teaches + # the owner to ignore this channel. Only a reading the HARDWARE cannot produce is + # worth a line. + immersion_kw = float(getattr(self.heat_pump_model, "immersion_heater_kw", 0.0) or 0.0) + machine_ceiling = (max_power + immersion_kw) * POWER_VALIDATION_MARGIN - if current_power_kw > max_with_margin: + if current_power_kw > machine_ceiling: return { "valid": False, - "warning": f"Power {current_power_kw:.1f}kW exceeds {self.heat_pump_model.model_name} max {max_power:.1f}kW (auxiliary heating active?)", - "severity": "info", + "warning": ( + f"Power {current_power_kw:.1f}kW exceeds what a " + f"{self.heat_pump_model.model_name} can draw " + f"(compressor max {max_power:.1f}kW + immersion {immersion_kw:.1f}kW). " + f"Check the sensor's unit and scaling." + ), + "severity": "warning", } # Check if unusually low (possible sensor issue) diff --git a/custom_components/effektguard/optimization/dhw_optimizer.py b/custom_components/effektguard/optimization/dhw_optimizer.py index 193c3416..149b00d4 100644 --- a/custom_components/effektguard/optimization/dhw_optimizer.py +++ b/custom_components/effektguard/optimization/dhw_optimizer.py @@ -30,8 +30,11 @@ DHW_COOLING_RATE, DHW_DEFAULT_HEATING_RATE, DHW_EXTENDED_RUNTIME_MINUTES, + DHW_HEATING_RATE_MAX, + DHW_HEATING_RATE_MIN, DHW_LEGIONELLA_DETECT, DHW_LEGIONELLA_MAX_DAYS, + DHW_LEGIONELLA_OVERDUE_DAYS, DHW_LEGIONELLA_PREVENT_TEMP, DHW_MAX_TEMP, DHW_MAX_TEMP_VALIDATION, @@ -49,18 +52,26 @@ DHW_SPACE_HEATING_OUTDOOR_THRESHOLD, DHW_TREND_DEFICIT_THRESHOLD, DHW_TREND_RATE_THRESHOLD, + DM_CRITICAL_T2_MARGIN, + DM_THRESHOLD_AUX_LIMIT, + MIN_TEMP_LIMIT, + DM_DHW_ABORT_BUFFER, DM_DHW_ABORT_FALLBACK, DM_DHW_BLOCK_FALLBACK, DM_RECOVERY_SAFETY_BUFFER, DM_THRESHOLD_START, MIN_DHW_TARGET_TEMP, + MINUTES_PER_QUARTER, QuarterClassification, SPACE_HEATING_DEMAND_DROP_HOURS, SPACE_HEATING_DEMAND_HIGH_THRESHOLD, SPACE_HEATING_DEMAND_LOW_THRESHOLD, SPACE_HEATING_DEMAND_MODERATE_THRESHOLD, ) -from .thermal_layer import estimate_dm_recovery_time +from homeassistant.util import dt as dt_util + +from ..utils.price_math import price_savings_fraction +from .thermal_layer import estimate_dm_recovery_time, get_thermal_debt_status from .price_layer import PriceAnalyzer from ..utils.volatile_helpers import get_volatile_info @@ -251,6 +262,10 @@ def __init__( self.emergency_layer = emergency_layer self.price_analyzer = price_analyzer self.last_legionella_boost: datetime | None = None + # A scheduled window that SAFETY refused. The owner asked for hot water and safety said not + # yet - which is a debt to be settled the moment the house is out of danger, not a shower + # silently cancelled. Cleared when the water reaches target. + self._scheduled_window_owed: bool = False self.bt7_history: deque = deque(maxlen=48) # 12 hours @ 15-min intervals # Learned DHW heating rate (persisted across restarts) @@ -363,8 +378,9 @@ def calculate_heating_rate(self) -> float: if duration_hours > 0.1 and temp_change > 2.0: # At least 6 min and 2°C change calculated_rate = temp_change / duration_hours - # Sanity check: rate should be between 5-25°C/hour for heat pumps - if 5.0 <= calculated_rate <= 25.0: + # Same plausibility band that gates the restore path - a rate that cannot be + # learned must not be loadable from storage either. + if DHW_HEATING_RATE_MIN <= calculated_rate <= DHW_HEATING_RATE_MAX: # Update learned rate with weighted average self._update_learned_rate(calculated_rate) _LOGGER.debug( @@ -432,20 +448,57 @@ def restore_from_persistence(self, state: dict) -> None: Args: state: Dict with persisted state from get_dhw_state_for_persistence() """ + # Stored state is UNTRUSTED input. A power loss mid-write, or a hand-edited .storage + # file, must degrade to defaults - not crash setup, and not poison the scheduler. An + # unguarded ValueError here aborts all of async_initialize_learning, including the + # BT7-history recovery that follows. if "last_legionella_boost" in state: - self.last_legionella_boost = datetime.fromisoformat(state["last_legionella_boost"]) - _LOGGER.info( - "Restored last Legionella boost: %s", - self.last_legionella_boost, - ) + try: + self.last_legionella_boost = datetime.fromisoformat(state["last_legionella_boost"]) + _LOGGER.info( + "Restored last Legionella boost: %s", + self.last_legionella_boost, + ) + except (ValueError, TypeError) as err: + _LOGGER.warning( + "Ignoring unreadable stored Legionella timestamp %r: %s", + state.get("last_legionella_boost"), + err, + ) + if "learned_heating_rate" in state: - self.learned_heating_rate = state["learned_heating_rate"] - self.heating_rate_observations = state.get("heating_rate_observations", 1) - _LOGGER.info( - "Restored DHW heating rate: %.1f°C/hour (%d observations)", - self.learned_heating_rate, - self.heating_rate_observations, - ) + restored = self._validated_heating_rate(state["learned_heating_rate"]) + if restored is None: + _LOGGER.warning( + "Ignoring implausible stored DHW heating rate %r - keeping %.1f°C/hour " + "(expected %.0f-%.0f°C/hour).", + state.get("learned_heating_rate"), + self.learned_heating_rate or DHW_DEFAULT_HEATING_RATE, + DHW_HEATING_RATE_MIN, + DHW_HEATING_RATE_MAX, + ) + else: + self.learned_heating_rate = restored + self.heating_rate_observations = state.get("heating_rate_observations", 1) + _LOGGER.info( + "Restored DHW heating rate: %.1f°C/hour (%d observations)", + self.learned_heating_rate, + self.heating_rate_observations, + ) + + @staticmethod + def _validated_heating_rate(value: object) -> float | None: + """Return `value` if it is a plausible DHW heating rate (°C/h), else None. + + The same band gates a rate LEARNED from BT7 history, so a value that could never + have been learned must not be loadable from storage either. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + rate = float(value) + if DHW_HEATING_RATE_MIN <= rate <= DHW_HEATING_RATE_MAX: + return rate + return None def estimate_heating_time( self, current_temp: float, target_temp: float, heating_rate: float | None = None @@ -468,6 +521,20 @@ def estimate_heating_time( if temp_deficit <= 0: return 0.0 + # Defence in depth: this is a divisor. Even with the restore path now validated, + # never divide by zero or by an implausible rate - a near-zero rate would produce a + # heat-up estimate of hundreds of hours and make the scheduler heat immediately at + # any price, forever. + if heating_rate < DHW_HEATING_RATE_MIN: + _LOGGER.warning( + "DHW heating rate %.2f°C/h is below the plausible minimum %.0f°C/h - " + "using the default %.0f°C/h for this estimate.", + heating_rate, + DHW_HEATING_RATE_MIN, + DHW_DEFAULT_HEATING_RATE, + ) + heating_rate = DHW_DEFAULT_HEATING_RATE + hours_needed = temp_deficit / heating_rate _LOGGER.debug( "DHW heating time estimate: %.1f°C → %.1f°C (Δ%.1f°C) at %.1f°C/h = %.2fh", @@ -528,7 +595,6 @@ async def initialize_from_history(self, hass, bt7_entity_id: str) -> None: try: from homeassistant.components import recorder from homeassistant.components.recorder import history - import homeassistant.util.dt as dt_util # Get recorder instance if not recorder.is_entity_recorded(hass, bt7_entity_id): @@ -677,14 +743,10 @@ def should_start_dhw( should_block_for_thermal_debt = self.emergency_layer.should_block_dhw( thermal_debt_dm, outdoor_temp ) - # Get THERMAL MASS ADJUSTED thresholds for abort conditions - # CRITICAL: Must use same adjusted thresholds as should_block_dhw() uses internally - # to prevent start-then-abort cycles when block passes but abort fails + dm_block_threshold, dm_abort_threshold = self.get_dm_block_and_abort_thresholds( + outdoor_temp + ) dm_thresholds = self.emergency_layer.get_adjusted_dm_thresholds(outdoor_temp) - dm_block_threshold = dm_thresholds["warning"] - # Abort threshold should be LESS strict (more negative) than block threshold - # to avoid immediate abort after starting. Use 80 DM buffer beyond warning. - dm_abort_threshold = dm_thresholds["warning"] - 80 _LOGGER.debug( "DHW using shared EmergencyLayer: should_block=%s, DM=%.0f, outdoor=%.1f°C, " @@ -698,8 +760,9 @@ def should_start_dhw( elif self.climate_detector: # Fallback to local climate detector dm_thresholds = self.climate_detector.get_expected_dm_range(outdoor_temp) - dm_block_threshold = dm_thresholds["warning"] # Use warning threshold for blocking - dm_abort_threshold = dm_thresholds["warning"] - 80 # 80 DM buffer before critical + dm_block_threshold, dm_abort_threshold = self.get_dm_block_and_abort_thresholds( + outdoor_temp + ) should_block_for_thermal_debt = thermal_debt_dm <= dm_block_threshold _LOGGER.debug( @@ -711,8 +774,9 @@ def should_start_dhw( ) else: # Fallback to fixed thresholds from const.py if climate detector unavailable - dm_block_threshold = DM_DHW_BLOCK_FALLBACK - dm_abort_threshold = DM_DHW_ABORT_FALLBACK + dm_block_threshold, dm_abort_threshold = self.get_dm_block_and_abort_thresholds( + outdoor_temp + ) should_block_for_thermal_debt = thermal_debt_dm <= dm_block_threshold # Check if scheduling is active (user configured demand periods) @@ -775,6 +839,39 @@ def should_start_dhw( if within_scheduled_window: # === LANE 1: SCHEDULED WINDOW - PRIORITY MODE === + # + # This lane deliberately outranks the thermal-debt block (RULE 1) and the + # space-heating checks (RULES 2 and 4) - a scheduled shower is one the owner wants - + # but it does NOT outrank safety (this gate), which it once did. + unsafe = self.scheduled_dhw_unsafe_reason(thermal_debt_dm, indoor_temp) + if unsafe: + # Not cancelled. OWED, and settled once the house is out of danger, even after + # the window has closed (see RULE 0.5). + self._scheduled_window_owed = True + _LOGGER.warning( + "Scheduled DHW refused for safety: %s. It will be heated as soon as the " + "house is safe again.", + unsafe, + ) + return DHWScheduleDecision( + should_heat=False, + priority_reason="DHW_SCHEDULED_BLOCKED_BY_SAFETY", + target_temp=self.user_target_temp, + max_runtime_minutes=0, + abort_conditions=[], + # Best estimate of when the house is safe again; RULE 0.5 heats the moment it + # actually happens, whichever comes first. + recommended_start_time=self._find_next_dhw_opportunity( + current_time=current_time, + current_dhw_temp=current_dhw_temp, + thermal_debt_dm=thermal_debt_dm, + outdoor_temp=outdoor_temp, + price_periods=price_periods, + blocking_reason="DHW_SCHEDULED_BLOCKED_BY_SAFETY", + dm_block_threshold=dm_block_threshold, + ), + ) + if target_reached: # Target fully reached - STOP heating to avoid waste reason = ( @@ -848,23 +945,25 @@ def should_start_dhw( if optimal_window: # Check if waiting is worth it (significant savings AND reachable in time) # Get current price for comparison - # QuarterPeriod spans 15 minutes from start_time current_quarter_price = next( ( p.price for p in price_periods if p.start_time <= current_time - < p.start_time + timedelta(minutes=15) + < p.start_time + timedelta(minutes=MINUTES_PER_QUARTER) ), None, ) - if current_quarter_price: - price_savings_pct = ( - current_quarter_price - optimal_window.avg_price - ) / current_quarter_price + # `is not None`, NOT truthiness (0.00 is a real Nordic price), and the + # ratio is taken against the MAGNITUDE - `(current - optimal) / current` + # inverts on negative prices. See price_savings_fraction. + price_savings_pct = price_savings_fraction( + current_quarter_price, optimal_window.avg_price + ) + if price_savings_pct is not None: # Can wait if: # 1. Savings significant (≥15%) # 2. Optimal window reachable (time_to_window + heat_time < time_until_target) @@ -985,11 +1084,9 @@ def should_start_dhw( priority_reason=reason, target_temp=self.user_target_temp, max_runtime_minutes=DHW_NORMAL_RUNTIME_MINUTES, - abort_conditions=[ - f"thermal_debt < {dm_abort_threshold:.0f}", - f"indoor_temp < {target_indoor_temp - 0.5}", - f"dhw_temp >= {self.user_target_temp}", - ], + # The safety gate that let this cycle start, and nothing else. Anything + # narrower would abort it in the state it was permitted to begin in. + abort_conditions=self.scheduled_dhw_abort_conditions(), recommended_start_time=( current_time if not optimal_window else optimal_window.start_time ), @@ -1016,6 +1113,33 @@ def should_start_dhw( ) # Continue to normal rules below (LANE 2 = normal optimization) + # === RULE 0.5: A SCHEDULED WINDOW THAT SAFETY REFUSED, SETTLED === + # + # When safety refuses a scheduled window (LANE 1), the shower is OWED, not cancelled. Once + # the house is safe again, heat it now - even after the window has closed and even though the + # ordinary thermal-debt block below would refuse it - carrying the window's own priority. + if self._scheduled_window_owed: + if current_dhw_temp >= self.user_target_temp: + # Settled - by this rule, or by the pump's own schedule. Either way, nothing is owed. + self._scheduled_window_owed = False + elif self.scheduled_dhw_unsafe_reason(thermal_debt_dm, indoor_temp) is None: + _LOGGER.info( + "Resuming the scheduled DHW that safety refused: house is safe again " + "(DM %.0f, indoor %.1f°C)", + thermal_debt_dm, + indoor_temp, + ) + return DHWScheduleDecision( + should_heat=True, + priority_reason="DHW_SCHEDULED_RETRY_AFTER_SAFETY", + target_temp=self.user_target_temp, + max_runtime_minutes=DHW_NORMAL_RUNTIME_MINUTES, + abort_conditions=self.scheduled_dhw_abort_conditions(), + recommended_start_time=current_time, + ) + # Still unsafe: keep the debt on the books and fall through to the ordinary rules, + # which will refuse for thermal debt anyway. + # === RULE 1: CRITICAL THERMAL DEBT - NEVER START DHW === if should_block_for_thermal_debt: _LOGGER.warning( @@ -1203,29 +1327,27 @@ def should_start_dhw( recommended_start_time=next_non_peak, ) - # === RULE 2.3: HYGIENE BOOST (HIGH-TEMP CYCLE FOR LEGIONELLA PREVENTION) === - # If DHW hasn't been above 56°C in past 14 days, heat to 56°C during cheapest period - # This prevents Legionella bacteria growth in the low-temp range (20-45°C) - # with the new lower safety thresholds (10°C/20°C). + # === RULE 2.3: OPPORTUNISTIC HIGH-TEMPERATURE DHW CYCLE === # - # REQUIRES DHW IMMERSION HEATER (Swedish: elpatron): - # - Heat pump compressor can only reach ~50-55°C max (COP limitation) - # - NIBE automatically engages DHW tank immersion heater for high-temp cycles - # - Real-world observation: Max 56°C achieved with compressor + immersion heater - # - This is normal operation for Legionella prevention in all NIBE systems - # - Scheduling during cheap periods minimizes immersion heater cost + # THIS RULE DOES NOT, AND CANNOT, PERFORM A LEGIONELLA CYCLE. It is an OPPORTUNISTIC top-up + # into a cheap window (cost optimisation), with no forced deadline on purpose - forcing a + # boost that can never reach the detection threshold would re-trigger the immersion heater + # indefinitely. # - # NOTE: This is the DHW tank's built-in immersion heater (elpatron), NOT the - # space heating auxiliary heater. They are separate electrical heating systems. + # Hygiene is NIBE's own "periodic increase" (menu 2.9.1 F-series / 2.4 S-series; factory + # ACTIVATED every 14 days to 55 C, using compressor + immersion heater). EffektGuard cannot + # block it - our only DHW actuator is the temporary-lux switch, which does not touch NIBE's + # schedule. (Source: NIBE F750/F730/F1155 installer manuals, menus 2.9.1 and 5.1.1; register + # map 47046/47050/47051.) # - # References: - # - Boverket.se: Water heaters should maintain ≥60°C (ideal), bacteria killed at high temps - # - User observation: System reaches max 56°C with electrical boost (real-world constraint) - # - Swedish forum: "Vp klarar inte 60°C, därför elpatron för legionella" - # (Heat pump can't reach 60°C, therefore immersion heater for Legionella) - # - NIBE Menu 4.9.5: Built-in Legionella function uses immersion heater weekly/bi-weekly + # Our boost cannot reach 55 C: temporary lux drives the tank to the LUXURY STOP temperature + # (factory F750 54 / F730 53 / F1155 52 C, measured on BT6, all below DHW_LEGIONELLA_DETECT + # 55 C; installer-adjustable, so unknown at runtime). The BT7 >= 55 C detector is also + # unsound: BT7 is the DISPLAY sensor (setpoints act on BT6, CONTROL) and is optional on + # F1155/S1155, so a BT7 >= 55 C reading is most likely NIBE's own cycle, not ours. # - # PRIORITY: Higher than emergency completion (bacteria prevention critical) + # myuplink excludes params 47050/47051 (PARAMETER_ID_TO_EXCLUDE_F730), so we cannot observe + # NIBE's cycle; if none is seen far past its interval, warn (below) - do not substitute. days_since_legionella = None if self.last_legionella_boost: try: @@ -1284,6 +1406,28 @@ def should_start_dhw( is_volatile, ) + # Diagnostic: NIBE's own periodic increase (menu 2.9.1) runs every + # DHW_LEGIONELLA_MAX_DAYS from the factory and targets >= 55 C. If we have not + # observed ANY high-temperature cycle well past that, the function has most + # likely been switched off on the pump - and EffektGuard cannot substitute for + # it (temporary lux stops at 53-54 C). Tell the user; do not act. + if ( + days_since_legionella is not None + and days_since_legionella >= DHW_LEGIONELLA_OVERDUE_DAYS + ): + _LOGGER.warning( + "No hot-water temperature above %.0f C observed for %.0f days. NIBE's " + "periodic increase (menu 2.9.1 on F-series, 2.4 on S-series) normally " + "runs every %.0f days and is enabled from the factory - check that it is " + "still activated on the pump. EffektGuard cannot perform this cycle " + "itself: the temporary-lux boost only reaches the pump's luxury stop " + "temperature (53-54 C on factory settings), below the %.0f C threshold.", + DHW_LEGIONELLA_DETECT, + days_since_legionella, + DHW_LEGIONELLA_MAX_DAYS, + DHW_LEGIONELLA_DETECT, + ) + # === RULE 2.5: COMPLETE EMERGENCY HEATING TO COMFORT LEVEL === # After emergency heating reached DHW_SAFETY_MIN (30°C), complete to comfort level # during stable cheap prices. This is the second phase of two-tier emergency heating. @@ -1655,11 +1799,14 @@ def should_start_dhw( None, ) - if current_quarter_price and optimal_window.avg_price < current_quarter_price: - price_savings_pct = ( - current_quarter_price - optimal_window.avg_price - ) / current_quarter_price + # `price_savings_fraction`, not the arithmetic inline: the naive + # `(current - optimal) / current` is falsy on a price of exactly 0.00 and inverts + # on negative prices, heating hot water NOW instead of waiting to be PAID for it. + price_savings_pct = price_savings_fraction( + current_quarter_price, optimal_window.avg_price + ) + if price_savings_pct is not None: # Can wait if: # 1. Savings significant (≥15%) # 2. Optimal window is not too far away (within lookahead) @@ -2144,6 +2291,76 @@ def format_planning_summary( return "\n".join(lines) + def scheduled_dhw_unsafe_reason(self, thermal_debt_dm: float, indoor_temp: float) -> str | None: + """Why a SCHEDULED hot-water cycle must not run, or None if it may. + + A scheduled window outranks thermal debt and space-heating demand (deliberate priority), but + not the two points at which the house is in trouble: + + * indoor below MIN_TEMP_LIMIT - the safety layer already commands maximum heat, which DHW + would take the compressor away from; + * degree minutes at the absolute limit - the immersion heater is engaging. + + The scheduled path's ABORT conditions are built from these same two tests + (scheduled_dhw_abort_conditions): if a cycle can start in a state its own abort conditions + reject, it starts/aborts/rate-limits/restarts, cycling the compressor and heating no water. + One predicate, both ends. + """ + if indoor_temp < MIN_TEMP_LIMIT: + return ( + f"indoor {indoor_temp:.1f}°C is below the {MIN_TEMP_LIMIT:.1f}°C safety floor - " + f"the house needs the compressor more than the tank does" + ) + + if thermal_debt_dm <= DM_THRESHOLD_AUX_LIMIT: + return ( + f"DM {thermal_debt_dm:.0f} is at the absolute limit {DM_THRESHOLD_AUX_LIMIT:.0f} - " + f"auxiliary heat is engaging and DHW must not compete with recovery" + ) + + return None + + def scheduled_dhw_abort_conditions(self) -> list[str]: + """The abort conditions for a scheduled cycle: the safety gate, and nothing else. + + Built from the same two thresholds as `scheduled_dhw_unsafe_reason`, so a cycle that was + permitted to start cannot be aborted by the state it started in. + """ + return [ + f"thermal_debt < {DM_THRESHOLD_AUX_LIMIT:.0f}", + f"indoor_temp < {MIN_TEMP_LIMIT:.1f}", + ] + + def get_dm_block_and_abort_thresholds(self, outdoor_temp: float) -> tuple[float, float]: + """Return (block, abort) degree-minute thresholds for hot water. + + BLOCK is "do not START a DHW cycle below this". ABORT is "STOP a running cycle below this". + **Abort is always the deeper of the two, and it has to be:** a cycle sinks degree minutes, so + an abort shallower than the block would abort every cycle on the next tick (start/stop/start). + Both come from one place here, abort DERIVED from whichever block the caller enforces. + + The two paths enforce DIFFERENT blocks (shared EmergencyLayer at T2, local-detector fallback + at `warning`) - left as-is, because changing it would move when hot water is refused, a safety + behaviour and a separate question from this one. + """ + if self.emergency_layer: + # What should_block_dhw() actually enforces: "Block at T2 threshold or worse". + warning = self.emergency_layer.get_adjusted_dm_thresholds(outdoor_temp)["warning"] + block = warning - DM_CRITICAL_T2_MARGIN + elif self.climate_detector: + # What this branch actually enforces: `thermal_debt_dm <= warning`. + block = self.climate_detector.get_expected_dm_range(outdoor_temp)["warning"] + else: + return DM_DHW_BLOCK_FALLBACK, DM_DHW_ABORT_FALLBACK + + # A running cycle gives up a buffer deeper than the block, floored at the absolute limit + # ITSELF (not limit + buffer, which would re-create the inversion: in the coldest zone the + # block already sits near -1400, so clamping abort up to -1340 would put it shallower than + # block again). Deep zones have less room between block and floor, which is fine. + abort = max(block - DM_DHW_ABORT_BUFFER, DM_THRESHOLD_AUX_LIMIT) + + return block, abort + def check_abort_conditions( self, abort_conditions: list[str], @@ -2332,8 +2549,6 @@ def calculate_recommendation( availability_time = upcoming_demand["availability_time"] if upcoming_demand else None # Build detailed planning attributes - from .thermal_layer import get_thermal_debt_status - planning_details = { "should_heat": decision.should_heat, "priority_reason": decision.priority_reason, @@ -2437,7 +2652,8 @@ def _check_upcoming_demand_period(self, current_time: datetime) -> DemandPeriodI closest_hours = float("inf") for period in self.demand_periods: - # Calculate next availability time + # Calculate next availability time (a wall-clock hour - tomorrow's 06:00 is + # tomorrow's 06:00 whatever the clocks did overnight). availability_time = current_time.replace( hour=period.availability_hour, minute=0, second=0, microsecond=0 ) @@ -2445,7 +2661,12 @@ def _check_upcoming_demand_period(self, current_time: datetime) -> DemandPeriodI if availability_time < current_time: availability_time += timedelta(days=1) - hours_until = (availability_time - current_time).total_seconds() / 3600 + # But the DISTANCE to it is real hours, on the absolute time line: subtracting + # aware local datetimes directly is wall-clock arithmetic, which loses the + # repeated hour on the fall-back night and plans the heating an hour short. + hours_until = ( + dt_util.as_utc(availability_time) - dt_util.as_utc(current_time) + ).total_seconds() / 3600 # Check if within 24h window and is closer than previous closest if hours_until <= DHW_SCHEDULING_WINDOW_MAX and hours_until < closest_hours: diff --git a/custom_components/effektguard/optimization/effect_layer.py b/custom_components/effektguard/optimization/effect_layer.py index 21fe40bb..2d13429e 100644 --- a/custom_components/effektguard/optimization/effect_layer.py +++ b/custom_components/effektguard/optimization/effect_layer.py @@ -1,13 +1,18 @@ """Effect tariff manager for Swedish Effektavgift optimization. -Tracks 15-minute power consumption windows and manages monthly peak -avoidance to minimize effect tariff charges. - -Swedish effect tariff rules: -- Measured in 15-minute windows (quarterly periods) -- Daytime (06:00-22:00): Full weight -- Nighttime (22:00-06:00): 50% weight -- Monthly charge based on top 3 peaks +Tracks the mean power of each billing PERIOD and manages monthly peak avoidance to minimise effect +tariff charges. + +This is the OWNER'S tariff model, not a universal fact - operator rules vary across thousands of +DSOs (finding F-107, owner-gated). What the owner configures here: +- Measured over a 15-minute PERIOD (BILLING_PERIOD_MINUTES): the time-weighted mean power of each + quarter-hour, not the instantaneous draw. +- Daytime (06:00-22:00): full weight. +- Nighttime (22:00-06:00): half the peak counts (NIGHT_TARIFF_WEIGHT). Predates the audit. +- Monthly charge on the mean of the three highest periods (plain, date-blind top-3). + +The 81.25 kr/kW/month rate (SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH) is Ellevio's published figure, +kept as an illustrative example for the simulator - not a claim about what any given owner pays. """ import logging @@ -20,6 +25,8 @@ from homeassistant.util import dt as dt_util from ..const import ( + BILLABLE_POWER_SOURCES, + BILLING_PERIOD_MINUTES, COMPRESSOR_HZ_MIN, COMPRESSOR_HZ_RANGE, COMPRESSOR_POWER_MAX_KW, @@ -32,8 +39,8 @@ COMPRESSOR_TEMP_FACTOR_COOL, COMPRESSOR_TEMP_FACTOR_EXTREME_COLD, COMPRESSOR_TEMP_FACTOR_MILD, - DAYTIME_END_QUARTER, - DAYTIME_START_QUARTER, + DAYTIME_END_HOUR, + DAYTIME_START_HOUR, DEFAULT_HEAT_PUMP_POWER_KW, EFFECT_MARGIN_PREDICTIVE, EFFECT_OFFSET_CRITICAL, @@ -49,35 +56,61 @@ EFFECT_PREDICTIVE_RAPID_COOLING_INCREASE, EFFECT_PREDICTIVE_RAPID_COOLING_THRESHOLD, EFFECT_PREDICTIVE_WARMING_DECREASE, + EFFECT_STORAGE_VERSION, EFFECT_WEIGHT_CRITICAL, EFFECT_WEIGHT_PREDICTIVE, EFFECT_WEIGHT_WARNING_RISING, EFFECT_WEIGHT_WARNING_STABLE, + NIGHT_TARIFF_WEIGHT, + PEAK_RECORDING_MAXIMUM, PEAK_RECORDING_MINIMUM, POWER_MULTIPLIER_COLD, POWER_MULTIPLIER_MILD, POWER_MULTIPLIER_VERY_COLD, + POWER_SOURCE_EXTERNAL_METER, + POWER_SOURCE_NONE, POWER_STANDBY_KW, POWER_TEMP_COLD_THRESHOLD, POWER_TEMP_VERY_COLD_THRESHOLD, STORAGE_KEY, - STORAGE_VERSION, THERMAL_CHANGE_MODERATE, THERMAL_CHANGE_MODERATE_COOLING, ) -from ..utils.time_utils import get_current_quarter +from ..utils.time_utils import get_current_billing_period _LOGGER = logging.getLogger(__name__) +def is_daytime_period(period: int) -> bool: + """Whether this billing PERIOD (0-95) is billed at the full tariff rate. + + The night discount is a wall-clock window (22:00-06:00), so the quarter-index is folded down to + its hour to test it: period * BILLING_PERIOD_MINUTES // 60. + """ + hour = period * BILLING_PERIOD_MINUTES // 60 + return DAYTIME_START_HOUR <= hour < DAYTIME_END_HOUR + + +def effective_tariff_power_kw(power_kw: float, period: int) -> float: + """What the effect tariff will BILL this period's mean power as. Night periods count half. + + `period` is the 15-minute quarter of the day (0-95). THE ONE DEFINITION - everything that goes + near a monthly peak comes through here. When the weighting was open-coded, the savings baseline + compared an UNWEIGHTED peak against a weighted one and reported phantom savings (a night peak + looked ~half off with the optimiser idle). + """ + return power_kw if is_daytime_period(period) else power_kw * NIGHT_TARIFF_WEIGHT + + class PeakEventDict(TypedDict): """Dictionary representation of a PeakEvent for serialization.""" timestamp: str # ISO format - quarter_of_day: int + period_of_day: int actual_power: float effective_power: float is_daytime: bool + source: str class PeakSummaryPeakDict(TypedDict): @@ -87,48 +120,69 @@ class PeakSummaryPeakDict(TypedDict): effective_power: float actual_power: float is_daytime: bool + source: str + billable: bool class MonthlyPeakSummaryDict(TypedDict): - """Summary of monthly peaks for display.""" + """Summary of monthly peaks for display. + + `billable` is False as soon as ANY peak in the history came from something other than a + whole-house meter. The tariff is charged on the top three PERIODS together, so one pump-only + period in the set makes the whole figure something other than the bill - and the owner is told + that rather than shown a number that looks like money. + """ count: int highest: float + billable: bool peaks: list[PeakSummaryPeakDict] @dataclass class PeakEvent: - """Record of a 15-minute peak power event. + """One billing period's mean power - a 15-minute quarter, which is what the tariff bills. - Tracks both actual and effective power (accounting for day/night weighting). + Tracks both actual and effective power (accounting for the 22:00-06:00 half-price window). """ timestamp: datetime - quarter_of_day: int # 0-95 + period_of_day: int # the billing period (quarter of the day), 0-95 actual_power: float # kW effective_power: float # kW (with day/night weighting) is_daytime: bool + # Where the number came from. A peak from NIBE's phase currents is a valid CONTROL threshold but + # is not whole-house grid import, so it must never be reported as the month's billing peak. + # Carrying provenance lets one history serve both purposes. + source: str = POWER_SOURCE_EXTERNAL_METER + + @property + def is_billable(self) -> bool: + """Whether this peak may appear in a billing figure shown to the owner.""" + return self.source in BILLABLE_POWER_SOURCES def to_dict(self) -> PeakEventDict: """Convert to dictionary for storage.""" return { "timestamp": self.timestamp.isoformat(), - "quarter_of_day": self.quarter_of_day, + "period_of_day": self.period_of_day, "actual_power": self.actual_power, "effective_power": self.effective_power, "is_daytime": self.is_daytime, + "source": self.source, } @classmethod def from_dict(cls, data: PeakEventDict) -> "PeakEvent": - """Create from dictionary.""" + """Create from dictionary. Records older than this schema never reach here: the store + migration (EffectStore) discards them, so every field is present.""" return cls( timestamp=dt_util.parse_datetime(data["timestamp"]), - quarter_of_day=data["quarter_of_day"], + period_of_day=data["period_of_day"], actual_power=data["actual_power"], effective_power=data["effective_power"], is_daytime=data["is_daytime"], + source=data["source"], ) @@ -155,8 +209,56 @@ class EffectLayerDecision: reason: str # Human-readable explanation +class EffectStore(Store): + """Peak-history storage, with migration from the version-1 schema. + + Version 1 recorded 15-minute quarter peaks (``quarter_of_day``) and carried no per-sample + ``source``. The owner's tariff measures the same 15-minute quarter, so a v1 record is the SAME + billed quantity: migration CONVERTS it (``quarter_of_day`` -> ``period_of_day``, ``source`` -> + POWER_SOURCE_NONE) rather than discarding it. Only provenance is unrecoverable, so a converted + peak is treated as unbillable until fresh measurement replaces it. Parsing a v1 record directly + is what broke setup for every upgrading install: ``PeakEvent.from_dict`` raised + ``KeyError: 'period_of_day'`` inside ``async_setup_entry``. + """ + + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: dict | None, + ) -> dict: + """Migrate stored peak history to the current schema.""" + if old_major_version < EFFECT_STORAGE_VERSION: + if not isinstance(old_data, dict): + return {"peaks": []} + converted = [] + for peak in old_data.get("peaks", []): + if not isinstance(peak, dict): + continue + record = dict(peak) + # v1 keyed the quarter as `quarter_of_day`; the field is `period_of_day` now, the + # same 0-95 index. A record already carrying `period_of_day` passes through. + if "period_of_day" not in record and "quarter_of_day" in record: + record["period_of_day"] = record.pop("quarter_of_day") + # v1 stored no provenance. It cannot be reconstructed, so the peak is marked + # unbillable and only counts as a control threshold until live data replaces it. + record.setdefault("source", POWER_SOURCE_NONE) + # A record missing the quarter index at all is unusable - drop it rather than crash. + if "period_of_day" not in record: + continue + converted.append(record) + if converted: + _LOGGER.info( + "Migrated %d peak record(s) from the version-1 store (quarter_of_day -> " + "period_of_day, source unknown so marked unbillable).", + len(converted), + ) + return {"peaks": converted} + return old_data if isinstance(old_data, dict) else {"peaks": []} + + class EffectManager: - """Manage effect tariff optimization with 15-minute granularity.""" + """Manage effect tariff optimization on 15-minute billing periods.""" def __init__(self, hass: HomeAssistant): """Initialize effect manager. @@ -165,7 +267,7 @@ def __init__(self, hass: HomeAssistant): hass: Home Assistant instance for storage """ self.hass = hass - self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._store = EffectStore(hass, EFFECT_STORAGE_VERSION, STORAGE_KEY) self._monthly_peaks: list[PeakEvent] = [] # Top 3 peaks this month self._current_peak: float = 0.0 @@ -206,21 +308,25 @@ async def async_save(self) -> None: } ) - async def record_quarter_measurement( + async def record_period_measurement( self, power_kw: float, - quarter: int, + period: int, timestamp: datetime, + source: str = POWER_SOURCE_EXTERNAL_METER, ) -> PeakEvent | None: - """Record a 15-minute power measurement. + """Record one completed BILLING PERIOD - an HOUR, which is what the tariff bills. - Only records measurements above minimum threshold to avoid storing - standby power or startup transients as legitimate peaks. + The tariff bills the mean power over a whole hour, so a 15-minute 9 kW hot-water cycle in an + otherwise idle hour is a 3 kW billed peak, not 9 - recording the quarter-hour mean overstates + it and throttles the pump to defend the difference. Args: - power_kw: Power consumption in kW - quarter: Quarter of day (0-95) + power_kw: MEAN power over the hour, in kW + period: the billing hour of the day (0-23) timestamp: Measurement timestamp + source: Where the reading came from. A NIBE-currents peak is a valid CONTROL threshold + but is not whole-house grid import, so it never reaches a billing figure. Returns: PeakEvent if this creates a new monthly peak, None otherwise @@ -235,15 +341,43 @@ async def record_quarter_measurement( ) return None - # Determine if daytime (06:00-22:00) - is_daytime = DAYTIME_START_QUARTER <= quarter <= DAYTIME_END_QUARTER + # A plausibility CEILING, for the same reason the floor exists. A peak is persisted for a + # month and every later hour is judged against it, so one impossible reading (a mis-scaled + # unit once put 5 000 000 kW here) makes every real hour look safe and disables peak + # protection until the month rolls over. Nothing behind a domestic main fuse reaches + # PEAK_RECORDING_MAXIMUM, so no real house is refused. + if power_kw > PEAK_RECORDING_MAXIMUM: + _LOGGER.warning( + "Refusing to record %.0f kW as a tariff peak: no domestic supply can deliver it " + "(ceiling %.0f kW), so this is a sensor fault or a unit-scaling error. Recording " + "it would make every real hour look safe against it and disable peak protection " + "for the rest of the month.", + power_kw, + PEAK_RECORDING_MAXIMUM, + ) + return None + + is_daytime = is_daytime_period(period) + effective_power = effective_tariff_power_kw(power_kw, period) - # Calculate effective power (50% weight at night) - effective_power = power_kw if is_daytime else power_kw * 0.5 + # AT MOST ONE PEAK PER DAY: the monthly charge is the mean of the three highest period + # peaks, one per day - only a day's highest period counts. Date-blind top-3 let one cold Saturday fill all three slots, which + # overstates the bill and understates the margin the pump is then throttled against. + # https://www.ellevio.se/abonnemang/elnatspriser/ny-prismodell-baserad-pa-effekt/ + same_day = next( + (p for p in self._monthly_peaks if p.timestamp.date() == timestamp.date()), + None, + ) + if same_day is not None and effective_power <= same_day.effective_power: + return None # Check if this is a new peak is_new_peak = False - if len(self._monthly_peaks) < 3: + if same_day is not None: + # The day's counted peak is its highest hour; this hour outbills it. + self._monthly_peaks.remove(same_day) + is_new_peak = True + elif len(self._monthly_peaks) < 3: # Haven't filled top 3 yet is_new_peak = True else: @@ -258,10 +392,11 @@ async def record_quarter_measurement( # Create new peak event peak_event = PeakEvent( timestamp=timestamp, - quarter_of_day=quarter, + period_of_day=period, actual_power=power_kw, effective_power=effective_power, is_daytime=is_daytime, + source=source, ) self._monthly_peaks.append(peak_event) @@ -269,11 +404,11 @@ async def record_quarter_measurement( self._monthly_peaks.sort(key=lambda p: p.effective_power, reverse=True) _LOGGER.info( - "New monthly peak #%d: %.2f kW effective (%.2f kW actual) at Q%d %s", + "New monthly peak #%d: %.2f kW effective (%.2f kW actual) in hour %02d, %s", len(self._monthly_peaks), effective_power, power_kw, - quarter, + period, "day" if is_daytime else "night", ) @@ -284,7 +419,7 @@ async def record_quarter_measurement( def should_limit_power( self, current_power: float, - current_quarter: int, + current_period: int, ) -> PowerLimitDecision: """Determine if power should be limited to avoid new 15-minute peak. @@ -292,14 +427,12 @@ def should_limit_power( Args: current_power: Current household power draw (kW) - current_quarter: Current quarter of day (0-95) + current_period: the billing HOUR of the day (0-23) Returns: Decision with limit recommendation and severity """ - # Calculate effective power - is_daytime = DAYTIME_START_QUARTER <= current_quarter <= DAYTIME_END_QUARTER - effective_power = current_power if is_daytime else current_power * 0.5 + effective_power = effective_tariff_power_kw(current_power, current_period) # If no peaks yet, no limit needed if not self._monthly_peaks: @@ -354,20 +487,20 @@ def should_limit_power( def get_peak_protection_offset( self, current_power: float, - current_quarter: int, + current_period: int, base_offset: float, ) -> float: - """Calculate additional offset for 15-minute peak protection. + """Calculate additional offset for HOURLY peak protection. Args: current_power: Current power draw (kW) - current_quarter: Quarter of day (0-95) + current_period: the billing HOUR of the day (0-23) base_offset: Base offset from price optimization Returns: Additional negative offset if needed to reduce consumption """ - decision = self.should_limit_power(current_power, current_quarter) + decision = self.should_limit_power(current_power, current_period) if decision.should_limit: # Return the recommended offset (already negative) @@ -376,6 +509,18 @@ def get_peak_protection_offset( # No additional offset needed return 0.0 + def prune_peaks_for_current_month(self) -> None: + """Drop peaks that belong to a previous month. + + The effect tariff bills a MONTHLY peak, so last month's peaks must not survive into + this one. Must be reachable outside async_load(): an instance that stays up across a + month boundary would otherwise carry the old month's top-3 forward, leaving the + threshold, the peak_this_month sensor and the savings figure all stale. + + Called on the month-change branch of the coordinator's daily rollover. + """ + self._clean_old_peaks() + def _clean_old_peaks(self) -> None: """Remove peaks from previous months.""" now = dt_util.now() @@ -407,18 +552,22 @@ def get_monthly_peak_summary(self) -> MonthlyPeakSummaryDict: return { "count": 0, "highest": 0.0, + "billable": False, "peaks": [], } return { "count": len(self._monthly_peaks), "highest": self._monthly_peaks[0].effective_power, + "billable": all(p.is_billable for p in self._monthly_peaks), "peaks": [ { "timestamp": p.timestamp.isoformat(), "effective_power": p.effective_power, "actual_power": p.actual_power, "is_daytime": p.is_daytime, + "source": p.source, + "billable": p.is_billable, } for p in self._monthly_peaks ], @@ -546,11 +695,10 @@ def evaluate_layer( reason="Disabled by user", ) - # Get current quarter - current_quarter = get_current_quarter() + # The billing period is the HOUR - see the module docstring and BILLING_PERIOD_MINUTES. + current_period = get_current_billing_period() - # Check if approaching monthly 15-minute peak - limit_decision = self.should_limit_power(current_power, current_quarter) + limit_decision = self.should_limit_power(current_power, current_period) # Get thermal trend for predictive analysis trend_rate = thermal_trend.get("rate_per_hour", 0.0) @@ -589,9 +737,17 @@ def evaluate_layer( weight=EFFECT_WEIGHT_CRITICAL, reason=f"CRITICAL ({current_power:.1f}/{current_peak:.1f} kW)", ) - elif predicted_margin < EFFECT_MARGIN_PREDICTIVE and predicted_power_increase > 0: - # PREDICTIVE: Will approach peak in next 15 min - act NOW - # This is the key innovation: prevent spike before it happens + elif ( + current_peak > 0 + and predicted_margin < EFFECT_MARGIN_PREDICTIVE + and predicted_power_increase > 0 + ): + # PREDICTIVE: Will approach peak in next 15 min - act NOW to prevent the spike. + # + # The `current_peak > 0` guard is required: with no peak history current_peak is 0.0, so + # `predicted_margin = 0.0 - predicted_power` is ALWAYS negative and this branch would fire + # on every cooling house from day one, voting a heat-reducing offset that outranks T1/T2 + # recovery. Missing input must abstain, never vote to reduce heat. return EffectLayerDecision( name="Peak", offset=EFFECT_OFFSET_PREDICTIVE, diff --git a/custom_components/effektguard/optimization/prediction_layer.py b/custom_components/effektguard/optimization/prediction_layer.py index 06f2d671..7eee56ea 100644 --- a/custom_components/effektguard/optimization/prediction_layer.py +++ b/custom_components/effektguard/optimization/prediction_layer.py @@ -40,6 +40,9 @@ PREDICTION_THERMAL_RESPONSIVENESS_MIN, PREDICTION_TREND_FALLING_THRESHOLD, PREDICTION_TREND_RISING_THRESHOLD, + PREDICTION_LEARNED_PREHEAT_MIN_HOURS, + PREDICTION_MIN_HISTORY_HOURS, + PREDICTION_RESPONSIVENESS_MIN_HOURS, SAMPLES_PER_HOUR, ) from .learning_types import ( @@ -199,7 +202,7 @@ def predict_temperature( - Uses heat_loss_coefficient for proper heat loss calculation - Uses thermal_decay_rate for natural cooling prediction """ - if len(self.state_history) < 4: # Need at least 1 hour of history + if len(self.state_history) < PREDICTION_MIN_HISTORY_HOURS * SAMPLES_PER_HOUR: # Insufficient data - return simple projection current_temp = self.state_history[-1].indoor_temp if self.state_history else 21.0 return TempPrediction( @@ -465,13 +468,16 @@ def evaluate_layer( Returns: PredictionLayerDecision with learned pre-heating recommendation """ - # Skip if not enough data - if len(self.state_history) < 96: # Less than 24 hours of data + # Skip until a full day of history exists. Derive the sample count from SAMPLES_PER_HOUR: a + # hardcoded 96 is only EIGHT hours at the 5-minute tick, engaging the learned pre-heat on a + # third of a day - and eight hours of a Swedish winter night is not a representative day. + required = PREDICTION_LEARNED_PREHEAT_MIN_HOURS * SAMPLES_PER_HOUR + if len(self.state_history) < required: return PredictionLayerDecision( name="Learned Pre-heat", offset=0.0, weight=0.0, - reason=f"Learning: {len(self.state_history)}/96 observations", + reason=f"Learning: {len(self.state_history)}/{required} observations", ) # Skip if no weather forecast available @@ -570,7 +576,7 @@ def _calculate_thermal_responsiveness(self) -> float: Returns: Responsiveness factor (°C per offset per hour) """ - if len(self.state_history) < 8: # Need 2+ hours + if len(self.state_history) < PREDICTION_RESPONSIVENESS_MIN_HOURS * SAMPLES_PER_HOUR: return PREDICTION_THERMAL_RESPONSIVENESS_DEFAULT # Analyze temperature changes relative to offset diff --git a/custom_components/effektguard/optimization/price_layer.py b/custom_components/effektguard/optimization/price_layer.py index f3ae2b74..b5e157fb 100644 --- a/custom_components/effektguard/optimization/price_layer.py +++ b/custom_components/effektguard/optimization/price_layer.py @@ -31,7 +31,9 @@ PRICE_OFFSET_PEAK, PRICE_OFFSET_VERY_CHEAP, PRICE_PERCENTILE_CHEAP, + PRICE_FLAT_DAY_SPREAD_FRACTION, PRICE_PERCENTILE_EXPENSIVE, + PRICE_PERCENTILE_MEDIAN, PRICE_PERCENTILE_NORMAL, PRICE_PERCENTILE_VERY_CHEAP, PRICE_PRE_PEAK_OFFSET, @@ -61,7 +63,6 @@ "PriceForecast", "PriceLayerDecision", "QuarterPeriod", - "get_fallback_prices", ] @@ -118,36 +119,6 @@ class PriceForecast: in_peak_cluster: bool # True when EXPENSIVE sandwiched between PEAKs -def get_fallback_prices() -> PriceData: - """Get fallback price data when spot price unavailable. - - Returns neutral price classification to maintain safe operation - without optimization. All periods are set to price=1.0 (normalized). - - Moved from coordinator._get_fallback_prices for shared reuse. - - Returns: - PriceData with 96 neutral-priced quarters for today, empty tomorrow - """ - _LOGGER.debug("Creating fallback price data (no optimization)") - - # Create neutral periods - all classified as "normal" - fallback_periods = [] - base_date = dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) - - for quarter in range(96): # 96 quarters per day (15-min intervals) - hour = quarter // 4 - minute = (quarter % 4) * MINUTES_PER_QUARTER - start_time = base_date.replace(hour=hour, minute=minute) - fallback_periods.append(QuarterPeriod(start_time=start_time, price=1.0)) - - return PriceData( - today=fallback_periods, - tomorrow=[], - has_tomorrow=False, - ) - - @dataclass class PriceLayerDecision: """Decision from price/spot optimization layer. @@ -240,31 +211,42 @@ def classify_quarterly_periods( p90, ) - # Special case: Uniform prices (all equal) - happens with fallback mode - # When spot price unavailable, fallback creates 96 periods with price=1.0 - # Without variance, classification is meaningless - mark all as NORMAL - if p25 == p90: # No price variance + median = float(np.percentile(prices, PRICE_PERCENTILE_MEDIAN)) + + # A flat day carries no signal, and percentile RANK cannot detect one (rank is + # scale-invariant: a 0.4 ore spread bands like a 130 ore one). Compare the spread against the + # day's own price SCALE, not an absolute ore threshold - PriceData carries no unit (GE-Spot + # publishes whatever the owner configured), so an ore threshold would be 100x wrong. + spread = p90 - p10 + scale = max(abs(median), abs(p10), abs(p90)) + if scale <= 0.0 or spread < scale * PRICE_FLAT_DAY_SPREAD_FRACTION: _LOGGER.info( - "Uniform prices detected (%.3f), classifying all periods as NORMAL (no optimization)", - p25, + "Price spread %.3f is negligible against a scale of %.3f - classifying every " + "period NORMAL rather than chasing ranking noise", + spread, + scale, ) return {index: QuarterClassification.NORMAL for index, _ in enumerate(periods)} - # Classify each period - # Order: VERY_CHEAP (bottom 10%) -> CHEAP (10-25%) -> NORMAL (25-75%) -> - # EXPENSIVE (75-90%) -> PEAK (top 10%) + # Classify each period. A band must not be a pure RANK: on a high-wind day the distribution + # is a step, not a curve (e.g. 83 quarters at 120 ore, 13 at -10), so p25 == p75 == p90 and + # the 83 dearest quarters all satisfy `price <= p25` - on rank alone they'd classify CHEAP + # and command +4 C at the most expensive moment. The `price < p90` guard on the CHEAP band + # stops that (the spread check above guarantees p90 > p10). The dear side keeps strict `>`: + # an inescapable plateau is the price of the day, not a PEAK to coast through. classifications = {} for index, period in enumerate(periods): - if period.price <= p10: + price = period.price + if price <= p10: classification = QuarterClassification.VERY_CHEAP - elif period.price <= p25: + elif price <= p25 and price < p90: classification = QuarterClassification.CHEAP - elif period.price <= p75: - classification = QuarterClassification.NORMAL - elif period.price <= p90: + elif price > p90: + classification = QuarterClassification.PEAK + elif price > p75: classification = QuarterClassification.EXPENSIVE else: - classification = QuarterClassification.PEAK + classification = QuarterClassification.NORMAL classifications[index] = classification diff --git a/custom_components/effektguard/optimization/savings_calculator.py b/custom_components/effektguard/optimization/savings_calculator.py index b959ecc1..06e153df 100644 --- a/custom_components/effektguard/optimization/savings_calculator.py +++ b/custom_components/effektguard/optimization/savings_calculator.py @@ -14,7 +14,6 @@ from ..const import ( BASELINE_EMA_WEIGHT_NEW, BASELINE_EMA_WEIGHT_OLD, - BASELINE_PEAK_MULTIPLIER, DAYS_PER_MONTH, ORE_TO_SEK_CONVERSION, PRICE_MAINUNIT_PREFIXES, @@ -34,6 +33,10 @@ class SavingsEstimate: spot_savings: float # Savings from spot price optimization (SEK) baseline_cost: float # Estimated cost without optimization (SEK) optimized_cost: float # Estimated cost with optimization (SEK) + # False when no unoptimized baseline peak has ever been observed, so the effect-tariff half of + # this estimate is not a measurement and is reported as zero rather than guessed. See + # estimate_monthly_savings. + effect_baseline_measured: bool = False class SavingsCalculator: @@ -45,27 +48,38 @@ class SavingsCalculator: - Observed peak reductions and price avoidance """ - def price_to_main_unit_factor(self) -> float: - """Factor converting the configured price unit to the main currency. + def price_to_main_unit_factor(self) -> float | None: + """Factor converting the configured price unit to the main currency, or None. - GE-Spot preserves the user's display unit: öre/cent-style sub-units - divide by 100; SEK/EUR-style main units pass through. Unknown units - keep the historical öre/kWh assumption so existing Swedish setups - are unaffected, with a one-time log. + Sub-units (öre/cent) divide by 100; main units (SEK/EUR/NOK/DKK) pass through. + + An unrecognised or absent unit returns None - we do NOT guess. The old fallback + assumed öre/kWh, but EVERY price integration publishes `/kWh` by default: + + Nord Pool (HA core) -> SEK/kWh (no cents option exists at all) + custom-components/nordpool -> SEK/kWh (öre only if price_in_cents: true) + GE-Spot -> SEK/kWh (öre only if display format = subunit) + + So the öre assumption was 100x WRONG against all three. It fired whenever + `price_unit` was None - which it is until the first successful price read. Reporting + a savings figure that is 100x too large is worse than reporting none: skip the + accumulation instead. """ unit = (self.price_unit or "").lower().replace(" ", "") if unit.startswith(PRICE_SUBUNIT_PREFIXES): return 1.0 / ORE_TO_SEK_CONVERSION if unit.startswith(PRICE_MAINUNIT_PREFIXES): return 1.0 + if not self._unknown_unit_logged: self._unknown_unit_logged = True - _LOGGER.info( - "Price unit '%s' not recognized - assuming öre/kWh-style " - "sub-unit for savings math (ranking is unaffected)", + _LOGGER.warning( + "Price unit %r not recognised - skipping monetary savings for this cycle " + "rather than guessing. Price-based OPTIMIZATION is unaffected (it ranks " + "prices and does not need the unit).", self.price_unit, ) - return 1.0 / ORE_TO_SEK_CONVERSION + return None @property def is_sek_price_unit(self) -> bool: @@ -112,16 +126,30 @@ def estimate_monthly_savings( Returns: SavingsEstimate with breakdown """ - # Estimate baseline peak if not provided - if baseline_peak_kw is None: - # Conservative estimate: optimization typically reduces peak by 10-15% - # Using 15% reduction assumption from const.py - baseline_peak_kw = current_peak_kw * BASELINE_PEAK_MULTIPLIER - - # Calculate effect tariff savings - # Reduction in peak × monthly cost per kW + # THE EFFECT-TARIFF SAVING WAS FABRICATED, AND IT COULD NOT READ ZERO. + # + # With no observed baseline the code assumed one: + # + # baseline_peak_kw = current_peak_kw * BASELINE_PEAK_MULTIPLIER # 1.176 + # + # and `update_baseline_peak` - the only thing that could ever set a real baseline - had NO + # production caller, so the assumption fired every single time. The arithmetic then reduces + # to `effect_savings = 0.176 * current_peak * tariff`: a number computed from the peak + # itself, which means a HIGHER peak reported MORE "savings", and the sensor could never + # read zero however badly the optimiser was doing. It was unfalsifiable. + # + # This module already refuses to guess a price unit for exactly this reason. The same rule + # applies here: with no baseline there is no measured saving, and zero is the honest number. + # The coordinator now feeds `update_baseline_peak` from the quarters recorded while the + # optimisation switch is OFF - the offset is held at 0.0 then, so those peaks genuinely are + # what the house does unoptimised. + effect_baseline_measured = baseline_peak_kw is not None + + if not effect_baseline_measured: + baseline_peak_kw = current_peak_kw + peak_reduction_kw = baseline_peak_kw - current_peak_kw - effect_savings = max(0, peak_reduction_kw * self.effect_tariff_sek_per_kw_month) + effect_savings = max(0.0, peak_reduction_kw * self.effect_tariff_sek_per_kw_month) # Calculate spot price savings (30 days) spot_savings = average_spot_savings_per_day * DAYS_PER_MONTH @@ -150,6 +178,7 @@ def estimate_monthly_savings( spot_savings=round(spot_savings, 0), baseline_cost=round(baseline_cost, 0), optimized_cost=round(optimized_cost, 0), + effect_baseline_measured=effect_baseline_measured, ) def calculate_spot_savings_per_cycle( @@ -190,8 +219,12 @@ def calculate_spot_savings_per_cycle( cycle_hours = cycle_minutes / 60.0 energy_kwh = actual_power_kw * cycle_hours - # Actual cost at current price + # Actual cost at current price. An unrecognised unit yields None - report no + # savings rather than a figure that could be 100x out. to_main = self.price_to_main_unit_factor() + if to_main is None: + return 0.0 + actual_cost = energy_kwh * current_price * to_main # What it would have cost at average price (baseline) diff --git a/custom_components/effektguard/optimization/thermal_layer.py b/custom_components/effektguard/optimization/thermal_layer.py index 8a47932c..92345510 100644 --- a/custom_components/effektguard/optimization/thermal_layer.py +++ b/custom_components/effektguard/optimization/thermal_layer.py @@ -12,7 +12,14 @@ from datetime import datetime, timedelta from typing import Callable, Optional, Protocol +from homeassistant.util import dt as dt_util + from ..const import ( + THERMAL_MASS_CONCRETE_UFH_THRESHOLD, + THERMAL_MASS_TIMBER_UFH_THRESHOLD, + UFH_CONCRETE_PREDICTION_HORIZON, + UFH_RADIATOR_PREDICTION_HORIZON, + UFH_TIMBER_PREDICTION_HORIZON, ANTI_WINDUP_CAUSATION_WINDOW_MINUTES, ANTI_WINDUP_COOLDOWN_MINUTES, ANTI_WINDUP_DM_DROPPING_RATE, @@ -39,6 +46,7 @@ DM_THRESHOLD_AUX_LIMIT, LAYER_WEIGHT_EMERGENCY, LAYER_WEIGHT_PROACTIVE_MIN, + MIN_OFFSET, MULTIPLIER_BOOST_30_PERCENT, MULTIPLIER_REDUCTION_20_PERCENT, PROACTIVE_ZONE1_OFFSET, @@ -100,7 +108,7 @@ DM_RECOVERY_RATE_MILD, DM_RECOVERY_RATE_VERY_COLD, ) -from .climate_zones import ClimateZoneDetector +from .climate_zones import ClimateZoneDetector, keep_triggers_clear_of_the_compressor_band from ..utils.time_utils import resolve_period_index from ..utils.volatile_helpers import should_skip_volatile_boost @@ -234,7 +242,10 @@ class EmergencyLayerDecision: weight: float reason: str # Additional diagnostic fields - tier: str = "" # "T1", "T2", "T3", "WARNING", "CAUTION", "OK" + # Authoritative discriminator for safety dispatch in the decision engine. + # "EMERGENCY" (DM past the aux limit), "T3", "T2", "T1", "WARNING", "CAUTION", + # "COOLDOWN", "ANTI_WINDUP", "OK". See const.DM_TIER_EMERGENCY / DM_RECOVERY_TIERS. + tier: str = "" degree_minutes: float = 0.0 threshold_used: float = 0.0 damping_applied: bool = False @@ -288,15 +299,62 @@ def __init__( self.insulation_quality = insulation_quality def get_prediction_horizon(self) -> float: - """Get prediction horizon for weather forecasting. + """How far ahead this house must look to act in time - heavier fabric, longer lag. - Base implementation returns default 12 hours. - AdaptiveThermalModel overrides this with UFH-type-specific values. + A concrete slab's slow time constant (~70 h) makes six hours its LAG, not its horizon: a + flat 12 h window cannot see the slow, deep outdoor slide that actually drains it (only a + sudden plunge, which the pump's own curve already catches), so the pre-heat never fires. + Horizon is UFH-type-specific; see docs/research/03_concrete_slab_response.md and the + UFH_*_PREDICTION_HORIZON constants. Returns: - Prediction horizon in hours (default 12.0) + Prediction horizon in hours. """ - return 12.0 # Default medium horizon + if self.thermal_mass >= THERMAL_MASS_CONCRETE_UFH_THRESHOLD: + return UFH_CONCRETE_PREDICTION_HORIZON + if self.thermal_mass >= THERMAL_MASS_TIMBER_UFH_THRESHOLD: + return UFH_TIMBER_PREDICTION_HORIZON + return UFH_RADIATOR_PREDICTION_HORIZON + + +def apply_thermal_mass_buffer(base_thresholds: dict, heating_type: str) -> dict: + """Move the degree-minute thresholds to suit how slowly this house responds. + + The slower the emitter, the SOONER it must start recovering. Degree minutes are NEGATIVE, so the + buffer DIVIDES (multiplying would deepen the threshold and delay the response). + + Shared by EmergencyLayer and ProactiveLayer on purpose: a threshold is a property of the house, + not the layer. When only one layer applied the buffer there was a band of DM in which neither + responded. + + The divide can undo the warm-side ceiling, so keep_triggers_clear_of_the_compressor_band is + re-applied HERE, on the value the layers actually read - clamping before the last thing that + changes it is no clamp at all (e.g. -110 / 1.3 = -85, back inside the compressor's own cycling + band, once flagged a warm-morning slab house as in thermal debt). + + Args: + base_thresholds: Climate-aware thresholds from ClimateZoneDetector + heating_type: "radiator", "concrete_ufh", "concrete_slab", "timber", "timber_ufh" + + Returns: + The same thresholds, moved to suit the emitter's thermal lag. + """ + if heating_type in ("concrete_ufh", "concrete_slab"): + multiplier = DM_THERMAL_MASS_BUFFER_CONCRETE + elif heating_type in ("timber", "timber_ufh"): + multiplier = DM_THERMAL_MASS_BUFFER_TIMBER + else: + multiplier = DM_THERMAL_MASS_BUFFER_RADIATOR + + return keep_triggers_clear_of_the_compressor_band( + { + "normal_min": base_thresholds["normal_min"] / multiplier, + "normal_max": base_thresholds["normal_max"] / multiplier, + "warning": base_thresholds["warning"] / multiplier, + # The auxiliary-heat limit is hardware. It is the same for every emitter. + "critical": base_thresholds["critical"], + } + ) class EmergencyLayer: @@ -319,6 +377,14 @@ class EmergencyLayer: Absolute maximum DM -1500 is ALWAYS enforced regardless of conditions. This is the hard safety limit validated by Swedish NIBE forums. + + VOLATILE-PRICE SUPPRESSION (deliberate smoothness/recovery trade-off): when the current + spot-price run is shorter than VOLATILE_MIN_DURATION_QUARTERS, should_skip_volatile_boost() + zeroes the T1/T2/T3 recovery offset and cuts weight to VOLATILE_WEIGHT_REDUCTION, to avoid + compressor cycling on brief windows. Cost: DM may keep falling during a volatile run. Bounded + because the DM <= DM_THRESHOLD_AUX_LIMIT check at the TOP of evaluate_layer returns first, so the + EMERGENCY tier is never suppressed. If a DM spiral ever coincides with short price runs, look + here first. """ def __init__( @@ -601,7 +667,10 @@ def evaluate_layer( outdoor_temp = nibe_state.outdoor_temp indoor_temp = nibe_state.indoor_temp current_offset = getattr(nibe_state, "current_offset", 0.0) - timestamp = getattr(nibe_state, "timestamp", datetime.now()) + # dt_util.utcnow(), never a naive datetime.now(): this fallback feeds the anti-windup + # causation window, and a naive datetime there raises TypeError inside the emergency layer - + # the one path that must never fail. + timestamp = getattr(nibe_state, "timestamp", None) or dt_util.utcnow() # Track offset changes for causation detection (Jan 2026) # This records when offset was raised to distinguish self-induced spirals @@ -619,6 +688,26 @@ def evaluate_layer( temp_deviation = indoor_temp - target_temp + # ======================================== + # HARD LIMIT: DM -1500 absolute maximum (never exceed) + # ======================================== + # MUST come before every other branch: the anti-windup and "too warm" cases return early, so + # placing any of them first makes the hard limit unenforceable (e.g. "too warm" trips at only + # tolerance_range over target, silencing this on a solar-gain morning during a debt spiral). + # Past this threshold NIBE engages the aux immersion heater; declining to respond guarantees + # it rather than preventing it. + if degree_minutes <= DM_THRESHOLD_AUX_LIMIT: + return EmergencyLayerDecision( + name="Thermal Debt", + offset=SAFETY_EMERGENCY_OFFSET, + weight=1.0, + reason=f"EMERGENCY: DM {degree_minutes:.0f} at aux limit {DM_THRESHOLD_AUX_LIMIT}", + tier="EMERGENCY", + degree_minutes=degree_minutes, + threshold_used=DM_THRESHOLD_AUX_LIMIT, + dm_rate=dm_rate, + ) + # ======================================== # ANTI-WINDUP: Prevent offset raises that make DM worse (Jan 2026 fix) # ======================================== @@ -667,7 +756,7 @@ def evaluate_layer( reduction = ( abs(dm_rate) / ANTI_WINDUP_REDUCTION_RATE_DIVISOR ) * ANTI_WINDUP_REDUCTION_MULTIPLIER - new_offset = max(-10.0, current_offset - reduction) # Floor at MIN_OFFSET + new_offset = max(MIN_OFFSET, current_offset - reduction) reason = ( f"DM dropping {dm_rate:.0f}/h - reducing offset by {reduction:.1f}°C " f"(from +{current_offset:.0f}°C to {new_offset:.1f}°C)" @@ -700,8 +789,14 @@ def evaluate_layer( dm_rate=dm_rate, ) + # Cases 1 and 2 reason about indoor comfort, so both need a REAL indoor reading. Without a + # room sensor the adapter reports DEFAULT_INDOOR_TEMP (== target), so temp_deviation == 0.0 + # and Case 2's `>= 0` gate would permanently disable DM protection on exactly the systems + # that depend on it most. Skip both and use the degree-minute tiers, as NIBE does sensorless. + indoor_is_measured = getattr(nibe_state, "indoor_temp_valid", True) + # Case 1: Too warm (above tolerance) - if temp_deviation > tolerance_range: + if indoor_is_measured and temp_deviation > tolerance_range: return EmergencyLayerDecision( name="Thermal Debt", offset=0.0, @@ -713,7 +808,7 @@ def evaluate_layer( # Case 2: At target + Not cheap (and not at absolute limit) # Use _is_price_cheap to check current price classification - if temp_deviation >= 0 and degree_minutes > DM_THRESHOLD_AUX_LIMIT: + if indoor_is_measured and temp_deviation >= 0 and degree_minutes > DM_THRESHOLD_AUX_LIMIT: if not self._is_price_cheap(price_data, get_current_datetime): return EmergencyLayerDecision( name="Thermal Debt", @@ -724,18 +819,6 @@ def evaluate_layer( degree_minutes=degree_minutes, ) - # HARD LIMIT: DM -1500 absolute maximum (never exceed) - if degree_minutes <= DM_THRESHOLD_AUX_LIMIT: - return EmergencyLayerDecision( - name="Thermal Debt", - offset=SAFETY_EMERGENCY_OFFSET, - weight=1.0, - reason=f"EMERGENCY: DM {degree_minutes:.0f} at aux limit -1500", - tier="EMERGENCY", - degree_minutes=degree_minutes, - threshold_used=DM_THRESHOLD_AUX_LIMIT, - ) - # Calculate context-aware thresholds based on outdoor temperature expected_dm_range = self.climate_detector.get_expected_dm_range(outdoor_temp) adjusted_dm_range = self._get_thermal_mass_adjusted_thresholds(expected_dm_range) @@ -1020,38 +1103,12 @@ def _is_price_cheap(self, price_data, get_current_datetime: Optional[Callable] = return False def _get_thermal_mass_adjusted_thresholds(self, base_thresholds: dict) -> dict: - """Adjust DM thresholds based on thermal mass. - - High thermal mass systems need tighter thresholds because: - - Long thermal lag (6+ hours for concrete slab) - - Current DM doesn't immediately affect indoor temperature - - Solar gain can mask underlying thermal debt accumulation - - Args: - base_thresholds: Climate-aware thresholds from ClimateZoneDetector - - Returns: - Adjusted thresholds with thermal mass buffer applied - """ - if self.heating_type in ("concrete_ufh", "concrete_slab"): - multiplier = DM_THERMAL_MASS_BUFFER_CONCRETE - elif self.heating_type in ("timber", "timber_ufh"): - multiplier = DM_THERMAL_MASS_BUFFER_TIMBER - else: - multiplier = DM_THERMAL_MASS_BUFFER_RADIATOR - - adjusted = { - "normal_min": base_thresholds["normal_min"] * multiplier, - "normal_max": base_thresholds["normal_max"] * multiplier, - "warning": base_thresholds["warning"] * multiplier, - "critical": base_thresholds["critical"], # Never adjust absolute maximum - } + """Thresholds moved to suit this house's thermal lag. See apply_thermal_mass_buffer.""" + adjusted = apply_thermal_mass_buffer(base_thresholds, self.heating_type) _LOGGER.debug( - "Thermal mass adjusted thresholds: heating type '%s' (multiplier %.2f) " - "→ warning %.0f (base: %.0f)", + "Thermal mass adjusted thresholds: heating type '%s' → warning %.0f (base: %.0f)", self.heating_type, - multiplier, adjusted["warning"], base_thresholds["warning"], ) @@ -1253,14 +1310,19 @@ def __init__( self, climate_detector: ClimateZoneDetector, get_thermal_trend: Optional[Callable[[], dict]] = None, + heating_type: str = "radiator", ): """Initialize proactive layer. Args: climate_detector: ClimateZoneDetector for context-aware thresholds get_thermal_trend: Callable returning thermal trend dict + heating_type: Heating system type. The proactive layer must read the SAME thresholds + as the emergency layer, or a band of degree minutes falls between them in which + neither responds. """ self.climate_detector = climate_detector + self.heating_type = heating_type self._get_thermal_trend = get_thermal_trend or ( lambda: {"rate_per_hour": 0.0, "confidence": 0.0} ) @@ -1591,7 +1653,9 @@ def _calculate_expected_dm_for_temperature(self, outdoor_temp: float) -> dict: Returns: Dictionary with normal and warning thresholds """ - dm_range = self.climate_detector.get_expected_dm_range(outdoor_temp) + dm_range = apply_thermal_mass_buffer( + self.climate_detector.get_expected_dm_range(outdoor_temp), self.heating_type + ) return { "normal": dm_range["normal_max"], diff --git a/custom_components/effektguard/optimization/weather_layer.py b/custom_components/effektguard/optimization/weather_layer.py index 233f1a23..cd3616f5 100644 --- a/custom_components/effektguard/optimization/weather_layer.py +++ b/custom_components/effektguard/optimization/weather_layer.py @@ -1,21 +1,18 @@ -"""Weather compensation calculations for optimal flow temperature. +"""Weather compensation: the flow temperature the emitters need for the current weather. TODO: Rename this module to flow_temp_layer.py and classes to FlowTemp* for clarity. "Weather compensation" is confusing - this layer optimizes FLOW TEMPERATURE based on outdoor conditions, not weather forecasting. (Dec 19, 2025) -Implements scientifically-validated mathematical formulas from OpenEnergyMonitor research: -1. André Kühne's Universal Formula (validated across Vaillant, Daikin, Mitsubishi, NIBE) -2. Timbones' Heat Transfer Method (radiator output approach) -3. UFH-specific flow temperature adjustments -4. Adaptive Climate System (combines universal zones with weather learning) - -References: - - Mathematical_Enhancement_Summary.md - - OpenEnergyMonitor.org community research - - Timbones' calculation spreadsheet - - HeatpumpMonitor.org performance data - - POST_PHASE_5_ROADMAP.md Phase 6 - Adaptive learning +The flow temperature comes from the EN 442 emitter law (see utils/emitter.py), anchored either +on the emitters' rated output or on the system's design point. This layer then: + +1. adds a climate-zone safety margin (latitude-derived, globally applicable), +2. converts the result into a curve OFFSET relative to what the pump is currently delivering, +3. defers to thermal reality by shedding weight when degree minutes show real thermal debt. + +The offset is a TRIM on the pump's own heating curve, not a replacement for it: if the curve is +correctly tuned the correction is near zero, and it is bounded either way. """ import logging @@ -27,18 +24,17 @@ from ..const import ( DEFAULT_CURVE_SENSITIVITY, + DEFAULT_DESIGN_FLOW_TEMP_RADIATOR, + DEFAULT_DESIGN_FLOW_TEMP_UFH, + DEFAULT_DESIGN_OUTDOOR_TEMP, + DEFAULT_DESIGN_SPREAD, DEFAULT_HEAT_LOSS_COEFFICIENT, DEFAULT_WEATHER_COMPENSATION_WEIGHT, DHW_WEATHER_COOLDOWN_MINUTES, - KUEHNE_COEFFICIENT, - KUEHNE_POWER, LAYER_WEIGHT_WEATHER_PREDICTION, RADIATOR_POWER_COEFFICIENT, RADIATOR_RATED_DT, - UFH_FLOW_REDUCTION_CONCRETE, - UFH_FLOW_REDUCTION_TIMBER, - UFH_MIN_FLOW_TEMP_CONCRETE, - UFH_MIN_FLOW_TEMP_TIMBER, + UFH_POWER_COEFFICIENT, WEATHER_COMP_DEFER_DM_CRITICAL, WEATHER_COMP_DEFER_DM_LIGHT, WEATHER_COMP_DEFER_DM_MODERATE, @@ -47,12 +43,17 @@ WEATHER_COMP_DEFER_WEIGHT_LIGHT, WEATHER_COMP_DEFER_WEIGHT_MODERATE, WEATHER_COMP_DEFER_WEIGHT_SIGNIFICANT, + WEATHER_COMP_MAX_OFFSET, WEATHER_FORECAST_DROP_THRESHOLD, + BALANCE_POINT_MAX_OFFSET, + BALANCE_POINT_MIN_OFFSET, + INTERNAL_GAINS_W, WEATHER_FORECAST_HORIZON, WEATHER_GENTLE_OFFSET, WEATHER_INDOOR_COOLING_CONFIRMATION, WEATHER_WEIGHT_CAP, ) +from ..utils.emitter import en442_flow_temp from .climate_zones import ClimateZoneDetector _LOGGER = logging.getLogger(__name__) @@ -96,11 +97,9 @@ class WeatherCompensationLayerDecision: """Decision from the mathematical weather compensation layer. Encapsulates the flow temperature optimization based on: - - Universal flow temperature formula (André Kühne) - - Heat transfer method (Timbones, if radiator specs available) - - UFH-specific adjustments - - Adaptive climate zones - - Weather learning (unusual pattern detection) + - the EN 442 emitter law (rated-output anchor, or the system design point) + - adaptive climate zones + - weather learning (unusual pattern detection) """ name: str @@ -120,21 +119,23 @@ class FlowTempCalculation: """Result of flow temperature calculation with reasoning.""" flow_temp: float # Calculated optimal flow temperature (°C) - method: str # Calculation method used + method: str # "en442_rated_output" or "en442_design_point" heating_type: str # "radiator", "concrete_ufh", "timber_ufh", etc. confidence: float # 0-1 confidence in calculation reasoning: str # Explanation of calculation - raw_kuehne: Optional[float] = None # Raw Kühne result before adjustments - raw_timbones: Optional[float] = None # Raw Timbones result before adjustments + raw_design_point: Optional[float] = None # EN 442 anchored on the system design point + raw_rated_output: Optional[float] = None # EN 442 anchored on the emitters' rated output class WeatherCompensationCalculator: """Calculate optimal flow temperatures using validated mathematical formulas. - Implements three complementary methods: - 1. André Kühne's formula - Universal physics-based calculation - 2. Timbones' method - Radiator-specific heat transfer approach - 3. UFH adjustments - Specialized underfloor heating optimization + One law - the EN 442 emitter characteristic equation - with two anchors: + 1. the emitters' rated output at DT50, when the installer has supplied it (preferred); + 2. otherwise the system's design point (design flow temperature at the design outdoor temp). + + Underfloor heating is handled by its own exponent (EN 1264, n ~ 1.1) and its own design flow + temperature, not by subtracting a fixed amount from a radiator curve. """ def __init__( @@ -142,272 +143,233 @@ def __init__( heat_loss_coefficient: float = DEFAULT_HEAT_LOSS_COEFFICIENT, radiator_rated_output: Optional[float] = None, heating_type: str = "radiator", + design_outdoor_temp: float = DEFAULT_DESIGN_OUTDOOR_TEMP, + design_flow_temp: Optional[float] = None, + design_spread: float = DEFAULT_DESIGN_SPREAD, + internal_gains_w: float = INTERNAL_GAINS_W, ): """Initialize weather compensation calculator. Args: heat_loss_coefficient: Building heat loss in W/°C (typical 100-300) - radiator_rated_output: Total rated radiator output at DT50 in Watts + radiator_rated_output: Total rated emitter output at DT50 in Watts heating_type: "radiator", "concrete_ufh", "timber_ufh", "mixed" + design_outdoor_temp: Dimensioning outdoor temperature (DUT/DVUT, °C) + design_flow_temp: Supply temperature needed at the design outdoor temperature (°C). + Defaults by emitter type, since an underfloor system is dimensioned far cooler + than radiators. + design_spread: Flow-return spread at the design load (°C) + internal_gains_w: Free heat from bodies, appliances and the sun (W). Set to 0.0 to + reproduce the UK reference tools (OpenEnergyMonitor's WeatherComp, Timbones' + spreadsheet), which carry no gains term. A real house is not gains-free; see const.py. """ self.heat_loss_coefficient = heat_loss_coefficient self.radiator_rated_output = radiator_rated_output + self.internal_gains_w = internal_gains_w self.heating_type = heating_type + self.design_outdoor_temp = design_outdoor_temp + self.design_spread = design_spread + + # Underfloor heating has its own emitter exponent (EN 1264, n ~ 1.1) and a far lower + # design flow temperature. Its lower temperatures belong HERE, in the curve itself - not + # as a fixed subtraction applied to a radiator curve afterwards. + self.is_underfloor = heating_type in ("concrete_ufh", "timber_ufh", "timber") + self.emitter_exponent = ( + UFH_POWER_COEFFICIENT if self.is_underfloor else RADIATOR_POWER_COEFFICIENT + ) + if design_flow_temp is not None: + self.design_flow_temp = design_flow_temp + elif self.is_underfloor: + self.design_flow_temp = DEFAULT_DESIGN_FLOW_TEMP_UFH + else: + self.design_flow_temp = DEFAULT_DESIGN_FLOW_TEMP_RADIATOR _LOGGER.debug( - "WeatherCompensationCalculator initialized: HC=%.1f W/°C, " - "radiator_output=%s W, type=%s", + "WeatherCompensationCalculator initialized: type=%s, design %.1f°C @ %.1f°C outdoor, " + "spread=%.1f°C, emitter exponent n=%.2f, HC=%.1f W/°C, rated_output=%s W", + heating_type, + self.design_flow_temp, + self.design_outdoor_temp, + self.design_spread, + self.emitter_exponent, heat_loss_coefficient, radiator_rated_output, - heating_type, ) - def calculate_kuehne_flow_temp( + def balance_point_temp(self, indoor_setpoint: float) -> float: + """Outdoor temperature at which this house needs no heat at all. + + Derived from watts, not fitted to a curve: + + balance = indoor_setpoint - internal_gains_W / heat_loss_W_per_K + + Gains are watts divided by the house's own heat loss, so the same free heat is worth FEWER + degrees in a leaky house, not more; a fixed offset in degrees gets that backwards. The + balance point cannot be recovered by fitting a heating curve - the constant-spread term has + the same shape and opposite sign (see `utils/emitter.py`). + + Args: + indoor_setpoint: Target indoor temperature (°C) + + Returns: + Balance-point outdoor temperature (°C) + """ + if self.internal_gains_w <= 0.0: + return indoor_setpoint # gains disabled: the UK reference tools' demand model + + offset = self.internal_gains_w / self.heat_loss_coefficient + offset = max(BALANCE_POINT_MIN_OFFSET, min(offset, BALANCE_POINT_MAX_OFFSET)) + return indoor_setpoint - offset + + def calculate_design_point_flow_temp( self, indoor_setpoint: float, outdoor_temp: float, ) -> float: - """Calculate optimal flow temperature using André Kühne's universal formula. + """Flow temperature from the EN 442 emitter law, anchored on the system design point. - Formula: TFlow = 2.55 × (HC × (Tset - Tout))^0.78 + Tset - - Note: HC must be in kW/K for correct results! - - Validated across manufacturers: Vaillant, Daikin, Mitsubishi, NIBE. - Based on heat transfer physics, not manufacturer-specific curves. + The default path: it needs only quantities an installer knows (the dimensioning outdoor + temperature, the supply temperature the system needs at it, the flow-return spread, and + the emitter type), and by construction it reproduces the design flow temperature at the + design outdoor temperature - so a correctly tuned pump curve gets a near-zero correction. Args: indoor_setpoint: Target indoor temperature (°C) outdoor_temp: Current outdoor temperature (°C) Returns: - Optimal flow temperature (°C) - - References: - Mathematical_Enhancement_Summary.md: André Kühne's formula - OpenEnergyMonitor community validation data + Required flow temperature (°C) """ - # Calculate temperature differential - temp_diff = indoor_setpoint - outdoor_temp - - # Ensure positive differential (can't heat when outdoor > indoor setpoint) - if temp_diff <= 0: - return indoor_setpoint - - # André Kühne's formula - # TFlow = 2.55 × (HC × (Tset - Tout))^0.78 + Tset - # Convert heat loss coefficient from W/°C to kW/K - heat_loss_kw = self.heat_loss_coefficient / 1000.0 - heat_term = heat_loss_kw * temp_diff - flow_temp = KUEHNE_COEFFICIENT * (heat_term**KUEHNE_POWER) + indoor_setpoint + flow_temp = en442_flow_temp( + indoor_setpoint=indoor_setpoint, + outdoor_temp=outdoor_temp, + design_outdoor_temp=self.design_outdoor_temp, + design_flow_temp=self.design_flow_temp, + design_spread=self.design_spread, + emitter_exponent=self.emitter_exponent, + balance_point_temp=self.balance_point_temp(indoor_setpoint), + ) _LOGGER.debug( - "Kühne formula: outdoor=%.1f°C, indoor_target=%.1f°C, " - "temp_diff=%.1f°C, HC=%.3f kW/K -> flow=%.1f°C", + "EN 442 (design point): outdoor=%.1f°C, target=%.1f°C, design=%.1f°C@%.1f°C, " + "spread=%.1f°C, n=%.2f -> flow=%.1f°C", outdoor_temp, indoor_setpoint, - temp_diff, - heat_loss_kw, + self.design_flow_temp, + self.design_outdoor_temp, + self.design_spread, + self.emitter_exponent, flow_temp, ) return flow_temp - def calculate_timbones_flow_temp( + def calculate_rated_output_flow_temp( self, indoor_setpoint: float, outdoor_temp: float, - flow_return_dt: float = 5.0, + flow_return_dt: float, ) -> Optional[float]: - """Calculate optimal flow temperature using Timbones' heat transfer method. + """The same EN 442 law, parameterised by the emitters' rated output instead. + + When the installer knows the total rated emitter output at ΔT50, the law can be anchored + on the EN 442 rating point directly: - Based on radiator output calculations and heat loss coefficient. - Requires radiator_rated_output to be configured. + ΔT = ΔT_N × (Φ / Φ_N) ** (1 / n) - Formula: - 1. Heat demand = heat_loss_coefficient × (indoor - outdoor) - 2. Required DT = 50K × (heat_demand / radiator_output)^(1/1.3) - 3. Flow temp = indoor + required_DT + (flow_return_dt / 2) + Preferred over the design-point form, because Φ_N is a measured nameplate figure. Same + physics either way - both invert `Φ / Φ_N = (ΔT / ΔT_N) ** n`; only the anchor differs. Args: indoor_setpoint: Target indoor temperature (°C) outdoor_temp: Current outdoor temperature (°C) - flow_return_dt: Design flow-return temperature differential (°C) + flow_return_dt: Flow-return spread at the design load (°C) Returns: - Optimal flow temperature (°C), or None if radiator output not configured - - References: - Timbones' calculation spreadsheet (OpenEnergyMonitor community) - Radiator output formula: Heat = Rated × (DT/50K)^1.3 + Required flow temperature (°C), or None if the rated output is not configured. """ - if self.radiator_rated_output is None: - _LOGGER.debug("Timbones method requires radiator_rated_output configuration") + if self.radiator_rated_output is None or self.radiator_rated_output <= 0: return None - # Calculate heat demand - temp_diff = indoor_setpoint - outdoor_temp - if temp_diff <= 0: - return indoor_setpoint - - heat_demand = self.heat_loss_coefficient * temp_diff + # The SAME balance point the design-point anchor uses. This is the PREFERRED path + # (confidence 0.95), so a gains term applied to only the other anchor would leave the two + # anchors of "the same law" disagreeing for every installer who supplied a rated output. + load = self.balance_point_temp(indoor_setpoint) - outdoor_temp + if load <= 0: + # Continuous limit as load -> 0, matching en442_flow_temp. See utils/emitter.py. + return indoor_setpoint + (flow_return_dt / 2.0) - # Calculate required radiator temperature differential - # From radiator equation: Output = Rated × (ΔT/50K)^1.3 - # Inverted: ΔT = 50K × (Output/Rated)^(1/1.3) + heat_demand = self.heat_loss_coefficient * load output_ratio = heat_demand / self.radiator_rated_output - required_dt = RADIATOR_RATED_DT * (output_ratio ** (1 / RADIATOR_POWER_COEFFICIENT)) - # Mean water temperature = room temp + required DT - mean_water_temp = indoor_setpoint + required_dt + # The exponent is the EMITTER's: underfloor uses EN 1264's n ~ 1.1, not a radiator's 1.3. + required_dt = RADIATOR_RATED_DT * (output_ratio ** (1.0 / self.emitter_exponent)) - # Flow temperature = MWT + half of flow-return differential - flow_temp = mean_water_temp + (flow_return_dt / 2) + flow_temp = indoor_setpoint + required_dt + (flow_return_dt / 2.0) _LOGGER.debug( - "Timbones method: heat_demand=%.0f W, radiator_output=%.0f W, " - "required_DT=%.1f K, MWT=%.1f°C -> flow=%.1f°C", + "EN 442 (rated output): demand=%.0f W, rated=%.0f W, ratio=%.3f, n=%.2f, " + "required ΔT=%.1f K -> flow=%.1f°C", heat_demand, self.radiator_rated_output, + output_ratio, + self.emitter_exponent, required_dt, - mean_water_temp, flow_temp, ) return flow_temp - def apply_ufh_adjustment( - self, - radiator_flow_temp: float, - ufh_type: str, - ) -> float: - """Apply underfloor heating adjustments to radiator-calculated flow temp. - - UFH systems require lower flow temperatures due to larger heat exchange surface. - Adjustments based on real-world UFH installations and thermal properties. - - Args: - radiator_flow_temp: Flow temperature calculated for radiators (°C) - ufh_type: "concrete_slab", "timber", or "radiator" (no adjustment) - - Returns: - Adjusted flow temperature for UFH system (°C) - - References: - Mathematical_Enhancement_Summary.md: UFH-specific optimizations - Floor_Heating_Enhancements.md: Thermal lag and mass modeling - """ - if ufh_type == "concrete_slab": - # Concrete slab UFH: 8°C reduction, minimum 25°C - ufh_flow_temp = radiator_flow_temp - UFH_FLOW_REDUCTION_CONCRETE - ufh_flow_temp = max(ufh_flow_temp, UFH_MIN_FLOW_TEMP_CONCRETE) - - _LOGGER.debug( - "UFH concrete adjustment: radiator=%.1f°C -> UFH=%.1f°C " - "(reduction=%.1f°C, min=%.1f°C)", - radiator_flow_temp, - ufh_flow_temp, - UFH_FLOW_REDUCTION_CONCRETE, - UFH_MIN_FLOW_TEMP_CONCRETE, - ) - - elif ufh_type == "timber": - # Timber UFH: 5°C reduction, minimum 22°C - ufh_flow_temp = radiator_flow_temp - UFH_FLOW_REDUCTION_TIMBER - ufh_flow_temp = max(ufh_flow_temp, UFH_MIN_FLOW_TEMP_TIMBER) - - _LOGGER.debug( - "UFH timber adjustment: radiator=%.1f°C -> UFH=%.1f°C " - "(reduction=%.1f°C, min=%.1f°C)", - radiator_flow_temp, - ufh_flow_temp, - UFH_FLOW_REDUCTION_TIMBER, - UFH_MIN_FLOW_TEMP_TIMBER, - ) - - else: - # Radiator system - no adjustment - ufh_flow_temp = radiator_flow_temp - - return ufh_flow_temp - def calculate_optimal_flow_temp( self, indoor_setpoint: float, outdoor_temp: float, - prefer_method: str = "kuehne", - flow_return_dt: float = 5.0, + flow_return_dt: Optional[float] = None, ) -> FlowTempCalculation: - """Calculate optimal flow temperature using best available method. + """Flow temperature the emitters need, by the EN 442 emitter law. - Prioritizes André Kühne's formula by default (universal, physics-based). - Falls back to Timbones' method if configured and requested. - Applies UFH adjustments automatically based on heating_type. + One law, two anchors: the emitters' rated output when the installer has supplied it, + otherwise the system's design point. Args: indoor_setpoint: Target indoor temperature (°C) outdoor_temp: Current outdoor temperature (°C) - prefer_method: "kuehne" (default), "timbones", or "auto" - flow_return_dt: Design flow-return differential (°C) + flow_return_dt: Flow-return spread (°C). Defaults to the configured design spread. Returns: - FlowTempCalculation with optimal temperature and reasoning + FlowTempCalculation with the required temperature and its reasoning. """ - raw_kuehne = None - raw_timbones = None - method_used = "kuehne" - confidence = 0.9 # High confidence in physics-based formula - - # Calculate using André Kühne's formula (always available) - raw_kuehne = self.calculate_kuehne_flow_temp(indoor_setpoint, outdoor_temp) - flow_temp = raw_kuehne - - # Try Timbones' method if configured and requested - if prefer_method in ("timbones", "auto") and self.radiator_rated_output is not None: - raw_timbones = self.calculate_timbones_flow_temp( - indoor_setpoint, outdoor_temp, flow_return_dt - ) + spread = flow_return_dt if flow_return_dt is not None else self.design_spread - if raw_timbones is not None: - if prefer_method == "timbones": - # User explicitly prefers Timbones - flow_temp = raw_timbones - method_used = "timbones" - confidence = 0.85 # Slightly lower (requires radiator spec) - elif prefer_method == "auto": - # Average both methods for robustness - flow_temp = (raw_kuehne + raw_timbones) / 2 - method_used = "kuehne+timbones" - confidence = 0.95 # Higher confidence with multiple methods - - # Apply UFH adjustments if applicable - if self.heating_type in ("concrete_ufh", "timber"): - ufh_type = "concrete_slab" if self.heating_type == "concrete_ufh" else "timber" - flow_temp = self.apply_ufh_adjustment(flow_temp, ufh_type) - - # Build reasoning string - reasoning_parts = [f"Outdoor: {outdoor_temp:.1f}°C, Indoor target: {indoor_setpoint:.1f}°C"] - - if method_used == "kuehne": - reasoning_parts.append( - f"André Kühne formula: {raw_kuehne:.1f}°C " - f"(HC={self.heat_loss_coefficient:.0f} W/°C)" - ) - elif method_used == "timbones": - reasoning_parts.append( - f"Timbones method: {raw_timbones:.1f}°C " - f"(radiator={self.radiator_rated_output:.0f}W)" + raw_rated_output = self.calculate_rated_output_flow_temp( + indoor_setpoint, outdoor_temp, spread + ) + raw_design_point = self.calculate_design_point_flow_temp(indoor_setpoint, outdoor_temp) + + if raw_rated_output is not None: + flow_temp = raw_rated_output + method_used = "en442_rated_output" + confidence = 0.95 # anchored on a measured nameplate figure + detail = ( + f"EN 442 via rated output: {raw_rated_output:.1f}°C " + f"(emitters {self.radiator_rated_output:.0f} W @ ΔT50, n={self.emitter_exponent})" ) - elif method_used == "kuehne+timbones": - reasoning_parts.append( - f"Combined: Kühne={raw_kuehne:.1f}°C, " - f"Timbones={raw_timbones:.1f}°C, avg={flow_temp:.1f}°C" + else: + flow_temp = raw_design_point + method_used = "en442_design_point" + confidence = 0.9 + detail = ( + f"EN 442 via design point: {raw_design_point:.1f}°C " + f"({self.design_flow_temp:.0f}°C @ {self.design_outdoor_temp:.0f}°C outdoor, " + f"n={self.emitter_exponent})" ) - if self.heating_type in ("concrete_ufh", "timber"): - reasoning_parts.append(f"UFH adjustment applied ({self.heating_type})") - - reasoning = "; ".join(reasoning_parts) + reasoning = "; ".join( + [ + f"Outdoor: {outdoor_temp:.1f}°C, Indoor target: {indoor_setpoint:.1f}°C", + detail, + ] + ) return FlowTempCalculation( flow_temp=flow_temp, @@ -415,48 +377,64 @@ def calculate_optimal_flow_temp( heating_type=self.heating_type, confidence=confidence, reasoning=reasoning, - raw_kuehne=raw_kuehne, - raw_timbones=raw_timbones, + raw_design_point=raw_design_point, + raw_rated_output=raw_rated_output, ) def calculate_required_offset( self, optimal_flow_temp: float, current_flow_temp: float, - curve_sensitivity: float = 1.5, + curve_sensitivity: float = DEFAULT_CURVE_SENSITIVITY, ) -> float: - """Calculate heating curve offset needed to achieve optimal flow temperature. + """Curve offset needed to move the flow temperature to the calculated optimum. + + Weather compensation TRIMS the pump's own heating curve; it does not replace it. If the + curve is correctly tuned this correction is near zero. Bounded so that a mis-configured + design point can never command a large swing. Args: optimal_flow_temp: Target flow temperature from weather compensation (°C) current_flow_temp: Current actual flow temperature (°C) curve_sensitivity: Flow temp change per offset unit (°C/offset) - Typical NIBE: 1.5°C per offset unit Returns: - Recommended heating curve offset adjustment (°C) + Recommended heating curve offset adjustment (°C), bounded by WEATHER_COMP_MAX_OFFSET. """ temp_deviation = optimal_flow_temp - current_flow_temp offset_adjustment = temp_deviation / curve_sensitivity - _LOGGER.debug( - "Offset calculation: optimal=%.1f°C, current=%.1f°C, " - "error=%.1f°C, sensitivity=%.1f -> offset=%.1f", - optimal_flow_temp, - current_flow_temp, - temp_deviation, - curve_sensitivity, - offset_adjustment, - ) + bounded = max(-WEATHER_COMP_MAX_OFFSET, min(WEATHER_COMP_MAX_OFFSET, offset_adjustment)) + + if bounded != offset_adjustment: + _LOGGER.debug( + "Weather compensation offset %.1f°C bounded to %.1f°C (optimal=%.1f°C, " + "current=%.1f°C) - a correction this large means the pump's curve or the " + "configured design point disagrees with reality", + offset_adjustment, + bounded, + optimal_flow_temp, + current_flow_temp, + ) + else: + _LOGGER.debug( + "Offset calculation: optimal=%.1f°C, current=%.1f°C, " + "error=%.1f°C, sensitivity=%.1f -> offset=%.1f", + optimal_flow_temp, + current_flow_temp, + temp_deviation, + curve_sensitivity, + bounded, + ) - return offset_adjustment + return bounded class AdaptiveClimateSystem: """Combine universal climate zones with adaptive weather learning. DESIGN PHILOSOPHY: - - Universal math (André Kühne, Timbones) works globally + - The emitter law (EN 442) works globally - Climate zones provide baseline safety margins - Weather learning adapts to local unusual patterns - No country-specific hardcoding needed @@ -638,13 +616,19 @@ class WeatherPredictionLayer: 3. MODERATION: Let SAFETY, COMFORT, EFFECT layers handle naturally via weighted aggregation """ - def __init__(self, thermal_mass: float = 1.0): + def __init__( + self, thermal_mass: float = 1.0, forecast_horizon: float = WEATHER_FORECAST_HORIZON + ): """Initialize weather prediction layer. Args: thermal_mass: Building thermal mass (0.5=light, 1.0=medium, 1.5=heavy) + forecast_horizon: How far ahead to scan, in hours. Comes from the thermal model because + it depends on the fabric: a fixed 12 h window cannot see the slow, deep slide that + drains a slab, so the pre-heat never fires. See ThermalModel.get_prediction_horizon. """ self.thermal_mass = thermal_mass + self.forecast_horizon = forecast_horizon def evaluate_layer( self, @@ -693,7 +677,7 @@ def evaluate_layer( # Check forecast for significant temperature drop current_outdoor = nibe_state.outdoor_temp - forecast_hours = weather_data.forecast_hours[: int(WEATHER_FORECAST_HORIZON)] + forecast_hours = weather_data.forecast_hours[: int(self.forecast_horizon)] if not forecast_hours: return WeatherLayerDecision( @@ -750,7 +734,9 @@ def evaluate_layer( if forecast_triggered and indoor_cooling: trigger = f"Forecast {temp_drop:.1f}°C drop + Indoor cooling {trend_rate:.2f}°C/h (confirmed)" elif forecast_triggered: - trigger = f"Forecast {temp_drop:.1f}°C drop in {WEATHER_FORECAST_HORIZON:.0f}h (proactive)" + trigger = ( + f"Forecast {temp_drop:.1f}°C drop in {self.forecast_horizon:.0f}h (proactive)" + ) else: trigger = f"Indoor cooling {trend_rate:.2f}°C/h (reactive confirmation)" @@ -772,12 +758,10 @@ def evaluate_layer( class WeatherCompensationLayer: """Mathematical weather compensation layer with adaptive climate system. - Calculates optimal flow temperature using: - - Universal flow temperature formula (validated across manufacturers) - - Heat transfer method (if radiator specs available) - - UFH-specific adjustments (concrete/timber) - - Adaptive climate zones (latitude-based, globally applicable) - - Weather learning (unusual pattern detection) + Calculates the required flow temperature using: + - the EN 442 emitter law (rated-output anchor, or the system design point) + - adaptive climate zones (latitude-based, globally applicable) + - weather learning (unusual pattern detection) Automatically adapts to global climates: - Kiruna, Sweden (-30°C) → Arctic zone → 2.5°C base margin @@ -872,19 +856,19 @@ def evaluate_layer( reason=f"DHW cooldown ({minutes_since_dhw:.0f}/{DHW_WEATHER_COOLDOWN_MINUTES}min)", ) - if not weather_data or not weather_data.forecast_hours: - return WeatherCompensationLayerDecision( - name="Math WC", offset=0.0, weight=0.0, reason="No weather data" - ) - + # NO GUARD ON weather_data HERE, AND THERE MUST NOT BE ONE. This layer is the EN 442 emitter + # law, driven by the pump's own outdoor and flow sensors (below), not by the forecast - the + # forecast is used only for unusual-weather detection further down, which carries its own + # guard. A weather entity is vol.Optional in the config flow, so guarding here silently + # switched off the primary control law (the one layer that votes every cycle) on any install + # that left the dropdown blank. current_outdoor = nibe_state.outdoor_temp current_flow = nibe_state.flow_temp - # Calculate optimal flow temperature using physics-based formulas + # Flow temperature the emitters actually need, by the EN 442 emitter law flow_calc = self.weather_comp.calculate_optimal_flow_temp( indoor_setpoint=target_temp, outdoor_temp=current_outdoor, - prefer_method="auto", # Combines universal formula + heat transfer if available ) # Adaptive climate system safety adjustments @@ -893,7 +877,9 @@ def evaluate_layer( unusual_severity = 0.0 # Check for unusual weather patterns if weather learner available - if self.weather_learner and weather_data.forecast_hours: + # The one thing here that DOES need the forecast. Without it, unusual-weather detection + # simply stands down - the emitter law above does not, and did not need to. + if self.weather_learner and weather_data and weather_data.forecast_hours: try: current_date = get_current_datetime() if get_current_datetime else dt_util.now() @@ -924,11 +910,19 @@ def evaluate_layer( unusual_severity=unusual_severity, ) - # Apply safety margin to calculated flow temp - adjusted_flow_temp = flow_calc.flow_temp + safety_margin + # The safety margin is an ASYMMETRIC TOLERANCE, not an addition to the setpoint. Inside the + # band [required, required + margin] the curve is left alone; below it the curve is pulled up + # to what the emitter law demands; above it, back down to the band top. Adding the margin to + # the setpoint instead makes the correction strictly positive everywhere - a DC bias that + # permanently tells a perfectly tuned curve to add heat. + required_flow = flow_calc.flow_temp + if current_flow < required_flow: + adjusted_flow_temp = required_flow + elif current_flow > required_flow + safety_margin: + adjusted_flow_temp = required_flow + safety_margin + else: + adjusted_flow_temp = current_flow - # Calculate required offset from current flow temperature - # NIBE curve sensitivity: ~1.5°C flow change per 1°C offset required_offset = self.weather_comp.calculate_required_offset( optimal_flow_temp=adjusted_flow_temp, current_flow_temp=current_flow, diff --git a/custom_components/effektguard/options.py b/custom_components/effektguard/options.py index e37541a5..a238dd85 100644 --- a/custom_components/effektguard/options.py +++ b/custom_components/effektguard/options.py @@ -132,18 +132,30 @@ async def async_step_init( self, user_input: "EffektGuardConfigDict | None" = None ) -> FlowResult: """Manage runtime options.""" - if user_input is not None: - validated_input = self._validate_and_convert_dhw_config(user_input) + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} - # Preserve ALL existing options not in the form - # This is critical for values set by other entities (e.g., target_indoor_temp from climate) - # and for options from entry.data that should persist - for key, value in self.config_entry.options.items(): - if key not in validated_input: - _LOGGER.debug("Preserving option %s = %s", key, value) - validated_input[key] = value + if user_input is not None: + try: + validated_input = self._validate_and_convert_dhw_config(user_input) + except vol.Invalid as err: + # _validate_and_convert_dhw_config raises with a message that names the field and + # its permitted range. Letting it escape the step throws that away: Home Assistant + # renders an escaped exception as "Unknown error occurred", so the user is told + # that something failed but not what, and their input is discarded (audit F-075). + _LOGGER.warning("Rejected options: %s", err) + errors["base"] = "invalid_dhw_config" + placeholders["reason"] = str(err) + else: + # Preserve ALL existing options not in the form + # This is critical for values set by other entities (e.g., target_indoor_temp from + # climate) and for options from entry.data that should persist + for key, value in self.config_entry.options.items(): + if key not in validated_input: + _LOGGER.debug("Preserving option %s = %s", key, value) + validated_input[key] = value - return self.async_create_entry(title="", data=validated_input) + return self.async_create_entry(title="", data=validated_input) # Get current values for defaults morning_hour = self.config_entry.options.get("dhw_morning_hour", DEFAULT_DHW_MORNING_HOUR) @@ -335,4 +347,9 @@ async def async_step_init( {"collapsed": False}, ) - return self.async_show_form(step_id="init", data_schema=vol.Schema(schema_dict)) + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(schema_dict), + errors=errors, + description_placeholders=placeholders or None, + ) diff --git a/custom_components/effektguard/sensor.py b/custom_components/effektguard/sensor.py index 07a15ab7..b458b185 100644 --- a/custom_components/effektguard/sensor.py +++ b/custom_components/effektguard/sensor.py @@ -27,12 +27,23 @@ from homeassistant.util import dt as dt_util from .const import ( + BILLING_PERIOD_MINUTES, + BILLABLE_POWER_SOURCES, + POWER_SOURCE_ESTIMATE, + POWER_SOURCE_EXTERNAL_METER, + POWER_SOURCE_NIBE_CURRENTS, + POWER_SOURCE_NONE, + PRICE_UNIT_FALLBACK, DOMAIN, ) from .coordinator import EffektGuardCoordinator +from .optimization.effect_layer import effective_tariff_power_kw _LOGGER = logging.getLogger(__name__) +# Read-only: every sensor serves the coordinator's last decision. Nothing here reaches the pump. +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class EffektGuardSensorEntityDescription(SensorEntityDescription): @@ -65,20 +76,19 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): SENSORS: tuple[EffektGuardSensorEntityDescription, ...] = ( EffektGuardSensorEntityDescription( key="current_offset", - name="Current Offset", + translation_key="current_offset", icon="mdi:thermometer-lines", - device_class=SensorDeviceClass.TEMPERATURE, + # A heating-curve offset is an INTERVAL, not an absolute temperature. device_class + # TEMPERATURE applies absolute conversion, so an imperial user saw 0.0 C as 32.0 F and + # statistics stored the converted value; TEMPERATURE_DELTA converts as an interval. + device_class=SensorDeviceClass.TEMPERATURE_DELTA, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda coordinator: ( - coordinator.data["decision"].offset - if coordinator.data and coordinator.data.get("decision") - else 0.0 - ), + value_fn=lambda coordinator: coordinator.current_offset, ), EffektGuardSensorEntityDescription( key="degree_minutes", - name="Degree Minutes", + translation_key="degree_minutes", icon="mdi:timer-outline", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -90,7 +100,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="supply_temperature", - name="Supply Temperature", + translation_key="supply_temperature", icon="mdi:thermometer", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -104,7 +114,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="outdoor_temperature", - name="Outdoor Temperature", + translation_key="outdoor_temperature", icon="mdi:thermometer", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -118,7 +128,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="indoor_temperature", - name="Indoor Temperature", + translation_key="indoor_temperature", icon="mdi:home-thermometer", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -132,11 +142,13 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="current_price", - name="Current Electricity Price", + translation_key="current_price", icon="mdi:currency-eur", - device_class=SensorDeviceClass.MONETARY, - # Unit dynamically set from spot price entity in native_unit_of_measurement property - # Note: monetary device_class doesn't support state_class + # NOT device_class=MONETARY: the unit comes from the spot-price entity and is typically + # "öre/kWh", a RATE not an amount, and MONETARY permits only state_class TOTAL, which would + # have the recorder SUM a price. MEASUREMENT is what a price is and gives it min/max/mean + # long-term statistics (F-070). + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda coordinator: ( coordinator.data["price"].current_price if coordinator.data @@ -147,7 +159,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="peak_today", - name="Peak Today", + translation_key="peak_today", icon="mdi:transmission-tower", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -156,7 +168,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="peak_this_month", - name="Peak This Month", + translation_key="peak_this_month", icon="mdi:transmission-tower-export", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -165,7 +177,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="nibe_power", - name="NIBE Power", + translation_key="nibe_power", icon="mdi:heat-pump", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -185,7 +197,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="compressor_frequency", - name="Compressor Frequency", + translation_key="compressor_frequency", icon="mdi:engine", native_unit_of_measurement="Hz", state_class=SensorStateClass.MEASUREMENT, @@ -200,7 +212,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="compressor_health", - name="Compressor Health Status", + translation_key="compressor_health", icon="mdi:engine-outline", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda coordinator: ( @@ -211,7 +223,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="optimization_reasoning", - name="Optimization Reasoning", + translation_key="optimization_reasoning", icon="mdi:brain", value_fn=lambda coordinator: ( # Truncate to 255 chars for Home Assistant state limit @@ -229,7 +241,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="quarter_of_day", - name="Quarter of Day", + translation_key="quarter_of_day", icon="mdi:clock-outline", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda coordinator: ( @@ -238,7 +250,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="price_period_classification", - name="Price Period Classification", + translation_key="price_period_classification", icon="mdi:chart-timeline-variant", value_fn=lambda coordinator: ( coordinator.data.get("current_classification") if coordinator.data else "unknown" @@ -246,7 +258,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="temperature_trend", - name="Indoor Temperature Trend", + translation_key="temperature_trend", icon="mdi:trending-up", # No device_class - this is a rate of change, not a temperature native_unit_of_measurement="°C/h", @@ -264,7 +276,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="outdoor_temperature_trend", - name="Outdoor Temperature Trend", + translation_key="outdoor_temperature_trend", icon="mdi:weather-partly-cloudy", # No device_class - this is a rate of change, not a temperature native_unit_of_measurement="°C/h", @@ -282,11 +294,18 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="savings_estimate", - name="Estimated Monthly Savings", + translation_key="savings_estimate", icon="mdi:cash-multiple", - device_class=SensorDeviceClass.MONETARY, + # NOT device_class=MONETARY: MONETARY permits only state_class TOTAL, and TOTAL makes the + # recorder keep a running SUM - but this is a forward-looking monthly PROJECTION that rises + # and falls with the forecast, so summing it is meaningless (F-070). + # + # The unit is hardcoded SEK, correctly: the effect-tariff term is + # SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH and the spot component is dropped when the price + # unit is not SEK-compatible. Do NOT derive the unit from the spot entity, which reports + # öre/kWh - labelling a SEK value "öre" is a 100x error. (A non-Swedish user seeing a SEK + # figure from a Swedish tariff is a tariff-model issue, F-107, open with the owner.) native_unit_of_measurement="SEK", - state_class=SensorStateClass.TOTAL, value_fn=lambda coordinator: ( coordinator.data["savings"].monthly_estimate if coordinator.data @@ -297,14 +316,14 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="optional_features_status", - name="Optional Features Status", + translation_key="optional_features_status", icon="mdi:feature-search-outline", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda coordinator: ("active" if coordinator.data else "initializing"), ), EffektGuardSensorEntityDescription( key="heat_pump_model", - name="Heat Pump Model", + translation_key="heat_pump_model", icon="mdi:heat-pump", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda coordinator: ( @@ -316,7 +335,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): # DHW (Domestic Hot Water) sensors EffektGuardSensorEntityDescription( key="dhw_status", - name="DHW Status", + translation_key="dhw_status", icon="mdi:water-boiler", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda coordinator: ( @@ -325,7 +344,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="dhw_recommendation", - name="DHW Recommendation", + translation_key="dhw_recommendation", icon="mdi:water-boiler-auto", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda coordinator: ( @@ -336,7 +355,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="dhw_next_boost_time", - name="DHW Scheduled Start", + translation_key="dhw_next_boost_time", icon="mdi:clock-outline", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, @@ -347,7 +366,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): # Airflow Optimization sensors (Exhaust Air Heat Pump) EffektGuardSensorEntityDescription( key="airflow_enhancement", - name="Airflow Enhancement", + translation_key="airflow_enhancement", icon="mdi:fan", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda coordinator: ( @@ -358,7 +377,7 @@ class EffektGuardSensorEntityDescription(SensorEntityDescription): ), EffektGuardSensorEntityDescription( key="airflow_thermal_gain", - name="Airflow Thermal Gain", + translation_key="airflow_thermal_gain", icon="mdi:heat-wave", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -503,23 +522,26 @@ def native_value(self) -> float | str | datetime | None: return self._restored_value return self._restored_value + def _spot_price_unit(self) -> str | None: + """The unit the user's own spot-price integration reports, e.g. "öre/kWh" or "SEK/kWh".""" + try: + gespot_entity_id = self.coordinator.entry.data.get("gespot_entity") + if gespot_entity_id: + gespot_state = self.coordinator.hass.states.get(gespot_entity_id) + if gespot_state: + unit = gespot_state.attributes.get("unit_of_measurement") + if isinstance(unit, str) and unit: + return unit + except (AttributeError, KeyError): + pass + return None + @property def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement dynamically for price sensor.""" - # For current_price sensor, get unit from spot price entity + """Return the unit, following the user's own price data where money is involved.""" if self.entity_description.key == "current_price": - try: - gespot_entity_id = self.coordinator.entry.data.get("gespot_entity") - if gespot_entity_id: - gespot_state = self.coordinator.hass.states.get(gespot_entity_id) - if gespot_state: - return gespot_state.attributes.get("unit_of_measurement", "öre/kWh") - except (AttributeError, KeyError): - pass - # Fallback to öre/kWh if spot price entity not available - return "öre/kWh" - - # For all other sensors, use description's unit + return self._spot_price_unit() or PRICE_UNIT_FALLBACK + return self.entity_description.native_unit_of_measurement def _add_weather_forecast_to_attrs(self, attrs: dict[str, Any], hours: int = 12) -> None: @@ -885,6 +907,17 @@ def extra_state_attributes(self) -> dict[str, Any]: attrs["baseline_cost"] = savings.baseline_cost if hasattr(savings, "optimized_cost"): attrs["optimized_cost"] = savings.optimized_cost + if hasattr(savings, "effect_baseline_measured"): + # A zero effect saving is ambiguous: either the baseline has never been + # measured (no unoptimised period seen) or it has and the saving really is + # zero. Surface the flag so the two are distinguishable. + attrs["effect_baseline_measured"] = savings.effect_baseline_measured + if not savings.effect_baseline_measured: + attrs["effect_savings_note"] = ( + "No effect-tariff saving is claimed: this house has never been " + "observed with optimization switched off, so there is nothing to " + "compare against. Turn optimization off for a while to measure it." + ) elif key == "peak_today": # Peak tracking metadata - when, how, and context @@ -908,53 +941,60 @@ def extra_state_attributes(self) -> dict[str, Any]: attrs["peak_time"] = None attrs["time_since_peak"] = "No peak recorded today" - # Which 15-minute quarter (Swedish effect tariff) - if self.coordinator.peak_today_quarter is not None: - attrs["peak_quarter"] = self.coordinator.peak_today_quarter - # Convert quarter to human-readable time - hour = self.coordinator.peak_today_quarter // 4 - minute = (self.coordinator.peak_today_quarter % 4) * 15 - attrs["peak_quarter_time"] = f"{hour:02d}:{minute:02d}" + # The owner's tariff bills the 15-minute period mean, so report the billing PERIOD + # (0-95) and the wall-clock time it starts. + if self.coordinator.peak_today_period is not None: + period = self.coordinator.peak_today_period + attrs["peak_billing_period"] = period + attrs["peak_billing_period_time"] = ( + f"{period * BILLING_PERIOD_MINUTES // 60:02d}:" + f"{period * BILLING_PERIOD_MINUTES % 60:02d}" + ) else: - attrs["peak_quarter"] = None - attrs["peak_quarter_time"] = None + attrs["peak_billing_period"] = None + attrs["peak_billing_period_time"] = None # How was it measured? (Trust/accuracy) attrs["measurement_source"] = self.coordinator.peak_today_source # Human-readable source description source_descriptions = { - "external_meter": "Whole-house power meter (best accuracy)", - "nibe_currents": "NIBE phase currents (NIBE only)", - "estimate": "Estimated from compressor (display only)", - "unknown": "No measurement available yet", + POWER_SOURCE_EXTERNAL_METER: "Whole-house power meter (best accuracy)", + POWER_SOURCE_NIBE_CURRENTS: "NIBE phase currents (NIBE only)", + POWER_SOURCE_ESTIMATE: "Estimated from compressor (display only)", + POWER_SOURCE_NONE: "No measurement available yet", } - attrs["measurement_description"] = source_descriptions.get( - self.coordinator.peak_today_source, "Unknown source" + source = self.coordinator.peak_today_source + attrs["measurement_description"] = source_descriptions.get(source, "Unknown source") + + # Billable sources are defined once (BILLABLE_POWER_SOURCES) and the coordinator's peak + # recorder tests the same set, so what the owner is told cannot disagree with what was + # recorded against the tariff. + attrs["is_real_measurement"] = source in BILLABLE_POWER_SOURCES + + # Compare like with like: peak_this_month is the EFFECTIVE (tariff-weighted) peak - night + # hours count half - while peak_today is raw kW. Weight peak_today the same way before + # comparing, or a 3.1 kW night blip (billed as 1.55 kW) is flagged as a new monthly peak. + today_as_billed = ( + effective_tariff_power_kw( + self.coordinator.peak_today, self.coordinator.peak_today_period + ) + if self.coordinator.peak_today_period is not None + else self.coordinator.peak_today ) - - # Is this real measurement or estimate? - attrs["is_real_measurement"] = self.coordinator.peak_today_source in [ - "external_meter", - "nibe_currents", - ] - - # Will this affect monthly billing? - # Only real measurements from external meter affect effect tariff billing - # NIBE currents measure only heat pump (missing other house loads) will_affect = ( - self.coordinator.peak_today_source == "external_meter" - and self.coordinator.peak_today > self.coordinator.peak_this_month + source in BILLABLE_POWER_SOURCES + and today_as_billed > self.coordinator.peak_this_month ) attrs["will_affect_billing"] = will_affect if will_affect: attrs["billing_impact"] = ( - f"New monthly peak: {self.coordinator.peak_today:.2f} kW " + f"New monthly peak: {today_as_billed:.2f} kW " f"(previous: {self.coordinator.peak_this_month:.2f} kW)" ) - elif self.coordinator.peak_today_source != "external_meter": - attrs["billing_impact"] = "Not used for billing (no external meter configured)" + elif source not in BILLABLE_POWER_SOURCES: + attrs["billing_impact"] = "Not used for billing (no real power measurement)" else: attrs["billing_impact"] = ( f"Below monthly peak of {self.coordinator.peak_this_month:.2f} kW" @@ -1329,21 +1369,9 @@ def extra_state_attributes(self) -> dict[str, Any]: attrs["flow_enhanced_m3h"] = self.coordinator.airflow_optimizer.flow_enhanced elif key == "airflow_thermal_gain": - # Thermal gain statistics and breakdown if "airflow_decision" in self.coordinator.data: decision = self.coordinator.data["airflow_decision"] if decision: attrs["mode"] = decision.mode.value - # Enhancement statistics (gain-related) - if ( - hasattr(self.coordinator, "airflow_optimizer") - and self.coordinator.airflow_optimizer - ): - stats = self.coordinator.airflow_optimizer.get_enhancement_stats() - attrs["total_decisions"] = stats.get("total_decisions", 0) - attrs["enhance_recommendations"] = stats.get("enhance_recommendations", 0) - attrs["enhance_percentage"] = round(stats.get("enhance_percentage", 0.0), 1) - attrs["average_gain_kw"] = round(stats.get("average_gain_kw", 0.0), 3) - return attrs diff --git a/custom_components/effektguard/services.yaml b/custom_components/effektguard/services.yaml index f3ec09bc..ef8d348a 100644 --- a/custom_components/effektguard/services.yaml +++ b/custom_components/effektguard/services.yaml @@ -53,23 +53,15 @@ boost_heating: boost_dhw: name: Boost DHW - description: Force DHW heating to target temperature (for immediate hot water needs) + description: >- + Start an immediate hot-water boost via NIBE temporary lux. The pump heats to its own lux + temperature; EffektGuard keeps the boost running for the requested duration (price + optimization will not cancel it - only the thermal-debt safety stop will) and then turns + it off. fields: - target_temp: - name: Target temperature - description: Target DHW temperature (40-70°C) - required: false - default: 55.0 - example: 60.0 - selector: - number: - min: 40.0 - max: 70.0 - step: 1.0 - unit_of_measurement: "°C" duration: name: Duration - description: Maximum heating duration (minutes) + description: How long the boost may run before EffektGuard turns it off (minutes) required: false default: 90 example: 120 diff --git a/custom_components/effektguard/strings.json b/custom_components/effektguard/strings.json index 74f693fc..f0afb9ff 100644 --- a/custom_components/effektguard/strings.json +++ b/custom_components/effektguard/strings.json @@ -147,6 +147,9 @@ } } } + }, + "error": { + "invalid_dhw_config": "Invalid hot water setting: {reason}" } }, "entity": { @@ -169,6 +172,80 @@ "airflow_optimization": { "name": "Airflow Optimization" } + }, + "sensor": { + "current_offset": { + "name": "Current Offset" + }, + "degree_minutes": { + "name": "Degree Minutes" + }, + "supply_temperature": { + "name": "Supply Temperature" + }, + "outdoor_temperature": { + "name": "Outdoor Temperature" + }, + "indoor_temperature": { + "name": "Indoor Temperature" + }, + "current_price": { + "name": "Current Electricity Price" + }, + "peak_today": { + "name": "Peak Today" + }, + "peak_this_month": { + "name": "Peak This Month" + }, + "nibe_power": { + "name": "NIBE Power" + }, + "compressor_frequency": { + "name": "Compressor Frequency" + }, + "compressor_health": { + "name": "Compressor Health Status" + }, + "optimization_reasoning": { + "name": "Optimization Reasoning" + }, + "quarter_of_day": { + "name": "Quarter of Day" + }, + "price_period_classification": { + "name": "Price Period Classification" + }, + "temperature_trend": { + "name": "Indoor Temperature Trend" + }, + "outdoor_temperature_trend": { + "name": "Outdoor Temperature Trend" + }, + "savings_estimate": { + "name": "Estimated Monthly Savings" + }, + "optional_features_status": { + "name": "Optional Features Status" + }, + "heat_pump_model": { + "name": "Heat Pump Model" + }, + "dhw_status": { + "name": "DHW Status" + }, + "dhw_recommendation": { + "name": "DHW Recommendation" + }, + "dhw_next_boost_time": { + "name": "DHW Scheduled Start" + }, + "airflow_enhancement": { + "name": "Airflow Enhancement" + }, + "airflow_thermal_gain": { + "name": "Airflow Thermal Gain" + } } }, "services": { @@ -180,5 +257,15 @@ "name": "Boost Hot Water", "description": "Start immediate hot water heating cycle" } + }, + "issues": { + "no_price_source": { + "title": "No electricity price source", + "description": "EffektGuard has no electricity price data, so price optimisation is not running - the heating curve is being managed on comfort and safety alone. Configure a spot-price entity (GE-Spot, Nord Pool or similar) in the integration's options to enable it." + }, + "no_dhw_control_entity": { + "title": "No hot-water control entity", + "description": "EffektGuard drives hot water by toggling NIBE's temporary-lux switch, and no such entity was found. Home Assistant's NIBE integration exposes that register (50004) for F-series pumps only, so an S-series pump has no equivalent - hot-water optimisation is NOT running, and any scheduled boost shown by its sensors will not happen. Space-heating optimisation is unaffected." + } } -} \ No newline at end of file +} diff --git a/custom_components/effektguard/switch.py b/custom_components/effektguard/switch.py index 035b4444..cb89fe06 100644 --- a/custom_components/effektguard/switch.py +++ b/custom_components/effektguard/switch.py @@ -33,6 +33,10 @@ _LOGGER = logging.getLogger(__name__) +# The switches write feature flags into entry.data. They do not reach the pump - the coordinator +# reads those flags at the point of use, on its own clock. +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class EffektGuardSwitchEntityDescription(SwitchEntityDescription): @@ -192,6 +196,11 @@ async def async_turn_on(self, **kwargs: Any) -> None: _LOGGER.info("Enabling %s", config_key) + if config_key == CONF_ENABLE_OPTIMIZATION: + await self.coordinator.set_optimization_enabled(True) + self.async_write_ha_state() + return + # Update config entry data (triggers update listener) # This follows Home Assistant best practices for SwitchEntity new_data = dict(self._entry.data) @@ -213,6 +222,11 @@ async def async_turn_off(self, **kwargs: Any) -> None: _LOGGER.info("Disabling %s", config_key) + if config_key == CONF_ENABLE_OPTIMIZATION: + await self.coordinator.set_optimization_enabled(False) + self.async_write_ha_state() + return + # Update config entry data (triggers update listener) # This follows Home Assistant best practices for SwitchEntity new_data = dict(self._entry.data) diff --git a/custom_components/effektguard/translations/da.json b/custom_components/effektguard/translations/da.json index f49ec940..f2cf1cd9 100644 --- a/custom_components/effektguard/translations/da.json +++ b/custom_components/effektguard/translations/da.json @@ -90,56 +90,66 @@ "step": { "init": { "title": "EffektGuard-indstillinger", - "description": "Konfigurer optimeringsadfærd og bygningsegenskaber.\n\n**Ændringer træder i kraft øjeblikkeligt** uden genstart. Brug **Omkonfigurer** for at ændre sensorvalg.", - "data": { - "optimization_mode": "Optimeringstilstand" - }, - "data_description": { - "optimization_mode": "**Komfort**: Stram temperaturkontrol, minimal prisoptimering, prioriterer stabil opvarmning. **Balanceret**: Moderat optimering der tillader små temperaturvariationer for omkostningsbesparelser. **Besparelser**: Maksimal prisoptimering med større temperaturudsving i billige/dyre perioder" - }, "sections": { - "comfort_settings": { - "name": "Komfortindstillinger", - "description": "Finjuster temperaturkomforttolerance", + "optimization_settings": { + "name": "Optimeringsindstillinger", + "description": "Vælg optimeringstilstand og temperaturtolerance", "data": { + "optimization_mode": "Optimeringstilstand", "tolerance": "Temperaturtolerance" }, "data_description": { + "optimization_mode": "**Komfort**: Stram temperaturkontrol, minimal prisoptimering, prioriterer stabil opvarmning. **Balanceret**: Moderat optimering der tillader små temperaturvariationer for omkostningsbesparelser. **Besparelser**: Maksimal prisoptimering med større temperaturudsving i billige/dyre perioder", "tolerance": "Styrer tilladt temperaturafvigelse fra mål (±0,2°C dødzone opretholdes altid). Højere værdier tillader mere aggressiv omkostningsoptimering men større temperaturudsving" } }, - "dhw_settings": { - "name": "Varmtvandsindstillinger", - "description": "Planlæg hvornår varmtvand skal opvarmes", + "building_characteristics": { + "name": "Bygningsegenskaber", + "description": "Hjælper med at forudsige dit hjems termiske adfærd", + "data": { + "thermal_mass": "Bygningens termiske masse", + "insulation_quality": "Bygningens isoleringskvalitet" + }, + "data_description": { + "thermal_mass": "Hvor hurtigt bygningen reagerer på ændringer (0,5=let/træ, 1,0=normal, 2,0=tung/beton)", + "insulation_quality": "Samlet isoleringsniveau (0,5=dårlig, 1,0=normal, 2,0=fremragende)" + } + }, + "domestic_hot_water": { + "name": "Varmt brugsvand (DHW)", + "description": "Planlæg hvornår det varme vand skal være klar, og hvor meget du har brug for", "data": { "dhw_target_temp": "Varmtvand måltemperatur", - "dhw_morning_enabled": "Morgen varmtvand", + "dhw_min_amount": "Mindste mængde varmt vand på det planlagte tidspunkt", + "dhw_schedules": "Aktivér/deaktivér", "dhw_morning_hour": "Morgen starttid", - "dhw_evening_enabled": "Aften varmtvand", "dhw_evening_hour": "Aften starttid" }, "data_description": { "dhw_target_temp": "Måltemperatur for varmtvand (45-60°C)", - "dhw_morning_enabled": "Aktiver morgen varmtvandsopvarmning", + "dhw_min_amount": "Antal minutters varmt vand, der skal være tilgængeligt på det planlagte tidspunkt.\n\nEffektGuard opvarmer i de billigste timer for at sikre, at mængden er klar, når du har brug for den.", + "dhw_schedules": "Vælg hvilke opvarmningsperioder der skal være aktive. Fjern markeringen for at slå opvarmning fra i perioden.", "dhw_morning_hour": "Morgenbehovsperiode starttid", - "dhw_evening_enabled": "Aktiver aften varmtvandsopvarmning", "dhw_evening_hour": "Aftenbehovsperiode starttid" } }, - "building_characteristics": { - "name": "Bygningsegenskaber", - "description": "Hjælper med at forudsige dit hjems termiske adfærd", + "airflow_optimization": { + "name": "Luftstrømsoptimering (udsugningsluft)", + "description": "Konfigurer luftmængder for udsugningsvarmepumper. Aktivér/deaktivér via kontakten Luftstrømsoptimering under Betjening.", "data": { - "thermal_mass": "Bygningens termiske masse", - "insulation_quality": "Bygningens isoleringskvalitet" + "airflow_standard_rate": "Normal luftmængde", + "airflow_enhanced_rate": "Forøget luftmængde" }, "data_description": { - "thermal_mass": "Hvor hurtigt bygningen reagerer på ændringer (0,5=let/træ, 1,0=normal, 2,0=tung/beton)", - "insulation_quality": "Samlet isoleringsniveau (0,5=dårlig, 1,0=normal, 2,0=fremragende)" + "airflow_standard_rate": "Normal ventilationsmængde ved almindelig drift.\nIndstil den til dit foretrukne komfortniveau til daglig brug.\n\nNIBE F750 standard: 150 m³/h (hastighed 2).", + "airflow_enhanced_rate": "Forøget ventilationsmængde, der bruges under gunstige forhold for at udvinde mere varme fra udsugningsluften og forbedre COP.\n\nNIBE F750 standard: 252 m³/h (hastighed 4)." } } } } + }, + "error": { + "invalid_dhw_config": "Ugyldig indstilling for varmt brugsvand: {reason}" } }, "entity": { @@ -162,6 +172,80 @@ "airflow_optimization": { "name": "Luftstrømsoptimering" } + }, + "sensor": { + "current_offset": { + "name": "Aktuel forskydning" + }, + "degree_minutes": { + "name": "Gradminutter" + }, + "supply_temperature": { + "name": "Fremløbstemperatur" + }, + "outdoor_temperature": { + "name": "Udetemperatur" + }, + "indoor_temperature": { + "name": "Indetemperatur" + }, + "current_price": { + "name": "Aktuel elpris" + }, + "peak_today": { + "name": "Effekttop i dag" + }, + "peak_this_month": { + "name": "Effekttop denne måned" + }, + "nibe_power": { + "name": "NIBE effekt" + }, + "compressor_frequency": { + "name": "Kompressorfrekvens" + }, + "compressor_health": { + "name": "Kompressorens helbredsstatus" + }, + "optimization_reasoning": { + "name": "Optimeringsbegrundelse" + }, + "quarter_of_day": { + "name": "Kvarter i døgnet" + }, + "price_period_classification": { + "name": "Prisperiodens klassificering" + }, + "temperature_trend": { + "name": "Tendens for indetemperatur" + }, + "outdoor_temperature_trend": { + "name": "Tendens for udetemperatur" + }, + "savings_estimate": { + "name": "Anslået månedlig besparelse" + }, + "optional_features_status": { + "name": "Status for valgfrie funktioner" + }, + "heat_pump_model": { + "name": "Varmepumpemodel" + }, + "dhw_status": { + "name": "Status for varmt brugsvand" + }, + "dhw_recommendation": { + "name": "Anbefaling for varmt brugsvand" + }, + "dhw_next_boost_time": { + "name": "Planlagt start for varmt brugsvand" + }, + "airflow_enhancement": { + "name": "Forøget luftstrøm" + }, + "airflow_thermal_gain": { + "name": "Varmegevinst fra luftstrøm" + } } }, "services": { @@ -173,5 +257,15 @@ "name": "Boost varmtvand", "description": "Start øjeblikkelig varmtvandscyklus" } + }, + "issues": { + "no_price_source": { + "title": "Ingen elpriskilde", + "description": "EffektGuard mangler elprisdata, så prisoptimeringen kører ikke - varmekurven styres kun af komfort og sikkerhed. Konfigurer en spotprisentitet (GE-Spot, Nord Pool eller lignende) i integrationens indstillinger for at aktivere den." + }, + "no_dhw_control_entity": { + "title": "Ingen entitet til varmtvandsstyring", + "description": "EffektGuard styrer varmt vand ved at tænde NIBEs midlertidige luksus-kontakt, og ingen sådan entitet blev fundet. Home Assistants NIBE-integration eksponerer det register (50004) kun for F-serien, så en S-seriepumpe har ingen tilsvarende - varmtvandsoptimeringen kører IKKE, og en planlagt kørsel, som sensorerne viser, vil ikke ske. Varmeoptimeringen påvirkes ikke." + } } -} \ No newline at end of file +} diff --git a/custom_components/effektguard/translations/en.json b/custom_components/effektguard/translations/en.json index 74f693fc..f0afb9ff 100644 --- a/custom_components/effektguard/translations/en.json +++ b/custom_components/effektguard/translations/en.json @@ -147,6 +147,9 @@ } } } + }, + "error": { + "invalid_dhw_config": "Invalid hot water setting: {reason}" } }, "entity": { @@ -169,6 +172,80 @@ "airflow_optimization": { "name": "Airflow Optimization" } + }, + "sensor": { + "current_offset": { + "name": "Current Offset" + }, + "degree_minutes": { + "name": "Degree Minutes" + }, + "supply_temperature": { + "name": "Supply Temperature" + }, + "outdoor_temperature": { + "name": "Outdoor Temperature" + }, + "indoor_temperature": { + "name": "Indoor Temperature" + }, + "current_price": { + "name": "Current Electricity Price" + }, + "peak_today": { + "name": "Peak Today" + }, + "peak_this_month": { + "name": "Peak This Month" + }, + "nibe_power": { + "name": "NIBE Power" + }, + "compressor_frequency": { + "name": "Compressor Frequency" + }, + "compressor_health": { + "name": "Compressor Health Status" + }, + "optimization_reasoning": { + "name": "Optimization Reasoning" + }, + "quarter_of_day": { + "name": "Quarter of Day" + }, + "price_period_classification": { + "name": "Price Period Classification" + }, + "temperature_trend": { + "name": "Indoor Temperature Trend" + }, + "outdoor_temperature_trend": { + "name": "Outdoor Temperature Trend" + }, + "savings_estimate": { + "name": "Estimated Monthly Savings" + }, + "optional_features_status": { + "name": "Optional Features Status" + }, + "heat_pump_model": { + "name": "Heat Pump Model" + }, + "dhw_status": { + "name": "DHW Status" + }, + "dhw_recommendation": { + "name": "DHW Recommendation" + }, + "dhw_next_boost_time": { + "name": "DHW Scheduled Start" + }, + "airflow_enhancement": { + "name": "Airflow Enhancement" + }, + "airflow_thermal_gain": { + "name": "Airflow Thermal Gain" + } } }, "services": { @@ -180,5 +257,15 @@ "name": "Boost Hot Water", "description": "Start immediate hot water heating cycle" } + }, + "issues": { + "no_price_source": { + "title": "No electricity price source", + "description": "EffektGuard has no electricity price data, so price optimisation is not running - the heating curve is being managed on comfort and safety alone. Configure a spot-price entity (GE-Spot, Nord Pool or similar) in the integration's options to enable it." + }, + "no_dhw_control_entity": { + "title": "No hot-water control entity", + "description": "EffektGuard drives hot water by toggling NIBE's temporary-lux switch, and no such entity was found. Home Assistant's NIBE integration exposes that register (50004) for F-series pumps only, so an S-series pump has no equivalent - hot-water optimisation is NOT running, and any scheduled boost shown by its sensors will not happen. Space-heating optimisation is unaffected." + } } -} \ No newline at end of file +} diff --git a/custom_components/effektguard/translations/fi.json b/custom_components/effektguard/translations/fi.json index 9fd9fd48..78e1c014 100644 --- a/custom_components/effektguard/translations/fi.json +++ b/custom_components/effektguard/translations/fi.json @@ -90,56 +90,66 @@ "step": { "init": { "title": "EffektGuard-asetukset", - "description": "Määritä optimointikäyttäytyminen ja rakennuksen ominaisuudet.\n\n**Muutokset tulevat voimaan välittömästi** ilman uudelleenkäynnistystä. Käytä **Määritä uudelleen** muuttaaksesi sensoreita.", - "data": { - "optimization_mode": "Optimointitila" - }, - "data_description": { - "optimization_mode": "**Mukavuus**: Tiukka lämpötilan hallinta, minimaalinen hintojen optimointi, priorisoi vakaan lämmityksen. **Tasapainotettu**: Kohtalainen optimointi sallien pieniä lämpötilavaihteluita kustannussäästöjä varten. **Säästöt**: Maksimaalinen hintojen optimointi suuremmilla lämpötilavaihteluilla halpojen/kalliiden jaksojen aikana" - }, "sections": { - "comfort_settings": { - "name": "Mukavuusasetukset", - "description": "Hienosäädä lämpötilan mukavuustoleranssia", + "optimization_settings": { + "name": "Optimointiasetukset", + "description": "Valitse optimointitila ja lämpötilan toleranssi", "data": { + "optimization_mode": "Optimointitila", "tolerance": "Lämpötilatoleranssi" }, "data_description": { + "optimization_mode": "**Mukavuus**: Tiukka lämpötilan hallinta, minimaalinen hintojen optimointi, priorisoi vakaan lämmityksen. **Tasapainotettu**: Kohtalainen optimointi sallien pieniä lämpötilavaihteluita kustannussäästöjä varten. **Säästöt**: Maksimaalinen hintojen optimointi suuremmilla lämpötilavaihteluilla halpojen/kalliiden jaksojen aikana", "tolerance": "Ohjaa sallittua lämpötilapoikkeamaa tavoitteesta (±0,2°C katvealue ylläpidetään aina). Korkeammat arvot sallivat aggressiivisemman kustannusoptimoinnin mutta suuremmat lämpötilavaihtelut" } }, - "dhw_settings": { - "name": "Lämpimän veden asetukset", - "description": "Aikatauluta milloin lämmin vesi lämmitetään", + "building_characteristics": { + "name": "Rakennuksen ominaisuudet", + "description": "Auttaa ennustamaan kotisi lämpökäyttäytymistä", + "data": { + "thermal_mass": "Rakennuksen lämpömassa", + "insulation_quality": "Rakennuksen eristyslaatu" + }, + "data_description": { + "thermal_mass": "Kuinka nopeasti rakennus reagoi muutoksiin (0,5=kevyt/puu, 1,0=normaali, 2,0=raskas/betoni)", + "insulation_quality": "Yleinen eristystaso (0,5=huono, 1,0=normaali, 2,0=erinomainen)" + } + }, + "domestic_hot_water": { + "name": "Lämmin käyttövesi (DHW)", + "description": "Ajoita milloin lämpimän veden tulee olla valmiina ja kuinka paljon sitä tarvitset", "data": { "dhw_target_temp": "Lämpimän veden tavoitelämpötila", - "dhw_morning_enabled": "Aamun lämmin vesi", + "dhw_min_amount": "Vähimmäismäärä lämmintä vettä ajoitettuna ajankohtana", + "dhw_schedules": "Ota käyttöön / poista käytöstä", "dhw_morning_hour": "Aamun aloitusaika", - "dhw_evening_enabled": "Illan lämmin vesi", "dhw_evening_hour": "Illan aloitusaika" }, "data_description": { "dhw_target_temp": "Tavoitelämpötila lämpimälle vedelle (45-60°C)", - "dhw_morning_enabled": "Ota käyttöön aamun lämpimän veden lämmitys", + "dhw_min_amount": "Lämpimän veden minuuttimäärä, jonka tulee olla käytettävissä ajoitettuna ajankohtana.\n\nEffektGuard lämmittää halvimpien tuntien aikana varmistaakseen, että määrä on valmiina kun tarvitset sitä.", + "dhw_schedules": "Valitse mitkä lämmitysjaksot ovat käytössä. Poista valinta kytkeäksesi lämmityksen pois kyseiseltä jaksolta.", "dhw_morning_hour": "Aamun kysyntäjakson aloitusaika", - "dhw_evening_enabled": "Ota käyttöön illan lämpimän veden lämmitys", "dhw_evening_hour": "Illan kysyntäjakson aloitusaika" } }, - "building_characteristics": { - "name": "Rakennuksen ominaisuudet", - "description": "Auttaa ennustamaan kotisi lämpökäyttäytymistä", + "airflow_optimization": { + "name": "Ilmavirran optimointi (poistoilma)", + "description": "Määritä ilmavirrat poistoilmalämpöpumpuille. Ota käyttöön / poista käytöstä Ilmavirran optimointi -kytkimellä Ohjaimet-osiossa.", "data": { - "thermal_mass": "Rakennuksen lämpömassa", - "insulation_quality": "Rakennuksen eristyslaatu" + "airflow_standard_rate": "Normaali ilmavirta", + "airflow_enhanced_rate": "Tehostettu ilmavirta" }, "data_description": { - "thermal_mass": "Kuinka nopeasti rakennus reagoi muutoksiin (0,5=kevyt/puu, 1,0=normaali, 2,0=raskas/betoni)", - "insulation_quality": "Yleinen eristystaso (0,5=huono, 1,0=normaali, 2,0=erinomainen)" + "airflow_standard_rate": "Normaali ilmanvaihdon teho tavallisessa käytössä.\nAseta se haluamallesi mukavuustasolle päivittäiseen käyttöön.\n\nNIBE F750 oletus: 150 m³/h (nopeus 2).", + "airflow_enhanced_rate": "Tehostettu ilmanvaihto, jota käytetään suotuisissa olosuhteissa poistoilman lämmön talteenoton ja COP:n parantamiseksi.\n\nNIBE F750 oletus: 252 m³/h (nopeus 4)." } } } } + }, + "error": { + "invalid_dhw_config": "Virheellinen lämpimän käyttöveden asetus: {reason}" } }, "entity": { @@ -162,6 +172,80 @@ "airflow_optimization": { "name": "Ilmavirran optimointi" } + }, + "sensor": { + "current_offset": { + "name": "Nykyinen siirtymä" + }, + "degree_minutes": { + "name": "Asteminuutit" + }, + "supply_temperature": { + "name": "Menoveden lämpötila" + }, + "outdoor_temperature": { + "name": "Ulkolämpötila" + }, + "indoor_temperature": { + "name": "Sisälämpötila" + }, + "current_price": { + "name": "Nykyinen sähkön hinta" + }, + "peak_today": { + "name": "Huipputeho tänään" + }, + "peak_this_month": { + "name": "Huipputeho tässä kuussa" + }, + "nibe_power": { + "name": "NIBE teho" + }, + "compressor_frequency": { + "name": "Kompressorin taajuus" + }, + "compressor_health": { + "name": "Kompressorin kunto" + }, + "optimization_reasoning": { + "name": "Optimoinnin perustelu" + }, + "quarter_of_day": { + "name": "Vuorokauden neljännes" + }, + "price_period_classification": { + "name": "Hintajakson luokitus" + }, + "temperature_trend": { + "name": "Sisälämpötilan suuntaus" + }, + "outdoor_temperature_trend": { + "name": "Ulkolämpötilan suuntaus" + }, + "savings_estimate": { + "name": "Arvioitu kuukausisäästö" + }, + "optional_features_status": { + "name": "Valinnaisten toimintojen tila" + }, + "heat_pump_model": { + "name": "Lämpöpumpun malli" + }, + "dhw_status": { + "name": "Lämpimän käyttöveden tila" + }, + "dhw_recommendation": { + "name": "Lämpimän käyttöveden suositus" + }, + "dhw_next_boost_time": { + "name": "Lämpimän käyttöveden ajastettu käynnistys" + }, + "airflow_enhancement": { + "name": "Tehostettu ilmavirta" + }, + "airflow_thermal_gain": { + "name": "Ilmavirran lämpöhyöty" + } } }, "services": { @@ -173,5 +257,15 @@ "name": "Tehosta lämmintä vettä", "description": "Käynnistä välitön lämpimän veden kierros" } + }, + "issues": { + "no_price_source": { + "title": "Ei sähkön hintalähdettä", + "description": "EffektGuardilla ei ole sähkön hintatietoja, joten hintaoptimointi ei ole käynnissä - lämpökäyrää ohjataan vain mukavuuden ja turvallisuuden perusteella. Määritä pörssisähköentiteetti (GE-Spot, Nord Pool tai vastaava) integraation asetuksissa ottaaksesi sen käyttöön." + }, + "no_dhw_control_entity": { + "title": "Lämpimän veden ohjausentiteettiä ei löytynyt", + "description": "EffektGuard ohjaa lämmintä vettä kytkemällä NIBEn väliaikaisen luksus-kytkimen, eikä tällaista entiteettiä löytynyt. Home Assistantin NIBE-integraatio tuo kyseisen rekisterin (50004) esiin vain F-sarjalle, joten S-sarjan lämpöpumpulla ei ole vastaavaa - lämpimän veden optimointi EI ole käynnissä, eikä sensorien näyttämä ajastettu ajo toteudu. Lämmityksen optimointiin tämä ei vaikuta." + } } -} \ No newline at end of file +} diff --git a/custom_components/effektguard/translations/no.json b/custom_components/effektguard/translations/no.json index 51cb30a0..c592d64b 100644 --- a/custom_components/effektguard/translations/no.json +++ b/custom_components/effektguard/translations/no.json @@ -90,56 +90,66 @@ "step": { "init": { "title": "EffektGuard-alternativer", - "description": "Konfigurer optimaliseringsadferd og bygningsegenskaper.\n\n**Endringer gjelder umiddelbart** uten omstart. Bruk **Omkonfigurer** for å endre sensorvalg.", - "data": { - "optimization_mode": "Optimaliseringsmodus" - }, - "data_description": { - "optimization_mode": "**Komfort**: Tett temperaturkontroll, minimal prisoptimalisering, prioriterer stabil oppvarming. **Balansert**: Moderat optimalisering som tillater små temperaturvariasjoner for kostnadsbesparelser. **Besparelser**: Maksimal prisoptimalisering med større temperatursvingninger under billige/dyre perioder" - }, "sections": { - "comfort_settings": { - "name": "Komfortinnstillinger", - "description": "Finjuster temperaturkomforttoleranse", + "optimization_settings": { + "name": "Optimeringsinnstillinger", + "description": "Velg optimeringsmodus og temperaturtoleranse", "data": { + "optimization_mode": "Optimaliseringsmodus", "tolerance": "Temperaturtoleranse" }, "data_description": { + "optimization_mode": "**Komfort**: Tett temperaturkontroll, minimal prisoptimalisering, prioriterer stabil oppvarming. **Balansert**: Moderat optimalisering som tillater små temperaturvariasjoner for kostnadsbesparelser. **Besparelser**: Maksimal prisoptimalisering med større temperatursvingninger under billige/dyre perioder", "tolerance": "Styrer tillatt temperaturavvik fra mål (±0,2°C dødband opprettholdes alltid). Høyere verdier tillater mer aggressiv kostnadsoptimalisering men større temperatursvingninger" } }, - "dhw_settings": { - "name": "Varmtvannsinnstillinger", - "description": "Planlegg når varmtvann skal varmes", + "building_characteristics": { + "name": "Bygningsegenskaper", + "description": "Hjelper med å forutsi hjemmets termiske oppførsel", + "data": { + "thermal_mass": "Bygningens termiske masse", + "insulation_quality": "Bygningens isoleringskvalitet" + }, + "data_description": { + "thermal_mass": "Hvor raskt bygningen reagerer på endringer (0,5=lett/tre, 1,0=normal, 2,0=tung/betong)", + "insulation_quality": "Samlet isoleringsnivå (0,5=dårlig, 1,0=normal, 2,0=utmerket)" + } + }, + "domestic_hot_water": { + "name": "Varmtvann (DHW)", + "description": "Planlegg når varmtvannet skal være klart og hvor mye du trenger", "data": { "dhw_target_temp": "Varmtvann måltemperatur", - "dhw_morning_enabled": "Morgen varmtvann", + "dhw_min_amount": "Minste mengde varmtvann til planlagt tid", + "dhw_schedules": "Aktiver/deaktiver", "dhw_morning_hour": "Morgen starttid", - "dhw_evening_enabled": "Kveld varmtvann", "dhw_evening_hour": "Kveld starttid" }, "data_description": { "dhw_target_temp": "Måltemperatur for varmtvann (45-60°C)", - "dhw_morning_enabled": "Aktiver morgen varmtvannsoppvarming", + "dhw_min_amount": "Antall minutter med varmtvann som skal være tilgjengelig til planlagt tid.\n\nEffektGuard varmer i de billigste timene for å sikre at mengden er klar når du trenger den.", + "dhw_schedules": "Velg hvilke oppvarmingsperioder som skal være aktive. Fjern haken for å slå av oppvarming i perioden.", "dhw_morning_hour": "Morgenbehovsperiode starttid", - "dhw_evening_enabled": "Aktiver kveld varmtvannsoppvarming", "dhw_evening_hour": "Kveldsbehovsperiode starttid" } }, - "building_characteristics": { - "name": "Bygningsegenskaper", - "description": "Hjelper med å forutsi hjemmets termiske oppførsel", + "airflow_optimization": { + "name": "Luftstrømoptimering (avtrekksluft)", + "description": "Konfigurer luftmengder for avtrekksvarmepumper. Aktiver/deaktiver via bryteren Luftstrømoptimering under Kontroller.", "data": { - "thermal_mass": "Bygningens termiske masse", - "insulation_quality": "Bygningens isoleringskvalitet" + "airflow_standard_rate": "Normal luftmengde", + "airflow_enhanced_rate": "Forhøyet luftmengde" }, "data_description": { - "thermal_mass": "Hvor raskt bygningen reagerer på endringer (0,5=lett/tre, 1,0=normal, 2,0=tung/betong)", - "insulation_quality": "Samlet isoleringsnivå (0,5=dårlig, 1,0=normal, 2,0=utmerket)" + "airflow_standard_rate": "Normal ventilasjonsmengde ved vanlig drift.\nStill den inn på ønsket komfortnivå for daglig bruk.\n\nNIBE F750 standard: 150 m³/h (hastighet 2).", + "airflow_enhanced_rate": "Forhøyet ventilasjonsmengde brukt under gunstige forhold for å hente ut mer varme fra avtrekksluften og forbedre COP.\n\nNIBE F750 standard: 252 m³/h (hastighet 4)." } } } } + }, + "error": { + "invalid_dhw_config": "Ugyldig varmtvannsinnstilling: {reason}" } }, "entity": { @@ -162,6 +172,80 @@ "airflow_optimization": { "name": "Luftstrømoptimalisering" } + }, + "sensor": { + "current_offset": { + "name": "Gjeldende forskyvning" + }, + "degree_minutes": { + "name": "Gradminutter" + }, + "supply_temperature": { + "name": "Turtemperatur" + }, + "outdoor_temperature": { + "name": "Utetemperatur" + }, + "indoor_temperature": { + "name": "Innetemperatur" + }, + "current_price": { + "name": "Gjeldende strømpris" + }, + "peak_today": { + "name": "Effekttopp i dag" + }, + "peak_this_month": { + "name": "Effekttopp denne måneden" + }, + "nibe_power": { + "name": "NIBE effekt" + }, + "compressor_frequency": { + "name": "Kompressorfrekvens" + }, + "compressor_health": { + "name": "Kompressorens helsestatus" + }, + "optimization_reasoning": { + "name": "Optimeringsbegrunnelse" + }, + "quarter_of_day": { + "name": "Kvarter i døgnet" + }, + "price_period_classification": { + "name": "Prisperiodens klassifisering" + }, + "temperature_trend": { + "name": "Trend for innetemperatur" + }, + "outdoor_temperature_trend": { + "name": "Trend for utetemperatur" + }, + "savings_estimate": { + "name": "Estimert månedlig besparelse" + }, + "optional_features_status": { + "name": "Status for valgfrie funksjoner" + }, + "heat_pump_model": { + "name": "Varmepumpemodell" + }, + "dhw_status": { + "name": "Varmtvannsstatus" + }, + "dhw_recommendation": { + "name": "Varmtvannsanbefaling" + }, + "dhw_next_boost_time": { + "name": "Planlagt varmtvannsstart" + }, + "airflow_enhancement": { + "name": "Økt luftstrøm" + }, + "airflow_thermal_gain": { + "name": "Varmegevinst fra luftstrøm" + } } }, "services": { @@ -173,5 +257,15 @@ "name": "Boost varmtvann", "description": "Start umiddelbar varmtvannssyklus" } + }, + "issues": { + "no_price_source": { + "title": "Ingen strømpriskilde", + "description": "EffektGuard mangler strømprisdata, så prisoptimaliseringen kjører ikke - varmekurven styres kun av komfort og sikkerhet. Konfigurer en spotprisentitet (GE-Spot, Nord Pool eller lignende) i integrasjonens innstillinger for å aktivere den." + }, + "no_dhw_control_entity": { + "title": "Ingen entitet for varmtvannsstyring", + "description": "EffektGuard styrer varmtvann ved å slå på NIBEs midlertidige luksus-bryter, og ingen slik entitet ble funnet. Home Assistants NIBE-integrasjon eksponerer det registeret (50004) kun for F-serien, så en S-seriepumpe har ingen tilsvarende - varmtvannsoptimaliseringen kjører IKKE, og en planlagt kjøring som sensorene viser vil ikke skje. Varmeoptimaliseringen påvirkes ikke." + } } -} \ No newline at end of file +} diff --git a/custom_components/effektguard/translations/sv.json b/custom_components/effektguard/translations/sv.json index ed308eb6..1d89ff2b 100644 --- a/custom_components/effektguard/translations/sv.json +++ b/custom_components/effektguard/translations/sv.json @@ -90,42 +90,19 @@ "step": { "init": { "title": "EffektGuard-alternativ", - "description": "Konfigurera optimeringsbeteende och byggnadsegenskaper.\n\n**Ändringar tillämpas omedelbart** utan omstart. Använd **Omkonfigurera** för att ändra sensorval.", - "data": { - "optimization_mode": "Optimeringsläge" - }, - "data_description": { - "optimization_mode": "**Komfort**: Tight temperaturkontroll, minimal prisoptimering, prioriterar stabil värme. **Balanserad**: Måttlig optimering som tillåter små temperaturvariationer för kostnadsbesparingar. **Besparingar**: Maximal prisoptimering med större temperatursvängningar under billiga/dyra perioder" - }, "sections": { - "comfort_settings": { - "name": "Komfortinställningar", - "description": "Finjustera temperaturkomforttolerans", + "optimization_settings": { + "name": "Optimeringsinställningar", + "description": "Välj optimeringsläge och temperaturtolerans", "data": { + "optimization_mode": "Optimeringsläge", "tolerance": "Temperaturtolerans" }, "data_description": { + "optimization_mode": "**Komfort**: Tight temperaturkontroll, minimal prisoptimering, prioriterar stabil värme. **Balanserad**: Måttlig optimering som tillåter små temperaturvariationer för kostnadsbesparingar. **Besparingar**: Maximal prisoptimering med större temperatursvängningar under billiga/dyra perioder", "tolerance": "Styr tillåten temperaturavvikelse från mål (±0,2°C dödzon bibehålls alltid). Högre värden tillåter mer aggressiv kostnadsoptimering men större temperatursvängningar" } }, - "dhw_settings": { - "name": "Varmvatteninställningar", - "description": "Schemalägg när varmvatten ska värmas", - "data": { - "dhw_target_temp": "Varmvatten måltemperatur", - "dhw_morning_enabled": "Morgon varmvatten", - "dhw_morning_hour": "Morgon starttid", - "dhw_evening_enabled": "Kväll varmvatten", - "dhw_evening_hour": "Kväll starttid" - }, - "data_description": { - "dhw_target_temp": "Måltemperatur för varmvatten (45-60°C)", - "dhw_morning_enabled": "Aktivera uppvärmning av varmvatten på morgonen", - "dhw_morning_hour": "Morgonbehovsperiod starttimme", - "dhw_evening_enabled": "Aktivera uppvärmning av varmvatten på kvällen", - "dhw_evening_hour": "Kvällsbehovsperiod starttimme" - } - }, "building_characteristics": { "name": "Byggnadsegenskaper", "description": "Hjälper till att förutsäga ditt hems termiska beteende", @@ -138,6 +115,24 @@ "insulation_quality": "Övergripande isoleringsnivå (0,5=dålig, 1,0=normal, 2,0=utmärkt)" } }, + "domestic_hot_water": { + "name": "Varmvatten (DHW)", + "description": "Schemalägg när varmvattnet ska vara klart och hur mycket du behöver", + "data": { + "dhw_target_temp": "Varmvatten måltemperatur", + "dhw_min_amount": "Minsta mängd varmvatten vid schemalagd tid", + "dhw_schedules": "Aktivera/inaktivera", + "dhw_morning_hour": "Morgon starttid", + "dhw_evening_hour": "Kväll starttid" + }, + "data_description": { + "dhw_target_temp": "Måltemperatur för varmvatten (45-60°C)", + "dhw_min_amount": "Antal minuters varmvatten som ska finnas tillgängligt vid den schemalagda tiden.\n\nEffektGuard värmer under de billigaste timmarna för att säkerställa att mängden är klar när du behöver den.", + "dhw_schedules": "Välj vilka uppvärmningsperioder som ska vara aktiva. Avmarkera för att stänga av uppvärmning under perioden.", + "dhw_morning_hour": "Morgonbehovsperiod starttimme", + "dhw_evening_hour": "Kvällsbehovsperiod starttimme" + } + }, "airflow_optimization": { "name": "Luftflödesoptimering (Frånluft)", "description": "Konfigurera luftflödeshastigheter för frånluftsvärmepumpar. Aktivera/inaktivera via Luftflödesoptimering-knappen i Kontroller.", @@ -152,6 +147,9 @@ } } } + }, + "error": { + "invalid_dhw_config": "Ogiltig varmvatteninställning: {reason}" } }, "entity": { @@ -174,6 +172,80 @@ "airflow_optimization": { "name": "Luftflödesoptimering" } + }, + "sensor": { + "current_offset": { + "name": "Aktuell förskjutning" + }, + "degree_minutes": { + "name": "Gradminuter" + }, + "supply_temperature": { + "name": "Framledningstemperatur" + }, + "outdoor_temperature": { + "name": "Utomhustemperatur" + }, + "indoor_temperature": { + "name": "Inomhustemperatur" + }, + "current_price": { + "name": "Aktuellt elpris" + }, + "peak_today": { + "name": "Effekttopp idag" + }, + "peak_this_month": { + "name": "Effekttopp denna månad" + }, + "nibe_power": { + "name": "NIBE effekt" + }, + "compressor_frequency": { + "name": "Kompressorfrekvens" + }, + "compressor_health": { + "name": "Kompressorns hälsostatus" + }, + "optimization_reasoning": { + "name": "Optimeringsmotivering" + }, + "quarter_of_day": { + "name": "Kvart på dygnet" + }, + "price_period_classification": { + "name": "Prisperiodens klassificering" + }, + "temperature_trend": { + "name": "Trend för inomhustemperatur" + }, + "outdoor_temperature_trend": { + "name": "Trend för utomhustemperatur" + }, + "savings_estimate": { + "name": "Uppskattad besparing per månad" + }, + "optional_features_status": { + "name": "Status för valfria funktioner" + }, + "heat_pump_model": { + "name": "Värmepumpsmodell" + }, + "dhw_status": { + "name": "Varmvattenstatus" + }, + "dhw_recommendation": { + "name": "Varmvattenrekommendation" + }, + "dhw_next_boost_time": { + "name": "Schemalagd varmvattenstart" + }, + "airflow_enhancement": { + "name": "Förhöjt luftflöde" + }, + "airflow_thermal_gain": { + "name": "Värmevinst från luftflöde" + } } }, "services": { @@ -185,5 +257,15 @@ "name": "Boosta varmvatten", "description": "Starta omedelbar varmvattencykel" } + }, + "issues": { + "no_price_source": { + "title": "Ingen elpriskälla", + "description": "EffektGuard saknar elprisdata, så prisoptimeringen är inte igång - värmekurvan styrs enbart av komfort och säkerhet. Konfigurera en spotprisentitet (GE-Spot, Nord Pool eller liknande) i integrationens inställningar för att aktivera den." + }, + "no_dhw_control_entity": { + "title": "Ingen entitet för varmvattenstyrning", + "description": "EffektGuard styr varmvatten genom att slå på NIBEs tillfälliga lyx-brytare, och ingen sådan entitet hittades. Home Assistants NIBE-integration exponerar det registret (50004) endast för F-serien, så en S-seriepump saknar motsvarighet - varmvattenoptimeringen är INTE igång, och en schemalagd körning som sensorerna visar kommer inte att ske. Värmeoptimeringen påverkas inte." + } } -} \ No newline at end of file +} diff --git a/custom_components/effektguard/utils/compressor_monitor.py b/custom_components/effektguard/utils/compressor_monitor.py index b92b93e7..b17cf651 100644 --- a/custom_components/effektguard/utils/compressor_monitor.py +++ b/custom_components/effektguard/utils/compressor_monitor.py @@ -17,6 +17,15 @@ from homeassistant.util import dt as dt_util +from ..const import ( + COMPRESSOR_HZ_MAX, + COMPRESSOR_RISK_ELEVATED, + COMPRESSOR_RISK_HIGH, + COMPRESSOR_RISK_NOTABLE, + COMPRESSOR_RISK_OK, + COMPRESSOR_RISK_WATCH, +) + _LOGGER = logging.getLogger(__name__) @@ -126,10 +135,12 @@ def update( if timestamp is None: timestamp = dt_util.now() - # Validate Hz reading - if hz < 0 or hz > 150: - _LOGGER.warning("Invalid compressor Hz reading: %d (expected 0-120 range)", hz) - hz = max(0, min(hz, 150)) + # Clamp and warning message both bound by COMPRESSOR_HZ_MAX, so they cannot disagree. + if hz < 0 or hz > COMPRESSOR_HZ_MAX: + _LOGGER.warning( + "Invalid compressor Hz reading: %d (expected 0-%d range)", hz, COMPRESSOR_HZ_MAX + ) + hz = max(0, min(hz, COMPRESSOR_HZ_MAX)) # Add to history self.hz_history.append((timestamp, hz)) @@ -330,7 +341,7 @@ def assess_risk(self, stats: CompressorStats) -> tuple[str, str]: if stats.time_above_100hz > timedelta(minutes=15): minutes = stats.time_above_100hz.total_seconds() / 60 return ( - "HIGH", + COMPRESSOR_RISK_HIGH, f"Compressor at {stats.current_hz} Hz for {minutes:.0f} min", ) @@ -339,7 +350,7 @@ def assess_risk(self, stats: CompressorStats) -> tuple[str, str]: if stats.time_above_80hz > timedelta(hours=2): hours = stats.time_above_80hz.total_seconds() / 3600 return ( - "ELEVATED", + COMPRESSOR_RISK_ELEVATED, f"Sustained operation ({stats.avg_1h:.0f} Hz avg) for {hours:.1f}h", ) @@ -347,7 +358,7 @@ def assess_risk(self, stats: CompressorStats) -> tuple[str, str]: # System consistently operating at high capacity if stats.avg_6h > 70: return ( - "NOTABLE", + COMPRESSOR_RISK_NOTABLE, f"6-hour average: {stats.avg_6h:.0f} Hz", ) @@ -356,18 +367,18 @@ def assess_risk(self, stats: CompressorStats) -> tuple[str, str]: # Normal if brief, concerning if sustained if stats.avg_1h > 75: return ( - "WATCH", + COMPRESSOR_RISK_WATCH, f"High demand period (1h avg: {stats.avg_1h:.0f} Hz, current: {stats.current_hz} Hz)", ) # OK: Normal operation if stats.current_hz > 0: return ( - "OK", + COMPRESSOR_RISK_OK, f"Normal operation ({stats.current_hz} Hz, 6h avg: {stats.avg_6h:.0f} Hz)", ) else: - return ("OK", "Compressor idle") + return (COMPRESSOR_RISK_OK, "Compressor idle") def get_diagnostic_info(self, stats: CompressorStats) -> CompressorDiagnosticsDict: """Get diagnostic information for troubleshooting. diff --git a/custom_components/effektguard/utils/emitter.py b/custom_components/effektguard/utils/emitter.py new file mode 100644 index 00000000..737230c2 --- /dev/null +++ b/custom_components/effektguard/utils/emitter.py @@ -0,0 +1,110 @@ +"""The EN 442 emitter law: the flow temperature a heating system needs. + +The single source of truth for "how hot must the water be right now". + + phi = (T_room - T_out) / (T_room - T_out_design) relative load [EN 12831] + dT = dT_design * phi ** (1 / n) emitter law [EN 442-1 3.31] + T_flow = T_room + dT + spread_design / 2 + +EN 12831 makes heat loss linear in the indoor/outdoor difference, so relative load `phi` is a +ratio of temperature differences. EN 442-1 3.31 gives output as (dT / dT_N) ** n; setting output +equal to load and inverting yields the 1/n exponent. + +THE SPREAD IS CONSTANT - never scale it by phi. A fixed-speed circulator gives constant mass flow +and a load-proportional spread (a wet boiler); a heat pump MODULATES its circulator to hold the +commissioned spread and varies the flow RATE. Scaling it runs the curve too COOL in mild weather +and too HOT in cold, pivoting invisibly on the design point where the error is zero. + +INTERNAL GAINS ARE WATTS, NEVER A CURVE FIT. Demand is linear in (balance - T_out), not +(T_room - T_out), so the caller passes a balance point DERIVED from watts +(`indoor - gains_W / heat_loss_W_per_K`). It must never be fitted: the constant-spread term and +the balance-point term are the same basis function with opposite signs, so any assumed spread +manufactures a matching "gains" figure - even out of a curve with provably zero gains. + +This is OpenEnergyMonitor's method (github.com/openenergymonitor/tools, weathercomp.js), not an +invention; see docs/research/02_emitter_law.md and +tests/validation/test_emitter_law_matches_openenergymonitor.py. +""" + +import logging + +_LOGGER = logging.getLogger(__name__) + + +def en442_flow_temp( + indoor_setpoint: float, + outdoor_temp: float, + design_outdoor_temp: float, + design_flow_temp: float, + design_spread: float, + emitter_exponent: float, + balance_point_temp: float | None = None, +) -> float: + """Flow temperature the emitters need to hold ``indoor_setpoint`` at ``outdoor_temp``. + + Args: + indoor_setpoint: Target indoor temperature (C). + outdoor_temp: Current outdoor temperature (C). + design_outdoor_temp: Dimensioning outdoor temperature the emitters were sized for (C). + design_flow_temp: Supply temperature the system needs at ``design_outdoor_temp`` (C). + design_spread: Flow-return spread the circulator holds (C). NOT scaled by load. + emitter_exponent: EN 442 exponent n (1.3 radiators, 1.1 underfloor). + balance_point_temp: Outdoor temperature at which the house needs no heat at all, because + bodies, appliances and the sun already supply its losses. DERIVE it from watts - + ``indoor - internal_gains_W / heat_loss_W_per_K`` - and never fit it against a heating + curve; see the module docstring for why that fit cannot work. Defaults to + ``indoor_setpoint``, i.e. no gains, which is what OpenEnergyMonitor's tool assumes and + what a bare emitter law implies. + + Returns: + Required flow temperature (C). Never below ``indoor_setpoint``: water colder than the + room removes heat from it. + """ + balance = indoor_setpoint if balance_point_temp is None else balance_point_temp + + load = balance - outdoor_temp + if load <= 0: + # Warmer than the balance point: the house is heating itself, so the emitters need no + # excess over the room at all. Return the CONTINUOUS limit of the expression below as + # load -> 0 (excess -> 0), not the bare setpoint: dropping the spread term here would put + # a spread/2 cliff - 2.5 C on the defaults - at the balance point, and the balance point + # sits in the middle of the Swedish shoulder season, where the outdoor temperature crosses + # it back and forth all day. A step there is chatter. + return indoor_setpoint + (design_spread / 2.0) + + design_load = balance - design_outdoor_temp + if design_load <= 0 or emitter_exponent <= 0: + # A design point that cannot be extrapolated from. Return the setpoint rather than + # fabricate a temperature; weather compensation then commands no change. + _LOGGER.warning( + "Cannot size emitters: design outdoor %.1f C is not below the %.1f C setpoint " + "(exponent %.2f)", + design_outdoor_temp, + indoor_setpoint, + emitter_exponent, + ) + return indoor_setpoint + + # Mean water temperature above the room at the design point, implied by the design point + # rather than configured separately. + design_excess = design_flow_temp - (design_spread / 2.0) - indoor_setpoint + if design_excess <= 0: + _LOGGER.warning( + "Cannot size emitters: design flow %.1f C with spread %.1f C does not exceed the " + "%.1f C setpoint", + design_flow_temp, + design_spread, + indoor_setpoint, + ) + return indoor_setpoint + + # Exceeds 1.0 below the design temperature, which must ask for MORE than the design flow. + # Do not clamp it: that silently under-heats exactly when the house can least afford it. + phi = load / design_load + + excess = design_excess * (phi ** (1.0 / emitter_exponent)) + + # The spread does NOT scale with load - a heat pump modulates its circulator to hold it. + # See the module docstring: scaling it made the curve too cool when mild and too hot when + # cold, pivoting invisibly on the design point. + return indoor_setpoint + excess + (design_spread / 2.0) diff --git a/custom_components/effektguard/utils/offset.py b/custom_components/effektguard/utils/offset.py new file mode 100644 index 00000000..7ef7cd1f --- /dev/null +++ b/custom_components/effektguard/utils/offset.py @@ -0,0 +1,43 @@ +"""Turning a fractional curve offset into the integer NIBE's register (47011) can hold. + +TRUNCATE (int()), never round() - main's original design, and it is deliberate. The caller +recomputes the pending demand every cycle as (calculated - register), so truncation applies +only the WHOLE degrees the demand actually covers and leaves the fraction pending for the +next cycle: nothing is lost, and the register never receives tenths the engine did not ask +for. round() would over-apply by up to 0.5 C and then oscillate back as the recomputed +demand reverses sign. + +The sub-degree DEADBAND is hysteresis, not rounding: it stops MyUplink's rate-limited +register being rewritten as demand wanders across a boundary. + +Shared by the adapter and the simulation harness so the two cannot drift apart. + +tests/unit/utils/test_the_pump_does_what_the_engine_asked.py +""" + +from ..const import ( + MAX_OFFSET, + MIN_OFFSET, + NIBE_FRACTIONAL_ACCUMULATOR_THRESHOLD, +) + + +def integer_offset_for(calculated: float, current: int) -> int: + """The integer the pump's offset register should hold. + + Args: + calculated: The fractional offset the decision engine asked for (°C). + current: What the register holds right now (°C). + + Returns: + The integer to write, clamped to the register's range. Equal to ``current`` when the + demand has not crossed a whole degree yet - the fraction stays pending and is + re-derived next cycle. + """ + demand = calculated - current + if abs(demand) < NIBE_FRACTIONAL_ACCUMULATOR_THRESHOLD: + return current + + # int(), not round(): apply only the whole degrees the demand covers. See module docstring. + target = current + int(demand) + return int(max(MIN_OFFSET, min(target, MAX_OFFSET))) diff --git a/custom_components/effektguard/utils/power.py b/custom_components/effektguard/utils/power.py new file mode 100644 index 00000000..1d03bf0f --- /dev/null +++ b/custom_components/effektguard/utils/power.py @@ -0,0 +1,77 @@ +"""Reading a Home Assistant power entity as kilowatts. + +One function, used by everything that reads the owner's power meter, so two readers cannot disagree +by a factor of a thousand over what an absent unit means - which is what happened when the NIBE +adapter read a unit-less sensor as kilowatts and the coordinator read the same sensor as watts. + +There is no defensible default: this number decides whether the house is about to set a monthly +billing peak. An unrecognised unit is refused, and the caller withdraws whatever depends on it. +""" + +import logging + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfPower +from homeassistant.core import State + +from ..const import KILOWATTS_PER_MEGAWATT, MILLIWATTS_PER_KILOWATT, WATTS_PER_KILOWATT + +_LOGGER = logging.getLogger(__name__) + +# Every unit that IS a power, keyed on HA's OWN strings, CASE-SENSITIVELY. +# +# `UnitOfPower.MILLIWATT` ("mW") and `UnitOfPower.MEGA_WATT` ("MW") differ ONLY in case, so folding +# them reads a milliwatt sensor as megawatts (10^9x). That does not throttle the house - it makes +# every real hour look safe against a 5 000 000 kW peak, silently disabling peak protection for the +# rest of the month. Anything else (no unit, kWh, Wh, %) is refused; kWh is one dropdown entry away. +# tests/unit/utils/test_milliwatts_are_not_megawatts.py +POWER_UNIT_FACTORS_KW: dict[str, float] = { + UnitOfPower.MILLIWATT: 1.0 / MILLIWATTS_PER_KILOWATT, + UnitOfPower.WATT: 1.0 / WATTS_PER_KILOWATT, + UnitOfPower.KILO_WATT: 1.0, + UnitOfPower.MEGA_WATT: KILOWATTS_PER_MEGAWATT, +} + +# Hand-written template sensors do not always match HA's capitalisation, and refusing "kw" would +# break working installations for no safety gain. So a case-insensitive second pass is allowed - +# but ONLY where the fold is unambiguous. "mw" is not: it is both milliwatts and megawatts. +_UNAMBIGUOUS_FOLDED_UNITS: dict[str, float] = { + folded: factor + for folded, factor in ((unit.lower(), factor) for unit, factor in POWER_UNIT_FACTORS_KW.items()) + if [u.lower() for u in POWER_UNIT_FACTORS_KW].count(folded) == 1 +} + + +def power_kw_from_state(state: State | None) -> float | None: + """Return the state's value in kW, or None if it cannot be trusted. + + None means "no power reading", and callers must treat it as exactly that - not as zero, and not as + a reason to substitute a guess into a field that is documented to hold a measurement. + """ + if state is None or state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): + return None + + unit = str(state.attributes.get("unit_of_measurement", "")).strip() + factor = POWER_UNIT_FACTORS_KW.get(unit) + if factor is None: + factor = _UNAMBIGUOUS_FOLDED_UNITS.get(unit.lower()) + if factor is None: + _LOGGER.warning( + "Power sensor %s reports %s in units of %r, which is not a power unit this integration " + "will act on (expected one of %s, spelled as Home Assistant spells them). Refusing to " + "guess: 'mW' and 'MW' differ only in case and are a factor of %d apart, and this " + "reading decides whether the house is about to set a monthly billing peak.", + state.entity_id, + state.state, + unit or "none", + ", ".join(POWER_UNIT_FACTORS_KW), + int(MILLIWATTS_PER_KILOWATT * KILOWATTS_PER_MEGAWATT), + ) + return None + + try: + return float(state.state) * factor + except (ValueError, TypeError): + _LOGGER.warning( + "Power sensor %s reports %r, which is not a number", state.entity_id, state.state + ) + return None diff --git a/custom_components/effektguard/utils/price_math.py b/custom_components/effektguard/utils/price_math.py new file mode 100644 index 00000000..1890c39f --- /dev/null +++ b/custom_components/effektguard/utils/price_math.py @@ -0,0 +1,32 @@ +"""How much cheaper one price is than another, when either may be zero or negative. + +Nordic spot prices reach exactly 0.00 (~100 h/year per SE zone) and go negative on windy days, +which breaks the naive `(current - optimal) / current` two ways: truthiness treats a real 0.00 +price as "no price" and skips the cheaper window, and a signed divisor inverts the fraction when +the current price is negative, so genuinely cheaper windows score negative and are declined. +Shared so the DHW optimizer's two comparisons cannot drift apart again. + +tests/unit/utils/test_a_negative_price_is_still_a_price.py +""" + + +def price_savings_fraction(current: float | None, candidate: float) -> float | None: + """How much cheaper `candidate` is than `current`, as a fraction of `current`'s magnitude. + + `current is None` means no current price (NOT zero) and returns None. A candidate that is not + cheaper returns None. The denominator is |current|, so the sign tracks which price is lower and + nothing else; when current is exactly 0.00 any cheaper (negative) window returns 1.0 rather than + dividing by zero. + """ + if current is None: + return None + + if candidate >= current: + return None + + reference = abs(current) + if reference == 0.0: + # Free now, and being PAID in the candidate window. That is as good as it gets. + return 1.0 + + return (current - candidate) / reference diff --git a/custom_components/effektguard/utils/time_utils.py b/custom_components/effektguard/utils/time_utils.py index 0080bc05..b3d445a2 100644 --- a/custom_components/effektguard/utils/time_utils.py +++ b/custom_components/effektguard/utils/time_utils.py @@ -8,10 +8,10 @@ from homeassistant.util import dt as dt_util -from ..const import QUARTER_INTERVAL_MINUTES, QUARTERS_PER_DAY +from ..const import MINUTES_PER_QUARTER, QUARTERS_PER_DAY # Minutes per quarter (15 min = Swedish Effektavgift measurement period) -QUARTERS_PER_HOUR = 60 // QUARTER_INTERVAL_MINUTES # 4 +QUARTERS_PER_HOUR = 60 // MINUTES_PER_QUARTER # 4 def get_current_quarter(now: Optional[datetime] = None) -> int: @@ -28,7 +28,7 @@ def get_current_quarter(now: Optional[datetime] = None) -> int: """ if now is None: now = dt_util.now() - return (now.hour * QUARTERS_PER_HOUR) + (now.minute // QUARTER_INTERVAL_MINUTES) + return (now.hour * QUARTERS_PER_HOUR) + (now.minute // MINUTES_PER_QUARTER) def resolve_period_index(price_data: object, now: Optional[datetime] = None) -> Optional[int]: @@ -65,3 +65,12 @@ def resolve_period_index(price_data: object, now: Optional[datetime] = None) -> if len(periods) == QUARTERS_PER_DAY and quarter < len(periods): return quarter return None + + +def get_current_billing_period(now: Optional[datetime] = None) -> int: + """The owner's effect-tariff billing period: the 15-minute quarter of the day, 0-95. + + The owner runs 15-minute measurement intervals, so the billing period is the quarter-hour, the + same cadence get_current_quarter returns. Operator models vary (F-107); this is the owner's. + """ + return get_current_quarter(now) diff --git a/custom_components/effektguard/utils/volatile_helpers.py b/custom_components/effektguard/utils/volatile_helpers.py index 9f487203..d109fd53 100644 --- a/custom_components/effektguard/utils/volatile_helpers.py +++ b/custom_components/effektguard/utils/volatile_helpers.py @@ -21,6 +21,8 @@ PEAK periods are treated as part of the PEAK cluster (not volatile). """ +import time + from dataclasses import dataclass from ..const import ( @@ -303,7 +305,6 @@ def record_change(self, offset: float, reason: str = "") -> None: offset: The new offset value reason: Reason for the change (for logging) """ - import time self._last_change = OffsetChangeInfo( offset=offset, @@ -329,8 +330,6 @@ def is_reversal_volatile(self, new_offset: float) -> bool: if self._last_change is None: return False - import time - last_offset = self._last_change.offset change_magnitude = abs(new_offset - last_offset) @@ -378,8 +377,6 @@ def get_volatile_reason(self, new_offset: float) -> str: if self._last_change is None: return "" - import time - time_since_last = time.time() - self._last_change.timestamp minutes_since = time_since_last / SECONDS_PER_MINUTE diff --git a/docs/CLIMATE_ZONES.md b/docs/CLIMATE_ZONES.md index d1edf32c..ef9abf92 100644 --- a/docs/CLIMATE_ZONES.md +++ b/docs/CLIMATE_ZONES.md @@ -32,9 +32,9 @@ EffektGuard automatically detects: **Cold Zone** (56-60.5°N) | Outdoor Temp | Normal DM Range | Warning Threshold | |--------------|----------------|-------------------| -| -30°C | -800 to -1200 | -1200 | -| -20°C | -600 to -1000 | -1000 | -| -10°C | -400 to -800 | -800 | +| -30°C | -1000 to -1400 | -1400 | +| -20°C | -800 to -1200 | -1200 | +| -10°C | -600 to -1000 | -1000 | **Safety margin:** +2.5°C additional flow temperature headroom @@ -47,7 +47,7 @@ EffektGuard automatically detects: **Cold Zone** (56-60.5°N) **Examples:** Luleå (SWE), Umeå (SWE), Oulu (FIN), Trondheim (NOR) **Winter characteristics:** -- Average winter low: -15°C +- Average winter low: -12°C - Heavy heating demands - Extended compressor run times @@ -55,10 +55,10 @@ EffektGuard automatically detects: **Cold Zone** (56-60.5°N) | Outdoor Temp | Normal DM Range | Warning Threshold | |--------------|----------------|-------------------| -| -20°C | -700 to -1100 | -1100 | -| -15°C | -600 to -1000 | -1000 | -| -10°C | -500 to -900 | -900 | -| -5°C | -400 to -800 | -800 | +| -20°C | -760 to -1160 | -1160 | +| -15°C | -660 to -1060 | -1060 | +| -10°C | -560 to -960 | -960 | +| -5°C | -460 to -860 | -860 | **Safety margin:** +1.5°C additional flow temperature headroom @@ -69,7 +69,7 @@ EffektGuard automatically detects: **Cold Zone** (56-60.5°N) **Examples:** Stockholm (SWE), Oslo (NOR), Göteborg (SWE), Helsinki (FIN) **Winter characteristics:** -- Average winter low: -10°C +- Average winter low: -8°C - Substantial heating demands - Standard Nordic winter operation @@ -77,10 +77,10 @@ EffektGuard automatically detects: **Cold Zone** (56-60.5°N) | Outdoor Temp | Normal DM Range | Warning Threshold | |--------------|----------------|-------------------| -| -15°C | -550 to -800 | -800 | -| -10°C | -450 to -700 | -700 | -| -5°C | -350 to -600 | -600 | -| 0°C | -250 to -500 | -500 | +| -15°C | -590 to -840 | -840 | +| -10°C | -490 to -740 | -740 | +| -5°C | -390 to -640 | -640 | +| 0°C | -290 to -540 | -540 | **Safety margin:** +1.0°C additional flow temperature headroom @@ -101,9 +101,9 @@ EffektGuard automatically detects: **Cold Zone** (56-60.5°N) | Outdoor Temp | Normal DM Range | Warning Threshold | |--------------|----------------|-------------------| -| -5°C | -400 to -600 | -600 | -| 0°C | -300 to -500 | -500 | -| 5°C | -200 to -400 | -400 | +| -5°C | -380 to -580 | -580 | +| 0°C | -280 to -480 | -480 | +| 5°C | -180 to -380 | -380 | **Safety margin:** +0.5°C additional flow temperature headroom @@ -114,7 +114,7 @@ EffektGuard automatically detects: **Cold Zone** (56-60.5°N) **Examples:** Paris (FRA), London (UK), Berlin (GER), and all other locations **Winter characteristics:** -- Average winter low: +5°C +- Average winter low: 0°C - Minimal heating demands - Mild climate, less optimization benefit @@ -122,9 +122,9 @@ EffektGuard automatically detects: **Cold Zone** (56-60.5°N) | Outdoor Temp | Normal DM Range | Warning Threshold | |--------------|----------------|-------------------| -| 0°C | -350 to -550 | -550 | -| 5°C | -200 to -350 | -350 | -| 10°C+ | Minimal | -200 | +| 0°C | -200 to -350 | -350 | +| 5°C | -100 to -250 | -250 | +| 10°C | 0 to -150 | -150 | **Safety margin:** No additional headroom needed @@ -136,20 +136,35 @@ EffektGuard automatically detects: **Cold Zone** (56-60.5°N) The base DM thresholds adjust dynamically based on current outdoor temperature: -**Formula:** `adjustment = (zone_avg_winter_low - outdoor_temp) × 20 DM/°C` +**Formula:** `adjustment = (outdoor_temp - zone_avg_winter_low) × 20 DM/°C` -**Example (Stockholm - Cold Zone, winter avg -10°C):** -- Zone winter average: -10°C -- Current outdoor: -20°C -- Difference: 10°C colder than average -- Adjustment: 10 × 20 = -200 DM deeper threshold -- Base warning: -700 DM -- **Adjusted: -900 DM** (allows deeper DM in colder weather) +Colder than the zone average ⇒ the difference is negative ⇒ the threshold moves **deeper**. +Warmer ⇒ positive ⇒ **shallower**. (`climate_zones.py:231` — `temp_delta = outdoor_temp - +self.zone_info.winter_avg_low`. This document previously stated the subtraction the other way +round, which yields the opposite sign, and then wrote the correct number anyway — so the formula +and its own worked example disagreed. If you are deriving a threshold by hand, use the code.) + +**Example (Stockholm — Cold Zone, winter avg −8°C):** +- Zone winter average: **−8°C** +- Current outdoor: −20°C +- `temp_delta` = −20 − (−8) = **−12°C** (colder than average) +- Adjustment: −12 × 20 = **−240 DM** (deeper threshold) +- Base warning: −700 DM +- **Adjusted: −940 DM** (allows deeper DM in colder weather) **Conversely at 0°C:** -- 10°C warmer than average -- Adjustment: +200 DM shallower threshold -- **Adjusted: -500 DM** (tighter tolerance in mild weather) +- `temp_delta` = 0 − (−8) = **+8°C** (warmer than average) +- Adjustment: +8 × 20 = **+160 DM** (shallower threshold) +- **Adjusted: −540 DM** (tighter tolerance in mild weather) + +**⚠️ The WARNING band has zero width.** In every zone `normal_max == warning` +(`DM_CRITICAL_T1_MARGIN` is 0), so a DM that is "past normal" is already "past warning". The +intermediate WARNING/CAUTION states are unreachable. This is a known open finding — do not +"correct" the tables to conceal it. + +**⚠️ On a NIBE F750 the pump acts first.** Its own "start addition" (menu 4.9.3) defaults to +**−700**, so the immersion heater engages *before* Stockholm's −740 warning threshold is ever +reached, and works DM back up. Do not reason about −1500 as though the elpatron were not there. ### Absolute Safety Limit @@ -248,7 +263,7 @@ Not currently needed - the automatic detection is very accurate. If you have a m ### Does this replace weather compensation? No! Climate zones work **with** weather compensation: -- **Weather compensation** (André Kühne formula): Calculates optimal flow temperature +- **Weather compensation** (the EN 442 emitter law): Calculates optimal flow temperature - **Climate zones**: Add safety margins and set DM expectations - **Together**: Maximum efficiency with appropriate safety buffers diff --git a/docs/architecture/00_overview.md b/docs/architecture/00_overview.md index 2660a286..e77c0322 100644 --- a/docs/architecture/00_overview.md +++ b/docs/architecture/00_overview.md @@ -31,7 +31,7 @@ This folder contains detailed Mermaid diagrams showing how EffektGuard works in ### 1. Context-Aware Safety System - **Not fixed thresholds** - adapts degree minutes limits based on climate zone + outdoor temperature -- Stockholm (-10°C, Cold zone): Expects DM -450 to -700, warning at -700 +- Stockholm (-10°C, Cold zone): Expects DM -490 to -740, warning at -740 - Kiruna (-25°C, Extreme Cold zone): Expects DM -900 to -1300, warning at -1300 - **Absolute maximum -1500 DM** never exceeded regardless of conditions - **T2 thermal recovery damping** prevents overshoot when solar gain or natural warming detected diff --git a/docs/architecture/02_emergency_thermal_debt.md b/docs/architecture/02_emergency_thermal_debt.md index 96b14c5e..1f5cb244 100644 --- a/docs/architecture/02_emergency_thermal_debt.md +++ b/docs/architecture/02_emergency_thermal_debt.md @@ -12,7 +12,7 @@ flowchart TD subgraph "Context-Aware Analysis" D[Calculate Expected DM Range
Based on Climate Zone + Temp] - E[Cold Zone at -10°C:
Expected: -450 to -700 DM
Warning: -700 DM] + E[Cold Zone at -10°C:
Expected: -490 to -740 DM
Warning: -740 DM] F[Extreme Cold Zone at -25°C:
Expected: -900 to -1300 DM
Warning: -1300 DM] G[Adjustment: -20 DM per °C
colder than zone winter avg] H[Distance from Absolute Max
-1500 DM NEVER EXCEED] @@ -96,8 +96,8 @@ EffektGuard uses **climate zone detection** (based on latitude) combined with ** | Zone | Latitude | Winter Avg | Base Normal Range | Base Warning | |------|----------|------------|-------------------|--------------| | Extreme Cold | 66.5°+ | -20°C | -800 to -1200 | -1200 | -| Very Cold | 60.5°-66.5° | -15°C | -600 to -1000 | -1000 | -| Cold | 56°-60.5° | -10°C | -450 to -700 | -700 | +| Very Cold | 60.5°-66.5° | -12°C | -600 to -1000 | -1000 | +| Cold | 56°-60.5° | -8°C | -450 to -700 | -700 | | Moderate Cold | 54.5°-56° | -1°C | -300 to -500 | -500 | | Standard | <54.5° | 0°C | -200 to -350 | -350 | @@ -107,14 +107,24 @@ EffektGuard uses **climate zone detection** (based on latitude) combined with ** adjusted_warning = zone_warning + (outdoor_temp - zone_winter_avg) × 20 ``` -**Example: Stockholm (Cold zone, winter avg -10°C)** -- At -10°C: warning = -700 (no adjustment) -- At 0°C: warning = -700 + (0 - (-10)) × 20 = -700 + 200 = -500 (shallower) -- At -20°C: warning = -700 + (-20 - (-10)) × 20 = -700 - 200 = -900 (deeper) +**Example: Stockholm (Cold zone, winter avg -8°C)** +- At -8°C: warning = -700 (no adjustment - this is the zone average) +- At -10°C: warning = -700 + (-10 - (-8)) × 20 = -700 - 40 = **-740** (deeper) +- At 0°C: warning = -700 + (0 - (-8)) × 20 = -700 + 160 = **-540** (shallower) +- At -20°C: warning = -700 + (-20 - (-8)) × 20 = -700 - 240 = **-940** (deeper) + +⚠️ The Cold zone's winter average is **-8.0 °C**, not -10. This worked example used to say -10, +and every threshold derived from it was 40 degree-minutes shallower than the code's. -450 to -700 +IS a real Stockholm range - at -8 °C. It is not the range at -10 °C. ### Absolute Safety Limit -**DM -1500 is NEVER exceeded** regardless of outdoor temperature. This is the hard safety limit validated by Swedish NIBE forums and represents the point where heat pump damage becomes likely. +**DM -1500 is NEVER exceeded** regardless of outdoor temperature - it is the absolute floor. + +⚠️ It is **not** the number that governs a real F750: the pump's own "start addition" (menu 4.9.3) +fires at **-700** and works DM back up, so the immersion heater engages long before -1500 is +approached. The -1500 figure is attributed to Swedish forums and is **not sourced in this +repository** - see `docs/research/01_degree_minutes.md`. ### Graduated Response System diff --git a/docs/architecture/04_weather_preheating.md b/docs/architecture/04_weather_preheating.md index 8ec315df..79509ca1 100644 --- a/docs/architecture/04_weather_preheating.md +++ b/docs/architecture/04_weather_preheating.md @@ -108,46 +108,71 @@ This prevents unnecessary pre-heating during: - Small temperature variations (thermal mass handles it) - Forecast uncertainties (avoid overreaction) -### Thermal Decay Modeling - -The pre-heating calculation accounts for **heat loss during the forecast period**: - -#### Step 1: Calculate Heat Loss Rate -``` -heat_loss_rate = temperature_difference / (insulation_quality × 10) +### The Actual Algorithm + +> ⚠️ **This section used to describe a thermal-decay model that does not exist.** It documented a +> `heat_loss_rate`, an `expected_temp_end`, a `deficit`, a `1.0 / thermal_mass` safety margin, a +> `-2.0 × thermal_mass` dynamic threshold and a +3.0 °C cap. **Not one of those variables is in +> `weather_layer.py`.** Its worked example concluded "+3.0 °C", overstating the layer's authority +> by 3.6× against the +0.83 the code actually emitted at the time. What follows is the code. + +`WeatherPredictionLayer.evaluate_layer()` is deliberately simple: it decides **whether** to +pre-heat, not **how much**. The amount is a constant. + +```python +# 1. Look ahead as far as this building's thermal mass justifies. +# 6 h radiators / 12 h timber UFH / 24 h concrete slab, floored at WEATHER_FORECAST_HORIZON. +forecast_hours = weather_data.forecast_hours[: int(self.forecast_horizon)] + +# 2. The coldest hour in that window, relative to now. +temp_drop = min(f.temperature for f in forecast_hours) - nibe_state.outdoor_temp + +# 3. Two independent triggers - either fires the layer. +forecast_triggered = temp_drop <= WEATHER_FORECAST_DROP_THRESHOLD # -4.0 °C +indoor_cooling = (trend_rate <= WEATHER_INDOOR_COOLING_CONFIRMATION # -0.5 °C/h + and trend_confidence > 0.4) + +if forecast_triggered or indoor_cooling: + return WeatherLayerDecision( + offset=WEATHER_PREHEAT_OFFSET, # +2.0 °C, a constant + weight=min(LAYER_WEIGHT_WEATHER_PREDICTION * thermal_mass, WEATHER_WEIGHT_CAP), + ) ``` -#### Step 2: Project End Temperature -``` -expected_temp_end = current_temp - (heat_loss_rate × forecast_hours) -``` +**The horizon must follow the thermal mass, and this is not cosmetic.** A concrete slab does not +get into thermal debt from a sudden plunge — the pump's own curve is reactive but fast, and +catches that. It gets into debt from a slow, deep slide, and a fixed 12-hour window cannot see one: -#### Step 3: Calculate Required Pre-heating -``` -deficit = desired_temp - expected_temp_end -safety_margin = 1.0 / thermal_mass -target_temp = desired_temp + deficit + safety_margin -``` +| cold snap | drop within 12 h | fires? | drop within 24 h | fires? | +|---|---|---|---|---| +| 15 °C over 6 h (plunge) | −15.0 °C | yes | −15.0 °C | yes | +| 15 °C over 48 h (two days) | **−3.8 °C** | **NO** | −7.5 °C | yes | +| 20 °C over 72 h (three days) | **−3.3 °C** | **NO** | −6.7 °C | yes | -#### Step 4: Apply Limits -``` -final_target = min(target_temp, desired_temp + 3.0) // Cap at +3°C -offset = final_target - current_target_temp -``` +Within any twelve hours of a two-day slide the temperature falls less than the four degrees needed +to trigger, so the pre-heat **never fired** — while the sudden plunge, which *did* trigger it, is +the case that needed it least. See audit F-130. ### Safety Mechanisms -#### Maximum Pre-heat Limit -Pre-heating is capped at **+3.0°C above normal target** to prevent: -- Excessive energy consumption -- Thermal debt accumulation -- Overheating during forecast errors - -#### Thermal Mass-Based Safety Margin -Higher thermal mass buildings get **smaller safety margins**: -- Low mass (0.5): 2.0°C safety margin -- Normal mass (1.0): 1.0°C safety margin -- High mass (2.0): 0.5°C safety margin +#### The pre-heat is bounded by construction +`WEATHER_PREHEAT_OFFSET` is **+2.0 °C**, and it is sized, not tuned: the fabric must reach the edge +of `THERMAL_BATTERY_BAND` (±1.0 °C) **within the horizon the house is given**, or the pre-heat is +decoration. On the simulator's validated plant models the previous +0.83 took **28.4 h** (radiator) +and **34.6 h** (concrete slab) to fill that band, against horizons of 12 h and 24 h. It could never +charge the battery before the cold arrived. At +2.0 it takes 9.6 h and 14.8 h — both inside. See +audit F-130 and `tests/unit/optimization/test_preheat_can_actually_charge_the_house.py`. + +It cannot cook the house: the comfort layer takes charge at the edge of the storage band, and ++2.0 sits within `WEATHER_COMP_MAX_OFFSET` (3.0), the bound placed on every weather-driven +correction. + +#### Weight scales with thermal mass +``` +weight = min(LAYER_WEIGHT_WEATHER_PREDICTION × thermal_mass, WEATHER_WEIGHT_CAP) +``` +A heavy building both needs more warning and can store more, so its pre-heat vote carries further. +The cap keeps it below the Safety layer. This reflects their ability to store and retain heat effectively. diff --git a/docs/architecture/06_learning_integration.md b/docs/architecture/06_learning_integration.md index a221f0ca..d7462f9d 100644 --- a/docs/architecture/06_learning_integration.md +++ b/docs/architecture/06_learning_integration.md @@ -1,5 +1,38 @@ # Scenario 6: Phase 6 Learning Integration +> ## ⚠️ THIS DESCRIBES A SUBSYSTEM THAT DOES NOT CURRENTLY DRIVE YOUR HEAT PUMP +> +> Everything below is wired up and runs. Observations are recorded, parameters are computed, and +> the prediction layer *would* consume them at weight 0.65. It does not, because the confidence +> gate is never passed — and until recently, when it *was* passed, it was passed for the wrong +> reason. +> +> **Confidence never reaches 70% at the real observation cadence.** The coordinator records one +> observation per aligned refresh — every **5 minutes** — while `LEARNING_OBSERVATION_WINDOW` +> (672) and `LEARNING_MIN_OBSERVATIONS` (96) are both sized for **15-minute** samples. So the +> "672 observations = 1 week" below is really **56 hours**, the window is a rolling one, and +> `time_confidence` is capped at 0.33 forever. Meanwhile the indoor sensor reads to 0.1 °C, and +> over 5 minutes a building's response is smaller than that — the deltas quantise into steps of +> 1.2 °C/h, larger than any real heating rate, so the consistency term is honestly zero. +> Confidence settles at **0.47** against the 0.70 gate. +> +> **There is no "Day 1-3 → Day 4-7 → Day 8-14" timeline.** The observation window rolls. Day 90 +> sees exactly what day 3 saw. +> +> Before the fix in `d111114`, the consistency term scored a **flatlined sensor** at 1.000 — +> `std/mean` collapses to 0 when every reading is identical — so a house that had taught the model +> nothing, or a *failed* indoor sensor, earned maximum confidence, learning engaged, and the pump +> was driven from a flat line. It engaged on day 4, disengaged by day 7, engaged again on day 60. +> A degenerate signal now scores zero. +> +> **Control today is deterministic** — the heating curve, the EN 442 emitter law, weather +> compensation, and the degree-minute safety net. None of it depends on learning. +> +> Making learning genuinely work means sampling slowly enough that the signal exceeds the sensor's +> resolution (≥30 min clears the gate; hourly is comfortably clear). That is a deliberate decision +> about putting a never-validated learned model into the control path of real heating equipment, +> and it is **open with the owner** — audit finding **F-132**. + **Description**: Self-learning thermal prediction and adaptive behavior. ```mermaid @@ -23,7 +56,7 @@ flowchart TD subgraph "Thermal Prediction" L[ThermalStatePredictor] - M[672 Observations
1 week × 96 quarters] + M[672 Observations
56 h at the real 5-min cadence] N[Confidence ≥ 70%
Use learned params] O[Predict Temperature
6-hour horizon] end diff --git a/docs/architecture/08_layer_priority_system.md b/docs/architecture/08_layer_priority_system.md index e577c80d..80e84318 100644 --- a/docs/architecture/08_layer_priority_system.md +++ b/docs/architecture/08_layer_priority_system.md @@ -103,35 +103,56 @@ Provides **responsive temperature correction**: ### Aggregation Algorithm -#### Critical Layer Override -```python -def _aggregate_layers(self, layers: list[LayerDecision]) -> float: - # Separate critical layers (weight = 1.0) - critical_layers = [layer for layer in layers if layer.weight >= 1.0] - - if critical_layers: - # Take the strongest critical vote - max_offset = max(layer.offset for layer in critical_layers) - min_offset = min(layer.offset for layer in critical_layers) - - # Choose more conservative (safety-oriented) option - if abs(max_offset) > abs(min_offset): - return max_offset - else: - return min_offset -``` +> ⚠️ **This section previously reproduced a version of `_aggregate_layers` that no longer +> exists, and whose behaviour was a bug.** It showed a single "critical layer override" whose +> tie-break was `if abs(max_offset) > abs(min_offset)`. With the emergency layer asking for +> **+10.0** at DM −1520 and a cost layer at critical weight asking for **−10.0**, `abs(+10) > +> abs(−10)` is **False** — so it returned **−10.0: maximum cooling, in a thermal-debt +> emergency.** The stated philosophy ("take the stronger absolute vote… when in doubt, protect +> the heat pump") *was* the defect. Do not restore it. What follows is the algorithm that runs. + +`_aggregate_layers` is an ordered cascade, not a vote. The invariant it exists to enforce: + +> **A cost layer (spot price, effect tariff) must NEVER reduce heating while the thermal-debt +> layer is actively recovering.** -#### Weighted Average Calculation ```python - # Otherwise, weighted average of all layers - total_weight = sum(layer.weight for layer in layers) - if total_weight == 0: - return 0.0 - - weighted_sum = sum(layer.offset * layer.weight for layer in layers) - return weighted_sum / total_weight +# 1. Safety layer - indoor below MIN_TEMP_LIMIT. Absolute; nothing else is consulted. +if safety_layer and safety_layer.weight >= LAYER_WEIGHT_SAFETY: + return clamp(safety_layer.offset) + +# 2. EMERGENCY tier - DM past DM_THRESHOLD_AUX_LIMIT. Absolute. +# Suppressing recovery to protect the effect tariff does not avoid the peak: it +# guarantees a bigger one from the immersion heater, while the debt deepens. +if emergency_layer and emergency_layer.tier == DM_TIER_EMERGENCY: + return clamp(emergency_layer.offset) + +# 3. Recovery tiers T1/T2/T3 - debt past the climate-aware warning threshold. +# A critical cost layer may MODERATE the response, never reverse it. +if emergency_layer and emergency_layer.tier in DM_RECOVERY_TIERS: + floor = DM_CRITICAL_PEAK_AWARE_OFFSETS[emergency_layer.tier] # by TIER, not by weight + if has_critical_cost_layer(layers): + return clamp(floor) # peak-aware compromise + return clamp(max(weighted_average(layers), floor)) # never below the tier's floor + +# 4. Any remaining critical layers, with no recovery in progress. +critical = [l for l in layers if l.weight >= LAYER_WEIGHT_SAFETY] +if critical: + hi, lo = max(l.offset for l in critical), min(l.offset for l in critical) + return clamp(hi if abs(hi) >= abs(lo) else lo) # `>=`, so an exact tie HEATS + +# 5. Everything else: weighted average. +return clamp(weighted_average(layers)) ``` +Two details that look like nits and are not: + +- **Tiers are read from `EmergencyLayerDecision.tier`, never inferred from a weight or an offset + magnitude.** Damping mutates the offset and a weight is a tuning knob, so inferring from either + lets a damped or retuned tier fall through into the cost-layer override path. +- **The tie-break at step 4 is `>=`, not `>`.** `SAFETY_EMERGENCY_OFFSET` (+10) and + `PRICE_OFFSET_PEAK` (−10) tie by construction, and `>` returned the negative vote. + ### Layer Weight Rationale #### Critical Layers (1.0) @@ -195,14 +216,12 @@ Final Decision: +5.0°C #### Critical Layer Override ``` -Safety: +5.0°C × 1.0 = 5.00 (too cold: 17°C indoor) -Emergency: +2.0°C × 0.8 = 1.60 (moderate thermal debt) -Effect: -1.0°C × 0.65 = -0.65 (warning state) -[All other layers ignored due to critical safety override] +Safety: +5.0°C weight 1.0 (too cold: 17°C indoor) +Emergency: +2.0°C weight 0.8 (moderate thermal debt) +Effect: -1.0°C weight 0.65 (warning state) -Critical Override: -- Safety has weight 1.0 (critical) -- Takes complete precedence +Step 1 of the cascade matches: the safety layer is at LAYER_WEIGHT_SAFETY. +It RETURNS. No weighted average is computed and no other layer is consulted. Final Decision: +5.0°C ``` @@ -212,11 +231,20 @@ Final Decision: +5.0°C ### Conflict Resolution Strategy #### Critical Layer Conflicts -When multiple critical layers disagree: - -1. **Take the stronger absolute vote**: `max(abs(offset))` -2. **Rationale**: More urgent situations take precedence -3. **Safety philosophy**: When in doubt, protect the heat pump +The cascade decides by **priority**, not by magnitude. Safety, then the EMERGENCY tier, then the +recovery tiers, then any other critical layer, then the weighted average — the first one that +matches returns. + +Only step 4 compares two critical layers directly, and it prefers the **heating** vote on an +exact tie (`abs(hi) >= abs(lo)`). "Take the stronger absolute vote" is **not** the rule, and was +never a safe one: the strongest absolute vote in a thermal-debt emergency can be a cost layer +demanding −10.0. + +⚠️ **Both cost layers promote themselves to critical weight.** The price layer (in PEAK quarters) +and the effect layer (at the monthly peak) reach `LAYER_WEIGHT_SAFETY`. That is why steps 2 and 3 +exist at all: without them, a critical cost layer would sit in step 4 alongside the emergency +layer and could win. An earlier version of this document asserted that only Safety and Effect ever +reach 1.0 — it does not hold, and reasoning from it produced real defects. #### Advisory Layer Balance Non-critical layers achieve **natural balance** through weighted averaging: diff --git a/docs/architecture/10_adaptive_climate_zones.md b/docs/architecture/10_adaptive_climate_zones.md index 7641fa5d..631e95f7 100644 --- a/docs/architecture/10_adaptive_climate_zones.md +++ b/docs/architecture/10_adaptive_climate_zones.md @@ -71,10 +71,10 @@ flowchart TD C -->|75° YES| D1 C -->|59.33° NO| E{Check very_cold
60.5° - 66.5°} - E -->|62.45° YES| D2["Zone: Very Cold
Safety margin: +1.5°C
Winter avg: -15°C"] + E -->|62.45° YES| D2["Zone: Very Cold
Safety margin: +1.5°C
Winter avg: -12°C"] E -->|59.33° NO| F{Check cold
56.0° - 60.5°} - F -->|59.33° YES| D3["Zone: Cold
Safety margin: +1.0°C
Winter avg: -10°C"] + F -->|59.33° YES| D3["Zone: Cold
Safety margin: +1.0°C
Winter avg: -8°C"] F -->|55.68° NO| G{Check moderate_cold
54.5° - 56.0°} G -->|55.68° YES| D4["Zone: Moderate Cold
Safety margin: +0.5°C
Winter avg: -1°C"] @@ -113,7 +113,7 @@ flowchart TD E2 -->|Extract data| F["outdoor_temp = nibe_state.outdoor_temp
current_flow = nibe_state.flow_temp
Example: -12°C, 38°C"] - F -->|Calculate optimal| G["weather_comp.calculate_optimal_flow_temp()
Uses André Kühne formula"] + F -->|Calculate optimal| G["weather_comp.calculate_optimal_flow_temp()
Uses the EN 442 emitter law"] G -->|Result| H["flow_calc.flow_temp = 35.5°C
(mathematical optimum)"] H -->|Check weather learner| I{self.weather_learner
exists?} @@ -126,7 +126,7 @@ flowchart TD K & M -->|Pass to climate system| N["climate_system.get_safety_margin(
outdoor_temp=-12.0,
unusual_weather_detected=False,
unusual_severity=0.0
)"] - N -->|Get zone info| O["zone_info = CLIMATE_ZONES[self.climate_zone]
Example Cold zone:
base: 1.0°C
winter_avg_low: -10.0°C"] + N -->|Get zone info| O["zone_info = CLIMATE_ZONES[self.climate_zone]
Example Cold zone:
base: 1.0°C
winter_avg_low: -8.0°C"] O -->|Calculate component 1| P["base_margin = zone_info['safety_margin_base']
= 1.0°C"] @@ -306,14 +306,14 @@ detector = ClimateZoneDetector(latitude=59.33) # Zone info: # name: "Cold" # description: "Substantial winter heating demands" -# winter_avg_low: -10.0°C +# winter_avg_low: -8.0°C # safety_margin_base: 1.0°C # dm_normal_range: (-450, -700) # examples: ["Stockholm (SWE)", "Oslo (NOR)", "Göteborg (SWE)", "Helsinki (FIN)"] _LOGGER.info( "Climate zone detected: Cold (Substantial winter heating demands) at latitude 59.33°N - " - "Winter avg: -10.0°C, DM normal range: -450 to -700" + "Winter avg: -8.0°C, DM normal range: -450 to -700" ) ``` @@ -335,7 +335,7 @@ def _weather_compensation_layer(self, nibe_state, weather_data): outdoor_temp=-12.0, prefer_method="auto" ) - # Result: flow_calc.flow_temp = 35.5°C (from Kühne formula) + # Result: flow_calc.flow_temp = 36.0°C (EN 442 emitter law, UFH n=1.1) # Check for unusual weather unusual_weather = False @@ -445,7 +445,7 @@ Initialization: → dm_normal_range: (-800, -1200) Runtime (outdoor = -35°C, no unusual weather): - 1. Kühne formula: 42.0°C optimal flow + 1. EN 442 emitter law: 42.6°C optimal flow 2. Safety margin calculation: - base: 2.5°C (extreme_cold) - temp: (-20 - (-35)) × 0.1 = 1.5°C (colder than avg) @@ -463,18 +463,18 @@ Result: Aggressive heating to prevent thermal debt in extreme cold Initialization: hass.config.latitude = 59.33 → Detected zone: cold - → winter_avg_low: -10.0°C + → winter_avg_low: -8.0°C → safety_margin_base: 1.0°C → dm_normal_range: (-450, -700) Runtime (outdoor = -10°C, no unusual weather): - 1. Kühne formula: 35.5°C optimal flow + 1. EN 442 emitter law: 36.0°C optimal flow 2. Safety margin calculation: - base: 1.0°C (cold) - temp: 0.0°C (at winter average) - unusual: 0.0°C (no unusual weather) - total: 1.0°C - 3. Adjusted flow: 35.5 + 1.0 = 36.5°C + 3. Adjusted flow: 36.0 + 1.0 = 37.0°C 4. Dynamic weight: 0.75 (cold) 5. Final weight: 0.75 × 0.75 = 0.5625 @@ -491,7 +491,7 @@ Initialization: → dm_normal_range: (-300, -500) Runtime (outdoor = 2°C, no unusual weather): - 1. Kühne formula: 28.0°C optimal flow + 1. EN 442 emitter law: 31.5°C optimal flow 2. Safety margin calculation: - base: 0.5°C (moderate_cold) - temp: 0.0°C (warmer than winter avg) @@ -509,18 +509,18 @@ Result: Moderate heating for Øresund region winter conditions Initialization: hass.config.latitude = 48.86 → Detected zone: standard - → winter_avg_low: 5.0°C + → winter_avg_low: 0.0°C → safety_margin_base: 0.0°C → dm_normal_range: (-200, -350) Runtime (outdoor = 8°C, no unusual weather): - 1. Kühne formula: 24.0°C optimal flow + 1. EN 442 emitter law: 27.7°C optimal flow 2. Safety margin calculation: - base: 0.0°C (standard) - temp: 0.0°C (warmer than winter avg) - unusual: 0.0°C (no unusual weather) - total: 0.0°C - 3. Adjusted flow: 24.0 + 0.0 = 24.0°C (formulas alone!) + 3. Adjusted flow: 27.7 + 0.0 = 27.7°C (the emitter law alone) 4. Dynamic weight: 0.50 (warm weather) 5. Final weight: 0.50 × 0.75 = 0.375 @@ -683,3 +683,14 @@ Leave headroom for emergency/safety layers to override. Weather compensation is - **Output**: Safety margin (°C) and dynamic weight (0.0-1.0) for decision layer - **Dependencies**: Optional weather_learner (Phase 6) for unusual weather detection - **Side effects**: None (pure calculation, no state modification) + +--- + +⚠️ **The flow temperatures above are computed by `utils/emitter.py` (the EN 442 emitter law), not by +"the Kühne formula" this document used to name.** That model was removed (audit F-119/F-121): it was +fed a heat-loss coefficient where the derivation requires a dimensionless relative load, so a +dimensionally inconsistent input went into a structurally correct law and produced numbers that +looked plausible and were not. It drove the flow temperature of a real heat pump. + +Against NIBE's own published curve 9 (41.0 °C at 0 °C outdoor), the EN 442 law lands **0.20 °C** away; +a straight line is out by **2.37 °C**. See `docs/research/02_emitter_law.md`. diff --git a/docs/architecture/11_airflow_optimization.md b/docs/architecture/11_airflow_optimization.md index 0e93c745..afac89b0 100644 --- a/docs/architecture/11_airflow_optimization.md +++ b/docs/architecture/11_airflow_optimization.md @@ -14,44 +14,69 @@ The trade-off is **ventilation penalty** — more cold outdoor air enters and mu ## The Physics +> ⚠️ **This page used to add a "+20% COP improvement" term worth +1.32 kW, and concluded a net +> gain of +1.03 kW. That term double-counts.** Extracting more heat from more air and "improving +> the COP" are not two benefits; they are the same joules described twice. In steady state the +> first law gives `Q_cond = P_el + Q_evap`, so at constant electrical input +> `d(Q_cond) = d(Q_evap) = P_el · d(COP)` — an identity. Adding `P_el · d(COP)` to `d(Q_evap)` +> counts the same heat again. NIBE's own S735 manual publishes four points at identical conditions +> with exhaust airflow as the only variable, and they confirm it. + ``` -Net Benefit = (Extra heat extracted) + (COP improvement) - (Ventilation penalty) +Net gain = (extra heat extracted at the evaporator) - (extra fresh air the building must reheat) ``` -| Component | Formula | Typical Value | -|-----------|---------|---------------| +There is no third term. + +| Component | Formula | At 0°C outdoor | +|-----------|---------|----------------| | Heat extraction | Q = ṁ × cp × ΔT | +0.41 kW | -| COP improvement | 20% × baseline output | +1.32 kW | -| Ventilation penalty | ṁ × cp × (T_in - T_out) | -0.70 kW (at 0°C) | -| **Net gain** | | **+1.03 kW** | +| Ventilation penalty | ṁ × cp × (T_in − T_out) | −0.70 kW | +| **Net gain** | | **-0.31 kW** | + +**The net gain is negative, and it gets worse as it gets colder** (-0.65 kW at −10 °C) — +because the penalty scales with (T_in − T_out) while the extraction does not. `calculate_net_thermal_gain()` +returns this number and `airflow_optimizer` refuses to enhance when it is ≤ 0, so **on an +exhaust-air pump this feature does not currently fire at all.** That is the correct behaviour for +the physics as written; whether the feature survives at all is audit finding F-032, open with the +owner. Do not "restore" the COP term to make the numbers look better. ### Physical Constants | Constant | Value | Description | |----------|-------|-------------| -| Air density | 1.2 kg/m³ | At ~20°C | -| Specific heat | 1.005 kJ/kg·K | Air at constant pressure | -| Evaporator ΔT | 12°C | Typical temp drop through evaporator | -| COP improvement | 20% | Empirical gain from warmer evaporator | -| Standard flow | 150 m³/h | NIBE F750 normal ventilation | -| Enhanced flow | 252 m³/h | NIBE F750 maximum ventilation | +| `AIRFLOW_AIR_DENSITY` | 1.2 kg/m³ | At ~20°C | +| `AIRFLOW_SPECIFIC_HEAT` | 1.005 kJ/kg·K | Air at constant pressure | +| `AIRFLOW_DEFAULT_STANDARD` | 150 m³/h | NIBE F750 normal ventilation | +| `AIRFLOW_DEFAULT_ENHANCED` | 252 m³/h | NIBE F750 maximum ventilation | ## When Enhanced Airflow Helps ### ✅ Beneficial (Green Zone) -| Outdoor Temp | Min Compressor | Expected Gain | Max Duration | -|--------------|----------------|---------------|--------------| -| +5°C to +10°C | ≥50% | +1.0 to +1.4 kW | Until recovered | -| 0°C to +5°C | ≥50% | +0.8 to +1.1 kW | 45-60 min | -| -5°C to 0°C | ≥62% | +0.5 to +0.9 kW | 30-45 min | +| Outdoor Temp | Min Compressor | Net Thermal Gain | Enhances? | +|--------------|----------------|------------------|-----------| +| +10°C | ≥61% | **+0.03 kW** | never fires | +| +5°C | ≥61% | **-0.14 kW** | never fires | +| +0°C | ≥61% | **-0.31 kW** | never fires | +| -5°C | ≥74% | **-0.48 kW** | never fires | +| -10°C | ≥86% | **-0.65 kW** | never fires | +| -15°C | ≥98% | **-0.82 kW** | never fires | + +**Every row at or below +5 °C is negative.** The only positive gain is **+0.03 kW at +10 °C** - +and enhanced airflow is a cold-weather recovery measure, so the single temperature at which it +helps is the one at which nobody needs it. The penalty scales with (T_in − T_out); the extraction +does not. + +`AIRFLOW_COMPRESSOR_BASE_THRESHOLD` is **61.0**, not the 50.0 this page used to print, so the +compressor thresholds were wrong as well. The gains above are what `calculate_net_thermal_gain()` +returns once the double-counted COP term is removed, and `airflow_optimizer` declines to enhance at +a gain ≤ 0 - so in the cold, where this feature exists to help, **it does not fire**. Whether it +survives at all is audit finding F-032, open with the owner. Do not restore the COP term to make +the table look better. ### ⚠️ Marginal (Yellow Zone) -| Outdoor Temp | Min Compressor | Expected Gain | Max Duration | -|--------------|----------------|---------------|--------------| -| -10°C to -5°C | ≥75% | +0.3 to +0.6 kW | 20-30 min | -| -15°C to -10°C | ≥87% | +0.1 to +0.3 kW | 15-20 min | ### ❌ Don't Use (Red Zone) @@ -176,7 +201,7 @@ AIRFLOW_INDOOR_DEFICIT_MIN = 0.2 # Minimum deficit to trigger AIRFLOW_TREND_WARMING_THRESHOLD = 0.1 # °C/h - already warming # Compressor threshold formula -AIRFLOW_COMPRESSOR_BASE_THRESHOLD = 50.0 # % at 0°C outdoor +AIRFLOW_COMPRESSOR_BASE_THRESHOLD = 61.0 # % base threshold at 0°C (81 Hz) <- NOT 50.0 AIRFLOW_COMPRESSOR_SLOPE = -2.5 # % per °C below 0 # Duration limits (minutes) diff --git a/docs/research/01_degree_minutes.md b/docs/research/01_degree_minutes.md new file mode 100644 index 00000000..15f2fc33 --- /dev/null +++ b/docs/research/01_degree_minutes.md @@ -0,0 +1,101 @@ +# Degree minutes, and the number that actually governs an F750 + +## What a degree minute is + +NIBE integrates the gap between the measured supply temperature and the calculated setpoint: + +``` +DM = ∫ (BT25 − S1) dt BT25 = measured supply, S1 = calculated supply setpoint +``` + +Units are °C·minutes. DM goes **negative** when the pump is not keeping up, and NIBE uses it as the +single scalar that decides when to start the compressor and when to call for auxiliary heat. + +## NIBE menu 4.9.3 — the primary source + +**NIBE F750 Installer Manual, IHB GB 1301-1 (part 231236), menu 4.9.3:** + +| setting | default | range | +|---|---|---| +| `start compressor` | **−60** | −1000 … −30 | +| `start addition` | **−700** | −2000 … −30 | + +`DM_THRESHOLD_START = -60` in `const.py` is this number. + +## ⚠️ The consequence the codebase was built without + +**`start addition` is −700. The immersion heater (elpatron) engages there, by design, and works DM +back UP.** On a healthy pump DM therefore *asymptotes* near −700; it does not run away toward +−1500. + +`DM_THRESHOLD_AUX_LIMIT = -1500` is treated throughout the code as the emergency threshold — and on +the owner's F750, **the pump's own auxiliary heat has already been running for 800 degree-minutes +by the time DM gets there.** EffektGuard's climate-zone warning threshold for Stockholm is **−740**, +which is *deeper* than −700: the elpatron fires **first**, every time. + +This is not a small calibration point. It means: + +- The EMERGENCY tier (DM ≤ −1500) has, as far as can be determined, **never fired in the product's + life.** +- Reasoning about "what happens as DM approaches −1500" describes a régime a healthy F750 never + enters. +- The elpatron is **not the enemy.** NIBE placed it at −700 deliberately, to spare the compressor + from grinding at full frequency for hours. Fighting it with a bigger curve offset trades cheap + kWh for expensive compressor life. + +Audit findings F-112 and F-129 both turn on this. F-112 (rescaling the recovery ladder to the real +aux-start) is **open with the owner** — the −700 figure is sourced, but the ladder redesign is not, +and 68 existing safety tests encode the −1500 world. + +## ⚠️ Why the DM safety net is structurally blind to EffektGuard's own under-heating + +Read the identity again: + +``` +DM = ∫ (BT25 − S1) dt +``` + +EffektGuard's only actuator is the **curve offset**, and lowering the offset lowers **S1**. So when +EffektGuard cuts heat to save money: + +- S1 falls, +- `BT25 − S1` becomes *less negative*, +- **DM improves** — while the house gets colder. + +**The degree-minute safety net cannot see under-heating that EffektGuard itself causes.** DM is a +measure of whether the pump is meeting *its own setpoint*, not whether the house is warm. Lower the +setpoint and the pump is meeting it comfortably, by construction. (Audit F-120.) + +The mirror case (F-124): raising the offset on a compressor that is already saturated raises S1 +while BT25 cannot follow, so **DM gets worse** — the boost that was meant to recover the debt +deepens it, and buys nothing but compressor hours. This is why the compressor-wear guard holds the +offset at HIGH frequency risk rather than raising it (F-129). + +**The comfort floor, not DM, is what protects the occupant.** + +## Compressor wear + +`CompressorHealthMonitor.assess_risk()`: + +| risk | condition | +|---|---| +| HIGH | above 100 Hz for more than 15 minutes — at maximum, nothing left to give | +| ELEVATED | above 80 Hz for more than 2 hours | + +At HIGH risk the decision engine **holds** the offset: it may decline to ask for more, never for +less, and it stands aside entirely for the absolute safety floor. This costs no comfort, and that is +not a judgement but an identity — the extra offset was not producing heat, because the compressor +had no frequency left to give it with. + +## Sources + +- NIBE F750 Installer Manual **IHB GB 1301-1** (231236), menu 4.9.3 — `start compressor`, + `start addition` defaults and ranges. +- NIBE's degree-minute definition is consistent across the F-series and S-series manuals. + +## Not sourced + +The **−1500** figure itself. It is attributed in the code to "Swedish forums" and to a `stevedvo` +F2040 case study, neither of which is in this repository. It functions as an absolute backstop and +is far below the pump's own aux-start, so it is harmless as a floor — but it should not be +described as research-validated until someone can produce the research. diff --git a/docs/research/02_emitter_law.md b/docs/research/02_emitter_law.md new file mode 100644 index 00000000..6601e5f6 --- /dev/null +++ b/docs/research/02_emitter_law.md @@ -0,0 +1,166 @@ +# Flow temperature: the EN 442 emitter law + +This is the derivation behind `utils/emitter.py`. The fitted expression it replaced ("Kühne") **was +removed** — it was being fed the wrong quantity, and the end of this page shows exactly which. + +## The model + +``` +balance = T_room − internal_gains_W / heat_loss_W_per_K the house heats itself to here +φ = (balance − T_out) / (balance − T_out_design) dimensionless relative load +ΔT = ΔT_design · φ^(1/n) invert the emitter law +T_flow = T_room + ΔT_design · φ^(1/n) + spread_design / 2 ← spread CONSTANT, not scaled +``` + +Every step from a published standard: + +1. **EN 12831** — building heat loss is linear in the air-temperature difference, and it reaches + zero at the **balance point**, not at room temperature: bodies, appliances and the sun cover the + losses until several degrees below the setpoint. Hence + `φ = Φ/Φ_design = (balance − T_out) / (balance − T_out_design)`. +2. **EN 442-1:2014 §3.31** ("characteristic equation") — an emitter's output follows + `Φ/Φ_N = (ΔT/ΔT_N)^n`. +3. Set emitter output equal to the building load and **invert (2)**: + `ΔT = ΔT_design · φ^(1/n)`. **The 1/n exponent enters exactly here**, as the inverse of the + emitter exponent — it is not a fitted constant. +4. **The spread is CONSTANT.** `Φ = ṁ·c·(T_V − T_R)`, so a *fixed-speed* circulator gives constant + mass flow and a spread proportional to load — that is a wet boiler. **A heat pump modulates its + circulator** to hold the commissioned spread and varies the flow *rate* instead. Scaling the + spread models the wrong machine, and the error pivots invisibly on the design point. +5. `T_V = T_room + ΔT_mean + spread/2`. + +⚠️ **This page used to print `spread = spread_design · φ` right here**, in the headline equation, +after the code below it had already been fixed. A reader implementing from the old version rebuilt +the exact bug. Both halves of the model — the constant spread, and the balance point in `φ` — are +easy to get wrong in *cancelling* ways; see the two warnings further down. + +## The constants, checked against the standard's normative text + +- **EN 442-1 §3.23**: *"excess temperature of **50 K** … inlet 75 °C, outlet 65 °C, reference air + 20 °C."* ⇒ `RADIATOR_RATED_DT = 50.0` ✅. Note this is the **arithmetic** mean, not the log-mean. + ⚠️ The log-mean of the same reference point is 49.83 K. **Never mix a log-mean ΔT with the 50 K + reference.** +- `RADIATOR_POWER_COEFFICIENT = 1.3` ✅ — panel radiators measure 1.26–1.33; sectional 1.30. (A real + EN 442 conformity sheet, Global MIX 600, gives 1.32266.) +- `UFH_POWER_COEFFICIENT = 1.1` ✅ — **underfloor heating is not 1.3.** EN 1264 gives the UFH base + equation as `q = 8.92 · (θ_floor − θ_room)^1.1` W/m², which reproduces Uponor's official design + table on all 13 rows. + +| emitter | n | 1/n | +|---|---|---| +| Underfloor (golvvärme) | **1.0 – 1.1** | 0.91 – 1.00 | +| Panel radiators | **1.26 – 1.33** (use 1.3) | 0.75 – 0.79 | +| Sectional / column radiators | 1.30 | 0.77 | +| Convectors | 1.25 – 1.45 | 0.69 – 0.80 | + +EN 1264 also caps the occupied-floor **surface** temperature at 29 °C, which bounds what a UFH +system can deliver regardless of what the flow temperature is. + +## Validation against NIBE's own curve + +The model is checked against NIBE's published heating curves, digitised from the vector artwork in +the **FIGHTER 1225 Monterings- och skötselanvisning (MOS SE 0735-3, p.23)** — Bézier control points +extracted, axes calibrated, residuals < 0.11 °C. Validated three independent ways: + +- re-digitised from the FIGHTER 1115 manual — agrees to **0.01 °C**; +- reproduces the F1155 manual's own screenshot (curve 9, offset 0, outdoor 0 °C → the display reads + **41**; digitised value **41.0**, exact); +- reproduces NIBE's three official worked examples in VVM 225 IHB. + +### ⚠️ NIBE's published curve does not validate this model, and cannot + +This page used to claim it did. That claim was wrong three times over, and the corrections matter +more than the original argument, so they are kept here rather than quietly deleted. + +**1. Curve 9 is a straight line.** Least-squares through the six digitised points leaves a residual +of **0.19 °C**. Its successive slopes are −0.800, −0.740, −0.780, −0.820, −0.880 °C/°C — they wobble +*non-monotonically*, and a real emitter law steepens monotonically toward cold. The wobble is +digitisation noise and it is **larger than the curvature it was being used to detect**. Collinear +points confirm every model fitted to them. + +The old table on this page claimed a straight line was out by **2.37 °C**. It is out by 0.19 °C. The +honest comparison at 0 °C outdoor: + +| model | flow at 0 °C | error vs NIBE's 41.0 | +|---|---|---| +| least-squares straight line | 40.76 °C | **−0.24 °C** | +| EN 442 + derived gains | 41.64 °C | +0.64 °C | +| EN 442, no gains | 42.72 °C | +1.72 °C | + +NIBE's controller **interpolates its curves linearly**. Ours follows EN 442. The gap between them is +not our error — **it is the trim**, which is the entire reason this layer exists. + +**2. The gains term cannot be fitted to a heating curve at all.** A constant spread lifts the curve +by `(spread/2)·(1 − φ^(1/n))`; a balance point drops it by a term of the *same shape and opposite +sign*. Both vanish at the design point and grow in mild weather. They are **the same basis +function**, so they are not separately identifiable, and whatever spread you assume the fit hands +you a "gains" figure that absorbs it. + +Proof, and it is run as a test: fit this law to **Kühne's Vaillant curve, which is a pure power law +with provably zero gains**, and a balance point appears anyway, tracking the spread you assumed — +0.3 K at spread 0, **2.6 K at spread 5**, 4.9 K at spread 10. + +**3. So `DEFAULT_BALANCE_POINT_OFFSET = 4.0` was fitted to noise, through a degenerate basis.** It is +gone. Gains are **watts**, and the balance point is *derived*: + +``` +balance_point = indoor_setpoint − INTERNAL_GAINS_W / heat_loss_coefficient +``` + +600 W over 180 W/K → 3.33 K → a balance point of 17.7 °C for a 21 °C room. A fixed offset in +*degrees* would have made the free heat from your fridge scale with how leaky your house is, which +is backwards. The two measured sources both express it in watts: heatpumpmonitor.org's **583 W +median across 383 monitored systems**, and OpenEnergyMonitor's SCOP tool (15.5 °C base against a +19.3 °C room). + +### The pair of errors this page used to hide + +An earlier version printed **40.80 °C, error 0.20 °C** — a *better* fit than the honest model gets. +It was not better. It was **two bugs cancelling**: + +* **`design_spread=10.0`**, captioned "EN 442 reference: 75/65". That 10 K is the **rating** spread + that *defines* a radiator's ΔT50 output, not the spread a heat pump's circulator holds (~5 K). And + the code then **scaled** it by load, modelling a fixed-speed pump. +* **No balance point**, so the house was assumed to need heat at 20 °C outdoors. + +Same place, opposite signs. Together they matched NIBE to a fifth of a degree, and fixing *either +one alone made the fit worse* — which is exactly what keeps a pair of errors like this alive. + +And then the fix repeated the mistake in a subtler form: the replacement pair (constant spread + +fitted balance point) is *also* a cancelling pair, which is how a curve fit could report a +triumphant RMS for a constant that was measuring nothing. **Agreement with a curve is not evidence +when your basis functions are degenerate.** That is the lesson worth keeping from this page. + +Reference points, offset 0, outdoor −15 °C: + +| system | NIBE curve | flow temp | +|---|---|---| +| Radiators (F1155 factory default) | curve **9** | **52.6 °C** | +| UFH concrete (F750 factory default) | curve **5** | **38.2 °C** | + +NIBE's own definitions: a low-temperature radiator system *"needs a flow temperature of **55 °C on +the coldest day**"*; underfloor, *"about **35–40 °C**"*. + +## What was wrong before + +The previous implementation used a fitted expression whose exponent was **0.78**. + +`1/n = 1/1.3 = 0.769 ≈ 0.77`. **The 0.78 was the inverse emitter exponent all along** — an +independent empirical fit of a real Vaillant curve landed on the same number, "consistent with a +radiator law of ΔT^1.3 or thereabouts". The *structure* was right. + +What was wrong was the **input**: it was being fed a **heat-loss coefficient** where the derivation +requires a **dimensionless relative load φ**, and the emitter sizing was hidden inside another +fitted constant. A dimensionally inconsistent input to a structurally correct law produces numbers +that look plausible and are not. + +The inputs the EN 442 model needs are all quantities an installer actually knows: the design outdoor +temperature (DUT), the design ΔT and spread (from the pump's curve, or NIBE's recommended starting +values), and the emitter exponent n. None of them is a reverse-engineered dimensionless label. + +## Sources + +- **EN 442-1:2014**, §3.23 (reference conditions), §3.31 (characteristic equation) +- **EN 12831** (heat load — linearity in ΔT_air) +- **EN 1264** (underfloor heating: base equation, surface-temperature limits) +- NIBE **FIGHTER 1225** MOS SE 0735-3 p.23; **FIGHTER 1115**; **F1155** IHB; **VVM 225** IHB diff --git a/docs/research/03_concrete_slab_response.md b/docs/research/03_concrete_slab_response.md new file mode 100644 index 00000000..bf246316 --- /dev/null +++ b/docs/research/03_concrete_slab_response.md @@ -0,0 +1,112 @@ +# A concrete slab: why the horizon is 24 hours — and why the pre-heat is still an open question + +One constant comes from this analysis: + +```python +UFH_CONCRETE_PREDICTION_HORIZON = 24.0 # hours +``` + +A second change this analysis argues for — a stronger pre-heat offset — has **not** been made, +and the last section says exactly where that stands. + +## The thermal model + +Two-node transient model of the owner's floor — 100 mm ground slab plus 60 mm screed: + +| quantity | value | +|---|---| +| slab heat capacity `C_slab` | 7.04 kWh/K | +| room/air heat capacity `C_room` | 3.0 kWh/K | +| slab → room coupling `K` | 1614 W/K | +| building heat-loss coefficient `UA` | 150 W/K | +| thermal diffusivity `α` | 8.05 × 10⁻⁷ m²/s | + +Which gives: + +| | | +|---|---| +| conduction lag, pipe → floor surface | **0.9 h** | +| conduction through the full slab | **3.5 h** | +| room moves **+1.0 °C** | **2.4 – 4.6 h** | +| fast time constant (slab ↔ room) | **1.25 h** | +| slow time constant (fabric → outdoors) | **70 h** | +| slab charge under a constant heat input | **~9 % at 6 h · ~19 % at 14 h · ~29 % at 24 h** | + +⚠️ An earlier version of this table claimed the slab "reaches 63 % of its response in ~14 h". +**That is not what this model computes.** 63 % is one time constant of a first-order system, and +the coupled system's slow constant is ~70 h, not 14 — the eigenvalues in this very table said so +while the row above them said otherwise. Integrating the documented matrix gives ~19 % at 14 h. +The corrected numbers make the conclusion *stronger*, not weaker. + +## ⚠️ Six hours is the LAG. Twenty-four is the MINIMUM horizon. + +These are different questions and the codebase conflated them. + +"It takes about six hours to heat a concrete slab" is roughly right *as a lag* — the room begins +moving within 2.4–4.6 h. But the slab is only **about a fifth charged at fourteen hours, and +under a third charged at twenty-four**; the store's own filling time constant is measured in +days. If you are deciding *today* whether to start storing heat for a cold snap, six hours of +look-ahead tells you almost nothing — and even a day only begins the job. Which is what the +owner said before this model existed: *"we need to pre-heat super early … like DAYS ahead."* + +## Why the pre-heat trigger never fired (fixed — the horizon follows thermal mass) + +The trigger is *"a drop of at least 4 °C within the forecast horizon"*. The horizon was a **fixed +12 hours, for every house, whatever it was built of**: + +| cold snap | drop within 12 h | fires? | drop within 24 h | fires? | +|---|---:|---|---:|---| +| 15 °C over 6 h — sudden plunge | −15.0 | **yes** | −15.0 | yes | +| 15 °C over 24 h | −7.5 | yes | −7.5 | yes | +| **15 °C over 48 h — a two-day slide** | **−3.8** | **NO** | −7.5 | yes | +| **20 °C over 72 h — a three-day slide** | **−3.3** | **NO** | −6.7 | yes | + +**A slab does not get into thermal debt from a plunge.** The pump's own heating curve is reactive, +but it is fast, and it catches that. A slab gets into debt from a **slow, deep slide** — and within +any twelve hours of a two-day slide the temperature falls less than the four degrees needed to +trigger. So the pre-heat **never fired on the case that needed it**, while firing reliably on the +case that needed it least. + +Guarded by `tests/unit/optimization/test_preheat_sees_the_cold_coming.py` — the horizon must +follow thermal mass, and a two-day slide must be visible to a slab. + +## 🛑 OPEN — the pre-heat offset is too small to charge the store, and changing it is the owner's call + +Production ships: + +```python +WEATHER_GENTLE_OFFSET = 0.83 # °C - the pre-heat offset actually in const.py +``` + +The pre-heat's job is to fill the building's thermal store — `THERMAL_BATTERY_BAND`, ±1.0 °C — +*before* the cold arrives. The sizing rule is arithmetic, not taste: + +``` +energy to fill the band = C_fabric × THERMAL_BATTERY_BAND +surplus the offset buys = offset × DEFAULT_CURVE_SENSITIVITY × dQ/dFlow +time to fill = energy / surplus (must be ≤ the forecast horizon) +``` + +Against the simulator's plant models, `+0.83 °C` fills the band in **28.4 h** (radiator house, +12 h horizon) and **34.6 h** (slab, 24 h horizon): the cold always arrives first. The constant's +own history records the struggle without diagnosing it — *"tuned Oct 20, was 0.5 → 0.6 → 0.7 → +0.77"* — nudged in hundredths when the arithmetic wanted it tripled. At **+2.0 °C** the times are +9.6 h and 14.8 h, inside both horizons, bounded by `WEATHER_COMP_MAX_OFFSET` (3.0) and handed +over to the comfort layer at the edge of the band. + +**This change has not been made.** It is a control-tuning decision on real heat pumps — a +stronger pre-heat buys comfort insurance with money — and an earlier version of this document +declared it as `WEATHER_PREHEAT_OFFSET = 2.0` as if it had shipped, citing a guard test that +does not exist. It had not shipped, there is no such constant, and the validation suite now +fails any fenced declaration of a constant the code does not have. + +## Owner's words + +> *"it takes around 6 hours to heat concrete slab … we need to pre-heat super early if we know a +> cold snap is coming, I mean like DAYS ahead."* + +Then, on being shown the 6-hour figure: + +> *"well, 6 hours was low. 24 hours is more correct actually"* + +Both correct — and the corrected transient above says the second is a floor, not a ceiling. diff --git a/docs/research/04_exhaust_air_recovery.md b/docs/research/04_exhaust_air_recovery.md new file mode 100644 index 00000000..94d391fe --- /dev/null +++ b/docs/research/04_exhaust_air_recovery.md @@ -0,0 +1,95 @@ +# Exhaust-air recovery: the same joules, counted twice + +The airflow feature raises exhaust ventilation to pull more heat out of the outgoing air. The +question is whether that is worth the extra fresh air the building must then reheat. + +The original answer had three terms: + +``` +Net Benefit = (Extra heat extracted) + (COP improvement) − (Ventilation penalty) + +0.41 kW +1.32 kW −0.70 kW = +1.03 kW +``` + +**The middle term is the first term, written again.** + +## The identity + +In steady state, the first law across the heat pump gives + +``` +Q_cond = P_el + Q_evap +``` + +Differentiate at constant electrical input: + +``` +d(Q_cond) = d(Q_evap) = P_el · d(COP) +``` + +`P_el · d(COP)` **is** `d(Q_evap)`. It is not a separate benefit that arrives alongside the extra +extraction; it is a second name for it. Adding both counts the same heat twice. + +Notice the direction of the error: it is not conservative. It inflates the benefit, so the feature +appears to pay when it does not. + +## The manufacturer's own controlled experiment + +This is not a theoretical objection. **The NIBE S735 installer manual publishes four operating points +at identical conditions — A20(12)W35, minimum compressor frequency — where the only variable is the +exhaust airflow.** A COP-versus-airflow experiment, run by the manufacturer, under EN 14511. + +Taking the 90 → 252 m³/h step: + +| quantity | value | +|---|---| +| ΔP_H (measured heat output) | **+0.410 kW** | +| P_el × (COP₂ − COP₁) — the code's `delta_cop_benefit` | **+0.387 kW** | +| ΔQ_evap = ΔP_H − ΔP_el — the code's `delta_extraction` | **+0.404 kW** | + +**The "COP benefit" and the "extra extraction" are the same number**, to within the manual's own +rounding. The identity is confirmed by NIBE's published data, not merely argued from theory. + +## What is left when the double-count is removed + +``` +net_gain = (extra heat extracted at the evaporator) − (extra fresh air the building must reheat) +``` + +`calculate_net_thermal_gain()` computes exactly this, and it is negative: + +| outdoor | net gain | +|---|---| +| +10 °C | **+0.03 kW** | +| +5 °C | −0.14 kW | +| 0 °C | **−0.31 kW** | +| −5 °C | −0.48 kW | +| −10 °C | **−0.65 kW** | +| −15 °C | −0.82 kW | + +It gets worse as it gets colder, because the ventilation penalty scales with (T_in − T_out) while +the extraction does not. + +## ⚠️ The honest conclusion + +**Enhanced airflow is a cold-weather recovery measure, and in cold weather it loses heat.** The only +outdoor temperature at which it shows a gain is **+10 °C**, where it gains 0.03 kW — and where +nobody needs it. + +`airflow_optimizer` declines to enhance when the net gain is ≤ 0, so the feature is currently inert +in the conditions it exists for. That is the correct behaviour for the physics as written. + +Whether the feature survives at all is **audit finding F-032, open with the owner**: the choice is +between deleting it and disabling it, and that is not a call to make on someone else's heat pump. +What is *not* an option is restoring the COP term to make the numbers look better. + +## Sources + +- **NIBE S735 installer manual**, EN 14511 performance tables — four points at A20(12)W35, minimum + compressor frequency, exhaust airflow as the sole variable. +- First law of thermodynamics. + +## Not sourced + +The **"~20 % COP improvement from a warmer evaporator"** figure that produced the +1.32 kW. It is +described in the old docs as "empirical" with no citation, and the S735 data above shows that +whatever its magnitude, it is not additive. diff --git a/docs/research/README.md b/docs/research/README.md new file mode 100644 index 00000000..25a4372a --- /dev/null +++ b/docs/research/README.md @@ -0,0 +1,35 @@ +# Research + +This directory holds the evidence for the numbers in `const.py`. + +`.github/copilot-instructions.md` carries a binding rule: **never guess NIBE behaviour, verify it +against research.** For most of this project's life that rule could not be obeyed. The code and the +docs cited **fifteen** research documents as the authority for safety-critical thresholds — +`IMPLEMENTATION_PLAN/02_Research/Forum_Summary.md`, `Swedish_NIBE_Forum_Findings.md`, +`COMPLETED/DHW_RESEARCH_FINDINGS.md`, and a dozen more — and **every one of them is absent from the +repository.** They are gitignored internal notes. Anyone cloning this repo inherited a set of +safety limits whose justification they could not read, check, or challenge. + +So the citations here are to things you can actually obtain: published European standards, NIBE's +own manuals, and calculations reproduced in full so you can redo them. + +## The rule + +**A constant that governs heating behaviour needs a source in this directory, or a comment saying +honestly that it is a guess.** A number with a confident-sounding citation to a document nobody has +is worse than a number with no citation at all: it looks settled. + +## Contents + +| | | +|---|---| +| [01_degree_minutes.md](01_degree_minutes.md) | What DM is, NIBE menu 4.9.3, and why the auxiliary heater — not −1500 — is the number that governs a real F750 | +| [02_emitter_law.md](02_emitter_law.md) | EN 442 / EN 1264. How flow temperature is derived, validated against NIBE's own published curve | +| [03_concrete_slab_response.md](03_concrete_slab_response.md) | Why a concrete slab needs a 24-hour forecast horizon and a +2.0 °C pre-heat | +| [04_exhaust_air_recovery.md](04_exhaust_air_recovery.md) | Why "extra heat extracted" and "improved COP" are the same joules, and what that does to the airflow feature | + +## What is *not* in here + +Several numbers in `const.py` still rest on forum anecdote rather than on anything citable — the +`stevedvo` and `glyn.hudson` case studies, the "Swedish forums validated −1500" claim. They are +marked as such where they appear. **Do not launder them into facts by citing this directory.** diff --git a/pyproject.toml b/pyproject.toml index f5161e46..01aac027 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,14 @@ name = "effektguard" requires-python = ">=3.13" +[tool.black] +# Both .github/copilot-instructions.md and docs/dev/CODE_STANDARDS.md have always claimed +# this section existed. It did not, so a bare `black .` silently used the default line +# length of 88 while CI passed --line-length 100 - two different formatters depending on how +# you invoked it. +line-length = 100 +target-version = ["py313"] + [tool.pytest.ini_options] # Pytest configuration for EffektGuard diff --git a/scripts/check_hardcoded_values.py b/scripts/check_hardcoded_values.py new file mode 100644 index 00000000..2188a270 --- /dev/null +++ b/scripts/check_hardcoded_values.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Detect hardcoded numeric values (magic numbers) in production code. + +The constants-only rule is the repository's most-emphasised convention: every numeric +threshold, tunable, physical constant, interval and safety limit belongs in const.py, +documented and reused. It is also the rule that has caused the most damage when broken - +a hardcoded `weight >= 0.85` gate silently stopped matching DM_CRITICAL_T2_WEIGHT after +that constant was retuned to 0.81, which let a cost layer override thermal-debt recovery. + +The previous enforcement was a regex that flagged EVERY numeric literal - array indices, +loop bounds, `/ 60`, everything. It produced ~1,196 hits, so it was disabled with a bare +`return` and the rule became unenforced. + +This checker is AST-based and deliberately high-signal. It reports a literal only when it +is used as a VALUE with meaning, and it ignores the structurally benign cases that made +the regex useless. + +Usage: + python scripts/check_hardcoded_values.py # report violations + python scripts/check_hardcoded_values.py --baseline # rewrite the baseline file + python scripts/check_hardcoded_values.py --check # fail if any file regressed +""" + +from __future__ import annotations + +import argparse +import ast +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +PROD_DIR = REPO_ROOT / "custom_components" / "effektguard" +BASELINE_PATH = REPO_ROOT / "tests" / "validation" / "hardcoded_values_baseline.json" + +# const.py is where numbers are SUPPOSED to live. +EXCLUDED_FILENAMES = {"const.py"} + +# Values that carry no tuning meaning: identity/neutral elements, and the handful of +# universal unit conversions. A literal equal to one of these is never a "magic number". +BENIGN_VALUES: frozenset[float] = frozenset( + { + 0, + 1, + 2, # identity / neutral / pairs + -1, + -2, + 0.0, + 1.0, + 2.0, + -1.0, + 60, # seconds per minute, minutes per hour + 60.0, + 100, # percent + 100.0, + 1000, # milli / kilo + 1000.0, + 3600, # seconds per hour + 3600.0, + 24, # hours per day + 24.0, + } +) + + +class MagicNumberVisitor(ast.NodeVisitor): + """Collect numeric literals that are used as meaningful values.""" + + def __init__(self) -> None: + self.violations: list[tuple[int, str]] = [] + self._skip: set[int] = set() # id() of nodes to ignore + + # --- contexts where a literal is structurally benign --------------------------- + + def visit_Subscript(self, node: ast.Subscript) -> None: + # x[3], x[:48], x[i - 1] - indices and slices are structure, not tuning. + for child in ast.walk(node.slice): + self._skip.add(id(child)) + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + # range(96), enumerate(x, 1), round(x, 2) - argument positions are structural. + func_name = "" + if isinstance(node.func, ast.Name): + func_name = node.func.id + elif isinstance(node.func, ast.Attribute): + func_name = node.func.attr + + if func_name in {"range", "enumerate", "round", "zip"}: + for arg in node.args: + for child in ast.walk(arg): + self._skip.add(id(child)) + + self.generic_visit(node) + + # --- the actual check ---------------------------------------------------------- + + def visit_Constant(self, node: ast.Constant) -> None: + if id(node) in self._skip: + return + if isinstance(node.value, bool) or not isinstance(node.value, (int, float)): + return + if float(node.value) in BENIGN_VALUES: + return + + self.violations.append((node.lineno, repr(node.value))) + + +def scan_file(path: Path) -> list[tuple[int, str]]: + """Return [(lineno, literal)] for one file.""" + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except SyntaxError as err: # pragma: no cover - a broken file is a bigger problem + print(f"error: cannot parse {path}: {err}", file=sys.stderr) + return [] + + visitor = MagicNumberVisitor() + visitor.visit(tree) + return sorted(visitor.violations) + + +def scan_production() -> dict[str, list[tuple[int, str]]]: + """Return {relative_path: [(lineno, literal)]} for all production files.""" + results: dict[str, list[tuple[int, str]]] = {} + for path in sorted(PROD_DIR.rglob("*.py")): + if path.name in EXCLUDED_FILENAMES: + continue + violations = scan_file(path) + if violations: + results[str(path.relative_to(REPO_ROOT))] = violations + return results + + +def counts(results: dict[str, list[tuple[int, str]]]) -> dict[str, int]: + return {path: len(violations) for path, violations in results.items()} + + +def load_baseline() -> dict[str, int]: + if not BASELINE_PATH.exists(): + return {} + return json.loads(BASELINE_PATH.read_text(encoding="utf-8")) + + +def write_baseline(current: dict[str, int]) -> None: + BASELINE_PATH.write_text(json.dumps(current, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def check_against_baseline(current: dict[str, int], baseline: dict[str, int]) -> list[str]: + """Return human-readable regressions. Empty list means no regression.""" + regressions: list[str] = [] + for path, count in sorted(current.items()): + allowed = baseline.get(path, 0) + if count > allowed: + regressions.append( + f"{path}: {count} hardcoded values (baseline allows {allowed}) " + f"- {count - allowed} new" + ) + return regressions + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--baseline", action="store_true", help="rewrite the baseline to the current state" + ) + parser.add_argument( + "--check", action="store_true", help="exit non-zero if any file exceeds its baseline" + ) + args = parser.parse_args() + + current = scan_production() + + if args.baseline: + write_baseline(counts(current)) + total = sum(counts(current).values()) + print(f"Baseline written: {len(current)} files, {total} hardcoded values.") + return 0 + + if args.check: + regressions = check_against_baseline(counts(current), load_baseline()) + if regressions: + print("NEW hardcoded values introduced (constants-only rule):\n") + for line in regressions: + print(f" {line}") + print("\nMove these into const.py, or regenerate the baseline deliberately.") + return 1 + print("No new hardcoded values.") + return 0 + + # Default: report everything, grouped by file. + total = 0 + for path, violations in current.items(): + print(f"\n{path} ({len(violations)})") + for lineno, literal in violations: + print(f" {lineno}: {literal}") + total += len(violations) + print(f"\nTotal: {total} hardcoded values across {len(current)} files.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/demo_dhw_day_boundary_fix.py b/scripts/demo_dhw_day_boundary_fix.py index 083cb743..d000bf7b 100644 --- a/scripts/demo_dhw_day_boundary_fix.py +++ b/scripts/demo_dhw_day_boundary_fix.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Test script to verify DHW day boundary fix.""" + from datetime import datetime, timedelta from custom_components.effektguard.const import DHW_NORMAL_RUNTIME_MINUTES from custom_components.effektguard.optimization.dhw_optimizer import IntelligentDHWScheduler diff --git a/scripts/find_duplicate_constants.py b/scripts/find_duplicate_constants.py index bfe08c27..232c9a2a 100755 --- a/scripts/find_duplicate_constants.py +++ b/scripts/find_duplicate_constants.py @@ -26,24 +26,24 @@ def get_project_root() -> Path: def parse_constants(const_file: Path) -> dict[str, tuple[any, int]]: """Parse const.py and extract all constant definitions. - + Returns dict of {constant_name: (value, line_number)} """ constants = {} - + with open(const_file, "r") as f: content = f.read() lines = content.split("\n") - + # Pattern for constants: NAME: Final = value pattern = re.compile(r"^([A-Z][A-Z0-9_]*)\s*:\s*Final\s*=\s*(.+?)(?:\s*#.*)?$") - + for line_num, line in enumerate(lines, 1): match = pattern.match(line.strip()) if match: name = match.group(1) value_str = match.group(2).strip() - + # Try to evaluate the value try: # Handle references to other constants @@ -54,21 +54,21 @@ def parse_constants(const_file: Path) -> dict[str, tuple[any, int]]: except (ValueError, SyntaxError): # Keep as string if can't evaluate value = value_str - + constants[name] = (value, line_num) - + return constants def find_duplicate_values(constants: dict[str, tuple[any, int]]) -> dict[any, list[str]]: """Find constants with identical values.""" value_to_names = defaultdict(list) - + for name, (value, _) in constants.items(): # Only check numeric values (most likely to be duplicated) if isinstance(value, (int, float)): value_to_names[value].append(name) - + # Filter to only duplicates return {v: names for v, names in value_to_names.items() if len(names) > 1} @@ -76,21 +76,21 @@ def find_duplicate_values(constants: dict[str, tuple[any, int]]) -> dict[any, li def find_similar_names(constants: dict[str, tuple[any, int]]) -> list[tuple[str, str, float]]: """Find constants with similar names that might be duplicates.""" from difflib import SequenceMatcher - + similar = [] names = list(constants.keys()) - + for i, name1 in enumerate(names): - for name2 in names[i+1:]: + for name2 in names[i + 1 :]: # Skip if same prefix group (e.g., PROACTIVE_ZONE1 vs PROACTIVE_ZONE2) # These are intentionally different if _same_prefix_group(name1, name2): continue - + ratio = SequenceMatcher(None, name1, name2).ratio() if ratio > 0.7: # 70% similar similar.append((name1, name2, ratio)) - + return sorted(similar, key=lambda x: -x[2]) # Sort by similarity @@ -100,34 +100,32 @@ def _same_prefix_group(name1: str, name2: str) -> bool: pattern = re.compile(r"^(.+?)(\d+)(.*)$") m1 = pattern.match(name1) m2 = pattern.match(name2) - + if m1 and m2: # Same prefix and suffix, different number if m1.group(1) == m2.group(1) and m1.group(3) == m2.group(3): return True - + return False def find_unused_constants( - constants: dict[str, tuple[any, int]], - project_root: Path, - include_test_usage: bool = True + constants: dict[str, tuple[any, int]], project_root: Path, include_test_usage: bool = True ) -> list[tuple[str, int]]: """Find constants that are never imported in production code. - + Args: constants: Dict of constant names to (value, line_number) project_root: Project root path include_test_usage: If True, also check tests/scripts for usage """ unused = [] - + # Get all Python files in production code (not tests, not scripts) prod_files = list((project_root / "custom_components" / "effektguard").rglob("*.py")) const_file = project_root / "custom_components" / "effektguard" / "const.py" prod_files = [f for f in prod_files if f != const_file] - + # Read all production code all_code = "" for file in prod_files: @@ -137,7 +135,7 @@ def find_unused_constants( except Exception: # Skip files that can't be read (permissions, encoding issues) pass - + # Optionally include tests and scripts if include_test_usage: test_files = list((project_root / "tests").rglob("*.py")) @@ -149,7 +147,7 @@ def find_unused_constants( except Exception: # Skip files that can't be read (permissions, encoding issues) pass - + # Read const.py to check for building block usage (constants used to derive others) const_code = "" try: @@ -158,7 +156,7 @@ def find_unused_constants( except Exception: # Skip if const.py can't be read pass - + # Check each constant for name, (_, line_num) in constants.items(): # Skip configuration keys (CONF_*) - these are used dynamically @@ -176,44 +174,44 @@ def find_unused_constants( # Skip domain - always used if name == "DOMAIN": continue - + pattern = re.compile(rf"\b{re.escape(name)}\b") - + # Check if used in production/test code if pattern.search(all_code): continue - + # Check if used as building block in const.py (more than just its definition) # Count occurrences - if > 1, it's used somewhere else in const.py matches = list(pattern.finditer(const_code)) if len(matches) > 1: continue # Used as building block - + unused.append((name, line_num)) - + return sorted(unused, key=lambda x: x[1]) # Sort by line number def find_unused_imports(project_root: Path) -> list[tuple[Path, str, int]]: """Find unused imports across all Python files using ruff. - + Returns list of (file_path, message, line_number) tuples. """ import subprocess - + unused_imports = [] - + # Check production code and tests dirs_to_check = [ project_root / "custom_components" / "effektguard", project_root / "tests", project_root / "scripts", ] - + for check_dir in dirs_to_check: if not check_dir.exists(): continue - + try: result = subprocess.run( ["ruff", "check", str(check_dir), "--select", "F401", "--output-format", "text"], @@ -221,7 +219,7 @@ def find_unused_imports(project_root: Path) -> list[tuple[Path, str, int]]: text=True, cwd=project_root, ) - + # Parse ruff output: file:line:col: F401 message for line in result.stdout.strip().split("\n"): if not line or "F401" not in line: @@ -238,19 +236,19 @@ def find_unused_imports(project_root: Path) -> list[tuple[Path, str, int]]: pass except Exception: pass - + return unused_imports def find_semantic_duplicates(constants: dict[str, tuple[any, int]]) -> list[tuple[str, str, str]]: """Find constants that might be semantically equivalent. - + Looks for patterns like: - EFFECT_MARGIN_WARNING vs EFFECT_PEAK_MARGIN_WARNING - FOO_THRESHOLD vs FOO_LIMIT """ duplicates = [] - + # Group by base name patterns patterns = [ (r"_THRESHOLD$", r"_LIMIT$"), @@ -258,9 +256,9 @@ def find_semantic_duplicates(constants: dict[str, tuple[any, int]]) -> list[tupl (r"_MIN$", r"_MINIMUM$"), (r"^EFFECT_", r"^EFFECT_PEAK_"), ] - + names = list(constants.keys()) - + for name1 in names: for pattern1, pattern2 in patterns: if re.search(pattern1, name1): @@ -277,36 +275,41 @@ def find_semantic_duplicates(constants: dict[str, tuple[any, int]]) -> list[tupl duplicates.append((name1, name2, f"Same value: {val1}")) elif isinstance(val1, (int, float)) and isinstance(val2, (int, float)): if abs(val1 - val2) < 0.1: # Very similar values - duplicates.append((name1, name2, f"Similar values: {val1} vs {val2}")) - + duplicates.append( + (name1, name2, f"Similar values: {val1} vs {val2}") + ) + return duplicates def main(): parser = argparse.ArgumentParser(description="Find duplicate and unused constants") - parser.add_argument("--remove-unused", action="store_true", - help="Show commands to remove unused constants") - parser.add_argument("--verbose", "-v", action="store_true", - help="Show detailed output") - parser.add_argument("--prod-only", action="store_true", - help="Only check production code (exclude tests/scripts)") + parser.add_argument( + "--remove-unused", action="store_true", help="Show commands to remove unused constants" + ) + parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed output") + parser.add_argument( + "--prod-only", + action="store_true", + help="Only check production code (exclude tests/scripts)", + ) args = parser.parse_args() - + project_root = get_project_root() const_file = project_root / "custom_components" / "effektguard" / "const.py" - + if not const_file.exists(): print(f"Error: {const_file} not found") sys.exit(1) - + print("=" * 70) print("CONSTANT ANALYSIS REPORT") print("=" * 70) - + # Parse constants constants = parse_constants(const_file) print(f"\nTotal constants defined: {len(constants)}") - + # 1. Find duplicate values print("\n" + "-" * 70) print("1. DUPLICATE VALUES (same number, different names)") @@ -325,7 +328,7 @@ def main(): print(f" - {name} (line {line})") else: print(" No non-trivial duplicate values found.") - + # 2. Find similar names print("\n" + "-" * 70) print("2. SIMILAR NAMES (>70% string similarity)") @@ -341,7 +344,7 @@ def main(): print(f" Values: {val1} vs {val2}") else: print(" No highly similar names found.") - + # 3. Find semantic duplicates print("\n" + "-" * 70) print("3. SEMANTIC DUPLICATES (pattern matching)") @@ -353,7 +356,7 @@ def main(): print(f" Reason: {reason}") else: print(" No semantic duplicates found.") - + # 4. Find unused constants print("\n" + "-" * 70) print("4. UNUSED CONSTANTS (not imported in production code)") @@ -366,14 +369,14 @@ def main(): for name, line in unused: value = constants[name][0] print(f" Line {line:4d}: {name} = {value}") - + if args.remove_unused: print("\n To remove these, delete the following lines from const.py:") for name, line in unused: print(f" Line {line}: {name}") else: print(" All constants are used!") - + # 5. Find unused imports across all files print("\n" + "-" * 70) print("5. UNUSED IMPORTS (imported but never used in file)") @@ -387,7 +390,7 @@ def main(): print("\n Fix with: ruff check --select F401 --fix .") else: print(" All imports are used!") - + # Summary print("\n" + "=" * 70) print("SUMMARY") @@ -398,7 +401,7 @@ def main(): print(f" Semantic duplicates: {len(semantic)}") print(f" Unused constants: {len(unused)}") print(f" Unused imports: {len(unused_imports)}") - + has_issues = unused or unused_imports if has_issues: if unused: @@ -406,7 +409,7 @@ def main(): if unused_imports: print(f" ⚠️ {len(unused_imports)} unused imports found!") return 1 - + return 0 diff --git a/scripts/run_all_tests.sh b/scripts/run_all_tests.sh index 9a4d7f5a..e06c963e 100755 --- a/scripts/run_all_tests.sh +++ b/scripts/run_all_tests.sh @@ -122,9 +122,9 @@ if command -v black &> /dev/null; then if black custom_components/effektguard/ --check --line-length 100 &> /dev/null; then echo -e "${GREEN}✓ Black formatting: PASS${NC}" else - echo -e "${YELLOW}⚠ Black formatting issues detected. Running black...${NC}" - black custom_components/effektguard/ --line-length 100 - echo -e "${GREEN}✓ Black formatting: FIXED${NC}" + # A gate that silently rewrites the tree and reports success is not a gate. + echo -e "${RED}✗ Black formatting: FAIL - run: black custom_components/effektguard/ --line-length 100${NC}" + exit 1 fi else echo -e "${YELLOW}⚠ Black not installed, skipping formatting check${NC}" diff --git a/scripts/simulation/data/gespot_live_se4.json b/scripts/simulation/data/gespot_live_se4.json new file mode 100644 index 00000000..f6a98507 --- /dev/null +++ b/scripts/simulation/data/gespot_live_se4.json @@ -0,0 +1,973 @@ +{ + "captured_from": "sensor.gespot_current_price_se4", + "attributes": { + "unit_of_measurement": "öre/kWh", + "currency": "SEK", + "area": "SE4", + "today_interval_prices": [ + { + "time": "2026-07-12T00:00:00+02:00", + "value": 152.9584, + "raw_value": 152.9584 + }, + { + "time": "2026-07-12T00:15:00+02:00", + "value": 152.4076, + "raw_value": 152.4076 + }, + { + "time": "2026-07-12T00:30:00+02:00", + "value": 153.476, + "raw_value": 153.476 + }, + { + "time": "2026-07-12T00:45:00+02:00", + "value": 144.334, + "raw_value": 144.334 + }, + { + "time": "2026-07-12T01:00:00+02:00", + "value": 149.4998, + "raw_value": 149.4998 + }, + { + "time": "2026-07-12T01:15:00+02:00", + "value": 148.9931, + "raw_value": 148.9931 + }, + { + "time": "2026-07-12T01:30:00+02:00", + "value": 144.6865, + "raw_value": 144.6865 + }, + { + "time": "2026-07-12T01:45:00+02:00", + "value": 138.8047, + "raw_value": 138.8047 + }, + { + "time": "2026-07-12T02:00:00+02:00", + "value": 141.4702, + "raw_value": 141.4702 + }, + { + "time": "2026-07-12T02:15:00+02:00", + "value": 137.4169, + "raw_value": 137.4169 + }, + { + "time": "2026-07-12T02:30:00+02:00", + "value": 135.8308, + "raw_value": 135.8308 + }, + { + "time": "2026-07-12T02:45:00+02:00", + "value": 134.421, + "raw_value": 134.421 + }, + { + "time": "2026-07-12T03:00:00+02:00", + "value": 141.4592, + "raw_value": 141.4592 + }, + { + "time": "2026-07-12T03:15:00+02:00", + "value": 139.4326, + "raw_value": 139.4326 + }, + { + "time": "2026-07-12T03:30:00+02:00", + "value": 133.694, + "raw_value": 133.694 + }, + { + "time": "2026-07-12T03:45:00+02:00", + "value": 132.4383, + "raw_value": 132.4383 + }, + { + "time": "2026-07-12T04:00:00+02:00", + "value": 139.2673, + "raw_value": 139.2673 + }, + { + "time": "2026-07-12T04:15:00+02:00", + "value": 134.1346, + "raw_value": 134.1346 + }, + { + "time": "2026-07-12T04:30:00+02:00", + "value": 131.8326, + "raw_value": 131.8326 + }, + { + "time": "2026-07-12T04:45:00+02:00", + "value": 130.8302, + "raw_value": 130.8302 + }, + { + "time": "2026-07-12T05:00:00+02:00", + "value": 134.9497, + "raw_value": 134.9497 + }, + { + "time": "2026-07-12T05:15:00+02:00", + "value": 132.967, + "raw_value": 132.967 + }, + { + "time": "2026-07-12T05:30:00+02:00", + "value": 131.1937, + "raw_value": 131.1937 + }, + { + "time": "2026-07-12T05:45:00+02:00", + "value": 122.4702, + "raw_value": 122.4702 + }, + { + "time": "2026-07-12T06:00:00+02:00", + "value": 130.0592, + "raw_value": 130.0592 + }, + { + "time": "2026-07-12T06:15:00+02:00", + "value": 123.3073, + "raw_value": 123.3073 + }, + { + "time": "2026-07-12T06:30:00+02:00", + "value": 120.774, + "raw_value": 120.774 + }, + { + "time": "2026-07-12T06:45:00+02:00", + "value": 115.9607, + "raw_value": 115.9607 + }, + { + "time": "2026-07-12T07:00:00+02:00", + "value": 123.0981, + "raw_value": 123.0981 + }, + { + "time": "2026-07-12T07:15:00+02:00", + "value": 118.9566, + "raw_value": 118.9566 + }, + { + "time": "2026-07-12T07:30:00+02:00", + "value": 111.5989, + "raw_value": 111.5989 + }, + { + "time": "2026-07-12T07:45:00+02:00", + "value": 85.1972, + "raw_value": 85.1972 + }, + { + "time": "2026-07-12T08:00:00+02:00", + "value": 113.3282, + "raw_value": 113.3282 + }, + { + "time": "2026-07-12T08:15:00+02:00", + "value": 87.4551, + "raw_value": 87.4551 + }, + { + "time": "2026-07-12T08:30:00+02:00", + "value": 54.5879, + "raw_value": 54.5879 + }, + { + "time": "2026-07-12T08:45:00+02:00", + "value": 13.5478, + "raw_value": 13.5478 + }, + { + "time": "2026-07-12T09:00:00+02:00", + "value": 18.5374, + "raw_value": 18.5374 + }, + { + "time": "2026-07-12T09:15:00+02:00", + "value": 5.287, + "raw_value": 5.287 + }, + { + "time": "2026-07-12T09:30:00+02:00", + "value": 4.2186, + "raw_value": 4.2186 + }, + { + "time": "2026-07-12T09:45:00+02:00", + "value": 3.9101, + "raw_value": 3.9101 + }, + { + "time": "2026-07-12T10:00:00+02:00", + "value": 5.6284, + "raw_value": 5.6284 + }, + { + "time": "2026-07-12T10:15:00+02:00", + "value": 5.309, + "raw_value": 5.309 + }, + { + "time": "2026-07-12T10:30:00+02:00", + "value": 4.5159, + "raw_value": 4.5159 + }, + { + "time": "2026-07-12T10:45:00+02:00", + "value": 4.2296, + "raw_value": 4.2296 + }, + { + "time": "2026-07-12T11:00:00+02:00", + "value": 9.2081, + "raw_value": 9.2081 + }, + { + "time": "2026-07-12T11:15:00+02:00", + "value": 8.1177, + "raw_value": 8.1177 + }, + { + "time": "2026-07-12T11:30:00+02:00", + "value": 5.8817, + "raw_value": 5.8817 + }, + { + "time": "2026-07-12T11:45:00+02:00", + "value": 5.8707, + "raw_value": 5.8707 + }, + { + "time": "2026-07-12T12:00:00+02:00", + "value": 6.4655, + "raw_value": 6.4655 + }, + { + "time": "2026-07-12T12:15:00+02:00", + "value": 7.3357, + "raw_value": 7.3357 + }, + { + "time": "2026-07-12T12:30:00+02:00", + "value": 5.8267, + "raw_value": 5.8267 + }, + { + "time": "2026-07-12T12:45:00+02:00", + "value": 7.3246, + "raw_value": 7.3246 + }, + { + "time": "2026-07-12T13:00:00+02:00", + "value": 8.2719, + "raw_value": 8.2719 + }, + { + "time": "2026-07-12T13:15:00+02:00", + "value": 7.3467, + "raw_value": 7.3467 + }, + { + "time": "2026-07-12T13:30:00+02:00", + "value": 8.7896, + "raw_value": 8.7896 + }, + { + "time": "2026-07-12T13:45:00+02:00", + "value": 10.0893, + "raw_value": 10.0893 + }, + { + "time": "2026-07-12T14:00:00+02:00", + "value": 10.7171, + "raw_value": 10.7171 + }, + { + "time": "2026-07-12T14:15:00+02:00", + "value": 11.6093, + "raw_value": 11.6093 + }, + { + "time": "2026-07-12T14:30:00+02:00", + "value": 11.8516, + "raw_value": 11.8516 + }, + { + "time": "2026-07-12T14:45:00+02:00", + "value": 13.702, + "raw_value": 13.702 + }, + { + "time": "2026-07-12T15:00:00+02:00", + "value": 11.411, + "raw_value": 11.411 + }, + { + "time": "2026-07-12T15:15:00+02:00", + "value": 12.6887, + "raw_value": 12.6887 + }, + { + "time": "2026-07-12T15:30:00+02:00", + "value": 13.7241, + "raw_value": 13.7241 + }, + { + "time": "2026-07-12T15:45:00+02:00", + "value": 14.8475, + "raw_value": 14.8475 + }, + { + "time": "2026-07-12T16:00:00+02:00", + "value": 13.4817, + "raw_value": 13.4817 + }, + { + "time": "2026-07-12T16:15:00+02:00", + "value": 13.7902, + "raw_value": 13.7902 + }, + { + "time": "2026-07-12T16:30:00+02:00", + "value": 14.6273, + "raw_value": 14.6273 + }, + { + "time": "2026-07-12T16:45:00+02:00", + "value": 15.3762, + "raw_value": 15.3762 + }, + { + "time": "2026-07-12T17:00:00+02:00", + "value": 14.1206, + "raw_value": 14.1206 + }, + { + "time": "2026-07-12T17:15:00+02:00", + "value": 17.5791, + "raw_value": 17.5791 + }, + { + "time": "2026-07-12T17:30:00+02:00", + "value": 21.1589, + "raw_value": 21.1589 + }, + { + "time": "2026-07-12T17:45:00+02:00", + "value": 63.4325, + "raw_value": 63.4325 + }, + { + "time": "2026-07-12T18:00:00+02:00", + "value": 36.0064, + "raw_value": 36.0064 + }, + { + "time": "2026-07-12T18:15:00+02:00", + "value": 111.4337, + "raw_value": 111.4337 + }, + { + "time": "2026-07-12T18:30:00+02:00", + "value": 126.1491, + "raw_value": 126.1491 + }, + { + "time": "2026-07-12T18:45:00+02:00", + "value": 131.5021, + "raw_value": 131.5021 + }, + { + "time": "2026-07-12T19:00:00+02:00", + "value": 132.4714, + "raw_value": 132.4714 + }, + { + "time": "2026-07-12T19:15:00+02:00", + "value": 144.6865, + "raw_value": 144.6865 + }, + { + "time": "2026-07-12T19:30:00+02:00", + "value": 149.4998, + "raw_value": 149.4998 + }, + { + "time": "2026-07-12T19:45:00+02:00", + "value": 150.4581, + "raw_value": 150.4581 + }, + { + "time": "2026-07-12T20:00:00+02:00", + "value": 149.7091, + "raw_value": 149.7091 + }, + { + "time": "2026-07-12T20:15:00+02:00", + "value": 153.0685, + "raw_value": 153.0685 + }, + { + "time": "2026-07-12T20:30:00+02:00", + "value": 154.0708, + "raw_value": 154.0708 + }, + { + "time": "2026-07-12T20:45:00+02:00", + "value": 154.17, + "raw_value": 154.17 + }, + { + "time": "2026-07-12T21:00:00+02:00", + "value": 152.1763, + "raw_value": 152.1763 + }, + { + "time": "2026-07-12T21:15:00+02:00", + "value": 155.1943, + "raw_value": 155.1943 + }, + { + "time": "2026-07-12T21:30:00+02:00", + "value": 154.7647, + "raw_value": 154.7647 + }, + { + "time": "2026-07-12T21:45:00+02:00", + "value": 154.4343, + "raw_value": 154.4343 + }, + { + "time": "2026-07-12T22:00:00+02:00", + "value": 159.9526, + "raw_value": 159.9526 + }, + { + "time": "2026-07-12T22:15:00+02:00", + "value": 156.6482, + "raw_value": 156.6482 + }, + { + "time": "2026-07-12T22:30:00+02:00", + "value": 155.7781, + "raw_value": 155.7781 + }, + { + "time": "2026-07-12T22:45:00+02:00", + "value": 153.2447, + "raw_value": 153.2447 + }, + { + "time": "2026-07-12T23:00:00+02:00", + "value": 153.0024, + "raw_value": 153.0024 + }, + { + "time": "2026-07-12T23:15:00+02:00", + "value": 151.8789, + "raw_value": 151.8789 + }, + { + "time": "2026-07-12T23:30:00+02:00", + "value": 151.4604, + "raw_value": 151.4604 + }, + { + "time": "2026-07-12T23:45:00+02:00", + "value": 150.0946, + "raw_value": 150.0946 + } + ], + "tomorrow_interval_prices": [ + { + "time": "2026-07-13T00:00:00+02:00", + "value": 145.7439, + "raw_value": 145.7439 + }, + { + "time": "2026-07-13T00:15:00+02:00", + "value": 143.3868, + "raw_value": 143.3868 + }, + { + "time": "2026-07-13T00:30:00+02:00", + "value": 142.5717, + "raw_value": 142.5717 + }, + { + "time": "2026-07-13T00:45:00+02:00", + "value": 140.545, + "raw_value": 140.545 + }, + { + "time": "2026-07-13T01:00:00+02:00", + "value": 140.567, + "raw_value": 140.567 + }, + { + "time": "2026-07-13T01:15:00+02:00", + "value": 136.1172, + "raw_value": 136.1172 + }, + { + "time": "2026-07-13T01:30:00+02:00", + "value": 135.5995, + "raw_value": 135.5995 + }, + { + "time": "2026-07-13T01:45:00+02:00", + "value": 133.2424, + "raw_value": 133.2424 + }, + { + "time": "2026-07-13T02:00:00+02:00", + "value": 134.7624, + "raw_value": 134.7624 + }, + { + "time": "2026-07-13T02:15:00+02:00", + "value": 131.436, + "raw_value": 131.436 + }, + { + "time": "2026-07-13T02:30:00+02:00", + "value": 132.2291, + "raw_value": 132.2291 + }, + { + "time": "2026-07-13T02:45:00+02:00", + "value": 132.163, + "raw_value": 132.163 + }, + { + "time": "2026-07-13T03:00:00+02:00", + "value": 132.3943, + "raw_value": 132.3943 + }, + { + "time": "2026-07-13T03:15:00+02:00", + "value": 132.7357, + "raw_value": 132.7357 + }, + { + "time": "2026-07-13T03:30:00+02:00", + "value": 134.2888, + "raw_value": 134.2888 + }, + { + "time": "2026-07-13T03:45:00+02:00", + "value": 135.9079, + "raw_value": 135.9079 + }, + { + "time": "2026-07-13T04:00:00+02:00", + "value": 130.9844, + "raw_value": 130.9844 + }, + { + "time": "2026-07-13T04:15:00+02:00", + "value": 134.465, + "raw_value": 134.465 + }, + { + "time": "2026-07-13T04:30:00+02:00", + "value": 137.6041, + "raw_value": 137.6041 + }, + { + "time": "2026-07-13T04:45:00+02:00", + "value": 139.3004, + "raw_value": 139.3004 + }, + { + "time": "2026-07-13T05:00:00+02:00", + "value": 139.2233, + "raw_value": 139.2233 + }, + { + "time": "2026-07-13T05:15:00+02:00", + "value": 143.3207, + "raw_value": 143.3207 + }, + { + "time": "2026-07-13T05:30:00+02:00", + "value": 142.2743, + "raw_value": 142.2743 + }, + { + "time": "2026-07-13T05:45:00+02:00", + "value": 142.9131, + "raw_value": 142.9131 + }, + { + "time": "2026-07-13T06:00:00+02:00", + "value": 137.6041, + "raw_value": 137.6041 + }, + { + "time": "2026-07-13T06:15:00+02:00", + "value": 139.1902, + "raw_value": 139.1902 + }, + { + "time": "2026-07-13T06:30:00+02:00", + "value": 145.5456, + "raw_value": 145.5456 + }, + { + "time": "2026-07-13T06:45:00+02:00", + "value": 142.9131, + "raw_value": 142.9131 + }, + { + "time": "2026-07-13T07:00:00+02:00", + "value": 151.4714, + "raw_value": 151.4714 + }, + { + "time": "2026-07-13T07:15:00+02:00", + "value": 148.4424, + "raw_value": 148.4424 + }, + { + "time": "2026-07-13T07:30:00+02:00", + "value": 127.9554, + "raw_value": 127.9554 + }, + { + "time": "2026-07-13T07:45:00+02:00", + "value": 134.6633, + "raw_value": 134.6633 + }, + { + "time": "2026-07-13T08:00:00+02:00", + "value": 152.2975, + "raw_value": 152.2975 + }, + { + "time": "2026-07-13T08:15:00+02:00", + "value": 143.4859, + "raw_value": 143.4859 + }, + { + "time": "2026-07-13T08:30:00+02:00", + "value": 124.2876, + "raw_value": 124.2876 + }, + { + "time": "2026-07-13T08:45:00+02:00", + "value": 117.6569, + "raw_value": 117.6569 + }, + { + "time": "2026-07-13T09:00:00+02:00", + "value": 134.0024, + "raw_value": 134.0024 + }, + { + "time": "2026-07-13T09:15:00+02:00", + "value": 136.7781, + "raw_value": 136.7781 + }, + { + "time": "2026-07-13T09:30:00+02:00", + "value": 125.0146, + "raw_value": 125.0146 + }, + { + "time": "2026-07-13T09:45:00+02:00", + "value": 115.1786, + "raw_value": 115.1786 + }, + { + "time": "2026-07-13T10:00:00+02:00", + "value": 131.5021, + "raw_value": 131.5021 + }, + { + "time": "2026-07-13T10:15:00+02:00", + "value": 116.1589, + "raw_value": 116.1589 + }, + { + "time": "2026-07-13T10:30:00+02:00", + "value": 105.552, + "raw_value": 105.552 + }, + { + "time": "2026-07-13T10:45:00+02:00", + "value": 74.3809, + "raw_value": 74.3809 + }, + { + "time": "2026-07-13T11:00:00+02:00", + "value": 68.9177, + "raw_value": 68.9177 + }, + { + "time": "2026-07-13T11:15:00+02:00", + "value": 69.7879, + "raw_value": 69.7879 + }, + { + "time": "2026-07-13T11:30:00+02:00", + "value": 69.865, + "raw_value": 69.865 + }, + { + "time": "2026-07-13T11:45:00+02:00", + "value": 65.36, + "raw_value": 65.36 + }, + { + "time": "2026-07-13T12:00:00+02:00", + "value": 70.1183, + "raw_value": 70.1183 + }, + { + "time": "2026-07-13T12:15:00+02:00", + "value": 57.4406, + "raw_value": 57.4406 + }, + { + "time": "2026-07-13T12:30:00+02:00", + "value": 58.3438, + "raw_value": 58.3438 + }, + { + "time": "2026-07-13T12:45:00+02:00", + "value": 64.7212, + "raw_value": 64.7212 + }, + { + "time": "2026-07-13T13:00:00+02:00", + "value": 62.4412, + "raw_value": 62.4412 + }, + { + "time": "2026-07-13T13:15:00+02:00", + "value": 64.1044, + "raw_value": 64.1044 + }, + { + "time": "2026-07-13T13:30:00+02:00", + "value": 56.5705, + "raw_value": 56.5705 + }, + { + "time": "2026-07-13T13:45:00+02:00", + "value": 61.3728, + "raw_value": 61.3728 + }, + { + "time": "2026-07-13T14:00:00+02:00", + "value": 64.545, + "raw_value": 64.545 + }, + { + "time": "2026-07-13T14:15:00+02:00", + "value": 65.349, + "raw_value": 65.349 + }, + { + "time": "2026-07-13T14:30:00+02:00", + "value": 65.9108, + "raw_value": 65.9108 + }, + { + "time": "2026-07-13T14:45:00+02:00", + "value": 66.4615, + "raw_value": 66.4615 + }, + { + "time": "2026-07-13T15:00:00+02:00", + "value": 62.8928, + "raw_value": 62.8928 + }, + { + "time": "2026-07-13T15:15:00+02:00", + "value": 64.6331, + "raw_value": 64.6331 + }, + { + "time": "2026-07-13T15:30:00+02:00", + "value": 74.8986, + "raw_value": 74.8986 + }, + { + "time": "2026-07-13T15:45:00+02:00", + "value": 92.709, + "raw_value": 92.709 + }, + { + "time": "2026-07-13T16:00:00+02:00", + "value": 77.4209, + "raw_value": 77.4209 + }, + { + "time": "2026-07-13T16:15:00+02:00", + "value": 84.6685, + "raw_value": 84.6685 + }, + { + "time": "2026-07-13T16:30:00+02:00", + "value": 92.9293, + "raw_value": 92.9293 + }, + { + "time": "2026-07-13T16:45:00+02:00", + "value": 99.6812, + "raw_value": 99.6812 + }, + { + "time": "2026-07-13T17:00:00+02:00", + "value": 117.6459, + "raw_value": 117.6459 + }, + { + "time": "2026-07-13T17:15:00+02:00", + "value": 133.9473, + "raw_value": 133.9473 + }, + { + "time": "2026-07-13T17:30:00+02:00", + "value": 147.6604, + "raw_value": 147.6604 + }, + { + "time": "2026-07-13T17:45:00+02:00", + "value": 151.7137, + "raw_value": 151.7137 + }, + { + "time": "2026-07-13T18:00:00+02:00", + "value": 145.7328, + "raw_value": 145.7328 + }, + { + "time": "2026-07-13T18:15:00+02:00", + "value": 157.2871, + "raw_value": 157.2871 + }, + { + "time": "2026-07-13T18:30:00+02:00", + "value": 160.272, + "raw_value": 160.272 + }, + { + "time": "2026-07-13T18:45:00+02:00", + "value": 167.9161, + "raw_value": 167.9161 + }, + { + "time": "2026-07-13T19:00:00+02:00", + "value": 166.4181, + "raw_value": 166.4181 + }, + { + "time": "2026-07-13T19:15:00+02:00", + "value": 172.3108, + "raw_value": 172.3108 + }, + { + "time": "2026-07-13T19:30:00+02:00", + "value": 186.0129, + "raw_value": 186.0129 + }, + { + "time": "2026-07-13T19:45:00+02:00", + "value": 203.1514, + "raw_value": 203.1514 + }, + { + "time": "2026-07-13T20:00:00+02:00", + "value": 194.9897, + "raw_value": 194.9897 + }, + { + "time": "2026-07-13T20:15:00+02:00", + "value": 196.003, + "raw_value": 196.003 + }, + { + "time": "2026-07-13T20:30:00+02:00", + "value": 196.719, + "raw_value": 196.719 + }, + { + "time": "2026-07-13T20:45:00+02:00", + "value": 198.294, + "raw_value": 198.294 + }, + { + "time": "2026-07-13T21:00:00+02:00", + "value": 199.7259, + "raw_value": 199.7259 + }, + { + "time": "2026-07-13T21:15:00+02:00", + "value": 186.0239, + "raw_value": 186.0239 + }, + { + "time": "2026-07-13T21:30:00+02:00", + "value": 175.9126, + "raw_value": 175.9126 + }, + { + "time": "2026-07-13T21:45:00+02:00", + "value": 169.6013, + "raw_value": 169.6013 + }, + { + "time": "2026-07-13T22:00:00+02:00", + "value": 185.5943, + "raw_value": 185.5943 + }, + { + "time": "2026-07-13T22:15:00+02:00", + "value": 169.5572, + "raw_value": 169.5572 + }, + { + "time": "2026-07-13T22:30:00+02:00", + "value": 169.3369, + "raw_value": 169.3369 + }, + { + "time": "2026-07-13T22:45:00+02:00", + "value": 167.4094, + "raw_value": 167.4094 + }, + { + "time": "2026-07-13T23:00:00+02:00", + "value": 164.05, + "raw_value": 164.05 + }, + { + "time": "2026-07-13T23:15:00+02:00", + "value": 161.5276, + "raw_value": 161.5276 + }, + { + "time": "2026-07-13T23:30:00+02:00", + "value": 158.0801, + "raw_value": 158.0801 + }, + { + "time": "2026-07-13T23:45:00+02:00", + "value": 156.5601, + "raw_value": 156.5601 + } + ] + }, + "state": "11.609283" +} \ No newline at end of file diff --git a/scripts/simulation/data/prices_se1_jan2024.json b/scripts/simulation/data/prices_se1_jan2024.json new file mode 100644 index 00000000..0b9c4dfe --- /dev/null +++ b/scripts/simulation/data/prices_se1_jan2024.json @@ -0,0 +1,3044 @@ +{ + "source": "elprisetjustnu.se (Nord Pool day-ahead), zone SE1, fetched 2026-07-16", + "unit": "öre/kWh", + "days": { + "2024-01-01": [ + { + "start": "2024-01-01T00:00:00+01:00", + "price": 32.915 + }, + { + "start": "2024-01-01T01:00:00+01:00", + "price": 31.69 + }, + { + "start": "2024-01-01T02:00:00+01:00", + "price": 29.686 + }, + { + "start": "2024-01-01T03:00:00+01:00", + "price": 27.258 + }, + { + "start": "2024-01-01T04:00:00+01:00", + "price": 26.735 + }, + { + "start": "2024-01-01T05:00:00+01:00", + "price": 23.64 + }, + { + "start": "2024-01-01T06:00:00+01:00", + "price": 25.187 + }, + { + "start": "2024-01-01T07:00:00+01:00", + "price": 27.882 + }, + { + "start": "2024-01-01T08:00:00+01:00", + "price": 29.218 + }, + { + "start": "2024-01-01T09:00:00+01:00", + "price": 35.866 + }, + { + "start": "2024-01-01T10:00:00+01:00", + "price": 46.032 + }, + { + "start": "2024-01-01T11:00:00+01:00", + "price": 48.448 + }, + { + "start": "2024-01-01T12:00:00+01:00", + "price": 47.903 + }, + { + "start": "2024-01-01T13:00:00+01:00", + "price": 49.317 + }, + { + "start": "2024-01-01T14:00:00+01:00", + "price": 51.488 + }, + { + "start": "2024-01-01T15:00:00+01:00", + "price": 56.354 + }, + { + "start": "2024-01-01T16:00:00+01:00", + "price": 66.22 + }, + { + "start": "2024-01-01T17:00:00+01:00", + "price": 72.366 + }, + { + "start": "2024-01-01T18:00:00+01:00", + "price": 68.747 + }, + { + "start": "2024-01-01T19:00:00+01:00", + "price": 61.32 + }, + { + "start": "2024-01-01T20:00:00+01:00", + "price": 53.459 + }, + { + "start": "2024-01-01T21:00:00+01:00", + "price": 49.005 + }, + { + "start": "2024-01-01T22:00:00+01:00", + "price": 50.33 + }, + { + "start": "2024-01-01T23:00:00+01:00", + "price": 42.313 + } + ], + "2024-01-02": [ + { + "start": "2024-01-02T00:00:00+01:00", + "price": 42.367 + }, + { + "start": "2024-01-02T01:00:00+01:00", + "price": 37.417 + }, + { + "start": "2024-01-02T02:00:00+01:00", + "price": 35.711 + }, + { + "start": "2024-01-02T03:00:00+01:00", + "price": 36.001 + }, + { + "start": "2024-01-02T04:00:00+01:00", + "price": 40.918 + }, + { + "start": "2024-01-02T05:00:00+01:00", + "price": 46.514 + }, + { + "start": "2024-01-02T06:00:00+01:00", + "price": 54.832 + }, + { + "start": "2024-01-02T07:00:00+01:00", + "price": 65.502 + }, + { + "start": "2024-01-02T08:00:00+01:00", + "price": 73.217 + }, + { + "start": "2024-01-02T09:00:00+01:00", + "price": 73.707 + }, + { + "start": "2024-01-02T10:00:00+01:00", + "price": 76.907 + }, + { + "start": "2024-01-02T11:00:00+01:00", + "price": 81.991 + }, + { + "start": "2024-01-02T12:00:00+01:00", + "price": 87.142 + }, + { + "start": "2024-01-02T13:00:00+01:00", + "price": 87.934 + }, + { + "start": "2024-01-02T14:00:00+01:00", + "price": 88.346 + }, + { + "start": "2024-01-02T15:00:00+01:00", + "price": 89.149 + }, + { + "start": "2024-01-02T16:00:00+01:00", + "price": 90.821 + }, + { + "start": "2024-01-02T17:00:00+01:00", + "price": 90.219 + }, + { + "start": "2024-01-02T18:00:00+01:00", + "price": 83.563 + }, + { + "start": "2024-01-02T19:00:00+01:00", + "price": 70.441 + }, + { + "start": "2024-01-02T20:00:00+01:00", + "price": 63.673 + }, + { + "start": "2024-01-02T21:00:00+01:00", + "price": 53.561 + }, + { + "start": "2024-01-02T22:00:00+01:00", + "price": 45.701 + }, + { + "start": "2024-01-02T23:00:00+01:00", + "price": 42.757 + } + ], + "2024-01-03": [ + { + "start": "2024-01-03T00:00:00+01:00", + "price": 45.31 + }, + { + "start": "2024-01-03T01:00:00+01:00", + "price": 43.545 + }, + { + "start": "2024-01-03T02:00:00+01:00", + "price": 41.49 + }, + { + "start": "2024-01-03T03:00:00+01:00", + "price": 40.169 + }, + { + "start": "2024-01-03T04:00:00+01:00", + "price": 42.434 + }, + { + "start": "2024-01-03T05:00:00+01:00", + "price": 47.287 + }, + { + "start": "2024-01-03T06:00:00+01:00", + "price": 54.906 + }, + { + "start": "2024-01-03T07:00:00+01:00", + "price": 59.448 + }, + { + "start": "2024-01-03T08:00:00+01:00", + "price": 70.498 + }, + { + "start": "2024-01-03T09:00:00+01:00", + "price": 72.175 + }, + { + "start": "2024-01-03T10:00:00+01:00", + "price": 66.844 + }, + { + "start": "2024-01-03T11:00:00+01:00", + "price": 66.589 + }, + { + "start": "2024-01-03T12:00:00+01:00", + "price": 66.411 + }, + { + "start": "2024-01-03T13:00:00+01:00", + "price": 65.367 + }, + { + "start": "2024-01-03T14:00:00+01:00", + "price": 72.175 + }, + { + "start": "2024-01-03T15:00:00+01:00", + "price": 76.439 + }, + { + "start": "2024-01-03T16:00:00+01:00", + "price": 81.725 + }, + { + "start": "2024-01-03T17:00:00+01:00", + "price": 91.376 + }, + { + "start": "2024-01-03T18:00:00+01:00", + "price": 92.142 + }, + { + "start": "2024-01-03T19:00:00+01:00", + "price": 87.767 + }, + { + "start": "2024-01-03T20:00:00+01:00", + "price": 81.325 + }, + { + "start": "2024-01-03T21:00:00+01:00", + "price": 74.862 + }, + { + "start": "2024-01-03T22:00:00+01:00", + "price": 72.186 + }, + { + "start": "2024-01-03T23:00:00+01:00", + "price": 64.545 + } + ], + "2024-01-04": [ + { + "start": "2024-01-04T00:00:00+01:00", + "price": 65.582 + }, + { + "start": "2024-01-04T01:00:00+01:00", + "price": 59.874 + }, + { + "start": "2024-01-04T02:00:00+01:00", + "price": 60.813 + }, + { + "start": "2024-01-04T03:00:00+01:00", + "price": 61.639 + }, + { + "start": "2024-01-04T04:00:00+01:00", + "price": 65.069 + }, + { + "start": "2024-01-04T05:00:00+01:00", + "price": 66.074 + }, + { + "start": "2024-01-04T06:00:00+01:00", + "price": 81.154 + }, + { + "start": "2024-01-04T07:00:00+01:00", + "price": 94.972 + }, + { + "start": "2024-01-04T08:00:00+01:00", + "price": 102.177 + }, + { + "start": "2024-01-04T09:00:00+01:00", + "price": 105.227 + }, + { + "start": "2024-01-04T10:00:00+01:00", + "price": 102.747 + }, + { + "start": "2024-01-04T11:00:00+01:00", + "price": 100.982 + }, + { + "start": "2024-01-04T12:00:00+01:00", + "price": 96.838 + }, + { + "start": "2024-01-04T13:00:00+01:00", + "price": 96.022 + }, + { + "start": "2024-01-04T14:00:00+01:00", + "price": 102.892 + }, + { + "start": "2024-01-04T15:00:00+01:00", + "price": 110.164 + }, + { + "start": "2024-01-04T16:00:00+01:00", + "price": 118.106 + }, + { + "start": "2024-01-04T17:00:00+01:00", + "price": 148.825 + }, + { + "start": "2024-01-04T18:00:00+01:00", + "price": 161.582 + }, + { + "start": "2024-01-04T19:00:00+01:00", + "price": 135.577 + }, + { + "start": "2024-01-04T20:00:00+01:00", + "price": 112.354 + }, + { + "start": "2024-01-04T21:00:00+01:00", + "price": 108.477 + }, + { + "start": "2024-01-04T22:00:00+01:00", + "price": 105.316 + }, + { + "start": "2024-01-04T23:00:00+01:00", + "price": 99.228 + } + ], + "2024-01-05": [ + { + "start": "2024-01-05T00:00:00+01:00", + "price": 92.486 + }, + { + "start": "2024-01-05T01:00:00+01:00", + "price": 93.069 + }, + { + "start": "2024-01-05T02:00:00+01:00", + "price": 89.608 + }, + { + "start": "2024-01-05T03:00:00+01:00", + "price": 89.798 + }, + { + "start": "2024-01-05T04:00:00+01:00", + "price": 92.397 + }, + { + "start": "2024-01-05T05:00:00+01:00", + "price": 98.725 + }, + { + "start": "2024-01-05T06:00:00+01:00", + "price": 114.798 + }, + { + "start": "2024-01-05T07:00:00+01:00", + "price": 147.808 + }, + { + "start": "2024-01-05T08:00:00+01:00", + "price": 223.201 + }, + { + "start": "2024-01-05T09:00:00+01:00", + "price": 266.93 + }, + { + "start": "2024-01-05T10:00:00+01:00", + "price": 286.587 + }, + { + "start": "2024-01-05T11:00:00+01:00", + "price": 236.463 + }, + { + "start": "2024-01-05T12:00:00+01:00", + "price": 227.693 + }, + { + "start": "2024-01-05T13:00:00+01:00", + "price": 220.636 + }, + { + "start": "2024-01-05T14:00:00+01:00", + "price": 221.622 + }, + { + "start": "2024-01-05T15:00:00+01:00", + "price": 335.961 + }, + { + "start": "2024-01-05T16:00:00+01:00", + "price": 503.639 + }, + { + "start": "2024-01-05T17:00:00+01:00", + "price": 589.449 + }, + { + "start": "2024-01-05T18:00:00+01:00", + "price": 391.977 + }, + { + "start": "2024-01-05T19:00:00+01:00", + "price": 203.756 + }, + { + "start": "2024-01-05T20:00:00+01:00", + "price": 167.566 + }, + { + "start": "2024-01-05T21:00:00+01:00", + "price": 126.369 + }, + { + "start": "2024-01-05T22:00:00+01:00", + "price": 112.121 + }, + { + "start": "2024-01-05T23:00:00+01:00", + "price": 104.023 + } + ], + "2024-01-06": [ + { + "start": "2024-01-06T00:00:00+01:00", + "price": 95.266 + }, + { + "start": "2024-01-06T01:00:00+01:00", + "price": 93.381 + }, + { + "start": "2024-01-06T02:00:00+01:00", + "price": 92.068 + }, + { + "start": "2024-01-06T03:00:00+01:00", + "price": 92.236 + }, + { + "start": "2024-01-06T04:00:00+01:00", + "price": 92.629 + }, + { + "start": "2024-01-06T05:00:00+01:00", + "price": 94.425 + }, + { + "start": "2024-01-06T06:00:00+01:00", + "price": 94.817 + }, + { + "start": "2024-01-06T07:00:00+01:00", + "price": 98.128 + }, + { + "start": "2024-01-06T08:00:00+01:00", + "price": 101.989 + }, + { + "start": "2024-01-06T09:00:00+01:00", + "price": 106.602 + }, + { + "start": "2024-01-06T10:00:00+01:00", + "price": 112.697 + }, + { + "start": "2024-01-06T11:00:00+01:00", + "price": 116.333 + }, + { + "start": "2024-01-06T12:00:00+01:00", + "price": 114.852 + }, + { + "start": "2024-01-06T13:00:00+01:00", + "price": 109.711 + }, + { + "start": "2024-01-06T14:00:00+01:00", + "price": 107.197 + }, + { + "start": "2024-01-06T15:00:00+01:00", + "price": 110.553 + }, + { + "start": "2024-01-06T16:00:00+01:00", + "price": 123.46 + }, + { + "start": "2024-01-06T17:00:00+01:00", + "price": 132.742 + }, + { + "start": "2024-01-06T18:00:00+01:00", + "price": 120.026 + }, + { + "start": "2024-01-06T19:00:00+01:00", + "price": 114.784 + }, + { + "start": "2024-01-06T20:00:00+01:00", + "price": 106.602 + }, + { + "start": "2024-01-06T21:00:00+01:00", + "price": 101.72 + }, + { + "start": "2024-01-06T22:00:00+01:00", + "price": 100.272 + }, + { + "start": "2024-01-06T23:00:00+01:00", + "price": 92.932 + } + ], + "2024-01-07": [ + { + "start": "2024-01-07T00:00:00+01:00", + "price": 96.766 + }, + { + "start": "2024-01-07T01:00:00+01:00", + "price": 92.621 + }, + { + "start": "2024-01-07T02:00:00+01:00", + "price": 88.982 + }, + { + "start": "2024-01-07T03:00:00+01:00", + "price": 87.084 + }, + { + "start": "2024-01-07T04:00:00+01:00", + "price": 86.365 + }, + { + "start": "2024-01-07T05:00:00+01:00", + "price": 86.96 + }, + { + "start": "2024-01-07T06:00:00+01:00", + "price": 88.117 + }, + { + "start": "2024-01-07T07:00:00+01:00", + "price": 90.274 + }, + { + "start": "2024-01-07T08:00:00+01:00", + "price": 94.587 + }, + { + "start": "2024-01-07T09:00:00+01:00", + "price": 98.361 + }, + { + "start": "2024-01-07T10:00:00+01:00", + "price": 100.81 + }, + { + "start": "2024-01-07T11:00:00+01:00", + "price": 93.936 + }, + { + "start": "2024-01-07T12:00:00+01:00", + "price": 85.422 + }, + { + "start": "2024-01-07T13:00:00+01:00", + "price": 76.38 + }, + { + "start": "2024-01-07T14:00:00+01:00", + "price": 72.841 + }, + { + "start": "2024-01-07T15:00:00+01:00", + "price": 71.606 + }, + { + "start": "2024-01-07T16:00:00+01:00", + "price": 67.686 + }, + { + "start": "2024-01-07T17:00:00+01:00", + "price": 67.663 + }, + { + "start": "2024-01-07T18:00:00+01:00", + "price": 67.607 + }, + { + "start": "2024-01-07T19:00:00+01:00", + "price": 64.26 + }, + { + "start": "2024-01-07T20:00:00+01:00", + "price": 61.991 + }, + { + "start": "2024-01-07T21:00:00+01:00", + "price": 67.203 + }, + { + "start": "2024-01-07T22:00:00+01:00", + "price": 65.54 + }, + { + "start": "2024-01-07T23:00:00+01:00", + "price": 65.709 + } + ], + "2024-01-08": [ + { + "start": "2024-01-08T00:00:00+01:00", + "price": 60.722 + }, + { + "start": "2024-01-08T01:00:00+01:00", + "price": 60.946 + }, + { + "start": "2024-01-08T02:00:00+01:00", + "price": 61.463 + }, + { + "start": "2024-01-08T03:00:00+01:00", + "price": 61.766 + }, + { + "start": "2024-01-08T04:00:00+01:00", + "price": 63.024 + }, + { + "start": "2024-01-08T05:00:00+01:00", + "price": 65.293 + }, + { + "start": "2024-01-08T06:00:00+01:00", + "price": 60.924 + }, + { + "start": "2024-01-08T07:00:00+01:00", + "price": 60.419 + }, + { + "start": "2024-01-08T08:00:00+01:00", + "price": 60.463 + }, + { + "start": "2024-01-08T09:00:00+01:00", + "price": 60.419 + }, + { + "start": "2024-01-08T10:00:00+01:00", + "price": 60.228 + }, + { + "start": "2024-01-08T11:00:00+01:00", + "price": 60.115 + }, + { + "start": "2024-01-08T12:00:00+01:00", + "price": 59.756 + }, + { + "start": "2024-01-08T13:00:00+01:00", + "price": 59.307 + }, + { + "start": "2024-01-08T14:00:00+01:00", + "price": 58.543 + }, + { + "start": "2024-01-08T15:00:00+01:00", + "price": 56.779 + }, + { + "start": "2024-01-08T16:00:00+01:00", + "price": 55.072 + }, + { + "start": "2024-01-08T17:00:00+01:00", + "price": 54.746 + }, + { + "start": "2024-01-08T18:00:00+01:00", + "price": 52.949 + }, + { + "start": "2024-01-08T19:00:00+01:00", + "price": 52.59 + }, + { + "start": "2024-01-08T20:00:00+01:00", + "price": 52.522 + }, + { + "start": "2024-01-08T21:00:00+01:00", + "price": 53.769 + }, + { + "start": "2024-01-08T22:00:00+01:00", + "price": 54.173 + }, + { + "start": "2024-01-08T23:00:00+01:00", + "price": 53.522 + } + ], + "2024-01-09": [ + { + "start": "2024-01-09T00:00:00+01:00", + "price": 31.196 + }, + { + "start": "2024-01-09T01:00:00+01:00", + "price": 30.092 + }, + { + "start": "2024-01-09T02:00:00+01:00", + "price": 29.642 + }, + { + "start": "2024-01-09T03:00:00+01:00", + "price": 29.575 + }, + { + "start": "2024-01-09T04:00:00+01:00", + "price": 30.261 + }, + { + "start": "2024-01-09T05:00:00+01:00", + "price": 31.466 + }, + { + "start": "2024-01-09T06:00:00+01:00", + "price": 31.815 + }, + { + "start": "2024-01-09T07:00:00+01:00", + "price": 39.583 + }, + { + "start": "2024-01-09T08:00:00+01:00", + "price": 40.427 + }, + { + "start": "2024-01-09T09:00:00+01:00", + "price": 39.504 + }, + { + "start": "2024-01-09T10:00:00+01:00", + "price": 38.727 + }, + { + "start": "2024-01-09T11:00:00+01:00", + "price": 39.38 + }, + { + "start": "2024-01-09T12:00:00+01:00", + "price": 40.562 + }, + { + "start": "2024-01-09T13:00:00+01:00", + "price": 41.519 + }, + { + "start": "2024-01-09T14:00:00+01:00", + "price": 39.662 + }, + { + "start": "2024-01-09T15:00:00+01:00", + "price": 40.619 + }, + { + "start": "2024-01-09T16:00:00+01:00", + "price": 40.27 + }, + { + "start": "2024-01-09T17:00:00+01:00", + "price": 39.223 + }, + { + "start": "2024-01-09T18:00:00+01:00", + "price": 37.59 + }, + { + "start": "2024-01-09T19:00:00+01:00", + "price": 38.93 + }, + { + "start": "2024-01-09T20:00:00+01:00", + "price": 37.725 + }, + { + "start": "2024-01-09T21:00:00+01:00", + "price": 37.061 + }, + { + "start": "2024-01-09T22:00:00+01:00", + "price": 35.023 + }, + { + "start": "2024-01-09T23:00:00+01:00", + "price": 34.145 + } + ], + "2024-01-10": [ + { + "start": "2024-01-10T00:00:00+01:00", + "price": 32.156 + }, + { + "start": "2024-01-10T01:00:00+01:00", + "price": 31.976 + }, + { + "start": "2024-01-10T02:00:00+01:00", + "price": 31.382 + }, + { + "start": "2024-01-10T03:00:00+01:00", + "price": 30.721 + }, + { + "start": "2024-01-10T04:00:00+01:00", + "price": 30.676 + }, + { + "start": "2024-01-10T05:00:00+01:00", + "price": 28.983 + }, + { + "start": "2024-01-10T06:00:00+01:00", + "price": 29.532 + }, + { + "start": "2024-01-10T07:00:00+01:00", + "price": 30.709 + }, + { + "start": "2024-01-10T08:00:00+01:00", + "price": 31.046 + }, + { + "start": "2024-01-10T09:00:00+01:00", + "price": 31.651 + }, + { + "start": "2024-01-10T10:00:00+01:00", + "price": 31.864 + }, + { + "start": "2024-01-10T11:00:00+01:00", + "price": 32.044 + }, + { + "start": "2024-01-10T12:00:00+01:00", + "price": 32.122 + }, + { + "start": "2024-01-10T13:00:00+01:00", + "price": 31.169 + }, + { + "start": "2024-01-10T14:00:00+01:00", + "price": 30.754 + }, + { + "start": "2024-01-10T15:00:00+01:00", + "price": 30.126 + }, + { + "start": "2024-01-10T16:00:00+01:00", + "price": 29.51 + }, + { + "start": "2024-01-10T17:00:00+01:00", + "price": 29.756 + }, + { + "start": "2024-01-10T18:00:00+01:00", + "price": 29.084 + }, + { + "start": "2024-01-10T19:00:00+01:00", + "price": 28.467 + }, + { + "start": "2024-01-10T20:00:00+01:00", + "price": 28.131 + }, + { + "start": "2024-01-10T21:00:00+01:00", + "price": 28.389 + }, + { + "start": "2024-01-10T22:00:00+01:00", + "price": 27.391 + }, + { + "start": "2024-01-10T23:00:00+01:00", + "price": 24.106 + } + ], + "2024-01-11": [ + { + "start": "2024-01-11T00:00:00+01:00", + "price": 23.607 + }, + { + "start": "2024-01-11T01:00:00+01:00", + "price": 24.458 + }, + { + "start": "2024-01-11T02:00:00+01:00", + "price": 27.301 + }, + { + "start": "2024-01-11T03:00:00+01:00", + "price": 30.234 + }, + { + "start": "2024-01-11T04:00:00+01:00", + "price": 32.204 + }, + { + "start": "2024-01-11T05:00:00+01:00", + "price": 38.461 + }, + { + "start": "2024-01-11T06:00:00+01:00", + "price": 46.945 + }, + { + "start": "2024-01-11T07:00:00+01:00", + "price": 59.896 + }, + { + "start": "2024-01-11T08:00:00+01:00", + "price": 66.601 + }, + { + "start": "2024-01-11T09:00:00+01:00", + "price": 71.862 + }, + { + "start": "2024-01-11T10:00:00+01:00", + "price": 79.731 + }, + { + "start": "2024-01-11T11:00:00+01:00", + "price": 83.85 + }, + { + "start": "2024-01-11T12:00:00+01:00", + "price": 82.126 + }, + { + "start": "2024-01-11T13:00:00+01:00", + "price": 82.485 + }, + { + "start": "2024-01-11T14:00:00+01:00", + "price": 82.63 + }, + { + "start": "2024-01-11T15:00:00+01:00", + "price": 79.507 + }, + { + "start": "2024-01-11T16:00:00+01:00", + "price": 70.396 + }, + { + "start": "2024-01-11T17:00:00+01:00", + "price": 66.534 + }, + { + "start": "2024-01-11T18:00:00+01:00", + "price": 66.276 + }, + { + "start": "2024-01-11T19:00:00+01:00", + "price": 60.657 + }, + { + "start": "2024-01-11T20:00:00+01:00", + "price": 55.497 + }, + { + "start": "2024-01-11T21:00:00+01:00", + "price": 51.837 + }, + { + "start": "2024-01-11T22:00:00+01:00", + "price": 49.139 + }, + { + "start": "2024-01-11T23:00:00+01:00", + "price": 47.024 + } + ], + "2024-01-12": [ + { + "start": "2024-01-12T00:00:00+01:00", + "price": 50.369 + }, + { + "start": "2024-01-12T01:00:00+01:00", + "price": 50.503 + }, + { + "start": "2024-01-12T02:00:00+01:00", + "price": 50.436 + }, + { + "start": "2024-01-12T03:00:00+01:00", + "price": 49.797 + }, + { + "start": "2024-01-12T04:00:00+01:00", + "price": 49.428 + }, + { + "start": "2024-01-12T05:00:00+01:00", + "price": 49.495 + }, + { + "start": "2024-01-12T06:00:00+01:00", + "price": 49.226 + }, + { + "start": "2024-01-12T07:00:00+01:00", + "price": 52.06 + }, + { + "start": "2024-01-12T08:00:00+01:00", + "price": 52.027 + }, + { + "start": "2024-01-12T09:00:00+01:00", + "price": 55.511 + }, + { + "start": "2024-01-12T10:00:00+01:00", + "price": 58.457 + }, + { + "start": "2024-01-12T11:00:00+01:00", + "price": 62.378 + }, + { + "start": "2024-01-12T12:00:00+01:00", + "price": 69.896 + }, + { + "start": "2024-01-12T13:00:00+01:00", + "price": 73.996 + }, + { + "start": "2024-01-12T14:00:00+01:00", + "price": 78.007 + }, + { + "start": "2024-01-12T15:00:00+01:00", + "price": 78.713 + }, + { + "start": "2024-01-12T16:00:00+01:00", + "price": 77.514 + }, + { + "start": "2024-01-12T17:00:00+01:00", + "price": 72.943 + }, + { + "start": "2024-01-12T18:00:00+01:00", + "price": 71.464 + }, + { + "start": "2024-01-12T19:00:00+01:00", + "price": 65.28 + }, + { + "start": "2024-01-12T20:00:00+01:00", + "price": 60.295 + }, + { + "start": "2024-01-12T21:00:00+01:00", + "price": 55.141 + }, + { + "start": "2024-01-12T22:00:00+01:00", + "price": 53.853 + }, + { + "start": "2024-01-12T23:00:00+01:00", + "price": 51.68 + } + ], + "2024-01-13": [ + { + "start": "2024-01-13T00:00:00+01:00", + "price": 55.963 + }, + { + "start": "2024-01-13T01:00:00+01:00", + "price": 52.517 + }, + { + "start": "2024-01-13T02:00:00+01:00", + "price": 49.555 + }, + { + "start": "2024-01-13T03:00:00+01:00", + "price": 47.832 + }, + { + "start": "2024-01-13T04:00:00+01:00", + "price": 47.911 + }, + { + "start": "2024-01-13T05:00:00+01:00", + "price": 49.026 + }, + { + "start": "2024-01-13T06:00:00+01:00", + "price": 51.379 + }, + { + "start": "2024-01-13T07:00:00+01:00", + "price": 57.664 + }, + { + "start": "2024-01-13T08:00:00+01:00", + "price": 65.243 + }, + { + "start": "2024-01-13T09:00:00+01:00", + "price": 66.64 + }, + { + "start": "2024-01-13T10:00:00+01:00", + "price": 66.685 + }, + { + "start": "2024-01-13T11:00:00+01:00", + "price": 65.739 + }, + { + "start": "2024-01-13T12:00:00+01:00", + "price": 63.295 + }, + { + "start": "2024-01-13T13:00:00+01:00", + "price": 63.475 + }, + { + "start": "2024-01-13T14:00:00+01:00", + "price": 66.64 + }, + { + "start": "2024-01-13T15:00:00+01:00", + "price": 74.028 + }, + { + "start": "2024-01-13T16:00:00+01:00", + "price": 73.983 + }, + { + "start": "2024-01-13T17:00:00+01:00", + "price": 69.917 + }, + { + "start": "2024-01-13T18:00:00+01:00", + "price": 63.149 + }, + { + "start": "2024-01-13T19:00:00+01:00", + "price": 62.833 + }, + { + "start": "2024-01-13T20:00:00+01:00", + "price": 63.396 + }, + { + "start": "2024-01-13T21:00:00+01:00", + "price": 66.618 + }, + { + "start": "2024-01-13T22:00:00+01:00", + "price": 70.683 + }, + { + "start": "2024-01-13T23:00:00+01:00", + "price": 69.433 + } + ], + "2024-01-14": [ + { + "start": "2024-01-14T00:00:00+01:00", + "price": 71.61 + }, + { + "start": "2024-01-14T01:00:00+01:00", + "price": 69.473 + }, + { + "start": "2024-01-14T02:00:00+01:00", + "price": 70.39 + }, + { + "start": "2024-01-14T03:00:00+01:00", + "price": 71.598 + }, + { + "start": "2024-01-14T04:00:00+01:00", + "price": 72.337 + }, + { + "start": "2024-01-14T05:00:00+01:00", + "price": 72.929 + }, + { + "start": "2024-01-14T06:00:00+01:00", + "price": 75.29 + }, + { + "start": "2024-01-14T07:00:00+01:00", + "price": 80.155 + }, + { + "start": "2024-01-14T08:00:00+01:00", + "price": 85.837 + }, + { + "start": "2024-01-14T09:00:00+01:00", + "price": 90.759 + }, + { + "start": "2024-01-14T10:00:00+01:00", + "price": 90.189 + }, + { + "start": "2024-01-14T11:00:00+01:00", + "price": 91.542 + }, + { + "start": "2024-01-14T12:00:00+01:00", + "price": 89.92 + }, + { + "start": "2024-01-14T13:00:00+01:00", + "price": 86.173 + }, + { + "start": "2024-01-14T14:00:00+01:00", + "price": 88.533 + }, + { + "start": "2024-01-14T15:00:00+01:00", + "price": 89.629 + }, + { + "start": "2024-01-14T16:00:00+01:00", + "price": 86.699 + }, + { + "start": "2024-01-14T17:00:00+01:00", + "price": 83.947 + }, + { + "start": "2024-01-14T18:00:00+01:00", + "price": 83.735 + }, + { + "start": "2024-01-14T19:00:00+01:00", + "price": 82.728 + }, + { + "start": "2024-01-14T20:00:00+01:00", + "price": 82.616 + }, + { + "start": "2024-01-14T21:00:00+01:00", + "price": 82.437 + }, + { + "start": "2024-01-14T22:00:00+01:00", + "price": 81.844 + }, + { + "start": "2024-01-14T23:00:00+01:00", + "price": 74.82 + } + ], + "2024-01-15": [ + { + "start": "2024-01-15T00:00:00+01:00", + "price": 77.907 + }, + { + "start": "2024-01-15T01:00:00+01:00", + "price": 73.88 + }, + { + "start": "2024-01-15T02:00:00+01:00", + "price": 71.934 + }, + { + "start": "2024-01-15T03:00:00+01:00", + "price": 69.395 + }, + { + "start": "2024-01-15T04:00:00+01:00", + "price": 71.106 + }, + { + "start": "2024-01-15T05:00:00+01:00", + "price": 78.936 + }, + { + "start": "2024-01-15T06:00:00+01:00", + "price": 89.428 + }, + { + "start": "2024-01-15T07:00:00+01:00", + "price": 93.779 + }, + { + "start": "2024-01-15T08:00:00+01:00", + "price": 111.072 + }, + { + "start": "2024-01-15T09:00:00+01:00", + "price": 111.844 + }, + { + "start": "2024-01-15T10:00:00+01:00", + "price": 106.128 + }, + { + "start": "2024-01-15T11:00:00+01:00", + "price": 100.96 + }, + { + "start": "2024-01-15T12:00:00+01:00", + "price": 93.802 + }, + { + "start": "2024-01-15T13:00:00+01:00", + "price": 91.889 + }, + { + "start": "2024-01-15T14:00:00+01:00", + "price": 94.517 + }, + { + "start": "2024-01-15T15:00:00+01:00", + "price": 100.043 + }, + { + "start": "2024-01-15T16:00:00+01:00", + "price": 101.162 + }, + { + "start": "2024-01-15T17:00:00+01:00", + "price": 97.739 + }, + { + "start": "2024-01-15T18:00:00+01:00", + "price": 105.233 + }, + { + "start": "2024-01-15T19:00:00+01:00", + "price": 105.166 + }, + { + "start": "2024-01-15T20:00:00+01:00", + "price": 106.251 + }, + { + "start": "2024-01-15T21:00:00+01:00", + "price": 99.226 + }, + { + "start": "2024-01-15T22:00:00+01:00", + "price": 97.202 + }, + { + "start": "2024-01-15T23:00:00+01:00", + "price": 95.904 + } + ], + "2024-01-16": [ + { + "start": "2024-01-16T00:00:00+01:00", + "price": 92.644 + }, + { + "start": "2024-01-16T01:00:00+01:00", + "price": 91.754 + }, + { + "start": "2024-01-16T02:00:00+01:00", + "price": 90.255 + }, + { + "start": "2024-01-16T03:00:00+01:00", + "price": 88.215 + }, + { + "start": "2024-01-16T04:00:00+01:00", + "price": 87.459 + }, + { + "start": "2024-01-16T05:00:00+01:00", + "price": 94.065 + }, + { + "start": "2024-01-16T06:00:00+01:00", + "price": 104.345 + }, + { + "start": "2024-01-16T07:00:00+01:00", + "price": 112.957 + }, + { + "start": "2024-01-16T08:00:00+01:00", + "price": 145.228 + }, + { + "start": "2024-01-16T09:00:00+01:00", + "price": 142.095 + }, + { + "start": "2024-01-16T10:00:00+01:00", + "price": 122.594 + }, + { + "start": "2024-01-16T11:00:00+01:00", + "price": 113.599 + }, + { + "start": "2024-01-16T12:00:00+01:00", + "price": 113.205 + }, + { + "start": "2024-01-16T13:00:00+01:00", + "price": 106.689 + }, + { + "start": "2024-01-16T14:00:00+01:00", + "price": 107.433 + }, + { + "start": "2024-01-16T15:00:00+01:00", + "price": 114.49 + }, + { + "start": "2024-01-16T16:00:00+01:00", + "price": 121.918 + }, + { + "start": "2024-01-16T17:00:00+01:00", + "price": 116.09 + }, + { + "start": "2024-01-16T18:00:00+01:00", + "price": 139.344 + }, + { + "start": "2024-01-16T19:00:00+01:00", + "price": 125.987 + }, + { + "start": "2024-01-16T20:00:00+01:00", + "price": 107.636 + }, + { + "start": "2024-01-16T21:00:00+01:00", + "price": 97.176 + }, + { + "start": "2024-01-16T22:00:00+01:00", + "price": 92.475 + }, + { + "start": "2024-01-16T23:00:00+01:00", + "price": 87.572 + } + ], + "2024-01-17": [ + { + "start": "2024-01-17T00:00:00+01:00", + "price": 78.78 + }, + { + "start": "2024-01-17T01:00:00+01:00", + "price": 78.183 + }, + { + "start": "2024-01-17T02:00:00+01:00", + "price": 77.179 + }, + { + "start": "2024-01-17T03:00:00+01:00", + "price": 76.154 + }, + { + "start": "2024-01-17T04:00:00+01:00", + "price": 75.872 + }, + { + "start": "2024-01-17T05:00:00+01:00", + "price": 75.838 + }, + { + "start": "2024-01-17T06:00:00+01:00", + "price": 79.637 + }, + { + "start": "2024-01-17T07:00:00+01:00", + "price": 90.108 + }, + { + "start": "2024-01-17T08:00:00+01:00", + "price": 107.478 + }, + { + "start": "2024-01-17T09:00:00+01:00", + "price": 115.955 + }, + { + "start": "2024-01-17T10:00:00+01:00", + "price": 120.34 + }, + { + "start": "2024-01-17T11:00:00+01:00", + "price": 108.527 + }, + { + "start": "2024-01-17T12:00:00+01:00", + "price": 94.651 + }, + { + "start": "2024-01-17T13:00:00+01:00", + "price": 88.463 + }, + { + "start": "2024-01-17T14:00:00+01:00", + "price": 80.279 + }, + { + "start": "2024-01-17T15:00:00+01:00", + "price": 76.03 + }, + { + "start": "2024-01-17T16:00:00+01:00", + "price": 67.44 + }, + { + "start": "2024-01-17T17:00:00+01:00", + "price": 63.98 + }, + { + "start": "2024-01-17T18:00:00+01:00", + "price": 70.619 + }, + { + "start": "2024-01-17T19:00:00+01:00", + "price": 71.014 + }, + { + "start": "2024-01-17T20:00:00+01:00", + "price": 70.923 + }, + { + "start": "2024-01-17T21:00:00+01:00", + "price": 67.564 + }, + { + "start": "2024-01-17T22:00:00+01:00", + "price": 65.659 + }, + { + "start": "2024-01-17T23:00:00+01:00", + "price": 66.167 + } + ], + "2024-01-18": [ + { + "start": "2024-01-18T00:00:00+01:00", + "price": 54.618 + }, + { + "start": "2024-01-18T01:00:00+01:00", + "price": 56.087 + }, + { + "start": "2024-01-18T02:00:00+01:00", + "price": 53.002 + }, + { + "start": "2024-01-18T03:00:00+01:00", + "price": 51.18 + }, + { + "start": "2024-01-18T04:00:00+01:00", + "price": 53.195 + }, + { + "start": "2024-01-18T05:00:00+01:00", + "price": 54.152 + }, + { + "start": "2024-01-18T06:00:00+01:00", + "price": 52.319 + }, + { + "start": "2024-01-18T07:00:00+01:00", + "price": 51.761 + }, + { + "start": "2024-01-18T08:00:00+01:00", + "price": 50.816 + }, + { + "start": "2024-01-18T09:00:00+01:00", + "price": 51.203 + }, + { + "start": "2024-01-18T10:00:00+01:00", + "price": 52.193 + }, + { + "start": "2024-01-18T11:00:00+01:00", + "price": 52.603 + }, + { + "start": "2024-01-18T12:00:00+01:00", + "price": 51.601 + }, + { + "start": "2024-01-18T13:00:00+01:00", + "price": 52.148 + }, + { + "start": "2024-01-18T14:00:00+01:00", + "price": 54.095 + }, + { + "start": "2024-01-18T15:00:00+01:00", + "price": 54.3 + }, + { + "start": "2024-01-18T16:00:00+01:00", + "price": 57.294 + }, + { + "start": "2024-01-18T17:00:00+01:00", + "price": 58.9 + }, + { + "start": "2024-01-18T18:00:00+01:00", + "price": 59.845 + }, + { + "start": "2024-01-18T19:00:00+01:00", + "price": 60.756 + }, + { + "start": "2024-01-18T20:00:00+01:00", + "price": 60.585 + }, + { + "start": "2024-01-18T21:00:00+01:00", + "price": 59.947 + }, + { + "start": "2024-01-18T22:00:00+01:00", + "price": 58.296 + }, + { + "start": "2024-01-18T23:00:00+01:00", + "price": 59.628 + } + ], + "2024-01-19": [ + { + "start": "2024-01-19T00:00:00+01:00", + "price": 54.221 + }, + { + "start": "2024-01-19T01:00:00+01:00", + "price": 54.687 + }, + { + "start": "2024-01-19T02:00:00+01:00", + "price": 55.324 + }, + { + "start": "2024-01-19T03:00:00+01:00", + "price": 56.678 + }, + { + "start": "2024-01-19T04:00:00+01:00", + "price": 57.896 + }, + { + "start": "2024-01-19T05:00:00+01:00", + "price": 57.441 + }, + { + "start": "2024-01-19T06:00:00+01:00", + "price": 59.966 + }, + { + "start": "2024-01-19T07:00:00+01:00", + "price": 64.05 + }, + { + "start": "2024-01-19T08:00:00+01:00", + "price": 65.984 + }, + { + "start": "2024-01-19T09:00:00+01:00", + "price": 69.602 + }, + { + "start": "2024-01-19T10:00:00+01:00", + "price": 77.725 + }, + { + "start": "2024-01-19T11:00:00+01:00", + "price": 82.06 + }, + { + "start": "2024-01-19T12:00:00+01:00", + "price": 84.858 + }, + { + "start": "2024-01-19T13:00:00+01:00", + "price": 85.495 + }, + { + "start": "2024-01-19T14:00:00+01:00", + "price": 87.498 + }, + { + "start": "2024-01-19T15:00:00+01:00", + "price": 91.195 + }, + { + "start": "2024-01-19T16:00:00+01:00", + "price": 102.082 + }, + { + "start": "2024-01-19T17:00:00+01:00", + "price": 121.263 + }, + { + "start": "2024-01-19T18:00:00+01:00", + "price": 123.425 + }, + { + "start": "2024-01-19T19:00:00+01:00", + "price": 96.815 + }, + { + "start": "2024-01-19T20:00:00+01:00", + "price": 86.258 + }, + { + "start": "2024-01-19T21:00:00+01:00", + "price": 83.061 + }, + { + "start": "2024-01-19T22:00:00+01:00", + "price": 80.421 + }, + { + "start": "2024-01-19T23:00:00+01:00", + "price": 75.871 + } + ], + "2024-01-20": [ + { + "start": "2024-01-20T00:00:00+01:00", + "price": 76.962 + }, + { + "start": "2024-01-20T01:00:00+01:00", + "price": 73.992 + }, + { + "start": "2024-01-20T02:00:00+01:00", + "price": 74.094 + }, + { + "start": "2024-01-20T03:00:00+01:00", + "price": 72.091 + }, + { + "start": "2024-01-20T04:00:00+01:00", + "price": 72.125 + }, + { + "start": "2024-01-20T05:00:00+01:00", + "price": 72.0 + }, + { + "start": "2024-01-20T06:00:00+01:00", + "price": 73.354 + }, + { + "start": "2024-01-20T07:00:00+01:00", + "price": 76.803 + }, + { + "start": "2024-01-20T08:00:00+01:00", + "price": 80.627 + }, + { + "start": "2024-01-20T09:00:00+01:00", + "price": 85.453 + }, + { + "start": "2024-01-20T10:00:00+01:00", + "price": 83.734 + }, + { + "start": "2024-01-20T11:00:00+01:00", + "price": 82.528 + }, + { + "start": "2024-01-20T12:00:00+01:00", + "price": 79.386 + }, + { + "start": "2024-01-20T13:00:00+01:00", + "price": 76.803 + }, + { + "start": "2024-01-20T14:00:00+01:00", + "price": 71.545 + }, + { + "start": "2024-01-20T15:00:00+01:00", + "price": 65.251 + }, + { + "start": "2024-01-20T16:00:00+01:00", + "price": 54.336 + }, + { + "start": "2024-01-20T17:00:00+01:00", + "price": 51.115 + }, + { + "start": "2024-01-20T18:00:00+01:00", + "price": 49.931 + }, + { + "start": "2024-01-20T19:00:00+01:00", + "price": 48.986 + }, + { + "start": "2024-01-20T20:00:00+01:00", + "price": 47.461 + }, + { + "start": "2024-01-20T21:00:00+01:00", + "price": 44.9 + }, + { + "start": "2024-01-20T22:00:00+01:00", + "price": 42.248 + }, + { + "start": "2024-01-20T23:00:00+01:00", + "price": 40.723 + } + ], + "2024-01-21": [ + { + "start": "2024-01-21T00:00:00+01:00", + "price": 39.726 + }, + { + "start": "2024-01-21T01:00:00+01:00", + "price": 39.669 + }, + { + "start": "2024-01-21T02:00:00+01:00", + "price": 39.566 + }, + { + "start": "2024-01-21T03:00:00+01:00", + "price": 39.292 + }, + { + "start": "2024-01-21T04:00:00+01:00", + "price": 39.155 + }, + { + "start": "2024-01-21T05:00:00+01:00", + "price": 39.532 + }, + { + "start": "2024-01-21T06:00:00+01:00", + "price": 39.178 + }, + { + "start": "2024-01-21T07:00:00+01:00", + "price": 39.954 + }, + { + "start": "2024-01-21T08:00:00+01:00", + "price": 39.783 + }, + { + "start": "2024-01-21T09:00:00+01:00", + "price": 40.64 + }, + { + "start": "2024-01-21T10:00:00+01:00", + "price": 42.124 + }, + { + "start": "2024-01-21T11:00:00+01:00", + "price": 43.175 + }, + { + "start": "2024-01-21T12:00:00+01:00", + "price": 42.616 + }, + { + "start": "2024-01-21T13:00:00+01:00", + "price": 42.01 + }, + { + "start": "2024-01-21T14:00:00+01:00", + "price": 42.307 + }, + { + "start": "2024-01-21T15:00:00+01:00", + "price": 42.867 + }, + { + "start": "2024-01-21T16:00:00+01:00", + "price": 42.73 + }, + { + "start": "2024-01-21T17:00:00+01:00", + "price": 41.028 + }, + { + "start": "2024-01-21T18:00:00+01:00", + "price": 40.582 + }, + { + "start": "2024-01-21T19:00:00+01:00", + "price": 39.931 + }, + { + "start": "2024-01-21T20:00:00+01:00", + "price": 39.6 + }, + { + "start": "2024-01-21T21:00:00+01:00", + "price": 39.463 + }, + { + "start": "2024-01-21T22:00:00+01:00", + "price": 39.783 + }, + { + "start": "2024-01-21T23:00:00+01:00", + "price": 33.158 + } + ], + "2024-01-22": [ + { + "start": "2024-01-22T00:00:00+01:00", + "price": 31.182 + }, + { + "start": "2024-01-22T01:00:00+01:00", + "price": 25.403 + }, + { + "start": "2024-01-22T02:00:00+01:00", + "price": 21.268 + }, + { + "start": "2024-01-22T03:00:00+01:00", + "price": 5.757 + }, + { + "start": "2024-01-22T04:00:00+01:00", + "price": 5.78 + }, + { + "start": "2024-01-22T05:00:00+01:00", + "price": 22.844 + }, + { + "start": "2024-01-22T06:00:00+01:00", + "price": 29.754 + }, + { + "start": "2024-01-22T07:00:00+01:00", + "price": 37.921 + }, + { + "start": "2024-01-22T08:00:00+01:00", + "price": 37.784 + }, + { + "start": "2024-01-22T09:00:00+01:00", + "price": 37.544 + }, + { + "start": "2024-01-22T10:00:00+01:00", + "price": 36.196 + }, + { + "start": "2024-01-22T11:00:00+01:00", + "price": 35.26 + }, + { + "start": "2024-01-22T12:00:00+01:00", + "price": 34.951 + }, + { + "start": "2024-01-22T13:00:00+01:00", + "price": 33.455 + }, + { + "start": "2024-01-22T14:00:00+01:00", + "price": 32.644 + }, + { + "start": "2024-01-22T15:00:00+01:00", + "price": 34.632 + }, + { + "start": "2024-01-22T16:00:00+01:00", + "price": 36.116 + }, + { + "start": "2024-01-22T17:00:00+01:00", + "price": 37.521 + }, + { + "start": "2024-01-22T18:00:00+01:00", + "price": 37.053 + }, + { + "start": "2024-01-22T19:00:00+01:00", + "price": 36.265 + }, + { + "start": "2024-01-22T20:00:00+01:00", + "price": 35.877 + }, + { + "start": "2024-01-22T21:00:00+01:00", + "price": 32.941 + }, + { + "start": "2024-01-22T22:00:00+01:00", + "price": 29.777 + }, + { + "start": "2024-01-22T23:00:00+01:00", + "price": 26.305 + } + ], + "2024-01-23": [ + { + "start": "2024-01-23T00:00:00+01:00", + "price": 18.005 + }, + { + "start": "2024-01-23T01:00:00+01:00", + "price": 11.207 + }, + { + "start": "2024-01-23T02:00:00+01:00", + "price": 10.389 + }, + { + "start": "2024-01-23T03:00:00+01:00", + "price": 4.74 + }, + { + "start": "2024-01-23T04:00:00+01:00", + "price": 11.617 + }, + { + "start": "2024-01-23T05:00:00+01:00", + "price": 24.586 + }, + { + "start": "2024-01-23T06:00:00+01:00", + "price": 29.246 + }, + { + "start": "2024-01-23T07:00:00+01:00", + "price": 33.406 + }, + { + "start": "2024-01-23T08:00:00+01:00", + "price": 38.214 + }, + { + "start": "2024-01-23T09:00:00+01:00", + "price": 35.032 + }, + { + "start": "2024-01-23T10:00:00+01:00", + "price": 34.009 + }, + { + "start": "2024-01-23T11:00:00+01:00", + "price": 31.451 + }, + { + "start": "2024-01-23T12:00:00+01:00", + "price": 30.212 + }, + { + "start": "2024-01-23T13:00:00+01:00", + "price": 30.417 + }, + { + "start": "2024-01-23T14:00:00+01:00", + "price": 30.383 + }, + { + "start": "2024-01-23T15:00:00+01:00", + "price": 32.963 + }, + { + "start": "2024-01-23T16:00:00+01:00", + "price": 33.054 + }, + { + "start": "2024-01-23T17:00:00+01:00", + "price": 32.622 + }, + { + "start": "2024-01-23T18:00:00+01:00", + "price": 32.974 + }, + { + "start": "2024-01-23T19:00:00+01:00", + "price": 34.441 + }, + { + "start": "2024-01-23T20:00:00+01:00", + "price": 37.225 + }, + { + "start": "2024-01-23T21:00:00+01:00", + "price": 40.851 + }, + { + "start": "2024-01-23T22:00:00+01:00", + "price": 46.398 + }, + { + "start": "2024-01-23T23:00:00+01:00", + "price": 37.805 + } + ], + "2024-01-24": [ + { + "start": "2024-01-24T00:00:00+01:00", + "price": 32.488 + }, + { + "start": "2024-01-24T01:00:00+01:00", + "price": 28.582 + }, + { + "start": "2024-01-24T02:00:00+01:00", + "price": 23.438 + }, + { + "start": "2024-01-24T03:00:00+01:00", + "price": 21.371 + }, + { + "start": "2024-01-24T04:00:00+01:00", + "price": 20.27 + }, + { + "start": "2024-01-24T05:00:00+01:00", + "price": 22.2 + }, + { + "start": "2024-01-24T06:00:00+01:00", + "price": 28.957 + }, + { + "start": "2024-01-24T07:00:00+01:00", + "price": 43.98 + }, + { + "start": "2024-01-24T08:00:00+01:00", + "price": 56.131 + }, + { + "start": "2024-01-24T09:00:00+01:00", + "price": 56.255 + }, + { + "start": "2024-01-24T10:00:00+01:00", + "price": 56.891 + }, + { + "start": "2024-01-24T11:00:00+01:00", + "price": 57.641 + }, + { + "start": "2024-01-24T12:00:00+01:00", + "price": 44.673 + }, + { + "start": "2024-01-24T13:00:00+01:00", + "price": 42.402 + }, + { + "start": "2024-01-24T14:00:00+01:00", + "price": 51.145 + }, + { + "start": "2024-01-24T15:00:00+01:00", + "price": 56.721 + }, + { + "start": "2024-01-24T16:00:00+01:00", + "price": 57.686 + }, + { + "start": "2024-01-24T17:00:00+01:00", + "price": 61.559 + }, + { + "start": "2024-01-24T18:00:00+01:00", + "price": 57.675 + }, + { + "start": "2024-01-24T19:00:00+01:00", + "price": 56.187 + }, + { + "start": "2024-01-24T20:00:00+01:00", + "price": 47.898 + }, + { + "start": "2024-01-24T21:00:00+01:00", + "price": 45.468 + }, + { + "start": "2024-01-24T22:00:00+01:00", + "price": 47.705 + }, + { + "start": "2024-01-24T23:00:00+01:00", + "price": 43.753 + } + ], + "2024-01-25": [ + { + "start": "2024-01-25T00:00:00+01:00", + "price": 49.842 + }, + { + "start": "2024-01-25T01:00:00+01:00", + "price": 49.99 + }, + { + "start": "2024-01-25T02:00:00+01:00", + "price": 48.614 + }, + { + "start": "2024-01-25T03:00:00+01:00", + "price": 49.854 + }, + { + "start": "2024-01-25T04:00:00+01:00", + "price": 51.286 + }, + { + "start": "2024-01-25T05:00:00+01:00", + "price": 57.994 + }, + { + "start": "2024-01-25T06:00:00+01:00", + "price": 67.521 + }, + { + "start": "2024-01-25T07:00:00+01:00", + "price": 75.093 + }, + { + "start": "2024-01-25T08:00:00+01:00", + "price": 101.208 + }, + { + "start": "2024-01-25T09:00:00+01:00", + "price": 96.922 + }, + { + "start": "2024-01-25T10:00:00+01:00", + "price": 92.488 + }, + { + "start": "2024-01-25T11:00:00+01:00", + "price": 86.655 + }, + { + "start": "2024-01-25T12:00:00+01:00", + "price": 82.574 + }, + { + "start": "2024-01-25T13:00:00+01:00", + "price": 85.769 + }, + { + "start": "2024-01-25T14:00:00+01:00", + "price": 90.453 + }, + { + "start": "2024-01-25T15:00:00+01:00", + "price": 78.39 + }, + { + "start": "2024-01-25T16:00:00+01:00", + "price": 81.289 + }, + { + "start": "2024-01-25T17:00:00+01:00", + "price": 112.827 + }, + { + "start": "2024-01-25T18:00:00+01:00", + "price": 127.289 + }, + { + "start": "2024-01-25T19:00:00+01:00", + "price": 82.847 + }, + { + "start": "2024-01-25T20:00:00+01:00", + "price": 74.274 + }, + { + "start": "2024-01-25T21:00:00+01:00", + "price": 71.864 + }, + { + "start": "2024-01-25T22:00:00+01:00", + "price": 72.08 + }, + { + "start": "2024-01-25T23:00:00+01:00", + "price": 70.875 + } + ], + "2024-01-26": [ + { + "start": "2024-01-26T00:00:00+01:00", + "price": 60.143 + }, + { + "start": "2024-01-26T01:00:00+01:00", + "price": 50.026 + }, + { + "start": "2024-01-26T02:00:00+01:00", + "price": 45.178 + }, + { + "start": "2024-01-26T03:00:00+01:00", + "price": 43.407 + }, + { + "start": "2024-01-26T04:00:00+01:00", + "price": 41.045 + }, + { + "start": "2024-01-26T05:00:00+01:00", + "price": 37.468 + }, + { + "start": "2024-01-26T06:00:00+01:00", + "price": 39.001 + }, + { + "start": "2024-01-26T07:00:00+01:00", + "price": 39.978 + }, + { + "start": "2024-01-26T08:00:00+01:00", + "price": 39.512 + }, + { + "start": "2024-01-26T09:00:00+01:00", + "price": 40.931 + }, + { + "start": "2024-01-26T10:00:00+01:00", + "price": 41.249 + }, + { + "start": "2024-01-26T11:00:00+01:00", + "price": 39.966 + }, + { + "start": "2024-01-26T12:00:00+01:00", + "price": 40.716 + }, + { + "start": "2024-01-26T13:00:00+01:00", + "price": 39.683 + }, + { + "start": "2024-01-26T14:00:00+01:00", + "price": 37.537 + }, + { + "start": "2024-01-26T15:00:00+01:00", + "price": 36.583 + }, + { + "start": "2024-01-26T16:00:00+01:00", + "price": 37.537 + }, + { + "start": "2024-01-26T17:00:00+01:00", + "price": 37.537 + }, + { + "start": "2024-01-26T18:00:00+01:00", + "price": 40.841 + }, + { + "start": "2024-01-26T19:00:00+01:00", + "price": 38.048 + }, + { + "start": "2024-01-26T20:00:00+01:00", + "price": 31.315 + }, + { + "start": "2024-01-26T21:00:00+01:00", + "price": 29.248 + }, + { + "start": "2024-01-26T22:00:00+01:00", + "price": 27.693 + }, + { + "start": "2024-01-26T23:00:00+01:00", + "price": 24.956 + } + ], + "2024-01-27": [ + { + "start": "2024-01-27T00:00:00+01:00", + "price": 21.86 + }, + { + "start": "2024-01-27T01:00:00+01:00", + "price": 20.661 + }, + { + "start": "2024-01-27T02:00:00+01:00", + "price": 19.948 + }, + { + "start": "2024-01-27T03:00:00+01:00", + "price": 20.389 + }, + { + "start": "2024-01-27T04:00:00+01:00", + "price": 20.921 + }, + { + "start": "2024-01-27T05:00:00+01:00", + "price": 21.317 + }, + { + "start": "2024-01-27T06:00:00+01:00", + "price": 21.962 + }, + { + "start": "2024-01-27T07:00:00+01:00", + "price": 23.433 + }, + { + "start": "2024-01-27T08:00:00+01:00", + "price": 23.761 + }, + { + "start": "2024-01-27T09:00:00+01:00", + "price": 23.795 + }, + { + "start": "2024-01-27T10:00:00+01:00", + "price": 23.908 + }, + { + "start": "2024-01-27T11:00:00+01:00", + "price": 23.783 + }, + { + "start": "2024-01-27T12:00:00+01:00", + "price": 23.229 + }, + { + "start": "2024-01-27T13:00:00+01:00", + "price": 22.72 + }, + { + "start": "2024-01-27T14:00:00+01:00", + "price": 22.663 + }, + { + "start": "2024-01-27T15:00:00+01:00", + "price": 22.822 + }, + { + "start": "2024-01-27T16:00:00+01:00", + "price": 23.003 + }, + { + "start": "2024-01-27T17:00:00+01:00", + "price": 23.387 + }, + { + "start": "2024-01-27T18:00:00+01:00", + "price": 23.093 + }, + { + "start": "2024-01-27T19:00:00+01:00", + "price": 21.068 + }, + { + "start": "2024-01-27T20:00:00+01:00", + "price": 17.119 + }, + { + "start": "2024-01-27T21:00:00+01:00", + "price": 11.473 + }, + { + "start": "2024-01-27T22:00:00+01:00", + "price": 3.937 + }, + { + "start": "2024-01-27T23:00:00+01:00", + "price": 3.123 + } + ], + "2024-01-28": [ + { + "start": "2024-01-28T00:00:00+01:00", + "price": 0.011 + }, + { + "start": "2024-01-28T01:00:00+01:00", + "price": 2.973 + }, + { + "start": "2024-01-28T02:00:00+01:00", + "price": 3.949 + }, + { + "start": "2024-01-28T03:00:00+01:00", + "price": 13.027 + }, + { + "start": "2024-01-28T04:00:00+01:00", + "price": 17.078 + }, + { + "start": "2024-01-28T05:00:00+01:00", + "price": 18.542 + }, + { + "start": "2024-01-28T06:00:00+01:00", + "price": 20.675 + }, + { + "start": "2024-01-28T07:00:00+01:00", + "price": 21.288 + }, + { + "start": "2024-01-28T08:00:00+01:00", + "price": 21.049 + }, + { + "start": "2024-01-28T09:00:00+01:00", + "price": 21.174 + }, + { + "start": "2024-01-28T10:00:00+01:00", + "price": 21.152 + }, + { + "start": "2024-01-28T11:00:00+01:00", + "price": 21.254 + }, + { + "start": "2024-01-28T12:00:00+01:00", + "price": 20.981 + }, + { + "start": "2024-01-28T13:00:00+01:00", + "price": 20.482 + }, + { + "start": "2024-01-28T14:00:00+01:00", + "price": 19.188 + }, + { + "start": "2024-01-28T15:00:00+01:00", + "price": 20.323 + }, + { + "start": "2024-01-28T16:00:00+01:00", + "price": 20.902 + }, + { + "start": "2024-01-28T17:00:00+01:00", + "price": 20.72 + }, + { + "start": "2024-01-28T18:00:00+01:00", + "price": 20.607 + }, + { + "start": "2024-01-28T19:00:00+01:00", + "price": 15.228 + }, + { + "start": "2024-01-28T20:00:00+01:00", + "price": 2.235 + }, + { + "start": "2024-01-28T21:00:00+01:00", + "price": 0.011 + }, + { + "start": "2024-01-28T22:00:00+01:00", + "price": -0.011 + }, + { + "start": "2024-01-28T23:00:00+01:00", + "price": -0.352 + } + ], + "2024-01-29": [ + { + "start": "2024-01-29T00:00:00+01:00", + "price": -0.477 + }, + { + "start": "2024-01-29T01:00:00+01:00", + "price": -1.952 + }, + { + "start": "2024-01-29T02:00:00+01:00", + "price": -2.02 + }, + { + "start": "2024-01-29T03:00:00+01:00", + "price": -2.008 + }, + { + "start": "2024-01-29T04:00:00+01:00", + "price": -1.6 + }, + { + "start": "2024-01-29T05:00:00+01:00", + "price": -0.125 + }, + { + "start": "2024-01-29T06:00:00+01:00", + "price": 6.706 + }, + { + "start": "2024-01-29T07:00:00+01:00", + "price": 21.753 + }, + { + "start": "2024-01-29T08:00:00+01:00", + "price": 23.33 + }, + { + "start": "2024-01-29T09:00:00+01:00", + "price": 24.681 + }, + { + "start": "2024-01-29T10:00:00+01:00", + "price": 24.215 + }, + { + "start": "2024-01-29T11:00:00+01:00", + "price": 23.92 + }, + { + "start": "2024-01-29T12:00:00+01:00", + "price": 22.729 + }, + { + "start": "2024-01-29T13:00:00+01:00", + "price": 22.536 + }, + { + "start": "2024-01-29T14:00:00+01:00", + "price": 22.15 + }, + { + "start": "2024-01-29T15:00:00+01:00", + "price": 22.15 + }, + { + "start": "2024-01-29T16:00:00+01:00", + "price": 22.264 + }, + { + "start": "2024-01-29T17:00:00+01:00", + "price": 22.547 + }, + { + "start": "2024-01-29T18:00:00+01:00", + "price": 22.23 + }, + { + "start": "2024-01-29T19:00:00+01:00", + "price": 22.32 + }, + { + "start": "2024-01-29T20:00:00+01:00", + "price": 22.116 + }, + { + "start": "2024-01-29T21:00:00+01:00", + "price": 21.753 + }, + { + "start": "2024-01-29T22:00:00+01:00", + "price": 21.594 + }, + { + "start": "2024-01-29T23:00:00+01:00", + "price": 21.481 + } + ], + "2024-01-30": [ + { + "start": "2024-01-30T00:00:00+01:00", + "price": 19.126 + }, + { + "start": "2024-01-30T01:00:00+01:00", + "price": 19.535 + }, + { + "start": "2024-01-30T02:00:00+01:00", + "price": 20.011 + }, + { + "start": "2024-01-30T03:00:00+01:00", + "price": 20.658 + }, + { + "start": "2024-01-30T04:00:00+01:00", + "price": 21.327 + }, + { + "start": "2024-01-30T05:00:00+01:00", + "price": 22.938 + }, + { + "start": "2024-01-30T06:00:00+01:00", + "price": 25.037 + }, + { + "start": "2024-01-30T07:00:00+01:00", + "price": 26.568 + }, + { + "start": "2024-01-30T08:00:00+01:00", + "price": 27.34 + }, + { + "start": "2024-01-30T09:00:00+01:00", + "price": 27.476 + }, + { + "start": "2024-01-30T10:00:00+01:00", + "price": 27.351 + }, + { + "start": "2024-01-30T11:00:00+01:00", + "price": 27.635 + }, + { + "start": "2024-01-30T12:00:00+01:00", + "price": 27.646 + }, + { + "start": "2024-01-30T13:00:00+01:00", + "price": 27.737 + }, + { + "start": "2024-01-30T14:00:00+01:00", + "price": 27.476 + }, + { + "start": "2024-01-30T15:00:00+01:00", + "price": 27.555 + }, + { + "start": "2024-01-30T16:00:00+01:00", + "price": 27.6 + }, + { + "start": "2024-01-30T17:00:00+01:00", + "price": 27.203 + }, + { + "start": "2024-01-30T18:00:00+01:00", + "price": 26.874 + }, + { + "start": "2024-01-30T19:00:00+01:00", + "price": 26.205 + }, + { + "start": "2024-01-30T20:00:00+01:00", + "price": 25.49 + }, + { + "start": "2024-01-30T21:00:00+01:00", + "price": 25.978 + }, + { + "start": "2024-01-30T22:00:00+01:00", + "price": 25.593 + }, + { + "start": "2024-01-30T23:00:00+01:00", + "price": 23.528 + } + ], + "2024-01-31": [ + { + "start": "2024-01-31T00:00:00+01:00", + "price": 20.756 + }, + { + "start": "2024-01-31T01:00:00+01:00", + "price": 20.835 + }, + { + "start": "2024-01-31T02:00:00+01:00", + "price": 20.88 + }, + { + "start": "2024-01-31T03:00:00+01:00", + "price": 20.971 + }, + { + "start": "2024-01-31T04:00:00+01:00", + "price": 21.399 + }, + { + "start": "2024-01-31T05:00:00+01:00", + "price": 21.885 + }, + { + "start": "2024-01-31T06:00:00+01:00", + "price": 24.695 + }, + { + "start": "2024-01-31T07:00:00+01:00", + "price": 25.654 + }, + { + "start": "2024-01-31T08:00:00+01:00", + "price": 26.219 + }, + { + "start": "2024-01-31T09:00:00+01:00", + "price": 25.079 + }, + { + "start": "2024-01-31T10:00:00+01:00", + "price": 23.036 + }, + { + "start": "2024-01-31T11:00:00+01:00", + "price": 21.58 + }, + { + "start": "2024-01-31T12:00:00+01:00", + "price": 20.925 + }, + { + "start": "2024-01-31T13:00:00+01:00", + "price": 20.056 + }, + { + "start": "2024-01-31T14:00:00+01:00", + "price": 19.187 + }, + { + "start": "2024-01-31T15:00:00+01:00", + "price": 18.408 + }, + { + "start": "2024-01-31T16:00:00+01:00", + "price": 19.131 + }, + { + "start": "2024-01-31T17:00:00+01:00", + "price": 18.815 + }, + { + "start": "2024-01-31T18:00:00+01:00", + "price": 11.716 + }, + { + "start": "2024-01-31T19:00:00+01:00", + "price": 3.916 + }, + { + "start": "2024-01-31T20:00:00+01:00", + "price": 0.0 + }, + { + "start": "2024-01-31T21:00:00+01:00", + "price": -0.079 + }, + { + "start": "2024-01-31T22:00:00+01:00", + "price": -1.005 + }, + { + "start": "2024-01-31T23:00:00+01:00", + "price": -2.28 + } + ] + } +} diff --git a/scripts/simulation/data/weather_kiruna_jan2024.json b/scripts/simulation/data/weather_kiruna_jan2024.json new file mode 100644 index 00000000..394ab686 --- /dev/null +++ b/scripts/simulation/data/weather_kiruna_jan2024.json @@ -0,0 +1 @@ +{"latitude": 67.80316, "longitude": 20.25, "timezone": "Europe/Stockholm", "source": "Open-Meteo ERA5 archive, Kiruna 67.8558N 20.2253E, fetched 2026-07-16", "hourly": {"time": ["2024-01-01T00:00", "2024-01-01T01:00", "2024-01-01T02:00", "2024-01-01T03:00", "2024-01-01T04:00", "2024-01-01T05:00", "2024-01-01T06:00", "2024-01-01T07:00", "2024-01-01T08:00", "2024-01-01T09:00", "2024-01-01T10:00", "2024-01-01T11:00", "2024-01-01T12:00", "2024-01-01T13:00", "2024-01-01T14:00", "2024-01-01T15:00", "2024-01-01T16:00", "2024-01-01T17:00", "2024-01-01T18:00", "2024-01-01T19:00", "2024-01-01T20:00", "2024-01-01T21:00", "2024-01-01T22:00", "2024-01-01T23:00", "2024-01-02T00:00", "2024-01-02T01:00", "2024-01-02T02:00", "2024-01-02T03:00", "2024-01-02T04:00", "2024-01-02T05:00", "2024-01-02T06:00", "2024-01-02T07:00", "2024-01-02T08:00", "2024-01-02T09:00", "2024-01-02T10:00", "2024-01-02T11:00", "2024-01-02T12:00", "2024-01-02T13:00", "2024-01-02T14:00", "2024-01-02T15:00", "2024-01-02T16:00", "2024-01-02T17:00", "2024-01-02T18:00", "2024-01-02T19:00", "2024-01-02T20:00", "2024-01-02T21:00", "2024-01-02T22:00", "2024-01-02T23:00", "2024-01-03T00:00", "2024-01-03T01:00", "2024-01-03T02:00", "2024-01-03T03:00", "2024-01-03T04:00", "2024-01-03T05:00", "2024-01-03T06:00", "2024-01-03T07:00", "2024-01-03T08:00", "2024-01-03T09:00", "2024-01-03T10:00", "2024-01-03T11:00", "2024-01-03T12:00", "2024-01-03T13:00", "2024-01-03T14:00", "2024-01-03T15:00", "2024-01-03T16:00", "2024-01-03T17:00", "2024-01-03T18:00", "2024-01-03T19:00", "2024-01-03T20:00", "2024-01-03T21:00", "2024-01-03T22:00", "2024-01-03T23:00", "2024-01-04T00:00", "2024-01-04T01:00", "2024-01-04T02:00", "2024-01-04T03:00", "2024-01-04T04:00", "2024-01-04T05:00", "2024-01-04T06:00", "2024-01-04T07:00", "2024-01-04T08:00", "2024-01-04T09:00", "2024-01-04T10:00", "2024-01-04T11:00", "2024-01-04T12:00", "2024-01-04T13:00", "2024-01-04T14:00", "2024-01-04T15:00", "2024-01-04T16:00", "2024-01-04T17:00", "2024-01-04T18:00", "2024-01-04T19:00", "2024-01-04T20:00", "2024-01-04T21:00", "2024-01-04T22:00", "2024-01-04T23:00", "2024-01-05T00:00", "2024-01-05T01:00", "2024-01-05T02:00", "2024-01-05T03:00", "2024-01-05T04:00", "2024-01-05T05:00", "2024-01-05T06:00", "2024-01-05T07:00", "2024-01-05T08:00", "2024-01-05T09:00", "2024-01-05T10:00", "2024-01-05T11:00", "2024-01-05T12:00", "2024-01-05T13:00", "2024-01-05T14:00", "2024-01-05T15:00", "2024-01-05T16:00", "2024-01-05T17:00", "2024-01-05T18:00", "2024-01-05T19:00", "2024-01-05T20:00", "2024-01-05T21:00", "2024-01-05T22:00", "2024-01-05T23:00", "2024-01-06T00:00", "2024-01-06T01:00", "2024-01-06T02:00", "2024-01-06T03:00", "2024-01-06T04:00", "2024-01-06T05:00", "2024-01-06T06:00", "2024-01-06T07:00", "2024-01-06T08:00", "2024-01-06T09:00", "2024-01-06T10:00", "2024-01-06T11:00", "2024-01-06T12:00", "2024-01-06T13:00", "2024-01-06T14:00", "2024-01-06T15:00", "2024-01-06T16:00", "2024-01-06T17:00", "2024-01-06T18:00", "2024-01-06T19:00", "2024-01-06T20:00", "2024-01-06T21:00", "2024-01-06T22:00", "2024-01-06T23:00", "2024-01-07T00:00", "2024-01-07T01:00", "2024-01-07T02:00", "2024-01-07T03:00", "2024-01-07T04:00", "2024-01-07T05:00", "2024-01-07T06:00", "2024-01-07T07:00", "2024-01-07T08:00", "2024-01-07T09:00", "2024-01-07T10:00", "2024-01-07T11:00", "2024-01-07T12:00", "2024-01-07T13:00", "2024-01-07T14:00", "2024-01-07T15:00", "2024-01-07T16:00", "2024-01-07T17:00", "2024-01-07T18:00", "2024-01-07T19:00", "2024-01-07T20:00", "2024-01-07T21:00", "2024-01-07T22:00", "2024-01-07T23:00", "2024-01-08T00:00", "2024-01-08T01:00", "2024-01-08T02:00", "2024-01-08T03:00", "2024-01-08T04:00", "2024-01-08T05:00", "2024-01-08T06:00", "2024-01-08T07:00", "2024-01-08T08:00", "2024-01-08T09:00", "2024-01-08T10:00", "2024-01-08T11:00", "2024-01-08T12:00", "2024-01-08T13:00", "2024-01-08T14:00", "2024-01-08T15:00", "2024-01-08T16:00", "2024-01-08T17:00", "2024-01-08T18:00", "2024-01-08T19:00", "2024-01-08T20:00", "2024-01-08T21:00", "2024-01-08T22:00", "2024-01-08T23:00", "2024-01-09T00:00", "2024-01-09T01:00", "2024-01-09T02:00", "2024-01-09T03:00", "2024-01-09T04:00", "2024-01-09T05:00", "2024-01-09T06:00", "2024-01-09T07:00", "2024-01-09T08:00", "2024-01-09T09:00", "2024-01-09T10:00", "2024-01-09T11:00", "2024-01-09T12:00", "2024-01-09T13:00", "2024-01-09T14:00", "2024-01-09T15:00", "2024-01-09T16:00", "2024-01-09T17:00", "2024-01-09T18:00", "2024-01-09T19:00", "2024-01-09T20:00", "2024-01-09T21:00", "2024-01-09T22:00", "2024-01-09T23:00", "2024-01-10T00:00", "2024-01-10T01:00", "2024-01-10T02:00", "2024-01-10T03:00", "2024-01-10T04:00", "2024-01-10T05:00", "2024-01-10T06:00", "2024-01-10T07:00", "2024-01-10T08:00", "2024-01-10T09:00", "2024-01-10T10:00", "2024-01-10T11:00", "2024-01-10T12:00", "2024-01-10T13:00", "2024-01-10T14:00", "2024-01-10T15:00", "2024-01-10T16:00", "2024-01-10T17:00", "2024-01-10T18:00", "2024-01-10T19:00", "2024-01-10T20:00", "2024-01-10T21:00", "2024-01-10T22:00", "2024-01-10T23:00", "2024-01-11T00:00", "2024-01-11T01:00", "2024-01-11T02:00", "2024-01-11T03:00", "2024-01-11T04:00", "2024-01-11T05:00", "2024-01-11T06:00", "2024-01-11T07:00", "2024-01-11T08:00", "2024-01-11T09:00", "2024-01-11T10:00", "2024-01-11T11:00", "2024-01-11T12:00", "2024-01-11T13:00", "2024-01-11T14:00", "2024-01-11T15:00", "2024-01-11T16:00", "2024-01-11T17:00", "2024-01-11T18:00", "2024-01-11T19:00", "2024-01-11T20:00", "2024-01-11T21:00", "2024-01-11T22:00", "2024-01-11T23:00", "2024-01-12T00:00", "2024-01-12T01:00", "2024-01-12T02:00", "2024-01-12T03:00", "2024-01-12T04:00", "2024-01-12T05:00", "2024-01-12T06:00", "2024-01-12T07:00", "2024-01-12T08:00", "2024-01-12T09:00", "2024-01-12T10:00", "2024-01-12T11:00", "2024-01-12T12:00", "2024-01-12T13:00", "2024-01-12T14:00", "2024-01-12T15:00", "2024-01-12T16:00", "2024-01-12T17:00", "2024-01-12T18:00", "2024-01-12T19:00", "2024-01-12T20:00", "2024-01-12T21:00", "2024-01-12T22:00", "2024-01-12T23:00", "2024-01-13T00:00", "2024-01-13T01:00", "2024-01-13T02:00", "2024-01-13T03:00", "2024-01-13T04:00", "2024-01-13T05:00", "2024-01-13T06:00", "2024-01-13T07:00", "2024-01-13T08:00", "2024-01-13T09:00", "2024-01-13T10:00", "2024-01-13T11:00", "2024-01-13T12:00", "2024-01-13T13:00", "2024-01-13T14:00", "2024-01-13T15:00", "2024-01-13T16:00", "2024-01-13T17:00", "2024-01-13T18:00", "2024-01-13T19:00", "2024-01-13T20:00", "2024-01-13T21:00", "2024-01-13T22:00", "2024-01-13T23:00", "2024-01-14T00:00", "2024-01-14T01:00", "2024-01-14T02:00", "2024-01-14T03:00", "2024-01-14T04:00", "2024-01-14T05:00", "2024-01-14T06:00", "2024-01-14T07:00", "2024-01-14T08:00", "2024-01-14T09:00", "2024-01-14T10:00", "2024-01-14T11:00", "2024-01-14T12:00", "2024-01-14T13:00", "2024-01-14T14:00", "2024-01-14T15:00", "2024-01-14T16:00", "2024-01-14T17:00", "2024-01-14T18:00", "2024-01-14T19:00", "2024-01-14T20:00", "2024-01-14T21:00", "2024-01-14T22:00", "2024-01-14T23:00", "2024-01-15T00:00", "2024-01-15T01:00", "2024-01-15T02:00", "2024-01-15T03:00", "2024-01-15T04:00", "2024-01-15T05:00", "2024-01-15T06:00", "2024-01-15T07:00", "2024-01-15T08:00", "2024-01-15T09:00", "2024-01-15T10:00", "2024-01-15T11:00", "2024-01-15T12:00", "2024-01-15T13:00", "2024-01-15T14:00", "2024-01-15T15:00", "2024-01-15T16:00", "2024-01-15T17:00", "2024-01-15T18:00", "2024-01-15T19:00", "2024-01-15T20:00", "2024-01-15T21:00", "2024-01-15T22:00", "2024-01-15T23:00", "2024-01-16T00:00", "2024-01-16T01:00", "2024-01-16T02:00", "2024-01-16T03:00", "2024-01-16T04:00", "2024-01-16T05:00", "2024-01-16T06:00", "2024-01-16T07:00", "2024-01-16T08:00", "2024-01-16T09:00", "2024-01-16T10:00", "2024-01-16T11:00", "2024-01-16T12:00", "2024-01-16T13:00", "2024-01-16T14:00", "2024-01-16T15:00", "2024-01-16T16:00", "2024-01-16T17:00", "2024-01-16T18:00", "2024-01-16T19:00", "2024-01-16T20:00", "2024-01-16T21:00", "2024-01-16T22:00", "2024-01-16T23:00", "2024-01-17T00:00", "2024-01-17T01:00", "2024-01-17T02:00", "2024-01-17T03:00", "2024-01-17T04:00", "2024-01-17T05:00", "2024-01-17T06:00", "2024-01-17T07:00", "2024-01-17T08:00", "2024-01-17T09:00", "2024-01-17T10:00", "2024-01-17T11:00", "2024-01-17T12:00", "2024-01-17T13:00", "2024-01-17T14:00", "2024-01-17T15:00", "2024-01-17T16:00", "2024-01-17T17:00", "2024-01-17T18:00", "2024-01-17T19:00", "2024-01-17T20:00", "2024-01-17T21:00", "2024-01-17T22:00", "2024-01-17T23:00", "2024-01-18T00:00", "2024-01-18T01:00", "2024-01-18T02:00", "2024-01-18T03:00", "2024-01-18T04:00", "2024-01-18T05:00", "2024-01-18T06:00", "2024-01-18T07:00", "2024-01-18T08:00", "2024-01-18T09:00", "2024-01-18T10:00", "2024-01-18T11:00", "2024-01-18T12:00", "2024-01-18T13:00", "2024-01-18T14:00", "2024-01-18T15:00", "2024-01-18T16:00", "2024-01-18T17:00", "2024-01-18T18:00", "2024-01-18T19:00", "2024-01-18T20:00", "2024-01-18T21:00", "2024-01-18T22:00", "2024-01-18T23:00", "2024-01-19T00:00", "2024-01-19T01:00", "2024-01-19T02:00", "2024-01-19T03:00", "2024-01-19T04:00", "2024-01-19T05:00", "2024-01-19T06:00", "2024-01-19T07:00", "2024-01-19T08:00", "2024-01-19T09:00", "2024-01-19T10:00", "2024-01-19T11:00", "2024-01-19T12:00", "2024-01-19T13:00", "2024-01-19T14:00", "2024-01-19T15:00", "2024-01-19T16:00", "2024-01-19T17:00", "2024-01-19T18:00", "2024-01-19T19:00", "2024-01-19T20:00", "2024-01-19T21:00", "2024-01-19T22:00", "2024-01-19T23:00", "2024-01-20T00:00", "2024-01-20T01:00", "2024-01-20T02:00", "2024-01-20T03:00", "2024-01-20T04:00", "2024-01-20T05:00", "2024-01-20T06:00", "2024-01-20T07:00", "2024-01-20T08:00", "2024-01-20T09:00", "2024-01-20T10:00", "2024-01-20T11:00", "2024-01-20T12:00", "2024-01-20T13:00", "2024-01-20T14:00", "2024-01-20T15:00", "2024-01-20T16:00", "2024-01-20T17:00", "2024-01-20T18:00", "2024-01-20T19:00", "2024-01-20T20:00", "2024-01-20T21:00", "2024-01-20T22:00", "2024-01-20T23:00", "2024-01-21T00:00", "2024-01-21T01:00", "2024-01-21T02:00", "2024-01-21T03:00", "2024-01-21T04:00", "2024-01-21T05:00", "2024-01-21T06:00", "2024-01-21T07:00", "2024-01-21T08:00", "2024-01-21T09:00", "2024-01-21T10:00", "2024-01-21T11:00", "2024-01-21T12:00", "2024-01-21T13:00", "2024-01-21T14:00", "2024-01-21T15:00", "2024-01-21T16:00", "2024-01-21T17:00", "2024-01-21T18:00", "2024-01-21T19:00", "2024-01-21T20:00", "2024-01-21T21:00", "2024-01-21T22:00", "2024-01-21T23:00", "2024-01-22T00:00", "2024-01-22T01:00", "2024-01-22T02:00", "2024-01-22T03:00", "2024-01-22T04:00", "2024-01-22T05:00", "2024-01-22T06:00", "2024-01-22T07:00", "2024-01-22T08:00", "2024-01-22T09:00", "2024-01-22T10:00", "2024-01-22T11:00", "2024-01-22T12:00", "2024-01-22T13:00", "2024-01-22T14:00", "2024-01-22T15:00", "2024-01-22T16:00", "2024-01-22T17:00", "2024-01-22T18:00", "2024-01-22T19:00", "2024-01-22T20:00", "2024-01-22T21:00", "2024-01-22T22:00", "2024-01-22T23:00", "2024-01-23T00:00", "2024-01-23T01:00", "2024-01-23T02:00", "2024-01-23T03:00", "2024-01-23T04:00", "2024-01-23T05:00", "2024-01-23T06:00", "2024-01-23T07:00", "2024-01-23T08:00", "2024-01-23T09:00", "2024-01-23T10:00", "2024-01-23T11:00", "2024-01-23T12:00", "2024-01-23T13:00", "2024-01-23T14:00", "2024-01-23T15:00", "2024-01-23T16:00", "2024-01-23T17:00", "2024-01-23T18:00", "2024-01-23T19:00", "2024-01-23T20:00", "2024-01-23T21:00", "2024-01-23T22:00", "2024-01-23T23:00", "2024-01-24T00:00", "2024-01-24T01:00", "2024-01-24T02:00", "2024-01-24T03:00", "2024-01-24T04:00", "2024-01-24T05:00", "2024-01-24T06:00", "2024-01-24T07:00", "2024-01-24T08:00", "2024-01-24T09:00", "2024-01-24T10:00", "2024-01-24T11:00", "2024-01-24T12:00", "2024-01-24T13:00", "2024-01-24T14:00", "2024-01-24T15:00", "2024-01-24T16:00", "2024-01-24T17:00", "2024-01-24T18:00", "2024-01-24T19:00", "2024-01-24T20:00", "2024-01-24T21:00", "2024-01-24T22:00", "2024-01-24T23:00", "2024-01-25T00:00", "2024-01-25T01:00", "2024-01-25T02:00", "2024-01-25T03:00", "2024-01-25T04:00", "2024-01-25T05:00", "2024-01-25T06:00", "2024-01-25T07:00", "2024-01-25T08:00", "2024-01-25T09:00", "2024-01-25T10:00", "2024-01-25T11:00", "2024-01-25T12:00", "2024-01-25T13:00", "2024-01-25T14:00", "2024-01-25T15:00", "2024-01-25T16:00", "2024-01-25T17:00", "2024-01-25T18:00", "2024-01-25T19:00", "2024-01-25T20:00", "2024-01-25T21:00", "2024-01-25T22:00", "2024-01-25T23:00", "2024-01-26T00:00", "2024-01-26T01:00", "2024-01-26T02:00", "2024-01-26T03:00", "2024-01-26T04:00", "2024-01-26T05:00", "2024-01-26T06:00", "2024-01-26T07:00", "2024-01-26T08:00", "2024-01-26T09:00", "2024-01-26T10:00", "2024-01-26T11:00", "2024-01-26T12:00", "2024-01-26T13:00", "2024-01-26T14:00", "2024-01-26T15:00", "2024-01-26T16:00", "2024-01-26T17:00", "2024-01-26T18:00", "2024-01-26T19:00", "2024-01-26T20:00", "2024-01-26T21:00", "2024-01-26T22:00", "2024-01-26T23:00", "2024-01-27T00:00", "2024-01-27T01:00", "2024-01-27T02:00", "2024-01-27T03:00", "2024-01-27T04:00", "2024-01-27T05:00", "2024-01-27T06:00", "2024-01-27T07:00", "2024-01-27T08:00", "2024-01-27T09:00", "2024-01-27T10:00", "2024-01-27T11:00", "2024-01-27T12:00", "2024-01-27T13:00", "2024-01-27T14:00", "2024-01-27T15:00", "2024-01-27T16:00", "2024-01-27T17:00", "2024-01-27T18:00", "2024-01-27T19:00", "2024-01-27T20:00", "2024-01-27T21:00", "2024-01-27T22:00", "2024-01-27T23:00", "2024-01-28T00:00", "2024-01-28T01:00", "2024-01-28T02:00", "2024-01-28T03:00", "2024-01-28T04:00", "2024-01-28T05:00", "2024-01-28T06:00", "2024-01-28T07:00", "2024-01-28T08:00", "2024-01-28T09:00", "2024-01-28T10:00", "2024-01-28T11:00", "2024-01-28T12:00", "2024-01-28T13:00", "2024-01-28T14:00", "2024-01-28T15:00", "2024-01-28T16:00", "2024-01-28T17:00", "2024-01-28T18:00", "2024-01-28T19:00", "2024-01-28T20:00", "2024-01-28T21:00", "2024-01-28T22:00", "2024-01-28T23:00", "2024-01-29T00:00", "2024-01-29T01:00", "2024-01-29T02:00", "2024-01-29T03:00", "2024-01-29T04:00", "2024-01-29T05:00", "2024-01-29T06:00", "2024-01-29T07:00", "2024-01-29T08:00", "2024-01-29T09:00", "2024-01-29T10:00", "2024-01-29T11:00", "2024-01-29T12:00", "2024-01-29T13:00", "2024-01-29T14:00", "2024-01-29T15:00", "2024-01-29T16:00", "2024-01-29T17:00", "2024-01-29T18:00", "2024-01-29T19:00", "2024-01-29T20:00", "2024-01-29T21:00", "2024-01-29T22:00", "2024-01-29T23:00", "2024-01-30T00:00", "2024-01-30T01:00", "2024-01-30T02:00", "2024-01-30T03:00", "2024-01-30T04:00", "2024-01-30T05:00", "2024-01-30T06:00", "2024-01-30T07:00", "2024-01-30T08:00", "2024-01-30T09:00", "2024-01-30T10:00", "2024-01-30T11:00", "2024-01-30T12:00", "2024-01-30T13:00", "2024-01-30T14:00", "2024-01-30T15:00", "2024-01-30T16:00", "2024-01-30T17:00", "2024-01-30T18:00", "2024-01-30T19:00", "2024-01-30T20:00", "2024-01-30T21:00", "2024-01-30T22:00", "2024-01-30T23:00", "2024-01-31T00:00", "2024-01-31T01:00", "2024-01-31T02:00", "2024-01-31T03:00", "2024-01-31T04:00", "2024-01-31T05:00", "2024-01-31T06:00", "2024-01-31T07:00", "2024-01-31T08:00", "2024-01-31T09:00", "2024-01-31T10:00", "2024-01-31T11:00", "2024-01-31T12:00", "2024-01-31T13:00", "2024-01-31T14:00", "2024-01-31T15:00", "2024-01-31T16:00", "2024-01-31T17:00", "2024-01-31T18:00", "2024-01-31T19:00", "2024-01-31T20:00", "2024-01-31T21:00", "2024-01-31T22:00", "2024-01-31T23:00"], "temperature_2m": [-16.6, -16.1, -16.2, -15.4, -15.3, -15.5, -16.2, -16.0, -16.6, -16.5, -16.7, -17.5, -17.6, -17.7, -17.7, -18.5, -19.5, -20.3, -20.9, -21.4, -22.6, -22.9, -22.8, -23.6, -25.3, -27.7, -29.1, -29.5, -30.4, -30.5, -30.9, -31.6, -31.1, -32.8, -33.0, -33.3, -33.2, -33.3, -33.2, -30.1, -31.3, -32.0, -33.1, -32.5, -33.5, -32.9, -33.2, -34.0, -33.6, -33.2, -34.2, -36.6, -36.6, -36.5, -36.1, -35.0, -35.8, -35.5, -35.2, -34.8, -34.5, -34.4, -35.0, -35.3, -35.1, -34.8, -34.6, -35.8, -36.8, -36.7, -36.5, -36.6, -36.2, -36.2, -36.1, -35.2, -35.7, -35.9, -35.7, -35.7, -35.8, -35.6, -35.1, -34.4, -34.3, -34.0, -33.1, -35.0, -34.1, -34.4, -35.7, -36.1, -35.5, -35.5, -36.4, -35.6, -36.1, -35.6, -35.9, -34.1, -31.6, -31.8, -30.8, -29.7, -28.6, -27.5, -27.3, -27.8, -27.8, -28.8, -28.7, -25.4, -25.6, -25.4, -23.4, -23.6, -24.4, -25.7, -25.1, -23.8, -22.6, -23.7, -24.1, -21.5, -21.9, -22.2, -21.9, -20.7, -19.9, -20.2, -20.2, -19.4, -19.6, -17.4, -16.4, -16.2, -16.6, -17.2, -16.7, -15.9, -14.5, -13.3, -12.1, -11.0, -9.9, -10.1, -10.2, -9.0, -7.9, -7.1, -6.5, -6.1, -6.1, -6.3, -5.9, -5.0, -4.5, -4.6, -4.6, -4.7, -2.7, -0.9, -1.3, -2.4, -3.2, -3.6, -3.1, -3.1, -3.8, -3.4, -3.8, -3.9, -4.7, -5.3, -6.1, -6.3, -5.4, -4.3, -3.7, -3.2, -2.9, -2.4, -2.2, -1.7, -0.5, -0.3, -0.1, 0.4, -0.1, 0.7, 0.5, 0.4, 0.8, 2.0, 2.5, 2.9, 2.8, 3.1, 3.2, 3.5, 3.5, 3.2, 2.4, 2.2, 1.8, 1.3, 0.6, 1.4, 0.6, 0.3, 0.5, 0.5, -0.5, -3.1, -5.0, -3.1, -3.8, -2.9, -3.0, -1.9, -2.2, -0.2, 1.0, 1.9, 1.9, 2.1, 2.2, 2.4, 2.2, 1.1, 0.2, -0.1, -1.2, -2.2, -2.7, -3.2, -3.7, -4.0, -3.8, -3.8, -4.5, -5.0, -5.5, -6.5, -7.0, -7.2, -7.9, -8.4, -8.5, -8.7, -9.7, -10.3, -8.8, -9.4, -9.7, -8.9, -8.4, -8.0, -6.9, -6.1, -6.4, -6.9, -7.0, -6.9, -6.9, -7.1, -7.1, -7.5, -7.6, -7.6, -8.2, -8.4, -8.5, -8.9, -9.3, -9.9, -10.2, -10.2, -10.0, -10.8, -12.0, -12.2, -11.7, -11.0, -10.8, -10.2, -9.8, -9.7, -9.7, -10.0, -10.2, -11.2, -11.3, -11.7, -11.9, -11.8, -11.7, -11.7, -12.6, -14.5, -15.7, -16.7, -16.3, -12.7, -12.6, -12.8, -13.3, -14.7, -16.6, -18.8, -18.5, -18.6, -18.3, -19.1, -18.9, -16.5, -16.5, -16.9, -16.3, -16.3, -16.1, -15.8, -16.2, -16.9, -18.0, -17.1, -17.2, -18.6, -16.0, -15.2, -14.9, -18.6, -21.1, -22.5, -22.7, -23.3, -25.0, -23.9, -25.3, -18.4, -17.9, -15.5, -15.1, -14.6, -15.1, -20.1, -18.1, -19.1, -18.1, -18.3, -19.6, -18.4, -17.8, -17.7, -17.7, -17.9, -18.1, -19.0, -19.5, -19.3, -19.5, -19.5, -19.8, -18.6, -18.9, -19.1, -19.1, -20.1, -20.8, -21.0, -21.0, -21.0, -20.8, -20.2, -19.9, -19.5, -19.4, -19.4, -19.4, -19.5, -19.4, -19.3, -19.3, -19.2, -19.2, -19.3, -19.3, -18.9, -18.9, -18.9, -19.0, -18.9, -19.1, -20.3, -19.7, -20.1, -20.4, -20.9, -21.4, -25.2, -24.6, -24.8, -25.4, -25.7, -26.5, -25.7, -24.8, -23.2, -22.9, -22.8, -23.0, -21.7, -21.2, -21.1, -21.6, -21.2, -20.3, -19.7, -18.9, -18.3, -17.8, -17.4, -17.1, -17.9, -17.9, -18.4, -18.7, -18.4, -17.9, -17.5, -17.1, -16.8, -16.4, -16.2, -15.9, -14.9, -14.9, -15.0, -15.2, -15.4, -15.5, -15.7, -17.1, -19.1, -22.9, -24.1, -25.5, -24.9, -24.2, -26.2, -27.3, -28.0, -28.1, -28.0, -28.7, -29.2, -29.0, -28.3, -28.7, -29.4, -29.3, -29.7, -29.6, -29.8, -30.1, -29.9, -29.2, -28.2, -28.2, -27.5, -27.3, -25.0, -25.4, -24.4, -23.4, -22.3, -24.4, -24.2, -24.3, -22.7, -22.5, -20.6, -20.3, -23.0, -23.3, -23.9, -22.9, -21.4, -21.7, -23.0, -23.3, -23.2, -22.4, -22.2, -22.2, -21.6, -21.4, -21.7, -21.4, -21.5, -21.2, -20.8, -20.3, -19.6, -18.8, -17.5, -15.4, -14.0, -13.6, -13.6, -14.9, -15.0, -14.6, -12.9, -11.6, -10.5, -9.7, -9.1, -6.6, -4.9, -4.4, -3.9, -3.2, -2.8, -2.5, -2.3, -2.2, -2.2, -2.2, -2.2, -2.2, -2.7, -2.7, -2.8, -3.1, -3.0, -3.1, -3.5, -3.4, -3.6, -3.8, -3.6, -3.9, -4.0, -3.1, -2.3, -2.5, -3.6, -3.7, -4.2, -5.4, -9.3, -10.3, -11.8, -13.6, -13.3, -14.0, -14.8, -14.8, -16.7, -17.0, -16.9, -17.4, -17.8, -16.1, -15.3, -15.3, -15.7, -15.4, -14.3, -13.6, -13.0, -12.5, -12.0, -12.3, -11.8, -11.3, -11.1, -10.4, -10.2, -12.4, -12.4, -12.7, -13.1, -13.8, -15.8, -16.9, -18.5, -19.3, -19.6, -20.0, -19.2, -19.8, -20.8, -19.7, -19.2, -18.7, -18.7, -19.5, -18.9, -18.2, -18.1, -18.3, -18.7, -18.3, -17.7, -17.0, -15.8, -15.6, -16.5, -16.9, -17.1, -17.3, -17.6, -17.3, -13.4, -12.9, -12.8, -12.7, -11.9, -10.8, -9.8, -8.6, -7.8, -7.3, -6.7, -6.2, -7.7, -7.8, -7.4, -7.1, -8.0, -9.4, -9.3, -8.2, -6.7, -4.6, -4.2, -4.4, -5.2, -4.4, -2.7, -1.8, -0.8, 0.5, 1.4, 1.0, 0.4, -0.3, -1.2, -1.5, -2.0, -1.6, -2.0, -2.1, -2.0, -2.0, -2.5, -2.6, -2.6, -2.6, -2.7, -2.3, -2.3, -2.2, -2.1, -1.4, -0.9, -0.2, 0.2, 1.0, 1.6, 2.0, 2.2, 2.6, 1.7, 2.0, 1.9, 2.2, 2.6, 3.0, 3.1, 2.1, 1.9, 1.5, 0.7, 0.2, -0.6, -0.5, -0.2, -1.0, -1.1, -1.2, -1.8, -2.1, -1.9, -1.7, -1.9, -2.0, -2.0, -1.7, -1.2, -1.6, -1.2, -1.6, -1.8, -1.1, 1.2, 2.7, 3.1, 2.9, 2.5, 2.2, 2.2, 1.9, 1.6, 0.6, -0.2, -0.9, -1.1, -1.3, -1.2, -1.3, -1.6, -1.8, -2.2, -2.7, -2.8, -2.8, -2.6, -3.3, -3.5, -3.1, -2.7, -1.9, -0.8, 0.5, 1.0, 0.9, 0.6, 0.3, -0.2, -0.6, -2.2]}} \ No newline at end of file diff --git a/scripts/simulation/nibe_modbus_simulator.py b/scripts/simulation/nibe_modbus_simulator.py index 85007bbd..4097bf14 100644 --- a/scripts/simulation/nibe_modbus_simulator.py +++ b/scripts/simulation/nibe_modbus_simulator.py @@ -66,18 +66,18 @@ def setValues(self, address, values): # F-series register ids are shared across F750/F1155 (verified: yozik04/nibe # f750.csv uses the same 40004/40013/43005/47011/48132 ids). REGISTERS_F750 = { - 40004: s16(-32), # BT1 outdoor -3.2 C (same site) - 40008: s16(382), # BT2 supply 38.2 C - 40012: s16(320), # BT3 return 32.0 C - 40013: s16(512), # BT7 HW top 51.2 C - 40014: s16(460), # BT6 HW charging 46.0 C - 40033: s16(218), # BT50 room 21.8 C + 40004: s16(-32), # BT1 outdoor -3.2 C (same site) + 40008: s16(382), # BT2 supply 38.2 C + 40012: s16(320), # BT3 return 32.0 C + 40013: s16(512), # BT7 HW top 51.2 C + 40014: s16(460), # BT6 HW charging 46.0 C + 40033: s16(218), # BT50 room 21.8 C 43005: s16(-850), # DM -85.0 - 43086: s16(30), # Prio - 43136: s16(450), # Compressor 45.0 Hz - 43427: s16(60), # Running - 47011: s16(0), # Heat offset S1 - 48132: s16(0), # Temporary Lux + 43086: s16(30), # Prio + 43136: s16(450), # Compressor 45.0 Hz + 43427: s16(60), # Running + 47011: s16(0), # Heat offset S1 + 48132: s16(0), # Temporary Lux } @@ -88,9 +88,7 @@ def main() -> None: device_f1155 = ModbusDeviceContext(hr=block, ir=block) block_f750 = LoggingSparseBlock({addr + 1: val for addr, val in REGISTERS_F750.items()}) device_f750 = ModbusDeviceContext(hr=block_f750, ir=block_f750) - context = ModbusServerContext( - devices={1: device_f1155, 2: device_f750}, single=False - ) + context = ModbusServerContext(devices={1: device_f1155, 2: device_f750}, single=False) LOG.info("Starting NIBE F1155 (unit 1) + F750 (unit 2) simulator on 127.0.0.1:5020") asyncio.run(StartAsyncTcpServer(context=context, address=("127.0.0.1", 5020))) diff --git a/scripts/simulation/output/summary-concrete_f1155-selftest-baseline.json b/scripts/simulation/output/summary-concrete_f1155-selftest-baseline.json deleted file mode 100644 index 31180a0e..00000000 --- a/scripts/simulation/output/summary-concrete_f1155-selftest-baseline.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "house": "concrete_f1155", - "days": 2, - "stats": { - "indoor_min": 21.969169082114874, - "indoor_max": 22.0174591785138, - "dm_min": -92.00450450450396, - "cost_sek": 20.527250240442537, - "energy_kwh": 34.247699833943415, - "aux_kwh": 0.0, - "writes": 0, - "offset_min": 0, - "offset_max": 0, - "exceptions": 0, - "comfort_minutes_below": 0, - "comfort_minutes_above": 0, - "compressor_starts": 23, - "sign_flips": 0, - "peak_kw_quarter_mean": 1.2, - "tariff_top3_kw": 1.2, - "tariff_cost_sek": 97.0, - "total_cost_sek": 118.0, - "indoor_mean": 22.0, - "violations": 0 - }, - "violations": [] -} \ No newline at end of file diff --git a/scripts/simulation/output/summary-concrete_f1155-selftest.json b/scripts/simulation/output/summary-concrete_f1155-selftest.json deleted file mode 100644 index 2bcf9ac3..00000000 --- a/scripts/simulation/output/summary-concrete_f1155-selftest.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "house": "concrete_f1155", - "days": 2, - "stats": { - "indoor_min": 21.965025981200416, - "indoor_max": 22.194184410636346, - "dm_min": -89.87987987987937, - "cost_sek": 20.89249514899089, - "energy_kwh": 35.178631091344315, - "aux_kwh": 0.0, - "writes": 24, - "offset_min": -1, - "offset_max": 1, - "exceptions": 0, - "comfort_minutes_below": 0, - "comfort_minutes_above": 0, - "compressor_starts": 22, - "sign_flips": 0, - "peak_kw_quarter_mean": 1.28, - "tariff_top3_kw": 1.27, - "tariff_cost_sek": 104.0, - "total_cost_sek": 125.0, - "indoor_mean": 22.07, - "violations": 0 - }, - "violations": [] -} \ No newline at end of file diff --git a/scripts/simulation/output/summary-wooden_f750-selftest-baseline.json b/scripts/simulation/output/summary-wooden_f750-selftest-baseline.json deleted file mode 100644 index bff59802..00000000 --- a/scripts/simulation/output/summary-wooden_f750-selftest-baseline.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "house": "wooden_f750", - "days": 2, - "stats": { - "indoor_min": 21.9106248983374, - "indoor_max": 22.032779490000692, - "dm_min": -153.33333333333331, - "cost_sek": 22.070691830280357, - "energy_kwh": 37.267650365195564, - "aux_kwh": 0.0, - "writes": 0, - "offset_min": 0, - "offset_max": 0, - "exceptions": 0, - "comfort_minutes_below": 0, - "comfort_minutes_above": 0, - "compressor_starts": 23, - "sign_flips": 0, - "peak_kw_quarter_mean": 1.31, - "tariff_top3_kw": 1.31, - "tariff_cost_sek": 107.0, - "total_cost_sek": 129.0, - "indoor_mean": 22.0, - "violations": 0 - }, - "violations": [] -} \ No newline at end of file diff --git a/scripts/simulation/output/summary-wooden_f750-selftest.json b/scripts/simulation/output/summary-wooden_f750-selftest.json deleted file mode 100644 index 5fa5faaf..00000000 --- a/scripts/simulation/output/summary-wooden_f750-selftest.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "house": "wooden_f750", - "days": 2, - "stats": { - "indoor_min": 21.47637556891358, - "indoor_max": 21.963541666666668, - "dm_min": -153.33333333333331, - "cost_sek": 21.553804940141195, - "energy_kwh": 36.557933567642515, - "aux_kwh": 0.0, - "writes": 11, - "offset_min": -2, - "offset_max": 0, - "exceptions": 0, - "comfort_minutes_below": 60, - "comfort_minutes_above": 0, - "compressor_starts": 23, - "sign_flips": 0, - "peak_kw_quarter_mean": 1.28, - "tariff_top3_kw": 1.27, - "tariff_cost_sek": 103.0, - "total_cost_sek": 125.0, - "indoor_mean": 21.65, - "violations": 0 - }, - "violations": [] -} \ No newline at end of file diff --git a/scripts/simulation/output/trace-concrete_f1155-selftest-baseline.json b/scripts/simulation/output/trace-concrete_f1155-selftest-baseline.json deleted file mode 100644 index 13aebc7d..00000000 --- a/scripts/simulation/output/trace-concrete_f1155-selftest-baseline.json +++ /dev/null @@ -1 +0,0 @@ -[{"t": "2026-01-01T00:00:00+01:00", "tout": -5.0, "tin": 22.0, "flow": 32.5, "dm": -36, "offset": 0, "calc": 0.0, "kw": 1.01, "price": 50.0, "comp": 1}, {"t": "2026-01-01T00:30:00+01:00", "tout": -4.9, "tin": 22.01, "flow": 34.6, "dm": -6, "offset": 0, "calc": 0.0, "kw": 1.19, "price": 50.0, "comp": 1}, {"t": "2026-01-01T01:00:00+01:00", "tout": -4.8, "tin": 22.01, "flow": 32.6, "dm": -1, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T01:30:00+01:00", "tout": -4.8, "tin": 21.98, "flow": 32.6, "dm": -67, "offset": 0, "calc": 0.0, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:00:00+01:00", "tout": -4.7, "tin": 22.0, "flow": 34.5, "dm": -37, "offset": 0, "calc": 0.0, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:30:00+01:00", "tout": -4.6, "tin": 22.01, "flow": 34.5, "dm": -7, "offset": 0, "calc": 0.0, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-01T03:00:00+01:00", "tout": -4.5, "tin": 22.01, "flow": 32.5, "dm": -1, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T03:30:00+01:00", "tout": -4.4, "tin": 21.98, "flow": 32.5, "dm": -68, "offset": 0, "calc": 0.0, "kw": 1.01, "price": 50.0, "comp": 1}, {"t": "2026-01-01T04:00:00+01:00", "tout": -4.3, "tin": 22.0, "flow": 34.4, "dm": -38, "offset": 0, "calc": 0.0, "kw": 1.17, "price": 50.0, "comp": 1}, {"t": "2026-01-01T04:30:00+01:00", "tout": -4.2, "tin": 22.01, "flow": 34.4, "dm": -8, "offset": 0, "calc": 0.0, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-01T05:00:00+01:00", "tout": -4.2, "tin": 22.01, "flow": 32.3, "dm": -2, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T05:30:00+01:00", "tout": -4.1, "tin": 21.98, "flow": 32.3, "dm": -68, "offset": 0, "calc": 0.0, "kw": 0.99, "price": 50.0, "comp": 1}, {"t": "2026-01-01T06:00:00+01:00", "tout": -4.0, "tin": 22.0, "flow": 34.2, "dm": -38, "offset": 0, "calc": 0.0, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-01T06:30:00+01:00", "tout": -3.9, "tin": 22.01, "flow": 34.2, "dm": -8, "offset": 0, "calc": 0.0, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-01T07:00:00+01:00", "tout": -3.8, "tin": 22.01, "flow": 32.2, "dm": -3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T07:30:00+01:00", "tout": -3.8, "tin": 21.98, "flow": 32.2, "dm": -69, "offset": 0, "calc": 0.0, "kw": 0.98, "price": 90.0, "comp": 1}, {"t": "2026-01-01T08:00:00+01:00", "tout": -3.7, "tin": 22.0, "flow": 34.1, "dm": -39, "offset": 0, "calc": 0.0, "kw": 1.14, "price": 90.0, "comp": 1}, {"t": "2026-01-01T08:30:00+01:00", "tout": -3.6, "tin": 22.01, "flow": 34.1, "dm": -9, "offset": 0, "calc": 0.0, "kw": 1.13, "price": 90.0, "comp": 1}, {"t": "2026-01-01T09:00:00+01:00", "tout": -3.5, "tin": 22.01, "flow": 32.1, "dm": -4, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T09:30:00+01:00", "tout": -3.4, "tin": 21.98, "flow": 32.1, "dm": -70, "offset": 0, "calc": 0.0, "kw": 0.96, "price": 90.0, "comp": 1}, {"t": "2026-01-01T10:00:00+01:00", "tout": -3.3, "tin": 21.99, "flow": 34.0, "dm": -40, "offset": 0, "calc": 0.0, "kw": 1.12, "price": 90.0, "comp": 1}, {"t": "2026-01-01T10:30:00+01:00", "tout": -3.2, "tin": 22.01, "flow": 33.9, "dm": -10, "offset": 0, "calc": 0.0, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-01T11:00:00+01:00", "tout": -3.2, "tin": 22.02, "flow": 32.4, "dm": 5, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T11:30:00+01:00", "tout": -3.1, "tin": 21.98, "flow": 29.4, "dm": -61, "offset": 0, "calc": 0.0, "kw": 0.73, "price": 50.0, "comp": 1}, {"t": "2026-01-01T12:00:00+01:00", "tout": -3.0, "tin": 21.99, "flow": 33.8, "dm": -41, "offset": 0, "calc": 0.0, "kw": 1.11, "price": 50.0, "comp": 1}, {"t": "2026-01-01T12:30:00+01:00", "tout": -2.9, "tin": 22.01, "flow": 33.8, "dm": -11, "offset": 0, "calc": 0.0, "kw": 1.1, "price": 50.0, "comp": 1}, {"t": "2026-01-01T13:00:00+01:00", "tout": -2.8, "tin": 22.02, "flow": 32.3, "dm": 4, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T13:30:00+01:00", "tout": -2.8, "tin": 21.98, "flow": 29.3, "dm": -62, "offset": 0, "calc": 0.0, "kw": 0.72, "price": 50.0, "comp": 1}, {"t": "2026-01-01T14:00:00+01:00", "tout": -2.7, "tin": 21.99, "flow": 33.7, "dm": -42, "offset": 0, "calc": 0.0, "kw": 1.09, "price": 50.0, "comp": 1}, {"t": "2026-01-01T14:30:00+01:00", "tout": -2.6, "tin": 22.01, "flow": 33.6, "dm": -12, "offset": 0, "calc": 0.0, "kw": 1.09, "price": 50.0, "comp": 1}, {"t": "2026-01-01T15:00:00+01:00", "tout": -2.5, "tin": 22.02, "flow": 32.1, "dm": 3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T15:30:00+01:00", "tout": -2.4, "tin": 21.98, "flow": 29.1, "dm": -63, "offset": 0, "calc": 0.0, "kw": 0.7, "price": 50.0, "comp": 1}, {"t": "2026-01-01T16:00:00+01:00", "tout": -2.3, "tin": 21.99, "flow": 33.5, "dm": -43, "offset": 0, "calc": 0.0, "kw": 1.08, "price": 50.0, "comp": 1}, {"t": "2026-01-01T16:30:00+01:00", "tout": -2.2, "tin": 22.01, "flow": 33.5, "dm": -13, "offset": 0, "calc": 0.0, "kw": 1.07, "price": 50.0, "comp": 1}, {"t": "2026-01-01T17:00:00+01:00", "tout": -2.2, "tin": 22.01, "flow": 32.0, "dm": 3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T17:30:00+01:00", "tout": -2.1, "tin": 21.98, "flow": 29.0, "dm": -64, "offset": 0, "calc": 0.0, "kw": 0.69, "price": 90.0, "comp": 1}, {"t": "2026-01-01T18:00:00+01:00", "tout": -2.0, "tin": 21.99, "flow": 33.4, "dm": -44, "offset": 0, "calc": 0.0, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-01T18:30:00+01:00", "tout": -1.9, "tin": 22.01, "flow": 33.3, "dm": -14, "offset": 0, "calc": 0.0, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-01T19:00:00+01:00", "tout": -1.8, "tin": 22.01, "flow": 31.8, "dm": 2, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T19:30:00+01:00", "tout": -1.8, "tin": 21.98, "flow": 28.8, "dm": -65, "offset": 0, "calc": 0.0, "kw": 0.68, "price": 90.0, "comp": 1}, {"t": "2026-01-01T20:00:00+01:00", "tout": -1.7, "tin": 21.99, "flow": 33.2, "dm": -44, "offset": 0, "calc": 0.0, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-01T20:30:00+01:00", "tout": -1.6, "tin": 22.01, "flow": 33.2, "dm": -14, "offset": 0, "calc": 0.0, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-01T21:00:00+01:00", "tout": -1.5, "tin": 22.01, "flow": 31.7, "dm": 1, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T21:30:00+01:00", "tout": -1.4, "tin": 21.98, "flow": 28.7, "dm": -66, "offset": 0, "calc": 0.0, "kw": 0.66, "price": 50.0, "comp": 1}, {"t": "2026-01-01T22:00:00+01:00", "tout": -1.3, "tin": 21.99, "flow": 33.1, "dm": -45, "offset": 0, "calc": 0.0, "kw": 1.03, "price": 50.0, "comp": 1}, {"t": "2026-01-01T22:30:00+01:00", "tout": -1.2, "tin": 22.01, "flow": 33.1, "dm": -15, "offset": 0, "calc": 0.0, "kw": 1.03, "price": 50.0, "comp": 1}, {"t": "2026-01-01T23:00:00+01:00", "tout": -1.2, "tin": 22.02, "flow": 32.0, "dm": 7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T23:30:00+01:00", "tout": -3.1, "tin": 21.98, "flow": 29.0, "dm": -59, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T00:00:00+01:00", "tout": -5.0, "tin": 21.98, "flow": 34.7, "dm": -76, "offset": 0, "calc": 0.0, "kw": 1.2, "price": 50.0, "comp": 1}, {"t": "2026-01-02T00:30:00+01:00", "tout": -4.9, "tin": 21.99, "flow": 34.6, "dm": -46, "offset": 0, "calc": 0.0, "kw": 1.2, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:00:00+01:00", "tout": -4.8, "tin": 22.01, "flow": 34.6, "dm": -16, "offset": 0, "calc": 0.0, "kw": 1.19, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:30:00+01:00", "tout": -4.8, "tin": 22.02, "flow": 33.6, "dm": 7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T02:00:00+01:00", "tout": -4.7, "tin": 21.99, "flow": 30.6, "dm": -45, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T02:30:00+01:00", "tout": -4.6, "tin": 21.99, "flow": 34.5, "dm": -47, "offset": 0, "calc": 0.0, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:00:00+01:00", "tout": -4.5, "tin": 22.01, "flow": 34.5, "dm": -17, "offset": 0, "calc": 0.0, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:30:00+01:00", "tout": -4.4, "tin": 22.02, "flow": 33.4, "dm": 6, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T04:00:00+01:00", "tout": -4.3, "tin": 21.99, "flow": 30.4, "dm": -45, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T04:30:00+01:00", "tout": -4.2, "tin": 21.99, "flow": 34.4, "dm": -47, "offset": 0, "calc": 0.0, "kw": 1.17, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:00:00+01:00", "tout": -4.2, "tin": 22.01, "flow": 34.3, "dm": -17, "offset": 0, "calc": 0.0, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:30:00+01:00", "tout": -4.1, "tin": 22.02, "flow": 33.3, "dm": 5, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T06:00:00+01:00", "tout": -4.0, "tin": 21.99, "flow": 30.3, "dm": -46, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T06:30:00+01:00", "tout": -3.9, "tin": 21.99, "flow": 34.2, "dm": -48, "offset": 0, "calc": 0.0, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-02T07:00:00+01:00", "tout": -3.8, "tin": 22.01, "flow": 34.2, "dm": -18, "offset": 0, "calc": 0.0, "kw": 1.15, "price": 90.0, "comp": 1}, {"t": "2026-01-02T07:30:00+01:00", "tout": -3.8, "tin": 22.02, "flow": 33.1, "dm": 4, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T08:00:00+01:00", "tout": -3.7, "tin": 21.99, "flow": 30.1, "dm": -47, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T08:30:00+01:00", "tout": -3.6, "tin": 21.99, "flow": 34.1, "dm": -49, "offset": 0, "calc": 0.0, "kw": 1.13, "price": 90.0, "comp": 1}, {"t": "2026-01-02T09:00:00+01:00", "tout": -3.5, "tin": 22.0, "flow": 34.0, "dm": -19, "offset": 0, "calc": 0.0, "kw": 1.13, "price": 90.0, "comp": 1}, {"t": "2026-01-02T09:30:00+01:00", "tout": -3.4, "tin": 22.02, "flow": 33.0, "dm": 4, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T10:00:00+01:00", "tout": -3.3, "tin": 21.99, "flow": 30.0, "dm": -48, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T10:30:00+01:00", "tout": -3.2, "tin": 21.99, "flow": 33.9, "dm": -50, "offset": 0, "calc": 0.0, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-02T11:00:00+01:00", "tout": -3.2, "tin": 22.0, "flow": 33.9, "dm": -20, "offset": 0, "calc": 0.0, "kw": 1.11, "price": 50.0, "comp": 1}, {"t": "2026-01-02T11:30:00+01:00", "tout": -3.1, "tin": 22.02, "flow": 32.9, "dm": 3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T12:00:00+01:00", "tout": -3.0, "tin": 21.99, "flow": 29.9, "dm": -49, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T12:30:00+01:00", "tout": -2.9, "tin": 21.99, "flow": 33.8, "dm": -51, "offset": 0, "calc": 0.0, "kw": 1.1, "price": 50.0, "comp": 1}, {"t": "2026-01-02T13:00:00+01:00", "tout": -2.8, "tin": 22.0, "flow": 33.7, "dm": -21, "offset": 0, "calc": 0.0, "kw": 1.1, "price": 50.0, "comp": 1}, {"t": "2026-01-02T13:30:00+01:00", "tout": -2.8, "tin": 22.02, "flow": 33.2, "dm": 7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T14:00:00+01:00", "tout": -2.7, "tin": 22.0, "flow": 30.2, "dm": -30, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T14:30:00+01:00", "tout": -2.6, "tin": 21.99, "flow": 33.6, "dm": -52, "offset": 0, "calc": 0.0, "kw": 1.09, "price": 50.0, "comp": 1}, {"t": "2026-01-02T15:00:00+01:00", "tout": -2.5, "tin": 22.0, "flow": 33.6, "dm": -22, "offset": 0, "calc": 0.0, "kw": 1.08, "price": 50.0, "comp": 1}, {"t": "2026-01-02T15:30:00+01:00", "tout": -2.4, "tin": 22.02, "flow": 33.1, "dm": 6, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T16:00:00+01:00", "tout": -2.3, "tin": 22.0, "flow": 30.1, "dm": -31, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T16:30:00+01:00", "tout": -2.2, "tin": 21.99, "flow": 33.5, "dm": -52, "offset": 0, "calc": 0.0, "kw": 1.07, "price": 50.0, "comp": 1}, {"t": "2026-01-02T17:00:00+01:00", "tout": -2.2, "tin": 22.0, "flow": 33.5, "dm": -22, "offset": 0, "calc": 0.0, "kw": 1.07, "price": 90.0, "comp": 1}, {"t": "2026-01-02T17:30:00+01:00", "tout": -2.1, "tin": 22.02, "flow": 32.9, "dm": 5, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T18:00:00+01:00", "tout": -2.0, "tin": 22.0, "flow": 29.9, "dm": -32, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T18:30:00+01:00", "tout": -1.9, "tin": 21.99, "flow": 33.3, "dm": -53, "offset": 0, "calc": 0.0, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-02T19:00:00+01:00", "tout": -1.8, "tin": 22.0, "flow": 33.3, "dm": -23, "offset": 0, "calc": 0.0, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-02T19:30:00+01:00", "tout": -1.8, "tin": 22.02, "flow": 32.8, "dm": 4, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T20:00:00+01:00", "tout": -1.7, "tin": 22.0, "flow": 29.8, "dm": -32, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T20:30:00+01:00", "tout": -1.6, "tin": 21.99, "flow": 33.2, "dm": -54, "offset": 0, "calc": 0.0, "kw": 1.05, "price": 50.0, "comp": 1}, {"t": "2026-01-02T21:00:00+01:00", "tout": -1.5, "tin": 22.0, "flow": 33.2, "dm": -24, "offset": 0, "calc": 0.0, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-02T21:30:00+01:00", "tout": -1.4, "tin": 22.02, "flow": 32.6, "dm": 3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T22:00:00+01:00", "tout": -1.3, "tin": 22.0, "flow": 29.6, "dm": -33, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T22:30:00+01:00", "tout": -1.2, "tin": 21.99, "flow": 33.1, "dm": -55, "offset": 0, "calc": 0.0, "kw": 1.03, "price": 50.0, "comp": 1}, {"t": "2026-01-02T23:00:00+01:00", "tout": -1.2, "tin": 22.0, "flow": 33.0, "dm": -25, "offset": 0, "calc": 0.0, "kw": 1.03, "price": 50.0, "comp": 1}, {"t": "2026-01-02T23:30:00+01:00", "tout": -1.2, "tin": 22.01, "flow": 32.5, "dm": 3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}] \ No newline at end of file diff --git a/scripts/simulation/output/trace-concrete_f1155-selftest.json b/scripts/simulation/output/trace-concrete_f1155-selftest.json deleted file mode 100644 index 1e5c11c5..00000000 --- a/scripts/simulation/output/trace-concrete_f1155-selftest.json +++ /dev/null @@ -1 +0,0 @@ -[{"t": "2026-01-01T00:00:00+01:00", "tout": -5.0, "tin": 22.0, "flow": 32.5, "dm": -36, "offset": 0, "calc": 0.65, "kw": 1.01, "price": 50.0, "comp": 1}, {"t": "2026-01-01T00:30:00+01:00", "tout": -4.9, "tin": 22.01, "flow": 34.6, "dm": -6, "offset": 0, "calc": 0.11, "kw": 1.19, "price": 50.0, "comp": 1}, {"t": "2026-01-01T01:00:00+01:00", "tout": -4.8, "tin": 22.01, "flow": 32.6, "dm": -1, "offset": 0, "calc": 0.53, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T01:30:00+01:00", "tout": -4.8, "tin": 21.98, "flow": 32.6, "dm": -72, "offset": 1, "calc": 0.33, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:00:00+01:00", "tout": -4.7, "tin": 22.01, "flow": 35.5, "dm": -44, "offset": 1, "calc": 0.14, "kw": 1.27, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:30:00+01:00", "tout": -4.6, "tin": 22.04, "flow": 35.5, "dm": -14, "offset": 1, "calc": 0.14, "kw": 1.26, "price": 50.0, "comp": 1}, {"t": "2026-01-01T03:00:00+01:00", "tout": -4.5, "tin": 22.05, "flow": 33.0, "dm": 1, "offset": 0, "calc": 0.45, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T03:30:00+01:00", "tout": -4.4, "tin": 22.01, "flow": 30.0, "dm": -65, "offset": 1, "calc": 1.06, "kw": 0.79, "price": 50.0, "comp": 1}, {"t": "2026-01-01T04:00:00+01:00", "tout": -4.3, "tin": 22.03, "flow": 35.4, "dm": -52, "offset": 1, "calc": 0.43, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-01T04:30:00+01:00", "tout": -4.2, "tin": 22.06, "flow": 35.4, "dm": -22, "offset": 1, "calc": 0.16, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-01T05:00:00+01:00", "tout": -4.2, "tin": 22.08, "flow": 33.8, "dm": 6, "offset": 0, "calc": 0.28, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T05:30:00+01:00", "tout": -4.1, "tin": 22.06, "flow": 30.8, "dm": -31, "offset": 0, "calc": 0.93, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T06:00:00+01:00", "tout": -4.0, "tin": 22.05, "flow": 35.2, "dm": -65, "offset": 1, "calc": 0.45, "kw": 1.23, "price": 50.0, "comp": 1}, {"t": "2026-01-01T06:30:00+01:00", "tout": -3.9, "tin": 22.08, "flow": 35.2, "dm": -35, "offset": 0, "calc": -0.9, "kw": 1.23, "price": 50.0, "comp": 1}, {"t": "2026-01-01T07:00:00+01:00", "tout": -3.8, "tin": 22.09, "flow": 33.2, "dm": -5, "offset": -1, "calc": -1.51, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-01T07:30:00+01:00", "tout": -3.8, "tin": 22.07, "flow": 30.7, "dm": -12, "offset": -1, "calc": -0.99, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T08:00:00+01:00", "tout": -3.7, "tin": 22.03, "flow": 33.1, "dm": -66, "offset": -1, "calc": -0.98, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-01T08:30:00+01:00", "tout": -3.6, "tin": 22.03, "flow": 33.1, "dm": -36, "offset": -1, "calc": -0.98, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-01T09:00:00+01:00", "tout": -3.5, "tin": 22.03, "flow": 33.0, "dm": -6, "offset": -1, "calc": -1.47, "kw": 1.04, "price": 90.0, "comp": 1}, {"t": "2026-01-01T09:30:00+01:00", "tout": -3.4, "tin": 22.02, "flow": 31.0, "dm": 0, "offset": -1, "calc": -1.05, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T10:00:00+01:00", "tout": -3.3, "tin": 21.97, "flow": 31.0, "dm": -67, "offset": -1, "calc": -0.63, "kw": 0.87, "price": 90.0, "comp": 1}, {"t": "2026-01-01T10:30:00+01:00", "tout": -3.2, "tin": 21.98, "flow": 33.9, "dm": -37, "offset": 0, "calc": 0.41, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-01T11:00:00+01:00", "tout": -3.2, "tin": 21.99, "flow": 33.9, "dm": -7, "offset": 0, "calc": 0.26, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-01T11:30:00+01:00", "tout": -3.1, "tin": 22.0, "flow": 31.9, "dm": -1, "offset": 0, "calc": 0.68, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T12:00:00+01:00", "tout": -3.0, "tin": 21.97, "flow": 31.9, "dm": -82, "offset": 1, "calc": 0.94, "kw": 0.94, "price": 50.0, "comp": 1}, {"t": "2026-01-01T12:30:00+01:00", "tout": -2.9, "tin": 21.99, "flow": 34.8, "dm": -55, "offset": 1, "calc": 0.65, "kw": 1.19, "price": 50.0, "comp": 1}, {"t": "2026-01-01T13:00:00+01:00", "tout": -2.8, "tin": 22.02, "flow": 34.7, "dm": -25, "offset": 1, "calc": 0.49, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-01T13:30:00+01:00", "tout": -2.8, "tin": 22.05, "flow": 34.2, "dm": 3, "offset": 1, "calc": 0.45, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T14:00:00+01:00", "tout": -2.7, "tin": 22.04, "flow": 31.2, "dm": -34, "offset": 1, "calc": 1.01, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T14:30:00+01:00", "tout": -2.6, "tin": 22.05, "flow": 34.6, "dm": -55, "offset": 1, "calc": 0.5, "kw": 1.17, "price": 50.0, "comp": 1}, {"t": "2026-01-01T15:00:00+01:00", "tout": -2.5, "tin": 22.08, "flow": 34.6, "dm": -25, "offset": 1, "calc": 0.5, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-01T15:30:00+01:00", "tout": -2.4, "tin": 22.1, "flow": 34.6, "dm": 5, "offset": 1, "calc": 0.37, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T16:00:00+01:00", "tout": -2.3, "tin": 22.1, "flow": 31.6, "dm": -17, "offset": 1, "calc": 0.95, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T16:30:00+01:00", "tout": -2.2, "tin": 22.1, "flow": 34.5, "dm": -56, "offset": 0, "calc": -0.61, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-01T17:00:00+01:00", "tout": -2.2, "tin": 22.11, "flow": 33.5, "dm": -26, "offset": 0, "calc": -0.91, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-01T17:30:00+01:00", "tout": -2.1, "tin": 22.12, "flow": 32.4, "dm": 4, "offset": -1, "calc": -1.21, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T18:00:00+01:00", "tout": -2.0, "tin": 22.09, "flow": 29.4, "dm": -18, "offset": -1, "calc": -0.3, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T18:30:00+01:00", "tout": -1.9, "tin": 22.05, "flow": 32.3, "dm": -57, "offset": -1, "calc": -0.73, "kw": 0.97, "price": 90.0, "comp": 1}, {"t": "2026-01-01T19:00:00+01:00", "tout": -1.8, "tin": 22.05, "flow": 32.3, "dm": -27, "offset": -1, "calc": -0.73, "kw": 0.97, "price": 90.0, "comp": 1}, {"t": "2026-01-01T19:30:00+01:00", "tout": -1.8, "tin": 22.05, "flow": 32.3, "dm": 3, "offset": -1, "calc": -1.2, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T20:00:00+01:00", "tout": -1.7, "tin": 22.03, "flow": 29.3, "dm": -19, "offset": -1, "calc": -0.28, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T20:30:00+01:00", "tout": -1.6, "tin": 22.0, "flow": 33.2, "dm": -65, "offset": 0, "calc": -0.42, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-01T21:00:00+01:00", "tout": -1.5, "tin": 22.01, "flow": 33.2, "dm": -35, "offset": 0, "calc": -0.42, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-01T21:30:00+01:00", "tout": -1.4, "tin": 22.02, "flow": 33.1, "dm": -5, "offset": 0, "calc": -0.78, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-01T22:00:00+01:00", "tout": -1.3, "tin": 22.03, "flow": 31.1, "dm": 0, "offset": 0, "calc": -0.4, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T22:30:00+01:00", "tout": -1.2, "tin": 21.99, "flow": 31.1, "dm": -66, "offset": 1, "calc": 1.03, "kw": 0.87, "price": 50.0, "comp": 1}, {"t": "2026-01-01T23:00:00+01:00", "tout": -1.2, "tin": 22.02, "flow": 34.0, "dm": -38, "offset": 1, "calc": 0.73, "kw": 1.11, "price": 50.0, "comp": 1}, {"t": "2026-01-01T23:30:00+01:00", "tout": -3.1, "tin": 22.05, "flow": 34.8, "dm": -8, "offset": 1, "calc": 0.07, "kw": 1.19, "price": 50.0, "comp": 1}, {"t": "2026-01-02T00:00:00+01:00", "tout": -5.0, "tin": 22.06, "flow": 33.1, "dm": -10, "offset": 1, "calc": 0.43, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T00:30:00+01:00", "tout": -4.9, "tin": 22.05, "flow": 35.6, "dm": -66, "offset": 1, "calc": 0.13, "kw": 1.28, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:00:00+01:00", "tout": -4.8, "tin": 22.08, "flow": 35.6, "dm": -36, "offset": 1, "calc": 0.13, "kw": 1.27, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:30:00+01:00", "tout": -4.8, "tin": 22.1, "flow": 34.6, "dm": -6, "offset": 0, "calc": 0.11, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-02T02:00:00+01:00", "tout": -4.7, "tin": 22.1, "flow": 32.6, "dm": -1, "offset": 0, "calc": 0.53, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T02:30:00+01:00", "tout": -4.6, "tin": 22.07, "flow": 32.6, "dm": -72, "offset": 1, "calc": 0.33, "kw": 1.01, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:00:00+01:00", "tout": -4.5, "tin": 22.09, "flow": 35.5, "dm": -44, "offset": 1, "calc": 0.14, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:30:00+01:00", "tout": -4.4, "tin": 22.12, "flow": 35.4, "dm": -14, "offset": 1, "calc": 0.16, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-02T04:00:00+01:00", "tout": -4.3, "tin": 22.13, "flow": 32.9, "dm": 1, "offset": 0, "calc": 0.47, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T04:30:00+01:00", "tout": -4.2, "tin": 22.09, "flow": 29.9, "dm": -65, "offset": 1, "calc": 1.08, "kw": 0.77, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:00:00+01:00", "tout": -4.2, "tin": 22.11, "flow": 35.3, "dm": -52, "offset": 1, "calc": 0.44, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:30:00+01:00", "tout": -4.1, "tin": 22.14, "flow": 35.3, "dm": -22, "offset": 1, "calc": 0.18, "kw": 1.23, "price": 50.0, "comp": 1}, {"t": "2026-01-02T06:00:00+01:00", "tout": -4.0, "tin": 22.15, "flow": 33.7, "dm": 5, "offset": 0, "calc": 0.3, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T06:30:00+01:00", "tout": -3.9, "tin": 22.13, "flow": 30.7, "dm": -31, "offset": 0, "calc": -0.14, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T07:00:00+01:00", "tout": -3.8, "tin": 22.12, "flow": 34.2, "dm": -53, "offset": -1, "calc": -1.16, "kw": 1.14, "price": 90.0, "comp": 1}, {"t": "2026-01-02T07:30:00+01:00", "tout": -3.8, "tin": 22.12, "flow": 33.1, "dm": -23, "offset": -1, "calc": -0.98, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-02T08:00:00+01:00", "tout": -3.7, "tin": 22.11, "flow": 32.6, "dm": 5, "offset": -1, "calc": -1.39, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T08:30:00+01:00", "tout": -3.6, "tin": 22.08, "flow": 29.6, "dm": -32, "offset": -1, "calc": -0.4, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T09:00:00+01:00", "tout": -3.5, "tin": 22.05, "flow": 33.0, "dm": -54, "offset": -1, "calc": -0.96, "kw": 1.04, "price": 90.0, "comp": 1}, {"t": "2026-01-02T09:30:00+01:00", "tout": -3.4, "tin": 22.05, "flow": 33.0, "dm": -24, "offset": -1, "calc": -0.96, "kw": 1.04, "price": 90.0, "comp": 1}, {"t": "2026-01-02T10:00:00+01:00", "tout": -3.3, "tin": 22.05, "flow": 32.5, "dm": 4, "offset": -1, "calc": -1.36, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T10:30:00+01:00", "tout": -3.2, "tin": 22.02, "flow": 29.5, "dm": -53, "offset": 1, "calc": 1.1, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T11:00:00+01:00", "tout": -3.2, "tin": 22.02, "flow": 34.9, "dm": -75, "offset": 1, "calc": 0.49, "kw": 1.2, "price": 50.0, "comp": 1}, {"t": "2026-01-02T11:30:00+01:00", "tout": -3.1, "tin": 22.05, "flow": 34.8, "dm": -45, "offset": 1, "calc": 0.26, "kw": 1.19, "price": 50.0, "comp": 1}, {"t": "2026-01-02T12:00:00+01:00", "tout": -3.0, "tin": 22.08, "flow": 34.8, "dm": -15, "offset": 1, "calc": 0.47, "kw": 1.19, "price": 50.0, "comp": 1}, {"t": "2026-01-02T12:30:00+01:00", "tout": -2.9, "tin": 22.1, "flow": 33.3, "dm": 1, "offset": 1, "calc": 0.62, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T13:00:00+01:00", "tout": -2.8, "tin": 22.08, "flow": 30.3, "dm": -66, "offset": 1, "calc": 1.15, "kw": 0.8, "price": 50.0, "comp": 1}, {"t": "2026-01-02T13:30:00+01:00", "tout": -2.8, "tin": 22.1, "flow": 34.7, "dm": -45, "offset": 1, "calc": 0.49, "kw": 1.17, "price": 50.0, "comp": 1}, {"t": "2026-01-02T14:00:00+01:00", "tout": -2.7, "tin": 22.13, "flow": 34.7, "dm": -15, "offset": 1, "calc": 0.49, "kw": 1.17, "price": 50.0, "comp": 1}, {"t": "2026-01-02T14:30:00+01:00", "tout": -2.6, "tin": 22.15, "flow": 33.6, "dm": 7, "offset": 1, "calc": 0.56, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T15:00:00+01:00", "tout": -2.5, "tin": 22.14, "flow": 30.6, "dm": -44, "offset": 1, "calc": 1.1, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T15:30:00+01:00", "tout": -2.4, "tin": 22.15, "flow": 34.6, "dm": -46, "offset": 1, "calc": 0.5, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-02T16:00:00+01:00", "tout": -2.3, "tin": 22.17, "flow": 34.5, "dm": -16, "offset": 1, "calc": 0.52, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-02T16:30:00+01:00", "tout": -2.2, "tin": 22.19, "flow": 33.5, "dm": 6, "offset": 0, "calc": -0.85, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T17:00:00+01:00", "tout": -2.2, "tin": 22.18, "flow": 30.5, "dm": -15, "offset": 0, "calc": -0.46, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T17:30:00+01:00", "tout": -2.1, "tin": 22.14, "flow": 33.4, "dm": -81, "offset": 0, "calc": -0.9, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-02T18:00:00+01:00", "tout": -2.0, "tin": 22.16, "flow": 33.4, "dm": -51, "offset": 0, "calc": -0.9, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-02T18:30:00+01:00", "tout": -1.9, "tin": 22.17, "flow": 33.3, "dm": -21, "offset": 0, "calc": -0.88, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-02T19:00:00+01:00", "tout": -1.8, "tin": 22.17, "flow": 31.8, "dm": 6, "offset": -1, "calc": -1.1, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T19:30:00+01:00", "tout": -1.8, "tin": 22.13, "flow": 28.8, "dm": -30, "offset": -1, "calc": -0.21, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T20:00:00+01:00", "tout": -1.7, "tin": 22.1, "flow": 32.2, "dm": -52, "offset": -1, "calc": -0.72, "kw": 0.95, "price": 90.0, "comp": 1}, {"t": "2026-01-02T20:30:00+01:00", "tout": -1.6, "tin": 22.1, "flow": 32.2, "dm": -22, "offset": -1, "calc": -0.27, "kw": 0.95, "price": 50.0, "comp": 1}, {"t": "2026-01-02T21:00:00+01:00", "tout": -1.5, "tin": 22.1, "flow": 31.7, "dm": 6, "offset": -1, "calc": -0.51, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T21:30:00+01:00", "tout": -1.4, "tin": 22.06, "flow": 28.7, "dm": -36, "offset": 0, "calc": 0.26, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T22:00:00+01:00", "tout": -1.3, "tin": 22.05, "flow": 33.1, "dm": -75, "offset": 0, "calc": -0.4, "kw": 1.03, "price": 50.0, "comp": 1}, {"t": "2026-01-02T22:30:00+01:00", "tout": -1.2, "tin": 22.06, "flow": 33.1, "dm": -45, "offset": 0, "calc": -0.4, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-02T23:00:00+01:00", "tout": -1.2, "tin": 22.07, "flow": 33.0, "dm": -15, "offset": 0, "calc": -0.39, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-02T23:30:00+01:00", "tout": -1.2, "tin": 22.08, "flow": 31.5, "dm": 0, "offset": 0, "calc": -0.47, "kw": 0.1, "price": 50.0, "comp": 0}] \ No newline at end of file diff --git a/scripts/simulation/output/trace-wooden_f750-selftest-baseline.json b/scripts/simulation/output/trace-wooden_f750-selftest-baseline.json deleted file mode 100644 index 9f099038..00000000 --- a/scripts/simulation/output/trace-wooden_f750-selftest-baseline.json +++ /dev/null @@ -1 +0,0 @@ -[{"t": "2026-01-01T00:00:00+01:00", "tout": -5.0, "tin": 21.96, "flow": 32.5, "dm": -80, "offset": 0, "calc": 0.0, "kw": 0.69, "price": 50.0, "comp": 1}, {"t": "2026-01-01T00:30:00+01:00", "tout": -4.9, "tin": 21.92, "flow": 43.4, "dm": -143, "offset": 0, "calc": 0.0, "kw": 1.31, "price": 50.0, "comp": 1}, {"t": "2026-01-01T01:00:00+01:00", "tout": -4.8, "tin": 21.94, "flow": 43.3, "dm": -113, "offset": 0, "calc": 0.0, "kw": 1.3, "price": 50.0, "comp": 1}, {"t": "2026-01-01T01:30:00+01:00", "tout": -4.8, "tin": 21.97, "flow": 43.2, "dm": -83, "offset": 0, "calc": 0.0, "kw": 1.3, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:00:00+01:00", "tout": -4.7, "tin": 21.99, "flow": 43.2, "dm": -53, "offset": 0, "calc": 0.0, "kw": 1.29, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:30:00+01:00", "tout": -4.6, "tin": 22.01, "flow": 43.1, "dm": -23, "offset": 0, "calc": 0.0, "kw": 1.28, "price": 50.0, "comp": 1}, {"t": "2026-01-01T03:00:00+01:00", "tout": -4.5, "tin": 22.03, "flow": 42.6, "dm": 5, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T03:30:00+01:00", "tout": -4.4, "tin": 22.01, "flow": 39.6, "dm": -31, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T04:00:00+01:00", "tout": -4.3, "tin": 21.99, "flow": 42.9, "dm": -52, "offset": 0, "calc": 0.0, "kw": 1.26, "price": 50.0, "comp": 1}, {"t": "2026-01-01T04:30:00+01:00", "tout": -4.2, "tin": 22.01, "flow": 42.9, "dm": -22, "offset": 0, "calc": 0.0, "kw": 1.26, "price": 50.0, "comp": 1}, {"t": "2026-01-01T05:00:00+01:00", "tout": -4.2, "tin": 22.03, "flow": 42.3, "dm": 5, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T05:30:00+01:00", "tout": -4.1, "tin": 22.0, "flow": 39.3, "dm": -31, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T06:00:00+01:00", "tout": -4.0, "tin": 21.99, "flow": 42.7, "dm": -52, "offset": 0, "calc": 0.0, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-01T06:30:00+01:00", "tout": -3.9, "tin": 22.01, "flow": 42.6, "dm": -22, "offset": 0, "calc": 0.0, "kw": 1.23, "price": 50.0, "comp": 1}, {"t": "2026-01-01T07:00:00+01:00", "tout": -3.8, "tin": 22.03, "flow": 42.1, "dm": 6, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T07:30:00+01:00", "tout": -3.8, "tin": 22.0, "flow": 39.1, "dm": -31, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T08:00:00+01:00", "tout": -3.7, "tin": 21.99, "flow": 42.4, "dm": -52, "offset": 0, "calc": 0.0, "kw": 1.22, "price": 90.0, "comp": 1}, {"t": "2026-01-01T08:30:00+01:00", "tout": -3.6, "tin": 22.01, "flow": 42.4, "dm": -22, "offset": 0, "calc": 0.0, "kw": 1.21, "price": 90.0, "comp": 1}, {"t": "2026-01-01T09:00:00+01:00", "tout": -3.5, "tin": 22.03, "flow": 41.8, "dm": 6, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T09:30:00+01:00", "tout": -3.4, "tin": 22.0, "flow": 38.8, "dm": -30, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T10:00:00+01:00", "tout": -3.3, "tin": 21.99, "flow": 42.2, "dm": -51, "offset": 0, "calc": 0.0, "kw": 1.19, "price": 90.0, "comp": 1}, {"t": "2026-01-01T10:30:00+01:00", "tout": -3.2, "tin": 22.01, "flow": 42.1, "dm": -21, "offset": 0, "calc": 0.0, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-01T11:00:00+01:00", "tout": -3.2, "tin": 22.03, "flow": 41.6, "dm": 6, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T11:30:00+01:00", "tout": -3.1, "tin": 22.0, "flow": 38.6, "dm": -30, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T12:00:00+01:00", "tout": -3.0, "tin": 21.99, "flow": 41.9, "dm": -51, "offset": 0, "calc": 0.0, "kw": 1.17, "price": 50.0, "comp": 1}, {"t": "2026-01-01T12:30:00+01:00", "tout": -2.9, "tin": 22.01, "flow": 41.9, "dm": -21, "offset": 0, "calc": 0.0, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-01T13:00:00+01:00", "tout": -2.8, "tin": 22.03, "flow": 41.3, "dm": 7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T13:30:00+01:00", "tout": -2.8, "tin": 22.0, "flow": 38.3, "dm": -29, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T14:00:00+01:00", "tout": -2.7, "tin": 21.99, "flow": 41.7, "dm": -50, "offset": 0, "calc": 0.0, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-01T14:30:00+01:00", "tout": -2.6, "tin": 22.01, "flow": 41.6, "dm": -20, "offset": 0, "calc": 0.0, "kw": 1.14, "price": 50.0, "comp": 1}, {"t": "2026-01-01T15:00:00+01:00", "tout": -2.5, "tin": 22.03, "flow": 41.1, "dm": 7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T15:30:00+01:00", "tout": -2.4, "tin": 22.0, "flow": 38.1, "dm": -29, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T16:00:00+01:00", "tout": -2.3, "tin": 21.99, "flow": 41.4, "dm": -50, "offset": 0, "calc": 0.0, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-01T16:30:00+01:00", "tout": -2.2, "tin": 22.01, "flow": 41.4, "dm": -20, "offset": 0, "calc": 0.0, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-01T17:00:00+01:00", "tout": -2.2, "tin": 22.03, "flow": 40.8, "dm": 7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T17:30:00+01:00", "tout": -2.1, "tin": 22.0, "flow": 37.8, "dm": -29, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T18:00:00+01:00", "tout": -2.0, "tin": 21.99, "flow": 41.2, "dm": -50, "offset": 0, "calc": 0.0, "kw": 1.1, "price": 90.0, "comp": 1}, {"t": "2026-01-01T18:30:00+01:00", "tout": -1.9, "tin": 22.01, "flow": 41.1, "dm": -20, "offset": 0, "calc": 0.0, "kw": 1.09, "price": 90.0, "comp": 1}, {"t": "2026-01-01T19:00:00+01:00", "tout": -1.8, "tin": 22.02, "flow": 40.1, "dm": 3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T19:30:00+01:00", "tout": -1.8, "tin": 21.99, "flow": 37.1, "dm": -48, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T20:00:00+01:00", "tout": -1.7, "tin": 21.99, "flow": 40.9, "dm": -49, "offset": 0, "calc": 0.0, "kw": 1.08, "price": 90.0, "comp": 1}, {"t": "2026-01-01T20:30:00+01:00", "tout": -1.6, "tin": 22.01, "flow": 40.8, "dm": -19, "offset": 0, "calc": 0.0, "kw": 1.07, "price": 50.0, "comp": 1}, {"t": "2026-01-01T21:00:00+01:00", "tout": -1.5, "tin": 22.02, "flow": 39.8, "dm": 3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T21:30:00+01:00", "tout": -1.4, "tin": 21.99, "flow": 36.8, "dm": -47, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T22:00:00+01:00", "tout": -1.3, "tin": 21.99, "flow": 40.7, "dm": -49, "offset": 0, "calc": 0.0, "kw": 1.06, "price": 50.0, "comp": 1}, {"t": "2026-01-01T22:30:00+01:00", "tout": -1.2, "tin": 22.01, "flow": 40.6, "dm": -19, "offset": 0, "calc": 0.0, "kw": 1.05, "price": 50.0, "comp": 1}, {"t": "2026-01-01T23:00:00+01:00", "tout": -1.2, "tin": 22.02, "flow": 39.6, "dm": 4, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T23:30:00+01:00", "tout": -3.1, "tin": 21.97, "flow": 36.6, "dm": -73, "offset": 0, "calc": 0.0, "kw": 0.88, "price": 50.0, "comp": 1}, {"t": "2026-01-02T00:00:00+01:00", "tout": -5.0, "tin": 21.98, "flow": 43.4, "dm": -64, "offset": 0, "calc": 0.0, "kw": 1.32, "price": 50.0, "comp": 1}, {"t": "2026-01-02T00:30:00+01:00", "tout": -4.9, "tin": 22.0, "flow": 43.4, "dm": -34, "offset": 0, "calc": 0.0, "kw": 1.31, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:00:00+01:00", "tout": -4.8, "tin": 22.02, "flow": 43.3, "dm": -4, "offset": 0, "calc": 0.0, "kw": 1.3, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:30:00+01:00", "tout": -4.8, "tin": 22.01, "flow": 40.8, "dm": -11, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T02:00:00+01:00", "tout": -4.7, "tin": 21.98, "flow": 43.2, "dm": -64, "offset": 0, "calc": 0.0, "kw": 1.29, "price": 50.0, "comp": 1}, {"t": "2026-01-02T02:30:00+01:00", "tout": -4.6, "tin": 22.0, "flow": 43.1, "dm": -34, "offset": 0, "calc": 0.0, "kw": 1.28, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:00:00+01:00", "tout": -4.5, "tin": 22.02, "flow": 43.1, "dm": -4, "offset": 0, "calc": 0.0, "kw": 1.27, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:30:00+01:00", "tout": -4.4, "tin": 22.01, "flow": 40.5, "dm": -10, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T04:00:00+01:00", "tout": -4.3, "tin": 21.98, "flow": 42.9, "dm": -63, "offset": 0, "calc": 0.0, "kw": 1.26, "price": 50.0, "comp": 1}, {"t": "2026-01-02T04:30:00+01:00", "tout": -4.2, "tin": 22.0, "flow": 42.9, "dm": -33, "offset": 0, "calc": 0.0, "kw": 1.26, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:00:00+01:00", "tout": -4.2, "tin": 22.02, "flow": 42.8, "dm": -3, "offset": 0, "calc": 0.0, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:30:00+01:00", "tout": -4.1, "tin": 22.01, "flow": 40.3, "dm": -10, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T06:00:00+01:00", "tout": -4.0, "tin": 21.98, "flow": 42.7, "dm": -63, "offset": 0, "calc": 0.0, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-02T06:30:00+01:00", "tout": -3.9, "tin": 22.0, "flow": 42.6, "dm": -33, "offset": 0, "calc": 0.0, "kw": 1.23, "price": 50.0, "comp": 1}, {"t": "2026-01-02T07:00:00+01:00", "tout": -3.8, "tin": 22.02, "flow": 42.5, "dm": -3, "offset": 0, "calc": 0.0, "kw": 1.23, "price": 90.0, "comp": 1}, {"t": "2026-01-02T07:30:00+01:00", "tout": -3.8, "tin": 22.01, "flow": 40.0, "dm": -9, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T08:00:00+01:00", "tout": -3.7, "tin": 21.98, "flow": 42.4, "dm": -62, "offset": 0, "calc": 0.0, "kw": 1.22, "price": 90.0, "comp": 1}, {"t": "2026-01-02T08:30:00+01:00", "tout": -3.6, "tin": 22.0, "flow": 42.4, "dm": -32, "offset": 0, "calc": 0.0, "kw": 1.21, "price": 90.0, "comp": 1}, {"t": "2026-01-02T09:00:00+01:00", "tout": -3.5, "tin": 22.02, "flow": 42.3, "dm": -2, "offset": 0, "calc": 0.0, "kw": 1.2, "price": 90.0, "comp": 1}, {"t": "2026-01-02T09:30:00+01:00", "tout": -3.4, "tin": 22.01, "flow": 39.8, "dm": -9, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T10:00:00+01:00", "tout": -3.3, "tin": 21.98, "flow": 42.2, "dm": -62, "offset": 0, "calc": 0.0, "kw": 1.19, "price": 90.0, "comp": 1}, {"t": "2026-01-02T10:30:00+01:00", "tout": -3.2, "tin": 22.0, "flow": 42.1, "dm": -32, "offset": 0, "calc": 0.0, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-02T11:00:00+01:00", "tout": -3.2, "tin": 22.02, "flow": 42.0, "dm": -2, "offset": 0, "calc": 0.0, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-02T11:30:00+01:00", "tout": -3.1, "tin": 22.01, "flow": 39.5, "dm": -9, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T12:00:00+01:00", "tout": -3.0, "tin": 21.98, "flow": 41.9, "dm": -62, "offset": 0, "calc": 0.0, "kw": 1.17, "price": 50.0, "comp": 1}, {"t": "2026-01-02T12:30:00+01:00", "tout": -2.9, "tin": 22.0, "flow": 41.9, "dm": -32, "offset": 0, "calc": 0.0, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-02T13:00:00+01:00", "tout": -2.8, "tin": 22.02, "flow": 41.8, "dm": -2, "offset": 0, "calc": 0.0, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-02T13:30:00+01:00", "tout": -2.8, "tin": 22.01, "flow": 39.3, "dm": -8, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T14:00:00+01:00", "tout": -2.7, "tin": 21.98, "flow": 41.7, "dm": -61, "offset": 0, "calc": 0.0, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-02T14:30:00+01:00", "tout": -2.6, "tin": 22.0, "flow": 41.6, "dm": -31, "offset": 0, "calc": 0.0, "kw": 1.14, "price": 50.0, "comp": 1}, {"t": "2026-01-02T15:00:00+01:00", "tout": -2.5, "tin": 22.02, "flow": 41.5, "dm": -1, "offset": 0, "calc": 0.0, "kw": 1.13, "price": 50.0, "comp": 1}, {"t": "2026-01-02T15:30:00+01:00", "tout": -2.4, "tin": 22.01, "flow": 39.0, "dm": -8, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T16:00:00+01:00", "tout": -2.3, "tin": 21.98, "flow": 41.4, "dm": -61, "offset": 0, "calc": 0.0, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-02T16:30:00+01:00", "tout": -2.2, "tin": 22.0, "flow": 41.4, "dm": -31, "offset": 0, "calc": 0.0, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-02T17:00:00+01:00", "tout": -2.2, "tin": 22.02, "flow": 41.3, "dm": -1, "offset": 0, "calc": 0.0, "kw": 1.11, "price": 90.0, "comp": 1}, {"t": "2026-01-02T17:30:00+01:00", "tout": -2.1, "tin": 22.01, "flow": 38.8, "dm": -7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T18:00:00+01:00", "tout": -2.0, "tin": 21.98, "flow": 41.2, "dm": -60, "offset": 0, "calc": 0.0, "kw": 1.1, "price": 90.0, "comp": 1}, {"t": "2026-01-02T18:30:00+01:00", "tout": -1.9, "tin": 22.0, "flow": 41.1, "dm": -30, "offset": 0, "calc": 0.0, "kw": 1.09, "price": 90.0, "comp": 1}, {"t": "2026-01-02T19:00:00+01:00", "tout": -1.8, "tin": 22.02, "flow": 41.0, "dm": 0, "offset": 0, "calc": 0.0, "kw": 1.09, "price": 90.0, "comp": 1}, {"t": "2026-01-02T19:30:00+01:00", "tout": -1.8, "tin": 22.01, "flow": 38.5, "dm": -7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T20:00:00+01:00", "tout": -1.7, "tin": 21.98, "flow": 40.9, "dm": -60, "offset": 0, "calc": 0.0, "kw": 1.08, "price": 90.0, "comp": 1}, {"t": "2026-01-02T20:30:00+01:00", "tout": -1.6, "tin": 22.0, "flow": 40.8, "dm": -30, "offset": 0, "calc": 0.0, "kw": 1.07, "price": 50.0, "comp": 1}, {"t": "2026-01-02T21:00:00+01:00", "tout": -1.5, "tin": 22.02, "flow": 40.8, "dm": 0, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T21:30:00+01:00", "tout": -1.4, "tin": 22.0, "flow": 37.8, "dm": -21, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T22:00:00+01:00", "tout": -1.3, "tin": 21.98, "flow": 40.7, "dm": -60, "offset": 0, "calc": 0.0, "kw": 1.06, "price": 50.0, "comp": 1}, {"t": "2026-01-02T22:30:00+01:00", "tout": -1.2, "tin": 22.0, "flow": 40.6, "dm": -30, "offset": 0, "calc": 0.0, "kw": 1.05, "price": 50.0, "comp": 1}, {"t": "2026-01-02T23:00:00+01:00", "tout": -1.2, "tin": 22.02, "flow": 40.5, "dm": 0, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T23:30:00+01:00", "tout": -1.2, "tin": 22.0, "flow": 37.5, "dm": -22, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}] \ No newline at end of file diff --git a/scripts/simulation/output/trace-wooden_f750-selftest.json b/scripts/simulation/output/trace-wooden_f750-selftest.json deleted file mode 100644 index 92be1ae0..00000000 --- a/scripts/simulation/output/trace-wooden_f750-selftest.json +++ /dev/null @@ -1 +0,0 @@ -[{"t": "2026-01-01T00:00:00+01:00", "tout": -5.0, "tin": 21.96, "flow": 32.5, "dm": -80, "offset": 0, "calc": 0.32, "kw": 0.69, "price": 50.0, "comp": 1}, {"t": "2026-01-01T00:30:00+01:00", "tout": -4.9, "tin": 21.91, "flow": 42.4, "dm": -143, "offset": -1, "calc": -1.33, "kw": 1.26, "price": 50.0, "comp": 1}, {"t": "2026-01-01T01:00:00+01:00", "tout": -4.8, "tin": 21.92, "flow": 42.3, "dm": -113, "offset": -1, "calc": -1.32, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-01T01:30:00+01:00", "tout": -4.8, "tin": 21.92, "flow": 42.2, "dm": -83, "offset": -1, "calc": -1.3, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:00:00+01:00", "tout": -4.7, "tin": 21.92, "flow": 42.2, "dm": -53, "offset": -1, "calc": -1.31, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:30:00+01:00", "tout": -4.6, "tin": 21.93, "flow": 42.1, "dm": -23, "offset": -1, "calc": -1.3, "kw": 1.23, "price": 50.0, "comp": 1}, {"t": "2026-01-01T03:00:00+01:00", "tout": -4.5, "tin": 21.93, "flow": 41.6, "dm": 5, "offset": -1, "calc": -1.78, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T03:30:00+01:00", "tout": -4.4, "tin": 21.88, "flow": 38.6, "dm": -31, "offset": -1, "calc": -0.72, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T04:00:00+01:00", "tout": -4.3, "tin": 21.85, "flow": 41.9, "dm": -52, "offset": -1, "calc": -1.27, "kw": 1.22, "price": 50.0, "comp": 1}, {"t": "2026-01-01T04:30:00+01:00", "tout": -4.2, "tin": 21.85, "flow": 41.9, "dm": -22, "offset": -1, "calc": -0.19, "kw": 1.21, "price": 50.0, "comp": 1}, {"t": "2026-01-01T05:00:00+01:00", "tout": -4.2, "tin": 21.86, "flow": 41.3, "dm": 5, "offset": -1, "calc": -0.37, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T05:30:00+01:00", "tout": -4.1, "tin": 21.82, "flow": 38.3, "dm": -41, "offset": 0, "calc": 0.41, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T06:00:00+01:00", "tout": -4.0, "tin": 21.82, "flow": 42.7, "dm": -48, "offset": 0, "calc": -0.33, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-01T06:30:00+01:00", "tout": -3.9, "tin": 21.85, "flow": 42.6, "dm": -18, "offset": -1, "calc": -1.4, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-01T07:00:00+01:00", "tout": -3.8, "tin": 21.85, "flow": 40.6, "dm": 5, "offset": -2, "calc": -2.16, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T07:30:00+01:00", "tout": -3.8, "tin": 21.79, "flow": 37.6, "dm": -16, "offset": -1, "calc": -0.75, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T08:00:00+01:00", "tout": -3.7, "tin": 21.74, "flow": 41.4, "dm": -75, "offset": -1, "calc": -1.22, "kw": 1.17, "price": 90.0, "comp": 1}, {"t": "2026-01-01T08:30:00+01:00", "tout": -3.6, "tin": 21.75, "flow": 41.4, "dm": -45, "offset": -1, "calc": -1.23, "kw": 1.17, "price": 90.0, "comp": 1}, {"t": "2026-01-01T09:00:00+01:00", "tout": -3.5, "tin": 21.76, "flow": 41.3, "dm": -15, "offset": -1, "calc": -1.22, "kw": 1.16, "price": 90.0, "comp": 1}, {"t": "2026-01-01T09:30:00+01:00", "tout": -3.4, "tin": 21.76, "flow": 40.3, "dm": 7, "offset": -1, "calc": -1.47, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T10:00:00+01:00", "tout": -3.3, "tin": 21.71, "flow": 37.3, "dm": -43, "offset": -1, "calc": -0.72, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T10:30:00+01:00", "tout": -3.2, "tin": 21.7, "flow": 41.1, "dm": -45, "offset": -1, "calc": -0.87, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-01T11:00:00+01:00", "tout": -3.2, "tin": 21.71, "flow": 41.0, "dm": -15, "offset": -1, "calc": -0.86, "kw": 1.14, "price": 50.0, "comp": 1}, {"t": "2026-01-01T11:30:00+01:00", "tout": -3.1, "tin": 21.71, "flow": 39.5, "dm": 0, "offset": -1, "calc": -0.97, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T12:00:00+01:00", "tout": -3.0, "tin": 21.65, "flow": 36.5, "dm": -65, "offset": -1, "calc": -0.19, "kw": 0.9, "price": 50.0, "comp": 1}, {"t": "2026-01-01T12:30:00+01:00", "tout": -2.9, "tin": 21.66, "flow": 40.9, "dm": -44, "offset": -1, "calc": -0.68, "kw": 1.13, "price": 50.0, "comp": 1}, {"t": "2026-01-01T13:00:00+01:00", "tout": -2.8, "tin": 21.67, "flow": 40.8, "dm": -14, "offset": -1, "calc": -0.68, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-01T13:30:00+01:00", "tout": -2.8, "tin": 21.68, "flow": 39.3, "dm": 1, "offset": -1, "calc": -0.79, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T14:00:00+01:00", "tout": -2.7, "tin": 21.62, "flow": 36.3, "dm": -65, "offset": -1, "calc": -0.17, "kw": 0.88, "price": 50.0, "comp": 1}, {"t": "2026-01-01T14:30:00+01:00", "tout": -2.6, "tin": 21.64, "flow": 41.6, "dm": -44, "offset": 0, "calc": 0.07, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-01T15:00:00+01:00", "tout": -2.5, "tin": 21.67, "flow": 41.5, "dm": -14, "offset": 0, "calc": 0.07, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-01T15:30:00+01:00", "tout": -2.4, "tin": 21.7, "flow": 40.0, "dm": 1, "offset": 0, "calc": 0.09, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T16:00:00+01:00", "tout": -2.3, "tin": 21.66, "flow": 37.0, "dm": -64, "offset": 0, "calc": 0.57, "kw": 0.91, "price": 50.0, "comp": 1}, {"t": "2026-01-01T16:30:00+01:00", "tout": -2.2, "tin": 21.69, "flow": 41.4, "dm": -44, "offset": 0, "calc": -0.76, "kw": 1.13, "price": 50.0, "comp": 1}, {"t": "2026-01-01T17:00:00+01:00", "tout": -2.2, "tin": 21.72, "flow": 41.3, "dm": -14, "offset": -1, "calc": -1.09, "kw": 1.13, "price": 90.0, "comp": 1}, {"t": "2026-01-01T17:30:00+01:00", "tout": -2.1, "tin": 21.72, "flow": 38.8, "dm": 2, "offset": -1, "calc": -1.15, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T18:00:00+01:00", "tout": -2.0, "tin": 21.66, "flow": 35.8, "dm": -64, "offset": -1, "calc": -0.48, "kw": 0.83, "price": 90.0, "comp": 1}, {"t": "2026-01-01T18:30:00+01:00", "tout": -1.9, "tin": 21.67, "flow": 40.1, "dm": -43, "offset": -1, "calc": -0.96, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-01T19:00:00+01:00", "tout": -1.8, "tin": 21.68, "flow": 40.0, "dm": -13, "offset": -1, "calc": -0.95, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-01T19:30:00+01:00", "tout": -1.8, "tin": 21.68, "flow": 38.5, "dm": 2, "offset": -1, "calc": -1.11, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T20:00:00+01:00", "tout": -1.7, "tin": 21.63, "flow": 35.5, "dm": -63, "offset": -1, "calc": -0.45, "kw": 0.82, "price": 90.0, "comp": 1}, {"t": "2026-01-01T20:30:00+01:00", "tout": -1.6, "tin": 21.63, "flow": 39.8, "dm": -43, "offset": -1, "calc": -0.59, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-01T21:00:00+01:00", "tout": -1.5, "tin": 21.65, "flow": 39.8, "dm": -13, "offset": -1, "calc": -0.6, "kw": 1.03, "price": 50.0, "comp": 1}, {"t": "2026-01-01T21:30:00+01:00", "tout": -1.4, "tin": 21.65, "flow": 38.3, "dm": 2, "offset": -1, "calc": -0.7, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T22:00:00+01:00", "tout": -1.3, "tin": 21.59, "flow": 35.3, "dm": -63, "offset": -1, "calc": -0.09, "kw": 0.8, "price": 50.0, "comp": 1}, {"t": "2026-01-01T22:30:00+01:00", "tout": -1.2, "tin": 21.6, "flow": 39.6, "dm": -43, "offset": -1, "calc": -0.57, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-01T23:00:00+01:00", "tout": -1.2, "tin": 21.62, "flow": 39.5, "dm": -13, "offset": -1, "calc": -0.57, "kw": 1.01, "price": 50.0, "comp": 1}, {"t": "2026-01-01T23:30:00+01:00", "tout": -3.1, "tin": 21.62, "flow": 38.8, "dm": -5, "offset": -1, "calc": -0.86, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T00:00:00+01:00", "tout": -5.0, "tin": 21.56, "flow": 41.8, "dm": -74, "offset": -1, "calc": -0.89, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-02T00:30:00+01:00", "tout": -4.9, "tin": 21.58, "flow": 42.4, "dm": -44, "offset": -1, "calc": -0.97, "kw": 1.27, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:00:00+01:00", "tout": -4.8, "tin": 21.59, "flow": 42.3, "dm": -14, "offset": -1, "calc": -0.96, "kw": 1.27, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:30:00+01:00", "tout": -4.8, "tin": 21.6, "flow": 40.8, "dm": 1, "offset": -1, "calc": -1.09, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T02:00:00+01:00", "tout": -4.7, "tin": 21.54, "flow": 37.8, "dm": -65, "offset": -1, "calc": -0.39, "kw": 1.01, "price": 50.0, "comp": 1}, {"t": "2026-01-02T02:30:00+01:00", "tout": -4.6, "tin": 21.55, "flow": 42.1, "dm": -44, "offset": -1, "calc": -0.93, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:00:00+01:00", "tout": -4.5, "tin": 21.57, "flow": 42.1, "dm": -14, "offset": -1, "calc": -0.94, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:30:00+01:00", "tout": -4.4, "tin": 21.58, "flow": 40.5, "dm": 1, "offset": -1, "calc": -1.06, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T04:00:00+01:00", "tout": -4.3, "tin": 21.52, "flow": 37.5, "dm": -64, "offset": -1, "calc": -0.37, "kw": 0.99, "price": 50.0, "comp": 1}, {"t": "2026-01-02T04:30:00+01:00", "tout": -4.2, "tin": 21.53, "flow": 41.9, "dm": -44, "offset": -1, "calc": -0.1, "kw": 1.23, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:00:00+01:00", "tout": -4.2, "tin": 21.55, "flow": 41.8, "dm": -14, "offset": -1, "calc": -0.09, "kw": 1.22, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:30:00+01:00", "tout": -4.1, "tin": 21.56, "flow": 40.3, "dm": 2, "offset": -1, "calc": -0.08, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T06:00:00+01:00", "tout": -4.0, "tin": 21.52, "flow": 40.3, "dm": -69, "offset": 0, "calc": 0.09, "kw": 1.13, "price": 50.0, "comp": 1}, {"t": "2026-01-02T06:30:00+01:00", "tout": -3.9, "tin": 21.56, "flow": 42.6, "dm": -39, "offset": -1, "calc": -1.02, "kw": 1.26, "price": 50.0, "comp": 1}, {"t": "2026-01-02T07:00:00+01:00", "tout": -3.8, "tin": 21.57, "flow": 41.5, "dm": -9, "offset": -1, "calc": -1.61, "kw": 1.19, "price": 90.0, "comp": 1}, {"t": "2026-01-02T07:30:00+01:00", "tout": -3.8, "tin": 21.57, "flow": 39.5, "dm": -3, "offset": -1, "calc": -1.31, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T08:00:00+01:00", "tout": -3.7, "tin": 21.52, "flow": 39.5, "dm": -68, "offset": -1, "calc": -0.96, "kw": 1.08, "price": 90.0, "comp": 1}, {"t": "2026-01-02T08:30:00+01:00", "tout": -3.6, "tin": 21.54, "flow": 41.4, "dm": -38, "offset": -1, "calc": -1.2, "kw": 1.18, "price": 90.0, "comp": 1}, {"t": "2026-01-02T09:00:00+01:00", "tout": -3.5, "tin": 21.56, "flow": 41.3, "dm": -8, "offset": -1, "calc": -1.59, "kw": 1.17, "price": 90.0, "comp": 1}, {"t": "2026-01-02T09:30:00+01:00", "tout": -3.4, "tin": 21.56, "flow": 39.3, "dm": -3, "offset": -1, "calc": -1.3, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T10:00:00+01:00", "tout": -3.3, "tin": 21.5, "flow": 39.3, "dm": -68, "offset": -1, "calc": -0.94, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-02T10:30:00+01:00", "tout": -3.2, "tin": 21.52, "flow": 41.1, "dm": -38, "offset": -1, "calc": -0.84, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-02T11:00:00+01:00", "tout": -3.2, "tin": 21.54, "flow": 41.0, "dm": -8, "offset": -1, "calc": -1.16, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-02T11:30:00+01:00", "tout": -3.1, "tin": 21.54, "flow": 39.0, "dm": -2, "offset": -1, "calc": -0.87, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T12:00:00+01:00", "tout": -3.0, "tin": 21.49, "flow": 39.0, "dm": -68, "offset": -1, "calc": -0.45, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-02T12:30:00+01:00", "tout": -2.9, "tin": 21.51, "flow": 40.9, "dm": -38, "offset": -1, "calc": -0.66, "kw": 1.13, "price": 50.0, "comp": 1}, {"t": "2026-01-02T13:00:00+01:00", "tout": -2.8, "tin": 21.53, "flow": 40.8, "dm": -8, "offset": -1, "calc": -0.97, "kw": 1.13, "price": 50.0, "comp": 1}, {"t": "2026-01-02T13:30:00+01:00", "tout": -2.8, "tin": 21.53, "flow": 38.8, "dm": -2, "offset": -1, "calc": -0.7, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T14:00:00+01:00", "tout": -2.7, "tin": 21.48, "flow": 38.8, "dm": -67, "offset": -1, "calc": -0.43, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-02T14:30:00+01:00", "tout": -2.6, "tin": 21.51, "flow": 41.6, "dm": -37, "offset": 0, "calc": 0.08, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-02T15:00:00+01:00", "tout": -2.5, "tin": 21.55, "flow": 41.5, "dm": -7, "offset": 0, "calc": -0.08, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-02T15:30:00+01:00", "tout": -2.4, "tin": 21.57, "flow": 39.5, "dm": -2, "offset": 0, "calc": 0.18, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T16:00:00+01:00", "tout": -2.3, "tin": 21.54, "flow": 39.5, "dm": -67, "offset": 0, "calc": 0.31, "kw": 1.05, "price": 50.0, "comp": 1}, {"t": "2026-01-02T16:30:00+01:00", "tout": -2.2, "tin": 21.58, "flow": 41.4, "dm": -37, "offset": 0, "calc": -0.75, "kw": 1.14, "price": 50.0, "comp": 1}, {"t": "2026-01-02T17:00:00+01:00", "tout": -2.2, "tin": 21.62, "flow": 41.3, "dm": -7, "offset": -1, "calc": -1.46, "kw": 1.13, "price": 90.0, "comp": 1}, {"t": "2026-01-02T17:30:00+01:00", "tout": -2.1, "tin": 21.61, "flow": 38.3, "dm": -1, "offset": -1, "calc": -1.07, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T18:00:00+01:00", "tout": -2.0, "tin": 21.56, "flow": 38.3, "dm": -66, "offset": -1, "calc": -0.74, "kw": 0.97, "price": 90.0, "comp": 1}, {"t": "2026-01-02T18:30:00+01:00", "tout": -1.9, "tin": 21.57, "flow": 40.1, "dm": -36, "offset": -1, "calc": -0.94, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-02T19:00:00+01:00", "tout": -1.8, "tin": 21.59, "flow": 40.0, "dm": -6, "offset": -1, "calc": -1.3, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-02T19:30:00+01:00", "tout": -1.8, "tin": 21.59, "flow": 38.0, "dm": -1, "offset": -1, "calc": -1.03, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T20:00:00+01:00", "tout": -1.7, "tin": 21.54, "flow": 38.0, "dm": -66, "offset": -1, "calc": -0.71, "kw": 0.95, "price": 90.0, "comp": 1}, {"t": "2026-01-02T20:30:00+01:00", "tout": -1.6, "tin": 21.55, "flow": 39.8, "dm": -36, "offset": -1, "calc": -0.58, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-02T21:00:00+01:00", "tout": -1.5, "tin": 21.57, "flow": 39.8, "dm": -6, "offset": -1, "calc": -0.88, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-02T21:30:00+01:00", "tout": -1.4, "tin": 21.57, "flow": 37.8, "dm": -1, "offset": -1, "calc": -0.62, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T22:00:00+01:00", "tout": -1.3, "tin": 21.52, "flow": 37.8, "dm": -66, "offset": -1, "calc": -0.36, "kw": 0.93, "price": 50.0, "comp": 1}, {"t": "2026-01-02T22:30:00+01:00", "tout": -1.2, "tin": 21.53, "flow": 39.6, "dm": -36, "offset": -1, "calc": -0.57, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-02T23:00:00+01:00", "tout": -1.2, "tin": 21.55, "flow": 39.5, "dm": -6, "offset": -1, "calc": -0.85, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-02T23:30:00+01:00", "tout": -1.2, "tin": 21.55, "flow": 37.5, "dm": -1, "offset": -1, "calc": -0.58, "kw": 0.1, "price": 50.0, "comp": 0}] \ No newline at end of file diff --git a/scripts/simulation/sim_harness.py b/scripts/simulation/sim_harness.py index d29012dc..be9ecc5a 100644 --- a/scripts/simulation/sim_harness.py +++ b/scripts/simulation/sim_harness.py @@ -21,7 +21,7 @@ - DM integrates (flow_actual - flow_target) minutes, clamped to [-3000, 100] - Heat output Q = K_EMIT * (flow - Tin); K_EMIT sized for design point - Electrical power = Q / COP(Tout) from the pump profile curve - - Aux heat: DM below -1500 adds electric aux steps (like real NIBE) + - Aux heat: engages at the pump's own factory start-addition DM (menu 4.9.3) The engine's wall-clock reads (dt_util.now/utcnow) are monkeypatched to the simulation clock each step so price-quarter and forecast logic see sim time. @@ -30,30 +30,48 @@ sim-results/. Run: .venv/bin/python sim_harness.py [--selftest] """ +import asyncio +import functools import json import sys import zoneinfo -from dataclasses import dataclass + +import numpy as np +from dataclasses import dataclass, replace from datetime import datetime, timedelta from pathlib import Path -from unittest.mock import MagicMock +from typing import Any +from unittest.mock import AsyncMock, MagicMock sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from homeassistant.util import dt as dt_util -from custom_components.effektguard.adapters.gespot_adapter import PriceData, QuarterPeriod +from custom_components.effektguard.adapters.gespot_adapter import GESpotAdapter, PriceData +from custom_components.effektguard.const import ( + CONF_GESPOT_ENTITY, + INTERNAL_GAINS_W, + POWER_SOURCE_EXTERNAL_METER, + SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH, +) +from custom_components.effektguard.utils.emitter import en442_flow_temp +from custom_components.effektguard.utils.offset import integer_offset_for +from custom_components.effektguard.utils.time_utils import QUARTERS_PER_HOUR from custom_components.effektguard.adapters.nibe_adapter import NibeState from custom_components.effektguard.adapters.weather_adapter import ( WeatherData, WeatherForecastHour, ) -try: - from custom_components.effektguard.models.nibe import NibeF750Profile, NibeF1155Profile -except ImportError: # F1155 profile ships with the multi-source PR (#19) - from custom_components.effektguard.models.nibe import NibeF750Profile - from custom_components.effektguard.models.nibe import NibeS1155Profile as NibeF1155Profile +from custom_components.effektguard.models.nibe import ( + NibeF730Profile, + NibeF750Profile, + NibeF1155Profile, + NibeF2040Profile, + NibeS1155Profile, +) +from custom_components.effektguard.optimization.billing_period import BillingPeriodAccumulator +from custom_components.effektguard.optimization.effect_layer import effective_tariff_power_kw 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 @@ -64,25 +82,139 @@ DATA_DIR = Path(__file__).parent / "data" OUT_DIR = Path(__file__).parent / "output" +# The archived Nordpool files quote SEK/MWh; GE-Spot publishes what the user +# configured, which for a Swedish user is conventionally öre/kWh. +# 1 SEK/MWh = 0.1 öre/kWh. +ORE_PER_KWH_FROM_SEK_PER_MWH = 0.1 +GESPOT_UNIT_ORE = "öre/kWh" + # Plant constants -FLOW_RAMP_ON = 0.5 # C/min toward target while compressor runs -FLOW_DECAY_OFF = 0.1 # C/min toward indoor when off DM_START = -60.0 DM_STOP = 0.0 -DM_AUX = -1500.0 -AUX_STEP_KW = 3.0 # one aux step +# THE F2040 HAS NO IMMERSION HEATER. It is an outdoor monobloc; its electric addition lives in the +# indoor module it is paired with (a VVM or SMO), which this package does not model. Every other +# machine's heater is on its profile, from its datasheet. This is the fallback for the F2040 alone, +# and it is an ASSUMPTION about that indoor module - not a NIBE figure - so it is named as one. +# +# It matters: the immersion burn is a headline number in the saturated-compressor finding, and the +# simulator used to apply this one invented value to all five machines, matching none of them. +ASSUMED_INDOOR_MODULE_HEATER_KW = 3.0 +STANDBY_KW = 0.1 # controller, pumps, standby losses +J_PER_KWH = 3_600_000.0 + +# Float arithmetic only. A real leak is orders of magnitude bigger: the one this replaced a fake +# audit to catch was 183 kWh. +WATER_NODE_LEAK_BUDGET_KWH = 0.5 + +# How far the run's seasonal COP may exceed the datasheet's own figure for the weather it saw. +# The healthy range, measured across all five houses and all four scenarios, is 0.72 to 1.03; the +# margin is for the mild hours when the curve runs water below the W35 rating point and the pump +# legitimately beats its rating. Doubling the plant's COP lands at 1.5 to 2.1 and is caught on +# every house - which is the whole point, because the identity this replaced called that PASS. +COP_ENVELOPE_TOLERANCE = 1.15 + +# Heat capacity of the water loop and the emitter metal it fills. Roughly 70 L of water +# (0.081 kWh/K) plus the steel of the radiators. Without this the plant HANDS OUT the heat stored +# in the water for free every time the compressor stops, and charges nothing to put it back. +WATER_LOOP_J_PER_K = 350_000.0 # ~0.10 kWh/K +COMPRESSOR_RESPONSE_S = 900.0 # how briskly the compressor closes on its flow target + +# Bounds on the degree-minute integrator. Reaching the floor is not a normal operating state: it +# means the deficit grew without limit despite the curve offset AND the auxiliary heater, so the +# recovery system failed. The harness treats it as such. +DM_INTEGRATOR_FLOOR = -3000.0 +DM_INTEGRATOR_CEILING = 100.0 + +# Above this the house is not "warm", it is being cooked - and on a heat pump it is usually the +# immersion heater doing it, at COP 1.0. +INDOOR_CEILING = 26.0 TOMORROW_VISIBLE_HOUR = 13 # Nordpool day-ahead published ~12:45 CET +QUARTER_MINUTES = 15 +SIM_DAYS = 31 +# The --dst run: Sat 24 Oct through Mon 26 Oct 2026, spanning the fall-back night. +DST_SIM_DAYS = 3 +# 2026-10-25: at 03:00 CEST the clock goes back to 02:00 CET, so the day is 25 hours long and +# the wall-clock hour 02 is metered twice. From the tz database, not from an assumption. +DST_FALL_BACK_DAY = "2026-10-25" +DST_FALL_BACK_PERIODS = 100 + +# The --arctic scenario: a REAL January in Kiruna against REAL SE1 prices, same dates. +STOCKHOLM_LATITUDE = 59.33 +KIRUNA_LATITUDE = 67.86 # 25 hours x 4 quarter-periods + +# CAPACITY AND COP NOW COME FROM THE DATASHEET. See HouseConfig.capacity_kw_at / cop_at. +# +# What used to be here was ASHP_DERATE_PER_C = 0.025, "fraction of rated output lost per C below +# A7", justified by a comment claiming the EN 14511 rating points "trace a near-linear decline". +# They trace a near-linear RISE. The whole derating was invented, backwards, and cited to a +# standard that says the opposite. It is gone. +# +# COP is set by the LIFT, not by the weather. These place the source and the condenser. +KELVIN = 273.15 +# The exergy penalty for hotter water, BEYOND what Carnot already accounts for. Measured on the +# machines whose datasheets identify it (F1155/S1155: -0.00552/K; F2040: -0.00277/K) and imported +# as a STATED ASSUMPTION by the two whose datasheets cannot (F750/F730 confound load with flow). +# The value is the arithmetic mean of the two measured ones - it used to say that while being +# -0.0046, which is the mean of nothing. +FLOW_EXERGY_PENALTY_PER_K = -0.00415 + +# Physical bounds on the exergy efficiency. A real machine achieves 30-70% of Carnot; these only +# stop a fit extrapolating off the end of its own data into nonsense, which the first version did. +# a + b*load + c*(flow-35). Three of them, so a fit needs at least four points to have any +# degrees of freedom at all - see HouseConfig.exergy_fit. +EXERGY_FIT_PARAMETERS = 3 + +MIN_EXERGY_EFFICIENCY = 0.15 +MAX_EXERGY_EFFICIENCY = 0.80 + +COP_RATING_FLOW_C = 35.0 # EN 14511 rating point is W35: the profile's COP curve is measured here +CONDENSER_APPROACH_K = 5.0 # refrigerant condenses this far above the water it is heating +EVAPORATOR_APPROACH_K = 5.0 # and evaporates this far below the source it is drawing from +MIN_LIFT_K = 10.0 # a compressor cannot usefully run at zero lift; bound the division +EXHAUST_AIR_SOURCE_C = 20.0 # F750/F730 draw ~20 C indoor extract air, all year +BRINE_SOURCE_C = 0.0 # F1155/S1155 draw ~0 C brine, stable year-round + # Comfort accounting matches the engine's configured tolerance (not a looser # ad-hoc band): minutes below target-tolerance count as under-heating. TARGET_INDOOR = 22.0 COMFORT_TOLERANCE = 0.5 +# THE DESIGN TEMPERATURE IS THE SIZING CONVENTION, AND IT IS LOAD-BEARING. +# +# Houses are sized from their pump's Pdesignh, so the design temperature decides how big each house +# is - and therefore whether the pump ever saturates at all. It moved the F750 between "saturates in +# a cold snap" and "does not". That is exactly the kind of arbitrary, unexamined choice this audit +# exists to find, and it used to be -15.0 with no justification whatsoever. +# +# NIBE declares Pdesignh at BOTH EN 14825 reference climates, and both are published: +# +# cold (-22 C) the Nordic reference. A Swedish house is sized here. +# average (-10 C) the central-European reference. The F730's ErP block confirms it by +# declaring TOL = -10 C. +# +# This is a Swedish integration simulating a Swedish January, so the COLD reference is the honest +# default. The average-climate sizing is not discarded - it is a real case (a pump under-sized for +# its house, which is the commonest installation fault there is) and `--undersized` runs it. The +# saturation finding is reported across BOTH, because it must not depend on which one I picked. +EN14825_COLD_DESIGN_C = -22.0 +EN14825_AVERAGE_DESIGN_C = -10.0 +DESIGN_OUTDOOR = EN14825_COLD_DESIGN_C + +# Sizing a house at the average-climate design point instead of the cold one makes it this much +# bigger for the same pump - i.e. it is the same as fitting a pump one size too small. +UNDERSIZED_PUMP_FACTOR = (22.0 - EN14825_COLD_DESIGN_C) / (22.0 - EN14825_AVERAGE_DESIGN_C) +DESIGN_SPREAD = 5.0 +RADIATOR_EXPONENT = 1.3 # EN 442 +UFH_EXPONENT = 1.1 # EN 1264 OVERSHOOT_TOLERANCE = 1.5 # overshoot band stays wider; heat is banked, not lost # Illustrative Swedish effect tariff (SEK per kW of the mean of the top-3 # daily quarter-hour-mean peaks, per month). Rate is fictional-but-typical; # the point is comparing runs, not billing accuracy. -EFFECT_TARIFF_SEK_PER_KW = 81.25 +# Ellevio's published rate, and it lives in const.py now - see SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH. +# The harness used to carry its own copy and call it "fictional-but-typical". It is neither: it is +# Ellevio's real 81,25 kr/kW/month, and production carried a DIFFERENT unsourced number (50.0). +EFFECT_TARIFF_SEK_PER_KW = SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH @dataclass @@ -95,47 +227,564 @@ class HouseConfig: profile: object heating_type: str design_flow: float # flow temp at design outdoor -15C - max_heat_kw: float @property def capacity_j_per_k(self) -> float: return self.hlc_w_per_k * self.tau_hours * 3600.0 @property - def k_emit(self) -> float: - # Sized so design heat demand is met at design flow with Tin=22 - design_q = self.hlc_w_per_k * (22.0 - (-15.0)) - return design_q / (self.design_flow - 22.0) + def emitter_exponent(self) -> float: + """EN 442 / EN 1264 exponent for this house's emitters.""" + return UFH_EXPONENT if self.heating_type != "radiator" else RADIATOR_EXPONENT @property - def curve_slope(self) -> float: - # Curve calibrated so the plant balances at 22 C indoor with offset 0 - # (a correctly tuned NIBE): flow_target(-15) == design_flow - return (self.design_flow - 22.0) / 37.0 + def design_excess(self) -> float: + """Mean water temperature above the room at the design point.""" + return self.design_flow - DESIGN_SPREAD / 2.0 - TARGET_INDOOR + + @property + def design_heat_w(self) -> float: + """Emitter output at the design point - net of the free heat the house makes itself.""" + return self.hlc_w_per_k * (TARGET_INDOOR - DESIGN_OUTDOOR) - INTERNAL_GAINS_W + + def heat_output_w(self, flow: float, indoor: float) -> float: + """Emitter output, by the EN 442 characteristic equation. + + Q / Q_design = (dT_mean / dT_mean_design) ** n + + A LINEAR emitter (n = 1) is not a radiator: it exaggerates output at low flow + temperatures, which flatters a controller that under-supplies. + + THE SPREAD IS CONSTANT. This model used to widen it with load - `DESIGN_SPREAD * + load_ratio` - and iterate to a fixed point. That is a FIXED-SPEED circulator on a wet + boiler: constant mass flow, so the flow-return spread rises and falls with the heat being + carried. A NIBE modulates its circulator (GP1) to HOLD the commissioned spread and varies + the flow RATE instead, which is why the controller's own emitter law holds it constant too. + + With the spread fixed there is no fixed point left to solve: the mean water temperature is + just `flow - spread/2`, and the output follows directly. + """ + excess = flow - DESIGN_SPREAD / 2.0 - indoor + if excess <= 0: + return 0.0 + return self.design_heat_w * (excess / self.design_excess) ** self.emitter_exponent + + def curve_flow_temp(self, outdoor: float, tuned: bool = False) -> float: + """The supply temperature the pump's own heating curve calls for, at offset 0. + + A correctly tuned NIBE curve follows the emitter law, not a straight line. NIBE's + published curve 9 (offset 0) reads 41 C at 0 C outdoor; the emitter law gives 40.6 C, + a straight line between the same anchors gives 38.7 C. Modelling the curve as linear + makes it under-supply everywhere between its endpoints, and the house cannot hold target + even with the controller switched off. + """ + # A STOCK NIBE CURVE HAS NO INTERNAL-GAINS TERM, and that is not an oversight in this + # model - it is what the hardware does. The installer picks a curve number and the pump + # draws a line from the design point; nothing in it knows that the occupants and the + # fridge are supplying several hundred watts. So a stock curve OVER-SUPPLIES in mild + # weather, and the simulated baseline house duly sits at 22.5 C against a 22.0 C target. + # + # WHICH MEANS THE DEFAULT BASELINE IS A SOFT ONE, AND I WAS QUOTING SAVINGS AGAINST IT. + # A diligent owner trims the curve down until the house actually holds target, and against + # THAT baseline the optimiser's saving falls from 1.5-4.4 % to 0.5-1.4 %. Most of what I + # reported was the controller correcting a mis-tuned curve rather than optimising anything. + # + # Both yardsticks are real and they answer different questions, so the harness offers both: + # `--tuned-baseline` gives the pump a curve that knows about the gains, which is the honest + # question "what is this worth to someone whose pump is already set up properly?" + return en442_flow_temp( + indoor_setpoint=TARGET_INDOOR, + outdoor_temp=outdoor, + design_outdoor_temp=DESIGN_OUTDOOR, + design_flow_temp=self.design_flow, + design_spread=DESIGN_SPREAD, + emitter_exponent=self.emitter_exponent, + balance_point_temp=( + TARGET_INDOOR - INTERNAL_GAINS_W / self.hlc_w_per_k if tuned else None + ), + ) + + @property + def immersion_heater_kw(self) -> float: + """This machine's immersion heater, from its datasheet. The F2040 has none. + + The plant used to give every house the same invented 3.0 kW, which is no machine's actual + setting. NIBE ships the F750 and F730 with a 6.5 kW heater set to 3.5 kW at delivery, and + the F1155-12/S1155-12 with a 7 kW heater in seven automatic steps. + """ + published = float(getattr(self.profile, "immersion_heater_kw", 0.0)) + return published if published > 0.0 else ASSUMED_INDOOR_MODULE_HEATER_KW + + @property + def aux_start_dm(self) -> float: + """Where the PLANT's additive heat engages: the pump's own factory start-addition. + + Not EffektGuard's -1500 emergency floor. A factory-default F750 fires its elpatron at + DM -700 and works the debt back up (menu 4.9.3; audit F-112) - waiting for the floor + under-fired the elpatron by hundreds of degree-minutes in exactly the runs meant to + measure what it costs, and the cold-snap headline was computed against a machine no + factory ships. + """ + return float(self.profile.aux_start_dm) + + def source_temp_c(self, outdoor_temp: float) -> float: + """The temperature of the heat SOURCE the compressor is lifting from. + + A heat pump's efficiency is set by the LIFT - how far it has to raise the heat - not by + the weather as such. What the weather changes is the source, and only for some machines: + + - Outdoor air (F2040): the source IS the outdoor air. + - Exhaust air (F750, F730): ~20 C indoor extract air, all year. The weather barely touches + it, which is why these pumps hold their COP through a cold snap. + - Ground source (F1155, S1155): ~0 C brine, stable year-round. + """ + if getattr(self.profile, "supports_exhaust_airflow", False): + return EXHAUST_AIR_SOURCE_C + if "GSHP" in getattr(self.profile, "model_type", ""): + return BRINE_SOURCE_C + return outdoor_temp + + @functools.cached_property + def exergy_fit(self) -> tuple[float, float, float]: + """(a, b, c) in eta = a + b*load + c*(flow - 35), fitted to this machine's OWN datasheet. + + THE COP MODEL USED TO BE ANCHORED ON A CURVE THAT WAS INVENTED. Every profile carried an + outdoor-keyed `cop_curve`, called "Real-world COP curve (tested and validated)" and sourced + to "NIBE F750 datasheet, Swedish NIBE forum validation". The F750 and F730 shipped + byte-identical curves despite being different machines, and the number 5.0 - labelled "Best + COP" - appears in neither datasheet. The simulator computed a month of kWh and SEK from it + and I published the savings. + + A heat pump's COP is the Carnot limit between its source and its sink, degraded by how good + the machine is, how hard it is pushed, and how hot the water is. All of that is IN the + datasheet: + + eta = COP_published / Carnot(source, flow) at each published rating point + load = PH_published / PH_max at that same point + + AND MY FIRST VERSION OF THIS FIT WAS ITSELF A FICTION. Fitting all three of the F750's + points gave b = +0.586 - efficiency RISING with load, which is backwards - and it + extrapolated to COP 9.86 at full load and 35 C flow. The simulator visits that condition, + and the Carnot guard (ceiling 12.5 there) would have waved it straight through. + + The cause was in the datasheet and I had not read it closely enough: the F750's two + MINIMUM-frequency points differ by AIRFLOW (108 vs 252 m3/h), not by compressor load. More + ventilation air, more source heat, higher output AND higher COP. They are not a load pair. + Drop the off-rating airflow point and only TWO usable points remain - and between them load + and flow move TOGETHER, so the F750's datasheet cannot separate the two effects at all. Any + fit that claims to is fitting noise. + + So the flow penalty is MEASURED where the data identifies it, and IMPORTED where it does + not, and the difference is stated rather than hidden: + + F1155 / S1155 c = -0.0055 /K measured (0/35 vs 0/45, and 10/35 vs 10/45) + F2040 c = -0.0028 /K measured (7/35 vs 7/45, and 2/35 vs 2/45) + F750 / F730 NOT IDENTIFIABLE - the mean of the above, as a stated ASSUMPTION + + That assumption is not a measurement of the F750 and nothing here pretends it is. + """ + rated_airflow = max( + (p.airflow_m3h for p in self.profile.datasheet_points if p.airflow_m3h), default=None + ) + points = [ + p + for p in self.profile.datasheet_points + if rated_airflow is None or p.airflow_m3h == rated_airflow + ] + ph_max = self.profile.max_heat_output_kw + + def eta(point) -> float: + return point.cop / self.carnot_at(point.source_temp_c, point.flow_temp_c) + + # THE FLOW PENALTY IS ONLY IDENTIFIABLE WITH MORE POINTS THAN PARAMETERS. + # + # My first identifiability test asked whether any two points shared a source temperature + # and differed in flow. The F750's two rated-airflow points do - but they ALSO differ in + # load, so the two effects are still confounded, and lstsq happily solved 3 unknowns from + # 2 equations and returned a minimum-norm answer with b = +0.10: efficiency rising with + # load. Backwards again, from a test I wrote to catch exactly that. + # + # Three parameters need at least four points. That is the whole condition. + if len(points) > EXERGY_FIT_PARAMETERS: + design = np.array( + [ + [1.0, p.heat_output_kw / ph_max, p.flow_temp_c - COP_RATING_FLOW_C] + for p in points + ] + ) + target = np.array([eta(p) for p in points]) + a, b, c = np.linalg.lstsq(design, target, rcond=None)[0] + return float(a), float(b), float(c) + + c = FLOW_EXERGY_PENALTY_PER_K + design = np.array([[1.0, p.heat_output_kw / ph_max] for p in points]) + target = np.array([eta(p) - c * (p.flow_temp_c - COP_RATING_FLOW_C) for p in points]) + a, b = np.linalg.lstsq(design, target, rcond=None)[0] + return float(a), float(b), c + + def exergy_efficiency(self, load_fraction: float, flow_temp: float) -> float: + """How much of Carnot this machine actually achieves, here. From its own datasheet.""" + a, b, c = self.exergy_fit + eta = a + b * min(max(load_fraction, 0.0), 1.0) + c * (flow_temp - COP_RATING_FLOW_C) + return min(max(eta, MIN_EXERGY_EFFICIENCY), MAX_EXERGY_EFFICIENCY) + + def cop_at(self, outdoor_temp: float, flow_temp: float, load_fraction: float = 1.0) -> float: + """COP = exergy_efficiency(load, flow) x Carnot(source, flow). No invented curve. + + Note what is NOT here: the outdoor temperature. It enters only through `source_temp_c`, and + for four of the five machines it does not enter at all - an exhaust-air pump breathes 20 C + house air and a ground-source pump drinks 0 C brine, whatever the weather is doing. The + model this replaces dropped an F1155's COP from 5.3 to 3.3 because the air outside got + cold, while its heat source sat at 0 C and never moved. + """ + source = self.source_temp_c(outdoor_temp) + return max( + 1.0, + self.exergy_efficiency(load_fraction, flow_temp) * self.carnot_at(source, flow_temp), + ) + + def carnot_at(self, source_temp: float, flow_temp: float) -> float: + """The thermodynamic ceiling between a SOURCE and a SINK.""" + t_cond = flow_temp + CONDENSER_APPROACH_K + KELVIN + t_evap = source_temp - EVAPORATOR_APPROACH_K + KELVIN + return t_cond / max(t_cond - t_evap, MIN_LIFT_K) + + def carnot_cop(self, outdoor_temp: float, flow_temp: float) -> float: + """The Carnot bound at this weather. The harness asserts the plant never beats it.""" + return self.carnot_at(self.source_temp_c(outdoor_temp), flow_temp) + + def capacity_kw_at(self, outdoor_temp: float) -> float: + """The most heat this machine can make right now. FROM ITS DATASHEET. + + AND IT DOES NOT DERATE AS IT GETS COLDER. It rises. + + This method used to be: + + derate = 1.0 - ASHP_DERATE_PER_C * max(0.0, ASHP_RATING_POINT_C - outdoor_temp) + return rated * max(ASHP_MIN_CAPACITY_FRACTION, derate) + + with a comment claiming "the EN 14511 rating points (A7/W35, A2/W35, A-7/W35, A-15/W35) + trace a near-linear decline". They trace a near-linear RISE. The F2040-8's published + capacity goes 3.86 -> 5.11 -> 6.60 kW from +7 to +2 to -7 C, because it is an INVERTER: at + its +7 rating point it is throttled back to part load, and as the weather cools it ramps + the compressor UP. What collapses in the cold is the COP (4.65 -> 3.76 -> 2.68), not the + capacity. There is no derating table in the datasheet because there is no derating. + + I invented that citation and got the sign of the effect backwards, and the entire + saturated-compressor finding (F-124) was built on the result. + + The capacity is now interpolated from the machine's own published points, against its own + SOURCE temperature - which for four of the five machines is a constant, so their capacity + is flat, which is correct and is what the datasheets show. Below the coldest published + point the curve is HELD, because NIBE tabulates nothing there (only a graph), and holding + is the honest thing to do with the end of the evidence. + """ + # BELOW THE PUBLISHED OPERATING FLOOR THERE IS NO MACHINE TO MODEL. + # + # The F2040 manual (IHB EN 1848-8/231846 p.65): "Min. / Max. air temp: -20 / 43 C". A hard + # edge, not a derating - strictly below it the unit does not run, and this model used to + # hold the -7 C capacity forever, making phantom compressor heat through 28% of a real + # Kiruna January. Only the F2040 carries a floor: NIBE publishes none for the brine and + # exhaust-air machines, whose sources the weather never touches, and inventing one would be + # exactly the unsourced physics this file exists to remove. AT the floor the machine is in + # range and every datasheet pin at -20.0 still holds. + floor = self.profile.min_operating_outdoor_c + if floor is not None and outdoor_temp < floor: + return 0.0 + + # THE MODULATION ENVELOPE WINS WHERE THE DATASHEET PUBLISHES ONE. + # + # "Heating capacity (PH): 3 - 12 kW" is what an F1155-12 can actually deliver. Its 0/35 + # rating point of 5.06 kW is its output at NOMINAL (50 Hz) frequency, and reading THAT as + # the machine's ceiling would halve a 12 kW heat pump. The exhaust-air pumps publish their + # maximum directly - their third rating point is explicitly "max compressor frequency" - so + # for them the envelope and the top rating point are the same number. + if self.profile.heating_capacity_range_kw[1] > 0.0: + return self.profile.heating_capacity_range_kw[1] + + # Only the F2040 has no envelope row, and it is the only machine whose source IS the + # weather. Its capacity is the EN 14511 curve against SOURCE temperature, HELD below the + # coldest published point - because NIBE tabulates nothing below -7 C, only a graph. + # + # THAT MEANS THIS UNDERSTATES THE F2040. Its true maximum below -7 C is not a number I + # have. Any saturation the simulator shows for this machine is therefore an UPPER BOUND on + # the real thing, and must never be reported as a measured failure. F-124 was. + source = self.source_temp_c(outdoor_temp) + by_source = sorted( + { + point.source_temp_c: point + for point in self.profile.datasheet_points + if point.flow_temp_c == COP_RATING_FLOW_C + }.items() + ) + if len(by_source) < 2: + return self.profile.max_heat_output_kw + + temps = [t for t, _ in by_source] + caps = [point.heat_output_kw for _, point in by_source] + + # BELOW THE COLDEST RATING POINT, NIBE'S OWN ErP DECLARATION CLOSES THE MODEL. + # + # The manual tabulates the F2040's maximum output down to -7 C and no further - below that + # it gives a graph. But the AVERAGE-climate ErP declaration is one complete published + # statement: Pdesignh 8.2 kW at -10 C with Psup 1.1 kW, so the COMPRESSOR must deliver + # 8.2 - 1.1 = 7.1 kW at -10 C, against 6.60 kW measured at -7 C. An earlier version + # spliced the COLD-climate Pdesignh (9.0, declared at -22) onto that same Psup and + # anchored the result at -22 - a capacity from two different declarations that NIBE + # never published, worth +0.8 kW of phantom compressor in exactly the runs that decide + # whether this machine saturates. + # + # So capacity keeps RISING below -7 C to the -10 C declaration, and is HELD below it, + # because that is where every published statement stops. + # + # The old model derated 2.5 %/C in the opposite direction and blamed EN 14511 for it. + pdesign_avg = self.profile.design_heat_load_average_kw + psup = self.profile.supplementary_heat_kw + if source < temps[0] and pdesign_avg > 0.0 and psup > 0.0: + at_design = pdesign_avg - psup + if EN14825_AVERAGE_DESIGN_C < temps[0]: + span = temps[0] - EN14825_AVERAGE_DESIGN_C + frac = min(1.0, (temps[0] - source) / span) + return caps[0] + (at_design - caps[0]) * frac + return at_design + + return float(np.interp(source, temps, caps)) + + +# EVERY HOUSE IS SIZED FROM ITS PUMP'S OWN Pdesignh. It used to be sized from nothing at all. +# +# NIBE declares, for every machine, the design heat load it is certified for. That is the +# manufacturer's own statement of how big a house the pump is for, and it is the only sourced way +# to size a simulated building: +# +# hlc = (Pdesignh + internal_gains) / (target_indoor - design_outdoor) +# +# The houses used to carry invented heat-loss coefficients, and three of the five paired a pump +# with a house it was far too big for: +# +# concrete_f1155 6.06 kW house, 12 kW pump -> 2.0x oversized +# villa_s1155 5.32 kW house, 12 kW pump -> 2.3x oversized +# apartment_f730 2.73 kW house, 5 kW pump -> 1.8x oversized +# +# THAT DECIDED WHAT THE SIMULATION WAS ABLE TO FIND. A pump with twice the capacity its house needs +# cannot saturate, cannot fall behind, and cannot reach for its immersion heater - so it can never +# exercise the degree-minute recovery ladder at all. I reported that "the ground-source houses never +# engage the emergency ladder" as if it were a fact about the controller. It was a fact about my +# sizing. The only two correctly-sized systems in the set were the only two that failed. +# +# DESIGN_OUTDOOR is the Swedish DVUT (dimensionerande vinterutetemperatur) for mid-Sweden; Boverket +# puts Stockholm near -16 C. It is a stated convention of this harness, not a datasheet figure, and +# every house is sized against it consistently, so the PAIRING is what is being asserted here. +# WHERE EVERY NUMBER IN THIS PLANT MODEL CAME FROM. +# +# This table exists because the numbers that came from nowhere were the ones that decided what the +# simulation was able to find, and nobody could tell them apart from measurements. The COP curves +# were called "Real-world ... (tested and validated)" and sourced to "NIBE F750 datasheet, Swedish +# NIBE forum validation"; they were in neither. The capacity derating cited EN 14511 and ran in the +# opposite direction to it. The houses had heat-loss coefficients from nowhere at all. +# +# Each entry is exactly one of two things, and the difference is the point: +# +# SOURCED a document, quoted, that a reader can open. +# ASSUMED no published source exists. Then the sensitivity is MEASURED and stated here, because +# an unsourced number that moves the answer is a finding about the modeller. +# +# tests/validation/test_every_simulator_constant_says_where_it_came_from.py enforces it: a new +# physical constant cannot be added to this file without declaring one or the other. +PROVENANCE: dict[str, str] = { + # ---- the pump, from NIBE ---- + "DM_START": ( + "SOURCED: NIBE starts the compressor at -60 degree minutes. docs/research/01_degree_" + "minutes.md, from the NIBE manual (menu 4.9.3)." + ), + "DM_STOP": "SOURCED: NIBE stops the compressor at 0 degree minutes. docs/research/01.", + "COP_RATING_FLOW_C": ( + "SOURCED: EN 14511 rates heat pumps at W35. Every NIBE datasheet's rating points say so - " + "'A20(12)W35', '0/35 nominal', 'A7/W35'." + ), + "DESIGN_SPREAD": ( + "SOURCED: EN 14511 dT5K - the 5 K water-side temperature difference the standard rates at. " + "The F2040 installer manual's table header says it verbatim: 'Output data according to " + "EN 14511 dT5K'. IHB EN 1848-8/231846 p.65." + ), + "RADIATOR_EXPONENT": "SOURCED: EN 442 panel radiators, n = 1.3. docs/research/02_emitter_law.md.", + "UFH_EXPONENT": "SOURCED: EN 1264 underfloor heating, n = 1.1. docs/research/02_emitter_law.md.", + "EXHAUST_AIR_SOURCE_C": ( + "SOURCED: the F750 and F730 are rated at A20(12) - 20 C dry-bulb extract air. That IS their " + "heat source, and it does not change with the weather. NIBE F750 datasheet, part no. " + "066 063." + ), + "BRINE_SOURCE_C": ( + "SOURCED: the F1155 and S1155 are rated at B0 - 0 C incoming brine. Their capacity chart's " + "x-axis is labelled 'Incoming brine temp, C'. F1155 installer manual IHB EN 2008-5/331379." + ), + "STOCKHOLM_LATITUDE": ( + "SOURCED: geography (https://www.lantmateriet.se - Stockholm 59.33 N). The latitude every " + "non-arctic scenario has always run at; selects ClimateZoneDetector's Southern Nordics " + "band (56.0-60.5)." + ), + "KIRUNA_LATITUDE": ( + "SOURCED: geography (Kiruna 67.86 N, inside the Arctic Circle). Weather: Open-Meteo ERA5 " + "(https://archive-api.open-meteo.com, Kiruna, January 2024). Prices: Nord Pool SE1 via " + "https://www.elprisetjustnu.se for the same dates. DVUT: Boverket 1991-2020 " + "(https://www.boverket.se, Kiruna 1-dygn -29.4 C). Selects the integration's " + "own Arctic climate zone (66.5-90.0 in climate_zones.py), which is the point of the " + "scenario - the zone logic runs for real. The paired weather file is Open-Meteo ERA5 for " + "Kiruna, January 2024 (min -36.8 C; 211 of 744 hours below the F2040's published -20 C " + "floor; SMHI: Kiruna Flygplats reached -36.7 C on 4-5 Jan, 7.3 C below the town's " + "Boverket DVUT 1-dygn of -29.4 C). Prices are the real Nord Pool SE1 days for the same " + "dates (elprisetjustnu.se), including the 5 Jan 2024 spike to 589 ore/kWh." + ), + "DST_FALL_BACK_PERIODS": ( + "SOURCED: the IANA time zone database (https://www.iana.org/time-zones), zone " + "Europe/Stockholm. On 2026-10-25 the offset goes from +02:00 to +01:00 at 03:00 local, so " + "the wall-clock hour 02 is metered twice and the day is 25 hours long - 100 fifteen-minute " + "billing periods at the owner's tariff cadence. EU Directive 2000/84/EC fixes the " + "transition to the last Sunday of October across the union. Verified by stepping the " + "absolute time line through the zone: the harness counts 100 distinct billing periods on " + "that date and fails the run if it does not." + ), + "EN14825_COLD_DESIGN_C": ( + "SOURCED: EN 14825 cold-climate reference design temperature. NIBE declares a Pdesignh at " + "this reference for every machine, and the houses are sized from it." + ), + "EN14825_AVERAGE_DESIGN_C": ( + "SOURCED: EN 14825 average-climate reference. The F730's own ErP block confirms it by " + "declaring TOL = -10 C. Used by --undersized, which sizes a house here and fits it a pump " + "certified for the cold reference - the commonest installation fault there is." + ), + "STANDBY_KW": ( + "SOURCED (as a range; the value is mid-band): the F750 datasheet, part no. 066 063, " + "publishes its running auxiliaries - 'Drive output heating medium pump 2: 5-45 W' and " + "'Driving power exhaust air fan: 25-140 W', so 30-185 W with the compressor running. " + "0.1 kW sits inside that. Swept " + "across the full published band the saturation findings do not move at all (2.1-2.2x vs " + "2.2x baseline)." + ), + "FLOW_EXERGY_PENALTY_PER_K": ( + "SOURCED where the datasheets identify it, ASSUMED where they cannot, and the difference " + "is stated in HouseConfig.exergy_fit. Measured from the EN 14511 rating points of the " + "F1155/S1155 (IHB EN 2008-5/331379: -0.0055/K) and the F2040 (IHB EN 1848-8/231846: " + "-0.0028/K), which publish W35 and W45 at the same source and load. The F750/F730 confound " + "load with flow and cannot identify it, so they inherit the mean of the two." + ), + "ASSUMED_INDOOR_MODULE_HEATER_KW": ( + "ASSUMED. The F2040 has NO immersion heater - it is an outdoor monobloc and its electric " + "addition lives in the paired indoor module (VVM/SMO), which this package does not model. " + "Every other machine's heater is on its profile, from its datasheet. Sensitivity: the F750's " + "cold-snap burn moved 38.1 -> 35.8 kWh when the heaters were sourced per machine, and the " + "saturation finding did not move." + ), + # ---- the plant, where NIBE publishes nothing ---- + "CONDENSER_APPROACH_K": ( + "ASSUMED. No datasheet publishes the refrigerant's approach temperatures. The exergy fit " + "ABSORBS them at the rating points - a different Carnot gives a different eta that " + "reproduces the same published COP - so the datasheet is matched whatever this is. Away " + "from the rating points it matters, by up to 42 % on an extrapolation to W55 full load. " + "Sensitivity, swept 3-7 K through the whole simulation: seasonal cost moves +/-2 %, and the " + "saturation finding does not move AT ALL, because saturation is a capacity constraint and " + "not an efficiency one." + ), + "EVAPORATOR_APPROACH_K": "ASSUMED. See CONDENSER_APPROACH_K - same assumption, same sensitivity.", + "WATER_LOOP_J_PER_K": ( + "ASSUMED, and only half of it could be sourced. The F750 publishes its own buffer: 'Volume " + "boiler section (of which buffer vessel) litre 35 (25)' - 35 L of water is 146 kJ/K. The " + "EMITTER side is a property of the HOUSE, and NIBE publishes no system volume for it (the " + "manuals only say 'if the climate system volume is too small ... supplement with a buffer " + "vessel'). 350 kJ/K is about 84 L of water-equivalent: the pump's 35 L plus a radiator " + "circuit. Sensitivity, halved and doubled: the saturation finding holds throughout " + "(2.0-3.0x over what physics forces, houses cooked to 27.8-30.3 C, against 2.0-2.5x and " + "29.1-29.8 C at the committed value)." + ), + "COMPRESSOR_RESPONSE_S": ( + "ASSUMED. How briskly the compressor closes on its flow target. No datasheet publishes it. " + "Sensitivity, swept 300-1800 s: the saturation finding holds throughout (1.9-2.4x, houses " + "at 29.1-29.4 C)." + ), +} + + +def compressor_available(house: "HouseConfig", outdoor_c: float) -> bool: + """Whether this machine's compressor can run at this outdoor temperature. + + THE ONE RULE both the plant physics and the reported NibeState derive from. Zeroing capacity + alone left `compressor_on` True, so the plant reported compressor_hz > 0 and is_heating=True + to the DecisionEngine for a machine that was physically stopped - the engine under test was + being fed a lying plant state, and its decisions below -20 C were decisions about a fiction. + """ + return house.capacity_kw_at(outdoor_c) > 0.0 HOUSES = [ HouseConfig( - name="wooden_f750", + name="wooden_f750", # exhaust air, radiators, light timber frame. ~130 m2. thermal_mass=0.7, insulation_quality=1.0, - hlc_w_per_k=150.0, + hlc_w_per_k=127.0, # F750 Pdesignh 5.0 kW at the EN 14825 COLD design point (-22 C) tau_hours=30.0, profile=NibeF750Profile(), heating_type="radiator", design_flow=50.0, - max_heat_kw=8.0, ), HouseConfig( - name="concrete_f1155", + name="concrete_f1155", # ground source, underfloor, heavy slab. A LARGE villa, ~280 m2. thermal_mass=1.8, insulation_quality=1.2, - hlc_w_per_k=180.0, + hlc_w_per_k=286.0, # F1155-12 Pdesignh 12 kW at -22 C. Was 180 - the pump was twice the house. tau_hours=80.0, profile=NibeF1155Profile(), heating_type="concrete_ufh", design_flow=38.0, - max_heat_kw=12.0, + ), + HouseConfig( + name="apartment_f730", # DELIBERATELY OVERSIZED, and that is the point of this one. + # + # The F730 is the SMALLEST exhaust-air machine NIBE makes, and a small flat cannot buy a + # smaller one. So a 2.7 kW flat gets a 5 kW pump, and that is not a modelling error - it is + # what actually happens. It is kept, and named, so that the set contains one system where + # the pump has headroom to spare. The difference between this house and the other four is + # now a STATED scenario rather than an accident of numbers nobody checked. + thermal_mass=0.9, + insulation_quality=1.3, + hlc_w_per_k=90.0, # 2.7 kW load against a 5 kW pump: 1.8x oversized, on purpose + tau_hours=45.0, + profile=NibeF730Profile(), + heating_type="radiator", + design_flow=45.0, + ), + HouseConfig( + name="villa_s1155", # S-series ground source, timber underfloor. A large villa. + thermal_mass=1.2, + insulation_quality=1.1, + hlc_w_per_k=286.0, # S1155-12 Pdesignh 12 kW at -22 C. Was 160 - the pump was 2.3x the house. + tau_hours=55.0, + profile=NibeS1155Profile(), + heating_type="timber_ufh", + design_flow=40.0, + ), + HouseConfig( + name="airsource_f2040", # outdoor air. The only machine whose source IS the weather. + # + # EVERY NUMBER HERE COMES FROM THE SAME COLUMN OF THE DATASHEET, and it did not used to. + # NIBE publishes the F2040-8's capacity and COP at 35 C flow, and its Pdesignh separately + # for the 35 C and 55 C applications (9.0 and 10.0 kW in a cold climate). The house was + # sized from the 35 C Pdesignh and then run at a 55 C design flow, where the machine is + # weaker - three inputs from three different columns. It is a low-temperature (underfloor) + # system now, so the capacity curve, the COP and the design load all describe one machine in + # one application. + # + # WHAT REMAINS UNKNOWN, and it bounds every conclusion drawn from this house: NIBE tabulates + # the F2040's maximum output only down to -7 C. Below that the manual gives a GRAPH and no + # numbers, so the model holds capacity at the -7 C figure. A Swedish January goes lower. The + # results for this house below -7 C therefore rest on an assumption, and are reported as a + # bound rather than a measurement. The F750 carries no such caveat - see the F-124 test. + thermal_mass=1.0, + insulation_quality=0.9, + hlc_w_per_k=218.0, # F2040-8 Pdesignh 9.0 kW (COLD climate, 35 C application) + tau_hours=40.0, + profile=NibeF2040Profile(), + heating_type="concrete_ufh", + design_flow=35.0, # the flow temperature its published capacity curve is measured at ), ] @@ -150,24 +799,117 @@ def apply_coldsnap(times, temps): return out -def load_data(selftest: bool): - """Load real weather + prices, or synthetic 2-day data for --selftest.""" +def _to_gespot_shape(days: dict, ore_per_unit: float) -> dict[str, list[dict[str, Any]]]: + """Normalise a raw price file into the GE-Spot attribute shape. + + Every price source ends up as {"time": iso8601, "value": } so a + single code path - the real adapter - parses all of them. Hourly sources are + expanded to four identical quarters, which is what an hourly market genuinely + means for a quarter-hour tariff. + """ + utc = zoneinfo.ZoneInfo("UTC") + out: dict[str, list[dict[str, Any]]] = {} + for day, raw in days.items(): + entries: list[dict[str, Any]] = [] + expand = 1 if len(raw) >= 90 else QUARTERS_PER_HOUR + for item in raw: + start = datetime.fromisoformat(item["start"]) + if start.tzinfo is None: + start = start.replace(tzinfo=TZ) + + # THE QUARTERS ARE STEPPED ON THE ABSOLUTE LINE, AND THE FOLD IS WHY. + # + # This used to convert to Europe/Stockholm and then do + # `(start + timedelta(minutes=15 * q)).isoformat()`. Adding a timedelta to an AWARE + # datetime is wall-clock arithmetic, and - this is the part that is easy to miss - + # `datetime.__add__` RESETS `fold` TO 0. Even at q = 0, where the timedelta is zero. + # + # So on the night the clocks go back, the second 02:00 (CET, fold=1) came back out of + # here stamped +02:00: an exact duplicate of the first 02:00 (CEST), and the CET hour's + # prices vanished. The harness then reported that the integration could not price the + # repeated hour - a defect in the INSTRUMENT, presented as a defect in the code it was + # measuring. The real adapter parses GE-Spot's own timestamps, which carry the right + # offset, and compares them interzone (i.e. in UTC); it prices that hour correctly. + # + # Stepping UTC and converting back keeps each quarter the instant it actually is. + base = start.astimezone(utc) + for q in range(expand): + moment = (base + timedelta(minutes=QUARTER_MINUTES * q)).astimezone(TZ) + entries.append({"time": moment.isoformat(), "value": item["price"] * ore_per_unit}) + out[day] = entries + return out + + +def load_live_se4() -> tuple[dict[str, list[dict[str, Any]]], str]: + """The real SE4 day captured from a live GE-Spot integration. + + Already in GE-Spot's own attribute shape, so it is handed to the adapter + untouched - byte-for-byte what the integration sees in production. + """ + payload = json.loads((DATA_DIR / "gespot_live_se4.json").read_text(encoding="utf-8")) + attrs = payload["attributes"] + days: dict[str, list[dict[str, Any]]] = {} + for key in ("today_interval_prices", "tomorrow_interval_prices"): + for item in attrs.get(key) or []: + day = datetime.fromisoformat(item["time"]).date().isoformat() + days.setdefault(day, []).append({"time": item["time"], "value": item["value"]}) + return days, attrs["unit_of_measurement"] + + +def _synthetic_days(start: datetime, days: int): + """Synthetic weather + quarter-hourly prices, generated on the ABSOLUTE time line. + + Everything here steps UTC and converts back, because the wall clock is not a uniform ruler. The + day the clocks go back is 25 hours long and carries 100 quarter-hour prices, not 96 - and a + generator that assumes 96 would quietly manufacture a day that no market ever published, which + is the opposite of what a harness is for. + """ + start_absolute = start.astimezone(zoneinfo.ZoneInfo("UTC")) + end_absolute = (start + timedelta(days=days)).astimezone(zoneinfo.ZoneInfo("UTC")) + + hours = int((end_absolute - start_absolute).total_seconds() // 3600) + times = [(start_absolute + timedelta(hours=h)).astimezone(TZ) for h in range(hours)] + temps = [-5.0 + 4.0 * ((t.hour % 24) / 24.0) for t in times] + + raw: dict = {} + quarters = int((end_absolute - start_absolute).total_seconds() // (60 * QUARTER_MINUTES)) + for q in range(quarters): + moment = (start_absolute + timedelta(minutes=QUARTER_MINUTES * q)).astimezone(TZ) + # The expensive blocks are wall-clock ones (morning and evening peaks), so they are keyed + # off the LOCAL quarter-of-day - which is what a price area actually does. + local_quarter = moment.hour * 4 + moment.minute // QUARTER_MINUTES + price = 500.0 + 400.0 * (1 if 28 <= local_quarter <= 40 or 68 <= local_quarter <= 80 else 0) + raw.setdefault(moment.date().isoformat(), []).append( + {"start": moment.isoformat(), "price": price} + ) + return times, temps, _to_gespot_shape(raw, ORE_PER_KWH_FROM_SEK_PER_MWH), GESPOT_UNIT_ORE + + +def load_data(selftest: bool, live_se4: bool = False, dst: bool = False, arctic: bool = False): + """Load real weather + prices, or synthetic data for --selftest / --dst.""" + if arctic: + # A REAL arctic month: Kiruna, January 2024 (Open-Meteo ERA5 - min -36.8 C, 28% of the + # month below the F2040's published -20 C operating floor) against the REAL Nord Pool SE1 + # prices for the SAME dates (elprisetjustnu.se), including the 5 January spike to + # 589 ore/kWh two days after the deepest cold. No re-stamping, no shape replay: the + # weather and the prices are the same real days. + weather = json.load(open(DATA_DIR / "weather_kiruna_jan2024.json")) + times = [datetime.fromisoformat(t).replace(tzinfo=TZ) for t in weather["hourly"]["time"]] + temps = weather["hourly"]["temperature_2m"] + payload = json.load(open(DATA_DIR / "prices_se1_jan2024.json")) + # Hourly ore/kWh entries; _to_gespot_shape expands each hour to four quarters. + return times, temps, _to_gespot_shape(payload["days"], 1.0), GESPOT_UNIT_ORE + + if dst: + # The last Sunday of October 2026: at 03:00 CEST the clock goes back to 02:00 CET, so the + # wall-clock hour 02 happens TWICE and the day is 25 hours long. This is the day on which + # the coordinator used to DELETE a billing hour - see + # tests/unit/coordinator/test_the_billing_period_survives_the_clocks_going_back.py - and the + # harness could not see it, because its own clock advanced by wall time and its tariff + # periods were keyed on (date, hour), which those two hours share. + return _synthetic_days(datetime(2026, 10, 24, tzinfo=TZ), 3) if selftest: - start = datetime(2026, 1, 1, tzinfo=TZ) - hours = 48 - temps = [-5.0 + 4.0 * ((h % 24) / 24.0) for h in range(hours)] - times = [start + timedelta(hours=h) for h in range(hours)] - prices = {} - for d in range(2): - day = (start + timedelta(days=d)).date().isoformat() - prices[day] = [ - { - "start": (start + timedelta(days=d, minutes=15 * q)).isoformat(), - "price": 500.0 + 400.0 * (1 if 28 <= q <= 40 or 68 <= q <= 80 else 0), - } - for q in range(96) - ] - return times, temps, prices + return _synthetic_days(datetime(2026, 1, 1, tzinfo=TZ), 2) weather = json.load(open(DATA_DIR / "weather_jan2026.json")) times = [ @@ -175,8 +917,40 @@ def load_data(selftest: bool): for t in weather["hourly"]["time"] ] temps = weather["hourly"]["temperature_2m"] + + if live_se4: + # Real captured SE4 prices, replayed against January weather. The market + # day is a July one; the point is the price SHAPE (a 41x spread between + # cheapest and dearest quarter), which is far harsher on the optimiser + # than the January SE3 profile. + se4_days, unit = load_live_se4() + days: dict[str, list[dict[str, Any]]] = {} + # Take the price SHAPE (the ordered quarters) and re-stamp it onto the simulated days. + # The timestamps must be REBUILT in the simulation's timezone, not edited: the captured + # day is a July one at UTC+02:00 and the simulated days are January at UTC+01:00, so + # rewriting only the date leaves every interval an hour out of place - which is exactly + # what the adapter's timestamp lookup then refuses to price, and rightly so. + shape = [values for _, values in sorted(se4_days.items())] + midnight = times[0].replace(hour=0, minute=0, second=0, microsecond=0) + for index in range(SIM_DAYS + 1): + start = midnight + timedelta(days=index) + entries = shape[index % len(shape)] + days[start.date().isoformat()] = [ + { + "time": (start + timedelta(minutes=QUARTER_MINUTES * quarter)).isoformat(), + "value": entry["value"], + } + for quarter, entry in enumerate(entries) + ] + return times, temps, days, unit + prices = json.load(open(DATA_DIR / "prices_jan2026.json"))["days"] - return times, temps, prices + return ( + times, + temps, + _to_gespot_shape(prices, ORE_PER_KWH_FROM_SEK_PER_MWH), + GESPOT_UNIT_ORE, + ) def outdoor_at(times, temps, when: datetime) -> float: @@ -188,47 +962,154 @@ def outdoor_at(times, temps, when: datetime) -> float: return temps[idx] * (1 - frac) + temps[idx + 1] * frac -def quarters_for_day(prices: dict, day: datetime) -> list[QuarterPeriod]: - """Build QuarterPeriods (ore/kWh) for a day; expand hourly data if needed.""" - raw = prices.get(day.date().isoformat()) - if not raw: - return [] - periods = [] - if len(raw) >= 90: # 15-min data - for entry in raw: - st = datetime.fromisoformat(entry["start"]) - if st.tzinfo is None: - st = st.replace(tzinfo=TZ) - periods.append( - QuarterPeriod(start_time=st.astimezone(TZ), price=entry["price"] / 10.0) - ) # SEK/MWh -> ore/kWh - else: # hourly -> repeat 4x - for entry in raw: - st = datetime.fromisoformat(entry["start"]) - if st.tzinfo is None: - st = st.replace(tzinfo=TZ) - st = st.astimezone(TZ) - for q in range(4): - periods.append( - QuarterPeriod( - start_time=st + timedelta(minutes=15 * q), price=entry["price"] / 10.0 - ) - ) - return periods +class _StubState: + """The two fields GESpotAdapter reads off a Home Assistant state object.""" + + def __init__(self, state: str, attributes: dict[str, Any]): + self.state = state + self.attributes = attributes + + +class _StubStates: + def __init__(self) -> None: + self._states: dict[str, _StubState] = {} + def set(self, entity_id: str, state: _StubState) -> None: + self._states[entity_id] = state -def build_engine(house: HouseConfig, mode: str = "balanced"): + def get(self, entity_id: str) -> _StubState | None: + return self._states.get(entity_id) + + +class _StubHass: + """Just enough Home Assistant to run the real adapter against.""" + + def __init__(self) -> None: + self.states = _StubStates() + + +class PriceSource: + """Feeds the simulation through the REAL GESpotAdapter. + + The harness used to construct QuarterPeriod objects by hand, which meant the + adapter that actually runs in production - unit detection, timestamp parsing, + the sort by absolute instant, the DST-aware interval lookup - was never + exercised by any simulation. A price-parsing regression could not have been + caught here. Now the day's intervals are published as a Home Assistant state + shaped exactly like a live GE-Spot entity, and the adapter parses it. + + PriceData is cached per (day, tomorrow-visible), so the adapter runs ~62 times + across a month rather than once per 5-minute step. + """ + + ENTITY_ID = "sensor.gespot_current_price_sim" + + def __init__(self, days: dict[str, list[dict[str, Any]]], unit: str): + self._days = days + self._unit = unit + self._hass = _StubHass() + self._adapter = GESpotAdapter( + self._hass, # type: ignore[arg-type] + {CONF_GESPOT_ENTITY: self.ENTITY_ID}, + ) + self._cache: dict[tuple[str, bool], PriceData] = {} + + @property + def unit(self) -> str: + """The unit the adapter detected off the entity (not what we assumed).""" + return self._adapter.price_unit or self._unit + + def get(self, now: datetime) -> PriceData: + today_key = now.date().isoformat() + tomorrow_key = (now + timedelta(days=1)).date().isoformat() + tomorrow_visible = now.hour >= TOMORROW_VISIBLE_HOUR + cache_key = (today_key, tomorrow_visible) + if cache_key in self._cache: + return self._cache[cache_key] + + today_raw = self._days.get(today_key, []) + tomorrow_raw = self._days.get(tomorrow_key, []) if tomorrow_visible else [] + + current = today_raw[0]["value"] if today_raw else 0.0 + self._hass.states.set( + self.ENTITY_ID, + _StubState( + state=str(current), + attributes={ + "unit_of_measurement": self._unit, + "currency": "SEK", + "today_interval_prices": today_raw, + "tomorrow_interval_prices": tomorrow_raw, + }, + ), + ) + price_data = asyncio.run(self._adapter.get_prices()) + self._cache[cache_key] = price_data + return price_data + + +# Reference thermal-battery controller. Not a proposal for production - a YARDSTICK. It knows +# nothing about degree minutes, weather, peaks or the pump; it only charges the house when power +# is cheap and coasts when it is dear, inside a hard comfort band. If EffektGuard cannot beat +# this, the sophistication is not paying for itself. +BATTERY_BAND = 1.0 # °C swing around target the house is allowed to use as storage +BATTERY_CHARGE_OFFSET = 4.0 # curve offset while charging on cheap power +BATTERY_COAST_OFFSET = -4.0 # curve offset while coasting on dear power +BATTERY_CHEAP_PERCENTILE = 30 # below this percentile of the day, charge +BATTERY_DEAR_PERCENTILE = 70 # above this percentile of the day, coast + + +def battery_reference_offset(price_data: PriceData, now: datetime, indoor: float) -> float: + """Charge the fabric when power is cheap, coast when dear, never leave the comfort band.""" + if indoor > TARGET_INDOOR + BATTERY_BAND: + return BATTERY_COAST_OFFSET # full - stop charging + if indoor < TARGET_INDOOR - BATTERY_BAND: + return BATTERY_CHARGE_OFFSET # flat - must heat regardless of price + + prices = [q.price for q in price_data.today] + period = price_data.get_period(now) + if not prices or period is None: + return 0.0 + + ordered = sorted(prices) + cheap = ordered[int(len(ordered) * BATTERY_CHEAP_PERCENTILE / 100)] + dear = ordered[int(len(ordered) * BATTERY_DEAR_PERCENTILE / 100)] + + if period.price <= cheap: + return BATTERY_CHARGE_OFFSET + if period.price >= dear: + return BATTERY_COAST_OFFSET + return 0.0 + + +def build_engine( + house: HouseConfig, + mode: str = "balanced", + enable_price: bool = True, + enable_weather: bool = True, + tuned_curve: bool = False, + latitude: float = STOCKHOLM_LATITUDE, +): + """Build the real DecisionEngine for this house. + + `enable_price` / `enable_weather` exist so the harness can ABLATE a layer and attribute the + result. "The optimiser costs 2 % more than doing nothing" is not actionable; "the price layer + costs 3 % and the weather compensation saves 1 %" is. + """ hass = MagicMock() effect = EffectManager(hass) + # The harness has no Home Assistant storage; the peak history lives for the run only. + effect._store = MagicMock() + effect._store.async_save = AsyncMock() thermal = ThermalModel(house.thermal_mass, house.insulation_quality) config = { "target_indoor_temp": TARGET_INDOOR, "tolerance": COMFORT_TOLERANCE, "optimization_mode": mode, - "enable_weather_compensation": True, + "enable_weather_compensation": enable_weather, "enable_peak_protection": True, - "enable_price_optimization": True, - "latitude": 59.33, + "enable_price_optimization": enable_price, + "latitude": latitude, "heating_type": house.heating_type, "heat_loss_coefficient": house.hlc_w_per_k, "thermal_mass": house.thermal_mass, @@ -248,20 +1129,27 @@ def simulate( house: HouseConfig, times, temps, - prices, + price_source: PriceSource, days: int, mode: str = "balanced", baseline: bool = False, + fixed_offset: float | None = None, + battery: bool = False, + enable_price: bool = True, + enable_weather: bool = True, + tuned_curve: bool = False, + forecast_available: bool = True, + latitude: float = STOCKHOLM_LATITUDE, ): - engine, effect = build_engine(house, mode) + engine, effect = build_engine(house, mode, enable_price, enable_weather, latitude=latitude) start = times[0].replace(hour=0, minute=0, second=0, microsecond=0) steps = days * 24 * 60 // STEP_MIN indoor = 22.0 + indoor_start = indoor dm = -30.0 offset_applied = 0 # integer offset "in the pump" (register 47011) - accumulator_ref = 0 # mirrors adapter _last_nibe_offset behaviour compressor_on = True flow = 30.0 @@ -275,6 +1163,7 @@ def simulate( "cost_sek": 0.0, "energy_kwh": 0.0, "aux_kwh": 0.0, + "unavoidable_aux_kwh": 0.0, "writes": 0, "offset_min": 0, "offset_max": 0, @@ -282,238 +1171,885 @@ def simulate( "comfort_minutes_below": 0, "comfort_minutes_above": 0, "compressor_starts": 0, + "compressor_blocked_hours": 0.0, "sign_flips": 0, + "heat_kwh": 0.0, + "loss_kwh": 0.0, + "layer_votes": {}, + "water_node_leak_kwh": 0.0, + "flow_target_max": -999.0, + "compressor_heat_kwh": 0.0, + "datasheet_cop_x_heat": 0.0, } + best_published_cop = max(p.cop for p in house.profile.datasheet_points) last_offsets = [] - quarter_samples: list[float] = [] - quarter_id = None - daily_peaks: dict = {} # date -> max quarter-mean kW - - for step in range(steps): - now = start + timedelta(minutes=STEP_MIN * step) - # Freeze engine wall clock to sim time - dt_util.now = lambda tz=None, _n=now: _n - dt_util.utcnow = lambda _n=now: _n.astimezone(zoneinfo.ZoneInfo("UTC")) - - tout = outdoor_at(times, temps, now) - - # --- plant step --- - flow_target = 22.0 + house.curve_slope * (22.0 - tout) + offset_applied - if compressor_on: - flow = min(flow + FLOW_RAMP_ON * STEP_MIN, flow_target + 1.0) - else: - flow = max(flow - FLOW_DECAY_OFF * STEP_MIN, indoor) - - q_w = max(0.0, house.k_emit * (flow - indoor)) - q_w = min(q_w, house.max_heat_kw * 1000.0) - - aux_kw = 0.0 - if dm <= DM_AUX: - aux_kw = AUX_STEP_KW - q_w += aux_kw * 1000.0 - - # Indoor temperature ODE - d_indoor = (q_w - house.hlc_w_per_k * (indoor - tout)) / house.capacity_j_per_k - indoor += d_indoor * STEP_MIN * 60.0 - - # DM dynamics + compressor hysteresis - dm += (flow - flow_target) * STEP_MIN - dm = max(-3000.0, min(dm, 100.0)) - if not compressor_on and dm <= DM_START: - compressor_on = True - stats["compressor_starts"] += 1 - elif compressor_on and dm >= DM_STOP: - compressor_on = False - - cop = house.profile.get_cop_at_temperature(tout) - power_kw = (q_w / 1000.0 - aux_kw) / cop + aux_kw + 0.1 if compressor_on or aux_kw else 0.1 - hz = 40 + int(min(50, max(0, (flow_target - indoor)))) if compressor_on else 0 - - # --- price/weather context --- - today_q = quarters_for_day(prices, now) - tomorrow_q = ( - quarters_for_day(prices, now + timedelta(days=1)) - if now.hour >= TOMORROW_VISIBLE_HOUR - else [] - ) - price_data = PriceData(today=today_q, tomorrow=tomorrow_q, has_tomorrow=bool(tomorrow_q)) - cur_q = (now.hour * 4) + now.minute // 15 - cur_price_ore = today_q[cur_q].price if len(today_q) > cur_q else 100.0 - - fc = [ - WeatherForecastHour( - datetime=now + timedelta(hours=h), - temperature=outdoor_at(times, temps, now + timedelta(hours=h)), + # The REAL one, from the integration. Not a copy of it. + billing = BillingPeriodAccumulator() + daily_peaks: dict = {} # date -> max HOURLY-mean kW (physical, for peak_kw_hourly_mean) + daily_billed: dict = {} # date -> max EFFECTIVE kW: what the tariff counts, night hours half + # date -> how many billing hours the PRODUCTION accumulator actually billed on it. A day is not + # always 24 hours long, + # and the tariff bills every hour the meter recorded: the fall-back day has 25 and the + # spring-forward day 23. Counting them is how this harness proves it is actually TRAVERSING + # the transition rather than merely surviving it - a flat night load is priced identically + # whether the repeated hour is billed once or twice, so the tariff figure alone cannot tell. + billing_periods: dict = {} + # Highest completed quarter-hour MEAN so far: what the coordinator publishes as + # peak_this_month, and therefore what the effect layer is defending. Starts at + # zero, as it does on a fresh install. + running_peak_kw = 0.0 + + # THE CLOCK ADVANCES ON THE ABSOLUTE TIME LINE, NOT THE WALL CLOCK. + # + # This was `now = start + timedelta(minutes=STEP_MIN * step)`, and `start` is aware + # (Europe/Stockholm). Adding a timedelta to an AWARE datetime is WALL-CLOCK arithmetic: the + # digits advance uniformly and the UTC offset is recomputed from wherever they land. Real time + # does not work that way. Across a spring-forward that clock walks through a wall time that never + # happened; across a fall-back it passes the repeated hour once instead of twice. + # + # So the harness could not have experienced a DST transition honestly even if pointed straight + # at one - and the coordinator bug that deleted a billing hour on the fall-back night (a peak of + # 9 kW recorded as 1) would have been invisible to it. Step UTC; derive local from it. + start_absolute = start.astimezone(zoneinfo.ZoneInfo("UTC")) + + # The engine's clock is frozen to sim time below. RESTORE IT even on a crash - a + # leaked monkeypatch poisons every test that runs after a failed simulation (F-100). + _real_now, _real_utcnow = dt_util.now, dt_util.utcnow + try: + for step in range(steps): + now = (start_absolute + timedelta(minutes=STEP_MIN * step)).astimezone(TZ) + # Freeze engine wall clock to sim time + dt_util.now = lambda tz=None, _n=now: _n + dt_util.utcnow = lambda _n=now: _n.astimezone(zoneinfo.ZoneInfo("UTC")) + + tout = outdoor_at(times, temps, now) + + # --- plant step --- + # S1 IS CLAMPED TO THE PUMP'S MAXIMUM SUPPLY TEMPERATURE, as it is on the real hardware. + # + # This clamp was missing while `flow` (BT25) was clamped, twelve lines below. Degree + # minutes are the integral of (BT25 - S1), so the plant was integrating against a setpoint + # the pump was physically forbidden to reach: in the F2040 cold snap the curve asked for up + # to 4.1 C above max_flow_temp for 513 samples, and DM therefore fell at up to 4.1 per + # minute NO MATTER WHAT ANY CONTROLLER DID. Degree minutes ran to the integrator floor on + # their own, and the harness reported it as a control failure. It was a plant artefact. + # + # A NIBE limits the calculated supply temperature to the configured maximum; it does not + # chase a setpoint it cannot make. Removing this artefact is what makes the residual trap + # underneath it (F-124) measurable at its true size rather than at an inflated one. + max_flow = float(house.profile.max_flow_temp) + flow_target = min(house.curve_flow_temp(tout, tuned_curve) + offset_applied, max_flow) + + # The compressor's capacity now bounds the water node directly (see below), so the flow + # saturates below target of its own accord when the pump runs out - which is what lets + # degree minutes actually run away, and is the real mechanism behind an undersized pump + # falling back on its immersion heater in a cold snap. + + # THE WATER LOOP IS A THERMAL MASS, NOT A RAMP RATE. + # + # This used to move `flow` toward its target at a fixed C/min and then compute the room's + # heat from wherever the flow happened to be - including while the compressor was OFF, so + # the decaying water heated the room for free and nothing ever charged for putting the heat + # in. The plant manufactured energy in proportion to how long the compressor spent idle, + # which systematically flattered whichever controller ran the pump least. + # + # The physics is simply a first-order node: the compressor heats the water, the water heats + # the room, and the flow temperature is what the balance between them leaves behind. + # + # C_water * dT_flow/dt = Q_compressor - Q_emitters + # + # Now every joule the room receives was paid for, the loop is a buffer rather than a + # source, and a controller that swings the flow pays the real cost of doing so. + q_emit_w = house.heat_output_w(flow, indoor) + + capacity_w = house.capacity_kw_at(tout) * 1000.0 + # Below the machine's published operating floor the capacity is zero and everything + # here must agree with that - the physics above AND the state reported to the engine. + available = capacity_w > 0.0 + if not available: + # Outside the machine's published operating range. Counted so a failed arctic run + # attributes itself: 'indoor fell to -13 C' next to '211 blocked hours' is the + # machine's envelope speaking, not the controller's. + stats["compressor_blocked_hours"] += STEP_MIN / 60.0 + if compressor_on: + # The compressor modulates toward the flow its curve is asking for, bounded by what it + # can actually deliver - which comes from the datasheet, not from an invented derating. + demand_w = ( + q_emit_w + WATER_LOOP_J_PER_K * (flow_target - flow) / COMPRESSOR_RESPONSE_S + ) + q_comp_w = max(0.0, min(demand_w, capacity_w)) + else: + q_comp_w = 0.0 + + # How hard the compressor is being pushed, which is what sets its efficiency. No + # circularity: q_comp is fixed by demand and capacity, both computed above. + load_fraction = q_comp_w / capacity_w if capacity_w > 0 else 0.0 + + # THE IMMERSION HEATER IS THERMOSTATIC, because every real one is. + # + # It used to dump a flat 3 kW into the water node whenever degree minutes passed the aux + # limit - including when the node was already at its ceiling. In a five-minute step that is + # 900 kJ into a 350 kJ/K loop: 2.6 K of overshoot per step, which the clamp below then + # deleted. The heater was metered, paid for, and its heat thrown away, 183 kWh of it in the + # F2040 cold snap, while every energy "audit" in the harness reported 0.00 % error. + # + # A real immersion heater has a high-limit thermostat and cycles on the water temperature. + # So it injects at most what fits under the ceiling: the heat the emitters are taking out, + # less what the compressor is already putting in, plus whatever headroom the node has left. + aux_headroom_w = ( + WATER_LOOP_J_PER_K * (max_flow - flow) / (STEP_MIN * 60.0) + q_emit_w - q_comp_w ) - for h in range(1, 49) - ] - weather = WeatherData(current_temp=tout, forecast_hours=fc, source_entity="sim") - - nibe = NibeState( - outdoor_temp=round(tout, 1), - indoor_temp=round(indoor, 2), - supply_temp=round(flow, 1), - return_temp=round(flow - 5.0, 1), - degree_minutes=round(dm, 0), - current_offset=float(offset_applied), - is_heating=compressor_on, - is_hot_water=False, - timestamp=now, - compressor_hz=hz, - power_kw=round(power_kw, 2), - ) + aux_w = 0.0 + if dm <= house.aux_start_dm: + aux_w = min(house.immersion_heater_kw * 1000.0, max(0.0, aux_headroom_w)) - # --- the real decision engine (or neutral baseline) --- - if baseline: - calc_offset = 0.0 - else: - try: - decision = engine.calculate_decision( - nibe_state=nibe, - price_data=price_data, - weather_data=weather, - current_peak=6.0, - current_power=power_kw, - ) - calc_offset = decision.offset - except Exception as err: # noqa: BLE001 - we are hunting bugs - stats["exceptions"] += 1 + flow_unclamped = ( + flow + (q_comp_w + aux_w - q_emit_w) * (STEP_MIN * 60.0) / WATER_LOOP_J_PER_K + ) + flow = max(indoor, min(flow_unclamped, max_flow)) + + # THE ONLY ENERGY STATEMENT IN THIS PLANT THAT CAN ACTUALLY FAIL. + # + # Everything downstream of here - the room ODE, the "first law residual", the compressor + # audit - is an algebraic rearrangement of the same two lines and CANNOT disagree with + # itself. This clamp is different: it overwrites a state variable AFTER the ODE has + # integrated it, so every joule it removes is energy the meter charged for and the room + # never received. Nothing else in the harness can see that, and it measured 0.00 % error + # while 183 kWh vanished in the F2040 cold snap. + # + # In a healthy plant the clamp never binds and this stays at zero. It is an assertion, not + # a statistic. + stats["water_node_leak_kwh"] += WATER_LOOP_J_PER_K * (flow - flow_unclamped) / J_PER_KWH + + # THE DATASHEET, AT THE WEATHER THIS RUN ACTUALLY SAW. Accumulated here, asserted in + # check_invariants. The plant's COP is the manufacturer's rated figure scaled by the Carnot + # ratio between the flow it is making and the W35 rating point, so whenever the water is + # HOTTER than W35 the scale is below one and the realised COP cannot exceed the datasheet. + # That is a bound the energy bookkeeping does not determine, which is exactly why it can + # fail - and a doubled COP, the bug the deleted identity waved through, breaks it on every + # house. + heat_kwh_this_step = q_comp_w / 1000.0 * STEP_MIN / 60.0 + stats["compressor_heat_kwh"] += heat_kwh_this_step + stats["datasheet_cop_x_heat"] += best_published_cop * heat_kwh_this_step + + q_w = q_emit_w + + aux_kw = aux_w / 1000.0 + + # Indoor temperature ODE + # INTERNAL GAINS. The simulated house used to have none: its only heat source was the + # emitters. A real house is warmed by its occupants, its fridge, its lighting and the sun + # to the tune of a few hundred watts, all year - which is why heat demand reaches zero at + # the BALANCE POINT (~17 C outdoor) rather than at room temperature. + # + # Leaving them out did not just make the plant unrealistic, it made it BLIND: the + # controller models 600 W of gains and asks for correspondingly less flow, so a house with + # zero gains would be systematically under-supplied - and deleting the controller's gains + # term (a real regression) would have been INVISIBLE here, because the two errors cancel. + d_indoor = ( + q_w + INTERNAL_GAINS_W - house.hlc_w_per_k * (indoor - tout) + ) / house.capacity_j_per_k + indoor += d_indoor * STEP_MIN * 60.0 + + # DM dynamics + compressor hysteresis + dm += (flow - flow_target) * STEP_MIN + dm = max(DM_INTEGRATOR_FLOOR, min(dm, DM_INTEGRATOR_CEILING)) + if not compressor_on and dm <= DM_START: + compressor_on = True + # A start only counts if the machine can actually run: an F2040 below its -20 C + # floor "restarting" every hysteresis cycle would be phantom compressor wear. + if available: + stats["compressor_starts"] += 1 + elif compressor_on and dm >= DM_STOP: + compressor_on = False + + cop = house.cop_at(tout, flow, load_fraction) + + # THE SECOND LAW. No machine can beat Carnot between the temperatures it is working across. + # + # Unlike the energy "audits" this replaces, this one is not derived from the plant's own + # bookkeeping - it is an external physical bound on the COP MODEL, so it can disagree with + # it. It catches a wrong anchor, a flipped exponent or bad approach temperatures. It does + # NOT catch a COP that is merely too generous but still sub-Carnot; the datasheet envelope + # in check_invariants is what covers that, and between them they bracket the model from + # both sides. + if cop > house.carnot_cop(tout, flow): violations.append( { "t": now.isoformat(), - "type": "exception", - "detail": f"{type(err).__name__}: {err}", + "type": "cop_beats_carnot", + "detail": f"COP {cop:.2f} > Carnot {house.carnot_cop(tout, flow):.2f}", } ) + + power_kw = (q_comp_w / 1000.0) / cop + aux_kw + STANDBY_KW + hz = ( + 40 + int(min(50, max(0, (flow_target - indoor)))) + if (compressor_on and available) + else 0 + ) + + # --- price/weather context (parsed by the REAL GE-Spot adapter) --- + price_data = price_source.get(now) + cur_q = (now.hour * 4) + now.minute // 15 + # Locate the interval by timestamp, exactly as the integration does, rather + # than indexing by quarter number - the two disagree on DST days. + cur_period = price_data.get_period(now) + if cur_period is None: + violations.append( + {"t": now.isoformat(), "type": "no_price_for_instant", "detail": f"q{cur_q}"} + ) + cur_price_ore = 100.0 + else: + cur_price_ore = cur_period.price + + fc = [ + WeatherForecastHour( + datetime=now + timedelta(hours=h), + temperature=outdoor_at(times, temps, now + timedelta(hours=h)), + ) + for h in range(1, 49) + ] + # A weather entity is vol.Optional in the config flow, and with none configured + # WeatherAdapter.get_forecast() returns None outright ("Weather forecast disabled - no + # entity configured in setup"). That is a SUPPORTED install, and until this flag existed the + # harness had never simulated it: it fed a perfect 48 h forecast to every run. + # + # Note this is NOT --no-weather. That flag clears enable_weather_compensation, which kills + # the Math WC layer - the core control law, voting 100% of the time - and which the config + # flow never writes, so production cannot reach it. Withholding the FORECAST is the thing a + # real user can do, and it is the weaker ablation: Math WC still runs off outdoor and flow + # temperature. Only the forecast-fed layers go quiet. + weather = ( + WeatherData(current_temp=tout, forecast_hours=fc, source_entity="sim") + if forecast_available + else None + ) + + nibe = NibeState( + outdoor_temp=round(tout, 1), + indoor_temp=round(indoor, 2), + supply_temp=round(flow, 1), + return_temp=round(flow - 5.0, 1), + degree_minutes=round(dm, 0), + current_offset=float(offset_applied), + is_heating=compressor_on and available, + is_hot_water=False, + timestamp=now, + compressor_hz=hz, + power_kw=round(power_kw, 2), + ) + + # --- the real decision engine (or neutral baseline) --- + if battery: + calc_offset = battery_reference_offset(price_data, now, indoor) + elif fixed_offset is not None: + calc_offset = fixed_offset + elif baseline: calc_offset = 0.0 + else: + try: + # The peak the effect layer defends is the one this simulation has + # actually produced so far, not a constant. A hardcoded 6.0 kW meant + # the layer was always defending a peak the plant never set, and the + # "no peak recorded yet" path (where predictive protection must stay + # silent) was never reached at all. + decision = engine.calculate_decision( + nibe_state=nibe, + price_data=price_data, + weather_data=weather, + current_peak=running_peak_kw, + current_power=power_kw, + ) + calc_offset = decision.offset + # WHICH LAYERS ACTUALLY VOTED. "5/5 PASS" says nothing about a layer that never + # fired - and this harness has already shipped a run where the Peak layer voted + # weight 0.00 in all 8928 steps of every run ever made, while reporting PASS. A + # green run over silent code is not evidence, and the only way to know which it is + # is to count. + for layer in decision.layers: + if layer.weight > 0.0: + stats["layer_votes"][layer.name] = ( + stats["layer_votes"].get(layer.name, 0) + 1 + ) + except Exception as err: # noqa: BLE001 - we are hunting bugs + stats["exceptions"] += 1 + violations.append( + { + "t": now.isoformat(), + "type": "exception", + "detail": f"{type(err).__name__}: {err}", + } + ) + calc_offset = 0.0 - # Adapter-faithful integer write (fractional accumulator, threshold 1.0) - if abs(calc_offset - accumulator_ref) >= 1.0: - new_int = accumulator_ref + int(calc_offset - accumulator_ref) - new_int = int(max(-10, min(10, new_int))) + # The REAL quantisation the adapter uses, not a copy of it. This harness used to carry its + # own transcription of that arithmetic - including the int() truncation - which is exactly + # how a plant model and the code it is meant to be testing drift apart unnoticed. + new_int = integer_offset_for(calc_offset, offset_applied) if new_int != offset_applied: offset_applied = new_int - accumulator_ref = new_int stats["writes"] += 1 - # --- invariants & stats --- - if dm < -1500 and aux_kw == 0: - violations.append( - {"t": now.isoformat(), "type": "dm_below_aux_limit", "detail": f"DM {dm:.0f}"} - ) - if indoor < 18.0: - violations.append( - {"t": now.isoformat(), "type": "indoor_below_18", "detail": f"indoor {indoor:.2f}"} + # --- invariants & stats --- + # A degree-minute deficit that reaches the integrator floor means the recovery system - + # the curve offset AND the auxiliary heater together - failed to arrest it. That is the + # signal worth failing on. + # + # The previous invariant here ("DM below the aux limit while aux is off") was a FALSE + # POSITIVE: aux is decided from the degree minutes at the START of the step and the check + # ran against the value at the END, so a deficit that crossed the limit mid-step tripped + # it even though aux engages on the very next step - which is simply what a controller + # sampling at an interval does. Worse, it could never catch a real defect, because aux + # engages exactly when DM crosses the limit. It was unfalsifiable in both directions. + if dm <= DM_INTEGRATOR_FLOOR: + violations.append( + { + "t": now.isoformat(), + "type": "dm_runaway", + "detail": f"DM floored at {dm:.0f}", + } + ) + + # Nothing here used to fail on OVERHEATING. The harness counted comfort_minutes_above and + # asserted nothing about it, so a run that cooked the house to 35 C reported "violations: + # 0". Overheating is a comfort failure, an efficiency failure, and - when it is auxiliary + # heat doing it - an expensive one. + if indoor > INDOOR_CEILING: + violations.append( + { + "t": now.isoformat(), + "type": "indoor_above_ceiling", + "detail": f"indoor {indoor:.2f}", + } + ) + if indoor < 18.0: + violations.append( + { + "t": now.isoformat(), + "type": "indoor_below_18", + "detail": f"indoor {indoor:.2f}", + } + ) + if not -10 <= calc_offset <= 10: + violations.append( + { + "t": now.isoformat(), + "type": "offset_out_of_range", + "detail": f"offset {calc_offset:.2f}", + } + ) + + last_offsets.append(offset_applied) + if len(last_offsets) > 9: + last_offsets.pop(0) + deltas = [b - a for a, b in zip(last_offsets, last_offsets[1:])] + flips = sum(1 for a, b in zip(deltas, deltas[1:]) if a * b < 0) + if flips >= 3: + stats["sign_flips"] += 1 + + stats["indoor_min"] = min(stats["indoor_min"], indoor) + stats["indoor_max"] = max(stats["indoor_max"], indoor) + stats["indoor_sum"] += indoor + stats["dm_min"] = min(stats["dm_min"], dm) + # What the plant actually ASKED the pump for. Degree minutes integrate (BT25 - S1), so if + # S1 can exceed what the pump may make, DM falls forever regardless of the controller. The + # number is published so a test can check the plant rather than recompute the clamp and + # assert on its own arithmetic - which is what the first version of that test did. + stats["flow_target_max"] = max(stats["flow_target_max"], flow_target) + stats["offset_min"] = min(stats["offset_min"], offset_applied) + stats["offset_max"] = max(stats["offset_max"], offset_applied) + energy = power_kw * STEP_MIN / 60.0 + stats["energy_kwh"] += energy + + # First-law audit. Heat INTO the room, and heat OUT of it. Over a month these must balance + # to within the change in the fabric's stored energy - otherwise the plant is inventing or + # destroying energy and every cost number it produces is fiction. + stats["heat_kwh"] += q_w * STEP_MIN / 60.0 / 1000.0 + stats["loss_kwh"] += ( + (house.hlc_w_per_k * (indoor - tout) - INTERNAL_GAINS_W) * STEP_MIN / 60.0 / 1000.0 ) - if not -10 <= calc_offset <= 10: - violations.append( - { - "t": now.isoformat(), - "type": "offset_out_of_range", - "detail": f"offset {calc_offset:.2f}", - } + stats["aux_kwh"] += aux_kw * STEP_MIN / 60.0 + + # THE RESISTIVE HEAT PHYSICS FORCES, as opposed to the resistive heat the optimiser causes. + # + # A correctly-sized air-source system in Sweden is BIVALENT: NIBE declares Tbiv = -9 C for + # the F2040-8, below which the machine cannot meet the design load and supplementary heat is + # REQUIRED. The harness used to assert that a healthy pump burns no resistive heat at all, + # which is an assertion about a machine that does not exist - and it duly failed the only + # correctly-sized air-source house in the set, for doing exactly what it is designed to do. + # + # What CAN be asked, and is worth asking, is whether the optimiser burns more resistive heat + # than the pump's own capacity deficit forces. That is computable here: the house's heat + # demand at this instant, against what the compressor can physically deliver. Anything above + # it is the controller's doing, not the weather's. + demand_now_w = house.hlc_w_per_k * (indoor - tout) - INTERNAL_GAINS_W + stats["unavoidable_aux_kwh"] += ( + max(0.0, demand_now_w - capacity_w) / 1000.0 * STEP_MIN / 60.0 ) + stats["cost_sek"] += energy * cur_price_ore / 100.0 + + # EFFECT TARIFF BASIS: the owner's 15-minute period mean (BILLING_PERIOD_MINUTES). + # THE BILLED QUANTITY IS COMPUTED BY THE PRODUCTION CODE, NOT BY A LOOKALIKE. + # + # This used to be the harness's OWN accumulator: `sum(period_samples) / len(period_samples)`, + # keyed on its own idea of an hour. The coordinator has always used a TIME-WEIGHTED mean over + # an absolute hour. Two implementations of the single most consequential number this + # integration computes - and the harness was validating the one nobody runs. + # + # They agreed only because this loop steps a perfectly uniform five minutes, which Home + # Assistant does not. And they were both wrong on the night the clocks go back, INDEPENDENTLY, + # so neither could see the other's bug: the coordinator merged the repeated hour and deleted a + # 9 kW billing peak. An instrument that re-implements the thing it measures cannot measure it. + # + # `BillingPeriodAccumulator` is now the only definition, and this is the real one. Break it + # and --dst fails here as well as in the unit tests. + completed = billing.add(now, power_kw, POWER_SOURCE_EXTERNAL_METER) + if completed is not None: + # COUNT WHAT THE ACCUMULATOR ACTUALLY BILLED, not what this loop thinks an hour is. + # + # The first version of this counter re-derived the hour key here, from `now`, and so it + # kept reporting 25 hours on the fall-back day even when the production accumulator was + # merging the two 02:00s into one. It was measuring the harness, not the code under test + # - the exact vacuity this whole commit exists to remove, reintroduced one line below the + # comment complaining about it. Verified by mutation: reinstate the DST bug in + # billing_period.py and this now reports 24 hours and fails the run. + # COUNTED, not collected in a set: on the fall-back day both 02:00 hours carry the SAME + # local `started_at`, and PEP 495 makes those two datetimes compare EQUAL (and hash + # equal), so a set would silently merge them back into one and report 24 again - passing + # the check by making the same mistake it exists to catch. + billing_periods[completed.started_at.date()] = ( + billing_periods.get(completed.started_at.date(), 0) + 1 + ) - last_offsets.append(offset_applied) - if len(last_offsets) > 9: - last_offsets.pop(0) - deltas = [b - a for a, b in zip(last_offsets, last_offsets[1:])] - flips = sum(1 for a, b in zip(deltas, deltas[1:]) if a * b < 0) - if flips >= 3: - stats["sign_flips"] += 1 - - stats["indoor_min"] = min(stats["indoor_min"], indoor) - stats["indoor_max"] = max(stats["indoor_max"], indoor) - stats["indoor_sum"] += indoor - stats["dm_min"] = min(stats["dm_min"], dm) - stats["offset_min"] = min(stats["offset_min"], offset_applied) - stats["offset_max"] = max(stats["offset_max"], offset_applied) - energy = power_kw * STEP_MIN / 60.0 - stats["energy_kwh"] += energy - stats["aux_kwh"] += aux_kw * STEP_MIN / 60.0 - stats["cost_sek"] += energy * cur_price_ore / 100.0 - - # Effect tariff basis: quarter-hour MEAN power (Swedish effektavgift), - # never the instantaneous sample. - this_quarter = (now.date(), cur_q) - if quarter_id is not None and this_quarter != quarter_id: - q_mean = sum(quarter_samples) / len(quarter_samples) - day = quarter_id[0] - daily_peaks[day] = max(daily_peaks.get(day, 0.0), q_mean) - quarter_samples = [] - quarter_id = this_quarter - quarter_samples.append(power_kw) - - if indoor < TARGET_INDOOR - COMFORT_TOLERANCE: - stats["comfort_minutes_below"] += STEP_MIN - elif indoor > TARGET_INDOOR + OVERSHOOT_TOLERANCE: - stats["comfort_minutes_above"] += STEP_MIN - - if step % 6 == 0: # 30-min trace resolution - trace.append( - { - "t": now.isoformat(), - "tout": round(tout, 1), - "tin": round(indoor, 2), - "flow": round(flow, 1), - "dm": round(dm), - "offset": offset_applied, - "calc": round(calc_offset, 2), - "kw": round(power_kw, 2), - "price": round(cur_price_ore, 1), - "comp": int(compressor_on), - } - ) + day = completed.started_at.date() + daily_peaks[day] = max(daily_peaks.get(day, 0.0), completed.mean_power_kw) + # What the tariff COUNTS is the effective power - Ellevio halves 22:00-06:00. + # The harness used to skip the night weighting, overstating every tariff figure + # with night-shifted load - which is exactly where this optimiser puts load. + daily_billed[day] = max( + daily_billed.get(day, 0.0), + effective_tariff_power_kw(completed.mean_power_kw, completed.billing_period), + ) + running_peak_kw = max(running_peak_kw, completed.mean_power_kw) + + # THE EFFECT LAYER WAS NEVER GIVEN A PEAK HISTORY. The harness computed + # `running_peak_kw` and handed it to the engine, but never called + # `record_quarter_measurement()` - so `EffectManager._monthly_peaks` stayed empty for + # all 8928 steps, and `should_limit_power()` short-circuits on an empty history: + # + # if not self._monthly_peaks: + # return PowerLimitDecision(should_limit=False, severity="OK", ...) + # + # The peak layer therefore voted weight 0.00 on every single step of every run. Every + # claim this harness made about effect-tariff protection - the feature the integration + # is named for - was vacuous. (The coordinator had the mirror-image bug for meter-less + # houses; this is the same hole, in the instrument that was supposed to catch it.) + asyncio.run( + effect.record_period_measurement( + power_kw=completed.mean_power_kw, + period=completed.billing_period, + timestamp=completed.started_at, + source=POWER_SOURCE_EXTERNAL_METER, + ) + ) + + if indoor < TARGET_INDOOR - COMFORT_TOLERANCE: + stats["comfort_minutes_below"] += STEP_MIN + elif indoor > TARGET_INDOOR + OVERSHOOT_TOLERANCE: + stats["comfort_minutes_above"] += STEP_MIN - if quarter_samples and quarter_id is not None: - q_mean = sum(quarter_samples) / len(quarter_samples) - day = quarter_id[0] - daily_peaks[day] = max(daily_peaks.get(day, 0.0), q_mean) - top3 = sorted(daily_peaks.values(), reverse=True)[:3] + if step % 6 == 0: # 30-min trace resolution + trace.append( + { + "t": now.isoformat(), + "tout": round(tout, 1), + "tin": round(indoor, 2), + "flow": round(flow, 1), + "dm": round(dm), + "offset": offset_applied, + "calc": round(calc_offset, 2), + "kw": round(power_kw, 2), + "price": round(cur_price_ore, 1), + "comp": int(compressor_on), + } + ) + + finally: + dt_util.now, dt_util.utcnow = _real_now, _real_utcnow + + # The run ends on an hour boundary, and that final hour is complete in sim-time. Production + # never flushes - Home Assistant keeps running, and an hour cut short by a shutdown was never + # measured and is not a bill. + final = billing.flush() + if final is not None: + day = final.started_at.date() + daily_peaks[day] = max(daily_peaks.get(day, 0.0), final.mean_power_kw) + daily_billed[day] = max( + daily_billed.get(day, 0.0), + effective_tariff_power_kw(final.mean_power_kw, final.billing_period), + ) + billing_periods[day] = billing_periods.get(day, 0) + 1 + top3 = sorted(daily_billed.values(), reverse=True)[:3] tariff_kw = sum(top3) / len(top3) if top3 else 0.0 - stats["peak_kw_quarter_mean"] = round(max(daily_peaks.values()), 2) if daily_peaks else 0.0 + stats["peak_kw_hourly_mean"] = round(max(daily_peaks.values()), 2) if daily_peaks else 0.0 stats["tariff_top3_kw"] = round(tariff_kw, 2) + stats["billing_periods_by_day"] = { + day.isoformat(): count for day, count in sorted(billing_periods.items()) + } stats["tariff_cost_sek"] = round(tariff_kw * EFFECT_TARIFF_SEK_PER_KW, 0) stats["total_cost_sek"] = round(stats["cost_sek"] + stats["tariff_cost_sek"], 0) stats["indoor_mean"] = round(stats["indoor_sum"] / steps, 2) del stats["indoor_sum"] + + # THE ROOM-SIDE BALANCE IS AN IDENTITY, AND I SPENT SEVERAL COMMITS QUOTING IT AS EVIDENCE. + # + # residual = heat_in - loss - stored + # the ODE d_indoor = (q_w + GAINS - HLC*(indoor - tout)) / C + # + # are the same terms rearranged, so the residual is zero by construction. It says the room ODE + # integrates consistently and NOTHING ELSE. Proved by making the compressor pay for only HALF + # the heat it produced: electricity fell from 912 to 487 kWh and the residual stayed at 0.00. + # + # And that is precisely where the original free-heat bug lived - the COMPRESSOR side. So the + # room balance could never have caught it, and I found it by reasoning rather than by the check + # I built to find it. It is kept because a non-zero value would still mean the ODE is broken, + # but it is no longer the thing being claimed. + stored_kwh = house.capacity_j_per_k * (indoor - indoor_start) / J_PER_KWH + residual = stats["heat_kwh"] - stats["loss_kwh"] - stored_kwh + stats["heat_kwh"] = round(stats["heat_kwh"], 1) + stats["loss_kwh"] = round(stats["loss_kwh"], 1) + stats["energy_balance_residual_kwh"] = round(residual, 2) + + # AND SO WAS THE COMPRESSOR-SIDE "AUDIT" I ADDED TO REPLACE IT. It is deleted here. + # + # power_kw = q_comp/cop + aux + standby (the plant) + # metered = power_kw - aux - standby (the "meter") + # owed = q_comp/cop (the "independent" figure) + # + # Substitute the first into the second and you get the third, exactly: x - y + y = x. Two + # symbols, one line, and I called them "two independent expressions of the same joules" in the + # code and in a test docstring. Doubling the compressor's COP - which halves the bill, a + # catastrophic plant bug - left it reporting 0.00 % error and PASS. + # + # There is no exact energy audit to be had inside a closed ODE plant: every residual you can + # write is a rearrangement of the equations that produced it. What CAN fail is a statement + # about something the bookkeeping does not determine, and there are exactly two of those: + # + # * water_node_leak_kwh - the flow clamp overwrites a state variable AFTER the ODE has + # integrated it, so it can destroy metered joules. It measured 183 kWh in the F2040 cold + # snap while every "audit" above read 0.00 %. + # * the second law (per step, above) and the datasheet envelope (in check_invariants), which + # bracket the COP model from above and below using data the plant's energy accounting does + # not reference. + stats["water_node_leak_kwh"] = round(stats["water_node_leak_kwh"], 1) + stats["datasheet_cop"] = round( + stats["datasheet_cop_x_heat"] / max(stats["compressor_heat_kwh"], 1e-9), 2 + ) + del stats["datasheet_cop_x_heat"] + del stats["compressor_heat_kwh"] + stats["mean_cop"] = round( + stats["heat_kwh"] / max(stats["energy_kwh"] - STANDBY_KW * steps * STEP_MIN / 60.0, 1e-9), 2 + ) stats["violations"] = len(violations) return stats, violations, trace -def main(): +# Safety invariants. A run that trips one of these has demonstrated the optimiser +# doing something it must never do, and the harness exits non-zero so that a human +# - or CI - cannot mistake a bad run for a good one. Previously every run exited 0 +# no matter what it found, so the simulation could not fail and therefore could not +# hold anything up. +FATAL_VIOLATIONS = frozenset( + { + "indoor_below_18", # comfort floor breached: the pump was starved + "indoor_above_ceiling", # house cooked, usually by the immersion heater + "offset_out_of_range", # engine emitted an offset the register cannot hold + "exception", # engine raised while controlling a heat pump + "dm_runaway", # the deficit outran the curve offset AND the aux heater + "no_price_for_instant", # adapter could not price a moment that exists + "cop_beats_carnot", # the PLANT broke the second law: every cost it reports is fiction + } +) + +# The optimiser is allowed to move heat around, but not to make the house colder +# than a do-nothing controller would. Baseline mean indoor is the comparison. +MIN_MEAN_INDOOR_C = TARGET_INDOOR - COMFORT_TOLERANCE + +# THE INVARIANTS BELOW USED TO BE UNFALSIFIABLE, AND SO DID THIS WHOLE HARNESS. +# +# Every mutation of a safety constant still printed "PASS: all safety invariants held": +# +# MIN_TEMP_LIMIT 18.0 -> 5.0 PASS <- the comfort floor, gutted +# DM_THRESHOLD_AUX_LIMIT -1500 -> -400 PASS <- immersion heater at shallow debt +# WEATHER_GENTLE_OFFSET 0.83 -> 2.0 PASS <- the overheat bug, hand-tuned against +# INTERNAL_GAINS_W 600 -> 0 PASS +# comfort-layer abstention removed PASS +# +# The reason was not the invariants themselves - it was that a mild January never brings the +# house within reach of any of them, and three of the most telling numbers were COUNTED AND +# NEVER ASSERTED. `aux_kwh` was tracked and ignored, so driving the pump into the immersion +# heater was free. `comfort_minutes_below` and `comfort_minutes_above` were tracked and ignored, +# so the house could sit outside its comfort band for the entire month and still report zero +# violations. The file's own comment complains about exactly this pattern ("The harness counted +# comfort_minutes_above and asserted nothing about it") - and then did it again, twice. +# +# A test that cannot fail cannot detect. These now bite, and the gate runs the COLD SNAP as well +# as the mild month, so the house is actually taken near its limits. + +# The immersion heater is a COP-1.0 resistive element. On a correctly sized pump in a Swedish +# January the optimiser must never reach for it: that is the whole point of the degree-minute +# ladder. A little is tolerated in a deep cold snap on an air-source pump whose capacity has +# genuinely collapsed - that is physics, not a control failure - so the budget is per-scenario. +# How much MORE resistive heat than physics forces the optimiser may burn. Not an absolute budget: +# a bivalent system is designed to use its immersion heater below Tbiv, and asserting otherwise is +# asserting something about a machine NIBE does not sell. +AUX_OVER_PHYSICS_TOLERANCE = 1.25 +AUX_SLACK_KWH = 5.0 # so a house that needs essentially none is not failed by rounding + +# Degree minutes must stay clear of the aux limit by a real margin. Skimming it means the ladder +# is only just holding, and the next colder night tips into resistive heat. +DM_AUX_MARGIN = 200.0 + +# Minutes outside the comfort band, per 31-day month. Not zero - the optimiser is ALLOWED to +# coast into the band's edge to dodge a price peak, that is its job - but a house that spends +# whole days out of band is not being optimised, it is being neglected. +MAX_COMFORT_MINUTES_BELOW = 240 +MAX_COMFORT_MINUTES_ABOVE = 720 + + +def check_invariants(tag: str, stats: dict, violations: list, house=None) -> list[str]: + """Return the reasons this run must be treated as a failure.""" + failures = [] + + fatal = [v for v in violations if v["type"] in FATAL_VIOLATIONS] + if fatal: + kinds = sorted({v["type"] for v in fatal}) + failures.append(f"{len(fatal)} safety violation(s): {', '.join(kinds)}") + + if stats["indoor_min"] < 18.0: + failures.append(f"indoor fell to {stats['indoor_min']:.2f} C (floor is 18.0)") + + if stats["indoor_mean"] < MIN_MEAN_INDOOR_C: + failures.append( + f"mean indoor {stats['indoor_mean']:.2f} C is below the comfort band " + f"({MIN_MEAN_INDOOR_C:.2f} C) - the optimiser under-heated the house" + ) + + if stats["exceptions"]: + failures.append(f"{stats['exceptions']} engine exception(s)") + + # THE PLANT MAY NOT DESTROY ENERGY THE METER CHARGED FOR. The flow clamp overwrites the water + # node's temperature after the ODE has integrated it, so it is the one place in the harness + # where joules can go missing without any residual noticing - and 183 kWh did, in the F2040 + # cold snap, while the "audits" that preceded this reported 0.00 % error. + if abs(stats["water_node_leak_kwh"]) > WATER_NODE_LEAK_BUDGET_KWH: + failures.append( + f"the flow clamp destroyed {abs(stats['water_node_leak_kwh']):.1f} kWh that the meter " + f"charged for and the room never received - the plant is deleting energy, and every " + f"cost number it produces is fiction by that much" + ) + + # THE COP MODEL, BOUNDED BY THE MANUFACTURER'S OWN BEST FIGURE. And the bound is ONE-WAY. + # + # This check used to compare the run's seasonal COP against the datasheet point nearest to the + # flow temperature - which is a FULL-LOAD figure. A heat pump at part load is legitimately more + # efficient than its full-load rating (the F750 publishes COP 4.72 at minimum frequency and 2.43 + # at maximum), so the check failed an honest plant the moment the models became real. + # + # And the F2040 legitimately runs BELOW its published range: NIBE's coldest rating point is + # -7 C, and a Swedish January reaches -11.6 C. Going below the datasheet there is physics, not + # a bug. + # + # So only one direction is a defect: a plant that buys heat MORE CHEAPLY than the machine can + # possibly make it. That is what a doubled COP looks like, and that is what this catches. + if house is not None and stats["datasheet_cop"] > 0: + ratio = stats["mean_cop"] / stats["datasheet_cop"] + if ratio > COP_ENVELOPE_TOLERANCE: + failures.append( + f"the run's seasonal COP was {stats['mean_cop']:.2f}, against a best published " + f"figure of {stats['datasheet_cop']:.2f} for this machine at ANY of its rating " + f"points ({ratio:.2f}x) - the plant is buying heat more cheaply than the machine " + f"can make it, so every cost number in this run is too low" + ) + + # THE OPTIMISER MAY NOT BURN MORE RESISTIVE HEAT THAN THE PUMP'S CAPACITY DEFICIT FORCES. + # + # This used to be an absolute budget - 0 kWh in a mild month, 25 kWh in a cold snap - and it was + # a statement about a machine that does not exist. A correctly-sized air-source system is + # BIVALENT by design: NIBE declares Tbiv = -9 C for the F2040-8, with 1.1 kW of supplementary + # heat, and below that temperature the immersion heater is SUPPOSED to run. The absolute budget + # failed the only correctly-sized air-source house in the set for doing what it was built to do. + # + # The physics-grounded question is the one worth asking, and the plant can answer it: at every + # step, how much heat did the house need that the compressor could not physically deliver? Sum + # that, and it is the resistive heat the WEATHER forces. Everything above it is the CONTROLLER's. + unavoidable = stats["unavoidable_aux_kwh"] + allowed = unavoidable * AUX_OVER_PHYSICS_TOLERANCE + AUX_SLACK_KWH + + if stats["aux_kwh"] > allowed: + failures.append( + f"the immersion heater burned {stats['aux_kwh']:.1f} kWh, but the pump's capacity " + f"deficit only forced {unavoidable:.1f} kWh of it " + f"({stats['aux_kwh'] / max(unavoidable, 1e-9):.1f}x). The rest is the controller's " + f"doing: resistive heat at COP 1.0, bought because the offset was pinned at maximum " + f"against a compressor that had nothing left to give" + ) + + if stats["comfort_minutes_below"] > MAX_COMFORT_MINUTES_BELOW: + failures.append( + f"{stats['comfort_minutes_below']} minutes below the comfort band " + f"(budget {MAX_COMFORT_MINUTES_BELOW}) - the optimiser starved the house" + ) + + if stats["comfort_minutes_above"] > MAX_COMFORT_MINUTES_ABOVE: + failures.append( + f"{stats['comfort_minutes_above']} minutes above the comfort band " + f"(budget {MAX_COMFORT_MINUTES_ABOVE}) - the optimiser cooked the house" + ) + + return failures + + +def main() -> int: selftest = "--selftest" in sys.argv coldsnap = "--coldsnap" in sys.argv baseline = "--baseline" in sys.argv + battery = "--battery" in sys.argv + live_se4 = "--live-se4" in sys.argv + no_price = "--no-price" in sys.argv + no_weather = "--no-weather" in sys.argv + tuned_curve = "--tuned-baseline" in sys.argv + undersized = "--undersized" in sys.argv + no_forecast = "--no-forecast" in sys.argv + dst = "--dst" in sys.argv + arctic = "--arctic" in sys.argv mode = "balanced" if "--mode" in sys.argv: mode = sys.argv[sys.argv.index("--mode") + 1] - days = 2 if selftest else 31 - times, temps, prices = load_data(selftest) + # --dst spans the fall-back weekend: 3 days, one of them 25 hours long. + days = DST_SIM_DAYS if dst else (2 if selftest else SIM_DAYS) + times, temps, price_days, unit = load_data(selftest, live_se4, dst, arctic) if coldsnap: temps = apply_coldsnap(times, temps) OUT_DIR.mkdir(exist_ok=True) - for house in HOUSES: - stats, violations, trace = simulate(house, times, temps, prices, days, mode, baseline) + price_source = PriceSource(price_days, unit) + exit_code = 0 + + houses = HOUSES + if undersized: + # THE COMMONEST INSTALLATION FAULT THERE IS: a pump one size too small for its house. + # + # Sizing a house at the EN 14825 AVERAGE-climate design point while fitting it with a pump + # certified at the COLD one is exactly that, and both figures are published, so the gap is + # the manufacturer's own. It is not a hypothetical - it is what happens when a European-spec + # sizing meets a Swedish winter. + houses = [ + replace(h, name=f"{h.name}", hlc_w_per_k=h.hlc_w_per_k * UNDERSIZED_PUMP_FACTOR) + for h in HOUSES + ] + + for house in houses: + stats, violations, trace = simulate( + house, + times, + temps, + price_source, + days, + mode, + baseline, + battery=battery, + enable_price=not no_price, + enable_weather=not no_weather, + tuned_curve=tuned_curve, + forecast_available=not no_forecast, + latitude=KIRUNA_LATITUDE if arctic else STOCKHOLM_LATITUDE, + ) + stats["price_unit_seen_by_adapter"] = price_source.unit tag = f"{house.name}{'-selftest' if selftest else ''}" if mode != "balanced": tag += f"-{mode}" if coldsnap: tag += "-coldsnap" + if undersized: + tag += "-undersized" + if live_se4: + tag += "-live-se4" + if battery: + tag += "-battery" if baseline: tag += "-baseline" + if no_price: + tag += "-noprice" + if no_weather: + tag += "-noweather" + if no_forecast: + tag += "-noforecast" + if dst: + tag += "-dst" + if arctic: + tag += "-arctic" + if tuned_curve: + tag += "-tuned" + + # The baseline run is a do-nothing controller used as a yardstick. It is + # expected to breach comfort - that is the point of it - so it reports but + # does not gate. + failures = [] if (baseline or battery) else check_invariants(tag, stats, violations, house) + + if dst: + # THE DST RUN MUST BE ABLE TO FAIL, OR IT IS DECORATION. + # + # A green --dst run proves very little on its own: the October night load is flat and + # low, so merging the repeated 02:00 quarters produces the SAME mean, + # the same tariff figure, and the same PASS. I checked - reverting the harness's period + # key to the ambiguous `(date, hour)` moved not one of the reported numbers. + # + # What the merge DOES change is how many billable periods the day contains. A + # fall-back day is 25 hours - 100 quarter-periods. Count them, and the run can fail + # for the reason it exists. + periods_on_the_long_day = stats["billing_periods_by_day"].get(DST_FALL_BACK_DAY) + if periods_on_the_long_day != DST_FALL_BACK_PERIODS: + failures.append( + f"{DST_FALL_BACK_DAY} was billed as {periods_on_the_long_day} periods. The " + f"clocks go back that night, so it is 25 hours - {DST_FALL_BACK_PERIODS} " + f"fifteen-minute periods - and every one is separately metered. Billing 96 " + f"means the repeated 02:00 quarters, which print the same digits and are an " + f"hour apart, were merged." + ) + json.dump( - {"house": house.name, "days": days, "stats": stats, "violations": violations[:200]}, + { + "house": house.name, + "days": days, + "stats": stats, + "failures": failures, + "violations": violations[:200], + }, open(OUT_DIR / f"summary-{tag}.json", "w"), indent=1, ) json.dump(trace, open(OUT_DIR / f"trace-{tag}.json", "w")) + votes = stats.pop("layer_votes", {}) print(f"[{tag}] {json.dumps(stats)}") + if votes: + ranked = sorted(votes.items(), key=lambda kv: -kv[1]) + total = max(days * 24 * 60 // STEP_MIN, 1) + share = ", ".join(f"{n} {100 * h / total:.0f}%" for n, h in ranked) + print(f"[{tag}] layers that voted: {share}") if violations: print(f"[{tag}] first violations: {violations[:5]}") + if failures: + exit_code = 1 + for failure in failures: + print(f"[{tag}] FAIL: {failure}") + else: + print(f"[{tag}] PASS: all safety invariants held") + + return exit_code if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/scripts/start_week.sh b/scripts/start_week.sh new file mode 100755 index 00000000..6645f171 --- /dev/null +++ b/scripts/start_week.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Bring up (or repair) the week-long live observation. Idempotent - safe to run any number of times. +# +# RUN THIS AFTER EVERY REBOOT. This box has no init: pid 1 is `docker-init -- sleep infinity`, there +# is no systemd and no cron, so nothing starts Home Assistant or the watcher when the machine comes +# back. That is the box, not the script. +# +# bash scripts/start_week.sh +# +# What it does: +# - starts Home Assistant if it is not answering on :8125 +# - starts the watcher if it is not already running (pid file, checked against /proc) +# - prints what it found and what it did + +set -u +cd /workspace || exit 1 + +ha_is_up() { curl -s -o /dev/null -m 8 "http://localhost:8125/" 2>/dev/null; } +# Alive AND actually a week_watch - a pid file alone would happily point at a recycled pid. +watcher_is_up() { + local pidfile=/workspace/.ha-config/week_watch.pid pid + [ -f "$pidfile" ] || return 1 + pid=$(tr -dc '0-9' <"$pidfile") + [ -n "$pid" ] && [ -d "/proc/$pid" ] || return 1 + tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null | grep -q week_watch.sh +} +# The heat pump itself. Without it HA has no BT1/BT25/degree-minutes, EffektGuard correctly refuses +# to control on incomplete data, and the week records nothing but that refusal. The first version of +# this script started Home Assistant and the watcher and forgot the pump they were meant to watch. +pump_is_up() { python3 -c " +import socket, sys +s = socket.socket(); s.settimeout(3) +try: + s.connect(('127.0.0.1', 5020)) +except OSError: + sys.exit(1) +finally: + s.close() +" 2>/dev/null; } + +if pump_is_up; then + echo "NIBE simulator : already up (modbus :5020)" +else + echo "NIBE simulator : down - starting" + setsid nohup /workspace/.venv/bin/python scripts/simulation/nibe_modbus_simulator.py \ + >>/workspace/.ha-config/nibe_sim.log 2>&1 >/workspace/.ha-config/ha.log 2>&1 & + for _ in $(seq 1 36); do + sleep 10 + ha_is_up && break + done + ha_is_up && echo "Home Assistant : up" || echo "Home Assistant : STILL DOWN - check .ha-config/ha.log" +fi + +if watcher_is_up; then + echo "watcher : already running" +else + echo "watcher : starting" + setsid nohup bash /workspace/scripts/week_watch.sh >/dev/null 2>&1 /dev/null || echo 1) - 1)) samples in .ha-config/week_watch.csv" diff --git a/scripts/test_decision_scenarios.py b/scripts/test_decision_scenarios.py index ab86625f..a6898d36 100755 --- a/scripts/test_decision_scenarios.py +++ b/scripts/test_decision_scenarios.py @@ -89,12 +89,13 @@ import argparse import importlib.util import sys +from pathlib import Path from dataclasses import dataclass from typing import Any, Optional from enum import Enum # Import constants from production code - single source of truth -sys.path.insert(0, "/workspaces/EffektGuard/custom_components/effektguard") +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "custom_components" / "effektguard")) from const import ( DM_CRITICAL_T1_MARGIN, DM_CRITICAL_T1_OFFSET, @@ -211,7 +212,13 @@ # Inject the const values into the module's globals before execution spec = importlib.util.spec_from_file_location( "climate_zones", - "/workspaces/EffektGuard/custom_components/effektguard/optimization/climate_zones.py", + str( + Path(__file__).resolve().parents[1] + / "custom_components" + / "effektguard" + / "optimization" + / "climate_zones.py" + ), ) climate_zones_module = importlib.util.module_from_spec(spec) @@ -810,9 +817,10 @@ def calculate_price_layer(self, price_data: MockPriceData) -> LayerVote: # Linear interpolation: 0.5 → 0.2 (conservative), 3.0 → 1.0 (full offset) tolerance_range = PRICE_TOLERANCE_MAX - PRICE_TOLERANCE_MIN # 2.5 factor_range = PRICE_TOLERANCE_FACTOR_MAX - PRICE_TOLERANCE_FACTOR_MIN # 0.8 - tolerance_factor = PRICE_TOLERANCE_FACTOR_MIN + ( - (self.tolerance - PRICE_TOLERANCE_MIN) / tolerance_range - ) * factor_range + tolerance_factor = ( + PRICE_TOLERANCE_FACTOR_MIN + + ((self.tolerance - PRICE_TOLERANCE_MIN) / tolerance_range) * factor_range + ) adjusted_offset = offset * tolerance_factor # Extra boost for negative prices diff --git a/scripts/test_seasonal_defaults.py b/scripts/test_seasonal_defaults.py index baee4397..378a071c 100644 --- a/scripts/test_seasonal_defaults.py +++ b/scripts/test_seasonal_defaults.py @@ -2,8 +2,9 @@ """Test seasonal defaults for weather learning""" import sys +from pathlib import Path -sys.path.insert(0, "/workspaces/EffektGuard") +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector from custom_components.effektguard.optimization.weather_learning import WeatherPatternLearner diff --git a/scripts/visualize_price_optimization.py b/scripts/visualize_price_optimization.py index e00a525a..621eaa0c 100644 --- a/scripts/visualize_price_optimization.py +++ b/scripts/visualize_price_optimization.py @@ -9,30 +9,69 @@ import matplotlib.pyplot as plt import matplotlib.patches as mpatches import numpy as np +from pathlib import Path from datetime import datetime, timedelta # Real price data approximated from screenshot (öre/kWh) # Today: Dec 5, 2025 - prices from 00:00 to 23:45 today_prices_hourly = [ # Night hours (cheap) - 80, 75, 70, 65, 60, 55, # 00:00-05:00 + 80, + 75, + 70, + 65, + 60, + 55, # 00:00-05:00 # Morning ramp - 90, 120, 150, 170, # 06:00-09:00 + 90, + 120, + 150, + 170, # 06:00-09:00 # Day (expensive) - 175, 180, 185, 175, 170, 176, # 10:00-15:00 + 175, + 180, + 185, + 175, + 170, + 176, # 10:00-15:00 # Peak hours - 190, 200, 185, 160, # 16:00-19:00 + 190, + 200, + 185, + 160, # 16:00-19:00 # Evening decline - 130, 110, 95, 85, # 20:00-23:00 + 130, + 110, + 95, + 85, # 20:00-23:00 ] # Tomorrow: Dec 6, 2025 - cheap all day tomorrow_prices_hourly = [ - 52, 50, 48, 47, 46, 48, # 00:00-05:00 - 55, 58, 60, 60, # 06:00-09:00 - 58, 56, 55, 54, 55, 56, # 10:00-15:00 - 58, 60, 58, 55, # 16:00-19:00 - 52, 50, 48, 46, # 20:00-23:00 + 52, + 50, + 48, + 47, + 46, + 48, # 00:00-05:00 + 55, + 58, + 60, + 60, # 06:00-09:00 + 58, + 56, + 55, + 54, + 55, + 56, # 10:00-15:00 + 58, + 60, + 58, + 55, # 16:00-19:00 + 52, + 50, + 48, + 46, # 20:00-23:00 ] # Expand to 15-min intervals @@ -65,15 +104,15 @@ # - Prediction layer: +1.5°C (constant pre-heating for predicted cold) # - Price layer: reduced weight due to volatility # - Weather comp: -1.4°C - + if price > 180: # Peak offset = -1.0 # Some reduction but prediction still fights it elif price > 160: # Expensive - offset = 1.0 # Prediction wins (current bug) + offset = 1.0 # Prediction wins (current bug) elif price > 100: # Normal offset = 0.5 else: # Cheap - offset = 1.5 # Pre-heating correctly activates + offset = 1.5 # Pre-heating correctly activates current_offset.append(offset) # --- Calculate EXPECTED behavior (fixed) --- @@ -90,7 +129,7 @@ if all_prices[j] < 80: # Cheap threshold hours_until_cheap = (j - i) / 4 break - + if price > 180: # Peak offset = -3.0 # Maximum reduction elif price > 160: # Expensive @@ -107,109 +146,161 @@ # --- Create visualization --- fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True) -fig.suptitle('EffektGuard Price Optimization: Current Bug vs Expected Behavior\n(Based on Dec 5-6, 2025 real prices)', - fontsize=14, fontweight='bold') +fig.suptitle( + "EffektGuard Price Optimization: Current Bug vs Expected Behavior\n(Based on Dec 5-6, 2025 real prices)", + fontsize=14, + fontweight="bold", +) + # Color coding for price regions def get_price_color(price): if price > 180: - return '#ff4444' # Red - Peak + return "#ff4444" # Red - Peak elif price > 160: - return '#ff8844' # Orange - Expensive + return "#ff8844" # Orange - Expensive elif price > 100: - return '#ffcc44' # Yellow - Normal + return "#ffcc44" # Yellow - Normal else: - return '#44cc44' # Green - Cheap + return "#44cc44" # Green - Cheap + # Plot 1: Electricity prices ax1 = axes[0] colors = [get_price_color(p) for p in all_prices] for i in range(len(all_prices) - 1): - ax1.fill_between([hours_from_now[i], hours_from_now[i+1]], - [all_prices[i], all_prices[i+1]], - alpha=0.7, color=colors[i]) -ax1.axvline(x=0, color='black', linestyle='--', linewidth=2, label='Current time (16:00)') -ax1.set_ylabel('Price (öre/kWh)', fontsize=11) + ax1.fill_between( + [hours_from_now[i], hours_from_now[i + 1]], + [all_prices[i], all_prices[i + 1]], + alpha=0.7, + color=colors[i], + ) +ax1.axvline(x=0, color="black", linestyle="--", linewidth=2, label="Current time (16:00)") +ax1.set_ylabel("Price (öre/kWh)", fontsize=11) ax1.set_ylim(0, 220) ax1.grid(True, alpha=0.3) -ax1.set_title('Electricity Prices: Today (expensive) → Tomorrow (65% cheaper)', fontsize=11) +ax1.set_title("Electricity Prices: Today (expensive) → Tomorrow (65% cheaper)", fontsize=11) # Add price zone legend -peak_patch = mpatches.Patch(color='#ff4444', label='PEAK (>180 öre)') -expensive_patch = mpatches.Patch(color='#ff8844', label='EXPENSIVE (160-180 öre)') -normal_patch = mpatches.Patch(color='#ffcc44', label='NORMAL (100-160 öre)') -cheap_patch = mpatches.Patch(color='#44cc44', label='CHEAP (<100 öre)') -ax1.legend(handles=[peak_patch, expensive_patch, normal_patch, cheap_patch], - loc='upper right', fontsize=9) +peak_patch = mpatches.Patch(color="#ff4444", label="PEAK (>180 öre)") +expensive_patch = mpatches.Patch(color="#ff8844", label="EXPENSIVE (160-180 öre)") +normal_patch = mpatches.Patch(color="#ffcc44", label="NORMAL (100-160 öre)") +cheap_patch = mpatches.Patch(color="#44cc44", label="CHEAP (<100 öre)") +ax1.legend( + handles=[peak_patch, expensive_patch, normal_patch, cheap_patch], loc="upper right", fontsize=9 +) # Add "Tomorrow" label -ax1.annotate('← TODAY', xy=(-4, 200), fontsize=10, fontweight='bold', color='gray') -ax1.annotate('TOMORROW →', xy=(12, 60), fontsize=10, fontweight='bold', color='green') +ax1.annotate("← TODAY", xy=(-4, 200), fontsize=10, fontweight="bold", color="gray") +ax1.annotate("TOMORROW →", xy=(12, 60), fontsize=10, fontweight="bold", color="green") # Plot 2: Current (buggy) behavior ax2 = axes[1] # Only plot from current time onwards future_hours = hours_from_now[current_quarter:] future_current = current_offset[current_quarter:] -ax2.fill_between(future_hours, future_current, 0, - where=[o > 0 for o in future_current], - color='#ff6666', alpha=0.7, label='Heating (+offset)') -ax2.fill_between(future_hours, future_current, 0, - where=[o <= 0 for o in future_current], - color='#6666ff', alpha=0.7, label='Reducing (-offset)') -ax2.axhline(y=0, color='gray', linestyle='-', linewidth=0.5) -ax2.axvline(x=0, color='black', linestyle='--', linewidth=2) -ax2.set_ylabel('Offset (°C)', fontsize=11) +ax2.fill_between( + future_hours, + future_current, + 0, + where=[o > 0 for o in future_current], + color="#ff6666", + alpha=0.7, + label="Heating (+offset)", +) +ax2.fill_between( + future_hours, + future_current, + 0, + where=[o <= 0 for o in future_current], + color="#6666ff", + alpha=0.7, + label="Reducing (-offset)", +) +ax2.axhline(y=0, color="gray", linestyle="-", linewidth=0.5) +ax2.axvline(x=0, color="black", linestyle="--", linewidth=2) +ax2.set_ylabel("Offset (°C)", fontsize=11) ax2.set_ylim(-4, 3) ax2.grid(True, alpha=0.3) -ax2.set_title('CURRENT Behavior (Bug): Heating during expensive period!', fontsize=11, color='red') -ax2.legend(loc='upper right', fontsize=9) +ax2.set_title("CURRENT Behavior (Bug): Heating during expensive period!", fontsize=11, color="red") +ax2.legend(loc="upper right", fontsize=9) # Annotate the problem -ax2.annotate('BUG: +1°C offset\n(prediction layer\noverrides price)', - xy=(1, 1.0), xytext=(3, 2.2), - fontsize=9, color='red', - arrowprops=dict(arrowstyle='->', color='red')) +ax2.annotate( + "BUG: +1°C offset\n(prediction layer\noverrides price)", + xy=(1, 1.0), + xytext=(3, 2.2), + fontsize=9, + color="red", + arrowprops=dict(arrowstyle="->", color="red"), +) # Plot 3: Expected (fixed) behavior ax3 = axes[2] future_expected = expected_offset[current_quarter:] -ax3.fill_between(future_hours, future_expected, 0, - where=[o > 0 for o in future_expected], - color='#ff6666', alpha=0.7, label='Heating (+offset)') -ax3.fill_between(future_hours, future_expected, 0, - where=[o <= 0 for o in future_expected], - color='#6666ff', alpha=0.7, label='Reducing (-offset)') -ax3.axhline(y=0, color='gray', linestyle='-', linewidth=0.5) -ax3.axvline(x=0, color='black', linestyle='--', linewidth=2) -ax3.set_ylabel('Offset (°C)', fontsize=11) -ax3.set_xlabel('Hours from now', fontsize=11) +ax3.fill_between( + future_hours, + future_expected, + 0, + where=[o > 0 for o in future_expected], + color="#ff6666", + alpha=0.7, + label="Heating (+offset)", +) +ax3.fill_between( + future_hours, + future_expected, + 0, + where=[o <= 0 for o in future_expected], + color="#6666ff", + alpha=0.7, + label="Reducing (-offset)", +) +ax3.axhline(y=0, color="gray", linestyle="-", linewidth=0.5) +ax3.axvline(x=0, color="black", linestyle="--", linewidth=2) +ax3.set_ylabel("Offset (°C)", fontsize=11) +ax3.set_xlabel("Hours from now", fontsize=11) ax3.set_ylim(-4, 3) ax3.grid(True, alpha=0.3) -ax3.set_title('EXPECTED Behavior (Fixed): Reduce now, pre-heat during cheap tomorrow', - fontsize=11, color='green') -ax3.legend(loc='upper right', fontsize=9) +ax3.set_title( + "EXPECTED Behavior (Fixed): Reduce now, pre-heat during cheap tomorrow", + fontsize=11, + color="green", +) +ax3.legend(loc="upper right", fontsize=9) # Annotate the fix -ax3.annotate('FIXED: -1.8°C offset\n(reduce heating,\nwait for cheap)', - xy=(1, -1.8), xytext=(3, -3.0), - fontsize=9, color='green', - arrowprops=dict(arrowstyle='->', color='green')) +ax3.annotate( + "FIXED: -1.8°C offset\n(reduce heating,\nwait for cheap)", + xy=(1, -1.8), + xytext=(3, -3.0), + fontsize=9, + color="green", + arrowprops=dict(arrowstyle="->", color="green"), +) -ax3.annotate('Pre-heat when\nprices are cheap', - xy=(10, 1.5), xytext=(14, 2.5), - fontsize=9, color='green', - arrowprops=dict(arrowstyle='->', color='green')) +ax3.annotate( + "Pre-heat when\nprices are cheap", + xy=(10, 1.5), + xytext=(14, 2.5), + fontsize=9, + color="green", + arrowprops=dict(arrowstyle="->", color="green"), +) # Add hour markers on x-axis ax3.set_xticks(range(-16, 32, 4)) ax3.set_xlim(-16, 32) plt.tight_layout() -plt.savefig('/workspaces/EffektGuard/docs/dev/price_optimization_comparison.png', dpi=150, bbox_inches='tight') +plt.savefig( + str(Path(__file__).resolve().parents[1] / "docs" / "dev" / "price_optimization_comparison.png"), + dpi=150, + bbox_inches="tight", +) plt.show() -print("\n✅ Graph saved to: /workspaces/EffektGuard/docs/dev/price_optimization_comparison.png") +print(f"\n✅ Graph saved to: {Path(__file__).resolve().parents[1] / 'docs' / 'dev'}") print("\nKey observations:") print(" • Current (bug): +1°C offset during expensive period (176 öre)") print(" • Expected (fix): -1.8°C offset during expensive, +1.5°C during cheap tomorrow") diff --git a/scripts/week_watch.sh b/scripts/week_watch.sh new file mode 100755 index 00000000..402cd46f --- /dev/null +++ b/scripts/week_watch.sh @@ -0,0 +1,175 @@ +#!/bin/bash +# Week-long live observation of EffektGuard against real SE4 spot prices. +# +# Do not run this directly - run scripts/start_week.sh, which is idempotent and also brings +# Home Assistant up. This script assumes it is the only copy of itself. +# +# Two jobs: +# 1. keep Home Assistant up (restart it if it dies) +# 2. snapshot what the integration actually DID, every 15 minutes, to a CSV +# +# SINGLE INSTANCE, ENFORCED WITH A PID FILE. Two earlier attempts at this were both wrong: +# +# `pkill -f week_watch.sh` does not reliably reach a process in its own `setsid` session, so a +# "restarted" watcher ran ALONGSIDE the old one and both appended to the same CSV. +# +# Then `flock` on fd 9 - and an `exec 9>` fd is INHERITED BY CHILDREN. Killing the watcher left +# its `sleep 900` child holding the lock, so the lock outlived the process: start_week.sh reported +# "already running" when nothing was, and a new watcher could never take the lock. A lock a corpse +# can hold is worse than no lock. +# +# A pid file cannot be inherited. We check the pid is alive AND is actually a week_watch. +# +# THIS BOX HAS NO INIT. pid 1 is `docker-init -- sleep infinity`: no systemd, no cron, nothing that +# runs on boot. A reboot kills Home Assistant and this watcher, and NOTHING brings them back. +# After a reboot somebody has to run scripts/start_week.sh. That is a property of the box, not +# something the script can fix. +# +# Output: /workspace/.ha-config/week_watch.csv (git-excluded, like the rest of .ha-config) + +set -u + +LOG=/workspace/.ha-config/ha.log +CSV=/workspace/.ha-config/week_watch.csv +WATCHLOG=/workspace/.ha-config/week_watch.log +PIDFILE=/workspace/.ha-config/week_watch.pid +INTERVAL=900 # 15 minutes + +if [ -f "$PIDFILE" ]; then + OLD=$(tr -dc '0-9' <"$PIDFILE") + if [ -n "$OLD" ] && [ -d "/proc/$OLD" ] && tr '\0' ' ' <"/proc/$OLD/cmdline" 2>/dev/null | grep -q week_watch.sh; then + echo "$(date -u +%FT%TZ) another week_watch is already running (pid $OLD) - exiting" >>"$WATCHLOG" + exit 0 + fi + echo "$(date -u +%FT%TZ) stale pidfile (pid $OLD gone) - taking over" >>"$WATCHLOG" +fi +echo $$ >"$PIDFILE" +trap 'rm -f "$PIDFILE"' EXIT + +ha_is_up() { + curl -s -o /dev/null -m 8 "http://localhost:8125/" 2>/dev/null +} + +# THE PUMP IS PART OF THE STACK, and the first version of this watcher did not know that. +# +# The simulated F1155 serves BT1, BT25 and the degree minutes over modbus on :5020. Without it +# EffektGuard cannot read the sensors it requires, and it does the right thing - it refuses to +# control the heat pump on incomplete data and says so, every cycle. After the reboot that is +# exactly what the week recorded: a static house, a frozen price, and the error count climbing by +# six every fifteen minutes. Home Assistant was up, the watcher was up, and the thing they were +# both watching was not there. +pump_is_up() { + python3 -c " +import socket, sys +s = socket.socket(); s.settimeout(3) +try: + s.connect(('127.0.0.1', 5020)) +except OSError: + sys.exit(1) +finally: + s.close() +" 2>/dev/null +} + +# Devbox login. These default to the throwaway onboarding account this box's CLAUDE.md +# creates (dev/dev); override via env for any box where that is not true. A committed literal +# password is a bad habit even when it guards nothing. +HA_USER="${WEEK_WATCH_HA_USER:-dev}" +HA_PASS="${WEEK_WATCH_HA_PASS:-dev}" + +token() { + local cid="http://localhost:8125/" fid code + fid=$(curl -s -m 10 -X POST http://localhost:8125/auth/login_flow \ + -H 'Content-Type: application/json' \ + -d "{\"client_id\":\"$cid\",\"handler\":[\"homeassistant\",null],\"redirect_uri\":\"$cid\"}" | + python3 -c "import sys,json;print(json.load(sys.stdin).get('flow_id',''))" 2>/dev/null) || return 1 + [ -z "$fid" ] && return 1 + code=$(curl -s -m 10 -X POST "http://localhost:8125/auth/login_flow/$fid" \ + -H 'Content-Type: application/json' \ + -d "{\"client_id\":\"$cid\",\"username\":\"$HA_USER\",\"password\":\"$HA_PASS\"}" | + python3 -c "import sys,json;print(json.load(sys.stdin).get('result',''))" 2>/dev/null) || return 1 + [ -z "$code" ] && return 1 + curl -s -m 10 -X POST http://localhost:8125/auth/token \ + -d "grant_type=authorization_code&code=$code&client_id=$cid" | + python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null +} + +[ -f "$CSV" ] || echo "utc,offset,degree_minutes,indoor,supply,outdoor,price_ore,peak_today_kw,peak_month_kw,hvac,errors,restarts" >"$CSV" + +echo "$(date -u +%FT%TZ) week_watch started (pid $$)" >>"$WATCHLOG" +RESTARTS=0 + +while true; do + if ! pump_is_up; then + RESTARTS=$((RESTARTS + 1)) + echo "$(date -u +%FT%TZ) NIBE simulator down - starting it (#$RESTARTS)" >>"$WATCHLOG" + setsid nohup /workspace/.venv/bin/python /workspace/scripts/simulation/nibe_modbus_simulator.py \ + >>/workspace/.ha-config/nibe_sim.log 2>&1 >"$WATCHLOG" + nohup start-ha >>"$LOG" 2>&1 & + for _ in $(seq 1 30); do + sleep 10 + ha_is_up && break + done + fi + + TOK=$(token) || TOK="" + if [ -n "$TOK" ]; then + # `grep -c` prints 0 AND exits 1 when it matches nothing, so a `|| echo 0` fallback appends a + # SECOND zero and splits the CSV row in half. Force it to one integer, always. + ERRS=$(grep -c "ERROR.*effektguard" "$LOG" 2>/dev/null | head -1 | tr -dc '0-9') + ERRS=${ERRS:-0} + + ROW=$(curl -s -m 15 -H "Authorization: Bearer $TOK" http://localhost:8125/api/states | + RESTARTS="$RESTARTS" ERRS="$ERRS" python3 -c " +import sys, json, os, datetime +try: + states = {e['entity_id']: e for e in json.load(sys.stdin)} +except Exception: + sys.exit(1) +def s(eid, attr=None): + e = states.get(eid) + if not e: + return '' + v = e['attributes'].get(attr) if attr else e['state'] + return '' if v in (None, 'unknown', 'unavailable') else v +row = [ + datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + s('sensor.effektguard_current_offset'), + s('sensor.effektguard_degree_minutes'), + s('climate.effektguard', 'current_temperature'), + s('sensor.effektguard_supply_temperature'), + s('climate.effektguard', 'outdoor_temp'), + s('climate.effektguard', 'current_price'), + s('sensor.effektguard_peak_today'), + s('sensor.effektguard_peak_this_month'), + s('climate.effektguard'), + ''.join(c for c in os.environ.get('ERRS', '0') if c.isdigit()) or '0', + ''.join(c for c in os.environ.get('RESTARTS', '0') if c.isdigit()) or '0', +] +line = ','.join(str(x).replace(',', ' ').replace(chr(10), ' ') for x in row) +if line.count(',') != 11: + sys.exit(1) +print(line) +") + # Only a row with exactly 12 fields is written. A malformed record over seven days is worse + # than a missing one: it looks fine until the day somebody tries to read it. + if [ -n "$ROW" ]; then + echo "$ROW" >>"$CSV" + else + echo "$(date -u +%FT%TZ) skipped a malformed/empty sample" >>"$WATCHLOG" + fi + else + echo "$(date -u +%FT%TZ) could not authenticate to HA - skipping this sample" >>"$WATCHLOG" + fi + + sleep "$INTERVAL" +done diff --git a/tests/test_config_reload.py b/tests/test_config_reload.py index 6a0c491f..f75e32e4 100644 --- a/tests/test_config_reload.py +++ b/tests/test_config_reload.py @@ -1,21 +1,10 @@ -"""Unit tests for config reload functionality. - -Tests to verify that runtime configuration changes (temperature, thermal mass, etc.) -trigger hot-reload without full integration restart, and that all components properly -update their internal state. - -Critical behaviors tested: -1. Select/Number entities update entry.options (not entry.data) -2. Runtime option changes trigger async_update_config (not full reload) -3. Critical option changes trigger full reload -4. Decision engine cached values are properly updated -5. Sensor state restoration works correctly -6. Complete chain from user action to optimization is verified -7. Learning data persists across restarts and reloads - -VERIFICATION STATUS: ✅ ALL 24 TESTS PASSING -Date: October 18, 2025 -Analysis: FINAL_ANALYSIS_CONFIG_RELOAD.md +"""Unit tests for config reload (runtime option changes without full restart). + +Covers: +- Runtime option changes trigger async_update_config, not a full reload +- Critical option changes trigger full reload +- Decision engine cached values (target_temp, tolerance) are updated on config change +- Sensor state restoration and learning-data persistence across restarts/reloads """ import pytest @@ -111,7 +100,7 @@ def mock_coordinator(mock_hass, mock_config_entry): class TestUpdateListenerSmartReload: - """Test update listener's smart detection of runtime vs critical changes.""" + """The update listener always hot-reloads runtime settings; it never tears the entry down.""" @pytest.mark.asyncio async def test_runtime_option_triggers_hot_reload( @@ -131,8 +120,7 @@ async def test_runtime_option_triggers_hot_reload( # Call update listener await async_reload_entry(mock_hass, mock_config_entry) - # Should call async_update_config (hot reload) - # FIX: Now passes merged config (entry.data + entry.options) for switch support + # Hot reload is called with the merged config (entry.data + entry.options). expected_config = dict(mock_config_entry.data) expected_config.update(mock_config_entry.options) mock_coordinator.async_update_config.assert_called_once_with(expected_config) @@ -165,15 +153,10 @@ async def test_multiple_runtime_options_trigger_hot_reload( async def test_entity_in_options_still_hot_reloads( self, mock_hass, mock_config_entry, mock_coordinator ): - """Verify options always hot-reload since entity selections are in data, not options. - - Entity selections (nibe_entity, gespot_entity, etc.) are set during initial - config flow and stored in entry.data. The options flow only exposes runtime - settings. Therefore, the update_listener will never see entity changes - - those only happen through reconfigure flow which triggers async_setup_entry. + """Options always hot-reload; entity keys never legitimately appear here. - Even if someone manually puts entity keys in options (shouldn't happen), - we still hot-reload because the options flow is designed for runtime changes. + Entity selections live in entry.data and change only via the reconfigure flow. + Even an entity-like key in options still hot-reloads, never a full reload. """ mock_hass.data[DOMAIN][mock_config_entry.entry_id] = mock_coordinator @@ -438,33 +421,6 @@ async def test_peak_today_sensor_restores_state(self, mock_coordinator, mock_con assert mock_coordinator.peak_today == 5.75 -class TestRuntimeOptionsCompleteness: - """Verify all runtime options are properly handled.""" - - def test_runtime_options_defined(self): - """Verify runtime options set is defined and contains expected keys.""" - # Runtime options from __init__.py - runtime_options = { - "target_indoor_temp", - "tolerance", - "optimization_mode", - "control_priority", - "thermal_mass", - "insulation_quality", - "dhw_morning_hour", - "dhw_morning_enabled", - "dhw_evening_hour", - "dhw_evening_enabled", - "dhw_target_temp", - "peak_protection_margin", - } - - # These are the keys that can be changed without full reload - assert len(runtime_options) > 0 - assert "target_indoor_temp" in runtime_options - assert "optimization_mode" in runtime_options - - class TestLearningDataPersistence: """Test that learning data persists across restarts and config changes.""" @@ -483,6 +439,11 @@ async def test_learning_data_saved_on_shutdown(self, mock_coordinator): coordinator.effect.async_save = AsyncMock() coordinator._power_sensor_listener = None + # State the base DataUpdateCoordinator.async_shutdown touches: it calls super(), + # which sets _shutdown_requested and shuts down the debouncer. + coordinator._shutdown_requested = False + coordinator._debounced_refresh = Mock() + # Bind real shutdown method coordinator.async_shutdown = EffektGuardCoordinator.async_shutdown.__get__( coordinator, EffektGuardCoordinator @@ -583,19 +544,9 @@ async def test_chain_no_reload_for_runtime_options( # Should NOT call async_reload (full reload) mock_hass.config_entries.async_reload.assert_not_called() - # CHAIN VERIFIED: Runtime changes → Hot-reload only ✓ - class TestStorageMechanismValidation: - """Validate all four independent storage mechanisms.""" - - def test_config_options_storage_location(self): - """Document: Config options stored in core.config_entries.""" - # This is handled by Home Assistant core - # Storage location: .storage/core.config_entries - # Persistence: Automatic - # Restoration: Automatic on HA startup - assert True # Documentation test + """Validate the independent persistence storage mechanisms are wired up.""" def test_learning_data_storage_location(self): """Document: Learning data stored in effektguard_learning.""" @@ -835,15 +786,6 @@ async def test_thermal_predictor_survives_config_reload(self): class TestOffsetPersistence: """Test offset persistence to avoid redundant API calls on restart.""" - @pytest.mark.asyncio - async def test_offset_tracking_initialized(self): - """Verify coordinator initializes offset tracking attributes.""" - from custom_components.effektguard.coordinator import EffektGuardCoordinator - - # Check that tracking attributes exist in coordinator - assert hasattr(EffektGuardCoordinator, "__init__") - # Attributes will be set in __init__, can't test without full initialization - @pytest.mark.asyncio async def test_offset_saved_after_successful_application(self): """Verify offset is saved to learning data after successful application.""" @@ -896,26 +838,6 @@ async def test_offset_restored_on_startup(self): assert restored_timestamp is not None assert "2025-10-18" in restored_timestamp - @pytest.mark.asyncio - async def test_redundant_offset_call_avoided(self): - """Verify redundant API call is skipped when offset matches last applied.""" - # Test logic: if last_applied_offset == 2.5 and decision.offset == 2.5 - # then API call should be skipped - - last_applied = 2.5 - decision_offset = 2.5 - - # Check if values match (within tolerance) - should_skip = abs(decision_offset - last_applied) < 0.01 - - assert should_skip is True - - # Test with different offset - decision_offset = 3.0 - should_skip = abs(decision_offset - last_applied) < 0.01 - - assert should_skip is False - @pytest.mark.asyncio async def test_offset_persistence_across_restart_cycle(self): """Integration test: Offset survives full save/restore cycle.""" @@ -956,11 +878,7 @@ async def test_offset_persistence_across_restart_cycle(self): class TestTargetTemperaturePersistence: - """Tests for target temperature persistence across restarts (Dec 10, 2025). - - Issue: Target temperature reset from 21.5°C to 21.0°C after restart. - Root cause: entry.options should override entry.data during initialization. - """ + """Target temperature persists across restarts: entry.options overrides entry.data.""" def test_decision_engine_uses_options_over_data(self): """Verify DecisionEngine reads target_temp from options first, then data.""" diff --git a/tests/test_entity_comprehensive.py b/tests/test_entity_comprehensive.py index c5008ee4..291d90cc 100644 --- a/tests/test_entity_comprehensive.py +++ b/tests/test_entity_comprehensive.py @@ -1,15 +1,7 @@ """Comprehensive tests for all EffektGuard entities, sensors, and attributes. -Tests verify: -- All sensor entities are created and have correct configuration -- All sensor attributes are properly defined -- Climate entity functions correctly with proper attributes -- Switch entities are created with correct configuration -- All entity unique IDs, names, and device info are correct -- Sensor value functions handle None/missing data gracefully -- Extra state attributes are populated correctly - -This ensures complete entity coverage for production use. +Guards sensor/switch/climate entity configuration, unique IDs and device info, sensor +value functions (including None/missing data), and extra state attributes. """ import pytest @@ -238,9 +230,8 @@ def test_all_sensor_keys_are_unique(self): assert len(keys) == len(set(keys)), "Duplicate sensor keys found" def test_temperature_sensors_have_correct_config(self): - """Verify temperature sensors have proper device class and units.""" + """Verify ABSOLUTE temperature sensors have proper device class and units.""" temp_sensors = [ - "current_offset", "supply_temperature", "outdoor_temperature", "indoor_temperature", @@ -252,6 +243,21 @@ def test_temperature_sensors_have_correct_config(self): assert sensor.native_unit_of_measurement == UnitOfTemperature.CELSIUS assert sensor.state_class == SensorStateClass.MEASUREMENT + def test_curve_offset_is_a_temperature_delta_not_a_temperature(self): + """current_offset is a curve INTERVAL: device_class TEMPERATURE_DELTA, not TEMPERATURE. + + TEMPERATURE would make HA convert it absolutely (0 C -> 32 F for non-metric users); + TEMPERATURE_DELTA is the correct class and still permits MEASUREMENT. + """ + offset = next(s for s in SENSORS if s.key == "current_offset") + + assert offset.device_class == SensorDeviceClass.TEMPERATURE_DELTA, ( + "current_offset is a temperature DELTA. Declaring it TEMPERATURE makes HA " + "convert it absolutely - 0 C becomes 32 F for any non-metric user." + ) + assert offset.native_unit_of_measurement == UnitOfTemperature.CELSIUS + assert offset.state_class == SensorStateClass.MEASUREMENT + def test_power_sensors_have_correct_config(self): """Verify power sensors have proper device class and units.""" power_sensors = ["peak_today", "peak_this_month", "nibe_power"] @@ -310,11 +316,12 @@ class TestSensorValueFunctions: """Test sensor value functions with real data.""" def test_current_offset_sensor(self, mock_coordinator_with_data, mock_config_entry): - """Test current_offset sensor reads decision offset.""" + """Test current_offset sensor reads the offset applied to NIBE.""" sensor_desc = next(s for s in SENSORS if s.key == "current_offset") sensor = EffektGuardSensor(mock_coordinator_with_data, mock_config_entry, sensor_desc) + mock_coordinator_with_data.current_offset = 3.0 - assert sensor.native_value == 2.0 + assert sensor.native_value == 3.0 def test_degree_minutes_sensor(self, mock_coordinator_with_data, mock_config_entry): """Test degree_minutes sensor reads NIBE data.""" diff --git a/tests/test_optional_features.py b/tests/test_optional_features.py index a8a97f07..0ee8da33 100644 --- a/tests/test_optional_features.py +++ b/tests/test_optional_features.py @@ -258,81 +258,11 @@ def test_optional_features_sensor_attributes(self): sensor = next(s for s in SENSORS if s.key == "optional_features_status") - assert sensor.name == "Optional Features Status" + # Name is resolved by HA from the translation_key, not a hardcoded English name. + assert sensor.translation_key == "optional_features_status" assert sensor.icon == "mdi:feature-search-outline" assert sensor.value_fn is not None -class TestOptionalFeaturesEstimation: - """Test estimation fallbacks for optional features.""" - - def test_degree_minutes_estimation_note(self): - """Test that missing DM sensor shows estimation note.""" - # This will be implemented in adapters - # For now, just verify the sensor can show the status - from custom_components.effektguard.sensor import SENSORS - - sensor = next(s for s in SENSORS if s.key == "optional_features_status") - assert sensor is not None - - def test_power_estimation_note(self): - """Test that missing power sensor shows estimation note.""" - from custom_components.effektguard.sensor import SENSORS - - sensor = next(s for s in SENSORS if s.key == "optional_features_status") - assert sensor is not None - - -class TestWeatherForecastValidation: - """Test weather forecast validation.""" - - def test_weather_with_sufficient_forecast(self, mock_hass): - """Test weather entity with 24h forecast (sufficient).""" - config_flow = EffektGuardConfigFlow() - config_flow.hass = mock_hass - - weather_state = mock_hass.states.get("weather.home") - forecast = weather_state.attributes.get("forecast") - - assert len(forecast) >= 12 # Minimum 12h required - - def test_weather_with_short_forecast(self): - """Test weather entity with only 6h forecast (insufficient).""" - hass = MagicMock(spec=HomeAssistant) - - # Short forecast - mock_state = MagicMock( - entity_id="weather.home", - attributes={"forecast": [{"datetime": "2025-10-14T12:00:00", "temperature": 15}] * 6}, - ) - mock_states_obj = MagicMock() - hass.states = mock_states_obj - mock_states_obj.get = lambda entity_id: mock_state - - forecast = mock_state.attributes.get("forecast") - - assert len(forecast) < 12 # Less than minimum - - -class TestTomorrowPricesDetection: - """Test tomorrow prices detection from spot price integration.""" - - def test_gespot_with_tomorrow_prices(self): - """Test spot price integration with tomorrow prices available.""" - # This will be implemented in gespot_adapter.py - # For now, verify the status sensor can detect it - from custom_components.effektguard.sensor import SENSORS - - sensor = next(s for s in SENSORS if s.key == "optional_features_status") - assert sensor is not None - - def test_gespot_without_tomorrow_prices(self): - """Test spot price integration with only today prices.""" - from custom_components.effektguard.sensor import SENSORS - - sensor = next(s for s in SENSORS if s.key == "optional_features_status") - assert sensor is not None - - if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/test_regression_imports.py b/tests/test_regression_imports.py index b906a009..0d3c7cdd 100644 --- a/tests/test_regression_imports.py +++ b/tests/test_regression_imports.py @@ -1,13 +1,8 @@ """Regression tests for import statements and module structure. -This test suite validates: -1. All Python files can be imported without errors -2. All import statements are valid and resolve correctly -3. No circular dependencies exist -4. All required modules are accessible -5. Constants and shared resources are properly defined - -Critical for catching regressions after major refactoring. +Guards that every module imports cleanly, all absolute and relative imports resolve, +there are no circular dependencies, and shared constants are defined and used. Catches +import-level regressions after refactoring. """ import ast @@ -18,7 +13,6 @@ from typing import Dict, List, Set, Tuple import pytest - # Root directory of the custom component COMPONENT_ROOT = Path(__file__).parent.parent / "custom_components" / "effektguard" @@ -767,13 +761,10 @@ def test_all_constants_are_used(self, validator): warnings.warn("\n".join(report_lines)) def test_no_duplicate_constants_in_const_py(self, validator): - """Check that const.py has no duplicate constant definitions. - - Detects: - 1. Same constant name defined multiple times at module level - 2. Different constant names with the same value (potential semantic duplicates) + """const.py has no duplicate constant definitions. - Skips enum class members (they can have same names like UNKNOWN in different enums). + Flags a name defined twice at module level, plus numeric semantic duplicates + (same value, different names). Skips enum members, which may repeat names. """ const_file = COMPONENT_ROOT / "const.py" content = const_file.read_text() diff --git a/tests/test_services.py b/tests/test_services.py index 3f8fa022..20f20e8f 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -11,7 +11,7 @@ from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.exceptions import ServiceValidationError from custom_components.effektguard.const import ( @@ -46,6 +46,7 @@ def mock_coordinator(mock_hass): coordinator = MagicMock(spec=EffektGuardCoordinator) coordinator.hass = mock_hass + coordinator.optimization_enabled = True # Mock decision engine coordinator.engine = MagicMock(spec=DecisionEngine) @@ -56,8 +57,11 @@ def mock_coordinator(mock_hass): coordinator.effect.reset_monthly_peaks = MagicMock() coordinator.effect.async_save = AsyncMock() - # Mock coordinator methods + # Two refresh paths: async_request_refresh reads/decides but writes nothing; + # async_refresh_and_apply drives the heat pump. coordinator.async_request_refresh = AsyncMock() + coordinator.async_refresh_and_apply = AsyncMock() + coordinator.async_apply_manual_override = AsyncMock() # Mock data for calculate_optimal_schedule coordinator.data = { @@ -121,8 +125,11 @@ async def test_force_offset_sets_override(mock_hass, mock_coordinator): await handler(call) # Verify override was set - mock_coordinator.engine.set_manual_override.assert_called_once_with(2.5, 60) - mock_coordinator.async_request_refresh.assert_called_once() + mock_coordinator.async_apply_manual_override.assert_awaited_once_with(2.5, 60) + + # Applied immediately via async_apply_manual_override; the handler does not call the write + # path directly, and does not settle for a plain refresh that would leave the override unwritten. + mock_coordinator.async_refresh_and_apply.assert_not_called() async def test_force_offset_with_zero_duration(mock_hass, mock_coordinator): @@ -147,7 +154,7 @@ async def test_force_offset_with_zero_duration(mock_hass, mock_coordinator): await handler(call) - mock_coordinator.engine.set_manual_override.assert_called_once_with(-3.0, 0) + mock_coordinator.async_apply_manual_override.assert_awaited_once_with(-3.0, 0) async def test_force_offset_validates_range(mock_hass, mock_coordinator): @@ -208,7 +215,11 @@ async def test_reset_peak_tracking_clears_peaks(mock_hass, mock_coordinator): # Verify peaks were reset mock_coordinator.effect.reset_monthly_peaks.assert_called_once() mock_coordinator.effect.async_save.assert_called_once() + + # A plain refresh so entities catch up - resetting a counter must not write a curve + # offset to the pump. mock_coordinator.async_request_refresh.assert_called_once() + mock_coordinator.async_refresh_and_apply.assert_not_called() # ============================================================================ @@ -242,9 +253,9 @@ async def test_boost_heating_sets_max_offset(mock_hass, mock_coordinator): await handler(call) - # Should set MAX_OFFSET (+10°C) - mock_coordinator.engine.set_manual_override.assert_called_once_with(MAX_OFFSET, 120) - mock_coordinator.async_request_refresh.assert_called_once() + # Should set MAX_OFFSET (+10°C) and drive the pump with it immediately. + mock_coordinator.async_apply_manual_override.assert_awaited_once_with(MAX_OFFSET, 120) + mock_coordinator.async_refresh_and_apply.assert_not_called() async def test_boost_heating_default_duration(mock_hass, mock_coordinator): @@ -270,7 +281,7 @@ async def test_boost_heating_default_duration(mock_hass, mock_coordinator): await handler(call) # Should use default duration (120 minutes) - mock_coordinator.engine.set_manual_override.assert_called_once_with(MAX_OFFSET, 120) + mock_coordinator.async_apply_manual_override.assert_awaited_once_with(MAX_OFFSET, 120) # ============================================================================ @@ -284,13 +295,13 @@ async def test_calculate_optimal_schedule_service_registration(mock_hass): await _async_register_services(mock_hass) - # Should be registered with supports_response=True + # Registered with the SupportsResponse enum, not a bare True: HA compares supports_response + # by identity, and True is neither SupportsResponse.NONE nor SupportsResponse.OPTIONAL. calls = mock_hass.services.async_register.call_args_list schedule_call = next(call for call in calls if call[0][1] == SERVICE_CALCULATE_OPTIMAL_SCHEDULE) - # Verify supports_response is True assert "supports_response" in schedule_call[1] - assert schedule_call[1]["supports_response"] is True + assert schedule_call[1]["supports_response"] is SupportsResponse.OPTIONAL async def test_calculate_optimal_schedule_returns_24h_schedule(mock_hass, mock_coordinator): @@ -512,14 +523,14 @@ def test_effect_manager_reset_monthly_peaks(): effect._monthly_peaks = [ PeakEvent( timestamp=datetime.now(), - quarter_of_day=50, + period_of_day=50, actual_power=5.0, effective_power=5.0, is_daytime=True, ), PeakEvent( timestamp=datetime.now(), - quarter_of_day=60, + period_of_day=60, actual_power=4.5, effective_power=4.5, is_daytime=True, 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_gespot_dst_days.py b/tests/unit/adapters/test_gespot_dst_days.py index ad903afd..7899b4a0 100644 --- a/tests/unit/adapters/test_gespot_dst_days.py +++ b/tests/unit/adapters/test_gespot_dst_days.py @@ -14,7 +14,7 @@ PriceData, QuarterPeriod, ) -from custom_components.effektguard.const import QUARTER_INTERVAL_MINUTES, QUARTERS_PER_DAY +from custom_components.effektguard.const import MINUTES_PER_QUARTER, QUARTERS_PER_DAY from custom_components.effektguard.utils.time_utils import resolve_period_index @@ -31,7 +31,7 @@ def make_raw_day(base: datetime, skip_hours: tuple[int, ...] = ()) -> list[dict] continue raw.append( { - "time": (base + timedelta(minutes=QUARTER_INTERVAL_MINUTES * quarter)).isoformat(), + "time": (base + timedelta(minutes=MINUTES_PER_QUARTER * quarter)).isoformat(), "value": float(quarter), } ) diff --git a/tests/unit/adapters/test_nibe_discovery.py b/tests/unit/adapters/test_nibe_discovery.py index e1295210..737b370b 100644 --- a/tests/unit/adapters/test_nibe_discovery.py +++ b/tests/unit/adapters/test_nibe_discovery.py @@ -14,6 +14,7 @@ import pytest from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed from conftest import make_mock_async_all from custom_components.effektguard.adapters.nibe_adapter import NibeAdapter @@ -33,6 +34,21 @@ def make_state(entity_id: str, state: str, attributes: dict | None = None) -> Ma return mock +def required_core_states() -> list[MagicMock]: + """The readings get_current_state() refuses to run without. + + Outdoor temp, supply/flow temp and degree minutes are no longer substituted with + plausible constants when absent - the adapter raises UpdateFailed instead, so a broken + installation cannot be mistaken for a healthy one. Tests that exercise unrelated + behaviour (status parsing, priority, discovery ranking) must therefore supply them. + """ + return [ + make_state("sensor.bt1_outdoor_temperature_40004", "-3.2", TEMP_ATTRS), + make_state("sensor.bt2_supply_temp_s1_40008", "35.8", TEMP_ATTRS), + make_state("sensor.degree_minutes_16_bit_43005", "-120", {}), + ] + + def make_registry_entry( entity_id: str, disabled_by: str | None = None, @@ -421,8 +437,12 @@ async def test_core_keys_from_registry_trigger_rediscovery(self, monkeypatch): ] hass = make_hass([], registry_entries, monkeypatch) adapter = NibeAdapter(hass, {}) - # First cycle: only the stale registry entry exists - await adapter.get_current_state() + + # First cycle: only the stale registry entry exists, so no reading resolves. + # Refusing here is correct - the adapter must not invent values - but discovery + # still runs and caches the registry-backed id. + with pytest.raises(UpdateFailed): + await adapter.get_current_state() assert adapter.entity_cache["outdoor_temp"] == "sensor.bt1_outdoor_temperature_40004" # Live entities appear; the next cycle must re-discover and displace @@ -431,6 +451,7 @@ async def test_core_keys_from_registry_trigger_rediscovery(self, monkeypatch): make_state("sensor.bt1_outdoor_temperature_40004_2", "-3.2", TEMP_ATTRS), make_state("sensor.bt50_room_temp_s1_40033", "21.3", TEMP_ATTRS), make_state("sensor.bt2_supply_temp_s1_40008", "35.8", TEMP_ATTRS), + make_state("sensor.degree_minutes_16_bit_43005", "-120", {}), ], registry_entries, monkeypatch, @@ -526,7 +547,7 @@ class TestStatusParsing: async def test_numeric_compressor_state_falls_back_to_frequency(self, monkeypatch): """Raw Modbus 43427 is a numeric enum the bool parser cannot read; is_heating must derive from compressor frequency instead.""" - states = [ + states = required_core_states() + [ make_state("sensor.compressor_status_ep14_43427", "60", {}), make_state("sensor.compressor_frequency_43136", "62.0", {}), ] @@ -542,7 +563,7 @@ async def test_numeric_dhw_priority_is_not_heating(self, monkeypatch): """Raw Modbus priority 20 (Hot Water) with the compressor spinning: DHW production, not space heating - even though the numeric status enum is unreadable and the frequency fallback would fire.""" - states = [ + states = required_core_states() + [ make_state("sensor.prio_43086", "20", {}), make_state("sensor.compressor_status_ep14_43427", "60", {}), make_state("sensor.compressor_frequency_43136", "62.0", {}), @@ -557,7 +578,7 @@ async def test_numeric_dhw_priority_is_not_heating(self, monkeypatch): async def test_numeric_heating_priority_is_heating(self, monkeypatch): """Raw Modbus priority 30 (Heat) with the compressor spinning.""" - states = [ + states = required_core_states() + [ make_state("sensor.prio_43086", "30", {}), make_state("sensor.compressor_frequency_43136", "62.0", {}), ] @@ -572,7 +593,7 @@ async def test_numeric_heating_priority_is_heating(self, monkeypatch): async def test_unknown_priority_leaves_status_reads_untouched(self, monkeypatch): """MyUplink priority enums are not fully documented (raw 31 observed): unknown values must not override the pattern-based status reads.""" - states = [ + states = required_core_states() + [ make_state("sensor.gotham_city_priority", "31", {}), make_state("sensor.gotham_city_status_compressor", "Running", {}), ] @@ -586,7 +607,7 @@ async def test_unknown_priority_leaves_status_reads_untouched(self, monkeypatch) assert state.is_hot_water is False async def test_zero_frequency_is_not_heating_and_not_none(self, monkeypatch): - states = [ + states = required_core_states() + [ make_state("sensor.compressor_frequency_43136", "0.0", {}), ] hass = make_hass(states, [], monkeypatch) diff --git a/tests/unit/adapters/test_nibe_power_calculation.py b/tests/unit/adapters/test_nibe_power_calculation.py index dc636d5d..21668cfe 100644 --- a/tests/unit/adapters/test_nibe_power_calculation.py +++ b/tests/unit/adapters/test_nibe_power_calculation.py @@ -3,10 +3,15 @@ Tests the calculate_power_from_currents() method that reads BE1/BE2/BE3 sensors and calculates real power consumption. -Based on Swedish 3-phase electrical standards: -- 400V between phases, 240V phase-to-neutral +Based on European 3-phase electrical standards: +- IEC 60038 / EN 50160: the low-voltage supply is 230/400 V +- 400 V between phases, 230 V phase-to-neutral (400 / sqrt(3) = 230.94) - All NIBE heat pumps in Sweden are 3-phase - Power factor 0.95 (conservative for inverter compressor) + +NIBE_VOLTAGE_PER_PHASE must stay 230 V, not the legacy 240 V: at 240 V every power figure +derived from BE1/BE2/BE3 comes out ~4.3 % high, into the monthly peak history that drives +peak protection. """ import pytest @@ -30,7 +35,7 @@ def test_single_phase_current_only(self, nibe_adapter): ) # P = V × I × PF / 1000 - # 240V × 1.0A × 0.95 = 228W = 0.228 kW + # 230V × 1.0A × 0.95 = 218.5W = 0.2185 kW expected = (NIBE_VOLTAGE_PER_PHASE * 1.0 * NIBE_POWER_FACTOR) / 1000 assert power == pytest.approx(expected, rel=1e-3) @@ -44,7 +49,7 @@ def test_all_three_phases_active(self, nibe_adapter): ) # Total current: 10 + 8 + 9 = 27A - # P = 240V × 27A × 0.95 / 1000 = 6.156 kW + # P = 230V × 27A × 0.95 / 1000 = 5.900 kW total_amps = 10.0 + 8.0 + 9.0 expected = (NIBE_VOLTAGE_PER_PHASE * total_amps * NIBE_POWER_FACTOR) / 1000 assert power == pytest.approx(expected, rel=1e-3) @@ -74,19 +79,24 @@ def test_phase2_and_phase3_default_to_zero(self, nibe_adapter): class TestNibePowerCalculationSwedishStandards: """Test power calculation matches Swedish 3-phase standards.""" - def test_uses_swedish_voltage_standard(self, nibe_adapter): - """Test power calculation uses Swedish 240V phase-to-neutral.""" - # Swedish standard: 400V between phases, 240V phase-to-neutral + def test_uses_the_european_low_voltage_standard(self, nibe_adapter): + """230 V phase-to-neutral, per IEC 60038 - not the legacy 240 V.""" power = nibe_adapter.calculate_power_from_currents( phase1_amps=1.0, phase2_amps=0.0, phase3_amps=0.0, ) - # Should use NIBE_VOLTAGE_PER_PHASE (240V) not 230V or other expected = (NIBE_VOLTAGE_PER_PHASE * 1.0 * NIBE_POWER_FACTOR) / 1000 assert power == pytest.approx(expected, rel=1e-3) - assert NIBE_VOLTAGE_PER_PHASE == 240.0 # Verify constant + + assert NIBE_VOLTAGE_PER_PHASE == 230.0, ( + f"NIBE_VOLTAGE_PER_PHASE is {NIBE_VOLTAGE_PER_PHASE} V. IEC 60038 and EN 50160 declare " + f"the European supply as 230/400 V, and 400 / sqrt(3) = 230.94 - so a 400 V " + f"line-to-line system CANNOT have 240 V phase-to-neutral. 240 V is the legacy UK/US " + f"figure, and at that value every power reading derived from BE1/BE2/BE3 comes out " + f"4.3 % high, straight into the monthly peak history that drives peak protection." + ) def test_uses_conservative_power_factor(self, nibe_adapter): """Test power calculation uses conservative 0.95 power factor.""" @@ -221,7 +231,7 @@ class TestNibePowerCalculationCustomParameters: def test_custom_voltage_value(self, nibe_adapter): """Test power calculation with custom voltage.""" - # Test with slightly different voltage (e.g., 230V instead of 240V) + # Test with an explicit custom voltage that overrides the default power = nibe_adapter.calculate_power_from_currents( phase1_amps=10.0, phase2_amps=10.0, @@ -243,7 +253,7 @@ def test_custom_power_factor(self, nibe_adapter): power_factor=0.98, # Custom power factor ) - # P = 240V × 30A × 0.98 / 1000 = 7.056 kW + # P = 230V × 30A × 0.98 / 1000 = 6.762 kW expected = (NIBE_VOLTAGE_PER_PHASE * 30.0 * 0.98) / 1000 assert power == pytest.approx(expected, rel=1e-3) diff --git a/tests/unit/adapters/test_nibe_write_path.py b/tests/unit/adapters/test_nibe_write_path.py index 34520e2e..8bf081d5 100644 --- a/tests/unit/adapters/test_nibe_write_path.py +++ b/tests/unit/adapters/test_nibe_write_path.py @@ -45,7 +45,7 @@ async def test_successful_write_is_blocking(self): # Big offset so the accumulator crosses the +-1 threshold from 0 result = await adapter.set_curve_offset(3.0) - assert result is True + assert result == 3 call = hass.services.async_call.call_args assert call.args[:2] == ("number", "set_value") assert call.kwargs["blocking"] is True @@ -57,7 +57,7 @@ async def test_handler_failure_does_not_record_success(self): adapter, hass = make_adapter(call_side_effect=HomeAssistantError("value out of range")) result = await adapter.set_curve_offset(3.0) - assert result is False + assert result is None # bookkeeping not advanced to the failed value assert adapter._last_nibe_offset == 0 assert adapter._last_write is None @@ -67,7 +67,7 @@ async def test_handler_failure_never_raises(self): adapter, hass = make_adapter(call_side_effect=TypeError("boom")) # Must not raise result = await adapter.set_curve_offset(3.0) - assert result is False + assert result is None class TestOffsetClamp: @@ -93,7 +93,7 @@ async def test_default_template_number_range_still_allows_configured(self): adapter, hass = make_adapter(offset_attrs={"min": 0.0, "max": 100.0}) # -2 requested; clamps to 0, equals current -> no write, no crash result = await adapter.set_curve_offset(-2.0) - assert result is False + assert result is None async def test_malformed_minmax_does_not_crash(self): """Non-numeric min/max attributes must be ignored, not raise.""" @@ -101,10 +101,21 @@ async def test_malformed_minmax_does_not_crash(self): result = await adapter.set_curve_offset(3.0) # Falls back to MIN_OFFSET/MAX_OFFSET clamp, write still succeeds - assert result is True + assert result == 3 written = hass.services.async_call.call_args.args[2]["value"] assert written == 3 + async def test_forced_neutral_write_bypasses_cooldown(self): + """OFF must reach the pump even immediately after an ordinary write.""" + adapter, hass = make_adapter(offset_state="2") + adapter._last_nibe_offset = 2 + adapter._last_write = datetime.now(timezone.utc) + + result = await adapter.set_curve_offset(0.0, force_write=True) + + assert result == 0 + assert hass.services.async_call.call_args.args[2]["value"] == 0 + class TestUnknownValueMarker: """-32768 / -3276.8 are 'no reading' markers, not real values.""" 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/climate/test_climate_zones.py b/tests/unit/climate/test_climate_zones.py index 85885f7e..c8bb931c 100644 --- a/tests/unit/climate/test_climate_zones.py +++ b/tests/unit/climate/test_climate_zones.py @@ -5,18 +5,18 @@ """ from custom_components.effektguard.const import ( - CLIMATE_ZONE_EXTREME_COLD_WINTER_AVG, - CLIMATE_ZONE_VERY_COLD_WINTER_AVG, CLIMATE_ZONE_COLD_WINTER_AVG, + CLIMATE_ZONE_EXTREME_COLD_WINTER_AVG, CLIMATE_ZONE_MODERATE_COLD_WINTER_AVG, CLIMATE_ZONE_STANDARD_WINTER_AVG, + CLIMATE_ZONE_VERY_COLD_WINTER_AVG, + DM_THRESHOLD_AUX_LIMIT, ) from custom_components.effektguard.optimization.climate_zones import ( ClimateZoneDetector, ClimateZoneInfo, HEATING_CLIMATE_ZONES, ZONE_ORDER, - DM_ABSOLUTE_MAXIMUM, ) @@ -96,7 +96,7 @@ def test_extreme_cold_at_average(self): assert dm_range["normal_min"] == -800 assert dm_range["normal_max"] == -1200 assert dm_range["warning"] == -1200 - assert dm_range["critical"] == DM_ABSOLUTE_MAXIMUM + assert dm_range["critical"] == DM_THRESHOLD_AUX_LIMIT def test_cold_at_average(self): """Test Stockholm at winter average temperature.""" @@ -167,10 +167,10 @@ def test_never_exceeds_absolute_maximum(self): dm_range = detector.get_expected_dm_range(CLIMATE_ZONE_EXTREME_COLD_WINTER_AVG - 30.0) # Should stay above absolute maximum (less negative) - assert dm_range["normal_min"] > DM_ABSOLUTE_MAXIMUM - assert dm_range["normal_max"] > DM_ABSOLUTE_MAXIMUM - assert dm_range["warning"] > DM_ABSOLUTE_MAXIMUM - assert dm_range["critical"] == DM_ABSOLUTE_MAXIMUM + assert dm_range["normal_min"] > DM_THRESHOLD_AUX_LIMIT + assert dm_range["normal_max"] > DM_THRESHOLD_AUX_LIMIT + assert dm_range["warning"] > DM_THRESHOLD_AUX_LIMIT + assert dm_range["critical"] == DM_THRESHOLD_AUX_LIMIT class TestSafetyMargins: @@ -259,8 +259,13 @@ def test_heating_climate_zones_complete(self): assert zone_key in HEATING_CLIMATE_ZONES def test_absolute_maximum_constant(self): - """Test DM_ABSOLUTE_MAXIMUM is defined correctly.""" - assert DM_ABSOLUTE_MAXIMUM == -1500 + """The absolute floor has ONE definition, and climate_zones publishes THAT one. + + This used to assert DM_ABSOLUTE_MAXIMUM == -1500 - a test pinning the VALUE of a duplicate + constant, which is how the duplicate survived. What matters is that the threshold this + module publishes as `critical` is the same one the emergency tier fires on (F-076). + """ + assert DM_THRESHOLD_AUX_LIMIT == -1500 def test_zone_data_structure(self): """Test each zone has required fields.""" diff --git a/tests/unit/climate/test_swedish_climate_region_detection.py b/tests/unit/climate/test_swedish_climate_region_detection.py deleted file mode 100644 index 7ae671e4..00000000 --- a/tests/unit/climate/test_swedish_climate_region_detection.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Test Swedish climate region detection. - -Verifies that the coordinator correctly detects Swedish climate regions -(Southern, Central, Northern, Lapland) based on GPS latitude coordinates. -""" - -import pytest -from unittest.mock import Mock -from conftest import create_mock_hass, create_mock_entry -from custom_components.effektguard.coordinator import EffektGuardCoordinator -from custom_components.effektguard.const import ( - CLIMATE_SOUTHERN_SWEDEN, - CLIMATE_CENTRAL_SWEDEN, - CLIMATE_MID_NORTHERN_SWEDEN, - CLIMATE_NORTHERN_SWEDEN, - CLIMATE_NORTHERN_LAPLAND, -) - - -class TestSwedishClimateRegionDetection: - """Test climate region detection for different Swedish latitudes.""" - - @pytest.mark.asyncio - async def test_detects_southern_sweden_malmo(self): - """Test detection of southern Sweden region (Malmö, 55.6°N).""" - mock_hass = create_mock_hass(latitude=55.6) # Malmö - mock_entry = create_mock_entry() - - coordinator = EffektGuardCoordinator( - hass=mock_hass, - nibe_adapter=Mock(), - gespot_adapter=Mock(), - weather_adapter=Mock(), - decision_engine=Mock(), - effect_manager=Mock(), - entry=mock_entry, - ) - - assert coordinator.climate_region == CLIMATE_SOUTHERN_SWEDEN - - @pytest.mark.asyncio - async def test_detects_southern_sweden_gothenburg(self): - """Test detection of southern Sweden region (Gothenburg, 57.7°N).""" - mock_hass = create_mock_hass(latitude=57.7) # Gothenburg - mock_entry = create_mock_entry() - - coordinator = EffektGuardCoordinator( - hass=mock_hass, - nibe_adapter=Mock(), - gespot_adapter=Mock(), - weather_adapter=Mock(), - decision_engine=Mock(), - effect_manager=Mock(), - entry=mock_entry, - ) - - assert coordinator.climate_region == CLIMATE_SOUTHERN_SWEDEN - - @pytest.mark.asyncio - async def test_detects_central_sweden_stockholm(self): - """Test detection of central Sweden region (Stockholm, 59.3°N).""" - mock_hass = create_mock_hass(latitude=59.3) # Stockholm - mock_entry = create_mock_entry() - - coordinator = EffektGuardCoordinator( - hass=mock_hass, - nibe_adapter=Mock(), - gespot_adapter=Mock(), - weather_adapter=Mock(), - decision_engine=Mock(), - effect_manager=Mock(), - entry=mock_entry, - ) - - assert coordinator.climate_region == CLIMATE_CENTRAL_SWEDEN - - @pytest.mark.asyncio - async def test_detects_central_sweden_uppsala(self): - """Test detection of central Sweden region (Uppsala, 59.9°N).""" - mock_hass = create_mock_hass(latitude=59.9) # Uppsala - mock_entry = create_mock_entry() - - coordinator = EffektGuardCoordinator( - hass=mock_hass, - nibe_adapter=Mock(), - gespot_adapter=Mock(), - weather_adapter=Mock(), - decision_engine=Mock(), - effect_manager=Mock(), - entry=mock_entry, - ) - - assert coordinator.climate_region == CLIMATE_CENTRAL_SWEDEN - - @pytest.mark.asyncio - async def test_detects_mid_northern_sweden_sundsvall(self): - """Test detection of mid-northern Sweden region (Sundsvall, 62.4°N).""" - mock_hass = create_mock_hass(latitude=62.4) # Sundsvall - mock_entry = create_mock_entry() - - coordinator = EffektGuardCoordinator( - hass=mock_hass, - nibe_adapter=Mock(), - gespot_adapter=Mock(), - weather_adapter=Mock(), - decision_engine=Mock(), - effect_manager=Mock(), - entry=mock_entry, - ) - - assert coordinator.climate_region == CLIMATE_MID_NORTHERN_SWEDEN - - @pytest.mark.asyncio - async def test_detects_northern_sweden_lulea(self): - """Test detection of northern Sweden region (Luleå, 65.6°N).""" - mock_hass = create_mock_hass(latitude=65.6) # Luleå - mock_entry = create_mock_entry() - - coordinator = EffektGuardCoordinator( - hass=mock_hass, - nibe_adapter=Mock(), - gespot_adapter=Mock(), - weather_adapter=Mock(), - decision_engine=Mock(), - effect_manager=Mock(), - entry=mock_entry, - ) - - assert coordinator.climate_region == CLIMATE_NORTHERN_SWEDEN - - @pytest.mark.asyncio - async def test_detects_lapland_kiruna(self): - """Test detection of Lapland region (Kiruna, 67.9°N).""" - mock_hass = create_mock_hass(latitude=67.9) # Kiruna - mock_entry = create_mock_entry() - - coordinator = EffektGuardCoordinator( - hass=mock_hass, - nibe_adapter=Mock(), - gespot_adapter=Mock(), - weather_adapter=Mock(), - decision_engine=Mock(), - effect_manager=Mock(), - entry=mock_entry, - ) - - assert coordinator.climate_region == CLIMATE_NORTHERN_LAPLAND - - @pytest.mark.asyncio - async def test_detects_lapland_abisko(self): - """Test detection of Lapland region (Abisko, 68.4°N).""" - mock_hass = create_mock_hass(latitude=68.4) # Abisko - mock_entry = create_mock_entry() - - coordinator = EffektGuardCoordinator( - hass=mock_hass, - nibe_adapter=Mock(), - gespot_adapter=Mock(), - weather_adapter=Mock(), - decision_engine=Mock(), - effect_manager=Mock(), - entry=mock_entry, - ) - - assert coordinator.climate_region == CLIMATE_NORTHERN_LAPLAND - - -class TestClimateRegionBoundaries: - """Test climate region detection at boundary latitudes.""" - - @pytest.mark.asyncio - async def test_boundary_southern_to_central(self): - """Test boundary between Southern and Central Sweden (~58°N).""" - # Just below boundary (Southern) - mock_hass_south = create_mock_hass(latitude=57.9) - mock_entry_south = create_mock_entry() - coordinator_south = EffektGuardCoordinator( - hass=mock_hass_south, - nibe_adapter=Mock(), - gespot_adapter=Mock(), - weather_adapter=Mock(), - decision_engine=Mock(), - effect_manager=Mock(), - entry=mock_entry_south, - ) - assert coordinator_south.climate_region == CLIMATE_SOUTHERN_SWEDEN - - # Just above boundary (Central) - mock_hass_central = create_mock_hass(latitude=58.1) - mock_entry_central = create_mock_entry() - coordinator_central = EffektGuardCoordinator( - hass=mock_hass_central, - nibe_adapter=Mock(), - gespot_adapter=Mock(), - weather_adapter=Mock(), - decision_engine=Mock(), - effect_manager=Mock(), - entry=mock_entry_central, - ) - assert coordinator_central.climate_region == CLIMATE_CENTRAL_SWEDEN - - @pytest.mark.asyncio - async def test_boundary_central_to_mid_northern(self): # renamed from central_to_northern - """Test boundary between Central and Mid-Northern Sweden (~62°N).""" - # Just below boundary (Central) - mock_hass_central = create_mock_hass(latitude=60.9) - mock_entry_central = create_mock_entry() - coordinator_central = EffektGuardCoordinator( - hass=mock_hass_central, - nibe_adapter=Mock(), - gespot_adapter=Mock(), - weather_adapter=Mock(), - decision_engine=Mock(), - effect_manager=Mock(), - entry=mock_entry_central, - ) - assert coordinator_central.climate_region == CLIMATE_CENTRAL_SWEDEN - - # Just above boundary (still Central until 62.0) - mock_hass_central2 = create_mock_hass(latitude=61.1) - mock_entry_central2 = create_mock_entry() - coordinator_central2 = EffektGuardCoordinator( - hass=mock_hass_central2, - nibe_adapter=Mock(), - gespot_adapter=Mock(), - weather_adapter=Mock(), - decision_engine=Mock(), - effect_manager=Mock(), - entry=mock_entry_central2, - ) - assert coordinator_central2.climate_region == CLIMATE_CENTRAL_SWEDEN - - @pytest.mark.asyncio - async def test_boundary_northern_to_lapland(self): - """Test boundary between Northern Sweden and Lapland (~67°N).""" - # Just below boundary (Northern) - mock_hass_northern = create_mock_hass(latitude=66.9) - mock_entry_northern = create_mock_entry() - coordinator_northern = EffektGuardCoordinator( - hass=mock_hass_northern, - nibe_adapter=Mock(), - gespot_adapter=Mock(), - weather_adapter=Mock(), - decision_engine=Mock(), - effect_manager=Mock(), - entry=mock_entry_northern, - ) - assert coordinator_northern.climate_region == CLIMATE_NORTHERN_SWEDEN - - # Just above boundary (Lapland) - mock_hass_lapland = create_mock_hass(latitude=67.1) - mock_entry_lapland = create_mock_entry() - coordinator_lapland = EffektGuardCoordinator( - hass=mock_hass_lapland, - nibe_adapter=Mock(), - gespot_adapter=Mock(), - weather_adapter=Mock(), - decision_engine=Mock(), - effect_manager=Mock(), - entry=mock_entry_lapland, - ) - assert coordinator_lapland.climate_region == CLIMATE_NORTHERN_LAPLAND diff --git a/tests/unit/climate/test_weather_compensation.py b/tests/unit/climate/test_weather_compensation.py index 1623fe4f..107966ae 100644 --- a/tests/unit/climate/test_weather_compensation.py +++ b/tests/unit/climate/test_weather_compensation.py @@ -1,12 +1,26 @@ -"""Tests for weather compensation mathematical formulas. +"""Tests for the EN 442 emitter law used by weather compensation. -Validates universal flow temperature formula, heat transfer method, and UFH adjustments -against real-world production data. +The old Kuehne formula, Timbones "method" and UFH flow "adjustment" are gone (F-119 / F-121): +weather compensation is now one law anchored on either the emitters' rated output or the system +design point. These tests check the law, its anchors, and the properties any heating curve must +have (monotonic, physically plausible slope, flat above the balance point, bounded offset). + +Two external references are retained as validation of the law itself: Timbones' published +spreadsheet example (18 kW emitters, 260 W/K, 19 C, 0 C outdoor -> ~40 C flow), and the adequacy +a 150 W/K house needs at 0 C (39.3 C) - not the "outdoor + 27" efficiency aspiration that, asserted +as a requirement, made EffektGuard under-heat. """ +import pytest + from custom_components.effektguard.const import ( - UFH_FLOW_REDUCTION_CONCRETE, - UFH_FLOW_REDUCTION_TIMBER, + DEFAULT_DESIGN_FLOW_TEMP_RADIATOR, + DEFAULT_DESIGN_FLOW_TEMP_UFH, + DEFAULT_DESIGN_OUTDOOR_TEMP, + DEFAULT_DESIGN_SPREAD, + RADIATOR_POWER_COEFFICIENT, + UFH_POWER_COEFFICIENT, + WEATHER_COMP_MAX_OFFSET, ) from custom_components.effektguard.optimization.weather_layer import ( FlowTempCalculation, @@ -14,489 +28,313 @@ ) -class TestKuehneFormula: - """Test universal weather compensation formula. - - Formula: TFlow = 2.55 × (HC × (Tset - Tout))^0.78 + Tset - Validated across multiple manufacturers. - """ - - def test_kuehne_formula_basic(self): - """Test basic Kühne formula calculation.""" - calc = WeatherCompensationCalculator(heat_loss_coefficient=180.0) - - # Test case: 20°C indoor target, 0°C outdoor - # Expected: ~40-45°C flow temp (typical for SPF 4.0 systems) - flow_temp = calc.calculate_kuehne_flow_temp(indoor_setpoint=20.0, outdoor_temp=0.0) - - # Verify formula: TFlow = 2.55 × (180 × (20 - 0))^0.78 + 20 - # = 2.55 × (3600)^0.78 + 20 - # = 2.55 × 347.9 + 20 ≈ 907.2 + 20 = 927.2°C... wait, this is wrong! - # Let me recalculate: (180 × 20)^0.78 = 3600^0.78 ≈ 347.9 - # TFlow = 2.55 × 347.9 + 20 ≈ 887.2 + 20 = 907.2°C - # - # That can't be right. Let me check the formula interpretation... - # Ah! The formula might be: TFlow = 2.55 × ((HC × (Tset - Tout))^0.78) + Tset - # Or could it be normalized differently? - # - # Based on HeatpumpMonitor.org data: SPF 4.0 systems run at outdoor + 27°C - # So at 0°C outdoor, we expect ~27-30°C flow temp, not 900°C! - # - # Let me check if HC is meant to be a normalized coefficient... - # Typical heat loss: 180 W/°C means 180 W per degree difference - # At 20°C difference: 3600W = 3.6kW heat demand - # - # Let's assume the formula needs HC in kW/K: HC = 0.18 kW/K - # TFlow = 2.55 × (0.18 × 20)^0.78 + 20 - # = 2.55 × (3.6)^0.78 + 20 - # = 2.55 × 2.95 + 20 ≈ 7.5 + 20 = 27.5°C - # - # That's much more reasonable! The formula uses HC in kW/K, not W/°C - - # For now, let's test that flow temp is reasonable - assert 25.0 <= flow_temp <= 50.0, f"Flow temp {flow_temp:.1f}°C out of reasonable range" - - # Flow temp should be higher than indoor setpoint - assert flow_temp > 20.0 - - def test_kuehne_cold_weather(self): - """Test Kühne formula in Swedish winter conditions.""" - calc = WeatherCompensationCalculator(heat_loss_coefficient=180.0) - - # Extreme cold: -20°C outdoor, 21°C indoor target - flow_temp = calc.calculate_kuehne_flow_temp(indoor_setpoint=21.0, outdoor_temp=-20.0) - - # At 41°C temp difference, flow temp should be significantly higher - # but still reasonable for heat pump operation (<65°C) - assert 30.0 <= flow_temp <= 65.0 - assert flow_temp > 21.0 - - def test_kuehne_mild_weather(self): - """Test Kühne formula in mild weather.""" - calc = WeatherCompensationCalculator(heat_loss_coefficient=180.0) - - # Mild: +10°C outdoor, 20°C indoor - flow_temp = calc.calculate_kuehne_flow_temp(indoor_setpoint=20.0, outdoor_temp=10.0) - - # Small temp difference should give low flow temp - assert 20.0 <= flow_temp <= 35.0 - - def test_kuehne_no_heating_needed(self): - """Test when outdoor temp equals or exceeds indoor setpoint.""" - calc = WeatherCompensationCalculator(heat_loss_coefficient=180.0) - - # Outdoor temp equals indoor - flow_temp = calc.calculate_kuehne_flow_temp(indoor_setpoint=20.0, outdoor_temp=20.0) - assert flow_temp == 20.0 - - # Outdoor temp exceeds indoor - flow_temp = calc.calculate_kuehne_flow_temp(indoor_setpoint=20.0, outdoor_temp=25.0) - assert flow_temp == 20.0 - - def test_kuehne_different_heat_loss(self): - """Test Kühne formula with different building insulation.""" - # Well-insulated house (low heat loss) - calc_good = WeatherCompensationCalculator(heat_loss_coefficient=100.0) - flow_good = calc_good.calculate_kuehne_flow_temp(indoor_setpoint=20.0, outdoor_temp=0.0) +class TestEmitterLawAnchors: + """One law, two anchors: the emitters' rated output, or the system's design point.""" - # Poorly-insulated house (high heat loss) - calc_poor = WeatherCompensationCalculator(heat_loss_coefficient=300.0) - flow_poor = calc_poor.calculate_kuehne_flow_temp(indoor_setpoint=20.0, outdoor_temp=0.0) - - # Poor insulation should require higher flow temp - assert flow_poor > flow_good + def test_design_point_anchor_reproduces_the_design_point(self): + """At the design outdoor temperature the law must return the design flow temperature. + This is what "anchored" means, and it is what makes the correction near zero on a + correctly tuned pump. Kuehne returned 31.7 C here, where the house needs 50 C. + """ + calc = WeatherCompensationCalculator(heat_loss_coefficient=150.0, heating_type="radiator") -class TestTimbonesMethod: - """Test radiator-based heat transfer method. + result = calc.calculate_optimal_flow_temp( + indoor_setpoint=22.0, + outdoor_temp=DEFAULT_DESIGN_OUTDOOR_TEMP, + ) - Formula: - 1. Heat demand = HC × (Tin - Tout) - 2. Required DT = 50K × (demand / radiator_output)^(1/1.3) - 3. Flow = Tin + required_DT + (flow_return_dt / 2) - """ + assert result.method == "en442_design_point" + assert result.flow_temp == pytest.approx(DEFAULT_DESIGN_FLOW_TEMP_RADIATOR, abs=0.01) - def test_timbones_basic(self): - """Test heat transfer method with realistic radiator setup.""" - # Example radiator configuration: - # Radiator output at DT50: 18,000W - # Heat loss coefficient: 260 W/K + def test_rated_output_anchor_is_preferred_when_configured(self): + """A measured nameplate figure beats an assumed design point, so it wins.""" calc = WeatherCompensationCalculator( heat_loss_coefficient=260.0, radiator_rated_output=18000.0, ) - # Test: 19°C indoor, 0°C outdoor - # Heat demand = 260 × 19 = 4940W - # Ratio = 4940 / 18000 = 0.274 - # Required DT = 50 × (0.274)^(1/1.3) = 50 × 0.373 ≈ 18.6K - # MWT = 19 + 18.6 = 37.6°C - # Flow = 37.6 + 2.5 = 40.1°C (with 5K flow-return DT) - - flow_temp = calc.calculate_timbones_flow_temp( - indoor_setpoint=19.0, - outdoor_temp=0.0, - flow_return_dt=5.0, - ) + result = calc.calculate_optimal_flow_temp(indoor_setpoint=19.0, outdoor_temp=0.0) - assert flow_temp is not None - # Allow some tolerance for calculation differences - assert 38.0 <= flow_temp <= 42.0 + assert result.method == "en442_rated_output" + assert result.raw_rated_output is not None + assert result.raw_design_point is not None # both computed, for diagnostics - def test_timbones_requires_radiator_spec(self): - """Test that heat transfer method requires radiator output.""" + def test_design_point_anchor_used_when_rated_output_unknown(self): + """Nothing in the config flow asks for rated output, so this is the default path.""" calc = WeatherCompensationCalculator( heat_loss_coefficient=180.0, - radiator_rated_output=None, # Not configured + radiator_rated_output=None, ) - flow_temp = calc.calculate_timbones_flow_temp(indoor_setpoint=20.0, outdoor_temp=0.0) - - assert flow_temp is None - - def test_timbones_low_demand(self): - """Test Timbones with low heat demand (mild weather).""" - calc = WeatherCompensationCalculator( - heat_loss_coefficient=180.0, - radiator_rated_output=10000.0, - ) + result = calc.calculate_optimal_flow_temp(indoor_setpoint=20.0, outdoor_temp=0.0) - # Mild weather: 15°C outdoor, 20°C indoor - # Heat demand = 180 × 5 = 900W (very low) - flow_temp = calc.calculate_timbones_flow_temp(indoor_setpoint=20.0, outdoor_temp=15.0) + assert result.method == "en442_design_point" + assert result.raw_rated_output is None - # Low demand should give low flow temp (slightly higher due to flow-return DT) - assert flow_temp is not None - assert 22.0 <= flow_temp <= 31.0 # Adjusted upper bound + def test_timbones_published_example(self): + """External reference: Timbones' spreadsheet, 18 kW emitters, 260 W/K, 19 C, 0 C outdoor. - def test_timbones_high_demand(self): - """Test Timbones with high heat demand (cold weather).""" + Published result ~40 C. Like OpenEnergyMonitor's WeatherComp, Timbones' spreadsheet models + demand as linear in (room - outdoor) and carries NO internal-gains term - so the demand + model is held identical on both sides here, and what is being checked is the EMITTER LAW, + which is what the reference is authoritative about. + """ calc = WeatherCompensationCalculator( - heat_loss_coefficient=250.0, - radiator_rated_output=12000.0, + heat_loss_coefficient=260.0, + radiator_rated_output=18000.0, + internal_gains_w=0.0, # match the reference's demand model, not our house ) - # Cold weather: -15°C outdoor, 21°C indoor - # Heat demand = 250 × 36 = 9000W (high demand, near radiator capacity) - flow_temp = calc.calculate_timbones_flow_temp(indoor_setpoint=21.0, outdoor_temp=-15.0) - - assert flow_temp is not None - # High demand with radiators near capacity will push flow temp high - assert 40.0 <= flow_temp <= 65.0 # Adjusted upper bound for high demand - - -class TestUFHAdjustments: - """Test underfloor heating flow temperature adjustments.""" - - def test_concrete_ufh_adjustment(self): - """Test concrete slab UFH flow temperature reduction.""" - calc = WeatherCompensationCalculator(heating_type="radiator") + result = calc.calculate_optimal_flow_temp(indoor_setpoint=19.0, outdoor_temp=0.0) - # Radiator flow temp: 40°C - radiator_flow = 40.0 + assert result.flow_temp == pytest.approx(40.0, abs=0.5) - # Apply concrete UFH adjustment: -8°C - ufh_flow = calc.apply_ufh_adjustment(radiator_flow, "concrete_slab") + def test_internal_gains_are_what_move_us_off_the_uk_reference_tools(self): + """Modelling internal gains asks for cooler water than the gains-free UK tools. - assert ufh_flow == 40.0 - UFH_FLOW_REDUCTION_CONCRETE - assert ufh_flow == 32.0 - - def test_timber_ufh_adjustment(self): - """Test timber UFH flow temperature reduction.""" - calc = WeatherCompensationCalculator(heating_type="radiator") + The gains term must reach the (preferred) rated-output anchor; if these flows converge it + has been switched off. A deliberate, evidenced departure (heatpumpmonitor.org fleet gains). + """ + house = dict(heat_loss_coefficient=260.0, radiator_rated_output=18000.0) - # Radiator flow temp: 35°C - radiator_flow = 35.0 + reference = WeatherCompensationCalculator(**house, internal_gains_w=0.0) + ours = WeatherCompensationCalculator(**house) - # Apply timber UFH adjustment: -5°C - ufh_flow = calc.apply_ufh_adjustment(radiator_flow, "timber") + flow_reference = reference.calculate_optimal_flow_temp(19.0, 0.0).flow_temp + flow_ours = ours.calculate_optimal_flow_temp(19.0, 0.0).flow_temp - assert ufh_flow == 35.0 - UFH_FLOW_REDUCTION_TIMBER - assert ufh_flow == 30.0 + assert flow_ours < flow_reference - 1.0, ( + f"Modelling internal gains should ask for cooler water than the gains-free UK tools: " + f"they want {flow_reference:.2f} C and we want {flow_ours:.2f} C. If these have " + f"converged, the gains term is no longer reaching the rated-output anchor - which is " + f"the anchor this layer PREFERS, and which has silently missed the gains before." + ) - def test_ufh_minimum_temperature_concrete(self): - """Test that concrete UFH doesn't go below minimum temperature.""" - calc = WeatherCompensationCalculator(heating_type="radiator") - # Very low radiator flow temp - radiator_flow = 28.0 +class TestHeatingCurveProperties: + """Properties every heating curve must have, whatever the anchor.""" - # Should be clamped to UFH_MIN_TEMP_CONCRETE (25°C) - ufh_flow = calc.apply_ufh_adjustment(radiator_flow, "concrete_slab") + def test_flow_temp_rises_as_it_gets_colder(self): + """Colder outside means hotter water. Kuehne's curve rose only 0.22 C per -1 C.""" + calc = WeatherCompensationCalculator(heat_loss_coefficient=150.0, heating_type="radiator") - assert ufh_flow >= 25.0 - assert ufh_flow == 25.0 # 28 - 8 = 20, clamped to 25 + walk = [15.0, 10.0, 5.0, 0.0, -5.0, -10.0, -15.0, -20.0] + flows = [calc.calculate_optimal_flow_temp(22.0, t).flow_temp for t in walk] - def test_ufh_minimum_temperature_timber(self): - """Test that timber UFH doesn't go below minimum temperature.""" - calc = WeatherCompensationCalculator(heating_type="radiator") + for warm, cold, flow_warm, flow_cold in zip(walk, walk[1:], flows, flows[1:]): + assert flow_cold > flow_warm, ( + f"{cold:+.0f} C asks for {flow_cold:.1f} C but the warmer {warm:+.0f} C asks for " + f"{flow_warm:.1f} C - the curve slopes the wrong way." + ) - # Very low radiator flow temp - radiator_flow = 24.0 + def test_curve_slope_is_physically_plausible(self): + """The curve must be steep enough to track the building's load. - # Should be clamped to UFH_MIN_TEMP_TIMBER (22°C) - ufh_flow = calc.apply_ufh_adjustment(radiator_flow, "timber") + This house needs (50 - 22) / (22 - -15) = 0.757 C of supply per C of outdoor. Kuehne's + 0.22 was 3.5x too flat, which is why its shortfall grew as it got colder. + """ + calc = WeatherCompensationCalculator(heat_loss_coefficient=150.0, heating_type="radiator") - assert ufh_flow >= 22.0 - assert ufh_flow == 22.0 # 24 - 5 = 19, clamped to 22 + warm = calc.calculate_optimal_flow_temp(22.0, 10.0).flow_temp + cold = calc.calculate_optimal_flow_temp(22.0, -20.0).flow_temp + slope = (cold - warm) / 30.0 - def test_no_adjustment_for_radiators(self): - """Test that radiator systems don't get UFH adjustments.""" - calc = WeatherCompensationCalculator(heating_type="radiator") + assert 0.5 <= slope <= 1.0, ( + f"Curve slope {slope:.2f} C of supply per C of outdoor is not plausible for a " + f"radiator system needing ~0.76." + ) - radiator_flow = 42.0 + def test_colder_than_design_asks_for_more_than_design_flow(self): + """Below the design temperature the load exceeds design, so the flow must too. - # No adjustment for radiator type - adjusted_flow = calc.apply_ufh_adjustment(radiator_flow, "radiator") + Clamping to the design flow here would silently under-heat in exactly the conditions the + house is least able to tolerate it. + """ + calc = WeatherCompensationCalculator(heat_loss_coefficient=150.0, heating_type="radiator") - assert adjusted_flow == radiator_flow + flow = calc.calculate_optimal_flow_temp(22.0, DEFAULT_DESIGN_OUTDOOR_TEMP - 10.0).flow_temp + assert flow > DEFAULT_DESIGN_FLOW_TEMP_RADIATOR -class TestOptimalFlowCalculation: - """Test integrated optimal flow temperature calculation.""" + def test_no_heat_needed_above_the_balance_point(self): + """Above the balance point the flow is FLAT at room + spread/2, with no cliff. - def test_optimal_flow_kuehne_method(self): - """Test optimal flow using Kühne method.""" - calc = WeatherCompensationCalculator( - heat_loss_coefficient=180.0, - heating_type="radiator", + Zero load means zero excess over the room, but the spread term does not vanish: like OEM's + WeatherComp (flowT = MWT + systemDT/2), the curve converges to room + spread/2, not room. + The flow must never fall below the setpoint (colder water would cool the room), and must not + step at the boundary - a bare `indoor_setpoint` put a spread/2 cliff right in the shoulder + season where the outdoor temperature crosses the balance point back and forth all day. + """ + calc = WeatherCompensationCalculator(heat_loss_coefficient=180.0) + balance = calc.balance_point_temp(20.0) + no_load_flow = 20.0 + DEFAULT_DESIGN_SPREAD / 2.0 + + for outdoor in (balance + 0.1, 20.0, 25.0, 30.0): + result = calc.calculate_optimal_flow_temp(indoor_setpoint=20.0, outdoor_temp=outdoor) + + assert result.flow_temp >= 20.0, ( + f"At {outdoor:.1f} C outdoor the layer asks for {result.flow_temp:.2f} C of flow, " + f"below the 20.0 C room. Water colder than the room removes heat from it." + ) + assert result.flow_temp == pytest.approx(no_load_flow, abs=0.01), ( + f"Above the balance point ({balance:.1f} C) the house heats itself, so the curve " + f"must be flat at room + spread/2 = {no_load_flow:.1f} C - the same value it " + f"converges to from below. It returns {result.flow_temp:.2f} C at {outdoor:.1f} C." + ) + + def test_a_leakier_house_needs_hotter_water(self): + """Only via the rated-output anchor: the design-point anchor encodes sizing already.""" + tight = WeatherCompensationCalculator( + heat_loss_coefficient=150.0, radiator_rated_output=12000.0 ) - - result = calc.calculate_optimal_flow_temp( - indoor_setpoint=20.0, - outdoor_temp=0.0, - prefer_method="kuehne", + leaky = WeatherCompensationCalculator( + heat_loss_coefficient=300.0, radiator_rated_output=12000.0 ) - assert isinstance(result, FlowTempCalculation) - assert result.method == "kuehne" - assert result.heating_type == "radiator" - assert 0.8 <= result.confidence <= 1.0 - assert result.raw_kuehne is not None - assert result.flow_temp > 20.0 - - def test_optimal_flow_timbones_method(self): - """Test optimal flow using Timbones method.""" - calc = WeatherCompensationCalculator( - heat_loss_coefficient=180.0, - radiator_rated_output=15000.0, - heating_type="radiator", - ) + flow_tight = tight.calculate_optimal_flow_temp(21.0, 0.0).flow_temp + flow_leaky = leaky.calculate_optimal_flow_temp(21.0, 0.0).flow_temp - result = calc.calculate_optimal_flow_temp( - indoor_setpoint=20.0, - outdoor_temp=0.0, - prefer_method="timbones", - ) + assert flow_leaky > flow_tight - assert result.method == "timbones" - assert result.raw_timbones is not None - assert result.confidence >= 0.8 - def test_optimal_flow_auto_method(self): - """Test optimal flow using auto (combined) method.""" - calc = WeatherCompensationCalculator( - heat_loss_coefficient=180.0, - radiator_rated_output=15000.0, - heating_type="radiator", - ) - - result = calc.calculate_optimal_flow_temp( - indoor_setpoint=20.0, - outdoor_temp=0.0, - prefer_method="auto", - ) +class TestUnderfloorHeating: + """UFH gets its own exponent and its own design point - not a radiator curve minus 8 C.""" - # Should combine both methods - assert result.method == "kuehne+timbones" - assert result.raw_kuehne is not None - assert result.raw_timbones is not None - assert result.confidence >= 0.9 # Higher confidence with multiple methods + def test_underfloor_uses_its_own_emitter_exponent(self): + """EN 1264 gives n ~ 1.1 for underfloor, not the radiator's 1.3.""" + ufh = WeatherCompensationCalculator(heating_type="concrete_ufh") + rad = WeatherCompensationCalculator(heating_type="radiator") - # Combined result should be average of both - expected_avg = (result.raw_kuehne + result.raw_timbones) / 2 - assert abs(result.flow_temp - expected_avg) < 0.1 + assert ufh.emitter_exponent == UFH_POWER_COEFFICIENT + assert rad.emitter_exponent == RADIATOR_POWER_COEFFICIENT - def test_optimal_flow_with_concrete_ufh(self): - """Test optimal flow for concrete slab UFH system.""" - calc = WeatherCompensationCalculator( - heat_loss_coefficient=180.0, - heating_type="concrete_ufh", - ) + def test_underfloor_is_dimensioned_cooler_than_radiators(self): + """NIBE: underfloor supply is normally set between 35 and 45 C.""" + ufh = WeatherCompensationCalculator(heating_type="concrete_ufh") - result = calc.calculate_optimal_flow_temp( - indoor_setpoint=20.0, - outdoor_temp=0.0, - ) + assert ufh.design_flow_temp == DEFAULT_DESIGN_FLOW_TEMP_UFH + assert ufh.design_flow_temp < DEFAULT_DESIGN_FLOW_TEMP_RADIATOR - # Should apply UFH adjustment - assert result.heating_type == "concrete_ufh" - assert "UFH" in result.reasoning or "ufh" in result.reasoning.lower() + def test_underfloor_curve_is_not_flat(self): + """The old model pinned concrete slabs to 25 C at EVERY outdoor temperature. - # Flow temp should be reduced by ~8°C from radiator calculation - assert result.raw_kuehne is not None - expected_reduction = result.raw_kuehne - UFH_FLOW_REDUCTION_CONCRETE - # Allow for minimum temp clamping - assert result.flow_temp <= result.raw_kuehne - - def test_optimal_flow_with_timber_ufh(self): - """Test optimal flow for timber UFH system.""" + Kuehne (fed a heat-loss coefficient) already produced a low-temperature curve; the code + then subtracted a further 8 C and floored the result at UFH_MIN_FLOW_TEMP_CONCRETE = 25. + The floor won across the whole Swedish winter, so weather compensation was completely + INERT for a concrete-slab house - it targeted 25 C from +10 C down to -20 C. + """ calc = WeatherCompensationCalculator( - heat_loss_coefficient=180.0, - heating_type="timber", + heat_loss_coefficient=180.0, heating_type="concrete_ufh" ) - result = calc.calculate_optimal_flow_temp( - indoor_setpoint=20.0, - outdoor_temp=5.0, - ) + mild = calc.calculate_optimal_flow_temp(21.0, 10.0).flow_temp + cold = calc.calculate_optimal_flow_temp(21.0, -20.0).flow_temp - assert result.heating_type == "timber" - assert result.flow_temp <= result.raw_kuehne # Reduced for UFH + assert ( + cold > mild + 5.0 + ), f"Underfloor curve is flat: {mild:.1f} C at +10 C vs {cold:.1f} C at -20 C." - def test_reasoning_string_quality(self): - """Test that reasoning strings are informative.""" - calc = WeatherCompensationCalculator( - heat_loss_coefficient=200.0, - radiator_rated_output=16000.0, - ) + def test_underfloor_reaches_its_own_design_point(self): + calc = WeatherCompensationCalculator(heating_type="timber_ufh") - result = calc.calculate_optimal_flow_temp( - indoor_setpoint=21.0, - outdoor_temp=-5.0, - prefer_method="auto", - ) + flow = calc.calculate_optimal_flow_temp(21.0, DEFAULT_DESIGN_OUTDOOR_TEMP).flow_temp - # Reasoning should contain key information - assert "outdoor" in result.reasoning.lower() - assert "indoor" in result.reasoning.lower() - assert "21" in result.reasoning # Indoor setpoint - assert "-5" in result.reasoning # Outdoor temp + assert flow == pytest.approx(DEFAULT_DESIGN_FLOW_TEMP_UFH, abs=0.01) class TestOffsetCalculation: - """Test heating curve offset calculations.""" + """Converting a flow-temperature target into a heating-curve offset.""" def test_offset_calculation_basic(self): - """Test basic offset calculation.""" calc = WeatherCompensationCalculator() - # Need +3°C increase in flow temp - # With default sensitivity 1.5°C per offset: offset = 3 / 1.5 = 2.0 + # +3 C of flow, at 1.5 C of flow per offset unit -> +2.0 offset = calc.calculate_required_offset( optimal_flow_temp=40.0, current_flow_temp=37.0, curve_sensitivity=1.5, ) - assert abs(offset - 2.0) < 0.1 + assert offset == pytest.approx(2.0, abs=0.1) def test_offset_calculation_negative(self): - """Test offset calculation when current flow too high.""" calc = WeatherCompensationCalculator() - # Current flow is 4°C too high offset = calc.calculate_required_offset( optimal_flow_temp=35.0, current_flow_temp=39.0, curve_sensitivity=2.0, ) - assert offset < 0 - assert abs(offset - (-2.0)) < 0.1 + assert offset == pytest.approx(-2.0, abs=0.1) def test_offset_calculation_different_sensitivity(self): - """Test offset with different curve sensitivity.""" calc = WeatherCompensationCalculator() - # Same temp error, different sensitivities - offset_high_sensitivity = calc.calculate_required_offset( - optimal_flow_temp=40.0, - current_flow_temp=35.0, - curve_sensitivity=2.5, # More sensitive curve - ) - - offset_low_sensitivity = calc.calculate_required_offset( - optimal_flow_temp=40.0, - current_flow_temp=35.0, - curve_sensitivity=1.0, # Less sensitive curve - ) + high = calc.calculate_required_offset(40.0, 35.0, curve_sensitivity=2.5) + low = calc.calculate_required_offset(40.0, 35.0, curve_sensitivity=1.0) - # Higher sensitivity needs smaller offset - assert offset_high_sensitivity < offset_low_sensitivity + assert high < low # a more sensitive curve needs a smaller offset + def test_offset_is_bounded_in_both_directions(self): + """The old implementation had no clamp and could return -11.4.""" + calc = WeatherCompensationCalculator() -class TestRealWorldScenarios: - """Test against real-world examples from OpenEnergyMonitor community.""" - - def test_timbones_spreadsheet_example(self): - """Test against Timbones' documented example. - - From forum post: 18,000W radiators, 260 W/K heat loss, 19°C target - At 0°C outdoor: should give ~40°C flow temp - """ - calc = WeatherCompensationCalculator( - heat_loss_coefficient=260.0, - radiator_rated_output=18000.0, - ) + assert calc.calculate_required_offset(80.0, 20.0, 1.5) == WEATHER_COMP_MAX_OFFSET + assert calc.calculate_required_offset(20.0, 80.0, 1.5) == -WEATHER_COMP_MAX_OFFSET - result = calc.calculate_optimal_flow_temp( - indoor_setpoint=19.0, - outdoor_temp=0.0, - prefer_method="timbones", - ) - # Should be around 40°C based on Timbones' spreadsheet - assert 38.0 <= result.flow_temp <= 42.0 +class TestRealWorldScenarios: + """Whole-system checks in real Swedish conditions.""" - def test_heatpumpmonitor_spf4_target(self): - """Test against HeatpumpMonitor.org SPF 4.0 performance target. + def test_house_that_needs_hot_water_gets_it(self): + """A 150 W/K house at 20 C, 0 C outdoor, needs 39.3 C - adequacy, not aspiration. - SPF 4.0+ systems: Flow = Outdoor + 27°C ±3°C - At 0°C outdoor: target flow 27°C + The old test asserted 24-35 C here ("flow = outdoor + 27 for SPF 4.0"), an efficiency + aspiration that holds only when the emitters can deliver the load at that temperature. + Demanding it of a system that cannot just leaves the house cold. """ - calc = WeatherCompensationCalculator( - heat_loss_coefficient=150.0, # Well-optimized system - ) + calc = WeatherCompensationCalculator(heat_loss_coefficient=150.0) - result = calc.calculate_optimal_flow_temp( - indoor_setpoint=20.0, - outdoor_temp=0.0, - ) + result = calc.calculate_optimal_flow_temp(indoor_setpoint=20.0, outdoor_temp=0.0) - # Should target ~27-30°C for SPF 4.0 - # (May be slightly higher due to heat loss calculations) - assert 24.0 <= result.flow_temp <= 35.0 + # What the emitters actually need to carry a 3.0 kW load in this house. + assert result.flow_temp == pytest.approx(39.3, abs=1.0) + assert result.flow_temp > 20.0 + 27.0 - 10.0 # nowhere near the flat aspiration def test_swedish_winter_kiruna(self): - """Test extreme Swedish winter conditions (-30°C Kiruna).""" + """Extreme Swedish winter (-30 C), concrete slab.""" calc = WeatherCompensationCalculator( - heat_loss_coefficient=200.0, # Moderate insulation + heat_loss_coefficient=200.0, heating_type="concrete_ufh", ) - result = calc.calculate_optimal_flow_temp( - indoor_setpoint=21.0, - outdoor_temp=-30.0, - ) + result = calc.calculate_optimal_flow_temp(indoor_setpoint=21.0, outdoor_temp=-30.0) - # Even in extreme cold, flow temp should be reasonable - assert result.flow_temp >= 25.0 # UFH minimum - assert result.flow_temp <= 65.0 # Heat pump max assert result.heating_type == "concrete_ufh" + # Colder than the design point, so it asks for MORE than the design flow - and stays + # inside what a heat pump can physically produce. + assert DEFAULT_DESIGN_FLOW_TEMP_UFH < result.flow_temp <= 65.0 def test_swedish_mild_stockholm(self): - """Test typical Stockholm winter conditions (-5°C).""" + """Typical Stockholm winter (-5 C), radiators.""" calc = WeatherCompensationCalculator( heat_loss_coefficient=180.0, heating_type="radiator", ) - result = calc.calculate_optimal_flow_temp( - indoor_setpoint=21.0, - outdoor_temp=-5.0, - ) + result = calc.calculate_optimal_flow_temp(indoor_setpoint=21.0, outdoor_temp=-5.0) + + assert 38.0 <= result.flow_temp <= 48.0 + assert isinstance(result, FlowTempCalculation) + + def test_reasoning_names_the_law_and_its_anchor(self): + """The reasoning string is surfaced to the user; it must say what it actually did.""" + calc = WeatherCompensationCalculator(heat_loss_coefficient=180.0, heating_type="radiator") + + reasoning = calc.calculate_optimal_flow_temp(21.0, -5.0).reasoning - # Should be moderate flow temp - # Kühne formula gives ~29.5°C for this scenario, which is good for efficiency - assert 28.0 <= result.flow_temp <= 45.0 # Adjusted lower bound + assert "EN 442" in reasoning + assert "design point" in reasoning + assert "-5.0" in reasoning # the outdoor temperature it reasoned from diff --git a/tests/unit/coordinator/test_a_billing_period_remembers_where_its_samples_came_from.py b/tests/unit/coordinator/test_a_billing_period_remembers_where_its_samples_came_from.py new file mode 100644 index 00000000..4ba92114 --- /dev/null +++ b/tests/unit/coordinator/test_a_billing_period_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_period_stays_a_meter_period(self): + acc = BillingPeriodAccumulator() + for minute in range(0, 15, 5): + acc.add(_hour(minute), 4.0, POWER_SOURCE_EXTERNAL_METER) + completed = acc.add(_hour(15), 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_period_to_control_grade(self): + acc = BillingPeriodAccumulator() + for minute in range(0, 15, 5): + source = POWER_SOURCE_NIBE_CURRENTS if minute == 5 else POWER_SOURCE_EXTERNAL_METER + acc.add(_hour(minute), 4.0, source) + completed = acc.add(_hour(15), 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_period_is_not_billed_as_a_meter_period(monkeypatch): + """Meter for the first cycle, pump currents for the rest, meter again at the boundary. + + The boundary cycle's source is the METER - and the old stamping would have recorded the + whole period as a billable meter measurement. Two-thirds of it never saw the house. + """ + coordinator = _coordinator() + + for minute in range(0, 15, UPDATE_INTERVAL_MINUTES): + monkeypatch.setattr(dt_util, "now", lambda tz=None, _m=minute: _hour(_m)) + meter_alive = minute < 5 + _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(15)) + _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 period was continuously sampled and must be recorded" + assert calls[0].kwargs["source"] == POWER_SOURCE_NIBE_CURRENTS, ( + f"The period was recorded with source {calls[0].kwargs['source']!r}. Ten minutes of it " + f"were measured at the PUMP, not the grid connection - the tariff bills whole-house " + f"import, so this period 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..06976864 --- /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_period(coordinator, nibe_data, monkeypatch) -> None: + """Samples one 15-minute period whole, so it completes and is recorded. + + The owner's effect tariff bills the 15-minute period mean (operator models vary - F-107). + """ + for hour, minute in [(10, m) for m in range(0, 15, 5)] + [(10, 15)]: + 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_period(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_period(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_period(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_period(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_period_the_meter_slept_through_is_not_a_bill.py b/tests/unit/coordinator/test_a_period_the_meter_slept_through_is_not_a_bill.py new file mode 100644 index 00000000..141f6220 --- /dev/null +++ b/tests/unit/coordinator/test_a_period_the_meter_slept_through_is_not_a_bill.py @@ -0,0 +1,229 @@ +"""A billing period 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 PERIOD +used to carry on and, at close, bill whatever the meter last said before it went quiet, stretched +across the silence. A fabricated peak stands for the rest of the month because the effect tariff +bills the three highest periods - throttling the pump to defend a number that happened in no +observed period. + +The guard: a period containing a silence longer than MAX_BILLING_OBSERVATION_GAP_MINUTES (10 min, +i.e. more than one dropped 5-minute cycle inside a 15-minute period) 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 ( + BILLING_PERIOD_MINUTES, + 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_period(coordinator, monkeypatch, reading_at) -> None: + """One 15-minute billing period (10:00-10:15), on the coordinator's real update cadence.""" + for minute in range(0, 15, 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 period is what closes this one. + monkeypatch.setattr( + dt_util, "now", lambda tz=None: datetime(2026, 1, 15, 10, 15, 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_a_period_the_meter_slept_through_is_not_billed(monkeypatch): + """The bug shape: one reading at the top, then silence across two-thirds of the period.""" + coordinator = _coordinator() + + # 9 kW at 10:00. Then the meter is `unavailable` for the rest of the quarter. + await _run_the_period(coordinator, monkeypatch, lambda minute: 9.0 if minute == 0 else None) + + assert _billed(coordinator) == [], ( + f"the coordinator billed {_billed(coordinator)} kW for a period in which the meter answered " + f"ONCE and was `unavailable` for the remaining ten minutes. That figure is the 9 kW reading " + f"taken at 10:00, stretched across a blackout nobody watched. It becomes one of the month's " + f"three billed peaks, and the pump is throttled for the rest of the month to defend it." + ) + + +@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_period(coordinator, monkeypatch, lambda minute: 6.0) + + assert _billed(coordinator) == [6.0], ( + f"a meter that answered on every cycle of the period billed {_billed(coordinator)}. A " + f"fully observed 6 kW period 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 exactly + MAX_BILLING_OBSERVATION_GAP_MINUTES. Refusing this would throw away most real periods and buy + nothing: the reading either side of a five-minute blink is the same reading. + """ + coordinator = _coordinator() + + await _run_the_period(coordinator, monkeypatch, lambda minute: None if minute == 5 else 6.0) + + assert _billed(coordinator) == [6.0], ( + f"a single missed update cycle threw the whole period away ({_billed(coordinator)}). Home " + f"Assistant misses cycles routinely; a guard that discards a period 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 period is discarded" + assert MAX_BILLING_OBSERVATION_GAP_MINUTES < BILLING_PERIOD_MINUTES, ( + "the tolerated gap must be strictly shorter than the period, or a single-sample period - " + "one reading resting to the boundary - could never be refused and the rule would not bite" + ) + + +@pytest.mark.asyncio +async def test_a_meter_that_dies_mid_period_bills_only_what_it_observed(monkeypatch): + """The trailing silence counts as a gap, and the tolerance is deliberate. + + The meter answers at 10:00 and 10:05, then stays `unavailable`. The trailing span to the 10:15 + boundary is ten minutes - exactly the tolerated gap, i.e. one dropped cycle - so THIS period is + billed from what was observed. The unobserved periods after it accumulate no samples at all and + are never billed: a dead meter must not keep generating bills. + """ + coordinator = _coordinator() + + for minute in (0, 5): + monkeypatch.setattr( + dt_util, + "now", + lambda tz=None, _m=minute: datetime(2026, 1, 15, 10, _m, tzinfo=STOCKHOLM), + ) + _meter(coordinator.hass, 9.0) + await coordinator._update_peak_tracking(_pump()) + # The meter is dead for 40 minutes; the next billable reading arrives at 10:45. + for minute in (10, 15, 20, 25, 30, 35, 40): + monkeypatch.setattr( + dt_util, + "now", + lambda tz=None, _m=minute: datetime(2026, 1, 15, 10, _m, tzinfo=STOCKHOLM), + ) + _meter(coordinator.hass, None) + await coordinator._update_peak_tracking(_pump()) + monkeypatch.setattr( + dt_util, "now", lambda tz=None: datetime(2026, 1, 15, 10, 45, tzinfo=STOCKHOLM) + ) + _meter(coordinator.hass, 2.0) + await coordinator._update_peak_tracking(_pump()) + + assert _billed(coordinator) == [9.0], ( + f"billed {_billed(coordinator)}. The 10:00 period was observed for two of its three cycles " + f"(one dropped cycle is tolerated by design) and bills 9.0; the quarters the meter slept " + f"through entirely must bill NOTHING - a dead meter must not keep generating bills." + ) + + +@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 == 0 else None + + await _run_the_period(coordinator, monkeypatch, reading_at) + + assert _billed(coordinator) == [], ( + f"billed {_billed(coordinator)} for a period 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_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_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..270fbee2 --- /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_period_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_manual_override_bypass.py b/tests/unit/coordinator/test_manual_override_bypass.py index 4242a54b..d7df00cb 100644 --- a/tests/unit/coordinator/test_manual_override_bypass.py +++ b/tests/unit/coordinator/test_manual_override_bypass.py @@ -1,11 +1,8 @@ """Manual force_offset commands must bypass the volatile-reversal blocker. -Live-reproduced regression risk: after a +4°C offset, a user-commanded -effektguard.force_offset with offset 0 was accepted by the service and the -decision engine, but the coordinator's volatility blocker deferred it as a -volatile reversal and kept the previous +4°C for 45 minutes. User commands -are authoritative and must apply immediately; the blocker still applies to -automatic decisions. +After a +4°C offset, a user-commanded force_offset(0) must apply immediately: the volatility +blocker previously deferred it as a volatile reversal and kept +4°C for 45 minutes. User commands +are authoritative; the blocker still applies to automatic decisions. """ from __future__ import annotations @@ -28,6 +25,9 @@ def _make_minimal_hass() -> MagicMock: hass.config = MagicMock() hass.config.latitude = 59.3 hass.config.config_dir = "/tmp/test" + # A real Store computes its path via hass.config.path(); an unstubbed MagicMock there + # makes os.makedirs create a literal ./MagicMock/... directory in the repo root. + hass.config.path = MagicMock(side_effect=lambda *parts: "/".join(("/tmp/test", *parts))) hass.loop = MagicMock() hass.loop.call_soon_threadsafe = MagicMock() hass.async_add_executor_job = AsyncMock(side_effect=lambda func, *args: func(*args)) @@ -64,7 +64,7 @@ def _make_coordinator(decision: OptimizationDecision) -> EffektGuardCoordinator: ) nibe = MagicMock() nibe.get_current_state = AsyncMock(return_value=nibe_data) - nibe.set_curve_offset = AsyncMock(return_value=True) + nibe.set_curve_offset = AsyncMock(side_effect=lambda offset, **_: round(offset)) nibe.power_sensor_entity = None nibe._power_sensor_entity = None @@ -114,10 +114,10 @@ async def test_manual_reduction_applies_immediately_after_raise(): ) coordinator = _make_coordinator(manual) - await coordinator._async_update_data() + await coordinator._drive_the_pump() assert coordinator.current_offset == 0.0 - coordinator.nibe.set_curve_offset.assert_awaited_with(0.0) + coordinator.nibe.set_curve_offset.assert_awaited_with(0.0, force_write=False) # The tracker adopted the manual value as the new baseline assert coordinator._offset_volatility_tracker.last_offset == 0.0 @@ -133,7 +133,7 @@ async def test_automatic_reversal_still_blocked(): ) coordinator = _make_coordinator(automatic) - await coordinator._async_update_data() + await coordinator._drive_the_pump() # Blocked: previous +4°C retained assert coordinator.current_offset == 4.0 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..6835be9f --- /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_period(coordinator, nibe_data, monkeypatch) -> None: + """Samples through one 15-minute PERIOD, the owner's tariff billing window.""" + for hour, minute in [(10, m) for m in range(0, 15, 5)] + [(10, 15)]: + 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_period( + 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_period( + 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_period(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_period(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_power_measurement_fallback.py b/tests/unit/coordinator/test_power_measurement_fallback.py index 7e4f4690..190f2116 100644 --- a/tests/unit/coordinator/test_power_measurement_fallback.py +++ b/tests/unit/coordinator/test_power_measurement_fallback.py @@ -245,23 +245,26 @@ async def test_kw_meter_used_verbatim(self, coordinator_with_external_meter): assert coordinator.peak_today == 6.0 -class TestSmartFallbackSolarOffset: - """Test smart fallback for grid meters with solar/battery offset.""" +class TestAMeterBehindSolarIsStillTheMeter: + """A grid-import meter reading low behind solar is reporting the truth, and the truth is billed. + + An old "smart fallback" substituted an ESTIMATE of compressor draw when the meter read under + 0.5 kW while the compressor ran above 20 Hz. But the operator bills grid import, which is exactly + what the meter saw: recording ~5.5 kW where 0.3 kW was imported inflated the month's peak by an + order of magnitude. The meter is the truth; there is nothing to override. + """ @pytest.mark.asyncio - async def test_low_meter_reading_with_high_compressor_uses_estimate( + async def test_a_low_reading_with_the_compressor_running_hard_is_taken_at_face_value( self, coordinator_with_external_meter ): - """Test that low meter reading with high compressor Hz uses estimate.""" coordinator = coordinator_with_external_meter - # Mock external meter showing low reading (solar export offset) mock_state = MagicMock() - mock_state.state = "300" # Only 300W (likely solar offset) + mock_state.state = "300" # 300 W of grid import; the panels are covering the rest mock_state.attributes = {"unit_of_measurement": "W"} coordinator.hass.states.get.return_value = mock_state - # Mock NIBE data showing compressor working hard nibe_data = NibeState( outdoor_temp=-5.0, indoor_temp=21.0, @@ -272,40 +275,29 @@ async def test_low_meter_reading_with_high_compressor_uses_estimate( is_heating=True, is_hot_water=False, timestamp=datetime.now(), - phase1_current=12.0, # High current - phase2_current=10.0, - phase3_current=11.0, - compressor_hz=60, # Working hard + compressor_hz=60, # working hard - this used to trigger the substitution ) - # Mock _estimate_power_from_compressor for fallback - coordinator._estimate_power_from_compressor = lambda nd: 5.5 - - # Mock effect manager save as async - coordinator.effect.async_save = AsyncMock() - await coordinator._update_peak_tracking(nibe_data) - # Smart fallback should detect solar offset and use estimate (>1 kW) - # However, the current implementation might not trigger fallback - # if external meter is available. Let's just check it uses external meter. - # The smart fallback is a planned feature, not fully implemented yet. - assert coordinator.peak_today >= 0.3 # At minimum uses meter reading + assert coordinator.peak_today == pytest.approx(0.3), ( + f"The meter reported 0.3 kW of grid import and {coordinator.peak_today:.2f} kW was " + f"recorded. What the compressor draws is not what the grid delivered, and it is not " + f"what will be billed." + ) @pytest.mark.asyncio - async def test_low_meter_reading_with_low_compressor_uses_meter( + async def test_a_low_reading_with_the_compressor_idle_is_also_taken_at_face_value( self, coordinator_with_external_meter ): - """Test that low meter with low compressor uses meter reading.""" + """The same rule, with nothing there to tempt it.""" coordinator = coordinator_with_external_meter - # Mock external meter showing low reading mock_state = MagicMock() - mock_state.state = "300" # 300W + mock_state.state = "300" mock_state.attributes = {"unit_of_measurement": "W"} coordinator.hass.states.get.return_value = mock_state - # Mock NIBE data showing compressor idle nibe_data = NibeState( outdoor_temp=10.0, indoor_temp=21.0, @@ -313,19 +305,15 @@ async def test_low_meter_reading_with_low_compressor_uses_meter( return_temp=28.0, degree_minutes=-20.0, current_offset=0.0, - is_heating=False, # Not heating + is_heating=False, is_hot_water=False, timestamp=datetime.now(), - phase1_current=0.5, # Low current - phase2_current=0.0, - phase3_current=0.0, - compressor_hz=0, # Not running + compressor_hz=0, ) await coordinator._update_peak_tracking(nibe_data) - # Should use meter reading (0.3 kW) because compressor idle - assert coordinator.peak_today == pytest.approx(0.3, rel=0.1) + assert coordinator.peak_today == pytest.approx(0.3) class TestPeakTrackingOnlyWithRealMeasurements: @@ -535,145 +523,146 @@ def coordinator(): return coordinator -class TestQuarterMeanRecording: - """Effect tariff quarters bill the 15-minute MEAN, not a sample. +class TestTheBillingPeriodMeanIsTheOwnersQuarter: + """The billing period is the owner's 15-minute quarter (operator models vary - F-107). - Regression: every 5-minute instantaneous reading was recorded as a - quarter measurement, so one short 9 kW spike among 1 kW readings - became a 9 kW tariff peak. + Under this model a sustained 15-minute hot-water cycle at 9 kW genuinely IS a 9 kW billing + peak - the owner's meter bills the quarter mean, so there is no quiet 45 minutes to average it + away. What must still hold: each quarter bills its own time-weighted mean, and a quarter that + began before observation is discarded. """ @pytest.mark.asyncio - async def test_spike_recorded_as_quarter_mean( + async def test_a_spike_is_averaged_over_the_whole_hour( self, coordinator_with_external_meter, monkeypatch ): - from homeassistant.util import dt as dt_util + """Each quarter bills ITS OWN mean: the hot-water quarter 9.0, the idle quarters 1.0.""" from datetime import datetime, timezone + from homeassistant.util import dt as dt_util + coordinator = coordinator_with_external_meter - coordinator.effect.record_quarter_measurement = AsyncMock(return_value=None) - - def nibe_state(): - return NibeState( - outdoor_temp=5.0, - indoor_temp=21.0, - supply_temp=35.0, - return_temp=30.0, - degree_minutes=-50.0, - current_offset=0.0, - is_heating=True, - is_hot_water=False, - timestamp=datetime.now(), + coordinator.effect.record_period_measurement = AsyncMock(return_value=None) + nibe_data = NibeState(5.0, 21.0, 35.0, 30.0, -50.0, 0.0, True, False, datetime.now()) + + # 9 kW for the first quarter of the hour, then the house idles at 1 kW. + for minute in range(0, 60, 5): + state = MagicMock() + state.state = "9000" if minute < 15 else "1000" + state.attributes = {"unit_of_measurement": "W"} + coordinator.hass.states.get.return_value = state + monkeypatch.setattr( + dt_util, + "now", + lambda tz=None, minute=minute: datetime( + 2026, 1, 15, 10, minute, tzinfo=timezone.utc + ), ) + await coordinator._update_peak_tracking(nibe_data) - # Three samples within quarter 40 (10:00-10:15): 1, 9 (spike), 2 kW - samples = [("1000", 0), ("9000", 5), ("2000", 10)] - for watts, minute in samples: - mock_state = MagicMock() - mock_state.state = watts - mock_state.attributes = {"unit_of_measurement": "W"} - coordinator.hass.states.get.return_value = mock_state - frozen = datetime(2026, 1, 15, 10, minute, tzinfo=timezone.utc) - monkeypatch.setattr(dt_util, "now", lambda tz=None, _f=frozen: _f) - await coordinator._update_peak_tracking(nibe_state()) - - # Nothing recorded yet - the quarter has not completed - coordinator.effect.record_quarter_measurement.assert_not_awaited() - - # First sample of the NEXT quarter completes quarter 40 - mock_state = MagicMock() - mock_state.state = "1500" - mock_state.attributes = {"unit_of_measurement": "W"} - coordinator.hass.states.get.return_value = mock_state - frozen = datetime(2026, 1, 15, 10, 15, tzinfo=timezone.utc) - monkeypatch.setattr(dt_util, "now", lambda tz=None, _f=frozen: _f) - await coordinator._update_peak_tracking(nibe_state()) + # The next hour's first sample completes the last quarter. + monkeypatch.setattr( + dt_util, "now", lambda tz=None: datetime(2026, 1, 15, 11, 0, tzinfo=timezone.utc) + ) + await coordinator._update_peak_tracking(nibe_data) - coordinator.effect.record_quarter_measurement.assert_awaited_once() - recorded = coordinator.effect.record_quarter_measurement.await_args.kwargs - assert recorded["quarter"] == 40 - # Mean of 1, 9, 2 kW = 4.0 kW - NOT the 9 kW spike - assert recorded["power_kw"] == pytest.approx(4.0) + recorded = [ + (c.kwargs["period"], round(c.kwargs["power_kw"], 2)) + for c in coordinator.effect.record_period_measurement.await_args_list + ] + assert recorded == [(40, 9.0), (41, 1.0), (42, 1.0), (43, 1.0)], ( + f"hour 10 is quarters 40-43. The hot-water quarter bills its own 9.0 kW mean - under " + f"the owner's 15-minute tariff that IS the billed quantity - and the idle quarters " + f"bill 1.0. Got {recorded}." + ) @pytest.mark.asyncio async def test_recording_starts_from_any_update_phase( self, coordinator_with_external_meter, monkeypatch ): - """Seeding must not require an update landing on a boundary minute. - - Regression: seeding was gated on minute % 15 == 0, but a 5-minute - cadence starting at e.g. minute 7 visits minutes 7/12/2 mod 15 and - never hits a boundary minute - no tariff quarter was EVER recorded - until scheduler drift eventually shifted the phase. - """ + """Seeding must not require an update landing on the hour boundary.""" from datetime import datetime, timezone + from homeassistant.util import dt as dt_util coordinator = coordinator_with_external_meter - coordinator.effect.record_quarter_measurement = AsyncMock(return_value=None) + coordinator.effect.record_period_measurement = AsyncMock(return_value=None) state = MagicMock() state.state = "2000" state.attributes = {"unit_of_measurement": "W"} coordinator.hass.states.get.return_value = state nibe_data = NibeState(5.0, 21.0, 35.0, 30.0, -50.0, 0.0, True, False, datetime.now()) - # Updates every 5 min from minute 7: 10:07, 10:12 (partial quarter 40), - # 10:17, 10:22, 10:27 (quarter 41), 10:32 (quarter 42 begins) - for minute in (7, 12, 17, 22, 27, 32): + # First update lands at 10:07 - mid-quarter. Quarter 40 is partial and must be discarded; + # quarter 41 (10:15) is observed from its start and must be recorded. + times = [(10, m) for m in (7, 12, 15, 20, 25, 30)] + for hour, minute in times: monkeypatch.setattr( dt_util, "now", - lambda tz=None, minute=minute: datetime( - 2026, 1, 15, 10, minute, tzinfo=timezone.utc + lambda tz=None, hour=hour, minute=minute: datetime( + 2026, 1, 15, hour, minute, tzinfo=timezone.utc ), ) await coordinator._update_peak_tracking(nibe_data) - # The partial startup quarter (10:00) is skipped; quarter 41 (10:15) - # is the first one observed from its start and must be recorded - coordinator.effect.record_quarter_measurement.assert_awaited_once() - recorded = coordinator.effect.record_quarter_measurement.await_args.kwargs - assert recorded["quarter"] == 41 + coordinator.effect.record_period_measurement.assert_awaited_once() + recorded = coordinator.effect.record_period_measurement.await_args.kwargs + + assert recorded["period"] == 41, "quarter 40 began before observation, so it is discarded" assert recorded["power_kw"] == pytest.approx(2.0) @pytest.mark.asyncio - async def test_partial_startup_quarter_is_discarded( + async def test_the_partial_startup_hour_is_discarded( self, coordinator_with_external_meter, monkeypatch ): + """An hour that began before the meter was watched is not an hour anyone measured.""" from datetime import datetime, timezone + from homeassistant.util import dt as dt_util coordinator = coordinator_with_external_meter - coordinator.effect.record_quarter_measurement = AsyncMock(return_value=None) + coordinator.effect.record_period_measurement = AsyncMock(return_value=None) state = MagicMock() state.state = "9000" state.attributes = {"unit_of_measurement": "W"} coordinator.hass.states.get.return_value = state nibe_data = NibeState(5.0, 21.0, 35.0, 30.0, -50.0, 0.0, True, False, datetime.now()) - monkeypatch.setattr( - dt_util, "now", lambda tz=None: datetime(2026, 1, 15, 10, 10, tzinfo=timezone.utc) - ) - await coordinator._update_peak_tracking(nibe_data) - monkeypatch.setattr( - dt_util, "now", lambda tz=None: datetime(2026, 1, 15, 10, 15, tzinfo=timezone.utc) - ) - await coordinator._update_peak_tracking(nibe_data) + for hour, minute in ((10, 40), (10, 42), (10, 45)): + 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) - coordinator.effect.record_quarter_measurement.assert_not_awaited() + coordinator.effect.record_period_measurement.assert_not_awaited() @pytest.mark.asyncio - async def test_irregular_samples_use_time_weighted_mean( + async def test_irregular_samples_use_a_time_weighted_mean( self, coordinator_with_external_meter, monkeypatch ): + """A sample that stands for ten minutes must not weigh the same as one standing for 5. + + The period's mean is time-weighted, not sample-counted. Demonstrated on an actually-observed + quarter (every gap within MAX_BILLING_OBSERVATION_GAP_MINUTES), where the formulas disagree: + + time-weighted: (1*10 + 9*5) / 15 = 3.67 kW <- what the grid bills + sample-counted: (1+9) / 2 = 5.0 kW + """ from datetime import datetime, timezone + from homeassistant.util import dt as dt_util coordinator = coordinator_with_external_meter - coordinator.effect.record_quarter_measurement = AsyncMock(return_value=None) + coordinator.effect.record_period_measurement = AsyncMock(return_value=None) nibe_data = NibeState(5.0, 21.0, 35.0, 30.0, -50.0, 0.0, True, False, datetime.now()) - for watts, minute in (("1000", 0), ("9000", 1), ("1000", 14), ("1000", 15)): + # 1 kW standing for ten minutes, then 9 kW for the last five of the quarter. + for watts, minute in (("1000", 0), ("9000", 10)): state = MagicMock() state.state = watts state.attributes = {"unit_of_measurement": "W"} @@ -687,6 +676,13 @@ async def test_irregular_samples_use_time_weighted_mean( ) await coordinator._update_peak_tracking(nibe_data) - recorded = coordinator.effect.record_quarter_measurement.await_args.kwargs - # 1 kW for 1 min, 9 kW for 13 min, 1 kW for 1 min = 119 / 15 kW. - assert recorded["power_kw"] == pytest.approx(119 / 15) + monkeypatch.setattr( + dt_util, "now", lambda tz=None: datetime(2026, 1, 15, 10, 15, tzinfo=timezone.utc) + ) + await coordinator._update_peak_tracking(nibe_data) + + recorded = coordinator.effect.record_period_measurement.await_args.kwargs + assert recorded["power_kw"] == pytest.approx((1 * 10 + 9 * 5) / 15), ( + f"billed {recorded['power_kw']:.2f} kW. 1 kW stood for ten minutes and 9 kW for five: " + f"the period's mean power is 3.67 kW. Counting the samples instead gives 5.0." + ) 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_startup_behavior.py b/tests/unit/coordinator/test_startup_behavior.py index f3a69c82..aae32135 100644 --- a/tests/unit/coordinator/test_startup_behavior.py +++ b/tests/unit/coordinator/test_startup_behavior.py @@ -107,6 +107,7 @@ async def test_airflow_control_not_applied_during_startup_grace(monkeypatch): phase2_current=None, phase3_current=None, power_kw=None, + current_offset=0.0, ) nibe = MagicMock() @@ -156,7 +157,9 @@ async def test_airflow_control_not_applied_during_startup_grace(monkeypatch): coordinator._apply_airflow_decision = AsyncMock() - result = await coordinator._async_update_data() + # Drive the pump for real. Calling the read hook would prove nothing: the read path applies + # nothing at all, so the assertion below would hold whether the grace period worked or not. + result = await coordinator._drive_the_pump() # First successful update is within grace period by default. coordinator._apply_airflow_decision.assert_not_awaited() @@ -182,6 +185,7 @@ async def test_rapid_fire_updates_dont_consume_grace_period(monkeypatch): dhw_top_temp=45.0, power_sensor_entity="sensor.power", dhw_amount_minutes=10, + current_offset=0.0, ) nibe.get_current_state = AsyncMock(return_value=nibe_data) nibe.set_curve_offset = AsyncMock(return_value=True) diff --git a/tests/unit/coordinator/test_the_billing_period_survives_the_clocks_going_back.py b/tests/unit/coordinator/test_the_billing_period_survives_the_clocks_going_back.py new file mode 100644 index 00000000..09eb89b0 --- /dev/null +++ b/tests/unit/coordinator/test_the_billing_period_survives_the_clocks_going_back.py @@ -0,0 +1,237 @@ +"""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) + + two_oclock = [period for period, _ in recorded if 8 <= period <= 11] + assert two_oclock == [8, 9, 10, 11, 8, 9, 10, 11], ( + f"the repeated 02:xx hour must yield its four quarters TWICE - they print the same digits " + f"and are an hour apart. 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 not any(8 <= h <= 11 for h in hours), ( + f"the coordinator billed a 02:xx quarter (periods 8-11) on the spring-forward day: " + f"{recorded}. Wall-clock 02:00 does not exist that night - no meter recorded it, and no " + f"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) + + ten_oclock = [period for period, _ in recorded if 44 <= period <= 47] + assert ten_oclock == [44, 45, 46, 47] 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_comprehensive.py b/tests/unit/dhw/test_dhw_comprehensive.py index 7ebbfb5e..08beff47 100644 --- a/tests/unit/dhw/test_dhw_comprehensive.py +++ b/tests/unit/dhw/test_dhw_comprehensive.py @@ -33,7 +33,6 @@ ) from custom_components.effektguard.adapters.gespot_adapter import QuarterPeriod - # ============================================================================== # TEST FIXTURES AND HELPERS # ============================================================================== 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..44633349 --- /dev/null +++ b/tests/unit/effect/test_a_version_1_store_does_not_break_setup.py @@ -0,0 +1,74 @@ +"""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, + POWER_SOURCE_NONE, + 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_converts_quarter_records_and_marks_them_unbillable(): + """A v1 quarter record IS the same billed quantity under the owner's 15-minute tariff. + + Migration renames `quarter_of_day` -> `period_of_day` and keeps the peak as a control + threshold. What v1 never stored is PROVENANCE, and it cannot be reconstructed - so the + record is marked POWER_SOURCE_NONE (unbillable) until live measurement replaces it. + """ + store = EffectStore(MagicMock(), EFFECT_STORAGE_VERSION, STORAGE_KEY) + + migrated = await store._async_migrate_func(1, 1, {"peaks": [V1_QUARTER_RECORD]}) + + assert len(migrated["peaks"]) == 1 + record = migrated["peaks"][0] + assert record["period_of_day"] == V1_QUARTER_RECORD["quarter_of_day"] + assert "quarter_of_day" not in record + assert record["source"] == POWER_SOURCE_NONE, ( + "a migrated peak has unknown provenance and must not be presented as a billable " + "meter measurement" + ) + assert record["actual_power"] == V1_QUARTER_RECORD["actual_power"] + + +@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_effect_manager.py b/tests/unit/effect/test_effect_manager.py index 95353f89..ea0a1ba7 100644 --- a/tests/unit/effect/test_effect_manager.py +++ b/tests/unit/effect/test_effect_manager.py @@ -13,7 +13,9 @@ from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch +from custom_components.effektguard.const import POWER_SOURCE_EXTERNAL_METER from custom_components.effektguard.optimization.effect_layer import ( + is_daytime_period, EffectManager, EffectLayerDecision, PeakEvent, @@ -47,7 +49,7 @@ def test_to_dict(self): timestamp = datetime(2025, 10, 14, 12, 30) peak = PeakEvent( timestamp=timestamp, - quarter_of_day=50, + period_of_day=50, actual_power=5.5, effective_power=5.5, is_daytime=True, @@ -56,7 +58,7 @@ def test_to_dict(self): data = peak.to_dict() assert data["timestamp"] == timestamp.isoformat() - assert data["quarter_of_day"] == 50 + assert data["period_of_day"] == 50 assert data["actual_power"] == 5.5 assert data["effective_power"] == 5.5 assert data["is_daytime"] is True @@ -66,45 +68,41 @@ def test_from_dict(self): timestamp = datetime(2025, 10, 14, 12, 30) data = { "timestamp": timestamp.isoformat(), - "quarter_of_day": 50, + "period_of_day": 12, "actual_power": 5.5, "effective_power": 5.5, "is_daytime": True, + "source": POWER_SOURCE_EXTERNAL_METER, } peak = PeakEvent.from_dict(data) - assert peak.quarter_of_day == 50 + assert peak.period_of_day == 12 assert peak.actual_power == 5.5 assert peak.effective_power == 5.5 assert peak.is_daytime is True -class TestQuarterOfDayCalculation: - """Test 15-minute quarter calculation.""" +class TestTheBillingPeriodIsTheOwnersQuarter: + """The owner's effect tariff bills the 15-minute period mean; day 06:00-22:00 at full weight. - def test_daytime_quarters(self): - """Test daytime quarter range (06:00-22:00).""" - # 06:00 = quarter 24 - assert 24 == (6 * 4) + (0 // 15) - # 22:00 = quarter 88 - assert 88 == (22 * 4) + (0 // 15) - # 21:45 = quarter 87 (last daytime quarter) - assert 87 == (21 * 4) + (45 // 15) + Operator models vary (F-107); this pins the OWNER'S configuration. The night discount is a + wall-clock window, so every quarter of a daytime hour is daytime and every quarter of a night + hour is night. + """ - # Verify daytime range - for quarter in range(24, 88): - hour = quarter // 4 - assert 6 <= hour < 22, f"Quarter {quarter} should be daytime" + def test_daytime_runs_06_to_22(self): + for period in range(6 * 4, 22 * 4): + assert is_daytime_period( + period + ), f"period {period} ({period // 4:02d}:{period % 4 * 15:02d}) is billed at full rate" - def test_nighttime_quarters(self): - """Test nighttime quarter range (22:00-06:00).""" - # 22:00 = quarter 88 (first nighttime) - assert 88 == (22 * 4) + (0 // 15) - # 00:00 = quarter 0 - assert 0 == (0 * 4) + (0 // 15) - # 05:45 = quarter 23 (last nighttime) - assert 23 == (5 * 4) + (45 // 15) + def test_the_night_discount_runs_22_to_06(self): + for period in list(range(22 * 4, 96)) + list(range(0, 6 * 4)): + assert not is_daytime_period(period), ( + f"period {period} ({period // 4:02d}:{period % 4 * 15:02d}) falls in the " + f"22:00-06:00 window, where half the peak counts" + ) class TestEffectivePoweCalculation: @@ -114,11 +112,10 @@ class TestEffectivePoweCalculation: async def test_daytime_full_weight(self, effect_manager): """Test daytime power at full weight (06:00-22:00).""" timestamp = datetime(2025, 10, 14, 12, 30) # 12:30 = daytime - quarter = 50 # 12:30 - peak = await effect_manager.record_quarter_measurement( + peak = await effect_manager.record_period_measurement( power_kw=6.0, - quarter=quarter, + period=timestamp.hour * 4 + timestamp.minute // 15, timestamp=timestamp, ) @@ -131,11 +128,10 @@ async def test_daytime_full_weight(self, effect_manager): async def test_nighttime_half_weight(self, effect_manager): """Test nighttime power at 50% weight (22:00-06:00).""" timestamp = datetime(2025, 10, 14, 23, 30) # 23:30 = nighttime - quarter = 94 # 23:30 - peak = await effect_manager.record_quarter_measurement( + peak = await effect_manager.record_period_measurement( power_kw=6.0, - quarter=quarter, + period=timestamp.hour * 4 + timestamp.minute // 15, timestamp=timestamp, ) @@ -152,11 +148,10 @@ class TestPeakTracking: async def test_records_first_peak(self, effect_manager): """Test recording first peak.""" timestamp = datetime(2025, 10, 14, 12, 0) - quarter = 48 - peak = await effect_manager.record_quarter_measurement( + peak = await effect_manager.record_period_measurement( power_kw=5.0, - quarter=quarter, + period=timestamp.hour * 4 + timestamp.minute // 15, timestamp=timestamp, ) @@ -167,12 +162,10 @@ async def test_records_first_peak(self, effect_manager): @pytest.mark.asyncio async def test_fills_top_three_peaks(self, effect_manager): """Test filling top 3 peaks.""" - timestamp = datetime(2025, 10, 14, 12, 0) - - # Add 3 peaks with different powers - await effect_manager.record_quarter_measurement(5.0, 48, timestamp) - await effect_manager.record_quarter_measurement(6.0, 49, timestamp) - await effect_manager.record_quarter_measurement(7.0, 50, timestamp) + # The tariff counts at most one peak per day, so the top 3 come from three days. + await effect_manager.record_period_measurement(5.0, 12 * 4, datetime(2025, 10, 14, 12, 0)) + await effect_manager.record_period_measurement(6.0, 12 * 4, datetime(2025, 10, 15, 12, 0)) + await effect_manager.record_period_measurement(7.0, 12 * 4, datetime(2025, 10, 16, 12, 0)) assert len(effect_manager._monthly_peaks) == 3 # Should be sorted highest first @@ -183,15 +176,15 @@ async def test_fills_top_three_peaks(self, effect_manager): @pytest.mark.asyncio async def test_replaces_lowest_peak(self, effect_manager): """Test replacing lowest peak when exceeding top 3.""" - timestamp = datetime(2025, 10, 14, 12, 0) - - # Fill top 3 - await effect_manager.record_quarter_measurement(5.0, 48, timestamp) - await effect_manager.record_quarter_measurement(6.0, 49, timestamp) - await effect_manager.record_quarter_measurement(7.0, 50, timestamp) - - # Add higher peak - should replace 5.0 - peak = await effect_manager.record_quarter_measurement(8.0, 51, timestamp) + # Fill top 3 from three days - the tariff counts at most one peak per day + await effect_manager.record_period_measurement(5.0, 12 * 4, datetime(2025, 10, 14, 12, 0)) + await effect_manager.record_period_measurement(6.0, 12 * 4, datetime(2025, 10, 15, 12, 0)) + await effect_manager.record_period_measurement(7.0, 12 * 4, datetime(2025, 10, 16, 12, 0)) + + # A fourth day beats the lowest counted day - should replace 5.0 + peak = await effect_manager.record_period_measurement( + 8.0, 12 * 4, datetime(2025, 10, 17, 12, 0) + ) assert peak is not None assert len(effect_manager._monthly_peaks) == 3 @@ -204,15 +197,15 @@ async def test_replaces_lowest_peak(self, effect_manager): @pytest.mark.asyncio async def test_ignores_lower_peak(self, effect_manager): """Test ignoring peak lower than top 3.""" - timestamp = datetime(2025, 10, 14, 12, 0) - - # Fill top 3 - await effect_manager.record_quarter_measurement(5.0, 48, timestamp) - await effect_manager.record_quarter_measurement(6.0, 49, timestamp) - await effect_manager.record_quarter_measurement(7.0, 50, timestamp) - - # Try to add lower peak - peak = await effect_manager.record_quarter_measurement(4.0, 51, timestamp) + # Fill top 3 from three days - the tariff counts at most one peak per day + await effect_manager.record_period_measurement(5.0, 12 * 4, datetime(2025, 10, 14, 12, 0)) + await effect_manager.record_period_measurement(6.0, 12 * 4, datetime(2025, 10, 15, 12, 0)) + await effect_manager.record_period_measurement(7.0, 12 * 4, datetime(2025, 10, 16, 12, 0)) + + # A fourth day below all three counted days changes nothing + peak = await effect_manager.record_period_measurement( + 4.0, 12 * 4, datetime(2025, 10, 17, 12, 0) + ) assert peak is None # Should not create new peak assert len(effect_manager._monthly_peaks) == 3 @@ -226,7 +219,7 @@ async def test_no_limit_when_no_peaks(self, effect_manager): """Test no limit when no peaks recorded.""" decision = effect_manager.should_limit_power( current_power=5.0, - current_quarter=48, # Daytime + current_period=12 * 4, # noon = daytime quarter ) assert decision.should_limit is False @@ -239,12 +232,12 @@ async def test_critical_when_exceeding_peak(self, effect_manager): timestamp = datetime(2025, 10, 14, 12, 0) # Set up peak at 5.0 kW - await effect_manager.record_quarter_measurement(5.0, 48, timestamp) + await effect_manager.record_period_measurement(5.0, 12 * 4, timestamp) # Test with power exceeding peak decision = effect_manager.should_limit_power( current_power=6.0, # Exceeds 5.0 kW peak - current_quarter=50, # Daytime + current_period=12 * 4, # noon = daytime quarter ) assert decision.should_limit is True @@ -257,12 +250,12 @@ async def test_critical_within_half_kw(self, effect_manager): timestamp = datetime(2025, 10, 14, 12, 0) # Set up peak at 5.0 kW - await effect_manager.record_quarter_measurement(5.0, 48, timestamp) + await effect_manager.record_period_measurement(5.0, 12 * 4, timestamp) # Test with power within 0.5 kW decision = effect_manager.should_limit_power( current_power=4.7, # Within 0.5 kW (margin 0.3) - current_quarter=50, # Daytime + current_period=12 * 4, # noon = daytime quarter ) assert decision.should_limit is True @@ -275,12 +268,12 @@ async def test_warning_within_one_kw(self, effect_manager): timestamp = datetime(2025, 10, 14, 12, 0) # Set up peak at 5.0 kW - await effect_manager.record_quarter_measurement(5.0, 48, timestamp) + await effect_manager.record_period_measurement(5.0, 12 * 4, timestamp) # Test with power within 1.0 kW decision = effect_manager.should_limit_power( current_power=4.3, # Within 1.0 kW (margin 0.7) - current_quarter=50, # Daytime + current_period=12 * 4, # noon = daytime quarter ) assert decision.should_limit is True @@ -293,12 +286,12 @@ async def test_ok_with_safe_margin(self, effect_manager): timestamp = datetime(2025, 10, 14, 12, 0) # Set up peak at 5.0 kW - await effect_manager.record_quarter_measurement(5.0, 48, timestamp) + await effect_manager.record_period_measurement(5.0, 12 * 4, timestamp) # Test with power well below peak decision = effect_manager.should_limit_power( current_power=3.5, # 1.5 kW margin - current_quarter=50, # Daytime + current_period=12 * 4, # noon = daytime quarter ) assert decision.should_limit is False @@ -311,12 +304,12 @@ async def test_nighttime_weighting_in_comparison(self, effect_manager): timestamp = datetime(2025, 10, 14, 12, 0) # Set up daytime peak at 5.0 kW effective - await effect_manager.record_quarter_measurement(5.0, 48, timestamp) + await effect_manager.record_period_measurement(5.0, 12 * 4, timestamp) # Test nighttime power - 10.0 kW actual = 5.0 kW effective decision = effect_manager.should_limit_power( current_power=10.0, # But effective = 5.0 (50% weight) - current_quarter=94, # 23:30 = nighttime + current_period=23 * 4 + 2, # 23:30 = night quarter ) # Should match peak exactly (margin = 0) @@ -331,11 +324,11 @@ class TestPeakProtectionOffset: async def test_returns_recommended_offset(self, effect_manager): """Test returns recommended offset when limiting.""" timestamp = datetime(2025, 10, 14, 12, 0) - await effect_manager.record_quarter_measurement(5.0, 48, timestamp) + await effect_manager.record_period_measurement(5.0, 12 * 4, timestamp) offset = effect_manager.get_peak_protection_offset( current_power=6.0, # Exceeds peak - current_quarter=50, + current_period=12 * 4, # the same DAYTIME period the peak was recorded in base_offset=0.0, ) @@ -345,11 +338,11 @@ async def test_returns_recommended_offset(self, effect_manager): async def test_returns_zero_when_safe(self, effect_manager): """Test returns zero when safe margin.""" timestamp = datetime(2025, 10, 14, 12, 0) - await effect_manager.record_quarter_measurement(5.0, 48, timestamp) + await effect_manager.record_period_measurement(5.0, 12 * 4, timestamp) offset = effect_manager.get_peak_protection_offset( current_power=3.0, # Safe margin - current_quarter=50, + current_period=12 * 4, # the same DAYTIME period the peak was recorded in base_offset=0.0, ) @@ -366,7 +359,7 @@ async def test_saves_peaks(self, hass_mock): with patch.object(manager._store, "async_save") as mock_save: timestamp = datetime(2025, 10, 14, 12, 0) - await manager.record_quarter_measurement(5.0, 48, timestamp) + await manager.record_period_measurement(5.0, 12 * 4, timestamp) await manager.async_save() @@ -387,10 +380,11 @@ async def test_loads_peaks(self, hass_mock): "peaks": [ { "timestamp": timestamp.isoformat(), - "quarter_of_day": 48, + "period_of_day": 12, "actual_power": 5.0, "effective_power": 5.0, "is_daytime": True, + "source": POWER_SOURCE_EXTERNAL_METER, } ] } @@ -418,9 +412,8 @@ async def test_empty_summary(self, effect_manager): @pytest.mark.asyncio async def test_summary_with_peaks(self, effect_manager): """Test summary with peaks.""" - timestamp = datetime(2025, 10, 14, 12, 0) - await effect_manager.record_quarter_measurement(5.0, 48, timestamp) - await effect_manager.record_quarter_measurement(6.0, 49, timestamp) + await effect_manager.record_period_measurement(5.0, 12 * 4, datetime(2025, 10, 14, 12, 0)) + await effect_manager.record_period_measurement(6.0, 12 * 4, datetime(2025, 10, 15, 12, 0)) summary = effect_manager.get_monthly_peak_summary() @@ -467,9 +460,9 @@ async def test_critical_returns_critical_offset(self, effect_manager): """Exceeding peak returns critical offset.""" # First record a peak so we have a threshold timestamp = datetime(2025, 10, 14, 12, 0) - await effect_manager.record_quarter_measurement(8.0, 48, timestamp) + await effect_manager.record_period_measurement(8.0, 12 * 4, timestamp) - # Mock dt_util.now() to ensure daytime (quarter calculation is correct) + # Mock dt_util.now() to ensure daytime (billing-period calculation is correct) with patch("custom_components.effektguard.utils.time_utils.dt_util") as mock_dt: mock_dt.now.return_value = datetime(2025, 10, 14, 12, 30) # Daytime, Q50 @@ -490,7 +483,7 @@ async def test_predictive_cooling_triggers_early_reduction(self, effect_manager) """Rapid cooling trend triggers predictive peak avoidance.""" # Record a peak timestamp = datetime(2025, 10, 14, 12, 0) - await effect_manager.record_quarter_measurement(7.0, 48, timestamp) + await effect_manager.record_period_measurement(7.0, 12 * 4, timestamp) # Test with power close to peak AND rapid cooling (predicts power increase) decision = effect_manager.evaluate_layer( 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..3de0a116 --- /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 * 4, + 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 * 4) + + 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 * 4, + 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 * 4, timestamp=JANUARY, source=POWER_SOURCE_EXTERNAL_METER + ) + await manager.record_period_measurement( + power_kw=5.0, + period=MIDDAY_HOUR * 4, + 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 * 4, + 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 * 4).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..e0a85aa7 --- /dev/null +++ b/tests/unit/effect/test_peak_reset_and_predictive_guard.py @@ -0,0 +1,141 @@ +"""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_PERIOD = (DAYTIME_START_HOUR + 1) * 4 # 07:00 quarter - avoids 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_PERIOD, 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_PERIOD, 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_PERIOD, OCTOBER) + event = await effect.record_period_measurement( + 2.0, + DAYTIME_PERIOD + 4, # one hour later, still daytime + 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_PERIOD, 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..34c99efb --- /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 * 4, + 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/learning/test_learned_params_integration.py b/tests/unit/learning/test_learned_params_integration.py index 1f385c03..4ec9f32a 100644 --- a/tests/unit/learning/test_learned_params_integration.py +++ b/tests/unit/learning/test_learned_params_integration.py @@ -12,7 +12,11 @@ from custom_components.effektguard.const import ( LEARNING_CONFIDENCE_THRESHOLD, + PREDICTION_LEARNED_PREHEAT_MIN_HOURS, PREDICTION_THERMAL_RESPONSIVENESS_DEFAULT, + SAMPLES_PER_HOUR, + UFHType, + UPDATE_INTERVAL_MINUTES, ) from custom_components.effektguard.optimization.learning_types import ( LearnedThermalParameters, @@ -20,7 +24,6 @@ from custom_components.effektguard.optimization.prediction_layer import ( ThermalStatePredictor, ) -from custom_components.effektguard.const import UFHType @pytest.fixture @@ -28,11 +31,13 @@ def predictor_with_history(): """Create a ThermalStatePredictor with sufficient history for predictions.""" predictor = ThermalStatePredictor() - # Add 120 observations (30 hours at 4 per hour) - more than 96 required + # Count and cadence are derived from constants (records one sample every UPDATE_INTERVAL_MINUTES) + # so the fixture cannot drift from the gate it is meant to clear. + required = PREDICTION_LEARNED_PREHEAT_MIN_HOURS * SAMPLES_PER_HOUR base_time = datetime.now() - for i in range(120): + for i in range(required + SAMPLES_PER_HOUR): predictor.record_state( - timestamp=base_time - timedelta(minutes=15 * i), + timestamp=base_time - timedelta(minutes=UPDATE_INTERVAL_MINUTES * i), indoor_temp=21.0 + (i % 4) * 0.1, # Small variation outdoor_temp=0.0, heating_offset=1.0, diff --git a/tests/unit/models/test_flow_temp_units.py b/tests/unit/models/test_flow_temp_units.py deleted file mode 100644 index ec87c183..00000000 --- a/tests/unit/models/test_flow_temp_units.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Regression tests for the flow-temperature formula unit conversion. - -The Kuehne formula operates on heat demand in kW: profiles previously fed the -heat-loss coefficient in W/K without converting (weather_layer.py converts), -producing astronomically large values that were always masked by the -efficiency clamp. With HLC=180 W/K and dT=31 K the formula term must be -~30.7 C, not ~2200 C. -""" - -from custom_components.effektguard.const import ( - KUEHNE_COEFFICIENT, - KUEHNE_POWER, - WATTS_PER_KILOWATT, -) -from custom_components.effektguard.models.nibe import ( - NibeF750Profile, - NibeF1155Profile, - NibeS1155Profile, -) - - -def kuehne_reference(hlc_w_per_k: float, temp_diff: float, indoor: float) -> float: - """The shared formula's correct form (kW basis, weather_layer parity).""" - return ( - KUEHNE_COEFFICIENT * (hlc_w_per_k / WATTS_PER_KILOWATT * temp_diff) ** KUEHNE_POWER + indoor - ) - - -class TestProfileFlowTempUnits: - def test_f750_formula_matches_kw_basis(self): - """At mild outdoor temp the formula (not the clamp) must decide.""" - profile = NibeF750Profile() - result = profile.calculate_optimal_flow_temp( - outdoor_temp=5.0, indoor_target=21.0, heat_demand_kw=3.0 - ) - # Correct formula: 2.55*(180/1000*16)**0.78+21 = ~26.8, below the - # efficiency clamp (5+30+3=38). The old W-basis value (~1300) always - # hit the clamp, masking the unit error. - reference = kuehne_reference(180.0, 16.0, 21.0) - assert result == min( - reference, 5.0 + profile.optimal_flow_delta + 3.0 - ), "formula must be computed on a kW basis" - assert result < 30.0 - - def test_gshp_profiles_share_correct_basis(self): - """S1155 and the inheriting F1155 use the same corrected formula.""" - for profile in (NibeS1155Profile(), NibeF1155Profile()): - result = profile.calculate_optimal_flow_temp( - outdoor_temp=5.0, indoor_target=21.0, heat_demand_kw=3.0 - ) - reference = kuehne_reference(180.0, 16.0, 21.0) - expected = max( - profile.min_flow_temp, - min(min(reference, 5.0 + profile.optimal_flow_delta + 3.0), profile.max_flow_temp), - ) - assert result == expected - - def test_formula_reference_value(self): - """HLC=180 W/K, dT=31 K: the reviewer's reference case ~30.7 C.""" - value = kuehne_reference(180.0, 31.0, 21.0) - assert 29.0 < value < 32.0 diff --git a/tests/unit/models/test_heat_pump_models.py b/tests/unit/models/test_heat_pump_models.py index 3fb8bb6e..6516f108 100644 --- a/tests/unit/models/test_heat_pump_models.py +++ b/tests/unit/models/test_heat_pump_models.py @@ -94,105 +94,52 @@ def test_basic_attributes(self, f750): assert f750.supports_modulation is True def test_power_characteristics(self, f750): - """Test F750 power characteristics.""" - assert f750.rated_power_kw == (2.0, 8.0) - assert f750.typical_electrical_range_kw == (1.2, 6.5) - assert f750.max_flow_temp == 60.0 - assert f750.min_flow_temp == 20.0 - - def test_cop_at_rated_conditions(self, f750): - """Test COP at rated conditions (7°C outdoor).""" - cop = f750.get_cop_at_temperature(7.0) - assert cop == 5.0 # Rated COP - - def test_cop_at_swedish_temperatures(self, f750): - """Test COP across Swedish temperature range.""" - test_cases = [ - (7, 5.0), # Mild - (0, 4.0), # Malmö/Gothenburg average - (-5, 3.5), # Stockholm common cold - (-10, 3.0), # Cold winter - (-15, 2.7), # Design temperature - (-20, 2.3), # Very cold - (-25, 2.0), # Extreme (Kiruna) - (-30, 1.8), # Survival mode - ] - - for outdoor_temp, expected_cop in test_cases: - cop = f750.get_cop_at_temperature(outdoor_temp) - assert ( - abs(cop - expected_cop) < 0.01 - ), f"COP mismatch at {outdoor_temp}°C: expected {expected_cop}, got {cop}" - - def test_cop_interpolation(self, f750): - """Test COP interpolation between data points.""" - # Between 0°C (COP 4.0) and -5°C (COP 3.5) - cop_minus_2_5 = f750.get_cop_at_temperature(-2.5) - expected = 3.75 # Midpoint - assert abs(cop_minus_2_5 - expected) < 0.01 - - # Between -10°C (COP 3.0) and -15°C (COP 2.7) - cop_minus_12_5 = f750.get_cop_at_temperature(-12.5) - expected = 2.85 # Midpoint - assert abs(cop_minus_12_5 - expected) < 0.01 - - def test_cop_extrapolation_beyond_range(self, f750): - """Test COP at temperatures beyond defined range.""" - # Above max temp - cop_high = f750.get_cop_at_temperature(15.0) - assert cop_high == 5.0 # Should return max COP - - # Below min temp - cop_low = f750.get_cop_at_temperature(-35.0) - assert cop_low == 1.8 # Should return min COP - - def test_electrical_consumption_estimation(self, f750): - """Test electrical consumption estimation.""" - # At 0°C with 6kW heat demand - # COP = 4.0, so electrical = 6 / 4.0 = 1.5kW - electrical = f750.estimate_electrical_consumption(heat_demand_kw=6.0, outdoor_temp=0.0) - assert abs(electrical - 1.5) < 0.1 - - # At -15°C with 10kW heat demand - # COP = 2.7, so electrical = 10 / 2.7 = 3.7kW - electrical = f750.estimate_electrical_consumption(heat_demand_kw=10.0, outdoor_temp=-15.0) - assert abs(electrical - 3.7) < 0.2 - - def test_electrical_consumption_capped_at_max(self, f750): - """Test electrical consumption is capped at max power.""" - # Very high heat demand should cap at max electrical - electrical = f750.estimate_electrical_consumption(heat_demand_kw=30.0, outdoor_temp=-20.0) - assert electrical <= f750.typical_electrical_range_kw[1] - - def test_optimal_flow_temp_calculation(self, f750): - """Test optimal flow temperature calculation.""" - # Mild conditions (-5°C outdoor) - flow_temp = f750.calculate_optimal_flow_temp( - outdoor_temp=-5.0, indoor_target=21.0, heat_demand_kw=6.0 - ) - - # Should be reasonable for F750 - assert 25.0 <= flow_temp <= 55.0 - - # Efficiency target: outdoor + 27°C ± 3°C - # -5 + 27 = 22°C (lower bound ~19, upper bound ~30 with adjustments) - # With heat demand formula it may be higher - assert flow_temp >= 20.0 # Above minimum - assert flow_temp <= 60.0 # Below maximum + """The F750's published maximum output is 4.994 kW (EN 14511, part no. 066 063), not 8.0. - def test_flow_temp_clamped_to_limits(self, f750): - """Test flow temperature is clamped to F750 limits.""" - # Extreme cold should not exceed max flow temp - flow_temp = f750.calculate_optimal_flow_temp( - outdoor_temp=-30.0, indoor_target=21.0, heat_demand_kw=15.0 - ) - assert flow_temp <= f750.max_flow_temp + It is an exhaust-air pump: output is bounded by the house's ventilation air, so the old + (2.0, 8.0) kW rating was invented. + """ + assert f750.rated_power_kw == (1.144, 4.994) + assert f750.max_heat_output_kw == 4.994 + assert f750.max_flow_temp == 60.0 - # Should never go below minimum - flow_temp = f750.calculate_optimal_flow_temp( - outdoor_temp=10.0, indoor_target=21.0, heat_demand_kw=2.0 - ) - assert flow_temp >= f750.min_flow_temp + def test_cop_matches_the_datasheet(self, f750): + """It used to assert COP 5.0 "at rated conditions (7 C outdoor)". Both halves were wrong. + + The number 5.0 appears nowhere in the F750's datasheet, and "7 C outdoor" is not a condition + this machine is rated at. Its three published points are all A20(12) - twenty-degree extract + air - and the outdoor air never touches its evaporator. + """ + published = {p.condition: (p.heat_output_kw, p.cop) for p in f750.datasheet_points} + + assert published == { + "A20(12)W35, exhaust air flow 108 m3/h (30 l/s) min compressor frequency": ( + 1.144, + 4.20, + ), + "A20(12)W35, exhaust air flow 252 m3/h (70 l/s) min compressor frequency": ( + 1.498, + 4.72, + ), + "A20(12)W45, exhaust air flow 252 m3/h (70 l/s) max compressor frequency": ( + 4.994, + 2.43, + ), + } + assert f750.typical_cop_range == (2.43, 4.72) + + def test_the_display_curve_is_labelled_as_a_proxy_not_a_measurement(self, f750): + """The outdoor-keyed COP curve is a dashboard proxy derived from the published endpoints. + + It used to assert a fabricated 8-point table (COP 5.0 at 7 C down to 1.8 at -30 C) as fact, + keyed on a variable this exhaust-air machine does not respond to. Nothing computes from it - + the simulator's physics comes from `datasheet_points`. + """ + curve = f750.cop_curve + + assert max(curve.values()) == pytest.approx(4.72), "the best published COP, min freq at W35" + assert min(curve.values()) == pytest.approx(2.43), "the worst, max freq at W45" + assert 5.0 not in curve.values(), "COP 5.0 is not a figure NIBE publishes for this machine" def test_power_validation_normal(self, f750): """Test power validation for normal consumption.""" @@ -236,22 +183,28 @@ def f730(self): """NIBE F730 profile fixture.""" return NibeF730Profile() - def test_smaller_than_f750(self, f730): - """Test F730 is smaller than F750.""" + def test_the_f730_is_not_a_smaller_f750(self, f730): + """At A20(12)W45 max frequency NIBE publishes 5.35 kW (F730) vs 4.994 kW (F750). + + The F730 is the stronger machine at full tilt; the old "F730 < F750" ordering was invented. + """ f750 = NibeF750Profile() - assert f730.rated_power_kw[1] < f750.rated_power_kw[1] - assert f730.typical_electrical_range_kw[1] < f750.typical_electrical_range_kw[1] + assert f730.max_heat_output_kw == 5.35 + assert f750.max_heat_output_kw == 4.994 + assert f730.max_heat_output_kw > f750.max_heat_output_kw + + def test_the_f730_does_not_share_the_f750s_cop_curve(self, f730): + """Two different machines must not carry byte-identical COP curves. - def test_cop_same_as_f750(self, f730): - """Test F730 has same COP curve as F750 (same technology).""" + The old `test_cop_same_as_f750` demanded they stay equal ("same technology"). NIBE publishes + COP 5.32 for the F730 at A20(12)W35 min frequency, and 4.72 for the F750. + """ f750 = NibeF750Profile() - # Should have same COP at all temperatures - for temp in [7, 0, -5, -10, -15, -20, -25, -30]: - cop_730 = f730.get_cop_at_temperature(temp) - cop_750 = f750.get_cop_at_temperature(temp) - assert cop_730 == cop_750 + assert f730.typical_cop_range == (2.43, 5.32) + assert f750.typical_cop_range == (2.43, 4.72) + assert f730.cop_curve != f750.cop_curve class TestNibeF2040Profile: @@ -262,31 +215,31 @@ def f2040(self): """NIBE F2040 profile fixture.""" return NibeF2040Profile() - def test_larger_than_f750(self, f2040): - """Test F2040 is larger than F750.""" - f750 = NibeF750Profile() - - assert f2040.rated_power_kw[1] > f750.rated_power_kw[1] - assert f2040.typical_electrical_range_kw[1] > f750.typical_electrical_range_kw[1] - - def test_slightly_lower_cop(self, f2040): - """Test F2040 has slightly lower COP than F750 (larger unit).""" - f750 = NibeF750Profile() - - # At -15°C: F750 = 2.7, F2040 = 2.5 - cop_f2040 = f2040.get_cop_at_temperature(-15.0) - cop_f750 = f750.get_cop_at_temperature(-15.0) + def test_the_f2040_is_an_air_source_machine_and_the_only_one(self, f2040): + """It is the ONLY profile here whose heat source really is the outdoor air. + + Which makes it the only one for which an outdoor-keyed COP curve was ever meaningful - and + its curve is now the datasheet's own W35 rows, not a template. + """ + assert f2040.cop_curve == {7: 4.65, 2: 3.76, -7: 2.68} + assert f2040.max_flow_temp == 58.0, "the datasheet says 58, the profile used to say 63" + assert not f2040.supports_aux_heating, ( + "The F2040 is an outdoor monobloc and has NO immersion heater - its technical " + "specifications table has no such row. The profile claimed 'True # Larger immersion " + "heaters'. The electric addition lives in the paired indoor module." + ) - assert cop_f2040 < cop_f750 - assert abs(cop_f2040 - 2.5) < 0.1 + def test_its_capacity_rises_as_the_weather_cools(self, f2040): + """It does not derate. It ramps up. The simulator had this backwards and cited EN 14511. - def test_higher_power_consumption(self, f2040): - """Test F2040 has higher power consumption for large houses.""" - # 12kW heat demand at -15°C - electrical = f2040.estimate_electrical_consumption(heat_demand_kw=12.0, outdoor_temp=-15.0) + NIBE publishes 3.86 kW at A7/W35 and 6.60 kW at A-7/W35. An inverter is throttled back at + its mild rating point and opens up as the load arrives. What collapses in the cold is the + COP - 4.65 to 2.68 - and not the capacity. + """ + by_source = {p.source_temp_c: p for p in f2040.datasheet_points if p.flow_temp_c == 35.0} - # COP ~2.5, so electrical ~4.8kW - assert 4.0 <= electrical <= 5.5 + assert by_source[-7.0].heat_output_kw > by_source[7.0].heat_output_kw + assert by_source[-7.0].cop < by_source[7.0].cop class TestNibeS1155Profile: @@ -302,38 +255,6 @@ def test_gshp_characteristics(self, s1155): assert s1155.model_type == "S-series GSHP" assert s1155.rated_power_kw[1] == 12.0 - def test_much_better_cop_than_ashp(self, s1155): - """Test S1155 has much better COP than equivalent ASHP.""" - f2040 = NibeF2040Profile() # Similar size ASHP - - # At -15°C: S1155 GSHP = 4.3, F2040 ASHP = 2.5 - cop_gshp = s1155.get_cop_at_temperature(-15.0) - cop_ashp = f2040.get_cop_at_temperature(-15.0) - - assert cop_gshp > cop_ashp - assert cop_gshp > 4.0 # GSHP stays high even in cold - assert abs(cop_gshp - 4.3) < 0.1 - - def test_lower_electrical_consumption(self, s1155): - """Test S1155 uses much less power than equivalent ASHP.""" - f2040 = NibeF2040Profile() - - # Same heat demand (10kW) at -15°C - elec_gshp = s1155.estimate_electrical_consumption(heat_demand_kw=10.0, outdoor_temp=-15.0) - elec_ashp = f2040.estimate_electrical_consumption(heat_demand_kw=10.0, outdoor_temp=-15.0) - - # GSHP should use much less power - assert elec_gshp < elec_ashp - # S1155: 10 / 4.3 = ~2.3kW - # F2040: 10 / 2.5 = ~4.0kW - assert abs(elec_gshp - 2.3) < 0.3 - - def test_stable_cop_in_extreme_cold(self, s1155): - """Test S1155 COP stays high even in extreme cold.""" - # At -30°C: GSHP = 3.5 (ground temp stable) - cop = s1155.get_cop_at_temperature(-30.0) - assert cop >= 3.5 # Much better than ASHP ~1.8 - def test_can_run_lower_flow_temps(self, s1155): """Test S1155 can run lower flow temperatures.""" f750 = NibeF750Profile() @@ -345,40 +266,6 @@ def test_can_run_lower_flow_temps(self, s1155): class TestModelComparisons: """Test comparisons between different models.""" - def test_power_consumption_hierarchy(self): - """Test power consumption increases with model size.""" - f730 = NibeF730Profile() - f750 = NibeF750Profile() - f2040 = NibeF2040Profile() - - # At -10°C with 8kW heat demand - # F730: May exceed capacity - # F750: ~2.7kW - # F2040: ~2.7kW but has more headroom - - elec_730 = f730.estimate_electrical_consumption(8.0, -10.0) - elec_750 = f750.estimate_electrical_consumption(8.0, -10.0) - elec_2040 = f2040.estimate_electrical_consumption(8.0, -10.0) - - # F730 may be capped at max - # F750 and F2040 should have similar electrical (same COP ~3.0) - assert abs(elec_750 - 2.7) < 0.4 - - def test_gshp_always_more_efficient_than_ashp(self): - """Test GSHP models are always more efficient than ASHP.""" - f750 = NibeF750Profile() # 8kW ASHP - s1155 = NibeS1155Profile() # 12kW GSHP - - test_temps = [7, 0, -5, -10, -15, -20, -25, -30] - - for temp in test_temps: - cop_ashp = f750.get_cop_at_temperature(temp) - cop_gshp = s1155.get_cop_at_temperature(temp) - - assert cop_gshp > cop_ashp, ( - f"GSHP should be more efficient at {temp}°C: " f"GSHP {cop_gshp} vs ASHP {cop_ashp}" - ) - def test_all_models_have_required_attributes(self): """Test all models have required profile attributes.""" models = [ @@ -412,7 +299,9 @@ def test_all_models_have_required_attributes(self): # Limits assert model.max_flow_temp > model.min_flow_temp - assert model.min_runtime_minutes > 0 + # The pump's own factory aux-start, per installer manual - the simulator's plant + # fires the elpatron here. Must sit above EffektGuard's -1500 emergency floor. + assert model.aux_start_dm > -1500 class TestValidationResults: @@ -453,54 +342,3 @@ def test_validation_severity_levels(self): class TestRealWorldScenarios: """Test real-world usage scenarios.""" - - def test_typical_swedish_house_f750(self): - """Test F750 in typical Swedish house (150m² standard insulation).""" - f750 = NibeF750Profile() - - # Stockholm winter: -10°C, 7kW heat demand - electrical = f750.estimate_electrical_consumption(heat_demand_kw=7.0, outdoor_temp=-10.0) - - # COP ~3.0, so 7 / 3.0 = ~2.3kW - assert 2.0 <= electrical <= 2.7 - assert electrical < f750.typical_electrical_range_kw[1] # Within normal range - - def test_undersized_f730_for_large_house(self): - """Test F730 undersized for 150m² house.""" - f730 = NibeF730Profile() - - # 10kW heat demand (too much for F730) - electrical = f730.estimate_electrical_consumption(heat_demand_kw=10.0, outdoor_temp=-15.0) - - # Should be at or below max electrical (may be capped) - assert electrical <= f730.typical_electrical_range_kw[1] - # For 10kW demand at -15°C (COP 2.7): 10/2.7 = 3.7kW - assert 3.5 <= electrical <= 4.5 - - def test_f2040_in_extreme_cold(self): - """Test F2040 in extreme cold (Kiruna -25°C).""" - f2040 = NibeF2040Profile() - - # Large house, 15kW heat demand - electrical = f2040.estimate_electrical_consumption(heat_demand_kw=15.0, outdoor_temp=-25.0) - - # COP ~1.9, so 15 / 1.9 = ~7.9kW - # Should be capped or close to max - assert electrical >= 7.0 - - def test_gshp_efficiency_advantage(self): - """Test GSHP efficiency advantage in real scenario.""" - f2040 = NibeF2040Profile() # ASHP - s1155 = NibeS1155Profile() # GSHP - - # Same conditions: -15°C, 10kW heat demand - elec_ashp = f2040.estimate_electrical_consumption(10.0, -15.0) - elec_gshp = s1155.estimate_electrical_consumption(10.0, -15.0) - - # GSHP should use ~40-50% less power - savings_ratio = (elec_ashp - elec_gshp) / elec_ashp - assert savings_ratio > 0.35 # At least 35% savings - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "-s"]) diff --git a/tests/unit/models/test_model_integration_with_codebase.py b/tests/unit/models/test_model_integration_with_codebase.py index bc06822a..febbd124 100644 --- a/tests/unit/models/test_model_integration_with_codebase.py +++ b/tests/unit/models/test_model_integration_with_codebase.py @@ -14,7 +14,14 @@ # Ensure model modules are imported so they register with the registry # The registry uses decorators executed at import time +from custom_components.effektguard.const import ( + DEFAULT_DESIGN_FLOW_TEMP_RADIATOR, + DEFAULT_DESIGN_OUTDOOR_TEMP, + DEFAULT_DESIGN_SPREAD, + RADIATOR_POWER_COEFFICIENT, +) from custom_components.effektguard.models import nibe as _nibe_models # noqa: F401 +from custom_components.effektguard.utils.emitter import en442_flow_temp # Mock existing EffektGuard components for testing @@ -175,120 +182,12 @@ def test_models_cop_decreases_monotonically_with_temperature(self): previous_temp = temp -class TestModelFlowTemperatureIntegration: - """Test models integrate with flow temperature formula.""" - - def test_models_flow_temp_in_realistic_range(self): - """Test flow temperatures are realistic for UFH and radiators.""" - from custom_components.effektguard.models.registry import HeatPumpModelRegistry - - registry = HeatPumpModelRegistry() - - print("\n" + "=" * 80) - print("FLOW TEMPERATURE REALISM TEST") - print("=" * 80) - print("UFH typical: 25-45°C, Radiators typical: 35-55°C") - print("=" * 80) - - for model_id in ["nibe_f750", "nibe_f2040", "nibe_s1155"]: - model = registry.get_model(model_id) - - print(f"\nModel: {model.model_name}") - print("-" * 80) - print( - f"{'Outdoor':>8} | {'Indoor':>7} | {'Flow (UFH)':>12} | " - f"{'Flow (Rads)':>12} | {'Valid':>6}" - ) - print("-" * 80) - - test_scenarios = [ - (10, 21), # Mild - (0, 21), # Average winter - (-10, 21), # Cold - (-20, 21), # Very cold - ] - - for outdoor, indoor in test_scenarios: - # Calculate heat demand for typical house - heat_loss_coef_ufh = 180.0 # W/°C typical house with UFH - heat_demand_ufh_kw = (indoor - outdoor) * heat_loss_coef_ufh / 1000.0 - - heat_loss_coef_rads = 200.0 # W/°C slightly worse insulation - heat_demand_rads_kw = (indoor - outdoor) * heat_loss_coef_rads / 1000.0 - - # Test UFH - flow_ufh = model.calculate_optimal_flow_temp(outdoor, indoor, heat_demand_ufh_kw) - - # Test radiators (higher heat demand) - flow_rads = model.calculate_optimal_flow_temp(outdoor, indoor, heat_demand_rads_kw) - - # Validate UFH range (allow clamping to model min_flow_temp) - ufh_ok = flow_ufh >= model.min_flow_temp and flow_ufh <= 50 - # Validate radiator range (allow clamping to model min_flow_temp) - rads_ok = flow_rads >= model.min_flow_temp and flow_rads <= 60 - - status = "✓" if (ufh_ok and rads_ok) else "⚠" - - print( - f"{outdoor:>6}°C | {indoor:>5}°C | {flow_ufh:>10.1f}°C | " - f"{flow_rads:>10.1f}°C | {status:>6}" - ) - - # Flow temp should be reasonable (respecting model limits) - assert ( - ufh_ok - ), f"UFH flow {flow_ufh:.1f}°C invalid (model min: {model.min_flow_temp}°C)" - assert ( - rads_ok - ), f"Radiator flow {flow_rads:.1f}°C invalid (model min: {model.min_flow_temp}°C)" - - # Radiators should need higher flow temp than UFH (or similar) - assert ( - flow_rads >= flow_ufh - 2 - ), f"Radiators should need higher/equal flow temp than UFH" - - def test_models_flow_temp_increases_as_outdoor_temp_drops(self): - """Test flow temperature increases as it gets colder (physics).""" - from custom_components.effektguard.models.registry import HeatPumpModelRegistry - - registry = HeatPumpModelRegistry() - f750 = registry.get_model("nibe_f750") - - print("\n" + "=" * 80) - print("FLOW TEMPERATURE WEATHER COMPENSATION TEST") - print("=" * 80) - print(f"Model: {f750.model_name}") - print("=" * 80) - print(f"{'Outdoor':>8} | {'Flow Temp':>10} | {'ΔFlow':>8} | {'Valid':>6}") - print("-" * 80) - - previous_flow = None - previous_outdoor = None - - for outdoor in range(10, -25, -5): - # Calculate heat demand - indoor = 21.0 - heat_loss_coef = 180.0 # W/°C - heat_demand_kw = (indoor - outdoor) * heat_loss_coef / 1000.0 - - flow = f750.calculate_optimal_flow_temp(outdoor, indoor, heat_demand_kw) - - if previous_flow is not None: - delta_flow = flow - previous_flow - # Flow should increase or stay same as outdoor drops - # (unless clamped by model's min_flow_temp) - is_valid = delta_flow >= -1.0 # Allow for clamping to min - status = "✓" if is_valid else "✗" - - print(f"{outdoor:>6}°C | {flow:>8.1f}°C | {delta_flow:>+6.1f}°C | {status:>6}") - - # Note: Flow can stop increasing if it hits min_flow_temp limit - # This is correct behavior, not a failure - else: - print(f"{outdoor:>6}°C | {flow:>8.1f}°C | baseline | ✓") - - previous_flow = flow - previous_outdoor = outdoor +# NOTE: TestModelFlowTemperatureIntegration was removed along with +# HeatPumpProfile.calculate_optimal_flow_temp. A heat-pump profile describes the PUMP; the flow +# temperature a house needs is a property of the HOUSE's emitters, which a profile cannot know. +# The flow-temperature model now lives in optimization/weather_layer.py (EN 442 emitter law, see +# utils/emitter.py) and is tested in tests/unit/climate/test_weather_compensation.py and +# tests/validation/test_weather_compensation_is_not_anti_compensation.py. See audit F-119 / F-121. class TestModelCapacityAndSizing: @@ -330,9 +229,16 @@ def test_models_detect_insufficient_capacity(self): max_heat = f730.typical_electrical_range_kw[1] * cop can_meet = heat_demand_kw <= max_heat - # Calculate optimal flow temp + # The flow temperature the emitters need, from the one model that owns it. indoor = 21 - optimal_flow = f730.calculate_optimal_flow_temp(outdoor, indoor, heat_demand_kw) + optimal_flow = en442_flow_temp( + indoor_setpoint=indoor, + outdoor_temp=outdoor, + design_outdoor_temp=DEFAULT_DESIGN_OUTDOOR_TEMP, + design_flow_temp=DEFAULT_DESIGN_FLOW_TEMP_RADIATOR, + design_spread=DEFAULT_DESIGN_SPREAD, + emitter_exponent=RADIATOR_POWER_COEFFICIENT, + ) # Validate with model's power check result = f730.validate_power_consumption( 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_additional_scenarios.py b/tests/unit/optimization/test_additional_scenarios.py index ec404eb2..91ed0bc1 100644 --- a/tests/unit/optimization/test_additional_scenarios.py +++ b/tests/unit/optimization/test_additional_scenarios.py @@ -1,484 +1,42 @@ -"""Additional integration tests for remaining scenarios. +"""Config-key guards for DecisionEngine. -Tests: -1. Sensor availability and requirements -2. Configuration flow validation -3. Wear protection (rate limiting) -4. Ventilation optimization readiness +The engine reads the user's target from config key 'target_indoor_temp'. If the key it reads +ever diverges from the key the config carries, a mismatched target is silently ignored and the +engine falls back to the default. These pin the key it reads. """ -import pytest +from unittest.mock import MagicMock -from homeassistant.const import CONF_NAME - -from custom_components.effektguard.const import ( - CONF_NIBE_ENTITY, - CONF_GESPOT_ENTITY, - CONF_WEATHER_ENTITY, - CONF_TARGET_TEMPERATURE, - CONF_TOLERANCE, - CONF_THERMAL_MASS, - CONF_INSULATION_QUALITY, -) - - -class TestSensorAvailability: - """Test that all required sensors are checked and available.""" - - @pytest.mark.asyncio - async def test_required_nibe_sensors(self): - """Test: Verify all required NIBE sensors are identified. - - Required NIBE sensors from adapters/nibe_adapter.py: - - Outdoor temperature (BT1) - - Indoor temperature (BT50 or separate) - - Supply temperature (BT25) - - Degree minutes (if available) - - Heating status - - Current offset - """ - # These are the entity patterns we look for - required_patterns = [ - "outdoor", # BT1 outdoor sensor - "supply", # BT25 supply temperature - "degree_minutes", # GM/DM tracking - "offset", # Current heating curve offset - ] - - # This test documents what we need - actual validation happens in adapters - assert len(required_patterns) == 4 - - # Note: Indoor temp can be from NIBE BT50 or separate sensor - # This flexibility is handled in config flow - - @pytest.mark.asyncio - async def test_required_price_sensors(self): - """Test: Verify required price sensors (spot price). - - Required from spot price integration: - - 96 quarterly prices (15-minute intervals) - - Today's prices - - Tomorrow's prices (if available) - """ - required_data = ["today", "quarterly_prices"] - assert len(required_data) == 2 - - # Spot price provides native 15-minute data (96 quarters per day) - quarters_per_day = 96 - assert quarters_per_day == 24 * 4 # 24 hours × 4 quarters per hour - - @pytest.mark.asyncio - async def test_required_weather_sensors(self): - """Test: Verify required weather sensors. - - Required from weather integration: - - Temperature forecast (next 12-24 hours) - - Hourly granularity minimum - """ - required_forecast_hours = 12 # Minimum for pre-heating decisions - assert required_forecast_hours >= 12 - - @pytest.mark.asyncio - async def test_graceful_degradation_without_optional_sensors(self): - """Test: System works with only required sensors. - - Optional sensors that improve but aren't required: - - Degree minutes (can estimate from temps) - - Tomorrow's prices (can optimize with today only) - - Extended weather forecast (12h is minimum) - """ - # System should function with core sensors only - core_required = ["outdoor_temp", "indoor_temp", "supply_temp", "prices_today"] - optional = ["degree_minutes", "prices_tomorrow", "weather_extended"] - - assert len(core_required) == 4 - assert len(optional) == 3 +from custom_components.effektguard.optimization.decision_engine import DecisionEngine +from custom_components.effektguard.const import DEFAULT_TARGET_TEMP class TestConfigurationFlow: - """Test configuration flow validation and setup.""" - - def test_config_flow_schema(self): - """Test: Configuration flow has all required fields.""" - # These should be in config_flow.py - required_config_fields = [ - CONF_NAME, - CONF_NIBE_ENTITY, - CONF_GESPOT_ENTITY, - CONF_WEATHER_ENTITY, - CONF_TARGET_TEMPERATURE, - CONF_TOLERANCE, - CONF_THERMAL_MASS, - CONF_INSULATION_QUALITY, - ] - - # Verify constants exist - assert CONF_TARGET_TEMPERATURE is not None - assert CONF_TOLERANCE is not None - assert CONF_THERMAL_MASS is not None - assert CONF_INSULATION_QUALITY is not None - - @pytest.mark.asyncio - async def test_config_validation_temperature_ranges(self): - """Test: Temperature configuration validates ranges.""" - # Target temperature should be 15-25°C - valid_target_temps = [18.0, 20.0, 21.0, 22.0, 24.0] - for temp in valid_target_temps: - assert 15.0 <= temp <= 25.0 - - # Invalid temperatures - invalid_target_temps = [10.0, 30.0] - for temp in invalid_target_temps: - assert not (15.0 <= temp <= 25.0) - - @pytest.mark.asyncio - async def test_config_validation_tolerance_ranges(self): - """Test: Tolerance configuration validates ranges.""" - # Tolerance should be 1-10 scale - valid_tolerances = [1, 3, 5, 7, 10] - for tol in valid_tolerances: - assert 1 <= tol <= 10 - - # Invalid tolerances - invalid_tolerances = [0, 15] - for tol in invalid_tolerances: - assert not (1 <= tol <= 10) - - @pytest.mark.asyncio - async def test_config_validation_thermal_mass_ranges(self): - """Test: Thermal mass configuration validates ranges.""" - # Thermal mass should be 0.5-2.0 - valid_masses = [0.5, 1.0, 1.5, 2.0] - for mass in valid_masses: - assert 0.5 <= mass <= 2.0 - - @pytest.mark.asyncio - async def test_config_validation_entity_existence(self): - """Test: Configuration validates entity existence. - - Note: Actual entity validation happens in Home Assistant - This test documents the requirement. - """ - # Entity patterns that should be validated - entity_patterns = [ - "sensor.*_outdoor_temperature", # NIBE outdoor - "sensor.*_supply_temperature", # NIBE supply - "sensor.*_nordpool*", # Spot price or similar - "weather.*", # Weather integration - ] - - assert len(entity_patterns) == 4 - - @pytest.mark.asyncio - async def test_options_flow_allows_runtime_changes(self): - """Test: Options flow allows runtime parameter changes. - - Should be changeable at runtime: - - Target temperature - - Tolerance - - Thermal mass - - Insulation quality - - Optimization mode - - Feature toggles (price opt, peak protection) - """ - runtime_changeable = [ - "target_temperature", - "tolerance", - "thermal_mass", - "insulation_quality", - "optimization_mode", - "price_optimization_enabled", - "peak_protection_enabled", - ] - - assert len(runtime_changeable) == 7 - - -class TestWearProtection: - """Test wear protection and rate limiting.""" - - @pytest.mark.asyncio - async def test_coordinator_update_interval(self): - """Test: Coordinator updates at reasonable intervals. - - Expected: 5-minute updates (tracks 15-min periods without excessive cycles) - """ - expected_update_interval = 5 # minutes - assert expected_update_interval == 5 - - # This prevents excessive compressor cycling - # 5-minute updates = max 12 changes per hour - max_changes_per_hour = 60 / expected_update_interval - assert max_changes_per_hour == 12 - - @pytest.mark.asyncio - async def test_offset_change_rate_limiting(self): - """Test: Offset changes are rate limited. - - Expected: Minimum time between offset changes to prevent wear. - """ - # From copilot instructions and research: - # Should have minimum interval between writes to avoid wear - min_write_interval_seconds = 300 # 5 minutes minimum - assert min_write_interval_seconds >= 300 - - # This protects against: - # 1. Excessive compressor cycling - # 2. NIBE controller wear - # 3. Thermal oscillations - - @pytest.mark.asyncio - async def test_offset_change_magnitude_limits(self): - """Test: Offset changes are gradual, not sudden. - - Expected: Maximum offset change per update cycle. - """ - # Offset changes should be gradual - # Typical offset range: -10 to +10 - # Max change per cycle: ~2-3°C to prevent shock - max_offset_change = 3.0 - assert max_offset_change <= 3.0 - - # Prevents: - # 1. Thermal shock - # 2. Comfort disruption - # 3. Excessive power spikes - - @pytest.mark.asyncio - async def test_startup_delay_prevents_conflicts(self): - """Test: Startup delay allows other integrations to initialize. - - Expected: 10-second delay after HA start. - """ - startup_delay_seconds = 10 - assert startup_delay_seconds >= 10 - - # Ensures: - # 1. NIBE Myuplink is ready - # 2. Spot price has data - # 3. Weather integration loaded - - -class TestVentilationReadiness: - """Test ventilation optimization readiness (future feature).""" - - @pytest.mark.asyncio - async def test_ventilation_correction_factor_placeholder(self): - """Test: Placeholder for ventilation correction factor. - - From Swedish NIBE forum research: - - Ventilation correction factor: ~0.85 - - Reduces defrosting frequency by 75% - - Important for ASHP systems - - Note: Not yet implemented, but data structure ready. - """ - # Future: ventilation correction factor - ventilation_correction_placeholder = 0.85 - assert 0.8 <= ventilation_correction_placeholder <= 1.0 - - # Expected benefits: - # - 75% reduction in defrost cycles - # - Improved COP - # - Less wear on compressor - - @pytest.mark.asyncio - async def test_ventilation_sensor_requirements(self): - """Test: Document ventilation sensor requirements. - - Future ventilation optimization would need: - - Ventilation fan status - - Ventilation speed setting - - Exhaust air temperature (if available) - """ - future_ventilation_sensors = [ - "ventilation_fan_status", # On/Off/Auto - "ventilation_speed", # % - "exhaust_air_temp", # °C (optional) - ] - - assert len(future_ventilation_sensors) == 3 - - # Note: Not required for Phase 3, documented for future - - -class TestCoordinatorIntegration: - """Test coordinator integration and data flow.""" - - @pytest.mark.asyncio - async def test_coordinator_handles_missing_entities_gracefully(self): - """Test: Coordinator handles missing entities without crashing.""" - # This is a documentation test - actual implementation in coordinator.py - # Expected behavior: - # 1. Try to read entity - # 2. If fails, log warning - # 3. Return None or default - # 4. Continue operation with degraded functionality - - error_handling_strategy = [ - "try_read_entity", - "log_warning_on_failure", - "return_safe_default", - "continue_with_degraded_mode", - ] - - assert len(error_handling_strategy) == 4 - - @pytest.mark.asyncio - async def test_coordinator_aggregates_all_data_sources(self): - """Test: Coordinator aggregates all required data sources. - - Data flow: - 1. NIBE adapter → heat pump state - 2. Spot price adapter → 15-min prices - 3. Weather adapter → temperature forecast - 4. Effect manager → peak status - 5. Decision engine → optimal offset - """ - data_sources = [ - "nibe_state", - "price_data", - "weather_data", - "peak_tracking", - "decision", - ] - - assert len(data_sources) == 5 - - @pytest.mark.asyncio - async def test_coordinator_updates_peak_tracking(self): - """Test: Coordinator updates peak tracking every cycle. - - Expected flow: - 1. Estimate current power - 2. Get current quarter - 3. Record measurement - 4. Check for new peak - 5. Update peak sensors - """ - peak_tracking_flow = [ - "estimate_power", - "calculate_quarter", - "record_measurement", - "check_new_peak", - "update_sensors", - ] - - assert len(peak_tracking_flow) == 5 - - -class TestDocumentationCompleteness: - """Test that implementation matches documented requirements.""" - - def test_swedish_effektavgift_compliance(self): - """Test: Verify Swedish Effektavgift compliance documented. - - Requirements from implementation plan: - - 15-minute measurement windows ✅ - - Daytime/nighttime weighting (full/50%) ✅ - - Monthly top 3 peaks ✅ - - Quarterly period tracking (0-95) ✅ - """ - compliance_features = [ - "15_minute_windows", - "day_night_weighting", - "monthly_top_3", - "quarterly_tracking", - ] - - assert len(compliance_features) == 4 - - def test_nibe_specific_features_documented(self): - """Test: Verify NIBE-specific features documented. - - From copilot instructions: - - Degree minutes thresholds ✅ - - Pump configuration requirements ✅ - - UFH type considerations ✅ - - MyUplink API requirements ✅ - """ - nibe_features = [ - "degree_minutes_thresholds", - "pump_configuration", - "ufh_types", - "myuplink_api", - ] - - assert len(nibe_features) == 4 - - def test_safety_first_principles_implemented(self): - """Test: Verify safety-first principles in code. - - From copilot instructions: - - Safety over savings ✅ - - Comfort over cost ✅ - - Real homes dependency ✅ - - Research-based thresholds ✅ - """ - safety_principles = [ - "safety_layer_highest_priority", - "comfort_maintained", - "production_quality", - "research_validated", - ] - - assert len(safety_principles) == 4 - - -# Summary of additional tests -""" -Additional Test Coverage Summary: - -✅ Sensor Availability (4 tests) - - Required NIBE sensors documented - - Required price sensors documented - - Required weather sensors documented - - Graceful degradation tested - -✅ Configuration Flow (6 tests) - - Schema validation - - Temperature range validation - - Tolerance range validation - - Thermal mass range validation - - Entity existence validation - - Runtime options changeability - -✅ Wear Protection (4 tests) - - Update interval reasonable - - Rate limiting implemented - - Gradual offset changes - - Startup delay protection - -✅ Ventilation Readiness (2 tests) - - Correction factor placeholder - - Sensor requirements documented - Note: Feature not yet implemented - -✅ Self-Learning (3 tests) - - Observation collection working - - History size limiting - - Future enhancement placeholder - Note: ML algorithms future work - -✅ Coordinator Integration (3 tests) - - Missing entity handling - - Data source aggregation - - Peak tracking updates - -✅ Documentation (3 tests) - - Swedish Effektavgift compliance - - NIBE-specific features - - Safety-first principles - -Total: 25 additional tests - -Answers to Remaining Questions: -✅ Sensor availability - Documented and tested -✅ Configuration flow - Validated -✅ Wear protection - Rate limiting tested -❓ Ventilation - Placeholder ready, not implemented yet -✅ Self-learning - Data collection ready, ML future -✅ All settings configurable - Validated - -Combined Total: 26 (integration) + 25 (additional) + 22 (effect_manager) = 73 tests -""" + """The engine must read the target from the key the config actually carries.""" + + def test_the_target_temperature_key_the_engine_reads_is_the_one_it_is_given(self): + """The engine reads config key 'target_indoor_temp'; a config carrying any other key is + silently ignored and it falls back to the default. + """ + engine = DecisionEngine( + price_analyzer=MagicMock(), + effect_manager=MagicMock(), + thermal_model=MagicMock(), + config={"target_indoor_temp": 19.0}, + ) + + assert engine.target_temp == 19.0, ( + f"The engine was configured with a 19.0 C target and read {engine.target_temp}. The " + f"key it reads is 'target_indoor_temp'; a config carrying 'target_temperature' is " + f"silently ignored, and the engine falls back to the default." + ) + + def test_a_config_without_a_target_falls_back_to_the_default(self): + engine = DecisionEngine( + price_analyzer=MagicMock(), + effect_manager=MagicMock(), + thermal_model=MagicMock(), + config={}, + ) + + assert engine.target_temp == DEFAULT_TARGET_TEMP 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_airflow_optimizer.py b/tests/unit/optimization/test_airflow_optimizer.py index f7c8ecd1..b259aca8 100644 --- a/tests/unit/optimization/test_airflow_optimizer.py +++ b/tests/unit/optimization/test_airflow_optimizer.py @@ -1,20 +1,9 @@ """Tests for the Thermal Airflow Optimizer. -Tests thermodynamic calculations for exhaust air heat pump airflow optimization. - -Physics basis: -- Net Benefit = (Extra heat extracted) + (COP improvement) - (Ventilation penalty) -- Enhanced airflow helps when compressor is working hard and outdoor temp is moderate -- Cold outdoor temps cause ventilation penalty to exceed gains - -Reference table (from documentation): -| Outdoor °C | Min Compressor % | Expected Gain | -|------------|-----------------|---------------| -| +10 | 50% | +1.3 kW | -| 0 | 50% | +0.9 kW | -| -5 | 62% | +0.7 kW | -| -10 | 75% | +0.4 kW | -| < -15 | Don't enhance | Negative | +Thermodynamic calculations for exhaust-air heat-pump airflow. Enhancing airflow only pays +above break-even (about indoor minus the evaporator temperature drop, ~+9 C outdoor): below +that the building must reheat every extra cubic metre from outdoor to indoor while the +evaporator recovers only its own temperature drop, so the net thermal gain is negative. """ from datetime import datetime @@ -85,7 +74,7 @@ class TestCompressorThresholds: """Test compressor percentage threshold calculations.""" def test_threshold_at_0c(self): - """At 0°C, threshold should be base (50%).""" + """At 0°C, threshold should be the base (AIRFLOW_COMPRESSOR_BASE_THRESHOLD).""" threshold = minimum_compressor_threshold(0.0) assert threshold == AIRFLOW_COMPRESSOR_BASE_THRESHOLD @@ -111,17 +100,20 @@ def test_threshold_at_minus_5c(self): class TestNetThermalGain: """Test net thermal gain calculations.""" - def test_positive_gain_at_moderate_temp(self): - """Should have positive gain at 0°C outdoor.""" + def test_net_loss_at_moderate_temp(self): + """A net LOSS at 0 C outdoor. The old expectation of ~+0.9 kW was a double-count. + + The evaporator recovers AIRFLOW_EVAPORATOR_TEMP_DROP from the extra air; the building has + to reheat that air the whole way from 0 C to 21 C. See test_airflow_energy_balance.py. + """ gain = calculate_net_thermal_gain( flow_standard=AIRFLOW_DEFAULT_STANDARD, flow_enhanced=AIRFLOW_DEFAULT_ENHANCED, temp_indoor=21.0, temp_outdoor=0.0, ) - # Per documentation: ~0.9 kW at 0°C - assert gain > 0 - assert 0.5 < gain < 1.5 + assert gain < 0 + assert -0.5 < gain < -0.1 def test_higher_gain_at_warm_temp(self): """Should have higher gain at +10°C outdoor.""" @@ -134,22 +126,27 @@ def test_higher_gain_at_warm_temp(self): # Warmer = less ventilation penalty = more gain assert gain_warm > gain_cold - def test_reduced_gain_at_cold_temp(self): - """Should have reduced gain at -10°C outdoor.""" + def test_deeper_loss_at_cold_temp(self): + """The loss DEEPENS as it gets colder - colder air is more expensive to reheat.""" gain = calculate_net_thermal_gain( AIRFLOW_DEFAULT_STANDARD, AIRFLOW_DEFAULT_ENHANCED, 21.0, -10.0 ) - # Per documentation: ~0.4 kW at -10°C - assert 0.2 < gain < 0.8 + assert -1.0 < gain < -0.4 class TestEvaluateAirflow: """Test airflow decision evaluation.""" - def test_enhance_at_optimal_conditions(self): - """Should recommend enhancement at ideal conditions.""" + def test_enhance_above_break_even(self): + """Enhancement pays only ABOVE break-even (indoor - evaporator drop, about +9 C). + + 0 C outdoor used to satisfy this test because the net gain double-counted the evaporator + heat as a COP improvement. Below break-even the building must reheat every extra cubic + metre from outdoor to indoor while the evaporator recovers only its own temperature drop + from it, so enhancing is a net thermal LOSS. + """ state = ThermalState( - temp_outdoor=0.0, + temp_outdoor=12.0, # above break-even temp_indoor=20.5, temp_target=21.0, compressor_pct=80.0, @@ -162,6 +159,21 @@ def test_enhance_at_optimal_conditions(self): assert decision.expected_gain_kw > 0 assert "beneficial" in decision.reason.lower() + def test_refuse_to_enhance_during_the_heating_season(self): + """At 0 C the extra air costs more to reheat than the evaporator recovers from it.""" + state = ThermalState( + temp_outdoor=0.0, + temp_indoor=20.5, + temp_target=21.0, + compressor_pct=80.0, + trend_indoor=-0.1, + ) + decision = evaluate_airflow(state) + + assert decision.mode == FlowMode.STANDARD + assert decision.expected_gain_kw < 0 + assert decision.duration_minutes == 0 + def test_no_enhance_outdoor_too_cold(self): """Should not enhance when outdoor temp too low.""" state = ThermalState( @@ -226,7 +238,7 @@ def test_no_enhance_low_compressor(self): temp_outdoor=0.0, temp_indoor=20.0, temp_target=21.0, - compressor_pct=30.0, # Below 50% threshold at 0°C + compressor_pct=30.0, # Below the 61% threshold at 0°C trend_indoor=-0.1, # Slightly cooling but above threshold - test focuses on compressor ) decision = evaluate_airflow(state) @@ -252,10 +264,10 @@ def test_returns_tuple(self): assert isinstance(result[0], bool) assert isinstance(result[1], int) - def test_enhance_true_at_optimal_conditions(self): - """Should return True at ideal conditions.""" + def test_enhance_true_above_break_even(self): + """Enhancement pays only above break-even (about +9 C), not at 0 C.""" should_enhance, duration = should_enhance_airflow( - temp_outdoor=0.0, + temp_outdoor=12.0, temp_indoor=20.5, temp_target=21.0, compressor_pct=80.0, @@ -305,7 +317,6 @@ def test_evaluate_updates_state(self): assert optimizer.current_decision is not None assert optimizer.current_decision == decision - assert len(optimizer._decision_history) == 1 def test_should_enhance_property(self): """Test the should_enhance property of FlowDecision.""" @@ -325,23 +336,6 @@ def test_should_enhance_property(self): ) assert decision_standard.should_enhance is False - def test_enhancement_stats(self): - """Test enhancement statistics tracking.""" - optimizer = AirflowOptimizer() - - # Make several evaluations - for _ in range(5): - optimizer.evaluate(0.0, 20.5, 21.0, 80.0, -0.1) # Should enhance (valid trend) - for _ in range(5): - optimizer.evaluate(-20.0, 20.0, 21.0, 80.0, 0.0) # Should not enhance (too cold) - - stats = optimizer.get_enhancement_stats() - - assert stats["total_decisions"] == 10 - assert stats["enhance_recommendations"] == 5 - assert stats["enhance_percentage"] == 50.0 - assert stats["average_gain_kw"] > 0 - class TestDurationCalculation: """Test duration calculation for enhanced airflow.""" @@ -458,9 +452,9 @@ def test_zero_compressor_pct(self): assert decision.mode == FlowMode.STANDARD def test_100_compressor_pct(self): - """Test with 100% compressor percentage.""" + """Test with 100% compressor percentage, above break-even.""" state = ThermalState( - temp_outdoor=0.0, + temp_outdoor=12.0, temp_indoor=20.0, temp_target=21.0, compressor_pct=100.0, # Full power diff --git a/tests/unit/optimization/test_anti_windup.py b/tests/unit/optimization/test_anti_windup.py index fcc7b1fb..f04040a1 100644 --- a/tests/unit/optimization/test_anti_windup.py +++ b/tests/unit/optimization/test_anti_windup.py @@ -1,27 +1,10 @@ -"""Tests for anti-windup protection in EmergencyLayer. - -Context: DM oscillation occurs when offset is raised during thermal debt recovery, -but heat hasn't yet reached the thermal mass (UFH concrete slab has 6+ hour lag). -Raising offset makes DM go MORE negative initially because: - - S1 target rises immediately - - BT25 (actual) takes hours to catch up - - DM = ∫(BT25 - S1) dt accumulates larger negative values - -Without anti-windup, the system "chases" thermal debt: - 1. DM at -300 → apply +2°C offset - 2. S1 target rises, but BT25 lags → DM drops to -500 - 3. "Not working!" → raise to +3°C → DM drops to -700 - 4. Eventually heat arrives → BT25 overshoots S1 → DM swings to +100 - 5. System backs off → slab cools → cycle repeats - -Anti-windup solution: - If DM is dropping WHILE current_offset is already positive: - → Heat is "in transit" through thermal mass - → Don't escalate offset further - cap at current level - -Reference: - - Wikipedia: Integral Windup - - Dec 9, 2025 debug.log analysis +"""Anti-windup protection in EmergencyLayer. + +DM = integral(BT25 - S1) dt. Raising the offset lifts S1 immediately, but BT25 (actual flow) +lags for hours through a concrete slab, so DM first goes MORE negative. Without anti-windup the +layer reads that as "not working", escalates the offset further, and oscillates once the heat +finally arrives. Anti-windup: when DM is dropping while current_offset is already positive, heat +is in transit - hold the offset rather than escalate it. """ import pytest @@ -304,13 +287,14 @@ def test_prevents_escalation_during_heat_transit(self, emergency_layer, nibe_sta tolerance_range=0.5, ) - # Anti-windup should have prevented escalation or reduced offset (Jan 2026) + # Anti-windup should have prevented escalation or reduced the offset. if decision.anti_windup_active: - # With severe dm_rate (-1200/h in this test), offset may be REDUCED - # Jan 2026 enhancement: At -1200/h, reduction = 1200/100 = 12°C - # So offset goes from +2 to -10 (capped at MIN_OFFSET) + # With a severe dm_rate (-1200/h here) the offset may be REDUCED: reduction = 1200/100 + # = 12°C, so +2 goes to -10 (capped at MIN_OFFSET). # Offset should be <= current_offset (kept or reduced, never raised) - assert decision.offset <= 2.0, f"Anti-windup should prevent raise, got {decision.offset}" + assert ( + decision.offset <= 2.0 + ), f"Anti-windup should prevent raise, got {decision.offset}" # New format options: # - Mild: "DM dropping -XX/h while offset +X°C - not raising" # - Severe: "DM dropping -XX/h - reducing offset by X°C..." @@ -322,21 +306,11 @@ def test_prevents_escalation_during_heat_transit(self, emergency_layer, nibe_sta class TestAntiWindupRealScenario: - """Test real-world scenario from Dec 9, 2025 debug.log.""" + """A logged DM-chasing oscillation, reproduced and prevented.""" def test_prevents_dm_chasing_scenario(self, emergency_layer, nibe_state_factory): - """Reproduce and prevent the DM oscillation scenario. - - Real scenario observed: - 1. DM at -347 (pre-existing at startup) - 2. DHW heating causes DM to drop to -711 - 3. System applies recovery offset - 4. DM drops further to -800+ (heat in transit) - 5. Eventually DM swings to +100 (overshoot) - - With anti-windup: - - When DM is dropping despite positive offset, don't escalate - - Wait for heat to arrive through thermal mass + """Across a run of steadily dropping DM values with a positive offset held, the layer must + not escalate the offset on every step - anti-windup caps it while heat is in transit. """ layer = emergency_layer @@ -383,13 +357,9 @@ def test_prevents_dm_chasing_scenario(self, emergency_layer, nibe_state_factory) class TestCausationWindow: - """Test causation window feature for anti-windup (Jan 2026). - - The causation window distinguishes between: - - Self-induced spiral: We raised offset recently → DM dropping → our fault - - Environmental drop: Offset stable for hours → DM dropping → cold snap arrived - - Anti-windup should only trigger for self-induced spirals. + """The causation window separates a self-induced spiral (offset raised recently, then DM + dropping) from an environmental drop (offset stable for hours, then DM dropping). Anti-windup + should trigger only for self-induced spirals. """ def test_tracks_offset_raises(self, emergency_layer): @@ -452,9 +422,7 @@ def test_raised_recently_false_when_never_raised(self, emergency_layer): assert layer._last_offset_raise_time is None assert layer._raised_offset_recently(now) is False - def test_anti_windup_triggers_for_recent_raise( - self, emergency_layer, nibe_state_factory - ): + def test_anti_windup_triggers_for_recent_raise(self, emergency_layer, nibe_state_factory): """Anti-windup triggers when offset was raised recently and DM dropping.""" layer = emergency_layer now = datetime.now() @@ -487,9 +455,7 @@ def test_anti_windup_triggers_for_recent_raise( assert decision.anti_windup_active is True assert decision.tier == "ANTI_WINDUP" - def test_anti_windup_skipped_for_old_offset( - self, emergency_layer, nibe_state_factory - ): + def test_anti_windup_skipped_for_old_offset(self, emergency_layer, nibe_state_factory): """Anti-windup skipped when offset was raised long ago (environmental drop).""" layer = emergency_layer now = datetime.now() 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_critical_scenarios.py b/tests/unit/optimization/test_critical_scenarios.py index 92011387..9c2ecb26 100644 --- a/tests/unit/optimization/test_critical_scenarios.py +++ b/tests/unit/optimization/test_critical_scenarios.py @@ -1,16 +1,12 @@ -"""Critical scenario tests for EffektGuard. - -Tests addressing key operational questions: -1. Peak calculation frequency and short-cycling prevention -2. Power outage recovery with peak proximity -3. Different preset modes (Comfort, Balanced/Auto, Eco, Away) -4. Rate limiting and wear protection -5. Quarter measurement timing +"""Critical scenario guards for EffektGuard. + +Pins the effect-manager power-limit response after an outage, the update/rate-limit cadence, +and the tolerance-to-tolerance_range mapping. """ import pytest import pytest_asyncio -from datetime import datetime, timedelta +from datetime import datetime from unittest.mock import MagicMock, patch @@ -61,29 +57,11 @@ def create_nibe_state( class TestPeakCalculationFrequency: - """Test peak calculation frequency and short-cycling prevention. - - Key Question: How often do we calculate peaks? Is 15 min too short to avoid short cycling? - - Answer: - - Coordinator updates every 5 minutes - - Peak measurements recorded every 15 minutes (quarterly periods) - - Offset changes rate-limited to prevent cycling - - 5-min updates allow response within 15-min windows without excessive cycling - """ + """Update cadence and rate limiting: safe for the compressor, aligned to 15-min windows.""" def test_coordinator_update_interval_prevents_cycling(self): - """Test: 5-minute update interval is reasonable for cycling prevention. - - Expected: - - Update interval: 5 minutes - - Max changes per hour: 12 - - Max changes per 15-min period: 3 - - Analysis: - - 5 minutes is safe: NIBE compressors typically have 5-10 min minimum cycle - - Allows response within each 15-min Effektavgift window - - Not excessive (vs 1-min updates = 60 changes/hour) + """The 5-minute update interval bounds changes to 12/hour (3 per 15-min window), which + stays within a NIBE compressor's minimum cycle time. """ update_interval = UPDATE_INTERVAL_MINUTES @@ -107,44 +85,24 @@ async def test_peak_recorded_once_per_quarter(self, effect_manager): Expected: - Each 15-minute period measured once - - Multiple measurements within same quarter don't create multiple peaks - - Only highest measurement in quarter matters + - Multiple measurements within same billing_hour don't create multiple peaks + - Only highest measurement in billing_hour matters """ timestamp_1 = datetime(2025, 10, 14, 12, 2) # Q48 (12:00-12:15) - timestamp_2 = datetime(2025, 10, 14, 12, 7) # Q48 (same quarter) - timestamp_3 = datetime(2025, 10, 14, 12, 14) # Q48 (same quarter) + timestamp_2 = datetime(2025, 10, 14, 12, 7) # Q48 (same billing_hour) + timestamp_3 = datetime(2025, 10, 14, 12, 14) # Q48 (same billing_hour) - quarter = 48 # All in same quarter + billing_hour = 12 # All in same billing_hour - # Record multiple measurements in same quarter - peak_1 = await effect_manager.record_quarter_measurement(4.0, quarter, timestamp_1) - peak_2 = await effect_manager.record_quarter_measurement(4.5, quarter, timestamp_2) - peak_3 = await effect_manager.record_quarter_measurement(4.2, quarter, timestamp_3) + # Record multiple measurements in same billing_hour + peak_1 = await effect_manager.record_period_measurement(4.0, billing_hour, timestamp_1) + peak_2 = await effect_manager.record_period_measurement(4.5, billing_hour, timestamp_2) + peak_3 = await effect_manager.record_period_measurement(4.2, billing_hour, timestamp_3) # All should be recorded (highest wins) # But only 3 peaks total for top 3 tracking assert len(effect_manager._monthly_peaks) <= 3 - @pytest.mark.asyncio - async def test_offset_changes_are_gradual(self): - """Test: Offset changes are gradual to prevent thermal shock. - - Expected: - - Maximum offset change per update: ~2-3°C - - Prevents sudden changes that cause cycling - - Smooth transitions protect compressor - """ - max_offset_change_per_update = 3.0 # °C - - # This limit is enforced by decision engine aggregation - # Even if one layer votes for large change, aggregation smooths it - assert max_offset_change_per_update <= 3.0 - - # Rationale: - # - Typical heating curve range: -10 to +10 (20°C total) - # - 3°C change per 5 minutes = 36°C/hour (very gradual) - # - Prevents thermal shock and excessive cycling - def test_rate_limiting_prevents_wear(self): """Test: Rate limiting prevents excessive wear on NIBE controller. @@ -153,31 +111,27 @@ def test_rate_limiting_prevents_wear(self): - Prevents excessive MyUplink API calls - Protects NIBE controller from wear """ - min_write_interval_seconds = 300 # 5 minutes - - assert min_write_interval_seconds >= 300 + from custom_components.effektguard.const import ( + SERVICE_RATE_LIMIT_MINUTES, + UPDATE_INTERVAL_MINUTES, + ) - # This prevents: - # 1. API rate limiting issues - # 2. NIBE controller wear - # 3. Excessive compressor cycling - # 4. Network congestion + # The PRODUCTION cooldown, not a local literal asserted against itself: the adapter + # refuses writes inside SERVICE_RATE_LIMIT_MINUTES, and a cooldown shorter than the + # update cadence would rate-limit nothing. + assert SERVICE_RATE_LIMIT_MINUTES * 60 >= 300 + assert SERVICE_RATE_LIMIT_MINUTES >= UPDATE_INTERVAL_MINUTES - max_writes_per_hour = 3600 / min_write_interval_seconds - assert max_writes_per_hour == 12 # Max 12 writes/hour + # Bounds MyUplink API calls and NIBE controller wear. + max_writes_per_hour = 60 / SERVICE_RATE_LIMIT_MINUTES + assert max_writes_per_hour <= 12 class TestPowerOutageRecovery: - """Test power outage recovery and peak proximity scenarios. - - Key Question: How close can we be to a high peak after a power outage - and still make it without hitting any limits? + """should_limit_power response as current power approaches the recorded monthly peak. - Answer: - - System uses 0.5 kW and 1.0 kW margins for safety - - Warning at 1.0 kW margin, critical at 0.5 kW - - After outage, system reads current peaks from storage - - Provides immediate protection against exceeding peaks + Margins: WARNING at 1.0 kW (offset -1.0), CRITICAL at 0.5 kW (-2.0), exceeding (-3.0). + Nighttime power is weighted 50% before comparison. """ @pytest.mark.asyncio @@ -189,12 +143,12 @@ async def test_recovery_with_close_peak(self, effect_manager): """ # Set up monthly peak at 5.0 kW (before outage) timestamp = datetime(2025, 10, 14, 10, 0) - await effect_manager.record_quarter_measurement(5.0, 40, timestamp) + await effect_manager.record_period_measurement(5.0, 10 * 4, timestamp) # Simulate system restart - storage persists # Current power: 4.2 kW (0.8 kW below peak) - quarter = 50 # Daytime - decision = effect_manager.should_limit_power(4.2, quarter) + billing_period = 12 * 4 # noon, daytime + decision = effect_manager.should_limit_power(4.2, billing_period) # Should be WARNING (between 0.5 and 1.0 kW margin) assert decision.severity == "WARNING" @@ -210,10 +164,10 @@ async def test_recovery_with_very_close_peak(self, effect_manager): """ # Set up monthly peak timestamp = datetime(2025, 10, 14, 10, 0) - await effect_manager.record_quarter_measurement(5.0, 40, timestamp) + await effect_manager.record_period_measurement(5.0, 10 * 4, timestamp) # Current power: 4.7 kW (0.3 kW below peak - within 0.5 kW critical zone) - decision = effect_manager.should_limit_power(4.7, 50) + decision = effect_manager.should_limit_power(4.7, 12 * 4) assert decision.severity == "CRITICAL" assert decision.recommended_offset == -2.0 @@ -227,10 +181,10 @@ async def test_recovery_exceeding_peak(self, effect_manager): """ # Set up monthly peak timestamp = datetime(2025, 10, 14, 10, 0) - await effect_manager.record_quarter_measurement(5.0, 40, timestamp) + await effect_manager.record_period_measurement(5.0, 10 * 4, timestamp) # Current power: 5.5 kW (exceeding peak by 0.5 kW) - decision = effect_manager.should_limit_power(5.5, 50) + decision = effect_manager.should_limit_power(5.5, 12 * 4) assert decision.severity == "CRITICAL" assert decision.recommended_offset == -3.0 # Maximum reduction @@ -244,10 +198,10 @@ async def test_safe_margin_after_recovery(self, effect_manager): """ # Set up monthly peak timestamp = datetime(2025, 10, 14, 10, 0) - await effect_manager.record_quarter_measurement(5.0, 40, timestamp) + await effect_manager.record_period_measurement(5.0, 10 * 4, timestamp) # Current power: 3.0 kW (2.0 kW below peak - safe) - decision = effect_manager.should_limit_power(3.0, 50) + decision = effect_manager.should_limit_power(3.0, 12 * 4) assert decision.severity == "OK" assert decision.should_limit is False @@ -262,11 +216,11 @@ async def test_nighttime_allows_higher_power_after_outage(self, effect_manager): """ # Set up daytime peak timestamp = datetime(2025, 10, 14, 12, 0) - await effect_manager.record_quarter_measurement(5.0, 48, timestamp) # Daytime + await effect_manager.record_period_measurement(5.0, 12 * 4, timestamp) # Daytime # Nighttime: 8.0 kW actual = 4.0 kW effective (1.0 kW margin from peak) - quarter = 94 # 23:30, nighttime - decision = effect_manager.should_limit_power(8.0, quarter) + billing_hour = 23 # 23:30, nighttime + decision = effect_manager.should_limit_power(8.0, billing_hour) # Should be OK since effective power (4.0) < peak (5.0) with >1.0 kW margin assert decision.severity == "OK" @@ -274,26 +228,11 @@ async def test_nighttime_allows_higher_power_after_outage(self, effect_manager): class TestPresetModes: - """Test different preset modes (Comfort, Balanced, Eco, Away). - - Key Question: Test the different modes (auto, eco, and so on) - - Modes: - - COMFORT: Prioritize comfort, minimal temperature deviation, accept higher costs - - BALANCED (NONE): Balance comfort and savings (default) - - ECO: Maximize savings, wider temperature tolerance - - AWAY: Reduce temperature when away - """ + """tolerance_range is tolerance * TOLERANCE_RANGE_MULTIPLIER (0.4) regardless of mode.""" @pytest.mark.asyncio async def test_comfort_mode_tight_tolerance(self, hass_mock): - """Test: COMFORT mode uses tighter temperature tolerance. - - Expected: - - Tolerance setting: 1-3 (tight) - - Less aggressive optimization - - Comfort prioritized over savings - """ + """tolerance_range = tolerance * 0.4: a tolerance of 2.0 gives a 0.8 C band.""" # Comfort mode configuration config = { "target_temperature": 21.0, @@ -319,13 +258,7 @@ async def test_comfort_mode_tight_tolerance(self, hass_mock): @pytest.mark.asyncio async def test_balanced_mode_moderate_tolerance(self, hass_mock): - """Test: BALANCED mode uses moderate tolerance. - - Expected: - - Tolerance setting: 4-6 (moderate) - - Balanced optimization - - Default mode - """ + """A tolerance of 5.0 gives a 2.0 C band (5.0 * 0.4).""" config = { "target_temperature": 21.0, "tolerance": 5.0, # Mid-range @@ -348,13 +281,7 @@ async def test_balanced_mode_moderate_tolerance(self, hass_mock): @pytest.mark.asyncio async def test_eco_mode_wide_tolerance(self, hass_mock): - """Test: ECO mode uses wider tolerance for maximum savings. - - Expected: - - Tolerance setting: 8-10 (wide) - - Aggressive optimization - - Maximum cost savings - """ + """A tolerance of 9.0 gives a 3.6 C band (9.0 * 0.4).""" config = { "target_temperature": 21.0, "tolerance": 9.0, # High tolerance @@ -375,108 +302,6 @@ async def test_eco_mode_wide_tolerance(self, hass_mock): assert engine.tolerance == 9.0 assert engine.tolerance_range == 3.6 # 9.0 * 0.4 - @pytest.mark.asyncio - async def test_tolerance_affects_price_optimization(self, hass_mock): - """Test: Higher tolerance = more aggressive price optimization. - - Expected: - - Comfort (tolerance 2): Less aggressive offsets - - Eco (tolerance 9): More aggressive offsets - - Tolerance factor scales price layer recommendations - """ - # Create two engines with different tolerances - price_analyzer = PriceAnalyzer() - effect_manager = EffectManager(hass_mock) - thermal_model = ThermalModel(thermal_mass=1.0, insulation_quality=1.0) - - # Comfort mode - engine_comfort = DecisionEngine( - price_analyzer=price_analyzer, - effect_manager=effect_manager, - thermal_model=thermal_model, - config={"target_temperature": 21.0, "tolerance": 2.0}, - ) - - # Eco mode - engine_eco = DecisionEngine( - price_analyzer=price_analyzer, - effect_manager=effect_manager, - thermal_model=thermal_model, - config={"target_temperature": 21.0, "tolerance": 9.0}, - ) - - # Tolerance factor = tolerance / 5.0 - # Comfort: 2.0 / 5.0 = 0.4 (less aggressive) - # Eco: 9.0 / 5.0 = 1.8 (more aggressive) - - comfort_factor = engine_comfort.tolerance / 5.0 - eco_factor = engine_eco.tolerance / 5.0 - - assert comfort_factor < 1.0 # Less than base - assert eco_factor > 1.0 # More than base - assert eco_factor > comfort_factor * 3 # Significantly more aggressive - - -class TestQuarterMeasurementTiming: - """Test 15-minute quarter measurement timing and alignment.""" - - def test_quarter_calculation_is_correct(self): - """Test: Quarter of day calculation matches Effektavgift windows. - - Expected: - - 96 quarters per day (24 hours × 4) - - Quarter 0 = 00:00-00:15 - - Quarter 48 = 12:00-12:15 - - Quarter 95 = 23:45-00:00 - """ - # Test specific times - test_cases = [ - (0, 0, 0), # 00:00 = Q0 - (6, 0, 24), # 06:00 = Q24 (day start) - (12, 0, 48), # 12:00 = Q48 - (12, 15, 49), # 12:15 = Q49 - (22, 0, 88), # 22:00 = Q88 (night start) - (23, 45, 95), # 23:45 = Q95 (last quarter) - ] - - for hour, minute, expected_quarter in test_cases: - quarter = (hour * 4) + (minute // 15) - assert quarter == expected_quarter, f"{hour}:{minute:02d} should be Q{expected_quarter}" - - def test_quarters_per_day(self): - """Test: Verify 96 quarters per day.""" - quarters_per_day = 96 - hours_per_day = 24 - quarters_per_hour = 4 - - assert quarters_per_day == hours_per_day * quarters_per_hour - - @pytest.mark.asyncio - async def test_multiple_measurements_same_quarter_handled(self, effect_manager): - """Test: Multiple measurements in same quarter don't cause issues. - - Expected: - - Each measurement evaluated independently - - Only top 3 effective powers stored - - Same quarter can be measured multiple times (coordinator updates) - """ - timestamp_base = datetime(2025, 10, 14, 12, 0) - quarter = 48 # 12:00-12:15 - - # Simulate 3 coordinator updates within same quarter - # (5-minute updates = 3 updates per 15-min quarter) - peak_1 = await effect_manager.record_quarter_measurement(4.0, quarter, timestamp_base) - peak_2 = await effect_manager.record_quarter_measurement( - 4.5, quarter, timestamp_base + timedelta(minutes=5) - ) - peak_3 = await effect_manager.record_quarter_measurement( - 4.2, quarter, timestamp_base + timedelta(minutes=10) - ) - - # All measurements processed - # Top 3 system works correctly - assert len(effect_manager._monthly_peaks) <= 3 - class TestSystemRobustness: """Test system robustness and edge cases.""" @@ -492,7 +317,7 @@ async def test_no_peaks_after_month_change(self, effect_manager): """ # Add peaks from previous month old_timestamp = datetime(2025, 9, 15, 12, 0) # September - await effect_manager.record_quarter_measurement(5.0, 48, old_timestamp) + await effect_manager.record_period_measurement(5.0, 12 * 4, old_timestamp) # Simulate month cleanup effect_manager._clean_old_peaks() @@ -522,7 +347,7 @@ async def test_persistent_storage_survives_restart(self, hass_mock): # Simulate saving peaks timestamp = datetime(2025, 10, 14, 12, 0) - await manager.record_quarter_measurement(5.0, 48, timestamp) + await manager.record_period_measurement(5.0, 12 * 4, timestamp) stored_data = {"peaks": [p.to_dict() for p in manager._monthly_peaks]} @@ -530,51 +355,3 @@ async def test_persistent_storage_survives_restart(self, hass_mock): assert "peaks" in stored_data assert len(stored_data["peaks"]) == 1 assert stored_data["peaks"][0]["effective_power"] == 5.0 - - -# Summary of test coverage -""" -Test Coverage Summary for Critical Scenarios: - -✅ Peak Calculation Frequency (4 tests) - Q: How often do we calculate peaks? Is 15 min too short? - A: - Coordinator updates: 5 minutes (safe, prevents cycling) - - Peak recordings: 15 minutes (quarterly periods) - - Offset changes: Rate limited, gradual (max 3°C per update) - - Result: Safe for compressor, responsive to Effektavgift - -✅ Power Outage Recovery (5 tests) - Q: How close to peak can we be after outage without hitting limits? - A: - Warning margin: 1.0 kW (offset -1.0°C) - - Critical margin: 0.5 kW (offset -2.0°C) - - Exceeding peak: Immediate protection (offset -3.0°C) - - Storage persists: Peaks survive restart - - Nighttime flexibility: 50% weighting allows recovery - -✅ Preset Modes (4 tests) - Q: Test different modes (comfort, auto, eco) - A: - COMFORT: Tolerance 1-3, tight (±0.4-1.2°C) - - BALANCED: Tolerance 4-6, moderate (±1.6-2.4°C) - - ECO: Tolerance 8-10, wide (±3.2-4.0°C) - - Tolerance affects price layer aggression (0.4x to 1.8x) - -✅ Quarter Measurement Timing (3 tests) - - Correct quarter calculation (0-95) - - Multiple measurements per quarter handled - - Aligned with Effektavgift billing - -✅ System Robustness (2 tests) - - Month boundary cleanup - - Persistent storage - -Total: 18 critical scenario tests - -Key Findings: -1. ✅ 5-min updates SAFE - Prevents cycling while responsive -2. ✅ 15-min quarters CORRECT - Matches Swedish Effektavgift exactly -3. ✅ Recovery PROTECTED - 0.5/1.0 kW margins provide safety -4. ✅ Modes DIFFERENTIATED - Clear comfort vs savings trade-off -5. ✅ Storage RELIABLE - Survives outages and restarts - -System is well-designed for real-world operation! -""" diff --git a/tests/unit/optimization/test_decision_engine_peak_protection.py b/tests/unit/optimization/test_decision_engine_peak_protection.py index 9bb27802..848b3a68 100644 --- a/tests/unit/optimization/test_decision_engine_peak_protection.py +++ b/tests/unit/optimization/test_decision_engine_peak_protection.py @@ -30,6 +30,9 @@ def mock_nibe_state(): state.outdoor_temp = 5.0 state.indoor_temp = 21.0 state.supply_temp = 35.0 + # Real NibeState exposes flow_temp as a @property aliasing supply_temp; MagicMock does not + # emulate properties, so mirror it here - the weather-compensation layer reads flow_temp. + state.flow_temp = 35.0 state.degree_minutes = -100.0 state.current_offset = 0.0 state.is_heating = True @@ -45,13 +48,13 @@ def mock_price_data(): price_data.tomorrow = [] for i in range(96): quarter = MagicMock() - quarter.quarter_of_day = i + quarter.period_of_day = i quarter.price = 1.0 quarter.is_daytime = 24 <= i <= 87 price_data.today.append(quarter) # Also populate tomorrow with same data quarter_tomorrow = MagicMock() - quarter_tomorrow.quarter_of_day = i + quarter_tomorrow.period_of_day = i quarter_tomorrow.price = 1.0 quarter_tomorrow.is_daytime = 24 <= i <= 87 price_data.tomorrow.append(quarter_tomorrow) @@ -79,9 +82,11 @@ async def decision_engine(hass_mock, mock_price_data): effect_manager = EffectManager(hass_mock) thermal_model = ThermalModel(thermal_mass=1.0, insulation_quality=1.0) + # The keys the engine actually reads. An old fixture set "target_temperature" and + # "tolerance" 5.0, neither of which the engine reads, so it silently ran on defaults. config = { - "target_temperature": 21.0, - "tolerance": 5.0, # Mid-range + "target_indoor_temp": 21.0, + "tolerance": 0.5, } engine = DecisionEngine( @@ -146,10 +151,10 @@ async def test_effect_layer_no_peaks( async def test_effect_layer_critical_peak( self, decision_engine, mock_nibe_state, mock_price_data, mock_weather_data ): - """Test effect layer responds to critical peak risk.""" + """Peak layer stays silent (weight 0.0) when power is comfortably under the monthly peak.""" # Set up peak in effect manager timestamp = datetime(2025, 10, 14, 12, 0) - await decision_engine.effect.record_quarter_measurement(3.0, 48, timestamp) + await decision_engine.effect.record_period_measurement(3.0, 12 * 4, timestamp) # Mock high current power to exceed peak mock_nibe_state.is_heating = True @@ -166,9 +171,13 @@ async def test_effect_layer_critical_peak( 3 ] # Effect is layer 3 (after safety, emergency, thermal debt) - # Should have Peak layer with appropriate weight assert effect_layer.name == "Peak" - assert effect_layer.weight >= 0.0 # Has some weight + # A weight >= 0.0 cannot fail. With power below the recorded peak and a healthy + # margin, the correct behavior is a QUIET layer - pin that instead. + assert effect_layer.weight == 0.0, ( + f"Peak layer voted weight {effect_layer.weight} with power comfortably under " + f"the monthly peak - peak protection should be silent here." + ) class TestLayerPriority: @@ -184,7 +193,7 @@ async def test_safety_overrides_peak_protection( # Set up peak to trigger protection timestamp = datetime(2025, 10, 14, 12, 0) - await decision_engine.effect.record_quarter_measurement(3.0, 48, timestamp) + await decision_engine.effect.record_period_measurement(3.0, 12 * 4, timestamp) decision = decision_engine.calculate_decision( nibe_state=mock_nibe_state, @@ -204,17 +213,9 @@ async def test_safety_overrides_peak_protection( async def test_emergency_overrides_peak_protection( self, decision_engine, mock_nibe_state, mock_price_data, mock_weather_data ): - """Test emergency layer behavior during CRITICAL degree minutes. - - Nov 30, 2025: Updated to reflect smart recovery behavior. - When indoor temp is at target and prices aren't cheap, the system correctly - ignores DM recovery (Smart Recovery feature). This is the correct behavior - because: - 1. Indoor temp is comfortable (21.0°C = target) - 2. DM recovery during non-cheap periods wastes money - 3. The system will recover DM when prices become cheap - - If we need emergency to trigger, we must set indoor temp BELOW target. + """With indoor temp BELOW target and CRITICAL degree minutes, emergency recovery must + trigger. (At target with non-cheap prices, Smart Recovery deliberately ignores the DM, so + the emergency case requires the temperature to be below target.) """ # Set critical degree minutes close to absolute max mock_nibe_state.degree_minutes = -1300.0 # Close to DM_THRESHOLD_AUX_LIMIT (-1500) @@ -224,7 +225,7 @@ async def test_emergency_overrides_peak_protection( # Set up CRITICAL monthly peak to trigger protection timestamp = datetime(2025, 10, 14, 12, 0) - await decision_engine.effect.record_quarter_measurement(3.0, 48, timestamp) + await decision_engine.effect.record_period_measurement(3.0, 12 * 4, timestamp) decision = decision_engine.calculate_decision( nibe_state=mock_nibe_state, @@ -253,9 +254,9 @@ async def test_daytime_peak_avoidance( """Test peak avoidance during expensive daytime period.""" # Set up monthly peaks timestamp = datetime(2025, 10, 14, 8, 0) # Morning - await decision_engine.effect.record_quarter_measurement(5.0, 32, timestamp) - await decision_engine.effect.record_quarter_measurement(5.2, 33, timestamp) - await decision_engine.effect.record_quarter_measurement(5.5, 34, timestamp) + await decision_engine.effect.record_period_measurement(5.0, 8 * 4, timestamp) + await decision_engine.effect.record_period_measurement(5.2, 8 * 4, timestamp) + await decision_engine.effect.record_period_measurement(5.5, 8 * 4, timestamp) # Simulate approaching peak during daytime mock_nibe_state.timestamp = datetime(2025, 10, 14, 12, 0) @@ -279,7 +280,7 @@ async def test_nighttime_peak_weighting( """Test nighttime peak with 50% weighting.""" # Set up daytime peaks timestamp = datetime(2025, 10, 14, 12, 0) - await decision_engine.effect.record_quarter_measurement(5.0, 48, timestamp) + await decision_engine.effect.record_period_measurement(5.0, 12 * 4, timestamp) # Simulate nighttime - can use more power due to 50% weight mock_nibe_state.timestamp = datetime(2025, 10, 14, 23, 0) # 23:00 @@ -326,7 +327,7 @@ async def test_aggregates_peak_and_price_layers( async def test_critical_layer_dominates( self, decision_engine, mock_nibe_state, mock_price_data, mock_weather_data ): - """Test critical layer (weight=0.99) has very high priority in aggregation.""" + """Test critical emergency layer (weight DM_CRITICAL_T3_WEIGHT) dominates aggregation.""" # Set critical degree minutes close to absolute max mock_nibe_state.degree_minutes = -1300.0 # Close to DM_THRESHOLD_AUX_LIMIT (-1500) mock_nibe_state.outdoor_temp = 5.0 @@ -365,15 +366,21 @@ async def test_smart_debt_recovery_ignores_dm_at_target( current_power=2.0, ) - # Should NOT force heating - # Emergency layer returns 0.0, Price returns 0.0 - assert decision.offset == 0.0 - - # Check the layer - when at target with normal prices, emergency should not force heating + # The DEBT layer must not force heating: at target, with normal prices, a DM of -1300 is + # ignored - "At target & price not cheap - ignoring DM -1300". emergency_layer = decision.layers[1] assert emergency_layer.name in ("Thermal Debt", "T1", "T2", "T3") - # When smart recovery is active, weight should be 0 (ignoring DM) assert emergency_layer.weight == 0.0 + assert emergency_layer.offset == 0.0 + + # Assert the intent rather than `decision.offset == 0.0` (which passed only because Math WC + # was mute). The debt layer is silent, which is what this test is for; the weather- + # compensation curve is the only thing entitled to move the offset here. + voting = [layer.name for layer in decision.layers if layer.weight > 0.0] + assert voting == ["Math WC"], ( + f"layers {voting} voted. At target with normal prices, the only thing entitled to move " + f"the offset is the weather-compensation curve. Anything else is the DM forcing heat." + ) class TestReasoningGeneration: 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_emergency_layer_evaluate.py b/tests/unit/optimization/test_emergency_layer_evaluate.py index ac0c4f26..52e4313d 100644 --- a/tests/unit/optimization/test_emergency_layer_evaluate.py +++ b/tests/unit/optimization/test_emergency_layer_evaluate.py @@ -252,23 +252,12 @@ def test_ok_tier_for_normal_dm(self): class TestEmergencyLayerThermalMass: """Test thermal mass adjusted thresholds.""" - def test_concrete_slab_tighter_thresholds(self): - """Test that concrete slab has tighter thresholds. + def test_concrete_slab_responds_before_a_radiator_system(self): + """A concrete slab must respond to thermal debt SOONER than a radiator system. - At 0°C in Stockholm: - - Radiator warning: -540 (no adjustment) - - Concrete warning: -702 (1.3× adjustment) - - Using DM -600 should be WARNING for radiator but within normal for concrete - (because concrete thresholds are multiplied, making them MORE negative). - - Wait, that's backwards - concrete has TIGHTER thresholds meaning it triggers - WARNING at LESS negative values. Let me check: - - Base warning: -540 - - Concrete: -540 × 1.3 = -702 (MORE negative = later warning) - - Actually the multiplier makes it MORE negative (later warning), not tighter. - Let me test at -580 which should be WARNING for radiator but OK for concrete. + At 0 C in Stockholm the radiator warns at -540 (buffer 1.0) and concrete at -540/1.3 = -415. + DM -450 is past concrete's threshold but not a radiator's: the slab is already recovering + while the radiator system has no need to act yet. """ layer_radiator = EmergencyLayer( climate_detector=ClimateZoneDetector(latitude=59.33), @@ -281,11 +270,11 @@ def test_concrete_slab_tighter_thresholds(self): heating_type="concrete_ufh", ) - # DM -580 at 0°C: - # - Radiator warning at -540, so -580 is beyond warning → triggers response - # - Concrete warning at -702, so -580 is within normal → no response + # DM -450 at 0 C: + # - Concrete warns at -415, so -450 is past it -> the slab starts recovering + # - Radiator warns at -540, so -450 is still within normal -> no need to act yet nibe_state = MockNibeState( - degree_minutes=-580, + degree_minutes=-450, outdoor_temp=0.0, indoor_temp=20.0, ) @@ -306,11 +295,15 @@ def test_concrete_slab_tighter_thresholds(self): tolerance_range=0.5, ) - # Radiator should have higher tier or more aggressive response - # Concrete should still be OK or CAUTION - assert result_radiator.tier in ("WARNING", "CAUTION", "T1", "T2", "T3") - # Concrete's adjusted threshold is -702, so -580 should be within normal - assert result_concrete.tier in ("OK", "CAUTION") + # The slab is already recovering; the radiator system has not needed to start. + assert result_concrete.tier in ("WARNING", "CAUTION", "T1", "T2", "T3"), ( + f"Concrete warns at -415 and DM is -450: the slab must be responding, not idle. " + f"Got tier {result_concrete.tier}." + ) + assert result_radiator.tier in ("OK", "CAUTION"), ( + f"A radiator system warns at -540 and DM is only -450: it has no need to act yet. " + f"Got tier {result_radiator.tier}." + ) class TestEmergencyLayerShouldBlockDhw: 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..8828a480 --- /dev/null +++ b/tests/unit/optimization/test_one_definition_of_the_billed_quantity.py @@ -0,0 +1,190 @@ +"""One definition of the billed quantity: the time-weighted mean power over a billing period. + +The owner's effect tariff measures a 15-minute period (BILLING_PERIOD_MINUTES; operator models vary +- F-107 - and this pins the OWNER'S). 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 period counted on the absolute time line, so the repeated DST fall-back quarters are + separate periods; + * the local period label and local start stamp, because the night discount and the calendar month + a peak belongs to are both wall-clock facts; + * a period 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_period_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, 15, 5): + completed = ( + accumulator.add(_local(2026, 1, 15, 10, minute), 6.0, POWER_SOURCE_EXTERNAL_METER) + or completed + ) + # The first sample of the NEXT period is what closes this one. + completed = ( + accumulator.add(_local(2026, 1, 15, 10, 15), 6.0, POWER_SOURCE_EXTERNAL_METER) or completed + ) + + assert completed is not None, "a whole period went by and no billing period completed" + assert completed.mean_power_kw == pytest.approx(6.0) + assert completed.billing_period == 10 * 4 # 10:00-10:15 is quarter 40 + 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, then 9 kW at :10 and :12 + time-weighted (what the grid bills): (1*10 + 9*5) / 15 = 3.67 kW + arithmetic mean of the samples: (1+9+9) / 3 = 6.33 kW (73% high) + + Home Assistant's update cycle jitters, so the samples in a period are not evenly spaced. The + gaps here stay within MAX_BILLING_OBSERVATION_GAP_MINUTES, so the period is observed and billed. + """ + accumulator = BillingPeriodAccumulator() + + for minute, power in ((0, 1.0), (10, 9.0), (12, 9.0)): + accumulator.add(_local(2026, 1, 15, 10, minute), power, POWER_SOURCE_EXTERNAL_METER) + completed = accumulator.add(_local(2026, 1, 15, 10, 15), 1.0, POWER_SOURCE_EXTERNAL_METER) + + assert completed is not None + assert completed.mean_power_kw == pytest.approx((1.0 * 10 + 9.0 * 5) / 15), ( + f"the period was billed at {completed.mean_power_kw:.2f} kW. 1 kW stood for ten minutes and " + f"9 kW for five; the grid bills the time-weighted mean, 3.67 kW. Counting samples instead " + f"gives 6.33 kW - 73% high, persisted as the month's peak." + ) + + +def test_the_period_is_counted_on_the_absolute_time_line(): + """The DST fall-back: wall-clock 02:00-03:00 happens twice, and all eight quarters 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 the repeated quarters and deleted a peak. + """ + accumulator = BillingPeriodAccumulator() + completed = [] + + # Step REAL time across the transition; the tz database does the rest. + # 02:00 CEST through 03:00 CET: two real wall-clock 02:xx hours, then one closing sample. + start = datetime(2026, 10, 25, 0, 0, tzinfo=UTC) # 02:00 CEST + for step in range(0, 125, 5): + instant = (start + timedelta(minutes=step)).astimezone(STOCKHOLM) + power = 9.0 if step < 60 else 1.0 # 9 kW through the FIRST 02:xx hour, 1 kW the second + event = accumulator.add(instant, power, POWER_SOURCE_EXTERNAL_METER) + if event is not None: + completed.append(event) + + labels = [event.billing_period for event in completed] + means = [round(event.mean_power_kw, 2) for event in completed] + + # Quarters 8..11 are 02:00-03:00. Both wall-clock passes must complete, separately. + assert labels == [ + 8, + 9, + 10, + 11, + 8, + 9, + 10, + 11, + ], f"the repeated 02:xx hour must yield its four quarters TWICE. Got periods {labels}." + assert means == [9.0, 9.0, 9.0, 9.0, 1.0, 1.0, 1.0, 1.0], ( + f"the two passes billed {means}. They are an hour apart and both real. Merging them deletes " + f"the 9 kW peaks - 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 first billing period of 1 November IS 23:00-23:15 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, 20, 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 period was stamped {completed.started_at.isoformat()} - month " + f"{completed.started_at.month}. It is the first period of November." + ) + assert completed.billing_period == 0 + + +def test_a_period_that_began_before_observation_is_not_billed(): + """Home Assistant starts mid-period. That period was never fully measured, so it is not a bill.""" + accumulator = BillingPeriodAccumulator() + + accumulator.add( + _local(2026, 1, 15, 10, 8), 5.0, POWER_SOURCE_EXTERNAL_METER + ) # first ever sample: mid-period + accumulator.add(_local(2026, 1, 15, 10, 13), 5.0, POWER_SOURCE_EXTERNAL_METER) + completed = accumulator.add(_local(2026, 1, 15, 10, 15), 5.0, POWER_SOURCE_EXTERNAL_METER) + + assert completed is None, ( + f"the 10:00 period was billed at {completed.mean_power_kw if completed else None} kW, but " + f"it was only observed from 10:08. A partial period is not a measurement of a period." + ) + + # ...and the NEXT, fully-observed period is billed normally. + for minute in (20, 25): + accumulator.add(_local(2026, 1, 15, 10, minute), 5.0, POWER_SOURCE_EXTERNAL_METER) + completed = accumulator.add(_local(2026, 1, 15, 10, 30), 5.0, POWER_SOURCE_EXTERNAL_METER) + + assert completed is not None and completed.mean_power_kw == pytest.approx(5.0) + assert completed.billing_period == 10 * 4 + 1 # 10:15-10:30 + + +def test_flush_closes_the_period_in_progress(): + """The simulator's run ends. The period it ends on is complete in sim-time and must be billed. + + Production never calls this - Home Assistant keeps running, and a period cut short by a shutdown + is not a bill. It exists so the harness does not silently drop its final period. + """ + accumulator = BillingPeriodAccumulator() + for minute in range(0, 15, 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_period == 10 * 4 + assert accumulator.flush() is None, "flushing twice must not bill the same period twice" + + +def test_the_billing_period_is_the_one_the_owners_tariff_uses(): + """The accumulator must not carry its own private idea of how long a period is. + + 15 minutes is the OWNER'S tariff cadence (operator models vary - F-107). Changing this constant + changes what every monthly peak means, so it is changed deliberately or not at all. + """ + assert BILLING_PERIOD_MINUTES == 15 diff --git a/tests/unit/optimization/test_overshoot_protection.py b/tests/unit/optimization/test_overshoot_protection.py index 13a8cdaf..bf390c6c 100644 --- a/tests/unit/optimization/test_overshoot_protection.py +++ b/tests/unit/optimization/test_overshoot_protection.py @@ -1,269 +1,124 @@ -"""Tests for overshoot protection in the decision engine. +"""Overshoot protection: the ComfortLayer coasts a warm house, never a cold one. -Verifies graduated coast response when indoor temp is above target. -Based on Dec 1-2, 2025 production analysis: overshoot was ignored, causing DM spiral. -Key insight: DM recovers when we STOP heating, not by boosting. +Drives the real ComfortLayer.evaluate_layer. The graduated response ramps the offset from +OVERSHOOT_PROTECTION_OFFSET_MIN (-7C) at the start of the band to OVERSHOOT_PROTECTION_OFFSET_MAX +(-10C) at full overshoot, and the weight from LAYER_WEIGHT_COMFORT_HIGH (0.7) to +LAYER_WEIGHT_COMFORT_CRITICAL (1.0). """ +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 ( - OVERSHOOT_PROTECTION_COLD_SNAP_THRESHOLD, - OVERSHOOT_PROTECTION_FORECAST_HORIZON, + LAYER_WEIGHT_COMFORT_CRITICAL, + LAYER_WEIGHT_COMFORT_HIGH, OVERSHOOT_PROTECTION_FULL, OVERSHOOT_PROTECTION_OFFSET_MAX, OVERSHOOT_PROTECTION_OFFSET_MIN, OVERSHOOT_PROTECTION_START, - OVERSHOOT_PROTECTION_WEIGHT_MAX, - OVERSHOOT_PROTECTION_WEIGHT_MIN, ) +from custom_components.effektguard.optimization.comfort_layer import ComfortLayer + +TARGET = 21.0 + + +def _house_at(indoor: float) -> NibeState: + return NibeState( + outdoor_temp=0.0, + indoor_temp=indoor, + supply_temp=40.0, + return_temp=35.0, + degree_minutes=-50.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=True, + ) -class TestOvershootProtectionConstants: - """Test that overshoot protection constants have correct values.""" +def _decide(overshoot: float): + """Drive the REAL layer with the house `overshoot` degrees above target.""" + return ComfortLayer(target_temp=TARGET, tolerance_range=0.5).evaluate_layer( + _house_at(TARGET + overshoot) + ) - def test_start_threshold_is_low_enough(self): - """Start threshold should catch 0.6°C overshoot from Dec 2 crisis.""" - assert OVERSHOOT_PROTECTION_START == 0.6 - def test_full_threshold_is_reasonable(self): - """Full response at 1.5°C overshoot.""" - assert OVERSHOOT_PROTECTION_FULL == 1.5 +class TestTheBandItselfIsCoherent: + """The constants production actually reads.""" - def test_offset_range_is_aggressive(self): - """Offset should be strongly negative to force coasting.""" - assert OVERSHOOT_PROTECTION_OFFSET_MIN == -7.0 - assert OVERSHOOT_PROTECTION_OFFSET_MAX == -10.0 + def test_protection_starts_before_it_is_full(self): + assert OVERSHOOT_PROTECTION_START < OVERSHOOT_PROTECTION_FULL - def test_weight_range_is_significant(self): - """Weight should be strong enough to override other layers.""" - assert OVERSHOOT_PROTECTION_WEIGHT_MIN == 0.5 - assert OVERSHOOT_PROTECTION_WEIGHT_MAX == 1.0 + def test_a_full_coast_is_stronger_than_the_start_of_one(self): + assert OVERSHOOT_PROTECTION_OFFSET_MAX < OVERSHOOT_PROTECTION_OFFSET_MIN < 0.0 - def test_cold_snap_threshold_prevents_coast_during_cold_snap(self): - """Cold snap threshold should be reasonable for forecast stability check.""" - assert OVERSHOOT_PROTECTION_COLD_SNAP_THRESHOLD == 3.0 + def test_the_weight_ramp_is_the_one_production_uses(self): + """The weight ramp runs from LAYER_WEIGHT_COMFORT_HIGH (0.7) to CRITICAL (1.0).""" + assert LAYER_WEIGHT_COMFORT_HIGH < LAYER_WEIGHT_COMFORT_CRITICAL - def test_forecast_horizon_is_long_enough(self): - """Forecast horizon should look ahead 12 hours.""" - assert OVERSHOOT_PROTECTION_FORECAST_HORIZON == 12 +class TestTheGraduatedResponse: + """Driving ComfortLayer, not a copy of it.""" -class TestOvershootProtectionGraduatedResponse: - """Test the graduated response calculation logic.""" + def test_at_the_start_of_the_band_the_layer_coasts_gently(self): + decision = _decide(OVERSHOOT_PROTECTION_START) - def calculate_response(self, overshoot: float) -> tuple[float, float]: - """Calculate coast offset and weight for given overshoot. + assert decision.offset == pytest.approx(OVERSHOOT_PROTECTION_OFFSET_MIN, abs=0.01) + assert decision.weight == pytest.approx(LAYER_WEIGHT_COMFORT_HIGH, abs=0.01), ( + f"At the start of the overshoot band the layer voted weight {decision.weight:.2f}; " + f"production ramps from LAYER_WEIGHT_COMFORT_HIGH ({LAYER_WEIGHT_COMFORT_HIGH})." + ) - Mirrors the logic in decision_engine._proactive_debt_prevention_layer(). - """ - if overshoot < OVERSHOOT_PROTECTION_START: - return None, None + def test_at_full_overshoot_the_layer_coasts_completely(self): + decision = _decide(OVERSHOOT_PROTECTION_FULL) - overshoot_range = OVERSHOOT_PROTECTION_FULL - OVERSHOOT_PROTECTION_START - fraction = min((overshoot - OVERSHOOT_PROTECTION_START) / overshoot_range, 1.0) + assert decision.offset == pytest.approx(OVERSHOOT_PROTECTION_OFFSET_MAX, abs=0.01) + assert decision.weight == pytest.approx(LAYER_WEIGHT_COMFORT_CRITICAL, abs=0.01) - coast_offset = OVERSHOOT_PROTECTION_OFFSET_MIN + fraction * ( - OVERSHOOT_PROTECTION_OFFSET_MAX - OVERSHOOT_PROTECTION_OFFSET_MIN - ) + def test_beyond_full_overshoot_the_ramp_is_clamped(self): + """A house four degrees past the band must not vote beyond the register.""" + decision = _decide(OVERSHOOT_PROTECTION_FULL + 4.0) - coast_weight = OVERSHOOT_PROTECTION_WEIGHT_MIN + fraction * ( - OVERSHOOT_PROTECTION_WEIGHT_MAX - OVERSHOOT_PROTECTION_WEIGHT_MIN - ) + assert decision.offset == pytest.approx(OVERSHOOT_PROTECTION_OFFSET_MAX, abs=0.01) + assert decision.weight == pytest.approx(LAYER_WEIGHT_COMFORT_CRITICAL, abs=0.01) - return coast_offset, coast_weight - - def test_below_start_threshold_no_response(self): - """Below 0.6°C overshoot should not trigger protection.""" - offset, weight = self.calculate_response(0.5) - assert offset is None - assert weight is None - - def test_at_start_threshold(self): - """At 0.6°C overshoot should get minimum response.""" - offset, weight = self.calculate_response(OVERSHOOT_PROTECTION_START) - assert offset == OVERSHOOT_PROTECTION_OFFSET_MIN # -7.0 - assert weight == OVERSHOOT_PROTECTION_WEIGHT_MIN # 0.5 - - def test_at_full_threshold(self): - """At 1.5°C overshoot should get maximum response.""" - offset, weight = self.calculate_response(OVERSHOOT_PROTECTION_FULL) - assert offset == OVERSHOOT_PROTECTION_OFFSET_MAX # -10.0 - assert weight == OVERSHOOT_PROTECTION_WEIGHT_MAX # 1.0 - - def test_above_full_threshold_capped(self): - """Above 1.5°C overshoot should cap at maximum response.""" - offset, weight = self.calculate_response(2.0) - assert offset == OVERSHOOT_PROTECTION_OFFSET_MAX # -10.0 - assert weight == OVERSHOOT_PROTECTION_WEIGHT_MAX # 1.0 - - def test_graduated_at_midpoint(self): - """At 1.05°C overshoot (midpoint) should get midpoint response.""" - midpoint = (OVERSHOOT_PROTECTION_START + OVERSHOOT_PROTECTION_FULL) / 2 # 1.05 - offset, weight = self.calculate_response(midpoint) - - expected_offset = (OVERSHOOT_PROTECTION_OFFSET_MIN + OVERSHOOT_PROTECTION_OFFSET_MAX) / 2 - expected_weight = (OVERSHOOT_PROTECTION_WEIGHT_MIN + OVERSHOOT_PROTECTION_WEIGHT_MAX) / 2 - - assert offset == pytest.approx(expected_offset, rel=0.01) # -8.5 - assert weight == pytest.approx(expected_weight, rel=0.01) # 0.75 - - def test_dec2_crisis_case_0_6_overshoot(self): - """Test 0.6°C overshoot case from Dec 2 crisis.""" - offset, weight = self.calculate_response(0.6) - # At start threshold: -7°C offset, 0.5 weight - assert offset == -7.0 - assert weight == 0.5 - - def test_dec2_crisis_case_0_8_overshoot(self): - """Test 0.8°C overshoot case from Dec 2 crisis.""" - offset, weight = self.calculate_response(0.8) - # 0.8 is 0.2 into the 0.9 range, so fraction = 0.222 - # Expected offset: -7 + 0.222 * -3 = -7.67 - # Expected weight: 0.5 + 0.222 * 0.5 = 0.61 - assert offset == pytest.approx(-7.67, rel=0.05) - assert weight == pytest.approx(0.61, rel=0.05) - - def test_dec2_crisis_case_1_0_overshoot(self): - """Test 1.0°C overshoot case from Dec 2 crisis logs.""" - offset, weight = self.calculate_response(1.0) - # 1.0 is 0.4 into the 0.9 range, so fraction = 0.444 - # Expected offset: -7 + 0.444 * -3 = -8.33 - # Expected weight: 0.5 + 0.444 * 0.5 = 0.72 - assert offset == pytest.approx(-8.33, rel=0.05) - assert weight == pytest.approx(0.72, rel=0.05) - - def test_dec2_crisis_case_1_3_overshoot(self): - """Test 1.3°C overshoot case from Dec 2 crisis logs (11:32 scenario).""" - offset, weight = self.calculate_response(1.3) - # 1.3 is 0.7 into the 0.9 range, so fraction = 0.778 - # Expected offset: -7 + 0.778 * -3 = -9.33 - # Expected weight: 0.5 + 0.778 * 0.5 = 0.89 - assert offset == pytest.approx(-9.33, rel=0.05) - assert weight == pytest.approx(0.89, rel=0.05) - - def test_dec2_crisis_case_1_5_overshoot(self): - """Test 1.5°C overshoot (full override).""" - offset, weight = self.calculate_response(1.5) - assert offset == -10.0 - assert weight == 1.0 - - -class TestOvershootProtectionVsDecisionTable: - """Test validation against expected decision table from implementation plan.""" - - def calculate_response(self, overshoot: float) -> tuple[float, float]: - """Calculate coast offset and weight for given overshoot.""" - if overshoot < OVERSHOOT_PROTECTION_START: - return None, None - - overshoot_range = OVERSHOOT_PROTECTION_FULL - OVERSHOOT_PROTECTION_START - fraction = min((overshoot - OVERSHOOT_PROTECTION_START) / overshoot_range, 1.0) - - coast_offset = OVERSHOOT_PROTECTION_OFFSET_MIN + fraction * ( - OVERSHOOT_PROTECTION_OFFSET_MAX - OVERSHOOT_PROTECTION_OFFSET_MIN - ) + def test_the_response_is_monotonic_across_the_band(self): + """More overshoot must never mean less coasting.""" + steps = [OVERSHOOT_PROTECTION_START + i * 0.1 for i in range(11)] + decisions = [_decide(o) for o in steps] - coast_weight = OVERSHOOT_PROTECTION_WEIGHT_MIN + fraction * ( - OVERSHOOT_PROTECTION_WEIGHT_MAX - OVERSHOOT_PROTECTION_WEIGHT_MIN - ) + offsets = [d.offset for d in decisions] + weights = [d.weight for d in decisions] - return coast_offset, coast_weight - - @pytest.mark.parametrize( - "overshoot,expected_offset,expected_weight", - [ - (0.6, -7.0, 0.50), - (0.8, -7.67, 0.61), - (1.0, -8.33, 0.72), - (1.1, -8.67, 0.78), - (1.3, -9.33, 0.89), - (1.5, -10.0, 1.00), - ], - ) - def test_graduated_response_table(self, overshoot, expected_offset, expected_weight): - """Validate graduated response matches implementation plan table.""" - offset, weight = self.calculate_response(overshoot) - assert offset == pytest.approx(expected_offset, rel=0.05) - assert weight == pytest.approx(expected_weight, rel=0.05) + assert offsets == sorted(offsets, reverse=True), f"offsets not monotonic: {offsets}" + assert weights == sorted(weights), f"weights not monotonic: {weights}" + def test_below_the_band_the_house_is_nudged_not_slammed(self): + decision = _decide(OVERSHOOT_PROTECTION_START - 0.1) -class TestOvershootProtectionScenarios: - """Test real-world scenarios from Dec 2, 2025 logs.""" + assert decision.offset > OVERSHOOT_PROTECTION_OFFSET_MIN, ( + f"A house only {OVERSHOOT_PROTECTION_START - 0.1:.1f} C above target - below the " + f"overshoot band - was given {decision.offset:.2f} C, as hard as the band's own floor." + ) - def calculate_response(self, overshoot: float) -> tuple[float, float]: - """Calculate coast offset and weight for given overshoot.""" - if overshoot < OVERSHOOT_PROTECTION_START: - return None, None - overshoot_range = OVERSHOOT_PROTECTION_FULL - OVERSHOOT_PROTECTION_START - fraction = min((overshoot - OVERSHOOT_PROTECTION_START) / overshoot_range, 1.0) +class TestOvershootProtectionOnlyFiresUpwards: + """The regression guard. It coasts a warm house; it must never coast a cold one.""" - coast_offset = OVERSHOOT_PROTECTION_OFFSET_MIN + fraction * ( - OVERSHOOT_PROTECTION_OFFSET_MAX - OVERSHOOT_PROTECTION_OFFSET_MIN - ) + def test_a_cold_house_is_never_coasted(self): + decision = _decide(-1.0) - coast_weight = OVERSHOOT_PROTECTION_WEIGHT_MIN + fraction * ( - OVERSHOOT_PROTECTION_WEIGHT_MAX - OVERSHOOT_PROTECTION_WEIGHT_MIN - ) + assert ( + decision.offset >= 0.0 + ), f"A house 1.0 C BELOW target was told to coast ({decision.offset:+.2f} C)." + + def test_a_house_on_target_is_left_alone(self): + decision = _decide(0.0) - return coast_offset, coast_weight - - def test_scenario_1132_dec2_smoking_gun(self): - """Test 11:32 Dec 2 - the smoking gun scenario. - - Indoor: 22.3°C (target: 21.0°C) → 1.3°C ABOVE target - OLD Decision: offset +1.70°C (WRONG - was heating during overshoot!) - NEW Decision: should be strongly negative offset - """ - overshoot = 1.3 - offset, weight = self.calculate_response(overshoot) - - # NEW behavior should force strong coast - assert offset < -9.0 # At least -9°C offset - assert weight > 0.8 # Strong weight to override other layers - - # This is the fix: instead of +1.70°C, we get ~-9.3°C - old_decision = +1.70 - improvement = old_decision - offset # How much better is new decision - assert improvement > 10.0 # At least 10°C improvement - - def test_scenario_dm_recovery_principle(self): - """Test that overshoot protection enables DM recovery. - - Key insight from Dec 2: DM recovers when we STOP heating. - When indoor is above target, we have thermal margin - use it. - """ - # At 1.0°C overshoot with DM -600 - overshoot = 1.0 - offset, weight = self.calculate_response(overshoot) - - # Should produce negative offset to stop/reduce heating - assert offset < 0 # Negative offset - assert offset <= -7 # At least -7°C to actually coast - - # Weight should be strong enough to override DM recovery boost - # which was incorrectly adding +2°C during overshoot - assert weight >= 0.7 - - def test_scenario_peak_plus_overshoot(self): - """Test PEAK price + overshoot should strongly reduce heating. - - Even if PEAK alone only produces -0.6°C (due to tolerance scaling), - overshoot protection should still produce strong negative offset. - """ - # 1.3°C overshoot during PEAK - overshoot = 1.3 - offset, weight = self.calculate_response(overshoot) - - # Overshoot protection takes precedence - assert offset < -9 # ~-9.3°C - assert weight > 0.85 # Strong override - - def test_below_threshold_no_coast(self): - """Below 0.6°C overshoot should not trigger coasting.""" - overshoot = 0.5 - offset, weight = self.calculate_response(overshoot) - - assert offset is None # Not triggered - assert weight is None + assert decision.offset == 0.0 + assert decision.weight == 0.0 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..989cd11b --- /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_accumulated_low_draw_dilutes_a_spike(): + acc = BillingPeriodAccumulator() + for minute in (0, 5): + acc.add(_t(minute), 2.0, POWER_SOURCE_EXTERNAL_METER) + + # 9 kW starting at 10:10: the period's mean, if it persists, is (2*10 + 9*5)/15. + projected = acc.projected_period_mean(_t(10), 9.0) + + assert projected == (2.0 * 10 + 9.0 * 5) / 15 + + +def test_an_empty_period_projects_the_draw_itself(): + acc = BillingPeriodAccumulator() + + assert acc.projected_period_mean(_t(0), 9.0) == 9.0 + + +def test_a_spike_in_the_last_five_minutes_only_partly_moves_the_period(): + acc = BillingPeriodAccumulator() + for minute in (0, 5, 10): + acc.add(_t(minute), 1.0, POWER_SOURCE_EXTERNAL_METER) + + projected = acc.projected_period_mean(_t(10), 9.0) + + assert projected == (1.0 * 10 + 9.0 * 5) / 15 + + +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_period_mean" in src, ( + "The decision path no longer projects the billing period. Handing the effect layer an " + "instantaneous reading compares a five-minute spike against a PERIOD-MEAN record - " + "the layer throttles the pump to defend a peak the meter would average away." + ) diff --git a/tests/unit/optimization/test_prediction_layer_evaluate.py b/tests/unit/optimization/test_prediction_layer_evaluate.py index d63e24c0..9c597a00 100644 --- a/tests/unit/optimization/test_prediction_layer_evaluate.py +++ b/tests/unit/optimization/test_prediction_layer_evaluate.py @@ -7,6 +7,11 @@ from unittest.mock import MagicMock from datetime import datetime, timedelta +from custom_components.effektguard.const import ( + PREDICTION_LEARNED_PREHEAT_MIN_HOURS, + SAMPLES_PER_HOUR, + UPDATE_INTERVAL_MINUTES, +) from custom_components.effektguard.optimization.prediction_layer import ( ThermalStatePredictor, PredictionLayerDecision, @@ -48,13 +53,21 @@ def mock_thermal_model(): return model +# A full day of history, in samples, at whatever cadence the coordinator actually runs at. +REQUIRED_SAMPLES = PREDICTION_LEARNED_PREHEAT_MIN_HOURS * SAMPLES_PER_HOUR + + class TestEvaluateLayerInsufficientData: """Test evaluate_layer when insufficient learning data.""" def test_insufficient_data_returns_learning_reason( self, predictor, mock_nibe_state, mock_weather_data, mock_thermal_model ): - """When <96 observations, returns learning status.""" + """Below a full day of history the layer reports progress (N/REQUIRED) and abstains. + + REQUIRED is PREDICTION_LEARNED_PREHEAT_MIN_HOURS * SAMPLES_PER_HOUR, derived from the + constants so the test and the code agree on the coordinator's sample cadence. + """ # Predictor has no history assert len(predictor.state_history) == 0 @@ -69,16 +82,16 @@ def test_insufficient_data_returns_learning_reason( assert result.offset == 0.0 assert result.weight == 0.0 assert "Learning:" in result.reason - assert "0/96" in result.reason + assert f"0/{REQUIRED_SAMPLES}" in result.reason def test_partial_data_shows_progress( self, predictor, mock_nibe_state, mock_weather_data, mock_thermal_model ): """When partial observations, shows learning progress.""" - # Add 48 observations (half of required 96) - for i in range(48): + half = REQUIRED_SAMPLES // 2 + for i in range(half): predictor.record_state( - timestamp=datetime.now() - timedelta(minutes=15 * i), + timestamp=datetime.now() - timedelta(minutes=UPDATE_INTERVAL_MINUTES * i), indoor_temp=21.0, outdoor_temp=0.0, heating_offset=0.0, @@ -95,7 +108,7 @@ def test_partial_data_shows_progress( assert result.offset == 0.0 assert result.weight == 0.0 - assert "48/96" in result.reason + assert f"{half}/{REQUIRED_SAMPLES}" in result.reason class TestEvaluateLayerNoWeatherData: @@ -104,9 +117,9 @@ class TestEvaluateLayerNoWeatherData: def test_no_weather_data_returns_zero(self, predictor, mock_nibe_state, mock_thermal_model): """When weather_data is None, returns no pre-heat.""" # Add enough observations - for i in range(100): + for i in range(REQUIRED_SAMPLES + 4): predictor.record_state( - timestamp=datetime.now() - timedelta(minutes=15 * i), + timestamp=datetime.now() - timedelta(minutes=UPDATE_INTERVAL_MINUTES * i), indoor_temp=21.0, outdoor_temp=0.0, heating_offset=0.0, @@ -129,9 +142,9 @@ def test_no_weather_data_returns_zero(self, predictor, mock_nibe_state, mock_the def test_empty_forecast_returns_zero(self, predictor, mock_nibe_state, mock_thermal_model): """When forecast_hours is empty, returns no pre-heat.""" # Add enough observations - for i in range(100): + for i in range(REQUIRED_SAMPLES + 4): predictor.record_state( - timestamp=datetime.now() - timedelta(minutes=15 * i), + timestamp=datetime.now() - timedelta(minutes=UPDATE_INTERVAL_MINUTES * i), indoor_temp=21.0, outdoor_temp=0.0, heating_offset=0.0, @@ -162,9 +175,9 @@ def test_returns_prediction_layer_decision( ): """evaluate_layer returns PredictionLayerDecision.""" # Add enough observations - for i in range(100): + for i in range(REQUIRED_SAMPLES + 4): predictor.record_state( - timestamp=datetime.now() - timedelta(minutes=15 * i), + timestamp=datetime.now() - timedelta(minutes=UPDATE_INTERVAL_MINUTES * i), indoor_temp=21.0, outdoor_temp=0.0, heating_offset=0.0, @@ -190,9 +203,9 @@ def test_decision_has_correct_name( ): """Decision name is always 'Learned Pre-heat'.""" # Add enough observations - for i in range(100): + for i in range(REQUIRED_SAMPLES + 4): predictor.record_state( - timestamp=datetime.now() - timedelta(minutes=15 * i), + timestamp=datetime.now() - timedelta(minutes=UPDATE_INTERVAL_MINUTES * i), indoor_temp=21.0, outdoor_temp=0.0, heating_offset=0.0, 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_real_world_scenario.py b/tests/unit/optimization/test_real_world_scenario.py index 1042a3ed..70092047 100644 --- a/tests/unit/optimization/test_real_world_scenario.py +++ b/tests/unit/optimization/test_real_world_scenario.py @@ -1,17 +1,8 @@ -"""Test real-world multi-layer optimization scenario. - -This test validates the exact scenario documented in REAL_WORLD_EXAMPLE_ALL_FACTORS.md: -- Time: 08:00 (Q32) -- Spot price: 1.90 SEK/kWh (EXPENSIVE) -- Outdoor: -5°C -- Indoor: 20.8°C -- DM: -180 -- All 8 layers voting and aggregating - -Expected result: -1.5°C offset from weighted aggregation of: -- Weather Compensation: -2.0°C (weight 0.8) -- Spot Price: -1.5°C (weight 0.6) -- Comfort: +0.1°C (weight 0.3) +"""Spot-price layer guards across a full day of real prices. + +Drives the engine at daytime-expensive, nighttime-cheap, and evening-peak quarters and pins the +resulting price-layer offset and weight, including the daytime multiplier, the cheap-period +pre-heat, and how the user's tolerance setting scales the reduction. """ import pytest @@ -157,208 +148,7 @@ async def decision_engine(hass_mock, expensive_price_data): class TestRealWorldScenario: - """Test complete real-world optimization scenario.""" - - @pytest.mark.asyncio - @freeze_time("2025-01-16 08:00:00") - async def test_08_00_expensive_morning_optimization( - self, - decision_engine, - real_world_nibe_state, - expensive_price_data, - winter_weather_data, - ): - """Test 08:00 expensive morning period with all layers active. - - Expected behavior: - - Layer 1 (Safety): 0.0°C (temp OK) - - Layer 2 (Emergency): 0.0°C (DM -180 acceptable) - - Layer 3 (Proactive Debt Prevention): +0.5°C (DM -180 approaching -240 threshold) - - Layer 4 (Effect Tariff): 0.0°C (no peak risk) - - Layer 5 (Prediction): 0.0°C (optional, not configured) - - Layer 6 (Weather Comp): Variable (based on current vs optimal) - - Layer 7 (Weather Pred): +3.0°C (5°C drop triggers preheating) - - Layer 8 (Spot Price): -1.5°C (EXPENSIVE period, daytime multiplier) - - Layer 9 (Comfort): 0.0°C (temp at target) - - Final offset will be POSITIVE (weather preheating overrides price savings) - Safety > cost savings: thermal protection during cold spell - """ - # Mock dt_util.now() to return our test timestamp (08:00) - test_time = datetime(2025, 1, 16, 8, 0, tzinfo=timezone.utc) - - with patch( - "custom_components.effektguard.optimization.decision_engine.dt_util.now", - return_value=test_time, - ): - decision = decision_engine.calculate_decision( - nibe_state=real_world_nibe_state, - price_data=expensive_price_data, - weather_data=winter_weather_data, - current_peak=2.8, # Safe margin from monthly peak 5.2 kW - current_power=1.5, - ) - - # Debug: Check what quarter we're actually in - calc_quarter = (test_time.hour * 4) + (test_time.minute // 15) - print(f"\n=== Debug Info ===") - print(f"Test timestamp: {test_time}") - print(f"Calculated quarter: Q{calc_quarter}") - print( - f"Price at Q{calc_quarter}: {expensive_price_data.today[calc_quarter].price:.2f} SEK" - ) - print(f"Price layer reason: {decision.layers[6].reason}") - print(f"==================\n") - - # Verify decision structure - assert decision is not None - assert hasattr(decision, "offset") - assert hasattr(decision, "reasoning") - assert hasattr(decision, "layers") - - # Verify all 9 layers exist (added proactive thermal debt layer) - assert len(decision.layers) == 9 - - # Layer 1: Safety (should be inactive, temp OK) - safety_layer = decision.layers[0] - assert safety_layer.offset == 0.0 - assert "Safety" in safety_layer.reason or "OK" in safety_layer.reason - - # Layer 2: Emergency (should be inactive, DM OK) - emergency_layer = decision.layers[1] - assert emergency_layer.offset == 0.0 - assert emergency_layer.weight == 0.0 - assert ( - "Emergency" in emergency_layer.reason - or "OK" in emergency_layer.reason - or "-180" in emergency_layer.reason - ) - - # Layer 3: Proactive Debt Prevention (NEW - may be active at DM -180) - proactive_layer = decision.layers[2] - # May vote for gentle heating to prevent debt progression - - # Layer 4: Effect Tariff (should be inactive, safe margin) - effect_layer = decision.layers[3] - assert effect_layer.offset == 0.0 - assert effect_layer.weight == 0.0 - - # Layer 5: Prediction (Phase 6 optional, not configured) - prediction_layer = decision.layers[4] - assert prediction_layer.offset == 0.0 - assert prediction_layer.weight == 0.0 - - # Layer 6: Weather Compensation (deferred when thermal debt exists) - weather_comp_layer = decision.layers[5] - # Note: With DM -180 (light debt), weather compensation defers to recovery layers - # This is correct production behavior: safety > optimization - # Weight will be 0.0 when deferred, or >0 if debt is minimal - assert weather_comp_layer.weight >= 0.0 # May be deferred - # When deferred, reason will mention "debt" or "Deferred" - - # Layer 7: Weather Prediction (may be active with forecast) - weather_pred_layer = decision.layers[6] - # Weather layer can vote for pre-heating - - # Layer 8: Spot Price (SHOULD BE ACTIVE - KEY TEST) - price_layer = decision.layers[7] - assert price_layer.offset < 0.0, "Price layer should reduce during EXPENSIVE period" - # Note: Real-world data may trigger volatile detection (8/9 non-NORMAL in scan window) - # Weight may be reduced based on VOLATILE_WEIGHT_REDUCTION constant - min_expected_weight = LAYER_WEIGHT_PRICE * VOLATILE_WEIGHT_REDUCTION - max_expected_weight = LAYER_WEIGHT_PRICE - assert min_expected_weight <= price_layer.weight <= max_expected_weight, ( - f"Price layer weight should be between {min_expected_weight} (volatile) and " - f"{max_expected_weight} (normal), got {price_layer.weight}" - ) - assert ( - "EXPENSIVE" in price_layer.reason - or "PEAK" in price_layer.reason - or "Q32" in price_layer.reason - ) - - # Calculate expected price offset - # With the new price data: - # Q32 = 2.40 SEK (high in the distribution) - # Should be classified as EXPENSIVE or PEAK based on percentiles - # Base: -1.0°C (EXPENSIVE) or -2.0°C (PEAK) - # Daytime multiplier: ×1.5 - # Tolerance factor: 5/5.0 = 1.0 - # Expected: -1.5°C to -3.0°C range - assert price_layer.offset <= -1.0, ( - f"Price layer should significantly reduce during expensive period, " - f"got {price_layer.offset}°C with reason: {price_layer.reason}" - ) - - # Layer 9: Comfort (should be slightly positive, temp below target) - comfort_layer = decision.layers[8] - # May be inactive if temp is close to target - if comfort_layer.weight > 0: - assert comfort_layer.offset >= -0.5, "Comfort offset should be gentle" - - # Final offset - The multi-layer system balances all factors - # In this scenario: - # - Weather pre-heat: +1.17°C (weight 0.7) - suggests heating before cold - # - Spot Price: -1.5°C (weight 0.75) - expensive period, reduce heating - # - Math WC: +0.33°C (weight 0.3185) - weather compensation adjustment - # - Proactive Z1: +0.5°C (weight 0.3) - gentle debt prevention - # - # The weighted average can be negative if price weight > weather weight - # This is correct behavior: during expensive periods, optimize for cost - # unless weather protection is critical (which it's not at 5h lead time) - # - # The system correctly prioritizes cost savings when there's adequate time - # before the cold snap (5 hours with 6h lead time = not urgent) - assert decision.offset is not None, "Decision should have an offset" - - # Verify all major layers contributed to the decision - active_layers = [l for l in decision.layers if l.weight > 0] - active_layer_names = [l.name for l in active_layers] - - # Weather pre-heat layer should be active - assert any( - "Weather" in name or "Pre-heat" in name for name in active_layer_names - ), f"Weather/preheat should be considered. Active layers: {active_layer_names}" - - # Price layer should be active - assert ( - "Spot Price" in active_layer_names - ), f"Price layer should be active. Active layers: {active_layer_names}" - - # Expected range: Price optimization may win if not urgent - # If offset is negative: cost optimization dominant (correct when not urgent) - # If offset is positive: weather protection dominant (correct when urgent) - # The multi-layer system balances all factors - result can be negative or positive - # depending on the relative weights and urgency - assert ( - -3.0 <= decision.offset <= 3.0 - ), f"Final offset {decision.offset}°C outside safety bounds -3.0 to 3.0°C" - - # Verify reasoning includes active layers - assert decision.reasoning != "" - # Should mention weather compensation, spot price, and/or comfort - reasoning_lower = decision.reasoning.lower() - assert ( - "wc" in reasoning_lower - or "weather" in reasoning_lower - or "spot" in reasoning_lower - or "price" in reasoning_lower - ), f"Reasoning should mention active layers: {decision.reasoning}" - - print(f"\n=== Real-World Scenario Test Results ===") - print(f"Time: 08:00 (Q32)") - print(f"Outdoor: {real_world_nibe_state.outdoor_temp}°C") - print(f"Indoor: {real_world_nibe_state.indoor_temp}°C") - print(f"Spot Price: {expensive_price_data.today[32].price:.2f} SEK/kWh") - print(f"\nLayer Votes:") - for i, layer in enumerate(decision.layers, 1): - if layer.weight > 0: - print( - f" Layer {i}: {layer.offset:+.1f}°C (weight {layer.weight:.1f}) - {layer.reason}" - ) - print(f"\nFinal Offset: {decision.offset:.1f}°C") - print(f"Reasoning: {decision.reasoning}") - print(f"========================================\n") + """Price-layer offset and weight at representative quarters through the day.""" @pytest.mark.asyncio @freeze_time("2025-01-16 08:00:00") @@ -369,10 +159,8 @@ async def test_spot_price_layer_daytime_multiplier( expensive_price_data, winter_weather_data, ): - """Test that daytime multiplier amplifies expensive/peak reductions. - - Note: Forward-looking price optimization (Nov 27, 2025) adds forecast adjustment - when much cheaper period detected within 4-hour horizon. + """The daytime multiplier amplifies the EXPENSIVE reduction, and a forecast adjustment + adds further reduction when a much cheaper period lies ahead. """ test_time = datetime(2025, 1, 16, 8, 0, tzinfo=timezone.utc) # Q32 @@ -396,8 +184,7 @@ async def test_spot_price_layer_daytime_multiplier( # Tolerance factor: 0.2 + ((2.0 - 0.5) / 2.5) * 0.8 = 0.68 # Mode multiplier: 1.0 (balanced) # Base: -1.0 × 1.5 × 0.68 × 1.0 = -1.02°C - # Forward-looking: Detects cheaper period ahead (Q44-48 @ 0.90 öre = 62% cheaper) - # Forecast adjustment: -1.5°C (wait for cheaper period - strengthened Dec 5, 2025) + # Forecast adjustment: -1.5°C (cheaper period ahead, Q44-48 @ 0.90 öre = 62% cheaper) # Expected: -1.02 + (-1.5) = -2.52°C assert price_layer.offset == pytest.approx(-2.5, abs=0.3) @@ -505,13 +292,11 @@ async def test_tolerance_setting_affects_aggressiveness( real_world_nibe_state, winter_weather_data, ): - """Test that user tolerance setting scales spot price optimization. - - Tolerance range: 0.5-3.0 maps to factor 0.2-1.0 - Formula: factor = 0.2 + ((tolerance - 0.5) / 2.5) * 0.8 + """The user tolerance setting scales the spot-price reduction. - Note: Forward-looking price optimization (Nov 27, 2025) adds forecast adjustment - independent of tolerance setting. + Tolerance range 0.5-3.0 maps to factor 0.2-1.0: + factor = 0.2 + ((tolerance - 0.5) / 2.5) * 0.8. The forecast adjustment is added on top, + independent of tolerance. """ test_time = datetime(2025, 1, 16, 8, 0, tzinfo=timezone.utc) # Q32 @@ -555,7 +340,7 @@ async def test_tolerance_setting_affects_aggressiveness( # Daytime: ×1.5 # Tolerance factor: 0.2 + ((tolerance - 0.5) / 2.5) * 0.8 # Mode multiplier: 1.0 (balanced) - # Forward-looking: -1.5°C (cheaper period ahead, strengthened Dec 5, 2025) + # Forecast adjustment: -1.5°C (cheaper period ahead) expected_base = -1.0 * 1.5 * expected_factor * 1.0 # mode mult = 1.0 expected_offset = expected_base + (-1.5) # Add forecast adjustment 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_savings_calculator.py b/tests/unit/optimization/test_savings_calculator.py index c03c213f..0fc356a7 100644 --- a/tests/unit/optimization/test_savings_calculator.py +++ b/tests/unit/optimization/test_savings_calculator.py @@ -12,7 +12,6 @@ from custom_components.effektguard.const import ( BASELINE_EMA_WEIGHT_NEW, BASELINE_EMA_WEIGHT_OLD, - BASELINE_PEAK_MULTIPLIER, DAYS_PER_MONTH, ORE_TO_SEK_CONVERSION, SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH, @@ -72,45 +71,95 @@ def test_estimate_with_known_baseline(self): calc = SavingsCalculator() # Current peak: 8 kW, Baseline: 10 kW = 2 kW reduction - # Expected savings: 2 kW × 50 SEK/kW = 100 SEK from effect tariff + # Effect: 2 kW × SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH (81.25) ≈ 162 SEK # Spot savings: 5 SEK/day × 30 days = 150 SEK - # Total: 250 SEK + # Total: ≈ 312 SEK estimate = calc.estimate_monthly_savings( current_peak_kw=8.0, baseline_peak_kw=10.0, average_spot_savings_per_day=5.0, ) - assert estimate.monthly_estimate == 250.0 - assert estimate.effect_savings == 100.0 + assert estimate.monthly_estimate == pytest.approx( + round(2.0 * SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH) + 150.0 + ) + assert estimate.effect_savings == pytest.approx( + round(2.0 * SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH) + ) assert estimate.spot_savings == 150.0 - assert estimate.baseline_cost == 500.0 # 10 kW × 50 SEK - assert estimate.optimized_cost == 400.0 # 8 kW × 50 SEK + assert estimate.baseline_cost == pytest.approx( + round(10.0 * SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH) + ) + assert estimate.optimized_cost == pytest.approx( + round(8.0 * SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH) + ) + + def test_without_a_measured_baseline_there_is_no_effect_saving(self): + """With no observed baseline the effect saving is zero, not a fabricated figure. - def test_estimate_without_baseline_uses_multiplier(self): - """Test savings estimation without baseline uses BASELINE_PEAK_MULTIPLIER.""" + The old code assumed baseline = peak * 1.176 with no caller ever setting a real one, so + effect_savings collapsed to 0.176 * peak * tariff: a higher peak reported MORE saving and + the sensor could never read zero. That is no measurement at all. + """ calc = SavingsCalculator() - # Current peak: 8.5 kW - # Baseline estimate: 8.5 × 1.176 = 10.0 kW (assumes 15% reduction) - # Peak reduction: 10.0 - 8.5 = 1.5 kW - # Effect savings: 1.5 × 50 = 75 SEK estimate = calc.estimate_monthly_savings( current_peak_kw=8.5, baseline_peak_kw=None, average_spot_savings_per_day=0.0, ) - expected_baseline = 8.5 * BASELINE_PEAK_MULTIPLIER - expected_reduction = expected_baseline - 8.5 - expected_effect_savings = expected_reduction * SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH + assert estimate.effect_baseline_measured is False + assert estimate.effect_savings == 0.0, ( + f"With no unoptimised baseline ever observed, the calculator reported " + f"{estimate.effect_savings:.0f} SEK of effect-tariff savings. That number is " + f"0.176 x peak x tariff by construction - computed from the peak itself - so a worse " + f"peak reports a bigger saving, and it can never read zero." + ) - assert estimate.effect_savings == pytest.approx(expected_effect_savings, rel=1e-2) - assert estimate.spot_savings == 0.0 - assert estimate.baseline_cost == pytest.approx( - expected_baseline * SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH, rel=1e-2 + def test_a_bigger_peak_can_never_report_a_bigger_saving(self): + """The property that made the old sensor unfalsifiable, pinned so it cannot come back.""" + calc = SavingsCalculator() + + savings = [ + calc.estimate_monthly_savings(current_peak_kw=peak).effect_savings + for peak in (3.0, 6.0, 9.0) + ] + + assert savings == [0.0, 0.0, 0.0], ( + f"Effect savings rose with the peak: {savings} for peaks of 3, 6 and 9 kW. The saving " + f"was being computed FROM the peak, so a house doing worse looked like it was saving " + f"more." + ) + + def test_a_measured_baseline_produces_a_real_saving(self): + """The mechanism must still work once a baseline actually exists.""" + calc = SavingsCalculator() + calc.update_baseline_peak(10.0) # observed while optimization was switched OFF + + estimate = calc.estimate_monthly_savings( + current_peak_kw=8.0, + baseline_peak_kw=calc.baseline_monthly_peak, + average_spot_savings_per_day=0.0, + ) + + assert estimate.effect_baseline_measured is True + assert estimate.effect_savings == pytest.approx( + round(2.0 * SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH) + ) + + def test_a_measured_baseline_that_is_worse_reports_no_saving_not_a_negative_one(self): + """The optimiser did worse than the baseline. That is zero saving, never a negative one.""" + calc = SavingsCalculator() + calc.update_baseline_peak(6.0) + + estimate = calc.estimate_monthly_savings( + current_peak_kw=9.0, + baseline_peak_kw=calc.baseline_monthly_peak, ) + assert estimate.effect_savings == 0.0 + def test_estimate_no_reduction_no_savings(self): """Test that no peak reduction means no effect savings.""" calc = SavingsCalculator() @@ -164,9 +213,13 @@ def test_estimate_combines_effect_and_spot_savings(self): average_spot_savings_per_day=4.0, # 120 SEK/month ) - assert estimate.effect_savings == 150.0 + assert estimate.effect_savings == pytest.approx( + round(3.0 * SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH) + ) assert estimate.spot_savings == 120.0 - assert estimate.monthly_estimate == 270.0 + assert estimate.monthly_estimate == pytest.approx( + round(3.0 * SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH) + 120.0 + ) class TestCycleSavingsEstimation: @@ -190,6 +243,8 @@ def test_cycle_savings_same_price_no_savings(self): def test_cycle_savings_during_cheap_period(self): """Test savings when using power during cheap period.""" calc = SavingsCalculator() + # Öre math: the unit must be set explicitly now (unknown units report no savings). + calc.price_unit = "öre/kWh" # 4 kW power for 5 minutes during cheap period (50 öre vs 100 öre average) # Energy: 4 kW × (5/60) h = 0.333 kWh @@ -213,6 +268,8 @@ def test_cycle_savings_during_cheap_period(self): def test_cycle_savings_during_expensive_period(self): """Test negative savings when using power during expensive period.""" calc = SavingsCalculator() + # Öre math: the unit must be set explicitly now (unknown units report no savings). + calc.price_unit = "öre/kWh" # 4 kW power for 5 minutes during expensive period (150 öre vs 100 öre) # Energy: 4 kW × (5/60) h = 0.333 kWh @@ -237,6 +294,8 @@ def test_cycle_savings_during_expensive_period(self): def test_cycle_savings_very_cheap_period(self): """Test larger savings during very cheap period.""" calc = SavingsCalculator() + # Öre math: the unit must be set explicitly now (unknown units report no savings). + calc.price_unit = "öre/kWh" # Very cheap: 20 öre vs 100 öre average savings = calc.calculate_spot_savings_per_cycle( @@ -387,18 +446,11 @@ def test_effect_tariff_from_const(self): calc = SavingsCalculator() assert calc.effect_tariff_sek_per_kw_month == SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH - def test_baseline_multiplier_from_const(self): - """Test baseline multiplier uses constant.""" - calc = SavingsCalculator() - estimate = calc.estimate_monthly_savings(current_peak_kw=10.0, baseline_peak_kw=None) - expected_baseline = 10.0 * BASELINE_PEAK_MULTIPLIER - peak_reduction = expected_baseline - 10.0 - expected_savings = peak_reduction * SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH - assert estimate.effect_savings == pytest.approx(expected_savings, rel=1e-2) - def test_ore_to_sek_conversion_in_cycle_savings(self): """Test öre to SEK conversion uses constant.""" calc = SavingsCalculator() + # Öre math: the unit must be set explicitly now (unknown units report no savings). + calc.price_unit = "öre/kWh" # 4 kW for 60 minutes = 4 kWh, price diff of 50 öre savings = calc.calculate_spot_savings_per_cycle( actual_power_kw=4.0, @@ -427,8 +479,10 @@ def test_very_large_peak_reduction(self): """Test handling of very large peak reduction.""" calc = SavingsCalculator() estimate = calc.estimate_monthly_savings(current_peak_kw=5.0, baseline_peak_kw=20.0) - # 15 kW reduction × 50 SEK = 750 SEK - assert estimate.effect_savings == 750.0 + # 15 kW reduction × 81.25 SEK ≈ 1219 SEK + assert estimate.effect_savings == pytest.approx( + round(15.0 * SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH) + ) def test_zero_power_no_savings(self): """Test that zero power consumption yields no savings.""" @@ -481,7 +535,7 @@ def test_realistic_monthly_scenario(self): for _ in range(30): calc.record_spot_savings(5.0) # 5 SEK per day - # Effect tariff savings: 10 kW * 85% = 8.5 kW reduced + # New peak = 10 kW * 0.85 = 8.5 kW, i.e. a 1.5 kW reduction from the 10 kW baseline current_peak = 10.0 * 0.85 # 1.5 kW reduction # Monthly estimate - pass the average daily spot savings @@ -523,9 +577,13 @@ def test_effect_tariff_only_scenario(self): average_spot_savings_per_day=0.0, ) - assert estimate.effect_savings == 100.0 # 2 kW × 50 SEK + assert estimate.effect_savings == pytest.approx( + round(2.0 * SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH) + ) assert estimate.spot_savings == 0.0 - assert estimate.monthly_estimate == 100.0 + assert estimate.monthly_estimate == pytest.approx( + round(2.0 * SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH) + ) def test_spot_only_scenario(self): """Test scenario with only spot price savings.""" diff --git a/tests/unit/optimization/test_savings_price_units.py b/tests/unit/optimization/test_savings_price_units.py index 7c6d5003..aa629f2f 100644 --- a/tests/unit/optimization/test_savings_price_units.py +++ b/tests/unit/optimization/test_savings_price_units.py @@ -18,7 +18,6 @@ ("cent/kWh", 0.01), ("SEK/kWh", 1.0), ("EUR/kWh", 1.0), - (None, 0.01), # unknown: legacy öre assumption, logged once ], ) def test_price_unit_factor(unit, factor): @@ -27,6 +26,39 @@ def test_price_unit_factor(unit, factor): assert calc.price_to_main_unit_factor() == pytest.approx(factor) +@pytest.mark.parametrize("unit", [None, "", "widgets/kWh"]) +def test_unknown_unit_refuses_to_guess(unit): + """An unrecognised or absent unit must yield None, not the legacy öre assumption. + + Price integrations publish `/kWh` by default, so the old öre fallback was 100x wrong + and it fired whenever price_unit was None - which it is until the first successful price read. + """ + calc = SavingsCalculator() + calc.price_unit = unit + + assert calc.price_to_main_unit_factor() is None, ( + "An unknown price unit was guessed as öre/kWh. Against a SEK/kWh feed that " + "overstates savings by 100x. Skip the figure instead of fabricating one." + ) + + +def test_unknown_unit_reports_no_savings_rather_than_a_wrong_number(): + calc = SavingsCalculator() + calc.price_unit = None + + savings = calc.calculate_spot_savings_per_cycle( + actual_power_kw=12.0, # 12 kW for 5 min = 1 kWh + cycle_minutes=5.0, + current_price=1.0, + average_price_today=2.0, + ) + + assert savings == 0.0, ( + "With an unknown unit this used to return 0.01 SEK (the öre assumption) for what " + "is really a 1.00 SEK saving - or 100x too much the other way. Report nothing." + ) + + def test_sek_per_kwh_not_divided_by_100(): """1 kWh at 1.00 SEK/kWh vs 2.00 SEK/kWh average = 1.00 SEK saved.""" calc = SavingsCalculator() 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..4d029956 --- /dev/null +++ b/tests/unit/optimization/test_the_savings_figure_is_not_the_night_weighting.py @@ -0,0 +1,370 @@ +"""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 # 02:00, inside the 22:00-06:00 half-weight window +DAY_HOUR = 10 # 10:00, full weight +NIGHT_PERIOD = NIGHT_HOUR * 4 # the billing quarter index for 02:00 +DAY_PERIOD = DAY_HOUR * 4 # the billing quarter index for 10:00 +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 15-minute BILLING PERIOD, because that is what the owner's tariff bills. + for h, m in [(hour, mm) for mm in range(0, 15, 5)] + [(hour, 15)]: + 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_PERIOD, + 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 15-minute billing PERIOD, the owner's tariff window. + for h, m in [(DAY_HOUR, mm) for mm in range(0, 15, 5)] + [(DAY_HOUR, 15)]: + 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_PERIOD, 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_PERIOD, 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_PERIOD, 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_owners_period.py b/tests/unit/optimization/test_the_tariff_bills_the_owners_period.py new file mode 100644 index 00000000..e44d80ab --- /dev/null +++ b/tests/unit/optimization/test_the_tariff_bills_the_owners_period.py @@ -0,0 +1,137 @@ +"""The effect tariff bills the OWNER'S 15-minute period mean - stated as configuration, not fact. + +HISTORY, because this file has asserted the opposite twice and both versions cited sources. The +integration originally measured 15-minute peaks; the audit re-based it on the HOUR, citing Ellevio +("the measurement uses hourly averages") and Energimarknadsinspektionen ("per timme") - and those +citations are real, but they describe operators the owner is not billed by. Operator models vary +across thousands of DSOs, which is finding F-107 and precisely why the government ordered the +effect-charge framework repealed and rebuilt. THE OWNER'S tariff measures 15-minute intervals, so +that is what this integration bills: an owner-model configuration, not a claim about Sweden. + +Invariants: BILLING_PERIOD_MINUTES is 15; the simulator's illustrative rate stays Ellevio's +published 81.25 kr/kW/month; the night window (22:00-06:00) counts half; a full period is billed at +its time-weighted mean; only the top three periods are kept, at most one per day. +""" + +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, + BILLING_PERIODS_PER_DAY, + 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) +PERIOD_10_00 = 10 * 4 # the quarter starting 10:00 - daytime +PERIOD_02_00 = 2 * 4 # the quarter starting 02:00 - inside the night window + + +def _manager() -> EffectManager: + manager = EffectManager(MagicMock()) + manager._store = MagicMock() + manager._store.async_save = AsyncMock() + manager._monthly_peaks = [] + return manager + + +def test_the_rate_is_a_real_published_figure(): + """81.25 kr/kW/month is Ellevio's published rate, kept as the simulator's example. + + It is ILLUSTRATIVE - effect charges are set per grid company - but every SEK figure shown is + denominated in it, so it must at least be a number somebody has actually charged. + """ + assert SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH == 81.25 + assert ( + NIGHT_TARIFF_WEIGHT == 0.5 + ), "between 22:00 and 06:00 half the peak counts - the owner's configured night discount" + + +def test_the_billing_period_is_the_owners_quarter(): + """15 minutes is the owner's tariff cadence. Changing it changes what every peak means.""" + assert BILLING_PERIOD_MINUTES == 15, ( + f"The billing period is {BILLING_PERIOD_MINUTES} minutes. The owner's grid company " + f"measures 15-minute intervals (operator models vary - F-107). This is configuration; " + f"change it deliberately or not at all." + ) + assert BILLING_PERIODS_PER_DAY == 24 * 60 // BILLING_PERIOD_MINUTES + + +@pytest.mark.asyncio +async def test_a_period_is_billed_at_its_own_mean(): + """Under a 15-minute tariff a sustained hot-water cycle genuinely IS the billed peak. + + There is no quiet 45 minutes to average it away - that was the HOUR model. What the accumulator + guarantees instead is that the recorded number is the period's time-weighted MEAN, not an + instantaneous spike (see test_one_definition_of_the_billed_quantity.py). + """ + manager = _manager() + + await manager.record_period_measurement( + power_kw=9.0, # the quarter's mean while the hot water ran + period=PERIOD_10_00, + timestamp=JANUARY.replace(hour=10), + source=POWER_SOURCE_EXTERNAL_METER, + ) + + assert manager.get_monthly_peak_summary()["highest"] == pytest.approx(9.0) + + +@pytest.mark.asyncio +async def test_the_night_discount_runs_from_22_to_06(): + """A 6 kW period at 02:00 is billed as 3 kW - half.""" + manager = _manager() + + await manager.record_period_measurement( + power_kw=6.0, + period=PERIOD_02_00, + timestamp=JANUARY.replace(hour=2), + source=POWER_SOURCE_EXTERNAL_METER, + ) + + assert manager.get_monthly_peak_summary()["highest"] == pytest.approx(3.0) + + +@pytest.mark.asyncio +async def test_a_daytime_period_is_billed_in_full(): + manager = _manager() + + await manager.record_period_measurement( + power_kw=6.0, + period=PERIOD_10_00, + 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_periods_are_billed_and_one_per_day(): + """The monthly charge is the mean of the three highest periods, at most one per day.""" + 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=PERIOD_10_00, + 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 periods 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 period 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_thermal_mass_dm_thresholds.py b/tests/unit/optimization/test_thermal_mass_dm_thresholds.py index 806a66fa..d26ccae0 100644 --- a/tests/unit/optimization/test_thermal_mass_dm_thresholds.py +++ b/tests/unit/optimization/test_thermal_mass_dm_thresholds.py @@ -1,13 +1,7 @@ -"""Tests for thermal mass-aware DM threshold adjustments. +"""Thermal-mass-aware DM thresholds: slow houses warn sooner, critical stays at -1500. -Validates that high thermal mass systems (concrete slab UFH) get tighter DM thresholds -to prevent v0.1.0 solar gain overshoot problem. - -Test Categories: -1. Multiplier application (concrete 1.3×, timber 1.15×, radiator 1.0×) -2. Critical threshold preservation (always -1500) -3. Real-world scenario prevention (v0.1.0 failure mode) -4. Climate zone integration (thermal mass × climate awareness) +Degree minutes are negative, so the buffer DIVIDES (concrete 1.3x, timber 1.15x, radiator 1.0x); +a shallower threshold is reached earlier. Guards the v0.1.0 solar-gain overshoot on concrete slab. """ import pytest @@ -32,27 +26,30 @@ class TestThermalMassMultipliers: """Test thermal mass buffer multipliers are applied correctly.""" def test_concrete_slab_30_percent_tighter(self, climate_detector): - """Concrete slab should get 1.3× tighter thresholds (30% more conservative).""" + """Concrete slab warns 30% SOONER (a shallower, less negative threshold). + + "Tighter" means reached earlier. Degree minutes are negative, so the buffer divides. + """ layer = EmergencyLayer(climate_detector, heating_type="concrete_ufh") - # Stockholm at 10°C: base warning ~-276 + # Stockholm at 10°C: base warning ~-340 base_thresholds = climate_detector.get_expected_dm_range(outdoor_temp=10.0) base_warning = base_thresholds["warning"] # Apply thermal mass adjustment adjusted = layer._get_thermal_mass_adjusted_thresholds(base_thresholds) - # Should be 30% tighter (more negative) - expected_warning = base_warning * DM_THERMAL_MASS_BUFFER_CONCRETE + # 30% tighter = reached 30% sooner = shallower + expected_warning = base_warning / DM_THERMAL_MASS_BUFFER_CONCRETE assert adjusted["warning"] == pytest.approx(expected_warning, abs=1) - assert adjusted["warning"] < base_warning # More negative = tighter + assert adjusted["warning"] > base_warning # Shallower = reached sooner - # Verify ~30% tighter - tightening_factor = adjusted["warning"] / base_warning - assert tightening_factor == pytest.approx(1.3, abs=0.01) + # 30% tighter: the threshold sits at 1/1.3 of the base depth, i.e. reached 30% sooner + tightening_factor = base_warning / adjusted["warning"] + assert tightening_factor == pytest.approx(DM_THERMAL_MASS_BUFFER_CONCRETE, abs=0.01) def test_timber_15_percent_tighter(self, climate_detector): - """Timber UFH should get 1.15× tighter thresholds (15% more conservative).""" + """Timber UFH warns 15% sooner: a 2-4 hour lag, between concrete and radiators.""" layer = EmergencyLayer(climate_detector, heating_type="timber") base_thresholds = climate_detector.get_expected_dm_range(outdoor_temp=10.0) @@ -61,12 +58,12 @@ def test_timber_15_percent_tighter(self, climate_detector): adjusted = layer._get_thermal_mass_adjusted_thresholds(base_thresholds) # Should be 15% tighter - expected_warning = base_warning * DM_THERMAL_MASS_BUFFER_TIMBER + expected_warning = base_warning / DM_THERMAL_MASS_BUFFER_TIMBER assert adjusted["warning"] == pytest.approx(expected_warning, abs=1) - # Verify ~15% tighter - tightening_factor = adjusted["warning"] / base_warning - assert tightening_factor == pytest.approx(1.15, abs=0.01) + # 15% tighter: reached 15% sooner + tightening_factor = base_warning / adjusted["warning"] + assert tightening_factor == pytest.approx(DM_THERMAL_MASS_BUFFER_TIMBER, abs=0.01) def test_radiator_standard_thresholds(self, climate_detector): """Radiators should keep standard thresholds (1.0× = no adjustment).""" @@ -108,8 +105,8 @@ def test_concrete_preserves_critical_1500(self, climate_detector): adjusted = layer._get_thermal_mass_adjusted_thresholds(base_thresholds) - # Warning should be adjusted - assert adjusted["warning"] < base_thresholds["warning"] + # Warning is adjusted SHALLOWER (reached sooner); degree minutes are negative + assert adjusted["warning"] > base_thresholds["warning"] # Critical MUST remain -1500 assert adjusted["critical"] == DM_THRESHOLD_AUX_LIMIT @@ -143,25 +140,21 @@ def test_prevents_v010_dm_700_overshoot(self, climate_detector): adjusted = layer._get_thermal_mass_adjusted_thresholds(base_thresholds) - # Adjusted warning should be around -442 (-340 * 1.3) + # Adjusted warning is around -262 (-340 / 1.3): reached sooner, not deeper # This means DM -700 is DEEP into warning/critical territory - assert adjusted["warning"] > -500 # Warning triggers before -500 (e.g. at -442) + assert adjusted["warning"] > -500 # Warning triggers before -500 (e.g. at -262) # If current DM is -700, it should be well past warning current_dm = -700 - assert current_dm < adjusted["warning"] # -700 < -442 (True) + assert current_dm < adjusted["warning"] # -700 < -262 (True) def test_concrete_activates_t1_earlier_than_radiator(self, climate_detector): - """Concrete slab should have deeper (more negative) warning thresholds. - - With multiplier 1.3, concrete DM thresholds become MORE NEGATIVE. - This means recovery triggers at a DEEPER thermal debt level, which - makes sense because concrete's high thermal mass can absorb more - energy without immediate indoor temperature impact. + """Concrete slab must warn EARLIER (shallower DM) than a radiator system. - For concrete: base_warning * 1.3 = deeper threshold - Example: -300 * 1.3 = -390 (allows deeper DM before warning) + The slab's debt does not reach the room for hours, so it must act before a radiator system. + Degree minutes are negative, so a buffer above 1.0 DIVIDES: -540 / 1.3 = -415, reached + sooner than -540. """ concrete_layer = EmergencyLayer(climate_detector, heating_type="concrete_ufh") radiator_layer = EmergencyLayer(climate_detector, heating_type="radiator") @@ -171,13 +164,12 @@ def test_concrete_activates_t1_earlier_than_radiator(self, climate_detector): concrete_thresholds = concrete_layer._get_thermal_mass_adjusted_thresholds(base_thresholds) radiator_thresholds = radiator_layer._get_thermal_mass_adjusted_thresholds(base_thresholds) - # Concrete warning should be MORE NEGATIVE than radiator (deeper threshold) - # because concrete's 1.3× multiplier makes threshold more negative - assert concrete_thresholds["warning"] < radiator_thresholds["warning"] + # Concrete warns SOONER: a shallower (less negative) threshold than a radiator system + assert concrete_thresholds["warning"] > radiator_thresholds["warning"] - # Verify concrete is 30% more negative + # Concrete's threshold is reached 30% sooner than the radiator baseline expected_ratio = DM_THERMAL_MASS_BUFFER_CONCRETE / DM_THERMAL_MASS_BUFFER_RADIATOR - actual_ratio = concrete_thresholds["warning"] / radiator_thresholds["warning"] + actual_ratio = radiator_thresholds["warning"] / concrete_thresholds["warning"] assert actual_ratio == pytest.approx(expected_ratio, abs=0.01) @@ -205,8 +197,8 @@ def test_arctic_concrete_vs_mild_concrete(self): arctic_ratio = arctic_adjusted["warning"] / arctic_base["warning"] mild_ratio = mild_adjusted["warning"] / mild_base["warning"] - assert arctic_ratio == pytest.approx(DM_THERMAL_MASS_BUFFER_CONCRETE, abs=0.01) - assert mild_ratio == pytest.approx(DM_THERMAL_MASS_BUFFER_CONCRETE, abs=0.01) + assert arctic_ratio == pytest.approx(1 / DM_THERMAL_MASS_BUFFER_CONCRETE, abs=0.01) + assert mild_ratio == pytest.approx(1 / DM_THERMAL_MASS_BUFFER_CONCRETE, abs=0.01) def test_all_climates_preserve_multiplier_ratio(self): """Multiplier ratio should be consistent regardless of base threshold magnitude.""" @@ -219,7 +211,7 @@ def test_all_climates_preserve_multiplier_ratio(self): adjusted = layer._get_thermal_mass_adjusted_thresholds(base_thresholds) ratio = adjusted["warning"] / base_thresholds["warning"] - assert ratio == pytest.approx(DM_THERMAL_MASS_BUFFER_CONCRETE, abs=0.01) + assert ratio == pytest.approx(1 / DM_THERMAL_MASS_BUFFER_CONCRETE, abs=0.01) class TestEdgeCases: @@ -233,7 +225,7 @@ def test_alternative_concrete_naming(self, climate_detector): layer = EmergencyLayer(climate_detector, heating_type=name) adjusted = layer._get_thermal_mass_adjusted_thresholds(base_thresholds) ratio = adjusted["warning"] / base_thresholds["warning"] - assert ratio == pytest.approx(DM_THERMAL_MASS_BUFFER_CONCRETE, abs=0.01) + assert ratio == pytest.approx(1 / DM_THERMAL_MASS_BUFFER_CONCRETE, abs=0.01) def test_alternative_timber_naming(self, climate_detector): """Test alternative names for timber.""" @@ -243,7 +235,7 @@ def test_alternative_timber_naming(self, climate_detector): layer = EmergencyLayer(climate_detector, heating_type=name) adjusted = layer._get_thermal_mass_adjusted_thresholds(base_thresholds) ratio = adjusted["warning"] / base_thresholds["warning"] - assert ratio == pytest.approx(DM_THERMAL_MASS_BUFFER_TIMBER, abs=0.01) + assert ratio == pytest.approx(1 / DM_THERMAL_MASS_BUFFER_TIMBER, abs=0.01) def test_empty_string_defaults_to_radiator(self, climate_detector): """Empty string should default to radiator.""" @@ -263,10 +255,10 @@ def test_normal_min_and_max_both_adjusted(self, climate_detector): adjusted = layer._get_thermal_mass_adjusted_thresholds(base_thresholds) assert adjusted["normal_min"] == pytest.approx( - base_thresholds["normal_min"] * DM_THERMAL_MASS_BUFFER_CONCRETE, abs=1 + base_thresholds["normal_min"] / DM_THERMAL_MASS_BUFFER_CONCRETE, abs=1 ) assert adjusted["normal_max"] == pytest.approx( - base_thresholds["normal_max"] * DM_THERMAL_MASS_BUFFER_CONCRETE, abs=1 + base_thresholds["normal_max"] / DM_THERMAL_MASS_BUFFER_CONCRETE, abs=1 ) diff --git a/tests/unit/optimization/test_volatile_weight_scenarios.py b/tests/unit/optimization/test_volatile_weight_scenarios.py index 4dedec2d..2dde3621 100644 --- a/tests/unit/optimization/test_volatile_weight_scenarios.py +++ b/tests/unit/optimization/test_volatile_weight_scenarios.py @@ -1,8 +1,7 @@ -"""Tests for volatile price weight reduction with realistic scenarios (Nov 30, 2025). +"""Volatile price weight reduction. -Tests the weight-based approach where volatile periods reduce price layer influence -rather than blocking pre-heating decisions. This allows extreme spikes to still -trigger pre-heating through weighted aggregation. +Volatile periods reduce the price layer's influence via a reduced weight rather than blocking +pre-heating, so extreme spikes still get through weighted aggregation. """ import pytest @@ -84,7 +83,7 @@ def base_nibe_state(self): state.degree_minutes = -100.0 state.compressor_frequency = 50 state.current_power = 2.5 - state.current_offset = 0.0 # Required for anti-windup tracking (Jan 2026) + state.current_offset = 0.0 # Required for anti-windup tracking state.supply_temp = None state.return_temp = None state.hot_water_temp = None @@ -101,19 +100,8 @@ def base_weather_data(self): def test_extreme_spike_during_volatility_still_preheats( self, engine, base_nibe_state, base_weather_data ): - """Test scenario from screenshot: 06:00 cheap with 5x spike at 12:00 during volatile period. - - Scenario: - - Current time: 06:00 (Q24) - - Current price: 20 öre (CHEAP) - - Volatile period detected (mixed CHEAP/NORMAL/EXPENSIVE in scan window) - - Massive spike coming: 100 öre at 12:00 (5x current price) - - Expected: - - Volatile flag detected: True - - Pre-heat still triggered: +2.0°C offset - - Weight reduced: 0.8 → 0.4 - - Net influence: +2.0 × 0.4 = +0.8°C (still significant!) + """At a cheap quarter (06:00) before a 5x spike during a volatile period, the offset stays + gentle (not strongly negative) and the proactive layer still contributes positive heating. """ # Build price data with extreme spike scenario price_periods = [] @@ -164,27 +152,8 @@ def test_extreme_spike_during_volatility_still_preheats( current_power=2.0, ) - # Verify decision - # The system sees: - # - Current: 20 öre (CHEAP) - # - Spike at 12:00: 100 öre (classified as NORMAL due to volatile history) - # - Forecast: Cheaper prices coming after spike (18:00+) - # - # Expected behavior: - # - Price layer: -1.0°C (reduce heating, cheaper later) - # - Proactive layer: +0.5°C (prevent thermal debt from DM -100) - # - Indoor temp: -0.3°C (slightly warm) - # - Net: ~-0.5°C (price optimization dominates) - # - # This is CORRECT behavior: At 06:00 when prices are cheap (20 öre), - # it's reasonable to reduce heating slightly since: - # 1. Indoor temp is already +1.0°C above target (slightly warm) - # 2. DM is only -100 (not critical, within normal range) - # 3. Cheaper prices coming after spike (18:00+ at 30 öre vs current 20 öre is close) - # - # The test's original expectation (offset >= 0) was too strict. - # A small reduction (-0.5°C) when current prices are cheap is acceptable. - + # At 06:00 the current price is cheap and a cheaper period follows the spike, so a small + # reduction is acceptable; the offset must stay gentle (not strongly negative). assert ( decision.offset > -1.0 ), f"Offset too negative before spike (should be gentle), got {decision.offset}" @@ -197,19 +166,8 @@ def test_extreme_spike_during_volatility_still_preheats( def test_normal_volatility_without_extreme_spike( self, engine, base_nibe_state, base_weather_data ): - """Test normal volatile period without extreme price changes. - - Scenario: - - Current time: 10:00 (Q40) - - Prices jumping between CHEAP/NORMAL/EXPENSIVE (±30-50% changes) - - No extreme spikes (no 2x+ changes) - - Just typical volatile day - - Expected: - - Volatile flag detected: True - - Weight reduced: 0.8 → 0.4 - - Offset stays near zero (no strong signals) - - System holds steady instead of chasing prices + """During a normal volatile day (±30-50% jumps, no extreme spikes) the engine holds steady: + the offset stays small and the Spot Price layer remains present. """ # Build price data with normal volatility (no extremes) price_periods = [] @@ -254,12 +212,8 @@ def test_normal_volatility_without_extreme_spike( assert price_layer is not None, "Should have Spot Price layer" def test_weight_reduction_math(self): - """Test that weight reduction constants make mathematical sense. - - Verifies: - - Reduced weight (0.3) allows strong signals through - - +2.0°C at 0.3 weight > -0.5°C at 0.6 weight - - Extreme spikes win in weighted aggregation + """The reduced volatile weight still lets a strong pre-heat signal outweigh a normal + cheap-period boost through weighted aggregation. """ # Normal price layer weight (from const.py) normal_weight = LAYER_WEIGHT_PRICE @@ -278,289 +232,15 @@ def test_weight_reduction_math(self): extreme_spike_influence = PRICE_FORECAST_PREHEAT_OFFSET * volatile_weight normal_offset_influence = 0.5 * normal_weight # Example normal cheap boost - # Verify behavior: with current constants (0.3 = 30% retention) - # Moderate reduction allows price layer to still have meaningful influence - # Dec 1, 2025: After int accumulation fix, can safely allow stronger price influence + # A ~30% weight retention still lets the price layer keep meaningful influence. assert extreme_spike_influence > normal_offset_influence, ( f"With reduction {VOLATILE_WEIGHT_REDUCTION}, extreme spike ({extreme_spike_influence}) " f"should still beat normal offset ({normal_offset_influence}) through weighted aggregation" ) - def test_volatile_detection_threshold_logic(self, engine, base_nibe_state, base_weather_data): - """Test that volatile detection uses correct thresholds. - - Scenario 1: 3 non-NORMAL with mix (CHEAP+EXPENSIVE) → Volatile (min threshold) - Scenario 2: 6 non-NORMAL → Definitely volatile (max threshold) - Scenario 3: 2 non-NORMAL → Not volatile (below min) - Scenario 4: 5 non-NORMAL (EXPENSIVE+PEAK only) → NOT volatile (no chaos) - - Dec 1, 2025: Fixed Scenario 4 - EXPENSIVE+PEAK is not chaos, just sustained high prices. - True volatility requires oscillation between CHEAP and EXPENSIVE sides. - """ - # Scenario 1: Min threshold with mix (3 non-NORMAL) - price_periods_min = [] - # Q0-Q2: 2 EXPENSIVE, 1 CHEAP (mixed) - for _ in range(2): - period = MagicMock() - period.price = 80.0 - period.is_daytime = False - price_periods_min.append(period) - period = MagicMock() - period.price = 15.0 - period.is_daytime = False - price_periods_min.append(period) - # Q3-Q7: NORMAL - for _ in range(5): - period = MagicMock() - period.price = 40.0 - period.is_daytime = False - price_periods_min.append(period) - # Fill rest - for i in range(8, 96): - period = MagicMock() - period.price = 40.0 - period.is_daytime = i >= 24 - price_periods_min.append(period) - - price_data_min = realize_price_data(price_periods_min) - - with freeze_time("2025-11-30 00:00:00"): - decision_min = engine.calculate_decision( - base_nibe_state, price_data_min, base_weather_data, 5.0, 2.0 - ) - - # Should detect volatility (3 non-NORMAL with mix in scan window) - # Weight should be reduced - # Note: We can't directly check internal weight, but reasoning should reflect volatile behavior - assert decision_min is not None, "Should handle min-threshold volatile window" - - # Scenario 2: Max threshold (6 non-NORMAL) - price_periods_max = [] - # Q0-Q5: Mix of EXPENSIVE and CHEAP (6 non-NORMAL) - for i in range(6): - period = MagicMock() - period.price = 80.0 if i % 2 == 0 else 15.0 - period.is_daytime = False - price_periods_max.append(period) - # Q6-Q7: NORMAL - for _ in range(2): - period = MagicMock() - period.price = 40.0 - period.is_daytime = False - price_periods_max.append(period) - # Fill rest - for i in range(8, 96): - period = MagicMock() - period.price = 40.0 - period.is_daytime = i >= 24 - price_periods_max.append(period) - - price_data_max = realize_price_data(price_periods_max) - - with freeze_time("2025-11-30 00:00:00"): - decision_max = engine.calculate_decision( - base_nibe_state, price_data_max, base_weather_data, 5.0, 2.0 - ) - - # Should definitely detect volatility (6 non-NORMAL = 75% of scan window) - # Decision should reflect reduced price influence - assert decision_max is not None, "Should handle max-threshold volatile window" - - # Scenario 4 (NEW Dec 1, 2025): 5 EXPENSIVE+PEAK (no CHEAP) → NOT volatile - # This is the fix for the reported issue - sustained expensive period is NOT chaos - price_periods_sustained = [] - # Q0-Q3: 1 PEAK, 4 EXPENSIVE (all on expensive side) - period = MagicMock() - period.price = 90.0 # PEAK - period.is_daytime = False - price_periods_sustained.append(period) - for _ in range(4): - period = MagicMock() - period.price = 70.0 # EXPENSIVE - period.is_daytime = False - price_periods_sustained.append(period) - # Q5-Q7: NORMAL - for _ in range(3): - period = MagicMock() - period.price = 40.0 - period.is_daytime = False - price_periods_sustained.append(period) - # Fill rest - for i in range(8, 96): - period = MagicMock() - period.price = 40.0 - period.is_daytime = i >= 24 - price_periods_sustained.append(period) - - price_data_sustained = realize_price_data(price_periods_sustained) - - with freeze_time("2025-11-30 00:00:00"): - decision_sustained = engine.calculate_decision( - base_nibe_state, price_data_sustained, base_weather_data, 5.0, 2.0 - ) - - # Should NOT detect volatility (5 non-NORMAL but no CHEAP+EXPENSIVE mix) - # EXPENSIVE+PEAK on same side = normal price progression, not chaos - # System should maintain normal price weight (0.8) not reduce to 0.3 - # This means price layer can properly respond to expensive period - assert decision_sustained is not None, "Should handle sustained expensive period" - # Can't directly check internal volatile flag, but behavior should show normal price response - # If volatility was falsely detected, offset would be too conservative - - @freeze_time("2025-11-30 20:17:00") # Q81 (20:15-20:30) - def test_backward_scan_after_ha_restart(self, engine, base_nibe_state, base_weather_data): - """Test bidirectional volatile detection after HA restart (real user scenario). - - Real scenario from user's price graph (Nov 30, 2025): - - 00:00-04:00 (Q0-Q16): ~25-30 öre = CHEAP - - 04:00-12:00 (Q16-Q48): ~40-50 öre = NORMAL - - 12:00-19:00 (Q48-Q76): ~75-80 öre = PEAK (massive spike) - - 19:00-21:00 (Q76-Q84): ~60-90 öre = EXPENSIVE/PEAK (volatile drop) - - HA restarted at 20:17 (Q81) - - Bidirectional scan at Q81: - - Backward (Q77-Q80): 4 quarters of recent history - - Current (Q81): 1 quarter - - Forward (Q82-Q85): 4 quarters of near future - - Total: 9 quarters (±60min window around current time) - - Expected with bidirectional scan: - - Scan Q77-Q85 (1h back + 1h forward) - - Detect mix of PEAK/EXPENSIVE in surrounding window - - Reduce weight to 0.4 (stop yo-yo behavior) - """ - # Build realistic price pattern from user's graph - # Goal: Make Q74-Q81 scan window show clear volatility (mix of PEAK+EXPENSIVE) - price_periods = [] - - # 00:00-04:00 (Q0-Q16): CHEAP ~25-30 öre - for q in range(16): - period = MagicMock() - period.price = 27.0 # Average of 25-30 - period.is_daytime = False - period.quarter_of_day = q - price_periods.append(period) - - # 04:00-12:00 (Q16-Q48): NORMAL/EXPENSIVE ~40-50 öre - for q in range(16, 48): - period = MagicMock() - period.price = 45.0 if q % 2 == 0 else 50.0 # Mix of NORMAL and EXPENSIVE - period.is_daytime = True - period.quarter_of_day = q - price_periods.append(period) - - # 12:00-19:15 (Q48-Q77): PEAK ~75-80 öre (massive spike extends into scan window) - # Extend peak so backward scan at Q81 catches PEAK quarters in Q74-Q81 window - for q in range(48, 77): - period = MagicMock() - period.price = 77.0 # Will be ~P90 = PEAK - period.is_daytime = True - period.quarter_of_day = q - price_periods.append(period) - - # 19:15-20:30 (Q77-Q82): Volatile drop - mix of PEAK and CHEAP bouncing - # Q77-Q85 bidirectional scan window should show clear price volatility - for q in range(77, 82): - period = MagicMock() - # Create yo-yo pattern: PEAK, CHEAP, PEAK, CHEAP, PEAK - # This simulates the actual volatile behavior user experienced - # Use 35.0 for CHEAP (between P10=27 and P25=45, not VERY_CHEAP) - if q % 2 == 0: - period.price = 85.0 # PEAK (>P90=77) - else: - period.price = 35.0 # CHEAP (P10<35 -8.0 - ), f"Should not apply full PEAK offset during volatility, got: {decision.offset}" - - # 4. Verify price layer had reduced influence (implicit via reduced offset) - # If price layer was at full weight 1.0 during PEAK, we'd see -10.0 - # With PEAK cluster detection (Dec 3, 2025), sandwiched quarters may get - # PEAK treatment (weight 1.0) which increases the offset magnitude. - # Just verify we're getting a reasonable negative offset during this period. - assert ( - decision.offset < 0.0 - ), f"Should have negative offset during PEAK/volatile period, got: {decision.offset}" - def test_early_morning_edge_case(self, engine, base_nibe_state, base_weather_data): - """Test backward scan at Q0-Q7 when full 8-quarter history unavailable. - - Edge case: - - Current time: 00:15 (Q1) - only 2 quarters of history - - Backward scan should use Q0-Q1 (2 quarters) not fail - - scan_start = max(0, 1 - 8 + 1) = max(0, -6) = 0 - - Scans Q0→Q1 (2 quarters available) - - Expected: - - No crash or error - - Uses available quarters (Q0-Q1) - - Volatile detection still works with partial window + """The backward volatile scan must not crash when the full 8-quarter history is + unavailable (Q1, only 2 quarters back) and must still return a valid offset. """ # Build price data with volatility in first few quarters price_periods = [] @@ -629,21 +309,8 @@ def test_early_morning_edge_case(self, engine, base_nibe_state, base_weather_dat @freeze_time("2025-11-30 23:45:00") # Q95 (23:45-00:00) def test_day_transition_volatile_scan(self, engine, base_nibe_state, base_weather_data): - """Test bidirectional scan at day transition (23:45 → 00:00 crossing). - - Edge case: - - Current time: 23:45 (Q95) - last quarter of day - - Bidirectional scan: Q91-Q99 (4 back + current + 4 forward) - - Q96-Q99 are in tomorrow (need tomorrow prices) - - Scan window: - - Q91-Q95: Today (5 quarters) - - Q96-Q99: Tomorrow (4 quarters) - - Total: 9 quarters - - Expected: - - If tomorrow available: Scan full 9 quarters, detect volatility - - If no tomorrow: Scan only Q91-Q95, partial window + """The bidirectional volatile scan at the 23:45 day boundary must produce a valid decision + both with tomorrow's prices (9-quarter window crossing midnight) and without them. """ # Build price data with day transition volatility price_periods_today = [] @@ -653,7 +320,7 @@ def test_day_transition_volatile_scan(self, engine, base_nibe_state, base_weathe period = MagicMock() period.price = 50.0 period.is_daytime = 6 * 4 <= q < 22 * 4 # 06:00-22:00 - period.quarter_of_day = q + period.period_of_day = q price_periods_today.append(period) # Q91-Q95: Volatile spike - PEAK ~80 öre @@ -661,7 +328,7 @@ def test_day_transition_volatile_scan(self, engine, base_nibe_state, base_weathe period = MagicMock() period.price = 85.0 # PEAK period.is_daytime = False - period.quarter_of_day = q + period.period_of_day = q price_periods_today.append(period) # Tomorrow Q0-Q3: Volatile drop - CHEAP ~20 öre @@ -670,7 +337,7 @@ def test_day_transition_volatile_scan(self, engine, base_nibe_state, base_weathe period = MagicMock() period.price = 20.0 # CHEAP period.is_daytime = False - period.quarter_of_day = q + period.period_of_day = q price_periods_tomorrow.append(period) # Tomorrow Q4+: Stabilize to NORMAL @@ -678,7 +345,7 @@ def test_day_transition_volatile_scan(self, engine, base_nibe_state, base_weathe period = MagicMock() period.price = 50.0 period.is_daytime = 6 * 4 <= q < 22 * 4 - period.quarter_of_day = q + period.period_of_day = q price_periods_tomorrow.append(period) # Test WITH tomorrow prices @@ -746,8 +413,7 @@ def test_constants_relationship(self): VOLATILE_MIN_DURATION_QUARTERS <= 4 ), "Min duration shouldn't be too long or real price changes get ignored" - # Weight reduction during volatility - moderate to prevent chasing erratic prices - # Dec 1, 2025: Changed to 0.25-0.35 range (25-35% retention) after int accumulation fix + # Weight reduction during volatility - moderate (25-35% retention) to avoid chasing prices. assert ( 0.25 <= VOLATILE_WEIGHT_REDUCTION <= 0.35 ), f"Weight reduction should be moderate (25-35% retention), got {VOLATILE_WEIGHT_REDUCTION}" @@ -759,20 +425,9 @@ def test_constants_relationship(self): @freeze_time("2025-01-15 17:15:00") def test_peak_cluster_expensive_between_peaks(self, engine, base_nibe_state, base_weather_data): - """Test PEAK cluster: EXPENSIVE quarter sandwiched between PEAKs uses PEAK offset. - - Scenario (real-world evening peak pattern): - - Q68 (17:00): PEAK (95 öre) - - Q69 (17:15): EXPENSIVE (85 öre) - current quarter, sandwiched - - Q70 (17:30): PEAK (92 öre) - - Q71 (17:45): PEAK (90 öre) - - Expected: - - EXPENSIVE at Q69 is volatile (run length = 1) - - But PEAK+EXPENSIVE cluster run = 4 (>=3 threshold) - - Therefore EXPENSIVE should inherit PEAK behavior: - - Use PEAK offset (-10.0 or scaled) - - Weight = 1.0 (critical priority) + """An EXPENSIVE quarter sandwiched between PEAKs (a PEAK+EXPENSIVE cluster run >= 3) inherits + PEAK behavior: weight 1.0 (critical) and an aggressive negative offset, not the small + EXPENSIVE reduction. """ # Build evening price data with PEAK cluster pattern price_periods = [] 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_weather_comp_layer_evaluate.py b/tests/unit/optimization/test_weather_comp_layer_evaluate.py index 4cc6a67e..0652e75e 100644 --- a/tests/unit/optimization/test_weather_comp_layer_evaluate.py +++ b/tests/unit/optimization/test_weather_comp_layer_evaluate.py @@ -101,27 +101,33 @@ def test_disabled_returns_zero(self): assert result.weight == 0.0 assert result.reason == "Disabled" - def test_no_weather_data_returns_zero(self): - """Test that missing weather data returns zero offset/weight.""" + def test_no_weather_data_still_runs_the_emitter_law(self): + """Math WC is the EN 442 emitter law over the pump's own outdoor and flow sensors. + + It has never read the forecast, so a missing weather entity (vol.Optional in the config + flow) must not switch it off - it needs the outdoor temperature, not a forecast. + """ layer = self._create_layer() - nibe_state = MockNibeState() + nibe_state = MockNibeState(outdoor_temp=-5.0, flow_temp=25.0) result = layer.evaluate_layer( nibe_state=nibe_state, - weather_data=None, + weather_data=None, # what WeatherAdapter returns with no entity configured target_temp=21.0, enable_weather_compensation=True, ) assert result.name == "Math WC" - assert result.offset == 0.0 - assert result.weight == 0.0 - assert result.reason == "No weather data" + assert result.weight > 0.0, f"Math WC abstained with no forecast: {result.reason!r}" + assert result.offset > 0.0, ( + "the flow is 25C at -5C outdoor, far below what the radiators need - the emitter law " + "must call for heat. It needs the outdoor temperature, not a forecast." + ) - def test_empty_forecast_returns_zero(self): - """Test that empty forecast returns zero offset/weight.""" + def test_empty_forecast_still_runs_the_emitter_law(self): + """Same defect, reached by the other path: an entity that returns no forecast hours.""" layer = self._create_layer() - nibe_state = MockNibeState() + nibe_state = MockNibeState(outdoor_temp=-5.0, flow_temp=25.0) weather_data = MockWeatherData(forecast_hours=[]) result = layer.evaluate_layer( @@ -132,9 +138,28 @@ def test_empty_forecast_returns_zero(self): ) assert result.name == "Math WC" - assert result.offset == 0.0 - assert result.weight == 0.0 - assert result.reason == "No weather data" + assert result.weight > 0.0, f"Math WC abstained on an empty forecast: {result.reason!r}" + assert result.offset > 0.0 + + def test_the_weather_learner_stands_down_without_a_forecast(self): + """The half that DOES need the forecast must still abstain - and must not crash on None. + + Unusual-weather detection is the one consumer of `weather_data` in this layer. Fixing the + emitter law must not drag the learner along, and must not leave it dereferencing None. + """ + learner = MagicMock() + layer = self._create_layer(weather_learner=learner) + nibe_state = MockNibeState(outdoor_temp=-5.0, flow_temp=25.0) + + result = layer.evaluate_layer( + nibe_state=nibe_state, + weather_data=None, + target_temp=21.0, + enable_weather_compensation=True, + ) + + learner.detect_unusual_weather.assert_not_called() + assert result.weight > 0.0 and not result.unusual_weather def test_returns_weather_compensation_layer_decision(self): """Test that result is WeatherCompensationLayerDecision with diagnostic fields.""" 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..26e36803 --- /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 * 4, # 10:00, a daytime quarter + 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 * 4, # 10:00, a daytime quarter + 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 * 4, # 10:00, a daytime quarter + 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 * 4, # 10:00, a daytime quarter + 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..aa0709e2 --- /dev/null +++ b/tests/unit/utils/test_the_pump_does_what_the_engine_asked.py @@ -0,0 +1,84 @@ +"""`integer_offset_for` applies whole demand-backed degrees: truncate, deadband, clamp. + +Truncation (int(), main's original design) is deliberate: the caller recomputes pending +demand every cycle as (calculated - register), so only the WHOLE degrees the demand covers +are applied and the fraction stays pending - never lost, never over-applied. round() would +write up to 0.5 C the engine did not ask for, then oscillate back when the recomputed demand +reversed sign. The 1 C deadband is hysteresis for a rate-limited register; the result is +clamped to the register's range. Shared with the simulation harness. +""" + +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 TestWholeDegreesOnly: + """Only the integer part of the demand reaches the register; the fraction stays pending.""" + + @pytest.mark.parametrize( + ("demand", "expected"), + [ + (-1.9, -1), # the 0.9 stays pending, re-derived next cycle + (1.9, 1), + (-2.6, -2), + (2.6, 2), + (1.0, 1), # exactly one degree is fully backed + (-1.0, -1), + ], + ) + def test_truncation_applies_only_backed_degrees(self, demand, expected): + assert integer_offset_for(calculated=demand, current=0) == expected + + def test_truncation_is_symmetric_toward_zero(self): + assert integer_offset_for(1.9, 0) == -integer_offset_for(-1.9, 0) + + def test_the_register_is_never_pushed_past_the_demand(self): + """The property truncation buys: the pump never does MORE than the engine asked.""" + for tenths in range(-100, 101): + calculated = tenths / 10.0 + written = integer_offset_for(calculated, current=0) + assert abs(written) <= abs(calculated) + 1e-9, ( + f"demand {calculated:+.1f} wrote {written:+d} - the register got more than " + f"the engine asked for, which truncation exists to prevent" + ) + + def test_the_pending_fraction_is_not_lost(self): + """Held demand converges as the fraction is re-derived against the updated register. + + Cycle 1 at +2.6 from 0 writes +2 (0.6 pending). If the engine's demand grows to + +3.1, the recomputed pending demand (1.1) crosses a whole degree and writes +3. + Nothing was truncated away permanently. + """ + first = integer_offset_for(2.6, current=0) + assert first == 2 + + second = integer_offset_for(3.1, current=first) + assert second == 3 + + +class TestTheDeadbandIsDeliberate: + def test_a_demand_that_has_barely_moved_does_not_rewrite_the_register(self): + assert integer_offset_for(2.4, current=2) == 2 + + def test_the_threshold_is_a_whole_degree(self): + assert NIBE_FRACTIONAL_ACCUMULATOR_THRESHOLD == 1.0 + + def test_it_settles_rather_than_oscillating(self): + """Demand wandering inside the deadband around the held value writes nothing.""" + current = 2 + for calculated in (2.3, 1.7, 2.4, 1.6, 2.0): + assert integer_offset_for(calculated, current) == current + + +class TestTheClamp: + def test_the_offset_is_clamped_to_what_the_register_can_hold(self): + assert integer_offset_for(25.0, current=0) == MAX_OFFSET + assert integer_offset_for(-25.0, current=0) == MIN_OFFSET diff --git a/tests/validation/hardcoded_values_baseline.json b/tests/validation/hardcoded_values_baseline.json new file mode 100644 index 00000000..0aeface9 --- /dev/null +++ b/tests/validation/hardcoded_values_baseline.json @@ -0,0 +1,27 @@ +{ + "custom_components/effektguard/__init__.py": 9, + "custom_components/effektguard/adapters/nibe_adapter.py": 6, + "custom_components/effektguard/adapters/weather_adapter.py": 2, + "custom_components/effektguard/coordinator.py": 6, + "custom_components/effektguard/models/base.py": 7, + "custom_components/effektguard/models/nibe/f1155.py": 32, + "custom_components/effektguard/models/nibe/f2040.py": 39, + "custom_components/effektguard/models/nibe/f730.py": 41, + "custom_components/effektguard/models/nibe/f750.py": 46, + "custom_components/effektguard/models/nibe/s1155.py": 40, + "custom_components/effektguard/optimization/adaptive_learning.py": 48, + "custom_components/effektguard/optimization/climate_zones.py": 26, + "custom_components/effektguard/optimization/comfort_layer.py": 5, + "custom_components/effektguard/optimization/decision_engine.py": 4, + "custom_components/effektguard/optimization/dhw_optimizer.py": 46, + "custom_components/effektguard/optimization/effect_layer.py": 1, + "custom_components/effektguard/optimization/prediction_layer.py": 16, + "custom_components/effektguard/optimization/price_layer.py": 10, + "custom_components/effektguard/optimization/savings_calculator.py": 1, + "custom_components/effektguard/optimization/thermal_layer.py": 7, + "custom_components/effektguard/optimization/weather_layer.py": 14, + "custom_components/effektguard/optimization/weather_learning.py": 50, + "custom_components/effektguard/options.py": 12, + "custom_components/effektguard/sensor.py": 14, + "custom_components/effektguard/utils/compressor_monitor.py": 15 +} 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_hardcoded_values.py b/tests/validation/test_no_hardcoded_values.py index 714014fc..bb8bbafb 100644 --- a/tests/validation/test_no_hardcoded_values.py +++ b/tests/validation/test_no_hardcoded_values.py @@ -1,324 +1,126 @@ -"""Test to ensure no hardcoded numeric values in production code. +"""Enforce the constants-only rule: no NEW hardcoded numeric values in production code. -This test enforces the constants-only rule: All numeric values, thresholds, -and tuning parameters MUST be defined in const.py and imported where needed. +The rule (.github/copilot-instructions.md, rules 3 and 4) puts every numeric threshold, tunable, +physical constant, interval and safety limit in const.py, documented and reused. A hardcoded +`weight >= 0.85` gate once stopped matching DM_CRITICAL_T2_WEIGHT after that constant was retuned +to 0.81, letting a cost layer override thermal-debt recovery: the constant moved, the magic number +did not. -STRICT MODE: Catches all numeric literals in production code. +`scripts/check_hardcoded_values.py` is AST-based and high-signal, but there are still too many +existing hits to fix in one go, so this is a RATCHET, not a gate: -Allowed exceptions: -- const.py (constants definition file) -- Initialization to 0 or 0.0 (neutral values) -- Unit conversions (3600 for seconds->hours, etc.) -- Type hints with Final annotation + - `tests/validation/hardcoded_values_baseline.json` records the accepted count PER FILE. + - Adding a magic number to any file exceeds its baseline -> the test FAILS. + - Removing magic numbers is always allowed; lower the baseline when you do. + +To regenerate the baseline deliberately (e.g. after moving values into const.py): + python scripts/check_hardcoded_values.py --baseline """ -import re +import json +import sys from pathlib import Path import pytest +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) -# Files/directories to exclude from checks -EXCLUDE_FILES = [ - "const.py", # Constants definition file - allowed to have values -] - -# Allowed patterns (very restrictive list) -ALLOWED_PATTERNS = [ - # const.py Final declarations - r":\s*Final\s*=", - # Initialization to 0 or 0.0 (neutral/no-op values only) - r"=\s*0\.0\s*$", - r"=\s*0\s*$", - # Unit conversion constants (seconds in hour, etc.) - r"/\s*3600\s*(?:#.*seconds)", # seconds to hours: / 3600 - r"\*\s*3600\s*(?:#.*hours)", # hours to seconds: * 3600 -] - - -def should_check_file(filepath: Path) -> bool: - """Determine if file should be checked for hardcoded values.""" - # Must be a Python file in custom_components/effektguard - if filepath.suffix != ".py": - return False - - filepath_str = str(filepath) - if not "custom_components/effektguard" in filepath_str: - return False - - # Exclude specific files - filename = filepath.name - if filename in EXCLUDE_FILES: - return False - - return True - - -def is_allowed_line(line: str) -> bool: - """Check if line is allowed to have numeric values.""" - line_stripped = line.strip() - - # Skip empty lines and pure comments - if not line_stripped or line_stripped.startswith("#"): - return True - - # Check if line is in docstring (contains triple quotes) - if '"""' in line or "'''" in line: - return True - - # Check allowed patterns - for pattern in ALLOWED_PATTERNS: - if re.search(pattern, line): - return True - - return False - - -def find_numeric_literals(line: str) -> list[str]: - """Find all numeric literals in a line of code. - - Returns list of numeric literals found (for reporting). - """ - # Pattern for numeric literals (integers and floats) - # Matches: 6.0, -60, 45, 0.5, -0.3, etc. - pattern = r"-?\d+\.?\d*" - - matches = re.findall(pattern, line) - - # Filter out matches that are just "0" or "0.0" (allowed neutral values) - filtered = [] - for match in matches: - if match in ["0", "0.0"]: - continue - # Skip if it's part of a unit conversion (3600) - if match == "3600" and ("3600" in line and "seconds" in line.lower()): - continue - filtered.append(match) - - return filtered - - -def find_hardcoded_values(filepath: Path) -> list[tuple[int, str, list[str]]]: - """Find hardcoded numeric values in a Python file. - - Returns: - List of (line_number, line_content, literals) tuples with issues - """ - issues = [] - - try: - with open(filepath, "r", encoding="utf-8") as f: - in_multiline_string = False - string_delimiter = None - - for line_num, line in enumerate(f, start=1): - # Track multiline strings (docstrings) - for delim in ['"""', "'''"]: - count = line.count(delim) - if count > 0: - if count % 2 == 1: # Odd number of delimiters - if not in_multiline_string: - in_multiline_string = True - string_delimiter = delim - elif string_delimiter == delim: - in_multiline_string = False - string_delimiter = None - - # Skip lines in docstrings - if in_multiline_string: - continue - - # Skip allowed lines - if is_allowed_line(line): - continue - - # Find numeric literals - literals = find_numeric_literals(line) - if literals: - issues.append((line_num, line.strip(), literals)) +from check_hardcoded_values import ( # noqa: E402 + BASELINE_PATH, + check_against_baseline, + counts, + load_baseline, + scan_production, +) - except Exception as e: - pytest.fail(f"Error reading {filepath}: {e}") - return issues +def test_no_new_hardcoded_values_in_production(): + """No file may contain MORE hardcoded numeric values than its baseline allows. - -def test_no_hardcoded_values_in_production(): - """Verify no hardcoded numeric values in production code. - - This is a STRICT test that catches ALL numeric literals except: - - const.py (where constants are defined) - - Initialization to 0 or 0.0 - - Unit conversion constant 3600 (seconds/hour) - - NOTE: Temporarily disabled - use scripts/check_hardcoded_values.py instead + This is the live enforcement of the constants-only rule. If it fails, you added a magic + number: move it into const.py with a descriptive name and a comment explaining where the + value comes from. """ - return # Disabled - too many violations (1,196+), use on-demand script - - root_dir = Path("/workspaces/EffektGuard") - prod_dir = root_dir / "custom_components" / "effektguard" - - if not prod_dir.exists(): - pytest.skip(f"Production directory not found: {prod_dir}") - - all_issues = {} - - # Scan all Python files in production code - for py_file in prod_dir.rglob("*.py"): - if should_check_file(py_file): - issues = find_hardcoded_values(py_file) - if issues: - relative_path = py_file.relative_to(root_dir) - all_issues[str(relative_path)] = issues - - # Report findings - if all_issues: - error_msg = [ - "\n" + "=" * 80, - "❌ HARDCODED NUMERIC VALUES FOUND IN PRODUCTION CODE", - "=" * 80, - "\n🚨 CONSTANTS-ONLY RULE VIOLATION", - "\nAll numeric values, thresholds, and tuning parameters MUST be constants", - "defined in const.py and imported where needed.", - "\n" + "=" * 80, - "\nViolations found:\n", - ] - - total_issues = 0 - for filepath, issues in sorted(all_issues.items()): - error_msg.append(f"\n📁 {filepath}") - error_msg.append("─" * 80) - for line_num, line_content, literals in issues: - literals_str = ", ".join(literals) - error_msg.append(f" Line {line_num:4d}: {line_content}") - error_msg.append(f" ⚠️ Hardcoded: {literals_str}") - total_issues += 1 - - error_msg.extend( - [ - "\n" + "=" * 80, - f"\n📊 Total violations: {total_issues}", - "\n📚 HOW TO FIX:", - " 1. Add the value as a constant in const.py", - " 2. Use descriptive naming: CATEGORY_PROPERTY_VARIANT", - " 3. Import the constant where needed", - " 4. Replace hardcoded value with constant reference", - "\n💡 See .github/copilot-instructions.md for naming conventions", - "=" * 80 + "\n", - ] + regressions = check_against_baseline(counts(scan_production()), load_baseline()) + + if regressions: + pytest.fail( + "New hardcoded numeric values were introduced (constants-only rule):\n\n" + + "\n".join(f" {line}" for line in regressions) + + "\n\nMove each value into const.py with a descriptive name " + "(CATEGORY_PROPERTY_VARIANT) and a comment recording its source.\n" + "Run `python scripts/check_hardcoded_values.py` to list them.\n" + "If you genuinely intend to accept them, regenerate the baseline with\n" + "`python scripts/check_hardcoded_values.py --baseline` and say why in the commit." ) - pytest.fail("\n".join(error_msg)) - - -def test_no_duplicate_constants(): - """Verify no duplicate constant values in const.py. - Detects when the same numeric value is defined with different constant names. - This violates the single source of truth principle and makes maintenance harder. +def test_baseline_is_present_and_honest(): + """The baseline must exist and must not silently drift above the recorded debt. - Allowed exceptions: - - Common values like 1.0, 0.5, 0.0 that have different semantic meanings - - Related but distinct concepts (e.g., different layer weights) - - NOTE: Temporarily disabled - use scripts/check_duplicate_constants.py instead + Guards the guard: a baseline that has been regenerated upward without anyone noticing + would quietly re-disable the rule, which is exactly how the previous version died. """ - return # Disabled - 85 legitimate duplicates remain, use on-demand script - - const_file = Path("/workspaces/EffektGuard/custom_components/effektguard/const.py") - - if not const_file.exists(): - pytest.skip("const.py not found") - - # Parse constants from const.py - constants = {} # value -> list of (name, line_num) - - with open(const_file, "r", encoding="utf-8") as f: - for line_num, line in enumerate(f, start=1): - # Match pattern: CONSTANT_NAME: Final = value - match = re.match( - r"^\s*([A-Z_][A-Z0-9_]*)\s*:\s*Final\s*=\s*(-?\d+\.?\d*)\s*(?:#.*)?$", line - ) - if match: - const_name = match.group(1) - const_value = match.group(2) - - # Skip common values that legitimately appear multiple times - if const_value in ["0", "0.0", "1", "1.0", "0.5"]: - continue - - if const_value not in constants: - constants[const_value] = [] - constants[const_value].append((const_name, line_num)) - - # Find duplicates - duplicates = {value: names for value, names in constants.items() if len(names) > 1} - - if duplicates: - error_msg = [ - "\n" + "=" * 80, - "❌ DUPLICATE CONSTANT VALUES FOUND", - "=" * 80, - "\n🚨 SINGLE SOURCE OF TRUTH VIOLATION", - "\nThe same numeric value is defined with different constant names.", - "This makes maintenance harder and can lead to inconsistencies.", - "\n" + "=" * 80, - "\nDuplicates found:\n", - ] - - total_duplicates = 0 - for value, names_list in sorted(duplicates.items()): - error_msg.append(f"\n💥 Value: {value}") - error_msg.append("─" * 80) - for const_name, line_num in names_list: - error_msg.append(f" Line {line_num:4d}: {const_name}") - total_duplicates += 1 + assert BASELINE_PATH.exists(), ( + f"{BASELINE_PATH} is missing. Generate it with " + "`python scripts/check_hardcoded_values.py --baseline`." + ) + + baseline = json.loads(BASELINE_PATH.read_text(encoding="utf-8")) + assert baseline, "The baseline is empty - the checker is probably not scanning anything." + + total = sum(baseline.values()) + # A tripwire, not a target. If the debt grows past this, someone regenerated the baseline + # to make a failure go away. Lower it as the debt is paid down; do not raise it. + max_accepted_debt = 510 + assert total <= max_accepted_debt, ( + f"Recorded hardcoded-value debt is {total}, above the accepted ceiling of " + f"{max_accepted_debt}. The baseline was regenerated upward instead of the values " + "being moved into const.py." + ) + + +def test_the_checker_actually_detects_a_magic_number(): + """Guard against the checker silently becoming a no-op. + + The previous enforcement reported PASSED while detecting nothing. A checker that finds + zero violations in a codebase with known debt is broken, not clean. + """ + results = scan_production() - error_msg.extend( - [ - "\n" + "=" * 80, - f"\n📊 Total duplicate definitions: {total_duplicates}", - "\n📚 HOW TO FIX:", - " 1. Determine which constant name is most descriptive", - " 2. Search for all usages of the duplicate constants", - " 3. Replace all usages with the single chosen constant", - " 4. Remove the duplicate constant definitions", - "\n💡 If values are legitimately different concepts, rename to clarify distinction", - "=" * 80 + "\n", - ] - ) + assert results, ( + "The hardcoded-value checker found NOTHING in production code. It is almost certainly " + "broken (wrong path, or an exception swallowed). It must not pass vacuously." + ) - pytest.fail("\n".join(error_msg)) + # The DM/°C slope in climate_zones.py is the single most load-bearing magic number in the + # system - every climate-aware DM threshold scales with it. If the checker cannot see that + # one, it is not doing its job. + climate_zones = "custom_components/effektguard/optimization/climate_zones.py" + assert climate_zones in results, ( + f"The checker no longer detects the known magic numbers in {climate_zones}. " + "Its detection logic has regressed." + ) def test_test_files_can_use_production_constants(): """Verify test files can import from production const.py.""" - try: - from custom_components.effektguard.const import ( - COMFORT_CORRECTION_MULT, - COMFORT_DEAD_ZONE, - LAYER_WEIGHT_WEATHER_PREDICTION, - PRICE_TOLERANCE_MIN, - PRICE_TOLERANCE_MAX, - PRICE_TOLERANCE_FACTOR_MIN, - PRICE_TOLERANCE_FACTOR_MAX, - TOLERANCE_RANGE_MULTIPLIER, - ) - - # Verify constants have expected types - assert isinstance(TOLERANCE_RANGE_MULTIPLIER, (int, float)) - assert isinstance(LAYER_WEIGHT_WEATHER_PREDICTION, (int, float)) - assert isinstance(PRICE_TOLERANCE_MIN, (int, float)) - assert isinstance(PRICE_TOLERANCE_MAX, (int, float)) - assert isinstance(PRICE_TOLERANCE_FACTOR_MIN, (int, float)) - assert isinstance(PRICE_TOLERANCE_FACTOR_MAX, (int, float)) - assert isinstance(COMFORT_DEAD_ZONE, (int, float)) - assert isinstance(COMFORT_CORRECTION_MULT, (int, float)) - - except ImportError as e: - pytest.fail(f"Cannot import production constants in tests: {e}") - - -if __name__ == "__main__": - # Run tests when executed directly - pytest.main([__file__, "-v"]) + from custom_components.effektguard.const import ( + COMFORT_CORRECTION_MULT, + COMFORT_DEAD_ZONE, + LAYER_WEIGHT_WEATHER_PREDICTION, + PRICE_TOLERANCE_FACTOR_MAX, + PRICE_TOLERANCE_FACTOR_MIN, + PRICE_TOLERANCE_MAX, + PRICE_TOLERANCE_MIN, + TOLERANCE_RANGE_MULTIPLIER, + ) + + # These must be real numbers, not accidentally-zeroed placeholders. + assert 0 < TOLERANCE_RANGE_MULTIPLIER <= 1 + assert 0 < LAYER_WEIGHT_WEATHER_PREDICTION <= 1 + assert PRICE_TOLERANCE_MIN < PRICE_TOLERANCE_MAX + assert PRICE_TOLERANCE_FACTOR_MIN < PRICE_TOLERANCE_FACTOR_MAX + assert COMFORT_DEAD_ZONE > 0 + assert COMFORT_CORRECTION_MULT > 0 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_arctic_stops_the_air_source_pump.py b/tests/validation/test_the_arctic_stops_the_air_source_pump.py new file mode 100644 index 00000000..55e8a148 --- /dev/null +++ b/tests/validation/test_the_arctic_stops_the_air_source_pump.py @@ -0,0 +1,97 @@ +"""Below -20 C outdoor, the F2040 does not run. NIBE's manual says so; the plant must too. + +The F2040 installer manual publishes an operating range - "Min. / Max. air temp: -20 / 43 C" - +and the profile has carried that number (f2040.py, MIN_AIR_TEMP_C) since the datasheet audit. +It was referenced NOWHERE: the simulated plant held the compressor's capacity at its coldest +published point forever, so at Kiruna temperatures the model made phantom heat with a machine +that is switched off in reality. + +Real January 2024 in Kiruna (Open-Meteo ERA5, scripts/simulation/data/weather_kiruna_jan2024.json) +spends 211 of 744 hours - 28% of the month - below that floor, with a minimum of -36.8 C. A plant +that keeps an F2040 running through that is not a model of the machine, it is a model of a wish. + +The cutoff is STRICTLY below the floor and F2040-only: + * At exactly -20.0 C the machine is inside its published range and the existing datasheet pins + (capacity at -20, cop_at at -20, Carnot sweeps) must keep holding. + * The other four machines do not have the outdoor air as their heat source. A brine pump's + source sits at 0 C and an exhaust-air pump breathes 20 C house air whatever the weather does; + NIBE publishes no outdoor operating floor for them, so the model imposes none. +""" + +from __future__ import annotations + +import importlib.util +import pathlib + +import pytest + +from custom_components.effektguard.models.nibe.f2040 import MIN_AIR_TEMP_C + +_SPEC = importlib.util.spec_from_file_location( + "sim_harness", pathlib.Path("scripts/simulation/sim_harness.py") +) +sim_harness = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(sim_harness) + +HOUSES = {house.name: house for house in sim_harness.HOUSES} +F2040 = HOUSES["airsource_f2040"] +KIRUNA_MINIMUM_C = -36.8 # the real ERA5 minimum, January 2024 + + +def test_the_f2040_stops_strictly_below_its_published_floor(): + """The manual's operating range is a hard edge, not a derating.""" + assert F2040.capacity_kw_at(MIN_AIR_TEMP_C) > 0.0, ( + f"at exactly {MIN_AIR_TEMP_C} C the F2040 is INSIDE its published operating range " + f"('Min. / Max. air temp: -20 / 43 C') and must still deliver heat - the datasheet " + f"pins at -20 depend on it." + ) + assert F2040.capacity_kw_at(MIN_AIR_TEMP_C - 0.1) == 0.0, ( + "0.1 C below the published floor the machine does not operate. The manual gives a " + "range, not a curve; outside it there is no compressor heat to model." + ) + assert F2040.capacity_kw_at(KIRUNA_MINIMUM_C) == 0.0, ( + "at the real Kiruna January minimum (-36.8 C, ERA5) the F2040 is 16.8 C below its " + "operating floor. Holding its -7 C capacity here is phantom heat." + ) + + +@pytest.mark.parametrize("name", ["wooden_f750", "apartment_f730", "concrete_f1155", "villa_s1155"]) +def test_the_indoor_sourced_machines_run_through_the_arctic_night(name): + """No invented floors. NIBE publishes no outdoor operating limit for these machines. + + Their heat sources are 20 C extract air and 0 C brine - the weather never touches them. + An arctic cutoff applied to all five machines would be exactly the kind of unsourced + physics this audit exists to remove. + """ + house = HOUSES[name] + assert house.capacity_kw_at(KIRUNA_MINIMUM_C) > 0.0, ( + f"{name} lost its capacity at -36.8 C outdoor. Its heat source is indoors (or in the " + f"ground); NIBE publishes no outdoor floor for it, so the model must not invent one." + ) + + +def test_cop_stays_finite_at_the_floor_itself(): + """The Carnot sweeps and datasheet pins evaluate cop_at(-20.0); it must stay a real COP.""" + cop = F2040.cop_at(MIN_AIR_TEMP_C, 35.0) + assert 1.0 <= cop < F2040.carnot_cop(MIN_AIR_TEMP_C, 35.0), ( + f"cop_at({MIN_AIR_TEMP_C}) returned {cop}. At the edge of the range the machine still " + f"runs; the cutoff zeroes CAPACITY strictly below the floor, never the COP - a COP " + f"sentinel would poison the mean-COP and Carnot accounting." + ) + + +def test_the_plant_does_not_lie_to_the_decision_engine_below_the_floor(): + """With the compressor physically stopped, the simulated NibeState must say so. + + Zeroing capacity alone leaves `compressor_on` True, so the plant would report + compressor_hz > 0 and is_heating=True for a machine that is off - and the DecisionEngine + under test would be optimising a lie. The harness exposes the availability rule so this + test fails if the reported state is decoupled from the physics. + """ + assert hasattr(sim_harness, "compressor_available"), ( + "sim_harness must expose compressor_available(house, outdoor_c) - the single rule that " + "both the plant physics and the reported NibeState derive from." + ) + assert sim_harness.compressor_available(F2040, MIN_AIR_TEMP_C) is True + assert sim_harness.compressor_available(F2040, MIN_AIR_TEMP_C - 0.1) is False + assert sim_harness.compressor_available(HOUSES["villa_s1155"], KIRUNA_MINIMUM_C) is True 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." + )