From dc271e959af784b3ceee8d11410dc5914dd592e2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 15 Jul 2026 09:09:18 +0000 Subject: [PATCH] Revert the water-only heat-network calculator changes (tracked in #1592) Backs out the WHC-950/951/952 primary-loss (d3c0b9e4) and flat-rate-charging (fecf4f62) changes to cert_to_inputs.py: they fire on ANY cert with those water-heating codes, regressing the gov-API RdSAP corpus (test_api_path_sap_accuracy_on_rdsap_21_0_1_corpus MAE 0.627 -> 0.630), and an Opus audit found the water-only branch has a pre-existing plant-efficiency double count they flow more kWh through. The calculator work + the correct fix (water_eff = 1.0/(DLF*1.05)) + an Elmhurst-worksheet regression are tracked in #1592, isolated from the extractor accuracy PR. The cylinder- insulation MAPPER fix (f5e4814b) is retained. Co-Authored-By: Claude Fable 5 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 37 +---------- .../rdsap/test_cert_to_inputs.py | 63 +------------------ 2 files changed, 6 insertions(+), 94 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 3daae3c28..7ebf4d40e 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1187,11 +1187,6 @@ _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) flat-rate-charging -# factor — the RdSAP 10 (spec p.59) default for a water-ONLY heat network -# (WHC 950/951/952), whose own charging method is never lodged. -_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 @@ -6590,16 +6585,6 @@ 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 "Heat networks" (PDF p.17 line 1482): "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-network - # space main (HP, direct-acting electric) can't zero it out. - 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- @@ -7104,20 +7089,13 @@ 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) — the Table 3 heat-network row keys on the DHW - # source, 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 heat_network_dhw + if is_heat_network_main(main) else _pipework_insulation_fraction_table_3(primary_age) ) base = primary_loss_monthly_kwh( @@ -7129,7 +7107,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=heat_network_dhw, + heat_network=is_heat_network_main(main), ) # 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 @@ -8029,16 +8007,7 @@ def cert_to_inputs( # 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. - # RdSAP 10 (spec p.59, "Heat Network (HN) supplying — water - # heating only"): a non-PCDB water-only network defaults to - # "flat-rate charging", so the Table 4c(3) worksheet (305a) - # factor 1.05 always applies — the scheme's own charging method - # is never lodged (the dwelling's heating-control code describes - # the SPACE network's charging, not this one's). - water_eff = water_eff / ( - _heat_network_dlf(primary_age) - * _TABLE_4C3_FLAT_RATE_CHARGING_FACTOR - ) + water_eff = water_eff / _heat_network_dlf(primary_age) 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 4149fe9b7..f0a2e14ac 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -478,57 +478,6 @@ def test_heat_network_primary_loss_uses_p1_h3_all_months_per_table_3() -> None: assert abs(sum(wh_result.primary_loss_monthly_kwh) - expected_primary_kwh) <= 1e-6 -def test_water_only_heat_network_cylinder_accrues_primary_loss() -> None: - # Arrange — DHW from a water-ONLY community scheme (WHC 950) with a - # lodged 210 L cylinder, on a heat-network space main (Table 4a 301, - # cat 6) — fixture 507644414148's shape. SAP 10.2 §4 "Heat networks" - # (PDF p.17 line 1482): "Primary circuit loss for insulated pipework - # and cylinderstat should be included (see Table 3)" — the network - # feeds the dwelling-side cylinder via primary pipework whichever WHC - # lodges it, so the loss applies to 950/951/952 exactly as to - # 901/902/914 (pre-fix these fell through to zero). Table 3 heat- - # network row: p = 1.0 and h = 3 for all months. - main = MainHeatingDetail( - has_fghrs=False, - main_fuel_type=51, # Table 12 community heat (from boilers) - heat_emitter_type=1, - emitter_temperature=1, - main_heating_control=2306, - main_heating_category=6, - sap_main_heating_code=301, - ) - part = make_building_part(construction_age_band="F") - 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=950, - water_heating_fuel=51, - cylinder_size=4, # Large (>170 litres) → 210 L - cylinder_insulation_type=1, - cylinder_insulation_thickness_mm=50, - ), - ) - - # Act - wh_result, _ = _water_heating_worksheet_and_gains( - epc=epc, - water_efficiency_pct=100.0, - is_instantaneous=False, - primary_age="F", - pcdb_record=None, - ) - - # Assert — p=1.0, h=3 all months → 365 × 14 × (0.0091×3 + 0.0263). - assert wh_result is not None - expected_primary_kwh = 365.0 * 14.0 * (0.0091 * 3.0 + 0.0263) - assert abs(sum(wh_result.primary_loss_monthly_kwh) - expected_primary_kwh) <= 1e-6 - - def test_dual_main_system_2_costed_at_its_own_fuel_price() -> None: # Arrange — SAP 10.2 §10a: main heating system 2's fuel cost (worksheet # line (213)) is billed at ITS OWN Table 32 fuel price, separately from @@ -1460,12 +1409,7 @@ def test_hot_water_only_heat_network_whc_950_applies_table12c_dlf() -> None: # 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 the DLF (age E → 1.41) × the Table 4c(3) - # charging factor. RdSAP 10 (spec p.59, "Heat Network (HN) supplying - # — water heating only"): a non-PCDB water-only network defaults to - # "flat-rate charging" — worksheet (305a) factor 1.05 — since the - # scheme's own charging method is never lodged (the dwelling's - # heating-control code describes the SPACE network, not this one). + # HW fuel must exceed it by exactly the DLF (age E → 1.41). part = make_building_part(construction_age_band="E") gas_main = MainHeatingDetail( has_fghrs=False, @@ -1501,9 +1445,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 - # × the flat-rate-charging default (1.41 × 1.05 = 1.4805). - assert abs(hw_network / baseline - 1.41 * 1.05) <= 0.02 + # Assert — the HW-only-heat-network fuel is scaled up by the age-E DLF. + assert abs(hw_network / baseline - 1.41) <= 0.02 def test_gas_boiler_main_efficiency_unchanged_by_dlf_override() -> None: