From 027ba6d89fcd3a6a325842c254265c7ed022e255 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sat, 20 Jun 2026 12:58:09 +0000 Subject: [PATCH] added landlord overrdies --- ...age-band-resolves-assumed-fabric-states.md | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/adr/0033-age-band-resolves-assumed-fabric-states.md diff --git a/docs/adr/0033-age-band-resolves-assumed-fabric-states.md b/docs/adr/0033-age-band-resolves-assumed-fabric-states.md new file mode 100644 index 00000000..9486bc8d --- /dev/null +++ b/docs/adr/0033-age-band-resolves-assumed-fabric-states.md @@ -0,0 +1,136 @@ +# Age band resolves the "(assumed)"/"as built"/"Unknown" fabric states + +ADR-0032 dec-4 **deferred** the `WallType` "(assumed) insulated" / "partial +insulation (assumed)" states (and, transitively, flat roofs): their +`wall_insulation_type` is "age-inferred in RdSAP, so the code is ambiguous and +must be pinned against the Elmhurst accuracy harness rather than guessed." This +ADR closes that deferral. The harness has now run. Terms in CONTEXT.md (Landlord +Overrides, Effective EPC, Validation Cohort); supersedes ADR-0032 dec-4. + +## Status + +Accepted. Supersedes the deferral in ADR-0032 dec-4. Evidence: +`scripts/hyde/uvalue_probe_{walls,roofs}.csv` (full A–M Elmhurst sweep, produced +by `scripts/hyde/probe_uvalues.py`). + +## Context: what the Elmhurst sweep showed + +The SAP 10.2 spec defers RdSAP to "a separate document" (Appendix S, p.122), so +the authoritative source for "what U-value does enum X at age Y produce" is the +accredited Elmhurst RdSAP-10 entry tool itself. We drove it across all 13 +construction-age bands (A–M) for the two components Khalim flagged and read the +tool's recomputed **Default U-value** for every option: + +* **Cavity wall, As Built** `[A–M]`: + `1.5, 1.5, 1.5, 1.5, 1.5, 1.0, 0.6, 0.6, 0.45, 0.35, 0.3, 0.28, 0.26` +* **Cavity wall, Filled** `[A–M]`: + `0.7, 0.7, 0.7, 0.7, 0.7, 0.4, 0.35, 0.35, 0.45, 0.35, 0.3, 0.28, 0.26` +* **Flat roof, As Built / Unknown** `[A–M]`: + `2.3, 2.3, 2.3, 2.3, 1.5, 0.68, 0.4, 0.35, 0.35, 0.25, 0.25, 0.18, 0.15` + +Two facts fall out: + +1. **Every value already lives in our tables.** As Built ≡ `(WALL_CAVITY, 0)`, + Filled ≡ `_CAVITY_FILLED_ENG`, flat As Built/Unknown ≡ `_FLAT_ROOF_BY_AGE` + (`domain/sap10_ml/rdsap_uvalues.py`) — exact match, all 13 bands. The U-value + layer was never the gap. +2. **"Unknown" computes identically to "As Built"** at every band — confirming + Khalim's read that Unknown is handled like As Built. + +## Decisions + +### 1. The "(assumed)" qualifier is *not* a distinct insulation code — age band is + +ADR-0032 dec-4 worried that "partial insulation (assumed)" needs a +`wall_insulation_type` code we couldn't name. The sweep shows why none exists: +Elmhurst has **no "partial" enum**. "Partial insulation (assumed)" is simply +*As Built (code 4) evaluated at band F (1976–1982)* → 1.0 W/m²K. Likewise +"no insulation (assumed)" is As Built at an old band (A–E → 1.5) and "insulated +(assumed)" is As Built at a newer band (G+ → ≤0.60). All three resolve to +`wall_insulation_type = 4`; the **construction-age-band overlay** (already a +separate, wired override component) supplies the U-value. This matches Khalim's +bands: cavity ≤1975 uninsulated / 1976–82 partial / later insulated is exactly +the As-Built-by-age curve above. + +### 2. Resolve to int codes, not a computed U-value override + +Because int codes + the existing age-band tables reproduce Elmhurst exactly, +overlays keep emitting `wall_construction` / `wall_insulation_type` (walls) and +`roof_construction` / `roof_insulation_thickness` (roofs) — no new +`wall_u_value` / `roof_u_value` override path. This keeps the landlord overlay on +the same code path as every Measure (ADR-0032 dec-1) and avoids a second way to +move the wall/roof U-value. (Considered and rejected: writing a computed U-value +override. It would duplicate the RdSAP tables already in the calculator and +diverge the moment those tables are revised.) + +### 3. Walls — extend the state map; no new machinery + +`wall_type_overlay._STATE_INSULATION` gains the three "as built" variants +("as built", "as built, insulated (assumed)", "as built, partial insulation +(assumed)") → code 4, alongside the existing "no insulation (assumed)" → 4. +These previously produced **no overlay** (silently dropping the landlord's +correction). The age-band overlay does the rest. + +### 4. Flat roofs — the overlay must set `roof_construction_type` + +The calculator's flat-roof age-band path (`u_roof`, `_FLAT_ROOF_BY_AGE`) fires +only when the roof is flat, which `heat_transmission.py` reads from the **string** +`part.roof_construction_type` (`"flat" in roof_type_lower`, line 999) — *not* the +int `roof_construction`. The roof overlay today sets only +`roof_insulation_thickness`, so a flat-roof override can't reach that path. This +ADR adds `roof_construction_type` to `BuildingPartOverlay` and has +`roof_type_overlay` set it to `"Flat"` for the "Flat, …" family, alongside the +existing `roof_insulation_thickness` (set when the description carries an explicit +mm depth). + +The same shape-flag mechanism extends to pitched roofs with no clean depth: + +* `"Pitched, Unknown loft insulation"` → `roof_construction_type="Pitched"`, + thickness left `None` → the pitched age-band default (`_ROOF_BY_AGE`). +* `"Pitched, no insulation*"` → `roof_construction_type="Pitched"` **plus** + `roof_insulation_thickness=0` → the uninsulated 2.30 (Table 16 row 0). Pitched + "no insulation" text is load-bearing in `u_roof` (§5.11 note) — it does NOT take + the age-band default, unlike flat. Setting `0` (not `None`) also lets this case + override a lodged numeric thickness, which the "Unknown" cases cannot. + +Constraint: the applicator only folds *non-None* overlay fields, so it cannot +*clear* a lodged `roof_insulation_thickness` to `None`. For As Built / Unknown +(no depth) the age-band default fires only when the baseline part carries no +numeric thickness — true on the Hyde flow, where the landlord override IS the roof +source and genuine As Built/Unknown lodgements carry `AB`/`ND`/None, not a number. +A flat/pitched-Unknown override layered on a part that already has a numeric +thickness keeps that thickness; that case is out of scope here and surfaced by the +coverage audit (below) for the verify step. + +## Consequences + +* Previously-dropped Hyde rows now move the score: ~1,366 flat-roof overrides + (`Flat: As Built` + `Flat: Unknown`) and every cavity "as built/partial/ + insulated (assumed)" wall. This is the point — a more accurate Effective EPC — + and (per ADR-0032 dec-5) such overlaid Properties stay excluded from the + Validation Cohort on the divergence signal. +* `BuildingPartOverlay` gains a `roof_construction` field; `apply_simulations` + must fold it like `wall_construction`. +* The decision is pinned by tests mirroring the probe CSVs (one row per + component × band), so a future RdSAP table revision that breaks parity trips a + regression rather than silently drifting from Elmhurst. +* `scripts/hyde/probe_uvalues.py` stays as the re-runnable oracle: re-sweep to + re-confirm parity whenever the RdSAP tables or Elmhurst change. +* A **coverage-audit pair** guards against the silent-no-op class this ADR fixes, + and is the reusable gate for every future portfolio (Hyde is the template): + - `scripts/hyde/audit_override_coverage.py` — enum-level: runs every override + value through its overlay mapper and flags those that produce no overlay or + only an inert field. Run it whenever an override enum or overlay changes. + - `scripts/hyde/audit_hyde_rows.py` — row-level: weights the verdict by actual + portfolio row counts and cross-checks that every "another/same dwelling above" + party-ceiling no-op is genuinely a Flat/Maisonette (a House/Bungalow with a + dwelling above is a data error, not a safe no-op — flagged to CSV for review). + Contradictory properties (party-ceiling roof on a non-flat type — Hyde had 50, + possibly houses split into flats) are **skipped entirely** by the `write` step: + no overrides are written for them and their org_refs are recorded to + `skipped_contradictory_properties.csv` for joint human review, rather than + guessing which of the two conflicting landlord fields is correct. + Known residue logged, not silently dropped: `PitchedWithSlopingCeiling: *` + (~272 Hyde rows, needs the col-3 path), `Roof room(s)` (needs `sap_room_in_roof`), + the `Renewables` column (no ComponentSpec — PV unmapped), and `Timber frame, + with additional insulation` (wall, ~7 rows).