mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
Merge pull request #1677 from Hestia-Homes/feat/roof-sloping-ceiling
Pitched sloping-ceiling roof-type family (closes #1676)
This commit is contained in:
commit
d5b94996df
11 changed files with 599 additions and 15 deletions
|
|
@ -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: 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**:
|
||||
|
|
|
|||
|
|
@ -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](
|
||||
|
|
|
|||
139
docs/adr/0066-pitched-sloping-ceiling-roof-type-family.md
Normal file
139
docs/adr/0066-pitched-sloping-ceiling-roof-type-family.md
Normal 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 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: <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 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
25
domain/epc/property_overrides/roof_guard.py
Normal file
25
domain/epc/property_overrides/roof_guard.py
Normal 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
|
||||
)
|
||||
75
domain/epc/property_overrides/roof_sloping_ceiling_guard.py
Normal file
75
domain/epc/property_overrides/roof_sloping_ceiling_guard.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
37
tests/domain/epc/test_roof_guard.py
Normal file
37
tests/domain/epc/test_roof_guard.py
Normal 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
|
||||
106
tests/domain/epc/test_roof_sloping_ceiling_guard.py
Normal file
106
tests/domain/epc/test_roof_sloping_ceiling_guard.py
Normal 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
|
||||
46
tests/domain/epc/test_roof_type.py
Normal file
46
tests/domain/epc/test_roof_type.py
Normal 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
|
||||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue