mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.140: §4 cylinder storage loss — extractor picks up §16 thermostat lodging + Table 2b note b restricts ×0.9 to boiler/warm-air/HP systems
Two compounding bugs were over-counting the SAP 10.2 §4 (56)m cylinder
storage loss by ~76 kWh/yr across all 17 cylinder-with-immersion
corpus variants (cascade HW kWh 2460.40 vs worksheet 2384.12):
(1) **Extractor gap.** Elmhurst Summary §15.1 "Hot Water Cylinder"
block lodges `Cylinder Size` / `Insulation Thickness` but NOT
`Cylinder Thermostat`. The thermostat is lodged separately in
§16 "Recommendations" as `Cylinder thermostat (Already installed)`.
The extractor only searched §15.1, so `cylinder_thermostat`
resolved to None for every variant on property 001431. The
cascade then defaulted `has_cylinder_thermostat=False`, applying
SAP 10.2 Table 2b's ×1.3 "no thermostat" multiplier.
(2) **Cascade spec gap.** `_separately_timed_dhw` returned True for
any cylinder-lodged cert regardless of HW fuel. Per SAP 10.2
Table 2b note b) (PDF p.159):
> "Multiply Temperature Factor by 0.9 if there is separate time
> control of domestic hot water (boiler systems, warm air systems
> and heat pump systems)"
Electric immersion is NOT in the bracketed list — the ×0.9
reduction is restricted to boiler / warm-air / HP systems. Pre-
slice the cascade over-applied ×0.9 on electric-immersion certs.
Combined, the cascade computed TF = 0.60 × 1.3 × 0.9 = 0.702 vs the
worksheet's TF = 0.60 (base — thermostat present, immersion exempt).
After both fixes the cascade HW kWh matches the worksheet's (64) at
1e-3 precision (2384.116 vs 2384.12).
Corpus impact (16 cylinder-with-immersion variants on 18-hour meter):
| variant | SAP_c shift | Cost shift |
|--------------|------------:|-----------:|
| electric 1 | -0.20 → -0.06 | -£3.34 |
| electric 2 | -1.27 → +0.47 | -£4.44 |
| electric 3 | +2.42 → +2.55 | -£2.91 |
| electric 5 | -0.06 → +0.07 | -£3.06 |
| electric 6 | +1.19 → +1.33 | -£3.20 |
| electric 7 | +1.14 → +1.29 | -£3.35 |
| electric 8 | -0.41 → -0.26 | -£3.50 |
| electric 9 | -0.24 → -0.12 | -£2.91 |
| solid fuel 4-11 | -0.45..-0.09 → -0.29..+0.10 | -£3 to -£4 |
The HW kWh line closes cleanly; some SAP residuals sign-flip slightly
because the cascade's now-correct HW kWh exposes the SH+Sec demand
mismatch for storage heaters (electric 3/6/7 — open driver is the
Table 11 `main_heating_category=None` default for codes 401/402,
queued for a mapper-side slice).
Tests:
- new AAA test `test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b`
- 16 corpus pins re-tightened (8 electric + 8 solid fuel)
Extended handover suite: 883 pass (was 882; +1 new test), 0 fail.
Pyright net-zero on touched files (43 → 43 errors, all pre-existing).
Per [[feedback-spec-citation-in-commits]] +
[[feedback-spec-floor-skepticism]] (the "HW +76 kWh uniform overcount"
across 17 variants traced to TWO spec-citable defaults the cascade
was getting wrong, not a precision floor).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c4db37db19
commit
068088bc2f
4 changed files with 137 additions and 20 deletions
|
|
@ -1344,6 +1344,19 @@ class ElmhurstSiteNotesExtractor:
|
|||
if cylinder_thermostat_raw is not None
|
||||
else None
|
||||
)
|
||||
# Fallback: Elmhurst Summary §16 "Recommendations" block carries
|
||||
# existing fittings as `<feature> (Already installed)` lines.
|
||||
# When §15.1 doesn't lodge "Cylinder Thermostat" directly, treat
|
||||
# the "Cylinder thermostat (Already installed)" recommendation
|
||||
# line as confirmation that the thermostat is present (per
|
||||
# S0380.140 corpus probe — all 41 variants on property 001431
|
||||
# lodge this in §16 but none in §15.1, so the §15.1-only lookup
|
||||
# returned None and the cascade defaulted `has_cylinder_thermostat
|
||||
# = False`, mis-applying SAP 10.2 Table 2b's ×1.3 "no thermostat"
|
||||
# multiplier).
|
||||
if cylinder_thermostat is None:
|
||||
if "Cylinder thermostat (Already installed)" in self._lines:
|
||||
cylinder_thermostat = True
|
||||
return WaterHeating(
|
||||
water_heating_code=self._str_val("Water Heating Code"),
|
||||
water_heating_sap_code=self._int_val("Water Heating SapCode"),
|
||||
|
|
|
|||
|
|
@ -196,16 +196,38 @@ class _CorpusExpectation:
|
|||
# = Table 11 Cat 7). Total absolute SAP residual across the cluster
|
||||
# went from 10.10 to 5.46. _RDSAP_DEFINITELY_OFF_PEAK frozenset was
|
||||
# deleted (dead code; canonical dispatch covers it).
|
||||
#
|
||||
# Slice S0380.140 fixed the §4 worksheet (56)m cylinder storage loss
|
||||
# cascade. Two compounding bugs were over-counting (56)m by ~76 kWh/yr
|
||||
# across all 17 cylinder-with-immersion corpus variants:
|
||||
# (1) the Elmhurst Summary §16 "Recommendations" block lodges the
|
||||
# cylinder thermostat as "Cylinder thermostat (Already
|
||||
# installed)" — but the extractor only looked in §15.1 for the
|
||||
# label "Cylinder Thermostat", so the field was None for every
|
||||
# variant on property 001431. The cascade defaulted
|
||||
# `has_cylinder_thermostat=False`, mis-applying SAP 10.2 Table
|
||||
# 2b's ×1.3 "no thermostat" multiplier;
|
||||
# (2) `_separately_timed_dhw` returned True for any cylinder cert,
|
||||
# but Table 2b note b restricts the ×0.9 separately-timed
|
||||
# multiplier to "boiler systems, warm air systems and heat
|
||||
# pump systems" — electric immersion is not in the list.
|
||||
# Combined, the cascade computed TF = 0.60 × 1.3 × 0.9 = 0.702 vs
|
||||
# the worksheet's TF = 0.60 (base — thermostat present, immersion
|
||||
# exempt from ×0.9). After both fixes the cascade HW kWh matches the
|
||||
# worksheet's (64) at 1e-3 (2384.116 vs 2384.12). Cost shifts -£3..-£6
|
||||
# per affected variant, SAP residuals shift ±0.15 across 16 variants;
|
||||
# the SH+Sec demand mismatch for electric 3/6/7 (Table 11 fraction
|
||||
# for codes 401/402) remains the open driver of those SAP residuals.
|
||||
_EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
|
||||
_CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+0.2418, expected_cost_resid_gbp=-5.5706, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=-11.8017),
|
||||
_CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.2021, expected_cost_resid_gbp=+4.6562, expected_co2_resid_kg=+14.3441, expected_pe_resid_kwh=+164.9052),
|
||||
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-1.2714, expected_cost_resid_gbp=+29.2944, expected_co2_resid_kg=+94.4364, expected_pe_resid_kwh=+970.7570),
|
||||
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+2.4189, expected_cost_resid_gbp=-55.7339, expected_co2_resid_kg=-112.3439, expected_pe_resid_kwh=-1059.2875),
|
||||
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-0.0579, expected_cost_resid_gbp=+1.3337, expected_co2_resid_kg=-5.3096, expected_pe_resid_kwh=-95.6333),
|
||||
_CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+1.1888, expected_cost_resid_gbp=-27.3926, expected_co2_resid_kg=-50.0685, expected_pe_resid_kwh=-494.3960),
|
||||
_CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+1.1449, expected_cost_resid_gbp=-26.3805, expected_co2_resid_kg=-31.5507, expected_pe_resid_kwh=-427.5932),
|
||||
_CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.4086, expected_cost_resid_gbp=+9.4133, expected_co2_resid_kg=+18.2051, expected_pe_resid_kwh=+199.7233),
|
||||
_CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-0.2444, expected_cost_resid_gbp=+5.6333, expected_co2_resid_kg=+11.1781, expected_pe_resid_kwh=+154.0936),
|
||||
_CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0573, expected_cost_resid_gbp=+1.3188, expected_co2_resid_kg=+8.0120, expected_pe_resid_kwh=+94.4789),
|
||||
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=+0.4737, expected_cost_resid_gbp=-10.9153, expected_co2_resid_kg=+10.9544, expected_pe_resid_kwh=+100.9401),
|
||||
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+2.5452, expected_cost_resid_gbp=-58.6455, expected_co2_resid_kg=-117.8401, expected_pe_resid_kwh=-1121.9666),
|
||||
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=+0.0747, expected_cost_resid_gbp=-1.7232, expected_co2_resid_kg=-11.0752, expected_pe_resid_kwh=-161.0345),
|
||||
_CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+1.3278, expected_cost_resid_gbp=-30.5954, expected_co2_resid_kg=-56.1047, expected_pe_resid_kwh=-562.5298),
|
||||
_CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+1.2903, expected_cost_resid_gbp=-29.7300, expected_co2_resid_kg=-37.8591, expected_pe_resid_kwh=-498.4709),
|
||||
_CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.2568, expected_cost_resid_gbp=+5.9163, expected_co2_resid_kg=+11.6231, expected_pe_resid_kwh=+126.0896),
|
||||
_CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-0.1181, expected_cost_resid_gbp=+2.7217, expected_co2_resid_kg=+5.6819, expected_pe_resid_kwh=+91.4145),
|
||||
_CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+1.1491, expected_cost_resid_gbp=-26.4775, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=-454.5023),
|
||||
_CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+2.6578, expected_cost_resid_gbp=-61.2402, expected_co2_resid_kg=-242.2677, expected_pe_resid_kwh=-1050.4919),
|
||||
_CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239),
|
||||
|
|
@ -221,14 +243,14 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
|
|||
# control-type gaps — separate slices.
|
||||
_CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.6383, expected_cost_resid_gbp=-60.7914, expected_co2_resid_kg=+53.9038, expected_pe_resid_kwh=-1211.3624),
|
||||
_CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3216, expected_cost_resid_gbp=-30.4512, expected_co2_resid_kg=-428.6594, expected_pe_resid_kwh=-934.5983),
|
||||
_CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=-0.4528, expected_cost_resid_gbp=+10.4331, expected_co2_resid_kg=-78.9461, expected_pe_resid_kwh=+151.1685),
|
||||
_CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=-0.3350, expected_cost_resid_gbp=+7.7205, expected_co2_resid_kg=-52.5294, expected_pe_resid_kwh=+160.0328),
|
||||
_CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=-0.0902, expected_cost_resid_gbp=+2.0800, expected_co2_resid_kg=+4.8671, expected_pe_resid_kwh=+87.0778),
|
||||
_CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+0.0025, expected_cost_resid_gbp=-0.0583, expected_co2_resid_kg=-91.3569, expected_pe_resid_kwh=+44.3084),
|
||||
_CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=-0.2280, expected_cost_resid_gbp=+5.2530, expected_co2_resid_kg=+26.9399, expected_pe_resid_kwh=+87.6830),
|
||||
_CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=-0.3344, expected_cost_resid_gbp=+7.7031, expected_co2_resid_kg=+28.0233, expected_pe_resid_kwh=+154.9673),
|
||||
_CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=-0.2932, expected_cost_resid_gbp=+6.7559, expected_co2_resid_kg=+25.7581, expected_pe_resid_kwh=+119.8372),
|
||||
_CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=-0.4180, expected_cost_resid_gbp=+9.6325, expected_co2_resid_kg=+32.7399, expected_pe_resid_kwh=+170.5611),
|
||||
_CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=-0.2919, expected_cost_resid_gbp=+6.7262, expected_co2_resid_kg=-68.4116, expected_pe_resid_kwh=+89.7782),
|
||||
_CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=-0.1655, expected_cost_resid_gbp=+3.8136, expected_co2_resid_kg=-44.3197, expected_pe_resid_kwh=+92.8384),
|
||||
_CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+0.0281, expected_cost_resid_gbp=-0.6473, expected_co2_resid_kg=+0.6642, expected_pe_resid_kwh=+44.7851),
|
||||
_CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+0.0994, expected_cost_resid_gbp=-2.3310, expected_co2_resid_kg=-75.1034, expected_pe_resid_kwh=+16.7917),
|
||||
_CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=-0.0804, expected_cost_resid_gbp=+1.8511, expected_co2_resid_kg=+18.0444, expected_pe_resid_kwh=+45.1812),
|
||||
_CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=-0.1956, expected_cost_resid_gbp=+4.5065, expected_co2_resid_kg=+19.6820, expected_pe_resid_kwh=+92.8981),
|
||||
_CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=-0.1605, expected_cost_resid_gbp=+3.6988, expected_co2_resid_kg=+17.7916, expected_pe_resid_kwh=+66.5227),
|
||||
_CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=-0.2633, expected_cost_resid_gbp=+6.0671, expected_co2_resid_kg=+23.5398, expected_pe_resid_kwh=+104.1723),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3432,21 +3432,35 @@ def _separately_timed_dhw(
|
|||
"""SAP 10.2 Table 2b note b) (PDF p.159): "Multiply Temperature
|
||||
Factor by 0.9 if there is separate time control of domestic hot
|
||||
water (boiler systems, warm air systems and heat pump systems)".
|
||||
RdSAP §3 default: when a hot-water cylinder is lodged, DHW timing
|
||||
is separate from space heat — the cylinder is heated on its own
|
||||
programmer / overnight boost regardless of which heat generator
|
||||
(boiler, HP, or combi-acting-as-boiler) feeds it.
|
||||
The spec restricts the ×0.9 reduction to those three system types
|
||||
— electric immersion DHW is NOT in the list, so the ×0.9 multiplier
|
||||
must NOT apply when the water-heating fuel is electric (whether
|
||||
on a standard meter or off-peak immersion timer).
|
||||
|
||||
RdSAP §3 default: when a hot-water cylinder is lodged AND the
|
||||
cylinder is fed by a boiler / warm-air / HP, DHW timing is separate
|
||||
from space heat — the cylinder is heated on its own programmer /
|
||||
overnight boost regardless of which heat generator feeds it.
|
||||
|
||||
Combi-only dwellings (no cylinder) skip the multiplier — DHW is
|
||||
instantaneous and shares the boiler's space-heating cycle, so
|
||||
there's no separate timer. Heat pumps (cat 4) keep their existing
|
||||
always-True default for the HP-without-cylinder edge case the
|
||||
earlier cohort calibration was sized around.
|
||||
|
||||
Pre-S0380.140 this returned True for any cylinder-lodged cert
|
||||
regardless of HW fuel, which over-applied the ×0.9 multiplier on
|
||||
electric-immersion certs. Combined with the cascade's
|
||||
`cylinder_thermostat is None → False` fallback (over-applying ×1.3),
|
||||
these compounded to TF=0.702 vs the worksheet's TF=0.60, over-
|
||||
counting (56)m storage loss by ~76 kWh/yr × 17 corpus variants.
|
||||
"""
|
||||
if main is None:
|
||||
return False
|
||||
if main.main_heating_category == 4:
|
||||
return True
|
||||
if _is_electric_water(epc.sap_heating.water_heating_fuel):
|
||||
return False
|
||||
return bool(epc.has_hot_water_cylinder)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
|||
_pv_pitch_deg, # pyright: ignore[reportPrivateUsage]
|
||||
_responsiveness, # pyright: ignore[reportPrivateUsage]
|
||||
_secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage]
|
||||
_separately_timed_dhw, # pyright: ignore[reportPrivateUsage]
|
||||
_space_heating_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
|
||||
_tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage]
|
||||
_water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage]
|
||||
|
|
@ -1430,6 +1431,73 @@ def test_is_off_peak_meter_recognises_bare_18_hour_lodging() -> None:
|
|||
assert _is_off_peak_meter("Unknown", fuel_is_electric=False) is False
|
||||
|
||||
|
||||
def test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b() -> None:
|
||||
# Arrange — SAP 10.2 Table 2b note b) (PDF p.159) restricts the
|
||||
# `×0.9 separately-timed` temperature-factor multiplier to "boiler
|
||||
# systems, warm air systems and heat pump systems". Electric
|
||||
# immersion DHW is NOT in this list — its `separately-timed` schedule
|
||||
# is implicit in the off-peak tariff and doesn't earn the ×0.9
|
||||
# storage-loss reduction. Pre-S0380.140 `_separately_timed_dhw`
|
||||
# returned True for every cylinder-lodged cert regardless of HW fuel,
|
||||
# which over-applied the ×0.9 multiplier on electric-immersion certs.
|
||||
# Combined with the cascade's `cylinder_thermostat is None → False`
|
||||
# fallback that simultaneously over-applied ×1.3, these compounded
|
||||
# to TF=0.702 (≈0.60×1.3×0.9) when the worksheet uses TF=0.60 (base
|
||||
# — has thermostat, not separately timed for immersion). The
|
||||
# cylinder-storage-loss (56)m line over-counted by ~76 kWh/yr across
|
||||
# the 17 cylinder-with-immersion variants of the corpus.
|
||||
main_gas_boiler = MainHeatingDetail(
|
||||
has_fghrs=False,
|
||||
main_fuel_type=26, # mains gas
|
||||
heat_emitter_type=1,
|
||||
emitter_temperature=1,
|
||||
main_heating_control=2106,
|
||||
main_heating_category=2, # gas boiler
|
||||
sap_main_heating_code=102,
|
||||
)
|
||||
cylinder_with_gas_boiler_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_heating=make_sap_heating(
|
||||
main_heating_details=[main_gas_boiler],
|
||||
water_heating_fuel=26, # gas → boiler-fed HW
|
||||
water_heating_code=901,
|
||||
cylinder_size=2,
|
||||
cylinder_insulation_type=1,
|
||||
cylinder_insulation_thickness_mm=38,
|
||||
),
|
||||
)
|
||||
cylinder_with_immersion_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_heating=make_sap_heating(
|
||||
main_heating_details=[main_gas_boiler],
|
||||
water_heating_fuel=30, # standard electricity → immersion
|
||||
water_heating_code=903,
|
||||
cylinder_size=2,
|
||||
cylinder_insulation_type=1,
|
||||
cylinder_insulation_thickness_mm=38,
|
||||
),
|
||||
)
|
||||
|
||||
# Act
|
||||
sep_boiler_fed = _separately_timed_dhw(
|
||||
cylinder_with_gas_boiler_epc, main_gas_boiler,
|
||||
)
|
||||
sep_immersion = _separately_timed_dhw(
|
||||
cylinder_with_immersion_epc, main_gas_boiler,
|
||||
)
|
||||
|
||||
# Assert — gas-boiler-fed cylinder keeps the ×0.9 multiplier (boiler
|
||||
# system per Table 2b note b); electric-immersion cylinder does not.
|
||||
assert sep_boiler_fed is True
|
||||
assert sep_immersion is False
|
||||
|
||||
|
||||
def test_space_heating_off_peak_fallback_uses_actual_tariff_low_rate_not_e7() -> None:
|
||||
# Arrange — an electric storage heater (SAP code 401) on an 18-hour
|
||||
# tariff. `_table_12a_system_for_main` returns None for storage
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue