diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 364ad23d..a0764b60 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -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, diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 5e3f5a77..f60c688e 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 ), diff --git a/tests/domain/property_baseline/test_calculator_rebaseliner.py b/tests/domain/property_baseline/test_calculator_rebaseliner.py index b20408a5..e77ee6da 100644 --- a/tests/domain/property_baseline/test_calculator_rebaseliner.py +++ b/tests/domain/property_baseline/test_calculator_rebaseliner.py @@ -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=(), diff --git a/tests/domain/sap10_calculator/test_calculator.py b/tests/domain/sap10_calculator/test_calculator.py index 37e56d16..dba25409 100644 --- a/tests/domain/sap10_calculator/test_calculator.py +++ b/tests/domain/sap10_calculator/test_calculator.py @@ -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 diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index 69ad44ba..05bdecbe 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -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