From fc738fa8436ccc322c687ccf632df793fb50a53f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:23:36 +0000 Subject: [PATCH] Document pitched sloping-ceiling roof-type family (ADR-0066 + glossary) Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 4 + ...itched-sloping-ceiling-roof-type-family.md | 139 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 docs/adr/0066-pitched-sloping-ceiling-roof-type-family.md diff --git a/CONTEXT.md b/CONTEXT.md index 5cc6c539a..e599a5f28 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -155,6 +155,10 @@ _Avoid_: "the LLM mapper is unreliable" (the failure mode is a too-small target The RdSAP string sentinels a cert may lodge in place of a numeric value (e.g. `roof_insulation_thickness`). They are data, not noise, and are **not interchangeable**: **`ND`** (Not Defined) — no data; resolve to the construction age band's **as-built** state (roof as-built: A–D uninsulated, E–F limited insulation, G+ insulated). **`NI`** — insulation **present** but thickness unknown (RdSAP 10 §5.11.4: score as 50 mm); never "no insulation". **`AB`** — As Built; resolve by age band like ND. The mapper deliberately passes roof sentinels through raw — the calculator's resolution depends on distinguishing them (ADR-0047). Scoring and eligibility may legitimately resolve the same sentinel differently (`NI`: 50 mm to the U-cascade, not-eligible to the roof generator). _Avoid_: reading `ND` as "no insulation"; reading `NI` as "no insulation" (it means the opposite); normalising sentinels at the mapper boundary; comparing a sentinel string against an int (`"ND" != 0` is always True) +**Pitched-Roof Insulation Geometry (loft / at-rafters / sloping ceiling)**: +Three *distinct* RdSAP constructions a pitched roof can carry, separated by `roof_insulation_location` and scored on different U-tables — **never interchangeable**. **Loft (joists)** — `roof_insulation_location = 2`, insulation lying on the ceiling joists over a loft void; scores on Table 16 **col (1)** by thickness, Table 18 **col (1)** (`_ROOF_BY_AGE`, floored at 0.40) by age band; the `Pitched, N mm loft insulation` **RoofType** family. **At-rafters** — `roof_insulation_location = 1`, insulation on the sloping underside of the rafters over a loft; a *shallower* cavity, so Table 16 **col (2)** (200 mm → 0.29 vs the joists' 0.21) and the Table 18 rafters column; the `Pitched, insulated at rafters` member (gov-API/Summary `roof_construction` still pitched, not code 8). **Sloping ceiling** — gov-API `roof_construction = 8` (`roof_insulation_location = 7`), the ceiling *follows the slope* so there is **no loft void**: by depth it takes Table 17 **col (1a)** "insulated slope" (byte-identical to col (1) — 150 mm → 0.30 for both, Elmhurst-confirmed), and **as built / unknown** it takes Table 18 **col (3)** (`_FLAT_ROOF_BY_AGE`: 2.30 A–D, 1.50 E, 0.68 F — *not* the loft col (1) 0.40 floor); the `Pitched, sloping ceiling, …` **RoofType** family (ADR-0066). The calculator keys the sloping path on the substring `"sloping ceiling"` (or `"vaulted"`) in `roof_construction_type` (`is_pitched_sloping_ceiling`), and both the SAP10 U-cascade and `recommend_roof_insulation` read that one string — so an overlay that means "sloping ceiling" **must** set the construction string, not just a thickness. The insulation Measure differs too: loft/at-rafters → loft top-up (joists, 300 mm) or `sloping_ceiling_insulation` respectively; a sloping ceiling → `sloping_ceiling_insulation` (rafters, 100 mm). +_Avoid_: "pitched with sloping ceiling is a room-in-roof" (it is a slope-following ceiling, `roof_construction` 8 — a habitable **Roof room(s)** is a separate construction); collapsing a sloping ceiling onto the `Pitched, N mm loft insulation` family (drops the slope shape → col (1) 0.40 floor at old bands + a loft-top-up recommendation); conflating **at-rafters** (col 2) with **sloping ceiling** (col 3 / col 1a); setting `roof_insulation_thickness` without `roof_construction_type` when the geometry is not a plain loft + ### Modelling **Effective EPC**: diff --git a/docs/adr/0066-pitched-sloping-ceiling-roof-type-family.md b/docs/adr/0066-pitched-sloping-ceiling-roof-type-family.md new file mode 100644 index 000000000..ecf537503 --- /dev/null +++ b/docs/adr/0066-pitched-sloping-ceiling-roof-type-family.md @@ -0,0 +1,139 @@ +# Pitched Sloping-Ceiling Gets Its Own RoofType Family, Modelled at the Slope Line (distinct from loft and from at-rafters) + +A `PitchedWithSlopingCeiling` source roof (gov-API `roof_construction` code 8 — +its ceiling follows the slope, so it has no loft void) was being classified by +the **Landlord-Description Classification** onto the `Pitched, N mm loft +insulation` family. That family's Simulation Overlay sets only +`roof_insulation_thickness` and leaves `roof_construction_type` untouched, so +the **Effective EPC**'s roof reads as a horizontal loft. Two consequences, both +wrong for a slope-following ceiling: + +- **Scoring** — with no `"sloping ceiling"` in `roof_construction_type`, the + SAP10 Calculation's `is_pitched_sloping_ceiling` flag stays False, so an + as-built / unknown roof takes the Table 18 **column (1)** age-band default + (`_ROOF_BY_AGE`, floored at 0.40 for bands A–G) instead of the **column (3)** + default (`_FLAT_ROOF_BY_AGE`: 2.30 A–D, 1.50 E, 0.68 F) that a slope-following + ceiling requires — a 3–5.75× understatement of roof heat loss at old bands. + It also loses the RdSAP 10 §3.8 inclined-area correction (÷cos 30°). +- **Recommendation** — `recommend_roof_insulation` dispatches on the same + `roof_construction_type` substring, so a roof mis-labelled as loft is offered + a loft top-up (joists, 300 mm) rather than `sloping_ceiling_insulation` + (rafters, 100 mm) — a measure that can't physically be installed the way the + overlay implies. + +The app-side enum + Drizzle migration (assessment-model#449, migration 0278) +already ships the canonical values; this repo is the lock-step source of truth +(ADR-0002) and must add them byte-for-byte. + +## Decision + +**Add a dedicated `Pitched, sloping ceiling, …` family to `RoofType`** — an +`as built` member plus a `12 mm … 400+ mm insulation` depth ladder (17 members +total), matching assessment-model#449 byte-for-byte. **Extends ADR-0032/0033; +the calculator changes in none** — it already models sloping ceilings; the fix +is to make the classifier and overlay *reach* that path. + +**1. The overlay sets the sloping-ceiling construction string; the calculator +does the rest.** `roof_overlay_for` resolves the new family to a +`BuildingPartOverlay(roof_construction_type="Pitched, sloping ceiling", …)`, +which folds onto the Effective EPC's building part and drives both the scorer +and the recommendation generator off one authoritative string: + +| Member | Overlay | SAP10 U-value resolution | +|---|---|---| +| `Pitched, sloping ceiling, as built` | construction `"Pitched, sloping ceiling"`, thickness `None` | Table 18 **col (3)** age-band default via `is_pitched_sloping_ceiling` (`_FLAT_ROOF_BY_AGE`) | +| `Pitched, sloping ceiling, N mm insulation` | construction `"Pitched, sloping ceiling"` + `roof_insulation_thickness = N` | Table 16 col (1) / Table 17 **col (1a)** "insulated slope" thickness ladder (`_ROOF_BY_THICKNESS`); `400+` → 400 | + +**The mm ladder scoring on col (1a) is correct, not a loft fallback.** RdSAP 10 +Table 17 col (1a) ("insulated slope – sloping ceiling, mineral wool/EPS") is +byte-identical to the Table 16 col (1) loft ladder — 100 mm → 0.40, 150 mm → +0.30, 200 mm → 0.21 — confirmed against accredited Elmhurst (age B + 150 mm reads +0.30 for both geometries). The slope-vs-loft distinction bites **only at the +age-band default** (unknown thickness), where the family diverges correctly to +col (3). Setting the explicit construction string guarantees the resolution is a +*deliberate* col-(1a) match, never an *accidental* loft-ladder fallthrough. This +reproduces exactly how a lodged code-8 cert already scores +(`_api_resolve_sloping_ceiling_thickness`), so a landlord-override sloping +ceiling scores identically to a lodged one of the same geometry and depth. + +**2. `as built` is sufficient at the no-depth end — no separate `Unknown` / +`no insulation` member.** `Unknown` depth folds to `as built` (mirrors #449). +This is both forced (a distinct member breaks byte-for-byte lock-step) and +correct: `as built` defers to the construction age band, which is itself +uninsulated (2.30) at pre-1950 bands A–D, 1.50 at E, 0.68 at F — so "as built" +already collapses to "uninsulated" precisely where an old slope-following +ceiling is. Mirrors the `…, as built` wall proposal (issue #1676 B7): the age +band drives the insulation assumption rather than a hard-coded label. + +**3. `Pitched, insulated at rafters` is kept distinct — this family does not +subsume it.** RdSAP separates the two by `roof_insulation_location`: **loc 1 = +insulation at rafters** (Table 16 **col (2)**: 200 mm → 0.29) versus **loc 7 = +sloping ceiling** (col (3) / Table 17 col (1a): 200 mm → 0.21). Different +constructions, different U-tables — folding them together would mis-score one to +the other. (That `Pitched, insulated at rafters` currently produces no overlay +is a pre-existing gap, out of scope here.) + +**4. A deterministic classifier guard, not the LLM alone, routes the family.** +`PitchedWithSlopingCeiling: ` resolves via a +`roof_sloping_ceiling_guard` (mirroring the #1376 `roof_party_ceiling_guard`), +composed into the roof `GuardedColumnClassifier` ahead of the +`ChatGptColumnClassifier` fallback. `As Built` / `Unknown` / empty → `as built`; +`N mm` → the ladder member (nearest tabulated depth ≤ N; ≥ 400 → `400+`). This +is the same defect class the party-ceiling guard was created to kill (#1376): +the LLM resolved *identical* roof inputs inconsistently, and adding 17 +near-duplicate `sloping ceiling` members beside the loft ladder is exactly the +ambiguity that produces that split. The guard makes the knowable, costly-if-wrong +mapping deterministic and unbilled; its output still lands in the +`source=classifier` reviewed cache, so the human keeps the final say. + +## Considered options + +- **Route the mm ladder to the Table 16 col (2) rafters ladder** (200 mm → + 0.29, the steeper "insulation at the slope" reading). Rejected: it requires a + calculator change (the `use_rafters` gate deliberately excludes sloping + ceilings) and a new overlay field, and it would make a landlord-override + sloping ceiling score *differently* from an identical lodged code-8 cert + (which routes to col (1a) today) — an override-vs-lodged divergence for zero + accuracy gain, since Elmhurst confirms col (1a) is the accredited value. +- **Fold `PitchedWithSlopingCeiling` into `Pitched, insulated at rafters` and + give that member a depth ladder.** Rejected: conflates RdSAP loc 7 with loc 1 + (col (3)/(1a) with col (2)), and #449 already shipped a *separate* named + family — reusing the rafters member would break lock-step. +- **Add a distinct `Unknown` / `no insulation` sloping-ceiling member.** + Rejected: breaks byte-for-byte parity with #449, and the age band already + resolves the no-depth end to the correct (uninsulated-at-old-bands) U. +- **Add the enum members and rely on the LLM classifier alone** (no guard). + Rejected: does not satisfy the issue's explicit "stop routing these onto + `Pitched, N mm loft insulation`" — the near-duplicate loft members are the + exact ambiguity #1376 fixed with a guard; a probabilistic route across 294 + costly as-built/Unknown rows is not equivalent to a deterministic one. +- **Guard + roof-classifier `extra_instructions` + a loft-direction eval.** + Rejected as over-scoped: the loft-direction risk (LLM over-picking a sloping + category for a loft description) is a review-surface concern, and CONTEXT.md + already frames `source=classifier` as a human-reviewed cache — the backstop + exists. Revisit if review flags real loft→sloping drift. + +## Consequences + +- The 311 portfolio-796 rows (`As Built` 202, `Unknown` 92, `150 mm` 16, + `100 mm` 1) currently on `Pitched, … loft insulation` are backfilled onto the + new family **after** this lands and #449 is live (same dry-run → commit flow + as the earlier 796 corrections). The 294 as-built/Unknown rows change U-value + (col (1) → col (3) at old bands); the 17 mm-ladder rows keep their U (col (1a) + ≡ col (1)) but gain the correct sloping-ceiling shape (recommendation + + inclined area). +- The roof override becomes **authoritative for both scoring and + recommendation**: an EPC-lodged loft overridden to sloping now dispatches + `sloping_ceiling_insulation`, not a loft top-up. +- A `Pitched, sloping ceiling, 12 mm insulation` override yields **no** roof + Recommendation (the ADR-0021 sloping branch only fires when uninsulated — + as-built A–D or explicit 0). This is unchanged ADR-0021 behaviour, accepted + consciously; whether thin-but-present slopes warrant a top-up is the same + open product question ADR-0047 left for the loft branch. +- **Lock-step obligation (ADR-0002):** the 17 member values are the contract + with assessment-model#449's `RoofTypeValues` / migration 0278 — any edit here + must be mirrored there and vice versa. +- The onboarder parity path (`EpcRoofDescriptions`, + `backend/onboarders/mappings/parity/`) already segregates sloping ceilings via + `map_sloping_ceiling_roof` (age band) and is **untouched** — this ADR governs + the Landlord-Override `RoofType` path only.