mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
c7419ca45a
commit
520488eb06
3 changed files with 131 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue