7.8 KiB
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:
- 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. - "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 leftNone→ the pitched age-band default (_ROOF_BY_AGE)."Pitched, no insulation*"→roof_construction_type="Pitched"plusroof_insulation_thickness=0→ the uninsulated 2.30 (Table 16 row 0). Pitched "no insulation" text is load-bearing inu_roof(§5.11 note) — it does NOT take the age-band default, unlike flat. Setting0(notNone) 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. BuildingPartOverlaygains aroof_constructionfield;apply_simulationsmust fold it likewall_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.pystays 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 thewritestep: no overrides are written for them and their org_refs are recorded toskipped_contradictory_properties.csvfor 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)(needssap_room_in_roof), theRenewablescolumn (no ComponentSpec — PV unmapped), andTimber frame, with additional insulation(wall, ~7 rows).