From 520488eb068fbd0f2d3b33b4c9051eabdb6f08c0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 31 May 2026 21:03:10 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.143:=20RdSAP=2010=20=C2=A710.11?= =?UTF-8?q?=20Table=2029=20=E2=80=94=20derive=20cylinder=20insulation=20de?= =?UTF-8?q?faults=20from=20construction=20age=20band=20when=20=C2=A715.1?= =?UTF-8?q?=20lodges=20"No=20Access"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/test_heating_systems_corpus.py | 2 +- .../tests/test_summary_pdf_mapper_chain.py | 50 +++++++++++ datatypes/epc/domain/mapper.py | 89 +++++++++++++++++-- 3 files changed, 131 insertions(+), 10 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index cfde8c83..46c80a79 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -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 diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index d3bf0822..5b7267e7 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -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 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 0258da83..0878f2ad 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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.