mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
fix(rdsap): thin present wall-insulation no longer maps to uninsulated bucket
Field-level bias scan (scripts/corpus_1000/field_bias.py) flagged solid-brick + internal-insulation certs (wall_con+ins=(3,3)) systematically under-rating (median -0.40, n=18). Root cause: _insulation_bucket routed insulation that is KNOWN PRESENT but with a thin lodged thickness (5-24mm, below the smallest tabulated 50mm column) to bucket 0 (uninsulated, U 1.7) - WORSE than the present-but-unknown case, which the RdSAP Table 6 footnote correctly routes to the 50mm row. A known 10mm shouldn't read as higher-U than not knowing at all. Now present insulation never falls to bucket 0; thin/unknown thickness both round up to the 50mm row. Only genuinely absent insulation maps to 0. Corpus cert 5117280 (10mm internal, no wall thickness -> bucket path): Δ -4.44 -> +1.08. Corpus MAE 0.641 -> 0.637, PE MAE 3.0 -> 2.9. Gauge 77.7% holds; 44 RealCert pins + 342 fabric tests green. TDD: 2 new unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
58125503f0
commit
a593921a83
4 changed files with 92 additions and 2 deletions
|
|
@ -353,7 +353,13 @@ def _insulation_bucket(thickness_mm: Optional[int], insulation_present: bool) ->
|
|||
to the 50 mm bucket when `insulation_present=True`; when not
|
||||
present, the as-built (bucket 0) row applies regardless.
|
||||
"""
|
||||
if insulation_present and (thickness_mm is None or thickness_mm == 0):
|
||||
# Insulation known to be present must never fall to the uninsulated
|
||||
# (bucket 0) row. RdSAP 10 Table 6 footnote routes present-but-unknown
|
||||
# thickness to the 50 mm row; a KNOWN thin lodgement (5-24 mm, below the
|
||||
# smallest tabulated 50 mm column) must not read as WORSE (higher U) than
|
||||
# the unknown case — so it too rounds up to the 50 mm row rather than to
|
||||
# bucket 0. Only genuinely absent insulation (present=False) maps to 0.
|
||||
if insulation_present and (thickness_mm is None or thickness_mm < 25):
|
||||
return 50
|
||||
if thickness_mm is None:
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from domain.sap10_ml.rdsap_uvalues import (
|
|||
WALL_STONE_SANDSTONE,
|
||||
WALL_SYSTEM_BUILT,
|
||||
WALL_TIMBER_FRAME,
|
||||
_insulation_bucket,
|
||||
thermal_bridging_y,
|
||||
u_door,
|
||||
u_exposed_floor,
|
||||
|
|
@ -114,6 +115,43 @@ def test_u_wall_solid_brick_with_ni_thickness_uses_50mm_row_per_table6_footnote(
|
|||
assert result == pytest.approx(0.55, abs=0.001)
|
||||
|
||||
|
||||
def test_u_wall_solid_brick_present_insulation_thin_known_thickness_not_uninsulated() -> None:
|
||||
# Arrange — a solid-brick wall lodging internal insulation (type 3)
|
||||
# with a KNOWN thin thickness (10 mm) but no documentary wall
|
||||
# thickness (so the §5.8 formula branch, which needs wall_thickness,
|
||||
# cannot fire) falls to the Table-6 bucket. A known 10 mm value sits
|
||||
# below the smallest tabulated 50 mm column, but insulation IS present
|
||||
# — it must NOT read as WORSE (higher U) than the present-but-unknown
|
||||
# case, which the footnote routes to the 50 mm row. Pre-fix,
|
||||
# `_insulation_bucket(10, present=True)` returned 0 (uninsulated,
|
||||
# U=1.7), under-crediting the wall (corpus cert 5117280: Δ −4.44).
|
||||
|
||||
# Act
|
||||
result = u_wall(
|
||||
country=Country.ENG,
|
||||
age_band="B",
|
||||
construction=WALL_SOLID_BRICK,
|
||||
insulation_thickness_mm=10,
|
||||
insulation_present=True,
|
||||
wall_insulation_type=3,
|
||||
)
|
||||
|
||||
# Assert — present insulation → 50 mm row (U=0.55), never uninsulated 1.7.
|
||||
assert result == pytest.approx(0.55, abs=0.001)
|
||||
|
||||
|
||||
def test_insulation_bucket_present_thin_thickness_rounds_up_to_50_not_zero() -> None:
|
||||
# Direct guard on the bucket: present insulation never maps to bucket 0.
|
||||
assert _insulation_bucket(10, True) == 50
|
||||
assert _insulation_bucket(24, True) == 50
|
||||
assert _insulation_bucket(None, True) == 50
|
||||
assert _insulation_bucket(50, True) == 50
|
||||
assert _insulation_bucket(100, True) == 100
|
||||
# Genuinely absent insulation still routes thin/zero to bucket 0.
|
||||
assert _insulation_bucket(10, False) == 0
|
||||
assert _insulation_bucket(None, False) == 0
|
||||
|
||||
|
||||
def test_u_wall_cavity_as_built_insulated_assumed_routes_to_as_built_row() -> None:
|
||||
# Arrange — a cavity lodged "Cavity wall, as built, insulated (assumed)"
|
||||
# with wall_insulation_type=4 is in its AS-BUILT state, NOT a retrofit
|
||||
|
|
|
|||
46
scripts/corpus_1000/field_bias.py
Normal file
46
scripts/corpus_1000/field_bias.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""Field-level systematic-bias scan: mean Δ(SAP) grouped by each mapped
|
||||
attribute. A subgroup with large |meanΔ| AND enough n is a systematic mapping
|
||||
error for that attribute (would cancel in the overall ~0 bias)."""
|
||||
from __future__ import annotations
|
||||
import json, statistics
|
||||
from collections import defaultdict
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs
|
||||
CORPUS="backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl"
|
||||
G=defaultdict(lambda: defaultdict(list))
|
||||
def gv(x): return x.get('value') if isinstance(x,dict) else x
|
||||
for line in open(CORPUS):
|
||||
d=json.loads(line); L=d.get('energy_rating_current')
|
||||
if L is None: continue
|
||||
try:
|
||||
epc=EpcPropertyDataMapper.from_api_response(d)
|
||||
r=calculate_sap_from_inputs(cert_to_inputs(epc,prices=SAP_10_2_SPEC_PRICES))
|
||||
except Exception: continue
|
||||
dsap=r.sap_score_continuous-L
|
||||
bp=(d.get('sap_building_parts') or [{}])[0]
|
||||
sh=d.get('sap_heating') or {}; md=(sh.get('main_heating_details') or [{}])[0]
|
||||
G['wall_con'][bp.get('wall_construction')].append(dsap)
|
||||
G['wall_ins'][bp.get('wall_insulation_type')].append(dsap)
|
||||
G['wall_con+ins'][(bp.get('wall_construction'),bp.get('wall_insulation_type'))].append(dsap)
|
||||
G['roof_con'][bp.get('roof_construction')].append(dsap)
|
||||
G['floor_hl'][bp.get('floor_heat_loss')].append(dsap)
|
||||
G['age'][bp.get('construction_age_band')].append(dsap)
|
||||
G['main_cat'][md.get('main_heating_category')].append(dsap)
|
||||
G['whc'][sh.get('water_heating_code')].append(dsap)
|
||||
G['meter'][(d.get('sap_energy_source') or {}).get('meter_type')].append(dsap)
|
||||
G['country'][d.get('country_code')].append(dsap)
|
||||
G['dwelling'][d.get('dwelling_type')].append(dsap)
|
||||
G['nbp'][len(d.get('sap_building_parts') or [])].append(dsap)
|
||||
G['glz'][tuple(sorted(set(w.get('glazing_type') for w in (d.get('sap_windows') or []))))[:1]].append(dsap)
|
||||
print("=== subgroups with |meanΔ| >= 0.5 and n >= 8 (systematic-bias candidates) ===")
|
||||
hits=[]
|
||||
for field, groups in G.items():
|
||||
for key, vals in groups.items():
|
||||
if len(vals) >= 8:
|
||||
m=statistics.mean(vals)
|
||||
if abs(m) >= 0.5:
|
||||
hits.append((abs(m), field, key, len(vals), m, statistics.median(vals)))
|
||||
for am,field,key,n,m,med in sorted(hits, reverse=True):
|
||||
print(f" {field:12s} = {str(key):20s} n={n:4d} meanΔ={m:+.2f} medianΔ={med:+.2f}")
|
||||
if not hits: print(" (none — no systematic subgroup bias)")
|
||||
|
|
@ -73,7 +73,7 @@ Statuses: `[ ]` todo · `[x]` resolved ≤0.5 · 🔧 fix landed · ⚠ xfail en
|
|||
- [ ] `10093342182` · L 82 / E 77.51 / Δ -4.49
|
||||
|
||||
### C022 · Σ|Δ| 4.4 · 1 certs · `hpcdb17973/gas | solid brick | (another dwelling`
|
||||
- [ ] `5117280` · L 76 / E 71.56 / Δ -4.44
|
||||
- [ ] `5117280` · L 76 / E 71.56 / Δ -4.44 <- 🔧 thin-internal-insulation bucket-0 fix: present insulation + known <25mm thickness was mapped to uninsulated (U 1.7); now routes to 50mm row like unknown-thickness. Δ-4.44 -> +1.08.
|
||||
|
||||
### C023 · Σ|Δ| 4.4 · 1 certs · `h191/elec | solid brick | (another dwelling | ext2`
|
||||
- [ ] `217060420` · L 42 / E 37.58 / Δ -4.42
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue