Extract shared reduced-field window synthesis core across the RdSAP family 🟪

ADR-0028's deferred extraction, triggered by 17.1 as the second instance: the
inherited 20.0.0 coefficients (0.148 + band multipliers + 4-way split) now live
in one `_synthesise_reduced_field_windows` core. The 20.0.0 / 18.0 / 17.1 seams
keep their own names (so each can diverge) but collapse to glazing-type
resolution (cascade + that spec's ND handling) plus a call to the core.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-11 13:16:15 +00:00
parent 0f3321e655
commit fcc2e5d0f8

View file

@ -3061,19 +3061,26 @@ _RDSAP20_GLAZED_AREA_BAND_MULTIPLIER: dict[int, float] = {
_RDSAP20_SYNTH_ORIENTATIONS: tuple[int, ...] = (1, 3, 5, 7)
def _synthesise_20_0_0_sap_windows(schema: RdSapSchema20_0_0) -> List[SapWindow]:
"""ADR-0027 Reduced-Field Synthesis of `sap_windows` for a 20.0.0 cert.
def _synthesise_reduced_field_windows(
glazed_area: int,
total_floor_area: float,
glazing_type: int,
) -> List[SapWindow]:
"""ADR-0028 shared core for pre-SAP10 reduced-field glazing synthesis.
993/1000 certs carry no per-window array, only a glazed_area band + floor
area; synthesise total glazing as `ratio x TFA`, split 4-way across N/E/S/W
with `window_width = area/4, window_height = 1.0` (width x height is the only
quantity the calculator reads, so height=1 makes width carry the area
exactly matching the existing Elmhurst precedent).
Certs with no per-window array carry only a glazed_area band + floor area;
synthesise total glazing as `ratio x TFA x band_multiplier`, split 4-way
across N/E/S/W with `window_width = area/4, window_height = 1.0` (width x
height is the only quantity the calculator reads, so height=1 makes width
carry the area exactly the Elmhurst precedent).
This is the single home of the inherited 20.0.0 coefficients across the
pre-SAP10 RdSAP family (20.0.0 / 18.0 / 17.1). `glazing_type` is pre-resolved
by each spec's seam (cascade + that spec's own "ND" handling), keeping spec
vocabulary out of the numeric core.
"""
band_multiplier = _RDSAP20_GLAZED_AREA_BAND_MULTIPLIER.get(schema.glazed_area, 1.0)
total_area = (
_RDSAP20_GLAZING_RATIO * float(schema.total_floor_area) * band_multiplier
)
band_multiplier = _RDSAP20_GLAZED_AREA_BAND_MULTIPLIER.get(glazed_area, 1.0)
total_area = _RDSAP20_GLAZING_RATIO * float(total_floor_area) * band_multiplier
per_window_area = total_area / len(_RDSAP20_SYNTH_ORIENTATIONS)
return [
SapWindow(
@ -3081,11 +3088,7 @@ def _synthesise_20_0_0_sap_windows(schema: RdSapSchema20_0_0) -> List[SapWindow]
glazing_gap=0,
orientation=orientation,
window_type=0,
# ADR-0027: 20.0.0 glazed_type codes 1-8+ND are identical to 21.0.1's,
# so reuse the verified 21.0.1 cascade (fixes code 1 "DG pre-2002"
# being mis-read as single). g⊥ comes from window_transmission_details
# (slice 6), so glazing_type only feeds the daylight g_L lookup.
glazing_type=_api_cascade_glazing_type(schema.multiple_glazing_type),
glazing_type=glazing_type,
window_width=per_window_area,
window_height=1.0,
draught_proofed=False,
@ -3097,6 +3100,17 @@ def _synthesise_20_0_0_sap_windows(schema: RdSapSchema20_0_0) -> List[SapWindow]
]
def _synthesise_20_0_0_sap_windows(schema: RdSapSchema20_0_0) -> List[SapWindow]:
"""ADR-0027/0028 seam: 20.0.0 glazed_type codes 1-8+ND are identical to
21.0.1's, so route multiple_glazing_type through the verified cascade (fixes
code 1 "DG pre-2002" read as single), then call the shared core."""
return _synthesise_reduced_field_windows(
schema.glazed_area,
schema.total_floor_area,
_api_cascade_glazing_type(schema.multiple_glazing_type),
)
# ADR-0028: multiple_glazing_type "ND" (Not Defined, 69/1000 18.0 certs) has no
# cascade mapping — treat as the DG-modal default (cascade code 2 → daylight g_L
# 0.80, matching the calculator's `_G_LIGHT_DEFAULT` for unknown glazing).
@ -3104,46 +3118,21 @@ _RDSAP18_ND_GLAZING_TYPE: int = 2
def _synthesise_18_0_sap_windows(schema: RdSapSchema18_0) -> List[SapWindow]:
"""ADR-0028 Reduced-Field Synthesis of `sap_windows` for an 18.0 cert.
A separate seam from the 20.0.0 helper so 18.0 can diverge as we learn more,
but it reuses the inherited 20.0.0 coefficients unchanged (ADR-0028: the
18.0 corpus can't self-fit — 958/1000 band-1 with no measured band-1 windows
and its band-4 rich certs reproduce `0.148 x 1.51 = 0.223`). 990/1000 certs
carry no per-window array, only a glazed_area band + floor area; synthesise
total glazing as `ratio x TFA`, split 4-way across N/E/S/W with
`window_width = area/4, window_height = 1.0`.
"""
band_multiplier = _RDSAP20_GLAZED_AREA_BAND_MULTIPLIER.get(schema.glazed_area, 1.0)
total_area = (
_RDSAP20_GLAZING_RATIO * float(schema.total_floor_area) * band_multiplier
)
per_window_area = total_area / len(_RDSAP20_SYNTH_ORIENTATIONS)
# ADR-0028: 18.0 glazed_type codes 1-8 are identical to 20.0.0's (verified
# against epc_codes.csv), so reuse the verified cascade for integer codes;
# the "ND" string falls back to the DG-modal default.
"""ADR-0028 seam: reuses the inherited 20.0.0 coefficients via the shared
core (the 18.0 corpus can't self-fit — 958/1000 band-1 with no measured
band-1 windows and its band-4 rich certs reproduce `0.148 x 1.51 = 0.223`).
18.0 glazed_type codes 1-8 are identical to 20.0.0's (verified vs
epc_codes.csv): route integer codes through the verified cascade; the "ND"
string falls back to the DG-modal default. Own seam so 18.0 can diverge."""
mgt = schema.multiple_glazing_type
glazing_type = (
_api_cascade_glazing_type(mgt)
if isinstance(mgt, int)
else _RDSAP18_ND_GLAZING_TYPE
)
return [
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=orientation,
window_type=0,
glazing_type=glazing_type,
window_width=per_window_area,
window_height=1.0,
draught_proofed=False,
window_location=0,
window_wall_type=0,
permanent_shutters_present=False,
)
for orientation in _RDSAP20_SYNTH_ORIENTATIONS
]
return _synthesise_reduced_field_windows(
schema.glazed_area, schema.total_floor_area, glazing_type
)
# ADR-0028: multiple_glazing_type "ND" (Not Defined, 56/1000 17.1 certs) → the
@ -3152,43 +3141,20 @@ _RDSAP17_1_ND_GLAZING_TYPE: int = 2
def _synthesise_17_1_sap_windows(schema: RdSapSchema17_1) -> List[SapWindow]:
"""ADR-0028 Reduced-Field Synthesis of `sap_windows` for a 17.1 cert.
A separate seam from the 18.0/20.0.0 helpers so 17.1 can diverge, but it
reuses the inherited 20.0.0 coefficients unchanged (ADR-0028: 969/1000 band-1
with no measured band-1 windows; its band-4 rich certs reproduce the model).
990/1000 certs carry no per-window array synthesise total glazing as
`ratio x TFA`, split 4-way across N/E/S/W with height=1.0.
"""
band_multiplier = _RDSAP20_GLAZED_AREA_BAND_MULTIPLIER.get(schema.glazed_area, 1.0)
total_area = (
_RDSAP20_GLAZING_RATIO * float(schema.total_floor_area) * band_multiplier
)
per_window_area = total_area / len(_RDSAP20_SYNTH_ORIENTATIONS)
# ADR-0028: 17.1 glazed_type codes 1-8 are identical to 20.0.0's (verified
# against epc_codes.csv); the "ND" string falls back to the DG-modal default.
"""ADR-0028 seam: reuses the inherited 20.0.0 coefficients via the shared
core (969/1000 band-1 with no measured band-1 windows; band-4 rich certs
reproduce the model). 17.1 glazed_type codes 1-8 are identical to 20.0.0's:
route integer codes through the verified cascade; the "ND" string falls back
to the DG-modal default. Own seam so 17.1 can diverge."""
mgt = schema.multiple_glazing_type
glazing_type = (
_api_cascade_glazing_type(mgt)
if isinstance(mgt, int)
else _RDSAP17_1_ND_GLAZING_TYPE
)
return [
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=orientation,
window_type=0,
glazing_type=glazing_type,
window_width=per_window_area,
window_height=1.0,
draught_proofed=False,
window_location=0,
window_wall_type=0,
permanent_shutters_present=False,
)
for orientation in _RDSAP20_SYNTH_ORIENTATIONS
]
return _synthesise_reduced_field_windows(
schema.glazed_area, schema.total_floor_area, glazing_type
)
# GOV.UK API `glazing_type` integer → (u_value W/m²K, g_perpendicular,