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
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:
# Arrange — Heating-systems corpus fixture 001431 / "electric 1" lodges
# §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(
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]:
"""Map an Elmhurst §15.1 "Cylinder Size" label to the SAP10
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
`_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10` that's a mapper-coverage
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:
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)
if code is None:
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(
survey.water_heating.cylinder_size_label,
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(
survey.water_heating.cylinder_insulation_label,