Slice S0380.19: count Elmhurst shower outlets by type (no more hardcoded 1)

Surfaces the lodged shower multiplicity from the Elmhurst Summary §16
on the EPC. Previously `_map_elmhurst_sap_heating` hardcoded:

  electric_shower_count = 1 if has_electric_shower else None
  mixer_shower_count    = 0 if has_electric_shower else None

losing the count for any cert with ≥ 2 outlets. Cert
7800-1501-0922-7127-3563 lodges TWO instantaneous electric showers
("Shower 01" + "Shower 11") but the mapper produced
`electric_shower_count=1`. After this slice:

  electric_shower_count = Σ(s for s in showers if s.outlet_type
                              == "Electric shower")
  mixer_shower_count    = Σ(s for s in showers if s.outlet_type
                              != "Electric shower")

**Cascade SAP effect:** None on cert 7800. Appendix J's eq J16
(`N_ES,per_outlet = N_shower / N_outlets`) and eq J18 (Σ_j E_ES,j)
are symmetric in N_electric_showers when there are no mixer outlets,
so the lodged (64a) kWh and (247a) cost are unchanged. The fix is
correctness-by-construction, not a delta-closer for the negative-band
certs (their +0.69 GBP total-cost gap traces to the gas hot-water
kWh path — separate slice).

**Hand-built fixture updates (5):** the cohort-1 hand-builts at
`domain/sap10_calculator/worksheet/tests/_elmhurst_worksheet_*.py`
previously omitted `electric_shower_count` / `mixer_shower_count`
(implicitly None), which matched the mapper's pre-slice None
sentinel. Updated each to the lodged counts the mapper now surfaces:
  000474: 1 mixer  → (0, 1)
  000477: 1 mixer  → (0, 1)
  000480: 1 mixer  → (0, 1)
  000490: 1 mixer  → (0, 1)
  000516: 1 mixer  → (0, 1)
000487 (already at (1, 0) for an electric-shower lodging) unchanged.

Tests:
- `test_summary_7800_two_electric_showers_count_as_two_not_one` —
  pins the multi-shower mapping for cert 7800 (Summary_000890.pdf).
- 5 hand-built field-parity tests
  (`test_from_elmhurst_site_notes_matches_hand_built_*`) now pass at
  the new integer counts instead of None.

Pyright net-zero per file:
- datatypes/epc/domain/mapper.py: 32 (baseline 32)
- backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0

Regression baseline: 699 pass + 10 fail (= prior 698 + 10 + 1 new).

Spec refs:
- SAP 10.2 Appendix J §1a — outlet counting drives `N_outlets` used
  in eq J6/J7 (mixer shower water draw) and eq J16/J17/J18 (electric
  shower energy).
- Cert 7800-1501-0922-7127-3563 Summary §16 "Showers" lodgement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 07:16:32 +00:00 committed by Jun-te Kim
parent 33ae3cc693
commit 15b3df1778
8 changed files with 60 additions and 24 deletions

Binary file not shown.

View file

@ -73,6 +73,7 @@ _SUMMARY_000902_PDF = _FIXTURES / "Summary_000902.pdf" # cert 9418
_SUMMARY_000889_PDF = _FIXTURES / "Summary_000889.pdf" # cert 2536 (Normal cylinder)
_SUMMARY_000884_PDF = _FIXTURES / "Summary_000884.pdf" # cert 9421 (Normal cylinder)
_SUMMARY_000910_PDF = _FIXTURES / "Summary_000910.pdf" # cert 0036 (Flat, party wall U=0)
_SUMMARY_000890_PDF = _FIXTURES / "Summary_000890.pdf" # cert 7800 (two electric showers)
# GOV.UK EPB API JSON for cert 001479 — the API-path counterpart of the
# Summary_001479.pdf fixture. Together they drive the API ≡ Summary
@ -945,6 +946,28 @@ def test_summary_mapper_raises_on_unmapped_glazing_type_label() -> None:
assert excinfo.value.value == "Quintuple glazed with helium"
def test_summary_7800_two_electric_showers_count_as_two_not_one() -> None:
# Arrange — cert 7800-1501-0922-7127-3563's Summary §16 lodges TWO
# instantaneous electric showers ("Shower 01" + "Shower 11", both
# `outlet_type='Electric shower'`). Pre-Slice S0380.19 the mapper
# hardcoded `electric_shower_count = 1 if has_electric_shower else
# None`, losing the multiplicity. Cascade-equivalent on this cert:
# Appendix J eq J16 (N_ES,per_outlet = N_shower / N_outlets) and
# eq J18 (Σ_j E_ES,j) yield the same (64a) value for 1 vs 2 outlets
# when there are no mixer outlets, so the SAP delta is unchanged
# — but the lodged multiplicity is now surfaced for any future
# cascade consumer that needs it.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000890_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
# Act
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Assert — both lodged electric showers surface on the EPC.
assert epc.sap_heating.electric_shower_count == 2
assert epc.sap_heating.mixer_shower_count == 0
def test_summary_0036_flat_unknown_party_wall_routes_to_u_zero() -> None:
# Arrange — cert 0036-6325-1100-0063-1226 is a "Flat, Mid-Terrace"
# whose Summary lodges party_wall_type='U Unable to determine'.

View file

@ -3564,12 +3564,19 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
# Shower-outlet classification: SAP10.2 Appendix J routes electric
# showers via §J line 64a (their own kWh stream) and treats mixer
# showers as drawing from the HW system. The Summary PDF lodges
# outlet_type as 'Electric shower' or 'Non-electric shower' — set
# the explicit counts so the cascade doesn't default mixer=1 on
# electric-only dwellings (000487).
has_electric_shower = any(
s.outlet_type == "Electric shower"
for s in survey.baths_and_showers.showers
# outlet_type as 'Electric shower' or 'Non-electric shower' — count
# each outlet by type so the cascade sees the actual lodged number
# (cert 7800-1501-0922-7127-3563 has 2 electric showers; the
# previous hardcoded `1 if has_electric_shower else None` lost the
# multiplicity, and `None` left the cascade defaulting to mixer=1
# on electric-only dwellings).
electric_shower_count_from_survey = sum(
1 for s in survey.baths_and_showers.showers
if s.outlet_type == "Electric shower"
)
mixer_shower_count_from_survey = sum(
1 for s in survey.baths_and_showers.showers
if s.outlet_type != "Electric shower"
)
# Water heating fuel: Summary §15 "Water Heating Fuel Type" lodges
# the fuel name as a string ("Mains gas", "Electricity", ...). Map
@ -3635,24 +3642,12 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
mh.secondary_heating_sap_code,
),
number_baths=survey.baths_and_showers.number_of_baths,
# Zero-shower lodgings resolve to explicit 0 (not None) so the
# cascade doesn't default-assume a mixer — same disposition
# the API path received in slice 102f-prep.8 ("API mapper
# resolves shower_outlets=None → 0 mixers") on cohort cert
# 2225. Non-zero shower lodgings keep the hand-built-fixture
# convention (None for non-electric → cascade derives count
# from `shower_outlets` instead) so the boiler-cohort parity
# tests in this file stay GREEN.
electric_shower_count=(
0
if not survey.baths_and_showers.showers
else (1 if has_electric_shower else None)
),
mixer_shower_count=(
0
if not survey.baths_and_showers.showers
else (0 if has_electric_shower else None)
),
# Both counts derived by tallying the lodged shower list. Zero-
# shower lodgings resolve to (0, 0) — the API path's slice 102f-
# prep.8 disposition for cert 2225. Multi-shower lodgings surface
# the lodged multiplicity (cert 7800: 2 electric showers).
electric_shower_count=electric_shower_count_from_survey,
mixer_shower_count=mixer_shower_count_from_survey,
)

View file

@ -221,6 +221,12 @@ def build_epc() -> EpcPropertyData:
epc.sap_heating.shower_outlets = ShowerOutlets(
shower_outlet=ShowerOutlet(shower_outlet_type="Non-electric shower"),
)
# Slice S0380.19: Elmhurst mapper now counts shower outlets by
# type (electric vs mixer) instead of the previous hardcoded
# 0/1/None sentinels. Cohort cert 000474 lodges 1 non-electric
# (mixer) outlet → electric=0, mixer=1.
epc.sap_heating.electric_shower_count = 0
epc.sap_heating.mixer_shower_count = 1
# Summary §14 "Heat pump age: Unknown" — surfaced by the Elmhurst
# mapper as the str dual-encoding that internal_gains.py reads.
# `make_main_heating_detail` doesn't expose the str kwarg, so set

View file

@ -188,6 +188,9 @@ def build_epc() -> EpcPropertyData:
epc.sap_heating.shower_outlets = ShowerOutlets(
shower_outlet=ShowerOutlet(shower_outlet_type="Non-electric shower"),
)
# Slice S0380.19: counted shower outlets (was: None/None sentinels).
epc.sap_heating.electric_shower_count = 0
epc.sap_heating.mixer_shower_count = 1
# Summary §14 "Heat pump age: Unknown" — surfaced by the Elmhurst
# mapper as the str dual-encoding that internal_gains.py reads.
# `make_main_heating_detail` doesn't expose the str kwarg, so set

View file

@ -238,6 +238,9 @@ def build_epc() -> EpcPropertyData:
epc.sap_heating.shower_outlets = ShowerOutlets(
shower_outlet=ShowerOutlet(shower_outlet_type="Non-electric shower"),
)
# Slice S0380.19: counted shower outlets (was: None/None sentinels).
epc.sap_heating.electric_shower_count = 0
epc.sap_heating.mixer_shower_count = 1
# Summary §14 "Heat pump age: Unknown" — surfaced by the Elmhurst
# mapper as the str dual-encoding that internal_gains.py reads.
epc.sap_heating.main_heating_details[0].central_heating_pump_age_str = "Unknown"

View file

@ -190,6 +190,9 @@ def build_epc() -> EpcPropertyData:
epc.sap_heating.shower_outlets = ShowerOutlets(
shower_outlet=ShowerOutlet(shower_outlet_type="Non-electric shower"),
)
# Slice S0380.19: counted shower outlets (was: None/None sentinels).
epc.sap_heating.electric_shower_count = 0
epc.sap_heating.mixer_shower_count = 1
epc.sap_heating.main_heating_details[0].central_heating_pump_age_str = "Unknown"
return epc

View file

@ -214,6 +214,9 @@ def build_epc() -> EpcPropertyData:
epc.sap_heating.shower_outlets = ShowerOutlets(
shower_outlet=ShowerOutlet(shower_outlet_type="Non-electric shower"),
)
# Slice S0380.19: counted shower outlets (was: None/None sentinels).
epc.sap_heating.electric_shower_count = 0
epc.sap_heating.mixer_shower_count = 1
epc.sap_heating.main_heating_details[0].central_heating_pump_age_str = "Unknown"
return epc