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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-15 09:09:18 +00:00
parent 21674d5ed0
commit dc271e959a
2 changed files with 6 additions and 94 deletions

View file

@ -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)

View file

@ -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: