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.