diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 0d2246394..be1ab2f62 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -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 diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index f7067759c..95993be78 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -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 diff --git a/scripts/corpus_1000/field_bias.py b/scripts/corpus_1000/field_bias.py new file mode 100644 index 000000000..8331780f7 --- /dev/null +++ b/scripts/corpus_1000/field_bias.py @@ -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)") diff --git a/scripts/corpus_1000/worklist.md b/scripts/corpus_1000/worklist.md index 758365356..4941bcc1f 100644 --- a/scripts/corpus_1000/worklist.md +++ b/scripts/corpus_1000/worklist.md @@ -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