mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(epc-prediction): roof-insulation +/-1-bucket reporting
Adds roof_insulation_thickness_pm1 (mirrors construction_age_band_pm1, issue #1222): adjacent RdSAP thickness buckets (0/NI,12mm..400mm+) carry near- identical roof U-values, so an off-by-one bucket is a SAP-neutral hit. 'ND' (no-data) is off the ordered scale, so only an exact match counts there. Honest measurement of SAP-relevant roof-insulation quality. Corpus (150pc/514): exact 49.3% -> +/-1 53.7% (the misses are often multiple buckets or ND, so the band gain is smaller than age's). Fixture: exact == +/-1 (0.4118) — its misses are all >1 bucket; gate floor added at 0.4118. Also fixes two pre-existing pyright errors in the touched test file (_epc main_fuel_type/main_heating_control were Optional but the MainHeatingDetail attributes are non-optional Union[int, str]). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7f48495ed5
commit
4afab2c3d8
3 changed files with 93 additions and 2 deletions
|
|
@ -85,6 +85,49 @@ def _age_band_within_one(predicted: object, actual: object) -> Optional[bool]:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# RdSAP roof-insulation thickness buckets, thinnest → thickest. Uninsulated is
|
||||||
|
# lodged as either 0 or "NI" (not insulated), so both map to the bottom rung;
|
||||||
|
# "ND" (no data) is off the scale entirely. Adjacent buckets carry near-identical
|
||||||
|
# roof U-values, so an off-by-one bucket is treated as a (SAP-neutral) ±1 hit —
|
||||||
|
# the same measurement honesty as the construction age band (issue #1222).
|
||||||
|
_ROOF_THICKNESS_ORDINAL: dict[object, int] = {
|
||||||
|
0: 0,
|
||||||
|
"NI": 0,
|
||||||
|
"12mm": 1,
|
||||||
|
"25mm": 2,
|
||||||
|
"50mm": 3,
|
||||||
|
"75mm": 4,
|
||||||
|
"100mm": 5,
|
||||||
|
"125mm": 6,
|
||||||
|
"150mm": 7,
|
||||||
|
"175mm": 8,
|
||||||
|
"200mm": 9,
|
||||||
|
"225mm": 10,
|
||||||
|
"250mm": 11,
|
||||||
|
"270mm": 12,
|
||||||
|
"300mm": 13,
|
||||||
|
"350mm": 14,
|
||||||
|
"400mm+": 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _roof_insulation_within_one(
|
||||||
|
predicted: object, actual: object
|
||||||
|
) -> Optional[bool]:
|
||||||
|
"""A ±1-bucket roof-insulation hit: None when the actual is absent, True on an
|
||||||
|
exact or adjacent-bucket match, else False. Off the ordered scale (e.g. the
|
||||||
|
"ND" no-data category) only an exact match counts."""
|
||||||
|
if actual is None:
|
||||||
|
return None
|
||||||
|
if predicted == actual:
|
||||||
|
return True
|
||||||
|
pred_rung = _ROOF_THICKNESS_ORDINAL.get(predicted)
|
||||||
|
actual_rung = _ROOF_THICKNESS_ORDINAL.get(actual)
|
||||||
|
if pred_rung is None or actual_rung is None:
|
||||||
|
return False
|
||||||
|
return abs(pred_rung - actual_rung) <= 1
|
||||||
|
|
||||||
|
|
||||||
def _main_heating_detail(epc: EpcPropertyData) -> Optional[MainHeatingDetail]:
|
def _main_heating_detail(epc: EpcPropertyData) -> Optional[MainHeatingDetail]:
|
||||||
"""The primary heating system's detail row, or None when none is lodged."""
|
"""The primary heating system's detail row, or None when none is lodged."""
|
||||||
details = epc.sap_heating.main_heating_details
|
details = epc.sap_heating.main_heating_details
|
||||||
|
|
@ -159,6 +202,10 @@ def _renewables_and_fabric_hits(
|
||||||
_main(predicted).roof_insulation_thickness,
|
_main(predicted).roof_insulation_thickness,
|
||||||
_main(actual).roof_insulation_thickness,
|
_main(actual).roof_insulation_thickness,
|
||||||
),
|
),
|
||||||
|
"roof_insulation_thickness_pm1": _roof_insulation_within_one(
|
||||||
|
_main(predicted).roof_insulation_thickness,
|
||||||
|
_main(actual).roof_insulation_thickness,
|
||||||
|
),
|
||||||
"floor_insulation": _classify(
|
"floor_insulation": _classify(
|
||||||
_main_floor_insulation(predicted), _main_floor_insulation(actual)
|
_main_floor_insulation(predicted), _main_floor_insulation(actual)
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ _RATE_FLOORS: dict[str, float] = {
|
||||||
"cylinder_insulation_type": 0.5000,
|
"cylinder_insulation_type": 0.5000,
|
||||||
"secondary_heating_type": 0.0000,
|
"secondary_heating_type": 0.0000,
|
||||||
"roof_insulation_thickness": 0.4118,
|
"roof_insulation_thickness": 0.4118,
|
||||||
|
"roof_insulation_thickness_pm1": 0.4118,
|
||||||
"floor_insulation": 0.9375,
|
"floor_insulation": 0.9375,
|
||||||
"has_room_in_roof": 0.8333,
|
"has_room_in_roof": 0.8333,
|
||||||
"modal_glazing_type": 0.5278,
|
"modal_glazing_type": 0.5278,
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,9 @@ def _epc(
|
||||||
door_count: int = 2,
|
door_count: int = 2,
|
||||||
has_pv: bool = False,
|
has_pv: bool = False,
|
||||||
solar_water_heating: bool = False,
|
solar_water_heating: bool = False,
|
||||||
main_fuel_type: Optional[int] = 20,
|
main_fuel_type: Union[int, str] = 20,
|
||||||
main_heating_category: Optional[int] = 2,
|
main_heating_category: Optional[int] = 2,
|
||||||
main_heating_control: Optional[Union[int, str]] = 2100,
|
main_heating_control: Union[int, str] = 2100,
|
||||||
water_heating_fuel: Optional[int] = 20,
|
water_heating_fuel: Optional[int] = 20,
|
||||||
water_heating_code: Optional[int] = 901,
|
water_heating_code: Optional[int] = 901,
|
||||||
has_hot_water_cylinder: bool = True,
|
has_hot_water_cylinder: bool = True,
|
||||||
|
|
@ -125,6 +125,49 @@ def test_age_band_two_apart_misses_both() -> None:
|
||||||
assert hits["construction_age_band_pm1"] is False
|
assert hits["construction_age_band_pm1"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_scores_roof_insulation_within_one_bucket() -> None:
|
||||||
|
# Arrange — predicted 250mm, actual 270mm (adjacent RdSAP buckets). Adjacent
|
||||||
|
# thicknesses carry near-identical roof U-values, so it misses the exact hit
|
||||||
|
# but counts as a ±1-bucket hit, like the age band (issue #1222).
|
||||||
|
predicted = _epc(roof_insulation_thickness="250mm")
|
||||||
|
actual = _epc(roof_insulation_thickness="270mm")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
hits = compare_prediction(predicted, actual).categorical_hits
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert hits["roof_insulation_thickness"] is False
|
||||||
|
assert hits["roof_insulation_thickness_pm1"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_roof_insulation_two_buckets_apart_misses_both() -> None:
|
||||||
|
# Arrange — predicted 100mm, actual 200mm (three buckets apart: 100/150/200):
|
||||||
|
# a real miss on both exact and ±1.
|
||||||
|
predicted = _epc(roof_insulation_thickness="100mm")
|
||||||
|
actual = _epc(roof_insulation_thickness="200mm")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
hits = compare_prediction(predicted, actual).categorical_hits
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert hits["roof_insulation_thickness"] is False
|
||||||
|
assert hits["roof_insulation_thickness_pm1"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_roof_insulation_off_scale_no_data_only_exact_counts() -> None:
|
||||||
|
# Arrange — actual is the off-scale "ND" (no-data) category; a non-equal
|
||||||
|
# prediction can't be an adjacent-bucket hit.
|
||||||
|
predicted = _epc(roof_insulation_thickness="200mm")
|
||||||
|
actual = _epc(roof_insulation_thickness="ND")
|
||||||
|
|
||||||
|
# Act
|
||||||
|
hits = compare_prediction(predicted, actual).categorical_hits
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert hits["roof_insulation_thickness"] is False
|
||||||
|
assert hits["roof_insulation_thickness_pm1"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_flags_a_correct_main_wall_construction_classification() -> None:
|
def test_flags_a_correct_main_wall_construction_classification() -> None:
|
||||||
# Arrange — predicted and actual agree on cavity (1).
|
# Arrange — predicted and actual agree on cavity (1).
|
||||||
predicted = _epc(wall_construction=1)
|
predicted = _epc(wall_construction=1)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue