Slice S0380.70: Secondary heating CO2/PE via lodged fuel type (cohort cert 2102 closure)

Cohort-2 cert 2102 (House coal secondary) and cohort-1 cert 0300-2747
(mains-gas secondary) both exposed the same bug: cert_to_inputs
hardcoded `_STANDARD_ELECTRICITY_FUEL_CODE` for the secondary CO2 and
PE factors, ignoring the cert's lodged `secondary_fuel_type`. The
cost-side helper `_secondary_fuel_cost_gbp_per_kwh` already routes
through the lodged code; this slice mirrors it on the CO2 and PE side.

Per SAP 10.2 Table 12d (p.195) and Table 12e (p.196) header text:
  "Where electricity is the fuel used, the relevant set of factors in
  the table below should be used to calculate the monthly [CO2
  emissions / primary energy] instead the annual average factor given
  in Table 12."

→ electricity end-uses use the monthly Table 12d/12e cascade;
non-electric fuels (House coal, mains gas, wood logs, etc.) pass
through the annual Table 12 factor.

Per Appendix M Table 4a + the API mapper's `_api_secondary_fuel_type`
spec-fuel override (S0380.43), cert 2102's lodged API code 33
(electricity off-peak) is rewritten to Table 32 code 11 (House coal)
because `secondary_heating_type=631` "Open fire in grate" is
physically incompatible with an electric secondary fuel. The new
`_secondary_fuel_code` helper preserves Table 12 codes (House coal 11
stays 11) and translates raw gov-API codes via API_FUEL_TO_TABLE_12
(e.g. lodged API 29 → Table 12 30 "standard electricity") so the
Table 12d/12e monthly lookups resolve consistently across both mapper
output regimes.

Cert 2102 DEMAND-path residuals (vs lodged):
  PE  +20.36 → +0.20 kWh/m²   (lodged 228 integer-rounded)
  CO2 -0.79  → +0.005 t/yr    (lodged 4.1 integer-rounded)

Cert 0300-2747 DEMAND-path residuals (mains-gas secondary, fuel 26):
  PE  +8.28  → +0.93  kWh/m²
  CO2 -0.25  → +0.25  t/yr

Other 23 golden certs all use the electricity default and stay pin-
exact via the API→Table 12 translation in `_secondary_fuel_code`.

New helpers in cert_to_inputs.py:
- `_secondary_fuel_code(epc)` — resolves the cert's secondary fuel
  code through the dual API/Table-12 fallback that
  `co2_factor_kg_per_kwh` already uses.
- `_secondary_heating_co2_factor_kg_per_kwh(epc, secondary_monthly_kwh)`
  — Table 12d monthly for electric, Table 12 annual for non-electric.
- `_secondary_heating_primary_factor(epc, secondary_monthly_kwh)`
  — Table 12e monthly for electric, Table 12 annual for non-electric.

Four call sites replaced:
- `cert_to_inputs` `secondary_heating_co2_factor_kg_per_kwh` field
  (line ~3552)
- `cert_to_inputs` `secondary_heating_primary_factor` field (line
  ~3625)
- `environmental_section_from_cert` secondary CO2 §12 (line ~1863)
- `primary_energy_section_from_cert` secondary PE §13a (line ~1967)

Tests:
- `test_house_coal_secondary_routes_to_annual_table_12_co2_and_pe_factors`
  pins 0.395 / 1.064 (Table 12 code 11).
- `test_secondary_heating_with_lodged_type_but_no_fuel_defaults_to_electricity`
  pins monthly-weighted electricity factors > annual 0.136 / 1.501
  (§A.2.2 default still applies).
- `test_golden_fixtures.py`: cert 2102 + 0300-2747 pins updated to
  the new residuals; 57 other golden certs untouched.

Baseline: 542 pass + 9 expected `test_sap_result_pin[000565-*]`
cascade-gap fails. Pyright net-zero on every touched file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-29 11:09:14 +00:00
parent a2a4396e25
commit fc68fb21f6
3 changed files with 173 additions and 22 deletions

View file

@ -84,6 +84,7 @@ from domain.sap10_calculator.tables.pcdb.postcode_weather import (
)
from domain.sap10_calculator.tables.table_12 import (
API_FUEL_TO_TABLE_12,
CO2_KG_PER_KWH,
co2_monthly_factors_kg_per_kwh,
co2_factor_kg_per_kwh,
pe_monthly_factors_kwh_per_kwh,
@ -1465,6 +1466,77 @@ def _main_heating_co2_factor_kg_per_kwh(
return high_frac * high_factor + (1.0 - high_frac) * low_factor
def _secondary_fuel_code(epc: EpcPropertyData) -> int:
"""SAP 10.2 secondary fuel code, resolved through the API mapper's
Appendix M Table 4a spec-fuel routing. When no `secondary_fuel_type`
is lodged (a secondary still required per Table 11 / §A.2.2), the
cascade falls back to standard electricity (Table 12 code 30)
the assumed portable-electric default that
`_secondary_fuel_cost_gbp_per_kwh` already mirrors on the cost side.
`sap_heating.secondary_fuel_type` is heterogeneous: it carries
either a gov API enum code (when the mapper passes through the
lodged value unchanged) or a Table 32/12 code (when the mapper's
`_api_secondary_fuel_type` override resolves Appendix M Table 4a
spec-fuel e.g. cert 2102 lodges API code 33 and the mapper
rewrites to Table 32 code 11 = House coal). Mirror the dual
accept-either-API-or-Table-12 logic from `co2_factor_kg_per_kwh`:
keep Table 12 codes as-is (so House coal 11 stays 11) and
translate raw API codes via `API_FUEL_TO_TABLE_12` so the Table
12d/12e monthly lookups resolve consistently (e.g. lodged API 29
Table 12 30 monthly electricity factors apply)."""
code = _int_or_none(epc.sap_heating.secondary_fuel_type)
if code is None:
return _STANDARD_ELECTRICITY_FUEL_CODE
if code in CO2_KG_PER_KWH:
return code
return API_FUEL_TO_TABLE_12.get(code, code)
def _secondary_heating_co2_factor_kg_per_kwh(
epc: EpcPropertyData,
secondary_fuel_monthly_kwh: tuple[float, ...],
) -> Optional[float]:
"""SAP 10.2 Table 12 / Table 12d (p.195) per-end-use CO2 factor for
the cert's lodged secondary fuel.
Per Table 12d header: "Where electricity is the fuel used, the
relevant set of factors in the table below should be used to
calculate the monthly CO2 emissions instead the annual average
factor given in Table 12." → electricity end-uses Σ(kWh_m × CO2_m);
non-electric fuels (House coal, wood logs, mineral oil, etc.) pass
through the annual Table 12 factor.
Cohort-2 cert 2102 lodges `secondary_fuel_type=11` (House coal,
after Appendix M Table 4a spec-fuel resolution from the lodged
physically-incompatible electricity code) 0.395 annual factor,
not the 0.136 electricity flat that the pre-S0380.70 hardcoded
`_STANDARD_ELECTRICITY_FUEL_CODE` path produced."""
code = _secondary_fuel_code(epc)
monthly = _effective_monthly_co2_factor(secondary_fuel_monthly_kwh, code)
if monthly is not None:
return monthly
return co2_factor_kg_per_kwh(code)
def _secondary_heating_primary_factor(
epc: EpcPropertyData,
secondary_fuel_monthly_kwh: tuple[float, ...],
) -> float:
"""SAP 10.2 Table 12 / Table 12e (p.196) per-end-use PE factor for
the cert's lodged secondary fuel. Mirror of
`_secondary_heating_co2_factor_kg_per_kwh` on the PE side per the
Table 12e header's identical "Where electricity is the fuel used …
instead the annual average factor given in Table 12" rubric. House
coal (Table 12 code 11) 1.064 annual factor, not the 1.501
electricity flat that the pre-S0380.70 hardcoded path produced."""
code = _secondary_fuel_code(epc)
monthly = _effective_monthly_pe_factor(secondary_fuel_monthly_kwh, code)
if monthly is not None:
return monthly
return primary_energy_factor(code)
def _int_or_none(value: object) -> Optional[int]:
return value if isinstance(value, int) else None
@ -1860,8 +1932,8 @@ def environmental_section_from_cert(
# cascade Σ(kWh_m × CO2_m); for gas end-uses, annual_kwh × annual factor.
main_1_co2 = er.main_1_fuel_kwh_per_yr * main_factor
main_2_co2 = er.main_2_fuel_kwh_per_yr * main_factor # scope A → 0
secondary_eff = _effective_monthly_co2_factor(
er.secondary_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
secondary_eff = _secondary_heating_co2_factor_kg_per_kwh(
epc, er.secondary_fuel_monthly_kwh,
)
secondary_co2 = er.secondary_fuel_kwh_per_yr * (
secondary_eff if secondary_eff is not None else 0.0
@ -1964,12 +2036,10 @@ def primary_energy_section_from_cert(
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
secondary_pe_factor = _secondary_heating_primary_factor(
epc, er.secondary_fuel_monthly_kwh,
)
secondary = er.secondary_fuel_kwh_per_yr * secondary_pe_factor
water = full_inputs.hot_water_kwh_per_yr * water_pe
electric_shower = (
full_inputs.electric_shower_kwh_per_yr
@ -3549,9 +3619,8 @@ def cert_to_inputs(
main, _rdsap_tariff(epc),
energy_requirements_result.main_1_fuel_monthly_kwh,
),
secondary_heating_co2_factor_kg_per_kwh=_effective_monthly_co2_factor(
energy_requirements_result.secondary_fuel_monthly_kwh,
_STANDARD_ELECTRICITY_FUEL_CODE,
secondary_heating_co2_factor_kg_per_kwh=_secondary_heating_co2_factor_kg_per_kwh(
epc, energy_requirements_result.secondary_fuel_monthly_kwh,
),
hot_water_co2_factor_kg_per_kwh=co2_factor_kg_per_kwh(
_water_heating_fuel_code(epc)
@ -3622,9 +3691,8 @@ def cert_to_inputs(
# monthly factors weighted by per-month kWh; gas end-uses pass
# through the annual Table 12 / Table 32 PE factor. Secondary
# defaults to standard electricity per RdSAP §A.2.2.
secondary_heating_primary_factor=_effective_monthly_pe_factor(
energy_requirements_result.secondary_fuel_monthly_kwh,
_STANDARD_ELECTRICITY_FUEL_CODE,
secondary_heating_primary_factor=_secondary_heating_primary_factor(
epc, energy_requirements_result.secondary_fuel_monthly_kwh,
),
pumps_fans_primary_factor=_effective_monthly_pe_factor(
_days_in_month_proportioned(pumps_fans_kwh, _DAYS_IN_MONTH),

View file

@ -942,6 +942,79 @@ def test_standard_meter_ashp_main_heating_co2_factor_falls_back_to_annual_table_
assert inputs.main_heating_co2_factor_kg_per_kwh == 0.136
def test_house_coal_secondary_routes_to_annual_table_12_co2_and_pe_factors() -> None:
# Arrange — cohort-2 cert 2102 lodges `secondary_heating_type=631`
# (Open fire in grate) with `secondary_fuel_type=33` (electricity
# off-peak). The mapper's spec-fuel routing (S0380.43) resolves the
# physically-incompatible electric lodgement to Table 32 code 11
# (House coal). The cascade's per-end-use CO2 / PE factors must
# follow that spec-fuel through to Table 12 (annual) factors —
# 0.395 / 1.064 — instead of falsely treating the secondary as
# electricity and applying Table 12d/12e monthly cascades. Per SAP
# 10.2 Table 12d header (p.195) and Table 12e header (p.196):
# "Where electricity is the fuel used, the relevant set of factors
# in the table below should be used to calculate the monthly
# [carbon emissions / primary energy] instead the annual average
# factor given in Table 12." → non-electric fuels use Table 12
# annual factors.
main = _gas_boiler_detail(sap_main_heating_code=102)
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()],
sap_heating=make_sap_heating(
main_heating_details=[main],
secondary_fuel_type=11, # House coal (Table 12 code 11)
secondary_heating_type=631, # Open fire in grate
),
)
# Act
inputs = cert_to_inputs(epc)
# Assert — Table 12 annual factors for House coal, not electricity.
co2_factor = inputs.secondary_heating_co2_factor_kg_per_kwh
pe_factor = inputs.secondary_heating_primary_factor
assert co2_factor is not None and abs(co2_factor - 0.395) <= 1e-4
assert pe_factor is not None and abs(pe_factor - 1.064) <= 1e-4
def test_secondary_heating_with_lodged_type_but_no_fuel_defaults_to_electricity() -> None:
# Arrange — RdSAP §A.2.2 default: when a secondary heating system
# is lodged (so `_secondary_fraction` is non-zero) but no
# `secondary_fuel_type` is lodged, the cascade defaults the fuel to
# standard electricity (Table 12 code 30) — the assumed portable
# electric heater per the §A.2.2 default that
# `_secondary_fuel_cost_gbp_per_kwh` already mirrors. The cascade
# must keep the monthly-weighted Table 12d/12e factors for this
# default so that pre-existing all-electric synthetic
# constructions don't regress.
main = _gas_boiler_detail(sap_main_heating_code=102)
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()],
sap_heating=make_sap_heating(
main_heating_details=[main],
secondary_heating_type=691, # Lodged but no fuel type
),
)
# Act
inputs = cert_to_inputs(epc)
# Assert — monthly-weighted electricity factors lie above the
# annual-flat 0.136 / 1.501 by the demand-profile weighting (winter
# months heavier per Table 12d/12e). The exact value depends on
# the secondary_fuel_monthly_kwh profile.
co2_factor = inputs.secondary_heating_co2_factor_kg_per_kwh
pe_factor = inputs.secondary_heating_primary_factor
assert co2_factor is not None and co2_factor > 0.136 - 1e-9
assert pe_factor is not None and pe_factor > 1.501 - 1e-9
def test_standard_meter_keeps_electric_costs_on_standard_rate() -> None:
# Arrange — same all-electric dwelling but meter_type=1 (Standard);
# space heating + HW should now bill at the standard rate, not E7.

View file

@ -97,8 +97,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0300-2747-7640-2526-2135",
actual_sap=78,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=+8.2769,
expected_co2_resid_tonnes_per_yr=-0.2480,
expected_pe_resid_kwh_per_m2=+0.9264,
expected_co2_resid_tonnes_per_yr=+0.2495,
notes=(
"Large semi-detached, TFA 526, age D, gas boiler PCDB-listed "
"(no Table 4b code). Cert lodges open_flues_count=1 + "
@ -114,7 +114,11 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"shower_outlets list normalisation + explicit electric/"
"mixer counts) surfaces this cert's 1 electric + 1 mixer "
"outlets vs the previous default 0+1: PE +7.52 → +8.44, "
"CO2 -0.27 → -0.23."
"CO2 -0.27 → -0.23. Slice S0380.70 routed the secondary "
"CO2 / PE factors through the cert's mains-gas "
"`secondary_fuel_type` (mirroring the cost-side Slice 58 "
"fix), closing PE +8.28 → +0.93 and CO2 0.25 → +0.25 — "
"second-biggest cohort PE closure to date."
),
),
_GoldenExpectation(
@ -379,12 +383,18 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_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."),
# Cert 2102: largest cohort-2 PE residual (+20.36 kWh/m²) — was
# invisible to any current test before S0380.69 added this pin per
# [[project-golden-coverage-state]]. Root cause likely sits in
# `secondary_heating_fuel_kwh_per_yr` cost cascade for House coal
# (S0380.43 closed SAP but not PE/CO2). Next-investigation target.
_GoldenExpectation(cert_number="2102-3018-0205-7886-5204", actual_sap=64, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+20.3639, expected_co2_resid_tonnes_per_yr=-0.7895, notes="Cohort-2 baseline pin captured by S0380.69. Largest residual in the cohort — open investigation target (House coal secondary PE cascade)."),
# Cert 2102: House coal secondary (`secondary_heating_type=631`
# "Open fire in grate" + Appendix M Table 4a spec-fuel override
# to Table 32 code 11). Pre-S0380.70 the cascade hardcoded
# `_STANDARD_ELECTRICITY_FUEL_CODE` for the secondary CO2 / PE
# factors, ignoring the lodged fuel — PE +20.36 / CO2 -0.79.
# S0380.70 routed the factors through `secondary_fuel_type` per
# SAP 10.2 Table 12d/12e headers (p.195/196: "Where electricity
# is the fuel used … instead the annual average factor given in
# Table 12") → House coal annual factors 0.395 / 1.064 apply
# instead of electricity 0.155 / 1.57. Residuals close to PE +0.20
# / 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=-2.7961, expected_co2_resid_tonnes_per_yr=-0.0981, 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=-3.4839, expected_co2_resid_tonnes_per_yr=-0.0582, notes="Cohort-2 baseline pin captured by S0380.69."),