Slice 100c: API path — surface PV arrays + gap-aware glazing lookup

Two final API gaps to close cert 9501 at 1e-4:

(a) PV array surfacing — third shape variant:
    Schema-21 EPCs carry `photovoltaic_supply` as one of three shapes:
    - legacy `{"none_or_no_details": {...}}` (PV absent / roof-only)
    - nested list `[[{...}], ...]` (cohort cert 2130)
    - dict wrapper `{"pv_arrays": [{...}]}` (cert 9501)
    The schema's `PhotovoltaicSupply` modelled only `none_or_no_details`
    — cert 9501's measured arrays under `pv_arrays` were silently
    dropped (Δ -£250 PV credit → -9.32 SAP). Added
    `SchemaPhotovoltaicArray` dataclass + `pv_arrays:
    Optional[List[...]]` sibling field on `PhotovoltaicSupply`; updated
    `_map_schema_21_pv` to dispatch on the new shape.

(b) Gap-aware glazing lookup (RdSAP 10 Table 24 row 2):
    DG pre-2002 spec U varies by gap: 6mm=3.1 / 12mm=2.8 / 16+=2.7.
    The mapper's flat `_API_GLAZING_TYPE_TO_TRANSMISSION[3]` returned
    U=2.8 unconditionally — cert 9501 lodges `glazing_gap="16+"` so
    the worksheet uses 2.7. Added `_API_GLAZING_TYPE_GAP_TO_
    TRANSMISSION` keyed by (type, gap) with the spec-table values for
    code 3; `_api_glazing_transmission` consults the per-gap dict
    first, falling back to type-only when no gap entry exists.
    Refactored the inline `SapWindow(...)` build into
    `_api_sap_window` helper (also nets one pyright error: net-zero
    actually improved 33 → 32 on mapper.py).

Effect on cert 9501 API path:
- sap_continuous 59.20 → **68.525161** (= worksheet 68.5252 exact;
  Δ -0.000039 — well within 1e-4)
- total_fuel_cost £1101 → £849.21 (= worksheet 849.21 exact)
- pv_export_credit £0 → £250.02 (= worksheet 250.02 exact)

Re-pinned residuals (5 cohort certs with glazing_gap="16+" or 6 now
pick up the spec-correct DG-pre-2002 U):
- 0300: PE +8.44 → +8.28, CO2 -0.23 → -0.25
- 6035: PE +48.30 → +47.85, CO2 +1.10 → +1.09
- 7536: PE -6.51 → -7.08, CO2 -0.17 → -0.19
- 8135: PE -5.31 → -3.66 (gap=6 spec U=3.1), CO2 -0.07 → -0.04
- 2130: PE -38.18 → -38.63, CO2 +0.30 → +0.30

Layer 4 chain test `test_api_9501_full_chain_sap_matches_worksheet
_pdf_exactly` added — third production gate after cert 001479 +
cert 0330. First flat-shaped cert in the production gate set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-26 22:13:48 +00:00
parent 814ae79813
commit 7992154ffd
4 changed files with 171 additions and 61 deletions

View file

@ -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

View file

@ -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],

View file

@ -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

View file

@ -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 "