added landlord overrdies

This commit is contained in:
Jun-te Kim 2026-06-20 12:58:09 +00:00
parent a3e2566378
commit 027ba6d89f

View file

@ -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 AM 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 (AM) for the two components Khalim flagged and read the
tool's recomputed **Default U-value** for every option:
* **Cavity wall, As Built** `[AM]`:
`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** `[AM]`:
`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** `[AM]`:
`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 (19761982)* → 1.0 W/m²K. Likewise
"no insulation (assumed)" is As Built at an old band (AE → 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 / 197682 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).