From bf4af89b3a21a488a40a73c0efe52d48f101d3db Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 10:22:37 +0000 Subject: [PATCH 01/10] =?UTF-8?q?Price=20water-only=20heat-network=20DHW?= =?UTF-8?q?=20without=20a=20plant-efficiency=20divisor=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 | 56 +++++++++++++++++++ 1 file changed, 56 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 f0a2e14ac..20333b032 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1449,6 +1449,62 @@ def test_hot_water_only_heat_network_whc_950_applies_table12c_dlf() -> None: assert abs(hw_network / baseline - 1.41) <= 0.02 +def test_water_only_heat_network_dhw_prices_heat_without_plant_efficiency() -> None: + # Arrange — SAP 10.2 §4.3 (PDF p.24): "The efficiency for water heating + # is incorporated in the price of heat from a heat network in Table 12." + # The dwelling buys DELIVERED heat at the Table 12 fuel-51 rate, so the + # RdSAP Table 4a plant efficiency (950 → 0.80) must NOT also divide on + # the dwelling side — worksheet (310) is "(64) × (305a) × (306)", with + # no efficiency term. C4 (p.53) scopes plant efficiency explicitly: "For + # heat networks not listed in the PCDB, FOR THE CALCULATION OF CO2 + # EMISSIONS AND PRIMARY ENERGY, the efficiency to be used is that of the + # heat generator" — i.e. never for delivered energy. + # + # (305a) is the Table 4c(3) "DHW-only system, flat rate charging" row = + # 1.05 (p.169); RdSAP 10 (spec p.59) defaults a non-PCDB water-only + # network to flat-rate charging. (306) is the age-E Table 12c DLF = 1.41. + # + # So HW fuel = q_useful × 1.41 × 1.05, i.e. water_eff = 1/(DLF × 1.05). + # Asserted as fuel ÷ q_useful (the §4 output at 100% efficiency) to pin + # the efficiency absolutely, mirroring the WHC-901 heat-network test + # above. The main is an ordinary gas boiler — NOT a heat network — since + # §4.3 keys on the DHW source ("Where hot water is provided by a heat + # network"), not on the space main. + part = make_building_part(construction_age_band="E") + gas_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + ) + hw_network_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[gas_main], + water_heating_code=950, # hot-water-only heat network (boilers) + ), + ) + + # Act + hw_fuel: float = cert_to_inputs(hw_network_epc).hot_water_kwh_per_yr + wh_result, _ = _water_heating_worksheet_and_gains( + epc=hw_network_epc, + water_efficiency_pct=100.0, + is_instantaneous=False, + primary_age="E", + pcdb_record=None, + ) + + # Assert — delivered HW fuel = q_useful × (305a) × (306); no 1/0.80. + assert wh_result is not None + assert abs(hw_fuel / wh_result.output_kwh_per_yr - 1.41 * 1.05) <= 1e-6 + + def test_gas_boiler_main_efficiency_unchanged_by_dlf_override() -> None: # Arrange — regression check: the DLF override only fires for heat- # network main heating. A standard gas boiler (cat=2, code=102) must From 353ebe55565ecf7c6baeaaa6b565480a55cf1364 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 10:24:34 +0000 Subject: [PATCH 02/10] =?UTF-8?q?Price=20water-only=20heat-network=20DHW?= =?UTF-8?q?=20without=20a=20plant-efficiency=20divisor=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 §4.3 (p.24) puts the water-heating efficiency in the Table 12 heat price, and C4 (p.53) scopes the plant efficiency to CO2/PE only, so worksheet (310) = (64) × (305a) × (306) carries no efficiency term. The dwelling-side divisor was applying it a second time. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 36 +++++++++++++++---- .../rdsap/test_cert_to_inputs.py | 29 ++++++++------- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 7ebf4d40e..97adc7ad3 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1187,6 +1187,13 @@ _HEAT_NETWORK_DHW_CHARGING_FACTOR_BY_CODE: Final[dict[int, float]] = { 2306: 1.00, 2308: 1.00, 2309: 1.00, 2310: 1.00, 2312: 1.00, 2314: 1.00, } +# SAP 10.2 Table 4c(3) (PDF p.169) worksheet (305a), "DHW-only system, +# flat rate charging" row — the RdSAP 10 (spec p.59) default for a +# water-ONLY heat network (WHC 950/951/952), whose own charging method is +# never lodged: the dwelling's heating-control code describes the SPACE +# system's charging, not this network's. +_TABLE_4C3_FLAT_RATE_CHARGING_FACTOR: Final[float] = 1.05 + def _heat_network_space_charging_factor(main: Optional[MainHeatingDetail]) -> float: """SAP 10.2 Table 4c(3) (PDF p.169) worksheet (305) — heat-network @@ -8001,13 +8008,28 @@ def cert_to_inputs( * _heat_network_dhw_charging_factor(main) ) elif epc.sap_heating.water_heating_code in _WATER_HEAT_NETWORK_ONLY_CODES: - # HW-only heat network (whc 950/951/952): the Table 4a plant - # efficiency is already in `water_eff`; apply the Table 12c - # distribution loss on top per RdSAP 10 §10 (spec p.36 "water - # heating only ... distribution loss"). q_generated = q_useful × - # DLF, so delivered-per-fuel efficiency = plant_eff / DLF. Fires - # on the WHC alone — the HW network is independent of the main. - water_eff = water_eff / _heat_network_dlf(primary_age) + # HW-only heat network (whc 950/951/952). SAP 10.2 §4.3 (p.24): + # "The efficiency for water heating is incorporated in the price of + # heat from a heat network in Table 12" — the dwelling buys + # DELIVERED heat (fuel 51), so the Table 4a plant efficiency must + # NOT divide here as well. C4 (p.53) scopes it: "For heat networks + # not listed in the PCDB, for the calculation of CO2 emissions and + # Primary Energy, the efficiency to be used is that of the heat + # generator" — CO2/PE only, never delivered energy. + # + # C6 "Heat network providing DHW only" (p.54): "Allow for the + # control factor from Table 4c(3), for the distribution loss factor + # as in C3.1" — i.e. worksheet (310) = (64) × (305a) × (306), with + # no efficiency term. Same shape as the WHC-901/902/914 branch + # above, differing only in that (305a) takes the Table 4c(3) + # DHW-only flat-rate default rather than the space system's lodged + # charging code. Fires on the WHC alone — §4.3 keys on the DHW + # source ("Where hot water is provided by a heat network"), not on + # the space main. + water_eff = 1.0 / ( + _heat_network_dlf(primary_age) + * _TABLE_4C3_FLAT_RATE_CHARGING_FACTOR + ) is_instantaneous = epc.sap_heating.water_heating_code in _INSTANTANEOUS_WATER_CODES # §9a Table 11 secondary fraction — pulled forward of §4 so the # post-§8 Equation D1 cascade can derive Q_space = (98c)m × (204) 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 20333b032..85494bbfd 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1399,17 +1399,22 @@ def test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh() -> None: assert abs(hn_hw_fuel / wh_result.output_kwh_per_yr - 1.41) <= 1e-6 -def test_hot_water_only_heat_network_whc_950_applies_table12c_dlf() -> None: +def test_hot_water_only_heat_network_whc_950_costs_more_fuel_than_boiler_dhw() -> None: # Arrange — water_heating_code 950 = "hot-water-only heat network - # (boilers)" (SAP 10.2 Table 4a HW section, plant eff 0.80). RdSAP 10 - # §10 (spec p.36) requires the Table 12c distribution loss applied on - # top of the plant efficiency for water-heating-only heat networks, so - # the delivered HW fuel = q_useful × DLF / 0.80. The DLF must fire on - # the WHC ALONE — independent of the main, which here is an ordinary - # gas boiler (NOT a heat network), mirroring cert 9093 (whc 950 + a - # warm-air main). Compare against a non-heat-network baseline at the - # same 0.80 water efficiency (whc 901 from the same gas main): the 950 - # HW fuel must exceed it by exactly the DLF (age E → 1.41). + # (boilers)". The Table 12c distribution loss must fire on the WHC + # ALONE — independent of the main, which here is an ordinary gas boiler + # (NOT a heat network), mirroring cert 9093 (whc 950 + a warm-air main). + # Compared against the SAME dwelling taking DHW from that gas boiler + # (whc 901, Table 4a eff 0.80), the water-only network bills: + # + # 950: q_useful × (306) DLF 1.41 × (305a) 1.05 = q × 1.4805 + # 901: q_useful ÷ 0.80 = q × 1.25 + # + # → the network's delivered fuel is 1.4805/1.25 ≈ 1.18× the boiler's. + # The network pays a distribution loss and a flat-rate charging penalty + # but NO plant-efficiency divisor (SAP 10.2 §4.3 p.24: that efficiency + # is in the Table 12 heat price) — see the absolute-efficiency pin in + # `test_water_only_heat_network_dhw_prices_heat_without_plant_efficiency`. part = make_building_part(construction_age_band="E") gas_main = MainHeatingDetail( has_fghrs=False, @@ -1445,8 +1450,8 @@ def test_hot_water_only_heat_network_whc_950_applies_table12c_dlf() -> None: hw_network: float = cert_to_inputs(hw_network_epc).hot_water_kwh_per_yr baseline: float = cert_to_inputs(baseline_epc).hot_water_kwh_per_yr - # Assert — the HW-only-heat-network fuel is scaled up by the age-E DLF. - assert abs(hw_network / baseline - 1.41) <= 0.02 + # Assert — DLF × flat-rate charging, against the boiler's 1/0.80. + assert abs(hw_network / baseline - (1.41 * 1.05) * 0.80) <= 0.02 def test_water_only_heat_network_dhw_prices_heat_without_plant_efficiency() -> None: From 5f3e9f893fc496c8a57575568642db44a697382f Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 10:25:59 +0000 Subject: [PATCH 03/10] =?UTF-8?q?Bill=20water-only=20heat-network=20DHW=20?= =?UTF-8?q?identically=20whatever=20plant=20is=20behind=20it=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 950 boilers / 951 CHP / 952 community heat pump differ only in plant efficiency, which SAP 10.2 §4.3 puts in the Table 12 heat price — so the dwelling's delivered-heat demand must not vary with it. Passes on the previous commit's expression; verified RED against the prior impl, where 951 leaked 0.80/0.75 and 952 under-billed its DHW fuel by 3.15×. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rdsap/test_cert_to_inputs.py | 51 +++++++++++++++++++ 1 file changed, 51 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 85494bbfd..f454eddd0 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1510,6 +1510,57 @@ def test_water_only_heat_network_dhw_prices_heat_without_plant_efficiency() -> N assert abs(hw_fuel / wh_result.output_kwh_per_yr - 1.41 * 1.05) <= 1e-6 +def test_water_only_heat_network_dhw_fuel_independent_of_plant_type() -> None: + # Arrange — the three water-only heat-network codes differ ONLY in the + # plant behind the network: 950 boilers (Table 4a eff 0.80), 951 CHP + # (0.75), 952 community heat pump (3.00). Because SAP 10.2 §4.3 (p.24) + # puts that efficiency in the Table 12 price of delivered heat, the + # DWELLING's delivered-heat demand cannot depend on it: worksheet (310) + # is "(64) × (305a) × (306)" whatever the plant is. C6 (p.54) likewise + # gives one rule for a DHW-only network, and the Table 4c(3) (p.169) + # "DHW-only system" rows make no 950/951/952 distinction. + # + # So all three must bill identical HW fuel. The plant efficiency still + # matters — but at (342)/(373)/(473), the network's CO2/PE factors (C4, + # p.53) — never as a dwelling-side divisor. + # + # This is the sharpest statement of the double-count fix: under the old + # `water_eff = plant_eff / DLF` the three diverged by their plant + # efficiencies, and 952 under-billed its DHW fuel by ~3× (0.47 × q_useful + # against the spec's 1.4805 × q_useful). + part = make_building_part(construction_age_band="E") + gas_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + ) + + def hw_fuel_for(water_heating_code: int) -> float: + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[gas_main], + water_heating_code=water_heating_code, + ), + ) + return cert_to_inputs(epc).hot_water_kwh_per_yr + + # Act — boilers / CHP / community heat pump behind the same network. + boilers = hw_fuel_for(950) + chp = hw_fuel_for(951) + heat_pump = hw_fuel_for(952) + + # Assert — the plant behind the network never reaches the dwelling. + assert chp == pytest.approx(boilers, abs=1e-6) + assert heat_pump == pytest.approx(boilers, abs=1e-6) + + def test_gas_boiler_main_efficiency_unchanged_by_dlf_override() -> None: # Arrange — regression check: the DLF override only fires for heat- # network main heating. A standard gas boiler (cat=2, code=102) must From d10b5210c68fd1695cd8c9b4868b257a45542ff4 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 10:27:54 +0000 Subject: [PATCH 04/10] =?UTF-8?q?Accrue=20Table=203=20primary-circuit=20lo?= =?UTF-8?q?ss=20for=20water-only=20heat-network=20cylinders=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 | 50 +++++++++++++++++++ 1 file changed, 50 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 f454eddd0..551e09d7d 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1561,6 +1561,56 @@ def test_water_only_heat_network_dhw_fuel_independent_of_plant_type() -> None: assert heat_pump == pytest.approx(boilers, abs=1e-6) +def test_water_only_heat_network_cylinder_accrues_table_3_primary_loss() -> None: + # Arrange — SAP 10.2 §4.3 (PDF p.24) scopes itself "Where hot water is + # provided by a heat network", then requires: "Primary circuit loss for + # insulated pipework and cylinderstat should be included (see Table 3)." + # A water-ONLY network (WHC 950) feeds the dwelling-side cylinder via + # primary pipework from the network connection, so the loss applies on + # the DHW source alone — whatever the SPACE main is. + # + # The space main here is warm air (Table 4a code 502, category 9), + # mirroring cert 9093 (whc 950 + warm-air main). That main is not a + # boiler, so no boiler-category branch can accrue the loss on its + # behalf: without a WHC-keyed gate the cylinder silently gets (59)m = 0. + part = make_building_part(construction_age_band="E") + warm_air_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=9, + sap_main_heating_code=502, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[warm_air_main], + water_heating_code=950, # hot-water-only heat network + cylinder_size=2, # Normal / 110 L + cylinder_thermostat="Y", + ), + ) + + # Act + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=100.0, + is_instantaneous=False, + primary_age="E", + pcdb_record=None, + ) + + # Assert — the primary circuit runs, so (59)m is billed every month. + assert wh_result is not None + assert all(m > 0.0 for m in wh_result.primary_loss_monthly_kwh) + + def test_gas_boiler_main_efficiency_unchanged_by_dlf_override() -> None: # Arrange — regression check: the DLF override only fires for heat- # network main heating. A standard gas boiler (cat=2, code=102) must From ef44a744a32bc65d08db6e6a8ddc317ffe94b7b0 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 10:28:19 +0000 Subject: [PATCH 05/10] =?UTF-8?q?Accrue=20Table=203=20primary-circuit=20lo?= =?UTF-8?q?ss=20for=20water-only=20heat-network=20cylinders=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 §4.3 (p.24) scopes itself to "where hot water is provided by a heat network" and requires the Table 3 primary loss, so the gate keys on the DHW source. Checked off the WHC before the main-keyed branches so a non-boiler space main (cert 9093's warm air) can't zero it out. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/sap10_calculator/rdsap/cert_to_inputs.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 97adc7ad3..21b05af9b 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -6592,6 +6592,18 @@ def _primary_loss_applies( # kWh/yr — zero before this branch. if water_heating_code in _WATER_HEATING_BOILER_CIRCULATOR_CODES: return True + # SAP 10.2 §4.3 "Heat networks" (PDF p.24), scoped "Where hot water is + # provided by a heat network": "Primary circuit loss for insulated + # pipework and cylinderstat should be included (see Table 3)." A water- + # ONLY community scheme (WHC 950/951/952) feeds the dwelling-side + # cylinder via primary pipework from the network connection whatever + # the space main is, so the loss applies exactly as for heat-network + # mains with WHC 901/902/914 (below). Checked off `water_heating_code` + # before the main-keyed branches so a non-boiler space main (warm air, + # HP, direct-acting electric) can't zero it out — cert 9093 is the + # cohort case (whc 950 + warm-air main 502). + if water_heating_code in _WATER_HEAT_NETWORK_ONLY_CODES: + return True # SAP 10.2 Table 3 (PDF p.160) zero-loss list names "Direct-acting # electric boiler" verbatim. RdSAP 10 §12 (p.62) classifies SAP code # 191 as the direct-acting electric boiler: its cylinder is immersion- From 15b78ba312accd0b0b9208a3b4848e1e15242b44 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 10:29:12 +0000 Subject: [PATCH 06/10] =?UTF-8?q?Treat=20water-only=20heat-network=20prima?= =?UTF-8?q?ry=20pipework=20as=20insulated=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 | 68 +++++++++++++++++++ 1 file changed, 68 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 551e09d7d..dea7253a9 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1611,6 +1611,74 @@ def test_water_only_heat_network_cylinder_accrues_table_3_primary_loss() -> None assert all(m > 0.0 for m in wh_result.primary_loss_monthly_kwh) +def test_water_only_heat_network_primary_loss_matches_heat_network_main() -> None: + # Arrange — SAP 10.2 Table 3 (PDF p.160): "For heat networks apply the + # formula above with p = 1.0 and h = 3 for all months", and §4.3 (p.24) + # pins the pipework literal: "Primary circuit loss for INSULATED + # pipework". Both scope on the DHW source, so the two ways a dwelling + # can take DHW from a heat network must accrue the SAME (59)m: + # + # - WHC 901 "from main" where the space main IS the network, and + # - WHC 950, a water-only network behind a warm-air space main. + # + # Without this the water-only cert falls back to the RdSAP §3 age-band + # default (band E → p = 0.0, uninsulated), billing a heat network for + # bare primary pipework the spec says is insulated. + part = make_building_part(construction_age_band="E") + warm_air_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=9, + sap_main_heating_code=502, + ) + heat_network_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=6, + sap_main_heating_code=301, + ) + + def primary_loss_for( + main: MainHeatingDetail, water_heating_code: int + ) -> tuple[float, ...]: + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[main], + water_heating_code=water_heating_code, + cylinder_size=2, # Normal / 110 L + cylinder_thermostat="Y", + ), + ) + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=100.0, + is_instantaneous=False, + primary_age="E", + pcdb_record=None, + ) + assert wh_result is not None + return wh_result.primary_loss_monthly_kwh + + # Act — DHW from a water-only network vs DHW from a network space main. + water_only = primary_loss_for(warm_air_main, 950) + from_network_main = primary_loss_for(heat_network_main, 901) + + # Assert — Table 3's heat-network row keys on the DHW source, not the + # space main, so both bill the same insulated p=1.0 / h=3 loss. + assert water_only == pytest.approx(from_network_main, abs=1e-6) + + def test_gas_boiler_main_efficiency_unchanged_by_dlf_override() -> None: # Arrange — regression check: the DLF override only fires for heat- # network main heating. A standard gas boiler (cat=2, code=102) must From 7e355ce2ea2b2325995dac1fe330d6cecc920fc1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 10:29:42 +0000 Subject: [PATCH 07/10] =?UTF-8?q?Treat=20water-only=20heat-network=20prima?= =?UTF-8?q?ry=20pipework=20as=20insulated=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 3 ("For heat networks apply the formula above with p = 1.0 and h = 3 for all months") and §4.3's "insulated pipework" literal both scope on the DHW source, so a water-only network gets the same row as a network space main. Was falling back to the RdSAP §3 age-band default — band E → p=0, h=5 — billing 64.58 kWh in January against the spec's 23.26. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/sap10_calculator/rdsap/cert_to_inputs.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 21b05af9b..a1a8af635 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -7108,13 +7108,21 @@ def _primary_loss_override( water_heating_code=epc.sap_heating.water_heating_code, ): return None + # Heat-network DHW: either the space main is a network (WHC 901/902/914 + # inherit its DHW) or the DHW is its own water-only community scheme + # (WHC 950/951/952). SAP 10.2 §4.3 (PDF p.24) and Table 3's heat-network + # row both scope on the DHW source — "Where hot water is provided by a + # heat network" — not on the space main. + heat_network_dhw = is_heat_network_main(main) or ( + epc.sap_heating.water_heating_code in _WATER_HEAT_NETWORK_ONLY_CODES + ) # SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482) pins community- # heating primary pipework to "insulated" (p=1.0), overriding the # RdSAP §3 age-band default which would otherwise return 0 for # pre-2007 stock. See `_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION`. pipework_p = ( _HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION - if is_heat_network_main(main) + if heat_network_dhw else _pipework_insulation_fraction_table_3(primary_age) ) base = primary_loss_monthly_kwh( @@ -7126,7 +7134,7 @@ def _primary_loss_override( # row applies regardless of the thermostat / separate-timing # lodgement (and so is robust to the community-fuel-as-electric # collision that would otherwise route DHW to the h=5 row). - heat_network=is_heat_network_main(main), + heat_network=heat_network_dhw, ) # SAP 10.2 §12.4.4 (PDF p.36-37): for back-boiler combos summer DHW # comes from an electric immersion, not from the boiler — the boiler From 97ab27c91470fb25ecf1db4e7321e8ac2568ab6a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 10:33:10 +0000 Subject: [PATCH 08/10] =?UTF-8?q?Assert=20water-only=20heat-network=20fuel?= =?UTF-8?q?=20with=20the=20file's=20tolerance=20idiom=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pytest.approx is untyped under pyright strict, so the new assertions added 3 errors against the repo's zero-new-errors bar. Switches to the abs(...) <= tol form already dominant in this file. No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../domain/sap10_calculator/rdsap/test_cert_to_inputs.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 dea7253a9..fb76456dc 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1557,8 +1557,8 @@ def test_water_only_heat_network_dhw_fuel_independent_of_plant_type() -> None: heat_pump = hw_fuel_for(952) # Assert — the plant behind the network never reaches the dwelling. - assert chp == pytest.approx(boilers, abs=1e-6) - assert heat_pump == pytest.approx(boilers, abs=1e-6) + assert abs(chp - boilers) <= 1e-6 + assert abs(heat_pump - boilers) <= 1e-6 def test_water_only_heat_network_cylinder_accrues_table_3_primary_loss() -> None: @@ -1676,7 +1676,10 @@ def test_water_only_heat_network_primary_loss_matches_heat_network_main() -> Non # Assert — Table 3's heat-network row keys on the DHW source, not the # space main, so both bill the same insulated p=1.0 / h=3 loss. - assert water_only == pytest.approx(from_network_main, abs=1e-6) + assert all( + abs(water - from_main) <= 1e-6 + for water, from_main in zip(water_only, from_network_main) + ) def test_gas_boiler_main_efficiency_unchanged_by_dlf_override() -> None: From a400005353a675e36186a88131375404af05bf7a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 10:34:19 +0000 Subject: [PATCH 09/10] =?UTF-8?q?Ratchet=20the=20API-corpus=20within-0.5?= =?UTF-8?q?=20floor=20to=2078.8%=20for=20the=20water-only=20DHW=20fix=20?= =?UTF-8?q?=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enumerates the 3 corpus certs that can move (all WHC 950) individually per #1592 sub-task D: two improve (one crossing inside 0.5), one worsens and is handed to sub-task C for an accredited oracle. No loosening required. 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 5271c2434..6a74fdc4d 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -241,7 +241,26 @@ _CORPUS = Path( # fallback (see the `_MAX_SAP_MAE` note below for the mechanism). # 77.8% -> 78.6% via the RdSAP 10 §5.7 Table 13 200/280mm solid-brick-wall # boundary fix (see the `_MAX_SAP_MAE` note below). -_MIN_WITHIN_HALF_SAP = 0.786 +# 78.7% -> 78.8% (MAE 0.6273 -> 0.6272) via the water-only heat-network DHW +# fix (#1592 sub-task B): SAP 10.2 §4.3 (PDF p.24) puts the water-heating +# efficiency in the Table 12 price of delivered heat, and C4 (p.53) scopes the +# plant efficiency to "the calculation of CO2 emissions and Primary Energy" — +# so worksheet (310) = (64) × (305a) × (306) carries NO efficiency divisor. +# The WHC-950/951/952 branch had been applying it a second time dwelling-side. +# Also re-applies the Table 3 primary loss (p=1.0, h=3) for these certs. +# Only 3 corpus certs can move (all lodge WHC 950), enumerated individually: +# 5300047580 err 0.695 -> 0.249 (crosses inside 0.5 — this is the +0.1%) +# 10010254955 err 1.535 -> 0.795 (improves, still outside) +# 22032926 err 1.707 -> 2.737 (WORSENS — it over-rates, and the fix +# raises SAP further; a separate baseline residual that pashub's lodged +# `pre_sap` cannot adjudicate. Tracked as #1592 sub-task C, which pins an +# accredited Elmhurst worksheet as the oracle.) +# Net MAE −0.00016 over 1000 certs. PE 2.845 -> 2.867 and CO2 0.0701 -> 0.0702 +# both drift slightly the wrong way but stay well inside their loose ceilings; +# the plant efficiency C4 reserves for CO2/PE is a known follow-up. Spec- +# verified against the SAP 10.2 PDF, not the ticket prose; unit-pinned in +# test_cert_to_inputs (950/951/952 must bill identical DHW fuel). +_MIN_WITHIN_HALF_SAP = 0.788 # 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak # trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion) # -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57, From 53ba1f16943ff419e3f61d9effb0c4366528a818 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 15 Jul 2026 10:57:16 +0000 Subject: [PATCH 10/10] =?UTF-8?q?Re-baseline=20the=20pashub=20MAE=20ceilin?= =?UTF-8?q?g=20to=201.415=20for=20the=20water-only=20DHW=20fix=20?= =?UTF-8?q?=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second sanctioned exception to "ratchets never loosen" — needs @KhalimCK's call, since the prior note expected to re-tighten here. Exactly one cohort property is WHC 950 (16 Bingley Close, +5.946 -> +6.818 = the whole +0.0043); within-0.5 unchanged at 23.9%. Its pre_sap is verified correct but its ~+6 residual is a known extraction bug, and this hybrid gauge cannot adjudicate a calculator fix (#1592 Problem 2). The gov-API corpus, which gauges the calculator alone, improves. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/test_pashub_sap_accuracy.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy.py b/backend/documents_parser/tests/test_pashub_sap_accuracy.py index f1b8037ce..b178461f4 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy.py @@ -142,7 +142,36 @@ _MIN_WITHIN_HALF: float = 0.23 # RdSAP corpus). 16 Bingley Close reverts +3.2 -> +4.3, so observed MAE 1.405 -> # 1.4103; re-tighten when the water-only branch is fixed correctly under #1592 # (the cylinder-insulation MAPPER fix is retained, so the revert is partial). -_MAX_SAP_MAE: float = 1.411 +# DELIBERATELY LOOSENED 1.411 -> 1.415 by the #1592 sub-task B water-only DHW +# fix — the SECOND sanctioned exception to "ratchets never loosen", and the +# note above expected to RE-TIGHTEN here, so this needs @KhalimCK's explicit +# call before merge. +# +# The fix is spec-verified against the SAP 10.2 PDF (not ticket prose): §4.3 +# p.24 "The efficiency for water heating is incorporated in the price of heat +# from a heat network in Table 12"; C4 p.53 scopes the plant efficiency to +# "the calculation of CO2 emissions and Primary Energy"; C6 p.54 + worksheet +# (310) = (64) × (305a) × (306) carry no efficiency term. The dwelling-side +# `plant_eff / DLF` divisor was applying it a SECOND time. +# +# Exactly ONE cohort property is WHC 950 — 507644414148 (16 Bingley Close), +# +5.946 -> +6.818 = 0.872/205 = +0.0043 MAE, the whole movement. within-0.5 +# UNCHANGED at 23.9% (49/205); mean signed +1.050 -> +1.054. Observed 1.41458. +# +# Why this is not a regression signal: Bingley's `pre_sap` E52 is Khalim- +# verified correct (2026-07-14), but its ~+6 residual is a KNOWN extraction bug +# still being hunted (see HANDOVER_PASHUB_EXTRACTOR_ACCURACY.md: "community- +# heating residual — ... community standing charge/tariff detail, or DLF +# 'Unknown' distribution type"). This hybrid gauge conflates that extraction +# error with the SAP-10.2-vs-lodged-2012-RdSAP engine offset, so per #1592 +# Problem 2 it CANNOT adjudicate a calculator fix — the same reasoning the +# orientation exception above rests on. The gov-API RdSAP corpus, which gauges +# the calculator alone, IMPROVES on this change (within-0.5 78.7% -> 78.8%, +# MAE 0.6273 -> 0.6272; exactly 3 certs move, all WHC 950). +# +# Re-tighten under #1592 sub-task C, once an accredited Elmhurst 10.2 worksheet +# pins WHC-950 — Bingley is the natural capture candidate. +_MAX_SAP_MAE: float = 1.415 _KNOWN_GAP_REASON = ( "pashub `from_site_notes` mapper does not int-code a main-heating "