Model/scripts/hyde/perimeter_decision.md
Jun-te Kim 2f0eb49eee Checkpoint: UPRN 10093116543 Elmhurst build + devcontainer VNC/Playwright + perms
- Add SAP-accuracy sample for uprn_10093116543 (epc.json, elmhurst_inputs.md,
  summary/worksheet PDFs)
- Persist hyde viewer stack (xvfb/fluxbox/x11vnc/novnc/websockify) and Playwright
  chromium in the backend devcontainer; forward noVNC 6080
- Broaden .claude/settings.local.json allowlist (display/python/grep/tail)
- In-progress campaign mapper/cert_to_inputs work carried from prior cert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:21:56 +00:00

122 lines
6.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Decision brief: deriving heat-loss perimeter for full-SAP 17.1
**For:** mapper design sign-off · **Date:** 2026-06-15 · **Corpus:** 1,000 real `SAP-Schema-17.1` certs (`backend/epc_api/json_samples/SAP-Schema-17.1/corpus.jsonl`)
---
## TL;DR — recommendation
Derive perimeter and party-wall length from the **measured per-wall areas full-SAP lodges**, classified by `wall_type`:
| `wall_type` | meaning | feeds | engine effect |
|---|---|---|---|
| 1, 2, 3 | **external / exposed** | `heat_loss_perimeter_m` = Σarea ÷ storey-height | wall + ground-floor heat loss |
| 4 | **party** | `party_wall_length_m` = Σarea ÷ storey-height | party-wall heat loss (≈0 to heated neighbour) |
| 5 | **internal partition** | *discarded* | none (not part of the envelope) |
Geometric `4·√(footprint)` is the **fallback only** (a part lodges no walls / no storey height).
**Why this and not geometric:** `wall_type` is a ~99%-clean classifier (evidence below), and for attached dwellings (flats + semis + terraces = the majority) the measured areas separate exposed from party, which geometric **cannot** — geometric assumes 4 exposed sides and over-states heat loss for anything sharing a wall.
---
## Why this is the #1 decision
The RdSAP engine reconstructs the *entire* envelope from perimeter:
- gross wall area = `Σ(heat_loss_perimeter × storey_height)``worksheet/dimensions.py:124`
- ground-floor heat loss = `f(area, perimeter)``rdsap/cert_to_inputs.py:4420`
- party walls billed separately via `party_wall_length_m``worksheet/dimensions.py:168`
The existing RdSAP path reads both straight from the cert (`mapper.py:3556-3557`). **RdSAP certs lodge them; full-SAP certs lodge neither.** So full-SAP *must* derive them, or the engine sees a wall-less dwelling → heat loss ≈ 0 → nonsense (band-A) score.
---
## Evidence 1 — `wall_type` cleanly classifies exposed / party / internal
Across all walls in the 1,000-cert corpus:
| `wall_type` | n | names containing party/common/etc | reading |
|---|---|---|---|
| 1 | 5 | 0% | external (basement) |
| **2** | 1,657 | 1% | **external** (`external wall 1/2/3`, `wall 1`) |
| 3 | 109 | 1% | external |
| **4** | 738 | **81%** | **party** (`party wall 0`, `party`, `common`) |
| **5** | 588 | 0% | **internal partition** (`internal wall`, `stud`, `block`) |
Type 4 → party and type 5 → internal are unambiguous; types 1/2/3 → external leak party names only ~1% of the time.
## Evidence 2 — total footprint is recoverable two independent ways
Geometric `4·√(footprint)·height` vs the measured wall-area footprint agree well, so the *total* envelope size is low-risk; the only thing that needs the `wall_type` split is **which part of it is exposed vs party**:
| stock | geometric ÷ measured (median) | p10p90 | off by >30% |
|---|---|---|---|
| flats (n=279) | 0.94 | 0.791.10 | 5% over / 5% under |
| houses (n=564) | 0.96 | 0.881.02 | 3% over / 2% under |
---
## Worked examples — Method A (geometric) vs Method B (measured areas) vs reality
**Reality** = the measured external-wall area the cert actually lodges (what we want the engine's `perimeter × height` to reproduce).
### House — *Semi-detached*, 69 m², 2 storeys, height 2.44 m
| wall (name / type) | area m² | class |
|---|---|---|
| External Wall 1 (t2) | 83.4 | **exposed** |
| Party Wall 0 (t4) | 37.3 | party |
| 3× Internal Wall (t5) | 155.4 | internal |
- **Reality (exposed only):** 83.4 m²
- **Method A geometric:** 23.5 m perim → implied wall **114.4 m²** ❌ (+37%: counts the party side as exposed)
- **Method B `wall_type`:** exposed 83.4 ÷ 2.44 → **17.1 m/storey**, implied wall **83.4 m²** ✅; party 37.3 → `party_wall_length`
### Flat — *Ground-floor flat*, 73 m², 1 storey, height 2.38 m
| wall (name / type) | area m² | class |
|---|---|---|
| walls (t2) | 50.9 | exposed |
| Common wall (t2) | 19.8 | exposed* |
| Part wall (t4) | 17.0 | party |
- **Method A geometric:** 34.2 m → implied wall 81.3 m²
- **Method B `wall_type`:** exposed 70.7 ÷ 2.38 → 29.7 m, party 17.0 → party length
- *Caveat:* "Common wall" is a party wall **mis-typed as 2** — the ~1% leakage case. Method B treats it as exposed (slightly over-counts); a name-hint refinement could catch it, at the cost of fragility.
### Sample cert `0862-…-2325` (lodged 83), Ground-floor flat, 68 m², height 2.4 m
| wall (name / type) | area m² | class |
|---|---|---|
| Brickwork (t2) | 13.1 | exposed |
| Weatherboarding (t2) | 47.0 | exposed |
| Sole Plate Detail (t2) | 3.1 | exposed* (junction detail) |
| Stair Wall (t2) | 28.4 | exposed* (likely party, mis-typed) |
| Stud Walls (t5) | 126.0 | internal |
- **Method B `wall_type`:** exposed 91.5 ÷ 2.4 → **38.1 m**. If "Stair Wall" is really party, true exposed ≈ 63 m² → 26 m. This single cert is one of the messy ~1%.
---
## The residual risk (and the open sub-question for you)
`wall_type` mis-labels a party wall as external ~1% of the time (the "Common wall" / "Stair Wall" cases). Options for that tail:
1. **Trust `wall_type` only** — simplest, ~1% of certs slightly over-count exposed area (conservative: under-rates a few flats). *Recommended for v1.*
2. **`wall_type` + name regex** (`party|common|separating`) to reclassify type-2 party walls — catches the tail, but free-text names are fragile and could mis-grab a genuinely-external "common entrance wall".
3. **Geometric for houses, measured for flats** — more code paths; the data shows measured wins for both, so this adds complexity without clear gain.
**My pick: option 1 for v1** (trust `wall_type`), revisit with name hints only if the score comparison later flags the affected certs.
---
## DECISION (2026-06-15) — trust `wall_type`, fail loudly
**Resolved:** Option 1. Classify by `wall_type` only:
- `{1, 2, 3}` → exposed (→ `heat_loss_perimeter_m`)
- `4` → party (→ `party_wall_length_m`)
- `5` → internal partition (discarded)
- **any other code → `raise UnmappedApiCode("wall_type", code)`** — the established strict-raise pattern (`mapper.py:3102` etc.). No silent default.
We deliberately accept the ~1% tail where a party wall is mis-typed as 2 (`Common wall` / `Stair Wall`) — it is *not* caught by a name heuristic in v1. The loud raise is reserved for genuinely unknown codes so new wall variants force an explicit decision rather than mis-routing.
**To revisit (the "go back to it" marker):** if the later expert score comparison flags certs with party walls mis-typed as 2, add the name-regex refinement (option 2). An ADR (`docs/adr/`) records this when the mapper lands. The exposed/party split is the load-bearing call; the ADR will cite this brief.