Slice S0380.143: RdSAP 10 §10.11 Table 29 — derive cylinder insulation defaults from construction age band when §15.1 lodges "No Access"

RdSAP 10 Specification §10.11 Table 29 page 56 — "Heating and hot
water parameters" → row "Hot water cylinder insulation if not
accessible":

  Age band of main property A to F: 12 mm loose jacket
  Age band of main property G, H:   25 mm foam
  Age band of main property I to M: 38 mm foam

Pre-slice the Elmhurst mapper passed through cylinder_insulation_type
and cylinder_insulation_thickness_mm as None whenever §15.1 lodged
"Cylinder Size: No Access" (the inaccessible-cylinder lodging form)
because the Summary doesn't carry the measured insulation label /
thickness on inaccessible cylinders. The cascade's §4 (56)m water
storage loss override at `_cylinder_storage_loss_override` then
returned None (gates on `insulation_type == _CYLINDER_INSULATION_
TYPE_FACTORY` + thickness lodged), so the worksheet's (56)m sum was
dropped entirely from (62)m.

Cert pcdb 1 (corpus 001431, Potterton KOA PCDB 716 + 110 L cylinder
+ §15.1 "No Access" + age G 1983-1990) exposes the gap: worksheet
(56)m monthly ≈ 59.06 kWh ((51) factor 0.024 from Note 1 formula
L = 0.005 + 0.55 / (t + 4) at t = 25 mm) × (52) volume factor 1.0294
× (53) Table 2b temperature factor 0.702 — annual sum ≈ 695 kWh,
missing from the pre-slice cascade entirely.

New helper
`_resolve_elmhurst_inaccessible_cylinder_insulation(age_band)` in
`datatypes/epc/domain/mapper.py` returns the
`(insulation_type_code, thickness_mm)` tuple for age G/H (factory
foam, 25 mm) and I/J/K/L/M (factory foam, 38 mm). Age bands A-F
(loose jacket, 12 mm) raise `UnmappedElmhurstLabel` — no current
Elmhurst corpus member is age A-F with §15.1 = "No Access", and the
loose-jacket SAP10 cylinder_insulation_type enum value is not yet
plumbed into the calculator's `cylinder_storage_loss_factor_table_2`
dispatch (only factory=1 is exercised). The strict-raise mirrors the
[[reference-unmapped-sap-code]] pattern so a future fixture forces
the loose-jacket extension explicitly.

`_map_elmhurst_sap_heating` calls the resolver before constructing
SapHeating; the accessible-cylinder path stays unchanged
(measured label + thickness from §15.1).

Corpus impact:

- pcdb 1 (only "No Access" cylinder variant in the corpus):
  SAP +2.86 → +0.57; cost -£63.22 → -£12.55; CO2 -328.74 → -51.19;
  PE -1257.97 → -109.46. The remaining residual is a ~1.3% cascade-
  side undercount on space-heating demand (cascade SH 7900 kWh vs
  worksheet (98c) 8004 kWh) plus minor pumps/fans rate noise — well
  within the spec-cascade floor.

Combined with S0380.141 (§9.4.11 -5pp interlock on SH + Eq D1) and
S0380.142 (§4 lines 7700/7702 cylinder-presence gates), the
pre-slice pcdb 1 residual SAP +6.95 closes to +0.57 (-92% magnitude),
cost -£157.61 to -£12.55, PE -3135.30 to -109.46.

Extended handover suite: 886 pass, 0 fail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 21:03:10 +00:00 committed by Jun-te Kim
parent c7419ca45a
commit 520488eb06
3 changed files with 131 additions and 10 deletions

View file

@ -233,7 +233,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_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),
_CorpusExpectation(variant='oil pcdb 2', 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),
_CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+1.1597, expected_cost_resid_gbp=-26.7204, expected_co2_resid_kg=-53.1709, expected_pe_resid_kwh=-271.4351),
_CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+2.8556, expected_cost_resid_gbp=-63.2154, expected_co2_resid_kg=-328.7435, expected_pe_resid_kwh=-1257.9712),
_CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+0.5677, expected_cost_resid_gbp=-12.5482, expected_co2_resid_kg=-51.1912, expected_pe_resid_kwh=-109.4555),
# Slice S0380.133 unblocked 10 solid-fuel variants by routing the
# Elmhurst §14.0 "Main Heating EES Code" through the new
# `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` dict. Pre-slice the

View file

@ -383,6 +383,56 @@ def test_summary_001431_pcdb_1_inaccessible_cylinder_resolves_to_normal_per_rdsa
assert epc.sap_heating.cylinder_size == 2
def test_summary_001431_pcdb_1_inaccessible_cylinder_resolves_insulation_to_25mm_foam_per_rdsap_10_table_29() -> None:
# Arrange — Heating-systems corpus fixture 001431 / "pcdb 1" lodges
# §15.1 "Cylinder Size: No Access" alongside age band G (1983-1990).
# Per RdSAP 10 Specification §10.11 Table 29 page 56 "Hot water
# cylinder insulation if not accessible":
#
# - Age band of main property A to F: 12 mm loose jacket
# - Age band of main property G, H: 25 mm foam
# - Age band of main property I to M: 38 mm foam
#
# pcdb 1 lodges construction_age_band = "G 1983-1990" → 25 mm foam.
# The SAP10 `cylinder_insulation_type` enum 1 maps to "factory-
# applied" (foam) per `_ELMHURST_CYLINDER_INSULATION_LABEL_TO_SAP10`;
# `cylinder_insulation_thickness_mm` carries the literal millimetre
# value the cascade feeds into SAP 10.2 Table 2 Note 1's smooth
# formula L = 0.005 + 0.55 / (t + 4) for the storage loss factor
# (worksheet pcdb 1 (51) = 0.024 ≡ 25 mm).
#
# Pre-slice the mapper left both fields as None on "No Access"
# lodging because `_elmhurst_cylinder_insulation_code` and the
# thickness field both look up only the §15.1 measured labels —
# which the Summary doesn't carry when the cylinder is
# inaccessible. The §4 (56)m storage-loss cascade then skipped the
# cylinder loss entirely (`_cylinder_storage_loss_override` requires
# insulation_type=factory + thickness to fire), driving worksheet
# (56)m sum ~695 kWh missing from cert pcdb 1's (62)m demand.
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_insulation_type == 1, (
f"pcdb 1 cylinder_insulation_type: got "
f"{epc.sap_heating.cylinder_insulation_type!r}, want 1 "
f"(factory-applied / foam) per RdSAP 10 §10.11 Table 29 age G "
f"row."
)
assert epc.sap_heating.cylinder_insulation_thickness_mm == 25, (
f"pcdb 1 cylinder_insulation_thickness_mm: got "
f"{epc.sap_heating.cylinder_insulation_thickness_mm!r}, want 25 "
f"per RdSAP 10 §10.11 Table 29 age G row (25 mm foam)."
)
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

@ -4319,6 +4319,54 @@ def _elmhurst_cylinder_insulation_code(
return code
def _resolve_elmhurst_inaccessible_cylinder_insulation(
age_band: str,
) -> tuple[int, int]:
"""RdSAP 10 §10.11 Table 29 page 56 — derive cylinder insulation
type + thickness when §15.1 lodges "No Access" / Inaccessible.
Spec rule verbatim ("Hot water cylinder insulation if not
accessible"):
- Age band of main property A to F: 12 mm loose jacket
- Age band of main property G, H: 25 mm foam
- Age band of main property I to M: 38 mm foam
Returns `(insulation_type_code, thickness_mm)` where the SAP10
`cylinder_insulation_type` enum value 1 means "factory-applied"
(foam) per `_ELMHURST_CYLINDER_INSULATION_LABEL_TO_SAP10`. The
cascade's SAP 10.2 Table 2 dispatch (worksheet (51) storage-loss
factor) reads thickness as a millimetre integer.
Age bands A-F (loose jacket) are deferred until a fixture lodges
that combination; no current Elmhurst corpus member is age A-F
with §15.1 = "No Access". The cascade has no loose-jacket SAP10
enum value plumbed (only factory=1 is exercised in
`cylinder_storage_loss_factor_table_2`), so raising
`UnmappedElmhurstLabel` is the spec-correct strict-fallback per
[[reference-unmapped-sap-code]] pattern.
"""
code = age_band[0] if age_band else ""
if code in {"G", "H"}:
return (1, 25)
if code in {"I", "J", "K", "L", "M"}:
return (1, 38)
if code in {"A", "B", "C", "D", "E", "F"}:
raise UnmappedElmhurstLabel(
"cylinder_insulation",
(
f"age band {code!r} (No Access) → 12 mm loose jacket "
f"per RdSAP 10 §10.11 Table 29 — loose-jacket SAP10 "
f"enum not yet exercised (no corpus member at age A-F "
f"with inaccessible cylinder)"
),
)
raise UnmappedElmhurstLabel(
"cylinder_insulation",
f"unrecognised age-band code {code!r} for No Access cylinder",
)
# Elmhurst Summary §11 "Windows" lodged glazing-type strings mapped to
# the SAP 10.2 Table U2 glazing-type enum that
# `domain/sap10_calculator/worksheet/internal_gains._G_LIGHT_BY_GLAZING_CODE`
@ -4638,6 +4686,36 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
if main_2_detail is not None
else [main_1_detail]
)
# RdSAP 10 §10.11 Table 29 (p.56) — when the Summary lodges §15.1
# "Cylinder Size: No Access" the cylinder is inaccessible during
# the survey, so the Summary doesn't carry the cylinder insulation
# label / thickness either. Per Table 29 the cascade defaults to
# the age-band lookup (G/H = 25 mm foam, I-M = 38 mm foam, A-F =
# 12 mm loose jacket). For accessible cylinders the Summary
# carries the measured label + thickness and the existing helpers
# apply unchanged.
is_inaccessible_cylinder = (
survey.water_heating.hot_water_cylinder_present
and survey.water_heating.cylinder_size_label == "No Access"
)
if is_inaccessible_cylinder:
ins_type_code, ins_thickness_mm = (
_resolve_elmhurst_inaccessible_cylinder_insulation(
survey.construction_age_band,
)
)
cylinder_insulation_type_field: Optional[int] = ins_type_code
cylinder_insulation_thickness_mm_field: Optional[int] = ins_thickness_mm
else:
cylinder_insulation_type_field = _elmhurst_cylinder_insulation_code(
survey.water_heating.cylinder_insulation_label,
survey.water_heating.hot_water_cylinder_present,
)
cylinder_insulation_thickness_mm_field = (
survey.water_heating.cylinder_insulation_thickness_mm
if survey.water_heating.hot_water_cylinder_present
else None
)
return SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=main_heating_details,
@ -4649,15 +4727,8 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
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,
survey.water_heating.hot_water_cylinder_present,
),
cylinder_insulation_thickness_mm=(
survey.water_heating.cylinder_insulation_thickness_mm
if survey.water_heating.hot_water_cylinder_present
else None
),
cylinder_insulation_type=cylinder_insulation_type_field,
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm_field,
# Cascade reads `cylinder_thermostat == "Y"` (string compare) per
# `cert_to_inputs.py:2252` / `:2218`. Map the bool to the Y/N
# string the cascade expects; None when no cylinder is present.