From fc738fa8436ccc322c687ccf632df793fb50a53f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:23:36 +0000 Subject: [PATCH 01/16] 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. From b8e94312920e22e6535d0db6937b52fb4b30e120 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:23:37 +0000 Subject: [PATCH 02/16] =?UTF-8?q?Recognise=20the=20pitched=20sloping-ceili?= =?UTF-8?q?ng=20descriptions=20as=20roof=20types=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/domain/epc/test_roof_type.py | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/domain/epc/test_roof_type.py diff --git a/tests/domain/epc/test_roof_type.py b/tests/domain/epc/test_roof_type.py new file mode 100644 index 000000000..0b0eacbfb --- /dev/null +++ b/tests/domain/epc/test_roof_type.py @@ -0,0 +1,46 @@ +"""Contract tests for the `RoofType` Landlord-Override taxonomy. + +The `Pitched, sloping ceiling, …` family is lock-step with the app-side +`RoofTypeValues` enum + Drizzle migration 0278 (assessment-model#449, ADR-0002 / +ADR-0066): the string values here are the shared contract and must match +byte-for-byte. The classifier resolves a reply value to a member via +`RoofType(value)` (`ChatGptColumnClassifier._to_category`), so "is this a +recognised roof type" is exactly the `RoofType(value)` construction below. +""" + +from __future__ import annotations + +import pytest + +from domain.epc.property_overrides.roof_type import RoofType + +# The pitched-sloping-ceiling family, byte-for-byte with assessment-model#449. +_SLOPING_CEILING_VALUES: list[str] = [ + "Pitched, sloping ceiling, as built", + "Pitched, sloping ceiling, 12 mm insulation", + "Pitched, sloping ceiling, 25 mm insulation", + "Pitched, sloping ceiling, 50 mm insulation", + "Pitched, sloping ceiling, 75 mm insulation", + "Pitched, sloping ceiling, 100 mm insulation", + "Pitched, sloping ceiling, 125 mm insulation", + "Pitched, sloping ceiling, 150 mm insulation", + "Pitched, sloping ceiling, 175 mm insulation", + "Pitched, sloping ceiling, 200 mm insulation", + "Pitched, sloping ceiling, 225 mm insulation", + "Pitched, sloping ceiling, 250 mm insulation", + "Pitched, sloping ceiling, 270 mm insulation", + "Pitched, sloping ceiling, 300 mm insulation", + "Pitched, sloping ceiling, 350 mm insulation", + "Pitched, sloping ceiling, 400 mm insulation", + "Pitched, sloping ceiling, 400+ mm insulation", +] + + +@pytest.mark.parametrize("value", _SLOPING_CEILING_VALUES) +def test_pitched_sloping_ceiling_string_is_a_recognised_roof_type(value: str) -> None: + # Act — the classifier resolves a canonical string through the enum + # constructor; a missing member raises ValueError (falls to UNKNOWN). + member = RoofType(value) + + # Assert — the canonical string round-trips as its own member value. + assert member.value == value From bec6e17347fffd5152ebffc690c1a1216c503a8d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:24:28 +0000 Subject: [PATCH 03/16] =?UTF-8?q?Recognise=20the=20pitched=20sloping-ceili?= =?UTF-8?q?ng=20descriptions=20as=20roof=20types=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/epc/property_overrides/roof_type.py | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/domain/epc/property_overrides/roof_type.py b/domain/epc/property_overrides/roof_type.py index bc3073f86..59437c91d 100644 --- a/domain/epc/property_overrides/roof_type.py +++ b/domain/epc/property_overrides/roof_type.py @@ -70,6 +70,33 @@ class RoofType(Enum): PITCHED_LOFT_400MM = "Pitched, 400 mm loft insulation" PITCHED_LOFT_400_PLUS_MM = "Pitched, 400+ mm loft insulation" + # A pitched roof whose ceiling follows the slope (gov-API roof_construction + # code 8) — no loft void, so its insulation sits at the rafter/slope line, NOT + # on horizontal ceiling joists. Distinct from both the PITCHED_LOFT_* ladder + # (loft joists) and PITCHED_INSULATED_AT_RAFTERS (a rafter-cavity over a loft). + # "as built" defers the insulation assumption to the construction age band + # (Table 18 col (3) via `is_pitched_sloping_ceiling`); the mm ladder carries + # the depth into the overlay, scored on Table 17 col (1a) "insulated slope". + # The value column is an FE-owned pgEnum kept lock-step with + # assessment-model#449 / migration 0278 (ADR-0002, ADR-0066). + PITCHED_SLOPING_CEILING_AS_BUILT = "Pitched, sloping ceiling, as built" + PITCHED_SLOPING_CEILING_12MM = "Pitched, sloping ceiling, 12 mm insulation" + PITCHED_SLOPING_CEILING_25MM = "Pitched, sloping ceiling, 25 mm insulation" + PITCHED_SLOPING_CEILING_50MM = "Pitched, sloping ceiling, 50 mm insulation" + PITCHED_SLOPING_CEILING_75MM = "Pitched, sloping ceiling, 75 mm insulation" + PITCHED_SLOPING_CEILING_100MM = "Pitched, sloping ceiling, 100 mm insulation" + PITCHED_SLOPING_CEILING_125MM = "Pitched, sloping ceiling, 125 mm insulation" + PITCHED_SLOPING_CEILING_150MM = "Pitched, sloping ceiling, 150 mm insulation" + PITCHED_SLOPING_CEILING_175MM = "Pitched, sloping ceiling, 175 mm insulation" + PITCHED_SLOPING_CEILING_200MM = "Pitched, sloping ceiling, 200 mm insulation" + PITCHED_SLOPING_CEILING_225MM = "Pitched, sloping ceiling, 225 mm insulation" + PITCHED_SLOPING_CEILING_250MM = "Pitched, sloping ceiling, 250 mm insulation" + PITCHED_SLOPING_CEILING_270MM = "Pitched, sloping ceiling, 270 mm insulation" + PITCHED_SLOPING_CEILING_300MM = "Pitched, sloping ceiling, 300 mm insulation" + PITCHED_SLOPING_CEILING_350MM = "Pitched, sloping ceiling, 350 mm insulation" + PITCHED_SLOPING_CEILING_400MM = "Pitched, sloping ceiling, 400 mm insulation" + PITCHED_SLOPING_CEILING_400_PLUS = "Pitched, sloping ceiling, 400+ mm insulation" + ROOF_ROOM_INSULATED = "Roof room(s), insulated" ROOF_ROOM_INSULATED_ASSUMED = "Roof room(s), insulated (assumed)" ROOF_ROOM_LIMITED_INSULATION = "Roof room(s), limited insulation" From e89f5a3a5c308fabaa643a3ae871847ac3438df1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:25:07 +0000 Subject: [PATCH 04/16] =?UTF-8?q?Defer=20an=20as-built=20sloping-ceiling?= =?UTF-8?q?=20override=20to=20the=20age-band=20default=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/domain/epc/test_roof_type_overlay.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/domain/epc/test_roof_type_overlay.py b/tests/domain/epc/test_roof_type_overlay.py index 1c39ec9f7..aab9f9cd7 100644 --- a/tests/domain/epc/test_roof_type_overlay.py +++ b/tests/domain/epc/test_roof_type_overlay.py @@ -160,6 +160,24 @@ def test_pitched_unknown_loft_drives_the_pitched_age_band_default() -> None: assert overlay.roof_insulation_thickness is None +def test_sloping_ceiling_as_built_defers_to_the_age_band_default() -> None: + # ADR-0066: a pitched sloping ceiling has no loft void, so "as built" defers + # the U-value to the construction age band (Table 18 col (3) via the + # calculator's `is_pitched_sloping_ceiling`). The calculator keys the sloping + # path on "sloping ceiling" in `roof_construction_type`, so the overlay sets + # that shape and leaves thickness None for the age band to drive — never the + # loft ladder. + + # Act + simulation = roof_overlay_for("Pitched, sloping ceiling, as built", 0) + + # Assert + assert simulation is not None + overlay = simulation.building_parts[BuildingPartIdentifier.MAIN] + assert overlay.roof_construction_type == "Pitched, sloping ceiling" + assert overlay.roof_insulation_thickness is None + + @pytest.mark.parametrize( "roof_type_value", [ From 9cccf830008980af848388ca3d335372aa2005f3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:25:37 +0000 Subject: [PATCH 05/16] =?UTF-8?q?Defer=20an=20as-built=20sloping-ceiling?= =?UTF-8?q?=20override=20to=20the=20age-band=20default=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/epc/property_overlays/roof_type_overlay.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/domain/epc/property_overlays/roof_type_overlay.py b/domain/epc/property_overlays/roof_type_overlay.py index a2ba1811b..bf47c67cd 100644 --- a/domain/epc/property_overlays/roof_type_overlay.py +++ b/domain/epc/property_overlays/roof_type_overlay.py @@ -50,6 +50,12 @@ def _overlay_for(roof_type_value: str) -> Optional[BuildingPartOverlay]: match = _LOFT_MM.search(roof_type_value) if match is not None: return BuildingPartOverlay(roof_insulation_thickness=int(match.group(1))) + if roof_type_value.startswith("Pitched, sloping ceiling"): + # ADR-0066: a slope-following ceiling (gov-API roof_construction 8) has no + # loft void. Assert the sloping-ceiling shape so the calculator's + # `is_pitched_sloping_ceiling` drives the Table 18 col (3) age-band default + # ("as built"), never the loft ladder. Leave thickness None here. + return BuildingPartOverlay(roof_construction_type="Pitched, sloping ceiling") if roof_type_value.startswith("Flat,"): flat_depth = _FLAT_MM.search(roof_type_value) if flat_depth is not None: From b6172987bdc4ef7beea2e057647b25ded72a486b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:26:19 +0000 Subject: [PATCH 06/16] =?UTF-8?q?Carry=20the=20sloping-ceiling=20insulatio?= =?UTF-8?q?n=20depth=20into=20the=20overlay=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/domain/epc/test_roof_type_overlay.py | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/domain/epc/test_roof_type_overlay.py b/tests/domain/epc/test_roof_type_overlay.py index aab9f9cd7..23d39890a 100644 --- a/tests/domain/epc/test_roof_type_overlay.py +++ b/tests/domain/epc/test_roof_type_overlay.py @@ -160,6 +160,46 @@ def test_pitched_unknown_loft_drives_the_pitched_age_band_default() -> None: assert overlay.roof_insulation_thickness is None +_SLOPING_CEILING_THICKNESS_MEMBERS: list[tuple[RoofType, int]] = [ + (RoofType.PITCHED_SLOPING_CEILING_12MM, 12), + (RoofType.PITCHED_SLOPING_CEILING_25MM, 25), + (RoofType.PITCHED_SLOPING_CEILING_50MM, 50), + (RoofType.PITCHED_SLOPING_CEILING_75MM, 75), + (RoofType.PITCHED_SLOPING_CEILING_100MM, 100), + (RoofType.PITCHED_SLOPING_CEILING_125MM, 125), + (RoofType.PITCHED_SLOPING_CEILING_150MM, 150), + (RoofType.PITCHED_SLOPING_CEILING_175MM, 175), + (RoofType.PITCHED_SLOPING_CEILING_200MM, 200), + (RoofType.PITCHED_SLOPING_CEILING_225MM, 225), + (RoofType.PITCHED_SLOPING_CEILING_250MM, 250), + (RoofType.PITCHED_SLOPING_CEILING_270MM, 270), + (RoofType.PITCHED_SLOPING_CEILING_300MM, 300), + (RoofType.PITCHED_SLOPING_CEILING_350MM, 350), + (RoofType.PITCHED_SLOPING_CEILING_400MM, 400), + (RoofType.PITCHED_SLOPING_CEILING_400_PLUS, 400), +] + + +@pytest.mark.parametrize(("member", "expected_mm"), _SLOPING_CEILING_THICKNESS_MEMBERS) +def test_every_sloping_ceiling_thickness_member_carries_its_depth( + member: RoofType, expected_mm: int +) -> None: + # ADR-0066: a known-depth sloping ceiling scores on Table 17 col (1a) + # "insulated slope" — the depth must reach the overlay (via + # `roof_insulation_thickness`) alongside the sloping-ceiling shape, so a + # landlord-stored member scores its slope, not the age-band default. `400+` + # carries 400 (the top tabulated row). + + # Act + simulation = roof_overlay_for(member.value, 0) + + # Assert + assert simulation is not None + overlay = simulation.building_parts[BuildingPartIdentifier.MAIN] + assert overlay.roof_construction_type == "Pitched, sloping ceiling" + assert overlay.roof_insulation_thickness == expected_mm + + def test_sloping_ceiling_as_built_defers_to_the_age_band_default() -> None: # ADR-0066: a pitched sloping ceiling has no loft void, so "as built" defers # the U-value to the construction age band (Table 18 col (3) via the From e384e37f5fafd5316dc38365454640dad1856533 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:26:48 +0000 Subject: [PATCH 07/16] =?UTF-8?q?Carry=20the=20sloping-ceiling=20insulatio?= =?UTF-8?q?n=20depth=20into=20the=20overlay=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/property_overlays/roof_type_overlay.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/domain/epc/property_overlays/roof_type_overlay.py b/domain/epc/property_overlays/roof_type_overlay.py index bf47c67cd..9fa8f3024 100644 --- a/domain/epc/property_overlays/roof_type_overlay.py +++ b/domain/epc/property_overlays/roof_type_overlay.py @@ -29,6 +29,9 @@ from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation _LOFT_MM = re.compile(r"(\d+)\+?\s*mm loft insulation") # A flat roof carries no loft, so its depth reads "N mm insulation" (no "loft"). _FLAT_MM = re.compile(r"(\d+)\+?\s*mm insulation") +# A sloping ceiling's depth reads the same "N mm insulation" shape (no "loft"); +# the "sloping ceiling" prefix, not the suffix, distinguishes it from a flat roof. +_SLOPING_MM = re.compile(r"(\d+)\+?\s*mm insulation") def roof_overlay_for( @@ -53,9 +56,17 @@ def _overlay_for(roof_type_value: str) -> Optional[BuildingPartOverlay]: if roof_type_value.startswith("Pitched, sloping ceiling"): # ADR-0066: a slope-following ceiling (gov-API roof_construction 8) has no # loft void. Assert the sloping-ceiling shape so the calculator's - # `is_pitched_sloping_ceiling` drives the Table 18 col (3) age-band default - # ("as built"), never the loft ladder. Leave thickness None here. - return BuildingPartOverlay(roof_construction_type="Pitched, sloping ceiling") + # `is_pitched_sloping_ceiling` keys off "sloping ceiling" in the string. + # A known depth ("N mm insulation") rides into the overlay and scores on + # Table 17 col (1a) "insulated slope"; "as built" leaves thickness None so + # the Table 18 col (3) age-band default drives it. Never the loft ladder. + sloping_depth = _SLOPING_MM.search(roof_type_value) + return BuildingPartOverlay( + roof_construction_type="Pitched, sloping ceiling", + roof_insulation_thickness=( + int(sloping_depth.group(1)) if sloping_depth is not None else None + ), + ) if roof_type_value.startswith("Flat,"): flat_depth = _FLAT_MM.search(roof_type_value) if flat_depth is not None: From c01f48d4fda0000348e95d2ebe5fb27cb457f70c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:38:00 +0000 Subject: [PATCH 08/16] =?UTF-8?q?Carry=20the=20sloping-ceiling=20insulatio?= =?UTF-8?q?n=20depth=20into=20the=20overlay=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../property_overlays/roof_type_overlay.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/domain/epc/property_overlays/roof_type_overlay.py b/domain/epc/property_overlays/roof_type_overlay.py index 9fa8f3024..e9b85f175 100644 --- a/domain/epc/property_overlays/roof_type_overlay.py +++ b/domain/epc/property_overlays/roof_type_overlay.py @@ -1,6 +1,6 @@ """Map a Landlord-Override `RoofType` value to a roof Simulation Overlay (ADR-0032/0033). -Two resolvable families: +Three resolvable families: * `"Pitched, N mm loft insulation"` — the loft depth N maps to `BuildingPartOverlay.roof_insulation_thickness` (mm); the calculator scores the @@ -9,9 +9,13 @@ Two resolvable families: age-band default (`_FLAT_ROOF_BY_AGE`, ADR-0033). The calculator's flat path keys on the `roof_construction_type` *string* (`"flat" in …`), so the overlay sets that to `"Flat"` and leaves thickness `None` for the (separately overlaid) - construction age band to drive the U-value. No flat `RoofType` value carries an - explicit mm depth, confirmed by the Elmhurst sweep (As Built / Unknown ≡ - age-band default). + construction age band to drive the U-value. A flat `RoofType` value may also + carry an explicit mm depth (Table 16 col (1), ADR-0041). +* `"Pitched, sloping ceiling, …"` — a slope-following ceiling (no loft void, + ADR-0066). The overlay sets `roof_construction_type` to `"Pitched, sloping + ceiling"` so the calculator's `is_pitched_sloping_ceiling` fires: "as built" + leaves thickness `None` for the Table 18 col (3) age-band default, while the + `N mm` ladder carries the depth (Table 17 col (1a) "insulated slope"). Everything else (room-in-roof, "Unknown loft insulation", party-ceiling adjacency markers like "Another Premises Above") has no clean depth or shape correction, so @@ -27,11 +31,10 @@ from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation _LOFT_MM = re.compile(r"(\d+)\+?\s*mm loft insulation") -# A flat roof carries no loft, so its depth reads "N mm insulation" (no "loft"). -_FLAT_MM = re.compile(r"(\d+)\+?\s*mm insulation") -# A sloping ceiling's depth reads the same "N mm insulation" shape (no "loft"); -# the "sloping ceiling" prefix, not the suffix, distinguishes it from a flat roof. -_SLOPING_MM = re.compile(r"(\d+)\+?\s*mm insulation") +# A flat roof or sloping ceiling carries no loft, so its depth reads +# "N mm insulation" (no "loft"); the leading shape ("Flat," / "Pitched, sloping +# ceiling"), not the suffix, distinguishes the two. +_MM_INSULATION = re.compile(r"(\d+)\+?\s*mm insulation") def roof_overlay_for( @@ -60,7 +63,7 @@ def _overlay_for(roof_type_value: str) -> Optional[BuildingPartOverlay]: # A known depth ("N mm insulation") rides into the overlay and scores on # Table 17 col (1a) "insulated slope"; "as built" leaves thickness None so # the Table 18 col (3) age-band default drives it. Never the loft ladder. - sloping_depth = _SLOPING_MM.search(roof_type_value) + sloping_depth = _MM_INSULATION.search(roof_type_value) return BuildingPartOverlay( roof_construction_type="Pitched, sloping ceiling", roof_insulation_thickness=( @@ -68,7 +71,7 @@ def _overlay_for(roof_type_value: str) -> Optional[BuildingPartOverlay]: ), ) if roof_type_value.startswith("Flat,"): - flat_depth = _FLAT_MM.search(roof_type_value) + flat_depth = _MM_INSULATION.search(roof_type_value) if flat_depth is not None: # A flat roof with a known depth: RdSAP 10 Table 16 col (1) ("joists at # ceiling level and flat roof") scores it by thickness, so carry the From 0625b9b179ed7a6ea8a630ce26aab3313a318821 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:45:58 +0000 Subject: [PATCH 09/16] =?UTF-8?q?Lock=20the=20sloping-ceiling=20override's?= =?UTF-8?q?=20U-value=20end-to-end=20(col-3=20as-built,=20col-1a=20by=20de?= =?UTF-8?q?pth)=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/domain/epc/test_roof_type_overlay.py | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/domain/epc/test_roof_type_overlay.py b/tests/domain/epc/test_roof_type_overlay.py index 23d39890a..3c4e859cc 100644 --- a/tests/domain/epc/test_roof_type_overlay.py +++ b/tests/domain/epc/test_roof_type_overlay.py @@ -12,6 +12,28 @@ import pytest from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier from domain.epc.property_overlays.roof_type_overlay import roof_overlay_for from domain.epc.property_overrides.roof_type import RoofType +from domain.sap10_ml.rdsap_uvalues import Country, u_roof + + +def _resolved_roof_u(roof_type_value: str, age_band: str) -> float: + """Score a resolved roof override through the real `u_roof`, deriving the + sloping-ceiling flag exactly as the calculator does from the overlay's + `roof_construction_type` string (`heat_transmission.py`: + `is_pitched_sloping_ceiling = "sloping ceiling" in roof_type_lower`). This + ties the overlay's emitted string to the U it actually produces — a silent + loft-scored roof if the string ever stops containing "sloping ceiling".""" + simulation = roof_overlay_for(roof_type_value, 0) + assert simulation is not None + overlay = simulation.building_parts[BuildingPartIdentifier.MAIN] + roof_type_lower = (overlay.roof_construction_type or "").lower() + is_sloping = "sloping ceiling" in roof_type_lower + return u_roof( + country=Country.ENG, + age_band=age_band, + insulation_thickness_mm=overlay.roof_insulation_thickness, + is_sloping_ceiling=is_sloping, + is_pitched_sloping_ceiling=is_sloping, + ) _FLAT_THICKNESS_MEMBERS: list[tuple[RoofType, int]] = [ (RoofType.FLAT_12MM, 12), @@ -218,6 +240,32 @@ def test_sloping_ceiling_as_built_defers_to_the_age_band_default() -> None: assert overlay.roof_insulation_thickness is None +def test_as_built_sloping_ceiling_scores_the_slope_default_not_the_loft_floor() -> None: + # ADR-0066: an as-built sloping ceiling at an old band takes Table 18 col (3) + # (band F = 0.68 W/m²K), NOT the loft col (1) 0.40 "modern-retrofit" floor it + # was mis-scored at when the classifier routed it onto the loft ladder. This + # is the whole point of the family — the age-band default must be the slope + # column, driven by the overlay's construction string. + + # Act + u_value = _resolved_roof_u("Pitched, sloping ceiling, as built", age_band="F") + + # Assert — col (3), not the loft col (1) 0.40. + assert abs(u_value - 0.68) <= 1e-4 + + +def test_known_depth_sloping_ceiling_scores_the_insulated_slope_ladder() -> None: + # ADR-0066: a known-depth sloping ceiling scores on Table 17 col (1a) + # "insulated slope" — 150 mm → 0.30 W/m²K (Elmhurst-confirmed, byte-identical + # to the loft col (1) value, so it is a *deliberate* match, not a fallback). + + # Act + u_value = _resolved_roof_u("Pitched, sloping ceiling, 150 mm insulation", age_band="F") + + # Assert + assert abs(u_value - 0.30) <= 1e-4 + + @pytest.mark.parametrize( "roof_type_value", [ From 6603d98c2c4ff2651fea246183a484354ca9b722 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:46:49 +0000 Subject: [PATCH 10/16] =?UTF-8?q?Resolve=20a=20depthless=20sloping-ceiling?= =?UTF-8?q?=20marker=20to=20as=20built=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../roof_sloping_ceiling_guard.py | 9 ++++ .../epc/test_roof_sloping_ceiling_guard.py | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 domain/epc/property_overrides/roof_sloping_ceiling_guard.py create mode 100644 tests/domain/epc/test_roof_sloping_ceiling_guard.py diff --git a/domain/epc/property_overrides/roof_sloping_ceiling_guard.py b/domain/epc/property_overrides/roof_sloping_ceiling_guard.py new file mode 100644 index 000000000..22ade4d28 --- /dev/null +++ b/domain/epc/property_overrides/roof_sloping_ceiling_guard.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Optional + +from domain.epc.property_overrides.roof_type import RoofType + + +def roof_sloping_ceiling_guard(description: str) -> Optional[RoofType]: + raise NotImplementedError diff --git a/tests/domain/epc/test_roof_sloping_ceiling_guard.py b/tests/domain/epc/test_roof_sloping_ceiling_guard.py new file mode 100644 index 000000000..022b36460 --- /dev/null +++ b/tests/domain/epc/test_roof_sloping_ceiling_guard.py @@ -0,0 +1,42 @@ +"""The pitched-sloping-ceiling roof guard (ADR-0066). + +`PitchedWithSlopingCeiling: ` is a slope-following +ceiling (gov-API roof_construction 8), NOT a horizontal loft — so it must +resolve deterministically to the `Pitched, sloping ceiling, …` family and never +reach the LLM to be routed onto the `Pitched, N mm loft insulation` ladder. Same +defect class the party-ceiling guard was created for (#1376): the LLM resolves +identical inputs inconsistently, and the near-duplicate loft members are exactly +the ambiguity. Returns None for anything that is not a sloping-ceiling marker so +the LLM (and the party-ceiling guard) still handle the rest. +""" + +from __future__ import annotations + +import pytest + +from domain.epc.property_overrides.roof_sloping_ceiling_guard import ( + roof_sloping_ceiling_guard, +) +from domain.epc.property_overrides.roof_type import RoofType + + +@pytest.mark.parametrize( + "description", + [ + "PitchedWithSlopingCeiling: As Built", + "PitchedWithSlopingCeiling: Unknown", + "PitchedWithSlopingCeiling", + "PitchedWithSlopingCeiling: ", + ], +) +def test_sloping_ceiling_without_a_depth_resolves_to_as_built( + description: str, +) -> None: + # ADR-0066: a sloping ceiling with no measured depth ("As Built" / "Unknown" + # / nothing) folds to `as built`, which defers the U-value to the age band. + + # Act + member = roof_sloping_ceiling_guard(description) + + # Assert + assert member is RoofType.PITCHED_SLOPING_CEILING_AS_BUILT From 870cb04631db543cae880543d770f2fcfd78a41b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:47:11 +0000 Subject: [PATCH 11/16] =?UTF-8?q?Resolve=20a=20depthless=20sloping-ceiling?= =?UTF-8?q?=20marker=20to=20as=20built=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../roof_sloping_ceiling_guard.py | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/domain/epc/property_overrides/roof_sloping_ceiling_guard.py b/domain/epc/property_overrides/roof_sloping_ceiling_guard.py index 22ade4d28..e9de671cf 100644 --- a/domain/epc/property_overrides/roof_sloping_ceiling_guard.py +++ b/domain/epc/property_overrides/roof_sloping_ceiling_guard.py @@ -1,9 +1,29 @@ from __future__ import annotations +import re from typing import Optional from domain.epc.property_overrides.roof_type import RoofType +# The gov-API sloping-ceiling roof token (before any ``: `` suffix), +# normalised to lower alphanumerics. A slope-following ceiling (roof_construction +# 8) has no loft void, so it must resolve to the `Pitched, sloping ceiling, …` +# family, never the loft ladder (ADR-0066). +_SLOPING_CEILING_TOKEN = "pitchedwithslopingceiling" + + +def _normalise_roof_token(description: str) -> str: + token = description.split(":", 1)[0] + return re.sub(r"[^a-z0-9]", "", token.lower()) + def roof_sloping_ceiling_guard(description: str) -> Optional[RoofType]: - raise NotImplementedError + """Deterministically resolve a `PitchedWithSlopingCeiling` roof description to + its `Pitched, sloping ceiling, …` RoofType member (ADR-0066). + + Returns None for anything that is not a sloping-ceiling marker, so the + party-ceiling guard and the LLM classifier still handle the rest. + """ + if _normalise_roof_token(description) != _SLOPING_CEILING_TOKEN: + return None + return RoofType.PITCHED_SLOPING_CEILING_AS_BUILT From a717ff7a9930dea189d021e4ebe0c446f43ce671 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:48:15 +0000 Subject: [PATCH 12/16] =?UTF-8?q?Resolve=20a=20measured=20sloping-ceiling?= =?UTF-8?q?=20depth=20to=20its=20ladder=20member=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_roof_sloping_ceiling_guard.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/domain/epc/test_roof_sloping_ceiling_guard.py b/tests/domain/epc/test_roof_sloping_ceiling_guard.py index 022b36460..d2820826f 100644 --- a/tests/domain/epc/test_roof_sloping_ceiling_guard.py +++ b/tests/domain/epc/test_roof_sloping_ceiling_guard.py @@ -40,3 +40,46 @@ def test_sloping_ceiling_without_a_depth_resolves_to_as_built( # Assert assert member is RoofType.PITCHED_SLOPING_CEILING_AS_BUILT + + +@pytest.mark.parametrize( + ("description", "expected"), + [ + # Exact tabulated depths → their own member. + ("PitchedWithSlopingCeiling: 12mm", RoofType.PITCHED_SLOPING_CEILING_12MM), + ("PitchedWithSlopingCeiling: 100mm", RoofType.PITCHED_SLOPING_CEILING_100MM), + ("PitchedWithSlopingCeiling: 150 mm", RoofType.PITCHED_SLOPING_CEILING_150MM), + ("PitchedWithSlopingCeiling: 270mm", RoofType.PITCHED_SLOPING_CEILING_270MM), + ("PitchedWithSlopingCeiling: 400mm", RoofType.PITCHED_SLOPING_CEILING_400MM), + # Off-ladder depth snaps to the nearest tabulated depth ≤ N (mirrors the + # calculator's `_ROOF_BY_THICKNESS` picker), so nothing leaks to the LLM. + ("PitchedWithSlopingCeiling: 130mm", RoofType.PITCHED_SLOPING_CEILING_125MM), + # Above 400 mm, or an explicit "400+" lodgement → the 400+ member. + ("PitchedWithSlopingCeiling: 450mm", RoofType.PITCHED_SLOPING_CEILING_400_PLUS), + ("PitchedWithSlopingCeiling: 400+mm", RoofType.PITCHED_SLOPING_CEILING_400_PLUS), + ], +) +def test_sloping_ceiling_measured_depth_resolves_to_its_ladder_member( + description: str, expected: RoofType +) -> None: + # ADR-0066: a measured sloping-ceiling depth rides onto the matching ladder + # member (Table 17 col (1a)); off-ladder depths snap down; ≥ 400 / "400+" + # caps at the top member. + + # Act + member = roof_sloping_ceiling_guard(description) + + # Assert + assert member is expected + + +def test_sloping_ceiling_depth_below_the_ladder_falls_back_to_as_built() -> None: + # No member exists below 12 mm; a sub-ladder depth defers to `as built` (the + # age band drives it — uninsulated at old bands anyway), never inventing a + # member or leaking to the loft LLM route. + + # Act + member = roof_sloping_ceiling_guard("PitchedWithSlopingCeiling: 5mm") + + # Assert + assert member is RoofType.PITCHED_SLOPING_CEILING_AS_BUILT From 02a71feae1c3f64cb31bbaad34ecf5f0178d06dd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:48:50 +0000 Subject: [PATCH 13/16] =?UTF-8?q?Resolve=20a=20measured=20sloping-ceiling?= =?UTF-8?q?=20depth=20to=20its=20ladder=20member=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../roof_sloping_ceiling_guard.py | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/domain/epc/property_overrides/roof_sloping_ceiling_guard.py b/domain/epc/property_overrides/roof_sloping_ceiling_guard.py index e9de671cf..6496603e6 100644 --- a/domain/epc/property_overrides/roof_sloping_ceiling_guard.py +++ b/domain/epc/property_overrides/roof_sloping_ceiling_guard.py @@ -11,19 +11,65 @@ from domain.epc.property_overrides.roof_type import RoofType # family, never the loft ladder (ADR-0066). _SLOPING_CEILING_TOKEN = "pitchedwithslopingceiling" +# The measured-depth ladder (Table 17 col (1a) tabulated thicknesses), ascending. +# A lodged depth snaps to the nearest tabulated depth ≤ N — mirroring the +# calculator's `_ROOF_BY_THICKNESS` picker — so an off-ladder value never leaks +# back to the loft LLM route. `400+` / any depth > 400 is the top member. +_DEPTH_LADDER: list[tuple[int, RoofType]] = [ + (12, RoofType.PITCHED_SLOPING_CEILING_12MM), + (25, RoofType.PITCHED_SLOPING_CEILING_25MM), + (50, RoofType.PITCHED_SLOPING_CEILING_50MM), + (75, RoofType.PITCHED_SLOPING_CEILING_75MM), + (100, RoofType.PITCHED_SLOPING_CEILING_100MM), + (125, RoofType.PITCHED_SLOPING_CEILING_125MM), + (150, RoofType.PITCHED_SLOPING_CEILING_150MM), + (175, RoofType.PITCHED_SLOPING_CEILING_175MM), + (200, RoofType.PITCHED_SLOPING_CEILING_200MM), + (225, RoofType.PITCHED_SLOPING_CEILING_225MM), + (250, RoofType.PITCHED_SLOPING_CEILING_250MM), + (270, RoofType.PITCHED_SLOPING_CEILING_270MM), + (300, RoofType.PITCHED_SLOPING_CEILING_300MM), + (350, RoofType.PITCHED_SLOPING_CEILING_350MM), + (400, RoofType.PITCHED_SLOPING_CEILING_400MM), +] +_TOP_TABULATED_MM = 400 + def _normalise_roof_token(description: str) -> str: token = description.split(":", 1)[0] return re.sub(r"[^a-z0-9]", "", token.lower()) +def _member_for_depth(insulation: str) -> Optional[RoofType]: + """The ladder member for a lodged insulation suffix, or None when it carries + no measured depth (`As Built` / `Unknown` / empty) so the caller folds it to + `as built`. An explicit `+` or any depth above 400 mm → the 400+ member; an + off-ladder depth snaps to the nearest tabulated depth ≤ N; a depth below the + ladder (< 12 mm) has no member and also folds to `as built`.""" + digits = re.search(r"\d+", insulation) + if digits is None: + return None + depth_mm = int(digits.group()) + if "+" in insulation or depth_mm > _TOP_TABULATED_MM: + return RoofType.PITCHED_SLOPING_CEILING_400_PLUS + member: Optional[RoofType] = None + for tabulated_mm, ladder_member in _DEPTH_LADDER: + if depth_mm >= tabulated_mm: + member = ladder_member + return member + + def roof_sloping_ceiling_guard(description: str) -> Optional[RoofType]: """Deterministically resolve a `PitchedWithSlopingCeiling` roof description to its `Pitched, sloping ceiling, …` RoofType member (ADR-0066). - Returns None for anything that is not a sloping-ceiling marker, so the + A measured depth resolves to its ladder member (nearest tabulated depth ≤ N; + `400+` / above-400 → the 400+ member); `As Built` / `Unknown` / a sub-ladder + depth / no suffix fold to `as built`, which defers the U-value to the age + band. Returns None for anything that is not a sloping-ceiling marker, so the party-ceiling guard and the LLM classifier still handle the rest. """ if _normalise_roof_token(description) != _SLOPING_CEILING_TOKEN: return None - return RoofType.PITCHED_SLOPING_CEILING_AS_BUILT + _, _, insulation = description.partition(":") + return _member_for_depth(insulation) or RoofType.PITCHED_SLOPING_CEILING_AS_BUILT From a6a2764b5ea315c865463831896d1f204ccd5e25 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:49:35 +0000 Subject: [PATCH 14/16] =?UTF-8?q?Leave=20non-sloping-ceiling=20roof=20desc?= =?UTF-8?q?riptions=20for=20the=20party=20guard=20and=20LLM=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_roof_sloping_ceiling_guard.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/domain/epc/test_roof_sloping_ceiling_guard.py b/tests/domain/epc/test_roof_sloping_ceiling_guard.py index d2820826f..93d8247f3 100644 --- a/tests/domain/epc/test_roof_sloping_ceiling_guard.py +++ b/tests/domain/epc/test_roof_sloping_ceiling_guard.py @@ -83,3 +83,24 @@ def test_sloping_ceiling_depth_below_the_ladder_falls_back_to_as_built() -> None # Assert assert member is RoofType.PITCHED_SLOPING_CEILING_AS_BUILT + + +@pytest.mark.parametrize( + "description", + [ + "Pitched, 150 mm loft insulation", + "PitchedNormalLoftAccess: 150mm", + "anotherdwellingabove: 100mm", + "Flat: As Built", + "", + ], +) +def test_non_sloping_ceiling_descriptions_are_not_guarded(description: str) -> None: + # A description that is not a sloping-ceiling marker returns None, so the + # party-ceiling guard and the LLM classifier still handle it (#1376 pattern). + + # Act + member = roof_sloping_ceiling_guard(description) + + # Assert + assert member is None From 82ac8c341b04f81110ed25c8e3caf4c491e936b8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:50:13 +0000 Subject: [PATCH 15/16] =?UTF-8?q?Compose=20the=20party-ceiling=20and=20slo?= =?UTF-8?q?ping-ceiling=20roof=20guards=20ahead=20of=20the=20LLM=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/epc/property_overrides/roof_guard.py | 9 +++++ tests/domain/epc/test_roof_guard.py | 37 +++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 domain/epc/property_overrides/roof_guard.py create mode 100644 tests/domain/epc/test_roof_guard.py diff --git a/domain/epc/property_overrides/roof_guard.py b/domain/epc/property_overrides/roof_guard.py new file mode 100644 index 000000000..e3203e950 --- /dev/null +++ b/domain/epc/property_overrides/roof_guard.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Optional + +from domain.epc.property_overrides.roof_type import RoofType + + +def roof_guard(description: str) -> Optional[RoofType]: + raise NotImplementedError diff --git a/tests/domain/epc/test_roof_guard.py b/tests/domain/epc/test_roof_guard.py new file mode 100644 index 000000000..afb86923b --- /dev/null +++ b/tests/domain/epc/test_roof_guard.py @@ -0,0 +1,37 @@ +"""The composed roof guard wired into the roof `GuardedColumnClassifier`. + +Two deterministic roof guards compose ahead of the LLM fallback: the +party-ceiling guard (#1376) and the sloping-ceiling guard (ADR-0066). A roof +description that neither recognises returns None and reaches the LLM. +""" + +from __future__ import annotations + +from domain.epc.property_overrides.roof_guard import roof_guard +from domain.epc.property_overrides.roof_type import RoofType + + +def test_party_ceiling_marker_resolves_to_its_party_member() -> None: + # Act + member = roof_guard("another dwelling above") + + # Assert + assert member is RoofType.ADJACENT_ANOTHER_DWELLING_ABOVE + + +def test_sloping_ceiling_marker_resolves_to_the_sloping_family() -> None: + # Act + member = roof_guard("PitchedWithSlopingCeiling: 150mm") + + # Assert + assert member is RoofType.PITCHED_SLOPING_CEILING_150MM + + +def test_a_plain_loft_description_is_left_for_the_llm() -> None: + # Neither deterministic guard recognises a loft depth, so it reaches the LLM. + + # Act + member = roof_guard("Pitched, 150 mm loft insulation") + + # Assert + assert member is None From 6f8724aa04fc517403fdf24c6b3159e0b2160c91 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 13:53:56 +0000 Subject: [PATCH 16/16] =?UTF-8?q?Route=20sloping-ceiling=20roof=20descript?= =?UTF-8?q?ions=20deterministically=20off=20the=20loft=20ladder=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose the party-ceiling and sloping-ceiling guards in the roof GuardedColumnClassifier so a PitchedWithSlopingCeiling override resolves to the Pitched, sloping ceiling family instead of being routed onto Pitched, N mm loft insulation by the LLM (ADR-0066). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../landlord_description_overrides/handler.py | 15 +++++++-------- domain/epc/property_overrides/roof_guard.py | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/applications/landlord_description_overrides/handler.py b/applications/landlord_description_overrides/handler.py index 151fb7299..236b28894 100644 --- a/applications/landlord_description_overrides/handler.py +++ b/applications/landlord_description_overrides/handler.py @@ -18,9 +18,7 @@ from domain.epc.property_overrides.main_heating_guard import main_heating_guard from domain.epc.property_overrides.property_type import PropertyType from domain.epc.property_overrides.property_type_guard import property_type_guard from domain.epc.property_overrides.roof_type import RoofType -from domain.epc.property_overrides.roof_party_ceiling_guard import ( - roof_party_ceiling_guard, -) +from domain.epc.property_overrides.roof_guard import roof_guard from domain.data_transformation.guarded_column_classifier import ( GuardedColumnClassifier, ) @@ -133,12 +131,13 @@ def _build_columns( "roof_type": lambda src: ClassifiableColumn( name="roof_type", source_column=src, - # A party ceiling ("another/same dwelling or premises above") has ~0 - # heat loss and must never be classified as an external roof; the - # deterministic guard resolves those markers and the LLM handles the - # rest (#1376). + # Deterministic roof guards resolve the markers they are certain of + # ahead of the LLM: a party ceiling ("another/same dwelling or + # premises above", ~0 heat loss, #1376) and a slope-following ceiling + # ("PitchedWithSlopingCeiling", rafter/slope-line insulation, never + # the loft ladder, ADR-0066). The LLM handles the rest. classifier=GuardedColumnClassifier( - guard=roof_party_ceiling_guard, + guard=roof_guard, fallback=ChatGptColumnClassifier(chat_gpt, RoofType, RoofType.UNKNOWN), ), repo=LandlordOverridesRepository[RoofType]( diff --git a/domain/epc/property_overrides/roof_guard.py b/domain/epc/property_overrides/roof_guard.py index e3203e950..a45732324 100644 --- a/domain/epc/property_overrides/roof_guard.py +++ b/domain/epc/property_overrides/roof_guard.py @@ -2,8 +2,24 @@ from __future__ import annotations from typing import Optional +from domain.epc.property_overrides.roof_party_ceiling_guard import ( + roof_party_ceiling_guard, +) +from domain.epc.property_overrides.roof_sloping_ceiling_guard import ( + roof_sloping_ceiling_guard, +) from domain.epc.property_overrides.roof_type import RoofType def roof_guard(description: str) -> Optional[RoofType]: - raise NotImplementedError + """The deterministic roof guard for the roof `GuardedColumnClassifier`. + + Composes the two roof guards that resolve a description with certainty, ahead + of the LLM fallback: a party ceiling (~0 heat loss, #1376) and a + slope-following ceiling (rafter/slope-line insulation, ADR-0066). The two + marker sets are disjoint, so precedence is immaterial; anything neither + recognises returns None and reaches the LLM classifier. + """ + return roof_party_ceiling_guard(description) or roof_sloping_ceiling_guard( + description + )