diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 0cf9fcf8..bd874ea3 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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), diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index d8d04759..2df7eb11 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -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. diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index 63f1d0e8..b5078fbe 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -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."),