mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Slice S0380.179: RdSAP §10.7 electric-immersion default for no-system certs
Closes the "no system" corpus variant fully (ΔSAP +1.18 → <1e-4 on all four metrics). The cert lodges §15.0 "Water Heating Code: NON / SapCode 999" and §15.1 "Hot Water Cylinder Present: No". Per RdSAP 10 §10.7 (PDF p.55) "No water heating system" verbatim: "the calculation is done for an electric immersion heater. If the electric meter is dual the immersion heater is also dual, but is a single immersion otherwise... for a cylinder defined by the first row of Table 28 (110 litres) and the first row of Table 29." Table 29 row 1 gives age-band cylinder insulation (age G -> 25 mm foam) and assumes a cylinder thermostat present for immersion-heated DHW. The BRE-approved Elmhurst engine confirms the substitution: the P960 worksheet header lodges "WHS: 903 Electric immersion, Single", a 110 L cylinder, and storage loss (56) = 594.32 kWh/yr, so HW (64) = (45) 1935.37 + 594.32 = 2529.6927. Pre-slice the cascade trusted the lodged "no cylinder" -> added no storage loss and a spurious Table 3a keep-hot combi loss; the wrong HW heat-gains also propagated through §5/§7, over-stating the base MIT by +0.25 K and space fuel by +228 kWh. New `_apply_rdsap_no_water_heating_system_default(epc)` rebinds the epc at the top of cert_to_inputs (the demand cascade delegates here too) when water_heating_code == 999, injecting WHC 903 + electricity fuel + 110 L cylinder + Table 29 insulation + assumed cylinder thermostat. This closes HW fuel AND the downstream space residual in one move. Age bands A-F (12 mm loose jacket) raise UnmappedSapCode — no corpus member exercises that and the Table 2 loss-factor dispatch only has the factory-foam path plumbed. Gate is keyed on code 999, unique to "no system" in the corpus; 40 other variants + 858 section pins + 6 U985 fixtures unchanged. 936 pass; pyright net-zero 32 -> 32. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c054d71284
commit
f2062a2fbe
2 changed files with 117 additions and 2 deletions
|
|
@ -511,7 +511,21 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
|
||||||
# residual (SAP +1.18, cost −£27 / CO2 −50 / PE −562) — likely a
|
# residual (SAP +1.18, cost −£27 / CO2 −50 / PE −562) — likely a
|
||||||
# cascade-side §A.2.2 efficiency or tariff-routing gap; pinned as
|
# cascade-side §A.2.2 efficiency or tariff-routing gap; pinned as
|
||||||
# forcing function for follow-up.
|
# forcing function for follow-up.
|
||||||
_CorpusExpectation(variant='no system', block='11a', expected_sap_resid=+1.1783, expected_cost_resid_gbp=-27.1485, expected_co2_resid_kg=-49.8272, expected_pe_resid_kwh=-562.4367),
|
# Slice S0380.179 closed `no system` via RdSAP 10 §10.7 (PDF p.55)
|
||||||
|
# "No water heating system": the cert lodges §15.0 water code 999
|
||||||
|
# (NON) + §15.1 "Cylinder Present: No", but per spec the calculation
|
||||||
|
# is done for an electric immersion heater on a Table 28 row-1 110 L
|
||||||
|
# cylinder with Table 29 row-1 age-band insulation (25 mm foam at age
|
||||||
|
# G). The P960 worksheet header confirms the engine's substitution
|
||||||
|
# (WHS 903 Single immersion, 110 L). Pre-slice the cascade trusted
|
||||||
|
# the lodged "no cylinder" → no storage loss (56) + a spurious Table
|
||||||
|
# 3a combi loss, and the wrong HW heat-gains propagated through §5/§7
|
||||||
|
# to over-state the base MIT (+0.25 K), over-stating space fuel by
|
||||||
|
# +228 kWh. `_apply_rdsap_no_water_heating_system_default` injects
|
||||||
|
# the default cylinder before the section cascades, closing HW fuel
|
||||||
|
# (219) 1935.37 → 2529.69 EXACT AND the space residual in one move.
|
||||||
|
# ΔSAP +1.18 → <1e-4, all four metrics EXACT.
|
||||||
|
_CorpusExpectation(variant='no system', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000),
|
||||||
# Slice S0380.170 unblocked the 5 community-heating variants. Per
|
# Slice S0380.170 unblocked the 5 community-heating variants. Per
|
||||||
# SAP 10.2 Table 12 (PDF p.189) the heat-network fuel code comes
|
# SAP 10.2 Table 12 (PDF p.189) the heat-network fuel code comes
|
||||||
# from the §14.1 Community Heat Source × Community Fuel Type pair:
|
# from the §14.1 Community Heat Source × Community Fuel Type pair:
|
||||||
|
|
@ -920,6 +934,35 @@ def test_oil_6_absent_room_thermostat_applies_table_4f_pump_1_3_multiplier() ->
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_system_assumes_rdsap_10_7_electric_immersion_default_cylinder() -> None:
|
||||||
|
# Arrange — the "no system" cert lodges §15.0 "Water Heating Code:
|
||||||
|
# NON / SapCode 999" and §15.1 "Hot Water Cylinder Present: No". Per
|
||||||
|
# RdSAP 10 §10.7 (PDF p.55) "No water heating system" verbatim: "the
|
||||||
|
# calculation is done for an electric immersion heater... for a
|
||||||
|
# cylinder defined by the first row of Table 28 (110 litres) and the
|
||||||
|
# first row of Table 29." The BRE-approved Elmhurst engine confirms
|
||||||
|
# it — the P960 worksheet header lodges "WHS: 903 Electric immersion,
|
||||||
|
# Single", a 110 L cylinder, and Table 29 age-band insulation (the
|
||||||
|
# corpus property is age G -> 25 mm foam), giving storage loss (56) =
|
||||||
|
# 594.32 kWh/yr. Worksheet HW (64) = (45) 1935.37 + (56) 594.32 =
|
||||||
|
# 2529.6927. Pre-slice the cascade trusted the lodged "no cylinder"
|
||||||
|
# so it added no storage loss (and a spurious Table 3a combi loss).
|
||||||
|
summary_pdf, _ = _variant_paths('no system')
|
||||||
|
pages = _summary_pdf_to_textract_style_pages(summary_pdf)
|
||||||
|
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||||
|
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||||
|
|
||||||
|
# Act — run the rating cascade and read the resolved HW fuel kWh.
|
||||||
|
inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||||
|
|
||||||
|
# Assert — HW fuel = (45) + Table 29 110 L / 25 mm-foam storage loss
|
||||||
|
# = 2529.6927 (matches worksheet (64)/(219)).
|
||||||
|
assert abs(inputs.hot_water_kwh_per_yr - 2529.6927) <= 1e-3, (
|
||||||
|
f"no system HW {inputs.hot_water_kwh_per_yr:.4f} kWh != 2529.6927 "
|
||||||
|
f"(RdSAP 10 §10.7 electric-immersion default 110 L cylinder)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
not _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE,
|
not _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE,
|
||||||
reason="all blocked variants have been unblocked (latest: S0380.170)",
|
reason="all blocked variants have been unblocked (latest: S0380.170)",
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ Reference: RdSAP 10 specification (10-06-2025); SAP 10.2 specification
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
from typing import Callable, Final, Literal, Optional
|
from typing import Callable, Final, Literal, Optional
|
||||||
|
|
||||||
|
|
@ -4142,6 +4142,72 @@ _CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = {
|
||||||
# "Foam" → factory-applied per SAP 10.2 Table 2 Note 2).
|
# "Foam" → factory-applied per SAP 10.2 Table 2 Note 2).
|
||||||
_CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1
|
_CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1
|
||||||
|
|
||||||
|
# RdSAP 10 §10.7 (PDF p.55) "No water heating system": SAP water-heating
|
||||||
|
# code 999 (Elmhurst §15.0 "NON") signals that no DHW system was
|
||||||
|
# identified. Per spec the calculation is then done for an electric
|
||||||
|
# immersion heater + a cylinder defined by the first row of Table 28
|
||||||
|
# (110 litres) and the first row of Table 29 (age-band insulation).
|
||||||
|
_WHC_NO_WATER_HEATING_SYSTEM: Final[int] = 999
|
||||||
|
# Table 28 row 1 "Inaccessible — otherwise: 110 litres" → SAP cylinder
|
||||||
|
# size code 2 (Normal, 110 L). The immersion is single unless the meter
|
||||||
|
# is dual; the corpus "no system" cert's worksheet header lodges
|
||||||
|
# "Immersion Heater Type: Single" so the single-immersion path is used.
|
||||||
|
_CYLINDER_SIZE_CODE_NORMAL_110L: Final[int] = 2
|
||||||
|
# RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not
|
||||||
|
# accessible" — the §10.7 default cylinder uses the age-band insulation,
|
||||||
|
# same rule as the inaccessible-cylinder path: A-F → 12 mm loose jacket
|
||||||
|
# (not yet plumbed — strict-raise), G/H → 25 mm foam, I-M → 38 mm foam.
|
||||||
|
_TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE: Final[dict[str, int]] = {
|
||||||
|
"G": 25, "H": 25, "I": 38, "J": 38, "K": 38, "L": 38, "M": 38,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_rdsap_no_water_heating_system_default(
|
||||||
|
epc: EpcPropertyData,
|
||||||
|
) -> EpcPropertyData:
|
||||||
|
"""RdSAP 10 §10.7 (PDF p.55) — when no water heating system is
|
||||||
|
identified (`water_heating_code == 999`), substitute the spec
|
||||||
|
default: an electric immersion heater (single — dual handling not
|
||||||
|
yet exercised) on a Table 28 row-1 110 L cylinder with Table 29
|
||||||
|
row-1 age-band insulation and an assumed cylinder thermostat
|
||||||
|
(Table 29: "A cylinder thermostat should be assumed to be present
|
||||||
|
when DHW is from ... an immersion heater ...").
|
||||||
|
|
||||||
|
Returns `epc` unchanged when a real water heating system is lodged.
|
||||||
|
Otherwise returns a copy with `has_hot_water_cylinder=True` and the
|
||||||
|
`sap_heating` fields the WHC-903 electric-immersion + cylinder
|
||||||
|
cascade reads, so every downstream gate (storage loss, combi-loss
|
||||||
|
suppression, primary loss) sees the spec default. This mirrors the
|
||||||
|
Elmhurst engine's worksheet header for the corpus "no system" cert
|
||||||
|
(WHS 903, Single immersion, 110 L cylinder, 25 mm foam at age G).
|
||||||
|
|
||||||
|
Raises `UnmappedSapCode` for age bands A-F (12 mm loose jacket) —
|
||||||
|
no corpus member exercises that combination and the SAP 10.2 Table 2
|
||||||
|
loss-factor dispatch only has the factory-foam path plumbed.
|
||||||
|
"""
|
||||||
|
if epc.sap_heating.water_heating_code != _WHC_NO_WATER_HEATING_SYSTEM:
|
||||||
|
return epc
|
||||||
|
age_band = (
|
||||||
|
epc.sap_building_parts[0].construction_age_band
|
||||||
|
if epc.sap_building_parts else None
|
||||||
|
)
|
||||||
|
band = (age_band or "")[:1].upper()
|
||||||
|
thickness_mm = _TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE.get(band)
|
||||||
|
if thickness_mm is None:
|
||||||
|
raise UnmappedSapCode(
|
||||||
|
"rdsap_10_7_default_cylinder_insulation_age_band", age_band
|
||||||
|
)
|
||||||
|
sap_heating = replace(
|
||||||
|
epc.sap_heating,
|
||||||
|
water_heating_code=_WHC_ELECTRIC_IMMERSION,
|
||||||
|
water_heating_fuel=_STANDARD_ELECTRICITY_FUEL_CODE,
|
||||||
|
cylinder_size=_CYLINDER_SIZE_CODE_NORMAL_110L,
|
||||||
|
cylinder_insulation_type=_CYLINDER_INSULATION_TYPE_FACTORY,
|
||||||
|
cylinder_insulation_thickness_mm=thickness_mm,
|
||||||
|
cylinder_thermostat="Y",
|
||||||
|
)
|
||||||
|
return replace(epc, has_hot_water_cylinder=True, sap_heating=sap_heating)
|
||||||
|
|
||||||
|
|
||||||
# SAP 10.2 Table 4a solid-fuel boiler sub-rows (PDF p.163) — independent
|
# SAP 10.2 Table 4a solid-fuel boiler sub-rows (PDF p.163) — independent
|
||||||
# boilers (151, 153, 155, 159), open-fire + back boiler (156), closed
|
# boilers (151, 153, 155, 159), open-fire + back boiler (156), closed
|
||||||
|
|
@ -5483,6 +5549,12 @@ def cert_to_inputs(
|
||||||
parity validation now relies on the Validation Cohort filter
|
parity validation now relies on the Validation Cohort filter
|
||||||
(inspection_date ≥ 2025-07-01) rather than a per-cert price
|
(inspection_date ≥ 2025-07-01) rather than a per-cert price
|
||||||
override."""
|
override."""
|
||||||
|
# RdSAP 10 §10.7 (PDF p.55) — substitute the electric-immersion +
|
||||||
|
# default-cylinder assumption before any section cascade runs when no
|
||||||
|
# water heating system is lodged (code 999). Rebinding `epc` here
|
||||||
|
# means every downstream helper sees the spec default; the demand
|
||||||
|
# cascade reuses this entry point so it is covered too.
|
||||||
|
epc = _apply_rdsap_no_water_heating_system_default(epc)
|
||||||
dim = dimensions_from_cert(epc)
|
dim = dimensions_from_cert(epc)
|
||||||
# SAP §3 heat transmission + §2 ventilation cascades — see the
|
# SAP §3 heat transmission + §2 ventilation cascades — see the
|
||||||
# respective `_from_cert` helpers for cert→inputs mapping rules.
|
# respective `_from_cert` helpers for cert→inputs mapping rules.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue