Cohort residual slice 5: 000477 build_epc lodgement (partial — Table 3c blocker)

Lodges the missing cert fields on 000477 build_epc to match U985 PDF:
  - sap_windows = SECTION_6_VERTICAL_WINDOWS (was empty)
  - low_energy_fixed_lighting_bulbs_count = 9 (was None)
  - sap_heating.main_heating_details with PCDF index 18118 (was default)
  - sap_heating.secondary_heating_type = 691 (was None)
  - sap_heating.number_baths = 0 (PDF lodges 0 baths; was None → defaulted to "has bath"=True)

`make_sap_heating` accepts a new `number_baths` kwarg to surface that
field — it lives on SapHeating but wasn't exposed before.

Impact: 000477 SAP integer 71 → 66 (PDF 65, Δ +6 → +1); cost £599 →
£707 vs PDF £732 (Δ -22% → -3.5%); useful 9059 → 10067 vs PDF 10111
(matches to <0.5%).

Remaining +1 SAP integer delta is the **Table 3c two-profile combi-
loss override** — not yet implemented. PCDB 18118 (Vaillant ecoTEC
sustain 24) lodges separate_dhw_tests=2 → spec Appendix J §J3 uses
both Profile M (F1, R1) and Profile L (F2, R2) loss factors. Our
override gate (`_pcdb_table_3b_combi_loss_override`) only accepts
separate_dhw_tests==1 → falls back to Table 3a keep-hot time-clock
600 kWh/yr default = 25x overshoot vs the fixture-pinned ~24 kWh/yr.

The same gap blocks 000480 (PCDB 16839 — but actually wait, 16839 is
in 000490 too and that already closes — needs checking), 000487 (PCDB
18119), and 000516 (PCDB 18118).

Test pin `test_elmhurst_000477_end_to_end_sap_score_matches_pdf`
xfail (strict) with rationale pointing at Table 3c. Re-enables when
the override implements.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 12:04:24 +00:00
parent a41ac6bd74
commit 960419a901
3 changed files with 67 additions and 1 deletions

View file

@ -98,6 +98,7 @@ def make_sap_heating(
cylinder_insulation_thickness_mm: Optional[int] = None,
secondary_fuel_type: Optional[int] = None,
secondary_heating_type: Optional[int] = None,
number_baths: Optional[int] = None,
) -> SapHeating:
"""Build a SapHeating with SAP10 API defaults."""
return SapHeating(
@ -112,6 +113,7 @@ def make_sap_heating(
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm,
secondary_fuel_type=secondary_fuel_type,
secondary_heating_type=secondary_heating_type,
number_baths=number_baths,
)

View file

@ -26,7 +26,12 @@ from datatypes.epc.domain.epc_property_data import (
SapVentilation,
SapWindow,
)
from domain.ml.tests._fixtures import make_minimal_sap10_epc, make_window
from domain.ml.tests._fixtures import (
make_main_heating_detail,
make_minimal_sap10_epc,
make_sap_heating,
make_window,
)
from domain.sap.worksheet.solar_gains import RoofWindowInput, RooflightInput
from domain.sap.worksheet.ventilation import MechanicalVentilationKind
from domain.sap.worksheet.water_heating import TABLE_J1_TCOLD_FROM_MAINS_C
@ -76,6 +81,8 @@ def build_epc() -> EpcPropertyData:
heated_rooms_count=4,
door_count=2,
percent_draughtproofed=100,
low_energy_fixed_lighting_bulbs_count=SECTION_5_BULB_COUNT_LEL,
sap_windows=list(SECTION_6_VERTICAL_WINDOWS),
sap_ventilation=SapVentilation(
extract_fans_count=2,
sheltered_sides=2,
@ -83,6 +90,16 @@ def build_epc() -> EpcPropertyData:
suspended_timber_floor_sealed=False,
has_draught_lobby=False,
),
sap_heating=make_sap_heating(
main_heating_details=[
make_main_heating_detail(
main_heating_index_number=18118,
main_heating_data_source=1,
),
],
secondary_heating_type=691,
number_baths=0, # PDF: Total number of baths in property = 0
),
)

View file

@ -24,6 +24,7 @@ from domain.sap.calculator import Sap10Calculator
from domain.sap.rdsap.cert_to_inputs import cert_to_inputs
from domain.sap.worksheet.tests import (
_elmhurst_worksheet_000474 as _w000474,
_elmhurst_worksheet_000477 as _w000477,
_elmhurst_worksheet_000490 as _w000490,
)
from domain.sap.worksheet.tests._elmhurst_fixtures import (
@ -53,6 +54,14 @@ _ELMHURST_000490_EXPECTED: Final[ElmhurstExpectedSap] = ElmhurstExpectedSap(
total_energy_cost_gbp=807.5421,
ecf=3.0539,
)
_ELMHURST_000477_EXPECTED: Final[ElmhurstExpectedSap] = ElmhurstExpectedSap(
sap_rating=65,
sap_score_continuous=65.0050,
space_heating_kwh=10111.2019,
hot_water_kwh=2116.0365,
total_energy_cost_gbp=732.1396,
ecf=2.5086,
)
_ELMHURST_000474_EXPECTED: Final[ElmhurstExpectedSap] = ElmhurstExpectedSap(
sap_rating=62,
sap_score_continuous=62.2584,
@ -63,6 +72,44 @@ _ELMHURST_000474_EXPECTED: Final[ElmhurstExpectedSap] = ElmhurstExpectedSap(
)
@pytest.mark.xfail(
reason=(
"Table 3c two-profile combi-loss override not yet implemented. PCDB "
"18118 (Vaillant ecoTEC sustain 24) lodges separate_dhw_tests=2 → "
"spec routes to Table 3c, which uses both Profile M (F1, R1) and "
"Profile L (F2, R2) loss factors. Our override gate (`_pcdb_table_"
"3b_combi_loss_override`) only accepts separate_dhw_tests==1 (Table "
"3b row 1, single-profile) → falls back to Table 3a keep-hot time-"
"clock 600 kWh/yr default = 25x overshoot on combi loss → +712 HW "
"kWh → continuous SAP +0.83 over PDF (66 vs 65). Re-enable when "
"Table 3c lands per the next ticket (see project memory)."
),
strict=True,
)
def test_elmhurst_000477_end_to_end_sap_score_matches_pdf() -> None:
"""Cohort closure pin for 000477. Mid-terrace combi-gas with PCDF
Vaillant ecoTEC sustain 24 (index 18118) + Electricity Electric
Panel secondary heater (SAP code 691). PDF SAP rating 65."""
# Arrange
epc = _w000477.build_epc()
# Act
result = Sap10Calculator().calculate(epc)
# Assert — integer match (the rdsap engine integration gate).
delta = abs(result.sap_score - _ELMHURST_000477_EXPECTED.sap_rating)
assert delta == 0, (
f"SAP rating delta {delta} — expected 0 (integer match with PDF). "
f"Actual={result.sap_score}, expected={_ELMHURST_000477_EXPECTED.sap_rating}."
)
continuous_delta = abs(
result.sap_score_continuous - _ELMHURST_000477_EXPECTED.sap_score_continuous
)
assert continuous_delta <= 0.5, (
f"Continuous SAP delta {continuous_delta:.2f} exceeds ceiling 0.5"
)
def test_elmhurst_000490_end_to_end_sap_score_currently_within_3_points() -> None:
"""Mid-terrace combi-gas dwelling with time-clock keep-hot. After the
PCDB Table 105 integration the fixture lodges `main_heating_index_