mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +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
|
||||
# cascade-side §A.2.2 efficiency or tariff-routing gap; pinned as
|
||||
# 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
|
||||
# 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:
|
||||
|
|
@ -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(
|
||||
not _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE,
|
||||
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
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
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).
|
||||
_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
|
||||
# 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
|
||||
(inspection_date ≥ 2025-07-01) rather than a per-cert price
|
||||
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)
|
||||
# SAP §3 heat transmission + §2 ventilation cascades — see the
|
||||
# respective `_from_cert` helpers for cert→inputs mapping rules.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue