Model/scripts/corpus_1000/demand_cohort.py
Jun-te Kim 4fba35acbd chore(corpus): demand-driven analyzer + confirm no generic fabric bug remains
Added scripts/corpus_1000/demand_cohort.py (isolates fabric/demand-driven
divergences from cost noise). Investigation conclusion: the demand-side tail is
one-offs, not a cluster. 12109191 (+13.1) is a LODGED decimal error (365.67 m²
floor, 10x its ground floor). 217060420 (-4.4) is spec-faithful (36.6 m2 lodged
glazing at spec U). The only >=3 structural clusters among diverging certs are
electric flats (already diagnosed spec-faithful). No generic fabric bug remains;
gauge 77.7% is near its structural ceiling. Marked 12109191 unbuildable in ledger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 05:09:10 +00:00

58 lines
2.9 KiB
Python

"""Demand-driven divergence analyzer. Flags corpus certs where the SAP miss is
driven by FABRIC/DEMAND (PE & CO2 move with SAP), not cost — the fixable class.
Tags nbp (building parts) and room-in-roof so multi-part / RR bugs surface."""
from __future__ import annotations
import json
from pathlib import Path
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_demand_inputs, cert_to_inputs)
CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl")
def has_rr(doc):
for bp in doc.get("sap_building_parts") or []:
r = bp.get("room_in_roof") or bp.get("rooms_in_roof")
if r: return True
for r in doc.get("roofs") or []:
if "room in roof" in (r.get("description") or "").lower(): return True
return False
rows = []
for line in CORPUS.read_text().splitlines():
doc = json.loads(line)
L = doc.get("energy_rating_current"); peL = doc.get("energy_consumption_current")
co2L = doc.get("co2_emissions_current")
if L is None: continue
try:
epc = EpcPropertyDataMapper.from_api_response(doc)
r = calculate_sap_from_inputs(cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES))
d = calculate_sap_from_inputs(cert_to_demand_inputs(epc, prices=SAP_10_2_SPEC_PRICES))
except Exception:
continue
dsap = r.sap_score_continuous - L
if abs(dsap) < 0.5: continue
peE = getattr(d, "primary_energy_kwh_per_m2", None)
dpe = (peE - peL) if (peE is not None and peL is not None) else None
co2E = d.co2_kg_per_yr/1000 if d.co2_kg_per_yr is not None else None
dco2 = (co2E - co2L) if (co2E is not None and co2L is not None) else None
nbp = len(doc.get("sap_building_parts") or [])
rr = has_rr(doc)
# demand-driven: PE moves same direction as SAP-miss inverse (PE up -> SAP down)
# i.e. dpe and dsap have OPPOSITE signs, and |dpe| is material
demand = (dpe is not None and abs(dpe) >= 4 and (dpe > 0) == (dsap < 0))
rows.append((doc.get("uprn"), L, round(r.sap_score_continuous,1), round(dsap,2),
round(dpe,1) if dpe is not None else None,
round(dco2,3) if dco2 is not None else None, nbp, rr, demand))
demand_rows = [r for r in rows if r[8]]
demand_rows.sort(key=lambda x:-abs(x[3]))
print(f"diverging certs: {len(rows)} | demand-driven: {len(demand_rows)}")
mp = [r for r in demand_rows if r[6] > 1]
rr = [r for r in demand_rows if r[7]]
print(f" demand+multipart(nbp>1): {len(mp)} | demand+RR: {len(rr)}")
print(f"\n=== top demand-driven divergences ===")
print(f"{'uprn':13s} {'L':>3} {'E':>5} {'dSAP':>6} {'dPE':>6} {'dCO2':>6} {'nbp':>3} {'RR':>3}")
for r in demand_rows[:32]:
print(f"{str(r[0]):13s} {r[1]:3d} {r[2]:5.1f} {r[3]:+6.2f} {str(r[4]):>6} {str(r[5]):>6} {r[6]:3d} {'Y' if r[7] else '':>3}")