mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
slice S-B6: glazing g_perpendicular + frame_factor lookups (Tables 6b/6c)
Replaces the two hardcoded glazing defaults (g⊥=0.63, FF=0.7) in the
cert→inputs mapper with spec-driven lookups:
- g_perpendicular by glazing_type (Table 6b):
single → 0.85, double 2002+ → 0.72, low-E soft → 0.63,
secondary → 0.76, triple → 0.68. Default 0.72 when missing.
- frame_factor by frame_material (Table 6c):
wood/PVC/composite → 0.70, aluminium/steel/metal → 0.83.
Measured values from window_transmission_details / SapWindow.frame_factor
still take precedence. Overshading factor stays at 0.77 ("average") since
RdSAP 10 doesn't lodge a per-window overshading code.
100-cert parity probe:
MAE 5.65 → 5.70 (flat)
exact-match within ±1: 18% → 20%
bias +1.13 → +1.50
Slight bias drift toward over-prediction is expected — bigger solar
gains reduce predicted heating demand. Net: the engine is now more
spec-correct (more exact matches), but composition of errors elsewhere
needs the next slice to bring bias back toward 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f3baa51a9b
commit
29c776bb23
2 changed files with 103 additions and 13 deletions
|
|
@ -88,6 +88,41 @@ _ORIENTATION_BY_CODE: Final[dict[int, Orientation]] = {
|
|||
}
|
||||
|
||||
|
||||
# SAP 10.3 Table 6b — g_perpendicular (solar transmittance at normal
|
||||
# incidence) by SAP10 glazing_type code. Default 0.72 (modern double
|
||||
# glazing, no low-E) when the cert's glazing_type is missing or
|
||||
# unrecognised — the modal RdSAP case.
|
||||
_G_PERPENDICULAR_BY_GLAZING_TYPE: Final[dict[int, float]] = {
|
||||
1: 0.85, # single
|
||||
2: 0.72, # double 2002-2022 (no low-E)
|
||||
3: 0.72, # double pre-2002
|
||||
4: 0.63, # double low-E soft coat
|
||||
5: 0.76, # secondary glazing
|
||||
6: 0.68, # triple
|
||||
}
|
||||
_G_PERPENDICULAR_DEFAULT: Final[float] = 0.72
|
||||
|
||||
|
||||
# SAP 10.3 Table 6c — frame factor (proportion of window area that is
|
||||
# glazed, not frame) by frame material. The cert lodges this as a free-
|
||||
# text string ("PVC", "Wood", "Metal", "Aluminium"); matching is case-
|
||||
# insensitive and substring-based because site-notes capitalisation
|
||||
# drifts.
|
||||
_FRAME_FACTOR_BY_MATERIAL: Final[tuple[tuple[str, float], ...]] = (
|
||||
("metal with thermal break", 0.80),
|
||||
("metal", 0.83),
|
||||
("aluminium with thermal break", 0.80),
|
||||
("aluminium", 0.83),
|
||||
("steel", 0.83),
|
||||
("wood", 0.70),
|
||||
("timber", 0.70),
|
||||
("pvc", 0.70),
|
||||
("upvc", 0.70),
|
||||
("composite", 0.70),
|
||||
)
|
||||
_FRAME_FACTOR_DEFAULT: Final[float] = 0.70
|
||||
|
||||
|
||||
# SAP 10.3 Table 12 CO2 emission factors (kg CO2 / kWh delivered).
|
||||
# Keys are SAP 10.2 Table 32 fuel codes (the existing fuel-price keys);
|
||||
# anything not listed cascades to mains-gas baseline.
|
||||
|
|
@ -206,14 +241,39 @@ def _living_area_fraction(habitable_rooms_count: Optional[int]) -> float:
|
|||
return _LIVING_AREA_FRACTION_MIN
|
||||
|
||||
|
||||
def _g_perpendicular(w: SapWindow) -> float:
|
||||
"""Solar transmittance at normal incidence per SAP 10.3 Table 6b.
|
||||
Prefer the cert's measured value (`window_transmission_details`);
|
||||
otherwise look up by `glazing_type`."""
|
||||
if w.window_transmission_details is not None:
|
||||
return float(w.window_transmission_details.solar_transmittance)
|
||||
if isinstance(w.glazing_type, int) and w.glazing_type in _G_PERPENDICULAR_BY_GLAZING_TYPE:
|
||||
return _G_PERPENDICULAR_BY_GLAZING_TYPE[w.glazing_type]
|
||||
return _G_PERPENDICULAR_DEFAULT
|
||||
|
||||
|
||||
def _frame_factor(w: SapWindow) -> float:
|
||||
"""SAP 10.3 Table 6c frame factor. Prefer the cert's measured value
|
||||
(`SapWindow.frame_factor`); otherwise look up by `frame_material`."""
|
||||
if w.frame_factor is not None:
|
||||
return float(w.frame_factor)
|
||||
material = (w.frame_material or "").lower()
|
||||
for needle, ff in _FRAME_FACTOR_BY_MATERIAL:
|
||||
if needle in material:
|
||||
return ff
|
||||
return _FRAME_FACTOR_DEFAULT
|
||||
|
||||
|
||||
def _window_inputs(windows: list[SapWindow]) -> tuple[WindowInput, ...]:
|
||||
"""Map each cert window with a known SAP octant to a `WindowInput`.
|
||||
|
||||
Defaults for the optical / shading factors are SAP 10.3 Table 6b/6c/6d
|
||||
typicals (low-e double-glazing, PVC frame, average overshading) — the
|
||||
cert rarely carries these directly. Pitch = 90° (vertical windows).
|
||||
Windows whose orientation isn't in 1-8 are dropped, matching the live
|
||||
ML feature-builder convention.
|
||||
`g_perpendicular` follows SAP 10.3 Table 6b (by glazing_type when no
|
||||
measured transmission details), `frame_factor` follows Table 6c (by
|
||||
frame_material when no measured value). Overshading factor stays at
|
||||
SAP's "average" default 0.77 because the cert doesn't lodge a per-
|
||||
window overshading code in RdSAP 10. Pitch = 90° (vertical windows).
|
||||
Windows whose orientation isn't in 1-8 are dropped, matching the
|
||||
live ML feature-builder convention.
|
||||
"""
|
||||
out: list[WindowInput] = []
|
||||
for w in windows:
|
||||
|
|
@ -221,19 +281,13 @@ def _window_inputs(windows: list[SapWindow]) -> tuple[WindowInput, ...]:
|
|||
if orientation_code is None or orientation_code not in _ORIENTATION_BY_CODE:
|
||||
continue
|
||||
area = float(w.window_width) * float(w.window_height)
|
||||
g = (
|
||||
float(w.window_transmission_details.solar_transmittance)
|
||||
if w.window_transmission_details is not None
|
||||
else 0.63
|
||||
)
|
||||
ff = float(w.frame_factor) if w.frame_factor is not None else 0.7
|
||||
out.append(
|
||||
WindowInput(
|
||||
area_m2=area,
|
||||
orientation=_ORIENTATION_BY_CODE[orientation_code],
|
||||
pitch_deg=90.0,
|
||||
g_perpendicular=g,
|
||||
frame_factor=ff,
|
||||
g_perpendicular=_g_perpendicular(w),
|
||||
frame_factor=_frame_factor(w),
|
||||
overshading_factor=0.77,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -253,6 +253,42 @@ def test_main_heating_control_code_maps_to_sap_control_type() -> None:
|
|||
assert type_3.control_type == 3
|
||||
|
||||
|
||||
def test_window_g_perpendicular_uses_table_6b_by_glazing_type() -> None:
|
||||
# Arrange — SAP 10.3 Table 6b: g⊥ depends on the glazing type when
|
||||
# transmission details aren't measured. Single (code 1) → 0.85;
|
||||
# double low-E soft coat (code 4) → 0.63; triple (code 6) → 0.68.
|
||||
single = make_window(orientation=5, glazing_type=1, frame_material="PVC")
|
||||
triple = make_window(orientation=5, glazing_type=6, frame_material="PVC")
|
||||
low_e_double = make_window(orientation=5, glazing_type=4, frame_material="PVC")
|
||||
base = _typical_semi_detached_epc()
|
||||
base.sap_windows = [single, triple, low_e_double]
|
||||
|
||||
# Act
|
||||
inputs = cert_to_inputs(base)
|
||||
|
||||
# Assert — same orientation, three different glazing types.
|
||||
g_values = sorted(w.g_perpendicular for w in inputs.windows)
|
||||
assert g_values == [0.63, 0.68, 0.85]
|
||||
|
||||
|
||||
def test_window_frame_factor_uses_table_6c_by_frame_material() -> None:
|
||||
# Arrange — Table 6c: PVC/Wood = 0.70; aluminium / steel = 0.83
|
||||
# (no thermal break). Metal-with-thermal-break would be 0.80 but
|
||||
# not tested here since cert strings rarely carry that distinction.
|
||||
pvc = make_window(orientation=5, frame_material="PVC")
|
||||
wood = make_window(orientation=5, frame_material="Wood")
|
||||
aluminium = make_window(orientation=5, frame_material="Aluminium")
|
||||
base = _typical_semi_detached_epc()
|
||||
base.sap_windows = [pvc, wood, aluminium]
|
||||
|
||||
# Act
|
||||
inputs = cert_to_inputs(base)
|
||||
|
||||
# Assert
|
||||
ff_values = sorted(w.frame_factor for w in inputs.windows)
|
||||
assert ff_values == [0.70, 0.70, 0.83]
|
||||
|
||||
|
||||
def test_electric_storage_heater_space_heating_at_off_peak_rate() -> None:
|
||||
# Arrange — RdSAP convention: when the main heating is electric-
|
||||
# storage (code 401-409) or direct-electric (191-196), space heating
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue