mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Merge pull request #1456 from Hestia-Homes/fix/electric-boiler-cpsu-companions
Electric boiler/CPSU overrides drag category 2 and the conservative Group 1 control (#1444 A+B for 191/192)
This commit is contained in:
commit
180863ed41
6 changed files with 460 additions and 39 deletions
|
|
@ -0,0 +1,88 @@
|
|||
# Electric boiler and CPSU overrides drag category 2 and the conservative Group 1 control
|
||||
|
||||
## Status
|
||||
|
||||
accepted
|
||||
|
||||
## Context
|
||||
|
||||
[ADR-0048](0048-heating-override-forces-category-and-control-defers-fuel-and-meter.md)
|
||||
requires a system-replacing heating override to emit a complete companion set
|
||||
(category and control archetype-forced, never inherited) and deferred the
|
||||
electric boiler / CPSU expansion; [ADR-0050](0050-electric-underfloor-override-drags-category-8-and-conservative-group-7-control.md)
|
||||
completed the underfloor half. This ADR completes the electric-boiler half
|
||||
(#1444 Parts A+B for codes **191/192** — the archetypes "Direct-acting
|
||||
electric", "Electric boiler", "Electric CPSU").
|
||||
|
||||
A portfolio-796 scan of the 126 live 191/192 overrides found **30 of the first
|
||||
40 in a suppressed or mis-billed state** from inherited companions:
|
||||
|
||||
- **Inherited category 6** (replaced community heating — the bulk): the heating
|
||||
generator's `is_heat_network_main` gate reads the dwelling as a heat network,
|
||||
so the **HHRSH bundle is never offered** — only tune-ups. Same class as
|
||||
ADR-0050's evidence property.
|
||||
- **Inherited category 4** (replaced heat pump — property 711954): *every*
|
||||
heating bundle suppressed.
|
||||
- **Inherited category 7** (replaced storage heaters — property 721912): the
|
||||
SAP 10.2 Table 12a resolver's category-7 branch bills the direct-acting
|
||||
boiler as storage — **100% at the off-peak low rate** — the precise
|
||||
ADR-0048 room-heater mis-billing, resurfaced.
|
||||
- Stale controls ride along (community 23xx, storage 2401, room-heater 2603),
|
||||
applying the wrong Table 4e temperature adjustment.
|
||||
|
||||
## Decision
|
||||
|
||||
The electric-boiler codes (SAP Table 4a **191** direct-acting electric boiler,
|
||||
**192** electric CPSU) join the forced-companion branches of
|
||||
`main_heating_system_overlay`:
|
||||
|
||||
1. **Category 2** ("boiler system with radiators or underfloor heating") in
|
||||
`_category_for` — deterministic from SAP Table 4a; the same category the
|
||||
gas-boiler overlay already stamps. Table 12a billing for these codes is
|
||||
code-keyed (191 → `DIRECT_ACTING_ELECTRIC_BOILER`, 192 → the deliberate
|
||||
Appendix-F fallback), so the category change *removes* stale-category
|
||||
misrouting rather than selecting a new billing row.
|
||||
2. **Control 2101** (SAP Table 4e **Group 1**, "no time or thermostatic control
|
||||
of room temperature", **+0.6 °C**) in `_control_for` — the conservative
|
||||
default within the group the category fixes: the largest Group 1
|
||||
mean-internal-temperature adjustment, so an unobserved control is never
|
||||
over-credited (the boiler mirror of storage 2401 / room-heater 2601 /
|
||||
underfloor 2701). This **supersedes the earlier "direct-acting electric
|
||||
drags no control" choice** — code 191 is a wet Group-1 boiler, and leaving
|
||||
the control unset inherits the replaced system's (the ADR-0048 bug class),
|
||||
which is strictly worse than a conservative assumption.
|
||||
|
||||
Deliberate asymmetry with gas boilers: the gas archetypes force **2106**
|
||||
(full modern controls) because they also assume a modern condensing install
|
||||
(ADR-0035, decided with Khalim) — the whole system is modelled modern. The
|
||||
electric-boiler archetypes describe an *existing* system of unknown vintage
|
||||
modelled at code 191/192's intrinsic efficiency, so the conservative
|
||||
default applies.
|
||||
|
||||
Fuel (electricity 29, ADR-0045) and meter (192 off-peak Dual via §12; 191
|
||||
Single) were already correct and are unchanged. A forced 2101 also makes the
|
||||
system tune-up options available (2101 is improvable) — a coherent cheap lever
|
||||
for these dwellings.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The suppressed cohort regains its heating pathway: category 6/4 inheritors
|
||||
become HHRSH-eligible (verified live: 711954 gains the HHRSH option), and
|
||||
category-7 inheritors stop billing direct-acting heat at the all-night low
|
||||
rate (721912).
|
||||
- The "incomplete companion set" error log no longer fires for
|
||||
Direct-acting electric / Electric boiler / Electric CPSU. Remaining gap-log
|
||||
archetypes (#1444): heat pumps (Group 2 control), community heating
|
||||
(Group 3 control — interacts with the Table 4c(3) charging factor, needs an
|
||||
accredited worksheet), then the log flips to a raise (Part C).
|
||||
- Overrides re-model with a different (usually lower) baseline where a stale
|
||||
category was over-crediting — an accuracy correction, not a regression.
|
||||
|
||||
### Alternatives rejected
|
||||
|
||||
- **Keep "no control" for direct-acting electric.** Rejected: `None` is not
|
||||
"no control assumed" — by last-wins composition it means *inherit the
|
||||
replaced system's control*, the silent mis-rating ADR-0048 exists to stop.
|
||||
- **Force modern controls (2106) like the gas boilers.** Rejected: nothing in
|
||||
the electric-boiler archetype implies a modern install; assuming full
|
||||
controls over-credits every dwelling whose controls were never observed.
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
# 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) 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
|
||||
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 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
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -130,6 +127,22 @@ _ELECTRIC_ROOM_HEATER_CODES = frozenset(range(691, 702))
|
|||
# unambiguously electric, so they drag electricity like the electric room heaters
|
||||
# (ADR-0045, closing the prior no-fuel gap on 191/192).
|
||||
_ELECTRIC_BOILER_CODES = frozenset({191, 192})
|
||||
# SAP Table 4a "boiler system" category — codes 191/192 are wet electric
|
||||
# boilers, same category the gas-boiler overlay stamps. Forced so an
|
||||
# electric-boiler override never keeps the replaced system's category: on
|
||||
# portfolio 796 an inherited 6 (community) or 4 (heat pump) suppressed the
|
||||
# HHRSH pathway, and an inherited 7 (storage) made the Table 12a resolver bill
|
||||
# direct-acting heat wholly at the off-peak low rate (ADR-0052, #1444).
|
||||
_ELECTRIC_BOILER_CATEGORY = 2
|
||||
# SAP Table 4e Group 1 boiler control: 2101 ("no time or thermostatic control
|
||||
# of room temperature", +0.6 C) is the conservative default when the landlord
|
||||
# named only the system — the largest Group 1 mean-internal-temperature
|
||||
# adjustment, so an unobserved control is never over-credited (the boiler
|
||||
# mirror of storage 2401 / room-heater 2601 / underfloor 2701). Deliberately
|
||||
# NOT the gas boilers' modern 2106: the gas archetypes assume a whole modern
|
||||
# condensing install; the electric-boiler archetypes describe an existing
|
||||
# system of unknown vintage (ADR-0052).
|
||||
_CONSERVATIVE_BOILER_CONTROL = 2101
|
||||
# Electric underfloor — SAP Table 4a 421/422 (off-peak) and 424 (screed) — also
|
||||
# unambiguously electric (ADR-0046).
|
||||
_ELECTRIC_UNDERFLOOR_CODES = frozenset({421, 422, 424})
|
||||
|
|
@ -152,6 +165,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
|
||||
|
|
@ -161,6 +185,18 @@ _HEAT_PUMP_CODES = frozenset(range(211, 225)) | frozenset(range(521, 528))
|
|||
_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: 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
|
||||
|
|
@ -274,8 +310,9 @@ def _meter_for(code: int) -> Optional[str]:
|
|||
def _control_for(code: int) -> Optional[int]:
|
||||
"""The control to assume when the landlord named only the system: a
|
||||
conservative manual charge control for storage heaters, full modern controls
|
||||
for a gas boiler, None for systems that take neither (direct-acting electric).
|
||||
Overwrites a stale control inherited from the system being replaced."""
|
||||
for a gas boiler, the conservative in-group default for the other mapped
|
||||
families (room heaters, underfloor, electric boilers). Overwrites a stale
|
||||
control inherited from the system being replaced."""
|
||||
if code == _HHR_STORAGE_CODE:
|
||||
return _HHR_CHARGE_CONTROL
|
||||
if code in _STORAGE_HEATER_CODES:
|
||||
|
|
@ -286,6 +323,12 @@ def _control_for(code: int) -> Optional[int]:
|
|||
return _ROOM_HEATER_CONTROL
|
||||
if code in _ELECTRIC_UNDERFLOOR_CODES:
|
||||
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
|
||||
if code in _HEAT_NETWORK_CODES:
|
||||
return _DEFAULT_HEAT_NETWORK_CONTROL
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -304,6 +347,8 @@ def _category_for(code: int) -> Optional[int]:
|
|||
return _STORAGE_HEATER_CATEGORY
|
||||
if code in _ELECTRIC_UNDERFLOOR_CODES:
|
||||
return _UNDERFLOOR_CATEGORY
|
||||
if code in _ELECTRIC_BOILER_CODES:
|
||||
return _ELECTRIC_BOILER_CATEGORY
|
||||
if code in _HEAT_PUMP_CODES:
|
||||
return _HEAT_PUMP_CATEGORY
|
||||
if code in _HEAT_NETWORK_CODES:
|
||||
|
|
@ -374,21 +419,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(
|
||||
|
|
@ -402,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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -92,20 +92,66 @@ 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:
|
||||
# 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 —
|
||||
# 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)
|
||||
# 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
|
||||
|
||||
# Assert — the gap is reported and names the archetype.
|
||||
assert "Air source heat pump" in caplog.text
|
||||
monkeypatch.setitem(mod._MAIN_HEATING_CODES, "Warm-air heat pump", 521)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"main_heating_value",
|
||||
["Community heating, boilers", "Community heating, CHP and boilers"],
|
||||
)
|
||||
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 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 default heat-network control.
|
||||
assert simulation is not None
|
||||
assert simulation.heating is not None
|
||||
assert simulation.heating.main_heating_control == 2313
|
||||
|
||||
|
||||
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(
|
||||
|
|
@ -434,14 +480,70 @@ def test_storage_heaters_drag_along_conservative_manual_charge_control(
|
|||
assert simulation.heating.main_heating_category == 7
|
||||
|
||||
|
||||
def test_direct_acting_electric_does_not_drag_along_a_control() -> None:
|
||||
# Charge control is a storage concept and full boiler controls are a wet-
|
||||
# system concept; direct-acting electric panel heaters take neither, so the
|
||||
# overlay leaves the control untouched.
|
||||
simulation = main_heating_overlay_for("Direct-acting electric", 0)
|
||||
@pytest.mark.parametrize(
|
||||
"main_heating_value",
|
||||
["Direct-acting electric", "Electric boiler", "Electric CPSU"],
|
||||
)
|
||||
def test_electric_boiler_overlays_the_boiler_category(
|
||||
main_heating_value: str,
|
||||
) -> None:
|
||||
# A system-replacing override must stamp the boiler heating category (SAP
|
||||
# Table 4a Category 2 — codes 191/192 are wet electric boilers). Leaving it
|
||||
# unset inherits the replaced system's: on portfolio 796, 30 of the first
|
||||
# 40 live 191/192 overrides carried an inherited category — 6 (community)
|
||||
# or 4 (heat pump) suppressed the HHRSH pathway entirely, and 7 (storage)
|
||||
# made the Table 12a resolver bill direct-acting heat 100% at the off-peak
|
||||
# low rate (ADR-0052, #1444).
|
||||
# Act
|
||||
simulation = main_heating_overlay_for(main_heating_value, 0)
|
||||
|
||||
# Assert — SAP Table 4a Category 2 (boiler system).
|
||||
assert simulation is not None
|
||||
assert simulation.heating is not None
|
||||
assert simulation.heating.main_heating_control is None
|
||||
assert simulation.heating.main_heating_category == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"main_heating_value",
|
||||
["Direct-acting electric", "Electric boiler", "Electric CPSU"],
|
||||
)
|
||||
def test_complete_electric_boiler_overlay_does_not_log_a_companion_gap(
|
||||
main_heating_value: str, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
# Electric boilers now resolve a full companion set (category 2 + control
|
||||
# 2101, ADR-0052), so the overlay must NOT log an incomplete-companion gap
|
||||
# — guarding that the set stays complete.
|
||||
# Act
|
||||
with caplog.at_level(logging.ERROR):
|
||||
main_heating_overlay_for(main_heating_value, 0)
|
||||
|
||||
# Assert
|
||||
assert "incomplete companion set" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"main_heating_value",
|
||||
["Direct-acting electric", "Electric boiler", "Electric CPSU"],
|
||||
)
|
||||
def test_electric_boiler_drags_the_conservative_group_1_control(
|
||||
main_heating_value: str,
|
||||
) -> None:
|
||||
# The landlord names the system, not its control, so the overlay assumes
|
||||
# the conservative Table 4e Group 1 default: 2101 ("no time or thermostatic
|
||||
# control", +0.6 C — the largest Group 1 adjustment, so an unobserved
|
||||
# control is never over-credited; the boiler mirror of storage 2401 /
|
||||
# room-heater 2601 / underfloor 2701). Supersedes the old "direct-acting
|
||||
# drags no control" pin: an unset control is not "no control" — it INHERITS
|
||||
# the replaced system's (a community 2306 or storage 2401 rode along on
|
||||
# portfolio 796). Deliberately NOT the gas boilers' modern 2106 — nothing
|
||||
# in the electric archetype implies a modern install (ADR-0052).
|
||||
# Act
|
||||
simulation = main_heating_overlay_for(main_heating_value, 0)
|
||||
|
||||
# Assert — SAP Table 4e Group 1 conservative boiler control.
|
||||
assert simulation is not None
|
||||
assert simulation.heating is not None
|
||||
assert simulation.heating.main_heating_control == 2101
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -545,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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue