mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
a2a4396e25
commit
fc68fb21f6
3 changed files with 173 additions and 22 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue