mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Merge branch 'main' of https://github.com/Hestia-Homes/Model into feature/bill-derivation
This commit is contained in:
commit
d02b7348a6
18 changed files with 1903 additions and 97 deletions
|
|
@ -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:
|
||||
|
|
|
|||
BIN
backend/documents_parser/tests/fixtures/Summary_001431_gas_combi.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_001431_gas_combi.pdf
vendored
Normal file
Binary file not shown.
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# + (1−chp_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.1504−0.136))
|
||||
# and PE −249.32→−0.0000 (× (1.5569−1.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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -622,6 +633,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
|
||||
|
|
@ -629,6 +647,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
|
||||
|
|
@ -688,6 +707,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
|
||||
|
|
@ -722,6 +747,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
|
||||
|
|
@ -764,6 +790,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,
|
||||
|
|
|
|||
265
domain/sap10_calculator/docs/AGENT_GUIDE.md
Normal file
265
domain/sap10_calculator/docs/AGENT_GUIDE.md
Normal 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 = 68−5 → 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).
|
||||
```
|
||||
172
domain/sap10_calculator/docs/HANDOVER_POST_S0380_179.md
Normal file
172
domain/sap10_calculator/docs/HANDOVER_POST_S0380_179.md
Normal 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.
|
||||
114
domain/sap10_calculator/docs/HANDOVER_POST_S0380_189.md
Normal file
114
domain/sap10_calculator/docs/HANDOVER_POST_S0380_189.md
Normal 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.185–189)
|
||||
|
||||
| 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 `(26–37)`, 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) = (1−chp_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,
|
||||
|
|
@ -6233,6 +6572,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 /
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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 + (1−chp)×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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 m², 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)
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue