Merge pull request #1162 from Hestia-Homes/feature/per-cert-mapper-validation

Feature/per cert mapper validation
This commit is contained in:
KhalimCK 2026-06-03 09:45:54 +01:00 committed by GitHub
commit 010a576a4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1903 additions and 97 deletions

View file

@ -1090,7 +1090,28 @@ class ElmhurstSiteNotesExtractor:
if inline_glazing_type is not None:
glazing_type = inline_glazing_type
else:
glazing_type = " ".join([*prefix, *suffix]).strip()
# The glazing-type phrase always starts with a glazing-start
# word (Single/Double/Triple/Secondary). The FIRST window in
# a building part has `before_start = 0`, so its prefix block
# reaches back into the wrapped windows-table header; the
# third header line's tail tokenises to "value value Proofed
# Shutters" (the "U value / g value / Draught Proofed /
# Permanent Shutters" column titles) and is neither an
# orientation nor a bp fragment, so it survives the pops.
# Drop any prefix fragments preceding the glazing-start word
# so they don't leak into the glazing type.
glazing_start = next(
(
idx
for idx, frag in enumerate(prefix)
if frag.split(" ", 1)[0] in self._GLAZING_TYPE_PREFIX_WORDS
),
None,
)
glazing_prefix = (
prefix[glazing_start:] if glazing_start is not None else prefix
)
glazing_type = " ".join([*glazing_prefix, *suffix]).strip()
# Building part: inline token wins; otherwise join prefix + suffix.
if bp_inline is not None:

View file

@ -513,3 +513,112 @@ class TestLightingLedCflUnknown:
def test_cfl_count_zero_when_unknown(self, result2: ElmhurstSiteNotes) -> None:
assert result2.lighting.cfl_count == 0
class TestWindowsLayoutHeaderRemnant:
"""Regression for the first-window glazing-type header leak.
Summary PDFs preprocessed from `pdftotext -layout` wrap the windows
table header across several lines. The third header line's tail
("U value / g value / Draught Proofed / Permanent Shutters") tokenises
to "value value Proofed Shutters" and sits directly above the FIRST
window's data row. Because the first window in a building part has
`before_start = 0`, its prefix block reaches back into that header
remnant, which is neither an orientation nor a building-part fragment
and so survived into `glazing_type` as
"value value Proofed Shutters Double between 2002 and 2021".
Reproduced from `sap worksheets/Recommendations Elmhurst Files/
cavity_wall_insulation - main wall/before/Summary_001431.pdf` (3
Manufacturer-data-source windows; only window 0 was corrupted).
"""
# Faithful reproduction of the tokenised windows section (one page),
# captured verbatim from the Summary PDF above. The header remnant
# "value value Proofed Shutters" precedes window 0's wrapped glazing
# cell ("Double between 2002" / "and 2021").
_WINDOWS_PAGE = "\n".join([
"11.0 Windows:",
"Frame Frame Glazing",
"Building",
"U",
"g Draught Permanent",
"W",
"H",
"Area Glazing Type",
"Location",
"Orient. Data-Source",
"Type Factor Gap",
"Part",
"value value Proofed Shutters",
"Double between 2002",
"North",
"0.97 1.00 0.97",
"PVC",
"0.70",
"Main",
"External wall",
"Manufacturer 2.00",
"0.72",
"Yes",
"None",
"and 2021",
"West",
"Double between 2002",
"South",
"2.66 1.00 2.66",
"PVC",
"0.70",
"Main",
"External wall",
"Manufacturer 2.00",
"0.72",
"Yes",
"None",
"and 2021",
"East",
"Double between 2002",
"South",
"2.66 1.00 2.66",
"PVC",
"0.70",
"Main",
"External wall",
"Manufacturer 2.00",
"0.72",
"Yes",
"None",
"and 2021",
"East",
"12.0 Ventilation",
])
@pytest.fixture(scope="class")
def windows(self) -> list[Window]:
return ElmhurstSiteNotesExtractor([self._WINDOWS_PAGE])._extract_windows()
def test_window_count(self, windows: list[Window]) -> None:
# Arrange / Act / Assert
assert len(windows) == 3
def test_first_window_glazing_type_excludes_header_remnant(
self, windows: list[Window]
) -> None:
# Arrange / Act / Assert — no "value value Proofed Shutters" leak.
assert windows[0].glazing_type == "Double between 2002 and 2021"
def test_all_windows_share_clean_glazing_type(
self, windows: list[Window]
) -> None:
# Arrange / Act / Assert — windows 1 and 2 were already clean;
# all three must agree after the fix.
assert [w.glazing_type for w in windows] == [
"Double between 2002 and 2021"
] * 3
def test_first_window_orientation_unaffected(
self, windows: list[Window]
) -> None:
# Arrange / Act / Assert — trimming the glazing prefix must not
# disturb orientation extraction (North + West fragments).
assert windows[0].orientation == "North-West"

View file

@ -68,13 +68,18 @@ _CORPUS_ROOT = (
# Per-pin absolute tolerances. Worksheet `SAP value` lodges 4 d.p.,
# (255) total fuel cost 4 d.p., (272) total CO2 4 d.p., (286) Total
# Primary energy kWh/year 4 d.p. — pin at 1e-4 relative to lodged
# precision so any drift outside cascade float noise fires.
_SAP_RESID_ABS_TOLERANCE = 0.001
_COST_RESID_ABS_TOLERANCE_GBP = 0.01
_CO2_RESID_ABS_TOLERANCE_KG = 0.1
_PE_RESID_ABS_TOLERANCE_KWH = 0.1
# (255)/(355) total fuel cost 4 d.p., (272)/(383) total CO2 4 d.p.,
# (286)/(483) Total Primary energy kWh/year 4 d.p. — so the hard floor
# on any residual is ~5e-5 (half a unit in the last printed digit),
# independent of cascade precision. Pin at 1e-4 on EVERY metric (per
# [[feedback-zero-error-strict]] / [[feedback-continuous-sap-tolerance]]
# — basically zero error across continuous SAP, cost, CO2 and PE) so
# any drift beyond PDF print-rounding fires loudly. All 41 variants hold
# at this tolerance; closures re-pin the smaller residual, never widen.
_SAP_RESID_ABS_TOLERANCE = 0.0001
_COST_RESID_ABS_TOLERANCE_GBP = 0.0001
_CO2_RESID_ABS_TOLERANCE_KG = 0.0001
_PE_RESID_ABS_TOLERANCE_KWH = 0.0001
@dataclass(frozen=True)
@ -674,11 +679,90 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# distribution" line — 118.38 kWh billed at electricity factors
# (CO2 0.1993, PE 1.760), not heat-network factors — the cascade
# doesn't currently meter this. Next follow-up slice.
_CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-23.6007, expected_pe_resid_kwh=-208.2267),
_CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-1435.0874, expected_pe_resid_kwh=+1123.0063),
_CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-98.9235, expected_pe_resid_kwh=-457.5428),
_CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-4401.8456, expected_pe_resid_kwh=+111.5798),
_CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-7.4942, expected_cost_resid_gbp=+172.6778, expected_co2_resid_kg=-2939.6683, expected_pe_resid_kwh=+7481.5658),
# Slice S0380.180 wired the SAP 10.2 Appendix C §C3.2 (PDF p.51)
# heat-network distribution pumping electricity (worksheet (313) =
# 0.01 × [(307)+(310)]; CO2 (372) / PE (472) on Table 12d/12e fuel-
# code-50 monthly factors weighted by the monthly heat profile).
# CH1 (Boilers/Gas) closes FULLY — the (372)/(472) line was its
# entire remaining residual (un-defers the front the predecessor
# handover flagged "don't guess"; the factor source is §C3.2 +
# Table 12f, not an empirical constant). CH3 (HP/Elec) closes its
# distribution component (CO2 98.92→75.32, PE 457.54→249.32);
# the remainder is the code-304 community-HP COP cascade (separate
# follow-up). CH2/CH4/CH6 gain their (372)/(472) component (CO2
# +23.6, PE +208.2/+208.2/+208.2); their dominant CHP displaced-
# electricity credit residual (Table 12f + block 12b/13b) remains
# for the next slice. Elmhurst DISPLAYS the (372) energy column as
# 0.01 × (307) (space only) but computes emissions on 0.01 ×
# (307+310) per the §C3.2 text — verified EXACT line-by-line.
#
# Slice S0380.182 wired the SAP 10.2 §12b/13b community-heating
# "CHP and boilers" (SAP code 302) CO2/PE cascade: per unit of
# network heat fuel H = (307)+(310), the effective generation factor
# = chp_frac × 100/(362) × f_fuel chp_frac × (361)/(362) × f_disp
# + (1chp_frac) × 100/(367) × f_fuel, where f_fuel is the Table 12
# heat-network fuel factor (CHP + back-up boilers burn the same
# community fuel) and f_disp is the Table 12f credit factor for the
# CHP-generated electricity (Elmhurst uses "flexible operation"
# 0.420 CO2 / 2.369 PE). RdSAP 10 §C (p.58) defaults: heat eff 50% /
# electrical eff 25% / boiler eff 80%; CHP frac 0.35 per-cert. Also
# fixed Table 12 heat-network-oil CO2 (codes 53/56 0.298→0.335 per
# Table 12 p.189 — the code-302 oil cascade was the first to use it).
# CH2 (gas) + CH4 (oil) CO2 + PE now EXACT (<1e-4). CH6 (coal) CO2/PE
# shift sign: its worksheet lodges a manual DLF=1.0 (two adjoining
# dwellings) the Summary doesn't carry, so the cascade's DLF=1.45
# over-scales H — pin + the CH6 SAP 7.49 / cost +£172 are the same
# DLF quirk (separate front, likely pin-forever). CH2/CH4 SAP +0.5277
# / cost £12.16 is the heat-network cost/standing residual exposed
# by S0380.175 (cost-side, untouched by this CO2/PE slice). CH3
# unchanged (code 304 community-HP COP front).
#
# Slice S0380.183 closed the CH2/CH4 HW cost residual: per SAP 10.2
# §10b the community-heating HW bills at the heat-network rate, not
# the Elmhurst §15.0 "Mains gas" placeholder. Worksheet (342) =
# (310) × the S0380.171 CHP heat-fraction blend (= the same rate as
# space heating (340)), not (310) × 3.48 p/kWh gas. Extended
# `_is_community_heating_hw_from_main` to include code 302 — the
# S0380.182 CO2/PE interception sits above this predicate's branch,
# so it now affects only the cost path. CH2 + CH4 are FULLY EXACT
# on all four metrics. CH6 SAP 7.49→8.02 / cost +£172.68→+£184.84
# (its HW now also bills the blend, compounding the DLF=1.0 quirk —
# same root, still the separate CH6 DLF front).
#
# Slice S0380.184 closed CH3 (HP/Elec, code 304) CO2 + PE: an
# electric-HP heat network meters grid electricity, so per SAP 10.2
# Table 12 note (s)/(t) + block 12b/13b footnote (a) its (367)/(467)
# factor is the MONTHLY Table 12d/12e (fuel code 41) weighted by the
# network heat profile, then × 1/COP — not the annual 0.136/1.501.
# New `_is_heat_network_electric_main` routes the four factor helpers
# through the monthly cascade for code 304 (fuel 41). CH3 was
# SAP/cost EXACT; CO2 75.32→+0.0000 (= (307+310)/3 × (0.15040.136))
# and PE 249.32→0.0000 (× (1.55691.501)) now EXACT. Non-electric
# heat networks (CH1 gas 51, CH6 coal 54) have no monthly factor set
# → unchanged.
#
# CH6 — PROVEN PIN-FOREVER (Summary-export gap, not a mapper miss).
# CH6's P960 *worksheet input* lodges Distribution Loss = "Two
# adjoining dwellings sharing a single heating system" → Value 0.0 →
# (306) DLF = 1.0000, whereas CH4 lodges "Calculated" → 1.5 → (306) =
# 1.4500. That DLF choice swings SAP / cost / CO2 / PE materially.
# But it is NOT in the Summary PDF: a controlled pair differing ONLY
# by the adjoining-dwellings setting (`CH adjoined dwellings/Summary_
# 001431 (1) vs (2).pdf`) is byte-identical across every RdSAP INPUT
# field — the two Summaries differ solely in the derived header
# (SAP 80 vs 75, bill £954 vs £1237, emissions 5.407 vs 7.394 t). A
# case-insensitive scan of the CH6 Summary for "distribution"/"adjoin"
# returns 0 hits. Since CH4 and CH6 Summaries are themselves identical
# bar fuel type, no Summary-derivable rule can yield CH4=1.45 AND
# CH6=1.0. Closing CH6 would require the P960 worksheet as a mapper
# input or an Elmhurst Summary-export change — neither is available.
# Pin held; do not re-litigate (verified 2026-06-02 with the
# user-supplied adjoining-dwellings pair).
_CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000),
_CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=-0.0000),
_CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-8.0219, expected_cost_resid_gbp=+184.8376, expected_co2_resid_kg=+2411.5399, expected_pe_resid_kwh=+5023.4766),
)

View file

@ -4143,6 +4143,59 @@ _LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = (
frozenset(range(120, 142))
)
# SAP 10.2 Table 4b gas-boiler code range (PDF p.168). Rows 101-119 are
# "Gas boilers (including mains gas, LPG and biogas)" — 101-109 are
# 1998-or-later, 110-114 pre-1998 fan-assisted flue, 115-119 pre-1998
# balanced/open flue. The code identifies the boiler TYPE/efficiency, not
# the specific carrier: the same row applies to mains gas, bulk/bottled
# LPG and biogas alike. The older Elmhurst export lodged §14.0 "Fuel
# Type: Mains gas" explicitly, but the newer form leaves §14.0 "Fuel
# Type" empty and lodges only the SAP code (e.g. 104 condensing combi,
# EES "BGW"). For these, §15.0 "Water Heating Fuel Type" names the
# carrier — a combi/boiler heats space + water from the one appliance —
# so it disambiguates mains-gas-vs-LPG. Codes 120-141 (CPSU + range
# cookers) are already covered by
# `_LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES`.
_GAS_BOILER_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = (
frozenset(range(101, 120))
)
# SAP10 main-fuel codes in the gas / LPG family — the only carriers a
# Table 4b gas-boiler row (101-119) can have (mains gas, mains gas
# community, bottled/bulk/special-condition LPG). Per
# `_ELMHURST_MAIN_FUEL_TO_SAP10`: mains gas = 26, mains gas community =
# 1, LPG bottled/bulk/special = 5/6/7, "Bulk LPG" = 27. The §15.0
# water-heating-fuel derivation is gated on the resolved fuel being one
# of these so it can't mis-assign electricity from a separate immersion
# (where §15.0 lodges the immersion's fuel, not the boiler's) — that
# case still strict-raises `MissingMainFuelType` to force a mapper fix.
_GAS_LPG_MAIN_FUEL_CODES: Final[frozenset[int]] = frozenset({1, 5, 6, 7, 26, 27})
def _elmhurst_gas_boiler_main_fuel(
sap_main_heating_code: Optional[int],
water_heating_fuel_code: Optional[int],
) -> Optional[int]:
"""Derive a gas/LPG main-fuel code for a Table 4b gas boiler whose
§14.0 "Fuel Type" string is absent (newer Elmhurst export form).
Returns the §15.0 water-heating fuel code when, and only when, the
SAP main-heating code is a Table 4b gas-boiler row (101-119) AND the
§15.0 fuel resolves to a gas/LPG carrier the same combi/boiler
heats space + water, so §15.0 names the boiler's carrier. Returns
None otherwise (non-gas-boiler code, or §15.0 lodges a non-gas fuel
such as an electric immersion), leaving the caller to strict-raise.
Spec: SAP 10.2 Table 4b "Seasonal efficiency for gas and liquid fuel
boilers" (PDF p.168) — rows 101-119 are gas-family boilers.
"""
if (
sap_main_heating_code in _GAS_BOILER_SAP_MAIN_HEATING_CODES
and water_heating_fuel_code in _GAS_LPG_MAIN_FUEL_CODES
):
return water_heating_fuel_code
return None
# Elmhurst §14.0 "Main Heating EES Code" → Table 32 main fuel code.
# Empirically derived from the heating-systems corpus at
@ -4770,6 +4823,19 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
and mh.main_heating_sap_code in _LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES
):
main_fuel_int = water_heating_fuel
# Gas / LPG boilers: SAP 10.2 Table 4b codes 101-119 (PDF p.168)
# identify a gas-family boiler but not the specific carrier (mains
# gas vs LPG vs biogas). The newer Elmhurst export leaves §14.0
# "Fuel Type" empty and lodges only the SAP code (e.g. 104 condensing
# combi, EES "BGW"); the §15.0 "Water Heating Fuel Type" names the
# carrier because the same combi/boiler heats space + water. Adopt it
# only when it resolves to a gas/LPG fuel, so a regular boiler paired
# with an electric immersion (where §15.0 lodges "Electricity") still
# strict-raises rather than mis-billing the gas boiler as electric.
if main_fuel_int is None:
main_fuel_int = _elmhurst_gas_boiler_main_fuel(
mh.main_heating_sap_code, water_heating_fuel,
)
# Solid-fuel main heating: SAP code rows 150-160 (open / closed
# room heaters with boiler) and 600-636 (independent solid-fuel
# boilers) cover multiple distinct fuels under a single Table 4a

View file

@ -236,6 +236,17 @@ class CalculatorInputs:
pumps_fans_primary_factor: Optional[float] = None
lighting_primary_factor: Optional[float] = None
electric_shower_primary_factor: Optional[float] = None
# SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution
# pumping electricity. For community-heating mains the network pump
# energy = 1% of (space + water) heat generated (worksheet (313));
# its CO2 / PE (worksheet (372)/(472)) bill on Table 12d/12e monthly
# electricity factors (fuel code 50) weighted by the monthly heat
# profile. The energy + effective factors are precomputed in
# cert_to_inputs. 0.0 / None for individually-heated certs (no
# distribution loop) leaves the cascade unchanged.
heat_network_distribution_kwh_per_yr: float = 0.0
heat_network_distribution_co2_factor_kg_per_kwh: Optional[float] = None
heat_network_distribution_primary_factor: Optional[float] = None
# Generation offsets — applied as a cost credit against the ECF
# numerator. SAP 10.2 Appendix M: PV self-consumption + export
# collapse to a single credit at the export rate (Table 12 code 60).
@ -596,6 +607,13 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
electric_shower_co2 = (
inputs.electric_shower_kwh_per_yr * electric_shower_co2_factor
)
# SAP 10.2 Appendix C §C3.2 (PDF p.51) worksheet (372) — electricity
# for pumping water through a heat network's distribution system.
# Zero for individually-heated certs (factor None → 0.0).
heat_network_distribution_co2 = (
inputs.heat_network_distribution_kwh_per_yr
* (inputs.heat_network_distribution_co2_factor_kg_per_kwh or 0.0)
)
co2 = (
main_heating_co2
+ secondary_heating_co2
@ -603,6 +621,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
+ pumps_fans_co2
+ lighting_co2
+ electric_shower_co2
+ heat_network_distribution_co2
)
# SAP 10.2 Appendix M1 §7 — subtract PV CO2 credit. Onsite consumption
# offsets grid imports at the IMPORT CO2 factor (Table 12d weighted
@ -662,6 +681,12 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
+ inputs.lighting_kwh_per_yr * lighting_primary_factor
+ inputs.electric_shower_kwh_per_yr * electric_shower_primary_factor
)
# SAP 10.2 Appendix C §C3.2 (PDF p.51) worksheet (472) — heat-network
# distribution pumping electricity primary energy (CO2 sister above).
heat_network_distribution_primary_kwh = (
inputs.heat_network_distribution_kwh_per_yr
* (inputs.heat_network_distribution_primary_factor or 0.0)
)
# SAP 10.2 Appendix M1 §8: PV onsite consumption credits at IMPORT
# PEF (offsets grid imports); PV exports credit at the EXPORT PEF
# ("electricity sold to grid, PV" — Table 12 code 60 = 0.501). When
@ -696,6 +721,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
space_heating_primary_kwh
+ hot_water_primary_kwh
+ other_primary_kwh
+ heat_network_distribution_primary_kwh
- pv_primary_offset_kwh,
)
primary_energy_per_m2 = primary_energy_kwh / tfa if tfa > 0 else 0.0
@ -738,6 +764,8 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
"hot_water_co2_kg_per_yr": hot_water_co2,
"pumps_fans_co2_kg_per_yr": pumps_fans_co2,
"lighting_co2_kg_per_yr": lighting_co2,
"heat_network_distribution_co2_kg_per_yr": heat_network_distribution_co2,
"heat_network_distribution_pe_kwh_per_yr": heat_network_distribution_primary_kwh,
"space_heating_pe_kwh_per_m2": space_heating_primary_kwh / tfa if tfa > 0 else 0.0,
"hot_water_pe_kwh_per_m2": hot_water_primary_kwh / tfa if tfa > 0 else 0.0,
"other_pe_kwh_per_m2": other_primary_kwh / tfa if tfa > 0 else 0.0,

View file

@ -0,0 +1,265 @@
# SAP calculator — agent guide (start here)
This is the **canonical onboarding doc** for working on the SAP 10.2 /
RdSAP 10 calculator. It is meant to get you productive **without reading
any historical handover**. The `HANDOVER_*.md` files in this directory
are point-in-time session notes (useful for the specific residual they
chase, ignore otherwise). For deep architecture/API see
[`SAP_CALCULATOR.md`](SAP_CALCULATOR.md).
Three things this doc gives you: (1) the **accuracy bar** for the two
input paths, (2) the **debugging loop**, (3) the **tools & pipeline**.
---
## 0. The one-paragraph mental model
A cert's data comes in via one of two front-ends — an **Elmhurst Summary
PDF** (site-notes path) or an **EPC-register API JSON** (API path). Both
map to the same typed `EpcPropertyData`, which feeds a deterministic
cascade that reproduces the RdSAP10 engine. Our **ground truth is the
Elmhurst worksheet PDF** (U985 / P960 / dr87) — the per-line `(1)..(286)`
calculation, not the rounded values the EPC register lodges. We pin the
cascade against the worksheet to **abs = 1e-4 on every line ref**.
---
## 1. Accuracy expectations — site-notes vs API
The worksheet PDF is **always** the target. The EPC register's lodged
SAP/CO2/PE are rounded *and* carry Elmhurst's own residual, so matching
the lodged values is not the goal — matching the worksheet is.
| Path | Input | When a worksheet PDF exists for the cert | API/site-notes-only (no worksheet) |
|---|---|---|---|
| **Site-notes** | Elmhurst Summary PDF → extractor → `from_elmhurst_site_notes` | **abs = 1e-4** on continuous SAP **and every populated line ref** and cost / CO2 / PE | n/a (we always have the worksheet for site-notes fixtures) |
| **API** | register JSON → `from_api_response` | **abs = 1e-4** on continuous SAP vs the worksheet (same bar as site-notes — the two paths must converge) | **±0.5** SAP vs the lodged register value (fallback only) |
Three rules that fall out of this:
- **Cross-mapper parity.** For a cert that has both an API JSON and an
Elmhurst Summary, the two paths must produce SAP within **1e-4 of each
other** *and* of the worksheet. The cascade output (not a structural
EPC diff) is the equivalence check. A divergence localises to one
mapper.
- **No tolerance widening.** A failing 1e-4 pin is a real cascade bug or
a fixture defect — diagnose it, don't relax it. No `rel=`, no `xfail`,
no adaptive ceilings. ΔSAP = 0.07 is **not** "closed".
- **±0.5 is a fallback, not a destination.** It's only for API-only
certs with no worksheet to check against. If you can get a worksheet,
the bar is 1e-4.
Two documented, deliberate exceptions to "match the spec literal" live
in [`SAP_CALCULATOR.md` §8](SAP_CALCULATOR.md) ("Elmhurst-mirrored spec
divergences") — cases where the BRE-approved Elmhurst engine diverges
from the SAP 10.2 text and we mirror the engine. Add a §8 row only with
≥2-cert evidence.
---
## 2. The tools & pipeline
### 2.1 The two PDFs per cert
- **`Summary_NNNNNN.pdf`** — the Elmhurst **site notes / input**. This is
what the assessor lodged: dimensions, fabric, heating system, controls,
cylinder, etc. It is the INPUT, equivalent to the API JSON.
- **The worksheet** — the **ground truth output**, every line ref
`(1)..(286)` to 4 d.p. Three families, all the same format:
- `U985-0001-NNNNNN.pdf` — the 6 gas-combi conformance fixtures.
- `P960-0001-NNNNNN.pdf` — the heating-systems corpus + community heating.
- `dr87-0001-NNNNNN.pdf` — the API-paired cohort ("Additional data with api").
### 2.2 The cascade pipeline (site-notes path)
```python
import subprocess, re
from pathlib import Path
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.rdsap.cert_to_inputs import (
cert_to_inputs, cert_to_demand_inputs, local_climate_for_cert,
)
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
# 1. Summary PDF -> per-page text (pdftotext -layout, one string per page)
def summary_pdf_to_pages(pdf: Path) -> list[str]:
n = int(re.search(r"Pages:\s+(\d+)",
subprocess.run(["pdfinfo", str(pdf)], capture_output=True, text=True).stdout).group(1))
pages = []
for i in range(1, n + 1):
layout = subprocess.run(
["pdftotext", "-layout", "-f", str(i), "-l", str(i), str(pdf), "-"],
capture_output=True, text=True).stdout
pages.append("\n".join(
tok for line in layout.splitlines() for tok in re.split(r"\s{2,}", line.strip()) if tok))
return pages
pages = summary_pdf_to_pages(Path("sap worksheets/.../Summary_NNNNNN.pdf"))
site_notes = ElmhurstSiteNotesExtractor(pages).extract() # -> ElmhurstSiteNotes
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) # -> EpcPropertyData
# 2. Two cascades. RATING = SAP/EI rating (UK-avg climate, region 0).
# DEMAND = Current Carbon / Current PE / Fuel Bill (postcode climate, PCDB Table 172).
rating = calculate_sap_from_inputs(cert_to_inputs(epc))
demand = calculate_sap_from_inputs(cert_to_demand_inputs(epc)) # climate = local_climate_for_cert(epc)
rating.sap_score_continuous # un-rounded SAP — pin THIS, not the integer
rating.total_fuel_cost_gbp
rating.co2_kg_per_yr
demand.primary_energy_kwh_per_yr
```
Shortcut: `Sap10Calculator().calculate(epc)` runs the rating cascade
(`cert_to_inputs``calculate_sap_from_inputs`) in one call.
### 2.3 The API path
Identical from `EpcPropertyData` onward — only the front-end changes:
```python
import json
data = json.loads(Path("tests/domain/sap10_calculator/rdsap/fixtures/golden/<cert>.json").read_text())
epc = EpcPropertyDataMapper.from_api_response(data) # -> EpcPropertyData
# ... same cert_to_inputs / calculate_sap_from_inputs as above
```
### 2.4 Section helpers — intermediate line refs
Every worksheet section has a `<section>_section_from_cert(epc)` helper
returning a typed result with the line-ref values. Use these to inspect
where a residual originates **without** running the whole cascade
(`postcode_climate=` selects rating vs demand):
```python
from domain.sap10_calculator.rdsap.cert_to_inputs import (
water_heating_section_from_cert, # §4 (42)..(65)m
heat_transmission_section_from_cert, # §3 (26)..(37)
internal_gains_section_from_cert, # §5 (66)..(73)
mean_internal_temperature_section_from_cert, # §7 (85)..(94)
space_heating_section_from_cert, # §8 (95)..(99)
fuel_cost_section_from_cert, # §10a (240)..(255)
environmental_section_from_cert, # §12 (261)..(274)
primary_energy_section_from_cert, # §13a (275)..(286)
)
wh = water_heating_section_from_cert(epc)
wh.energy_content_monthly_kwh # (45)m ; wh.output_kwh_per_yr # (62)/(64)
```
(Full table of helpers + line refs is in [`SAP_CALCULATOR.md` §1.3](SAP_CALCULATOR.md).)
### 2.5 Reading the worksheet from the shell
```bash
# Dump a worksheet line ref (e.g. (217)m water-heater monthly efficiency):
pdftotext -layout "sap worksheets/.../P960-0001-NNNNNN.pdf" - | grep -nE "\(217\)|\(62\)|\(210\)"
# Read a Summary input field (controls, cylinder, fuel):
pdftotext -layout "sap worksheets/.../Summary_NNNNNN.pdf" - | grep -niE "cylinder|control|interlock|fuel"
```
### 2.6 Where the test vectors live
| Set | Location | What |
|---|---|---|
| 6 U985 conformance fixtures | `tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_NNNNNN.py` (+ Summary PDFs in `backend/documents_parser/tests/fixtures/`) | Gas-combi certs, every line ref transcribed as `LINE_*` / `DEMAND_LINE_*` constants. Pinned in `worksheet/test_section_cascade_pins.py` + `worksheet/test_e2e_elmhurst_sap_score.py`. |
| Heating-systems corpus | `sap worksheets/heating systems examples/<variant>/` (Summary + P960) | 41 variants of **one property** with only the heating system changed → any residual is attributable to the heating subsystem. Pinned in `backend/documents_parser/tests/test_heating_systems_corpus.py`. |
| API golden fixtures | `tests/domain/sap10_calculator/rdsap/fixtures/golden/<cert>.json` | Register JSON for the API path. |
| API + worksheet pairs | `sap worksheets/Additional data with api/<cert>/` (Summary + dr87) | Certs that have BOTH an API JSON and a worksheet → cross-mapper parity checks. |
---
## 3. The debugging loop
When a cert's SAP/cost/CO2/PE is off, **never guess a fix** — walk it.
1. **Reproduce & decompose.** Build the epc (extractor+mapper, or a
fixture's `build_epc()`), run both cascades, and see **which of the
four outputs** drifts. Cost/CO2/PE drift with the same sign as energy;
isolate the carrier.
2. **Find the section.** Walk the four metrics back to a worksheet
section: SAP off but cost EXACT often means a demand/gains issue;
cost off but energy EXACT means a price/factor issue; CO2/PE off but
cost EXACT means a factor issue. Use the §2.4 section helpers to get
the cascade's intermediate line refs.
3. **Per-line compare vs the worksheet.** `pdftotext -layout` the
worksheet and compare the cascade's `(45)/(56)/(62)/(210)/(217)m/...`
line-by-line against the PDF. The first diverging line ref is the bug.
4. **Localise to a layer.**
- cascade value present in worksheet but cascade has 0 / wrong → **calculator** gap (a spec rule not wired, or a dispatch gate).
- the input field the worksheet used isn't in `epc`**mapper** (mis-mapped) or **extractor** (didn't capture the Summary field). Audit the Summary PDF for the field first — many lodgements are incomplete and the fixture, not the calculator, is wrong.
5. **Cite the spec.** Find the SAP 10.2 / RdSAP 10 rule (page + line) that
produces the worksheet's number. Confirm the worksheet matches the
spec literal; if it diverges, it's a candidate §8 Elmhurst-mirror
(needs ≥2-cert evidence). **SAP 10.2 only — never 10.3.**
6. **Cross-check vs API (when available).** If the cert has an API JSON
too, run `from_api_response` through the same cascade. If the API path
matches the worksheet but the site-notes path doesn't (or vice-versa),
the bug is in **that mapper**, not the calculator. If both diverge
identically, it's the **calculator/cascade**.
7. **Fix one cause, re-pin smaller.** TDD: one failing AAA test → one
impl → re-pin the (now smaller) residual. A spec-correct fix often
**exposes** the next residual that an offsetting bug was masking —
that's the next slice, not a regression. Don't conflate
`main_heating_category` (often `None` on Elmhurst Table 4b boilers)
with `sap_main_heating_code`.
### Worked shape (real example: oil 6)
Residual +3.05 SAP. (1) HW + space both off. (2) §4 HW efficiency. (3)
worksheet (210) space eff = 75 but Table 4b code 126 = 80; (217)m summer
= 63 = 685 → a 5pp penalty. (4) the Summary lodges control `2101` ("no
thermostatic control of room temperature") → no room thermostat → P960
header "Boiler Interlock: No". (5) RdSAP 10 §3 + SAP 10.2 Table 4c(2):
no room thermostat ⇒ not interlocked ⇒ 5pp Space+DHW. Fix the
`no_interlock` gate → space+HW fuel EXACT, residual collapses to a single
exposed pump cause (Table 4f footnote a) ×1.3) → next slice. Two slices,
fully closed.
---
## 4. Run the suite
```bash
PYTHONPATH=/workspaces/model python -m pytest \
tests/domain/sap10_calculator/ \
backend/documents_parser/tests/ \
--no-cov -q -p no:cacheprovider
```
Conformance pins only:
```bash
PYTHONPATH=/workspaces/model python -m pytest \
tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py \
tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py \
backend/documents_parser/tests/test_heating_systems_corpus.py \
--no-cov -q
```
Notes:
- `load_cells` tests pin against the gitignored `*.xlsx` reference
worksheet at repo root; they **skip** when it's absent (CI), run
locally when present.
- All new code passes `pyright` strict, zero errors. Tests use literal
`# Arrange / # Act / # Assert` headers and `abs(x - y) <= tol` (not
`pytest.approx`, which strict-pyright flags).
- Commit one slice per change, with the spec citation in the message.
---
## 5. Spec PDFs on disk
```
domain/sap10_calculator/docs/specs/
sap-10-2-full-specification-2025-03-14.pdf # SAP 10.2 (the methodology)
RdSAP 10 Specification 10-06-2025.pdf # RdSAP 10 (the reduced-data rules)
pcdb10.dat / pcdb_table_*.jsonl # PCDB (boilers, HPs, postcode weather)
```
Pages worth bookmarking: SAP 10.2 §7 MIT (p.28-32), Table 4b boiler eff
(p.168), Table 4c efficiency adjustments (p.169), Table 4e controls
(p.171-174), Table 4f auxiliary energy (p.175), Table 12 factors (p.191),
Appendix U region tables (p.124-127). RdSAP 10 §10 water heating (p.54-56,
incl. §10.7 no-water-heating default), Table 28/29 cylinder defaults,
Table 32 prices (p.95).
```

View file

@ -0,0 +1,172 @@
# Handover — post Slices S0380.177..179 (+ infra/CI work)
Branch: `feature/per-cert-mapper-validation`. **HEAD `af8e0d94`**
(post merge from main). Predecessor:
[`HANDOVER_POST_S0380_176.md`](HANDOVER_POST_S0380_176.md).
## TL;DR
The 41-variant heating-systems corpus is now **36 EXACT + 5 pinned**.
The only remaining residuals are the **5 community-heating (CH) variants**
— all `SAP code 302/301/304` heat-network systems. Everything else
(oil, electric, solid fuel, ASHP/GSHP, PCDB, "no system") is EXACT on
all four metrics (ΔSAP/Δcost/ΔCO2/ΔPE).
Three closure slices + four infra changes landed this session:
| Slice / change | HEAD | Scope |
|---|---|---|
| S0380.177 | `5276282d` | **oil 6 boiler interlock from room-thermostat absence.** Control code 2101 ("no thermostatic control of room temperature") ⇒ no room thermostat ⇒ per RdSAP 10 §3 NOT interlocked despite cylinderstat=Yes (P960 "Boiler Interlock: No") ⇒ SAP 10.2 Table 4c(2) 5pp Space+DHW. New `_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES={2101,2102}`; `no_interlock` ORs room-thermostat absence with stored-HW cylinderstat absence; Space 5pp leg now fires for Table 4b non-PCDB boilers. |
| S0380.178 | `c054d712` | **oil 6 circulation pump ×1.3 for absent room thermostat.** SAP 10.2 Table 4f footnote a) (PDF p.175) "Multiply by 1.3 if room thermostat is absent" ⇒ 41 × 1.3 = 53.3 kWh = ws (230c). Closes oil 6 FULLY (same root cause as .177). |
| S0380.179 | `f2062a2f` | **RdSAP 10 §10.7 electric-immersion default for "no system".** Cert lodges water code 999 (NON) + "cylinder present: No", but §10.7 substitutes an electric immersion on a Table 28 row-1 110 L cylinder + Table 29 row-1 insulation. New `_apply_rdsap_no_water_heating_system_default(epc)` rebinds the epc at the top of `cert_to_inputs` when `water_heating_code==999`. One fix closed HW (594 kWh storage loss) AND the downstream space residual (+228, a HW-gains→MIT artifact). Closes "no system" FULLY. |
| appliances+cooking | `2f039aeb` | Threaded `appliances_kwh_per_yr` + `cooking_kwh_per_yr` (Appendix L L13/L14/L16a + L20) onto `SapResult`/`CalculatorInputs` for ADR-0014 BillDerivation. **Output-only, zero rating drift.** |
| test fixes | `0e484aaa` | Fixed 11 pre-existing CI failures from an absorbed PR: `test_appendix_u.py` signature drift + mislabelled "SAP 10.3"→10.2; `test_table_32.py` re-pinned oil(4)=5.44 / FAME(73)=7.64 to the worksheet-canonical values the table actually uses. |
| corpus PDFs | `d1c87d84` | Committed the 82 heating-corpus PDF fixtures (`sap worksheets/heating systems examples/`) so CI can run the residual pins. |
| **test move** | `d7d5084f` | **Moved all 5 calculator test dirs → `tests/domain/sap10_calculator/`** so CI (which collects `tests/`) runs them. SEE "Test layout changed" below — it changes every command. |
## ⚠ Test layout changed this session — commands are different now
The calculator tests **moved** out of `domain/sap10_calculator/.../tests`
into `tests/domain/sap10_calculator/{,worksheet,rdsap,climate,validation}`.
Cross-imports were rewritten `domain.sap10_calculator.worksheet.tests`
`tests.domain.sap10_calculator.worksheet`. Any old handover command
that references `domain/sap10_calculator/worksheet/tests/...` is STALE.
**New full verification command** (replaces the old extended suite):
```bash
PYTHONPATH=/workspaces/model python -m pytest \
tests/domain/sap10_calculator/ \
backend/documents_parser/tests/ \
--no-cov -q -p no:cacheprovider
```
Expected at HEAD: **~2221 pass, 1 skipped, 0 fail** (the 1 skip is the
corpus blocked-variant `skipif`). The cascade-pin / golden / e2e
conformance suites are all under `tests/domain/sap10_calculator/`.
**Two gotchas:**
1. `load_cells` tests (`tests/domain/sap10_calculator/worksheet/test_{dimensions,ventilation,water_heating}.py`) pin against the gitignored `2026-05-19-17-18 RdSap10Worksheet.xlsx` at repo root. `_xlsx_loader.load_cells` `pytest.skip()`s when the xlsx is absent — so they run locally and skip in CI. If you're missing the xlsx locally, those skip (not fail).
2. **Uncommitted `pytest.ini` change** (came in with a main pull) REMOVES `tests/` + `domain/sap10_ml/tests` from `testpaths`. HEAD has them; the working tree strips them. This is NOT a slice change — confirm with the user before committing it, because removing `tests/` would un-collect the moved calculator tests.
## Current residual state at HEAD `af8e0d94`
### 36 variants EXACT (all four metrics < tolerance)
```
ashp, gshp,
electric 1, 2, 3, 5, 6, 7, 8, 9, 11, 12, 13, 14,
oil 1, oil 2, oil 3, oil 4, oil 5, oil 6, oil pcdb 1, oil pcdb 2, oil pcdb 3,
pcdb 1, pcdb 3,
solid fuel 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
no system
```
### 5 community-heating variants pinned
| Variant | SAP code | ΔSAP_c | Δcost | ΔCO2 | ΔPE | Closure driver |
|---|---:|---:|---:|---:|---:|---|
| CH6 (CHP/Coal) | 302 | 7.4942 | +£172.68 | 2939.67 | +7481.57 | SAP 302 CHP credit + DLF=1.0 P960 quirk |
| CH2 (CHP/Gas) | 302 | +0.5277 | £12.16 | 1435.09 | +1123.01 | SAP 302 CHP credit (CO2 + PE) |
| CH4 (CHP/Oil) | 302 | +0.5277 | £12.16 | 4401.85 | +111.58 | SAP 302 CHP credit (CO2) |
| CH3 (HP/Elec) | 304 | +0.0000 | £0.00 | 98.92 | 457.54 | (372) electrical-distribution + HP COP |
| CH1 (Boilers/Gas) | 301 | +0.0000 | £0.00 | 23.60 | 208.23 | (372) electrical-distribution factor |
Blocked tier: **empty**.
## Open fronts ranked by leverage
### 1. SAP 302 CHP CO2/PE credit cascade (3 variants — CH2/CH4/CH6) — HIGHEST
Closes the big CO2/PE residuals on CH2/CH4 AND the 7.49 SAP on CH6
simultaneously. Spec: block 13b PE (PDF p.153) + 12b CO2 — the
displaced-electricity CHP credit lines (worksheet (363)-(366),
(464)/(466)/(468)):
```
Space heating from CHP (307a) × 100 ÷ (362) = ... (363)
less credit emissions (307a)×(361) ÷ (362) = ... (364)
Water heated by CHP (310a) × 100 ÷ (362) = ... (365)
less credit emissions (310a)×(361) ÷ (362) = ... (366)
Heat from heat source 2 [(307b)+(310b)] × 100 ÷ (467b) (468)
```
RdSAP 10 §C defaults (verified vs CH2/CH4/CH6 worksheet (461)/(462)):
CHP overall eff 75%, heat-to-power 2.0 → heat_eff 50% / electric_eff
25%; boiler eff 80%. The `.172` scaling helper already keys on
`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` — add code 302 there once the
split formula is in place; the `.173` predicate
`_is_community_heating_hw_from_main` auto-activates.
**⚠ UNRESOLVED per-line caveat — walk before hypothesising.** The
Elmhurst worksheet (463) energy column = `spec_formula × 0.8523`
uniformly across non-CHP heat-network rows (the 0.8523 also shows in
CH1 (467)). It is NOT RdSAP 10 / SAP 10.2 spec-derived. Per
[[feedback-spec-floor-skepticism]] / [[feedback-software-no-special-handling]],
DUMP the worksheet per-line and reconcile 0.8523 before baking any CHP
formula into the cascade. Likely 2-3 slices.
### 2. CH1/CH3 (372)/(472) electrical-distribution CO2/PE — DEFERRED
CH1/CH3 are SAP + cost EXACT; only CO2/PE remain. Worksheet (372) CO2
factor = 0.1994 (block 11a) / 0.2114 (block 11b); PE = 1.7591 / 2.1872.
These don't match ANY Table 12 / 12d / 12e weighting derivable from the
(307) or (307)+(310) heating-demand monthly profile. (313) annual =
0.01 × (307) ONLY (verified across 5 variants, NOT 0.01 × (307+310) as
the spec text says). **Don't guess** — reverse-engineer the 0.1994
factor from a wider variant set or find BRE documentation first.
### 3. CH6 DLF=1.0 P960 quirk — architectural, likely pin-forever
P960 input lodges `Distribution Loss: Two adjoining dwellings...` +
`Distribution Loss Value: 0.0` → ws (306) = 1.0000, but the Summary
doesn't carry anything distinguishing CH6 from CH4. Per §C3.1 the
manual-DLF override is legal but not surfaced by the Summary.
Recommendation: pin + document once the CHP credit lands.
## Discipline (carried from every prior handover)
- **Per-line walk worksheet → spec → fix.** All 3 slices this session
landed via per-line P960 dumps. Don't form a spec hypothesis without
per-line data (the 0.8523 + 0.1994 factors are the live examples).
- **Spec-floor skepticism cuts BOTH ways** — a spec-correct fix often
EXPOSES the next residual (oil 6 .177→.178; "no system" HW→space).
Apply the spec uniformly; the surfaced residual is the next target.
- **SAP 10.2 ONLY, never 10.3.**
- **Don't conflate `main_heating_category` and `sap_main_heating_code`**
— the Elmhurst mapper leaves `category=None` on Table 4b liquid-fuel
boilers; cascade gates must check both.
- **Target is < 1e-4 vs worksheet** — ΔSAP=0.07 is NOT closed. Re-pin
smaller; never widen tolerance, never xfail.
- **One slice = one commit**, spec citation in the message, trailer
`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
## Memories to load (in order)
```
project-heating-systems-corpus # HEAD af8e0d94, 36 EXACT + 5 pinned
feedback-sap-10-2-only-never-10-3
feedback-software-no-special-handling
feedback-spec-floor-skepticism
feedback-worksheet-not-api-reference
feedback-spec-citation-in-commits
feedback-verify-handover-claims
feedback-zero-error-strict
feedback-commit-per-slice
feedback-aaa-test-convention
feedback-e2e-validation-philosophy
feedback-abs-diff-over-pytest-approx
feedback-one-e-minus-4-across-the-board
reference-unmapped-sap-code
reference-unmapped-api-code
project-oil-price-spec-divergence
```
## Master doc
Architecture + API + validation: [`SAP_CALCULATOR.md`](SAP_CALCULATOR.md)
(§8 "Elmhurst-mirrored spec divergences" carries .163 HW dual-rate
annual + .164 §12.4.4 summer-immersion). If the CHP 0.8523 multiplier
resolves to an Elmhurst-vs-spec divergence, add §8.3.
## Good luck.

View file

@ -0,0 +1,114 @@
# Handover — post S0380.189 (thermal mass parameter / Table 22)
Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for the
methodology, accuracy bar, and pipeline — this doc only records *what this
session did* and *what is open*.
- **Branch:** `feature/per-cert-mapper-validation`
- **HEAD:** `e03f08cd` (S0380.189)
- **Baseline:** `2290 passed, 1 skipped, 0 failed` (the skip is the
gitignored xlsx `load_cells` test). Verify with the §4 suite command.
---
## What this session shipped (S0380.185189)
| Slice | What | Spec |
|---|---|---|
| **.185** | Recorded the CH6 "pin-forever" proof — distribution-loss is an Elmhurst Summary-export gap, not a mapper miss (controlled adjoining-dwellings pair byte-identical in inputs). | — |
| **.186** | Added `test_golden_cert_pe_co2_matches_worksheet` — pins calc PE/CO2 for the 47 worksheet-backed certs against the dr87 `(286)`/`(272)` at full precision (not lodged register values). | Appendix U |
| **.187** | Appendix M1 §3a `D_PV,m` was missing **electric secondary** space heating `(215)m` — under-credited PV on gas-main+electric-secondary+PV certs. | SAP 10.2 App M1 §3a |
| **.188** | `D_PV,m` used the Appendix L **L12 lighting GAIN** (`= E_L×0.85`) instead of the **L10 lighting ELECTRICITY** `(232)`. Closed the whole PV cohort to 1e-4. Same gain-vs-electricity class as the S0380.73 cooking fix. | SAP 10.2 App L L10/L12, M1 §3a |
| **.189** | **Thermal mass parameter** was hardcoded 250 at all 5 §7/§8 call sites. Now `_thermal_mass_parameter_kj_per_m2_k(epc)` per Table 22. | RdSAP 10 §5.16 Table 22 (p.48) |
After .186.188 **all 47 worksheet-backed certs match calc≡worksheet at
<1e-4 on PE and CO2** (the convergence target). See
[`project-golden-coverage-state` memory] for the per-slice detail.
---
## The S0380.189 diagnosis (template for the open work)
Driven entirely by the **per-line walk** (AGENT_GUIDE §3) against a
**user-simulated worksheet** for a 6035-archetype property:
- **Fixture:** `sap worksheets/golden fixture debugging/simulated case 1/`
`Summary_001431 (1).pdf` (input) + `P960-0001-001431 - ….pdf` (worksheet
ground truth). A gas-combi mid-terrace, TFA 128, solid brick **with
internal insulation**, no PV/secondary/cylinder. (The `P960-…` prefix is
just the Elmhurst account id; cert is 001431.) **These PDFs are untracked**
— the user holds them locally.
- **Decompose:** PE +8.78 / SAP 1.76 / CO2 +0.21, *entirely* space-heating
demand (+838 kWh). Fabric `(2637)`, internal gains `(73)`, climate
`(96)m`, HTC `(39)` all EXACT → localised to **§7 MIT** `(92)` +0.71 °C.
- **Root cause:** TMP hardcoded 250; cert is masonry **with internal
insulation** → RdSAP 10 §5.16 Table 22 = **100**. Wrong TMP → time
constant τ=Cm/(3.6·H) ≈40 h not 16 h → §7 temperature reduction too small
→ MIT too high → space heating over-stated.
- **Fix + blast radius:** only golden cert **6035** re-pinned (SAP 6→2, PE
+46.42→+19.16, CO2 +1.07→+0.42). All other fixtures are masonry-no-internal
→ TMP unchanged.
### Two diagnostic traps that cost false starts (read before debugging §7/§8)
1. **The worksheet has TWO blocks per dwelling.** The first is the SAP-rating
block (UK-average climate, region 0); the second, under *"CALCULATION OF
EPC COSTS, EMISSIONS AND PRIMARY ENERGY"*, is the postcode block. The
**demand cascade (`cert_to_demand_inputs`) matches the POSTCODE block.**
Comparing calc HTC/MIT to the UK-avg block shows phantom gaps (e.g. HTC
"10.8"); against the postcode block HTC is exact.
2. **`(84)` Total gains = internal `(73)` + solar `(83)`.** The calc's
`internal_gains_annual_avg_w` is `(73)` only — don't diff it against `(84)`
(a phantom "248 W" gap that is just solar).
Use the §2.4 section helpers (`mean_internal_temperature_section_from_cert`,
`space_heating_section_from_cert`, `internal_gains_section_from_cert`,
`local_climate_for_cert`) for the per-line walk.
---
## OPEN — next slices (ranked)
### 1. Summary-path `main_fuel_type` derivation from the SAP code ← do first
The Elmhurst **Summary PDF has no main-heating fuel field** — only the SAP
code (`14.0 Main Heating1 → Main Heating SAP Code 104`). So
`from_elmhurst_site_notes` leaves `main_fuel_type=''`, and `cert_to_inputs`
raises `MissingMainFuelType` (cert_to_inputs.py `_main_fuel_code`). This
**blocks the Summary path for every gas-combi cert**, including the simulated
case (I injected `main_fuel_type=26` to run the diagnosis).
Fix: derive the fuel in the mapper (or `_main_fuel_code`) from
`sap_main_heating_code` via SAP 10.2 Table 4b (104 = condensing combi **mains
gas**), mirroring the existing strict-raise → derive pattern. Cite the table.
This is the higher-leverage win — it unblocks the whole site-notes gas-combi
population, not one cert.
### 2. Pin the simulated 001431 case end-to-end (after #1)
Once #1 lands, the Summary path runs natively. Add it as a site-notes fixture
and pin every line ref at 1e-4. **NB:** with .189 + injected fuel, PE and CO2
close to 1e-4 but **SAP showed +0.0007** vs a hand-computed target (`100
13.95×ECF` from the 4-dp `(257)`=1.6047). That is almost certainly ECF
rounding in the target, not a real gap — but **verify against the worksheet's
own continuous SAP** before declaring it closed (don't trust my hand value).
### 3. Cert 6035 remaining +19 PE
6035 is API/lodged-only (no worksheet) so it can't be pinned past the lodged
register. The user can reproduce 6035 *exactly* in Elmhurst to get a
worksheet — offer to format the golden JSON
(`tests/domain/sap10_calculator/rdsap/fixtures/golden/6035-7729-2309-0879-2296.json`)
as Elmhurst inputs. With a worksheet, walk the remaining +19 the same way.
### Carry-over (lower priority)
- `transform.py:973` treats `wall_construction in (5,6)` as timber-frame for
the ventilation structural-ACH split, but Table 22 / `rdsap_uvalues`
classify 6 = **system built (masonry)**, only 5/7/8 are timber/cob/park.
Possible latent ventilation-ACH bug — verify before touching.
---
## Process notes
- One slice = one commit, spec citation in the message, `Co-Authored-By:
Claude Opus 4.8` trailer. AAA tests, `abs(x-y) <= tol` (not `pytest.approx`).
- The golden worksheet PE/CO2 pins (.186) re-pin smaller as gaps close — never
widen. The lodged-residual pins (`test_golden_cert_residual_matches_pin`)
carry the API-vs-register residual and move when the calc improves.

View file

@ -1,5 +1,10 @@
# SAP 10.2 / RdSAP 10 calculator — module overview
> **New here? Start with [`AGENT_GUIDE.md`](AGENT_GUIDE.md)** — the
> accuracy bar (site-notes vs API), the debugging loop, and the
> tools/pipeline. This file is the deeper architecture + API reference;
> the `HANDOVER_*` files are point-in-time session notes, not onboarding.
Deterministic, bit-faithful replication of the RdSAP10 calculation engine.
Validated against the 6 Elmhurst U985 worksheet PDFs at **abs=1e-4 on
every line ref** for both the Rating cascade (UK-average climate, used
@ -495,3 +500,34 @@ shape (Table 12 annual where spec literal says monthly), so the gate
is implemented under the same `dual-rate → annual on top of monthly`
discipline. If a second §12.4.4-eligible cert worksheet diverges from
this rule it should be raised against this row before re-tuning.
### 8.3 Community-heating CHP uses Table 12f "flexible operation" by default
**Slice S0380.182.** For RdSAP-defaulted community heating with CHP
(SAP code 302) that is **not** in the PCDB, the displaced-electricity
credit (worksheet (364)/(366) CO2 and (464)/(466) PE) needs a Table 12f
(PDF p.196) "fuel factor for electricity generated by CHP". Table 12f
offers three regimes per CHP vintage:
| Regime | CO2 kg/kWh | PE | Note |
|---|---|---|---|
| export only | 0.394 | 2.345 | |
| **flexible operation** | **0.420** | **2.369** | needs assessor evidence |
| standard | 0.348 | 2.149 | "all other operating regimes" |
Table 12f's own notes make **standard** the default ("Standard ... should
be used for all other operating regimes of gas CHP plants") and require
submitted evidence for **flexible**. Yet the BRE-approved Elmhurst rdSAP
engine emits **0.420 / 2.369 (flexible)** for these RdSAP-defaulted
community-CHP certs — verified line-by-line against the CH2 (gas) / CH4
(oil) / CH6 (coal) corpus worksheets (364)/(366)/(464)/(466), all of
which carry 0.4200 CO2 and 2.3690 PE regardless of the community fuel.
RdSAP 10 §C (p.58) is silent on the Table 12f regime, so this is an
engine default not derivable from the spec text.
Per [[feedback-software-no-special-handling]] / [[feedback-worksheet-not-api-reference]]
we mirror the engine: `_TABLE_12F_CHP_FLEXIBLE_{CO2,PE}` in
`cert_to_inputs`. CH2 + CH4 close to <1e-4 on both CO2 and PE with this
factor; "standard" (0.348/2.149) would leave a residual. If a future
PCDB-listed or evidence-backed CHP cert diverges, raise it against this
row before re-tuning.

View file

@ -210,7 +210,26 @@ _LIVING_AREA_FRACTION_MIN: Final[float] = 0.13
_PENCE_TO_GBP: Final[float] = 0.01
# RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter (TMP),
# keyed on the construction type of the MAIN building (not extensions):
# 100 kJ/m²K — timber frame, cob, park home (the three types regardless
# of internal insulation); OR masonry (stone, solid brick,
# cavity, system built) WITH internal insulation.
# 250 kJ/m²K — masonry WITHOUT internal insulation.
# This default is the masonry-no-internal value; `_thermal_mass_parameter_
# kj_per_m2_k` lowers it to 100 for the Table 22 low-mass cases. Unknown /
# unmapped / curtain-wall constructions keep the 250 default (the
# pre-Table-22 behaviour, so no fixture regresses on a missing class).
_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0
_TMP_LOW_KJ_PER_M2_K: Final[float] = 100.0
# `wall_construction` int codes (domain/sap10_ml/rdsap_uvalues.py):
# 5 = timber frame, 7 = cob, 8 = park home — Table 22's "three types".
_TMP_ALWAYS_LOW_WALL_CONSTRUCTION_CODES: Final[frozenset[int]] = frozenset({5, 7, 8})
# `wall_insulation_type` int codes that are INTERNAL insulation
# (Table 22 "masonry … with internal insulation"): 3 = internal wall
# insulation, 7 = filled cavity + internal. External (1), filled cavity
# (2), cavity+external (6), as-built (4), none (5) keep the masonry 250.
_TMP_INTERNAL_WALL_INSULATION_CODES: Final[frozenset[int]] = frozenset({3, 7})
# SAP 10.2 Table 4f (PDF p.174) — Heating system circulation pump
# rows. Keyed on RdSAP API `central_heating_pump_age` enum:
@ -898,6 +917,21 @@ def _heat_network_heat_source_efficiency_scaling(
return 1.0 / eff
def _is_heat_network_electric_main(main: Optional[MainHeatingDetail]) -> bool:
"""True when the main is a heat network whose generator runs on grid
electricity (Table 4a code 304 Table 12 fuel code 41 "heat from
electric heat pump"). Such networks meter electricity, so SAP 10.2
Table 12 note (s)/(t) + worksheet block 12b/13b footnote (a) require
the MONTHLY Table 12d/12e factors (not the annual average), weighted
by the network heat profile, before the 1/heat-source-eff (1/COP)
scaling. Non-electric heat networks (gas/oil/coal boilers, codes
51/53/54) have no monthly factor set and keep the annual Table 12
value."""
if not _is_heat_network_main(main):
return False
return co2_monthly_factors_kg_per_kwh(_main_fuel_code(main)) is not None
def _heat_network_dlf(age_band: Optional[str]) -> float:
"""RdSAP 10 §10.11 + SAP 10.2 Table 12c distribution loss factor by
age band. Defaults to the K-or-newer value (1.50) when band missing.
@ -913,6 +947,168 @@ def _heat_network_dlf(age_band: Optional[str]) -> float:
raise UnmappedSapCode("heat_network_age_band", age_band)
# SAP 10.2 Table 12 fuel code 50 — "electricity for pumping in
# distribution network". Its CO2 / PE factors vary by month per Table
# 12d / 12e (= standard-electricity profile); worksheet (372)/(472)
# footnote (a) applies the monthly factors weighted by the heat profile.
_ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE: Final[int] = 50
# SAP 10.2 Appendix C §C3.2 (PDF p.51) — pumping energy = 1% of the
# energy required for space and water heating.
_HEAT_NETWORK_PUMPING_FRACTION_OF_HEAT: Final[float] = 0.01
def _heat_network_distribution_electricity(
main: Optional[MainHeatingDetail],
space_heating_monthly_kwh: tuple[float, ...],
hot_water_output_monthly_kwh: tuple[float, ...],
efficiency: float,
) -> Optional[tuple[float, float, float]]:
"""SAP 10.2 Appendix C §C3.2 (PDF p.51) — electricity for pumping
water through a heat network's distribution system.
Spec verbatim: "CO2 emissions and Primary Energy associated with the
electricity used for pumping water through the distribution system
are allowed for by adding electrical energy equal to 1% of the
energy required for space and water heating." Worksheet line (313) =
0.01 × [(307) + (310)]; its CO2 (372) and PE (472) bill on the
Table 12d / 12e monthly factors for fuel code 50 ("electricity for
pumping in distribution network"), weighted by the monthly heat
profile per worksheet footnote (a).
(307)m = space-heating fuel and (310)m = water-heating fuel: for a
heat network the cascade models the heat-generator efficiency as
1/DLF, so fuel = q_useful / efficiency = q_useful × DLF. The
monthly weighting of the Table 12d/12e factor is shape-only (the DLF
scalar cancels), and the energy carries the DLF.
Returns (energy_kwh, co2_factor, pe_factor) for heat-network mains
(Table 4a 301-304 / category 6); None otherwise so the default
0.0 / None fields leave individually-heated certs unchanged.
NB Elmhurst's worksheet DISPLAYS the (372) energy column as 0.01 ×
(307) (space only) but computes the EMISSIONS on 0.01 × (307+310)
per the §C3.2 text verified line-by-line against the community-
heating corpus worksheets. We mirror the spec text (space + water).
"""
if not _is_heat_network_main(main) or efficiency <= 0.0:
return None
distribution_monthly_kwh = tuple(
_HEAT_NETWORK_PUMPING_FRACTION_OF_HEAT * (sh + hw) / efficiency
for sh, hw in zip(
space_heating_monthly_kwh, hot_water_output_monthly_kwh
)
)
energy_kwh = sum(distribution_monthly_kwh)
if energy_kwh <= 0.0:
return None
co2_monthly = co2_monthly_factors_kg_per_kwh(
_ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE
)
pe_monthly = pe_monthly_factors_kwh_per_kwh(
_ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE
)
if co2_monthly is None or pe_monthly is None:
return None
co2_factor = sum(
kwh * f for kwh, f in zip(distribution_monthly_kwh, co2_monthly)
) / energy_kwh
pe_factor = sum(
kwh * f for kwh, f in zip(distribution_monthly_kwh, pe_monthly)
) / energy_kwh
return (energy_kwh, co2_factor, pe_factor)
# SAP 10.2 Table 12 fuel code 302's worksheet path. Community heating
# "CHP and boilers" (Table 4a code 302).
_SAP_CODE_COMMUNITY_CHP_AND_BOILERS: Final[int] = 302
# SAP 10.2 Table 12f (PDF p.196) — fuel factors for the electricity
# GENERATED BY CHP (the displaced-grid credit, worksheet (364)/(464)).
# The BRE-approved Elmhurst rdSAP engine applies the "flexible
# operation" row (0.420 kg CO2/kWh, 2.369 PE) to RdSAP-defaulted
# community CHP that is not in the PCDB — verified line-by-line against
# the CH2/CH4/CH6 corpus worksheets (363)..(366) / (463)..(466). Table
# 12f's own notes make "standard" (0.348 / 2.149) the default and
# require assessor evidence for "flexible"; we mirror the certified
# engine per [[feedback-software-no-special-handling]] (documented as a
# spec divergence in SAP_CALCULATOR.md §8).
_TABLE_12F_CHP_FLEXIBLE_CO2_KG_PER_KWH: Final[float] = 0.420
_TABLE_12F_CHP_FLEXIBLE_PE_KWH_PER_KWH: Final[float] = 2.369
# RdSAP 10 §C (PDF p.58) heat-network CHP defaults when the network is
# not in the PCDB: CHP overall efficiency 75% with heat-to-power ratio
# 2.0 → heat efficiency 50% (worksheet (362)) + electrical efficiency
# 25% (worksheet (361)); back-up boiler efficiency 80% (worksheet
# (367)). The CHP heat fraction (0.35 default) is per-cert via
# `community_heating_chp_fraction`.
_HEAT_NETWORK_CHP_HEAT_EFFICIENCY: Final[float] = 0.50
_HEAT_NETWORK_CHP_ELECTRICAL_EFFICIENCY: Final[float] = 0.25
_HEAT_NETWORK_CHP_BOILER_EFFICIENCY: Final[float] = 0.80
def _heat_network_code_302_effective_factor(
main: Optional[MainHeatingDetail],
*,
primary_energy: bool,
) -> Optional[float]:
"""SAP 10.2 worksheet block 12b (CO2) / 13b (PE) for community
heating "CHP and boilers" (SAP code 302) the effective per-kWh
factor to apply to the network heat fuel [(307) + (310)].
Per unit of network heat fuel H = (307) + (310), the worksheet sums:
CHP fuel (363)/(463) = chp_frac × 100/(362) × f_fuel
less credit (364)/(464) = chp_frac × (361)/(362) × f_disp
boiler fuel (368)/(468) = (1chp_frac) × 100/(367) × f_fuel
where f_fuel is the Table 12 heat-network fuel factor (the CHP unit
and the back-up boilers burn the same community fuel verified vs
CH2 gas / CH4 oil / CH6 coal worksheets) and f_disp is the Table 12f
credit factor for the electricity the CHP generates. RdSAP 10 §C
defaults: (362) heat eff 50%, (361) electrical eff 25%, (367) boiler
eff 80%.
Returns the blended factor for code-302 mains with the CHP-split
fields populated; None otherwise so callers fall through to the
existing single-fuel / heat-source-efficiency-scaling path.
NB the worksheet PDF DISPLAYS the (368)/(468) boiler emissions
rounded/mis-aligned, but the (373)/(473)/(386)/(486) totals
reconcile only with the boiler at the FULL Table 12 fuel factor
verified EXACT.
"""
if (
main is None
or main.sap_main_heating_code != _SAP_CODE_COMMUNITY_CHP_AND_BOILERS
):
return None
chp_fraction = main.community_heating_chp_fraction
boiler_fuel_code = main.community_heating_boiler_fuel_type
if chp_fraction is None or boiler_fuel_code is None:
return None
if primary_energy:
fuel_factor = primary_energy_factor(boiler_fuel_code)
displaced_factor = _TABLE_12F_CHP_FLEXIBLE_PE_KWH_PER_KWH
else:
fuel_factor = co2_factor_kg_per_kwh(boiler_fuel_code)
displaced_factor = _TABLE_12F_CHP_FLEXIBLE_CO2_KG_PER_KWH
boiler_fraction = 1.0 - chp_fraction
return (
chp_fraction
* (1.0 / _HEAT_NETWORK_CHP_HEAT_EFFICIENCY)
* fuel_factor
- chp_fraction
* (
_HEAT_NETWORK_CHP_ELECTRICAL_EFFICIENCY
/ _HEAT_NETWORK_CHP_HEAT_EFFICIENCY
)
* displaced_factor
+ boiler_fraction
* (1.0 / _HEAT_NETWORK_CHP_BOILER_EFFICIENCY)
* fuel_factor
)
@dataclass(frozen=True)
class PriceTable:
"""Seam between the spec-correct SAP 10.2/10.3 Table 12 prices and
@ -1334,24 +1530,26 @@ def _water_heating_fuel_code(epc: EpcPropertyData) -> Optional[int]:
def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool:
"""True iff the cert's WHC routes HW from the main heating system
(codes 901 / 902 / 914) AND the main is a single-source heat
network with a registered heat-source efficiency
(`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` currently SAP code 301
boilers and 304 HP).
(codes 901 / 902 / 914) AND the main is a heat network the cascade
can cost/emission-rate: a registered single-source heat-source
efficiency (`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` SAP code 301
boilers / 304 HP) OR code 302 (CHP and boilers).
Elmhurst Summary §15.0 lodges `water_heating_fuel_type = "Mains gas"`
on community-heating certs regardless of the actual heat-network
source without this guard the HW cost / CO2 / PE bills via the
Mains-gas Table 12 code (3.48 p/kWh / 0.21 / 1.13) instead of the
heat-network code (4.24 p/kWh / Table 12 code 41 / 51).
heat-network rate.
SAP code 302 (CHP+boilers) is excluded because the 35%/65% split
requires the displaced-electricity credit line per spec block 13b
(464)/(466) on the HW side same constraint as `_main_heating_
co2_factor_kg_per_kwh` (S0380.172). Routing HW through main for
SAP 302 without the credit cascade would regress CO2 / PE; both
the SH and HW paths converge in a single follow-up slice that
wires the CHP credit + boiler-side factor split.
SAP code 302 (CHP+boilers) was previously excluded because the
35%/65% split needs the displaced-electricity credit line (spec
block 12b/13b (364)/(366)/(464)/(466)). S0380.182 wired that credit
via `_heat_network_code_302_effective_factor`, which intercepts the
HW CO2/PE helpers ABOVE this predicate's branch — so including 302
here now affects only the COST path, routing HW cost through
`_fuel_cost_gbp_per_kwh(main)` = the S0380.171 CHP heat-fraction
blend (the same rate as space heating, worksheet (342) = (310) ×
blend). Closes the CH2/CH4 HW cost residual (S0380.183).
"""
if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES:
return False
@ -1359,7 +1557,10 @@ def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool:
if not _is_heat_network_main(main):
return False
code = main.sap_main_heating_code if main is not None else None
return isinstance(code, int) and code in _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY
return isinstance(code, int) and (
code in _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY
or code == _SAP_CODE_COMMUNITY_CHP_AND_BOILERS
)
def _main_heating_efficiency(epc: EpcPropertyData) -> float:
@ -2109,24 +2310,39 @@ def _pv_eligible_demand_monthly_kwh(
electric_shower_monthly_kwh: tuple[float, ...],
pumps_fans_monthly_kwh: tuple[float, ...],
main_1_fuel_monthly_kwh: tuple[float, ...],
secondary_fuel_monthly_kwh: tuple[float, ...],
hot_water_monthly_kwh: tuple[float, ...],
main_fuel_code_table_12: Optional[int],
secondary_fuel_code_table_12: Optional[int],
water_heating_fuel_code_table_12: Optional[int],
) -> tuple[float, ...]:
"""SAP 10.2 Appendix M1 §3a (p.93) — monthly PV-eligible demand
D_PV,m. Always includes lighting + appliances + cooking + electric
shower + pumps & fans. Includes E_space,m only when the main
heating fuel is electricity at the standard tariff (codes 30, 32,
34, 35, 38 per spec). Includes E_water,m only when the water
heating fuel code is 30 (standard electricity) per spec.
shower + pumps & fans. Includes E_space,m (main AND secondary space
heating) only for the electric tariffs eligible for PV self-use
(codes 30, 32, 34, 35, 38 per spec). Includes E_water,m only when
the water heating fuel code is 30 (standard electricity) per spec.
Secondary space heating is included on the same footing as main:
Appendix M1 §3a counts E_space,m as the dwelling's total electric
space-heating demand, which for a gas-main / electric-secondary
dwelling is the (215)m secondary fuel. Omitting it understates
D_PV,m in the heating months only depressing the monthly β
onsite split and under-crediting PV primary energy (the calc-vs-
worksheet (233a) gap localised on the cohort-2 gas+PV certs:
cert 3136 onsite 726.9 790.3 vs worksheet 792.1).
The off-peak immersion × (243) Ewater branch and the Appendix G4
PV diverter adjustment are deferred current cohort fixtures
don't exercise them."""
include_space = (
include_main_space = (
main_fuel_code_table_12 is not None
and main_fuel_code_table_12 in _PV_ELIGIBLE_SPACE_HEATING_FUEL_CODES
)
include_secondary_space = (
secondary_fuel_code_table_12 is not None
and secondary_fuel_code_table_12 in _PV_ELIGIBLE_SPACE_HEATING_FUEL_CODES
)
include_water = (
water_heating_fuel_code_table_12 is not None
and water_heating_fuel_code_table_12 in _PV_ELIGIBLE_WATER_HEATING_FUEL_CODES
@ -2140,8 +2356,10 @@ def _pv_eligible_demand_monthly_kwh(
+ electric_shower_monthly_kwh[m]
+ pumps_fans_monthly_kwh[m]
)
if include_space:
if include_main_space:
d += main_1_fuel_monthly_kwh[m]
if include_secondary_space:
d += secondary_fuel_monthly_kwh[m]
if include_water:
d += hot_water_monthly_kwh[m]
monthly.append(d)
@ -2535,6 +2753,13 @@ def _main_heating_co2_factor_kg_per_kwh(
- zero-fuel cases (sum monthly_kwh == 0 effective factor None;
annual factor is the safe degenerate value)
"""
# SAP 10.2 §12b — community-heating CHP+boilers (code 302): the
# blended CHP-credit + boiler generation CO2 factor (S0380.182).
code_302_co2 = _heat_network_code_302_effective_factor(
main, primary_energy=False,
)
if code_302_co2 is not None:
return code_302_co2
if not _is_electric_main(main):
# Heat-network mains (SAP codes 301 / 304) are non-electric per
# `_is_electric_main` but require a heat-source-efficiency scaling
@ -2542,10 +2767,18 @@ def _main_heating_co2_factor_kg_per_kwh(
# heat_source_eff × Table 12 CO2 factor. The cascade meters
# network_input directly so scale the factor by 1/eff to land at
# the spec's fuel-input × factor.
return (
_co2_factor_kg_per_kwh(main)
* _heat_network_heat_source_efficiency_scaling(main)
)
scaling = _heat_network_heat_source_efficiency_scaling(main)
hn_fuel = _main_fuel_code(main)
if _is_heat_network_electric_main(main) and hn_fuel is not None:
# Electric-HP heat network (code 304 / fuel 41): the HP runs
# on grid electricity → MONTHLY Table 12d factors weighted by
# the network heat profile, then × 1/COP (S0380.184).
monthly = _effective_monthly_co2_factor(
main_fuel_monthly_kwh, hn_fuel,
)
if monthly is not None:
return monthly * scaling
return _co2_factor_kg_per_kwh(main) * scaling
if tariff is Tariff.STANDARD:
monthly = _effective_monthly_co2_factor(
main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
@ -2600,6 +2833,13 @@ def _main_heating_primary_factor(
Fallback to annual `primary_energy_factor` for non-electric mains
and the same edge cases as the CO2 helper (no Table 12a row,
unknown dual-rate codes, zero-fuel)."""
# SAP 10.2 §13b — community-heating CHP+boilers (code 302): the
# blended CHP-credit + boiler generation PE factor (S0380.182).
code_302_pe = _heat_network_code_302_effective_factor(
main, primary_energy=True,
)
if code_302_pe is not None:
return code_302_pe
fuel = _main_fuel_code(main)
if not _is_electric_main(main):
# PE-side mirror of `_main_heating_co2_factor_kg_per_kwh`
@ -2607,10 +2847,17 @@ def _main_heating_primary_factor(
# (467) = network_input × 100 / heat_source_eff × Table 12 PE
# factor; cascade meters network_input directly so scale by
# 1/eff at lookup time.
return (
primary_energy_factor(fuel)
* _heat_network_heat_source_efficiency_scaling(main)
)
scaling = _heat_network_heat_source_efficiency_scaling(main)
if _is_heat_network_electric_main(main) and fuel is not None:
# Electric-HP heat network (code 304 / fuel 41): MONTHLY
# Table 12e factors weighted by the network heat profile,
# then × 1/COP (S0380.184).
monthly = _effective_monthly_pe_factor(
main_fuel_monthly_kwh, fuel,
)
if monthly is not None:
return monthly * scaling
return primary_energy_factor(fuel) * scaling
if tariff is Tariff.STANDARD:
monthly = _effective_monthly_pe_factor(
main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
@ -2858,6 +3105,16 @@ def _hot_water_co2_factor_kg_per_kwh(
monthly HW fuel kWh the calculator uses an annual-flat HW
efficiency so the SHAPE of fuel monthly is identical to demand
monthly, and `_effective_monthly_co2_factor` is shape-only)."""
# SAP 10.2 §12b — community-heating CHP+boilers (code 302) HW from
# main: the same blended CHP-credit + boiler generation CO2 factor
# as SH (S0380.182). Gated on WHC ∈ {901, 902, 914} so immersion-
# heated DHW on a CHP network keeps the lodged electric factor.
if epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES:
code_302_co2 = _heat_network_code_302_effective_factor(
_water_heating_main(epc), primary_energy=False,
)
if code_302_co2 is not None:
return code_302_co2
# Community heating + WHC ∈ {901, 902, 914}: HW heat is delivered
# through the heat-network main, so HW CO2 must read the same
# Table 12 heat-network code factor as SH, scaled by 1/heat_source_
@ -2865,10 +3122,18 @@ def _hot_water_co2_factor_kg_per_kwh(
# gas" is an Elmhurst placeholder that mis-routes the lookup.
if _is_community_heating_hw_from_main(epc):
main = _water_heating_main(epc)
return (
_co2_factor_kg_per_kwh(main)
* _heat_network_heat_source_efficiency_scaling(main)
)
scaling = _heat_network_heat_source_efficiency_scaling(main)
hn_fuel = _main_fuel_code(main)
if _is_heat_network_electric_main(main) and hn_fuel is not None:
# Electric-HP heat network HW (code 304 / fuel 41): MONTHLY
# Table 12d factors weighted by the HW profile, × 1/COP
# (S0380.184) — mirror of the SH branch.
monthly = _effective_monthly_co2_factor(
hw_monthly_kwh, hn_fuel,
)
if monthly is not None:
return monthly * scaling
return _co2_factor_kg_per_kwh(main) * scaling
fuel = _water_heating_fuel_code(epc)
if fuel is None:
return _DEFAULT_CO2_KG_PER_KWH
@ -2906,16 +3171,33 @@ def _hot_water_primary_factor(
exactly to match the Elmhurst worksheet's (278) annual factor.
The 41-variant heating-systems corpus closes its HW PE residual
+25/+48 0 with this gate."""
# SAP 10.2 §13b — community-heating CHP+boilers (code 302) HW from
# main: same blended CHP-credit + boiler generation PE factor as SH
# (S0380.182). Gated on WHC ∈ {901, 902, 914}.
if epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES:
code_302_pe = _heat_network_code_302_effective_factor(
_water_heating_main(epc), primary_energy=True,
)
if code_302_pe is not None:
return code_302_pe
# Mirror of `_hot_water_co2_factor_kg_per_kwh` community-heating
# branch (S0380.173): WHC ∈ {901, 902, 914} on a heat-network main
# routes HW PE through the same Table 12 heat-network code as SH,
# scaled by 1/heat_source_eff per spec block 13a (463)/(467).
if _is_community_heating_hw_from_main(epc):
main = _water_heating_main(epc)
return (
primary_energy_factor(_main_fuel_code(main))
* _heat_network_heat_source_efficiency_scaling(main)
)
scaling = _heat_network_heat_source_efficiency_scaling(main)
hn_fuel = _main_fuel_code(main)
if _is_heat_network_electric_main(main) and hn_fuel is not None:
# Electric-HP heat network HW (code 304 / fuel 41): MONTHLY
# Table 12e factors weighted by the HW profile, × 1/COP
# (S0380.184) — mirror of the SH branch.
monthly = _effective_monthly_pe_factor(
hw_monthly_kwh, hn_fuel,
)
if monthly is not None:
return monthly * scaling
return primary_energy_factor(_main_fuel_code(main)) * scaling
fuel = _water_heating_fuel_code(epc)
if fuel is None:
return _DEFAULT_PEF
@ -3006,6 +3288,30 @@ def _int_or_none(value: object) -> Optional[int]:
return value if isinstance(value, int) else None
def _thermal_mass_parameter_kj_per_m2_k(epc: EpcPropertyData) -> float:
"""RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter from
the MAIN building's wall construction.
Timber frame / cob / park home 100 kJ/m²K regardless of insulation.
Masonry (stone, solid brick, cavity, system built) 100 with internal
insulation, else 250. Unknown / unmapped / curtain-wall constructions
fall back to the masonry default (250). See the Table 22 constant
comments above for the `wall_construction` / `wall_insulation_type`
code sets. TMP feeds the §7 time constant τ = Cm/(3.6·H); a wrong
(too-high) TMP slows the cooling rate, under-cuts the §7 temperature
reduction, and over-states mean internal temperature space heating.
"""
parts: list[SapBuildingPart] = epc.sap_building_parts or []
if not parts:
return _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K
main: SapBuildingPart = parts[0]
if _int_or_none(main.wall_construction) in _TMP_ALWAYS_LOW_WALL_CONSTRUCTION_CODES:
return _TMP_LOW_KJ_PER_M2_K
if _int_or_none(main.wall_insulation_type) in _TMP_INTERNAL_WALL_INSULATION_CODES:
return _TMP_LOW_KJ_PER_M2_K
return _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K
@dataclass(frozen=True)
class _VentilationCounts:
open_flues: int = 0
@ -3250,7 +3556,7 @@ def mean_internal_temperature_section_from_cert(
),
monthly_total_gains_w=monthly_total_gains_w,
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc),
total_floor_area_m2=dim.total_floor_area_m2,
control_type=_control_type(main),
responsiveness=_responsiveness(main, tariff=tariff),
@ -3349,7 +3655,7 @@ def space_cooling_section_from_cert(
monthly_external_temperature_c=monthly_external_temp_c,
monthly_total_gains_w=(0.0,) * 12,
total_floor_area_m2=dim.total_floor_area_m2,
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc),
cooled_area_fraction=0.0,
intermittency_factor=0.25,
)
@ -5817,15 +6123,36 @@ def cert_to_inputs(
internal_gains_result.total_internal_gains_monthly_w
)
lighting_kwh = internal_gains_result.lighting_kwh_per_yr
# Watts → kWh via n_days_in_month × 24 hours / 1000 W per kWh.
# Appendix M1 §3a D_PV,m needs each of these monthly so the
# PV-eligible-demand assembly downstream can sum them in kWh.
lighting_monthly_kwh = tuple(
# SAP 10.2 Appendix M1 §3a (p.93): D_PV,m sums E_L,m — the lighting
# ELECTRICITY (Appendix L eq L10, = line (232)) — NOT the L12
# internal heat gain G_L,m = E_L,m × 0.85 (spec: "assuming 15%" of
# lighting energy does not become internal heat). `lighting_
# monthly_w` is the L12 gain, so converting it W→kWh yields
# 0.85 × E_L,m and understates D_PV by 15% of lighting — depressing
# the monthly β onsite split and under-crediting PV primary energy
# uniformly across the year (the residual left after S0380.187 on
# the gas+PV certs: cert 3136 onsite 790.3 → 792.1 vs worksheet
# 792.1). Recover E_L,m by scaling the (shape-identical) gain
# profile to the annual E_L `lighting_kwh_per_yr` — mirroring the
# (219)m hot-water scale-to-annual below. Same mismatch the cooking
# term hit in S0380.73 (L18 gain vs L20 electricity); appliances
# need no scaling (G_A = E_A, no 0.85 factor). Magnitude-only: the
# shape-weighted lighting CO2/PE factor (Σkwh×f/Σkwh) is unchanged.
lighting_gain_monthly_kwh = tuple(
w * d * 24.0 / 1000.0
for w, d in zip(
internal_gains_result.lighting_monthly_w, _DAYS_IN_MONTH
)
)
_lighting_gain_total = sum(lighting_gain_monthly_kwh)
lighting_monthly_kwh = (
tuple(
g / _lighting_gain_total * lighting_kwh
for g in lighting_gain_monthly_kwh
)
if _lighting_gain_total > 0.0
else lighting_gain_monthly_kwh
)
appliances_monthly_kwh = tuple(
w * d * 24.0 / 1000.0
for w, d in zip(
@ -5890,7 +6217,7 @@ def cert_to_inputs(
),
monthly_total_gains_w=monthly_total_gains_w,
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc),
total_floor_area_m2=dim.total_floor_area_m2,
control_type=control_type_value,
responsiveness=responsiveness_value,
@ -5986,7 +6313,7 @@ def cert_to_inputs(
monthly_external_temperature_c=monthly_external_temp_c,
monthly_total_gains_w=(0.0,) * 12,
total_floor_area_m2=dim.total_floor_area_m2,
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc),
cooled_area_fraction=0.0,
intermittency_factor=0.25,
)
@ -6062,11 +6389,13 @@ def cert_to_inputs(
pumps_fans_kwh, _DAYS_IN_MONTH,
),
main_1_fuel_monthly_kwh=energy_requirements_result.main_1_fuel_monthly_kwh,
secondary_fuel_monthly_kwh=energy_requirements_result.secondary_fuel_monthly_kwh,
hot_water_monthly_kwh=hot_water_monthly_kwh_for_pv,
main_fuel_code_table_12=(
API_FUEL_TO_TABLE_12.get(main_fuel, main_fuel)
if main_fuel is not None else None
),
secondary_fuel_code_table_12=_secondary_fuel_code(epc),
water_heating_fuel_code_table_12=(
API_FUEL_TO_TABLE_12.get(
epc.sap_heating.water_heating_fuel,
@ -6125,6 +6454,16 @@ def cert_to_inputs(
tariff=_rdsap_tariff(epc),
) + _hw_extra_standing
# SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution
# pumping electricity (worksheet (313)/(372)/(472)). None for
# individually-heated certs.
heat_network_distribution = _heat_network_distribution_electricity(
main,
space_heating_result.total_space_heating_monthly_kwh,
hw_monthly_kwh_for_factors,
eff,
)
return CalculatorInputs(
dimensions=dim,
heat_transmission=ht,
@ -6158,7 +6497,7 @@ def cert_to_inputs(
responsiveness=responsiveness_value,
living_area_fraction=living_area_fraction_value,
control_temperature_adjustment_c=_control_temperature_adjustment_c(main),
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc),
main_heating_efficiency=eff,
hot_water_kwh_per_yr=hw_kwh,
pumps_fans_kwh_per_yr=pumps_fans_kwh,
@ -6214,6 +6553,21 @@ def cert_to_inputs(
epc, energy_requirements_result.secondary_fuel_monthly_kwh,
),
hot_water_co2_factor_kg_per_kwh=hw_co2_factor,
# SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution
# pumping electricity (worksheet (313)/(372)/(472)). 0.0 / None
# on individually-heated certs.
heat_network_distribution_kwh_per_yr=(
heat_network_distribution[0]
if heat_network_distribution is not None else 0.0
),
heat_network_distribution_co2_factor_kg_per_kwh=(
heat_network_distribution[1]
if heat_network_distribution is not None else None
),
heat_network_distribution_primary_factor=(
heat_network_distribution[2]
if heat_network_distribution is not None else None
),
# SAP 10.2 Table 12a Grid 2 (p.191) + Table 12d (p.194): pumps,
# lighting, and the electric-shower end-use all bill via the
# "All other uses" row → on off-peak tariffs blend the high /

View file

@ -192,8 +192,14 @@ CO2_KG_PER_KWH: Final[dict[int, float]] = {
30: 0.136, 31: 0.136, 32: 0.136, 33: 0.136, 34: 0.136, 35: 0.136,
38: 0.136, 40: 0.136, 39: 0.136, 60: 0.136, 36: 0.136,
# Heat networks
51: 0.210, 52: 0.241, 53: 0.298, 54: 0.375, 55: 0.269,
56: 0.298, 57: 0.036, 58: 0.018,
# Heat-network oil (code 53 "assumes 'gas oil'") and mineral-oil/
# biodiesel boilers (code 56) carry 0.335 kg CO2/kWh per SAP 10.2
# Table 12 (p.189) — NOT the individual-appliance heating-oil factor
# (code 4 = 0.298). (Fixed in S0380.182 when the code-302 CHP CO2
# cascade first exercised heat-network oil; PE 1.180 was already
# correct.)
51: 0.210, 52: 0.241, 53: 0.335, 54: 0.375, 55: 0.269,
56: 0.335, 57: 0.036, 58: 0.018,
41: 0.136, 42: 0.015, 43: 0.029, 44: 0.024,
45: 0.015, 46: 0.011, 47: 0.011, 48: 0.136, 49: 0.136,
50: 0.0,

View file

@ -41,8 +41,11 @@ from domain.sap10_calculator.exceptions import (
from domain.sap10_calculator.rdsap.cert_to_inputs import (
SAP_10_2_SPEC_PRICES,
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
_heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage]
_heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage]
_heat_network_dlf, # pyright: ignore[reportPrivateUsage]
_is_electric_main, # pyright: ignore[reportPrivateUsage]
_is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage]
_is_electric_water, # pyright: ignore[reportPrivateUsage]
_is_off_peak_meter, # pyright: ignore[reportPrivateUsage]
_main_floor_u_value, # pyright: ignore[reportPrivateUsage]
@ -56,6 +59,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_separately_timed_dhw, # pyright: ignore[reportPrivateUsage]
_space_heating_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
_tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage]
_thermal_mass_parameter_kj_per_m2_k, # pyright: ignore[reportPrivateUsage]
_water_efficiency_with_category_inherit, # pyright: ignore[reportPrivateUsage]
_water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage]
cert_to_demand_inputs,
@ -119,6 +123,64 @@ def _typical_semi_detached_epc():
)
@pytest.mark.parametrize(
"wall_construction, wall_insulation_type, expected_tmp",
[
# RdSAP 10 §5.16 Table 22 (PDF p.48) — timber frame (5), cob (7),
# park home (8) are always low-mass, regardless of insulation.
(5, 4, 100.0), # timber frame, as-built
(5, 2, 100.0), # timber frame, filled cavity — still 100
(7, 4, 100.0), # cob
(8, 1, 100.0), # park home, external insulation — still 100
# Masonry WITH internal insulation (ins 3 = internal, 7 =
# filled cavity + internal) → low-mass 100.
(3, 3, 100.0), # solid brick + internal
(3, 7, 100.0), # solid brick + filled cavity + internal
(4, 3, 100.0), # cavity + internal
(6, 3, 100.0), # system built + internal
(1, 3, 100.0), # stone granite + internal
# Masonry WITHOUT internal insulation → high-mass 250. External
# (1), filled cavity (2), cavity+external (6), as-built (4) all
# leave the structural mass coupled.
(3, 4, 250.0), # solid brick, as-built
(4, 2, 250.0), # cavity, filled
(4, 1, 250.0), # cavity, external insulation (NOT internal)
(4, 6, 250.0), # cavity + external
(1, 4, 250.0), # stone, as-built
(6, 4, 250.0), # system built, as-built (Table 22 lists it as masonry)
# Unmapped / curtain (9) / unknown (10) → masonry default 250
# (pre-Table-22 behaviour; no fixture regresses on a missing class).
(9, 4, 250.0), # curtain wall
(10, 4, 250.0), # unknown
],
)
def test_thermal_mass_parameter_follows_rdsap_table_22(
wall_construction: int, wall_insulation_type: int, expected_tmp: float
) -> None:
# Arrange — a single-part dwelling carrying the wall construction +
# insulation under test (RdSAP 10 §5.16 Table 22, PDF p.48).
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[
make_building_part(
wall_construction=wall_construction,
wall_insulation_type=wall_insulation_type,
),
],
sap_heating=make_sap_heating(
main_heating_details=[_gas_boiler_detail(sap_main_heating_code=102)],
),
)
# Act
tmp: float = _thermal_mass_parameter_kj_per_m2_k(epc)
# Assert
assert abs(tmp - expected_tmp) <= 1e-9
def test_heat_network_main_applies_table12c_dlf_to_main_heating_efficiency() -> None:
# Arrange — heat-network main heating (Table 4a code 301 = community
# heating with CHP/boilers; main_heating_category=6). Cert age band
@ -153,6 +215,168 @@ def test_heat_network_main_applies_table12c_dlf_to_main_heating_efficiency() ->
assert inputs.main_heating_efficiency == pytest.approx(1.0 / 1.41, abs=0.005)
def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> None:
# Arrange — heat-network main (Table 4a code 301 = community heating,
# category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution
# pumping electricity = 1% of the (space + water) heat generated,
# i.e. 0.01 × [(307) + (310)] where (307)m/(310)m = (space_demand +
# hw_output) / efficiency. Its CO2 (372) / PE (472) bill on the
# Table 12d / 12e monthly factors for fuel code 50 ("electricity for
# pumping in distribution network"), weighted by the monthly heat
# profile per worksheet footnote (a).
from domain.sap10_calculator.tables.table_12 import (
co2_monthly_factors_kg_per_kwh,
pe_monthly_factors_kwh_per_kwh,
)
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=20,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=6,
sap_main_heating_code=301,
)
space = (1000.0, 800.0, 600.0, 400.0, 200.0, 0.0,
0.0, 0.0, 0.0, 300.0, 700.0, 1000.0)
hw = (200.0,) * 12
efficiency = 1.0 / 1.45 # heat network models efficiency as 1/DLF
# Act
result = _heat_network_distribution_electricity(main, space, hw, efficiency)
# Assert — energy = 0.01 × Σ((space + hw) / eff) = 0.01 × 7400 × 1.45;
# factors = code-50 monthly weighted by the (space + hw) heat profile.
assert result is not None
energy_kwh, co2_factor, pe_factor = result
distribution_monthly = tuple(
0.01 * (s + w) / efficiency for s, w in zip(space, hw)
)
co2_monthly = co2_monthly_factors_kg_per_kwh(50)
pe_monthly = pe_monthly_factors_kwh_per_kwh(50)
assert co2_monthly is not None
assert pe_monthly is not None
expected_energy = 0.01 * (5000.0 + 2400.0) / efficiency
expected_co2_factor = sum(
d * f for d, f in zip(distribution_monthly, co2_monthly)
) / expected_energy
expected_pe_factor = sum(
d * f for d, f in zip(distribution_monthly, pe_monthly)
) / expected_energy
assert abs(energy_kwh - expected_energy) <= 1e-9
assert abs(energy_kwh - 0.01 * 7400.0 * 1.45) <= 1e-9
assert abs(co2_factor - expected_co2_factor) <= 1e-9
assert abs(pe_factor - expected_pe_factor) <= 1e-9
def test_heat_network_code_302_chp_effective_factor_per_sap_10_2_block_12b_13b() -> None:
# Arrange — community heating "CHP and boilers" (SAP code 302) on
# the RdSAP 10 §C (PDF p.58) defaults: CHP heat frac 0.35, heat eff
# 50% / electrical eff 25%, boiler eff 80%. CH2-style gas network
# (community_heating_boiler_fuel_type = 51 → Table 12 gas 0.210 CO2
# / 1.130 PE). SAP 10.2 §12b/13b effective generation factor:
# chp×100/(362)×f chp×(361)/(362)×f_disp + (1chp)×100/(367)×f
# with f_disp = Table 12f flexible operation (0.420 CO2 / 2.369 PE).
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=20,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2306,
main_heating_category=6,
sap_main_heating_code=302,
community_heating_chp_fraction=0.35,
community_heating_boiler_fuel_type=51,
)
# Act
co2 = _heat_network_code_302_effective_factor(main, primary_energy=False)
pe = _heat_network_code_302_effective_factor(main, primary_energy=True)
# Assert — gas: 0.35×2.0×0.210 0.35×0.5×0.420 + 0.65×1.25×0.210
# = 0.147 0.0735 + 0.170625 = 0.244125 (matches the
# CH2 worksheet (386) generation factor); PE mirror with 1.130 /
# 2.369 = 1.29455.
assert co2 is not None
assert pe is not None
assert abs(co2 - 0.244125) <= 1e-9
assert abs(pe - 1.29455) <= 1e-9
def test_is_heat_network_electric_main_true_only_for_electric_hp_network() -> None:
# Arrange — code 304 community heat pump (Table 12 fuel 41 = "heat
# from electric heat pump", which HAS monthly Table 12d/12e factors)
# vs code 301 community gas boilers (fuel 51, annual-only). SAP 10.2
# Table 12 note (s)/(t): grid-electricity factors vary monthly, so
# the HP network must use Table 12d/12e; the gas-boiler network keeps
# the annual factor.
hp_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=41, # Table 12 fuel 41 = heat from electric HP
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2306,
main_heating_category=6,
sap_main_heating_code=304,
)
gas_boiler_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=51, # Table 12 fuel 51 = heat from gas boilers
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2306,
main_heating_category=6,
sap_main_heating_code=301,
)
# Act / Assert
assert _is_heat_network_electric_main(hp_main) is True
assert _is_heat_network_electric_main(gas_boiler_main) is False
assert _is_heat_network_electric_main(None) is False
def test_heat_network_code_302_effective_factor_none_for_non_302_main() -> None:
# Arrange — a code-301 heat-network boiler main (no CHP split). The
# §12b/13b CHP+boilers blend applies only to code 302; code 301
# routes through the 1/heat-source-eff scaling path instead.
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=20,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2306,
main_heating_category=6,
sap_main_heating_code=301,
)
# Act / Assert
assert _heat_network_code_302_effective_factor(main, primary_energy=False) is None
assert _heat_network_code_302_effective_factor(main, primary_energy=True) is None
def test_heat_network_distribution_electricity_none_for_individual_main() -> None:
# Arrange — an individually-heated gas-boiler main (category 2, no
# heat-network SAP code). §C3.2 pumping electricity applies only to
# heat networks, so no distribution line should be emitted.
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=26,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=2,
)
space = (1000.0,) * 12
hw = (200.0,) * 12
# Act
result = _heat_network_distribution_electricity(main, space, hw, 0.85)
# Assert
assert result is None
def test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh() -> None:
# Arrange — when main heating is a heat network AND water heating
# inherits from main (water_heating_code=901), the HW also incurs
@ -1896,6 +2120,37 @@ def test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels() -> N
assert _ELMHURST_MAIN_FUEL_TO_SAP10["Bio-liquid HVO from used cooking oil"] == 71
def test_elmhurst_gas_boiler_main_fuel_derives_carrier_from_water_heating() -> None:
# Arrange — SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas
# boilers (including mains gas, LPG and biogas)". The code identifies
# the boiler type/efficiency, NOT the carrier. The newer Elmhurst
# export leaves §14.0 "Fuel Type" empty and lodges only the SAP code
# (e.g. 104 condensing combi); the §15.0 "Water Heating Fuel Type"
# names the carrier because the same combi/boiler heats space + water.
from datatypes.epc.domain.mapper import (
_elmhurst_gas_boiler_main_fuel, # pyright: ignore[reportPrivateUsage]
)
# Act / Assert — combi (104) + §15.0 mains gas (26) → mains gas.
assert _elmhurst_gas_boiler_main_fuel(104, 26) == 26
# Regular condensing (102) + §15.0 bulk LPG (27) → bulk LPG.
assert _elmhurst_gas_boiler_main_fuel(102, 27) == 27
# Boundary codes of the 101-119 gas-boiler range resolve too.
assert _elmhurst_gas_boiler_main_fuel(101, 26) == 26
assert _elmhurst_gas_boiler_main_fuel(119, 5) == 5 # bottled LPG
# §15.0 lodges a separate electric immersion's fuel (30), NOT the
# gas boiler's carrier → no derivation; caller strict-raises.
assert _elmhurst_gas_boiler_main_fuel(104, 30) is None
# Non-gas-boiler SAP code (224 = air-source heat pump) → None even
# when §15.0 names a gas fuel (the HP doesn't burn it).
assert _elmhurst_gas_boiler_main_fuel(224, 26) is None
# Liquid-fuel boiler range (120-141) is owned by the separate
# `_LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES` branch → None here.
assert _elmhurst_gas_boiler_main_fuel(120, 26) is None
# No SAP code lodged → None.
assert _elmhurst_gas_boiler_main_fuel(None, 26) is None
def test_elmhurst_main_heating_ees_maps_no_system_code_to_electricity() -> None:
# Arrange — SAP 10.2 §A.2.2 (PDF p.189 area) "When no main heating
# system is identified, the calculation is for the assumed system

View file

@ -54,6 +54,13 @@ _SAP_ABS_TOLERANCE = 0
_PE_ABS_TOLERANCE_KWH_PER_M2 = 0.01
_CO2_ABS_TOLERANCE_TONNES = 0.001
# Worksheet-pin tolerances (calc Elmhurst dr87 worksheet, full precision).
# These are deterministic so the tolerances are tight; they lock the
# current residual against the worksheet's full-precision (286)/(272)
# rather than the integer-rounded lodged register values.
_WS_PE_ABS_TOLERANCE_KWH_PER_M2 = 0.01
_WS_CO2_ABS_TOLERANCE_KG = 0.01
@dataclass(frozen=True)
class _GoldenExpectation:
@ -186,11 +193,20 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="6035-7729-2309-0879-2296",
actual_sap=70,
expected_sap_resid=-6,
expected_pe_resid_kwh_per_m2=+46.4156,
expected_co2_resid_tonnes_per_yr=+1.0677,
expected_sap_resid=-2,
expected_pe_resid_kwh_per_m2=+19.1566,
expected_co2_resid_tonnes_per_yr=+0.4211,
notes=(
"Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. "
"S0380.189 fixed the dominant driver: walls are solid brick "
"WITH internal insulation (wall_insulation_type=3), so "
"RdSAP 10 §5.16 Table 22 sets TMP=100 (not the old hardcoded "
"250). Correct TMP → §7 time constant ~16h not ~40h → larger "
"temperature reduction → MIT down ~0.7C → space heating drops. "
"SAP resid -6 → -2, PE +46.42 → +19.16, CO2 +1.07 → +0.42. "
"Validated 1e-4 against the user-simulated 001431 worksheet "
"(same archetype). Remaining +19 PE is other gaps + lodged "
"divergence (no worksheet for 6035 itself to pin further). "
"Slice 59 per-bp window apportionment tightens all 3 "
"residuals: SAP -5 → -4, PE +36.15 → +34.02, CO2 +0.81 → "
"+0.76 (2 of 8 windows route to Ext1 with ins_type 4 vs "
@ -258,7 +274,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="2130-1033-4050-5007-8395",
actual_sap=82,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-7.4998,
expected_pe_resid_kwh_per_m2=-7.5579,
expected_co2_resid_tonnes_per_yr=-0.0454,
notes=(
"End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, "
@ -315,8 +331,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0380-2471-3250-2596-8761",
actual_sap=89,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=+0.5259,
expected_co2_resid_tonnes_per_yr=-0.0074,
expected_pe_resid_kwh_per_m2=+0.4872,
expected_co2_resid_tonnes_per_yr=-0.0075,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, semi-detached bungalow "
"TFA 60.43 age D, PV 3 kWp + 5 kWh battery. Worksheet SAP "
@ -335,7 +351,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0350-2968-2650-2796-5255",
actual_sap=84,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-0.2812,
expected_pe_resid_kwh_per_m2=-0.2976,
expected_co2_resid_tonnes_per_yr=-0.0292,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with "
@ -347,7 +363,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="2225-3062-8205-2856-7204",
actual_sap=89,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-0.2978,
expected_pe_resid_kwh_per_m2=-0.3250,
expected_co2_resid_tonnes_per_yr=-0.0101,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with "
@ -359,7 +375,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="2636-0525-2600-0401-2296",
actual_sap=86,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-0.4127,
expected_pe_resid_kwh_per_m2=-0.4340,
expected_co2_resid_tonnes_per_yr=-0.0045,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with "
@ -372,7 +388,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="3800-8515-0922-3398-3563",
actual_sap=86,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-0.2093,
expected_pe_resid_kwh_per_m2=-0.2288,
expected_co2_resid_tonnes_per_yr=+0.0407,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with "
@ -384,7 +400,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="9285-3062-0205-7766-7200",
actual_sap=84,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-0.0736,
expected_pe_resid_kwh_per_m2=-0.0921,
expected_co2_resid_tonnes_per_yr=-0.0452,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with "
@ -396,7 +412,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="9418-3062-8205-3566-7200",
actual_sap=85,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-0.4291,
expected_pe_resid_kwh_per_m2=-0.4492,
expected_co2_resid_tonnes_per_yr=-0.0056,
notes=(
"Daikin Altherma EDLQ05CAV3 PCDB 102421 (heating_duration "
@ -422,18 +438,18 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
# `sap worksheets/Additional data with api/`.
# ------------------------------------------------------------------
_GoldenExpectation(cert_number="0036-6325-1100-0063-1226", actual_sap=63, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4019, expected_co2_resid_tonnes_per_yr=+0.0255, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0100-5141-0522-4696-3463", actual_sap=86, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.5174, expected_co2_resid_tonnes_per_yr=+0.0277, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0200-3155-0122-2602-3563", actual_sap=81, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.6041, expected_co2_resid_tonnes_per_yr=-0.0096, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0300-2403-2650-2206-0235", actual_sap=77, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.1308, expected_co2_resid_tonnes_per_yr=+0.0443, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0310-2763-5450-2506-3501", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.2791, expected_co2_resid_tonnes_per_yr=+0.0150, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0100-5141-0522-4696-3463", actual_sap=86, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4939, expected_co2_resid_tonnes_per_yr=+0.0277, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0200-3155-0122-2602-3563", actual_sap=81, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4660, expected_co2_resid_tonnes_per_yr=-0.0085, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0300-2403-2650-2206-0235", actual_sap=77, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0931, expected_co2_resid_tonnes_per_yr=+0.0453, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0310-2763-5450-2506-3501", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1548, expected_co2_resid_tonnes_per_yr=+0.0159, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0320-2126-2150-2326-6161", actual_sap=72, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2060, expected_co2_resid_tonnes_per_yr=+0.0128, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0320-2756-8640-2296-1101", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2370, expected_co2_resid_tonnes_per_yr=+0.0303, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0330-2257-3640-2196-3145", actual_sap=85, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2809, expected_co2_resid_tonnes_per_yr=+0.0350, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0360-2266-5650-2106-8285", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.6646, expected_co2_resid_tonnes_per_yr=-0.0170, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0320-2756-8640-2296-1101", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2633, expected_co2_resid_tonnes_per_yr=+0.0303, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0330-2257-3640-2196-3145", actual_sap=85, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2620, expected_co2_resid_tonnes_per_yr=+0.0350, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0360-2266-5650-2106-8285", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0196, expected_co2_resid_tonnes_per_yr=-0.0162, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0380-2530-6150-2326-4161", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0893, expected_co2_resid_tonnes_per_yr=-0.0315, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0390-2066-4250-2026-4555", actual_sap=65, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2522, expected_co2_resid_tonnes_per_yr=+0.0005, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0464-3032-0205-4276-3204", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.1607, expected_co2_resid_tonnes_per_yr=+0.0451, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0652-3022-1205-2826-1200", actual_sap=71, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.9954, expected_co2_resid_tonnes_per_yr=+0.0276, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0464-3032-0205-4276-3204", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2365, expected_co2_resid_tonnes_per_yr=+0.0459, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="0652-3022-1205-2826-1200", actual_sap=71, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.0214, expected_co2_resid_tonnes_per_yr=+0.0284, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="1536-9325-5100-0433-1226", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1568, expected_co2_resid_tonnes_per_yr=-0.0456, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="2007-3011-9205-8136-3204", actual_sap=68, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3773, expected_co2_resid_tonnes_per_yr=-0.0325, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="2031-3007-0205-1296-3204", actual_sap=64, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4198, expected_co2_resid_tonnes_per_yr=-0.0420, notes="Cohort-2 baseline pin captured by S0380.69."),
@ -450,26 +466,120 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
# / CO2 +0.005 (lodged values are integer-rounded; rounding noise).
_GoldenExpectation(cert_number="2102-3018-0205-7886-5204", actual_sap=64, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.1961, expected_co2_resid_tonnes_per_yr=+0.0048, notes="Cohort-2 baseline pin. House coal secondary — S0380.70 routed CO2/PE through `secondary_fuel_type` per SAP 10.2 Table 12d/12e headers, closed PE +20.36 → +0.20 and CO2 -0.79 → +0.005 (lodged values integer-rounded)."),
_GoldenExpectation(cert_number="2130-3018-4205-4686-5204", actual_sap=71, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4083, expected_co2_resid_tonnes_per_yr=-0.0357, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="2336-3124-3600-0517-1292", actual_sap=83, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.1247, expected_co2_resid_tonnes_per_yr=-0.0414, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="2536-2525-0600-0788-2292", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.4210, expected_co2_resid_tonnes_per_yr=-0.0244, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="2336-3124-3600-0517-1292", actual_sap=83, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.1077, expected_co2_resid_tonnes_per_yr=-0.0414, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="2536-2525-0600-0788-2292", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.4317, expected_co2_resid_tonnes_per_yr=-0.0244, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="2590-3025-7205-9066-0200", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1309, expected_co2_resid_tonnes_per_yr=-0.0036, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="2699-3025-5205-8066-0200", actual_sap=69, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4755, expected_co2_resid_tonnes_per_yr=-0.0016, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="2800-7999-0322-4594-3563", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2868, expected_co2_resid_tonnes_per_yr=-0.0049, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="3136-7925-4500-0246-6202", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.0936, expected_co2_resid_tonnes_per_yr=-0.0485, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="2800-7999-0322-4594-3563", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2727, expected_co2_resid_tonnes_per_yr=-0.0049, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="3136-7925-4500-0246-6202", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3624, expected_co2_resid_tonnes_per_yr=-0.0476, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="3336-2825-9400-0512-8292", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2060, expected_co2_resid_tonnes_per_yr=-0.0420, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="4536-5424-8600-0109-1226", actual_sap=82, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0660, expected_co2_resid_tonnes_per_yr=-0.0053, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="4536-5424-8600-0109-1226", actual_sap=82, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0867, expected_co2_resid_tonnes_per_yr=-0.0054, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="4536-8325-3100-0409-1222", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2794, expected_co2_resid_tonnes_per_yr=+0.0093, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="4800-3992-0422-0599-3563", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.5231, expected_co2_resid_tonnes_per_yr=-0.0406, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="6835-3920-2509-0933-5226", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.5284, expected_co2_resid_tonnes_per_yr=-0.0237, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="4800-3992-0422-0599-3563", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4814, expected_co2_resid_tonnes_per_yr=-0.0406, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="6835-3920-2509-0933-5226", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4924, expected_co2_resid_tonnes_per_yr=-0.0237, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="7700-3362-0922-7022-3563", actual_sap=63, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.4141, expected_co2_resid_tonnes_per_yr=+0.0216, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="7800-1501-0922-7127-3563", actual_sap=65, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0594, expected_co2_resid_tonnes_per_yr=+0.0440, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="7836-3125-0600-0526-2202", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.9583, expected_co2_resid_tonnes_per_yr=+0.0165, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="9036-0824-3500-0420-8222", actual_sap=84, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2791, expected_co2_resid_tonnes_per_yr=+0.0337, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="9370-3060-1205-3546-4204", actual_sap=88, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.0131, expected_co2_resid_tonnes_per_yr=-0.0060, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="9380-2957-7490-2595-3141", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.9173, expected_co2_resid_tonnes_per_yr=-0.0244, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="9421-3045-3205-1646-6200", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3253, expected_co2_resid_tonnes_per_yr=-0.0046, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="9796-3058-6205-0346-9200", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3101, expected_co2_resid_tonnes_per_yr=-0.0013, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="9836-7525-9500-0575-1202", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0766, expected_co2_resid_tonnes_per_yr=+0.0011, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="7836-3125-0600-0526-2202", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.0794, expected_co2_resid_tonnes_per_yr=+0.0172, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="9036-0824-3500-0420-8222", actual_sap=84, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2984, expected_co2_resid_tonnes_per_yr=+0.0336, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="9370-3060-1205-3546-4204", actual_sap=88, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0111, expected_co2_resid_tonnes_per_yr=-0.0060, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="9380-2957-7490-2595-3141", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.1976, expected_co2_resid_tonnes_per_yr=-0.0238, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="9421-3045-3205-1646-6200", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3541, expected_co2_resid_tonnes_per_yr=-0.0046, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="9796-3058-6205-0346-9200", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3533, expected_co2_resid_tonnes_per_yr=-0.0013, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="9836-7525-9500-0575-1202", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1132, expected_co2_resid_tonnes_per_yr=+0.0011, notes="Cohort-2 baseline pin captured by S0380.69."),
)
@dataclass(frozen=True)
class _WorksheetPin:
"""Full-precision PE / CO2 targets read from a cert's Elmhurst dr87
worksheet (the "CALCULATION OF EPC COSTS, EMISSIONS AND PRIMARY
ENERGY" block of the *current* dwelling), plus the recorded calc
residual against them.
Unlike `_GoldenExpectation` which compares against the integer-
rounded lodged register values (`energy_consumption_current` /
`co2_emissions_current`) these pin against the worksheet's
unrounded `(286)` primary energy and `(272)` CO2. That makes the
residual a *calculator-vs-Elmhurst* signal, free of register
rounding: a non-zero `expected_pe_resid` here is a genuine calc gap,
not lodged noise.
`ws_pe_kwh_per_m2` = worksheet (286) / worksheet (4) total floor area
(the worksheet's own decimal TFA, not the JSON's integer); the
calculator uses the same decimal TFA, so the comparison is
apples-to-apples. `ws_co2_kg_per_yr` = worksheet (272) total CO2.
"""
cert_number: str
ws_pe_kwh_per_m2: float
ws_co2_kg_per_yr: float
expected_pe_resid: float
expected_co2_resid_kg: float
# The 47 worksheet-validated certs (9 ASHP + 38 cohort-2). calc ≡
# worksheet on BOTH PE and CO2 at <1e-4 across the ENTIRE cohort
# (every expected_*_resid below is 0.0000) — the SAP 10.2 1e-4
# convergence target, met. Closed over two slices:
# S0380.187 — Appendix M1 §3a D_PV,m was missing electric SECONDARY
# space heating (215)m, under-crediting PV in heating months on the
# 10 gas+PV certs (PE +0.5..+1.5 / CO2 0.5..1.1 → ~0.03).
# S0380.188 — D_PV,m used the Appendix L L12 lighting GAIN G_L,m
# (= E_L,m × 0.85, "15% not internal heat") instead of the L10
# lighting ELECTRICITY E_L,m that §3a requires; the 15% shortfall
# depressed β uniformly across the year on every PV cert. Fixed by
# scaling the gain's seasonal shape to the annual E_L (232). Same
# L-gain-vs-L-electricity class as the cooking fix S0380.73.
# Values frozen from the dr87 PDFs (untracked, so not parsed at test
# time) per the worksheet_unrounded_sap convention.
_WORKSHEET_PE_CO2: tuple[_WorksheetPin, ...] = (
_WorksheetPin(cert_number="0036-6325-1100-0063-1226", ws_pe_kwh_per_m2=213.4019, ws_co2_kg_per_yr=2125.4851, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0100-5141-0522-4696-3463", ws_pe_kwh_per_m2=53.4939, ws_co2_kg_per_yr=427.6895, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0200-3155-0122-2602-3563", ws_pe_kwh_per_m2=192.4660, ws_co2_kg_per_yr=2191.4589, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0300-2403-2650-2206-0235", ws_pe_kwh_per_m2=224.9069, ws_co2_kg_per_yr=2445.3496, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0310-2763-5450-2506-3501", ws_pe_kwh_per_m2=233.8452, ws_co2_kg_per_yr=1715.8602, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0320-2126-2150-2326-6161", ws_pe_kwh_per_m2=177.7940, ws_co2_kg_per_yr=2312.8161, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0320-2756-8640-2296-1101", ws_pe_kwh_per_m2=45.7367, ws_co2_kg_per_yr=430.2596, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0330-2249-8150-2326-4121", ws_pe_kwh_per_m2=199.4413, ws_co2_kg_per_yr=3066.3286, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0330-2257-3640-2196-3145", ws_pe_kwh_per_m2=66.2620, ws_co2_kg_per_yr=435.0043, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0350-2968-2650-2796-5255", ws_pe_kwh_per_m2=55.7024, ws_co2_kg_per_yr=470.7988, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0360-2266-5650-2106-8285", ws_pe_kwh_per_m2=162.9804, ws_co2_kg_per_yr=2183.7720, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0380-2471-3250-2596-8761", ws_pe_kwh_per_m2=56.4872, ws_co2_kg_per_yr=292.5490, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0380-2530-6150-2326-4161", ws_pe_kwh_per_m2=174.9107, ws_co2_kg_per_yr=2368.5251, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0390-2066-4250-2026-4555", ws_pe_kwh_per_m2=176.7478, ws_co2_kg_per_yr=2500.4581, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0464-3032-0205-4276-3204", ws_pe_kwh_per_m2=179.2365, ws_co2_kg_per_yr=1845.9475, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0652-3022-1205-2826-1200", ws_pe_kwh_per_m2=251.0214, ws_co2_kg_per_yr=2828.3691, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="1536-9325-5100-0433-1226", ws_pe_kwh_per_m2=180.8432, ws_co2_kg_per_yr=2054.3609, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="2007-3011-9205-8136-3204", ws_pe_kwh_per_m2=172.6227, ws_co2_kg_per_yr=2567.5298, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="2031-3007-0205-1296-3204", ws_pe_kwh_per_m2=191.4198, ws_co2_kg_per_yr=2257.9561, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="2102-3018-0205-7886-5204", ws_pe_kwh_per_m2=228.1961, ws_co2_kg_per_yr=4104.7798, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="2130-3018-4205-4686-5204", ws_pe_kwh_per_m2=181.4083, ws_co2_kg_per_yr=2364.3480, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="2225-3062-8205-2856-7204", ws_pe_kwh_per_m2=52.6750, ws_co2_kg_per_yr=389.8819, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="2336-3124-3600-0517-1292", ws_pe_kwh_per_m2=68.1077, ws_co2_kg_per_yr=458.6131, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="2536-2525-0600-0788-2292", ws_pe_kwh_per_m2=87.5683, ws_co2_kg_per_yr=375.6003, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="2590-3025-7205-9066-0200", ws_pe_kwh_per_m2=171.8691, ws_co2_kg_per_yr=2396.4327, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="2636-0525-2600-0401-2296", ws_pe_kwh_per_m2=52.5660, ws_co2_kg_per_yr=395.4880, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="2699-3025-5205-8066-0200", ws_pe_kwh_per_m2=168.4755, ws_co2_kg_per_yr=2498.3764, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="2800-7999-0322-4594-3563", ws_pe_kwh_per_m2=89.2727, ws_co2_kg_per_yr=395.0757, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="3136-7925-4500-0246-6202", ws_pe_kwh_per_m2=238.6376, ws_co2_kg_per_yr=1752.3516, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="3336-2825-9400-0512-8292", ws_pe_kwh_per_m2=84.7840, ws_co2_kg_per_yr=458.0332, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="3800-8515-0922-3398-3563", ws_pe_kwh_per_m2=58.7712, ws_co2_kg_per_yr=440.6740, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="4536-5424-8600-0109-1226", ws_pe_kwh_per_m2=63.9133, ws_co2_kg_per_yr=494.6357, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="4536-8325-3100-0409-1222", ws_pe_kwh_per_m2=181.7206, ws_co2_kg_per_yr=2109.2633, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="4800-3992-0422-0599-3563", ws_pe_kwh_per_m2=66.4814, ws_co2_kg_per_yr=259.3652, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="6835-3920-2509-0933-5226", ws_pe_kwh_per_m2=224.4924, ws_co2_kg_per_yr=1476.3032, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="7700-3362-0922-7022-3563", ws_pe_kwh_per_m2=196.5859, ws_co2_kg_per_yr=2321.5875, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="7800-1501-0922-7127-3563", ws_pe_kwh_per_m2=172.9406, ws_co2_kg_per_yr=3144.0259, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="7836-3125-0600-0526-2202", ws_pe_kwh_per_m2=183.0794, ws_co2_kg_per_yr=1817.2248, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="9036-0824-3500-0420-8222", ws_pe_kwh_per_m2=56.7016, ws_co2_kg_per_yr=433.6372, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="9285-3062-0205-7766-7200", ws_pe_kwh_per_m2=56.9079, ws_co2_kg_per_yr=454.7771, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="9370-3060-1205-3546-4204", ws_pe_kwh_per_m2=51.9889, ws_co2_kg_per_yr=494.0023, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="9380-2957-7490-2595-3141", ws_pe_kwh_per_m2=207.1976, ws_co2_kg_per_yr=2176.1656, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="9418-3062-8205-3566-7200", ws_pe_kwh_per_m2=58.5508, ws_co2_kg_per_yr=394.3858, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="9421-3045-3205-1646-6200", ws_pe_kwh_per_m2=59.6459, ws_co2_kg_per_yr=295.3567, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="9501-3059-8202-7356-0204", ws_pe_kwh_per_m2=182.3673, ws_co2_kg_per_yr=3554.1642, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="9796-3058-6205-0346-9200", ws_pe_kwh_per_m2=53.6467, ws_co2_kg_per_yr=198.7122, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="9836-7525-9500-0575-1202", ws_pe_kwh_per_m2=253.8868, ws_co2_kg_per_yr=3101.1029, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
)
@ -530,6 +640,47 @@ def test_golden_cert_residual_matches_pin(expectation: _GoldenExpectation) -> No
)
@pytest.mark.parametrize(
"pin",
_WORKSHEET_PE_CO2,
ids=lambda p: p.cert_number,
)
def test_golden_cert_pe_co2_matches_worksheet(pin: _WorksheetPin) -> None:
"""Pin the demand cascade's PE / CO2 against the cert's Elmhurst dr87
worksheet at full precision the calculator-vs-Elmhurst signal that
the lodged-register residual (`test_golden_cert_residual_matches_pin`)
can't give, because lodged values are integer-rounded.
The worksheet's published *Current* PE `(286)` and CO2 `(272)` come
from its postcode-climate "CALCULATION OF EPC COSTS, EMISSIONS AND
PRIMARY ENERGY" block — so we drive the same `cert_to_demand_inputs`
(postcode climate) cascade the EPC publishes, not the UK-average SAP
cascade.
"""
# Arrange
doc = _load_cert(pin.cert_number)
epc = EpcPropertyDataMapper.from_api_response(doc)
# Act
demand = calculate_sap_from_inputs(
cert_to_demand_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
)
pe_resid = demand.primary_energy_kwh_per_m2 - pin.ws_pe_kwh_per_m2
co2_resid_kg = demand.co2_kg_per_yr - pin.ws_co2_kg_per_yr
# Assert
assert abs(pe_resid - pin.expected_pe_resid) <= _WS_PE_ABS_TOLERANCE_KWH_PER_M2, (
f"PE residual vs worksheet {pe_resid:+.4f} kWh/m² drifted from pin "
f"{pin.expected_pe_resid:+.4f} (tolerance "
f"±{_WS_PE_ABS_TOLERANCE_KWH_PER_M2})."
)
assert abs(co2_resid_kg - pin.expected_co2_resid_kg) <= _WS_CO2_ABS_TOLERANCE_KG, (
f"CO2 residual vs worksheet {co2_resid_kg:+.4f} kg/yr drifted from "
f"pin {pin.expected_co2_resid_kg:+.4f} (tolerance "
f"±{_WS_CO2_ABS_TOLERANCE_KG})."
)
# Cert 0390 lodges Firebird Boilers S 150-200 oil boiler at PCDB index_number
# 9005 (Table 105 winter eff 86.4%). End-to-end mapper → cert_to_inputs chain
# must surface that PCDB winter efficiency on `inputs.main_heating_efficiency`
@ -579,6 +730,4 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number(
main = epc.sap_heating.main_heating_details[0]
assert main.main_heating_index_number == expected_pcdb_id
if expected_winter_eff is not None:
assert inputs.main_heating_efficiency == pytest.approx(
expected_winter_eff, abs=1e-3
)
assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3

View file

@ -0,0 +1,126 @@
"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431
"simulated case 1" worksheet (gas-combi archetype).
Like 000565, this fixture does NOT hand-build the EpcPropertyData. It
routes the Summary PDF through ElmhurstSiteNotesExtractor +
EpcPropertyDataMapper.from_elmhurst_site_notes so the SAP-result pin
grid exercises the WHOLE extractor + mapper + calculator pipeline.
This is the cert that motivated S0380.190 the newer Elmhurst export
lodges the gas combi as §14.0 "Fuel Type" EMPTY + "Main Heating SAP
Code" 104 (condensing combi, EES "BGW"), with the carrier ("Mains
gas") only in §15.0 "Water Heating Fuel Type". Before S0380.190 the
mapper left `main_fuel_type=''` `cert_to_inputs` raised
`MissingMainFuelType`; `_elmhurst_gas_boiler_main_fuel` now derives
mains gas (code 26) from §15.0 per SAP 10.2 Table 4b (rows 101-119 are
gas-family boilers; the §15.0 fuel disambiguates the carrier because
the combi heats space + water from one appliance).
It is also the cert that motivated S0380.189 (thermal mass parameter
per RdSAP 10 §5.16 Table 22): solid brick WITH internal insulation
TMP 100, not the previously-hardcoded 250.
Source: user-simulated PDFs at `sap worksheets/golden fixture
debugging/simulated case 1/` (Summary_001431 (1).pdf input +
P960-0001-001431 - 2026-06-02T221203.958.pdf worksheet). The Summary
is mirrored into the tracked
`backend/documents_parser/tests/fixtures/Summary_001431_gas_combi.pdf`
(distinct name the corpus reuses cert 001431 across every heating
variant) so the test runs without depending on the unstaged workspace.
Cert shape (Summary §1-19): gas-combi mid-terrace, TFA 128 , solid
brick WITH internal insulation ( Table 22 TMP 100), no PV, no
secondary heating, no cylinder (combi instantaneous HW, WHC HWP / SAP
code 901). Condensing combi SAP code 104, EES "BGW".
Worksheet pin targets (P960-0001-001431 958.pdf, Block 1 energy
rating, lines 115-410; the second "FOR IMPROVED DWELLING" block is the
potential rating and is NOT pinned):
- SAP rating 78 (line 258)
- Energy cost factor 1.6047 (line 257; cascade carries it unrounded as
(255)*(256)/((4)+45) = 660.9750*0.4200/173.0 the continuous SAP
100 - 13.95*ECF is reconstructed from the unrounded ECF, NOT the
display-rounded 1.6047, so sap_score_continuous = 77.6147)
- Total fuel cost £660.9750 (line 255)
- CO2 3000.1664 kg/year (line 272)
- Space heating 8987.7669 kWh/year (Σ monthly (98))
- Main 1 fuel 10699.7225 kWh/year (line 211) mains gas
- Secondary fuel 0.0 (line 215)
- Hot water fuel 3327.1592 kWh/year (line 219) combi
- Lighting 283.2229 kWh/year (line 232)
- Pumps/fans 86.0 kWh/year (line 231)
Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation-
philosophy]]: pins are abs=1e-4 against the worksheet PDF. Failing
pins are named extractor / mapper / calculator gaps to fix.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Final
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/,
# [4]=repo root.
_SUMMARY_PDF: Final[Path] = (
Path(__file__).resolve().parents[4]
/ "backend" / "documents_parser" / "tests" / "fixtures"
/ "Summary_001431_gas_combi.pdf"
)
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
"""Convert a Summary PDF into the per-page text format the
ElmhurstSiteNotesExtractor expects (label\\nvalue sequences).
Mirror of the helper in `backend/documents_parser/tests/
test_summary_pdf_mapper_chain.py::_summary_pdf_to_textract_style_
pages` (and `_elmhurst_worksheet_000565.py`). `pdftotext -layout`
preserves the spatial label/value pairing on each line; we split on
2+ spaces to surface the tokens, then rejoin newline-delimited.
"""
info = subprocess.run(
["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True,
).stdout
m = re.search(r"Pages:\s+(\d+)", info)
if m is None:
raise RuntimeError(f"Could not parse page count from {pdf_path}")
page_count = int(m.group(1))
pages: list[str] = []
for i in range(1, page_count + 1):
layout = subprocess.run(
[
"pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(pdf_path), "-",
],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
return pages
def build_epc() -> EpcPropertyData:
"""Route the simulated 001431 Summary through extractor + mapper.
No hand-built EpcPropertyData the extractor and mapper are part of
the test target. Exercises the S0380.190 gas-combi fuel derivation
(§14.0 Fuel Type empty + SAP code 104 mains gas via §15.0).
"""
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)

View file

@ -37,6 +37,7 @@ from tests.domain.sap10_calculator.worksheet import (
_elmhurst_worksheet_000490 as _w000490,
_elmhurst_worksheet_000516 as _w000516,
_elmhurst_worksheet_000565 as _w000565,
_elmhurst_worksheet_001431 as _w001431,
)
from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import (
ALL_FIXTURES as _ELMHURST_FIXTURES,
@ -147,6 +148,25 @@ _FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = {
lighting_kwh_per_yr=1384.8353,
pumps_fans_kwh_per_yr=252.5159,
),
# Mapper-driven cohort entry — Summary_001431_gas_combi.pdf →
# extractor → mapper → calculator. Gas-combi mid-terrace, TFA 128,
# solid brick WITH internal insulation (Table 22 TMP 100), no PV /
# secondary / cylinder. The cert that motivated S0380.190 (gas-combi
# fuel from §15.0 when §14.0 Fuel Type is empty + SAP code 104) and
# S0380.189 (thermal mass parameter). Pins are worksheet Block 1
# (energy rating) line refs. sap_score_continuous is reconstructed
# from the UNROUNDED ECF ((255)*(256)/((4)+45)), not the display-
# rounded (257)=1.6047 — see the fixture module docstring.
"001431": FixtureCascadePins(
sap_score=78, sap_score_continuous=77.6147, ecf=1.6047,
total_fuel_cost_gbp=660.9750, co2_kg_per_yr=3000.1664,
space_heating_kwh_per_yr=8987.7669,
main_heating_fuel_kwh_per_yr=10699.7225,
secondary_heating_fuel_kwh_per_yr=0.0,
hot_water_kwh_per_yr=3327.1592,
lighting_kwh_per_yr=283.2229,
pumps_fans_kwh_per_yr=86.0,
),
}
@ -158,6 +178,7 @@ _FIXTURE_MODULES: Final[dict[str, ModuleType]] = {
"000490": _w000490,
"000516": _w000516,
"000565": _w000565,
"001431": _w001431,
}