From 2104c8c2da8412d5739aa7d23e0d157fcfd59489 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 19 May 2026 10:24:27 +0000 Subject: [PATCH] P5.6: SapResult.intermediate exposes per-end-use fuel costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-end-use £/yr costs (main heating, secondary heating, hot water, pumps_fans, lighting) lifted from the inlined total_cost sum into named locals and populated on `intermediate`. §12 sweep slices can now diff each line against the spec (Table 12 unit prices, future Table 12a fractional blending, Table 12c heat-network DLF) without re-deriving the cost decomposition. Behaviour-preserving — `total_fuel_cost_gbp` reconciles bit-for-bit. 136 SAP tests pass. --- packages/domain/src/domain/sap/calculator.py | 22 ++++++++++--- .../src/domain/sap/tests/test_calculator.py | 33 +++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index 97ea331e..0c156691 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -302,13 +302,20 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: + inputs.lighting_kwh_per_yr ) pv_credit = inputs.pv_generation_kwh_per_yr * inputs.pv_export_credit_gbp_per_kwh + main_heating_cost = main_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh + secondary_heating_cost = ( + secondary_fuel_kwh * inputs.secondary_heating_fuel_cost_gbp_per_kwh + ) + hot_water_cost = inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh + pumps_fans_cost = inputs.pumps_fans_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh + lighting_cost = inputs.lighting_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh total_cost = max( 0.0, - main_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh - + secondary_fuel_kwh * inputs.secondary_heating_fuel_cost_gbp_per_kwh - + inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh - + (inputs.pumps_fans_kwh_per_yr + inputs.lighting_kwh_per_yr) - * inputs.other_fuel_cost_gbp_per_kwh + main_heating_cost + + secondary_heating_cost + + hot_water_cost + + pumps_fans_cost + + lighting_cost - pv_credit, ) ecf = energy_cost_factor(total_cost_gbp=total_cost, total_floor_area_m2=tfa) @@ -352,6 +359,11 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: "internal_gains_annual_avg_w": sum(e.internal_gains_w for e in monthly) / 12.0, "mean_internal_temp_annual_avg_c": sum(e.internal_temp_c for e in monthly) / 12.0, "useful_space_heating_kwh_per_yr": space_heating_kwh, + "main_heating_cost_gbp": main_heating_cost, + "secondary_heating_cost_gbp": secondary_heating_cost, + "hot_water_cost_gbp": hot_water_cost, + "pumps_fans_cost_gbp": pumps_fans_cost, + "lighting_cost_gbp": lighting_cost, } return SapResult( diff --git a/packages/domain/src/domain/sap/tests/test_calculator.py b/packages/domain/src/domain/sap/tests/test_calculator.py index 98e2b3fd..609b30cb 100644 --- a/packages/domain/src/domain/sap/tests/test_calculator.py +++ b/packages/domain/src/domain/sap/tests/test_calculator.py @@ -229,6 +229,39 @@ def test_calculate_exposes_useful_space_heating_kwh() -> None: ) +def test_calculate_exposes_per_end_use_fuel_costs() -> None: + # Arrange — P5 trace mode: per-end-use fuel costs (§12 / Table 12) break + # out on `intermediate` so the §12 sweep can diff main vs hot water vs + # pumps/fans vs lighting individually rather than against the bundled + # `total_fuel_cost_gbp`. Secondary heating cost is also surfaced even + # though §11 omitted it — the field exists on the calculator and is a + # named worksheet variable. + inputs = _baseline_inputs() + + # Act + result = calculate_sap_from_inputs(inputs) + + # Assert + main_cost = ( + result.main_heating_fuel_kwh_per_yr * inputs.space_heating_fuel_cost_gbp_per_kwh + ) + secondary_cost = ( + result.secondary_heating_fuel_kwh_per_yr + * inputs.secondary_heating_fuel_cost_gbp_per_kwh + ) + hot_water_cost = inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh + pumps_cost = inputs.pumps_fans_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh + lighting_cost = inputs.lighting_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh + + assert result.intermediate["main_heating_cost_gbp"] == pytest.approx(main_cost, rel=1e-9) + assert result.intermediate["secondary_heating_cost_gbp"] == pytest.approx( + secondary_cost, rel=1e-9 + ) + assert result.intermediate["hot_water_cost_gbp"] == pytest.approx(hot_water_cost, rel=1e-9) + assert result.intermediate["pumps_fans_cost_gbp"] == pytest.approx(pumps_cost, rel=1e-9) + assert result.intermediate["lighting_cost_gbp"] == pytest.approx(lighting_cost, rel=1e-9) + + def test_higher_main_heating_efficiency_reduces_fuel_use() -> None: # Arrange — Direction check: doubling the boiler efficiency must halve # the main-heating fuel kWh, holding everything else constant.