mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Merge pull request #1447 from Hestia-Homes/fix/electric-underfloor-pathway-and-noop-walls
Electric underfloor gets its heating pathway; wall insulation gated on derived U (WS2 of #1435)
This commit is contained in:
commit
ebca28f704
10 changed files with 418 additions and 4 deletions
|
|
@ -317,7 +317,11 @@ The two competing **Measure Options** for insulating a *solid* (non-cavity) main
|
|||
_Avoid_: solid wall insulation (that names the pair, not one Option), cladding, drylining
|
||||
|
||||
**Wall Insulation Eligibility**:
|
||||
The rule fixing which wall Option(s) the main-wall **Recommendation** offers, by wall construction then planning status. By construction: **cavity** → cavity fill only (never solid insulation); **solid brick** / **system-built** → IWI + EWI; **timber-frame** → IWI only (EWI is not constructable); **cob** / **granite or whinstone** / **sandstone or limestone** → none (breathable fabric — standard insulation risks trapping moisture). Then, as planning gates: a **conservation area** or **flat** removes EWI (external-appearance / whole-block constraints), and a **listed** or **heritage** building removes **both** EWI and IWI (protected fabric).
|
||||
The rule fixing which wall Option(s) the main-wall **Recommendation** offers, by wall construction then planning status. By construction: **cavity** → cavity fill only (never solid insulation); **solid brick** / **system-built** → IWI + EWI; **timber-frame** → IWI only (EWI is not constructable); **cob** / **granite or whinstone** / **sandstone or limestone** → none (breathable fabric — standard insulation risks trapping moisture). Then, as planning gates: a **conservation area** or **flat** removes EWI (external-appearance / whole-block constraints), and a **listed** or **heritage** building removes **both** EWI and IWI (protected fabric). Finally the **Wall U-Value Gate** applies.
|
||||
|
||||
**Wall U-Value Gate**:
|
||||
The eligibility rule that a wall insulation Option is offered **only when it would lower the wall's derived U-value** (by more than a 0.01 W/m²K float-noise guard) — comparing `u_wall` on the effective wall's current state vs its post-measure state (ADR-0051). Kills the **no-op fabric offer**: on a modern construction age band (e.g. band L, 2012–2022) RdSAP's cascade assigns the as-built wall its at-regs U-value and added insulation resolves to the *same* value, so the measure costs thousands for +0.00 SAP. The gate is physical possibility only — how *much* improvement is worth the cost stays with the Optimiser (ADR-0016/0024).
|
||||
_Avoid_: no-op filter (it gates generation, not scored output), U-value threshold (there is no absolute target-U; the comparison is before-vs-after)
|
||||
_Avoid_: restricted measures (legacy collapsed conservation/listed/heritage into one boolean — they now gate different Options, so keep them distinct)
|
||||
|
||||
**Roof Insulation Eligibility**:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
# An electric-underfloor override drags category 8 and the conservative Group 7 control
|
||||
|
||||
## Status
|
||||
|
||||
accepted
|
||||
|
||||
## Context
|
||||
|
||||
[ADR-0048](0048-heating-override-forces-category-and-control-defers-fuel-and-meter.md)
|
||||
established that a system-replacing heating override must emit a **complete,
|
||||
coherent companion set** — category and control are archetype-forced, never
|
||||
inherited — and explicitly deferred the category expansion for the still-`None`
|
||||
archetypes ("electric boiler/CPSU→2, underfloor→8 … flows into the
|
||||
category-keyed Table 12a resolver and needs a corpus check") to its own change.
|
||||
|
||||
The portfolio-796 audit (PRD #1435, WS2) found the underfloor half of that gap
|
||||
end-to-end on property **711795** (uprn 10090343115): a lodged **community
|
||||
heating** cert (SAP 301, category 6, control 2311) overridden to *"Electric
|
||||
underfloor, in screed above insulation"* (SAP 424,
|
||||
[ADR-0046](0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md)).
|
||||
`_category_for(424)` and `_control_for(424)` both returned `None`, so the
|
||||
effective cert kept the replaced heat network's **category 6** and community
|
||||
control. Two independent mis-ratings followed:
|
||||
|
||||
1. **No heating pathway.** `is_heat_network_main` keys on category 6, so the
|
||||
heating Recommendation Generator excluded both HHRSH and ASHP by the
|
||||
heat-network topology rule — `recommend_heating` returned `None` and the
|
||||
flat's whole candidate menu was a no-op wall measure plus lighting. The
|
||||
dwelling was unliftable from band E (the `plan-stuck-in-low-band` audit
|
||||
group counts ~7 electric-underfloor dwellings).
|
||||
2. **Baseline mis-rated.** The calculator scored the electric underfloor as
|
||||
community heating: baseline SAP **51 (E)** vs **67 (D)** with coherent
|
||||
companions. With the pathway restored, HHRSH scores **80 (C)** for ~£1k —
|
||||
the dwelling reaches the scenario goal.
|
||||
|
||||
## Decision
|
||||
|
||||
The three electric-underfloor archetypes (SAP Table 4a **421 / 422 / 424**,
|
||||
ADR-0046) join the forced-companion branches of `main_heating_system_overlay`:
|
||||
|
||||
1. **Category 8** ("Electric underfloor heating") in `_category_for` —
|
||||
deterministic from SAP Table 4a, per ADR-0048's category rule.
|
||||
2. **Control 2701** (SAP Table 4e **Group 7**, "no time or thermostatic control
|
||||
of room temperature", **+0.3 °C**) in `_control_for` — the conservative
|
||||
default within the group the category fixes: the largest mean-internal-
|
||||
temperature adjustment in Group 7, so an unobserved control is never
|
||||
over-credited. Mirrors the storage manual-charge 2401
|
||||
([ADR-0035](0035-coherent-heating-system-synthesis.md)) and room-heater 2601
|
||||
(ADR-0048) precedents.
|
||||
|
||||
Fuel (electricity 29) and meter (Dual for 421/422; **deferred to the cert** for
|
||||
tariff-ambiguous 424) are already correct per ADR-0046 and are unchanged.
|
||||
|
||||
**Scope**: underfloor only. Electric boiler / CPSU (Group 1) remain
|
||||
log-and-continue gaps tracked by #1444 — their category (2) feeds the wet-boiler
|
||||
fallback classification in the generators and needs its own check.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An underfloor override on any lodged system now yields a coherent cert: the
|
||||
Table 12a resolver and the heat-network gates read the dwelling as electric
|
||||
underfloor, the baseline re-rates correctly, and the HHRSH bundle is offered
|
||||
where eligible (a flat's only heating pathway — ASHP stays house/bungalow-only
|
||||
per ADR-0024).
|
||||
- The "incomplete companion set" error log no longer fires for 421/422/424.
|
||||
- **Known deferred gap (pre-existing, unchanged by this ADR):** the Table 12a
|
||||
space-heating resolver's underfloor rows are wired in the fraction table but
|
||||
`_table_12a_system_for_main` never returns `UNDERFLOOR_HEATING` ("Underfloor
|
||||
heating (421-422) — TODO"), so an off-peak-metered underfloor dwelling falls
|
||||
to the all-low-rate fallback. That affects genuinely-lodged underfloor certs
|
||||
identically and needs spec sight (SAP 10.2 Table 12a Grid 1, PDF p.191) to
|
||||
split slab (storage-like) from screed (direct-acting-like); tracked as a
|
||||
follow-up, not ridden on this override fix.
|
||||
|
||||
### Alternatives rejected
|
||||
|
||||
- **A zero-adjustment Group 7 control (2703/2704).** Rejected: credits a room
|
||||
thermostat the landlord never reported; the conservative default is the
|
||||
ADR-0035/0048 pattern.
|
||||
- **Completing electric boiler / CPSU (category 2) in the same change.**
|
||||
Rejected: no evidence property in this workstream, and category 2 flows into
|
||||
the generators' wet-boiler fallback (`_WET_BOILER_CATEGORIES`), which would
|
||||
make an electric-boiler override newly eligible for tune-up options — a
|
||||
behaviour change needing its own corpus check (#1444).
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
# Wall insulation is offered only when it lowers the derived wall U-value
|
||||
|
||||
## Status
|
||||
|
||||
accepted
|
||||
|
||||
## Context
|
||||
|
||||
The wall Recommendation Generators trigger on the wall's **insulation state**
|
||||
alone: `recommend_cavity_wall` offers cavity fill for any cavity wall whose
|
||||
`wall_insulation_type` is as-built (4), and `recommend_solid_wall` offers
|
||||
EWI/IWI for any as-built suitable construction
|
||||
([ADR-0019](0019-solid-wall-insulation-options.md)). Neither asks whether
|
||||
adding insulation can actually **change the modelled wall**.
|
||||
|
||||
For modern construction age bands it cannot: RdSAP's Table 6-10 cascade
|
||||
(`u_wall` in `domain/sap10_ml/rdsap_uvalues.py`) assigns an as-built wall its
|
||||
age band's at-regs U-value, and the post-measure state resolves to the **same
|
||||
value**. Portfolio-796 evidence (PRD #1435, WS2): property **711795**, timber
|
||||
frame at age band **L** (2012–2022) — `u_wall` returns **0.280 W/m²K both
|
||||
as-built and with 100 mm IWI**, so the persisted plan offered
|
||||
`internal_wall_insulation` at **£4,027 for +0.00 SAP**. Any modern-age-band
|
||||
dwelling gets the same no-op fabric offer; on an unliftable dwelling it is the
|
||||
*entire* remedial menu, which reads as "we recommend spending £4k for nothing".
|
||||
|
||||
Scoring already prices the option at +0.00, but the Optimiser keeps
|
||||
zero-uplift candidates in the persisted menu (they are legitimate for some
|
||||
measure types, e.g. secondary-heating removal saves bills without SAP), so the
|
||||
fix belongs at **generation eligibility**, not a blanket score filter.
|
||||
|
||||
## Decision
|
||||
|
||||
Both wall generators consult the **derived wall U-value** before offering: the
|
||||
measure is offered only when the post-measure state's `u_wall` is **lower than
|
||||
the current state's by more than 0.01 W/m²K** (a float-noise guard, not a
|
||||
materiality threshold — economics stay with the Optimiser, ADR-0016/0024).
|
||||
|
||||
- **Current state**: `u_wall` with the effective main wall's construction, age
|
||||
band, lodged insulation type/thickness — a thin parameter mapping in the
|
||||
`envelope.py` style, deliberately omitting the calculator's rich extras
|
||||
(surveyor description, dry-lining, documentary-thickness routes). Omissions
|
||||
bias the gate toward **offering** (both sides are computed identically, so a
|
||||
no-op still compares equal), never toward suppressing a real measure.
|
||||
- **Post-measure state**: the same call with the Option's overlay applied —
|
||||
filled cavity (`wall_insulation_type=2`) for cavity fill; 100 mm
|
||||
internal/external insulation for IWI/EWI.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Age-band-L (and any at-regs age band) walls stop polluting candidate menus
|
||||
with £4k no-ops; genuinely treatable walls (e.g. age-band-D solid brick,
|
||||
U 2.1 → ~0.3) are offered exactly as before.
|
||||
- The generators gain a read on `domain/sap10_ml/rdsap_uvalues.u_wall` — same
|
||||
precedent as the heating generator reading the calculator's PCDB/Table 4b.
|
||||
- **Deferred:** (a) a cert that lodges an explicit `wall_u_value` overrides the
|
||||
cascade in the calculator, making fabric measures score +0.00 regardless of
|
||||
this gate — the measure overlay should adjust the lodged U; (b) roof/floor
|
||||
generators have the same anti-pattern in principle (roof age-band eligibility
|
||||
is partly covered by ADR-0047); both tracked as follow-ups.
|
||||
|
||||
### Alternatives rejected
|
||||
|
||||
- **Filter zero-SAP candidates after scoring.** Rejected: zero-uplift options
|
||||
are legitimate for some measure types (bill-driven), and the pollution would
|
||||
survive in any consumer that reads Generator output before scoring.
|
||||
- **Hard-code "age band ≥ K ⇒ no wall offer".** Rejected: duplicates the U
|
||||
tables coarsely; the tables already encode exactly which
|
||||
construction × age-band × insulation combinations move.
|
||||
- **Full-fidelity U parity with the calculator (description, dry-lining,
|
||||
lodged U).** Rejected for now: heavier coupling for no observed benefit —
|
||||
the omissions only ever bias toward offering, which is the status quo.
|
||||
|
|
@ -134,6 +134,19 @@ _ELECTRIC_BOILER_CODES = frozenset({191, 192})
|
|||
# unambiguously electric (ADR-0046).
|
||||
_ELECTRIC_UNDERFLOOR_CODES = frozenset({421, 422, 424})
|
||||
|
||||
# SAP Table 4a "electric underfloor heating" category. Stamped so an underfloor
|
||||
# override never leaves the replaced system's category behind — property 711795
|
||||
# (PRD #1435 WS2) kept a replaced heat network's category 6, which the heating
|
||||
# generator's `is_heat_network_main` gate read as community heating: no heating
|
||||
# upgrade was offered at all and the baseline mis-rated 51 vs 67 (ADR-0050).
|
||||
_UNDERFLOOR_CATEGORY = 8
|
||||
# SAP Table 4e Group 7 underfloor control: 2701 ("no time or thermostatic
|
||||
# control of room temperature", +0.3 C) is the conservative default when the
|
||||
# landlord named only the system — the largest mean-internal-temperature
|
||||
# adjustment in the group, so an unobserved control is never over-credited
|
||||
# (the underfloor mirror of storage-manual 2401 and room-heater 2601, ADR-0050).
|
||||
_UNDERFLOOR_CONTROL = 2701
|
||||
|
||||
# Heat pumps (SAP Table 4a 211-224 wet, 521-527 warm-air) are category 4 and
|
||||
# unambiguously electric (natural fuel 29). Modellable on the default code's SPF
|
||||
# without a PCDB index (ADR-0041).
|
||||
|
|
@ -271,6 +284,8 @@ def _control_for(code: int) -> Optional[int]:
|
|||
return _FULL_BOILER_CONTROL
|
||||
if code in _CATEGORY_10_ROOM_HEATER_CODES or code in _ELECTRIC_ROOM_HEATER_CODES:
|
||||
return _ROOM_HEATER_CONTROL
|
||||
if code in _ELECTRIC_UNDERFLOOR_CODES:
|
||||
return _UNDERFLOOR_CONTROL
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -287,6 +302,8 @@ def _category_for(code: int) -> Optional[int]:
|
|||
return _ROOM_HEATER_CATEGORY
|
||||
if code in _STORAGE_HEATER_CODES:
|
||||
return _STORAGE_HEATER_CATEGORY
|
||||
if code in _ELECTRIC_UNDERFLOOR_CODES:
|
||||
return _UNDERFLOOR_CATEGORY
|
||||
if code in _HEAT_PUMP_CODES:
|
||||
return _HEAT_PUMP_CATEGORY
|
||||
if code in _HEAT_NETWORK_CODES:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from domain.building_geometry import gross_heat_loss_wall_area
|
|||
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
|
||||
from domain.modelling.generators.wall_u_value_gate import wall_insulation_lowers_u
|
||||
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||
from repositories.product.product_repository import ProductRepository
|
||||
|
||||
|
|
@ -158,6 +159,15 @@ def recommend_solid_wall(
|
|||
measure_type
|
||||
for measure_type in measure_types
|
||||
if _allowed(measure_type, restrictions, is_flat)
|
||||
# Wall U-Value Gate (ADR-0051): withhold an Option that cannot lower
|
||||
# the wall's derived U — e.g. an at-regs modern age band, where the
|
||||
# as-built and insulated cascades resolve to the same value.
|
||||
and wall_insulation_lowers_u(
|
||||
main,
|
||||
epc.country_code,
|
||||
insulation_type=_INSULATION_TYPE[measure_type],
|
||||
insulation_thickness_mm=_SOLID_WALL_INSULATION_MM,
|
||||
)
|
||||
)
|
||||
if not allowed:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
EpcPropertyData,
|
||||
)
|
||||
from domain.building_geometry import gross_heat_loss_wall_area
|
||||
from domain.modelling.generators.wall_u_value_gate import wall_insulation_lowers_u
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
|
||||
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||
|
|
@ -46,6 +47,14 @@ def recommend_cavity_wall(
|
|||
):
|
||||
return None
|
||||
|
||||
# Wall U-Value Gate (ADR-0051): withhold the fill when it cannot lower the
|
||||
# wall's derived U — e.g. an at-regs modern age band, where the as-built and
|
||||
# filled-cavity cascades resolve to the same (or a no-better) value.
|
||||
if not wall_insulation_lowers_u(
|
||||
main, epc.country_code, insulation_type=_FILLED_CAVITY
|
||||
):
|
||||
return None
|
||||
|
||||
product = products.get(_CAVITY_MEASURE_TYPE)
|
||||
wall_area: float = gross_heat_loss_wall_area(epc, BuildingPartIdentifier.MAIN)
|
||||
cost = Cost(
|
||||
|
|
|
|||
80
domain/modelling/generators/wall_u_value_gate.py
Normal file
80
domain/modelling/generators/wall_u_value_gate.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""The Wall U-Value Gate (ADR-0051).
|
||||
|
||||
A wall insulation Option is offered only when it would actually lower the main
|
||||
wall's derived U-value: on a modern construction age band RdSAP's Table 6-10
|
||||
cascade assigns an as-built wall its at-regs U-value, and the post-measure
|
||||
state resolves to the *same* value — the measure costs thousands for +0.00 SAP
|
||||
(property 711795, PRD #1435 WS2: timber frame at age band L, 0.280 → 0.280).
|
||||
|
||||
The comparison uses a deliberately thin `u_wall` parameter mapping (the
|
||||
`envelope.py` style — construction, age band, insulation type/thickness,
|
||||
documentary wall thickness), omitting the calculator's rich extras (surveyor
|
||||
description, dry-lining, lodged `wall_u_value`). The omissions bias the gate
|
||||
toward OFFERING — both sides are computed identically, so a genuine no-op still
|
||||
compares equal — never toward suppressing a real measure.
|
||||
"""
|
||||
|
||||
from typing import Any, Final, Optional
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import SapBuildingPart
|
||||
from domain.sap10_ml.rdsap_uvalues import WALL_UNKNOWN, Country, u_wall
|
||||
|
||||
# Improvements below this are float noise, not a physical change. NOT a
|
||||
# materiality threshold — how much improvement is worth the cost stays with the
|
||||
# Optimiser (ADR-0016/0024).
|
||||
_NOISE_FLOOR_W_PER_M2K: Final[float] = 0.01
|
||||
|
||||
# RdSAP `wall_insulation_type` 4 = as-built / assumed (no added insulation).
|
||||
_WALL_INSULATION_NONE: Final[int] = 4
|
||||
|
||||
|
||||
def _int_or_none(value: Any) -> Optional[int]:
|
||||
return value if isinstance(value, int) else None
|
||||
|
||||
|
||||
def _parse_thickness_mm(value: Any) -> Optional[int]:
|
||||
if value is None or isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str) and value.strip().isdigit():
|
||||
return int(value.strip())
|
||||
return None # "NI" / free text — thickness not known
|
||||
|
||||
|
||||
def wall_insulation_lowers_u(
|
||||
main: SapBuildingPart,
|
||||
country_code: Optional[str],
|
||||
*,
|
||||
insulation_type: int,
|
||||
insulation_thickness_mm: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Whether insulating the main wall to ``insulation_type`` (+ optional
|
||||
thickness) lowers its derived U-value by more than float noise — the Wall
|
||||
U-Value Gate (ADR-0051)."""
|
||||
country: Country = Country.from_code(country_code)
|
||||
age_band = main.construction_age_band
|
||||
construction: Optional[int] = _int_or_none(main.wall_construction)
|
||||
if construction == WALL_UNKNOWN:
|
||||
construction = None
|
||||
wall_thickness_mm = main.wall_thickness_mm
|
||||
current_type: Optional[int] = _int_or_none(main.wall_insulation_type)
|
||||
current_u: float = u_wall(
|
||||
country,
|
||||
age_band,
|
||||
construction,
|
||||
_parse_thickness_mm(main.wall_insulation_thickness),
|
||||
insulation_present=(
|
||||
current_type is not None and current_type != _WALL_INSULATION_NONE
|
||||
),
|
||||
wall_insulation_type=current_type,
|
||||
wall_thickness_mm=wall_thickness_mm,
|
||||
)
|
||||
post_u: float = u_wall(
|
||||
country,
|
||||
age_band,
|
||||
construction,
|
||||
insulation_thickness_mm,
|
||||
insulation_present=True,
|
||||
wall_insulation_type=insulation_type,
|
||||
wall_thickness_mm=wall_thickness_mm,
|
||||
)
|
||||
return current_u - post_u > _NOISE_FLOOR_W_PER_M2K
|
||||
|
|
@ -910,10 +910,12 @@ def main() -> None:
|
|||
print_table=False,
|
||||
)
|
||||
# The full candidate menu (every Generator Option + its cost), so
|
||||
# measures the Optimiser did not select are still visible. A predicted
|
||||
# Property has no lodged cert, so the synthesised Effective EPC is used.
|
||||
# measures the Optimiser did not select are still visible. Generated
|
||||
# from the Effective EPC — the same picture the plan above modelled;
|
||||
# the lodged cert would print a menu the Optimiser never saw (e.g.
|
||||
# cavity fill on a wall an override moved to timber frame).
|
||||
candidates: list[Recommendation] = candidate_recommendations(
|
||||
epc if epc is not None else effective_epc,
|
||||
effective_epc,
|
||||
planning_restrictions=restrictions,
|
||||
solar_insights=solar_insights,
|
||||
considered_measures=considered,
|
||||
|
|
|
|||
|
|
@ -233,6 +233,80 @@ def test_off_peak_underfloor_asserts_a_dual_meter(main_heating_value: str) -> No
|
|||
assert simulation.heating.meter_type == "Dual"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"main_heating_value",
|
||||
[
|
||||
"Electric underfloor, in concrete slab (off-peak)",
|
||||
"Electric underfloor, integrated storage and direct-acting",
|
||||
"Electric underfloor, in screed above insulation",
|
||||
],
|
||||
)
|
||||
def test_electric_underfloor_overlays_the_underfloor_category(
|
||||
main_heating_value: str,
|
||||
) -> None:
|
||||
# A system-replacing override must stamp the underfloor heating category
|
||||
# (SAP Table 4a Category 8). Leaving it unset lets the replaced system's
|
||||
# category survive onto the effective cert — property 711795's community
|
||||
# heating (category 6) survived an underfloor override, so the heating
|
||||
# generator read the dwelling as a heat network and offered no heating
|
||||
# upgrade at all, and the baseline mis-rated 51 vs 67 (ADR-0050).
|
||||
# Act
|
||||
simulation = main_heating_overlay_for(main_heating_value, 0)
|
||||
|
||||
# Assert — SAP Table 4a Category 8 (Electric underfloor heating).
|
||||
assert simulation is not None
|
||||
assert simulation.heating is not None
|
||||
assert simulation.heating.main_heating_category == 8
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"main_heating_value",
|
||||
[
|
||||
"Electric underfloor, in concrete slab (off-peak)",
|
||||
"Electric underfloor, integrated storage and direct-acting",
|
||||
"Electric underfloor, in screed above insulation",
|
||||
],
|
||||
)
|
||||
def test_electric_underfloor_drags_the_conservative_group_7_control(
|
||||
main_heating_value: str,
|
||||
) -> None:
|
||||
# The landlord names the system, not its control, so the overlay assumes the
|
||||
# conservative Table 4e Group 7 default: 2701 ("no time or thermostatic
|
||||
# control", +0.3 C — the largest adjustment in the group, so an unobserved
|
||||
# control is never over-credited). Mirrors storage-manual 2401 / room-heater
|
||||
# 2601; also overwrites a stale control inherited from the replaced system
|
||||
# (711795 kept a community control 2311) — ADR-0050.
|
||||
# Act
|
||||
simulation = main_heating_overlay_for(main_heating_value, 0)
|
||||
|
||||
# Assert — SAP Table 4e Group 7 conservative underfloor control.
|
||||
assert simulation is not None
|
||||
assert simulation.heating is not None
|
||||
assert simulation.heating.main_heating_control == 2701
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"main_heating_value",
|
||||
[
|
||||
"Electric underfloor, in concrete slab (off-peak)",
|
||||
"Electric underfloor, integrated storage and direct-acting",
|
||||
"Electric underfloor, in screed above insulation",
|
||||
],
|
||||
)
|
||||
def test_complete_underfloor_overlay_does_not_log_a_companion_gap(
|
||||
main_heating_value: str, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
# Electric underfloor now resolves a full companion set (category 8 +
|
||||
# control 2701, ADR-0050), 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
|
||||
|
||||
|
||||
def test_screed_underfloor_defers_the_meter_to_the_cert() -> None:
|
||||
# "In screed above insulation (standard or off peak)" is tariff-ambiguous (81%
|
||||
# of the corpus lodge off-peak), so the archetype cannot know the meter — it
|
||||
|
|
|
|||
|
|
@ -90,6 +90,69 @@ def test_non_cavity_main_wall_yields_no_recommendation() -> None:
|
|||
assert recommendation is None
|
||||
|
||||
|
||||
def test_at_regs_age_band_cavity_yields_no_cavity_fill_recommendation() -> None:
|
||||
# Arrange — a cavity wall lodged as-built at construction age band L
|
||||
# (2012-2022): the as-built cascade already assigns the at-regs U-value, so
|
||||
# filling the cavity cannot lower it. The Wall U-Value Gate withholds the
|
||||
# Option (ADR-0051); the legacy insulation-state trigger alone would offer
|
||||
# a paid no-op.
|
||||
baseline: EpcPropertyData = build_epc() # MAIN: cavity (4), uninsulated (4)
|
||||
_part(baseline, BuildingPartIdentifier.MAIN).construction_age_band = "L"
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_cavity_wall(
|
||||
baseline, _StubProducts()
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert recommendation is None
|
||||
|
||||
|
||||
def test_at_regs_age_band_wall_yields_no_solid_wall_recommendation() -> None:
|
||||
# Arrange — a timber-frame wall lodged as-built at construction age band L
|
||||
# (2012-2022). RdSAP's cascade assigns it the at-regs U-value (0.28), and
|
||||
# 100 mm IWI resolves to the SAME value — the measure cannot change the
|
||||
# modelled wall, so offering it sells £4k of insulation for +0.00 SAP
|
||||
# (property 711795, PRD #1435 WS2). The Wall U-Value Gate withholds it
|
||||
# (ADR-0051).
|
||||
baseline: EpcPropertyData = build_epc()
|
||||
main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN)
|
||||
main.wall_construction = 5 # timber frame — IWI-only construction
|
||||
main.wall_insulation_type = 4 # as-built / uninsulated — the legacy trigger
|
||||
main.construction_age_band = "L"
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_solid_wall(
|
||||
baseline, _StubProducts()
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert recommendation is None
|
||||
|
||||
|
||||
def test_treatable_old_band_solid_wall_keeps_its_insulation_options() -> None:
|
||||
# Arrange — solid brick as-built at age band B: the cascade U (~2.1) far
|
||||
# exceeds what 100 mm EWI/IWI achieves, so the Wall U-Value Gate must let
|
||||
# both Options through unchanged (the gate is physical possibility only —
|
||||
# ADR-0051).
|
||||
baseline: EpcPropertyData = build_epc() # age band B
|
||||
main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN)
|
||||
main.wall_construction = 3 # solid brick — EWI + IWI constructable
|
||||
main.wall_insulation_type = 4
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_solid_wall(
|
||||
baseline, _StubProducts()
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert recommendation is not None
|
||||
assert {option.measure_type for option in recommendation.options} == {
|
||||
"external_wall_insulation",
|
||||
"internal_wall_insulation",
|
||||
}
|
||||
|
||||
|
||||
def test_park_home_wall_yields_no_solid_wall_recommendation() -> None:
|
||||
# Arrange — a park home (wall_construction code 8) with an uninsulated
|
||||
# as-built wall. Code 8 is NOT system-built (ADR-0019); a park home's
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue