P5.13: SapResult.intermediate exposes per-end-use CO2 breakdown

Closes the second §11-sketch gap noted in HANDOVER_SYSTEMATIC_REVIEW
("primary energy AND CO2 per end-use"). Lifts the single co2 = total
× factor expression into five named locals (main_heating, secondary,
hot_water, pumps_fans, lighting) and exposes them on `intermediate`.
The five components sum exactly to the top-level co2_kg_per_yr — no
PV deduction in the current implementation.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-19 12:24:59 +00:00
parent f09e83b6a1
commit 0fa39e859c
2 changed files with 57 additions and 2 deletions

View file

@ -328,7 +328,19 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
ecf = energy_cost_factor(total_cost_gbp=total_cost, total_floor_area_m2=tfa)
sap_int = sap_rating_integer(ecf=ecf)
sap_cont = sap_rating(ecf=ecf)
co2 = delivered_fuel_kwh * inputs.co2_factor_kg_per_kwh
co2_factor = inputs.co2_factor_kg_per_kwh
main_heating_co2 = main_fuel_kwh * co2_factor
secondary_heating_co2 = secondary_fuel_kwh * co2_factor
hot_water_co2 = inputs.hot_water_kwh_per_yr * co2_factor
pumps_fans_co2 = inputs.pumps_fans_kwh_per_yr * co2_factor
lighting_co2 = inputs.lighting_kwh_per_yr * co2_factor
co2 = (
main_heating_co2
+ secondary_heating_co2
+ hot_water_co2
+ pumps_fans_co2
+ lighting_co2
)
space_heating_primary_kwh = (
main_fuel_kwh + secondary_fuel_kwh
@ -379,7 +391,12 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
"ecf": ecf,
"deflator": ENERGY_COST_DEFLATOR,
"delivered_fuel_kwh_per_yr": delivered_fuel_kwh,
"co2_factor_kg_per_kwh": inputs.co2_factor_kg_per_kwh,
"co2_factor_kg_per_kwh": co2_factor,
"main_heating_co2_kg_per_yr": main_heating_co2,
"secondary_heating_co2_kg_per_yr": secondary_heating_co2,
"hot_water_co2_kg_per_yr": hot_water_co2,
"pumps_fans_co2_kg_per_yr": pumps_fans_co2,
"lighting_co2_kg_per_yr": lighting_co2,
"space_heating_pe_kwh_per_m2": space_heating_primary_kwh / tfa if tfa > 0 else 0.0,
"hot_water_pe_kwh_per_m2": hot_water_primary_kwh / tfa if tfa > 0 else 0.0,
"other_pe_kwh_per_m2": other_primary_kwh / tfa if tfa > 0 else 0.0,

View file

@ -352,6 +352,44 @@ def test_calculate_exposes_primary_energy_breakdown() -> None:
)
def test_calculate_exposes_per_end_use_co2() -> None:
# Arrange — P5 trace mode: §11 sketch lists "primary energy AND CO2
# per end-use". The calculator applies a single co2_factor_kg_per_kwh
# to total delivered fuel (no PV deduction on CO2 in the current
# implementation), so per-end-use CO2 is fuel_kwh × factor and the
# five components sum exactly to the top-level co2_kg_per_yr.
inputs = _baseline_inputs()
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
factor = inputs.co2_factor_kg_per_kwh
assert result.intermediate["main_heating_co2_kg_per_yr"] == pytest.approx(
result.main_heating_fuel_kwh_per_yr * factor, rel=1e-9
)
assert result.intermediate["secondary_heating_co2_kg_per_yr"] == pytest.approx(
result.secondary_heating_fuel_kwh_per_yr * factor, rel=1e-9
)
assert result.intermediate["hot_water_co2_kg_per_yr"] == pytest.approx(
result.hot_water_kwh_per_yr * factor, rel=1e-9
)
assert result.intermediate["pumps_fans_co2_kg_per_yr"] == pytest.approx(
result.pumps_fans_kwh_per_yr * factor, rel=1e-9
)
assert result.intermediate["lighting_co2_kg_per_yr"] == pytest.approx(
result.lighting_kwh_per_yr * factor, rel=1e-9
)
breakdown_sum = (
result.intermediate["main_heating_co2_kg_per_yr"]
+ result.intermediate["secondary_heating_co2_kg_per_yr"]
+ result.intermediate["hot_water_co2_kg_per_yr"]
+ result.intermediate["pumps_fans_co2_kg_per_yr"]
+ result.intermediate["lighting_co2_kg_per_yr"]
)
assert breakdown_sum == pytest.approx(result.co2_kg_per_yr, rel=1e-9)
def test_calculate_exposes_pv_export_credit() -> None:
# Arrange — P5 trace mode: total_fuel_cost_gbp = sum(per-end-use
# costs) pv_export_credit, floored at 0. The PV credit is the only