Model/docs/adr/0033-age-band-resolves-assumed-fabric-states.md
2026-06-20 12:58:09 +00:00

7.8 KiB
Raw Blame History

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).