feat(calculator): thread per-end-use fuel codes + PV export onto SapResult

ADR-0014 BillDerivation attributes each end-use (HEATING / HOT_WATER /
SECONDARY / APPLIANCES / COOKING) to a fuel carrier and credits PV
export. SapResult already carried the per-end-use kWh but not WHICH
fuel each end-use burns, nor the annual exported kWh — so a downstream
SapResult->EnergyBreakdown adapter could not pick the right tariff.

Surfaces five output-only fields, threaded exactly like the recently
merged appliances/cooking change (2f039aeb):
  main_heating_fuel_code      RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
  main_2_heating_fuel_code    code column (the lodged fuel code, e.g.
  secondary_heating_fuel_code mains gas 26). None when the corresponding
  hot_water_fuel_code         system is absent / fuel not resolvable.
  pv_exported_kwh_per_yr      SAP 10.2 Appendix M1 §3-4 annual export kWh
                              (0.0 when no PV).

cert_to_inputs.py populates the four fuel codes from the existing
resolvers the cost/CO2 cascade already uses — `_main_fuel_code`,
`_secondary_fuel_code`, `_water_heating_fuel_code` (not reinvented);
Main 2 is the second `main_heating_details` entry, guarded for length.
There is a single CalculatorInputs construction site (cert_to_demand_
inputs delegates to cert_to_inputs). `pv_exported_kwh_per_yr` already
existed on CalculatorInputs; SapResult collapses its Optional to 0.0.

HARD CONSTRAINT honoured — output-only, zero rating drift. These fields
do NOT feed ECF / total_fuel_cost_gbp / co2_kg_per_yr / primary_energy_*
/ sap_score / any monthly value. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical: calculator suite 1658 -> 1661 passed (+3 new tests),
4 skipped, 0 failed before and after. pyright net-zero (51 -> 51 in
domain/; no new errors in the touched test files).

New tests: a synthetic threading test (four fuel codes + PV export pass
unchanged through calculate_sap_from_inputs; None PV collapses to 0.0)
and a cert-level pin (mains-gas combi cert 000516 -> main fuel code 26,
no Main 2, secondary 30, HW 26). Synthetic CalculatorInputs / SapResult
fixtures updated for the new SapResult fields (defaults cover Inputs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 18:16:40 +00:00
parent 19a56461ba
commit 4e9ff7c3cb
5 changed files with 132 additions and 0 deletions

View file

@ -325,6 +325,18 @@ class CalculatorInputs:
# this field. cert_to_inputs sets this via `additional_standing_
# charges_gbp(main_fuel_code, water_heating_fuel_code, tariff)`.
standing_charges_gbp: float = 0.0
# Per-end-use fuel codes (RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
# code column) for ADR-0014 BillDerivation fuel attribution. Output-
# only — these do NOT feed ECF / cost / CO2 / primary energy /
# sap_score (the rating cascade already prices each end-use via the
# per-end-use cost/CO2/PE factor fields above). They tell the bill
# adapter WHICH fuel carrier each end-use burns. None when the
# corresponding system is absent (no main / no 2nd main / no
# secondary) or the water-heating fuel is not resolvable.
main_heating_fuel_code: Optional[int] = None
main_2_heating_fuel_code: Optional[int] = None
secondary_heating_fuel_code: Optional[int] = None
hot_water_fuel_code: Optional[int] = None
@dataclass(frozen=True)
@ -374,6 +386,20 @@ class SapResult:
# gas-cooker split, if ever needed, is a separate follow-up).
appliances_kwh_per_yr: float
cooking_kwh_per_yr: float
# Per-end-use fuel codes (RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
# code column) + annual PV export for ADR-0014 BillDerivation. Output-
# only metadata — these do NOT contribute to ecf / total_fuel_cost_gbp
# / co2_kg_per_yr / primary_energy_kwh_per_yr / sap_score. They tell
# the bill adapter WHICH fuel carrier each end-use burns; the fuel
# codes are None when the corresponding system is absent or the water-
# heating fuel is not resolvable. `pv_exported_kwh_per_yr` is the
# annual kWh exported to the grid (SAP 10.2 Appendix M1 §3-4 split),
# 0.0 when there is no PV.
main_heating_fuel_code: Optional[int]
main_2_heating_fuel_code: Optional[int]
secondary_heating_fuel_code: Optional[int]
hot_water_fuel_code: Optional[int]
pv_exported_kwh_per_yr: float
primary_energy_kwh_per_yr: float
primary_energy_kwh_per_m2: float
monthly: tuple[MonthlyEntry, ...]
@ -764,6 +790,11 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
lighting_kwh_per_yr=inputs.lighting_kwh_per_yr,
appliances_kwh_per_yr=inputs.appliances_kwh_per_yr,
cooking_kwh_per_yr=inputs.cooking_kwh_per_yr,
main_heating_fuel_code=inputs.main_heating_fuel_code,
main_2_heating_fuel_code=inputs.main_2_heating_fuel_code,
secondary_heating_fuel_code=inputs.secondary_heating_fuel_code,
hot_water_fuel_code=inputs.hot_water_fuel_code,
pv_exported_kwh_per_yr=inputs.pv_exported_kwh_per_yr or 0.0,
primary_energy_kwh_per_yr=primary_energy_kwh,
primary_energy_kwh_per_m2=primary_energy_per_m2,
monthly=monthly,

View file

@ -6170,6 +6170,25 @@ def cert_to_inputs(
# E_cook = 138 + 28×N, already summed in `cooking_monthly_kwh`.
appliances_kwh_per_yr=sum(appliances_monthly_kwh),
cooking_kwh_per_yr=sum(cooking_monthly_kwh),
# Per-end-use fuel codes (RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
# code column) for ADR-0014 BillDerivation fuel attribution.
# Output-only — they tell the bill adapter WHICH carrier each end-
# use burns and do NOT feed cost / CO2 / PE / sap_score (those are
# already priced via the per-end-use factor fields below). Resolved
# via the same helpers the cost/CO2 cascade uses: `_main_fuel_code`
# (None when no main system), `_secondary_fuel_code`, and
# `_water_heating_fuel_code` (None when the WHC fuel is not
# resolvable). Main 2 is the second `main_heating_details` entry,
# if any (None when the cert has a single main system).
main_heating_fuel_code=_main_fuel_code(main),
main_2_heating_fuel_code=_main_fuel_code(
epc.sap_heating.main_heating_details[1]
if epc.sap_heating
and len(epc.sap_heating.main_heating_details) > 1
else None
),
secondary_heating_fuel_code=_secondary_fuel_code(epc),
hot_water_fuel_code=_water_heating_fuel_code(epc),
space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_kwh(
main, _rdsap_tariff(epc), prices
),

View file

@ -49,6 +49,11 @@ def _sap_result(
lighting_kwh_per_yr=0.0,
appliances_kwh_per_yr=0.0,
cooking_kwh_per_yr=0.0,
main_heating_fuel_code=None,
main_2_heating_fuel_code=None,
secondary_heating_fuel_code=None,
hot_water_fuel_code=None,
pv_exported_kwh_per_yr=0.0,
primary_energy_kwh_per_yr=0.0,
primary_energy_kwh_per_m2=primary_energy_kwh_per_m2,
monthly=(),

View file

@ -131,6 +131,57 @@ def _baseline_inputs() -> CalculatorInputs:
)
def test_fuel_codes_and_pv_export_thread_unchanged_onto_sap_result() -> None:
"""Per-end-use fuel codes + PV export reach SapResult untouched.
ADR-0014 BillDerivation attributes each end-use to a fuel carrier, so
the per-end-use fuel codes (RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
code column) and the annual PV export kWh must surface on SapResult.
These are output-only metadata they must thread byte-identical from
CalculatorInputs through `calculate_sap_from_inputs` onto SapResult and
NOT be recomputed or perturbed. `pv_exported_kwh_per_yr` collapses a
None CalculatorInputs value to 0.0.
"""
# Arrange — set the four fuel codes + PV export to distinct known
# values on the baseline. Mains gas (1) main, LPG (2) main-2, standard
# electricity (30) secondary, mains gas (1) hot water.
inputs = replace(
_baseline_inputs(),
main_heating_fuel_code=1,
main_2_heating_fuel_code=2,
secondary_heating_fuel_code=30,
hot_water_fuel_code=1,
pv_exported_kwh_per_yr=850.0,
)
# Act
result = calculate_sap_from_inputs(inputs)
# Assert — threaded unchanged; PV export carried through.
assert result.main_heating_fuel_code == 1
assert result.main_2_heating_fuel_code == 2
assert result.secondary_heating_fuel_code == 30
assert result.hot_water_fuel_code == 1
assert abs(result.pv_exported_kwh_per_yr - 850.0) <= 1e-9
def test_pv_export_collapses_none_input_to_zero_on_sap_result() -> None:
"""`pv_exported_kwh_per_yr` is 0.0 (not None) on SapResult for no-PV.
CalculatorInputs.pv_exported_kwh_per_yr is Optional[float] (None on
certs without a PV split); SapResult.pv_exported_kwh_per_yr is a plain
float, so the assembly collapses None to 0.0 for the bill adapter.
"""
# Arrange — baseline has no PV split (pv_exported_kwh_per_yr defaults None).
inputs = replace(_baseline_inputs(), pv_exported_kwh_per_yr=None)
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
assert result.pv_exported_kwh_per_yr == 0.0
def test_calculator_consumes_solar_gains_monthly_w_field_for_per_month_solar() -> None:
# Arrange — replace the baseline inputs' solar with an explicit known
# 12-tuple. The §6 orchestrator produces this upstream; the calculator

View file

@ -256,3 +256,29 @@ def test_appliances_and_cooking_kwh_threaded_onto_sap_result() -> None:
assert result.appliances_kwh_per_yr == inputs.appliances_kwh_per_yr
assert result.cooking_kwh_per_yr == inputs.cooking_kwh_per_yr
assert abs(result.cooking_kwh_per_yr - expected_cooking_kwh) <= 1e-9
def test_main_heating_fuel_code_threaded_onto_sap_result_for_mains_gas_cert() -> None:
"""Per-end-use fuel codes reach SapResult for a real mains-gas cert.
ADR-0014 BillDerivation attributes each end-use to a fuel carrier.
Cert 000516 is a mains-gas combi (RdSAP10 Table 32 / SAP 10.2 Table 12
mains-gas fuel code 26 as lodged), so the cascade must surface fuel
code 26 on `SapResult.main_heating_fuel_code` and thread it unchanged
from CalculatorInputs. Output-only metadata it does NOT feed
cost / CO2 / PE / sap_score (those are pinned elsewhere in this file).
"""
# Arrange — a mains-gas combi cert.
epc = _FIXTURE_MODULES['000516'].build_epc()
# Act
inputs = cert_to_inputs(epc)
result = Sap10Calculator().calculate(epc)
# Assert — mains-gas main fuel code threaded unchanged; single main
# system (no Main 2); secondary defaults to standard electricity (30).
assert inputs.main_heating_fuel_code == 26
assert result.main_heating_fuel_code == 26
assert result.main_2_heating_fuel_code is None
assert result.secondary_heating_fuel_code == 30
assert result.hot_water_fuel_code == 26