From 6d414c7b51a18919a627c225e285e017937cf49e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 14:18:16 +0000 Subject: [PATCH 01/12] =?UTF-8?q?Air-source=20heat-pump=20overrides=20drag?= =?UTF-8?q?=20the=20conservative=20Group=202=20control=20(2201)=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../epc/test_main_heating_system_overlay.py | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index b42effd9b..795a9defe 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -95,17 +95,34 @@ def test_electric_room_heaters_overlay_the_room_heater_charge_control() -> None: def test_overlay_logs_when_it_cannot_complete_a_companion_set( caplog: pytest.LogCaptureFixture, ) -> None: - # An archetype the overlay cannot yet fully companion — here an air-source - # heat pump, whose Table 4e Group 2 conservative control is not yet mapped — + # An archetype the overlay cannot yet fully companion — here community + # heating, whose Table 4e Group 3 conservative control is not yet mapped — # is still overlaid (log-and-continue), but the incomplete companion set is # logged as an error so the known gap is visible (CloudWatch) rather than # silently inheriting the replaced system's stale value. # Act with caplog.at_level(logging.ERROR): - main_heating_overlay_for("Air source heat pump", 0) + main_heating_overlay_for("Community heating, boilers", 0) # Assert — the gap is reported and names the archetype. - assert "Air source heat pump" in caplog.text + assert "Community heating, boilers" in caplog.text + + +def test_air_source_heat_pump_drags_the_conservative_group_2_control() -> None: + # The landlord names the system, not its control, so the overlay assumes + # the conservative Table 4e Group 2 default: 2201 ("no time or thermostatic + # control", +0.3 C — the largest Group 2 adjustment, so an unobserved + # control is never over-credited; the heat-pump mirror of storage 2401 / + # boiler 2101). Deliberately NOT the ASHP Measure's 2210 zone control — + # a measure designs a modern end-state (ADR-0024); an override describes an + # existing pump (ADR-0053). + # Act + simulation = main_heating_overlay_for("Air source heat pump", 0) + + # Assert — SAP Table 4e Group 2 conservative heat-pump control. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_heating_control == 2201 def test_complete_room_heater_overlay_does_not_log_a_companion_gap( From 6bad3a216a6a92ee2641b2685e4a3304e19a9e17 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 14:19:20 +0000 Subject: [PATCH 02/12] =?UTF-8?q?Air-source=20heat-pump=20overrides=20drag?= =?UTF-8?q?=20the=20conservative=20Group=202=20control=20(2201)=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../main_heating_system_overlay.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 2053423b9..af334f41c 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -168,6 +168,17 @@ _UNDERFLOOR_CONTROL = 2701 # without a PCDB index (ADR-0041). _HEAT_PUMP_CATEGORY = 4 _HEAT_PUMP_CODES = frozenset(range(211, 225)) | frozenset(range(521, 528)) +# SAP Table 4e Group 2 heat-pump control: 2201 ("no time or thermostatic +# control of room temperature", +0.3 C) is the conservative default when the +# landlord named only the system — the largest Group 2 adjustment, so an +# unobserved control is never over-credited. Deliberately NOT the ASHP +# Measure's 2210 zone control: a measure designs a modern end-state +# (ADR-0024); an override describes an existing pump (ADR-0053). Scoped to the +# WET heat pumps (211-224) — warm-air pumps (521-527) take Table 4e Group 5 +# and stay unmapped (no archetype maps to them; the incomplete-companion raise +# guards that a future one cannot land without choosing its Group 5 control). +_WET_HEAT_PUMP_CODES = frozenset(range(211, 225)) +_CONSERVATIVE_HEAT_PUMP_CONTROL = 2201 # Community / heat-network heating (SAP Table 4a 301-304) is category 6; the # calculator's `_is_heat_network` keys off code OR category 6. The boiler-driven @@ -305,6 +316,8 @@ def _control_for(code: int) -> Optional[int]: return _UNDERFLOOR_CONTROL if code in _ELECTRIC_BOILER_CODES: return _CONSERVATIVE_BOILER_CONTROL + if code in _WET_HEAT_PUMP_CODES: + return _CONSERVATIVE_HEAT_PUMP_CONTROL return None From 950ec31d84a52717b67602bc1aab83b2d142c0f8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 14:19:52 +0000 Subject: [PATCH 03/12] =?UTF-8?q?Community-heating=20overrides=20drag=20th?= =?UTF-8?q?e=20conservative=20Group=203=20control=20(2301)=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../epc/test_main_heating_system_overlay.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 795a9defe..c092c5114 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -108,6 +108,30 @@ def test_overlay_logs_when_it_cannot_complete_a_companion_set( assert "Community heating, boilers" in caplog.text +@pytest.mark.parametrize( + "main_heating_value", + ["Community heating, boilers", "Community heating, CHP and boilers"], +) +def test_community_heating_drags_the_conservative_group_3_control( + main_heating_value: str, +) -> None: + # The landlord names the scheme, not its control or charging arrangement. + # The conservative Table 4e Group 3 default is 2301 ("flat rate charging, + # no thermostatic control of room temperature"): the largest Group 3 + # temperature adjustment (+0.3 C) AND the worst Table 4c(3) charging + # factors (space 1.10, DHW 1.05) — an unobserved control is never + # over-credited and an unobserved charging arrangement is never assumed + # usage-linked (ADR-0053). Leaving it unset inherits the replaced system's + # control instead (the ADR-0048 bug class). + # Act + simulation = main_heating_overlay_for(main_heating_value, 0) + + # Assert — SAP Table 4e Group 3 conservative heat-network control. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_heating_control == 2301 + + def test_air_source_heat_pump_drags_the_conservative_group_2_control() -> None: # The landlord names the system, not its control, so the overlay assumes # the conservative Table 4e Group 2 default: 2201 ("no time or thermostatic From 61f9a63b66fa2f456bb0a1a2b41d55c9451ff1ce Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 14:21:03 +0000 Subject: [PATCH 04/12] =?UTF-8?q?Community-heating=20overrides=20drag=20th?= =?UTF-8?q?e=20conservative=20Group=203=20control=20(2301)=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last real archetype on the companion-gap log; the log test now simulates a future incomplete archetype (warm-air heat pump, Group 5 control deliberately unmapped). Co-Authored-By: Claude Opus 4.8 --- .../main_heating_system_overlay.py | 10 ++++++++++ .../epc/test_main_heating_system_overlay.py | 20 ++++++++++++------- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index af334f41c..b8c945393 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -188,6 +188,14 @@ _CONSERVATIVE_HEAT_PUMP_CONTROL = 2201 _HEAT_NETWORK_CATEGORY = 6 _HEAT_NETWORK_CODES = frozenset({301, 302, 303, 304}) _COMMUNITY_BOILER_CODES = frozenset({301, 302, 303}) +# SAP Table 4e Group 3 heat-network control: 2301 ("flat rate charging, no +# thermostatic control of room temperature") is the conservative default when +# the landlord named only the scheme. It is never-over-crediting on BOTH axes +# the control drives: the largest Group 3 temperature adjustment (+0.3 C, tied +# with 2302) and the worst Table 4c(3) charging factors (space 1.10, DHW 1.05 +# — an unobserved charging arrangement is never assumed usage-linked). The +# community mirror of storage 2401 / boiler 2101 / heat-pump 2201 (ADR-0053). +_CONSERVATIVE_HEAT_NETWORK_CONTROL = 2301 _COMMUNITY_GAS_FUEL = 20 # SAP Table 4c full boiler-control code: programmer + room thermostat + TRVs. The @@ -318,6 +326,8 @@ def _control_for(code: int) -> Optional[int]: return _CONSERVATIVE_BOILER_CONTROL if code in _WET_HEAT_PUMP_CODES: return _CONSERVATIVE_HEAT_PUMP_CONTROL + if code in _HEAT_NETWORK_CODES: + return _CONSERVATIVE_HEAT_NETWORK_CONTROL return None diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index c092c5114..fb856b75c 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -94,18 +94,24 @@ def test_electric_room_heaters_overlay_the_room_heater_charge_control() -> None: def test_overlay_logs_when_it_cannot_complete_a_companion_set( caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, ) -> None: - # An archetype the overlay cannot yet fully companion — here community - # heating, whose Table 4e Group 3 conservative control is not yet mapped — - # is still overlaid (log-and-continue), but the incomplete companion set is - # logged as an error so the known gap is visible (CloudWatch) rather than - # silently inheriting the replaced system's stale value. + # Every real archetype now resolves a complete companion set (ADR-0053), + # so the incomplete case is simulated: a hypothetical warm-air heat-pump + # archetype (SAP 521 — category 4 but its Table 4e Group 5 control is + # deliberately unmapped). The overlay still applies (log-and-continue), + # but the incomplete companion set is reported rather than silently + # inheriting the replaced system's stale value. + from domain.epc.property_overlays import main_heating_system_overlay as mod + + monkeypatch.setitem(mod._MAIN_HEATING_CODES, "Warm-air heat pump", 521) + # Act with caplog.at_level(logging.ERROR): - main_heating_overlay_for("Community heating, boilers", 0) + main_heating_overlay_for("Warm-air heat pump", 0) # Assert — the gap is reported and names the archetype. - assert "Community heating, boilers" in caplog.text + assert "Warm-air heat pump" in caplog.text @pytest.mark.parametrize( From 1925cdd65d68cc03f7b505da69d81e240ae75fc1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 14:21:34 +0000 Subject: [PATCH 05/12] =?UTF-8?q?An=20incomplete=20heating=20companion=20s?= =?UTF-8?q?et=20raises=20instead=20of=20logging=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../epc/test_main_heating_system_overlay.py | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index fb856b75c..ec9e978aa 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -92,27 +92,24 @@ def test_electric_room_heaters_overlay_the_room_heater_charge_control() -> None: assert simulation.heating.main_heating_control == 2601 -def test_overlay_logs_when_it_cannot_complete_a_companion_set( - caplog: pytest.LogCaptureFixture, +def test_overlay_raises_on_an_incomplete_companion_set( monkeypatch: pytest.MonkeyPatch, ) -> None: - # Every real archetype now resolves a complete companion set (ADR-0053), - # so the incomplete case is simulated: a hypothetical warm-air heat-pump - # archetype (SAP 521 — category 4 but its Table 4e Group 5 control is - # deliberately unmapped). The overlay still applies (log-and-continue), - # but the incomplete companion set is reported rather than silently - # inheriting the replaced system's stale value. + # Every real archetype resolves a complete companion set, so the + # log-and-continue mitigation has no remaining population and the + # ADR-0048 end-state applies: an incomplete set FAILS LOUDLY instead of + # shipping a silently-incoherent cert (ADR-0053, #1444 Part C). Simulated + # with a hypothetical warm-air heat-pump archetype (SAP 521 — category 4 + # but its Table 4e Group 5 control is deliberately unmapped): a future + # archetype cannot land without choosing its companions. from domain.epc.property_overlays import main_heating_system_overlay as mod monkeypatch.setitem(mod._MAIN_HEATING_CODES, "Warm-air heat pump", 521) - # Act - with caplog.at_level(logging.ERROR): + # Act / Assert — the raise names the archetype. + with pytest.raises(ValueError, match="Warm-air heat pump"): main_heating_overlay_for("Warm-air heat pump", 0) - # Assert — the gap is reported and names the archetype. - assert "Warm-air heat pump" in caplog.text - @pytest.mark.parametrize( "main_heating_value", From 6707ab084fd1c38371a052e38b5bae1da39abb7c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 14:22:40 +0000 Subject: [PATCH 06/12] =?UTF-8?q?An=20incomplete=20heating=20companion=20s?= =?UTF-8?q?et=20raises=20instead=20of=20logging=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0048's intended end-state (#1444 Part C): every mapped archetype now resolves confident companions, so an incomplete set can only mean a new archetype landed without choosing them — fail loudly at overlay time. Co-Authored-By: Claude Opus 4.8 --- .../main_heating_system_overlay.py | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index b8c945393..6d25d979c 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -28,7 +28,6 @@ are left UNKNOWN until modelled. Unresolvable values produce no overlay. from __future__ import annotations -import logging from typing import Optional from domain.modelling.simulation import EpcSimulation, HeatingOverlay @@ -36,8 +35,6 @@ from domain.sap10_calculator.tables.table_12a import ( OFF_PEAK_IMPLYING_HEATING_CODES, ) -logger = logging.getLogger(__name__) - # Off-peak (Economy 7) meter. Electric storage / CPSU systems charge overnight at # the low rate and cannot run economically on a single-rate meter; "Dual" lets # the §12 dispatch resolve the specific tariff (storage 7-hour, CPSU 10-hour). @@ -418,21 +415,19 @@ def main_heating_overlay_for( category = _category_for(code) control = _control_for(code) if category is None or control is None: - # A system-replacing override should stamp a complete, coherent companion + # A system-replacing override must stamp a complete, coherent companion # set; an unmapped category or control leaves the field unset, so the - # effective cert inherits the REPLACED system's value (a silent mis-rating - # — e.g. a room heater keeping a storage heater's category/control). We do - # not yet have confident defaults for every archetype, so log-and-continue - # (matching `flag_fuel_mismatch`) to make the gap visible for review - # rather than raise mid-run. - logger.error( - "Landlord main_heating_system %r (SAP code %d) overlaid with an " - "incomplete companion set (category=%s, control=%s); the effective " - "cert may inherit the replaced system's value — known gap, review", - main_heating_value, - code, - category, - control, + # effective cert inherits the REPLACED system's value — the silent + # mis-rating class ADR-0048 exists to stop. Every mapped archetype now + # carries confident defaults (ADR-0050/0052/0053), so an incomplete set + # can only mean a new archetype landed without choosing its companions: + # fail loudly at overlay time rather than ship an incoherent cert + # (ADR-0048's intended end-state, #1444 Part C). + raise ValueError( + f"Landlord main_heating_system {main_heating_value!r} (SAP code " + f"{code}) resolves an incomplete companion set (category=" + f"{category}, control={control}); add the archetype's SAP Table 4a " + f"category and conservative Table 4e control before mapping it" ) return EpcSimulation( heating=HeatingOverlay( From 77110f22604f3009075ad61e052b0e50bfe770c4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 14:23:44 +0000 Subject: [PATCH 07/12] =?UTF-8?q?Record=20ADR-0053:=20companion=20taxonomy?= =?UTF-8?q?=20complete,=20incomplete=20set=20raises=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...taxonomy-complete-incomplete-set-raises.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/adr/0053-heating-override-companion-taxonomy-complete-incomplete-set-raises.md diff --git a/docs/adr/0053-heating-override-companion-taxonomy-complete-incomplete-set-raises.md b/docs/adr/0053-heating-override-companion-taxonomy-complete-incomplete-set-raises.md new file mode 100644 index 000000000..152e19224 --- /dev/null +++ b/docs/adr/0053-heating-override-companion-taxonomy-complete-incomplete-set-raises.md @@ -0,0 +1,89 @@ +# The heating-override companion taxonomy is complete; an incomplete set now raises + +## Status + +accepted + +## Context + +[ADR-0048](0048-heating-override-forces-category-and-control-defers-fuel-and-meter.md) +set the invariant — a system-replacing heating override emits a complete +category + control companion set, never inheriting the replaced system's — and +intended, once the taxonomy was covered, to "flip the log to a raise, so the +coherence invariant is enforced" (#1444 Part C). +[ADR-0050](0050-electric-underfloor-override-drags-category-8-and-conservative-group-7-control.md) +(underfloor) and +[ADR-0052](0052-electric-boiler-and-cpsu-overrides-drag-category-2-and-conservative-group-1-control.md) +(electric boilers) filled two families. Two archetype families remained on the +gap-log, and with them Part C stayed blocked: + +- **"Air source heat pump" (SAP 211)** — category 4 already forced, control + `None` (Table 4e Group 2 unmapped). No live overrides today (census across + all portfolios: 0), so this is taxonomy completion, not a live defect. +- **"Community heating, boilers" / "…, CHP and boilers" (301/302)** — category + 6 already forced, control `None` (Group 3 unmapped). **2,220 live + overrides** — the largest population on the log. The inherited control is + doubly load-bearing for heat networks: it feeds the Table 4e temperature + adjustment *and* the SAP 10.2 Table 4c(3) charging factors (worksheet + (305)/(305a)). + +## Decision + +1. **Wet heat pumps (Table 4a 211-224) drag control 2201** — Table 4e Group 2 + "no time or thermostatic control of room temperature", **+0.3 °C**, the + largest Group 2 adjustment: the conservative in-group default (the heat-pump + mirror of storage 2401 / room-heater 2601 / underfloor 2701 / boiler 2101). + Warm-air heat pumps (521-527) are deliberately left unmapped — no archetype + reaches them, and the raise below is the guard that a future warm-air + archetype cannot land without choosing its Group 5 control. + + This is the *existing-system* fill policy: the ASHP **Measure** overlay + installs 2210 (time-and-temperature zone control) because a measure designs + a modern end-state (ADR-0024); a landlord override describes an existing + pump whose control was never observed, so the conservative default applies. + +2. **Community heating (301/302) drags control 2301** — Table 4e Group 3 + "flat rate charging, no thermostatic control of room temperature". It is + the never-over-crediting default on **both** axes the control drives: the + largest Group 3 temperature adjustment (**+0.3 °C**, tied with 2302) and + the worst Table 4c(3) charging factors (space **1.10**, DHW **1.05** — an + unobserved charging arrangement is never assumed to be usage-linked). + As with every forced companion (ADR-0035/0048 pattern), this overwrites a + better control lodged for the replaced system: the override asserts the + system afresh and synthesis owns coherence. + +3. **An incomplete companion set now raises** (`ValueError`) instead of + logging. Every archetype in `_MAIN_HEATING_CODES` resolves a complete + category + control (gas boilers via their dedicated overlay; all others via + `_category_for`/`_control_for`), so the log-and-continue mitigation has no + remaining population — and a silent log was only ever a stop-gap + (ADR-0048: "the intended end-state is to fill each archetype's + category/control … and then flip the log to a raise"). A future archetype + added without companions now fails loudly at overlay time rather than + shipping a silently-incoherent cert. + +## Consequences + +- The companion-gap error log is gone; #1444 closes. +- Community overrides re-model against the conservative 2301: dwellings whose + replaced-system control was inherited converge on one defensible assumption. + Live sample (portfolio 796, 12 properties): baseline shifts of **0 to −3 + SAP** (typically −1 to −2). The inherited controls were unobserved + usage-linked credits (2306/2307/2309) or outright group-incoherent — two + dwellings carried a Group-1 *boiler* zone control (2110) on a heat network — + so the shift removes credit that was never evidenced. +- Adding a heating archetype now *requires* choosing its category + control + (or extending the family sets) — the raise turns the ADR-0048 checklist into + an enforced contract. + +### Alternatives rejected + +- **A zero-adjustment / usage-linked community default (2307+).** Rejected: + credits a charging arrangement and thermostat the landlord never reported; + the conservative default is the established pattern. +- **Keep log-and-continue indefinitely.** Rejected: the log was the interim + mitigation while the taxonomy was incomplete; leaving it means the next + archetype ships the ADR-0048 inheritance bug silently again. +- **Raise on warm-air heat-pump codes' missing control now.** Not applicable — + no archetype maps to 521-527; the raise fires exactly when one is added + without its Group 5 control, which is the point. From 2bfda711d8cc84024cbecbef97e09aa21907107b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 16:32:15 +0000 Subject: [PATCH 08/12] =?UTF-8?q?Community-heating=20overrides=20default?= =?UTF-8?q?=20to=20Group=203=20control=202313=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../epc/test_main_heating_system_overlay.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index ec9e978aa..576b8716d 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -115,24 +115,26 @@ def test_overlay_raises_on_an_incomplete_companion_set( "main_heating_value", ["Community heating, boilers", "Community heating, CHP and boilers"], ) -def test_community_heating_drags_the_conservative_group_3_control( +def test_community_heating_drags_the_default_group_3_control( main_heating_value: str, ) -> None: # The landlord names the scheme, not its control or charging arrangement. - # The conservative Table 4e Group 3 default is 2301 ("flat rate charging, - # no thermostatic control of room temperature"): the largest Group 3 - # temperature adjustment (+0.3 C) AND the worst Table 4c(3) charging - # factors (space 1.10, DHW 1.05) — an unobserved control is never - # over-credited and an unobserved charging arrangement is never assumed - # usage-linked (ADR-0053). Leaving it unset inherits the replaced system's - # control instead (the ADR-0048 bug class). + # The Table 4e Group 3 default is 2313 ("flat rate charging, room + # thermostat and TRVs"): community schemes skew modern and the portfolio's + # own lodged community certs are overwhelmingly thermostatted (modal 2306; + # none lodge 2301), so the KIT axis concedes to the evidence — while the + # CHARGING axis stays flat-rate (space 1.05 / DHW 1.05): the billing + # arrangement is scheme paperwork an override genuinely cannot see, so + # usage-linked (1.0/1.0) is never assumed (ADR-0053, decided with Khalim). + # Leaving the control unset inherits the replaced system's instead (the + # ADR-0048 bug class). # Act simulation = main_heating_overlay_for(main_heating_value, 0) - # Assert — SAP Table 4e Group 3 conservative heat-network control. + # Assert — SAP Table 4e Group 3 default heat-network control. assert simulation is not None assert simulation.heating is not None - assert simulation.heating.main_heating_control == 2301 + assert simulation.heating.main_heating_control == 2313 def test_air_source_heat_pump_drags_the_conservative_group_2_control() -> None: From 72ccc920c5d8a5a56355b20407bfc90ca6743ba7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 16:32:58 +0000 Subject: [PATCH 09/12] =?UTF-8?q?Community-heating=20overrides=20default?= =?UTF-8?q?=20to=20Group=203=20control=202313=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-axis choice (decided with Khalim): the kit axis concedes to the evidence (community schemes skew modern; lodged community certs are overwhelmingly thermostatted, none lodge 2301), the charging axis stays flat-rate — usage-linked billing is scheme paperwork an override cannot see. Co-Authored-By: Claude Opus 4.8 --- .../main_heating_system_overlay.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 6d25d979c..243326cfd 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -185,14 +185,18 @@ _CONSERVATIVE_HEAT_PUMP_CONTROL = 2201 _HEAT_NETWORK_CATEGORY = 6 _HEAT_NETWORK_CODES = frozenset({301, 302, 303, 304}) _COMMUNITY_BOILER_CODES = frozenset({301, 302, 303}) -# SAP Table 4e Group 3 heat-network control: 2301 ("flat rate charging, no -# thermostatic control of room temperature") is the conservative default when -# the landlord named only the scheme. It is never-over-crediting on BOTH axes -# the control drives: the largest Group 3 temperature adjustment (+0.3 C, tied -# with 2302) and the worst Table 4c(3) charging factors (space 1.10, DHW 1.05 -# — an unobserved charging arrangement is never assumed usage-linked). The -# community mirror of storage 2401 / boiler 2101 / heat-pump 2201 (ADR-0053). -_CONSERVATIVE_HEAT_NETWORK_CONTROL = 2301 +# SAP Table 4e Group 3 heat-network control: 2313 ("flat rate charging, room +# thermostat and TRVs") is the default when the landlord named only the scheme +# — a per-axis choice (ADR-0053, decided with Khalim). KIT axis: community +# schemes skew modern and the portfolio's lodged community certs are +# overwhelmingly thermostatted (modal 2306; none lodge 2301), so a room +# thermostat + TRVs is the evidence-backed assumption (temperature adjustment +# 0.0). CHARGING axis: flat rate (Table 4c(3) space 1.05 / DHW 1.05) — the +# billing arrangement is scheme paperwork an override cannot see, so the +# usage-linked credit (1.0/1.0) is never assumed. An assessor-OBSERVED Group 3 +# control on the cert is kept in preference to this default (the overlay sets +# `keep_existing_heat_network_control`; the applicator honours it). +_DEFAULT_HEAT_NETWORK_CONTROL = 2313 _COMMUNITY_GAS_FUEL = 20 # SAP Table 4c full boiler-control code: programmer + room thermostat + TRVs. The @@ -324,7 +328,7 @@ def _control_for(code: int) -> Optional[int]: if code in _WET_HEAT_PUMP_CODES: return _CONSERVATIVE_HEAT_PUMP_CONTROL if code in _HEAT_NETWORK_CODES: - return _CONSERVATIVE_HEAT_NETWORK_CONTROL + return _DEFAULT_HEAT_NETWORK_CONTROL return None From 04810896e06d39c407fe80b9fd43a1edd7c679ee Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 16:33:40 +0000 Subject: [PATCH 10/12] =?UTF-8?q?A=20community=20override=20keeps=20an=20a?= =?UTF-8?q?ssessor-observed=20Group=203=20control=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../epc/test_main_heating_system_overlay.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 576b8716d..dc50dd2d5 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -647,6 +647,51 @@ def test_unresolvable_or_unmodelled_heating_produces_no_overlay( assert simulation is None +def test_community_override_keeps_an_observed_group_3_control() -> None: + # A community override on a cert that ALREADY lodges community heating is a + # confirmation, not a system switch — the assessor observed the control + # (e.g. 2306, "charging linked to use, programmer and TRVs", worth ~1-2 SAP + # over the flat-rate default). Stamping the 2313 default over it would + # destroy real information, so the overlay opts into keeping an existing + # Table 4e GROUP 3 control (the heat-network mirror of + # `keep_existing_off_peak_meter`, ADR-0053). + # Arrange — a cert lodging community heating with an observed 2306. + baseline = build_epc() + main = baseline.sap_heating.main_heating_details[0] + main.sap_main_heating_code = 301 + main.main_heating_category = 6 + main.main_heating_control = 2306 + overlay = main_heating_overlay_for("Community heating, boilers", 0) + assert overlay is not None + + # Act + result = apply_simulations(baseline, [overlay]) + + # Assert — the observed usage-linked control survives. + assert result.sap_heating.main_heating_details[0].main_heating_control == 2306 + + +def test_community_override_replaces_a_cross_family_control() -> None: + # When the replaced system was a DIFFERENT family, its control is stale by + # definition — a Group-1 boiler zone control (2110) has no meaning on a + # heat network (two live portfolio-796 dwellings carried exactly that) — + # so the Group 3 default is stamped (ADR-0053). + # Arrange — a cert lodging a gas boiler with zone control. + baseline = build_epc() + main = baseline.sap_heating.main_heating_details[0] + main.sap_main_heating_code = 102 + main.main_heating_category = 2 + main.main_heating_control = 2110 + overlay = main_heating_overlay_for("Community heating, boilers", 0) + assert overlay is not None + + # Act + result = apply_simulations(baseline, [overlay]) + + # Assert — the stale boiler control is replaced by the Group 3 default. + assert result.sap_heating.main_heating_details[0].main_heating_control == 2313 + + def test_main_heating_override_remaps_the_primary_system_code() -> None: # Arrange baseline = build_epc() From 9153fa2523a5887b36ab5868d5c454d024b12f1f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 16:35:08 +0000 Subject: [PATCH 11/12] =?UTF-8?q?A=20community=20override=20keeps=20an=20a?= =?UTF-8?q?ssessor-observed=20Group=203=20control=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heat-network mirror of keep_existing_off_peak_meter: the override's 2313 default applies only when the replaced system's control is cross-family (stale); an observed community control survives. Co-Authored-By: Claude Opus 4.8 --- .../main_heating_system_overlay.py | 5 ++++ .../modelling/scoring/overlay_applicator.py | 24 +++++++++++++++++-- domain/modelling/simulation.py | 9 +++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 243326cfd..946c648ca 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -445,5 +445,10 @@ def main_heating_overlay_for( # (e.g. a 24-hour all-low tariff). Measures, which re-meter for real, # build their HeatingOverlay directly and leave this False. keep_existing_off_peak_meter=True, + # Likewise a heat-network override's control default (2313) must + # not overwrite an assessor-OBSERVED Group 3 control on a community + # cert being confirmed — only a stale cross-family control is + # replaced (ADR-0053). + keep_existing_heat_network_control=code in _HEAT_NETWORK_CODES, ) ) diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 216463dab..1a9357033 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -152,6 +152,13 @@ _SAP_HEATING_FIELDS: tuple[str, ...] = ( _ENERGY_SOURCE_FIELDS: tuple[str, ...] = ("meter_type", "gas_connection_available") +def _is_heat_network_control(control: object) -> bool: + """True iff the lodged control is a SAP Table 4e Group 3 (heat network) + code — 2301-2314 — i.e. an assessor-observed community control a + heat-network override's default must not overwrite (ADR-0053).""" + return isinstance(control, int) and 2301 <= control <= 2314 + + def _is_off_peak_meter(meter_type: object) -> bool: """True iff the meter resolves to an off-peak Table 12a tariff (not the STANDARD single-rate column). Unparseable / absent meters count as not @@ -177,8 +184,21 @@ def _fold_heating(epc: EpcPropertyData, overlay: HeatingOverlay) -> None: main = epc.sap_heating.main_heating_details[0] for field_name in _MAIN_HEATING_FIELDS: value = getattr(overlay, field_name) - if value is not None: - setattr(main, field_name, value) + if value is None: + continue + # A community override's control is a coherent default, not an observed + # one: when it opts in (`keep_existing_heat_network_control`) and the + # cert already lodges a Table 4e GROUP 3 control (assessor-observed on + # a community cert being confirmed), keep the cert's. A cross-family + # control (boiler 2110, storage 2401) is stale and is still replaced + # (ADR-0053). + if ( + field_name == "main_heating_control" + and overlay.keep_existing_heat_network_control + and _is_heat_network_control(main.main_heating_control) + ): + continue + setattr(main, field_name, value) # `main_heating_index_number` (PCDB-resolved, e.g. a heat pump) and # `sap_main_heating_code` (Table 4a-resolved, e.g. storage heaters) are # mutually-exclusive efficiency anchors: a whole-system replacement to one diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 79855940e..55c52855e 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -200,6 +200,15 @@ class HeatingOverlay: # MEASURE re-meters for real (Elmhurst re-lodges 18-hour → Dual on a storage # install), so it leaves this False. `_fold_heating` reads it. keep_existing_off_peak_meter: bool = False + # The community-heating mirror of the flag above (ADR-0053): a heat-network + # override's `main_heating_control` is a coherent DEFAULT, not an observed + # control — when the cert already lodges a Table 4e Group 3 control the + # assessor observed it (a community cert being confirmed, not replaced), so + # the default must not overwrite it (e.g. a usage-linked 2306 downgraded to + # flat-rate 2313 destroys ~1-2 real SAP). A cross-family control (a boiler + # 2110, storage 2401) is stale by definition and is still replaced. + # `_fold_heating` reads it. + keep_existing_heat_network_control: bool = False @dataclass(frozen=True) From 1bcb55784a811d1dc44fb0d8e93e4f89dff4cc6b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 16:37:20 +0000 Subject: [PATCH 12/12] =?UTF-8?q?Amend=20ADR-0053:=20community=20default?= =?UTF-8?q?=202313=20with=20keep-observed=20Group=203=20controls=20?= =?UTF-8?q?=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...taxonomy-complete-incomplete-set-raises.md | 58 +++++++++++++------ 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/docs/adr/0053-heating-override-companion-taxonomy-complete-incomplete-set-raises.md b/docs/adr/0053-heating-override-companion-taxonomy-complete-incomplete-set-raises.md index 152e19224..0f1ca0cd7 100644 --- a/docs/adr/0053-heating-override-companion-taxonomy-complete-incomplete-set-raises.md +++ b/docs/adr/0053-heating-override-companion-taxonomy-complete-incomplete-set-raises.md @@ -42,15 +42,27 @@ gap-log, and with them Part C stayed blocked: a modern end-state (ADR-0024); a landlord override describes an existing pump whose control was never observed, so the conservative default applies. -2. **Community heating (301/302) drags control 2301** — Table 4e Group 3 - "flat rate charging, no thermostatic control of room temperature". It is - the never-over-crediting default on **both** axes the control drives: the - largest Group 3 temperature adjustment (**+0.3 °C**, tied with 2302) and - the worst Table 4c(3) charging factors (space **1.10**, DHW **1.05** — an - unobserved charging arrangement is never assumed to be usage-linked). - As with every forced companion (ADR-0035/0048 pattern), this overwrites a - better control lodged for the replaced system: the override asserts the - system afresh and synthesis owns coherence. +2. **Community heating (301/302) defaults to control 2313, keeping an + assessor-observed Group 3 control** (decided with Khalim, revising the + first-cut 2301). The control encodes two independent unknowns and the + default is chosen per axis: + - **Kit axis** (thermostat/TRVs): community schemes skew modern, and the + portfolio's own lodged community certs are overwhelmingly thermostatted + (modal control 2306; none lodge 2301) — so 2313 ("flat rate charging, + room thermostat and TRVs", temperature adjustment 0.0) concedes this + axis to the evidence. + - **Charging axis** (flat rate vs usage-linked): the billing arrangement + is scheme paperwork an override genuinely cannot see, so flat rate + (Table 4c(3) space **1.05**, DHW **1.05**) is retained — the + usage-linked credit (1.0/1.0) is never assumed. + - **Keep-observed**: when the cert already lodges a Group 3 control the + override is confirming a community system, not replacing one — the + assessor observed that control, and stamping any default over it + destroys real information (a usage-linked 2306 is worth ~1-2 SAP over + flat-rate). The overlay opts in via + `keep_existing_heat_network_control` (the control mirror of ADR-0035's + `keep_existing_off_peak_meter`); only a stale cross-family control (the + boiler-2110 / storage-2401 inheritances) is replaced by 2313. 3. **An incomplete companion set now raises** (`ValueError`) instead of logging. Every archetype in `_MAIN_HEATING_CODES` resolves a complete @@ -65,22 +77,30 @@ gap-log, and with them Part C stayed blocked: ## Consequences - The companion-gap error log is gone; #1444 closes. -- Community overrides re-model against the conservative 2301: dwellings whose - replaced-system control was inherited converge on one defensible assumption. - Live sample (portfolio 796, 12 properties): baseline shifts of **0 to −3 - SAP** (typically −1 to −2). The inherited controls were unobserved - usage-linked credits (2306/2307/2309) or outright group-incoherent — two - dwellings carried a Group-1 *boiler* zone control (2110) on a heat network — - so the shift removes credit that was never evidenced. +- Community overrides converge on defensible controls with minimal score + churn. Live sample (portfolio 796, 12 properties): the ten dwellings with + assessor-observed Group 3 controls (2306/2307/2309) shift **±0.0** — their + observed controls survive the override — and only the two group-incoherent + dwellings (a Group-1 *boiler* zone control 2110 riding on a heat network) + move, **−1.0** each onto the 2313 default. Contrast the first-cut 2301 + design, which shifted the whole sample 0 to −3. - Adding a heating archetype now *requires* choosing its category + control (or extending the family sets) — the raise turns the ADR-0048 checklist into an enforced contract. ### Alternatives rejected -- **A zero-adjustment / usage-linked community default (2307+).** Rejected: - credits a charging arrangement and thermostat the landlord never reported; - the conservative default is the established pattern. +- **The maximally conservative community default (2301).** The first cut. + Rejected on evidence: no lodged community cert in the portfolio carries + 2301, so it under-rates the typical (modern, thermostatted) scheme and + shifted the sampled cohort 0 to −3 SAP for no informational gain. +- **A usage-linked community default (2306/2312/2314).** Rejected: credits a + billing arrangement the landlord never reported — the charging axis is the + one the override genuinely cannot observe, so it stays flat-rate. +- **Stamping the default over an observed Group 3 control.** Rejected: the + ADR-0035 "always stamp" pattern exists to kill *stale* companions; an + in-group control on a confirmed community cert is observed data, and + overwriting it is information destruction, not coherence. - **Keep log-and-continue indefinitely.** Rejected: the log was the interim mitigation while the taxonomy was incomplete; leaving it means the next archetype ships the ADR-0048 inheritance bug silently again.