Merge pull request #1677 from Hestia-Homes/feat/roof-sloping-ceiling

Pitched sloping-ceiling roof-type family (closes #1676)
This commit is contained in:
KhalimCK 2026-07-24 14:32:42 +01:00 committed by GitHub
commit d5b94996df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 599 additions and 15 deletions

View file

@ -167,6 +167,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: AD uninsulated, EF 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 AD, 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**:

View file

@ -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](

View file

@ -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 AG) instead of the **column (3)**
default (`_FLAT_ROOF_BY_AGE`: 2.30 AD, 1.50 E, 0.68 F) that a slope-following
ceiling requires — a 35.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 AD, 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: <As Built | Unknown | N mm>` 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 AD 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.

View file

@ -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,8 +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 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(
@ -50,8 +56,22 @@ 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` 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 = _MM_INSULATION.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)
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

View file

@ -0,0 +1,25 @@
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]:
"""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
)

View file

@ -0,0 +1,75 @@
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 ``: <insulation>`` 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"
# 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).
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
_, _, insulation = description.partition(":")
return _member_for_depth(insulation) or RoofType.PITCHED_SLOPING_CEILING_AS_BUILT

View file

@ -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"

View file

@ -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

View file

@ -0,0 +1,106 @@
"""The pitched-sloping-ceiling roof guard (ADR-0066).
`PitchedWithSlopingCeiling: <As Built | Unknown | N mm>` 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
@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
@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

View file

@ -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

View file

@ -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),
@ -160,6 +182,90 @@ 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
# 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
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",
[