mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
Merge pull request #1672 from Hestia-Homes/fix/sap10-accuracy-calc-batch
SAP 10.2 calculator accuracy batch: #1666 / #1667 / #1669 / #1668
This commit is contained in:
commit
09c2b93251
9 changed files with 797 additions and 36 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import copy
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from dataclasses import replace
|
||||
from datetime import date
|
||||
|
|
@ -212,6 +213,35 @@ def _sap_opening_area_m2(width: Any, height: Any) -> float:
|
|||
)
|
||||
|
||||
|
||||
def _normalised_main_heating_fraction(
|
||||
raw: Optional[Union[int, float]],
|
||||
all_fractions: Sequence[Optional[Union[int, float]]],
|
||||
) -> Optional[int]:
|
||||
"""Return `raw` as a canonical PERCENT (RdSAP 10 item 7-5, spec p.81).
|
||||
|
||||
The gov-EPC API lodges a two-main dwelling's `main_heating_fraction` pair in
|
||||
two incompatible encodings — a DECIMAL fraction summing to ~1.0
|
||||
(`[0.8, 0.2]`) or an integer PERCENT summing to ~100 (`[80, 20]`) — and every
|
||||
consumer divides by 100 unconditionally. A decimal pair therefore collapses
|
||||
the second system's share to ~1% of its true value, billing almost the whole
|
||||
load to the first system's fuel and efficiency (#1666).
|
||||
|
||||
Discriminate on the pair sum: a genuine two-main split summing nearer 1.0
|
||||
than 100 is decimal-encoded and scaled to percent; a percent-encoded pair
|
||||
(sum ~100) and any single main are left exactly as lodged, so no
|
||||
percent-encoded cert regresses. Returned as an int to match the domain
|
||||
invariant (`MainHeatingDetail.main_heating_fraction` is a 0-100 percent).
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
present = [float(f) for f in all_fractions if f is not None]
|
||||
if len(present) >= 2:
|
||||
pair_sum = sum(present)
|
||||
if abs(pair_sum - 1.0) < abs(pair_sum - 100.0):
|
||||
return round(float(raw) * 100.0)
|
||||
return round(float(raw))
|
||||
|
||||
|
||||
def _pv_array_field(array: Any, name: str) -> Any:
|
||||
"""Read a PV-array field from either a `PhotovoltaicArray` dataclass (21.0.x,
|
||||
where the schema Union parsed the nested list) or a raw dict (older schemas
|
||||
|
|
@ -753,7 +783,13 @@ class EpcPropertyDataMapper:
|
|||
main_heating_number=d.main_heating_number,
|
||||
main_heating_control=d.main_heating_control,
|
||||
main_heating_category=d.main_heating_category,
|
||||
main_heating_fraction=d.main_heating_fraction,
|
||||
main_heating_fraction=_normalised_main_heating_fraction(
|
||||
d.main_heating_fraction,
|
||||
[
|
||||
x.main_heating_fraction
|
||||
for x in schema.sap_heating.main_heating_details
|
||||
],
|
||||
),
|
||||
sap_main_heating_code=d.sap_main_heating_code,
|
||||
central_heating_pump_age=d.central_heating_pump_age,
|
||||
main_heating_data_source=d.main_heating_data_source,
|
||||
|
|
@ -1385,7 +1421,13 @@ class EpcPropertyDataMapper:
|
|||
main_heating_number=d.main_heating_number,
|
||||
main_heating_control=d.main_heating_control,
|
||||
main_heating_category=d.main_heating_category,
|
||||
main_heating_fraction=d.main_heating_fraction,
|
||||
main_heating_fraction=_normalised_main_heating_fraction(
|
||||
d.main_heating_fraction,
|
||||
[
|
||||
x.main_heating_fraction
|
||||
for x in schema.sap_heating.main_heating_details
|
||||
],
|
||||
),
|
||||
sap_main_heating_code=d.sap_main_heating_code,
|
||||
central_heating_pump_age=None,
|
||||
main_heating_data_source=d.main_heating_data_source,
|
||||
|
|
@ -1573,7 +1615,13 @@ class EpcPropertyDataMapper:
|
|||
main_heating_number=d.main_heating_number,
|
||||
main_heating_control=d.main_heating_control,
|
||||
main_heating_category=d.main_heating_category,
|
||||
main_heating_fraction=d.main_heating_fraction,
|
||||
main_heating_fraction=_normalised_main_heating_fraction(
|
||||
d.main_heating_fraction,
|
||||
[
|
||||
x.main_heating_fraction
|
||||
for x in schema.sap_heating.main_heating_details
|
||||
],
|
||||
),
|
||||
sap_main_heating_code=d.sap_main_heating_code,
|
||||
central_heating_pump_age=d.central_heating_pump_age,
|
||||
main_heating_data_source=d.main_heating_data_source,
|
||||
|
|
@ -1799,7 +1847,13 @@ class EpcPropertyDataMapper:
|
|||
main_heating_number=d.main_heating_number,
|
||||
main_heating_control=d.main_heating_control,
|
||||
main_heating_category=d.main_heating_category,
|
||||
main_heating_fraction=d.main_heating_fraction,
|
||||
main_heating_fraction=_normalised_main_heating_fraction(
|
||||
d.main_heating_fraction,
|
||||
[
|
||||
x.main_heating_fraction
|
||||
for x in schema.sap_heating.main_heating_details
|
||||
],
|
||||
),
|
||||
sap_main_heating_code=d.sap_main_heating_code,
|
||||
central_heating_pump_age=d.central_heating_pump_age,
|
||||
main_heating_data_source=d.main_heating_data_source,
|
||||
|
|
@ -2052,7 +2106,13 @@ class EpcPropertyDataMapper:
|
|||
main_heating_number=d.main_heating_number,
|
||||
main_heating_control=d.main_heating_control,
|
||||
main_heating_category=d.main_heating_category,
|
||||
main_heating_fraction=d.main_heating_fraction,
|
||||
main_heating_fraction=_normalised_main_heating_fraction(
|
||||
d.main_heating_fraction,
|
||||
[
|
||||
x.main_heating_fraction
|
||||
for x in schema.sap_heating.main_heating_details
|
||||
],
|
||||
),
|
||||
sap_main_heating_code=d.sap_main_heating_code,
|
||||
central_heating_pump_age=d.central_heating_pump_age,
|
||||
main_heating_data_source=d.main_heating_data_source,
|
||||
|
|
@ -2285,7 +2345,13 @@ class EpcPropertyDataMapper:
|
|||
boiler_ignition_type=d.boiler_ignition_type,
|
||||
main_heating_control=d.main_heating_control,
|
||||
main_heating_category=d.main_heating_category,
|
||||
main_heating_fraction=d.main_heating_fraction,
|
||||
main_heating_fraction=_normalised_main_heating_fraction(
|
||||
d.main_heating_fraction,
|
||||
[
|
||||
x.main_heating_fraction
|
||||
for x in schema.sap_heating.main_heating_details
|
||||
],
|
||||
),
|
||||
sap_main_heating_code=d.sap_main_heating_code,
|
||||
central_heating_pump_age=d.central_heating_pump_age,
|
||||
main_heating_data_source=d.main_heating_data_source,
|
||||
|
|
@ -2634,7 +2700,13 @@ class EpcPropertyDataMapper:
|
|||
boiler_ignition_type=d.boiler_ignition_type,
|
||||
main_heating_control=d.main_heating_control,
|
||||
main_heating_category=d.main_heating_category,
|
||||
main_heating_fraction=d.main_heating_fraction,
|
||||
main_heating_fraction=_normalised_main_heating_fraction(
|
||||
d.main_heating_fraction,
|
||||
[
|
||||
x.main_heating_fraction
|
||||
for x in schema.sap_heating.main_heating_details
|
||||
],
|
||||
),
|
||||
sap_main_heating_code=d.sap_main_heating_code,
|
||||
central_heating_pump_age=d.central_heating_pump_age,
|
||||
main_heating_data_source=d.main_heating_data_source,
|
||||
|
|
@ -3130,13 +3202,16 @@ class EpcPropertyDataMapper:
|
|||
)
|
||||
return None
|
||||
|
||||
return _clear_basement_flag_when_system_built(
|
||||
_with_renewable_heat_incentive(
|
||||
_with_recorded_performance(
|
||||
_drop_building_parts_without_age_band(mapped), data
|
||||
),
|
||||
data,
|
||||
)
|
||||
return _with_internal_dimensions(
|
||||
_clear_basement_flag_when_system_built(
|
||||
_with_renewable_heat_incentive(
|
||||
_with_recorded_performance(
|
||||
_drop_building_parts_without_age_band(mapped), data
|
||||
),
|
||||
data,
|
||||
)
|
||||
),
|
||||
data,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -3323,10 +3398,12 @@ def _sap_17_1_heating(schema: SapSchema17_1) -> SapHeating:
|
|||
main_heating_index_number=d.main_heating_index_number,
|
||||
main_heating_number=d.main_heating_number,
|
||||
main_heating_category=d.main_heating_category,
|
||||
main_heating_fraction=(
|
||||
int(d.main_heating_fraction)
|
||||
if d.main_heating_fraction is not None
|
||||
else None
|
||||
# #1666: normalise the encoding (and fix the old `int()` floor
|
||||
# that silently deleted a decimal-lodged second main, e.g.
|
||||
# int(0.35) -> 0) via the shared pair-sum discriminator.
|
||||
main_heating_fraction=_normalised_main_heating_fraction(
|
||||
d.main_heating_fraction,
|
||||
[x.main_heating_fraction for x in sh.main_heating_details],
|
||||
),
|
||||
main_heating_data_source=d.main_heating_data_source,
|
||||
)
|
||||
|
|
@ -3351,6 +3428,181 @@ def _sap_back_solved_habitable_rooms(schema: SapSchema17_1) -> int:
|
|||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RdSAP 10 §3.4 conversion of external to internal dimensions (#1669)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# `measurement_type` lodged value: 1 = internal dimensions, 2 = external.
|
||||
_EXTERNAL_MEASUREMENT: Final[int] = 2
|
||||
|
||||
|
||||
def _table_3_row(*vals: int) -> Dict[str, int]:
|
||||
"""Expand a Table 3 wall-thickness row's 10 spec columns to the 13 age
|
||||
bands: bands I/J/K share the 9th column and L/M share the 10th."""
|
||||
a, b, c, d, e, f, g, h, ijk, lm = vals
|
||||
return {
|
||||
"A": a, "B": b, "C": c, "D": d, "E": e, "F": f, "G": g, "H": h,
|
||||
"I": ijk, "J": ijk, "K": ijk, "L": lm, "M": lm,
|
||||
}
|
||||
|
||||
|
||||
# RdSAP 10 Table 3 (PDF p.19): default main-dwelling wall thickness (mm) by wall
|
||||
# construction row and age band, used only when the thickness is not lodged.
|
||||
_TABLE_3_WALL_THICKNESS_MM: Final[Dict[str, Dict[str, int]]] = {
|
||||
"solid": _table_3_row(220, 220, 220, 220, 240, 250, 270, 270, 300, 300),
|
||||
"cavity": _table_3_row(250, 250, 250, 250, 250, 260, 270, 270, 300, 300),
|
||||
"timber": _table_3_row(150, 150, 150, 250, 270, 270, 270, 270, 300, 300),
|
||||
"cob": _table_3_row(540, 540, 540, 540, 540, 540, 560, 560, 590, 590),
|
||||
"system": _table_3_row(250, 250, 250, 250, 250, 300, 300, 300, 300, 300),
|
||||
}
|
||||
# `wall_construction` code → Table 3 row. Table 3 has no stone row (stone is
|
||||
# normally measured); stone (1/2) and any non-masonry/unknown fall back to the
|
||||
# solid-masonry column.
|
||||
_WALL_CONSTRUCTION_TO_TABLE_3_ROW: Final[Dict[int, str]] = {
|
||||
1: "solid", 2: "solid", 3: "solid", # stone/granite, sandstone, solid brick
|
||||
4: "cavity", 5: "timber", 6: "system", 7: "cob",
|
||||
}
|
||||
|
||||
|
||||
def _main_wall_thickness_m(epc: EpcPropertyData) -> Optional[float]:
|
||||
"""The main dwelling's wall thickness in metres (RdSAP 10 §3.4 `w`): the
|
||||
lodged value if present, else the Table 3 default for the main part's wall
|
||||
construction + age band. None when neither is derivable."""
|
||||
parts = epc.sap_building_parts
|
||||
if not parts:
|
||||
return None
|
||||
main = parts[0]
|
||||
if main.wall_thickness_mm is not None:
|
||||
return main.wall_thickness_mm / 1000.0
|
||||
construction = main.wall_construction
|
||||
row = (
|
||||
_WALL_CONSTRUCTION_TO_TABLE_3_ROW.get(construction, "solid")
|
||||
if isinstance(construction, int)
|
||||
else "solid"
|
||||
)
|
||||
band = (main.construction_age_band or "").strip().upper()[:1]
|
||||
mm = _TABLE_3_WALL_THICKNESS_MM[row].get(band)
|
||||
return mm / 1000.0 if mm is not None else None
|
||||
|
||||
|
||||
def _table_2_ratios(
|
||||
built_form: Optional[str], area_ext: float, perim_ext: float, w: float
|
||||
) -> tuple[float, float]:
|
||||
"""RdSAP 10 Table 2 (PDF p.18) whole-dwelling external→internal
|
||||
(area_ratio, perimeter_ratio) by built form (1 detached, 2 semi, 3
|
||||
end-terrace, 4 mid-terrace, 5 enclosed end-terrace, 6 enclosed mid-terrace).
|
||||
`area_ext`/`perim_ext` are the ground-floor FOOTPRINT (§3.4 Note 4: one ratio
|
||||
for the whole dwelling). Returns (1, 1) — no conversion — for an unrecognised
|
||||
form or a degenerate footprint."""
|
||||
if area_ext <= 0.0 or perim_ext <= 0.0:
|
||||
return (1.0, 1.0)
|
||||
bf = (built_form or "").strip()
|
||||
if bf == "1": # detached
|
||||
perim_int = perim_ext - 8.0 * w
|
||||
area_int = area_ext - w * perim_int - 4.0 * w * w
|
||||
elif bf in ("2", "3"): # semi-detached / end-terrace
|
||||
if perim_ext**2 >= 8.0 * area_ext:
|
||||
perim_int = perim_ext - 5.0 * w
|
||||
a = 0.5 * (perim_ext - math.sqrt(perim_ext**2 - 8.0 * area_ext))
|
||||
area_int = area_ext - w * (perim_ext + 0.5 * a) + 3.0 * w * w
|
||||
else:
|
||||
perim_int = perim_ext - 3.0 * w
|
||||
area_int = area_ext - w * perim_ext + 3.0 * w * w
|
||||
elif bf == "4": # mid-terrace
|
||||
perim_int = perim_ext - 2.0 * w
|
||||
area_int = area_ext - w * (perim_ext + 2.0 * area_ext / perim_ext) + 2.0 * w * w
|
||||
elif bf == "5": # enclosed end-terrace
|
||||
perim_int = perim_ext - 3.0 * w
|
||||
area_int = area_ext - 1.5 * w * perim_ext + 2.25 * w * w
|
||||
elif bf == "6": # enclosed mid-terrace
|
||||
perim_int = perim_ext - w
|
||||
area_int = area_ext - w * (area_ext / perim_ext + 1.5 * perim_ext) + 1.5 * w * w
|
||||
else:
|
||||
return (1.0, 1.0)
|
||||
if area_int <= 0.0 or perim_int <= 0.0:
|
||||
return (1.0, 1.0)
|
||||
return (area_int / area_ext, perim_int / perim_ext)
|
||||
|
||||
|
||||
def _domain_total_floor_area_m2(building_parts: List[SapBuildingPart]) -> float:
|
||||
"""Sum the domain floor-dimension areas (plus each part's always-internal
|
||||
room-in-roof floor area) — used to refresh `total_floor_area_m2` after a
|
||||
§3.4 conversion, since `water_heating_from_cert` reads that scalar for
|
||||
occupancy N."""
|
||||
total = 0.0
|
||||
for bp in building_parts:
|
||||
for fd in bp.sap_floor_dimensions:
|
||||
total += fd.total_floor_area_m2 or 0.0
|
||||
rir = bp.sap_room_in_roof
|
||||
if rir is not None and rir.floor_area:
|
||||
total += rir.floor_area
|
||||
return total
|
||||
|
||||
|
||||
def _with_internal_dimensions(
|
||||
epc: EpcPropertyData, data: Dict[str, Any]
|
||||
) -> EpcPropertyData:
|
||||
"""Plumb the lodged `measurement_type` onto `epc` and, when the horizontal
|
||||
dimensions were measured externally (== 2), convert them to internal
|
||||
dimensions per RdSAP 10 §3.4 + Table 2 before they reach the cascade (#1669).
|
||||
|
||||
§3.4 Note 4: ONE whole-dwelling area/perimeter ratio (from the ground-floor
|
||||
footprint) is applied to every storey of every part — not a per-storey
|
||||
conversion, which over-corrects multi-part mid-terraces. Room-in-roof floor
|
||||
area is always measured internally (§3.1) and is left untouched; party wall
|
||||
lengths are reduced by 2w (Table 2 Note 5). Internal certs are unchanged.
|
||||
|
||||
Scope: the conversion is applied only to the RdSAP-Schema reduced-data family
|
||||
(17.0-21.x), where per-storey external dimensions are the lodgement standard
|
||||
and the fix is corpus-validated (RdSAP-21.0.1). `measurement_type` is still
|
||||
plumbed for every schema, but the legacy SAP-Schema-15.0/16.x certs — whose
|
||||
per-storey areas do not reconcile to the lodged TFA — are left unconverted."""
|
||||
epc.measurement_type = data.get("measurement_type")
|
||||
schema_type = data.get("schema_type", "")
|
||||
if (
|
||||
epc.measurement_type != _EXTERNAL_MEASUREMENT
|
||||
or not isinstance(schema_type, str)
|
||||
or not schema_type.startswith("RdSAP-Schema-")
|
||||
or not epc.sap_building_parts
|
||||
):
|
||||
return epc
|
||||
w = _main_wall_thickness_m(epc)
|
||||
if w is None:
|
||||
return epc
|
||||
ground_levels = [
|
||||
fd.floor
|
||||
for bp in epc.sap_building_parts
|
||||
for fd in bp.sap_floor_dimensions
|
||||
if fd.floor is not None
|
||||
]
|
||||
if not ground_levels:
|
||||
return epc
|
||||
ground = min(ground_levels)
|
||||
area_ext = sum(
|
||||
fd.total_floor_area_m2 or 0.0
|
||||
for bp in epc.sap_building_parts
|
||||
for fd in bp.sap_floor_dimensions
|
||||
if fd.floor == ground
|
||||
)
|
||||
perim_ext = sum(
|
||||
fd.heat_loss_perimeter_m or 0.0
|
||||
for bp in epc.sap_building_parts
|
||||
for fd in bp.sap_floor_dimensions
|
||||
if fd.floor == ground
|
||||
)
|
||||
area_ratio, perim_ratio = _table_2_ratios(epc.built_form, area_ext, perim_ext, w)
|
||||
if area_ratio == 1.0 and perim_ratio == 1.0:
|
||||
return epc
|
||||
for bp in epc.sap_building_parts:
|
||||
for fd in bp.sap_floor_dimensions:
|
||||
fd.total_floor_area_m2 *= area_ratio
|
||||
fd.heat_loss_perimeter_m *= perim_ratio
|
||||
if fd.party_wall_length_m:
|
||||
fd.party_wall_length_m = max(0.0, fd.party_wall_length_m - 2.0 * w)
|
||||
epc.total_floor_area_m2 = _domain_total_floor_area_m2(epc.sap_building_parts)
|
||||
return epc
|
||||
|
||||
|
||||
def _with_recorded_performance(
|
||||
epc: EpcPropertyData, data: Dict[str, Any]
|
||||
) -> EpcPropertyData:
|
||||
|
|
@ -5269,24 +5521,67 @@ _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: Dict[
|
|||
}
|
||||
|
||||
|
||||
# RdSAP 10 Table 24 METAL-frame U-column (PDF p.50-51) — #1667. Only the U
|
||||
# differs from the PVC/wooden column above (the glazing g is frame-independent);
|
||||
# the frame factor becomes 0.8 (SAP 10.2 Table 6c) instead of 0.70. Secondary
|
||||
# glazing (types 4/11/12) has no separate metal U — its combined U is
|
||||
# frame-independent — so it is absent here and keeps the PVC-column U. Values
|
||||
# mirror `u_window`'s metal column (`rdsap_uvalues.py`): single 5.7; DG pre-2002
|
||||
# 6/12/16 = 3.7/3.4/3.3; triple pre-2002 6/12/16 = 2.9/2.6/2.5; DG/triple 2002+
|
||||
# = 2.2; 2022+ = 1.6.
|
||||
_API_GLAZING_TYPE_TO_METAL_U: Dict[int, float] = {
|
||||
1: 3.4, 3: 3.4, 7: 3.4, # DG pre-2002 / unknown-date, 12mm default
|
||||
2: 2.2, 9: 2.2, # DG (2) / triple (9) 2002+ (pre-2022)
|
||||
13: 1.6, 14: 1.6, # DG / DG-or-triple 2022+
|
||||
5: 5.7, 15: 5.7, # single glazing
|
||||
6: 2.6, 8: 2.6, 10: 2.6, # triple pre-2002 / unknown / known, 12mm default
|
||||
}
|
||||
_API_GLAZING_TYPE_GAP_TO_METAL_U: Dict[tuple[int, object], float] = {
|
||||
(1, 6): 3.7, (1, 12): 3.4, (1, "16+"): 3.3,
|
||||
(3, 6): 3.7, (3, 12): 3.4, (3, "16+"): 3.3,
|
||||
(7, 6): 3.7, (7, 12): 3.4, (7, "16+"): 3.3,
|
||||
(6, 6): 2.9, (6, 12): 2.6, (6, "16+"): 2.5,
|
||||
(8, 6): 2.9, (8, 12): 2.6, (8, "16+"): 2.5,
|
||||
(10, 6): 2.9, (10, 12): 2.6, (10, "16+"): 2.5,
|
||||
}
|
||||
|
||||
# SAP 10.2 Table 6c window frame factor: 0.8 for metal, 0.70 for PVC/wooden.
|
||||
_METAL_FRAME_FACTOR: Final[float] = 0.8
|
||||
|
||||
|
||||
def _api_glazing_transmission(
|
||||
glazing_type: Optional[int],
|
||||
glazing_gap: object,
|
||||
pvc_frame: object = None,
|
||||
) -> Optional[tuple[float, float, float]]:
|
||||
"""Resolve (U, g, frame_factor) for an API window from RdSAP 10 Table 24.
|
||||
Per-gap override takes precedence over the type-only default. Returns None
|
||||
only when `glazing_type` is absent (None → cascade default). A glazing_type
|
||||
PRESENT but unmapped raises `UnmappedApiCode` rather than silently routing
|
||||
the window to the u_window all-None default U=2.5 — the forcing function
|
||||
that surfaced single-glazing (code 5 → 4.8) instead of letting it hide."""
|
||||
that surfaced single-glazing (code 5 → 4.8) instead of letting it hide.
|
||||
|
||||
`pvc_frame` is the lodged RdSAP input 6-5 boolean (spec p.80): "true" =
|
||||
wooden/PVC (grouped in Table 24), "false" = metal. Only an explicit "false"
|
||||
routes the window to the Table 24 metal U-column + FF 0.8 (#1667); "true",
|
||||
None or any other value keeps the PVC/wooden column + FF 0.70, so no
|
||||
PVC-lodged cert regresses."""
|
||||
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]
|
||||
if glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION:
|
||||
return _API_GLAZING_TYPE_TO_TRANSMISSION[glazing_type]
|
||||
raise UnmappedApiCode("glazing_type", glazing_type)
|
||||
base = _API_GLAZING_TYPE_GAP_TO_TRANSMISSION[gap_key]
|
||||
elif glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION:
|
||||
base = _API_GLAZING_TYPE_TO_TRANSMISSION[glazing_type]
|
||||
else:
|
||||
raise UnmappedApiCode("glazing_type", glazing_type)
|
||||
if pvc_frame != "false":
|
||||
return base
|
||||
u_pvc, g_perp, _pvc_ff = base
|
||||
metal_u = _API_GLAZING_TYPE_GAP_TO_METAL_U.get(
|
||||
gap_key, _API_GLAZING_TYPE_TO_METAL_U.get(glazing_type, u_pvc)
|
||||
)
|
||||
return (metal_u, g_perp, _METAL_FRAME_FACTOR)
|
||||
|
||||
|
||||
# GOV.UK RdSAP 21 `glazing_type` integer → SAP 10.2 Table 6b cascade
|
||||
|
|
@ -5405,7 +5700,7 @@ def _api_sap_roof_window(w: Any) -> SapRoofWindow:
|
|||
Table 6e Note 2 inclination adjustment (+0.30 W/m²K at 45° pitch) to
|
||||
the inclined-position value the worksheet bills on (27a). Mirror of
|
||||
the site-notes `_map_elmhurst_roof_window`."""
|
||||
transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap)
|
||||
transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap, w.pvc_frame)
|
||||
vertical_u = transmission[0] if transmission is not None else 2.0
|
||||
g_perp = transmission[1] if transmission is not None else 0.76
|
||||
frame_factor = w.frame_factor
|
||||
|
|
@ -5475,7 +5770,7 @@ def _api_sap_window(w: Any) -> SapWindow:
|
|||
to the SAP 10.2 Table 6b cascade enum so the cascade's daylight g_L
|
||||
lookup picks the spec-correct value (e.g. RdSAP 21 code 1 = DG
|
||||
pre-2002, cascade g_L=0.80, not single-glazed 0.90)."""
|
||||
transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap)
|
||||
transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap, w.pvc_frame)
|
||||
frame_factor: Optional[float] = w.frame_factor
|
||||
if frame_factor is None and transmission is not None:
|
||||
frame_factor = transmission[2]
|
||||
|
|
|
|||
|
|
@ -2141,9 +2141,52 @@ def _control_type(main: Optional[MainHeatingDetail]) -> int:
|
|||
raise UnmappedSapCode("main_heating_control", code)
|
||||
|
||||
|
||||
# RdSAP 10 Table 29 (PDF p.56-57): a solid floor in age bands A-E carries no
|
||||
# insulation layer, so wet underfloor pipes sit in a bare concrete slab (SAP 10.2
|
||||
# Table 4d R=0.25). F-M solid floors carry insulation → screed above (0.75). #1668
|
||||
_CONCRETE_SLAB_UNDERFLOOR_BANDS: Final[frozenset[str]] = frozenset("ABCDE")
|
||||
# Lumped gov-EPC "underfloor" heat-emitter code — carries no screed/timber/slab
|
||||
# subtype; the subtype is derived from floor construction + age band per Table 29.
|
||||
_UNDERFLOOR_EMITTER_CODE: Final[int] = 2
|
||||
|
||||
|
||||
def _underfloor_responsiveness(
|
||||
floor_description: Optional[str], age_band: Optional[str]
|
||||
) -> float:
|
||||
"""SAP 10.2 Table 4d underfloor responsiveness selected via RdSAP 10 Table 29
|
||||
from the main part's floor construction + age band (#1668):
|
||||
|
||||
- suspended TIMBER floor → 1.0 (fully responsive timber-floor UFH)
|
||||
- solid floor, age A-E → 0.25 (bare concrete slab, no insulation)
|
||||
- solid F-M / suspended-not-timber / unknown / no ground floor
|
||||
→ 0.75 (screed above insulation — the prior
|
||||
default, so ambiguous certs do not move).
|
||||
"""
|
||||
desc = (floor_description or "").lower()
|
||||
is_timber = "timber" in desc and "not timber" not in desc
|
||||
if is_timber:
|
||||
return 1.0
|
||||
band = (age_band or "").strip().upper()[:1]
|
||||
if "solid" in desc and band in _CONCRETE_SLAB_UNDERFLOOR_BANDS:
|
||||
return 0.25
|
||||
return 0.75
|
||||
|
||||
|
||||
def _main_underfloor_floor_context(
|
||||
epc: Optional[EpcPropertyData],
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""(floor construction description, age band) of the main building part —
|
||||
the Table 29 discriminator for the underfloor subtype in `_responsiveness`."""
|
||||
if epc is None or not epc.sap_building_parts:
|
||||
return (None, None)
|
||||
main_bp = epc.sap_building_parts[0]
|
||||
return (_effective_floor_description(epc, main_bp), main_bp.construction_age_band)
|
||||
|
||||
|
||||
def _responsiveness(
|
||||
main: Optional[MainHeatingDetail],
|
||||
tariff: Optional[Tariff] = None,
|
||||
epc: Optional[EpcPropertyData] = None,
|
||||
) -> float:
|
||||
"""SAP 10.2 responsiveness R ∈ [0, 1] per spec line 15271:
|
||||
|
||||
|
|
@ -2183,9 +2226,10 @@ def _responsiveness(
|
|||
4 = Warm air → R = 1.0
|
||||
5 = Fan coils → R = 1.0
|
||||
|
||||
"Concrete slab" UFH (Table 4d R=0.25) has no cert-side enum entry
|
||||
yet — that variant would need a new mapper code before the cascade
|
||||
can dispatch it.
|
||||
The lumped underfloor code 2 carries no screed/timber/concrete-slab
|
||||
subtype; its Table 4d responsiveness (0.25 / 0.75 / 1.0) is derived
|
||||
from the main part's floor construction + age band per RdSAP 10
|
||||
Table 29 via `_underfloor_responsiveness` (needs `epc`; #1668).
|
||||
|
||||
Strict-dispatch per [[reference-unmapped-sap-code]]: absent lodging
|
||||
(None / 0 / "") returns modal default R=1.0 (radiators); lodging
|
||||
|
|
@ -2210,6 +2254,9 @@ def _responsiveness(
|
|||
emitter = main.heat_emitter_type
|
||||
if not emitter:
|
||||
return 1.0
|
||||
if emitter == _UNDERFLOOR_EMITTER_CODE:
|
||||
floor_description, age_band = _main_underfloor_floor_context(epc)
|
||||
return _underfloor_responsiveness(floor_description, age_band)
|
||||
if isinstance(emitter, int) and emitter in _RESPONSIVENESS_BY_EMITTER_CODE:
|
||||
return _RESPONSIVENESS_BY_EMITTER_CODE[emitter]
|
||||
raise UnmappedSapCode("heat_emitter_type", emitter)
|
||||
|
|
@ -4958,7 +5005,7 @@ def mean_internal_temperature_section_from_cert(
|
|||
thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc),
|
||||
total_floor_area_m2=dim.total_floor_area_m2,
|
||||
control_type=_control_type(main),
|
||||
responsiveness=_responsiveness(main, tariff=tariff),
|
||||
responsiveness=_responsiveness(main, tariff=tariff, epc=epc),
|
||||
living_area_fraction=_living_area_fraction(
|
||||
epc.habitable_rooms_count, dim.total_floor_area_m2
|
||||
),
|
||||
|
|
@ -8255,7 +8302,7 @@ def cert_to_inputs(
|
|||
# for the Elmhurst corpus (cert-side mapping is a future slice).
|
||||
control_type_value = _control_type(main)
|
||||
_mit_tariff = tariff_from_meter_type(epc.sap_energy_source.meter_type)
|
||||
responsiveness_value = _responsiveness(main, tariff=_mit_tariff)
|
||||
responsiveness_value = _responsiveness(main, tariff=_mit_tariff, epc=epc)
|
||||
living_area_fraction_value = _living_area_fraction(
|
||||
epc.habitable_rooms_count, dim.total_floor_area_m2
|
||||
)
|
||||
|
|
@ -8279,7 +8326,7 @@ def cert_to_inputs(
|
|||
main_2_control_type_value = _control_type(_mit_main_2)
|
||||
main_2_fraction_value = _mit_main_2.main_heating_fraction / 100.0
|
||||
main_2_responsiveness_value = _responsiveness(
|
||||
_mit_main_2, tariff=_mit_tariff
|
||||
_mit_main_2, tariff=_mit_tariff, epc=epc
|
||||
)
|
||||
monthly_total_gains_w = tuple(
|
||||
internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
"""#1669 — when a cert lodges its horizontal dimensions measured externally
|
||||
(`measurement_type == 2`), RdSAP 10 §3.4 + Table 2 (spec p.17-18) require the
|
||||
external floor area and exposed perimeter to be converted to INTERNAL dimensions
|
||||
before any SAP use. `measurement_type` was read nowhere, so external certs
|
||||
carried a TFA ~11-15% too large — corrupting occupancy, reported floor area and
|
||||
every per-m² output. The mapper must plumb `measurement_type` through and, for
|
||||
external certs, apply the Table 2 whole-dwelling ratio (§3.4 Note 4) before the
|
||||
values reach the cascade, while leaving internal (`== 1`) certs verbatim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
from domain.sap10_calculator.calculator import Sap10Calculator
|
||||
|
||||
_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl")
|
||||
|
||||
|
||||
def _corpus_cert(uprn: str) -> dict[str, Any]:
|
||||
for line in _CORPUS.read_text().splitlines():
|
||||
if uprn in line:
|
||||
cert: dict[str, Any] = json.loads(line)
|
||||
return cert
|
||||
raise AssertionError(f"uprn {uprn} not found in the RdSAP-21.0.1 corpus")
|
||||
|
||||
|
||||
def _with_measurement_type(payload: dict[str, Any], value: int) -> dict[str, Any]:
|
||||
twin = deepcopy(payload)
|
||||
twin["measurement_type"] = value
|
||||
return twin
|
||||
|
||||
|
||||
def _mapped_tfa(payload: dict[str, Any]) -> float:
|
||||
epc = EpcPropertyDataMapper.from_api_response(payload)
|
||||
assert epc is not None
|
||||
assert epc.total_floor_area_m2 is not None
|
||||
return epc.total_floor_area_m2
|
||||
|
||||
|
||||
def test_measurement_type_is_plumbed_onto_the_domain_object() -> None:
|
||||
# Arrange — cert 100091435353 lodges measurement_type 2; the field was
|
||||
# dropped to None by every API mapper (`# What is this?`).
|
||||
payload = _corpus_cert("100091435353")
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_api_response(payload)
|
||||
|
||||
# Assert
|
||||
assert epc is not None
|
||||
assert epc.measurement_type == 2
|
||||
|
||||
|
||||
def test_external_cert_tfa_is_converted_to_internal_dimensions() -> None:
|
||||
# Arrange — 100091435353 (detached, w=270mm) lodges a 128.7 m^2 EXTERNAL
|
||||
# floor-area sum; its lodged internal TFA is 112.
|
||||
payload = _corpus_cert("100091435353")
|
||||
|
||||
# Act
|
||||
tfa = _mapped_tfa(payload)
|
||||
|
||||
# Assert — Table 2 detached conversion recovers the internal TFA to <0.5 m^2,
|
||||
# not the raw external 128.7.
|
||||
assert abs(tfa - 112.0) <= 0.5
|
||||
|
||||
|
||||
def test_internal_cert_dimensions_are_left_verbatim() -> None:
|
||||
# Arrange — the same cert forced to measurement_type 1 (internal); §3.4 does
|
||||
# not apply, so the floor areas must be used exactly as lodged (128.7).
|
||||
payload = _with_measurement_type(_corpus_cert("100091435353"), 1)
|
||||
|
||||
# Act
|
||||
tfa = _mapped_tfa(payload)
|
||||
|
||||
# Assert — no conversion; the external sum is passed through unchanged.
|
||||
assert abs(tfa - 128.7) <= 1e-6
|
||||
|
||||
|
||||
def test_external_conversion_moves_sap_toward_lodged() -> None:
|
||||
# Arrange — 100091435353 over-rated at 66.23 on HEAD (external dims), lodged 65.
|
||||
payload = _corpus_cert("100091435353")
|
||||
epc = EpcPropertyDataMapper.from_api_response(payload)
|
||||
assert epc is not None
|
||||
|
||||
# Act
|
||||
sap = Sap10Calculator().calculate(epc).sap_score_continuous
|
||||
|
||||
# Assert — the internal-dimension conversion pulls it to ~65.33, toward lodged.
|
||||
assert abs(sap - 65.33) <= 0.05
|
||||
116
tests/datatypes/epc/domain/test_mapper_main_heating_fraction.py
Normal file
116
tests/datatypes/epc/domain/test_mapper_main_heating_fraction.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""#1666 — a two-main dwelling's `main_heating_fraction` pair is lodged by the
|
||||
gov-EPC API in two incompatible encodings: a DECIMAL fraction summing to ~1.0
|
||||
(`[0.8, 0.2]`) or an integer PERCENT summing to ~100 (`[80, 20]`). Every consumer
|
||||
divides by 100 unconditionally, so a decimal-encoded pair collapses the second
|
||||
main's share to ~1% of its true value and bills almost the whole load to the
|
||||
first system's fuel/efficiency. The mapper must normalise both encodings to the
|
||||
spec-canonical percent (RdSAP 10 item 7-5, spec p.81) so the encoding is
|
||||
invisible downstream, without regressing the percent-encoded certs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
from domain.sap10_calculator.calculator import Sap10Calculator
|
||||
|
||||
_RDSAP_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl")
|
||||
_SAP_17_1_CORPUS = Path("backend/epc_api/json_samples/SAP-Schema-17.1/corpus.jsonl")
|
||||
|
||||
|
||||
def _corpus_cert(corpus: Path, uprn: str) -> dict[str, Any]:
|
||||
for line in corpus.read_text().splitlines():
|
||||
if uprn in line:
|
||||
cert: dict[str, Any] = json.loads(line)
|
||||
return cert
|
||||
raise AssertionError(f"uprn {uprn} not found in {corpus.name}")
|
||||
|
||||
|
||||
def _mapped_fractions(payload: dict[str, Any]) -> list[Optional[int]]:
|
||||
epc = EpcPropertyDataMapper.from_api_response(payload)
|
||||
assert epc is not None
|
||||
return [m.main_heating_fraction for m in epc.sap_heating.main_heating_details if m]
|
||||
|
||||
|
||||
def _continuous_sap(payload: dict[str, Any]) -> float:
|
||||
epc = EpcPropertyDataMapper.from_api_response(payload)
|
||||
assert epc is not None
|
||||
return Sap10Calculator().calculate(epc).sap_score_continuous
|
||||
|
||||
|
||||
def _scaled_fractions(payload: dict[str, Any], factor: float) -> dict[str, Any]:
|
||||
"""Deep-copy `payload`, multiplying every lodged `main_heating_fraction` by
|
||||
`factor` — used to build a percent-encoded twin of a decimal-encoded cert."""
|
||||
twin = deepcopy(payload)
|
||||
|
||||
def walk(node: object) -> None:
|
||||
if isinstance(node, dict):
|
||||
node_dict = cast("dict[str, Any]", node)
|
||||
for key, value in node_dict.items():
|
||||
if key == "main_heating_fraction" and value is not None:
|
||||
node_dict[key] = value * factor
|
||||
else:
|
||||
walk(value)
|
||||
elif isinstance(node, list):
|
||||
for value in cast("list[Any]", node):
|
||||
walk(value)
|
||||
|
||||
walk(twin)
|
||||
return twin
|
||||
|
||||
|
||||
def test_decimal_encoded_fraction_pair_is_normalised_to_percent() -> None:
|
||||
# Arrange — corpus cert 10070086972 (semi-detached, lodged SAP 18) lodges its
|
||||
# two mains as a DECIMAL pair [0.8, 0.2] summing to 1.0; the /100 consumers
|
||||
# would otherwise bill the second main at 0.2% of the load, not 20%.
|
||||
payload = _corpus_cert(_RDSAP_CORPUS, "10070086972")
|
||||
|
||||
# Act
|
||||
fractions = _mapped_fractions(payload)
|
||||
|
||||
# Assert — scaled to the spec-canonical percent (RdSAP 10 item 7-5, p.81).
|
||||
assert fractions == [80, 20]
|
||||
|
||||
|
||||
def test_percent_encoded_fraction_pair_is_left_unchanged() -> None:
|
||||
# Arrange — corpus cert 40037847 lodges a PERCENT pair [10, 90] summing to
|
||||
# ~100; the pair-sum discriminator must leave it exactly as lodged so the 3
|
||||
# percent-encoded corpus certs do not regress.
|
||||
payload = _corpus_cert(_RDSAP_CORPUS, "40037847")
|
||||
|
||||
# Act
|
||||
fractions = _mapped_fractions(payload)
|
||||
|
||||
# Assert
|
||||
assert fractions == [10, 90]
|
||||
|
||||
|
||||
def test_encoding_is_invisible_to_the_continuous_sap() -> None:
|
||||
# Arrange — the decimal cert and a PERCENT twin built by scaling its lodged
|
||||
# fractions x100 ([0.8, 0.2] -> [80, 20]); the encoding must not change SAP.
|
||||
decimal_payload = _corpus_cert(_RDSAP_CORPUS, "10070086972")
|
||||
percent_payload = _scaled_fractions(decimal_payload, 100)
|
||||
|
||||
# Act
|
||||
sap_decimal = _continuous_sap(decimal_payload)
|
||||
sap_percent = _continuous_sap(percent_payload)
|
||||
|
||||
# Assert — the two encodings must produce the same continuous SAP.
|
||||
assert abs(sap_decimal - sap_percent) <= 1e-9
|
||||
|
||||
|
||||
def test_sap_17_1_decimal_second_main_is_not_floored_to_zero() -> None:
|
||||
# Arrange — SAP-Schema-17.1 cert 38334849 lodges two mains as a DECIMAL pair
|
||||
# [0.5, 0.5]. The old `int(fraction)` floor mapped BOTH to 0, silently
|
||||
# deleting the split; the shared normaliser must scale to percent instead.
|
||||
payload = _corpus_cert(_SAP_17_1_CORPUS, "38334849")
|
||||
|
||||
# Act
|
||||
fractions = _mapped_fractions(payload)
|
||||
|
||||
# Assert
|
||||
assert fractions == [50, 50]
|
||||
100
tests/datatypes/epc/domain/test_mapper_metal_window_frame.py
Normal file
100
tests/datatypes/epc/domain/test_mapper_metal_window_frame.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""#1667 — on the gov-EPC API path a window's metal frame was unrepresentable:
|
||||
the transmission lookup had no frame dimension, so every window was billed at
|
||||
the PVC/wooden U-column and frame factor 0.70 even where the cert lodges a metal
|
||||
frame (`pvc_frame:"false"`). RdSAP 10 Table 24 gives metal a higher U (+0.2 to
|
||||
+0.9 W/m^2K) and SAP 10.2 Table 6c a frame factor of 0.8, so we over-rated
|
||||
metal-framed dwellings. The mapper must route a metal-lodged window to the
|
||||
Table 24 metal column + FF 0.8, leaving PVC-lodged windows unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
from domain.sap10_calculator.calculator import Sap10Calculator
|
||||
|
||||
_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl")
|
||||
|
||||
|
||||
def _corpus_cert(uprn: str) -> dict[str, Any]:
|
||||
for line in _CORPUS.read_text().splitlines():
|
||||
if uprn in line:
|
||||
cert: dict[str, Any] = json.loads(line)
|
||||
return cert
|
||||
raise AssertionError(f"uprn {uprn} not found in the RdSAP-21.0.1 corpus")
|
||||
|
||||
|
||||
def _forced_pvc_frame(payload: dict[str, Any], value: str) -> dict[str, Any]:
|
||||
"""Deep-copy `payload`, setting every lodged `pvc_frame` to `value` — used to
|
||||
build a PVC-framed twin of a metal-framed cert."""
|
||||
twin = deepcopy(payload)
|
||||
|
||||
def walk(node: object) -> None:
|
||||
if isinstance(node, dict):
|
||||
node_dict = cast("dict[str, Any]", node)
|
||||
for key, child in node_dict.items():
|
||||
if key == "pvc_frame" and child is not None:
|
||||
node_dict[key] = value
|
||||
else:
|
||||
walk(child)
|
||||
elif isinstance(node, list):
|
||||
for child in cast("list[Any]", node):
|
||||
walk(child)
|
||||
|
||||
walk(twin)
|
||||
return twin
|
||||
|
||||
|
||||
def _window_u_and_ff(payload: dict[str, Any]) -> tuple[set[float], set[float]]:
|
||||
epc = EpcPropertyDataMapper.from_api_response(payload)
|
||||
assert epc is not None
|
||||
u_values = {
|
||||
w.window_transmission_details.u_value
|
||||
for w in epc.sap_windows
|
||||
if w.window_transmission_details is not None
|
||||
}
|
||||
frame_factors = {w.frame_factor for w in epc.sap_windows if w.frame_factor is not None}
|
||||
return u_values, frame_factors
|
||||
|
||||
|
||||
def test_metal_frame_window_bills_table_24_metal_u_and_frame_factor_0p8() -> None:
|
||||
# Arrange — cert 21067296 lodges six pvc_frame:"false" (metal) double-glazed
|
||||
# (type 3, 12 mm) windows; HEAD billed them on the PVC column (U 2.8, FF 0.70).
|
||||
payload = _corpus_cert("21067296")
|
||||
|
||||
# Act
|
||||
u_values, frame_factors = _window_u_and_ff(payload)
|
||||
|
||||
# Assert — Table 24 metal DG-12mm column (3.4) and SAP 10.2 Table 6c FF 0.8.
|
||||
assert u_values == {3.4}
|
||||
assert frame_factors == {0.8}
|
||||
|
||||
|
||||
def test_pvc_frame_window_stays_on_the_pvc_column() -> None:
|
||||
# Arrange — the same cert forced to pvc_frame:"true"; the fix must not
|
||||
# regress PVC/wooden-framed windows (the discriminator is the boolean).
|
||||
payload = _forced_pvc_frame(_corpus_cert("21067296"), "true")
|
||||
|
||||
# Act
|
||||
u_values, frame_factors = _window_u_and_ff(payload)
|
||||
|
||||
# Assert — PVC DG-12mm column (2.8) and FF 0.70, exactly as before.
|
||||
assert u_values == {2.8}
|
||||
assert frame_factors == {0.7}
|
||||
|
||||
|
||||
def test_metal_frame_fix_moves_cert_toward_lodged() -> None:
|
||||
# Arrange — 21067296 scored 68.60 on HEAD (PVC assumption), lodged 67.
|
||||
payload = _corpus_cert("21067296")
|
||||
epc = EpcPropertyDataMapper.from_api_response(payload)
|
||||
assert epc is not None
|
||||
|
||||
# Act
|
||||
sap = Sap10Calculator().calculate(epc).sap_score_continuous
|
||||
|
||||
# Assert — the metal U-penalty pulls it to ~66.96, i.e. toward lodged 67.
|
||||
assert abs(sap - 66.96) <= 0.05
|
||||
|
|
@ -124,11 +124,20 @@ _RATE_FLOORS: dict[str, float] = {
|
|||
# dimensional predictions); floor_area loosened 12.0378 -> 12.0586 as the one
|
||||
# physical residual that fell (1-2 targets picking a new-build donor). See the
|
||||
# _RATE_FLOORS note above.
|
||||
# total_window_area 3.7184 -> 3.7484 and door_count 0.3333 -> 0.3737 re-baselined
|
||||
# when the external-measurement conversion landed (#1669: measurement_type=2 certs
|
||||
# now have their floor area + exposed perimeter converted external->internal per
|
||||
# RdSAP 10 §3.4 + Table 2). The leave-one-out scorer re-derives the *actual*
|
||||
# EpcPropertyData through the same mapper, so the sharper (internal) dimensions of
|
||||
# the fixture's external certs shift the geo-proximity-weighted donor mode — a
|
||||
# ground-truth-method change, not a prediction-logic loosening (spec-correct,
|
||||
# corpus MAE 0.571 -> 0.570 and the real-cert pin held). The move is two
|
||||
# single-target donor tips on the n=36 fixture. Tighten-only resumes from here.
|
||||
_RESIDUAL_CEILINGS: dict[str, float] = {
|
||||
"floor_area": 12.0586,
|
||||
"total_window_area": 3.7184,
|
||||
"total_window_area": 3.7484,
|
||||
"building_parts": 0.1212,
|
||||
"door_count": 0.3333,
|
||||
"door_count": 0.3737,
|
||||
}
|
||||
|
||||
_TOLERANCE = 1e-3
|
||||
|
|
|
|||
|
|
@ -55,6 +55,15 @@ _FIXTURE = (
|
|||
# 28-scoreable fixture) and residual ceilings — the measured values; tighten,
|
||||
# never loosen. The general (unconditioned) prediction floors live in
|
||||
# test_component_accuracy_gate.py; this gate guards the CONDITIONING path.
|
||||
#
|
||||
# has_pv re-baselined 0.8929 -> 0.8571 when the external-measurement conversion
|
||||
# landed (#1669: measurement_type=2 certs converted external->internal per RdSAP
|
||||
# 10 §3.4 + Table 2). This gate re-derives the *actual* EpcPropertyData through
|
||||
# the same mapper, so the sharper internal dimensions of the fixture's external
|
||||
# certs shift the conditioned donor mode and one pair's has_pv agreement tips
|
||||
# 25/28 -> 24/28 — a ground-truth-method change, not a prediction-logic loosening
|
||||
# (spec-correct; corpus MAE improved and the real-cert pin held). Tighten-only
|
||||
# resumes from here.
|
||||
_CLASSIFICATION_FLOORS: dict[str, float] = {
|
||||
"construction_age_band": 0.5357,
|
||||
"construction_age_band_pm1": 0.8214,
|
||||
|
|
@ -62,7 +71,7 @@ _CLASSIFICATION_FLOORS: dict[str, float] = {
|
|||
"floor_construction": 0.8636,
|
||||
"floor_insulation": 1.0000,
|
||||
"has_hot_water_cylinder": 0.8214,
|
||||
"has_pv": 0.8929,
|
||||
"has_pv": 0.8571,
|
||||
"has_room_in_roof": 0.8571,
|
||||
"heating_main_category": 1.0000,
|
||||
"heating_main_control": 0.6071,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
"""#1668 — wet underfloor responsiveness was pinned at R=0.75 for the lumped
|
||||
gov-EPC emitter code 2, regardless of floor construction. SAP 10.2 Table 4d
|
||||
splits it three ways and RdSAP 10 Table 29 (p.56-57) selects which from floor
|
||||
construction + age band: insulated timber floor R=1.0, screed above insulation
|
||||
R=0.75, concrete slab R=0.25. A concrete-slab system (solid floor, age A-E) was
|
||||
billed as screed (0.75), over-stating responsiveness and over-rating the
|
||||
dwelling. The calculator must derive the Table 29 subtype from the main part's
|
||||
floor construction + age band.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
from domain.sap10_calculator.calculator import Sap10Calculator
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
_underfloor_responsiveness, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl")
|
||||
|
||||
|
||||
def _corpus_cert(uprn: str) -> dict[str, Any]:
|
||||
for line in _CORPUS.read_text().splitlines():
|
||||
if uprn in line:
|
||||
cert: dict[str, Any] = json.loads(line)
|
||||
return cert
|
||||
raise AssertionError(f"uprn {uprn} not found in the RdSAP-21.0.1 corpus")
|
||||
|
||||
|
||||
def test_solid_floor_age_a_to_e_is_a_concrete_slab_r_0p25() -> None:
|
||||
# Arrange / Act / Assert — RdSAP 10 Table 29: a solid floor in age bands A-E
|
||||
# has no insulation layer, so the underfloor pipes sit in a concrete slab.
|
||||
assert _underfloor_responsiveness("Solid", "B") == 0.25
|
||||
assert _underfloor_responsiveness("Solid", "E") == 0.25
|
||||
|
||||
|
||||
def test_solid_floor_age_f_onwards_is_screed_r_0p75() -> None:
|
||||
# Arrange / Act / Assert — newer solid floors carry insulation, so the pipes
|
||||
# are in screed above it (0.75), not a bare slab.
|
||||
assert _underfloor_responsiveness("Solid", "F") == 0.75
|
||||
assert _underfloor_responsiveness("Solid", "M") == 0.75
|
||||
|
||||
|
||||
def test_suspended_timber_floor_is_r_1p0() -> None:
|
||||
# Arrange / Act / Assert — a timber-floor underfloor system is fully
|
||||
# responsive (Table 4d timber floor = 1.0).
|
||||
assert _underfloor_responsiveness("Suspended timber", "D") == 1.0
|
||||
|
||||
|
||||
def test_suspended_not_timber_is_screed_r_0p75_not_timber() -> None:
|
||||
# Arrange / Act / Assert — "Suspended, not timber" must NOT be read as timber
|
||||
# (the substring trap); it falls to the screed default 0.75.
|
||||
assert _underfloor_responsiveness("Suspended, not timber", "C") == 0.75
|
||||
|
||||
|
||||
def test_unknown_or_absent_floor_defaults_to_screed_r_0p75() -> None:
|
||||
# Arrange / Act / Assert — a main part with no ground floor / unlodged
|
||||
# construction keeps the prior 0.75, so ambiguous certs do not move.
|
||||
assert _underfloor_responsiveness(None, "A") == 0.75
|
||||
assert _underfloor_responsiveness("", None) == 0.75
|
||||
|
||||
|
||||
def test_concrete_slab_cert_corrects_to_lodged() -> None:
|
||||
# Arrange — cert 100100137438 (solid floor, band B, wet underfloor) over-rated
|
||||
# at 72 on HEAD via the pinned 0.75; lodged 69.
|
||||
payload = _corpus_cert("100100137438")
|
||||
epc = EpcPropertyDataMapper.from_api_response(payload)
|
||||
assert epc is not None
|
||||
|
||||
# Act
|
||||
sap = Sap10Calculator().calculate(epc).sap_score
|
||||
|
||||
# Assert — Table 29 concrete-slab R=0.25 corrects it to exactly lodged 69.
|
||||
assert sap == 69
|
||||
|
|
@ -280,7 +280,18 @@ _CORPUS = Path(
|
|||
# (U=0), previously mislabelled a pitched roof so the party override never fired.
|
||||
# Combined with main's gable fix: within-0.5 79.5% -> 80.3%, MAE 0.599 -> 0.584,
|
||||
# PE 2.71 -> 2.5. Ratcheted.
|
||||
_MIN_WITHIN_HALF_SAP = 0.803
|
||||
# SAP-10.2 CALCULATOR ACCURACY BATCH (#1656 backlog: #1666/#1667/#1669/#1668,
|
||||
# all stacked on the merged 80.3% baseline above):
|
||||
# #1666 normalise dual-encoded main_heating_fraction (decimal vs percent) ->
|
||||
# 80.3% -> 80.6%, MAE 0.584 -> 0.578;
|
||||
# #1667 bill metal-framed API windows on the Table 24 metal U-column + FF 0.8 ->
|
||||
# 80.6% -> 81.0%, MAE 0.578 -> 0.571;
|
||||
# #1669 convert measurement_type=2 external dims to internal (RdSAP §3.4/Table 2)
|
||||
# -> 81.0% -> 81.2%, MAE 0.571 -> 0.570, PE 2.5 -> 2.4;
|
||||
# #1668 derive wet-underfloor Table 29 subtype (concrete-slab R=0.25) ->
|
||||
# 81.2%, MAE 0.570 -> 0.565.
|
||||
# Measured within-0.5 = 0.812000, MAE = 0.565082. Ratcheted.
|
||||
_MIN_WITHIN_HALF_SAP = 0.812
|
||||
# 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak
|
||||
# trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion)
|
||||
# -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57,
|
||||
|
|
@ -415,7 +426,10 @@ _MIN_WITHIN_HALF_SAP = 0.803
|
|||
# ceiling roof-code-3 slice stacking on main's #1662 floor-to-unheated-space
|
||||
# fix: merged-corpus MAE measured 0.583761 (within-0.5 80.3%), rounded up to
|
||||
# 3 d.p. so the `<=` assert holds.
|
||||
_MAX_SAP_MAE = 0.584
|
||||
# Ratcheted 0.584 -> 0.566 by the #1666/#1667/#1669/#1668 calculator-accuracy
|
||||
# batch (see the _MIN_WITHIN_HALF_SAP note above): merged-corpus MAE measured
|
||||
# 0.565082, rounded up to 3 d.p.
|
||||
_MAX_SAP_MAE = 0.566
|
||||
_MAX_CO2_MAE_TONNES = 0.068 # t CO2 / yr vs co2_emissions_current
|
||||
_MAX_PE_PER_M2_MAE = 2.72 # kWh / m2 / yr vs energy_consumption_current
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue