Commit graph

249 commits

Author SHA1 Message Date
Daniel Roth
7ad0dc0595 Merge branch 'main' into feature/abri-api-integration 2026-07-07 12:46:17 +00:00
Daniel Roth
a3985389ab The deal row names the job number client_booking_reference, matching HubSpot and the schema 🟪
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:43:40 +00:00
Daniel Roth
55baddfd9e A local smoke drives HubSpot-to-write-back LogJob with only the Abri edge stubbed 🟩
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:47:03 +00:00
Daniel Roth
45bb3a7e61 A manual smoke script proves log then amend end-to-end against Abri UAT 🟩
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:30:08 +00:00
Daniel Roth
9bae8df6ea A changed booking amends the deal's OpenHousing appointment 🟩
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:01:01 +00:00
Jun-te Kim
bc1cca77db
Merge pull request #1356 from Hestia-Homes/feature/historic-epc-repository
Historic EPC repository: DDD port + resolver for address→UPRN
2026-07-07 10:56:33 +01:00
Daniel Roth
a5868cc2e0 One AbriOrchestrator runs every Abri flow for a deal 🟪
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:26:48 +00:00
Daniel Roth
6335796d6e
Merge pull request #1471 from Hestia-Homes/feature/abri-api-integration
Abri OpenHousing DomnaRelay — GetTenantData vertical (tenancy signatories to HubSpot)
2026-07-06 16:15:36 +01:00
Daniel Roth
859a7ec49a change fake contact details 2026-07-06 14:34:39 +00:00
Daniel Roth
de2a6d1d79 Add manual smoke script for the Abri tenant-contact HubSpot writes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:19:05 +00:00
Khalim Conn-Kowlessar
71bdd080c0 Expired-pairs integration gate: frozen single-file corpus + ratcheting floors 🟩
30 pairs (28 deterministically scoreable) from the 2,000-postcode sweep,
frozen as ONE anonymised raw-payload JSON (pairs + cohorts + actuals — a
thousand per-cert files would drown the PR diff). The gate replays the
whole conditioning path offline — mapper, conditioning, selection,
synthesis, comparison — in ~9s; floors are the measured values, tighten-
only. comparable_from_payload is extracted from the corpus loader so both
fixture formats share one payload->ComparableProperty path; the builder
(build_expired_pairs_corpus.py) refreezes from the raw-JSON disk cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:05:18 +00:00
Khalim Conn-Kowlessar
817c00720e The historic roof description conditions the cohort by form family 🟩
roof_construction codes group by FORM (empirical: 1=Flat 98%, 4/5/8=
Pitched 88-99%, 3=dwelling-above 100% over 7,974 certs; 7/9=premises-
above per #1452), so the filter matches families — an exact-code filter
would wrongly drop pitched neighbours lodged as 5/8. Historic prefixes
map to the same families; roof rooms and thatch stay unconditioned.
Harness ladder replay and telemetry mirror the new filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 12:05:56 +00:00
Khalim Conn-Kowlessar
13b75bcb0a Raw-JSON EPC disk cache + roof_construction co-occurrence sweep 🟩
JsonCachingEpcClient caches at the raw layer (cert payloads verbatim,
search rows as JSON) so warm re-runs replay through retry+mapper+parsing
in minutes AND the cache doubles as integration-test fixture material —
the ADR-0030 corpus pattern (anonymised raw JSON through the mapper),
which pickled domain objects could never be. Wired into the pairs
harness (--cache-dir). roof_construction_code_sweep pins the RdSAP roof
code table the basement-code-6 way: single-building-part certs only,
code x description-prefix purity table. Also fixes the tfa telemetry key
rename that left the agreement row empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:35:51 +00:00
Jun-te Kim
c238a0e6a0
Merge pull request #1449 from Hestia-Homes/feat/sap-corpus-campaign
fix(rdsap): RdSAP 10 Table 13 200/280mm solid-brick wall boundary
2026-07-06 12:25:17 +01:00
Khalim Conn-Kowlessar
38661c6bd7 Age band conditions within one band; the floor-area band widens to ±20% 🟩
Evidence (439-pair harness, PR #1466): historic-vs-new age band agrees
52% exactly but 90% within one band (assessors re-band, skewing newer);
TFA agrees 45% within ±5% but 82% within ±20%. Equality/±5% steered the
cohort toward stale values where they engaged and relaxed everywhere
else. Band definitions are public so the harness's ladder replay shares
one source of truth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:08:03 +00:00
Khalim Conn-Kowlessar
2903a069ea Pairs harness survives unmappable certs and appends telemetry incrementally 🟩
The 2000-postcode sweep died at ~884 on a strict-raising RdSAP-17.1 cert
(missing multiple_glazed_proportion) inside the pair-check, losing the
whole run's output. get_by_uprn and the cohort fetch now skip-and-log per
item; telemetry rows append as produced and carry raw values (age band,
TFA, fuel text) so band-width questions are answerable post hoc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:01:57 +00:00
Khalim Conn-Kowlessar
ee5bb6c4f2 Pairs harness telemetry: filter engagement + historic-vs-new agreement 🟩
Per pair, replay the age->fuel->TFA conditioning ladder over the plain
arm's cohort to record ENGAGED vs RELAXED per filter, and record whether
each historic stable attribute still agrees with the newly lodged cert
(the direct staleness measurement). Emitted as JSONL (--telemetry) and
aggregated into two diagnosis tables appended to the report.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 09:24:46 +00:00
Khalim Conn-Kowlessar
1d83637afa Pairs harness reports the full Component Accuracy suite per arm 🟩
Classification hit-rates for every compare_prediction component, all five
numeric residuals, and the secondary calculator-floored SAP residual
(calc(predicted) − lodged), plain vs conditioned side by side — the same
metric shape as the prediction-corpus gate (ADR-0030). Pair-check now
precedes the cohort fetch so a national postcode sweep only pays the
expensive search-by-postcode for shards that actually hold a pair.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 09:05:59 +00:00
Khalim Conn-Kowlessar
d9576db429 Pairs harness: score plain vs historic-conditioned prediction per attribute 🟩
Finds pre-2012 (S3 backup) x SAP-10.2 (new API) cert pairs per postcode,
predicts each from its leave-one-out cohort with and without ADR-0054
conditioning, and reports compare_prediction hit rates side by side.
Evidence for the stable-attribute whitelist, not a CI gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:46:16 +00:00
Jun-te Kim
5cae4a0ce5 fix(rdsap): RdSAP 10 Table 13 200/280mm solid-brick wall boundary
_u_brick_thin_wall_age_a_to_e's `<= 280` check put an uninsulated solid-
brick wall of exactly 280mm in the "200 to 280mm -> 1.7" row; RdSAP 10
§5.7 Table 13 (spec PDF p.41) shares 280 as an unlabelled edge between
that row and "280 to 420mm -> 1.4", so the table text alone doesn't say
which row owns it.

Found by building cert 100031768368 (280mm exactly, band C) in Elmhurst
and comparing every worksheet line to the calculator: volume/ACH/floor/
doors matched exactly, but the wall didn't (1.70 vs Elmhurst's 1.40)
despite an identical input crosswalk. Initially reverted a first attempt
at this fix when it appeared to regress an already-pinned cert
(217091901, band A, also nominally 280mm, previously "confirmed" at
U=1.70) — but that cert's build script never actually set a wall
thickness field, so its shared Elmhurst assessment had silently
inherited a stale 260mm from an earlier build. Fixed that build script
and rebuilt with the correct 280mm entry: Elmhurst's worksheet now also
gives U=1.40, matching 100031768368 and confirming the fix rather than
contradicting it.

Both certs move to (near-)exact lodged matches: 100031768368 59.12 ->
61.21 (lodged 61, within 0.5); 217091901 60.82 -> 61.59 (lodged 62,
exact). Corpus gauge 77.8% -> 78.6% within-0.5, MAE 0.636 -> 0.627 (14
corpus certs lodge exactly 280mm solid brick). TDD'd, 46 pins + full
suite green (2310 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:25:07 +00:00
KhalimCK
ebca28f704
Merge pull request #1447 from Hestia-Homes/fix/electric-underfloor-pathway-and-noop-walls
Electric underfloor gets its heating pathway; wall insulation gated on derived U (WS2 of #1435)
2026-07-03 13:17:36 +01:00
Khalim Conn-Kowlessar
bc39030707 The e2e candidate menu is generated from the Effective EPC, not the lodged cert 🟩
The printed "candidate measures considered" now matches what the
orchestrator actually offered — on 711795 the lodged cavity wall printed
a cavity-fill candidate the plan (timber-frame effective wall) never saw.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:42:29 +00:00
Jun-te Kim
5abc87ea18 chore(corpus): reconciliation pass over the 1-2 SAP band, no new bug found
Per the PR #1439 handoff: swept every cert now sitting at |Δ| in [1.0, 2.0)
(58 certs) using the cost-vs-demand signature bucketer (why_diverge.py's
logic) plus field_bias.py's full-corpus subgroup scan (re-run clean after
the two engine fixes above — no |meanΔ|>=0.5 systematic subgroup with
coherent median).

Breakdown: 17 room-in-roof (already spec-mandated/worksheet-validated per
PRs #1440/#1441), 7 electric cost/tariff variants (spec-faithful vs lodged,
consistent with the diagnosed electric cluster), 4 non-electric cost
residuals (spec-faithful), ~26 non-electric fabric/demand certs. Manually
inspected geometry (floor areas/perimeters/heights, wall/roof codes,
window/door counts) for the 4 largest fabric/demand outliers in the band
(100031768368, 100070450818, 100110097281, 100051051866) — no data-entry
anomaly comparable to the earlier 12109191 decimal-floor-area bug. This
confirms the handoff's "our calc faithful, lodged differs" pattern holds
across the band; no engine bug survived the pass. Notes appended per-cert
in worklist.md (`<-`, preserved by build_worklist.py reruns).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:26:05 +00:00
Jun-te Kim
6c1828e8bf chore(hyde): fix 100061275133 Elmhurst build — 0 fans + PCDB exact boiler
ventilation(): the cert lodges no extract-fan count and RdSAP 10 Table 5's
age-band B default is 0, but the build entered 1 — inflating Elmhurst's own
(8)/(21) infiltration lines relative to both the cert and our engine.

space_heating(): switched from the generic "Combi condens" SAP-Table dialog
code (84% efficiency, no PCDB record) to elmhurst_lib.set_pcdb_boiler(18908)
via clear_main_heating_code — resolves the exact lodged product (Worcester
Greenstar 4000, 88.70%) and its correct keep-hot classification (None),
matching the SAP engine's own PCDB lookup and removing the "Standard Combi"
keep-hot artifact the reused generic code carried.

Also corrected the file's stale docstring (described a different property
entirely — mid-terrace/250mm/DUAL meter/11 windows/lodged 63 — left over
from a copy-paste).

Verified against the refreshed worksheet: (21) shelter-adjusted ACH 0.6938
now EXACT vs our engine's ventilation_from_cert; (61) combi loss monthly
kWh matches ours to ~0.01 kWh (was carrying a ~360 kWh/yr keep-hot artifact).
Both open items from the PR #1439 handoff are resolved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:20:12 +00:00
Jun-te Kim
3fd7c5708d feat(hyde): PCDB exact-boiler entry (set_pcdb_boiler + clear_main_heating_code)
Cracks the 'PCDB disabled' blocker. The PCDF boiler reference resolves the exact
lodged boiler (e.g. 18908 -> Worcester Greenstar 4000, 88.70%) when: (1) the main
heating CODE is empty server-side (else the field + magnifying glass are
aspNetDisabled), and (2) the index is entered via REAL keyboard + Tab (a JS-set
.value doesn't survive the AutoPostBack). Now Elmhurst uses the same boiler
efficiency as the SAP engine, so worksheet vs calculator compare like-for-like.

Verified on 100061275133: Elmhurst now shows 'Efficiency of main space heating
system 1 = 88.70%' (was generic 84%). Includes updated worksheet evidence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 07:49:23 +00:00
Jun-te Kim
ad8d6534bb chore(hyde): fix 100061275133 draught-proofing (doors draught-proofed, cert=100%)
Third Playwright-entry error found via input reconciliation: my build left
draught-proofed doors at 0 (Elmhurst computed 33% draught proofing), but the
cert lodges percent_draughtproofed=100. Our calculator correctly uses 100% ->
infiltration 42.66 W/K; the ~19 W/K ventilation gap to Elmhurst was purely my
build error. On matched inputs our fabric+TB (109.38) equals Elmhurst's total
fabric (37) EXACTLY, confirming the calculator is RdSAP-faithful. Committed for
build-faithfulness record; the engine's +1.24 vs lodged 76 is not a bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 06:29:37 +00:00
Jun-te Kim
8b8cc96d43 chore(corpus): mapper faithfulness audit — EPC-API->EpcPropertyData is faithful
Audits identity pass-through fields (dims, counts, thicknesses) raw cert vs
EpcPropertyData across all 1000 certs. All apparent mismatches are legitimate
mapper behavior the first-pass audit didn't model: window_wall_type=4 windows
correctly split to sap_roof_windows (not dropped); empty placeholder building
parts correctly discarded; 'measured' insulation-thickness sentinel resolved to
the wall_insulation_thickness_measured value. Remaining per-BP diffs were an
index-misalignment artifact (raw BP[i] vs epc BP[i] after empty BP0 dropped).
Conclusion: mapper is faithful; divergences are Playwright-entry or calc-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 06:18:02 +00:00
Jun-te Kim
61934af97b chore(hyde): fix 100061275133 Elmhurst build faithfulness (lighting 5 LED, wall thickness 400)
Input reconciliation (Elmhurst summary vs EpcPropertyData vs raw cert) exposed
two Playwright-entry errors: lighting entered as 6 incandescent (cert has 5 LED,
100% low-energy) and wall thickness left at Elmhurst's 260mm default (cert lodges
400mm). Fixing both moved Elmhurst 67->69. Confirmed the MAPPER is faithful
(every fabric/heating/lighting field matches the raw cert) and the engine uses
the correct PCDB 18908 efficiency (88.7%). The remaining Elmhurst gap to lodged
76 is the un-enterable PCDB boiler (automation falls back to generic BGW 84% +
keep-hot); the engine (77) is faithful. Not a mapping error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 06:13:09 +00:00
Jun-te Kim
a593921a83 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>
2026-07-03 05:54:47 +00:00
Jun-te Kim
58125503f0 chore(corpus): why_diverge.py — categorize all 223 diverging certs by reason
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 05:19:20 +00:00
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
Jun-te Kim
6c47951d60 test(accuracy): pin code-195 electric storage-boiler certs as spec-faithful (§12 Rule 2)
Electric-tariff cluster investigation. The 112-cert electric-main cohort is
mixed-sign within every category/meter - no systematic tariff bias. Root cause
of the biggest non-xfail divergences (10012334488 +13.2, 10091578598 +7.81):
SAP code 195 = 'Electric water storage boiler' (Table 4a p.170), which SAP 10.2
§12 Rule 2 correctly bills mostly at the 7-hour off-peak rate. Both are pure
cost gaps (PE/CO2 match lodged -> demand right); the lodged software over-billed
the storage boilers at peak rate. The engine is SPEC-FAITHFUL - matching lodged
would tune against §12/Table-4a. Pinned to the spec-faithful engine values.

Adds scripts/corpus_1000/elec_cohort.py (electric-main cohort analyzer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 04:55:42 +00:00
Jun-te Kim
1032a4f128 test(accuracy): validate + pin 100061275133 (slice-5 external-insulation U confirmed)
2-storey semi, solid brick 400mm with EXTERNAL insulation, 300mm loft,
suspended uninsulated ground floor, gas combi, double glazing (glazed_type 13).
Built in accredited Elmhurst RdSAP10: roof (U 0.14, 5.459 W/K) and windows
(15.32 x eff U 1.3258 = 20.311 W/K) match the engine EXACTLY; the
externally-insulated wall is engine U 0.29 vs Elmhurst 0.30, validating the
slice-5 external-insulation default (100mm added over solid brick); suspended
ground floor engine 0.68 vs Elmhurst 0.70. Elmhurst's overall SAP 67 is not
comparable (reused-assessment Standard-Combi keep-hot loss, a build artifact).
Engine 77 vs lodged 76 (within 1.24). Pinned to the observed 77.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 04:28:25 +00:00
Jun-te Kim
17ced10e94 test(accuracy): validate + pin 217091901 (single-glazing confirmed vs Elmhurst)
Top-floor flat, solid brick uninsulated, uninsulated flat roof, gas combi,
SINGLE glazing (glazed_type 5). Built in accredited Elmhurst RdSAP10 and every
heat-loss element matches the engine EXACTLY: walls 53.18, flat roof 145.57,
floor 0, party 0, and — with the faithful single glazing — windows 34.7114 W/K
(8.62 m2 x eff U 4.0268). Confirms the mapper's glazed_type 5 -> single (raw U
4.8) is spec-correct per epc_codes.csv, not the double it superficially
resembles. Engine 61 vs lodged 62; the -1.18 is a cost-side residual, not a
fabric bug. Pinned to the observed engine 61.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 04:16:47 +00:00
Jun-te Kim
6f2b73e613 chore(hyde): fix window-grid automation (keep good row, delete stale below) + pin 100050881708
SOLVED the window-grid blocker (per Junte's "keep one and edit, don't
delete all"): set_single_window now ADDS the fresh window (it inserts at
DOM index 0), then deletes the STALE rows BELOW it by index (high->low,
never index 0) via the per-row Delete image button + JS-clicked Yes modal.
The freshly-added row survives as the single window; no 0.00 junk strands
to block the Recommendations gate. Verified end-to-end: cert 100050881708
built, worksheet downloaded, walls (0.70) + roof (0.19) match the engine
EXACTLY. Combined with the earlier fill+Tab (area postback) + frame/gap
(validation) fixes, the per-cert loop now runs autonomously on clean
single-main certs.

Pin: 100050881708 (semi, cavity filled, pitched 225mm, suspended ground
floor, combi) engine 72 = lodged 70 +2; walls/roof accredited-Elmhurst-
matched. NB the build entered the suspended ground floor as an upper
"above unheated space" floor, so Elmhurst's worksheet SAP (61) is not a
clean comparison — the engine uses the correct §3.12 suspended-ground U
(0.74); the pin rests on the walls+roof match + lodged.

No engine change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:55:48 +00:00
Jun-te Kim
b4dbaef7f2 chore(hyde): window-grid entry findings (fill+Tab persistence, frame/gap required)
Continued hardening for the per-cert Elmhurst loop. Confirmed findings:
- The window Width/Height must be set via Playwright .fill() + Tab (fires
  the ASP.NET AutoPostBack so the server commits the area); a JS-set of
  .value bypasses the postback and Elmhurst persists a 0.00-area row.
- Frame Type (PVC) + Glazing Gap (12 mm) are REQUIRED per row or the
  Recommendations gate blocks the worksheet ("Frame Type/Glazing Gap must
  be entered"). add_combined_window now sets both.

KNOWN BLOCKER (documented for the next attempt): the window grid is not
yet reliably automatable when stale rows exist on the shared assessment.
New rows insert at index 0; the delete flow reliably removes only index 0
(the newest), so accumulated 0.00 junk rows BELOW cannot be targeted, and
grid reads are timing-inconsistent across postbacks. A robust handler
needs to either (a) drive the per-row Delete by matching the row's
server-side id rather than DOM index, or (b) start each build from a
freshly-created assessment (not the shared one) so the grid is empty.
Until then the per-cert loop runs only on the FIRST build after a manual
window reset. build_100050881708.py is the WIP template.

No engine change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:44:49 +00:00
Jun-te Kim
7987c94876 chore(hyde): harden Elmhurst automation for per-cert builds (window cleanup + reset)
Support for the per-cert Elmhurst worksheet loop:
- goto()/save_close(): fall back networkidle -> domcontentloaded on
  timeout so an incomplete-validation state (half-set RR, 0-area window)
  no longer hangs navigation.
- add_combined_window(): select glazing first (AutoPostBack), then JS
  input+change+blur the width/height with settle waits so the Area cell
  computes before Add (a plain .fill() raced the postback -> 0.00 row).
- set_single_window(): leave the grid holding exactly one row, deleting
  rows carried over from a prior build of the SHARED assessment (the
  window grid is the only accumulating page).
- reset_transient_state(): return the shared assessment to neutral after
  a run (clear RR age band, secondary = No, meter = Single).

KNOWN RESIDUAL: the window grid still intermittently persists a 0.00 area
through Save & Close (Elmhurst's "each Add wipes the previous row" +
last-row-undeletable behaviour), and the secondary-heating dialog's modal
background can intercept the cascade click. Reliable autonomous grinding of
per-cert builds needs these two hardened further; the loop currently
completes clean single-main gas certs (see the 47084930 flat-roof slice)
but stalls on secondary-heating + stale-window combinations. build_
100021924710.py is the WIP template for that harder shape.

No engine change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:54:35 +00:00
Jun-te Kim
8e33a8de53 test(accuracy): ratchet 77.3% -> 77.7% via flat-roof insulation-thickness fix (Elmhurst-validated)
A FLAT roof lodges its insulation thickness in the DEDICATED gov-EPC API
`flat_roof_insulation_thickness` field (e.g. "75mm"), leaving
`roof_insulation_thickness` None (that field is for pitched-loft joists).
`heat_transmission_from_cert` read only the latter, so a measured
flat-roof thickness was ignored and the roof billed at the uninsulated
age-band flat default (age E = 1.5) instead of its Table-16 insulated U.

Fixed by preferring `flat_roof_insulation_thickness` when the part is a
flat roof — the exact mirror of the existing rafter-thickness branch.
An "AB"/"NI" (as-built/unknown) value parses to None and keeps the
age-band default, unchanged; only measured thicknesses move.

PER-CERT ELMHURST VALIDATION (cert 47084930, top-floor flat, flat roof
75 mm): built on the lodged inputs in accredited Elmhurst RdSAP10-Online
(evidence saved: elmhurst_summary.pdf / elmhurst_worksheet.pdf). The
worksheet bills "insulated flat roof" at U 0.5 (floor 0.70 + wall 0.25
also matching the engine). The fix takes the engine roof 96.4 -> 32.1 W/K
(= 64.26 x 0.5, Elmhurst-exact), PE +47 -> +0.2, SAP 69.51 -> 74.34 =
lodged 74.

Gauge: within 77.3% -> 77.7%, SAP MAE 0.648 -> 0.641, CO2 0.074 -> 0.072,
PE 3.1 -> 2.97. Unit-pinned in test_heat_transmission
(flat_roof_insulation_thickness -> U 0.5); RealCertExpectation 47084930
= 74 (Elmhurst-validated). Also adds the build script build_47084930.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:27:57 +00:00
Jun-te Kim
11a637c6bd test(accuracy): ratchet 77.0% -> 77.3% via unsized-cylinder storage-loss fix
A hot-water cylinder lodging gov-API `cylinder_size=0` ("size not
determined", with has_hot_water_cylinder=true) resolved to a None volume
in `_cylinder_storage_loss_override` (which reads `_cylinder_volume_l_
from_code`, returning None for code 0). The subsequent `if volume_l is
None: return None` then DROPPED the cylinder's Table-2 storage loss
entirely — under-costing the DHW and over-rating the dwelling. Meanwhile
the Table-13 high-rate-fraction path already used `_hot_water_cylinder_
volume_l` (which defaults size 0 to 110 L), so the two DHW paths
disagreed on the same cylinder.

RdSAP 10 §10.5 Table 28 ("if the actual size is not determined, the size
is taken as according to Table 28") makes an unsized present cylinder the
110 L "Normal" baseline, which STILL incurs the storage loss. Fixed by
defaulting the storage-loss volume to 110 L for the explicit size-0 case.

Gated on the EXPLICIT 0 (not None): a full-SAP cert whose RdSAP
cylinder_size is simply unlodged (None, e.g. pinned 10091568921) keeps
its own cylinder handling rather than a forced 110 L RdSAP default.

7 corpus certs lodge cylinder_size=0 + has_cylinder=true. Gauge: within
77.0% -> 77.3%, SAP MAE ~flat (0.648), CO2 0.074 -> 0.073, PE 3.2 -> 3.05
(the previously-dropped storage loss now correctly counted in the demand
cascade). Unit-pinned in test_cert_to_inputs (storage loss non-None for
size 0); RealCertExpectation 200004017091 = 71.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:02:27 +00:00
Jun-te Kim
8e487a9bae docs(elmhurst): validate slice-5 wall fix against accredited Elmhurst worksheet (uprn 200004296092)
Built the cert in accredited Elmhurst RdSAP10-Online on the lodged register
inputs; downloaded Input Summary + SAP Worksheets (committed alongside).

Confirmed:
- Slice 5 DIRECTION validated: the externally-insulated solid-brick wall is
  billed insulated by both tools (Elmhurst U 0.48, engine U 0.29), far below
  the uninsulated 1.40 the pre-fix §5.8 gate produced.
- ROOF matches EXACTLY (U 0.30, 9.35 W/K).
- Engine 71.74 = lodged 71; Elmhurst current SAP 66 (its "unknown external
  insulation" default is more conservative + this build used a generic combi
  and no keyed wall thickness, so 66 is not a clean engine-comparison).

Follow-up lead (documented in ELMHURST_FINDINGS.md): the engine feeds the
UNMEASURED lodged wall_thickness (360 mm, measured=N) into the §5.6 formula,
whereas RdSAP §5.3 uses measured thickness / Table-3 default otherwise. The
party-wall "difference" is NOT a bug (engine 0.25 is spec-correct, Table 15
row 4). Also adds the build script scripts/hyde/build_200004296092.py.

No engine change in this commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:49:58 +00:00
Jun-te Kim
0e784cbb1c test(accuracy): ratchet SAP MAE 0.658 -> 0.647 via wall added-insulation unknown-thickness default
A SOLID-BRICK or STONE wall lodging External/Internal insulation
(wall_insulation_type 1/3) but no measured insulation thickness (gov-API
"NI" -> None) was billed at its UNINSULATED §5.6/Table-6 U-value: the §5.8
added-insulation R-value adjustment gates on `insulation_thickness_mm > 0`,
so an unknown thickness fell straight through to the raw solid-wall U
(~1.4-1.7). This over-counted wall heat loss and under-rated the dwelling.

RdSAP 10 §5.4 (PDF p.32): "the U-value with additional insulation is based
on the thickness of insulation of mineral wool type (assume 100 mm if
thickness is unknown)". Apply that 100 mm default so the §5.8 Table-14
R-value fires. Scoped to solid brick / stone (the only constructions with
a §5.8 R-value path in u_wall) so cavity (composite path) and timber /
system-built walls are untouched.

Localised by deep-diving corpus cert 200004296092 (end-terrace, solid
brick 360 mm "with external insulation", NI): walls 101.7 W/K (U~1.40,
HLP 3.46 = absurd for an insulated wall) -> ~0.29; PE +59.6 -> ~0; engine
64.35 (Δ -6.65) -> 71.74 = lodged 71. ~8 solid-brick/stone corpus certs
carry this NI-thickness-with-insulation shape.

Gauge: within-0.5 held at 77.0%, SAP MAE 0.658 -> 0.647, CO2 0.074 ->
0.073, PE 3.2 -> 3.1 (floor ratcheted). Unit-pinned in test_rdsap_uvalues
(brick 360 mm external NI -> 0.29) + RealCertExpectation 200004296092 = 72.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:24:56 +00:00
Jun-te Kim
cb089a630c test(accuracy): ratchet SAP MAE 0.668 -> 0.658 via instantaneous-electric-DHW off-peak fix
An instantaneous point-of-use electric water heater (WHC 907/909, no
cylinder) on an off-peak tariff was billed 100% at the off-peak LOW rate
by the generic electric-off-peak else-branch. But SAP 10.2 §12 (PDF line
2680) computes the off-peak on-peak HW proportion via Table 13, "dependent
on the total floor area and the CYLINDER size" — it presumes a stored-water
cylinder charged overnight. An instantaneous heater has NO cylinder, heats
on demand, and cannot shift to the off-peak window, so 100% of its
consumption bills at the HIGH rate (Table 12a WH high-rate fraction 1.0).

Both the SAP-cost rate (`_hot_water_fuel_cost_gbp_per_kwh`) and the
ADR-0014 / CO2-PE fraction (`_hot_water_high_rate_fraction`) fixed
consistently (low-rate scalar -> 7-hour high rate; fraction 0.0 -> 1.0).

Localised by deep-diving corpus cert 74061136 (HHR-storage mid-floor flat,
WHC 909): PE matched lodged (+1.6, roof+floor zero-loss) while SAP over-rated
+7.72 — the cost-only signature. Its DHW was ours £59 vs lodged £344 (5.8x);
the tariff was the whole gap. Fix: +7.72 -> -1.25 (residual is separate small
fabric). 7 corpus certs carry electric-instantaneous DHW on off-peak; the 3
outside 0.5 all move sharply inward (MAE the win, not within-0.5 crossings).

RdSAP 10 §12 tariff routing confirmed spec-correct (Unknown meter + storage
409 -> off-peak 7-hour, Rule 2) — the bug was the DHW rate, not the tariff.
Unit-pinned in test_cert_to_inputs; RealCertExpectation 74061136 = 72.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:04:58 +00:00
Jun-te Kim
928ce293d0 docs(ledger): re-rank at 77.0% + triage electric-tariff & fabric tail
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:48:10 +00:00
Jun-te Kim
06a2c413f5 test(accuracy): ratchet 75.5% -> 77.0% via secondary-fuel collision fix on cost path
The gov-API `secondary_fuel_type` enums 5 (anthracite) / 9 (dual fuel) /
33 (coal) collide in VALUE with same-valued RdSAP-10 Table-32 codes for
OTHER fuels (5=bulk LPG, 9=LPG SC11F, 33=elec 10h-low). The main-fuel
boundary and the CO2/display `_secondary_fuel_cost_gbp_per_kwh` already
canonicalise these via `canonical_fuel_code`, but the SAP-DRIVING
`_fuel_cost` cascade had a SEPARATE inline secondary rate that passed the
raw enum straight to the price lookup — so the two paths diverged and the
cost path silently mis-priced.

Anthracite (enum 5) was billed at the bulk-LPG code-5 rate 12.19 p/kWh
instead of 3.64 — a 3x over-cost on the secondary that under-rated every
solid-fuel-secondary dwelling. Worst: corpus cert 100050355518 (semi,
2 extensions, anthracite room-heater secondary) lodged 36 / engine 20.8
-> 35.5. Its CO2/PE already matched lodged (the demand cascade was right)
— a pure cost-side gap, which is what localised it.

Fix: canonicalise `secondary_fuel` before the Table-32 lookup in
`_fuel_cost`, mirroring the main-fuel boundary. 55 corpus certs carry a
colliding secondary.

Gauge: within-0.5 75.5% -> 77.0%, SAP MAE 0.708 -> 0.668 (floors
ratcheted). The prior enum-9 test only guarded the display helper; new
test_fuel_cost_secondary_colliding_fuel_priced_at_canonical_rate pins the
_fuel_cost cost path directly. RealCertExpectation pinned for
100050355518 (36 = lodged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:43:31 +00:00
Jun-te Kim
c380377948 test(accuracy): ratchet 74.2% -> 75.5% via 'insulated (assumed)' roof Table-18 fix
RdSAP 10 §5.11.4: a pitched roof lodging thickness "NI" with description
"...insulated (assumed)" was billed at the §5.11.4 observed-retrofit
50 mm row (U 0.68) while the SAME description lodged with "ND" fell
through to the Table 18 age-band default (0.40 for bands A-G). The
ND-vs-NI sentinel is lodging-software noise; "(assumed)" means the
insulation PRESENCE is an age-band assumption, not an observed
retrofit, so Table 18's "thickness cannot be determined" clause
governs both forms. The bare "insulated" description (observed
retrofit, no qualifier) keeps the 50 mm row.

Evidence: the 27-cert loc4+NI corpus cohort was systematically
under-rated (~-1 SAP; zero positive movers among "(assumed)" certs),
vs the 102-cert loc4+ND cohort already on Table 18 at ~0 bias.

Gauge: within-0.5 74.2% -> 75.5%, SAP MAE 0.721 -> 0.708, CO2 MAE
0.09 -> 0.074, PE MAE 3.5 -> 3.2 (floors ratcheted). Unit-pinned in
test_rdsap_uvalues (assumed -> Table 18; bare-insulated -> 0.68
regression guard); integration pin in test_heat_transmission updated;
RealCertExpectation pinned for 10094975827 (66 = lodged, was -1.87).

Ledger: C002 103001004 deferred pending Elmhurst arbitration
(single-immersion off-peak pricing + declared cylinder loss vs
Table-2); C006 quadruplet marked ⚠ (LIG-21.0 software honours lodged
0 extract fans literally vs the Elmhurst-validated 0=unknown->default
convention, worksheet case 46).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:06:26 +00:00
Jun-te Kim
b9f1f428b9 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>
2026-07-02 18:28:20 +00:00
Jun-te Kim
318a859e88 Audit check: lodged cylinder with unresolvable insulation drops storage loss
Portfolio 814 / scenario 1271 audit (2026-07-03): all 26 properties lodging
has_hot_water_cylinder with insulation type 0 (none) or NULL over-rate their
effective baseline by up to +18 SAP (avg +12.7) because
_cylinder_storage_loss_override returns None for any insulation label it
can't resolve and the worksheet keeps the zero-storage-loss combi default.
Same-lodged neighbours split by 19 effective points (WD5 0DP pids
742215/742216, both lodged 54, effective 50 vs 69) and the inflated members
distorted neighbour-divergence cohort medians (E17 6EB pid 742355 Δ+18).

New HIGH check cylinder-storage-loss-dropped fires on the deterministic
input pattern (cylinder present, insulation type ∉ {factory, loose jacket}
or thickness missing) — 28/338 on the motivating portfolio (the 26 above
plus 2 same-pattern rows with no lodged score). The calculator fix
(uninsulated Table 2 loss, or RdSAP Table 29 age-band default) is separate
follow-up work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:27:15 +00:00
Jun-te Kim
7baab9376c Backfill uniform secondary-glazing overrides misfiled as Single glazing
39 Hyde-796 property_overrides rows ('100% secondary glazing (sap 9.94)')
plus the classifier-cache row; the cache phase self-gates on the
'Secondary glazing' pgEnum label landing (assessment-model#345).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:12:25 +00:00
Jun-te Kim
c1169b2d39
Merge pull request #1402 from Hestia-Homes/fix/glazing-dominant-single-guard
Resolve dominant-single glazing deterministically in the mix guard
2026-07-02 10:19:16 +01:00
Jun-te Kim
0fa47b1047
Merge pull request #1409 from Hestia-Homes/fix/property-type-leading-token-guard
Guard property_type to its leading dwelling-type token
2026-07-02 10:00:03 +01:00