Cohort residual slice 3: Table 4f gas-combi pumps_fans = 160 kWh/yr

Replaces the static `_DEFAULT_PUMPS_FANS_KWH_PER_YR = 130` for
gas-combi main heating systems with the SAP10.2 Table 4f cascade
value: 115 kWh/yr (230c central heating pump, post-2013 install) +
45 kWh/yr (230e main heating flue fan, balanced/condensing) = 160.
Selection keyed by `main.main_heating_category` — currently only
category 2 (Gas-fired boilers); other categories fall back to the
legacy 130 sentinel pending the next fixture exercising them.

Adds `_PUMPS_FANS_KWH_BY_MAIN_CATEGORY` lookup. Both `CalculatorInputs.
pumps_fans_kwh_per_yr` and the `_fuel_cost(...)` pumps_fans arg now
share the same per-cert value.

E2E pins: new parametrized test
`test_elmhurst_end_to_end_pumps_fans_kwh_matches_u985_worksheet`
asserts `result.pumps_fans_kwh_per_yr == 160` at abs=1e-3 for the
2 e2e fixtures (000474, 000490).

Impact on 000490: cost £803.62 → £807.58 (PDF £807.54, Δ +£0.04 ≈ 0%);
continuous SAP 57.77 → 57.57 (PDF 57.40, Δ +0.17 — was +0.38).
SAP integer still 58 vs PDF 57 — remaining residual is the SAP
rating constants (rating.py uses SAP 10.3 deflator 0.36 / slope
16.21/120.5; PDF lodges SAP 10.2 0.42 / 13.95/121) — next slice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 11:21:14 +00:00
parent af6fcfb190
commit b536b46ab4
2 changed files with 46 additions and 2 deletions

View file

@ -118,6 +118,15 @@ _LIVING_AREA_FRACTION_MIN: Final[float] = 0.13
_PENCE_TO_GBP: Final[float] = 0.01
_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0
_DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0
# SAP10.2 Table 4f cascade — annual pumps + fans electricity by main
# heating system category. The Elmhurst gas-combi cohort lodges 115
# (230c central heating pump, post-2013 install) + 45 (230e main
# heating flue fan, balanced/condensing) = 160 kWh/yr. Heat pumps,
# warm-air, oil/biomass, electric storage etc. use different rows
# (Table 4f spec lines 7905-8076) — deferred until a fixture exercises.
_PUMPS_FANS_KWH_BY_MAIN_CATEGORY: Final[dict[int, float]] = {
2: 160.0, # Gas-fired boilers (115 pump + 45 flue fan)
}
# SAP10.2 Table 6d note 1: "average or unknown" overshading is the
# default for existing dwellings. RdSAP doesn't lodge a per-dwelling
# overshading code so §5 always uses AVERAGE → Z_L = 0.83.
@ -1033,6 +1042,10 @@ def cert_to_inputs(
main_code = main.sap_main_heating_code if main is not None else None
main_category = main.main_heating_category if main is not None else None
main_fuel = _main_fuel_code(main)
pumps_fans_kwh = _PUMPS_FANS_KWH_BY_MAIN_CATEGORY.get(
main_category if main_category is not None else -1,
_DEFAULT_PUMPS_FANS_KWH_PER_YR,
)
primary_age = (
epc.sap_building_parts[0].construction_age_band if epc.sap_building_parts else None
)
@ -1287,7 +1300,7 @@ def cert_to_inputs(
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
main_heating_efficiency=eff,
hot_water_kwh_per_yr=hw_kwh,
pumps_fans_kwh_per_yr=_DEFAULT_PUMPS_FANS_KWH_PER_YR,
pumps_fans_kwh_per_yr=pumps_fans_kwh,
lighting_kwh_per_yr=lighting_kwh,
space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_kwh(
main, epc.sap_energy_source.meter_type, prices
@ -1320,7 +1333,7 @@ def cert_to_inputs(
main=main,
energy_requirements_result=energy_requirements_result,
hot_water_kwh=hw_kwh,
pumps_fans_kwh=_DEFAULT_PUMPS_FANS_KWH_PER_YR,
pumps_fans_kwh=pumps_fans_kwh,
lighting_kwh=lighting_kwh,
cooling_kwh=energy_requirements_result.cooling_fuel_kwh_per_yr,
),

View file

@ -217,6 +217,37 @@ def test_elmhurst_end_to_end_lighting_kwh_per_yr_matches_u985_worksheet(
assert result.lighting_kwh_per_yr == pytest.approx(expected_kwh, abs=1e-4)
@pytest.mark.parametrize(
"fixture, expected_kwh",
[
(_w000474, 160.0),
(_w000490, 160.0),
],
ids=["000474", "000490"],
)
def test_elmhurst_end_to_end_pumps_fans_kwh_matches_u985_worksheet(
fixture: object, expected_kwh: float
) -> None:
"""Component-level pin on `SapResult.pumps_fans_kwh_per_yr` for the
e2e fixtures. PDF (231) for both 000474 + 000490: 115 (central
heating pump (230c)) + 45 (main heating flue fan (230e)) = 160.
Pre-fix `cert_to_inputs` hardcoded 130 kWh/yr via
`_DEFAULT_PUMPS_FANS_KWH_PER_YR`. The shortfall (-30 kWh × elec
price = -£4) was the dominant remaining residual on 000490 after
Appendix L + secondary heating + ventilation closures pushed
continuous SAP +0.38 over PDF integer 58 vs 57.
"""
# Arrange
epc = fixture.build_epc() # type: ignore[attr-defined]
# Act
result = Sap10Calculator().calculate(epc)
# Assert
assert result.pumps_fans_kwh_per_yr == pytest.approx(expected_kwh, abs=1e-3)
def test_elmhurst_000490_end_to_end_secondary_heating_fuel_kwh_matches_u985_worksheet() -> None:
"""Component-level e2e pin on `SapResult.secondary_heating_fuel_kwh_per_yr`
for 000490 cert lodges secondary heating system "Electricity Electric