P5.6: SapResult.intermediate exposes per-end-use fuel costs

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.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-19 10:24:27 +00:00
parent 44b1d0d923
commit 2104c8c2da
2 changed files with 50 additions and 5 deletions

View file

@ -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(

View file

@ -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.