From 8a96003d45f041266b3478763df11f4100f5c039 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 13:31:33 +0000 Subject: [PATCH 1/9] =?UTF-8?q?Include=20the=20C6=20half=20heat-network=20?= =?UTF-8?q?standing=20charge=20on=20top=20of=20a=20water-only=20dwelling's?= =?UTF-8?q?=20fuel=20standing=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 11 ++++ .../rdsap/test_cert_to_inputs.py | 56 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index a1a8af635..d203776e9 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2021,6 +2021,17 @@ def _heat_network_standing_charge_gbp( return None +def _standing_charges_total_gbp( + epc: EpcPropertyData, + main: Optional[MainHeatingDetail], + *, + main_fuel_code: Optional[int], + water_heating_fuel_code: Optional[int], + tariff: Tariff, +) -> float: + raise NotImplementedError + + def _main_heating_efficiency(epc: EpcPropertyData) -> float: """SAP 10.2 (206) main system 1 efficiency as a 0..1 fraction. diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index fb76456dc..58c1150bc 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -62,6 +62,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _heat_network_dlf, # pyright: ignore[reportPrivateUsage] _heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage] _heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage] + _standing_charges_total_gbp, # pyright: ignore[reportPrivateUsage] _main_fuel_code, # pyright: ignore[reportPrivateUsage] _mev_decentralised_kwh_per_yr_from_cert, # pyright: ignore[reportPrivateUsage] _mev_default_data_iuf, # pyright: ignore[reportPrivateUsage] @@ -9104,6 +9105,61 @@ def test_non_heat_network_main_returns_none_so_caller_uses_fuel_standing() -> No assert _heat_network_standing_charge_gbp(epc, main) is None +def test_water_only_heat_network_half_charge_adds_to_the_dwellings_fuel_standing() -> None: + # Arrange — SAP 10.2 §C6 (PDF p.54): "Include one-half of the normal heat + # network standing charge in the calculation of fuel costs unless the space + # heating is also a heat network." WHC 950 is a DHW-only heat network, and + # this dwelling's space heating is electric storage (SAP 401) — not a + # network — so the "unless" does not fire and the half charge applies. + # + # C6 says "INCLUDE [...] IN the calculation of fuel costs", in deliberate + # contrast to the full case's "the TOTAL standing charge IS the normal heat + # network standing charge" (next paragraph, p.55). Only the full case is + # phrased as a replacement, so the half is ADDITIVE to whatever Table 12 + # note (a) already charges the dwelling for its own fuel. Note (a): "The + # standing charge for off-peak electricity is added to space and water + # heating costs where either main heating or hot water uses off-peak + # electricity" — a Dual meter + SAP 401 is §12 Rule 2 → 7-hour → Table 32 + # code 32 = £24/yr. + # + # Half of the RdSAP10 Table 32 code-51 heat-network row (£120/yr) is £60. + # Total £84. Cert 22032926 (22 Gwydyr Mansions) is the corpus instance. + from dataclasses import replace + + from domain.sap10_calculator.tables.table_12a import Tariff + + base = _typical_semi_detached_epc() + storage_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + heat_emitter_type=0, + emitter_temperature=1, + main_heating_control=2401, + main_heating_category=7, # electric storage heaters + sap_main_heating_code=401, + ) + epc = replace( + base, + sap_energy_source=replace(base.sap_energy_source, meter_type="1"), # Dual + sap_heating=make_sap_heating( + main_heating_details=[storage_main], + water_heating_code=950, # DHW-only heat network (boilers) + ), + ) + + # Act + total = _standing_charges_total_gbp( + epc, + storage_main, + main_fuel_code=29, + water_heating_fuel_code=51, # Table 32 heat-network row + tariff=Tariff.SEVEN_HOUR, + ) + + # Assert — £24 off-peak electric (note (a)) + £60 half network (C6). + assert abs(total - 84.0) <= 1e-9 + + def test_mev_default_data_iuf_is_table_329_system_type_10_value() -> None: # Arrange / Act — SAP 10.2 Table 4g note 3 (PDF p.176) directs the # default SFP to "the appropriate in-use factor for default data from From cc84a091e6b314a9d8aac03d60848fe2e32d2ac9 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 13:32:34 +0000 Subject: [PATCH 2/9] =?UTF-8?q?Include=20the=20C6=20half=20heat-network=20?= =?UTF-8?q?standing=20charge=20on=20top=20of=20a=20water-only=20dwelling's?= =?UTF-8?q?=20fuel=20standing=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index d203776e9..9bfa07707 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2029,7 +2029,39 @@ def _standing_charges_total_gbp( water_heating_fuel_code: Optional[int], tariff: Tariff, ) -> float: - raise NotImplementedError + """Total annual standing charge (£/yr) for worksheet (251) — the single + source of truth for how the heat-network and fuel-based standing charges + compose. + + SAP 10.2 §C6 (PDF p.54-55) settles the composition, and phrases its two + cases deliberately differently: + + - Space heating on a heat network → "the TOTAL standing charge IS the + normal heat network standing charge" — a REPLACEMENT. The dwelling's own + fuel standing does not apply on top (see + `_heat_network_standing_charge_gbp`). + - DHW-only heat network → "INCLUDE one-half of the normal heat network + standing charge IN the calculation of fuel costs unless the space heating + is also a heat network" — ADDITIVE. Only the full case is worded as a + total, so the half stacks on whatever Table 12 note (a) already charges + the dwelling for its own space-heating fuel (e.g. £24 off-peak electric + for a storage-heater main → £84). + + The DHW-only half keys off the WATER heating code (950 / 951 / 952), not + the space main: the space main is by definition NOT a network on this + branch, so gating on it (as the £120 branch does) can never fire here. + """ + network_standing = _heat_network_standing_charge_gbp(epc, main) + if network_standing is not None: + return network_standing + fuel_standing = additional_standing_charges_gbp( + main_fuel_code=main_fuel_code, + water_heating_fuel_code=water_heating_fuel_code, + tariff=tariff, + ) + if epc.sap_heating.water_heating_code in _WATER_HEAT_NETWORK_ONLY_CODES: + return fuel_standing + _HEAT_NETWORK_STANDING_CHARGE_GBP / 2.0 + return fuel_standing def _main_heating_efficiency(epc: EpcPropertyData) -> float: From bd3a7ac3178293d9e63c57693e8bcb3eb6181f16 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 13:37:19 +0000 Subject: [PATCH 3/9] =?UTF-8?q?Charge=20the=20C6=20half=20heat-network=20s?= =?UTF-8?q?tanding=20charge=20on=20a=20water-only=20dwelling's=20fuel=20co?= =?UTF-8?q?st=20worksheet=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- c6_cohort.py | 41 +++++++++++++++++++ .../rdsap/test_cert_to_inputs.py | 29 +++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 c6_cohort.py diff --git a/c6_cohort.py b/c6_cohort.py new file mode 100644 index 000000000..25c18c5cd --- /dev/null +++ b/c6_cohort.py @@ -0,0 +1,41 @@ +"""Which 21.0.1-corpus certs are in #1606's C6 half-charge cohort? +Cohort = WHC in {950,951,952} AND space main is NOT a heat network. +""" +import json +from pathlib import Path +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from datatypes.epc.domain.field_mappings import is_heat_network_main +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _first_main_heating, _heat_network_standing_charge_gbp, + _WATER_HEAT_NETWORK_ONLY_CODES, +) + +path = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") +certs = [json.loads(l) for l in path.read_text().splitlines() if l.strip()] + +hits = [] +for c in certs: + epc = EpcPropertyDataMapper.from_api_response(c) + whc = epc.sap_heating.water_heating_code if epc.sap_heating else None + if whc not in _WATER_HEAT_NETWORK_ONLY_CODES: + continue + m1 = _first_main_heating(epc) + net = is_heat_network_main(m1) + hits.append({ + "rrn": c.get("rrn") or c.get("lmk_key"), + "uprn": c.get("uprn"), + "addr": (c.get("address1") or "")[:34], + "whc": whc, + "main_code": m1.sap_main_heating_code if m1 else None, + "main_is_network": net, + "net_standing": _heat_network_standing_charge_gbp(epc, m1), + "in_C6_half_cohort": not net, + }) + +print(f"WHC 950/951/952 certs in 21.0.1 corpus: {len(hits)}\n") +for h in hits: + flag = " <== C6 HALF COHORT" if h["in_C6_half_cohort"] else "" + print(f" rrn={h['rrn']} uprn={h['uprn']}") + print(f" addr={h['addr']!r} whc={h['whc']} main={h['main_code']} " + f"network_main={h['main_is_network']} net_standing={h['net_standing']}{flag}") +print(f"\nIn C6 half cohort: {sum(h['in_C6_half_cohort'] for h in hits)}") diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 58c1150bc..fbdde3f29 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -68,6 +68,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _mev_default_data_iuf, # pyright: ignore[reportPrivateUsage] _TABLE_4G_DEFAULT_MEV_SFP_W_PER_L_PER_S, # pyright: ignore[reportPrivateUsage] dimensions_from_cert, + fuel_cost_section_from_cert, _table_12_factor_fuel_code, # pyright: ignore[reportPrivateUsage] _table_12a_system_for_main, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] @@ -9160,6 +9161,34 @@ def test_water_only_heat_network_half_charge_adds_to_the_dwellings_fuel_standing assert abs(total - 84.0) <= 1e-9 +def test_water_only_heat_network_half_charge_reaches_the_fuel_cost_worksheet() -> None: + # Arrange — the §C6 half charge must reach worksheet (251), not just the + # helper. A gas-boiler main (SAP 102, not a network) with WHC 950 keeps the + # dwelling on the STANDARD tariff, so `_fuel_cost` returns the real + # (240)..(255) result rather than the off-peak zero sentinel. + # + # Table 12 note (a): "The standing charge for gas fuels is added to space + # and water heating costs where the gas fuel is used for space heating" → + # Table 32 code 1 = £120. Plus the C6 half (£60) = £180. + from dataclasses import replace + + base = _typical_semi_detached_epc() + epc = replace( + base, + sap_heating=make_sap_heating( + main_heating_details=[_gas_boiler_detail(sap_main_heating_code=102)], + water_heating_code=950, # DHW-only heat network (boilers) + ), + ) + + # Act + fc = fuel_cost_section_from_cert(epc) + + # Assert + assert fc is not None + assert abs(fc.additional_standing_charges_gbp - 180.0) <= 1e-9 + + def test_mev_default_data_iuf_is_table_329_system_type_10_value() -> None: # Arrange / Act — SAP 10.2 Table 4g note 3 (PDF p.176) directs the # default SFP to "the appropriate in-use factor for default data from From 4472e47ed40217c27d93b1c29eeb8ba60b5fbb9c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 13:41:17 +0000 Subject: [PATCH 4/9] =?UTF-8?q?Charge=20the=20C6=20half=20heat-network=20s?= =?UTF-8?q?tanding=20charge=20on=20a=20water-only=20dwelling's=20fuel=20co?= =?UTF-8?q?st=20worksheet=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- c6_cohort.py | 41 ------------------- .../sap10_calculator/rdsap/cert_to_inputs.py | 15 +++---- 2 files changed, 6 insertions(+), 50 deletions(-) delete mode 100644 c6_cohort.py diff --git a/c6_cohort.py b/c6_cohort.py deleted file mode 100644 index 25c18c5cd..000000000 --- a/c6_cohort.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Which 21.0.1-corpus certs are in #1606's C6 half-charge cohort? -Cohort = WHC in {950,951,952} AND space main is NOT a heat network. -""" -import json -from pathlib import Path -from datatypes.epc.domain.mapper import EpcPropertyDataMapper -from datatypes.epc.domain.field_mappings import is_heat_network_main -from domain.sap10_calculator.rdsap.cert_to_inputs import ( - _first_main_heating, _heat_network_standing_charge_gbp, - _WATER_HEAT_NETWORK_ONLY_CODES, -) - -path = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") -certs = [json.loads(l) for l in path.read_text().splitlines() if l.strip()] - -hits = [] -for c in certs: - epc = EpcPropertyDataMapper.from_api_response(c) - whc = epc.sap_heating.water_heating_code if epc.sap_heating else None - if whc not in _WATER_HEAT_NETWORK_ONLY_CODES: - continue - m1 = _first_main_heating(epc) - net = is_heat_network_main(m1) - hits.append({ - "rrn": c.get("rrn") or c.get("lmk_key"), - "uprn": c.get("uprn"), - "addr": (c.get("address1") or "")[:34], - "whc": whc, - "main_code": m1.sap_main_heating_code if m1 else None, - "main_is_network": net, - "net_standing": _heat_network_standing_charge_gbp(epc, m1), - "in_C6_half_cohort": not net, - }) - -print(f"WHC 950/951/952 certs in 21.0.1 corpus: {len(hits)}\n") -for h in hits: - flag = " <== C6 HALF COHORT" if h["in_C6_half_cohort"] else "" - print(f" rrn={h['rrn']} uprn={h['uprn']}") - print(f" addr={h['addr']!r} whc={h['whc']} main={h['main_code']} " - f"network_main={h['main_is_network']} net_standing={h['net_standing']}{flag}") -print(f"\nIn C6 half cohort: {sum(h['in_C6_half_cohort'] for h in hits)}") diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 9bfa07707..0ae21a4fe 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -7723,15 +7723,12 @@ def _fuel_cost( table_32_unit_price_p_per_kwh(60) * _PENCE_TO_GBP ) - heat_network_standing = _heat_network_standing_charge_gbp(epc, main) - standing = ( - heat_network_standing - if heat_network_standing is not None - else additional_standing_charges_gbp( - main_fuel_code=main_fuel_code, - water_heating_fuel_code=water_heating_fuel_code, - tariff=tariff, - ) + standing = _standing_charges_total_gbp( + epc, + main, + main_fuel_code=main_fuel_code, + water_heating_fuel_code=water_heating_fuel_code, + tariff=tariff, ) # Worksheet display convention: when a row's kWh is zero (no main 2, no From b14fa31dde230c6d923f155d9ad0ccd657c6e7a7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 13:43:46 +0000 Subject: [PATCH 5/9] =?UTF-8?q?Charge=20the=20C6=20half=20heat-network=20s?= =?UTF-8?q?tanding=20charge=20on=20a=20water-only=20dwelling's=20off-peak?= =?UTF-8?q?=20fuel=20bill=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rdsap/test_cert_to_inputs.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index fbdde3f29..762f7bb40 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -9189,6 +9189,45 @@ def test_water_only_heat_network_half_charge_reaches_the_fuel_cost_worksheet() - assert abs(fc.additional_standing_charges_gbp - 180.0) <= 1e-9 +def test_water_only_heat_network_half_charge_reaches_the_off_peak_standing_scalar() -> None: + # Arrange — cert 22032926 (22 Gwydyr Mansions) in shape: electric storage + # main (SAP 401) on a Dual meter + WHC 950. Off-peak certs take the zero + # `FuelCostResult` sentinel from `_fuel_cost` and cost via the scalar + # `standing_charges_gbp` instead, so the §C6 half must reach BOTH paths — + # this is the one the corpus cert actually bills through. + # + # The lodged `water_heating_fuel` 20 ("mains gas (community)") is NOT a + # Table 32 gas code, so note (a) draws no gas standing — the dwelling has + # no gas meter, only the Economy-7 electric one. Hence £24 today. + from dataclasses import replace + + base = _typical_semi_detached_epc() + storage_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + heat_emitter_type=0, + emitter_temperature=1, + main_heating_control=2401, + main_heating_category=7, # electric storage heaters + sap_main_heating_code=401, + ) + epc = replace( + base, + sap_energy_source=replace(base.sap_energy_source, meter_type="1"), # Dual + sap_heating=make_sap_heating( + main_heating_details=[storage_main], + water_heating_code=950, # DHW-only heat network (boilers) + water_heating_fuel=20, # EPC "mains gas (community)", as lodged + ), + ) + + # Act + standing: float = cert_to_inputs(epc).standing_charges_gbp + + # Assert — £24 off-peak electric (Table 12 note (a)) + £60 half (C6). + assert abs(standing - 84.0) <= 1e-9 + + def test_mev_default_data_iuf_is_table_329_system_type_10_value() -> None: # Arrange / Act — SAP 10.2 Table 4g note 3 (PDF p.176) directs the # default SFP to "the appropriate in-use factor for default data from From 43e69f159ae9d26b68f2291c71021bfef3f53412 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 13:45:21 +0000 Subject: [PATCH 6/9] =?UTF-8?q?Charge=20the=20C6=20half=20heat-network=20s?= =?UTF-8?q?tanding=20charge=20on=20a=20water-only=20dwelling's=20off-peak?= =?UTF-8?q?=20fuel=20bill=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/sap10_calculator/rdsap/cert_to_inputs.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 0ae21a4fe..283b9f8c6 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -8595,16 +8595,16 @@ def cert_to_inputs( immersion_high_rate_fraction=_hw_immersion_high_frac, ) _hw_extra_standing = 0.0 - _heat_network_standing = _heat_network_standing_charge_gbp(epc, main) standing_charges_total = ( - _heat_network_standing - if _heat_network_standing is not None - else additional_standing_charges_gbp( + _standing_charges_total_gbp( + epc, + main, main_fuel_code=_main_fuel_code(main), water_heating_fuel_code=_water_heating_fuel_code(epc), tariff=_rdsap_tariff(epc), ) - ) + _hw_extra_standing + + _hw_extra_standing + ) # SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution # pumping electricity (worksheet (313)/(372)/(472)). None for From dd9e3167960b1912f6523c8ef7b0a16f6bf8ca3e Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 13:46:08 +0000 Subject: [PATCH 7/9] =?UTF-8?q?Take=20the=20full=20heat-network=20standing?= =?UTF-8?q?=20charge,=20not=20both,=20when=20space=20heating=20is=20also?= =?UTF-8?q?=20on=20the=20network=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rdsap/test_cert_to_inputs.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 762f7bb40..578cb67de 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -9161,6 +9161,52 @@ def test_water_only_heat_network_half_charge_adds_to_the_dwellings_fuel_standing assert abs(total - 84.0) <= 1e-9 +def test_water_only_code_on_a_heat_network_space_main_takes_the_full_charge_not_both() -> None: + # Arrange — SAP 10.2 §C6's "unless" clause. A water-only WHC (950) can sit + # on a cert whose SPACE main is ALSO a heat network (SAP 301). C6 gives the + # half "unless the space heating is also a heat network (see next + # paragraph)", and that next paragraph (p.55) says "the total standing + # charge is the normal heat network standing charge" — £120 flat. + # + # So the half must NOT stack onto the full charge here: £180 would be the + # double-count this branch exists to prevent, and £120 is not additive with + # the dwelling's fuel standing either ("the TOTAL standing charge IS"). + from dataclasses import replace + + from domain.sap10_calculator.tables.table_12a import Tariff + + base = _typical_semi_detached_epc() + network_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, # EPC mains gas (community) + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, + sap_main_heating_code=301, # community boilers — a heat network + ) + epc = replace( + base, + sap_heating=make_sap_heating( + main_heating_details=[network_main], + water_heating_code=950, + water_heating_fuel=20, + ), + ) + + # Act + total = _standing_charges_total_gbp( + epc, + network_main, + main_fuel_code=20, + water_heating_fuel_code=20, + tariff=Tariff.STANDARD, + ) + + # Assert — the full £120, not £120 + £60. + assert abs(total - 120.0) <= 1e-9 + + def test_water_only_heat_network_half_charge_reaches_the_fuel_cost_worksheet() -> None: # Arrange — the §C6 half charge must reach worksheet (251), not just the # helper. A gas-boiler main (SAP 102, not a network) with WHC 950 keeps the From a412c8942bbba088d7529bc18c69a5fdcad3605a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 13:50:17 +0000 Subject: [PATCH 8/9] =?UTF-8?q?Ratchet=20the=20API-corpus=20SAP=20MAE=20ce?= =?UTF-8?q?iling=20to=200.626=20for=20the=20water-only=20C6=20standing=20c?= =?UTF-8?q?harge=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc_client/test_sap_accuracy_corpus.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 6a74fdc4d..a0a45a31e 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -367,7 +367,26 @@ _MIN_WITHIN_HALF_SAP = 0.788 # 100031768368 SAP 59.12 -> 61.21 (lodged 61, now within 0.5); 217091901 # 60.82 -> 61.59 (lodged 62, now an exact match). 14 corpus certs lodge # exactly 280mm solid brick. Unit-pinned in test_rdsap_uvalues. -_MAX_SAP_MAE = 0.628 +# MAE 0.627 -> 0.625 (within-0.5 held at 78.8%) via the SAP 10.2 §C6 +# water-only heat-network standing charge: "Include one-half of the normal +# heat network standing charge in the calculation of fuel costs unless the +# space heating is also a heat network" (p.54). The half was gated on the +# SPACE main being a network, which WHC 950/951/952 never are by +# definition, so it could not fire. C6 phrases the half as "INCLUDE [...] +# IN the calculation of fuel costs" against the full case's "the TOTAL +# standing charge IS", so it is ADDITIVE to the dwelling's own Table 12 +# note (a) fuel standing rather than replacing it. +# Blast radius is 1 cert: of the three WHC-950 certs, only 22032926 (22 +# Gwydyr Mansions, electric storage main 401) has a non-network space +# main. £24 Economy-7 -> £24 + £60 = £84; SAP 63.737 -> 60.328 (lodged 61), +# |err| 2.737 -> 0.672. The other two (mains 304 / 301) are on network +# space mains and correctly keep the full £120 via the first branch. +# NOTE: this lands on a spec argument, not an oracle — there is no +# accredited Elmhurst worksheet for a water-only network yet (#1592-C). +# The -0.672 overshoot is expected to move again when the C4 plant- +# efficiency and C3.2 pumping siblings land. Unit-pinned in +# test_cert_to_inputs. +_MAX_SAP_MAE = 0.626 _MAX_CO2_MAE_TONNES = 0.072 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 3.0 # kWh / m2 / yr vs energy_consumption_current From 589eedba8ac3fa4625fbf35f378af634d37375df Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 13:53:59 +0000 Subject: [PATCH 9/9] =?UTF-8?q?Cite=20C6=20rather=20than=20C3.2=20for=20th?= =?UTF-8?q?e=20heat-network=20standing=20charge=20rule=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 283b9f8c6..1fc568e03 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1995,16 +1995,15 @@ def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool: def _heat_network_standing_charge_gbp( epc: EpcPropertyData, main: Optional[MainHeatingDetail] ) -> Optional[float]: - """SAP 10.2 Table 12 note (l) + §C3.2 heat-network standing charge, or - None when the dwelling is not on a heat network (caller then falls back - to the fuel-based `additional_standing_charges_gbp`). + """SAP 10.2 Table 12 note (l) + §C6 heat-network standing charge that + REPLACES the dwelling's fuel-based standing, or None when no replacement + applies (`_standing_charges_total_gbp` then composes the fuel-based + `additional_standing_charges_gbp` itself). A heat network carries the Table 12 £120/yr standing charge regardless of the network fuel — full when the SPACE heating is on the network - (§C3.2 "the total standing charge is the normal heat network standing - charge"), halved to £60 when ONLY DHW is provided by the heat network - (note (l)). This REPLACES the fuel-based gas/off-peak standing for a - heat-network main, so it must not be added on top of + (§C6 p.55 "the total standing charge is the normal heat network standing + charge"). Being a TOTAL, it must not be added on top of `additional_standing_charges_gbp` (which would double-count: a Summary-path community-gas main lodges Table-32 code 1 and already draws the £120 gas standing). Worksheet-validated: simulated case 14 @@ -2013,6 +2012,17 @@ def _heat_network_standing_charge_gbp( The API path under-counted this: an EPC community fuel (e.g. 20 = mains gas community) is not a Table-32 gas code, so `_is_gas_code` returned False and the standing came out £0 — cert 9390 lost the whole £120. + + The £60 branch is reachable ONLY for WHC 914 (`_water_heating_main` + returns Main 2 for 914 alone, and 901/902's Main 1 would already have + hit the branch above). Main 2 is a MAIN heating system, so it carries a + space-heating fraction — meaning the space heating IS also a heat + network, which is precisely C6's "unless", pointing at the full £120. + #1608 tracks that; it is deliberately left as-is here. + + NOTE: this is NOT the C6 half charge for water-only networks (WHC + 950/951/952) — that one is additive and lives in + `_standing_charges_total_gbp`. """ if is_heat_network_main(main): return _HEAT_NETWORK_STANDING_CHARGE_GBP