test(accuracy): corpus-1000 campaign ledger + C001 4510053280 pinned 75 (lodged-software PSR gap)

Setup for the RdSAP-21.0.1 corpus gauge campaign (74.2% within-0.5,
SAP MAE 0.721 at HEAD, 1000/1000 computed):

- scripts/corpus_1000/build_worklist.py — runs all 1000 corpus certs
  through the gauge's own path (from_api_response -> Sap10Calculator),
  ranks |engine - lodged| descending, clusters by dwelling signature,
  and (re)writes scripts/corpus_1000/worklist.md preserving per-cert
  statuses and notes. 258 certs outside 0.5 in 249 clusters.

- C001 (worst cert, Δ +23.9): uprn 4510053280, ground-floor flat 47 m²
  on ASHP PCDB 100053 (Mitsubishi Ecodan 5 kW, PSR table 0.2-2.0).
  Dwelling PSR 2.031 -> SAP 10.2 N2 footnotes 44/45 heat-pump extension
  (reciprocal interpolation toward 100% at 2x largest PSR) = 305%
  space efficiency, which accredited Elmhurst also applies (golden
  case 56, record 100061). The LODGED software instead treated the
  out-of-range record as invalid and billed 100% direct electric +
  standard schedule (reproduces lodged CO2 to 1%). Lodged-software
  methodology gap, worklist ⚠, engine untouched; observed engine 75
  pinned in test_real_cert_sap_accuracy.

Gauge floors unchanged (no engine change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-02 18:28:20 +00:00
parent fad2264def
commit b9f1f428b9
4 changed files with 2079 additions and 0 deletions

View file

@ -0,0 +1,299 @@
{
"uprn": 4510053280,
"roofs": [
{
"description": "(another dwelling above)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
}
],
"walls": [
{
"description": "Solid brick, as built, no insulation (assumed)",
"energy_efficiency_rating": 2,
"environmental_efficiency_rating": 2
},
{
"description": "Solid brick, as built, partial insulation (assumed)",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
}
],
"floors": [
{
"description": "Solid, no insulation (assumed)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
}
],
"status": "entered",
"tenure": 2,
"window": {
"description": "Full secondary glazing",
"energy_efficiency_rating": 2,
"environmental_efficiency_rating": 2
},
"lighting": {
"description": "Good lighting efficiency",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"postcode": "NE1 4DG",
"hot_water": {
"description": "From main system, no cylinder thermostat",
"energy_efficiency_rating": 1,
"environmental_efficiency_rating": 4
},
"post_town": "NEWCASTLE UPON TYNE",
"built_form": "NR",
"created_at": "2026-05-24 12:41:32",
"door_count": 0,
"region_code": 1,
"report_type": 2,
"sap_heating": {
"number_baths": 1,
"cylinder_size": 1,
"number_baths_wwhrs": 0,
"water_heating_code": 901,
"water_heating_fuel": 29,
"secondary_fuel_type": 29,
"main_heating_details": [
{
"has_fghrs": "N",
"main_fuel_type": 29,
"heat_emitter_type": 1,
"emitter_temperature": 0,
"main_heating_number": 1,
"main_heating_control": 2203,
"main_heating_category": 4,
"main_heating_fraction": 1,
"mcs_installed_heat_pump": "false",
"central_heating_pump_age": 0,
"main_heating_data_source": 1,
"main_heating_index_number": 100053
}
],
"immersion_heating_type": "NA",
"secondary_heating_type": 691,
"has_fixed_air_conditioning": "false"
},
"sap_version": 10.2,
"sap_windows": [
{
"orientation": 4,
"window_type": 1,
"glazing_type": 4,
"window_width": 1.3,
"window_height": 1.46,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"orientation": 4,
"window_type": 1,
"glazing_type": 4,
"window_width": 1.3,
"window_height": 1.46,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
}
],
"schema_type": "RdSAP-Schema-21.0.1",
"uprn_source": "Energy Assessor",
"country_code": "ENG",
"main_heating": [
{
"description": "Air source heat pump, radiators, electric",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 5
}
],
"air_tightness": {
"description": "(not tested)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"dwelling_type": "Ground-floor flat",
"language_code": 1,
"pressure_test": 4,
"property_type": 2,
"address_line_1": "64 Waterloo Street",
"assessment_type": "RdSAP",
"completion_date": "2026-05-24",
"inspection_date": "2026-05-22",
"extensions_count": 0,
"measurement_type": 1,
"sap_flat_details": {
"level": 1,
"top_storey": "N",
"storey_count": 3,
"flat_location": 0,
"heat_loss_corridor": 2,
"unheated_corridor_length": 3.5
},
"total_floor_area": 47,
"transaction_type": 8,
"conservatory_type": 1,
"has_draught_lobby": "true",
"heated_room_count": 2,
"registration_date": "2026-05-24",
"sap_energy_source": {
"mains_gas": "N",
"meter_type": 2,
"pv_connection": 0,
"photovoltaic_supply": {
"none_or_no_details": {
"percent_roof_area": 0
}
},
"wind_turbines_count": 0,
"gas_smart_meter_present": "false",
"is_dwelling_export_capable": "false",
"wind_turbines_terrain_type": 1,
"electricity_smart_meter_present": "false"
},
"secondary_heating": {
"description": "Room heaters, electric",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"lzc_energy_sources": [
9
],
"sap_building_parts": [
{
"identifier": "Main Dwelling",
"wall_dry_lined": "N",
"wall_thickness": 520,
"floor_heat_loss": 7,
"roof_construction": 3,
"wall_construction": 3,
"building_part_number": 1,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.32,
"quantity": "metres"
},
"floor_insulation": 1,
"total_floor_area": {
"value": 47,
"quantity": "square metres"
},
"party_wall_length": {
"value": 8,
"quantity": "metres"
},
"floor_construction": 1,
"heat_loss_perimeter": {
"value": 14.15,
"quantity": "metres"
}
}
],
"wall_insulation_type": 4,
"construction_age_band": "B",
"sap_alternative_wall_1": {
"wall_area": 8.12,
"sheltered_wall": "Y",
"wall_dry_lined": "N",
"wall_thickness": 520,
"wall_construction": 3,
"wall_insulation_type": 4,
"wall_thickness_measured": "Y",
"wall_insulation_thickness": "NI"
},
"party_wall_construction": 0,
"wall_thickness_measured": "Y",
"roof_insulation_location": "ND",
"roof_insulation_thickness": "ND",
"wall_insulation_thickness": "NI",
"floor_insulation_thickness": "NI"
}
],
"solar_water_heating": "N",
"habitable_room_count": 2,
"heating_cost_current": {
"value": 278,
"currency": "GBP"
},
"insulated_door_count": 0,
"co2_emissions_current": 0.8,
"energy_rating_average": 60,
"energy_rating_current": 51,
"lighting_cost_current": {
"value": 39,
"currency": "GBP"
},
"main_heating_controls": [
{
"description": "Room thermostat only",
"energy_efficiency_rating": 2,
"environmental_efficiency_rating": 2
}
],
"has_hot_water_cylinder": "false",
"heating_cost_potential": {
"value": 272,
"currency": "GBP"
},
"hot_water_cost_current": {
"value": 1295,
"currency": "GBP"
},
"mechanical_ventilation": 0,
"percent_draughtproofed": 100,
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": {
"value": 68,
"currency": "GBP"
},
"indicative_cost": "\u00a3220 - \u00a3250",
"improvement_type": "G",
"improvement_details": {
"improvement_number": 16
},
"improvement_category": 5,
"energy_performance_rating": 53,
"environmental_impact_rating": 88
}
],
"co2_emissions_potential": 0.8,
"energy_rating_potential": 53,
"lighting_cost_potential": {
"value": 39,
"currency": "GBP"
},
"schema_version_original": "21.0.1",
"hot_water_cost_potential": {
"value": 1233,
"currency": "GBP"
},
"renewable_heat_incentive": {
"water_heating": 5072.68,
"space_heating_existing_dwelling": 2884.0
},
"draughtproofed_door_count": 0,
"energy_consumption_current": 190,
"has_fixed_air_conditioning": "false",
"multiple_glazed_proportion": 100,
"calculation_software_version": "5.02r0344",
"energy_consumption_potential": 182,
"environmental_impact_current": 88,
"current_energy_efficiency_band": "E",
"environmental_impact_potential": 88,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "E",
"co2_emissions_current_per_floor_area": 18,
"low_energy_fixed_lighting_bulbs_count": 6,
"incandescent_fixed_lighting_bulbs_count": 0
}

View file

@ -0,0 +1,243 @@
"""Build the RdSAP-21.0.1 corpus campaign ledger (scripts/corpus_1000/worklist.md).
Runs all 1000 corpus certs through the gauge's own path
(from_api_response -> Sap10Calculator.calculate, mirroring
tests/infrastructure/epc_client/test_sap_accuracy_corpus.py), ranks by
|engine - lodged| descending, attaches a short dwelling signature per cert,
and groups same-signature certs into named clusters.
USAGE
-----
PYTHONPATH=/workspaces/model python scripts/corpus_1000/build_worklist.py
Rewrites worklist.md in place, PRESERVING existing per-cert statuses
([x] / 🔧 / / and trailing `<-` notes) keyed by uprn, so it doubles as
the re-rank step after each fix lands.
"""
from __future__ import annotations
import json
import re
from collections import defaultdict
from pathlib import Path
from typing import Any, Optional
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import Sap10Calculator
ROOT = Path(__file__).resolve().parents[2]
CORPUS = ROOT / "backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl"
WORKLIST = ROOT / "scripts/corpus_1000/worklist.md"
# Table 12 fuel names (abbreviated) for the signature column.
_FUEL = {
1: "gas", 2: "gas-bulk", 3: "biogas", 4: "biogas", 7: "bLPG", 9: "LPG",
17: "LPG-sc", 10: "oil", 15: "bioliquid", 16: "B30K", 18: "rapeseed",
58: "biodiesel", 51: "housecoal", 20: "coal-anth", 21: "smokeless",
22: "anthracite", 23: "manuf-smokeless", 12: "wood-log", 13: "wood-pellet",
75: "wood-pellet-bag", 71: "wood-chip", 48: "dual-mineral-wood",
26: "gas", 25: "elec", 29: "elec", 30: "elec7hr", 31: "elec10hr",
32: "elec18hr", 33: "elec24hr", 34: "elec-heatnet", 35: "elec",
36: "elec", 37: "elec", 47: "heatnet", 6: "heatnet",
}
def _fuel_name(code: Optional[int]) -> str:
if code is None:
return "?"
return _FUEL.get(code, f"fuel{code}")
def _lead(desc: Optional[str], n: int = 3) -> str:
"""First n comma/word tokens of a register description, lowercased."""
if not desc:
return "?"
return " ".join(desc.lower().replace(",", " ").split()[:n])
def signature(doc: dict[str, Any]) -> tuple[str, str]:
"""(cluster_key, human signature) for one cert."""
h = doc.get("sap_heating") or {}
mhd = (h.get("main_heating_details") or [{}])
m0 = mhd[0] if mhd else {}
code = m0.get("sap_main_heating_code")
idx = m0.get("main_heating_index_number")
fuel = _fuel_name(m0.get("main_fuel_type"))
heat = f"h{code or ('pcdb' + str(idx) if idx else '?')}/{fuel}"
if len(mhd) > 1:
heat += "+2mains"
walls = doc.get("walls") or []
wall = _lead(walls[0].get("description") if walls else None, 2)
roofs = doc.get("roofs") or []
roof = _lead(roofs[0].get("description") if roofs else None, 2)
es = doc.get("sap_energy_source") or {}
sec = (doc.get("secondary_heating") or {}).get("description") or "None"
feats: list[str] = []
pv = es.get("photovoltaic_supply") or {}
if es.get("pv_connection") not in (0, None) or not pv.get("none_or_no_details"):
feats.append("PV")
if (es.get("wind_turbines_count") or 0) > 0:
feats.append("wind")
if sec != "None":
feats.append("sec:" + _lead(sec, 2))
if (doc.get("extensions_count") or 0) > 0:
feats.append(f"ext{doc.get('extensions_count')}")
if (doc.get("conservatory_type") or 1) != 1:
feats.append(f"consv{doc.get('conservatory_type')}")
for bp in doc.get("sap_building_parts") or []:
if bp.get("sap_room_in_roof") is not None:
feats.append("RR")
break
mv = doc.get("mechanical_ventilation")
if mv not in (0, None):
feats.append(f"mv{mv}")
if (h.get("water_heating_code") or 901) != 901:
feats.append(f"whw{h.get('water_heating_code')}")
key = f"{heat} | {wall} | {roof}" + (" | " + ",".join(sorted(set(feats))) if feats else "")
sig = key
return key, sig
_LINE_RE = re.compile(
r"^- \[(?P<st>[ x])\] |^- (?P<emoji>🔧|⚠|⛔) "
)
def load_existing_statuses() -> dict[str, tuple[str, str]]:
"""uprn -> (status token, trailing note) from the current worklist."""
out: dict[str, tuple[str, str]] = {}
if not WORKLIST.exists():
return out
for line in WORKLIST.read_text().splitlines():
m = re.match(r"^- (?:\[(?P<st>[ x])\]|(?P<emoji>🔧|⚠|⛔)) `(?P<uprn>\w+)`", line)
if not m:
continue
status = f"[{m.group('st')}]" if m.group("st") is not None else m.group("emoji")
note = ""
if "<-" in line:
note = "<-" + line.split("<-", 1)[1]
out[m.group("uprn")] = (status, note)
return out
def main() -> None:
docs = [json.loads(l) for l in CORPUS.read_text().splitlines() if l.strip()]
calc = Sap10Calculator()
rows: list[dict[str, Any]] = []
skipped: list[tuple[str, str]] = []
sap_abs: list[float] = []
for idx, doc in enumerate(docs, 1):
# Some corpus docs lodge no top-level uprn; fall back to the corpus
# line number so ledger statuses don't collide on "None".
uprn = str(doc.get("uprn") or f"line{idx}")
lodged = doc.get("energy_rating_current")
if lodged is None:
skipped.append((uprn, "no lodged SAP"))
continue
try:
epc = EpcPropertyDataMapper.from_api_response(doc)
result = calc.calculate(epc)
except Exception as e:
skipped.append((uprn, f"{type(e).__name__}: {str(e)[:70]}"))
continue
eng = result.sap_score_continuous
delta = eng - lodged
sap_abs.append(abs(delta))
key, sig = signature(doc)
rows.append({
"uprn": uprn, "lodged": lodged, "eng": eng, "delta": delta,
"key": key, "sig": sig,
})
n = len(sap_abs)
within = sum(1 for e in sap_abs if e < 0.5) / n
mae = sum(sap_abs) / n
# Cluster: same signature key, only certs still outside 0.5.
clusters: dict[str, list[dict[str, Any]]] = defaultdict(list)
for r in rows:
if abs(r["delta"]) >= 0.5:
clusters[r["key"]].append(r)
ranked = sorted(
clusters.items(),
key=lambda kv: -sum(abs(r["delta"]) for r in kv[1]),
)
prev = load_existing_statuses()
lines: list[str] = []
lines.append("# RdSAP-21.0.1 corpus campaign ledger")
lines.append("")
lines.append(
f"**Gauge:** {n} computed / {len(skipped)} skipped · "
f"SAP within-0.5 = **{within:.1%}** · MAE = **{mae:.3f}** "
f"(floors in test_sap_accuracy_corpus.py)"
)
lines.append("")
lines.append(
"Statuses: `[ ]` todo · `[x]` resolved ≤0.5 · 🔧 fix landed · "
"⚠ xfail engine bug · ⛔ unbuildable. "
"Line: `uprn · lodged L / eng E / Δ · signature`. "
"Rebuild/re-rank: `PYTHONPATH=. python scripts/corpus_1000/build_worklist.py` "
"(preserves statuses & `<-` notes)."
)
lines.append("")
lines.append(f"## Clusters (certs with |Δ| ≥ 0.5, ranked by summed |Δ|) — {sum(len(v) for v in clusters.values())} certs in {len(ranked)} clusters")
lines.append("")
for i, (key, members) in enumerate(ranked, 1):
members.sort(key=lambda r: -abs(r["delta"]))
tot = sum(abs(r["delta"]) for r in members)
lines.append(
f"### C{i:03d} · Σ|Δ| {tot:.1f} · {len(members)} certs · `{key}`"
)
for r in members:
st, note = prev.get(r["uprn"], ("[ ]", ""))
frag = (
f"- {st} `{r['uprn']}` · L {r['lodged']} / E {r['eng']:.2f} / "
f"Δ {r['delta']:+.2f}"
)
if note:
frag += f" {note}"
lines.append(frag)
lines.append("")
if skipped:
lines.append(f"## Skipped ({len(skipped)})")
for uprn, why in skipped:
st, note = prev.get(uprn, ("", ""))
lines.append(f"- {st} `{uprn}` · {why} {note}".rstrip())
lines.append("")
resolved = [r for r in rows if abs(r["delta"]) < 0.5]
lines.append(f"## Resolved ≤0.5 ({len(resolved)} certs — no action)")
lines.append("")
lines.append("<details><summary>expand</summary>")
lines.append("")
for r in sorted(resolved, key=lambda r: -abs(r["delta"])):
st, note = prev.get(r["uprn"], ("[x]", ""))
if st == "[ ]":
st = "[x]"
lines.append(
f"- {st} `{r['uprn']}` · L {r['lodged']} / E {r['eng']:.2f} / "
f"Δ {r['delta']:+.2f} · `{r['key']}` {note}".rstrip()
)
lines.append("")
lines.append("</details>")
lines.append("")
WORKLIST.write_text("\n".join(lines))
print(f"wrote {WORKLIST}")
print(f"gauge: {n} computed / {len(skipped)} skipped, within-0.5 {within:.1%}, MAE {mae:.3f}")
print(f"clusters: {len(ranked)}, worst 5:")
for key, members in ranked[:5]:
tot = sum(abs(r["delta"]) for r in members)
print(f" Σ{tot:7.1f} n={len(members):3d} {key}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load diff

View file

@ -699,6 +699,31 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
cert_num="0018-2049-6232-4431-6934", cert_num="0018-2049-6232-4431-6934",
sap_score=54, sap_score=54,
), ),
# UPRN 4510053280 (corpus-1000 C001; the corpus doc lodges no cert number).
# RdSAP-Schema-21.0.1 native — GROUND-FLOOR FLAT 47 m², band B, solid brick
# 520 mm uninsulated (+ 8.12 m² sheltered alternative wall), roof "(another
# dwelling above)", ASHP radiators PCDB 100053 (Mitsubishi Ecodan 5 kW,
# 2010 record: PSR table 0.22.0, η_space 332.8% @ 2.0, η_water 187.5%,
# duration "24"), no cylinder, electric room-heater secondary, no PV.
# Lodged 51 / engine 74.94 (Δ +23.9, the corpus' worst cert). Dwelling PSR
# = 4.62 kW / (94.0 W/K × 24.2 K) = 2.031, just past the record's largest
# 2.0 → SAP 10.2 N2 (PDF p.101 footnotes 44/45) heat-pump extension:
# reciprocal-interpolate η_space toward 100% at PSR 4.0 → 321% × 0.95 =
# 305%. That is what the engine does, and what accredited Elmhurst did on
# the SAME record family / same PSR shape (golden case 56, record 100061,
# PSR 3.107 → 139.66%). The LODGED software instead treated the
# out-of-range record as invalid and billed direct electric: forcing
# η_space = η_water = 100% + the standard 9-h schedule reproduces the
# lodged CO2 to 1% (0.808 vs 0.800 t) and PE within 6% (178 vs 190),
# whereas the engine's spec path gives 0.444 t / 98.8. A lodged-software
# methodology gap (worklist ⚠), NOT an engine bug — do not tune. PINNED to
# the observed engine 75.
RealCertExpectation(
schema="RdSAP-Schema-21.0.1",
sample="uprn_4510053280",
cert_num="uprn-4510053280",
sap_score=75,
),
) )