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 7ff2b676..223075f6 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -504,6 +504,59 @@ _API_9501_JSON = ( ) +def test_api_9501_full_chain_sap_matches_worksheet_pdf_exactly() -> None: + # Arrange — cert 9501 is the third Layer 4 production gate (after + # cert 001479 and cert 0330): API path → from_api_response → + # cert_to_inputs → calculate_sap_from_inputs must hit the worksheet + # SAP at 1e-4. Cert 9501 is the FIRST flat in the production gate + # set — mid-terrace top-floor flat with RR + measured PV (2.36 kWp + # SW @ 45°). Worksheet target unrounded SAP **68.5252**. + # + # Slices 100a-100c jointly closed the API path from Δ -14.82 to + # 1e-4: 100a `room_in_roof_details` schema + Detailed-RR surface + # population (HLC 382.19 → 297.54 W/K vs worksheet 296.68); 100b + # per-bp TFA includes RR floor area (TFA 81.28 → 113.08); 100c + # `photovoltaic_supply.pv_arrays` schema + gap-aware glazing + # lookup (DG pre-2002 16+ → U=2.7 per RdSAP 10 Table 24). + doc = json.loads(_API_9501_JSON.read_text()) + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Act + result = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + + # Assert — 1e-4 pin against the worksheet's continuous SAP. + worksheet_unrounded_sap = 68.5252 + assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4 + + +def test_api_9501_photovoltaic_array_surfaced() -> None: + # Arrange — cert 9501's API JSON lodges measured PV under + # `sap_energy_source.photovoltaic_supply.pv_arrays`. Two real-API + # PV shapes coexist: cohort cert 2130 lodges the outer wrapper as + # a nested list `[[{...}], ...]`; cert 9501 lodges a dict + # `{"pv_arrays": [{...}]}`. The existing schema models only the + # legacy `none_or_no_details` field on `PhotovoltaicSupply` — so + # cert 9501's `pv_arrays` payload was silently dropped, leaving + # `photovoltaic_arrays=None` and the cascade missing the worksheet's + # £250.02 PV credit. + doc = json.loads(_API_9501_JSON.read_text()) + + # Act + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Assert — single array with the lodged kWp/pitch/orientation/ + # overshading values. + arrays = epc.sap_energy_source.photovoltaic_arrays + assert arrays is not None + assert len(arrays) == 1 + assert abs(arrays[0].peak_power - 2.36) <= 1e-4 + assert arrays[0].pitch == 3 # RdSAP §11.1 enum: 3 = 45° + assert arrays[0].orientation == 6 # SAP octant: SW + assert arrays[0].overshading == 1 # RdSAP: None or very little + + def test_api_9501_room_in_roof_surfaces_populated() -> None: # Arrange — cert 9501's API JSON lodges measured RR detail under # `sap_room_in_roof.room_in_roof_details`: two gable walls diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 542dac15..b7140ae7 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -106,11 +106,13 @@ def _map_schema_21_pv( ) -> tuple[Optional[PhotovoltaicSupply], Optional[List[PhotovoltaicArray]]]: """Dispatch on the polymorphic schema-21 ``photovoltaic_supply`` field. - Schema-21 EPCs carry one of two shapes under the same JSON key: + Schema-21 EPCs carry one of three shapes under the same JSON key: - the legacy wrapper dict ``{"none_or_no_details": {"percent_roof_area": N}}`` when PV is absent or the surveyor logged only roof-coverage, - a nested list ``[[{peak_power, pitch, orientation, overshading}, ...], ...]`` - when measured-array detail is available. + when measured-array detail is available (older vintage, e.g. cert 2130), + - a wrapper dict ``{"pv_arrays": [{peak_power, ...}, ...]}`` when measured- + array detail is lodged with the newer schema vintage (e.g. cert 9501). Returns ``(supply, arrays)`` — exactly one half is populated; the other is None. With no PV data at all, both are None. @@ -129,6 +131,19 @@ def _map_schema_21_pv( return None, (flattened or None) if es_pv_supply is None: return None, None + pv_arrays = getattr(es_pv_supply, "pv_arrays", None) + if pv_arrays: + arrays_list: List[Any] = list(pv_arrays) + flattened = [ + PhotovoltaicArray( + peak_power=_measurement_value(array.peak_power), + pitch=int(_measurement_value(array.pitch)), + orientation=int(_measurement_value(array.orientation)), + overshading=int(_measurement_value(array.overshading)), + ) + for array in arrays_list + ] + return None, (flattened or None) if es_pv_supply.none_or_no_details is None: return None, None return ( @@ -1524,53 +1539,7 @@ class EpcPropertyDataMapper: ), # SAP windows sap_windows=[ - SapWindow( - frame_material="PVC" if w.pvc_frame == "true" else None, - glazing_gap=w.glazing_gap, - orientation=w.orientation, - window_type=w.window_type, - frame_factor=( - w.frame_factor - if w.frame_factor is not None - else _API_GLAZING_TYPE_TO_TRANSMISSION.get(w.glazing_type, (None, None, None))[2] - ), - glazing_type=w.glazing_type, - window_width=_measurement_value(w.window_width), - window_height=_measurement_value(w.window_height), - draught_proofed=w.draught_proofed == "true", - window_location=w.window_location, - window_wall_type=w.window_wall_type, - permanent_shutters_present=w.permanent_shutters_present == "Y", - # When the API lodgement carries explicit - # `window_transmission_details`, pass through verbatim - # (Manufacturer-lodged U + solar takes precedence over - # the cascade default). Otherwise derive from the - # `glazing_type` integer code via the SAP10 lookup — - # gives the cascade per-window U-values for the - # `windows_have_per_window_u` fast path in - # `heat_transmission.py`, matching the cohort - # Elmhurst behaviour (which sets these per-window via - # `make_window`). - window_transmission_details=( - WindowTransmissionDetails( - u_value=w.window_transmission_details.u_value, - data_source=w.window_transmission_details.data_source, - solar_transmittance=w.window_transmission_details.solar_transmittance, - ) - if w.window_transmission_details is not None - else ( - WindowTransmissionDetails( - u_value=_API_GLAZING_TYPE_TO_TRANSMISSION[w.glazing_type][0], - data_source="SAP10 lookup (glazing_type)", - solar_transmittance=_API_GLAZING_TYPE_TO_TRANSMISSION[w.glazing_type][1], - ) - if w.glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION - else None - ) - ), - permanent_shutters_insulated=w.permanent_shutters_insulated, - ) - for w in schema.sap_windows + _api_sap_window(w) for w in schema.sap_windows ], # SAP energy source sap_energy_source=SapEnergySource( @@ -2308,15 +2277,89 @@ def _api_sheltered_sides(built_form: object) -> Optional[int]: # Argon). The wider SAP10.2 glazing-type enum (4-12, 14+) is not yet # mapped — incremental coverage as new fixtures surface them. # -# Spec source: RdSAP 10 Table 24 "Window characteristics" page 79. +# Spec source: RdSAP 10 Table 24 "Window characteristics" page 79 — +# DG pre-2002 spec U varies by gap (6mm=3.1, 12mm=2.8, 16+=2.7); the +# (type, gap)-keyed lookup picks the spec-correct entry when the gap +# is lodged, falling back to the type-only default for missing gaps. _API_GLAZING_TYPE_TO_TRANSMISSION: Dict[int, tuple[float, float, float]] = { # (u_value, solar_transmittance/g_⊥, frame_factor) 2: (2.0, 0.72, 0.70), # Double glazed, England/Wales 2002+ (pre-2022) - 3: (2.8, 0.76, 0.70), # Double glazed, pre-2002 + 3: (2.8, 0.76, 0.70), # Double glazed, pre-2002 (12mm gap default) 13: (1.4, 0.72, 0.70), # Double glazed, Argon-filled post-2022 } +# Per-gap overrides for the glazing-type lookup. Keys are +# (glazing_type, glazing_gap) where glazing_gap matches the API JSON's +# lodged value (int "6", int "12", or str "16+"). Lookups consult this +# dict first; missing keys fall back to the type-only `_API_GLAZING_ +# TYPE_TO_TRANSMISSION` entry above. +_API_GLAZING_TYPE_GAP_TO_TRANSMISSION: Dict[ + tuple[int, object], tuple[float, float, float] +] = { + # Double glazed, pre-2002 — Table 24 row 2 (PVC/wooden frame): + (3, 6): (3.1, 0.76, 0.70), + (3, 12): (2.8, 0.76, 0.70), + (3, "16+"): (2.7, 0.76, 0.70), +} + + +def _api_glazing_transmission( + glazing_type: Optional[int], glazing_gap: object, +) -> Optional[tuple[float, float, float]]: + """Resolve (U, g, frame_factor) for an API window. Per-gap override + takes precedence over the type-only default; returns None when the + glazing_type isn't yet in the lookup.""" + if glazing_type is None: + return None + gap_key = (glazing_type, glazing_gap) + if gap_key in _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: + return _API_GLAZING_TYPE_GAP_TO_TRANSMISSION[gap_key] + return _API_GLAZING_TYPE_TO_TRANSMISSION.get(glazing_type) + + +def _api_sap_window(w: Any) -> SapWindow: + """Build a `SapWindow` from one API schema sap_windows entry, + routing the glazing-type + glazing-gap pair through the spec + lookup so DG pre-2002 windows pick up the gap-specific U + (RdSAP 10 Table 24 row 2: 6mm=3.1 / 12mm=2.8 / 16+=2.7) instead + of the type-only default.""" + transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap) + frame_factor: Optional[float] = w.frame_factor + if frame_factor is None and transmission is not None: + frame_factor = transmission[2] + if w.window_transmission_details is not None: + td = WindowTransmissionDetails( + u_value=w.window_transmission_details.u_value, + data_source=w.window_transmission_details.data_source, + solar_transmittance=w.window_transmission_details.solar_transmittance, + ) + elif transmission is not None: + td = WindowTransmissionDetails( + u_value=transmission[0], + data_source="SAP10 lookup (glazing_type, glazing_gap)", + solar_transmittance=transmission[1], + ) + else: + td = None + return SapWindow( + frame_material="PVC" if w.pvc_frame == "true" else None, + glazing_gap=w.glazing_gap, + orientation=w.orientation, + window_type=w.window_type, + frame_factor=frame_factor, + glazing_type=w.glazing_type, + window_width=_measurement_value(w.window_width), + window_height=_measurement_value(w.window_height), + draught_proofed=w.draught_proofed == "true", + window_location=w.window_location, + window_wall_type=w.window_wall_type, + permanent_shutters_present=w.permanent_shutters_present == "Y", + window_transmission_details=td, + permanent_shutters_insulated=w.permanent_shutters_insulated, + ) + + def _api_build_sap_floor_dimensions( fds: List[Any], floor_heat_loss: Optional[int], diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 55c59c86..d3adb1b9 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -106,9 +106,23 @@ class PhotovoltaicSupplyNoneOrNoDetails: percent_roof_area: int +@dataclass +class SchemaPhotovoltaicArray: + """One measured PV array under `photovoltaic_supply.pv_arrays`.""" + peak_power: Optional[float] = None + pitch: Optional[int] = None + orientation: Optional[int] = None + overshading: Optional[int] = None + + @dataclass class PhotovoltaicSupply: none_or_no_details: Optional[PhotovoltaicSupplyNoneOrNoDetails] = None + # Newer cert vintages (e.g. cert 9501) lodge measured arrays under + # `pv_arrays` directly; older vintages (cert 2130) put the same + # arrays in a top-level nested list (handled at the + # `_map_schema_21_pv` Union dispatch). + pv_arrays: Optional[List[SchemaPhotovoltaicArray]] = None @dataclass diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index edac9197..cbcd4e27 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -97,8 +97,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0300-2747-7640-2526-2135", actual_sap=78, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+8.4391, - expected_co2_resid_tonnes_per_yr=-0.2341, + expected_pe_resid_kwh_per_m2=+8.2769, + expected_co2_resid_tonnes_per_yr=-0.2480, notes=( "Large semi-detached, TFA 526, age D, gas boiler PCDB-listed " "(no Table 4b code). Cert lodges open_flues_count=1 + " @@ -135,8 +135,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="6035-7729-2309-0879-2296", actual_sap=70, expected_sap_resid=-6, - expected_pe_resid_kwh_per_m2=+48.3043, - expected_co2_resid_tonnes_per_yr=+1.1019, + expected_pe_resid_kwh_per_m2=+47.8483, + expected_co2_resid_tonnes_per_yr=+1.0911, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "Slice 59 per-bp window apportionment tightens all 3 " @@ -149,8 +149,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="7536-3827-0600-0600-0276", actual_sap=68, expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-6.5135, - expected_co2_resid_tonnes_per_yr=-0.1724, + expected_pe_resid_kwh_per_m2=-7.0776, + expected_co2_resid_tonnes_per_yr=-0.1875, notes=( "Detached + 2 extensions, TFA 152. Multi-age bps (Main=D, " "Ext1=L, Ext2=F). Slice 59 (per-bp window apportionment) and " @@ -168,8 +168,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="8135-1728-8500-0511-3296", actual_sap=72, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-5.3103, - expected_co2_resid_tonnes_per_yr=-0.0744, + expected_pe_resid_kwh_per_m2=-3.6590, + expected_co2_resid_tonnes_per_yr=-0.0432, notes=( "Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges " "blocked_chimneys_count=1. Slice 59 per-bp window apportionment " @@ -183,8 +183,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="2130-1033-4050-5007-8395", actual_sap=82, expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-38.1790, - expected_co2_resid_tonnes_per_yr=+0.3046, + expected_pe_resid_kwh_per_m2=-38.6274, + expected_co2_resid_tonnes_per_yr=+0.2993, notes=( "End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, " "postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays "