Slice 36: §12 + §13a demand cascade closure (96/96 EPC Block 2 pins)

Pins the EPC's published "Current Carbon" + "Current Primary Energy"
values against the U985 Block 2 (postcode-climate cascade via PCDB
Table 172) for all 6 Elmhurst fixtures at abs=1e-4.

Adds:
- `PrimaryEnergySection` dataclass exposing §13a line refs (275)..(286).
- `primary_energy_section_from_cert(epc, postcode_climate=...)` —
  composes §9a per-system fuel kWh × Table 12 (gas) / Table 12e
  (electricity, monthly) PE factors. Handles (279) excludes (278a)
  electric-shower PE convention (mirrors §12 (265) excludes (264a)).
- Real postcode on each Elmhurst fixture (bd3 8aq / bd3 9DR / bd5 8dn /
  bd3 9JZ / bd19 3TF / BD4 7JR) via new `postcode` kwarg on
  `make_minimal_sap10_epc`.
- DEMAND_LINE_* constants per fixture for §9a annual kWh, §12 CO2 line
  refs (261..272), §13a PE line refs (275..286).
- 16 cascade pins per fixture × 6 fixtures = 96 demand pins.

EXACT match (000474, the canonical test):
  EPC Current Carbon (LINE_272) = 3104.1222 kg/yr ✓ (Summary PDF: 3.104t)
  EPC Current PE     (LINE_286) = 16931.7227 kWh/yr ✓

Reference: SAP 10.2 Appendix U paragraph 1 (p.124) — "For ratings (SAP
rating and environmental impact rating) the calculations are done with
UK average weather. Other calculations (such as for energy use and
costs on EPCs) are done using local weather. Weather data for each
postcode district are taken from the PCDB."

Full scoreboard: 840 rating-cascade pins + 96 demand-cascade pins +
existing 5 postcode-weather unit tests = 941 total pins. Wider
regression: 1585/1585 PASS — zero failures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 09:53:06 +00:00
parent 8cfeba8e2a
commit 4da8a4703d
9 changed files with 311 additions and 1 deletions

View file

@ -262,6 +262,7 @@ def make_minimal_sap10_epc(
blocked_chimneys_count: Optional[int] = None,
pressure_test: Optional[int] = None,
sap_ventilation: Optional[SapVentilation] = None,
postcode: str = "A1 1AA",
) -> EpcPropertyData:
"""Construct a minimal valid SAP10 EpcPropertyData with parametrisable targets."""
return EpcPropertyData(
@ -270,7 +271,7 @@ def make_minimal_sap10_epc(
tenure=tenure,
transaction_type=transaction_type,
address_line_1="1 Test Street",
postcode="A1 1AA",
postcode=postcode,
post_town="Testtown",
roofs=[],
walls=[],

View file

@ -1250,6 +1250,84 @@ def environmental_section_from_cert(
)
@dataclass(frozen=True)
class PrimaryEnergySection:
"""SAP 10.2 §13a worksheet line refs (275)..(286) — Primary Energy.
Per-end-use PE breakdown plus the total. Pin against the U985 Block 2
(postcode climate) §13a values to verify the EPC Current Primary
Energy output."""
main_1_pe_kwh_per_yr: float # (275)
main_2_pe_kwh_per_yr: float # (276)
secondary_pe_kwh_per_yr: float # (277)
water_heating_pe_kwh_per_yr: float # (278)
electric_shower_pe_kwh_per_yr: float # (278a) — when present
space_and_water_pe_kwh_per_yr: float # (279)
pumps_fans_pe_kwh_per_yr: float # (281)
lighting_pe_kwh_per_yr: float # (282)
total_pe_kwh_per_yr: float # (286)
def primary_energy_section_from_cert(
epc: EpcPropertyData,
*,
postcode_climate: Optional[PostcodeClimate] = None,
) -> Optional[PrimaryEnergySection]:
"""SAP 10.2 §13a cert→inputs cascade. Composes §9a per-system fuel kWh
× Table 12 (gas) / Table 12e (electricity, monthly) PE factors.
`postcode_climate` selects the demand cascade (EPC Current PE).
Returns None when TFA missing."""
if epc.total_floor_area_m2 is None:
return None
er = energy_requirements_section_from_cert(
epc, postcode_climate=postcode_climate,
)
assert er is not None, "energy_requirements None despite TFA present"
full_inputs = cert_to_inputs(epc, postcode_climate=postcode_climate)
main = _first_main_heating(epc)
main_fuel = _main_fuel_code(main)
main_pe = primary_energy_factor(main_fuel)
water_fuel = epc.sap_heating.water_heating_fuel or main_fuel
water_pe = primary_energy_factor(water_fuel)
main_1 = er.main_1_fuel_kwh_per_yr * main_pe
main_2 = er.main_2_fuel_kwh_per_yr * main_pe
secondary_eff = _effective_monthly_pe_factor(
er.secondary_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
)
secondary = er.secondary_fuel_kwh_per_yr * (
secondary_eff if secondary_eff is not None else 0.0
)
water = full_inputs.hot_water_kwh_per_yr * water_pe
electric_shower = (
full_inputs.electric_shower_kwh_per_yr
* (full_inputs.electric_shower_primary_factor or 0.0)
)
pumps_fans = full_inputs.pumps_fans_kwh_per_yr * (
full_inputs.pumps_fans_primary_factor or 0.0
)
lighting = full_inputs.lighting_kwh_per_yr * (
full_inputs.lighting_primary_factor or 0.0
)
# (279) excludes (278a) per the U985 worksheet convention — electric
# shower PE is reported as its own row but only contributes to (286)
# total, not to the "space + water heating" subtotal (mirrors the
# §12 (265) exclusion of (264a)).
space_and_water = main_1 + main_2 + secondary + water
total = space_and_water + electric_shower + pumps_fans + lighting
return PrimaryEnergySection(
main_1_pe_kwh_per_yr=main_1,
main_2_pe_kwh_per_yr=main_2,
secondary_pe_kwh_per_yr=secondary,
water_heating_pe_kwh_per_yr=water,
electric_shower_pe_kwh_per_yr=electric_shower,
space_and_water_pe_kwh_per_yr=space_and_water,
pumps_fans_pe_kwh_per_yr=pumps_fans,
lighting_pe_kwh_per_yr=lighting,
total_pe_kwh_per_yr=total,
)
def sap_rating_section_from_cert(
epc: EpcPropertyData,
) -> Optional[SapRatingSection]:

View file

@ -122,6 +122,7 @@ def build_epc() -> EpcPropertyData:
return make_minimal_sap10_epc(
total_floor_area_m2=56.79,
country_code="ENG",
postcode="bd3 8aq",
sap_building_parts=[main, extension_1, extension_2],
habitable_rooms_count=3,
heated_rooms_count=3,
@ -514,3 +515,29 @@ LINE_272_TOTAL_CO2: float = 3036.2933
LINE_273_CO2_PER_M2: float = 53.4700
EI_VALUE_CONTINUOUS: float = 59.9093
LINE_274_EI_RATING_INTEGER: int = 60
# ============================================================================
# DEMAND CASCADE (Block 2 — postcode climate via PCDB Table 172)
# §12 (261..272) + §13a (275..286) line refs — EPC consumer-facing values.
# (273)/(274) live only in Block 1 (rating climate); see LINE_273/LINE_274.
# ============================================================================
DEMAND_LINE_211_MAIN_1_KWH: float = 12288.0014
DEMAND_LINE_215_SECONDARY_KWH: float = 0.0
DEMAND_LINE_219_WATER_KWH: float = 2291.6641
# §12a Block 2 — CO2 emissions
DEMAND_LINE_261_MAIN_1_CO2: float = 2580.4803
DEMAND_LINE_262_MAIN_2_CO2: float = 0.0
DEMAND_LINE_263_SECONDARY_CO2: float = 0.0
DEMAND_LINE_264_WATER_CO2: float = 481.2495
DEMAND_LINE_264A_ELECTRIC_SHOWER_CO2: float = 0.0
DEMAND_LINE_265_SPACE_AND_WATER_CO2: float = 3061.7298
DEMAND_LINE_267_PUMPS_FANS_CO2: float = 22.1940
DEMAND_LINE_268_LIGHTING_CO2: float = 20.1984
DEMAND_LINE_272_TOTAL_CO2: float = 3104.1222
# §13a Block 2 — Primary Energy (kWh/yr)
DEMAND_LINE_275_MAIN_1_PE: float = 13885.4416
DEMAND_LINE_278_WATER_PE: float = 2589.5805
DEMAND_LINE_279_SPACE_WATER_TOTAL_PE: float = 16475.0220
DEMAND_LINE_281_PUMPS_FANS_PE: float = 242.0480
DEMAND_LINE_282_LIGHTING_PE: float = 214.6527
DEMAND_LINE_286_TOTAL_PE: float = 16931.7227

View file

@ -103,6 +103,7 @@ def build_epc() -> EpcPropertyData:
return make_minimal_sap10_epc(
total_floor_area_m2=77.58,
country_code="ENG",
postcode="bd3 9DR",
sap_building_parts=[main],
habitable_rooms_count=4,
heated_rooms_count=4,
@ -481,3 +482,25 @@ LINE_272_TOTAL_CO2: float = 2807.8621
LINE_273_CO2_PER_M2: float = 36.1900
EI_VALUE_CONTINUOUS: float = 69.3055
LINE_274_EI_RATING_INTEGER: int = 69
# ============================================================================
# DEMAND CASCADE (Block 2 — postcode climate)
# ============================================================================
DEMAND_LINE_211_MAIN_1_KWH: float = 10592.0474
DEMAND_LINE_215_SECONDARY_KWH: float = 1042.7282
DEMAND_LINE_219_WATER_KWH: float = 2115.0334
DEMAND_LINE_261_MAIN_1_CO2: float = 2224.3300
DEMAND_LINE_262_MAIN_2_CO2: float = 0.0
DEMAND_LINE_263_SECONDARY_CO2: float = 159.9935
DEMAND_LINE_264_WATER_CO2: float = 444.1570
DEMAND_LINE_264A_ELECTRIC_SHOWER_CO2: float = 0.0
DEMAND_LINE_265_SPACE_AND_WATER_CO2: float = 2828.4804
DEMAND_LINE_267_PUMPS_FANS_CO2: float = 22.1940
DEMAND_LINE_268_LIGHTING_CO2: float = 29.1080
DEMAND_LINE_272_TOTAL_CO2: float = 2879.7824
DEMAND_LINE_275_MAIN_1_PE: float = 11969.0136
DEMAND_LINE_278_WATER_PE: float = 2389.9878
DEMAND_LINE_279_SPACE_WATER_TOTAL_PE: float = 15994.0699
DEMAND_LINE_281_PUMPS_FANS_PE: float = 242.0480
DEMAND_LINE_282_LIGHTING_PE: float = 309.3364
DEMAND_LINE_286_TOTAL_PE: float = 16545.4543

View file

@ -142,6 +142,7 @@ def build_epc() -> EpcPropertyData:
return make_minimal_sap10_epc(
total_floor_area_m2=84.41,
country_code="ENG",
postcode="bd5 8dn",
sap_building_parts=[main, extension],
habitable_rooms_count=4,
heated_rooms_count=4,
@ -523,3 +524,25 @@ LINE_272_TOTAL_CO2: float = 3393.8852
LINE_273_CO2_PER_M2: float = 40.2100
EI_VALUE_CONTINUOUS: float = 64.8574
LINE_274_EI_RATING_INTEGER: int = 65
# ============================================================================
# DEMAND CASCADE (Block 2 — postcode climate)
# ============================================================================
DEMAND_LINE_211_MAIN_1_KWH: float = 12959.9928
DEMAND_LINE_215_SECONDARY_KWH: float = 1277.2793
DEMAND_LINE_219_WATER_KWH: float = 2423.5096
DEMAND_LINE_261_MAIN_1_CO2: float = 2721.5985
DEMAND_LINE_262_MAIN_2_CO2: float = 0.0
DEMAND_LINE_263_SECONDARY_CO2: float = 195.7478
DEMAND_LINE_264_WATER_CO2: float = 508.9370
DEMAND_LINE_264A_ELECTRIC_SHOWER_CO2: float = 0.0
DEMAND_LINE_265_SPACE_AND_WATER_CO2: float = 3426.2833
DEMAND_LINE_267_PUMPS_FANS_CO2: float = 22.1940
DEMAND_LINE_268_LIGHTING_CO2: float = 30.6780
DEMAND_LINE_272_TOTAL_CO2: float = 3479.1552
DEMAND_LINE_275_MAIN_1_PE: float = 14644.7919
DEMAND_LINE_278_WATER_PE: float = 2738.5658
DEMAND_LINE_279_SPACE_WATER_TOTAL_PE: float = 19385.3498
DEMAND_LINE_281_PUMPS_FANS_PE: float = 242.0480
DEMAND_LINE_282_LIGHTING_PE: float = 326.0211
DEMAND_LINE_286_TOTAL_PE: float = 19953.4189

View file

@ -145,6 +145,7 @@ def build_epc() -> EpcPropertyData:
return make_minimal_sap10_epc(
total_floor_area_m2=81.57,
country_code="ENG",
postcode="bd3 9JZ",
sap_building_parts=[main, extension],
habitable_rooms_count=3,
heated_rooms_count=3,
@ -548,3 +549,25 @@ LINE_272_TOTAL_CO2: float = 2931.4900
LINE_273_CO2_PER_M2: float = 35.9400
EI_VALUE_CONTINUOUS: float = 68.9642
LINE_274_EI_RATING_INTEGER: int = 69
# ============================================================================
# DEMAND CASCADE (Block 2 — postcode climate; has electric shower → 264a)
# ============================================================================
DEMAND_LINE_211_MAIN_1_KWH: float = 11346.9382
DEMAND_LINE_215_SECONDARY_KWH: float = 1115.7823
DEMAND_LINE_219_WATER_KWH: float = 1489.0503
DEMAND_LINE_261_MAIN_1_CO2: float = 2382.8570
DEMAND_LINE_262_MAIN_2_CO2: float = 0.0
DEMAND_LINE_263_SECONDARY_CO2: float = 171.0072
DEMAND_LINE_264_WATER_CO2: float = 312.7006
DEMAND_LINE_264A_ELECTRIC_SHOWER_CO2: float = 83.6458
DEMAND_LINE_265_SPACE_AND_WATER_CO2: float = 2866.5648
DEMAND_LINE_267_PUMPS_FANS_CO2: float = 22.1940
DEMAND_LINE_268_LIGHTING_CO2: float = 32.8621
DEMAND_LINE_272_TOTAL_CO2: float = 3005.2667
DEMAND_LINE_275_MAIN_1_PE: float = 12822.0401
DEMAND_LINE_278_WATER_PE: float = 1682.6269
DEMAND_LINE_279_SPACE_WATER_TOTAL_PE: float = 16253.5581
DEMAND_LINE_281_PUMPS_FANS_PE: float = 242.0480
DEMAND_LINE_282_LIGHTING_PE: float = 349.2325
DEMAND_LINE_286_TOTAL_PE: float = 17755.3174

View file

@ -116,6 +116,7 @@ def build_epc() -> EpcPropertyData:
return make_minimal_sap10_epc(
total_floor_area_m2=66.06,
country_code="ENG",
postcode="bd19 3TF",
sap_building_parts=[main, extension],
habitable_rooms_count=4,
heated_rooms_count=4,
@ -497,3 +498,25 @@ LINE_272_TOTAL_CO2: float = 3213.5359
LINE_273_CO2_PER_M2: float = 48.6500
EI_VALUE_CONTINUOUS: float = 61.1646
LINE_274_EI_RATING_INTEGER: int = 61
# ============================================================================
# DEMAND CASCADE (Block 2 — postcode climate)
# ============================================================================
DEMAND_LINE_211_MAIN_1_KWH: float = 11575.0840
DEMAND_LINE_215_SECONDARY_KWH: float = 1134.3582
DEMAND_LINE_219_WATER_KWH: float = 2850.1167
DEMAND_LINE_261_MAIN_1_CO2: float = 2430.7676
DEMAND_LINE_262_MAIN_2_CO2: float = 0.0
DEMAND_LINE_263_SECONDARY_CO2: float = 173.9427
DEMAND_LINE_264_WATER_CO2: float = 598.5245
DEMAND_LINE_264A_ELECTRIC_SHOWER_CO2: float = 0.0
DEMAND_LINE_265_SPACE_AND_WATER_CO2: float = 3203.2349
DEMAND_LINE_267_PUMPS_FANS_CO2: float = 22.1940
DEMAND_LINE_268_LIGHTING_CO2: float = 24.7414
DEMAND_LINE_272_TOTAL_CO2: float = 3250.1703
DEMAND_LINE_275_MAIN_1_PE: float = 13079.8449
DEMAND_LINE_278_WATER_PE: float = 3220.6318
DEMAND_LINE_279_SPACE_WATER_TOTAL_PE: float = 18078.8160
DEMAND_LINE_281_PUMPS_FANS_PE: float = 242.0480
DEMAND_LINE_282_LIGHTING_PE: float = 262.9323
DEMAND_LINE_286_TOTAL_PE: float = 18583.7962

View file

@ -122,6 +122,7 @@ def build_epc() -> EpcPropertyData:
return make_minimal_sap10_epc(
total_floor_area_m2=90.54,
country_code="ENG",
postcode="BD4 7JR",
sap_building_parts=[main],
habitable_rooms_count=3,
heated_rooms_count=3,
@ -523,3 +524,25 @@ LINE_272_TOTAL_CO2: float = 3416.8449
LINE_273_CO2_PER_M2: float = 37.7400
EI_VALUE_CONTINUOUS: float = 66.2198
LINE_274_EI_RATING_INTEGER: int = 66
# ============================================================================
# DEMAND CASCADE (Block 2 — postcode climate)
# ============================================================================
DEMAND_LINE_211_MAIN_1_KWH: float = 12984.0189
DEMAND_LINE_215_SECONDARY_KWH: float = 1278.2045
DEMAND_LINE_219_WATER_KWH: float = 2492.1280
DEMAND_LINE_261_MAIN_1_CO2: float = 2726.6440
DEMAND_LINE_262_MAIN_2_CO2: float = 0.0
DEMAND_LINE_263_SECONDARY_CO2: float = 195.9288
DEMAND_LINE_264_WATER_CO2: float = 523.3469
DEMAND_LINE_264A_ELECTRIC_SHOWER_CO2: float = 0.0
DEMAND_LINE_265_SPACE_AND_WATER_CO2: float = 3445.9197
DEMAND_LINE_267_PUMPS_FANS_CO2: float = 22.1940
DEMAND_LINE_268_LIGHTING_CO2: float = 33.3239
DEMAND_LINE_272_TOTAL_CO2: float = 3501.4376
DEMAND_LINE_275_MAIN_1_PE: float = 14671.9414
DEMAND_LINE_278_WATER_PE: float = 2816.1047
DEMAND_LINE_279_SPACE_WATER_TOTAL_PE: float = 19491.6356
DEMAND_LINE_281_PUMPS_FANS_PE: float = 242.0480
DEMAND_LINE_282_LIGHTING_PE: float = 354.1396
DEMAND_LINE_286_TOTAL_PE: float = 20087.8232

View file

@ -24,7 +24,9 @@ from domain.sap.rdsap.cert_to_inputs import (
fuel_cost_section_from_cert,
heat_transmission_section_from_cert,
internal_gains_section_from_cert,
local_climate_for_cert,
mean_internal_temperature_section_from_cert,
primary_energy_section_from_cert,
sap_rating_section_from_cert,
solar_gains_section_from_cert,
space_cooling_section_from_cert,
@ -944,3 +946,90 @@ def test_section_12_line_refs_match_pdf(
# Assert
_pin(actual, expected, f"§12 {fixture_attr} {fixture_name}")
# ============================================================================
# DEMAND CASCADE — Block 2 of U985 (postcode climate via PCDB Table 172)
# §12 CO2 emissions + §13a Primary Energy = EPC consumer-facing values
# ============================================================================
_DEMAND_SECTION_12_PINS: Final[tuple[tuple[str, str], ...]] = (
("DEMAND_LINE_261_MAIN_1_CO2", "main_1_co2_kg_per_yr"),
("DEMAND_LINE_262_MAIN_2_CO2", "main_2_co2_kg_per_yr"),
("DEMAND_LINE_263_SECONDARY_CO2", "secondary_co2_kg_per_yr"),
("DEMAND_LINE_264_WATER_CO2", "water_heating_co2_kg_per_yr"),
("DEMAND_LINE_264A_ELECTRIC_SHOWER_CO2", "electric_shower_co2_kg_per_yr"),
("DEMAND_LINE_265_SPACE_AND_WATER_CO2", "space_and_water_co2_kg_per_yr"),
("DEMAND_LINE_267_PUMPS_FANS_CO2", "pumps_fans_co2_kg_per_yr"),
("DEMAND_LINE_268_LIGHTING_CO2", "lighting_co2_kg_per_yr"),
("DEMAND_LINE_272_TOTAL_CO2", "total_co2_kg_per_yr"),
)
_DEMAND_SECTION_13A_PINS: Final[tuple[tuple[str, str], ...]] = (
("DEMAND_LINE_275_MAIN_1_PE", "main_1_pe_kwh_per_yr"),
("DEMAND_LINE_278_WATER_PE", "water_heating_pe_kwh_per_yr"),
("DEMAND_LINE_279_SPACE_WATER_TOTAL_PE", "space_and_water_pe_kwh_per_yr"),
("DEMAND_LINE_281_PUMPS_FANS_PE", "pumps_fans_pe_kwh_per_yr"),
("DEMAND_LINE_282_LIGHTING_PE", "lighting_pe_kwh_per_yr"),
("DEMAND_LINE_286_TOTAL_PE", "total_pe_kwh_per_yr"),
)
@pytest.mark.parametrize(
"fixture_name,fixture_attr,result_attr",
[
(fix, line, attr)
for fix in _FIXTURES
for line, attr in _DEMAND_SECTION_12_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_demand_section_12_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""Demand-cascade §12 pins — every (261)..(272) line ref of the
postcode-climate environmental section matches U985 Block 2 to
abs=1e-4. This is the EPC's published Current Carbon source."""
# Arrange
mod = _FIXTURES[fixture_name]
epc = mod.build_epc() # type: ignore[attr-defined]
expected = getattr(mod, fixture_attr)
pc = local_climate_for_cert(epc)
# Act
env = environmental_section_from_cert(epc, postcode_climate=pc)
assert env is not None, f"{fixture_name}: env_from_cert returned None"
actual = getattr(env, result_attr)
# Assert
_pin(actual, expected, f"demand-§12 {fixture_attr} {fixture_name}")
@pytest.mark.parametrize(
"fixture_name,fixture_attr,result_attr",
[
(fix, line, attr)
for fix in _FIXTURES
for line, attr in _DEMAND_SECTION_13A_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_demand_section_13a_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""Demand-cascade §13a pins — every (275)..(286) line ref of the
postcode-climate primary-energy section matches U985 Block 2 to
abs=1e-4. This is the EPC's published Current Primary Energy source."""
# Arrange
mod = _FIXTURES[fixture_name]
epc = mod.build_epc() # type: ignore[attr-defined]
expected = getattr(mod, fixture_attr)
pc = local_climate_for_cert(epc)
# Act
pe = primary_energy_section_from_cert(epc, postcode_climate=pc)
assert pe is not None, f"{fixture_name}: pe_from_cert returned None"
actual = getattr(pe, result_attr)
# Assert
_pin(actual, expected, f"demand-§13a {fixture_attr} {fixture_name}")