Slice S0380.127: resolve Elmhurst "No Access" cylinder via RdSAP 10 Table 28

Elmhurst Summary §15.1 sometimes lodges "Cylinder Size: No Access" (the
inaccessible-cylinder lodging form). Pre-slice the mapper strict-raised
`UnmappedElmhurstLabel` because `_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10`
only carried the three lodged-size labels (Normal/Medium/Large).

Per RdSAP 10 Specification Table 28 page 55 ("Cylinder size"):

  > "Inaccessible:
  >   - if off-peak electric dual immersion: 210 litres
  >   - if from solid fuel boiler: 160 litres
  >   - otherwise: 110 litres"

And per §10.5.1 page 53:

  > "An electric immersion is assumed dual in the following cases:
  >  - cylinder is inaccessible and electricity tariff is dual"

So the 210-L "off-peak electric dual immersion" branch fires automatically
when both (a) cylinder is inaccessible AND (b) water heating is electric
AND (c) meter type is dual / off-peak (no separate dual-immersion lodging
required).

New helper `_resolve_elmhurst_inaccessible_cylinder_size` keys off
§15.0 "Water Heating Fuel Type" + §14.2 "Electricity meter type":

  - solid fuel water heating fuel (Anthracite, House coal, Wood, etc.)
    → 160 L → SAP10 cylinder_size enum 3 (Medium)
  - "Electricity" + dual/18-hour/24-hour/off-peak meter
    → 210 L → SAP10 cylinder_size enum 4 (Large)
  - otherwise → 110 L → SAP10 cylinder_size enum 2 (Normal)

`_elmhurst_cylinder_size_code` extended with optional water_heating_fuel
+ meter_type kwargs; the single call site at line 4459 threads
`survey.water_heating.water_heating_fuel_type` and
`survey.meters.electricity_meter_type`.

Property 001431 (the heating-systems corpus dwelling) lodges `pcdb 1`
with §14.0 Potterton oil boiler (PCDF 716) + §15.0 "Water Heating Fuel
Type: Heating oil" + §14.2 "Electricity meter type: 18 Hour" — water
fuel is oil (not electric, not solid fuel) → "otherwise" branch → 110 L
→ enum 2 (Normal). `pcdb 1` now cascade-executes (corpus tally 34 → 35
OK / 41 populated).

Extended handover suite at HEAD post-slice: **831 pass, 0 fail**
(was 830 + 1 new AAA test).

Pyright net-zero on touched files (45 → 45 — pre-existing errors
unrelated).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 00:09:42 +00:00
parent e25aa02109
commit 11ecac94dc
2 changed files with 118 additions and 2 deletions

View file

@ -236,6 +236,39 @@ def test_summary_001479_mapper_extensions_count_matches_extension_bps() -> None:
assert len(epc.sap_building_parts) == 3 assert len(epc.sap_building_parts) == 3
def test_summary_001431_pcdb_1_inaccessible_cylinder_resolves_to_normal_per_rdsap_10_table_28() -> None:
# Arrange — Heating-systems corpus fixture 001431 / "pcdb 1" lodges
# §15.1 "Cylinder Size: No Access" (the Elmhurst inaccessible-cylinder
# lodging form). Per RdSAP 10 Specification Table 28 page 55:
#
# "Inaccessible:
# - if off-peak electric dual immersion: 210 litres
# - if from solid fuel boiler: 160 litres
# - otherwise: 110 litres"
#
# pcdb 1 lodges §14.0 Main Heating as a Potterton oil boiler (PCDF
# 716) + §15.0 Water Heating Fuel Type "Heating oil" → not an
# electric dual immersion, not a solid fuel boiler → the spec's
# "otherwise" branch → **110 litres** = SAP10 cylinder_size enum 2
# (Normal per `_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10`).
#
# Pre-slice the mapper strict-raised `UnmappedElmhurstLabel` on the
# "No Access" string because `_elmhurst_cylinder_size_code` only
# carried the three lodged-size dict entries (Normal/Medium/Large).
summary_pdf = (
Path(__file__).parents[3]
/ "sap worksheets/heating systems examples/pcdb 1/Summary_001431.pdf"
)
pages = _summary_pdf_to_textract_style_pages(summary_pdf)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
# Act
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Assert
assert epc.sap_heating.cylinder_size == 2
def test_summary_001431_electric_1_underfloor_heating_resolves_to_in_screed_per_rdsap_10_section_10_11() -> None: def test_summary_001431_electric_1_underfloor_heating_resolves_to_in_screed_per_rdsap_10_section_10_11() -> None:
# Arrange — Heating-systems corpus fixture 001431 / "electric 1" lodges # Arrange — Heating-systems corpus fixture 001431 / "electric 1" lodges
# §14.0 "Heat Emitter: Underfloor Heating" (bare form, no subtype # §14.0 "Heat Emitter: Underfloor Heating" (bare form, no subtype

View file

@ -4127,8 +4127,73 @@ _ELMHURST_CYLINDER_INSULATION_LABEL_TO_SAP10: Dict[str, int] = {
} }
# Elmhurst §15.0 "Water Heating Fuel Type" labels that route to solid-
# fuel Table 32 codes (Anthracite, House coal, Wood logs/pellets, etc.).
# Used by `_resolve_elmhurst_inaccessible_cylinder_size` to detect the
# "from solid fuel boiler" branch of RdSAP 10 Table 28 page 55.
_ELMHURST_SOLID_FUEL_WATER_HEATING_LABELS: frozenset[str] = frozenset({
"Anthracite",
"House coal",
"Manufactured smokeless fuel",
"Wood logs",
"Wood pellets",
"Wood chips",
"Dual fuel (mineral and wood)",
"Coal",
})
# Elmhurst §14.2 "Electricity meter type" labels that signify off-peak
# / dual metering (where an inaccessible electric immersion is assumed
# dual per RdSAP 10 §10.5.1 → Table 28 "off-peak electric dual
# immersion" 210 L branch).
_ELMHURST_DUAL_OFF_PEAK_METER_LABELS: frozenset[str] = frozenset({
"Dual",
"Dual (24 hour)",
"18 Hour",
"Off-peak 18 hour",
"10 Hour",
})
def _resolve_elmhurst_inaccessible_cylinder_size(
water_heating_fuel_label: str,
meter_type_label: str,
) -> int:
"""RdSAP 10 Specification Table 28 page 55 — derive cylinder size
when §15.1 lodges "No Access" / Inaccessible.
Spec rule verbatim:
"Inaccessible:
- if off-peak electric dual immersion: 210 litres
- if from solid fuel boiler: 160 litres
- otherwise: 110 litres"
Returns SAP10 cylinder_size enum (per
`_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10`):
2 (Normal) 110 L modal "otherwise" branch
3 (Medium) 160 L solid fuel boiler
4 (Large) 210 L off-peak electric dual immersion
Per RdSAP 10 §10.5.1: "An electric immersion is assumed dual in the
following cases: cylinder is inaccessible and electricity tariff
is dual" — so the 210-L branch fires automatically when both
conditions hold (no separate "is_dual_immersion" lodging needed).
"""
if water_heating_fuel_label in _ELMHURST_SOLID_FUEL_WATER_HEATING_LABELS:
return 3 # Medium / 160 L
is_electric = water_heating_fuel_label.startswith("Electricity")
is_off_peak = meter_type_label in _ELMHURST_DUAL_OFF_PEAK_METER_LABELS
if is_electric and is_off_peak:
return 4 # Large / 210 L
return 2 # Normal / 110 L
def _elmhurst_cylinder_size_code( def _elmhurst_cylinder_size_code(
cylinder_size_label: Optional[str], cylinder_present: bool, cylinder_size_label: Optional[str],
cylinder_present: bool,
water_heating_fuel_label: Optional[str] = None,
meter_type_label: Optional[str] = None,
) -> Optional[int]: ) -> Optional[int]:
"""Map an Elmhurst §15.1 "Cylinder Size" label to the SAP10 """Map an Elmhurst §15.1 "Cylinder Size" label to the SAP10
cascade enum. Returns None when no cylinder is present or the cascade enum. Returns None when no cylinder is present or the
@ -4136,9 +4201,25 @@ def _elmhurst_cylinder_size_code(
`UnmappedElmhurstLabel` when the label IS lodged but isn't in `UnmappedElmhurstLabel` when the label IS lodged but isn't in
`_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10` that's a mapper-coverage `_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10` that's a mapper-coverage
gap that should be made explicit so the next fixture forces a dict gap that should be made explicit so the next fixture forces a dict
entry, not silently routed off the HW-with-cylinder cascade path.""" entry, not silently routed off the HW-with-cylinder cascade path.
The bare lodging "No Access" (Inaccessible) routes through
`_resolve_elmhurst_inaccessible_cylinder_size` per RdSAP 10
Table 28 page 55."""
if not cylinder_present or cylinder_size_label is None: if not cylinder_present or cylinder_size_label is None:
return None return None
if cylinder_size_label == "No Access":
if water_heating_fuel_label is None or meter_type_label is None:
raise UnmappedElmhurstLabel(
"cylinder_size",
(
"lodged 'No Access' requires water_heating fuel + "
"meter context to apply RdSAP 10 Table 28 (p.55)"
),
)
return _resolve_elmhurst_inaccessible_cylinder_size(
water_heating_fuel_label, meter_type_label,
)
code = _ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10.get(cylinder_size_label) code = _ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10.get(cylinder_size_label)
if code is None: if code is None:
raise UnmappedElmhurstLabel("cylinder_size", cylinder_size_label) raise UnmappedElmhurstLabel("cylinder_size", cylinder_size_label)
@ -4459,6 +4540,8 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
cylinder_size=_elmhurst_cylinder_size_code( cylinder_size=_elmhurst_cylinder_size_code(
survey.water_heating.cylinder_size_label, survey.water_heating.cylinder_size_label,
survey.water_heating.hot_water_cylinder_present, survey.water_heating.hot_water_cylinder_present,
water_heating_fuel_label=survey.water_heating.water_heating_fuel_type,
meter_type_label=survey.meters.electricity_meter_type,
), ),
cylinder_insulation_type=_elmhurst_cylinder_insulation_code( cylinder_insulation_type=_elmhurst_cylinder_insulation_code(
survey.water_heating.cylinder_insulation_label, survey.water_heating.cylinder_insulation_label,