diff --git a/.claude/skills/audit-ara-portfolio/SKILL.md b/.claude/skills/audit-ara-portfolio/SKILL.md index 6621a9f33..569a48f86 100644 --- a/.claude/skills/audit-ara-portfolio/SKILL.md +++ b/.claude/skills/audit-ara-portfolio/SKILL.md @@ -16,6 +16,35 @@ Ask for **portfolio_id** and **scenario_id** if the user didn't give them. `scenario_id` is optional — without it, each Property's *default* plan (the one shown in the FE) is audited. +## Query safety (READ FIRST — a bad query can take the shared DB down) + +The `recommendation` table is **~26m rows and has NO index on `plan_id`** (only +`id` and `property_id`). Any query that reaches `recommendation` through +`plan_id` — a `JOIN ... ON r.plan_id = pl.id`, or a correlated +`EXISTS (SELECT 1 FROM recommendation WHERE plan_id = pl.id ...)` — forces a full +seq-scan of all 26m rows. On a large portfolio (e.g. 796/1268 has ~32k default +plans) the planner picks that seq-scan **even when the query also joins +`property_id`**. This is what blocked the DB during the portfolio-796 audit. + +Rules for EVERY query you write or run here (the runner already follows them): + +- **Confirm with the user before running any ad-hoc SQL** that touches + `recommendation`, and show them the `EXPLAIN` plan. Never improvise a + `recommendation` query against the live DB unprompted. +- **`EXPLAIN` first** (no `ANALYZE` — it executes nothing). If the plan contains + `Seq Scan on recommendation`, do NOT run it. Rewrite, or add the index below. +- **Never reach `recommendation` via `plan_id` or a correlated subquery.** Scope + to the portfolio's properties and reach `recommendation` via the indexed + `property_id`, and prefer ONE scoped aggregate over per-property subqueries. +- The runner sets a **`statement_timeout` (120s)** on its connection as a hard + ceiling, and the `recommendation` rollup is **opt-in** (see Phase 1). Keep any + ad-hoc connection equally bounded. +- **The real fix is an index:** `CREATE INDEX CONCURRENTLY + idx_recommendation_plan_id ON recommendation (plan_id)` (non-blocking build). + Until it exists, the solar checks can't run cheaply on a large portfolio — that + is a deliberate trade-off, not a bug. Propose it as a migration if the solar + checks are needed portfolio-wide. + ## Phase 1 — Build the dataset ``` @@ -26,6 +55,16 @@ Writes `modelling_audit.md` (grouped, ranked by severity) and `modelling_audit.csv` (every flagged row: property_id, uprn, severity, check, detail). Read the printed summary and `modelling_audit.md`. +**Recommendation checks are OFF by default.** The two solar checks +(`excessive-solar-sap`, `low-solar-bill-savings`) read the `recommendation` +rollup, which is the only query that scans the 26m-row table. They are gated +behind `--with-recommendations`, which is EXPLAIN-guarded and will **abort** if +the plan seq-scans `recommendation` (raising `RecommendationScanError`). Without +the flag those two checks are inert and every other check is bounded by the +portfolio — this is the safe default. Only pass `--with-recommendations` once +`idx_recommendation_plan_id` exists (or on a small portfolio where EXPLAIN is +clean), and confirm with the user first. + ## Phase 2 — Review the high-level results For each check group, HIGH severity first: note the count, read a few example @@ -53,6 +92,13 @@ group, cluster the flagged properties by a distinguishing trait — EPC source (lodged / predicted), `rebaseline_reason`, property type, the dominant measure, fuel — using SQL against the DB. Report the sub-classes and their sizes. +Traits from `property` / `property_baseline_performance` / `plan` are cheap to +cluster on. **Traits that need `recommendation` (dominant measure, measure mix) +are NOT** — obey the Query-safety rules above: scope to the *flagged* property +ids only (a few hundred, so the `property_id` index is used), `EXPLAIN` first, +and confirm the query with the user. Never cluster the whole portfolio's +measures in one `recommendation` query. + ## Phase 5 — Cross-reference open work Before proposing fixes, check whether one is already in flight: @@ -114,9 +160,27 @@ durable, compounding output of every audit. ## Notes - Read-only on the DB. `run_modelling_e2e` is a dry run. +- **Audit the default plan only** — every check, and every characterisation + query, must filter `plan.is_default = TRUE` (the FE-shown plan). The runner's + `_QUERY`/`_ROLLUP_QUERY` already do; keep ad-hoc SQL consistent or the counts + mix superseded plans into the picture. +- **The stored default plan can be STALE vs the live model** — the persisted plan + is the output of an *earlier* modelling run; `run_modelling_e2e` re-models + against current logic + live EPC/solar. A stored-vs-live gap is the single + biggest driver of MEDIUM/HIGH anomalies on a not-yet-re-modelled portfolio, and + it spans more checks than the one below names. The fix is operational + (**re-model the portfolio, then re-audit**), not a code change — confirm a + sample with `run_modelling_e2e` before debugging the calculator. - **Expected, not bugs** (until the override-aware-rebaseline + persistence-fidelity - PR deploys and the portfolio is re-modelled): much of `zero-works-post-differs` - and `plan-score-below-baseline` is the pre-fix baseline-vs-plan divergence and - should shrink after re-model — note it, don't re-debug it. + work — see `docs/adr/` — is re-modelled into the portfolio): much of + `zero-works-post-differs`, `plan-score-below-baseline`, `already-meets-goal-with-works`, + and the non-fuel-switch slice of `negative-bill-savings` is the same stale + stored-plan-vs-live divergence and should shrink after re-model — note it, + don't re-debug it. Confirm a sample's stored plan differs from `run_modelling_e2e` + (stored `air_source_heat_pump` for tens of £k where the live plan is a cheap + profitable `solar_pv`; stored works on a property the live model leaves at £0 + because it already meets goal). The `negative-bill` certs that DO carry an + `air_source_heat_pump` are a genuine gas→electric fuel-switch (bills rise at + current price ratios) — expected by design. - Adding a check is one decorated `(PropertyAudit) -> Optional[str]` function in `scripts/audit/anomalies.py`; see its module docstring. diff --git a/.claude/skills/expand-sap-accuracy-corpus/SKILL.md b/.claude/skills/expand-sap-accuracy-corpus/SKILL.md index 954264e70..25769ad4e 100644 --- a/.claude/skills/expand-sap-accuracy-corpus/SKILL.md +++ b/.claude/skills/expand-sap-accuracy-corpus/SKILL.md @@ -169,6 +169,34 @@ Pattern: `with E.session() as (ctx,page): E.goto(...); E.set_text/set_select(... lines 17/18). This drove the first campaign mapper fix — see Banked findings. ## Banked findings (fold new ones in here as the corpus grows) +- **Register cost-triangulation (no Elmhurst needed):** the register lodges + `heating_cost_current` / `hot_water_cost_current` / `lighting_cost_current` + at RETAIL prices — absolute £ never match SAP Table-12 spec prices, but for + same-fuel end-uses the RATIOS localize a divergence without a worksheet. + Normalize by lighting: `(lodged_hw/lodged_light) / (our_hw/our_light)` ≈ 1 + means our water cascade agrees with the lodging software; »1 flags the + divergent end-use. CAVEAT: the register's "hot water cost" INCLUDES the + SAP (64a) electric-shower electricity — add `instant_shower` cost to our + hw bucket before comparing on any electric-shower cert, else every + shower-outlet-type-2 cert false-positives at ratio ~2 (corpus-1000 band + triage, 36 certs, mean ratio 2.0 → 1.04 after adding (64a)). +- **Lodging-software conventions diverge from Elmhurst on two known axes** + (mark such corpus certs ⚠, don't tune): (a) out-of-range-PSR database + heat pumps — SAP 10.2 N2 footnotes 44/45 mandate the reciprocal extension + toward 100% at 2× the record's largest PSR (Elmhurst does this, golden + case 56); at least one software bills 100% direct electric + standard + schedule instead (corpus 4510053280, lodged 51 vs engine/Elmhurst-method + 75). (b) a lodged 0 extract fans — Elmhurst renders 0 as "unknown" → + Table 5 age-band default (worksheet case 46); LIG-21.0 lodgements + (calculation_software_version 10.2.2.0) honour the 0 literally (corpus + quadruplet at Δ −2.56, fans account for 1.42). +- **"insulated (assumed)" roof ≠ observed retrofit:** RdSAP renders + "(assumed)" when insulation PRESENCE is an age-band assumption — Table 18 + age default governs, for BOTH the "ND" and "NI" thickness sentinels + (which are lodging-software noise). The §5.11.4 50 mm row (U 0.68) is + only for the bare "insulated" description (observed retrofit, depth + unknown). Fixed in u_roof (corpus 74.2% → 75.5%); unit-pinned in + test_rdsap_uvalues. - **MAPPER GAP — cylinder insulation thickness dropped (RdSAP-17.0+):** the mapper carries `cylinder_size` + `cylinder_insulation_type` but NOT `cylinder_insulation_thickness` → `EpcPropertyData.sap_heating.cylinder_ diff --git a/.claude/skills/find-weird-recommendations/SKILL.md b/.claude/skills/find-weird-recommendations/SKILL.md new file mode 100644 index 000000000..b51723957 --- /dev/null +++ b/.claude/skills/find-weird-recommendations/SKILL.md @@ -0,0 +1,281 @@ +--- +name: find-weird-recommendations +description: Hunt for dubious *recommendations* in a modelled portfolio — measures that were wrongly withheld (SAP could have moved to the goal band but didn't), measures that contradict the dwelling (e.g. HHRSH on community heating), and neighbours of the same type/postcode that were modelled differently. Deterministic scan → neighbour scan → deep-dive with the live re-model → root-cause → codify. Use when the tech team flags "weird" or "dubious" recommendations on a portfolio and wants to understand why the engine did it. Asks for portfolio id and scenario id. +--- + +# Find weird recommendations + +Sibling to `audit-ara-portfolio`. That skill audits **baselines, plans and SAP +scores**; this one audits the **recommendations themselves** — *why a measure was +or wasn't offered*. It exists because the tech team keeps finding individual +dodgy recommendations (portfolio 814: Khalim's HHRSH-on-community-heating, +missing-HHRSH, missing-secondary-heating-removal, and a neighbour split) and we +want a repeatable way to turn "this one looks wrong" into a root cause and a +durable check that makes the engine better. + +The engine's job on an unlimited-budget scenario is to move each dwelling to the +goal band (usually C) with a sensible, physically-valid set of measures. +"Weird" = the engine did something a competent surveyor wouldn't: **left SAP on +the table**, **recommended a measure the dwelling can't take**, or **treated two +identical neighbours differently**. Each has a deterministic tell; this skill +runs the tells, then uses the live re-model to explain each hit. + +## Input + +Ask for **portfolio_id** and **scenario_id** if the user didn't give them. The +scenario matters here: its `goal_value` is the band we expect the plan to reach, +and its `budget` decides whether a shortfall is a bug or just "the money ran +out". Without a scenario_id the deterministic scan audits each Property's +*default* plan (the FE-shown one). + +## Query safety (READ FIRST — inherited from `audit-ara-portfolio`) + +The `recommendation` table is **~26m rows with NO index on `plan_id`**. Any query +reaching it via `plan_id` (a `JOIN ... ON r.plan_id = pl.id`, or a correlated +`EXISTS`) forces a full seq-scan and can take the shared DB down — this is what +blocked the portfolio-796 audit. The full rules are in the `audit-ara-portfolio` +skill; the ones that bite *here*: + +- **Confirm any ad-hoc `recommendation` SQL with the user, and show the + `EXPLAIN` plan first** (no `ANALYZE`). If it contains `Seq Scan on + recommendation`, do NOT run it. +- **Reach `recommendation` only via the indexed `property_id`, scoped to a + handful of flagged property ids** — never portfolio-wide, never via `plan_id`. +- The two scripts below are safe by construction: `anomalies.py`'s recommendation + rollup is opt-in + EXPLAIN-gated, and `neighbour_divergence.py` never touches + `recommendation` at all. The measure-set comparison that Khalim's neighbour + case needs is done in the **deep-dive** (Phase 4) with the live re-model, not a + portfolio-wide `recommendation` query. + +## Phase 1 — Deterministic recommendation scan + +``` +python -m scripts.audit.anomalies --portfolio --scenario +``` + +Writes `modelling_audit.md` / `.csv`. The check that carries this skill: + +- **`plan-stops-short-of-goal`** (HIGH) — the default plan ends *below* the goal + band on an **unlimited-budget** scenario. This is the deterministic worklist + for the "why didn't this get recommended X" class (Khalim's pid 742121). With + no budget cap, the only reasons to fall short are (a) a measure was wrongly + withheld — a bug — or (b) the dwelling genuinely can't reach the band. Phase 4 + decides which. Budget-capped scenarios are excluded (falling short is expected). + **Caveat: this check is noisy on flat-heavy portfolios** — it fired 322/338 on + portfolio 814 (every flat a band short of B). Use `plan-stuck-in-low-band` to + cut straight to the real hits. +- **`plan-stuck-in-low-band`** (HIGH) — the plan leaves the dwelling **still + rated E/F/G with the band unchanged** from baseline (unlimited budget). This is + the sharp "E→E, no big movement" worklist — the genuinely-trapped tail, not + every dwelling a band short of goal. On portfolio 814 it flagged just **4** + (pids 742121, 742210, 742265, 742347) vs `plan-stops-short-of-goal`'s 322. A + wrongly-withheld measure or a physically-trapped dwelling both surface here; + Phase 4 tells them apart. Band-keyed (no SAP-points threshold), and the + strictly-worse case is left to `plan-below-baseline-band` so the two partition. + +Also read `plan-below-baseline-band`, `already-meets-goal-with-works`, and — if +you pass `--with-recommendations` (EXPLAIN-gated; confirm with the user first) — +`excessive-solar-sap` / `low-solar-bill-savings`. These bound the "the plan did +something odd" surface. + +## Phase 2 — Neighbour-divergence scan + +``` +python -m scripts.audit.neighbour_divergence --portfolio +``` + +Writes `neighbour_divergence.md` / `.csv`. Groups the portfolio into cohorts of +(postcode, property_type, built_form) and flags any member whose **effective +baseline SAP** sits `--min-gap` (default 12) points from its cohort median. +Near-identical neighbours should model alike; a SAP split is what drives a +recommendation split (Khalim's neighbour case). This is the SAP half of the +heuristic — cheap and portfolio-wide because it never touches `recommendation`. +The **measure-set** half is Phase 4: re-model both neighbours and diff their +plans. + +Floor area is reported, not keyed on — discount a genuinely bigger/smaller +neighbour before trusting a hit. + +## Phase 3 — Triage the hits + +For each group, HIGH first: note the count, read a few rows, and give a one-line +**root-cause hypothesis** + a verdict (real bug / expected / threshold to tune). +Cross-check the two scans against each other — a `plan-stops-short-of-goal` +property that is *also* a divergent neighbour is a high-value case (the two +signals agree). + +## Phase 4 — Deep-dive with the live re-model (the core of this skill) + +Reproduce end-to-end, NO DB writes (never pass `--persist`): + +``` +python -m scripts.run_modelling_e2e [ ...] --scenario-id +``` + +This re-fetches the live EPC + solar and runs the full DDD Modelling stage. It +writes three files — read all three: + +- `modelling_e2e.md` — the chosen plan, measure by measure, with post-SAP. +- `modelling_e2e_candidates.csv` — **every candidate Option the generators + produced, including the ones the Optimiser didn't pick.** This is how you tell + a *withheld* measure from a *rejected* one: + - Measure **absent from candidates** → a **generator eligibility gate** + excluded it. This is the bug surface for "why no HHRSH / no + secondary_heating_removal". Open the generator and read its `_*_eligible` + predicate against the live EPC. + - Measure **present in candidates but not chosen** → the Optimiser judged it + not worth it (cost/SAP/goal already met). Usually correct; confirm the goal + was reached without it. +- `modelling_e2e.csv` — the row-level plan. + +For a neighbour pair, run **both** in one invocation and diff their candidate +sets and chosen plans — that is the measure-set comparison Khalim asked for, +done safely (no `recommendation` table). + +### Known recommendation-bug patterns (seed catalogue — grew from portfolio 814) + +Match each deep-dive against these before hypothesising a novel cause: + +- **HHRSH offered on a community-heated dwelling** (pids 742174, 742175). + **Confirmed bug.** The DDD generator's `_hhr_storage_eligible` + ([`domain/modelling/generators/heating_recommendation.py`](../../../domain/modelling/generators/heating_recommendation.py)) + returns eligible when the dwelling is `off_gas` — but a community-heated + dwelling is off-gas, and the legacy engine's + `is_high_heat_retention_valid` ([`recommendations/HeatingRecommender.py`](../../../recommendations/HeatingRecommender.py)) + gated on `main_fuel["is_community"]` and refused it. The community-heating + guard was **lost in the legacy→DDD translation**. Confirm by checking the EPC's + main fuel / heat-network flag, then fix by restoring the gate in + `_hhr_storage_eligible` (community heating ⇒ not HHR-eligible). A community + network is a shared asset a single dwelling can't rip out for storage heaters. +- **Measure withheld → plan stops short of goal** (the "why no HHRSH" class). + The dwelling can't reach the goal band, and the expected bundle is *absent from + candidates*. Read the generator's eligibility predicate against the live EPC — + an over-tight gate (a fuel/description/category condition that shouldn't + exclude this dwelling) is the usual cause. +- **Measure OFFERED but not selected because it *lowers* SAP** (pid 742121, "why + no HHRSH" — re-modelled 2026-07, NOT the stale mains-gas story). This dwelling + is an **electric** maisonette (`main_fuel_type=29`), so HHRSH **is** in the + candidate set — eligibility passes. But scoring the HHRSH overlay in isolation + gives **−6.3 SAP** (54.3 → 48.0): swapping its efficient existing electric + system for off-peak storage heaters *worsens* the cost-based SAP, so the + Optimiser correctly drops it. Its fabric is already done (double-glazed, walls + insulated, no cylinder, no own roof), so the plan is **empty** and it sits at E, + unreachable to B — a legitimate £0 result, not a bug. **Lesson: a measure + *absent from candidates* is a generator-gate bug; a measure *present but not + chosen* is the Optimiser judging it not worth it — score the overlay + (`PackageScorer.score(epc, [EpcSimulation(...)])`) before blaming a gate.** The + old note here claimed 742121 was a mains-gas maisonette that "correctly gets + only lighting" — that was stale; verify fuel against the live EPC every time. +- **Community-heated flat stuck low, correctly** (pid 742265, "might need loft + insulation?"). A **community-heated** flat (SAP main-heating code 301, category + 6) on portfolio 814. HHRSH and ASHP are both gated out by `is_heat_network_main` + (the topology guard) — right, per the community bug above. Its roof is + `"Pitched (vaulted ceiling)"` with insulation lodged as **`ND` (no data)**, so + the loft generator withholds insulation: it only fires on a definite `0 mm` + (`roof_insulation_thickness != _ROOF_UNINSULATED_MM` → None), and a vaulted + ceiling isn't the loft-fallback's target anyway. Its only real lever is IWI + (+6.7), not enough to clear E — so it stays E→E, legitimately. **Watch item:** + a vaulted-ceiling roof with `ND` insulation gets *no* roof measure at all + (neither the sloping-ceiling branch, which matches the literal `"sloping + ceiling"`, nor the loft branch, which needs `0 mm`). If several such roofs turn + out to be genuinely uninsulated upstream, that's a mapper/gate gap — but `ND` + is unknown, not zero, so withholding is defensible for now. +- **Missing `secondary_heating_removal`** (pid 742264, "gov site shows secondary + heating"). Check whether the live EPC carries a secondary-heating system and + whether + [`domain/modelling/generators/secondary_heating_recommendation.py`](../../../domain/modelling/generators/secondary_heating_recommendation.py) + detects it. A gap between the gov register's secondary-heating field and what + our EpcPropertyData mapper carries is a common upstream cause — verify the + mapped value, not just the register. +- **Neighbour split** — two same-cohort dwellings, one gets a bundle the other + doesn't. Almost always traces to a **baseline-input divergence** (a different + mapped fuel / heating code / floor area / an override on one and not the + other), which then flips a generator's eligibility gate. Diff the two EPCs' + mapped inputs first; the recommendation split is downstream of that. + +## Phase 5 — Root-cause and cross-reference open work + +Isolate **where** it goes wrong: a generator eligibility gate, the EPC→ +EpcPropertyData mapping, an override, or the Optimiser. Then check nothing is +already in flight: + +``` +gh pr list --repo Hestia-Homes/Model --state open +gh pr list --repo Hestia-Homes/Model --state all --search "" +``` + +Map each confirmed cause to an existing PR/ADR or flag it as new work. State, +per case, the smallest correct fix (usually: tighten/loosen one generator +predicate, or fix one mapper field) and which real property ids reproduce it. + +## Phase 6 — Self-improve (the compounding loop) + +When the review confirms a **novel, systematic** recommendation problem, codify +it so every future run catches it — same gates as `audit-ara-portfolio` Phase 6: + +1. **Systematic** — reproduced on **≥ 5** properties and root-caused, not a + one-off (a single weird property is a ticket, not a check). +2. **Not already covered** — no existing check fires on it; no open/merged PR or + ADR already addresses the cause. +3. **Pressure-tested** — for any threshold/heuristic, run `/grill-me` on the + proposed check: false-positive rate on this portfolio? threshold defensible + against the real distribution? overlaps an existing check? + +**What to change, smallest first:** +- **A per-property tell** → a decorated `(PropertyAudit) -> Optional[str]` check + in `scripts/audit/anomalies.py` (docstring records the motivating pids + the + one-line cause, like `plan-stops-short-of-goal` does). Extend the + `PropertyAudit` bundle + `_QUERY` if it needs a field — keep every query bounded + by the portfolio and off the `recommendation` table's `plan_id`. +- **A cross-property tell** (a neighbour/cohort pattern) → a detector in + `scripts/audit/neighbour_divergence.py`. The obvious next one: promote the + measure-set diff into a scripted check that reaches `recommendation` via + property-id-scoped, EXPLAIN-gated queries per cohort (kept out of v1 to stay + portfolio-wide-safe — do it once the SAP tell's false-positive rate is known). +- **This catalogue** → add any newly-confirmed bug pattern to Phase 4's list with + its pids and root cause, so the next reviewer starts ahead. + +Commit each codified check on its own referencing the motivating run, then re-run +Phases 1–2 to confirm it fires on the motivating cases and nothing surprising +else. The check registry — with provenance — is the durable output of every hunt. + +## Notes + +- Read-only on the DB. `run_modelling_e2e` is a dry run — never `--persist`. +- **Two engines exist.** Portfolios are modelled by the **DDD** engine + (`domain/modelling/generators/…`, what `run_modelling_e2e` runs); the legacy + `recommendations/…` engine is the historical reference. When a gate looks + wrong, compare the DDD predicate against its legacy counterpart — several + portfolio-814 bugs are gates the DDD translation **dropped** (community heating + is the confirmed one). The legacy code is the spec of intent, not dead code. +- **Stored plan can be STALE vs live.** The persisted default plan is an earlier + run's output; `run_modelling_e2e` re-models against current logic + live EPC. + A stored-vs-live gap is a big driver of Phase 1 hits and is fixed by + re-modelling, not by debugging the calculator — confirm a sample with the + re-model before blaming a generator (see `audit-ara-portfolio` Notes). +- A `plan-stops-short-of-goal` hit is **not automatically a bug** — the user's + own framing: "surely there's a reason, but if a sensible reason we should + understand why". The deliverable for a physically-unreachable dwelling is the + *explanation* (which measures were tried, why the band is out of reach), not a + code change. +- **On flat/maisonette-heavy portfolios, `plan-stops-short-of-goal` fires in + bulk — mostly by design, not bugs.** Characterise the cohort by property_type + and post-band before deep-diving (cheap: `property` + `epc_property` + `plan`, + no `recommendation` table). Portfolio 814 (94% flats/maisonettes, goal band B) + landed 311/338 short of B, but **every** D-or-worse lander and every £0-works + shortfall was a flat/maisonette — all 19 houses reached C+. The structural + cause: the heating-decarbonisation levers are gated away from flats — + `_ashp_eligible` offers ASHP **only to houses/bungalows** (`_is_house_or_bungalow`), + the gas-boiler upgrade needs an existing **wet** boiler (point-of-use gas water + heaters like WHC 908 don't qualify — see pid 742121, a mains-gas maisonette at + E that correctly gets only lighting), and HHRSH needs electric/off-gas. So a + mains-gas or community-heated flat with insulated walls + DG has almost no lever + and lands at D/E legitimately. Two takeaways: (1) triage the *houses* short of + goal first — those are the real bugs; (2) the flat cohort is a **product** + question (should ASHP/communal-heat measures be offered to flats?), not a + per-property modelling bug — surface it as such. +- **A measure can be SELECTED while *lowering* SAP** if it's a forced companion + (pid 742175: `mechanical_ventilation` at **−4.1 SAP**, bundled after + `cavity_wall_insulation`). Net plan SAP still rose, but the companion drags it + and worsens the bill — worth a look when a plan's post-SAP barely moves or dips + (overlaps `plan-score-below-baseline`). Not yet root-caused; note the pids. diff --git a/CONTEXT.md b/CONTEXT.md index fe6493ee4..81b2716c3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -101,6 +101,18 @@ _Avoid_: energy assessment, site survey, field survey, Domna survey, Hestia surv Property data supplied by a landlord that may correct or supplement the public EPC for a single Property; triggers Rebaselining when applied; not applicable when Site Notes are present. _Avoid_: patches (deprecated), corrections, manual EPC, edits +**Landlord-Description Classification**: +Resolving a **Landlord Description** (unbounded free-text a landlord supplies for one component — "CWI" / "Cav filled" / "cavity insulated" all name one thing) onto a **Recognised Internal Description** via an LLM classifier, persisted in the `landlord_*_overrides` table (`source=classifier`) as a reviewed cache. Four vocabularies are kept **distinct** and must not be conflated: a **Landlord Description** (unbounded input); a **Recognised Internal Description** (the closed target taxonomy — e.g. a `MainHeatingSystemType` archetype — each binding to a Simulation Overlay); a **Lodged Description** (the gov-EPC `main_heating[].description` rendering, e.g. "Room heaters, electric" — only an example of which system *types* occur, never a map key); and the **SAP main heating code** (Table 4a/4b, what the calculator consumes). The classifier maps Landlord → Recognised Internal → SAP code. When it cannot confidently place the text it emits **`None`** (no overlay → the lodged EPC stands, surfaced to the user as "no suitable match"), **never the nearest wrong archetype** — the target taxonomy must be complete enough that a real system always has a correct home, so the classifier never overflows into a garbage-drawer archetype (ADR-0041). + +**Heating Companion Set**: +The coherent SAP inputs a **main heating system** override drags alongside the **SAP main heating code** so the **Effective EPC** reads as one internally-consistent system, not a hybrid of the new code and the *replaced* system's leftovers — heating **category** (SAP Table 4a group: gas→2, heat pump→4, network→6, storage→7, electric underfloor→8, warm air→9, room heater→10), **charge/heating control** (Table 4e group), **natural fuel**, and **meter** (see **Off-Peak Meter**). Companions divide by how firmly the archetype fixes them (ADR-0048): **category and control are archetype-forced** — always written, never inherited, because a wrong value silently mis-scores or mis-bills (a leftover storage **category 7** makes the Table 12a resolver bill peaky room heaters at the all-night rate; a leftover storage **control 2401** adds a +0.7 °C Table-4e penalty a room heater should not carry). **Fuel and meter defer** only where multiple values are genuinely coherent — fuel for an ambiguous carrier (community heating: gas CHP / biomass / waste heat), meter for a meter-flexible system (room heaters run on either; screed underfloor per ADR-0046) — while a meter-*locked* system still forces (storage/CPSU/HHRSH→Dual, non-electric→Single, ADR-0035). Because the overlay composes last-wins, an **unset (`None`)** forced companion is *inherited from the replaced system*, not cleared — so an archetype the overlay cannot yet fully companion (heat pump / community / underfloor / electric boiler control defaults are unmapped) is **logged as an error and continues** (log-not-raise, matching `flag_fuel_mismatch`), making the gap visible for review rather than shipping a silently-incoherent cert; the intended end-state is to fill each and flip the log to a raise. +_Avoid_: coherent companions (informal), dragged fields, heating defaults +_Avoid_: "the LLM mapper is unreliable" (the failure mode is a too-small target taxonomy, not LLM language ability); conflating the landlord input vocabulary with the gov-EPC lodged rendering or the RdSAP entry-tool catalogue; treating a deterministic dict as a *replacement* for the LLM rather than a reviewed cache of its output + +**Lodgement Sentinels (ND / NI / AB)**: +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) + ### Modelling **Effective EPC**: @@ -116,11 +128,11 @@ Deterministically translating an **old / reduced-data EPC schema** into the curr _Avoid_: gap-fill (means the neighbour-ML path), reduced-data expansion (overloaded with the calculator's Table-5 step), remapping (the schema-translation part only) **Baseline Performance**: -A Property's current performance aggregate, holding both Lodged Performance and Effective Performance plus the energy block: delivered kWh **per end use** (heating, hot water, lighting, appliances, cooking, pumps/fans, cooling) and the **annual bill** composed into per-section costs plus a total, produced by **Bill Derivation** from SAP10 Calculation's per-end-use kWh × current Fuel Rates. Persisted as one row (flat typed columns, per-section kWh + cost + total); surfaced as one block in the UI. +A Property's current performance aggregate, holding both Lodged Performance and Effective Performance plus the energy block: delivered kWh **per end use** (heating, hot water, lighting, appliances, cooking, pumps/fans, cooling) and the **annual bill** composed into per-section costs plus a total, produced by **Bill Derivation** from SAP10 Calculation's per-end-use kWh × current Fuel Rates. Persisted as one row (flat typed columns, per-section kWh + cost + total); surfaced as one block in the UI. The **Lodged half is optional**: a predicted Property has no lodged record (see Lodged Performance), so its `lodged_*` are `NULL` and only the Effective half + energy block are populated (ADR-0004 amendment, #1361). _Avoid_: baseline predictions, predicted baseline, rebaselined values **Lodged Performance**: -The SAP / EPC Band / carbon emissions / Primary Energy Intensity recorded on the public EPC (or the Site Notes' as-surveyed values when Site Notes are the source) — unmodified by modelling. The half of Baseline Performance that says "what the government register says about this Property". +The SAP / EPC Band / carbon emissions / Primary Energy Intensity recorded on the public EPC (or the Site Notes' as-surveyed values when Site Notes are the source) — unmodified by modelling. The half of Baseline Performance that says "what the government register says about this Property". **Requires a record _of this Property_** — a lodged cert or a Site Notes survey — so it is **absent (`None`/NULL) for a predicted Property**: **EPC Prediction** synthesises the picture from neighbours, copying a comparable's recorded figures, so there is no government-register record of _this_ Property to lodge. Only the Effective half is persisted then (ADR-0004 amendment, #1361). Reading a predicted EPC's recorded fields as Lodged Performance manufactures a phantom — a neighbour's SAP presented as this Property's lodged figure. _Avoid_: original performance, raw EPC values, recorded baseline **Effective Performance**: @@ -305,7 +317,11 @@ The two competing **Measure Options** for insulating a *solid* (non-cavity) main _Avoid_: solid wall insulation (that names the pair, not one Option), cladding, drylining **Wall Insulation Eligibility**: -The rule fixing which wall Option(s) the main-wall **Recommendation** offers, by wall construction then planning status. By construction: **cavity** → cavity fill only (never solid insulation); **solid brick** / **system-built** → IWI + EWI; **timber-frame** → IWI only (EWI is not constructable); **cob** / **granite or whinstone** / **sandstone or limestone** → none (breathable fabric — standard insulation risks trapping moisture). Then, as planning gates: a **conservation area** or **flat** removes EWI (external-appearance / whole-block constraints), and a **listed** or **heritage** building removes **both** EWI and IWI (protected fabric). +The rule fixing which wall Option(s) the main-wall **Recommendation** offers, by wall construction then planning status. By construction: **cavity** → cavity fill only (never solid insulation); **solid brick** / **system-built** → IWI + EWI; **timber-frame** → IWI only (EWI is not constructable); **cob** / **granite or whinstone** / **sandstone or limestone** → none (breathable fabric — standard insulation risks trapping moisture). Then, as planning gates: a **conservation area** or **flat** removes EWI (external-appearance / whole-block constraints), and a **listed** or **heritage** building removes **both** EWI and IWI (protected fabric). Finally the **Wall U-Value Gate** applies. + +**Wall U-Value Gate**: +The eligibility rule that a wall insulation Option is offered **only when it would lower the wall's derived U-value** (by more than a 0.01 W/m²K float-noise guard) — comparing `u_wall` on the effective wall's current state vs its post-measure state (ADR-0051). Kills the **no-op fabric offer**: on a modern construction age band (e.g. band L, 2012–2022) RdSAP's cascade assigns the as-built wall its at-regs U-value and added insulation resolves to the *same* value, so the measure costs thousands for +0.00 SAP. The gate is physical possibility only — how *much* improvement is worth the cost stays with the Optimiser (ADR-0016/0024). +_Avoid_: no-op filter (it gates generation, not scored output), U-value threshold (there is no absolute target-U; the comparison is before-vs-after) _Avoid_: restricted measures (legacy collapsed conservation/listed/heritage into one boolean — they now gate different Options, so keep them distinct) **Roof Insulation Eligibility**: diff --git a/applications/audit_generator/Master Sero Template - Data Extraction.xlsx b/applications/audit_generator/Master Sero Template - Data Extraction.xlsx index 15bc8d3ab..31ab7f6b6 100644 Binary files a/applications/audit_generator/Master Sero Template - Data Extraction.xlsx and b/applications/audit_generator/Master Sero Template - Data Extraction.xlsx differ diff --git a/applications/audit_generator/local_handler/docker-compose.yml b/applications/audit_generator/local_handler/docker-compose.yml new file mode 100644 index 000000000..cc3f89fc9 --- /dev/null +++ b/applications/audit_generator/local_handler/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3.9" + +services: + audit-generator-lambda: + build: + context: ../../../ + dockerfile: applications/audit_generator/handler/Dockerfile + ports: + - "9001:8080" + env_file: + - ../../../.env diff --git a/applications/audit_generator/local_handler/invoke_local_lambda.py b/applications/audit_generator/local_handler/invoke_local_lambda.py new file mode 100644 index 000000000..14d1339a3 --- /dev/null +++ b/applications/audit_generator/local_handler/invoke_local_lambda.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +import json +import requests + +HOST = "localhost" +PORT = "9001" + +LAMBDA_URL = f"http://{HOST}:{PORT}/2015-03-31/functions/function/invocations" + +payload = { + "Records": [ + { + "messageId": "test-message-id", + "body": json.dumps( + { + "task_id": "00000000-0000-0000-0000-000000000001", + "sub_task_id": "00000000-0000-0000-0000-000000000002", + "hubspot_deal_id": "500311510234", + } + ), + } + ] +} + +response = requests.post(LAMBDA_URL, json=payload) + +print("Status code:", response.status_code) +print("Response:") +print(response.text) diff --git a/applications/audit_generator/local_handler/invoke_local_orchestrator.py b/applications/audit_generator/local_handler/invoke_local_orchestrator.py new file mode 100644 index 000000000..edc7bb8f4 --- /dev/null +++ b/applications/audit_generator/local_handler/invoke_local_orchestrator.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Run AuditGeneratorOrchestrator directly, bypassing @subtask_handler. + +Loads credentials from the repo-root .env file so no DB task/subtask rows +are needed. +""" + +import os +import sys +from pathlib import Path +from typing import Any + +import boto3 +from dotenv import load_dotenv + +REPO_ROOT = Path(__file__).resolve().parents[3] +load_dotenv(REPO_ROOT / ".env") + +sys.path.insert(0, str(REPO_ROOT)) + +from utilities.logger import setup_logger + +setup_logger() + +from infrastructure.postgres.config import PostgresConfig +from infrastructure.postgres.engine import make_engine, make_session +from infrastructure.s3.s3_client import S3Client +from orchestration.audit_generator_orchestrator import AuditGeneratorOrchestrator +from orchestration.audit_generator_unit_of_work import AuditGeneratorUnitOfWork + +HUBSPOT_DEAL_ID = "505712829658" + +boto3_client: Any = boto3.client # type: ignore[attr-defined] +s3_client = S3Client( + boto_s3_client=boto3_client("s3"), + # bucket=os.environ["S3_BUCKET_NAME"], + bucket="retrofit-energy-assessments-dev", +) + +engine = make_engine(PostgresConfig.from_env(os.environ)) + + +def session_factory() -> Any: + return make_session(engine) + + +def uow_factory() -> AuditGeneratorUnitOfWork: + return AuditGeneratorUnitOfWork(session_factory) + + +print(f"Running AuditGeneratorOrchestrator for deal: {HUBSPOT_DEAL_ID!r}") +AuditGeneratorOrchestrator( + hubspot_deal_id=HUBSPOT_DEAL_ID, + s3_client=s3_client, + uow_factory=uow_factory, +).run() +print("Done") diff --git a/applications/landlord_description_overrides/handler.py b/applications/landlord_description_overrides/handler.py index 4020bc512..151fb7299 100644 --- a/applications/landlord_description_overrides/handler.py +++ b/applications/landlord_description_overrides/handler.py @@ -10,11 +10,22 @@ from applications.landlord_description_overrides.landlord_description_overrides_ from domain.epc.property_overrides.built_form_type import BuiltFormType from domain.epc.property_overrides.construction_age_band import ConstructionAgeBand from domain.epc.property_overrides.glazing_type import GlazingType +from domain.epc.property_overrides.glazing_mix_guard import glazing_mix_guard from domain.epc.property_overrides.main_fuel_type import MainFuelType +from domain.epc.property_overrides.main_fuel_guard import main_fuel_guard from domain.epc.property_overrides.main_heating_system_type import MainHeatingSystemType +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.data_transformation.guarded_column_classifier import ( + GuardedColumnClassifier, +) from domain.epc.property_overrides.water_heating_type import WaterHeatingType +from domain.epc.property_overrides.water_heating_guard import water_heating_guard from domain.epc.property_overrides.wall_type import WallType from domain.epc.property_overrides.wall_type_construction_dates import ( wall_type_construction_date_prompt_hint, @@ -82,8 +93,15 @@ def _build_columns( "property_type": lambda src: ClassifiableColumn( name="property_type", source_column=src, - classifier=ChatGptColumnClassifier( - chat_gpt, PropertyType, PropertyType.UNKNOWN + # The dwelling type is the leading token of the ": " + # split; the deterministic guard claims it so the built-form tail can't + # flip the type (the LLM read "Bungalow: EndTerrace" as House), and the + # LLM handles varied phrasings (#1376). + classifier=GuardedColumnClassifier( + guard=property_type_guard, + fallback=ChatGptColumnClassifier( + chat_gpt, PropertyType, PropertyType.UNKNOWN + ), ), repo=LandlordOverridesRepository[PropertyType]( session, LandlordPropertyTypeOverrideRow @@ -115,8 +133,13 @@ def _build_columns( "roof_type": lambda src: ClassifiableColumn( name="roof_type", source_column=src, - classifier=ChatGptColumnClassifier( - chat_gpt, RoofType, RoofType.UNKNOWN + # 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). + classifier=GuardedColumnClassifier( + guard=roof_party_ceiling_guard, + fallback=ChatGptColumnClassifier(chat_gpt, RoofType, RoofType.UNKNOWN), ), repo=LandlordOverridesRepository[RoofType]( session, LandlordRoofTypeOverrideRow @@ -125,8 +148,14 @@ def _build_columns( "main_fuel": lambda src: ClassifiableColumn( name="main_fuel", source_column=src, - classifier=ChatGptColumnClassifier( - chat_gpt, MainFuelType, MainFuelType.UNKNOWN + # An individual wood-logs fuel had no LLM target and was funnelled into + # "biomass (community)"; the deterministic guard resolves it and the LLM + # handles the rest (#1376). + classifier=GuardedColumnClassifier( + guard=main_fuel_guard, + fallback=ChatGptColumnClassifier( + chat_gpt, MainFuelType, MainFuelType.UNKNOWN + ), ), repo=LandlordOverridesRepository[MainFuelType]( session, LandlordMainFuelOverrideRow @@ -135,8 +164,15 @@ def _build_columns( "glazing": lambda src: ClassifiableColumn( name="glazing", source_column=src, - classifier=ChatGptColumnClassifier( - chat_gpt, GlazingType, GlazingType.UNKNOWN + # An aggregate glazing mix ("40% double, 60% single") can't be applied + # per-window, so the deterministic guard resolves the structured split + # to MIXED (no overlay → keep the cert's per-window glazing) and the LLM + # handles uniform / varied phrasings (#1376, ADR-0042). + classifier=GuardedColumnClassifier( + guard=glazing_mix_guard, + fallback=ChatGptColumnClassifier( + chat_gpt, GlazingType, GlazingType.UNKNOWN + ), ), repo=LandlordOverridesRepository[GlazingType]( session, LandlordGlazingOverrideRow @@ -155,8 +191,16 @@ def _build_columns( "water_heating": lambda src: ClassifiableColumn( name="water_heating", source_column=src, - classifier=ChatGptColumnClassifier( - chat_gpt, WaterHeatingType, WaterHeatingType.UNKNOWN + # A biomass / wood / dual-fuel / biodiesel DHW description has no + # dedicated LLM target and was funnelled into "house coal"; the + # deterministic guard resolves the structured fuels (and the "electric + # immersion assumed" no-system case) and the LLM handles the rest + # (#1376, ADR-0043). + classifier=GuardedColumnClassifier( + guard=water_heating_guard, + fallback=ChatGptColumnClassifier( + chat_gpt, WaterHeatingType, WaterHeatingType.UNKNOWN + ), ), repo=LandlordOverridesRepository[WaterHeatingType]( session, LandlordWaterHeatingOverrideRow @@ -165,8 +209,15 @@ def _build_columns( "main_heating_system": lambda src: ClassifiableColumn( name="main_heating_system", source_column=src, - classifier=ChatGptColumnClassifier( - chat_gpt, MainHeatingSystemType, MainHeatingSystemType.UNKNOWN + # High heat retention storage heaters have no LLM target distinct from + # old storage, so they were funnelled into "Electric storage heaters, + # old" (401); the deterministic guard resolves HHRSH to its own + # archetype (SAP 409) and the LLM handles the rest (#1376, ADR-0044). + classifier=GuardedColumnClassifier( + guard=main_heating_guard, + fallback=ChatGptColumnClassifier( + chat_gpt, MainHeatingSystemType, MainHeatingSystemType.UNKNOWN + ), ), repo=LandlordOverridesRepository[MainHeatingSystemType]( session, LandlordMainHeatingSystemOverrideRow diff --git a/applications/modelling_e2e/handler.py b/applications/modelling_e2e/handler.py index 916c2c247..bf9ca8b06 100644 --- a/applications/modelling_e2e/handler.py +++ b/applications/modelling_e2e/handler.py @@ -108,7 +108,10 @@ from repositories.product.composite_product_repository import ( from repositories.property.in_memory_property_overrides_reader import ( InMemoryPropertyOverridesReader, ) -from repositories.property.landlord_override_overlays import overlays_from +from repositories.property.landlord_override_overlays import ( + flag_fuel_mismatch, + overlays_from, +) from repositories.property.override_backed_prediction_attributes_reader import ( OverrideBackedPredictionAttributesReader, ) @@ -509,12 +512,11 @@ def handler( # held-open read Session. (The ``finally`` is the safety net.) scenario = ScenarioPostgresRepository(read_session).get_many([scenario_id])[0] products = catalogue_snapshot_with_off_catalogue_overrides(read_session) + # Stored Solar is read whatever refetch_solar says — the flag decides + # whether a UPRN with no stored row gets a live Google fetch, never + # whether solar is modelled at all. stored_solar: dict[int, Optional[dict[str, Any]]] = ( - {} - if not refetch_solar - else SolarPostgresRepository(read_session).get_many( - list(set(uprns.values())) - ) + SolarPostgresRepository(read_session).get_many(list(set(uprns.values()))) ) epc_repo = EpcPostgresRepository(read_session) stored_lodged_epcs: dict[int, EpcPropertyData] = ( @@ -561,7 +563,9 @@ def handler( epc = ( None # no stored lodged EPC; prediction path handles this property ) - overrides = overlays_from(overrides_reader.overrides_for(pid)) + resolved_overrides = overrides_reader.overrides_for(pid) + flag_fuel_mismatch(resolved_overrides) + overrides = overlays_from(resolved_overrides) predicted_epc: Optional[EpcPropertyData] = None predicted_epc_is_new = False @@ -610,15 +614,16 @@ def handler( landlord_overrides=overrides, ).effective_epc - solar_insights: Optional[dict[str, Any]] + # refetch_solar gates ONLY the paid Google call for UPRNs with no + # stored row; stored insights always feed the Modelling stage. + # (False here once meant "no solar at all" — the pre-rename + # `no_solar` semantics — which silently stripped solar from every + # plan in a re-model batch.) + solar_insights: Optional[dict[str, Any]] = stored_solar.get(uprn) solar_was_fetched = False - if not refetch_solar: - solar_insights = None - else: - solar_insights = stored_solar.get(uprn) - if solar_insights is None: - solar_insights = _solar_insights_for(solar_client, spatial) - solar_was_fetched = solar_insights is not None + if solar_insights is None and refetch_solar: + solar_insights = _solar_insights_for(solar_client, spatial) + solar_was_fetched = solar_insights is not None plan = run_modelling( effective_epc, diff --git a/applications/sharepoint_renamer/handler.py b/applications/sharepoint_renamer/handler.py index dfaa510ce..f55f54741 100644 --- a/applications/sharepoint_renamer/handler.py +++ b/applications/sharepoint_renamer/handler.py @@ -8,7 +8,9 @@ from orchestration.sharepoint_renamer_orchestrator import SharepointRenamerOrche from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient from utils.sharepoint.domna_sites import DomnaSites -CSV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sero_address_list.csv") +CSV_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "sero_address_list.csv" +) def handler(event: dict[str, Any], context: Any) -> None: diff --git a/applications/sharepoint_renamer/sero_address_list.csv b/applications/sharepoint_renamer/sero_address_list.csv index 51cdf2eff..604387747 100644 --- a/applications/sharepoint_renamer/sero_address_list.csv +++ b/applications/sharepoint_renamer/sero_address_list.csv @@ -1,182 +1,366 @@ -UPRN,Address,Postcode -U1027392,"26 Silverdale Road, Oprington",BR5 2LT -U1003906,"54 Barnsdale Crescent, Oprington",BR5 2AX -U1034479,"90 Mead Way, Bromley",BR2 9EU -U1005549,"79 Lower Gravel Road, Bromley",BR2 8LP -U1016743,"16 Princes Plain, Bromley",BR2 8LE -U1041937,"75 Turpington Lane, Bromley",BR2 8JD -U1034805,"38 Narrow Way, Bromley",BR2 8JB -U1041933,"31 Turpington Lane, Bromley",BR2 8JA -U1037833,"3 Stiles Close, Bromley",BR2 8EQ -U1042734,"86 Whitebeam Avenue, Bromley",BR2 8DW -U1042575,"90 Whitebeam Avenue, Bromley",BR2 8DW -U1033177,"30 Larch Way, Bromley",BR2 8DU -U1027989,"5 Larch Way, Bromley",BR2 8DT -U1012309,"13 Almond Close, Bromley",BR2 8DS -U1022525,"30 Lovelace Avenue, Bromley",BR2 8DQ -U1047613,"13 Whitebeam Avenue, Bromley",BR2 8DJ -U1027549,"14 Thorn Close, Bromley",BR2 8DH -U1022726,"31 Hornbeam Way, Bromley",BR2 8DB -U1021308,"70 Faringdon Avenue, Bromley",BR2 8BU -U1026958,"77 Faringdon Avenue, Bromley",BR2 8BT -U1007553,"115d Faringdon Avenue, Bromley",BR2 8BT -U1032132,"115f Faringdon Avenue, Bromley",BR2 8BT -U1014627,"81 Faringdon Avenue, Bromley",BR2 8BT -U1009607,"20 Parkfield Way, Bromley",BR2 8AF -U1027838,"15 Holmcroft Way, Bromley",BR2 8AD -U1052376,"3 Shoreham Way, Bromley",BR2 7PU -U1015499,"25 Boughton Avenue, Bromley",BR2 7PL -U1005741,"2 Malling Way, Bromley",BR2 7PJ -U1019906,"26 Eastry Avenue, Bromley",BR2 7PF -U1021769,"49 Eastry Avenue, Bromley",BR2 7PE -U1031121,"32 Laburnum Way, Bromley",BR2 8BZ -U1022281,"35 Kingsdown Way, Bromley",BR2 7PT -U1035115,"30 Thorn Close, Bromley",BR2 8DH -U1027493,"35 Sudbury Crescent, Bromley",BR1 4PY -U1016746,"68 Princes Plain, Bromley",BR2 8LE -U1005168,"38 Holbrook Way, Bromley",BR2 8EE -U1036446,"14 Meath Close, Oprington",BR5 2HF -U1010452,"5 Canbury Path, Oprington",BR5 2EU -U1019785,"50 Cray Valley Road, Oprington",BR5 2EZ -U1024065,"64 Marion Crescent, Oprington",BR5 2HD -U1042248,"16 Stanley Way, Oprington",BR5 2HE -U1029229,"2 Meath Close, Oprington",BR5 2HF -U1037768,"13 Silverdale Road, Oprington",BR5 2LU -U1014589,"71 Empress Drive, Chislehurst",BR7 5BQ -U1024698,"4 Palace View, Bromley",BR1 3EL -U1052536,"12 Thorn Close, Bromley",BR2 8DH -U1022018,"12 Hazel Walk, Bromley",BR2 8DF -U1007728,"2 Hazel Walk, Bromley",BR2 8DF -U1002456,"54 Birch Row, Bromley",BR2 8DA -U1020349,"21 Laburnum Way, Bromley",BR2 8BY -U1032129,"78 Faringdon Avenue, Bromley",BR2 8BU -U1032130,"86 Faringdon Avenue, Bromley",BR2 8BU -U1021824,"115g Faringdon Avenue, Bromley",BR2 8BT -U1021827,"121 Faringdon Avenue, Bromley",BR2 8BT -U1026960,"105 Faringdon Avenue, Bromley",BR2 8BT -U1010578,"6 Cranbrook Close, Bromley",BR2 7QA -U1024709,"3 Parkfield Way, Bromley",BR2 8AE -U1024580,"24 Parkfield Way, Bromley",BR2 8AF -U1011190,"11 Shoreham Way, Bromley",BR2 7PU -U1011191,"32 Shoreham Way, Bromley",BR2 7PU -U1021535,"4 Chilham Way, Bromley",BR2 7PR -U1007556,"11 Farleigh Avenue, Bromley",BR2 7PP -U1010255,"7 Boughton Avenue, Bromley",BR2 7PL -U1034810,"24 Newbury Road, Bromley",BR2 0QW -U1009032,"10 Malling Way, Bromley",BR2 7PJ -U1004686,"55 Baston Road, Bromley",BR2 7BD -U1032061,"30 Eastry Avenue, Bromley",BR2 7PF -U1010582,"13 Cranworth Cottages, Keston",BR2 6DB -U1009511,"27 Newbury Road, Bromley",BR2 0QN -U1037954,"28 Treewall Gardens, Bromley",BR1 5BT -U1014793,"59 Headcorn Road, Bromley",BR1 4SQ -U1005123,"24 Bonville Road, Bromley",BR1 4QA -U1037872,"20 Sudbury Crescent, Bromley",BR1 4PZ -U1022305,"18a Lansdowne Road, Bromley",BR1 3LZ -U1035052,"1 Sudbury Crescent, Bromley",BR1 4PY -U1042298,"39 Sudbury Crescent, Bromley",BR1 4PY -U1019564,"32 Brook Lane, Bromley",BR1 4PU -U1024511,"81 Nightingale Lane, Bromley",BR1 2SA -U1032133,"119 Faringdon Avenue, Bromley",BR2 8BT -U1032134,"125 Faringdon Avenue, Bromley",BR2 8BT -U1032131,"93 Faringdon Avenue, Bromley",BR2 8BT -U1021825,"117a Faringdon Avenue, Bromley",BR2 8BT -U1010107,"42 Birch Row, Bromley",BR2 8DA -U1016880,"25 Almond Way, Bromley",BR2 8DR -U1038107,"152 Whitebeam Avenue, Bromley",BR2 8DW -U1052455,"10 Stiles Close, Bromley",BR2 8EQ -U1028328,"76 Magpie Hall Lane, Bromley",BR2 8ER -U1020064,"28 Green Way, Bromley",BR2 8EY -U1041934,"51 Turpington Lane, Bromley",BR2 8JA -U1005696,"158 Magpie Hall Lane, Bromley",BR2 8JG -U1030892,"140 Poverest Road, Oprington",BR5 1RH -U1011072,"45 Rookery Gardens, Oprington",BR5 4BA -U1031058,"303 Keedonwood Road, Bromley",BR1 4QR -U1052429,"76 Southover, Bromley",BR1 4RY -U1042153,"4 Scotts Road, Bromley",BR1 3QD -U1037814,"42 Stanley Road, Bromley",BR2 9JH -U1008158,"71 Lower Gravel Road, Bromley",BR2 8LP -U1032062,"46 Eastry Avenue, Bromley",BR2 7PF -U1016742,"4 Princes Plain, Bromley",BR2 8LE -U1038106,"84 Whitebeam Avenue, Bromley",BR2 8DW -U1013831,"33 Ash Row, Bromley",BR2 8DZ -U1005742,"18 Malling Way, Bromley",BR2 7PJ -U1042572,"37 Whitebeam Avenue, Bromley",BR2 8DJ -U1005167,"30 Holbrook Way, Bromley",BR2 8EE -U1024581,"30 Parkfield Way, Bromley",BR2 8AF -U1024050,"10 Marden Avenue, Bromley",BR2 7PX -U1020328,"31 Kingsdown Way, Bromley",BR2 7PT -U1020327,"5 Kingsdown Way, Bromley",BR2 7PT -U1005131,"10 Boughton Avenue, Bromley",BR2 7PL -U1023415,"27 Malling Way, Bromley",BR2 7PJ -U1052580,"25 Trentham Drive, Oprington",BR5 2EP -U1026447,"50 Princes Plain, Bromley",BR2 8LE -U1007432,"21 Detling Road, Bromley",BR1 4SH -U1053388,"52 Whitebeam Avenue, Bromley",BR2 8DL -U1022307,"20 Larch Way, Bromley",BR2 8DU -U1027743,"17 Whitebeam Avenue, Bromley",BR2 8DJ -U1022533,"81 Lower Gravel Road, Bromley",BR2 8LP -U1014630,"118 Faringdon Avenue, Bromley",BR2 8BU -U1030897,"60 Princes Plain, Bromley",BR2 8LE -U1022931,"15 Lennard Road, Bromley",BR2 8LN -U1042735,"108 Whitebeam Avenue, Bromley",BR2 8DW -U1013830,"31 Ash Row, Bromley",BR2 8DZ -U1020368,"11 Larch Way, Bromley",BR2 8DT -U1020369,"13 Larch Way, Bromley",BR2 8DT -U1042576,"112 Whitebeam Avenue, Bromley",BR2 8DW -U1027830,"26 Holbrook Way, Bromley",BR2 8EE -U1016744,"18 Princes Plain, Bromley",BR2 8LE -U1041772,"13 Stanley Road, Bromley",BR2 9JE -U1042573,"47 Whitebeam Avenue, Bromley",BR2 8DJ -U1008151,"61 Lovelace Avenue, Bromley",BR2 8EA -U1034985,"272 Southborough Lane, Bromley",BR2 8AS -U1007947,"8 Laburnum Way, Bromley",BR2 8BZ -U1027744,"33 Whitebeam Avenue, Bromley",BR2 8DJ -U1038102,"14 Whitebeam Avenue, Bromley",BR2 8DL -U1027745,"148 Whitebeam Avenue, Bromley",BR2 8DW -U1020208,"16 Holbrook Way, Bromley",BR2 8EE -U1023463,"30 MANOR WAY, Bromley",BR2 8ES -U1032647,"32 Narrow Way, Bromley",BR2 8JB -U1033406,"67 Lower Gravel Road, Bromley",BR2 8LP -U1000649,"42 Barnsdale Crescent, Oprington",BR5 2AX -U1036332,"50 Marion Crescent, Oprington",BR5 2HD -U1038103,"29 Whitebeam Avenue, Bromley",BR2 8DJ -U1042574,"66 Whitebeam Avenue, Bromley",BR2 8DL -U1042256,"2 Stiles Close, Bromley",BR2 8EQ -U1034017,"48 Princes Plain, Bromley",BR2 8LE -U1041435,"64 Princes Plain, Bromley",BR2 8LE -U1053386,"27 Whitebeam Avenue, Bromley",BR2 8DJ -U1038104,"32 Whitebeam Avenue, Bromley",BR2 8DL -U1019567,"8 Broom Close, Bromley",BR2 8EU -U1023539,"2 Marsham Close, Chislehurst",BR7 6JD -U1037465,"6 Princes Plain, Bromley",BR2 8LE -U1042733,"68 Whitebeam Avenue, Bromley",BR2 8DL -U1004442,"7 Birch Row, Bromley",BR2 8BX -U1005006,"44 Birch Row, Bromley",BR2 8DA -U1035331,"104 Whitebeam Avenue, Bromley",BR2 8DW -U1024017,"13 Manor Way, Bromley",BR2 8ES -U1014078,"43 Aylesbury Road, Bromley",BR2 0QR -U1052186,"4 Ravensleigh Gardens, Bromley",BR1 5SN -U1037450,"70 Pontefract Road, Bromley",BR1 4RB -U1031294,"70 London Lane, Bromley",BR1 4HE -U1014077,"32 Aylesbury Road, Bromley",BR2 0QP -U1030962,"7 Ravensleigh Gardens, Bromley",BR1 5SN -U1042232,"154 Southover, Bromley",BR1 4RZ -U1024484,"55 Mounthurst Road, Bromley",BR2 7PG -U1007701,"46 Harwood Avenue, Bromley",BR1 3DU -U1020199,"78 Hillside Road, Bromley",BR2 0ST -U1036758,"46 Newbury Road, Bromley",BR2 0QW -U1009369,"17 Minster Road, Bromley",BR1 4DY -U1009194,"84 Mays Hill Road, Bromley",BR2 0HT -U1013358,"3 Bird In Hand Lane, Bromley",BR1 2NA -U1009202,"63 Mead Way, Bromley",BR2 9ER -U1022991,"5 Link Way, Bromley",BR2 8JH -U1025820,"46 Princes Plain, Bromley",BR2 8LE -U1020237,"33 Hornbeam Way, Bromley",BR2 8DB -U1021310,"126 Faringdon Avenue, Bromley",BR2 8BU -U1021353,"66 George Lane, Bromley",BR2 7LQ -U1033165,"2 Laburnum Way, Bromley",BR2 8BZ -U1010811,"13 Gilbert Road, Bromley",BR1 3QP -U1027449,"11 Station Road, Bromley",BR1 3LP -U1035326,"2 Whitebeam Avenue, Bromley",BR2 8DL -U1020351,"27 Laburnum Way, Bromley",BR2 8BY +UPRN,Address,Postcode +U1041316,"U1041316_58 Ravensworth Road, Mottingham",SE9 4LW +U2022716,"U2022716_306 St Helier Avenue, Morden",SM4 6JX +U2018001,"U2018001_37 Leominster Road, Morden",SM4 6HN +U2019131,"U2019131_34 Middleton Road, Morden",SM4 6RT +U1035331,"104 Whitebeam Avenue, Bromley",BR2 8DW +U1010255,"7 Boughton Avenue, Bromley",BR2 7PL +U1024581,"30 Parkfield Way, Bromley",BR2 8AF +U1032130,"86 Faringdon Avenue, Bromley",BR2 8BU +U1014077,"32 Aylesbury Road, Bromley",BR2 0QP +U1007728,"2 Hazel Walk, Bromley",BR2 8DF +U1020327,"5 Kingsdown Way, Bromley",BR2 7PT +U1032134,"125 Faringdon Avenue, Bromley",BR2 8BT +U1053411,"U1053411_78 Windsor Drive, Orpington",BR6 6HF +U2022615,"U2022615_56 St Helier Avenue, Morden",SM4 6LF +U1022991,"5 Link Way, Bromley",BR2 8JH +U1010811,"13 Gilbert Road, Bromley",BR1 3QP +U1020328,"31 Kingsdown Way, Bromley",BR2 7PT +U1005131,"10 Boughton Avenue, Bromley",BR2 7PL +U1021353,"66 George Lane, Bromley",BR2 7LQ +U1034017,"48 Princes Plain, Bromley",BR2 8LE +U1036758,"46 Newbury Road, Bromley",BR2 0QW +U1020351,"27 Laburnum Way, Bromley",BR2 8BY +U1035052,"1 Sudbury Crescent, Bromley",BR1 4PY +U1027073,"U1027073_29 Haddon Road, St Mary Cray",BR5 4BU +U1034887,"U1034887_2 Saltwood Close, Orpington",BR6 9BU +U1035329,"U1035329_79 Whitebeam Avenue, Bromley",BR2 8DN +U1042571,"U1042571_19 Whitebeam Avenue, Bromley",BR2 8DJ +U1035330,"U1035330_87 Whitebeam Avenue, Bromley",BR2 8DN +U1047521,"U1047521_19 Walnuts Road, Orpington",BR6 0RG +U1033037,"U1033037_73 Homefield Rise, Orpington",BR6 0RL +U1027845,"U1027845_92 Homefield Rise, Orpington",BR6 0RW +U1031691,"U1031691_46 Boyland Road, Bromley",BR1 4QF +U1026916,"U1026916_19 Elizabeth Way, St Mary Cray",BR5 4BJ +U1015363,"U1015363_44a Birch Row, Bromley",BR2 8DA +U1010400,"U1010400_42 Brook Lane, Bromley",BR1 4PU +U1052426,"U1052426_49 Southover, Bromley",BR1 4SL +U1019591,"U1019591_18 Buckland Road, Orpington",BR6 9SR +U1011434,"U1011434_24 Upper Elmers End Road, Beckenham",BR3 4AL +U1023417,"U1023417_44 Malling Way, Hayes, Bromley",BR2 7PJ +U1014671,"U1014671_61 George Lane, Hayes, Bromley",BR2 7LG +U1037550,"U1037550_228 Repton Road, Orpington",BR6 9JB +U1014433,"U1014433_57 Cowden Road, Orpington",BR6 0TP +U1015779,"U1015779_21 Beck Lane, Beckenham",BR3 4RD +U1042236,"U1042236_250 Southover, Bromley",BR1 4SE +U1033036,"U1033036_61 Homefield Rise, Orpington",BR6 0RL +U1027951,"U1027951_2 Kingsdown Way, Hayes, Bromley",BR2 7PT +U1032770,"U1032770_18 Pembury Close, Hayes, Bromley",BR2 7PS +U1011129,"U1011129_40 Saltwood Close, Orpington",BR6 9BU +U1023267,"U1023267_47a Mackenzie Road, Beckenham",BR3 4RT +U1016998,"U1016998_68 Arundel Drive, Orpington",BR6 9JG +U1026576,"U1026576_1 Burwash Court, St Mary Cray",BR5 4BG +U1037867,"U1037867_45 Stowe Road, Orpington",BR6 9HG +U1027839,"U1027839_30 Holmcroft Way, Bromley",BR2 8AD +U1005351,"U1005351_2 Leaveland Close, Beckenham",BR3 3PL +U1041968,"U1041968_59 Uplands Road, Orpington",BR6 0RH +U1032149,"U1032149_12 Fordwich Close, Orpington",BR6 0TT +U1047347,"U1047347_30 Sudbury Crescent, Bromley",BR1 4PZ +U1004249,"U1004249_6 Beck Lane, Beckenham",BR3 4RE +U1005772,"U1005772_10 Manor Park Road, West Wickham",BR4 0JZ +U1010810,"U1010810_79 George Lane, Hayes, Bromley",BR2 7LG +U1011008,"U1011008_170 Repton Road, Orpington",BR6 9JA +U1026969,"U1026969_2 Fordwich Close, Orpington",BR6 0TT +U1032000,"U1032000_16 Detling Road, Bromley",BR1 4SH +U1013268,"U1013268_9 Bell Gardens, St Mary Cray",BR5 4DA +U1020217,"U1020217_71 Homefield Rise, Orpington",BR6 0RL +U1033287,"U1033287_12 Lincoln Green Road, St Mary Cray",BR5 2DX +U1041997,"U1041997_31 Walnuts Road, Orpington",BR6 0RG +U1047520,"U1047520_2 Walnuts Road, Orpington",BR6 0RQ +U1019606,"U1019606_20 Burrfield Drive, St Mary Cray",BR5 4BZ +U1007354,"U1007354_30 Cowden Road, Orpington",BR6 0TR +U1015603,"U1015603_1 Barwood Avenue, West Wickham",BR4 0LB +U1035393,"U1035393_18 Yalding Grove, St Mary Cray",BR5 3SE +U1000177,"U1000177_79 Arundel Drive, Orpington",BR6 9JQ +U1011428,"U1011428_21 Uplands Road, Orpington",BR6 0RH +U1024671,"U1024671_115 Oregon Square, Orpington",BR6 8BE +U1021513,"U1021513_18 Chamberlain Crescent, West Wickham",BR4 0LL +U1024663,"U1024663_32b Okemore Gardens, Orpington",BR5 3PJ +U1020879,"U1020879_8 Brockwell Close, St Mary Cray",BR5 1RJ +U1022713,"U1022713_53 Homefield Rise, Orpington",BR6 0RL +U1031157,"U1031157_94 Leesons Hill, St Pauls Cray",BR5 2NH +U1013666,"U1013666_11 Ampleforth Close, Orpington",BR6 9ES +U1052617,"U1052617_44 Uplands Road, Orpington",BR6 0RJ +U1010783,"U1010783_32 Foxbury Drive, Chelsfield, Orpington",BR6 6EL +U1021700,"U1021700_25 Daleside, Orpington",BR6 6EG +U1011163,"U1011163_35 Selwyn Place, St Pauls Cray",BR5 3BA +U1022175,"U1022175_54 Homefield Rise, Orpington",BR6 0RW +U1021461,"U1021461_27 Burrfield Drive, St Mary Cray",BR5 4BZ +U1007184,"U1007184_7 Buckland Road, Orpington",BR6 9SR +U1023112,"U1023112_97 Lower Gravel Road, Bromley",BR2 8LP +U1024516,"U1024516_18 Northolme Rise, Off Tubbenden Lane, Orpington",BR6 9RF +U1023910,"U1023910_40 Moorfield Road, Orpington",BR6 0HQ +U1031342,"U1031342_55 Lower Gravel Road, Bromley",BR2 8LP +U1037905,"U1037905_37 Sweeps Lane, St Mary Cray",BR5 3PE +U2019260,"U2019260_88 Montacute Road, Morden",SM4 6RL +U2019214,"U2019214_11 Missenden Gardens, Morden",SM4 6HW +U2022891,"U2022891_20 Swains Road, Tooting, London",SW17 9HS +U2022684,"U2022684_231 St Helier Avenue, Morden",SM4 6JH +U2023196,"U2023196_210 Tudor Drive, Morden",SM4 4PQ +U2019671,"U2019671_16 Netley Gardens, Morden",SM4 6JW +U2018944,"U2018944_79 Malmesbury Road, Morden",SM4 6HG +U2015873,"U2015873_1 Furness Road, Morden",SM4 6PS +U1041503,"U1041503_103 Ravensworth Road, Mottingham",SE9 4LX +U2023185,"U2023185_157 Tudor Drive, Morden",SM4 4PJ +U1053395,"U1053395_90 Widecombe Road, Mottingham",SE9 4HJ +U2018009,"U2018009_23 Leominster Walk, Morden",SM4 6HB +U2018941,"U2018941_74 Malmesbury Road, Morden",SM4 6HE +U2013950,"U2013950_2 Buckland Walk, Morden",SM4 5RZ +U2018160,"U2018160_6 Llanthony Road, Morden",SM4 6DX +U2015790,"U2015790_12 Flaxley Road, Morden",SM4 6LJ +U2013914,"U2013914_36 Bruton Road, Morden",SM4 5RY +U2022611,"U2022611_50 St Helier Avenue, Morden",SM4 6LF +U2015270,"U2015270_39 Dorchester Road, Morden",SM4 6QE +U1009098,"U1009098_37b Maple Road, Penge",SE20 8LA +U2018926,"U2018926_19 Malmesbury Road, Morden",SM4 6HD +U2023192,"U2023192_195 Tudor Drive, Morden",SM4 4PJ +U1027362,"U1027362_187 Seymour Villas, Penge",SE20 8TP +U2018973,"U2018973_26 Marham Gardens, Morden",SM4 6JJ +U2013913,"U2013913_30 Bruton Road, Morden",SM4 5RY +U1042214,"U1042214_25 Snowdown Close, London",SE20 7RU +U2013911,"U2013911_23 Bruton Road, Morden",SM4 5RY +U2013583,"U2013583_300 Bishopsford Road, Morden",SM4 6BY +U2018925,"U2018925_14 Malmesbury Road, Morden",SM4 6HD +U1037938,"U1037938_5 Torr Road, Penge",SE20 7PS +U1038117,"U1038117_40 Widecombe Road, Mottingham",SE9 4HQ +U1052365,"U1052365_127 Seymour Villas, Penge",SE20 8TP +U1024726,"U1024726_2 Penge Lane, Penge",SE20 7DU +U1026505,"U1026505_7 Bridlington Close, Biggin Hill",TN16 3PD +U2018913,"U2018913_23 Malling Gardens, Morden",SM4 6JG +U2015708,"U2015708_46 Faversham Road, Morden",SM4 6RE +U2019530,"U2019530_30 Neath Gardens, Morden",SM4 6JN +U2015710,"U2015710_49 Faversham Road, Morden",SM4 6RE +U2014478,"U2014478_222 Central Road, Morden",SM4 5PP +U2018922,"U2018922_11 Malmesbury Road, Morden",SM4 6HD +U2013615,"U2013615_47 Blanchland Road, Morden",SM4 5ND +U2022641,"U2022641_127 St Helier Avenue, Morden",SM4 6JD +U2019268,"U2019268_97 Montacute Road, Morden",SM4 6RJ +U2013947,"U2013947_95 Buckfast Road, Morden",SM4 5NA +U2018025,"U2018025_21 Lessness Road, Morden",SM4 6HP +U2013415,"U2013415_51 Bodnant Gardens, Raynes Park, London",SW20 0UD +U2022728,"U2022728_340 St Helier Avenue, Morden",SM4 6JU +U1009623,"U1009623_23 Pawleyne Close, Penge",SE20 8JH +U2015884,"U2015884_30 Furness Road, Morden",SM4 6PR +U2019728,"U2019728_17 Newhouse Walk, Morden",SM4 6BS +U1024719,"U1024719_14 Pawleyne Close, Penge",SE20 8JH +U2013935,"U2013935_43 Buckfast Road, Morden",SM4 5NA +U2013623,"U2013623_4 Bodmin Grove, Morden",SM4 5LU +U2018811,"U2018811_354 Lynmouth Avenue, Morden",SM4 4RT +U2019672,"U2019672_19 Netley Gardens, Morden",SM4 6JW +U2018927,"U2018927_29 Malmesbury Road, Morden",SM4 6HD +U2019237,"U2019237_37 Montacute Road, Morden",SM4 6RJ +U2019730,"U2019730_23 Newhouse Walk, Morden",SM4 6BS +U1038118,"U1038118_46 Widecombe Road, Mottingham",SE9 4HQ +U1000359,"U1000359_5 Avenue Road, Penge",SE20 7RT +U2018164,"U2018164_24 Llanthony Road, Morden",SM4 6DX +U2013982,"U2013982_18 Bury Grove, Morden",SM4 5NG +U1030964,"U1030964_13 Ravensworth Road, Mottingham",SE9 4LN +U2015882,"U2015882_25 Furness Road, Morden",SM4 6PS +U1031105,"U1031105_21 Kingsmead, Biggin Hill, Westerham",TN16 3UB +U1014008,"U1014008_9 Avenue Road, Penge",SE20 7RT +U2015779,"U2015779_57 Flanders Crescent, Tooting, London",SW17 9JB +U2013413,"U2013413_49 Bodnant Gardens, Raynes Park, London",SW20 0UD +U1021199,"U1021199_7 Dittisham Road, Mottingham",SE9 4BJ +U1041705,"U1041705_87 Seymour Villas, Penge",SE20 8TR +U1041706,"U1041706_91 Seymour Villas, Penge",SE20 8TR +U1042213,"U1042213_9 Snowdown Close, London",SE20 7RU +U2014444,"U2014444_86 Central Road, Morden",SM4 5RJ +U2013940,"U2013940_63 Buckfast Road, Morden",SM4 5NA +U1037789,"U1037789_5 Snowdown Close, London",SE20 7RU +U2013630,"U2013630_16 Bodmin Grove, Morden",SM4 5LU +U2014445,"U2014445_88 Central Road, Morden",SM4 5RJ +U2013602,"U2013602_5 Blanchland Road, Morden",SM4 5ND +U1017178,"U1017178_11 Avenue Road, Penge",SE20 7RT +U1013690,"U1013690_74 Anerley Vale, London",SE19 2BG +U2013676,"U2013676_52 Bordesley Road, Morden",SM4 5LR +U1042212,"U1042212_1 Snowdown Close, London",SE20 7RU +U2018810,"U2018810_331 Lynmouth Avenue, Morden",SM4 4RY +U2013476,"U2013476_1 Bristol Road, Morden",SM4 5SA +U1026660,"U1026660_40 Chilham Road, Mottingham",SE9 4BG +U1036877,"U1036877_2 Pawleyne Close, Penge",SE20 8JH +U2018814,"U2018814_364 Lynmouth Avenue, Morden",SM4 4RT +U2013573,"U2013573_264 Bishopsford Road, Morden",SM4 6BZ +U1007236,"U1007236_6 Castledine Road, Penge",SE20 8PL +U1027701,"U1027701_4 Wayside Grove, Mottingham",SE9 4ND +U1047208,"U1047208_83 Seymour Villas, Penge",SE20 8TR +U2013695,"U2013695_111 Bordesley Road, Morden",SM4 5LW +U1004961,"U1004961_36 Beverley Road, Penge",SE20 8SJ +U2015795,"U2015795_24 Flaxley Road, Morden",SM4 6LJ +U2014451,"U2014451_102 Central Road, Morden",SM4 5RJ +U2016606,"U2016606_3 Grayswood Gardens, Raynes Park, London",SW20 0UF +U1035025,"U1035025_29 Steyning Grove, Mottingham",SE9 4NG +U1027588,"U1027588_94 Tovil Close, Penge",SE20 8SZ +U2022632,"U2022632_112 St Helier Avenue, Morden",SM4 6LB +U1037462,"U1037462_68 Prestbury Square, Mottingham",SE9 4NA +U2018027,"U2018027_29 Lessness Road, Morden",SM4 6HP +U2018909,"U2018909_12 Malling Gardens, Morden",SM4 6JG +U1001247,"U1001247_13 Pawleyne Close, Penge",SE20 8JH +U1018547,"U1018547_9b Avenue Road, Penge",SE20 7RT +U1008780,"U1008780_7b Avenue Road, Penge",SE20 7RT +U1007212,"U1007212_2 Calcott Walk, Mottingham",SE9 4BN +U1003650,"U1003650_5b Avenue Road, Penge",SE20 7RT +U1010359,"U1010359_2 Bredhurst Close, Penge",SE20 7BG +U1041772,"13 Stanley Road, Bromley",BR2 9JE +U1011072,"45 Rookery Gardens, Oprington",BR5 4BA +U1020208,"16 Holbrook Way, Bromley",BR2 8EE +U1052580,"25 Trentham Drive, Oprington",BR5 2EP +U1038106,"84 Whitebeam Avenue, Bromley",BR2 8DW +U1010107,"42 Birch Row, Bromley",BR2 8DA +U1022307,"20 Larch Way, Bromley",BR2 8DU +U1020064,"28 Green Way, Bromley",BR2 8EY +U1023463,"30 MANOR WAY, Bromley",BR2 8ES +U1027745,"148 Whitebeam Avenue, Bromley",BR2 8DW +U1008151,"61 Lovelace Avenue, Bromley",BR2 8EA +U1019785,"50 Cray Valley Road, Oprington",BR5 2EZ +U1029229,"2 Meath Close, Oprington",BR5 2HF +U1005696,"158 Magpie Hall Lane, Bromley",BR2 8JG +U1036332,"50 Marion Crescent, Oprington",BR5 2HD +U1027830,"26 Holbrook Way, Bromley",BR2 8EE +U1052455,"10 Stiles Close, Bromley",BR2 8EQ +U1023539,"2 Marsham Close, Chislehurst",BR7 6JD +U1030962,"7 Ravensleigh Gardens, Bromley",BR1 5SN +U1037954,"28 Treewall Gardens, Bromley",BR1 5BT +U1020368,"11 Larch Way, Bromley",BR2 8DT +U1027743,"17 Whitebeam Avenue, Bromley",BR2 8DJ +U1021825,"117a Faringdon Avenue, Bromley",BR2 8BT +U1028328,"76 Magpie Hall Lane, Bromley",BR2 8ER +U1025820,"46 Princes Plain, Bromley",BR2 8LE +U1024580,"24 Parkfield Way, Bromley",BR2 8AF +U1010452,"5 Canbury Path, Oprington",BR5 2EU +U1022018,"12 Hazel Walk, Bromley",BR2 8DF +U1037814,"42 Stanley Road, Bromley",BR2 9JH +U1016744,"18 Princes Plain, Bromley",BR2 8LE +U1021310,"126 Faringdon Avenue, Bromley",BR2 8BU +U1035115,"30 Thorn Close, Bromley",BR2 8DH +U1033165,"2 Laburnum Way, Bromley",BR2 8BZ +U1000649,"42 Barnsdale Crescent, Oprington",BR5 2AX +U1052536,"12 Thorn Close, Bromley",BR2 8DH +U1042298,"39 Sudbury Crescent, Bromley",BR1 4PY +U1053388,"52 Whitebeam Avenue, Bromley",BR2 8DL +U1007556,"11 Farleigh Avenue, Bromley",BR2 7PP +U1007947,"8 Laburnum Way, Bromley",BR2 8BZ +U1019567,"8 Broom Close, Bromley",BR2 8EU +U1013358,"3 Bird In Hand Lane, Bromley",BR1 2NA +U1022533,"81 Lower Gravel Road, Bromley",BR2 8LP +U1021824,"115g Faringdon Avenue, Bromley",BR2 8BT +U1009369,"17 Minster Road, Bromley",BR1 4DY +U1022931,"15 Lennard Road, Bromley",BR2 8LN +U1024065,"64 Marion Crescent, Oprington",BR5 2HD +U1005168,"38 Holbrook Way, Bromley",BR2 8EE +U1038104,"32 Whitebeam Avenue, Bromley",BR2 8DL +U1021827,"121 Faringdon Avenue, Bromley",BR2 8BT +U1030892,"140 Poverest Road, Oprington",BR5 1RH +U1013831,"33 Ash Row, Bromley",BR2 8DZ +U1004442,"7 Birch Row, Bromley",BR2 8BX +U1019564,"32 Brook Lane, Bromley",BR1 4PU +U1032131,"93 Faringdon Avenue, Bromley",BR2 8BT +U1026960,"105 Faringdon Avenue, Bromley",BR2 8BT +U1005006,"44 Birch Row, Bromley",BR2 8DA +U1005742,"18 Malling Way, Bromley",BR2 7PJ +U1022281,"35 Kingsdown Way, Bromley",BR2 7PT +U1042574,"66 Whitebeam Avenue, Bromley",BR2 8DL +U1011191,"32 Shoreham Way, Bromley",BR2 7PU +U1032129,"78 Faringdon Avenue, Bromley",BR2 8BU +U1009032,"10 Malling Way, Bromley",BR2 7PJ +U1031121,"32 Laburnum Way, Bromley",BR2 8BZ +U1022305,"18a Lansdowne Road, Bromley",BR1 3LZ +U1042248,"16 Stanley Way, Oprington",BR5 2HE +U1038102,"14 Whitebeam Avenue, Bromley",BR2 8DL +U1007432,"21 Detling Road, Bromley",BR1 4SH +U1041934,"51 Turpington Lane, Bromley",BR2 8JA +U1032647,"32 Narrow Way, Bromley",BR2 8JB +U1052429,"76 Southover, Bromley",BR1 4RY +U1007701,"46 Harwood Avenue, Bromley",BR1 3DU +U1042572,"37 Whitebeam Avenue, Bromley",BR2 8DJ +U1042576,"112 Whitebeam Avenue, Bromley",BR2 8DW +U1020349,"21 Laburnum Way, Bromley",BR2 8BY +U1021535,"4 Chilham Way, Bromley",BR2 7PR +U1004686,"55 Baston Road, Bromley",BR2 7BD +U1027744,"33 Whitebeam Avenue, Bromley",BR2 8DJ +U1016880,"25 Almond Way, Bromley",BR2 8DR +U1023415,"27 Malling Way, Bromley",BR2 7PJ +U1011190,"11 Shoreham Way, Bromley",BR2 7PU +U1042256,"2 Stiles Close, Bromley",BR2 8EQ +U1053386,"27 Whitebeam Avenue, Bromley",BR2 8DJ +U1020237,"33 Hornbeam Way, Bromley",BR2 8DB +U1005167,"30 Holbrook Way, Bromley",BR2 8EE +U1014630,"118 Faringdon Avenue, Bromley",BR2 8BU +U1034810,"24 Newbury Road, Bromley",BR2 0QW +U1037768,"13 Silverdale Road, Oprington",BR5 2LU +U1024709,"3 Parkfield Way, Bromley",BR2 8AE +U1010582,"13 Cranworth Cottages, Keston",BR2 6DB +U1010578,"6 Cranbrook Close, Bromley",BR2 7QA +U1031058,"303 Keedonwood Road, Bromley",BR1 4QR +U1026447,"50 Princes Plain, Bromley",BR2 8LE +U1016746,"68 Princes Plain, Bromley",BR2 8LE +U1038107,"152 Whitebeam Avenue, Bromley",BR2 8DW +U1036446,"14 Meath Close, Oprington",BR5 2HF +U1014589,"71 Empress Drive, Chislehurst",BR7 5BQ +U1002456,"54 Birch Row, Bromley",BR2 8DA +U1020369,"13 Larch Way, Bromley",BR2 8DT +U1034985,"272 Southborough Lane, Bromley",BR2 8AS +U1041435,"64 Princes Plain, Bromley",BR2 8LE +U1031294,"70 London Lane, Bromley",BR1 4HE +U1033406,"67 Lower Gravel Road, Bromley",BR2 8LP +U1032061,"30 Eastry Avenue, Bromley",BR2 7PF +U1042573,"47 Whitebeam Avenue, Bromley",BR2 8DJ +U1032133,"119 Faringdon Avenue, Bromley",BR2 8BT +U1013830,"31 Ash Row, Bromley",BR2 8DZ +U1009511,"27 Newbury Road, Bromley",BR2 0QN +U1042735,"108 Whitebeam Avenue, Bromley",BR2 8DW +U1024511,"81 Nightingale Lane, Bromley",BR1 2SA +U1024050,"10 Marden Avenue, Bromley",BR2 7PX +U1035326,"2 Whitebeam Avenue, Bromley",BR2 8DL +U1009202,"63 Mead Way, Bromley",BR2 9ER +U1020199,"78 Hillside Road, Bromley",BR2 0ST +U1042232,"154 Southover, Bromley",BR1 4RZ +U1030897,"60 Princes Plain, Bromley",BR2 8LE +U1027989,"5 Larch Way, Bromley",BR2 8DT +U1021308,"70 Faringdon Avenue, Bromley",BR2 8BU +U1019906,"26 Eastry Avenue, Bromley",BR2 7PF +U1022726,"31 Hornbeam Way, Bromley",BR2 8DB +U1024698,"4 Palace View, Bromley",BR1 3EL +U1005549,"79 Lower Gravel Road, Bromley",BR2 8LP +U1052376,"3 Shoreham Way, Bromley",BR2 7PU +U1041937,"75 Turpington Lane, Bromley",BR2 8JD +U1005123,"24 Bonville Road, Bromley",BR1 4QA +U1008158,"71 Lower Gravel Road, Bromley",BR2 8LP +U1042575,"90 Whitebeam Avenue, Bromley",BR2 8DW +U1014793,"59 Headcorn Road, Bromley",BR1 4SQ +U1012309,"13 Almond Close, Bromley",BR2 8DS +U1009194,"84 Mays Hill Road, Bromley",BR2 0HT +U1027838,"15 Holmcroft Way, Bromley",BR2 8AD +U1027449,"11 Station Road, Bromley",BR1 3LP +U1014627,"81 Faringdon Avenue, Bromley",BR2 8BT +U1027493,"35 Sudbury Crescent, Bromley",BR1 4PY +U1032132,"115f Faringdon Avenue, Bromley",BR2 8BT +U1037833,"3 Stiles Close, Bromley",BR2 8EQ +U1016743,"16 Princes Plain, Bromley",BR2 8LE +U1007553,"115d Faringdon Avenue, Bromley",BR2 8BT +U1009607,"20 Parkfield Way, Bromley",BR2 8AF +U1034805,"38 Narrow Way, Bromley",BR2 8JB +U1032062,"46 Eastry Avenue, Bromley",BR2 7PF +U1047613,"13 Whitebeam Avenue, Bromley",BR2 8DJ +U1024484,"55 Mounthurst Road, Bromley",BR2 7PG +U1038103,"29 Whitebeam Avenue, Bromley",BR2 8DJ +U1022525,"30 Lovelace Avenue, Bromley",BR2 8DQ +U1042153,"4 Scotts Road, Bromley",BR1 3QD +U1037465,"6 Princes Plain, Bromley",BR2 8LE +U1041933,"31 Turpington Lane, Bromley",BR2 8JA +U1021769,"49 Eastry Avenue, Bromley",BR2 7PE +U1042734,"86 Whitebeam Avenue, Bromley",BR2 8DW +U1026958,"77 Faringdon Avenue, Bromley",BR2 8BT +U1037872,"20 Sudbury Crescent, Bromley",BR1 4PZ +U1027549,"14 Thorn Close, Bromley",BR2 8DH +U1037450,"70 Pontefract Road, Bromley",BR1 4RB +U1016742,"4 Princes Plain, Bromley",BR2 8LE +U1042733,"68 Whitebeam Avenue, Bromley",BR2 8DL +U1003906,"54 Barnsdale Crescent, Oprington",BR5 2AX +U1027392,"26 Silverdale Road, Oprington",BR5 2LT +U1052186,"4 Ravensleigh Gardens, Bromley",BR1 5SN +U1034479,"90 Mead Way, Bromley",BR2 9EU +U1005741,"2 Malling Way, Bromley",BR2 7PJ +U1024017,"13 Manor Way, Bromley",BR2 8ES +U1014078,"43 Aylesbury Road, Bromley",BR2 0QR +U1015499,"25 Boughton Avenue, Bromley",BR2 7PL +U1033177,"30 Larch Way, Bromley",BR2 8DU diff --git a/applications/sharepoint_renamer/sero_address_list_test.csv b/applications/sharepoint_renamer/sero_address_list_test.csv deleted file mode 100644 index 72b280477..000000000 --- a/applications/sharepoint_renamer/sero_address_list_test.csv +++ /dev/null @@ -1,2 +0,0 @@ -UPRN,Address,Postcode -U1014630,"118 Faringdon Avenue, Bromley",BR2 8BU \ No newline at end of file diff --git a/backend/app/db/models/hubspot_deal_data.py b/backend/app/db/models/hubspot_deal_data.py index caf1df5d5..48a6f1ba0 100644 --- a/backend/app/db/models/hubspot_deal_data.py +++ b/backend/app/db/models/hubspot_deal_data.py @@ -23,6 +23,7 @@ class HubspotDealData(SQLModel, table=True): landlord_property_id: Optional[str] = Field(default=None) uprn: Optional[str] = Field(default=None) outcome: Optional[str] = Field(default=None) + number_of_attempts: Optional[str] = Field(default=None) outcome_notes: Optional[str] = Field(default=None) booking_status: Optional[str] = Field(default=None) diff --git a/backend/documents_parser/tests/test_elmhurst_end_to_end.py b/backend/documents_parser/tests/test_elmhurst_end_to_end.py index 1ccd28c9e..e7917d58f 100644 --- a/backend/documents_parser/tests/test_elmhurst_end_to_end.py +++ b/backend/documents_parser/tests/test_elmhurst_end_to_end.py @@ -5,7 +5,10 @@ from datetime import date import pytest from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor -from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier, EpcPropertyData +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, +) from datatypes.epc.domain.mapper import EpcPropertyDataMapper FIXTURE_PATH = os.path.join( @@ -179,10 +182,15 @@ class TestBuildingPart: assert len(result.sap_building_parts[0].sap_floor_dimensions) == 1 def test_floor_dimension_area(self, result: EpcPropertyData) -> None: - assert result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2 == 44.89 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2 + == 44.89 + ) def test_floor_dimension_room_height(self, result: EpcPropertyData) -> None: - assert result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.24 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.24 + ) def test_floor_dimension_heat_loss_perimeter(self, result: EpcPropertyData) -> None: assert ( @@ -245,11 +253,17 @@ class TestWindows: def test_transmission_solar_transmittance(self, result: EpcPropertyData) -> None: assert result.sap_windows[0].window_transmission_details is not None - assert result.sap_windows[0].window_transmission_details.solar_transmittance == 0.72 + assert ( + result.sap_windows[0].window_transmission_details.solar_transmittance + == 0.72 + ) def test_transmission_data_source(self, result: EpcPropertyData) -> None: assert result.sap_windows[0].window_transmission_details is not None - assert result.sap_windows[0].window_transmission_details.data_source == "Manufacturer" + assert ( + result.sap_windows[0].window_transmission_details.data_source + == "Manufacturer" + ) class TestHeating: @@ -308,7 +322,7 @@ class TestHeating: class TestEnergySource: def test_mains_gas(self, result: EpcPropertyData) -> None: - assert result.sap_energy_source.mains_gas is True + assert result.sap_energy_source.gas_connection_available is True def test_meter_type(self, result: EpcPropertyData) -> None: assert result.sap_energy_source.meter_type == "Single" @@ -384,5 +398,7 @@ class TestWindowFrameMaterial: class TestLowEnergyLighting: - def test_low_energy_fixed_lighting_bulbs_count(self, result2: EpcPropertyData) -> None: + def test_low_energy_fixed_lighting_bulbs_count( + self, result2: EpcPropertyData + ) -> None: assert result2.low_energy_fixed_lighting_bulbs_count == 5 diff --git a/backend/documents_parser/tests/test_end_to_end.py b/backend/documents_parser/tests/test_end_to_end.py index d1a027173..6fc20ad09 100644 --- a/backend/documents_parser/tests/test_end_to_end.py +++ b/backend/documents_parser/tests/test_end_to_end.py @@ -177,7 +177,7 @@ class TestPdfToEpcPropertyData: ), ], sap_energy_source=SapEnergySource( - mains_gas=True, + gas_connection_available=True, meter_type="Single", pv_battery_count=0, wind_turbines_count=0, diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_100020942571/elmhurst_summary.pdf b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_100020942571/elmhurst_summary.pdf new file mode 100644 index 000000000..95f037b75 Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_100020942571/elmhurst_summary.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_100020942571/elmhurst_worksheet.pdf b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_100020942571/elmhurst_worksheet.pdf new file mode 100644 index 000000000..40c6a7bca Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_100020942571/elmhurst_worksheet.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_100020942571/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_100020942571/epc.json new file mode 100644 index 000000000..1d2a9055c --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_100020942571/epc.json @@ -0,0 +1,368 @@ +{ + "uprn": 100020942571, + "roofs": [ + { + "description": "(another dwelling above)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "walls": [ + { + "description": "System built, with internal insulation", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Solid, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + { + "description": "To external air, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "lighting": { + "description": "Low energy lighting in all fixed outlets", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "SE28 8BW", + "hot_water": { + "description": "Gas multipoint", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "post_town": "LONDON", + "built_form": 3, + "created_at": "2019-02-11 19:55:01.000000", + "door_count": 1, + "glazed_area": 1, + "region_code": 17, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "water_heating_code": 908, + "water_heating_fuel": 26, + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 2, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 29, + "heat_emitter_type": 0, + "emitter_temperature": "NA", + "main_heating_number": 1, + "main_heating_control": 2602, + "main_heating_category": 10, + "main_heating_fraction": 1, + "sap_main_heating_code": 691, + "main_heating_data_source": 2 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 9.93, + "schema_type": "RdSAP-Schema-18.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": "Room heaters, electric", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 2 + } + ], + "dwelling_type": "Ground-floor maisonette", + "language_code": 1, + "property_type": 3, + "address_line_1": "8, Booth Close", + "assessment_type": "RdSAP", + "completion_date": "2019-02-11", + "inspection_date": "2019-02-09", + "extensions_count": 1, + "measurement_type": 1, + "sap_flat_details": { + "level": 1, + "top_storey": "N", + "flat_location": 0, + "heat_loss_corridor": 0 + }, + "total_floor_area": 78, + "transaction_type": 8, + "conservatory_type": 1, + "heated_room_count": 4, + "registration_date": "2019-02-11", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "photovoltaic_supply": { + "none_or_no_details": { + "pv_connection": 0, + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 1 + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 260, + "floor_heat_loss": 7, + "roof_construction": 3, + "wall_construction": 8, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.36, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 36.42, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 5.64, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 19.05, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.24, + "quantity": "metres" + }, + "total_floor_area": { + "value": 36.42, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 5.64, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 12.95, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 3, + "construction_age_band": "E", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": "ND", + "roof_insulation_thickness": "ND", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + }, + { + "identifier": "Extension 1", + "wall_dry_lined": "N", + "wall_thickness": 260, + "floor_heat_loss": 1, + "roof_construction": 3, + "wall_construction": 8, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.24, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 5, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 0.82, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 6.82, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 3, + "construction_age_band": "E", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": "ND", + "roof_insulation_thickness": "ND", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "low_energy_lighting": 100, + "solar_water_heating": "N", + "habitable_room_count": 4, + "heating_cost_current": { + "value": 944, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 3.3, + "energy_rating_average": 60, + "energy_rating_current": 50, + "lighting_cost_current": { + "value": 60, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Appliance thermostats", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "multiple_glazing_type": 2, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 297, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 92, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 122, + "currency": "GBP" + }, + "indicative_cost": "6,000", + "improvement_type": "W2", + "improvement_details": { + "improvement_number": 58 + }, + "improvement_category": 5, + "energy_performance_rating": 55, + "environmental_impact_rating": 63 + }, + { + "sequence": 2, + "typical_saving": { + "value": 519, + "currency": "GBP" + }, + "indicative_cost": "7,000", + "improvement_type": "T", + "improvement_details": { + "improvement_number": 29 + }, + "improvement_category": 5, + "energy_performance_rating": 77, + "environmental_impact_rating": 77 + } + ], + "co2_emissions_potential": 1.8, + "energy_rating_potential": 77, + "lighting_cost_potential": { + "value": 60, + "currency": "GBP" + }, + "schema_version_original": "LIG-18.0", + "alternative_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 515, + "currency": "GBP" + }, + "improvement_type": "J2", + "improvement_details": { + "improvement_number": 54 + }, + "improvement_category": 6, + "energy_performance_rating": 78, + "environmental_impact_rating": 96 + }, + { + "sequence": 2, + "typical_saving": { + "value": 413, + "currency": "GBP" + }, + "improvement_type": "Z1", + "improvement_details": { + "improvement_number": 51 + }, + "improvement_category": 6, + "energy_performance_rating": 77, + "environmental_impact_rating": 79 + }, + { + "sequence": 3, + "typical_saving": { + "value": 500, + "currency": "GBP" + }, + "improvement_type": "Z3", + "improvement_details": { + "improvement_number": 53 + }, + "improvement_category": 6, + "energy_performance_rating": 76, + "environmental_impact_rating": 73 + } + ], + "hot_water_cost_potential": { + "value": 97, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 1519, + "space_heating_existing_dwelling": 5050 + }, + "energy_consumption_current": 250, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "3.08r09", + "energy_consumption_potential": 134, + "environmental_impact_current": 58, + "fixed_lighting_outlets_count": 6, + "current_energy_efficiency_band": "E", + "environmental_impact_potential": 77, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 43, + "low_energy_fixed_lighting_outlets_count": 6 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100040550095/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100040550095/epc.json new file mode 100644 index 000000000..98b566cf4 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100040550095/epc.json @@ -0,0 +1,510 @@ +{ + "uprn": 100040550095, + "roofs": [ + { + "description": "Pitched, 175 mm loft insulation", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": "Granite or whin, as built, no insulation (assumed)", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + }, + { + "description": "Solid brick, as built, no insulation (assumed)", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + } + ], + "floors": [ + { + "description": "Solid, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 3, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + "addendum": { + "stone_walls": "true" + }, + "lighting": { + "description": "Excellent lighting efficiency", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "TQ1 2BW", + "hot_water": { + "description": "Electric immersion, standard tariff", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 5 + }, + "post_town": "TORQUAY", + "built_form": 4, + "created_at": "2026-05-20 13:21:34", + "door_count": 2, + "region_code": 15, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 2, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_outlet_type": 2 + } + ], + "number_baths_wwhrs": 0, + "water_heating_code": 903, + "water_heating_fuel": 29, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 29, + "heat_emitter_type": 0, + "emitter_temperature": "NA", + "main_heating_number": 1, + "main_heating_control": 2603, + "main_heating_category": 10, + "main_heating_fraction": 1, + "sap_main_heating_code": 691, + "main_heating_data_source": 2 + } + ], + "immersion_heating_type": 2, + "cylinder_insulation_type": 2, + "has_fixed_air_conditioning": "false", + "cylinder_insulation_thickness": 38 + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 1, + "window_type": 1, + "glazing_type": 3, + "window_width": 0.91, + "window_height": 1.64, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 1, + "window_type": 1, + "glazing_type": 3, + "window_width": 0.91, + "window_height": 1.64, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 1, + "window_type": 1, + "glazing_type": 3, + "window_width": 0.62, + "window_height": 0.91, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 5, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.2, + "window_height": 0.88, + "draught_proofed": "true", + "window_location": 1, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 5, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.02, + "window_height": 0.82, + "draught_proofed": "true", + "window_location": 1, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 5, + "window_type": 1, + "glazing_type": 3, + "window_width": 0.6, + "window_height": 1.14, + "draught_proofed": "true", + "window_location": 1, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Room heaters, electric", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 5 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Mid-terrace house", + "language_code": 1, + "pressure_test": 4, + "property_type": 0, + "address_line_1": "11 Meadfoot Lane", + "assessment_type": "RdSAP", + "completion_date": "2026-05-20", + "inspection_date": "2026-05-20", + "extensions_count": 1, + "measurement_type": 1, + "total_floor_area": 59, + "transaction_type": 8, + "conservatory_type": 1, + "heated_room_count": 3, + "registration_date": "2026-05-20", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "true", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "true" + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "extract_fans_count": 1, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 510, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 1, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.31, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 17.29, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 7.27, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 4.72, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.35, + "quantity": "metres" + }, + "total_floor_area": { + "value": 17.29, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 7.27, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 4.72, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "A", + "party_wall_construction": 1, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "175mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + }, + { + "identifier": "Extension 1", + "wall_dry_lined": "N", + "wall_thickness": 185, + "floor_heat_loss": 7, + "sap_room_in_roof": { + "floor_area": 12.24, + "room_in_roof_type_2": { + "gable_wall_type_1": 1, + "gable_wall_type_2": 3, + "gable_wall_height_1": 2.5, + "gable_wall_height_2": 2.5, + "gable_wall_length_1": 7.39, + "gable_wall_length_2": 7.39, + "common_wall_height_1": 1.5, + "common_wall_height_2": 1.5, + "common_wall_length_1": 1.4, + "common_wall_length_2": 1.4 + }, + "construction_age_band": "A" + }, + "roof_construction": 5, + "wall_construction": 3, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.27, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 12.24, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 7.39, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 2.8, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "A", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 4, + "roof_insulation_thickness": "ND", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "solar_water_heating": "N", + "habitable_room_count": 3, + "heating_cost_current": { + "value": 1266, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 1.1, + "energy_rating_average": 60, + "energy_rating_current": 40, + "lighting_cost_current": { + "value": 43, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer and appliance thermostats", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 366, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 679, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 449, + "currency": "GBP" + }, + "indicative_cost": "\u00a37,500 - \u00a311,000", + "improvement_type": "Q", + "improvement_details": { + "improvement_number": 7 + }, + "improvement_category": 5, + "energy_performance_rating": 55, + "environmental_impact_rating": 88 + }, + { + "sequence": 2, + "typical_saving": { + "value": 57, + "currency": "GBP" + }, + "indicative_cost": "\u00a35,000 - \u00a310,000", + "improvement_type": "W2", + "improvement_details": { + "improvement_number": 58 + }, + "improvement_category": 5, + "energy_performance_rating": 56, + "environmental_impact_rating": 89 + }, + { + "sequence": 3, + "typical_saving": { + "value": 100, + "currency": "GBP" + }, + "indicative_cost": "\u00a320 - \u00a340", + "improvement_type": "C", + "improvement_details": { + "improvement_number": 2 + }, + "improvement_category": 5, + "energy_performance_rating": 59, + "environmental_impact_rating": 89 + }, + { + "sequence": 4, + "typical_saving": { + "value": 671, + "currency": "GBP" + }, + "indicative_cost": "\u00a33,500 - \u00a310,000", + "improvement_type": "T", + "improvement_details": { + "improvement_number": 29 + }, + "improvement_category": 5, + "energy_performance_rating": 76, + "environmental_impact_rating": 83 + }, + { + "sequence": 5, + "typical_saving": { + "value": 220, + "currency": "GBP" + }, + "indicative_cost": "\u00a38,000 - \u00a310,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 81, + "environmental_impact_rating": 84 + } + ], + "co2_emissions_potential": 1.1, + "energy_rating_potential": 81, + "lighting_cost_potential": { + "value": 43, + "currency": "GBP" + }, + "schema_version_original": "21.0.1", + "alternative_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 822, + "currency": "GBP" + }, + "improvement_type": "J2", + "improvement_details": { + "improvement_number": 54 + }, + "improvement_category": 6, + "energy_performance_rating": 79, + "environmental_impact_rating": 96 + }, + { + "sequence": 2, + "typical_saving": { + "value": 524, + "currency": "GBP" + }, + "improvement_type": "Z3", + "improvement_details": { + "improvement_number": 53 + }, + "improvement_category": 6, + "energy_performance_rating": 71, + "environmental_impact_rating": 74 + } + ], + "hot_water_cost_potential": { + "value": 301, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2452.35, + "space_heating_existing_dwelling": 4576.09 + }, + "draughtproofed_door_count": 2, + "energy_consumption_current": 189, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "5.02r0344", + "energy_consumption_potential": 102, + "environmental_impact_current": 84, + "current_energy_efficiency_band": "E", + "environmental_impact_potential": 84, + "led_fixed_lighting_bulbs_count": 7, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 18, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100050355518/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100050355518/epc.json new file mode 100644 index 000000000..1b70bbe6c --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100050355518/epc.json @@ -0,0 +1,700 @@ +{ + "uprn": 100050355518, + "roofs": [ + { + "description": "Pitched, 200 mm loft insulation", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + { + "description": "Pitched, limited insulation", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + } + ], + "walls": [ + { + "description": "Cavity wall, as built, no insulation (assumed)", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + } + ], + "floors": [ + { + "description": "Suspended, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "window": { + "description": "Partial double glazing", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + }, + "addendum": { + "addendum_numbers": [ + 15 + ], + "cavity_fill_recommended": "true" + }, + "lighting": { + "description": "Below average lighting efficiency", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "postcode": "BD23 1BG", + "hot_water": { + "description": "From main system, no cylinder thermostat", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + "post_town": "SKIPTON", + "psv_count": 0, + "built_form": 2, + "created_at": "2026-05-18 08:00:25", + "door_count": 1, + "region_code": 7, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 2, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_outlet_type": 2 + }, + { + "shower_wwhrs": 1, + "shower_outlet_type": 2 + } + ], + "water_heating_code": 901, + "water_heating_fuel": 26, + "cylinder_thermostat": "N", + "secondary_fuel_type": 5, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "N", + "heat_emitter_type": 1, + "main_heating_number": 1, + "main_heating_control": 2102, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 117, + "central_heating_pump_age": 0, + "main_heating_data_source": 2 + } + ], + "immersion_heating_type": "NA", + "secondary_heating_type": 631, + "cylinder_insulation_type": 2, + "has_fixed_air_conditioning": "false", + "cylinder_insulation_thickness": 80 + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 5, + "window_width": 0.4, + "window_height": 1.18, + "draught_proofed": "false", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 5, + "window_width": 5.03, + "window_height": 1.72, + "draught_proofed": "false", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 5, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.19, + "window_height": 1, + "draught_proofed": "false", + "window_location": 2, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 5, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.74, + "window_height": 2.05, + "draught_proofed": "false", + "window_location": 2, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 5, + "window_type": 2, + "glazing_type": 3, + "window_width": 0.6, + "window_height": 0.9, + "draught_proofed": "false", + "window_location": 2, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 5, + "window_type": 2, + "glazing_type": 3, + "window_width": 0.6, + "window_height": 0.9, + "draught_proofed": "false", + "window_location": 2, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 1, + "window_type": 1, + "glazing_type": 3, + "window_width": 2.34, + "window_height": 1.18, + "draught_proofed": "false", + "window_location": 1, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 5, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.79, + "window_height": 2.06, + "draught_proofed": "false", + "window_location": 2, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 1, + "window_type": 1, + "glazing_type": 5, + "window_width": 5.03, + "window_height": 1.23, + "draught_proofed": "false", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 1, + "window_type": 1, + "glazing_type": 5, + "window_width": 1.11, + "window_height": 1.17, + "draught_proofed": "false", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 5, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.19, + "window_height": 1, + "draught_proofed": "false", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 1, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.18, + "window_height": 1.15, + "draught_proofed": "false", + "window_location": 1, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 5, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.1, + "window_height": 1, + "draught_proofed": "false", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 5, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.66, + "window_height": 1.17, + "draught_proofed": "false", + "window_location": 1, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Semi-detached house", + "language_code": 1, + "pressure_test": 4, + "property_type": 0, + "address_line_1": "26 Regent Crescent", + "assessment_type": "RdSAP", + "completion_date": "2026-05-18", + "inspection_date": "2026-05-15", + "extensions_count": 2, + "measurement_type": 1, + "open_flues_count": 0, + "total_floor_area": 122, + "transaction_type": 15, + "conservatory_type": 1, + "has_draught_lobby": "true", + "heated_room_count": 5, + "other_flues_count": 0, + "registration_date": "2026-05-18", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_diverter": "false", + "pv_connection": 0, + "pv_battery_count": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "false", + "is_hydro_output_connected_to_dwelling_meter": "false" + }, + "secondary_heating": { + "description": "Room heaters, anthracite", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "closed_flues_count": 0, + "extract_fans_count": 1, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 230, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.53, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 35.63, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 6.09, + "quantity": "metres" + }, + "floor_construction": 2, + "heat_loss_perimeter": { + "value": 7.95, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.38, + "quantity": "metres" + }, + "total_floor_area": { + "value": 35.63, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 6.09, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 13.37, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "C", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "200mm", + "wall_insulation_thickness": "NI" + }, + { + "identifier": "Extension 1", + "wall_dry_lined": "N", + "wall_thickness": 320, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.29, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 15.62, + "quantity": "square metres" + }, + "party_wall_length": 0, + "floor_construction": 2, + "heat_loss_perimeter": { + "value": 7.95, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.24, + "quantity": "metres" + }, + "total_floor_area": { + "value": 15.62, + "quantity": "square metres" + }, + "party_wall_length": 0, + "heat_loss_perimeter": { + "value": 11.19, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "C", + "party_wall_construction": "NA", + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "200mm", + "wall_insulation_thickness": "NI" + }, + { + "identifier": "Extension 2", + "wall_dry_lined": "N", + "wall_thickness": 320, + "floor_heat_loss": 7, + "roof_construction": 8, + "wall_construction": 4, + "building_part_number": 3, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.86, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 19.28, + "quantity": "square metres" + }, + "party_wall_length": 0, + "floor_construction": 2, + "heat_loss_perimeter": { + "value": 12.66, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "C", + "party_wall_construction": "NA", + "wall_thickness_measured": "Y", + "roof_insulation_location": 7, + "wall_insulation_thickness": "NI", + "sloping_ceiling_insulation_thickness": "AB" + } + ], + "boilers_flues_count": 0, + "open_chimneys_count": 0, + "solar_water_heating": "N", + "habitable_room_count": 5, + "heating_cost_current": { + "value": 3523, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 12, + "energy_rating_average": 60, + "energy_rating_current": 36, + "lighting_cost_current": { + "value": 84, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, no room thermostat", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + } + ], + "blocked_chimneys_count": 1, + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 1387, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 574, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 0, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 260, + "indicative_cost": "\u00a3900 - \u00a31,200", + "improvement_type": "A2", + "improvement_details": { + "improvement_number": 45 + }, + "improvement_category": 5, + "energy_performance_rating": 39, + "environmental_impact_rating": 30 + }, + { + "sequence": 2, + "typical_saving": 606, + "indicative_cost": "\u00a3900 - \u00a31,500", + "improvement_type": "B", + "improvement_details": { + "improvement_number": 6 + }, + "improvement_category": 5, + "energy_performance_rating": 47, + "environmental_impact_rating": 38 + }, + { + "sequence": 3, + "typical_saving": 251, + "indicative_cost": "\u00a35,000 - \u00a310,000", + "improvement_type": "W1", + "improvement_details": { + "improvement_number": 57 + }, + "improvement_category": 5, + "energy_performance_rating": 51, + "environmental_impact_rating": 42 + }, + { + "sequence": 4, + "typical_saving": 134, + "indicative_cost": "\u00a3150 - \u00a3250", + "improvement_type": "D", + "improvement_details": { + "improvement_number": 10 + }, + "improvement_category": 5, + "energy_performance_rating": 53, + "environmental_impact_rating": 44 + }, + { + "sequence": 5, + "typical_saving": 276, + "indicative_cost": "\u00a3220 - \u00a3250", + "improvement_type": "G", + "improvement_details": { + "improvement_number": 12 + }, + "improvement_category": 5, + "energy_performance_rating": 57, + "environmental_impact_rating": 49 + }, + { + "sequence": 6, + "typical_saving": 629, + "indicative_cost": "\u00a32,200 - \u00a33,500", + "improvement_type": "I", + "improvement_details": { + "improvement_number": 20 + }, + "improvement_category": 5, + "energy_performance_rating": 67, + "environmental_impact_rating": 61 + }, + { + "sequence": 7, + "typical_saving": 197, + "indicative_cost": "\u00a34,500 - \u00a36,000", + "improvement_type": "O", + "improvement_details": { + "improvement_number": 8 + }, + "improvement_category": 5, + "energy_performance_rating": 70, + "environmental_impact_rating": 66 + }, + { + "sequence": 8, + "typical_saving": 233, + "indicative_cost": "\u00a38,000 - \u00a310,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 74, + "environmental_impact_rating": 67 + } + ], + "co2_emissions_potential": 4.6, + "energy_rating_potential": 74, + "lighting_cost_potential": { + "value": 84, + "currency": "GBP" + }, + "schema_version_original": "LIG-21.0.1", + "alternative_improvements": [ + { + "sequence": 1, + "typical_saving": 172, + "improvement_type": "Q2", + "improvement_details": { + "improvement_number": 55 + }, + "improvement_category": 6, + "energy_performance_rating": 50, + "environmental_impact_rating": 40 + }, + { + "sequence": 2, + "typical_saving": 774, + "improvement_type": "J2", + "improvement_details": { + "improvement_number": 54 + }, + "improvement_category": 6, + "energy_performance_rating": 64, + "environmental_impact_rating": 82 + }, + { + "sequence": 3, + "typical_saving": 828, + "improvement_type": "Z3", + "improvement_details": { + "improvement_number": 53 + }, + "improvement_category": 6, + "energy_performance_rating": 69, + "environmental_impact_rating": 66 + } + ], + "flueless_gas_fires_count": 0, + "hot_water_cost_potential": { + "value": 358, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 3736.92, + "space_heating_existing_dwelling": 24523.93 + }, + "draughtproofed_door_count": 0, + "energy_consumption_current": 466, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 52, + "calculation_software_version": "4.3.74", + "energy_consumption_potential": 170, + "environmental_impact_current": 27, + "current_energy_efficiency_band": "F", + "environmental_impact_potential": 67, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 98, + "low_energy_fixed_lighting_bulbs_count": 17, + "incandescent_fixed_lighting_bulbs_count": 4 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100050881708/elmhurst_summary.pdf b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100050881708/elmhurst_summary.pdf new file mode 100644 index 000000000..28a0ee26d Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100050881708/elmhurst_summary.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100050881708/elmhurst_worksheet.pdf b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100050881708/elmhurst_worksheet.pdf new file mode 100644 index 000000000..418feb6de Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100050881708/elmhurst_worksheet.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100050881708/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100050881708/epc.json new file mode 100644 index 000000000..100e4eb06 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100050881708/epc.json @@ -0,0 +1,426 @@ +{ + "uprn": 100050881708, + "roofs": [ + { + "description": "Pitched, 225 mm loft insulation", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": "Cavity wall, filled cavity", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "To unheated space, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 3, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": "Below average lighting efficiency", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + "postcode": "S65 3RW", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "ROTHERHAM", + "built_form": 2, + "created_at": "2026-05-27 15:09:39", + "door_count": 2, + "region_code": 3, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 1, + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 26, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 17741 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "orientation": 7, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.7, + "window_height": 1.4, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 7, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.2, + "window_height": 1, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 7, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.6, + "window_height": 1, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.6, + "window_height": 0.7, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.6, + "window_height": 1, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.2, + "window_height": 1, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.2, + "window_height": 1, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.1, + "window_height": 0.8, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.6, + "window_height": 0.7, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": 0.6, + "window_height": 1.1, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Semi-detached house", + "language_code": 1, + "pressure_test": 4, + "property_type": 0, + "address_line_1": "9 Woodgrove Road", + "assessment_type": "RdSAP", + "completion_date": "2026-05-27", + "inspection_date": "2026-05-27", + "extensions_count": 0, + "measurement_type": 1, + "total_floor_area": 63, + "transaction_type": 14, + "conservatory_type": 1, + "heated_room_count": 3, + "registration_date": "2026-05-27", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "true", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "true" + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "extract_fans_count": 2, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "floor_heat_loss": 2, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.4, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 31.62, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 6.2, + "quantity": "metres" + }, + "floor_construction": 2, + "heat_loss_perimeter": { + "value": 16.4, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.41, + "quantity": "metres" + }, + "total_floor_area": { + "value": 31.62, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 6.2, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 16.4, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 2, + "construction_age_band": "B", + "party_wall_construction": 1, + "wall_thickness_measured": "N", + "roof_insulation_location": 2, + "roof_insulation_thickness": "225mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "solar_water_heating": "N", + "habitable_room_count": 3, + "heating_cost_current": { + "value": 777, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.3, + "energy_rating_average": 60, + "energy_rating_current": 70, + "lighting_cost_current": { + "value": 79, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 671, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 172, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 110, + "currency": "GBP" + }, + "indicative_cost": "\u00a35,000 - \u00a310,000", + "improvement_type": "W1", + "improvement_details": { + "improvement_number": 57 + }, + "improvement_category": 5, + "energy_performance_rating": 73, + "environmental_impact_rating": 75 + }, + { + "sequence": 2, + "typical_saving": { + "value": 30, + "currency": "GBP" + }, + "indicative_cost": "\u00a3120 - \u00a3140", + "improvement_type": "E", + "improvement_details": { + "improvement_number": 35 + }, + "improvement_category": 5, + "energy_performance_rating": 74, + "environmental_impact_rating": 75 + }, + { + "sequence": 3, + "typical_saving": { + "value": 184, + "currency": "GBP" + }, + "indicative_cost": "\u00a38,000 - \u00a310,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 79, + "environmental_impact_rating": 77 + } + ], + "co2_emissions_potential": 1.9, + "energy_rating_potential": 79, + "lighting_cost_potential": { + "value": 45, + "currency": "GBP" + }, + "schema_version_original": "21.0.1", + "hot_water_cost_potential": { + "value": 172, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2070.91, + "space_heating_existing_dwelling": 7543.77 + }, + "draughtproofed_door_count": 2, + "energy_consumption_current": 205, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "5.02r0344", + "energy_consumption_potential": 161, + "environmental_impact_current": 71, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 77, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 37, + "low_energy_fixed_lighting_bulbs_count": 4, + "incandescent_fixed_lighting_bulbs_count": 4 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100051051866/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100051051866/epc.json new file mode 100644 index 000000000..4e226b29f --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100051051866/epc.json @@ -0,0 +1,351 @@ +{ + "uprn": 100051051866, + "roofs": [ + { + "description": "Pitched, insulated (assumed)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": "Cavity wall, as built, insulated (assumed)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Suspended, limited insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "addendum": { + "addendum_numbers": [ + 15 + ] + }, + "lighting": { + "description": "Excellent lighting efficiency", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "S20 7NQ", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "SHEFFIELD", + "psv_count": 0, + "built_form": 1, + "created_at": "2026-05-18 17:37:45", + "door_count": 1, + "region_code": 11, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 2, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_outlet_type": 4 + } + ], + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 26, + "cylinder_thermostat": "Y", + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 1, + "main_heating_number": 1, + "main_heating_control": 2104, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 102, + "central_heating_pump_age": 0, + "main_heating_data_source": 2, + "main_heating_index_number": 18773 + } + ], + "immersion_heating_type": "NA", + "cylinder_insulation_type": 1, + "has_fixed_air_conditioning": "false", + "cylinder_insulation_thickness": 50 + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "orientation": 7, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1.6, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 7, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1.2, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 7, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1.6, + "quantity": "m" + }, + "window_height": { + "value": 1.6, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1.6, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 0.5, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Detached bungalow", + "language_code": 1, + "pressure_test": 4, + "property_type": 1, + "address_line_1": "3 Pentland Gardens", + "address_line_2": "Waterthorpe", + "assessment_type": "RdSAP", + "completion_date": "2026-05-18", + "inspection_date": "2026-05-18", + "extensions_count": 0, + "measurement_type": 1, + "open_flues_count": 0, + "total_floor_area": 60, + "transaction_type": 1, + "conservatory_type": 2, + "heated_room_count": 4, + "other_flues_count": 0, + "registration_date": "2026-05-18", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 3, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "false" + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "closed_flues_count": 0, + "extract_fans_count": 0, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": 2.4, + "floor_insulation": 1, + "total_floor_area": 60, + "party_wall_length": 0, + "floor_construction": 2, + "heat_loss_perimeter": 32 + } + ], + "wall_insulation_type": 4, + "construction_age_band": "I", + "party_wall_construction": "NA", + "wall_thickness_measured": "Y", + "roof_insulation_location": 4, + "roof_insulation_thickness": "NI" + } + ], + "boilers_flues_count": 0, + "open_chimneys_count": 0, + "solar_water_heating": "N", + "habitable_room_count": 4, + "heating_cost_current": { + "value": 701, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.3, + "energy_rating_average": 60, + "energy_rating_current": 70, + "lighting_cost_current": { + "value": 40, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer and room thermostat", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "blocked_chimneys_count": 0, + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 701, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 235, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "schema_version_current": "LIG-21.0", + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 238, + "indicative_cost": "\u00a38,000 - \u00a310,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 82, + "environmental_impact_rating": 73 + } + ], + "co2_emissions_potential": 2.1, + "energy_rating_potential": 82, + "lighting_cost_potential": { + "value": 40, + "currency": "GBP" + }, + "schema_version_original": "LIG-21.0", + "hot_water_cost_potential": { + "value": 235, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2544.67, + "space_heating_existing_dwelling": 6292.1 + }, + "draughtproofed_door_count": 1, + "energy_consumption_current": 209, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "10.2.2.0", + "energy_consumption_potential": 185, + "environmental_impact_current": 70, + "cfl_fixed_lighting_bulbs_count": 0, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 73, + "led_fixed_lighting_bulbs_count": 8, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 38, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100061275133/elmhurst_summary.pdf b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100061275133/elmhurst_summary.pdf new file mode 100644 index 000000000..4a604044a Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100061275133/elmhurst_summary.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100061275133/elmhurst_worksheet.pdf b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100061275133/elmhurst_worksheet.pdf new file mode 100644 index 000000000..9bb10c008 Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100061275133/elmhurst_worksheet.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100061275133/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100061275133/epc.json new file mode 100644 index 000000000..43225f661 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_100061275133/epc.json @@ -0,0 +1,410 @@ +{ + "uprn": 100061275133, + "roofs": [ + { + "description": "Pitched, 300 mm loft insulation", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "walls": [ + { + "description": "Solid brick, with external insulation", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Suspended, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "window": { + "description": "High performance glazing", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "lighting": { + "description": "Excellent lighting efficiency", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "ME7 2ES", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "GILLINGHAM", + "built_form": 2, + "created_at": "2026-05-20 08:39:48", + "door_count": 2, + "region_code": 14, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 1, + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 26, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 18908 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "orientation": 8, + "window_type": 1, + "glazing_type": 13, + "window_width": 1.19, + "window_height": 1.13, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "orientation": 8, + "window_type": 1, + "glazing_type": 13, + "window_width": 0.57, + "window_height": 0.55, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "orientation": 8, + "window_type": 1, + "glazing_type": 13, + "window_width": 1.19, + "window_height": 1.11, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "orientation": 2, + "window_type": 1, + "glazing_type": 13, + "window_width": 1.05, + "window_height": 0.85, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "orientation": 2, + "window_type": 1, + "glazing_type": 13, + "window_width": 0.57, + "window_height": 0.56, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "orientation": 4, + "window_type": 1, + "glazing_type": 13, + "window_width": 2.11, + "window_height": 1.13, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "orientation": 4, + "window_type": 1, + "glazing_type": 13, + "window_width": 1.8, + "window_height": 0.97, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "orientation": 4, + "window_type": 1, + "glazing_type": 13, + "window_width": 1.8, + "window_height": 0.97, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "orientation": 4, + "window_type": 1, + "glazing_type": 13, + "window_width": 1.2, + "window_height": 0.99, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "orientation": 8, + "window_type": 1, + "glazing_type": 13, + "window_width": 1.8, + "window_height": 0.97, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "orientation": 8, + "window_type": 1, + "glazing_type": 13, + "window_width": 1.19, + "window_height": 0.97, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "orientation": 8, + "window_type": 1, + "glazing_type": 13, + "window_width": 1.18, + "window_height": 0.99, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Semi-detached house", + "language_code": 1, + "pressure_test": 4, + "property_type": 0, + "address_line_1": "75 Toronto Road", + "assessment_type": "RdSAP", + "completion_date": "2026-05-20", + "inspection_date": "2026-05-19", + "extensions_count": 0, + "measurement_type": 1, + "total_floor_area": 78, + "transaction_type": 8, + "conservatory_type": 1, + "heated_room_count": 3, + "registration_date": "2026-05-20", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "true", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "true" + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 400, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 3, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.4, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 38.99, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 5.09, + "quantity": "metres" + }, + "floor_construction": 0, + "heat_loss_perimeter": { + "value": 17.92, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.4, + "quantity": "metres" + }, + "total_floor_area": { + "value": 38.99, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 5.09, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 17.92, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 1, + "construction_age_band": "B", + "party_wall_construction": 1, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "300mm", + "wall_insulation_thickness": "100mm", + "floor_insulation_thickness": "NI" + } + ], + "solar_water_heating": "N", + "habitable_room_count": 3, + "heating_cost_current": { + "value": 608, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 1.9, + "energy_rating_average": 60, + "energy_rating_current": 76, + "lighting_cost_current": { + "value": 58, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 608, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 182, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 216, + "currency": "GBP" + }, + "indicative_cost": "\u00a38,000 - \u00a310,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 81, + "environmental_impact_rating": 79 + } + ], + "co2_emissions_potential": 1.8, + "energy_rating_potential": 81, + "lighting_cost_potential": { + "value": 58, + "currency": "GBP" + }, + "schema_version_original": "21.0.1", + "hot_water_cost_potential": { + "value": 182, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2203.16, + "space_heating_existing_dwelling": 5517.85 + }, + "draughtproofed_door_count": 2, + "energy_consumption_current": 133, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "5.02r0344", + "energy_consumption_potential": 118, + "environmental_impact_current": 78, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 79, + "led_fixed_lighting_bulbs_count": 5, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 24, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_10012334488/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_10012334488/epc.json new file mode 100644 index 000000000..59f78d7aa --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_10012334488/epc.json @@ -0,0 +1,337 @@ +{ + "uprn": 10012334488, + "roofs": [ + { + "description": "(another dwelling above)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "walls": [ + { + "description": "System built, as built, insulated (assumed)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "(another dwelling below)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 3, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": "Excellent lighting efficiency", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "SL4 3BP", + "hot_water": { + "description": "From main system, no cylinder thermostat", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 4 + }, + "post_town": "WINDSOR", + "built_form": "NR", + "created_at": "2026-05-14 18:33:57", + "door_count": 1, + "region_code": 17, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 0, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_outlet_type": 1 + } + ], + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 29, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 29, + "heat_emitter_type": 2, + "emitter_temperature": "NA", + "main_heating_number": 1, + "main_heating_control": 2110, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 195, + "central_heating_pump_age": 0, + "main_heating_data_source": 2 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 2, + "glazing_type": 2, + "window_width": 0.85, + "window_height": 1.11, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 2, + "glazing_type": 2, + "window_width": 0.85, + "window_height": 1.11, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 2, + "glazing_type": 2, + "window_width": 0.85, + "window_height": 1.11, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 2, + "glazing_type": 2, + "window_width": 0.85, + "window_height": 1.11, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 2, + "glazing_type": 2, + "window_width": 0.85, + "window_height": 1.11, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 2, + "glazing_type": 2, + "window_width": 0.85, + "window_height": 1.11, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and underfloor heating, electric", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 5 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Mid-floor flat", + "language_code": 1, + "pressure_test": 4, + "property_type": 2, + "address_line_1": "Flat 12", + "address_line_2": "Alexandra Court 25a", + "address_line_3": "St. Leonards Road", + "assessment_type": "RdSAP", + "completion_date": "2026-05-14", + "inspection_date": "2026-05-14", + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 2, + "top_storey": "N", + "storey_count": 4, + "flat_location": 3, + "heat_loss_corridor": 1 + }, + "total_floor_area": 62, + "transaction_type": 14, + "conservatory_type": 1, + "has_draught_lobby": "true", + "heated_room_count": 2, + "registration_date": "2026-05-14", + "sap_energy_source": { + "mains_gas": "N", + "meter_type": 1, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 1, + "electricity_smart_meter_present": "false" + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "extract_fans_count": 2, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 260, + "floor_heat_loss": 6, + "roof_construction": 3, + "wall_construction": 8, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.48, + "quantity": "metres" + }, + "total_floor_area": { + "value": 62, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 23.99, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 11.89, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "L", + "party_wall_construction": 1, + "wall_thickness_measured": "Y", + "roof_insulation_location": "ND", + "roof_insulation_thickness": "ND", + "wall_insulation_thickness": "NI" + } + ], + "solar_water_heating": "N", + "habitable_room_count": 2, + "heating_cost_current": { + "value": 136, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 0.5, + "energy_rating_average": 60, + "energy_rating_current": 72, + "lighting_cost_current": { + "value": 51, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Time and temperature zone control", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 136, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 893, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 91, + "currency": "GBP" + }, + "indicative_cost": "\u00a3600 - \u00a31,500", + "improvement_type": "Y", + "improvement_details": { + "improvement_number": 49 + }, + "improvement_category": 5, + "energy_performance_rating": 74, + "environmental_impact_rating": 93 + } + ], + "co2_emissions_potential": 0.5, + "energy_rating_potential": 74, + "lighting_cost_potential": { + "value": 51, + "currency": "GBP" + }, + "schema_version_original": "21.0.1", + "hot_water_cost_potential": { + "value": 802, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2802.07, + "space_heating_existing_dwelling": 628.72 + }, + "draughtproofed_door_count": 1, + "energy_consumption_current": 92, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "5.02r0344", + "energy_consumption_potential": 85, + "environmental_impact_current": 93, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 93, + "led_fixed_lighting_bulbs_count": 23, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 9, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_10070056075/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_10070056075/epc.json new file mode 100644 index 000000000..cc408d73d --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_10070056075/epc.json @@ -0,0 +1,491 @@ +{ + "uprn": 10070056075, + "roofs": [ + { + "description": "Pitched, insulated", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": "Cavity wall, as built, insulated (assumed)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Suspended, insulated (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "addendum": { + "addendum_numbers": [ + 15 + ] + }, + "lighting": { + "description": "Excellent lighting efficiency", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "BD10 9UG", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "BRADFORD", + "psv_count": 0, + "built_form": 1, + "created_at": "2026-05-15 10:22:34", + "door_count": 1, + "region_code": 11, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 4, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_outlet_type": 4 + } + ], + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 26, + "cylinder_thermostat": "Y", + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 1, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 19012 + } + ], + "immersion_heating_type": "NA", + "cylinder_insulation_type": 1, + "has_fixed_air_conditioning": "false", + "cylinder_insulation_thickness": 80 + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1, + "quantity": "m" + }, + "window_height": { + "value": 1.2, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Detached house", + "language_code": 1, + "pressure_test": 4, + "property_type": 0, + "address_line_1": "36 Longlands", + "address_line_2": "Idle", + "assessment_type": "RdSAP", + "completion_date": "2026-05-15", + "inspection_date": "2026-05-08", + "extensions_count": 0, + "measurement_type": 1, + "open_flues_count": 0, + "total_floor_area": 139, + "transaction_type": 5, + "conservatory_type": 1, + "heated_room_count": 8, + "other_flues_count": 0, + "registration_date": "2026-05-15", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 1, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "false" + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "closed_flues_count": 0, + "extract_fans_count": 0, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 400, + "floor_heat_loss": 7, + "roof_construction": 8, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": 2.3, + "floor_insulation": 1, + "total_floor_area": 69.6, + "party_wall_length": 0, + "floor_construction": 2, + "heat_loss_perimeter": 37.7 + }, + { + "floor": 1, + "room_height": 2.3, + "total_floor_area": 69.6, + "party_wall_length": 0, + "heat_loss_perimeter": 37.7 + } + ], + "wall_insulation_type": 4, + "construction_age_band": "K", + "party_wall_construction": "NA", + "wall_thickness_measured": "N", + "roof_insulation_location": 7, + "wall_insulation_thickness": "NI", + "sloping_ceiling_insulation_thickness": "AB" + } + ], + "boilers_flues_count": 0, + "open_chimneys_count": 0, + "solar_water_heating": "N", + "habitable_room_count": 8, + "heating_cost_current": { + "value": 938, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 3.0, + "energy_rating_average": 60, + "energy_rating_current": 80, + "lighting_cost_current": { + "value": 89, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "blocked_chimneys_count": 0, + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 938, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 236, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "schema_version_current": "LIG-21.0", + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 308, + "indicative_cost": "\u00a38,000 - \u00a310,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 87, + "environmental_impact_rating": 81 + } + ], + "co2_emissions_potential": 2.8, + "energy_rating_potential": 87, + "lighting_cost_potential": { + "value": 89, + "currency": "GBP" + }, + "schema_version_original": "LIG-21.0", + "hot_water_cost_potential": { + "value": 236, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2744.25, + "space_heating_existing_dwelling": 9473.16 + }, + "draughtproofed_door_count": 1, + "energy_consumption_current": 117, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "10.2.2.0", + "energy_consumption_potential": 106, + "environmental_impact_current": 79, + "cfl_fixed_lighting_bulbs_count": 0, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 81, + "led_fixed_lighting_bulbs_count": 18, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 21, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_10091578598/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_10091578598/epc.json new file mode 100644 index 000000000..04774b623 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_10091578598/epc.json @@ -0,0 +1,316 @@ +{ + "uprn": 10091578598, + "roofs": [ + { + "description": "(another dwelling above)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "walls": [ + { + "description": "Cavity wall, as built, insulated (assumed)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "(another dwelling below)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": "Good lighting efficiency", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "RM1 3FB", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 5 + }, + "post_town": "ROMFORD", + "built_form": "NR", + "created_at": "2026-05-16 17:47:19", + "door_count": 1, + "region_code": 2, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 4, + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 29, + "cylinder_thermostat": "Y", + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 29, + "heat_emitter_type": 1, + "emitter_temperature": "NA", + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 195, + "central_heating_pump_age": 0, + "main_heating_data_source": 2 + } + ], + "immersion_heating_type": "NA", + "cylinder_insulation_type": 1, + "has_fixed_air_conditioning": "false", + "cylinder_insulation_thickness": 50 + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.15, + "window_height": 1.65, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.15, + "window_height": 1.65, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.15, + "window_height": 1.65, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 2, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.15, + "window_height": 1.65, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, electric", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 5 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Mid-floor flat", + "language_code": 1, + "pressure_test": 4, + "property_type": 2, + "address_line_1": "41 Verve Apartments", + "address_line_2": "5 Mercury Gardens", + "assessment_type": "RdSAP", + "completion_date": "2026-05-16", + "inspection_date": "2026-05-16", + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 2, + "top_storey": "N", + "storey_count": 6, + "flat_location": 3, + "heat_loss_corridor": 2, + "unheated_corridor_length": 6 + }, + "total_floor_area": 60, + "transaction_type": 8, + "conservatory_type": 1, + "heated_room_count": 2, + "registration_date": "2026-05-16", + "sap_energy_source": { + "mains_gas": "N", + "meter_type": 4, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "true" + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 6, + "roof_construction": 3, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.5, + "quantity": "metres" + }, + "total_floor_area": { + "value": 60, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 20, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 12, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "I", + "sap_alternative_wall_1": { + "wall_area": 15, + "sheltered_wall": "Y", + "wall_dry_lined": "N", + "wall_thickness": 300, + "wall_construction": 4, + "wall_insulation_type": 4, + "wall_thickness_measured": "Y", + "wall_insulation_thickness": "NI" + }, + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": "ND", + "roof_insulation_thickness": "ND", + "wall_insulation_thickness": "NI" + } + ], + "solar_water_heating": "N", + "habitable_room_count": 2, + "heating_cost_current": { + "value": 202, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 0.6, + "energy_rating_average": 60, + "energy_rating_current": 71, + "lighting_cost_current": { + "value": 55, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 370, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 847, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 80, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 76, + "currency": "GBP" + }, + "indicative_cost": "\u00a3150 - \u00a3250", + "improvement_type": "D", + "improvement_details": { + "improvement_number": 10 + }, + "improvement_category": 5, + "energy_performance_rating": 79, + "environmental_impact_rating": 92 + } + ], + "co2_emissions_potential": 0.6, + "energy_rating_potential": 79, + "lighting_cost_potential": { + "value": 48, + "currency": "GBP" + }, + "schema_version_original": "21.0.1", + "hot_water_cost_potential": { + "value": 611, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2659.46, + "space_heating_existing_dwelling": 1061.64 + }, + "draughtproofed_door_count": 0, + "energy_consumption_current": 102, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "5.02r0344", + "energy_consumption_potential": 102, + "environmental_impact_current": 92, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 92, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 10, + "low_energy_fixed_lighting_bulbs_count": 20, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_10094975827/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_10094975827/epc.json new file mode 100644 index 000000000..5b68ee3b0 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_10094975827/epc.json @@ -0,0 +1,366 @@ +{ + "uprn": 10094975827, + "roofs": [ + { + "description": "Pitched, insulated (assumed)", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "walls": [ + { + "description": "Solid brick, as built, no insulation (assumed)", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + } + ], + "floors": [ + { + "description": "(other premises below)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 3, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + "lighting": { + "description": "Excellent lighting efficiency", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "B8 2NG", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "BIRMINGHAM", + "psv_count": 0, + "built_form": 3, + "created_at": "2026-05-14 13:27:59", + "door_count": 1, + "region_code": 6, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 1, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_outlet_type": 1 + } + ], + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 26, + "cylinder_thermostat": "N", + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 1, + "main_heating_number": 1, + "main_heating_control": 2107, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 18559 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 1, + "window_type": 1, + "glazing_type": 3, + "window_width": { + "value": 1.01, + "quantity": "m" + }, + "window_height": { + "value": 1.61, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 1, + "window_type": 1, + "glazing_type": 3, + "window_width": { + "value": 1.66, + "quantity": "m" + }, + "window_height": { + "value": 1.46, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 3, + "window_type": 1, + "glazing_type": 3, + "window_width": { + "value": 1.09, + "quantity": "m" + }, + "window_height": { + "value": 1.29, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 3, + "window_type": 1, + "glazing_type": 3, + "window_width": { + "value": 1.09, + "quantity": "m" + }, + "window_height": { + "value": 1.55, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 3, + "window_type": 1, + "glazing_type": 3, + "window_width": { + "value": 1.79, + "quantity": "m" + }, + "window_height": { + "value": 1.92, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Top-floor flat", + "language_code": 1, + "pressure_test": 4, + "property_type": 2, + "address_line_1": "Flat 2", + "address_line_2": "874 Washwood Heath Road", + "assessment_type": "RdSAP", + "completion_date": "2026-05-14", + "inspection_date": "2026-05-13", + "extensions_count": 0, + "measurement_type": 1, + "open_flues_count": 0, + "sap_flat_details": { + "level": 3, + "top_storey": "N", + "storey_count": 2, + "flat_location": 1, + "heat_loss_corridor": 0 + }, + "total_floor_area": 78, + "transaction_type": 8, + "conservatory_type": 1, + "heated_room_count": 3, + "other_flues_count": 0, + "registration_date": "2026-05-14", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 3, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "false" + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "closed_flues_count": 0, + "extract_fans_count": 0, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 340, + "floor_heat_loss": 3, + "roof_construction": 4, + "wall_construction": 3, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": 2.44, + "total_floor_area": 77.87, + "party_wall_length": 11.57, + "heat_loss_perimeter": 25.03 + } + ], + "wall_insulation_type": 4, + "construction_age_band": "B", + "party_wall_construction": 1, + "wall_thickness_measured": "Y", + "roof_insulation_location": 4, + "roof_insulation_thickness": "NI", + "wall_insulation_thickness": "NI" + } + ], + "boilers_flues_count": 0, + "open_chimneys_count": 0, + "solar_water_heating": "N", + "habitable_room_count": 3, + "heating_cost_current": { + "value": 952, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 3.2, + "energy_rating_average": 60, + "energy_rating_current": 66, + "lighting_cost_current": { + "value": 56, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, TRVs and bypass", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "blocked_chimneys_count": 0, + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 735, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 197, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "schema_version_current": "LIG-21.0", + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 171, + "indicative_cost": "\u00a37,500 - \u00a311,000", + "improvement_type": "Q", + "improvement_details": { + "improvement_number": 7 + }, + "improvement_category": 5, + "energy_performance_rating": 70, + "environmental_impact_rating": 70 + }, + { + "sequence": 2, + "typical_saving": 45, + "indicative_cost": "\u00a3220 - \u00a3250", + "improvement_type": "G", + "improvement_details": { + "improvement_number": 14 + }, + "improvement_category": 5, + "energy_performance_rating": 72, + "environmental_impact_rating": 71 + } + ], + "co2_emissions_potential": 2.6, + "energy_rating_potential": 72, + "lighting_cost_potential": { + "value": 56, + "currency": "GBP" + }, + "schema_version_original": "LIG-21.0", + "hot_water_cost_potential": { + "value": 198, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2548.95, + "space_heating_existing_dwelling": 10093.12 + }, + "draughtproofed_door_count": 1, + "energy_consumption_current": 227, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "10.2.2.0", + "energy_consumption_potential": 181, + "environmental_impact_current": 64, + "cfl_fixed_lighting_bulbs_count": 0, + "current_energy_efficiency_band": "D", + "environmental_impact_potential": 71, + "led_fixed_lighting_bulbs_count": 8, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 41, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_103001004/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_103001004/epc.json new file mode 100644 index 000000000..8897cb84d --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_103001004/epc.json @@ -0,0 +1,321 @@ +{ + "uprn": 103001004, + "roofs": [ + { + "description": "Pitched, Unknown loft insulation", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "walls": [ + { + "description": "Cavity wall, filled cavity", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Solid, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + "lighting": { + "description": "Good lighting efficiency", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "NE33 1TN", + "hot_water": { + "description": "Electric immersion, off-peak", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 4 + }, + "post_town": "SOUTH SHIELDS", + "built_form": "NR", + "created_at": "2026-04-16 10:25:37", + "door_count": 0, + "region_code": 1, + "report_type": 2, + "sap_heating": { + "number_baths": 0, + "cylinder_size": 3, + "shower_outlets": [ + { + "shower_outlet": { + "shower_wwhrs": 1, + "shower_outlet_type": 1 + } + } + ], + "cylinder_heat_loss": 1.8, + "number_baths_wwhrs": 0, + "water_heating_code": 903, + "water_heating_fuel": 29, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 20, + "heat_emitter_type": 0, + "emitter_temperature": "NA", + "main_heating_number": 1, + "main_heating_control": 2307, + "main_heating_category": 6, + "main_heating_fraction": 1, + "sap_main_heating_code": 301, + "main_heating_data_source": 2, + "community_heat_distribution_type": 6 + } + ], + "immersion_heating_type": 2, + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "glazing_gap": 12, + "orientation": 7, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.21, + "window_height": 1.44, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": 12, + "orientation": 7, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.77, + "window_height": 1.14, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Community scheme", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Ground-floor flat", + "language_code": 1, + "pressure_test": 4, + "property_type": 2, + "address_line_1": "3 Julius Court", + "address_line_2": "Mile End Road", + "assessment_type": "RdSAP", + "completion_date": "2026-04-16", + "inspection_date": "2026-04-16", + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 1, + "top_storey": "N", + "storey_count": 1, + "flat_location": 0, + "heat_loss_corridor": 1 + }, + "total_floor_area": 31, + "transaction_type": 8, + "conservatory_type": 1, + "has_draught_lobby": "true", + "heated_room_count": 2, + "registration_date": "2026-04-16", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 1, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "false" + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "extract_fans_count": 2, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.37, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 30.55, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 16.75, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 5.37, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 2, + "construction_age_band": "F", + "party_wall_construction": 0, + "wall_thickness_measured": "N", + "roof_insulation_location": 2, + "roof_insulation_thickness": "NI", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "solar_water_heating": "N", + "habitable_room_count": 2, + "heating_cost_current": { + "value": 251, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 1.2, + "energy_rating_average": 60, + "energy_rating_current": 50, + "lighting_cost_current": { + "value": 29, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Flat rate charging, TRVs", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 313, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 1054, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 35, + "currency": "GBP" + }, + "indicative_cost": "\u00a3900 - \u00a31,200", + "improvement_type": "A", + "improvement_details": { + "improvement_number": 5 + }, + "improvement_category": 5, + "energy_performance_rating": 52, + "environmental_impact_rating": 81 + }, + { + "sequence": 2, + "typical_saving": { + "value": 598, + "currency": "GBP" + }, + "indicative_cost": "\u00a320 - \u00a340", + "improvement_type": "C", + "improvement_details": { + "improvement_number": 1 + }, + "improvement_category": 5, + "energy_performance_rating": 68, + "environmental_impact_rating": 83 + }, + { + "sequence": 3, + "typical_saving": { + "value": 51, + "currency": "GBP" + }, + "indicative_cost": "\u00a3600 - \u00a31,500", + "improvement_type": "Y", + "improvement_details": { + "improvement_number": 49 + }, + "improvement_category": 5, + "energy_performance_rating": 70, + "environmental_impact_rating": 83 + } + ], + "co2_emissions_potential": 1.0, + "energy_rating_potential": 70, + "lighting_cost_potential": { + "value": 29, + "currency": "GBP" + }, + "schema_version_original": "21.0.1", + "hot_water_cost_potential": { + "value": 308, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 5719.21, + "space_heating_existing_dwelling": 1270.63 + }, + "draughtproofed_door_count": 0, + "energy_consumption_current": 367, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "5.02r0342", + "energy_consumption_potential": 214, + "environmental_impact_current": 78, + "current_energy_efficiency_band": "E", + "environmental_impact_potential": 83, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 41, + "low_energy_fixed_lighting_bulbs_count": 5, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004017091/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004017091/epc.json new file mode 100644 index 000000000..80a987a43 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004017091/epc.json @@ -0,0 +1,372 @@ +{ + "uprn": 200004017091, + "roofs": [ + { + "description": "Pitched, 100 mm loft insulation", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "walls": [ + { + "description": "Timber frame, as built, insulated (assumed)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Solid, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + "addendum": { + "addendum_numbers": [ + 15 + ] + }, + "lighting": { + "description": "Good lighting efficiency", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "BN14 9EL", + "hot_water": { + "description": "From main system, no cylinder thermostat", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "post_town": "WORTHING", + "built_form": 3, + "created_at": "2026-04-21 13:18:06", + "door_count": 2, + "region_code": 14, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 0, + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 26, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 17803 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 1, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.1, + "window_height": 1.2, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 1, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.1, + "window_height": 1.2, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 5, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.1, + "window_height": 1.2, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 5, + "window_type": 1, + "glazing_type": 3, + "window_width": 0.8, + "window_height": 0.8, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "glazing_gap": "16+", + "orientation": 5, + "window_type": 1, + "glazing_type": 3, + "window_width": 1.5, + "window_height": 0.8, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "End-terrace house", + "language_code": 1, + "pressure_test": 4, + "property_type": 0, + "address_line_1": "3 Radbone Close", + "assessment_type": "RdSAP", + "completion_date": "2026-04-21", + "inspection_date": "2026-04-20", + "extensions_count": 0, + "measurement_type": 1, + "total_floor_area": 65, + "transaction_type": 1, + "conservatory_type": 1, + "heated_room_count": 4, + "registration_date": "2026-04-21", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "false" + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "extract_fans_count": 1, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 280, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 5, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.37, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 32.38, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 7.42, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 16.15, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.35, + "quantity": "metres" + }, + "total_floor_area": { + "value": 32.37, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 7.42, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 16.15, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "G", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "100mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "solar_water_heating": "N", + "habitable_room_count": 4, + "heating_cost_current": { + "value": 556, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.2, + "energy_rating_average": 60, + "energy_rating_current": 71, + "lighting_cost_current": { + "value": 51, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 516, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 333, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 71, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 44, + "currency": "GBP" + }, + "indicative_cost": "\u00a35,000 - \u00a310,000", + "improvement_type": "W2", + "improvement_details": { + "improvement_number": 58 + }, + "improvement_category": 5, + "energy_performance_rating": 73, + "environmental_impact_rating": 74 + }, + { + "sequence": 2, + "typical_saving": { + "value": 143, + "currency": "GBP" + }, + "indicative_cost": "\u00a34,000 - \u00a37,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 76, + "environmental_impact_rating": 79 + }, + { + "sequence": 3, + "typical_saving": { + "value": 212, + "currency": "GBP" + }, + "indicative_cost": "\u00a38,000 - \u00a310,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 81, + "environmental_impact_rating": 80 + } + ], + "co2_emissions_potential": 1.5, + "energy_rating_potential": 81, + "lighting_cost_potential": { + "value": 51, + "currency": "GBP" + }, + "schema_version_original": "21.0.1", + "hot_water_cost_potential": { + "value": 186, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 3562.39, + "space_heating_existing_dwelling": 4593.09 + }, + "draughtproofed_door_count": 0, + "energy_consumption_current": 184, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "5.02r0342", + "energy_consumption_potential": 118, + "environmental_impact_current": 72, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 80, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 33, + "low_energy_fixed_lighting_bulbs_count": 12, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/ELMHURST_FINDINGS.md b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/ELMHURST_FINDINGS.md new file mode 100644 index 000000000..165cbe29a --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/ELMHURST_FINDINGS.md @@ -0,0 +1,70 @@ +# Elmhurst validation — UPRN 200004296092 (corpus, slice-5 wall fix) + +Built in accredited Elmhurst RdSAP10-Online on the lodged register inputs +(end-terrace house, solid brick + **external insulation, thickness "NI"**, +pitched 150 mm loft, solid ground floor, mains-gas combi, TFA 62). +Artefacts: `elmhurst_summary.pdf` (Input Summary), `elmhurst_worksheet.pdf` +(SAP Worksheets). + +## Headline +- **Lodged SAP 71**; **engine 71.74** (post slice-5 §5.4 100 mm fix, was 64.35); + **accredited Elmhurst current SAP 66** (band D), potential 75. +- Slice 5 is **directionally validated**: the externally-insulated wall must be + billed insulated, not at the uninsulated 1.40 the §5.8 gate produced. Both + Elmhurst (0.48) and the engine (0.29) are far below 1.40. + +## Fabric line-by-line (engine dive vs Elmhurst worksheet) +| Element | Engine | Elmhurst | Note | +|---|---|---|---| +| External wall | 21.1 W/K (U 0.29) | 30.7 W/K (U **0.48**, "SolidWallDensePlaster**200**") | see WALL below | +| Roof | 9.35 W/K (U 0.30) | 9.35 W/K (U 0.30) | **exact match** | +| Ground floor | 21.2 W/K | 22.7 W/K (U 0.73) | close | +| Party wall | **8.57 W/K** | **0.00 W/K** | see PARTY below | +| Windows | 7.96 W/K | ~same area (4.31 + 4×2.89 split) | ~ | + +The wall (engine −9.6) and party wall (engine +8.6) differences nearly cancel, +so the two HTCs are close; the residual SAP gap (71.7 vs 66) is dominated by the +heating system — this build used a GENERIC Post-98 condensing combi, whereas the +engine uses the lodged PCDB 16137 (Ideal Logic combi 30). Not a clean 1e-4 +comparison for that reason. + +## Leads (both partly confounded by an imperfect build — see caveats) +1. **WALL — base thickness on an unmeasured wall.** The cert lodges + `wall_thickness 360` but `wall_thickness_measured = N`. Elmhurst modelled a + **~200 mm** base wall ("SolidWallDensePlaster200") — but note my build did + NOT enter a wall thickness, so Elmhurst used its own default; had I keyed + 360 mm it might match the engine. The open question is genuine though: + RdSAP §5.3 uses the documentary/measured thickness, and when NOT measured + the engine perhaps should fall to the Table-3 default rather than trust the + unmeasured lodged 360 mm. WORTH A DEDICATED CHECK (build with the thickness + keyed both ways). +2. **PARTY WALL — NOT an engine gap.** Elmhurst rendered the party wall as + "Solid, U 0.00", but the engine's **0.25** is SPEC-CORRECT: RdSAP §5.10 + Table 15 row 4 ("unable to determine, house") = 0.25. The cert lodges + `party_wall_construction = 0` (unable to determine), so 0.25 is right; the + Elmhurst 0.00 reflects how my build's party-wall dropdown resolved (a + "Solid" masonry party wall, Table 15 row 1 = 0), not the cert's "unknown". + Build artifact, not a bug. + +## Build caveats (why the 71.7-vs-66 residual isn't cleanly attributable) +- Heating: built a GENERIC Post-98 condensing combi; the engine uses the lodged + PCDB 16137 (Ideal Logic combi 30) — different efficiency. +- Wall thickness: not keyed into Elmhurst (defaulted 200 mm) vs engine's 360 mm. +- Party wall: entered as a resolved type (0.00) vs the cert's "unable to + determine" (engine 0.25). +So this build's clean, confirmed results are: (a) the ROOF matches exactly +(U 0.30, 9.35 W/K), (b) slice 5's DIRECTION is validated (insulated wall, not +the uninsulated 1.40). The wall-thickness question (lead 1) is the one genuine +follow-up. + +## Slice-5 calibration note (banked) +The accredited "External insulation, **Unknown** thickness" selection in Elmhurst +produced U 0.48 (≈ a 50 mm equivalent on its 200 mm base), NOT the 0.29 the +§5.4-literal "assume 100 mm" gives on the engine's 360 mm base. The 100 mm fix +nevertheless matches the LODGED corpus better (MAE 0.658 → 0.647, within held) — +lodged assessors typically knew the real (~100 mm) thickness the register then +flattens to "NI". Per the campaign rule (engine = lodged is the goal; Elmhurst +arbitrates only when lodged can't be explained from register data), the 100 mm +fix STANDS; the lodged-vs-Elmhurst gap here is explained by the register's "NI" +discarding the assessor's thickness knowledge. But the base-thickness and +party-wall leads above are genuine and independent of the insulation default. diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/elmhurst_summary.pdf b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/elmhurst_summary.pdf new file mode 100644 index 000000000..2caa62058 Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/elmhurst_summary.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/elmhurst_worksheet.pdf b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/elmhurst_worksheet.pdf new file mode 100644 index 000000000..3ebe582a3 Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/elmhurst_worksheet.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/epc.json new file mode 100644 index 000000000..93fe00f70 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/epc.json @@ -0,0 +1,361 @@ +{ + "uprn": 200004296092, + "roofs": [ + { + "description": "Pitched, 150 mm loft insulation", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "walls": [ + { + "description": "Solid brick, with external insulation", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Solid, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": "Excellent lighting efficiency", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "LL16 3RL", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "DENBIGH", + "psv_count": 0, + "built_form": 3, + "created_at": "2026-05-14 14:33:05", + "door_count": 2, + "region_code": 7, + "report_type": 2, + "sap_heating": { + "number_baths": 0, + "cylinder_size": 1, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_outlet_type": 2 + } + ], + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 26, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 1, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 16137 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1.07, + "quantity": "m" + }, + "window_height": { + "value": 1.34, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1.07, + "quantity": "m" + }, + "window_height": { + "value": 1.34, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 0.47, + "quantity": "m" + }, + "window_height": { + "value": 0.85, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 0.47, + "quantity": "m" + }, + "window_height": { + "value": 0.85, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 0.76, + "quantity": "m" + }, + "window_height": { + "value": 0.84, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "WLS", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "End-terrace house", + "language_code": 1, + "pressure_test": 4, + "property_type": 0, + "address_line_1": "54 Myddleton Avenue", + "assessment_type": "RdSAP", + "completion_date": "2026-05-14", + "inspection_date": "2026-05-14", + "extensions_count": 0, + "measurement_type": 1, + "open_flues_count": 0, + "total_floor_area": 62, + "transaction_type": 1, + "conservatory_type": 1, + "heated_room_count": 3, + "other_flues_count": 0, + "registration_date": "2026-05-14", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "true", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "true" + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "closed_flues_count": 0, + "extract_fans_count": 0, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 360, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 3, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": 2.4, + "floor_insulation": 1, + "total_floor_area": 31.16, + "party_wall_length": 6.79, + "floor_construction": 1, + "heat_loss_perimeter": 15.97 + }, + { + "floor": 1, + "room_height": 2.4, + "total_floor_area": 31.16, + "party_wall_length": 6.79, + "heat_loss_perimeter": 15.97 + } + ], + "wall_insulation_type": 1, + "construction_age_band": "B", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "150mm", + "wall_insulation_thickness": "NI" + } + ], + "boilers_flues_count": 0, + "open_chimneys_count": 0, + "solar_water_heating": "N", + "habitable_room_count": 3, + "heating_cost_current": { + "value": 589, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 1.8, + "energy_rating_average": 60, + "energy_rating_current": 71, + "lighting_cost_current": { + "value": 50, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "blocked_chimneys_count": 0, + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 543, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 275, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "schema_version_current": "LIG-21.0", + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 47, + "indicative_cost": "\u00a35,000 - \u00a310,000", + "improvement_type": "W2", + "improvement_details": { + "improvement_number": 58 + }, + "improvement_category": 5, + "energy_performance_rating": 72, + "environmental_impact_rating": 79 + }, + { + "sequence": 2, + "typical_saving": 275, + "indicative_cost": "\u00a38,000 - \u00a310,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 84, + "environmental_impact_rating": 81 + } + ], + "co2_emissions_potential": 1.4, + "energy_rating_potential": 84, + "lighting_cost_potential": { + "value": 50, + "currency": "GBP" + }, + "schema_version_original": "LIG-21.0", + "hot_water_cost_potential": { + "value": 275, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 1768.31, + "space_heating_existing_dwelling": 5933.75 + }, + "draughtproofed_door_count": 2, + "energy_consumption_current": 169, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "10.2.2.0", + "energy_consumption_potential": 130, + "environmental_impact_current": 77, + "cfl_fixed_lighting_bulbs_count": 0, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 81, + "led_fixed_lighting_bulbs_count": 12, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 29, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/sum.txt b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/sum.txt new file mode 100644 index 000000000..25a3346a2 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/sum.txt @@ -0,0 +1,254 @@ + Summary Information +Surveyor: P960-0001 +Name: Richard Matthew Ratcliff Title: Mr. Tel Number: 07760 443 469 +Survey Reference: 001431 My Reference: Khalim-test +Current SAP rating: D 66 Potential SAP rating: C 75 Emissions (t/year): 1.927 tonnes +Current EI rating: C 72 Potential EI rating: C 76 Fuel Bill: £1026 +Property Details: +RdSAP version: RdSAP10 +Reference Number: P960-0001-001431 +My Reference: Khalim-test +Lodgement Required: No +Regs Region: England +EPC Language: English +UPRN: +Postcode: W6 9BF +Region: Thames Valley +House Name: 28 Distillery Wharf +House No: 28 +Street: Distillery Wharf +Locality: +Town: London +County: +Property Tenure: Owner-occupied +Transaction Type: Marketed sale +Inspection Date: 25/06/2025 +Process date: 02/07/2026 +Check for the existence of No +an EPC: +Does an EPC exist at the No +point of carrying out this +energy assessment: +Reason why another energy +assessment needs to be +undertaken: + + RdSAP Inputs + +Property Description: +1.0 Property type: H House + E End-Terrace +2.0 Number of Storeys: 2 + Habitable Rooms: 3 + Heated Habitable Rooms: 3 +3.0 Date Built: Main Property B 1900-1929 + + +4.0 Dimensions: +Dimension type: Internal +Main Property + Floor Room Heat Loss Party Wall + Area Height Wall Perimeter Length + [m2] [m] [m] [m] + + 1st Floor: 31.16 2.40 15.97 6.79 + Lowest Floor: 31.16 2.40 15.97 6.79 No + +5.0 Conservatory: +Is there a conservatory? No +7.0 Walls: + + + + + © Elmhurst Energy Systems Limited Registered Office Unit 16, St Johns Business Park, Lutterworth, Leicestershire LE17 4HB + Summary Information +Main Property + + Type SO Solid Brick + Insulation E External + Insulation Thickness Unknown + Conductivity Known No + Thermal Conductivity Lambda = 0.04 W/m*K + Wall Thickness Unknown No + Wall Thickness 260 mm + U-value Known No + Party Wall Type S Solid masonry / timber / system build +8.0 Roofs: +Main Property + + Type PA Pitched (slates/tiles), access to loft + Insulation J Joists + Insulation Thickness 150 mm + U-value Known No +8.1 Rooms in Roof: +9.0 Floors: +Main Property + + Location G Ground floor + Type S Solid + Insulation A As built + Default U-value 0.73 + U-value Known No +10.0 Doors: + Total Number of Doors 2 + Number of Insulated Doors 0 +11.0 Windows: + Frame Frame Glazing Building U g Draught Permanent +W H Area Glazing Type Location Orient. Data-Source + Type Factor Gap Part value value Proofed Shutters + Double with unknown +4.31 1.00 4.31 PVC 0.70 12 mm Main External wall North Manufacturer 2.80 0.76 Yes None + install date + Double with unknown +2.89 1.00 2.89 PVC 0.70 12 mm Main External wall West Manufacturer 2.80 0.76 Yes None + install date + Double with unknown +2.89 1.00 2.89 PVC 0.70 12 mm Main External wall North Manufacturer 2.80 0.76 Yes None + install date + Double with unknown +2.89 1.00 2.89 PVC 0.70 12 mm Main External wall East Manufacturer 2.80 0.76 Yes None + install date + + Draught Proofing 67 % +12.0 Ventilation & Cooling + No. of open chimneys 0 + No. of open flues 0 + No. of open chimneys/open flues attached to closed fire 0 + No. of flues attached to solid fuel boiler 0 + No. of open flues attached to other heater 0 + No. of blocked chimneys 0 + No. of intermittent extract fans 0 + No. of passive vents 0 + No. of flueless gas fires 0 + Fixed Space Cooling No + Draught Lobby Unable to determine + + + + + © Elmhurst Energy Systems Limited Registered Office Unit 16, St Johns Business Park, Lutterworth, Leicestershire LE17 4HB + Summary Information +12.1 Mechanical Ventilation + Mechanical Ventilation No +12.2 Air Pressure Test + Test Method Not available +13.0 Lighting + Total number of bulbs 6 + Number of LED and CFL Known Yes + Number of LED lights 0 + Number of CFL lights 0 + Total number of Low Energy 0 + Total number of incandescents 6 +14.0 Main Heating1 + PCDF boiler Reference 0 + Main Heating EES Code BGW + Main Heating SAP Code 104 + Heat Emitter Radiators + Heat pump age 2013 or later + Flue Type Balanced + Fan Assisted Flue Yes + Design flow temperature <= 35°C + PCDF Heating Controls 0 + Main Heating Controls EES CBE + Main Heating Controls Sap SAP code 2106, Programmer, room thermostat and TRVs + PCDF Compensator 0 + Percentage of Heat 100 % +14.1 Main Heating2 + PCDF boiler Reference 0 + Main Heating EES Code + Main Heating SAP Code 0 + Percentage of Heat 0% +14.1 Community Heating/Heat Network + Heating Type None +14.2 Meters +Electricity meter type Single +Main gas Yes +Electricity Smart Meter Present No +Gas Smart Meter Present No +15.0 Water Heating + Water Heating Code HWP + Water Heating SapCode 901 + Water Heating Fuel Type Mains gas +15.1 Hot Water Cylinder + Hot Water Cylinder Present No +15.2 Community Hot Water + PCDF boiler Reference 0 +16.0 Solar water heating + Solar Water Heating No +17.0 Waste Water Heat Recovery System + Is WWHRS present in the property? No / Unknown +1x.0 Baths and Showers + Total Number of Baths 2 + Number of Baths Connected 0 + +Description Type Connected + +1 Non-electric shower None + + + + + © Elmhurst Energy Systems Limited Registered Office Unit 16, St Johns Business Park, Lutterworth, Leicestershire LE17 4HB + Summary Information +18.0 Flue Gas Heat Recovery System + Present No +19.0 Photovoltaic Panel + Photovoltaic Panel None + Export capable meter No +20.0 Wind Turbine + Terrain Type Urban + Wind turbine present? No +22.0 Special Features +21.0 Small-Scale Hydro + Electricity generated [kWh/year] 0.00 + + + + + © Elmhurst Energy Systems Limited Registered Office Unit 16, St Johns Business Park, Lutterworth, Leicestershire LE17 4HB + Summary Information + + Recommendations + + Loft insulation (SAP increase too small) + Flat roof insulation (Not applicable) + Room-in-roof insulation (Not applicable) + Cavity wall insulation (Not applicable) + Solid wall insulation (SAP increase too small) + Floor insulation (solid floor) (Recommended) + Hot water cylinder insulation (Not applicable) + Draught proofing (SAP increase too small) + Low energy lighting (Recommended) + Cylinder thermostat (Not applicable) + Heating controls for wet central heating system (Already installed) + Upgrade boiler, same fuel (Already installed) + Change heating to condensing gas condensing boiler (fuel switch) (Not applicable) + Flue gas heat recovery in conjunction with new boiler (Not applicable) + Solar water heating (SAP increase too small) + Heat recovery system for mixer showers (SAP increase too small) + Double glazed windows (Already installed) + Insulated doors (SAP increase too small) + Solar photovoltaic panels (Recommended) + Wind turbine (Not applicable) + PV diverter (Not applicable) + PV battery (Not applicable) + Water heating controls (Not applicable) + + Alternative Recommendations + External wall insulation with cavity insulation (Not applicable) + Biomass boiler (alternative) (Not applicable) + Micro CHP (alternative) (Not applicable) + + Related Party Disclosure + +No related party + + Addenda +When considering the PV installation consider installing PV battery and a PV diverter for water heating + + + + + © Elmhurst Energy Systems Limited Registered Office Unit 16, St Johns Business Park, Lutterworth, Leicestershire LE17 4HB + \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/ws.txt b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/ws.txt new file mode 100644 index 000000000..20c66e415 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_200004296092/ws.txt @@ -0,0 +1,1311 @@ +---------------------------------------------------------------------------------------------------- + SAP 10.02 Input Data (House) 02/07/2026 +---------------------------------------------------------------------------------------------------- +Full RefNo 001431 +Inspection date 25/06/2025 00:00:00 +Regs Region England +SAP Region Thames Valley +Property address 28 Distillery Wharf, 28 Distillery Wharf London +Postcode W6 9BF +Dwelling Orientation North +Property Type House, End-Terrace +Storeys 2 +Property Age Band B +Sheltered Sides 1 +Sunlight Shade Average or unknown +Measurements Perimeter, Floor Area, Storey Height + 1st Storey: 15.97, 31.16, 2.40 + 2nd Storey: 15.97, 31.16, 2.65 +Living Area 18.7 m2, fraction: 30.0% +Thermal Mass Enter TMP value +Thermal MassValue 250.00 +External Walls Nett Area, Gross Area, Kappa, Element, Construction, Type, ShelterFactor, UValueFinal + External walls Main 63.97, 80.65, 190, SolidWallDensePlaster200, Solid, 0.0, 0.48, Gross +Party Walls Area, Kappa, Element, Construction, Type, ShelterFactor, UValueFinal + Party walls Main 34.29, 0, Other, Solid, 0, 0.00 +External Roofs Nett Area, Gross Area, Kappa, Construction, Element, UValueFinal + External roof Main 31.16, 31.16, 9, Plasterboard, insulated slope, 0.3 +Heat Loss Floors Area, Kappa, Construction, Element, Type, ShelterFactor, UValueFinal + Ground floor Main 31.16, 0.00, Other, Ground Floor - Solid, 0.00, 0.73 +Description Data Source, Type, Glazing, Glazing Gap, Argon Filled, Solar Trans, Frame Type, Frame Factor, U Value + Doors uninsulated 1 Manufacturer, Solid Door, , , , , , , + Windows 1 Manufacturer, Window, Double glazed, , , 0.76, , 0.7, +Openings Opening Type, Location, Orientation, Pitch, Width, Height, Count, Area + Doors uninsulated Solid Door, External walls Main, East, , 3.7, 1, 1, 3.70 + Windows 1(Main) Window, External walls Main, North, , 4.31, 1, 1, 4.31 + Windows 2(Main) Window, External walls Main, West, , 2.89, 1, 1, 2.89 + Windows 2(Main) Window, External walls Main, North, , 2.89, 1, 1, 2.89 + Windows 2(Main) Window, External walls Main, East, , 2.89, 1, 1, 2.89 +Conservatory None +Draught Proofing 67 +Draught Lobby No +Thermal Bridges + Bridging User Input + Y 0.15 + Description RDSAP conversion + List of Bridges Junction with, Bridge Type, Source Type, Imported, Length, Psi, Adjusted, Result, Reference + 0. External wall, E22 Basement floor, Table K1 - Default, No, 1.00, 0.220, 0.220, 0.22, TER +Pressure Test False +Pressure Test Method Blower Door +Designed AP50 15.00 +AsBuilt AP50 0.00 +Property Tested True +Mechanical Ventilation None +Open Chimneys 0 +Open Flues 0 +Chimneys/Flues Attached to Closed Fire0 +Flues Attached to Solid Fuel Boiler0 +Flues Attached to Other Heater0 +Blocked chimneys 0 +Intermittent Fans 0 +Passive Vents 0 +Flueless Gas Fires 0 +Cooling System None +Lightings Name, Power, Efficacy, Capacity, Count + incandescent lights 60, 11.2, 672, 6 +Percentage of LEL Fittings 0.00 +Electricity Tariff Standard +Main Heating 1 + Description + Percentage 100 + FuelType Mains gas + SAP Code 104 + Boiler Efficiency Type SAP Table + Controls by PCDF 0 + MHS Controls 2106 Programmer, room thermostat and TRVs + Boiler Interlock Yes + Compensator 0 + Delayed Start Stat No + Ctrl SAP Code 2106 + Flue Type Balanced + Fan Assisted Flue Yes + Pumped Pump in heated space + Heat Pump Age 2013 or later + Heat Emitter Radiators + Flow Temperature Enter value + Flow Temperature Value 35 + Combi boiler type Standard Combi + Combi keep hot type None +Main Heating 2 None +Smoke Control Area Unknown +Community Heating None +Secondary Heating + Description Electricity Electric Panel, convector or radiant heaters + SAP Code 691 + Efficiency 100 +Water Heating + Type MainHeating1 + WHS 901 From main heating system + Low Water Usage No + SAP Code 901 + Cold Water Source From mains + Total number of baths in property2 + Hot Water Cylinder None +Showers Description, Type, Flow rate, Rated power, Connected, Connected WWHRS + Shower 01 Vented hot water system, 7.00, , No, +Flue Gas Heat Recovery System None +Waste Water Heat Recovery none +PV Unit None +Terrain Type Urban +Wind Turbine None +Small Scale Hydro None +Special Features None +---------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------- +SAP 10 WORKSHEET FOR Existing dwelling (SAP) (Version 10.2, February 2022) +CALCULATION OF ENERGY RATING +---------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------- +1. Overall dwelling characteristics + ---------------------------------------------------------------------------------------------------- + Area Storey height Volume +Main dwelling (m2) (m) (m3) + Ground floor 31.1600 (1b) x 2.4000 (2b) = 74.7840 (1b) - (3b) + First floor 31.1600 (1c) x 2.6500 (2c) = 82.5740 (1c) - (3c) +Total floor area TFA = (1a)+(1b)+(1c)+(1d)+(1e)...(1n) 62.3200 (4) +Dwelling volume (3a)+(3b)+(3c)+(3d)+(3e)...(3n) = 157.3580 (5) +---------------------------------------------------------------------------------------------------- +2. Ventilation rate +---------------------------------------------------------------------------------------------------- + m3 per hour + +Number of open chimneys 0 * 80 = 0.0000 (6a) +Number of open flues 0 * 20 = 0.0000 (6b) +Number of chimneys / flues attached to closed fire 0 * 10 = 0.0000 (6c) +Number of flues attached to solid fuel boiler 0 * 20 = 0.0000 (6d) +Number of flues attached to other heater 0 * 35 = 0.0000 (6e) +Number of blocked chimneys 0 * 20 = 0.0000 (6f) +Number of intermittent extract fans 0 * 10 = 0.0000 (7a) +Number of passive vents 0 * 10 = 0.0000 (7b) +Number of flueless gas fires 0 * 40 = 0.0000 (7c) + Air changes per hour +Infiltration due to chimneys, flues and fans = (6a)+(6b)+(6c)+(6d)+(6e)+(6f)+(6g)+(7a)+(7b)+(7c) = 0.0000 / (5) = 0.0000 (8) + Number of storeys in the dwelling (ns) 2 (9) + Additional infiltration [(9) - 1] x 0.1 = 0.1000 (10) + Structural infiltration: 0.25 for steel or timber frame or 0.35 for masonry construction 0.3500 (11) + If suspended wooden floor, enter 0.2 (unsealed) or 0.1 (sealed), else enter 0 0.0000 (12) + If no draught lobby, enter 0.05, else enter 0 0.0500 (13) + Percentage of windows and doors draught stripped 67.0000 (14) + Window infiltration 0.25 - [0.2 * (14) / 100] = 0.1160 (15) +Pressure test No +Infiltration rate 0.6160 (18) +Number of sides sheltered 1 (19) +Shelter factor (20) = 1 - [0.075 x (19)] = 0.9250 (20) +Infiltration rate adjusted to include shelter factor (21) = (18) x (20) = 0.5698 (21) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Wind speed 5.1000 5.0000 4.9000 4.4000 4.3000 3.8000 3.8000 3.7000 4.0000 4.3000 4.5000 4.7000 (22) +Wind factor 1.2750 1.2500 1.2250 1.1000 1.0750 0.9500 0.9500 0.9250 1.0000 1.0750 1.1250 1.1750 (22a) +Adj infilt rate + 0.7265 0.7123 0.6980 0.6268 0.6125 0.5413 0.5413 0.5271 0.5698 0.6125 0.6410 0.6695 (22b) +Effective ac 0.7639 0.7537 0.7436 0.6964 0.6876 0.6465 0.6465 0.6389 0.6623 0.6876 0.7055 0.7241 (25) +---------------------------------------------------------------------------------------------------- +3. Heat losses and heat loss parameter +---------------------------------------------------------------------------------------------------- +Element Gross Openings NetArea U-value A x U K-value A x K +Main dwelling m2 m2 m2 W/m2K W/K kJ/m2K kJ/K + Doors uninsulated 1 3.7000 3.0000 11.1000 (26) + Windows 1 12.9800 2.5180 32.6835 (27) + Ground floor Main 31.1600 0.7300 22.7468 (28a) + External walls Main 80.6500 16.6800 63.9700 0.4800 30.7056 (29a) + External roof Main 31.1600 31.1600 0.3000 9.3480 (30) +Total net area of external elements Aum(A, m2) 142.9700 (31) +Fabric heat loss, W/K = Sum (A x U) (26)...(30) + (32) = 106.5839 (33) +Main dwelling + Party walls Main 34.2900 0.0000 0.0000 (32) +Thermal mass parameter (TMP = Cm / TFA) in kJ/m2K 250.0000 (35) +Thermal bridges (User defined value 0.150 * total exposed area) 21.4455 (36) +Point Thermal bridges (36a) = 0.0000 +Total fabric heat loss (33) + (36) + (36a) = 128.0294 (37) +Ventilation heat loss calculated monthly (38)m = 0.33 x (25)m x (5) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +(38)m 39.6678 39.1356 38.6140 36.1641 35.7058 33.5720 33.5720 33.1768 34.3939 35.7058 36.6330 37.6025 (38) +Heat transfer coeff + 167.6971 167.1650 166.6434 164.1935 163.7351 161.6013 161.6013 161.2062 162.4232 163.7351 164.6624 165.6318 (39) +Average = Sum(39)m / 12 = 164.1913 + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +HLP 2.6909 2.6824 2.6740 2.6347 2.6273 2.5931 2.5931 2.5867 2.6063 2.6273 2.6422 2.6578 (40) +HLP (average) 2.6346 +Days in mont 31 28 31 30 31 30 31 31 30 31 30 31 +---------------------------------------------------------------------------------------------------- +4. Water heating energy requirements (kWh/year) +---------------------------------------------------------------------------------------------------- +Assumed occupancy 2.0464 (42) +Hot water usage for mixer showers + 51.2148 50.4451 49.3236 47.1777 45.5941 43.8281 42.8243 43.9374 45.1575 47.0536 49.2456 51.0186 (42a) +Hot water usage for baths + 26.6248 26.2294 25.6726 24.6459 23.8771 23.0246 22.5642 23.1171 23.7192 24.6313 25.6791 26.5348 (42b) +Hot water usage for other uses + 37.4603 36.0981 34.7359 33.3737 32.0116 30.6494 30.6494 32.0116 33.3737 34.7359 36.0981 37.4603 (42c) +Average daily hot water use (litres/day) 105.9586 (43) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Daily hot water use + 115.2999 112.7727 109.7321 105.1973 101.4828 97.5021 96.0379 99.0660 102.2504 106.4209 111.0229 115.0137 (44) +Energy conte 182.6069 160.5868 168.6541 144.0099 136.6153 119.8902 116.1495 122.6644 126.0850 144.4121 158.1725 180.0836 (45) +Energy content (annual) Total = Sum(45)m = 1759.9303 +Distribution loss (46)m = 0.15 x (45)m + 27.3910 24.0880 25.2981 21.6015 20.4923 17.9835 17.4224 18.3997 18.9127 21.6618 23.7259 27.0125 (46) +Water storage loss (or HIU loss): +Total storage loss + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (56) +If cylinder contains dedicated solar storage + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (57) +Primary loss 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (59) +Combi loss 50.9589 46.0274 50.9589 49.3151 50.9589 48.0832 48.9398 50.4830 49.3151 50.9589 49.3151 50.9589 (61) +Total heat required for water heating calculated for each month + 233.5658 206.6142 219.6130 193.3250 187.5742 167.9734 165.0893 173.1474 175.4000 195.3711 207.4875 231.0425 (62) +WWHRS 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63a) +PV diverter 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63b) +Solar input 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63c) +FGHRS 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63d) +Output from w/h + 233.5658 206.6142 219.6130 193.3250 187.5742 167.9734 165.0893 173.1474 175.4000 195.3711 207.4875 231.0425 (64) + Total per year (kWh/year) = Sum(64)m = 2356.2034 (64) +Electric shower(s) + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (64a) + Total Energy used by instantaneous electric shower(s) (kWh/year) = Sum(64a)m = 0.0000 (64a) +Heat gains from water heating, kWh/month + 73.4565 64.9019 68.8172 60.2121 58.1643 51.8843 50.8547 53.4067 54.2520 60.7568 64.9211 72.6175 (65) +---------------------------------------------------------------------------------------------------- +5. Internal gains (see Table 5 and 5a) +---------------------------------------------------------------------------------------------------- +Metabolic gains (Table 5), Watts + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +(66)m 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 (66) +Lighting gains (calculated in Appendix L, equation L9 or L9a), also see Table 5 + 71.1593 63.2031 51.4002 38.9133 29.0881 24.5574 26.5352 34.4914 46.2943 58.7812 68.6064 73.1371 (67) +Appliances gains (calculated in Appendix L, equation L13 or L13a), also see Table 5 + 266.8027 269.5713 262.5946 247.7419 228.9932 211.3720 199.5999 196.8313 203.8081 218.6607 237.4095 255.0307 (68) +Cooking gains (calculated in Appendix L, equation L15 or L15a), also see Table 5 + 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 (69) +Pumps, fans 3.0000 3.0000 3.0000 3.0000 3.0000 0.0000 0.0000 0.0000 0.0000 3.0000 3.0000 3.0000 (70) +Losses e.g. evaporation (negative values) (Table 5) + -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 (71) +Water heating gains (Table 5) + 98.7319 96.5803 92.4963 83.6279 78.1778 72.0615 68.3530 71.7832 75.3500 81.6623 90.1682 97.6042 (72) +Total internal gains + 529.9471 522.6078 499.7442 463.5362 429.5123 398.2441 384.7412 393.3590 415.7055 452.3574 489.4372 519.0251 (73) +---------------------------------------------------------------------------------------------------- +6. Solar gains +---------------------------------------------------------------------------------------------------- +[Jan] Area Solar flux g FF Access Gains + m2 Table 6a Specific data Specific data factor W + W/m2 or Table 6b or Table 6c Table 6d +---------------------------------------------------------------------------------------------------- +North 7.2000 10.6334 0.7600 0.7000 0.7700 28.2260 (74) +East 2.8900 19.6403 0.7600 0.7000 0.7700 20.9262 (76) +West 2.8900 19.6403 0.7600 0.7000 0.7700 20.9262 (80) +---------------------------------------------------------------------------------------------------- +Solar gains 70.0784 135.8134 226.4912 343.8726 439.3250 459.0195 433.0958 359.0167 267.0192 161.3582 87.0054 57.9479 (83) +Total gains 600.0254 658.4212 726.2353 807.4088 868.8373 857.2635 817.8371 752.3758 682.7247 613.7156 576.4425 576.9730 (84) +---------------------------------------------------------------------------------------------------- +7. Mean internal temperature (heating season) +---------------------------------------------------------------------------------------------------- +Temperature during heating periods in the living area from Table 9, Th1 (C) 21.0000 (85) +Utilisation factor for gains for living area, ni1,m (see Table 9a) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +tau 25.8071 25.8893 25.9703 26.3578 26.4316 26.7806 26.7806 26.8462 26.6451 26.4316 26.2827 26.1289 +alpha 2.7205 2.7260 2.7314 2.7572 2.7621 2.7854 2.7854 2.7897 2.7763 2.7621 2.7522 2.7419 +util living area + 0.9881 0.9836 0.9735 0.9487 0.8963 0.8005 0.6824 0.7308 0.8833 0.9610 0.9831 0.9894 (86) +MIT 18.0348 18.2517 18.6986 19.3471 19.9916 20.5260 20.7917 20.7386 20.2963 19.4913 18.6708 18.0152 (87) +Th 2 18.9125 18.9172 18.9219 18.9438 18.9479 18.9673 18.9673 18.9709 18.9598 18.9479 18.9396 18.9309 (88) +util rest of house + 0.9835 0.9772 0.9621 0.9235 0.8361 0.6608 0.4360 0.4985 0.7860 0.9365 0.9751 0.9853 (89) +MIT 2 16.4138 16.6314 17.0756 17.7205 18.3240 18.7776 18.9318 18.9172 18.6162 17.8743 17.0631 16.4054 (90) +Living area fraction fLA = Living area / (4) = 0.3001 (91) +MIT 16.9002 17.1176 17.5626 18.2086 18.8244 19.3022 19.4899 19.4637 19.1203 18.3595 17.5455 16.8884 (92) +Temperature adjustment 0.0000 +adjusted MIT 16.9002 17.1176 17.5626 18.2086 18.8244 19.3022 19.4899 19.4637 19.1203 18.3595 17.5455 16.8884 (93) +---------------------------------------------------------------------------------------------------- +8. Space heating requirement +---------------------------------------------------------------------------------------------------- + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Utilisation 0.9765 0.9686 0.9509 0.9107 0.8312 0.6900 0.5126 0.5690 0.7966 0.9259 0.9666 0.9789 (94) +Useful gains 585.9077 637.7302 690.5719 735.2903 722.1589 591.5356 419.2138 428.1056 543.8706 568.2557 557.2080 564.7854 (95) +Ext temp. 4.3000 4.9000 6.5000 8.9000 11.7000 14.6000 16.6000 16.4000 14.1000 10.6000 7.1000 4.2000 (96) +Heat loss rate W + 2113.0154 2042.3548 1843.5024 1528.4133 1166.5069 759.8827 467.0161 493.8903 815.4187 1270.5091 1719.9789 2101.6097 (97) +Space heating kWh + 1136.1681 943.9077 857.7803 571.0485 330.5949 0.0000 0.0000 0.0000 0.0000 522.4765 837.1951 1143.3973 (98a) +Space heating requirement - total per year (kWh/year) 6342.5685 +Solar heating kWh + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (98b) +Solar heating contribution - total per year (kWh/year) 0.0000 +Space heating kWh + 1136.1681 943.9077 857.7803 571.0485 330.5949 0.0000 0.0000 0.0000 0.0000 522.4765 837.1951 1143.3973 (98c) +Space heating requirement after solar contribution - total per year (kWh/year) 6342.5685 +Space heating per m2 (98c) / (4) = 101.7742 (99) +---------------------------------------------------------------------------------------------------- +9a. Energy requirements - Individual heating systems, including micro-CHP +---------------------------------------------------------------------------------------------------- +Fraction of space heat from secondary/supplementary system (Table 11) 0.1000 (201) +Fraction of space heat from main system(s) 0.9000 (202) +Efficiency of main space heating system 1 (in %) 84.0000 (206) +Efficiency of main space heating system 2 (in %) 0.0000 (207) +Efficiency of secondary/supplementary heating system, % 100.0000 (208) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Space heating requirement + 1136.1681 943.9077 857.7803 571.0485 330.5949 0.0000 0.0000 0.0000 0.0000 522.4765 837.1951 1143.3973 (98) +Space heating efficiency (main heating system 1) + 84.0000 84.0000 84.0000 84.0000 84.0000 0.0000 0.0000 0.0000 0.0000 84.0000 84.0000 84.0000 (210) +Space heating fuel (main heating system) + 1217.3230 1011.3297 919.0504 611.8377 354.2088 0.0000 0.0000 0.0000 0.0000 559.7963 896.9947 1225.0686 (211) +Space heating efficiency (main heating system 2) + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (212) +Space heating fuel (main heating system 2) + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (213) +Space heating fuel (secondary) + 113.6168 94.3908 85.7780 57.1049 33.0595 0.0000 0.0000 0.0000 0.0000 52.2477 83.7195 114.3397 (215) +Water heating +Water heating requirement + 233.5658 206.6142 219.6130 193.3250 187.5742 167.9734 165.0893 173.1474 175.4000 195.3711 207.4875 231.0425 (64) +Efficiency of water heater 75.0000 (216) +(217)m 82.1666 82.0733 81.8254 81.3322 80.2753 75.0000 75.0000 75.0000 75.0000 81.1419 81.8785 82.1916 (217) +Fuel for water heating, kWh/month + 284.2588 251.7436 268.3923 237.6978 233.6638 223.9646 220.1191 230.8632 233.8667 240.7769 253.4089 281.1024 (219) +Space cooling fuel requirement +(221)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (221) +Pumps and Fa 7.3041 6.5973 7.3041 7.0685 7.3041 7.0685 7.3041 7.3041 7.0685 7.3041 7.0685 7.3041 (231) +Lighting 62.2853 49.9676 44.9903 32.9618 25.4607 20.8016 23.2261 30.1901 39.2140 51.4509 58.1136 64.0164 (232) +Electricity generated by PVs (Appendix M) (negative quantity) +(233a)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (233a) +Electricity generated by wind turbines (Appendix M) (negative quantity) +(234a)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (234a) +Electricity generated by hydro-electric generators (Appendix M) (negative quantity) +(235a)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235a) +Electricity used or net electricity generated by micro-CHP (Appendix N) (negative if net generation) +(235c)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235c) +Electricity generated by PVs (Appendix M) (negative quantity) +(233b)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (233b) +Electricity generated by wind turbines (Appendix M) (negative quantity) +(234b)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (234b) +Electricity generated by hydro-electric generators (Appendix M) (negative quantity) +(235b)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235b) +Electricity used or net electricity generated by micro-CHP (Appendix N) (negative if net generation) +(235d)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235d) +Annual totals kWh/year +Space heating fuel - main system 1 6795.6091 (211) +Space heating fuel - main system 2 0.0000 (213) +Space heating fuel - secondary 634.2569 (215) +Efficiency of water heater 75.0000 +Water heating fuel used 2959.8582 (219) +Space cooling fuel 0.0000 (221) +Electricity for pumps and fans: + central heating pump 41.0000 (230c) + main heating flue fan 45.0000 (230e) +Total electricity for the above, kWh/year 86.0000 (231) +Electricity for lighting (calculated in Appendix L) 502.6784 (232) + Energy saving/generation technologies (Appendices M ,N and Q) +PV generation 0.0000 (233) +Wind generation 0.0000 (234) +Hydro-electric generation (Appendix N) 0.0000 (235a) +Electricity generated - Micro CHP (Appendix N) 0.0000 (235) +Appendix Q - special features +Energy saved or generated 0.0000 (236) +Energy used 0.0000 (237) +Total delivered energy for all uses 10978.4026 (238) +---------------------------------------------------------------------------------------------------- +10a. Fuel costs - using Table 12 prices +---------------------------------------------------------------------------------------------------- + Fuel Fuel price Fuel cost + kWh/year p/kWh £/year +Space heating - main system 1 6795.6091 3.4800 236.4872 (240) +Total CO2 associated with community systems 0.0000 (473) +Space heating - secondary 634.2569 13.1900 83.6585 (242) +Water heating (other fuel) 2959.8582 3.4800 103.0031 (247) +Energy for instantaneous electric shower(s) 0.0000 13.1900 0.0000 (247a) +Pumps, fans and electric keep-hot 86.0000 13.1900 11.3434 (249) +Energy for lighting 502.6784 13.1900 66.3033 (250) +Additional standing charges 120.0000 (251) +Total energy cost 620.7954 (255) +---------------------------------------------------------------------------------------------------- +11a. SAP rating - Individual heating systems +---------------------------------------------------------------------------------------------------- +Energy cost deflator (Table 12): 0.4200 (256) +Energy cost factor (ECF) [(255) x (256)] / [(4) + 45.0] = 2.4295 (257) +SAP value 66.1085 +SAP rating (Section 12) 66 (258) +SAP band D +---------------------------------------------------------------------------------------------------- +12a. Carbon dioxide emissions - Individual heating systems including micro-CHP +---------------------------------------------------------------------------------------------------- + Energy Emission factor Emissions + kWh/year kg CO2/kWh kg CO2/year +Space heating - main system 1 6795.6091 0.2100 1427.0779 (261) +Total CO2 associated with community systems 0.0000 (373) +Space heating - secondary 634.2569 0.1540 97.6607 (263) +Water heating (other fuel) 2959.8582 0.2100 621.5702 (264) +Space and water heating 2146.3088 (265) +Pumps, fans and electric keep-hot 86.0000 0.1387 11.9293 (267) +Energy for lighting 502.6784 0.1443 72.5520 (268) +Total CO2, kg/year 2230.7901 (272) +CO2 emissions per m2 35.8000 (273) +EI value 72.1463 +EI rating 72 (274) +EI band C +---------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------- +SAP 10 WORKSHEET FOR Existing dwelling (SAP) (Version 10.2, February 2022) +CALCULATION OF EPC COSTS, EMISSIONS AND PRIMARY ENERGY +---------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------- +1. Overall dwelling characteristics +---------------------------------------------------------------------------------------------------- + Area Storey height Volume +Main dwelling (m2) (m) (m3) + Ground floor 31.1600 (1b) x 2.4000 (2b) = 74.7840 (1b) - (3b) + First floor 31.1600 (1c) x 2.6500 (2c) = 82.5740 (1c) - (3c) +Total floor area TFA = (1a)+(1b)+(1c)+(1d)+(1e)...(1n) 62.3200 (4) +Dwelling volume (3a)+(3b)+(3c)+(3d)+(3e)...(3n) = 157.3580 (5) +---------------------------------------------------------------------------------------------------- +2. Ventilation rate +---------------------------------------------------------------------------------------------------- + m3 per hour + +Number of open chimneys 0 * 80 = 0.0000 (6a) +Number of open flues 0 * 20 = 0.0000 (6b) +Number of chimneys / flues attached to closed fire 0 * 10 = 0.0000 (6c) +Number of flues attached to solid fuel boiler 0 * 20 = 0.0000 (6d) +Number of flues attached to other heater 0 * 35 = 0.0000 (6e) +Number of blocked chimneys 0 * 20 = 0.0000 (6f) +Number of intermittent extract fans 0 * 10 = 0.0000 (7a) +Number of passive vents 0 * 10 = 0.0000 (7b) +Number of flueless gas fires 0 * 40 = 0.0000 (7c) + Air changes per hour +Infiltration due to chimneys, flues and fans = (6a)+(6b)+(6c)+(6d)+(6e)+(6f)+(6g)+(7a)+(7b)+(7c) = 0.0000 / (5) = 0.0000 (8) + Number of storeys in the dwelling (ns) 2 (9) + Additional infiltration [(9) - 1] x 0.1 = 0.1000 (10) + Structural infiltration: 0.25 for steel or timber frame or 0.35 for masonry construction 0.3500 (11) + If suspended wooden floor, enter 0.2 (unsealed) or 0.1 (sealed), else enter 0 0.0000 (12) + If no draught lobby, enter 0.05, else enter 0 0.0500 (13) + Percentage of windows and doors draught stripped 67.0000 (14) + Window infiltration 0.25 - [0.2 * (14) / 100] = 0.1160 (15) +Pressure test No +Infiltration rate 0.6160 (18) +Number of sides sheltered 1 (19) +Shelter factor (20) = 1 - [0.075 x (19)] = 0.9250 (20) +Infiltration rate adjusted to include shelter factor (21) = (18) x (20) = 0.5698 (21) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Wind speed 3.8000 3.7000 3.7000 3.5000 3.5000 3.1000 3.3000 3.0000 3.0000 3.1000 3.1000 3.5000 (22) +Wind factor 0.9500 0.9250 0.9250 0.8750 0.8750 0.7750 0.8250 0.7500 0.7500 0.7750 0.7750 0.8750 (22a) +Adj infilt rate + 0.5413 0.5271 0.5271 0.4986 0.4986 0.4416 0.4701 0.4274 0.4274 0.4416 0.4416 0.4986 (22b) +Effective ac 0.6465 0.6389 0.6389 0.6243 0.6243 0.5975 0.6105 0.5913 0.5913 0.5975 0.5975 0.6243 (25) +---------------------------------------------------------------------------------------------------- +3. Heat losses and heat loss parameter +---------------------------------------------------------------------------------------------------- +Element Gross Openings NetArea U-value A x U K-value A x K +Main dwelling m2 m2 m2 W/m2K W/K kJ/m2K kJ/K + Doors uninsulated 1 3.7000 3.0000 11.1000 (26) + Windows 1 12.9800 2.5180 32.6835 (27) + Ground floor Main 31.1600 0.7300 22.7468 (28a) + External walls Main 80.6500 16.6800 63.9700 0.4800 30.7056 (29a) + External roof Main 31.1600 31.1600 0.3000 9.3480 (30) +Total net area of external elements Aum(A, m2) 142.9700 (31) +Fabric heat loss, W/K = Sum (A x U) (26)...(30) + (32) = 106.5839 (33) +Main dwelling + Party walls Main 34.2900 0.0000 0.0000 (32) +Thermal mass parameter (TMP = Cm / TFA) in kJ/m2K 250.0000 (35) +Thermal bridges (User defined value 0.150 * total exposed area) 21.4455 (36) +Point Thermal bridges (36a) = 0.0000 +Total fabric heat loss (33) + (36) + (36a) = 128.0294 (37) +Ventilation heat loss calculated monthly (38)m = 0.33 x (25)m x (5) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +(38)m 33.5720 33.1768 33.1768 32.4181 32.4181 31.0272 31.7016 30.7058 30.7058 31.0272 31.0272 32.4181 (38) +Heat transfer coeff + 161.6013 161.2062 161.2062 160.4475 160.4475 159.0566 159.7310 158.7352 158.7352 159.0566 159.0566 160.4475 (39) +Average = Sum(39)m / 12 = 159.9773 + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +HLP 2.5931 2.5867 2.5867 2.5746 2.5746 2.5523 2.5631 2.5471 2.5471 2.5523 2.5523 2.5746 (40) +HLP (average) 2.5670 +Days in mont 31 28 31 30 31 30 31 31 30 31 30 31 +---------------------------------------------------------------------------------------------------- +4. Water heating energy requirements (kWh/year) +---------------------------------------------------------------------------------------------------- +Assumed occupancy 2.0464 (42) +Hot water usage for mixer showers + 51.2148 50.4451 49.3236 47.1777 45.5941 43.8281 42.8243 43.9374 45.1575 47.0536 49.2456 51.0186 (42a) +Hot water usage for baths + 26.6248 26.2294 25.6726 24.6459 23.8771 23.0246 22.5642 23.1171 23.7192 24.6313 25.6791 26.5348 (42b) +Hot water usage for other uses + 37.4603 36.0981 34.7359 33.3737 32.0116 30.6494 30.6494 32.0116 33.3737 34.7359 36.0981 37.4603 (42c) +Average daily hot water use (litres/day) 105.9586 (43) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Daily hot water use + 115.2999 112.7727 109.7321 105.1973 101.4828 97.5021 96.0379 99.0660 102.2504 106.4209 111.0229 115.0137 (44) +Energy conte 182.6069 160.5868 168.6541 144.0099 136.6153 119.8902 116.1495 122.6644 126.0850 144.4121 158.1725 180.0836 (45) +Energy content (annual) Total = Sum(45)m = 1759.9303 +Distribution loss (46)m = 0.15 x (45)m + 27.3910 24.0880 25.2981 21.6015 20.4923 17.9835 17.4224 18.3997 18.9127 21.6618 23.7259 27.0125 (46) +Water storage loss (or HIU loss): +Total storage loss + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (56) +If cylinder contains dedicated solar storage + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (57) +Primary loss 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (59) +Combi loss 50.9589 46.0274 50.9589 49.3151 50.9589 48.0832 48.9398 50.4830 49.3151 50.9589 49.3151 50.9589 (61) +Total heat required for water heating calculated for each month + 233.5658 206.6142 219.6130 193.3250 187.5742 167.9734 165.0893 173.1474 175.4000 195.3711 207.4875 231.0425 (62) +WWHRS 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63a) +PV diverter 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63b) +Solar input 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63c) +FGHRS 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63d) +Output from w/h + 233.5658 206.6142 219.6130 193.3250 187.5742 167.9734 165.0893 173.1474 175.4000 195.3711 207.4875 231.0425 (64) + Total per year (kWh/year) = Sum(64)m = 2356.2034 (64) +Electric shower(s) + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (64a) + Total Energy used by instantaneous electric shower(s) (kWh/year) = Sum(64a)m = 0.0000 (64a) +Heat gains from water heating, kWh/month + 73.4565 64.9019 68.8172 60.2121 58.1643 51.8843 50.8547 53.4067 54.2520 60.7568 64.9211 72.6175 (65) +---------------------------------------------------------------------------------------------------- +5. Internal gains (see Table 5 and 5a) +---------------------------------------------------------------------------------------------------- +Metabolic gains (Table 5), Watts + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +(66)m 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 (66) +Lighting gains (calculated in Appendix L, equation L9 or L9a), also see Table 5 + 71.1593 63.2031 51.4002 38.9133 29.0881 24.5574 26.5352 34.4914 46.2943 58.7812 68.6064 73.1371 (67) +Appliances gains (calculated in Appendix L, equation L13 or L13a), also see Table 5 + 266.8027 269.5713 262.5946 247.7419 228.9932 211.3720 199.5999 196.8313 203.8081 218.6607 237.4095 255.0307 (68) +Cooking gains (calculated in Appendix L, equation L15 or L15a), also see Table 5 + 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 (69) +Pumps, fans 3.0000 3.0000 3.0000 3.0000 3.0000 0.0000 0.0000 0.0000 0.0000 3.0000 3.0000 3.0000 (70) +Losses e.g. evaporation (negative values) (Table 5) + -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 (71) +Water heating gains (Table 5) + 98.7319 96.5803 92.4963 83.6279 78.1778 72.0615 68.3530 71.7832 75.3500 81.6623 90.1682 97.6042 (72) +Total internal gains + 529.9471 522.6078 499.7442 463.5362 429.5123 398.2441 384.7412 393.3590 415.7055 452.3574 489.4372 519.0251 (73) +---------------------------------------------------------------------------------------------------- +6. Solar gains +---------------------------------------------------------------------------------------------------- +[Jan] Area Solar flux g FF Access Gains + m2 Table 6a Specific data Specific data factor W + W/m2 or Table 6b or Table 6c Table 6d +---------------------------------------------------------------------------------------------------- +North 7.2000 11.5683 0.7600 0.7000 0.7700 30.7077 (74) +East 2.8900 21.5704 0.7600 0.7000 0.7700 22.9826 (76) +West 2.8900 21.5704 0.7600 0.7000 0.7700 22.9826 (80) +---------------------------------------------------------------------------------------------------- +Solar gains 76.6730 133.8602 224.7811 352.4577 437.5532 487.5086 459.0611 390.9446 291.1241 173.9501 98.4071 64.8811 (83) +Total gains 606.6201 656.4679 724.5253 815.9939 867.0655 885.7527 843.8023 784.3036 706.8297 626.3075 587.8443 583.9062 (84) +---------------------------------------------------------------------------------------------------- +7. Mean internal temperature (heating season) +---------------------------------------------------------------------------------------------------- +Temperature during heating periods in the living area from Table 9, Th1 (C) 21.0000 (85) +Utilisation factor for gains for living area, ni1,m (see Table 9a) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +tau 26.7806 26.8462 26.8462 26.9732 26.9732 27.2090 27.0942 27.2641 27.2641 27.2090 27.2090 26.9732 +alpha 2.7854 2.7897 2.7897 2.7982 2.7982 2.8139 2.8063 2.8176 2.8176 2.8139 2.8139 2.7982 +util living area + 0.9851 0.9800 0.9656 0.9261 0.8356 0.6366 0.4258 0.4815 0.7985 0.9397 0.9769 0.9865 (86) +MIT 18.4242 18.6244 19.0967 19.7531 20.3898 20.8355 20.9671 20.9519 20.6203 19.8886 19.0627 18.4121 (87) +Th 2 18.9673 18.9709 18.9709 18.9778 18.9778 18.9906 18.9844 18.9935 18.9935 18.9906 18.9906 18.9778 (88) +util rest of house + 0.9789 0.9715 0.9493 0.8856 0.7279 0.3908 0.0727 0.1199 0.6192 0.8964 0.9650 0.9808 (89) +MIT 2 16.8338 17.0336 17.4974 18.1289 18.6833 18.9656 18.9843 18.9933 18.8769 18.2799 17.4816 16.8285 (90) +Living area fraction fLA = Living area / (4) = 0.3001 (91) +MIT 17.3110 17.5110 17.9773 18.6163 19.1954 19.5267 19.5793 19.5810 19.4000 18.7626 17.9560 17.3037 (92) +Temperature adjustment 0.0000 +adjusted MIT 17.3110 17.5110 17.9773 18.6163 19.1954 19.5267 19.5793 19.5810 19.4000 18.7626 17.9560 17.3037 (93) +---------------------------------------------------------------------------------------------------- +8. Space heating requirement +---------------------------------------------------------------------------------------------------- + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Utilisation 0.9710 0.9622 0.9376 0.8756 0.7429 0.4675 0.1840 0.2358 0.6665 0.8892 0.9554 0.9735 (94) +Useful gains 589.0545 631.6483 679.2849 714.4648 644.1081 414.0819 155.2945 184.9239 471.0774 556.9055 561.6531 568.4109 (95) +Ext temp. 5.6000 6.2000 8.0000 10.6000 13.7000 16.7000 18.6000 18.4000 15.7000 12.2000 8.5000 5.6000 (96) +Heat loss rate W + 1892.5112 1823.3964 1608.4000 1286.1922 881.7185 449.6045 156.4215 187.4638 587.3223 1043.8267 1504.0390 1877.8239 (97) +Space heating kWh + 969.7718 800.8547 691.2617 411.6437 176.7821 0.0000 0.0000 0.0000 0.0000 362.2694 678.5179 974.2033 (98a) +Space heating requirement - total per year (kWh/year) 5065.3045 +Solar heating kWh + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (98b) +Solar heating contribution - total per year (kWh/year) 0.0000 +Space heating kWh + 969.7718 800.8547 691.2617 411.6437 176.7821 0.0000 0.0000 0.0000 0.0000 362.2694 678.5179 974.2033 (98c) +Space heating requirement after solar contribution - total per year (kWh/year) 5065.3045 +Space heating per m2 (98c) / (4) = 81.2790 (99) +---------------------------------------------------------------------------------------------------- +9a. Energy requirements - Individual heating systems, including micro-CHP +---------------------------------------------------------------------------------------------------- +Fraction of space heat from secondary/supplementary system (Table 11) 0.1000 (201) + Fraction of space heat from main system(s) 0.9000 (202) +Efficiency of main space heating system 1 (in %) 84.0000 (206) +Efficiency of main space heating system 2 (in %) 0.0000 (207) +Efficiency of secondary/supplementary heating system, % 100.0000 (208) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Space heating requirement + 969.7718 800.8547 691.2617 411.6437 176.7821 0.0000 0.0000 0.0000 0.0000 362.2694 678.5179 974.2033 (98) +Space heating efficiency (main heating system 1) + 84.0000 84.0000 84.0000 84.0000 84.0000 0.0000 0.0000 0.0000 0.0000 84.0000 84.0000 84.0000 (210) +Space heating fuel (main heating system) + 1039.0412 858.0586 740.6375 441.0468 189.4094 0.0000 0.0000 0.0000 0.0000 388.1458 726.9834 1043.7892 (211) +Space heating efficiency (main heating system 2) + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (212) +Space heating fuel (main heating system 2) + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (213) +Space heating fuel (secondary) + 96.9772 80.0855 69.1262 41.1644 17.6782 0.0000 0.0000 0.0000 0.0000 36.2269 67.8518 97.4203 (215) +Water heating +Water heating requirement + 233.5658 206.6142 219.6130 193.3250 187.5742 167.9734 165.0893 173.1474 175.4000 195.3711 207.4875 231.0425 (64) +Efficiency of water heater 75.0000 (216) +(217)m 81.9246 81.8127 81.4500 80.6802 78.8786 75.0000 75.0000 75.0000 75.0000 80.3856 81.5192 81.9491 (217) +Fuel for water heating, kWh/month + 285.0986 252.5452 269.6294 239.6187 237.8011 223.9646 220.1191 230.8632 233.8667 243.0424 254.5261 281.9342 (219) +Space cooling fuel requirement +(221)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (221) +Pumps and Fa 7.3041 6.5973 7.3041 7.0685 7.3041 7.0685 7.3041 7.3041 7.0685 7.3041 7.0685 7.3041 (231) +Lighting 62.2853 49.9676 44.9903 32.9618 25.4607 20.8016 23.2261 30.1901 39.2140 51.4509 58.1136 64.0164 (232) +Electricity generated by PVs (Appendix M) (negative quantity) +(233a)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (233a) +Electricity generated by wind turbines (Appendix M) (negative quantity) +(234a)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (234a) +Electricity generated by hydro-electric generators (Appendix M) (negative quantity) +(235a)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235a) +Electricity used or net electricity generated by micro-CHP (Appendix N) (negative if net generation) +(235c)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235c) +Electricity generated by PVs (Appendix M) (negative quantity) +(233b)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (233b) +Electricity generated by wind turbines (Appendix M) (negative quantity) +(234b)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (234b) +Electricity generated by hydro-electric generators (Appendix M) (negative quantity) +(235b)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235b) +Electricity used or net electricity generated by micro-CHP (Appendix N) (negative if net generation) +(235d)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235d) +Annual totals kWh/year +Space heating fuel - main system 1 5427.1120 (211) +Space heating fuel - main system 2 0.0000 (213) +Space heating fuel - secondary 506.5305 (215) +Efficiency of water heater 75.0000 +Water heating fuel used 2973.0093 (219) +Space cooling fuel 0.0000 (221) +Electricity for pumps and fans: + central heating pump 41.0000 (230c) + main heating flue fan 45.0000 (230e) +Total electricity for the above, kWh/year 86.0000 (231) +Electricity for lighting (calculated in Appendix L) 502.6784 (232) +Energy saving/generation technologies (Appendices M ,N and Q) +PV generation 0.0000 (233) +Wind generation 0.0000 (234) +Hydro-electric generation (Appendix N) 0.0000 (235a) +Electricity generated - Micro CHP (Appendix N) 0.0000 (235) +Appendix Q - special features +Energy saved or generated 0.0000 (236) +Energy used 0.0000 (237) +Total delivered energy for all uses 9495.3302 (238) +---------------------------------------------------------------------------------------------------- +10a. Fuel costs - using BEDF prices (596) +---------------------------------------------------------------------------------------------------- + Fuel Fuel price Fuel cost + kWh/year p/kWh £/year +Space heating - main system 1 5427.1120 7.3200 397.2646 (240) +Total CO2 associated with community systems 0.0000 (473) +Space heating - secondary 506.5305 27.6700 140.1570 (242) +Water heating (other fuel) 2973.0093 7.3200 217.6243 (247) +Energy for instantaneous electric shower(s) 0.0000 27.6700 0.0000 (247a) +Pumps, fans and electric keep-hot 86.0000 27.6700 23.7962 (249) +Energy for lighting 502.6784 27.6700 139.0911 (250) +Additional standing charges 108.0000 (251) +Total energy cost 1025.9332 (255) +---------------------------------------------------------------------------------------------------- +12a. Carbon dioxide emissions - Individual heating systems including micro-CHP +---------------------------------------------------------------------------------------------------- + Energy Emission factor Emissions + kWh/year kg CO2/kWh kg CO2/year +Space heating - main system 1 5427.1120 0.2100 1139.6935 (261) +Total CO2 associated with community systems 0.0000 (373) +Space heating - secondary 506.5305 0.1549 78.4693 (263) +Water heating (other fuel) 2973.0093 0.2100 624.3320 (264) +Space and water heating 1842.4948 (265) +Pumps, fans and electric keep-hot 86.0000 0.1387 11.9293 (267) +Energy for lighting 502.6784 0.1443 72.5520 (268) +Total CO2, kg/year 1926.9760 (272) +---------------------------------------------------------------------------------------------------- +13a. Primary energy - Individual heating systems including micro-CHP +---------------------------------------------------------------------------------------------------- + Energy Primary energy factor Primary energy + kWh/year kg CO2/kWh kWh/year +Space heating - main system 1 5427.1120 1.1300 6132.6365 (275) +Total CO2 associated with community systems 0.0000 (473) +Space heating - secondary 506.5305 1.5736 797.0530 (277) +Water heating (other fuel) 2973.0093 1.1300 3359.5005 (278) +Space and water heating 10289.1901 (279) +Pumps, fans and electric keep-hot 86.0000 1.5128 130.1008 (281) +Energy for lighting 502.6784 1.5338 771.0249 (282) +Total Primary energy kWh/year 11190.3158 (286) +---------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------- +SAP 10 WORKSHEET FOR Existing dwelling (SAP) (Version 10.2, February 2022) +CALCULATION OF ENERGY RATING FOR IMPROVED DWELLING +---------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------- +1. Overall dwelling characteristics +---------------------------------------------------------------------------------------------------- + Area Storey height Volume +Main dwelling (m2) (m) (m3) + Ground floor 31.1600 (1b) x 2.4000 (2b) = 74.7840 (1b) - (3b) + First floor 31.1600 (1c) x 2.6500 (2c) = 82.5740 (1c) - (3c) +Total floor area TFA = (1a)+(1b)+(1c)+(1d)+(1e)...(1n) 62.3200 (4) + Dwelling volume (3a)+(3b)+(3c)+(3d)+(3e)...(3n) = 157.3580 (5) +---------------------------------------------------------------------------------------------------- +2. Ventilation rate +---------------------------------------------------------------------------------------------------- + m3 per hour + +Number of open chimneys 0 * 80 = 0.0000 (6a) +Number of open flues 0 * 20 = 0.0000 (6b) +Number of chimneys / flues attached to closed fire 0 * 10 = 0.0000 (6c) +Number of flues attached to solid fuel boiler 0 * 20 = 0.0000 (6d) +Number of flues attached to other heater 0 * 35 = 0.0000 (6e) +Number of blocked chimneys 0 * 20 = 0.0000 (6f) +Number of intermittent extract fans 0 * 10 = 0.0000 (7a) +Number of passive vents 0 * 10 = 0.0000 (7b) +Number of flueless gas fires 0 * 40 = 0.0000 (7c) + Air changes per hour +Infiltration due to chimneys, flues and fans = (6a)+(6b)+(6c)+(6d)+(6e)+(6f)+(6g)+(7a)+(7b)+(7c) = 0.0000 / (5) = 0.0000 (8) + Number of storeys in the dwelling (ns) 2 (9) + Additional infiltration [(9) - 1] x 0.1 = 0.1000 (10) + Structural infiltration: 0.25 for steel or timber frame or 0.35 for masonry construction 0.3500 (11) + If suspended wooden floor, enter 0.2 (unsealed) or 0.1 (sealed), else enter 0 0.0000 (12) + If no draught lobby, enter 0.05, else enter 0 0.0500 (13) + Percentage of windows and doors draught stripped 67.0000 (14) + Window infiltration 0.25 - [0.2 * (14) / 100] = 0.1160 (15) +Pressure test No +Infiltration rate 0.6160 (18) +Number of sides sheltered 1 (19) +Shelter factor (20) = 1 - [0.075 x (19)] = 0.9250 (20) +Infiltration rate adjusted to include shelter factor (21) = (18) x (20) = 0.5698 (21) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Wind speed 5.1000 5.0000 4.9000 4.4000 4.3000 3.8000 3.8000 3.7000 4.0000 4.3000 4.5000 4.7000 (22) +Wind factor 1.2750 1.2500 1.2250 1.1000 1.0750 0.9500 0.9500 0.9250 1.0000 1.0750 1.1250 1.1750 (22a) +Adj infilt rate + 0.7265 0.7123 0.6980 0.6268 0.6125 0.5413 0.5413 0.5271 0.5698 0.6125 0.6410 0.6695 (22b) +Effective ac 0.7639 0.7537 0.7436 0.6964 0.6876 0.6465 0.6465 0.6389 0.6623 0.6876 0.7055 0.7241 (25) +---------------------------------------------------------------------------------------------------- +3. Heat losses and heat loss parameter +---------------------------------------------------------------------------------------------------- +Element Gross Openings NetArea U-value A x U K-value A x K +Main dwelling m2 m2 m2 W/m2K W/K kJ/m2K kJ/K + Doors uninsulated 1 3.7000 3.0000 11.1000 (26) + Windows 1 12.9800 2.5180 32.6835 (27) + Ground floor Main 31.1600 0.2500 7.7900 (28a) + External walls Main 80.6500 16.6800 63.9700 0.4800 30.7056 (29a) + External roof Main 31.1600 31.1600 0.3000 9.3480 (30) +Total net area of external elements Aum(A, m2) 142.9700 (31) +Fabric heat loss, W/K = Sum (A x U) (26)...(30) + (32) = 91.6271 (33) +Main dwelling + Party walls Main 34.2900 0.0000 0.0000 (32) +Thermal mass parameter (TMP = Cm / TFA) in kJ/m2K 250.0000 (35) +Thermal bridges (User defined value 0.150 * total exposed area) 21.4455 (36) +Point Thermal bridges (36a) = 0.0000 +Total fabric heat loss (33) + (36) + (36a) = 113.0726 (37) +Ventilation heat loss calculated monthly (38)m = 0.33 x (25)m x (5) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +(38)m 39.6678 39.1356 38.6140 36.1641 35.7058 33.5720 33.5720 33.1768 34.3939 35.7058 36.6330 37.6025 (38) +Heat transfer coeff + 152.7403 152.2082 151.6866 149.2367 148.7783 146.6445 146.6445 146.2494 147.4664 148.7783 149.7056 150.6750 (39) +Average = Sum(39)m / 12 = 149.2345 + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +HLP 2.4509 2.4424 2.4340 2.3947 2.3873 2.3531 2.3531 2.3467 2.3663 2.3873 2.4022 2.4178 (40) +HLP (average) 2.3946 +Days in mont 31 28 31 30 31 30 31 31 30 31 30 31 +---------------------------------------------------------------------------------------------------- +4. Water heating energy requirements (kWh/year) +---------------------------------------------------------------------------------------------------- +Assumed occupancy 2.0464 (42) +Hot water usage for mixer showers + 51.2148 50.4451 49.3236 47.1777 45.5941 43.8281 42.8243 43.9374 45.1575 47.0536 49.2456 51.0186 (42a) +Hot water usage for baths + 26.6248 26.2294 25.6726 24.6459 23.8771 23.0246 22.5642 23.1171 23.7192 24.6313 25.6791 26.5348 (42b) +Hot water usage for other uses + 37.4603 36.0981 34.7359 33.3737 32.0116 30.6494 30.6494 32.0116 33.3737 34.7359 36.0981 37.4603 (42c) +Average daily hot water use (litres/day) 105.9586 (43) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Daily hot water use + 115.2999 112.7727 109.7321 105.1973 101.4828 97.5021 96.0379 99.0660 102.2504 106.4209 111.0229 115.0137 (44) +Energy conte 182.6069 160.5868 168.6541 144.0099 136.6153 119.8902 116.1495 122.6644 126.0850 144.4121 158.1725 180.0836 (45) +Energy content (annual) Total = Sum(45)m = 1759.9303 +Distribution loss (46)m = 0.15 x (45)m + 27.3910 24.0880 25.2981 21.6015 20.4923 17.9835 17.4224 18.3997 18.9127 21.6618 23.7259 27.0125 (46) +Water storage loss (or HIU loss): +Total storage loss + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (56) +If cylinder contains dedicated solar storage + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (57) +Primary loss 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (59) +Combi loss 50.9589 46.0274 50.9589 49.3151 50.9589 48.0832 48.9398 50.4830 49.3151 50.9589 49.3151 50.9589 (61) +Total heat required for water heating calculated for each month + 233.5658 206.6142 219.6130 193.3250 187.5742 167.9734 165.0893 173.1474 175.4000 195.3711 207.4875 231.0425 (62) +WWHRS 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63a) +PV diverter 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63b) +Solar input 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63c) +FGHRS 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63d) +Output from w/h + 233.5658 206.6142 219.6130 193.3250 187.5742 167.9734 165.0893 173.1474 175.4000 195.3711 207.4875 231.0425 (64) + Total per year (kWh/year) = Sum(64)m = 2356.2034 (64) +Electric shower(s) + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (64a) + Total Energy used by instantaneous electric shower(s) (kWh/year) = Sum(64a)m = 0.0000 (64a) +Heat gains from water heating, kWh/month + 73.4565 64.9019 68.8172 60.2121 58.1643 51.8843 50.8547 53.4067 54.2520 60.7568 64.9211 72.6175 (65) +---------------------------------------------------------------------------------------------------- +5. Internal gains (see Table 5 and 5a) +---------------------------------------------------------------------------------------------------- +Metabolic gains (Table 5), Watts + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +(66)m 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 (66) +Lighting gains (calculated in Appendix L, equation L9 or L9a), also see Table 5 + 21.4748 19.0737 15.5118 11.7434 8.7783 7.4110 8.0079 10.4090 13.9709 17.7393 20.7043 22.0716 (67) +Appliances gains (calculated in Appendix L, equation L13 or L13a), also see Table 5 + 266.8027 269.5713 262.5946 247.7419 228.9932 211.3720 199.5999 196.8313 203.8081 218.6607 237.4095 255.0307 (68) +Cooking gains (calculated in Appendix L, equation L15 or L15a), also see Table 5 + 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 (69) +Pumps, fans 3.0000 3.0000 3.0000 3.0000 3.0000 0.0000 0.0000 0.0000 0.0000 3.0000 3.0000 3.0000 (70) +Losses e.g. evaporation (negative values) (Table 5) + -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 (71) +Water heating gains (Table 5) + 98.7319 96.5803 92.4963 83.6279 78.1778 72.0615 68.3530 71.7832 75.3500 81.6623 90.1682 97.6042 (72) + Total internal gains + 480.2625 478.4784 463.8557 436.3663 409.2025 381.0977 366.2140 369.2766 383.3821 411.3154 441.5351 467.9596 (73) +---------------------------------------------------------------------------------------------------- +6. Solar gains +---------------------------------------------------------------------------------------------------- +[Jan] Area Solar flux g FF Access Gains + m2 Table 6a Specific data Specific data factor W + W/m2 or Table 6b or Table 6c Table 6d +---------------------------------------------------------------------------------------------------- +North 7.2000 10.6334 0.7600 0.7000 0.7700 28.2260 (74) +East 2.8900 19.6403 0.7600 0.7000 0.7700 20.9262 (76) +West 2.8900 19.6403 0.7600 0.7000 0.7700 20.9262 (80) +---------------------------------------------------------------------------------------------------- +Solar gains 70.0784 135.8134 226.4912 343.8726 439.3250 459.0195 433.0958 359.0167 267.0192 161.3582 87.0054 57.9479 (83) +Total gains 550.3409 614.2918 690.3469 780.2390 848.5275 840.1171 799.3098 728.2933 650.4014 572.6737 528.5405 525.9076 (84) +---------------------------------------------------------------------------------------------------- +7. Mean internal temperature (heating season) +---------------------------------------------------------------------------------------------------- +Temperature during heating periods in the living area from Table 9, Th1 (C) 21.0000 (85) +Utilisation factor for gains for living area, ni1,m (see Table 9a) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +tau 28.3342 28.4333 28.5310 28.9994 29.0888 29.5120 29.5120 29.5918 29.3475 29.0888 28.9086 28.7226 +alpha 2.8889 2.8956 2.9021 2.9333 2.9393 2.9675 2.9675 2.9728 2.9565 2.9393 2.9272 2.9148 +util living area + 0.9906 0.9863 0.9760 0.9497 0.8924 0.7878 0.6632 0.7179 0.8842 0.9654 0.9864 0.9919 (86) +MIT 18.2110 18.4316 18.8731 19.5070 20.1207 20.6096 20.8376 20.7883 20.3770 19.6041 18.8156 18.1916 (87) +Th 2 19.0497 19.0547 19.0597 19.0832 19.0876 19.1083 19.1083 19.1122 19.1003 19.0876 19.0787 19.0694 (88) +util rest of house + 0.9871 0.9810 0.9658 0.9258 0.8333 0.6527 0.4342 0.4999 0.7921 0.9440 0.9801 0.9888 (89) +MIT 2 16.6722 16.8940 17.3333 17.9651 18.5373 18.9492 19.0790 19.0662 18.7874 18.0739 17.2925 16.6652 (90) +Living area fraction fLA = Living area / (4) = 0.3001 (91) +MIT 17.1340 17.3553 17.7953 18.4277 19.0124 19.4474 19.6067 19.5829 19.2644 18.5330 17.7495 17.1232 (92) +Temperature adjustment 0.0000 +adjusted MIT 17.1340 17.3553 17.7953 18.4277 19.0124 19.4474 19.6067 19.5829 19.2644 18.5330 17.7495 17.1232 (93) +---------------------------------------------------------------------------------------------------- +8. Space heating requirement +---------------------------------------------------------------------------------------------------- + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Utilisation 0.9815 0.9738 0.9560 0.9143 0.8302 0.6825 0.5049 0.5658 0.8028 0.9348 0.9733 0.9838 (94) +Useful gains 540.1819 598.2008 659.9997 713.3767 704.4687 573.3873 403.5552 412.0764 522.1727 535.3218 514.4052 517.4024 (95) +Ext temp. 4.3000 4.9000 6.5000 8.9000 11.7000 14.6000 16.6000 16.4000 14.1000 10.6000 7.1000 4.2000 (96) +Heat loss rate W + 1960.2641 1895.8060 1713.3509 1421.8886 1087.9269 710.8496 440.9131 465.4991 761.5729 1180.2651 1594.2946 1947.2023 (97) +Space heating kWh + 1056.5411 871.9907 783.6932 510.1286 285.2929 0.0000 0.0000 0.0000 0.0000 479.8378 777.5204 1063.7711 (98a) +Space heating requirement - total per year (kWh/year) 5828.7758 +Solar heating kWh + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (98b) +Solar heating contribution - total per year (kWh/year) 0.0000 +Space heating kWh + 1056.5411 871.9907 783.6932 510.1286 285.2929 0.0000 0.0000 0.0000 0.0000 479.8378 777.5204 1063.7711 (98c) +Space heating requirement after solar contribution - total per year (kWh/year) 5828.7758 +Space heating per m2 (98c) / (4) = 93.5298 (99) +---------------------------------------------------------------------------------------------------- +9a. Energy requirements - Individual heating systems, including micro-CHP +---------------------------------------------------------------------------------------------------- +Fraction of space heat from secondary/supplementary system (Table 11) 0.1000 (201) +Fraction of space heat from main system(s) 0.9000 (202) +Efficiency of main space heating system 1 (in %) 84.0000 (206) +Efficiency of main space heating system 2 (in %) 0.0000 (207) +Efficiency of secondary/supplementary heating system, % 100.0000 (208) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Space heating requirement + 1056.5411 871.9907 783.6932 510.1286 285.2929 0.0000 0.0000 0.0000 0.0000 479.8378 777.5204 1063.7711 (98) +Space heating efficiency (main heating system 1) + 84.0000 84.0000 84.0000 84.0000 84.0000 0.0000 0.0000 0.0000 0.0000 84.0000 84.0000 84.0000 (210) +Space heating fuel (main heating system) + 1132.0083 934.2758 839.6713 546.5663 305.6709 0.0000 0.0000 0.0000 0.0000 514.1119 833.0576 1139.7547 (211) +Space heating efficiency (main heating system 2) + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (212) +Space heating fuel (main heating system 2) + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (213) +Space heating fuel (secondary) + 105.6541 87.1991 78.3693 51.0129 28.5293 0.0000 0.0000 0.0000 0.0000 47.9838 77.7520 106.3771 (215) +Water heating +Water heating requirement + 233.5658 206.6142 219.6130 193.3250 187.5742 167.9734 165.0893 173.1474 175.4000 195.3711 207.4875 231.0425 (64) +Efficiency of water heater 75.0000 (216) +(217)m 82.0582 81.9505 81.6730 81.1157 79.9500 75.0000 75.0000 75.0000 75.0000 80.9734 81.7563 82.0850 (217) +Fuel for water heating, kWh/month + 284.6342 252.1206 268.8932 238.3322 234.6145 223.9646 220.1191 230.8632 233.8667 241.2782 253.7878 281.4673 (219) +Space cooling fuel requirement +(221)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (221) +Pumps and Fa 7.3041 6.5973 7.3041 7.0685 7.3041 7.0685 7.3041 7.3041 7.0685 7.3041 7.0685 7.3041 (231) +Lighting 18.7968 15.0795 13.5774 9.9474 7.6836 6.2776 7.0093 9.1109 11.8342 15.5271 17.5378 19.3192 (232) +Electricity generated by PVs (Appendix M) (negative quantity) +(233a)m -34.1798 -49.1906 -71.4871 -79.7104 -83.3882 -72.3889 -71.4835 -67.4980 -60.4123 -54.9397 -37.6017 -29.4537 (233a) +Electricity generated by wind turbines (Appendix M) (negative quantity) +(234a)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (234a) +Electricity generated by hydro-electric generators (Appendix M) (negative quantity) +(235a)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235a) +Electricity used or net electricity generated by micro-CHP (Appendix N) (negative if net generation) +(235c)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235c) +Electricity generated by PVs (Appendix M) (negative quantity) +(233b)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (233b) +Electricity generated by wind turbines (Appendix M) (negative quantity) +(234b)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (234b) +Electricity generated by hydro-electric generators (Appendix M) (negative quantity) +(235b)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235b) +Electricity used or net electricity generated by micro-CHP (Appendix N) (negative if net generation) +(235d)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235d) +Annual totals kWh/year +Space heating fuel - main system 1 6245.1169 (211) +Space heating fuel - main system 2 0.0000 (213) +Space heating fuel - secondary 582.8776 (215) +Efficiency of water heater 75.0000 +Water heating fuel used 2963.9416 (219) +Space cooling fuel 0.0000 (221) +Electricity for pumps and fans: + central heating pump 41.0000 (230c) + main heating flue fan 45.0000 (230e) +Total electricity for the above, kWh/year 86.0000 (231) +Electricity for lighting (calculated in Appendix L) 151.7006 (232) +Energy saving/generation technologies (Appendices M ,N and Q) +PV generation -711.7338 (233) +Wind generation 0.0000 (234) +Hydro-electric generation (Appendix N) 0.0000 (235a) +Electricity generated - Micro CHP (Appendix N) 0.0000 (235) +Appendix Q - special features + Energy saved or generated 0.0000 (236) +Energy used 0.0000 (237) +Total delivered energy for all uses 9317.9029 (238) +---------------------------------------------------------------------------------------------------- +10a. Fuel costs - using Table 12 prices +---------------------------------------------------------------------------------------------------- + Fuel Fuel price Fuel cost + kWh/year p/kWh £/year +Space heating - main system 1 6245.1169 3.4800 217.3301 (240) +Total CO2 associated with community systems 0.0000 (473) +Space heating - secondary 582.8776 13.1900 76.8816 (242) +Water heating (other fuel) 2963.9416 3.4800 103.1452 (247) +Energy for instantaneous electric shower(s) 0.0000 13.1900 0.0000 (247a) +Pumps, fans and electric keep-hot 86.0000 13.1900 11.3434 (249) +Energy for lighting 151.7006 13.1900 20.0093 (250) +Additional standing charges 120.0000 (251) +Energy saving/generation technologies +PV Unit electricity used in dwelling -711.7338 13.1900 -93.8777 +PV Unit electricity exported 0.0000 13.1900 0.0000 +Total -93.8777 (252) +Total energy cost 454.8318 (255) +---------------------------------------------------------------------------------------------------- +11a. SAP rating - Individual heating systems +---------------------------------------------------------------------------------------------------- +Energy cost deflator (Table 12): 0.4200 (256) +Energy cost factor (ECF) [(255) x (256)] / [(4) + 45.0] = 1.7800 (257) +SAP value 75.1690 +SAP rating (Section 12) 75 (258) +SAP band C +---------------------------------------------------------------------------------------------------- +12a. Carbon dioxide emissions - Individual heating systems including micro-CHP +---------------------------------------------------------------------------------------------------- + Energy Emission factor Emissions + kWh/year kg CO2/kWh kg CO2/year +Space heating - main system 1 6245.1169 0.2100 1311.4746 (261) +Total CO2 associated with community systems 0.0000 (373) +Space heating - secondary 582.8776 0.1541 89.8305 (263) +Water heating (other fuel) 2963.9416 0.2100 622.4277 (264) +Space and water heating 2023.7328 (265) +Pumps, fans and electric keep-hot 86.0000 0.1387 11.9293 (267) +Energy for lighting 151.7006 0.1443 21.8951 (268) +Energy saving/generation technologies +PV Unit electricity used in dwelling -711.7338 0.1353 -96.2872 +PV Unit electricity exported 0.0000 0.0000 0.0000 +Total -96.2872 (269) +Total CO2, kg/year 1961.2700 (272) +CO2 emissions per m2 31.4700 (273) +EI value 75.5115 +EI rating 76 (274) +EI band C +---------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------- +SAP 10 WORKSHEET FOR Existing dwelling (SAP) (Version 10.2, February 2022) +CALCULATION OF EPC COSTS, EMISSIONS AND PRIMARY ENERGY FOR IMPROVED DWELLING +---------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------------------- +1. Overall dwelling characteristics +---------------------------------------------------------------------------------------------------- + Area Storey height Volume +Main dwelling (m2) (m) (m3) + Ground floor 31.1600 (1b) x 2.4000 (2b) = 74.7840 (1b) - (3b) + First floor 31.1600 (1c) x 2.6500 (2c) = 82.5740 (1c) - (3c) +Total floor area TFA = (1a)+(1b)+(1c)+(1d)+(1e)...(1n) 62.3200 (4) +Dwelling volume (3a)+(3b)+(3c)+(3d)+(3e)...(3n) = 157.3580 (5) +---------------------------------------------------------------------------------------------------- +2. Ventilation rate +---------------------------------------------------------------------------------------------------- + m3 per hour + +Number of open chimneys 0 * 80 = 0.0000 (6a) +Number of open flues 0 * 20 = 0.0000 (6b) +Number of chimneys / flues attached to closed fire 0 * 10 = 0.0000 (6c) +Number of flues attached to solid fuel boiler 0 * 20 = 0.0000 (6d) +Number of flues attached to other heater 0 * 35 = 0.0000 (6e) +Number of blocked chimneys 0 * 20 = 0.0000 (6f) +Number of intermittent extract fans 0 * 10 = 0.0000 (7a) +Number of passive vents 0 * 10 = 0.0000 (7b) +Number of flueless gas fires 0 * 40 = 0.0000 (7c) + Air changes per hour +Infiltration due to chimneys, flues and fans = (6a)+(6b)+(6c)+(6d)+(6e)+(6f)+(6g)+(7a)+(7b)+(7c) = 0.0000 / (5) = 0.0000 (8) + Number of storeys in the dwelling (ns) 2 (9) + Additional infiltration [(9) - 1] x 0.1 = 0.1000 (10) + Structural infiltration: 0.25 for steel or timber frame or 0.35 for masonry construction 0.3500 (11) + If suspended wooden floor, enter 0.2 (unsealed) or 0.1 (sealed), else enter 0 0.0000 (12) + If no draught lobby, enter 0.05, else enter 0 0.0500 (13) + Percentage of windows and doors draught stripped 67.0000 (14) + Window infiltration 0.25 - [0.2 * (14) / 100] = 0.1160 (15) +Pressure test No +Infiltration rate 0.6160 (18) +Number of sides sheltered 1 (19) +Shelter factor (20) = 1 - [0.075 x (19)] = 0.9250 (20) +Infiltration rate adjusted to include shelter factor (21) = (18) x (20) = 0.5698 (21) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Wind speed 3.8000 3.7000 3.7000 3.5000 3.5000 3.1000 3.3000 3.0000 3.0000 3.1000 3.1000 3.5000 (22) +Wind factor 0.9500 0.9250 0.9250 0.8750 0.8750 0.7750 0.8250 0.7500 0.7500 0.7750 0.7750 0.8750 (22a) +Adj infilt rate + 0.5413 0.5271 0.5271 0.4986 0.4986 0.4416 0.4701 0.4274 0.4274 0.4416 0.4416 0.4986 (22b) +Effective ac 0.6465 0.6389 0.6389 0.6243 0.6243 0.5975 0.6105 0.5913 0.5913 0.5975 0.5975 0.6243 (25) +---------------------------------------------------------------------------------------------------- +3. Heat losses and heat loss parameter +---------------------------------------------------------------------------------------------------- +Element Gross Openings NetArea U-value A x U K-value A x K +Main dwelling m2 m2 m2 W/m2K W/K kJ/m2K kJ/K + Doors uninsulated 1 3.7000 3.0000 11.1000 (26) + Windows 1 12.9800 2.5180 32.6835 (27) + Ground floor Main 31.1600 0.2500 7.7900 (28a) + External walls Main 80.6500 16.6800 63.9700 0.4800 30.7056 (29a) + External roof Main 31.1600 31.1600 0.3000 9.3480 (30) +Total net area of external elements Aum(A, m2) 142.9700 (31) +Fabric heat loss, W/K = Sum (A x U) (26)...(30) + (32) = 91.6271 (33) +Main dwelling + Party walls Main 34.2900 0.0000 0.0000 (32) +Thermal mass parameter (TMP = Cm / TFA) in kJ/m2K 250.0000 (35) +Thermal bridges (User defined value 0.150 * total exposed area) 21.4455 (36) +Point Thermal bridges (36a) = 0.0000 +Total fabric heat loss (33) + (36) + (36a) = 113.0726 (37) +Ventilation heat loss calculated monthly (38)m = 0.33 x (25)m x (5) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec + (38)m 33.5720 33.1768 33.1768 32.4181 32.4181 31.0272 31.7016 30.7058 30.7058 31.0272 31.0272 32.4181 (38) +Heat transfer coeff + 146.6445 146.2494 146.2494 145.4907 145.4907 144.0998 144.7742 143.7784 143.7784 144.0998 144.0998 145.4907 (39) +Average = Sum(39)m / 12 = 145.0205 + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +HLP 2.3531 2.3467 2.3467 2.3346 2.3346 2.3123 2.3231 2.3071 2.3071 2.3123 2.3123 2.3346 (40) +HLP (average) 2.3270 +Days in mont 31 28 31 30 31 30 31 31 30 31 30 31 +---------------------------------------------------------------------------------------------------- +4. Water heating energy requirements (kWh/year) +---------------------------------------------------------------------------------------------------- +Assumed occupancy 2.0464 (42) +Hot water usage for mixer showers + 51.2148 50.4451 49.3236 47.1777 45.5941 43.8281 42.8243 43.9374 45.1575 47.0536 49.2456 51.0186 (42a) +Hot water usage for baths + 26.6248 26.2294 25.6726 24.6459 23.8771 23.0246 22.5642 23.1171 23.7192 24.6313 25.6791 26.5348 (42b) +Hot water usage for other uses + 37.4603 36.0981 34.7359 33.3737 32.0116 30.6494 30.6494 32.0116 33.3737 34.7359 36.0981 37.4603 (42c) +Average daily hot water use (litres/day) 105.9586 (43) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Daily hot water use + 115.2999 112.7727 109.7321 105.1973 101.4828 97.5021 96.0379 99.0660 102.2504 106.4209 111.0229 115.0137 (44) +Energy conte 182.6069 160.5868 168.6541 144.0099 136.6153 119.8902 116.1495 122.6644 126.0850 144.4121 158.1725 180.0836 (45) +Energy content (annual) Total = Sum(45)m = 1759.9303 +Distribution loss (46)m = 0.15 x (45)m + 27.3910 24.0880 25.2981 21.6015 20.4923 17.9835 17.4224 18.3997 18.9127 21.6618 23.7259 27.0125 (46) +Water storage loss (or HIU loss): +Total storage loss + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (56) +If cylinder contains dedicated solar storage + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (57) +Primary loss 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (59) +Combi loss 50.9589 46.0274 50.9589 49.3151 50.9589 48.0832 48.9398 50.4830 49.3151 50.9589 49.3151 50.9589 (61) +Total heat required for water heating calculated for each month + 233.5658 206.6142 219.6130 193.3250 187.5742 167.9734 165.0893 173.1474 175.4000 195.3711 207.4875 231.0425 (62) +WWHRS 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63a) +PV diverter 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63b) +Solar input 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63c) +FGHRS 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (63d) +Output from w/h + 233.5658 206.6142 219.6130 193.3250 187.5742 167.9734 165.0893 173.1474 175.4000 195.3711 207.4875 231.0425 (64) + Total per year (kWh/year) = Sum(64)m = 2356.2034 (64) +Electric shower(s) + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (64a) + Total Energy used by instantaneous electric shower(s) (kWh/year) = Sum(64a)m = 0.0000 (64a) +Heat gains from water heating, kWh/month + 73.4565 64.9019 68.8172 60.2121 58.1643 51.8843 50.8547 53.4067 54.2520 60.7568 64.9211 72.6175 (65) +---------------------------------------------------------------------------------------------------- +5. Internal gains (see Table 5 and 5a) +---------------------------------------------------------------------------------------------------- +Metabolic gains (Table 5), Watts + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +(66)m 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 122.7847 (66) +Lighting gains (calculated in Appendix L, equation L9 or L9a), also see Table 5 + 21.4748 19.0737 15.5118 11.7434 8.7783 7.4110 8.0079 10.4090 13.9709 17.7393 20.7043 22.0716 (67) +Appliances gains (calculated in Appendix L, equation L13 or L13a), also see Table 5 + 266.8027 269.5713 262.5946 247.7419 228.9932 211.3720 199.5999 196.8313 203.8081 218.6607 237.4095 255.0307 (68) +Cooking gains (calculated in Appendix L, equation L15 or L15a), also see Table 5 + 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 49.3249 (69) +Pumps, fans 3.0000 3.0000 3.0000 3.0000 3.0000 0.0000 0.0000 0.0000 0.0000 3.0000 3.0000 3.0000 (70) +Losses e.g. evaporation (negative values) (Table 5) + -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 -81.8565 (71) +Water heating gains (Table 5) + 98.7319 96.5803 92.4963 83.6279 78.1778 72.0615 68.3530 71.7832 75.3500 81.6623 90.1682 97.6042 (72) +Total internal gains + 480.2625 478.4784 463.8557 436.3663 409.2025 381.0977 366.2140 369.2766 383.3821 411.3154 441.5351 467.9596 (73) +---------------------------------------------------------------------------------------------------- +6. Solar gains +---------------------------------------------------------------------------------------------------- +[Jan] Area Solar flux g FF Access Gains + m2 Table 6a Specific data Specific data factor W + W/m2 or Table 6b or Table 6c Table 6d +---------------------------------------------------------------------------------------------------- +North 7.2000 11.5683 0.7600 0.7000 0.7700 30.7077 (74) +East 2.8900 21.5704 0.7600 0.7000 0.7700 22.9826 (76) +West 2.8900 21.5704 0.7600 0.7000 0.7700 22.9826 (80) +---------------------------------------------------------------------------------------------------- +Solar gains 76.6730 133.8602 224.7811 352.4577 437.5532 487.5086 459.0611 390.9446 291.1241 173.9501 98.4071 64.8811 (83) +Total gains 556.9355 612.3386 688.6368 788.8241 846.7557 868.6063 825.2750 760.2212 674.5063 585.2655 539.9422 532.8408 (84) +---------------------------------------------------------------------------------------------------- +7. Mean internal temperature (heating season) +---------------------------------------------------------------------------------------------------- +Temperature during heating periods in the living area from Table 9, Th1 (C) 21.0000 (85) +Utilisation factor for gains for living area, ni1,m (see Table 9a) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +tau 29.5120 29.5918 29.5918 29.7461 29.7461 30.0332 29.8933 30.1003 30.1003 30.0332 30.0332 29.7461 +alpha 2.9675 2.9728 2.9728 2.9831 2.9831 3.0022 2.9929 3.0067 3.0067 3.0022 3.0022 2.9831 +util living area + 0.9881 0.9831 0.9683 0.9259 0.8265 0.6133 0.4021 0.4603 0.7942 0.9446 0.9810 0.9895 (86) +MIT 18.5901 18.7933 19.2566 19.8925 20.4875 20.8766 20.9779 20.9661 20.6784 19.9847 19.1949 18.5759 (87) +Th 2 19.1083 19.1122 19.1122 19.1196 19.1196 19.1333 19.1266 19.1365 19.1365 19.1333 19.1333 19.1196 (88) +util rest of house + 0.9832 0.9760 0.9536 0.8869 0.7217 0.3874 0.0923 0.1390 0.6247 0.9057 0.9713 0.9851 (89) +MIT 2 17.0860 17.2893 17.7444 18.3557 18.8686 19.1133 19.1266 19.1361 19.0331 18.4675 17.7026 17.0793 (90) +Living area fraction fLA = Living area / (4) = 0.3001 (91) +MIT 17.5373 17.7406 18.1982 18.8168 19.3543 19.6424 19.6821 19.6852 19.5268 18.9228 18.1504 17.5284 (92) +Temperature adjustment 0.0000 +adjusted MIT 17.5373 17.7406 18.1982 18.8168 19.3543 19.6424 19.6821 19.6852 19.5268 18.9228 18.1504 17.5284 (93) +---------------------------------------------------------------------------------------------------- +8. Space heating requirement +---------------------------------------------------------------------------------------------------- + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Utilisation 0.9770 0.9682 0.9433 0.8784 0.7380 0.4574 0.1888 0.2404 0.6697 0.8994 0.9634 0.9794 (94) +Useful gains 544.1060 592.8372 649.5622 692.8756 624.9256 397.2799 155.7782 182.7905 451.6915 526.4023 520.2010 521.8383 (95) +Ext temp. 5.6000 6.2000 8.0000 10.6000 13.7000 16.7000 18.6000 18.4000 15.7000 12.2000 8.5000 5.6000 (96) +Heat loss rate W + 1750.5467 1687.8026 1491.4798 1195.4735 822.6545 423.9972 156.6578 184.7872 550.2136 968.7502 1390.6200 1735.4681 (97) +Space heating kWh + 897.5919 735.8167 626.3867 361.8705 147.1103 0.0000 0.0000 0.0000 0.0000 329.1069 626.7017 902.9406 (98a) +Space heating requirement - total per year (kWh/year) 4627.5252 +Solar heating kWh + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (98b) +Solar heating contribution - total per year (kWh/year) 0.0000 +Space heating kWh + 897.5919 735.8167 626.3867 361.8705 147.1103 0.0000 0.0000 0.0000 0.0000 329.1069 626.7017 902.9406 (98c) +Space heating requirement after solar contribution - total per year (kWh/year) 4627.5252 +Space heating per m2 (98c) / (4) = 74.2543 (99) +---------------------------------------------------------------------------------------------------- +9a. Energy requirements - Individual heating systems, including micro-CHP + ---------------------------------------------------------------------------------------------------- +Fraction of space heat from secondary/supplementary system (Table 11) 0.1000 (201) +Fraction of space heat from main system(s) 0.9000 (202) +Efficiency of main space heating system 1 (in %) 84.0000 (206) +Efficiency of main space heating system 2 (in %) 0.0000 (207) +Efficiency of secondary/supplementary heating system, % 100.0000 (208) + Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec +Space heating requirement + 897.5919 735.8167 626.3867 361.8705 147.1103 0.0000 0.0000 0.0000 0.0000 329.1069 626.7017 902.9406 (98) +Space heating efficiency (main heating system 1) + 84.0000 84.0000 84.0000 84.0000 84.0000 0.0000 0.0000 0.0000 0.0000 84.0000 84.0000 84.0000 (210) +Space heating fuel (main heating system) + 961.7056 788.3750 671.1287 387.7184 157.6182 0.0000 0.0000 0.0000 0.0000 352.6145 671.4661 967.4363 (211) +Space heating efficiency (main heating system 2) + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (212) +Space heating fuel (main heating system 2) + 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (213) +Space heating fuel (secondary) + 89.7592 73.5817 62.6387 36.1870 14.7110 0.0000 0.0000 0.0000 0.0000 32.9107 62.6702 90.2941 (215) +Water heating +Water heating requirement + 233.5658 206.6142 219.6130 193.3250 187.5742 167.9734 165.0893 173.1474 175.4000 195.3711 207.4875 231.0425 (64) +Efficiency of water heater 75.0000 (216) +(217)m 81.7985 81.6695 81.2661 80.4060 78.4793 75.0000 75.0000 75.0000 75.0000 80.1761 81.3739 81.8263 (217) +Fuel for water heating, kWh/month + 285.5380 252.9883 270.2395 240.4361 239.0111 223.9646 220.1191 230.8632 233.8667 243.6773 254.9804 282.3572 (219) +Space cooling fuel requirement +(221)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (221) +Pumps and Fa 7.3041 6.5973 7.3041 7.0685 7.3041 7.0685 7.3041 7.3041 7.0685 7.3041 7.0685 7.3041 (231) +Lighting 18.7968 15.0795 13.5774 9.9474 7.6836 6.2776 7.0093 9.1109 11.8342 15.5271 17.5378 19.3192 (232) +Electricity generated by PVs (Appendix M) (negative quantity) +(233a)m -36.0565 -47.5984 -69.1347 -78.0275 -80.0163 -73.7910 -72.7946 -69.6016 -62.6000 -55.9069 -40.3706 -31.7267 (233a) +Electricity generated by wind turbines (Appendix M) (negative quantity) +(234a)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (234a) +Electricity generated by hydro-electric generators (Appendix M) (negative quantity) +(235a)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235a) +Electricity used or net electricity generated by micro-CHP (Appendix N) (negative if net generation) +(235c)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235c) +Electricity generated by PVs (Appendix M) (negative quantity) +(233b)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (233b) +Electricity generated by wind turbines (Appendix M) (negative quantity) +(234b)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (234b) +Electricity generated by hydro-electric generators (Appendix M) (negative quantity) +(235b)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235b) +Electricity used or net electricity generated by micro-CHP (Appendix N) (negative if net generation) +(235d)m 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 (235d) +Annual totals kWh/year +Space heating fuel - main system 1 4958.0627 (211) +Space heating fuel - main system 2 0.0000 (213) +Space heating fuel - secondary 462.7525 (215) +Efficiency of water heater 75.0000 +Water heating fuel used 2978.0416 (219) +Space cooling fuel 0.0000 (221) +Electricity for pumps and fans: + central heating pump 41.0000 (230c) + main heating flue fan 45.0000 (230e) +Total electricity for the above, kWh/year 86.0000 (231) +Electricity for lighting (calculated in Appendix L) 151.7006 (232) +Energy saving/generation technologies (Appendices M ,N and Q) +PV generation -717.6247 (233) +Wind generation 0.0000 (234) +Hydro-electric generation (Appendix N) 0.0000 (235a) +Electricity generated - Micro CHP (Appendix N) 0.0000 (235) +Appendix Q - special features +Energy saved or generated 0.0000 (236) +Energy used 0.0000 (237) +Total delivered energy for all uses 7918.9327 (238) +---------------------------------------------------------------------------------------------------- +10a. Fuel costs - using BEDF prices (596) +---------------------------------------------------------------------------------------------------- + Fuel Fuel price Fuel cost + kWh/year p/kWh £/year +Space heating - main system 1 4958.0627 7.3200 362.9302 (240) +Total CO2 associated with community systems 0.0000 (473) +Space heating - secondary 462.7525 27.6700 128.0436 (242) +Water heating (other fuel) 2978.0416 7.3200 217.9926 (247) +Energy for instantaneous electric shower(s) 0.0000 27.6700 0.0000 (247a) +Pumps, fans and electric keep-hot 86.0000 27.6700 23.7962 (249) +Energy for lighting 151.7006 27.6700 41.9755 (250) +Additional standing charges 108.0000 (251) +Energy saving/generation technologies +PV Unit electricity used in dwelling -717.6247 27.6700 -198.5668 +PV Unit electricity exported 0.0000 5.8100 0.0000 +Total -198.5668 (252) +Total energy cost 684.1714 (255) +---------------------------------------------------------------------------------------------------- +12a. Carbon dioxide emissions - Individual heating systems including micro-CHP +---------------------------------------------------------------------------------------------------- + Energy Emission factor Emissions + kWh/year kg CO2/kWh kg CO2/year +Space heating - main system 1 4958.0627 0.2100 1041.1932 (261) +Total CO2 associated with community systems 0.0000 (373) +Space heating - secondary 462.7525 0.1551 71.7611 (263) +Water heating (other fuel) 2978.0416 0.2100 625.3887 (264) +Space and water heating 1738.3430 (265) +Pumps, fans and electric keep-hot 86.0000 0.1387 11.9293 (267) +Energy for lighting 151.7006 0.1443 21.8951 (268) +Energy saving/generation technologies +PV Unit electricity used in dwelling -717.6247 0.1352 -97.0291 +PV Unit electricity exported 0.0000 0.0000 0.0000 +Total -97.0291 (269) +Total CO2, kg/year 1675.1383 (272) +---------------------------------------------------------------------------------------------------- +13a. Primary energy - Individual heating systems including micro-CHP +---------------------------------------------------------------------------------------------------- + Energy Primary energy factor Primary energy + kWh/year kg CO2/kWh kWh/year +Space heating - main system 1 4958.0627 1.1300 5602.6109 (275) +Total CO2 associated with community systems 0.0000 (473) +Space heating - secondary 462.7525 1.5741 728.4382 (277) +Water heating (other fuel) 2978.0416 1.1300 3365.1870 (278) +Space and water heating 9696.2361 (279) +Pumps, fans and electric keep-hot 86.0000 1.5128 130.1008 (281) +Energy for lighting 151.7006 1.5338 232.6834 (282) +Energy saving/generation technologies +PV Unit electricity used in dwelling -717.6247 1.4997 -1076.2548 +PV Unit electricity exported 0.0000 0.0000 0.0000 +Total -1076.2548 (283) +Total Primary energy kWh/year 8982.7654 (286) + \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_217091901/elmhurst_summary.pdf b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_217091901/elmhurst_summary.pdf new file mode 100644 index 000000000..bf5e1e382 Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_217091901/elmhurst_summary.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_217091901/elmhurst_worksheet.pdf b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_217091901/elmhurst_worksheet.pdf new file mode 100644 index 000000000..c99553292 Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_217091901/elmhurst_worksheet.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_217091901/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_217091901/epc.json new file mode 100644 index 000000000..5ac1c41ad --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_217091901/epc.json @@ -0,0 +1,384 @@ +{ + "uprn": 217091901, + "roofs": [ + { + "description": "Flat, no insulation", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + } + ], + "walls": [ + { + "description": "Solid brick, as built, no insulation (assumed)", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + } + ], + "floors": [ + { + "description": "(another dwelling below)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 3, + "window": { + "description": "Single glazed", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + }, + "lighting": { + "description": "Good lighting efficiency", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "W11 1EP", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "LONDON", + "built_form": "NR", + "created_at": "2026-05-16 15:45:17", + "door_count": 0, + "region_code": 17, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 1, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_outlet_type": 1 + } + ], + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 26, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2107, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 18658 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 5, + "window_width": 1.2, + "window_height": 1.8, + "draught_proofed": "false", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 5, + "window_width": 1.2, + "window_height": 1.8, + "draught_proofed": "false", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 9, + "window_type": 2, + "glazing_type": 5, + "window_width": 0.5, + "window_height": 0.5, + "draught_proofed": "false", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 9, + "window_type": 2, + "glazing_type": 5, + "window_width": 0.7, + "window_height": 0.5, + "draught_proofed": "false", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 5, + "window_width": 1.2, + "window_height": 1.8, + "draught_proofed": "false", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 1, + "window_type": 1, + "glazing_type": 5, + "window_width": 1.1, + "window_height": 1.4, + "draught_proofed": "false", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Top-floor flat", + "language_code": 1, + "pressure_test": 4, + "property_type": 2, + "address_line_1": "Second Floor Flat", + "address_line_2": "216 Westbourne Park Road", + "assessment_type": "RdSAP", + "completion_date": "2026-05-16", + "inspection_date": "2026-05-16", + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 3, + "top_storey": "Y", + "storey_count": 3, + "flat_location": 2, + "heat_loss_corridor": 0 + }, + "total_floor_area": 63, + "transaction_type": 8, + "conservatory_type": 1, + "has_draught_lobby": "true", + "heated_room_count": 3, + "registration_date": "2026-05-16", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "true", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "true" + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 280, + "floor_heat_loss": 6, + "roof_construction": 1, + "wall_construction": 3, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.85, + "quantity": "metres" + }, + "total_floor_area": { + "value": 63.29, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 22.8, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 14, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "A", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 6, + "wall_insulation_thickness": "NI", + "flat_roof_insulation_thickness": "AB" + } + ], + "solar_water_heating": "N", + "habitable_room_count": 3, + "heating_cost_current": { + "value": 967, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.8, + "energy_rating_average": 60, + "energy_rating_current": 62, + "lighting_cost_current": { + "value": 43, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, TRVs and bypass", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "blocked_chimneys_count": 1, + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 330, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 167, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 0, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 411, + "currency": "GBP" + }, + "indicative_cost": "\u00a3900 - \u00a31,200", + "improvement_type": "A2", + "improvement_details": { + "improvement_number": 45 + }, + "improvement_category": 5, + "energy_performance_rating": 74, + "environmental_impact_rating": 76 + }, + { + "sequence": 2, + "typical_saving": { + "value": 129, + "currency": "GBP" + }, + "indicative_cost": "\u00a37,500 - \u00a311,000", + "improvement_type": "Q", + "improvement_details": { + "improvement_number": 7 + }, + "improvement_category": 5, + "energy_performance_rating": 78, + "environmental_impact_rating": 81 + }, + { + "sequence": 3, + "typical_saving": { + "value": 18, + "currency": "GBP" + }, + "indicative_cost": "\u00a3150 - \u00a3250", + "improvement_type": "D", + "improvement_details": { + "improvement_number": 10 + }, + "improvement_category": 5, + "energy_performance_rating": 79, + "environmental_impact_rating": 82 + }, + { + "sequence": 4, + "typical_saving": { + "value": 78, + "currency": "GBP" + }, + "indicative_cost": "\u00a34,500 - \u00a36,000", + "improvement_type": "O", + "improvement_details": { + "improvement_number": 8 + }, + "improvement_category": 5, + "energy_performance_rating": 81, + "environmental_impact_rating": 85 + } + ], + "co2_emissions_potential": 1.0, + "energy_rating_potential": 81, + "lighting_cost_potential": { + "value": 45, + "currency": "GBP" + }, + "schema_version_original": "21.0.1", + "hot_water_cost_potential": { + "value": 167, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2022.69, + "space_heating_existing_dwelling": 9246.82 + }, + "draughtproofed_door_count": 0, + "energy_consumption_current": 245, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 0, + "calculation_software_version": "5.02r0344", + "energy_consumption_potential": 90, + "environmental_impact_current": 59, + "cfl_fixed_lighting_bulbs_count": 2, + "current_energy_efficiency_band": "D", + "environmental_impact_potential": 85, + "led_fixed_lighting_bulbs_count": 9, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 45, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_4510053280/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_4510053280/epc.json new file mode 100644 index 000000000..5e14204ab --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_4510053280/epc.json @@ -0,0 +1,299 @@ +{ + "uprn": 4510053280, + "roofs": [ + { + "description": "(another dwelling above)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "walls": [ + { + "description": "Solid brick, as built, no insulation (assumed)", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + { + "description": "Solid brick, as built, partial insulation (assumed)", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "floors": [ + { + "description": "Solid, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "window": { + "description": "Full secondary glazing", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + "lighting": { + "description": "Good lighting efficiency", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "NE1 4DG", + "hot_water": { + "description": "From main system, no cylinder thermostat", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 4 + }, + "post_town": "NEWCASTLE UPON TYNE", + "built_form": "NR", + "created_at": "2026-05-24 12:41:32", + "door_count": 0, + "region_code": 1, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 1, + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 29, + "secondary_fuel_type": 29, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 29, + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2203, + "main_heating_category": 4, + "main_heating_fraction": 1, + "mcs_installed_heat_pump": "false", + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 100053 + } + ], + "immersion_heating_type": "NA", + "secondary_heating_type": 691, + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "orientation": 4, + "window_type": 1, + "glazing_type": 4, + "window_width": 1.3, + "window_height": 1.46, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "orientation": 4, + "window_type": 1, + "glazing_type": 4, + "window_width": 1.3, + "window_height": 1.46, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Air source heat pump, radiators, electric", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 5 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Ground-floor flat", + "language_code": 1, + "pressure_test": 4, + "property_type": 2, + "address_line_1": "64 Waterloo Street", + "assessment_type": "RdSAP", + "completion_date": "2026-05-24", + "inspection_date": "2026-05-22", + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 1, + "top_storey": "N", + "storey_count": 3, + "flat_location": 0, + "heat_loss_corridor": 2, + "unheated_corridor_length": 3.5 + }, + "total_floor_area": 47, + "transaction_type": 8, + "conservatory_type": 1, + "has_draught_lobby": "true", + "heated_room_count": 2, + "registration_date": "2026-05-24", + "sap_energy_source": { + "mains_gas": "N", + "meter_type": 2, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 1, + "electricity_smart_meter_present": "false" + }, + "secondary_heating": { + "description": "Room heaters, electric", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 9 + ], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 520, + "floor_heat_loss": 7, + "roof_construction": 3, + "wall_construction": 3, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.32, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 47, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 8, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 14.15, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "B", + "sap_alternative_wall_1": { + "wall_area": 8.12, + "sheltered_wall": "Y", + "wall_dry_lined": "N", + "wall_thickness": 520, + "wall_construction": 3, + "wall_insulation_type": 4, + "wall_thickness_measured": "Y", + "wall_insulation_thickness": "NI" + }, + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": "ND", + "roof_insulation_thickness": "ND", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "solar_water_heating": "N", + "habitable_room_count": 2, + "heating_cost_current": { + "value": 278, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 0.8, + "energy_rating_average": 60, + "energy_rating_current": 51, + "lighting_cost_current": { + "value": 39, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Room thermostat only", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + } + ], + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 272, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 1295, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 68, + "currency": "GBP" + }, + "indicative_cost": "\u00a3220 - \u00a3250", + "improvement_type": "G", + "improvement_details": { + "improvement_number": 16 + }, + "improvement_category": 5, + "energy_performance_rating": 53, + "environmental_impact_rating": 88 + } + ], + "co2_emissions_potential": 0.8, + "energy_rating_potential": 53, + "lighting_cost_potential": { + "value": 39, + "currency": "GBP" + }, + "schema_version_original": "21.0.1", + "hot_water_cost_potential": { + "value": 1233, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 5072.68, + "space_heating_existing_dwelling": 2884.0 + }, + "draughtproofed_door_count": 0, + "energy_consumption_current": 190, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "5.02r0344", + "energy_consumption_potential": 182, + "environmental_impact_current": 88, + "current_energy_efficiency_band": "E", + "environmental_impact_potential": 88, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "E", + "co2_emissions_current_per_floor_area": 18, + "low_energy_fixed_lighting_bulbs_count": 6, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_47084930/elmhurst_summary.pdf b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_47084930/elmhurst_summary.pdf new file mode 100644 index 000000000..80c83175c Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_47084930/elmhurst_summary.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_47084930/elmhurst_worksheet.pdf b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_47084930/elmhurst_worksheet.pdf new file mode 100644 index 000000000..5e94b7ac8 Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_47084930/elmhurst_worksheet.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_47084930/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_47084930/epc.json new file mode 100644 index 000000000..f268aac88 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_47084930/epc.json @@ -0,0 +1,324 @@ +{ + "uprn": 47084930, + "roofs": [ + { + "description": "Flat, insulated", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "walls": [ + { + "description": "Cavity wall, with internal insulation", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "(other premises below)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 3, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": "Excellent lighting efficiency", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "NE12 8AQ", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "NEWCASTLE UPON TYNE", + "built_form": "NR", + "created_at": "2026-05-06 10:59:38", + "door_count": 0, + "region_code": 1, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 1, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_outlet_type": 1 + } + ], + "number_baths_wwhrs": 0, + "water_heating_code": 901, + "water_heating_fuel": 26, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 17815 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.23, + "window_height": 1.61, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.23, + "window_height": 1.61, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.23, + "window_height": 1.61, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.23, + "window_height": 1.61, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 3, + "window_type": 1, + "glazing_type": 2, + "window_width": 1.23, + "window_height": 1.61, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Top-floor flat", + "language_code": 1, + "pressure_test": 4, + "property_type": 2, + "address_line_1": "83 Station Road", + "address_line_2": "Forest Hall", + "assessment_type": "RdSAP", + "completion_date": "2026-05-06", + "inspection_date": "2026-05-05", + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 3, + "top_storey": "Y", + "storey_count": 2, + "flat_location": 1, + "heat_loss_corridor": 1 + }, + "total_floor_area": 64, + "transaction_type": 8, + "conservatory_type": 1, + "has_draught_lobby": "true", + "heated_room_count": 3, + "registration_date": "2026-05-06", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "true", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "true" + }, + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 380, + "floor_heat_loss": 3, + "roof_construction": 1, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.32, + "quantity": "metres" + }, + "total_floor_area": { + "value": 64.26, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 18.15, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 18.15, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 3, + "construction_age_band": "E", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 6, + "wall_insulation_thickness": "100mm", + "flat_roof_insulation_thickness": "75mm" + } + ], + "solar_water_heating": "N", + "habitable_room_count": 3, + "heating_cost_current": { + "value": 623, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.0, + "energy_rating_average": 60, + "energy_rating_current": 74, + "lighting_cost_current": { + "value": 42, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 553, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 207, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 69, + "currency": "GBP" + }, + "indicative_cost": "\u00a3900 - \u00a31,200", + "improvement_type": "A2", + "improvement_details": { + "improvement_number": 45 + }, + "improvement_category": 5, + "energy_performance_rating": 76, + "environmental_impact_rating": 78 + } + ], + "co2_emissions_potential": 1.8, + "energy_rating_potential": 76, + "lighting_cost_potential": { + "value": 42, + "currency": "GBP" + }, + "schema_version_original": "21.0.1", + "hot_water_cost_potential": { + "value": 208, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2384.75, + "space_heating_existing_dwelling": 5681.26 + }, + "draughtproofed_door_count": 0, + "energy_consumption_current": 170, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "5.02r0344", + "energy_consumption_potential": 154, + "environmental_impact_current": 76, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 78, + "led_fixed_lighting_bulbs_count": 9, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 31, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_74061136/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_74061136/epc.json new file mode 100644 index 000000000..86bc6b0c2 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-21.0.1/uprn_74061136/epc.json @@ -0,0 +1,315 @@ +{ + "uprn": 74061136, + "roofs": [ + { + "description": "(another dwelling above)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "walls": [ + { + "description": "Solid brick, with internal insulation", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + { + "description": "Timber frame, with additional insulation", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "(other premises below)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 1, + "window": { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": "Excellent lighting efficiency", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "S41 7TP", + "hot_water": { + "description": "Electric instantaneous at point of use", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 5 + }, + "post_town": "Chesterfield,", + "psv_count": 0, + "built_form": 4, + "created_at": "2026-05-20 13:45:13", + "door_count": 1, + "region_code": 6, + "report_type": 2, + "sap_heating": { + "number_baths": 1, + "cylinder_size": 1, + "shower_outlets": [ + { + "shower_wwhrs": 1, + "shower_outlet_type": 1 + } + ], + "number_baths_wwhrs": 0, + "water_heating_code": 909, + "water_heating_fuel": 29, + "cylinder_thermostat": "N", + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 29, + "storage_heaters": [ + { + "index_number": 230002, + "number_of_heaters": 1, + "high_heat_retention": "true" + } + ], + "fan_flue_present": "Y", + "heat_emitter_type": 0, + "emitter_temperature": "NA", + "main_heating_number": 1, + "main_heating_control": 2404, + "main_heating_category": 7, + "main_heating_fraction": 50, + "sap_main_heating_code": 409, + "main_heating_data_source": 2 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 10.2, + "sap_windows": [ + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1, + "quantity": "m" + }, + "window_height": { + "value": 1.6, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + }, + { + "pvc_frame": "true", + "orientation": 5, + "window_type": 1, + "glazing_type": 2, + "window_width": { + "value": 1, + "quantity": "m" + }, + "window_height": { + "value": 1.6, + "quantity": "m" + }, + "draught_proofed": "true", + "window_location": 0, + "window_wall_type": 1, + "permanent_shutters_present": "N", + "permanent_shutters_insulated": "N" + } + ], + "schema_type": "RdSAP-Schema-21.0.1", + "uprn_source": "Address Matched", + "country_code": "ENG", + "main_heating": [ + { + "description": "Electric storage heaters", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 5 + } + ], + "air_tightness": { + "description": "(not tested)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "dwelling_type": "Mid-floor flat", + "language_code": 1, + "pressure_test": 4, + "property_type": 2, + "address_line_1": "Flat 3,", + "address_line_2": "14-20 Corporation Street,", + "assessment_type": "RdSAP", + "completion_date": "2026-05-20", + "inspection_date": "2026-05-20", + "extensions_count": 0, + "measurement_type": 1, + "open_flues_count": 0, + "sap_flat_details": { + "level": 2, + "top_storey": "N", + "storey_count": 3, + "flat_location": 1, + "heat_loss_corridor": 2, + "unheated_corridor_length": 6 + }, + "total_floor_area": 24, + "transaction_type": 1, + "conservatory_type": 2, + "heated_room_count": 1, + "other_flues_count": 0, + "registration_date": "2026-05-20", + "sap_energy_source": { + "mains_gas": "N", + "meter_type": 3, + "pv_connection": 0, + "photovoltaic_supply": { + "none_or_no_details": { + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "gas_smart_meter_present": "false", + "is_dwelling_export_capable": "false", + "wind_turbines_terrain_type": 2, + "electricity_smart_meter_present": "false" + }, + "secondary_heating": { + "description": "Portable electric heaters (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "closed_flues_count": 0, + "extract_fans_count": 0, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 3, + "roof_construction": 3, + "wall_construction": 3, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": 2.4, + "total_floor_area": 24, + "party_wall_length": 8, + "heat_loss_perimeter": 12 + } + ], + "wall_insulation_type": 3, + "construction_age_band": "B", + "sap_alternative_wall_1": { + "wall_area": 14.4, + "sheltered_wall": "Y", + "wall_dry_lined": "N", + "wall_thickness": 150, + "wall_construction": 5, + "wall_insulation_type": 3, + "wall_thickness_measured": "Y", + "wall_insulation_thickness": "NI" + }, + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": "ND", + "roof_insulation_thickness": "ND", + "wall_insulation_thickness": "measured", + "wall_insulation_thickness_measured": 25, + "wall_insulation_thermal_conductivity": 3 + } + ], + "boilers_flues_count": 0, + "open_chimneys_count": 0, + "solar_water_heating": "N", + "habitable_room_count": 1, + "heating_cost_current": { + "value": 348, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 0.4, + "energy_rating_average": 60, + "energy_rating_current": 73, + "lighting_cost_current": { + "value": 23, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Controls for high heat retention storage heaters", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "blocked_chimneys_count": 0, + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 348, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 344, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "schema_version_current": "LIG-21.0", + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 66, + "indicative_cost": "\u00a3600 - \u00a31,500", + "improvement_type": "Y", + "improvement_details": { + "improvement_number": 49 + }, + "improvement_category": 5, + "energy_performance_rating": 76, + "environmental_impact_rating": 92 + } + ], + "co2_emissions_potential": 0.4, + "energy_rating_potential": 76, + "lighting_cost_potential": { + "value": 23, + "currency": "GBP" + }, + "schema_version_original": "LIG-21.0", + "hot_water_cost_potential": { + "value": 278, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 1079.39, + "space_heating_existing_dwelling": 2013.05 + }, + "draughtproofed_door_count": 1, + "energy_consumption_current": 200, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "10.2.2.0", + "energy_consumption_potential": 187, + "environmental_impact_current": 91, + "cfl_fixed_lighting_bulbs_count": 0, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 92, + "led_fixed_lighting_bulbs_count": 6, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 19, + "incandescent_fixed_lighting_bulbs_count": 0 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-19.1.0/uprn_10094615101/epc.json b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-19.1.0/uprn_10094615101/epc.json new file mode 100644 index 000000000..187b4a17a --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-19.1.0/uprn_10094615101/epc.json @@ -0,0 +1,507 @@ +{ + "der": 3.23, + "ter": 13.27, + "dfee": 35.2, + "dper": 41.21, + "tfee": 36.7, + "tper": 70.27, + "uprn": 10094615101, + "roofs": [ + { + "description": { + "value": "Average thermal transmittance 0.09 W/m\u00b2K", + "language": "1" + }, + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "walls": [ + { + "description": { + "value": "Average thermal transmittance 0.19 W/m\u00b2K", + "language": "1" + }, + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "floors": [ + { + "description": { + "value": "(other premises below)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": "ND", + "windows": { + "description": { + "value": "High performance glazing", + "language": "1" + }, + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "lighting": { + "description": { + "value": "Good lighting efficiency", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "TN37 7GF", + "data_type": 2, + "hot_water": { + "description": { + "value": "From main system, waste water heat recovery", + "language": "1" + }, + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "post_town": "ST LEONARDS-ON-SEA", + "built_form": 1, + "created_at": "2025-03-04 11:13:23", + "living_area": 28.88, + "orientation": 2, + "region_code": 14, + "report_type": 3, + "sap_heating": { + "number_baths": 1, + "thermal_store": 1, + "shower_outlets": [ + { + "shower_wwhrs": 2, + "shower_flow_rate": 8, + "shower_outlet_type": 3 + } + ], + "water_fuel_type": 39, + "water_heating_code": 914, + "instantaneous_wwhrs": { + "wwhrs_index_number1": 80140 + }, + "main_heating_details": [ + { + "has_fghrs": "false", + "main_fuel_type": 39, + "storage_heaters": [ + { + "index_number": 230003, + "number_of_heaters": 4, + "high_heat_retention": "true" + } + ], + "main_heating_code": 409, + "main_heating_number": 1, + "is_condensing_boiler": "false", + "main_heating_control": 2404, + "main_heating_category": 7, + "main_heating_fraction": 1, + "main_heating_data_source": 3, + "is_oil_pump_in_heated_space": "false", + "is_main_heating_hetas_approved": "false" + }, + { + "has_fghrs": "false", + "main_fuel_type": 39, + "main_heating_number": 2, + "main_heating_control": 2100, + "main_heating_category": 4, + "main_heating_fraction": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 104014, + "is_oil_pump_in_heated_space": "false", + "is_main_heating_hetas_approved": "false" + } + ], + "has_hot_water_cylinder": "false", + "has_cylinder_thermostat": "true", + "has_fixed_air_conditioning": "false", + "secondary_heating_category": 1, + "is_immersion_for_summer_use": "false", + "is_heat_pump_installed_to_mis": "false", + "is_hot_water_separately_timed": "true", + "main_heating_systems_interaction": 2, + "is_heat_pump_assisted_by_immersion": "false" + }, + "sap_version": 10.2, + "schema_type": "SAP-Schema-19.1.0", + "uprn_source": "Address Matched", + "country_code": "ENG", + "main_heating": [ + { + "description": { + "value": "Electric storage heaters", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 5 + } + ], + "sap_lighting": [ + [ + { + "lighting_power": 10, + "lighting_outlets": 18, + "lighting_efficacy": 90 + } + ] + ], + "terrain_type": 2, + "air_tightness": { + "description": { + "value": "Air permeability [AP50] = 4.7 m\u00b3/h.m\u00b2 (as tested)", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "dwelling_type": "Top-floor flat", + "language_code": 1, + "property_type": 2, + "pv_connection": 2, + "address_line_1": "7 Tyler House", + "address_line_2": "51 Potter Street", + "assessment_date": "2025-03-04", + "assessment_type": "SAP", + "completion_date": "2025-03-04", + "inspection_date": "2025-03-04", + "sap_ventilation": { + "psv_count": 0, + "wall_type": 1, + "pressure_test": 1, + "wet_rooms_count": 1, + "air_permeability": 4.65, + "open_flues_count": 0, + "ventilation_type": 8, + "has_draught_lobby": "false", + "other_flues_count": 0, + "closed_flues_count": 0, + "extract_fans_count": 0, + "boilers_flues_count": 0, + "open_chimneys_count": 0, + "sheltered_sides_count": 1, + "blocked_chimneys_count": 0, + "flueless_gas_fires_count": 0, + "mechanical_vent_duct_type": 2, + "mechanical_vent_duct_placement": 1, + "mechanical_ventilation_data_source": 1, + "mechanical_vent_system_index_number": 500298, + "mechanical_vent_duct_insulation_level": 2, + "is_mechanical_vent_approved_installer_scheme": "false" + }, + "design_water_use": 1, + "sap_data_version": 10.2, + "sap_flat_details": { + "level": 3, + "storeys": 4 + }, + "total_floor_area": 76, + "transaction_type": 6, + "cold_water_source": 1, + "conservatory_type": 1, + "registration_date": "2025-03-04", + "sap_energy_source": { + "pv_arrays": [ + { + "pitch": 1, + "peak_power": 1.26, + "orientation": 8, + "overshading": 1 + } + ], + "electricity_tariff": 2 + }, + "sap_opening_types": [ + { + "name": "Window", + "type": 4, + "u_value": 1.2, + "data_source": 2, + "frame_factor": 0.7, + "glazing_type": 7, + "isargonfilled": "false", + "solar_transmittance": 0.52 + }, + { + "name": "Apt door", + "type": 1, + "u_value": 0.71, + "data_source": 2, + "glazing_type": 1, + "isargonfilled": "false" + } + ], + "secondary_heating": { + "description": { + "value": "None", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lowest_storey_area": 75.94, + "lzc_energy_sources": [ + 11 + ], + "sap_building_parts": [ + { + "sap_roofs": [ + { + "name": "Roof (1)", + "u_value": 0.09, + "roof_type": 2, + "total_roof_area": 75.94 + }, + { + "name": "Party roof 1", + "u_value": 0, + "roof_type": 4, + "total_roof_area": 20 + } + ], + "sap_walls": [ + { + "name": "Walls (1)", + "u_value": 0.21, + "wall_type": 2, + "total_wall_area": 61.26, + "is_curtain_walling": "false" + }, + { + "name": "Walls (2)", + "u_value": 0.14, + "wall_type": 2, + "total_wall_area": 24.82, + "is_curtain_walling": "false" + } + ], + "sap_openings": [ + { + "name": "Opening 1", + "type": "Window", + "width": 8.28, + "height": 1, + "location": "Walls (1)", + "orientation": 2 + }, + { + "name": "Opening 2", + "type": "Window", + "width": 3.2, + "height": 1, + "location": "Walls (1)", + "orientation": 4 + }, + { + "name": "Opening 4", + "type": "Apt door", + "width": 2.12, + "height": 1, + "location": "Walls (2)", + "orientation": 5 + }, + { + "name": "Opening 3", + "type": "Window", + "width": 2.56, + "height": 1, + "location": "Walls (1)", + "orientation": 8 + } + ], + "construction_year": 2023, + "sap_thermal_bridges": { + "thermal_bridges": [ + { + "length": 10.1, + "psi_value": 0.08, + "psi_value_source": 1, + "thermal_bridge_type": "E2" + }, + { + "length": 5.91, + "psi_value": 0.063, + "psi_value_source": 1, + "thermal_bridge_type": "E3" + }, + { + "length": 24.94, + "psi_value": 0.06, + "psi_value_source": 1, + "thermal_bridge_type": "E4" + }, + { + "length": 0, + "psi_value": 0.107, + "psi_value_source": 1, + "thermal_bridge_type": "E5" + }, + { + "length": 0, + "psi_value": 0.076, + "psi_value_source": 1, + "thermal_bridge_type": "E5" + }, + { + "length": 25.74, + "psi_value": 0.037, + "psi_value_source": 1, + "thermal_bridge_type": "E7" + }, + { + "length": 10.43, + "psi_value": 0.026, + "psi_value_source": 1, + "thermal_bridge_type": "E7" + }, + { + "length": 11.9, + "psi_value": 0.043, + "psi_value_source": 1, + "thermal_bridge_type": "E16" + }, + { + "length": 0, + "psi_value": 0.12, + "psi_value_source": 1, + "thermal_bridge_type": "E25" + }, + { + "length": 0, + "psi_value": 0.045, + "psi_value_source": 1, + "thermal_bridge_type": "P1" + }, + { + "length": 2.38, + "psi_value": -0.044, + "psi_value_source": 1, + "thermal_bridge_type": "E17" + }, + { + "length": 15.34, + "psi_value": 0.064, + "psi_value_source": 1, + "thermal_bridge_type": "E10" + }, + { + "length": 10.4, + "psi_value": 0.053, + "psi_value_source": 1, + "thermal_bridge_type": "E12" + }, + { + "length": 10.43, + "psi_value": 0.038, + "psi_value_source": 1, + "thermal_bridge_type": "E12" + }, + { + "length": 0, + "psi_value": 0.05, + "psi_value_source": 1, + "thermal_bridge_type": "E18" + }, + { + "length": 0, + "psi_value": 0.15, + "psi_value_source": 4, + "thermal_bridge_type": "E11" + }, + { + "length": 0, + "psi_value": 0.012, + "psi_value_source": 1, + "thermal_bridge_type": "E13" + }, + { + "length": 0, + "psi_value": 0.24, + "psi_value_source": 1, + "thermal_bridge_type": "P5" + } + ], + "thermal_bridge_code": 5 + }, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "storey": 1, + "u_value": 0, + "floor_type": 4, + "storey_height": 2.38, + "heat_loss_area": 0, + "total_floor_area": 75.94 + } + ] + } + ], + "user_interface_name": "Design SAP 10", + "windows_overshading": 2, + "heating_cost_current": { + "value": 269, + "currency": "GBP" + }, + "co2_emissions_current": 0.2, + "energy_rating_average": 60, + "energy_rating_current": 92, + "lighting_cost_current": { + "value": 51, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": { + "value": "Controls for high heat retention storage heaters", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 269, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 94, + "currency": "GBP" + }, + "thermal_mass_parameter": 64, + "user_interface_version": "2.21.9", + "co2_emissions_potential": 0.2, + "energy_rating_potential": 92, + "gas_smart_meter_present": "false", + "lighting_cost_potential": { + "value": 51, + "currency": "GBP" + }, + "schema_version_original": "SAP-Schema-19.1.0", + "hot_water_cost_potential": { + "value": 94, + "currency": "GBP" + }, + "is_in_smoke_control_area": "unknown", + "seller_commission_report": "Y", + "energy_consumption_current": 35, + "has_fixed_air_conditioning": "false", + "is_dwelling_export_capable": "true", + "multiple_glazed_percentage": 100, + "calculation_software_version": "2.21.9", + "energy_consumption_potential": 35, + "environmental_impact_current": 97, + "current_energy_efficiency_band": "A", + "environmental_impact_potential": 97, + "electricity_smart_meter_present": "true", + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "A", + "co2_emissions_current_per_floor_area": 2.6 +} \ No newline at end of file diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 895e4ab30..328397e82 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -361,7 +361,7 @@ class PhotovoltaicArray: @dataclass class SapEnergySource: - mains_gas: bool + gas_connection_available: bool meter_type: str # int in API, str (e.g. "Single") in site notes pv_battery_count: int wind_turbines_count: int diff --git a/datatypes/epc/domain/field_mappings.py b/datatypes/epc/domain/field_mappings.py index cc0f90678..b5d0e6b3a 100644 --- a/datatypes/epc/domain/field_mappings.py +++ b/datatypes/epc/domain/field_mappings.py @@ -1,3 +1,21 @@ +from typing import Optional + +from datatypes.epc.domain.epc_property_data import MainHeatingDetail + PROPERTY_TYPE_LOOKUP = {0: "House", 1: "Bungalow", 2: "Flat", 3: "Maisonette"} ROOF_CONSTRUCTION_LOOKUP = {} ROOF_INSULATION_LOCATION_LOOKUP = {} + +HEAT_NETWORK_MAIN_CODES: frozenset[int] = frozenset({301, 302, 303, 304}) +HEAT_NETWORK_CATEGORY: int = 6 + + +def is_heat_network_main(main: Optional[MainHeatingDetail]) -> bool: + """True when the dwelling's main heating is connected to a shared heat + network — by SAP Table 4a code (301–304) or by main_heating_category 6.""" + if main is None: + return False + code = main.sap_main_heating_code + if isinstance(code, int) and code in HEAT_NETWORK_MAIN_CODES: + return True + return main.main_heating_category == HEAT_NETWORK_CATEGORY diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index e0758e3db..5a04e5165 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4,7 +4,18 @@ import re from dataclasses import replace from datetime import date from decimal import ROUND_HALF_UP, Decimal -from typing import Any, Dict, Final, List, Optional, Sequence, Tuple, TypeVar, Union, cast +from typing import ( + Any, + Dict, + Final, + List, + Optional, + Sequence, + Tuple, + TypeVar, + Union, + cast, +) from datatypes.epc.schema.helpers import from_dict from datatypes.epc.domain.epc import Epc @@ -77,16 +88,16 @@ _SAP_DEFAULT_AGE_BAND: Final[str] = "M" # (66% × 0.70 in-use factor) as the RdSAP-21 API path when no PCDF index is # lodged — the cascade handles a null index gracefully. _SAP_VENTILATION_TYPE_TO_MV_KIND: Final[Dict[int, Optional[str]]] = { - 1: None, # natural (with intermittent extract fans) - 2: None, # passive stack — treated as natural - 3: None, # positive input from loft → natural - 4: "EXTRACT_OR_PIV_OUTSIDE", # positive input from outside - 5: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, centralised (MEV c) - 6: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, decentralised (MEV dc) - 7: "MV", # balanced without heat recovery (MV) - 8: "MVHR", # balanced with heat recovery (MVHR) - 9: None, # natural with extract fans and/or passive vents (legacy) - 10: None, # natural with extract fans and passive vents + 1: None, # natural (with intermittent extract fans) + 2: None, # passive stack — treated as natural + 3: None, # positive input from loft → natural + 4: "EXTRACT_OR_PIV_OUTSIDE", # positive input from outside + 5: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, centralised (MEV c) + 6: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, decentralised (MEV dc) + 7: "MV", # balanced without heat recovery (MV) + 8: "MVHR", # balanced with heat recovery (MVHR) + 9: None, # natural with extract fans and/or passive vents (legacy) + 10: None, # natural with extract fans and passive vents } # rdsap_uvalues WALL_CAVITY = 4 (D7 fallback; U comes from the description). _SAP_DEFAULT_WALL_CONSTRUCTION: Final[int] = 4 @@ -114,6 +125,7 @@ from datatypes.epc.schema.rdsap_schema_21_0_1 import ( RdSapSchema21_0_1, EnergyElement as EnergyElement_21_0_1, ) +from datatypes.epc.schema.common import DescriptionV1 from domain.sap10_calculator.tables.pcdb import heat_pump_record from datatypes.epc.surveys.elmhurst_site_notes import ( AlternativeWall as ElmhurstAlternativeWall, @@ -336,7 +348,7 @@ class EpcPropertyDataMapper: sap_heating=_map_sap_heating(heating, ventilation, survey.water_use), sap_windows=[_map_sap_window(w) for w in survey.windows], sap_energy_source=SapEnergySource( - mains_gas=general.mains_gas_available, + gas_connection_available=general.mains_gas_available, meter_type=general.electric_meter_type, pv_battery_count=renewables.number_of_pv_batteries, wind_turbines_count=0 if not renewables.wind_turbines else 1, @@ -442,7 +454,7 @@ class EpcPropertyDataMapper: ] or None, sap_energy_source=SapEnergySource( - mains_gas=survey.meters.main_gas, + gas_connection_available=survey.meters.main_gas, meter_type=survey.meters.electricity_meter_type, pv_battery_count=0, wind_turbines_count=1 if survey.renewables.wind_turbine_present else 0, @@ -569,9 +581,11 @@ class EpcPropertyDataMapper: dwelling_type=( schema.dwelling_type if isinstance(schema.dwelling_type, str) - else schema.dwelling_type.value - if hasattr(schema.dwelling_type, "value") - else str(schema.dwelling_type) + else ( + schema.dwelling_type.value + if hasattr(schema.dwelling_type, "value") + else str(schema.dwelling_type) + ) ), property_type=str(schema.property_type), built_form=str(schema.built_form), @@ -596,18 +610,42 @@ class EpcPropertyDataMapper: heated_rooms_count=schema.heated_room_count, wet_rooms_count=0, extensions_count=schema.extensions_count, - open_chimneys_count=0, + # Both lodged by the schema and read by the 17.1/18.0/20.0 paths. + # open_chimneys_count was hardcoded 0 (understating infiltration); + # percent_draughtproofed was omitted entirely. + open_chimneys_count=schema.open_fireplaces_count, insulated_door_count=schema.insulated_door_count, draughtproofed_door_count=None, + percent_draughtproofed=schema.percent_draughtproofed, + # ADR-0028: the schema lodges total + low-energy OUTLET counts, not a + # bulb split — mirror the 17.1/18.0/20.0 paths (low-energy outlets → + # low_energy bulbs; the remainder → incandescent). Hardcoding all + # bulb counts to 0 dropped the lodged lighting mix, understating the + # low-energy lighting credit in the SAP lighting-energy calc. led_fixed_lighting_bulbs_count=0, cfl_fixed_lighting_bulbs_count=0, - incandescent_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=( + schema.fixed_lighting_outlets_count + - schema.low_energy_fixed_lighting_outlets_count + ), + low_energy_fixed_lighting_bulbs_count=schema.low_energy_fixed_lighting_outlets_count, roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), main_heating=EpcPropertyDataMapper._map_energy_elements( schema.main_heating ), + # First control system if multiple — mirrors the 19.0/21.0.1 paths. + # 17.0 lodges main_heating_controls (List[EnergyElement]) but omitted + # it, leaving main_heating_controls=None so the heating-control + # recommendations (programmer/room-stat/TRVs) had nothing to read. + main_heating_controls=( + EpcPropertyDataMapper._map_energy_element( + schema.main_heating_controls[0] + ) + if schema.main_heating_controls + else None + ), window=EpcPropertyDataMapper._map_energy_element(schema.window), lighting=EpcPropertyDataMapper._map_energy_element(schema.lighting), hot_water=EpcPropertyDataMapper._map_energy_element(schema.hot_water), @@ -616,12 +654,33 @@ class EpcPropertyDataMapper: ), sap_heating=SapHeating( instantaneous_wwhrs=InstantaneousWwhrs(), + # ADR-0028: derive HW-demand counts from the lodged bath/shower + # ROOM counts, mirroring the 17.1/18.0/20.0 paths. These were + # dropped (left None), so the water-heating demand fell back to + # defaults instead of the lodged bath/mixer-shower counts. + number_baths=( + schema.sap_heating.instantaneous_wwhrs.rooms_with_bath_and_or_shower + + schema.sap_heating.instantaneous_wwhrs.rooms_with_bath_and_mixer_shower + if schema.sap_heating.instantaneous_wwhrs is not None + else None + ), + mixer_shower_count=( + schema.sap_heating.instantaneous_wwhrs.rooms_with_mixer_shower_no_bath + + schema.sap_heating.instantaneous_wwhrs.rooms_with_bath_and_mixer_shower + if schema.sap_heating.instantaneous_wwhrs is not None + else None + ), main_heating_details=[ MainHeatingDetail( has_fghrs=d.has_fghrs == "Y", main_fuel_type=d.main_fuel_type, - boiler_flue_type=None, - fan_flue_present=None, + # Preserve the lodged flue/pump/PCDB fields, matching the + # 19.0/20.0/… paths. The 17.0 schema *does* lodge all of + # these (RdSapSchema17_0.MainHeatingDetail); hardcoding + # them to None silently dropped a PCDB main-heating index + # (efficiency lookup) and the flue/pump SAP adjustments. + boiler_flue_type=d.boiler_flue_type, + fan_flue_present=d.fan_flue_present == "Y", heat_emitter_type=d.heat_emitter_type, emitter_temperature=d.emitter_temperature, main_heating_number=d.main_heating_number, @@ -629,9 +688,9 @@ class EpcPropertyDataMapper: main_heating_category=d.main_heating_category, main_heating_fraction=d.main_heating_fraction, sap_main_heating_code=d.sap_main_heating_code, - central_heating_pump_age=None, + central_heating_pump_age=d.central_heating_pump_age, main_heating_data_source=d.main_heating_data_source, - main_heating_index_number=None, + main_heating_index_number=d.main_heating_index_number, ) for d in schema.sap_heating.main_heating_details ], @@ -642,35 +701,54 @@ class EpcPropertyDataMapper: water_heating_fuel=schema.sap_heating.water_heating_fuel, immersion_heating_type=schema.sap_heating.immersion_heating_type, cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type, - cylinder_thermostat=None, - secondary_fuel_type=None, - secondary_heating_type=None, - cylinder_insulation_thickness_mm=None, + # Preserve the lodged cylinder thermostat + insulation thickness, + # matching the 19.0/20.0/… paths. Both are lodged by the 17.0 + # schema; hardcoding them to None (a) read as "no cylinder + # thermostat" so the water-heating worksheet over-applied its + # ×1.3 penalty and `recommend_cylinder_thermostat` could fire on + # a dwelling that already has one, and (b) discarded the measured + # insulation thickness so the cylinder-loss cascade fell back to + # age-band defaults. + cylinder_thermostat=schema.sap_heating.cylinder_thermostat, + # Preserve the lodged secondary heating from the 17.0 sap_heating + # block, matching every other API path (19.0/20.0/…). Hardcoding + # these to None silently dropped a lodged FIXED secondary (SAP + # codes like 691/694 that the gov API does populate), which + # suppressed the secondary_heating_removal recommendation for any + # 17.0 cert carrying one. + secondary_fuel_type=_api_secondary_fuel_type( + schema.sap_heating.secondary_fuel_type, + schema.sap_heating.secondary_heating_type, + ), + secondary_heating_type=schema.sap_heating.secondary_heating_type, + cylinder_insulation_thickness_mm=schema.sap_heating.cylinder_insulation_thickness, ), # ADR-0028: 990/1000 omit sap_windows -> synthesised from the # glazed_area band + TFA via the shared reduced-field core. The 10 # rich certs keep their lodged per-window geometry (used directly: # window_width = area, height = 1.0). - sap_windows=[ - SapWindow( - frame_material=None, - glazing_gap=0, - orientation=w.orientation, - window_type=w.window_type, - glazing_type=w.glazing_type, - window_width=_measurement_value(w.window_area), - window_height=1.0, - draught_proofed=False, - window_location=w.window_location, - window_wall_type=0, - permanent_shutters_present=False, - ) - for w in schema.sap_windows - ] - if schema.sap_windows - else _synthesise_17_0_sap_windows(schema), + sap_windows=( + [ + SapWindow( + frame_material=None, + glazing_gap=0, + orientation=w.orientation, + window_type=w.window_type, + glazing_type=w.glazing_type, + window_width=_measurement_value(w.window_area), + window_height=1.0, + draught_proofed=False, + window_location=w.window_location, + window_wall_type=0, + permanent_shutters_present=False, + ) + for w in schema.sap_windows + ] + if schema.sap_windows + else _synthesise_17_0_sap_windows(schema) + ), sap_energy_source=SapEnergySource( - mains_gas=es.mains_gas == "Y", + gas_connection_available=es.mains_gas == "Y", meter_type=str(es.meter_type), pv_battery_count=0, wind_turbines_count=es.wind_turbines_count, @@ -706,11 +784,32 @@ class EpcPropertyDataMapper: bp, "wall_insulation_thermal_conductivity", None ), floor_heat_loss=bp.floor_heat_loss, - floor_insulation_thickness=None, + # 17.0 lodges these three building-part fabric fields but the + # mapper hardcoded them to None / omitted them, unlike the + # 19.0/20.0/21.x paths. "NI" (no measured thickness) → None so + # u_floor reaches its age-band default rather than the + # explicit-zero path (see the 19.0 note). roof_insulation_ + # thickness is left raw here (the sloping-ceiling resolve + # helper is a separate, roof-U-moving follow-up). + floor_insulation_thickness=( + None + if bp.floor_insulation_thickness == "NI" + else bp.floor_insulation_thickness + ), + flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, roof_construction=bp.roof_construction, roof_insulation_location=bp.roof_insulation_location, roof_insulation_thickness=bp.roof_insulation_thickness, - sap_room_in_roof=None, + sap_room_in_roof=( + SapRoomInRoof( + floor_area=_measurement_value( + bp.sap_room_in_roof.floor_area + ), + construction_age_band=bp.sap_room_in_roof.construction_age_band, + ) + if bp.sap_room_in_roof + else None + ), ) # RdSAP 10 §6.1 — exclude the glazed conservatory BP from the # fabric loop; carried as `sap_conservatory` below. Mirrors the @@ -719,6 +818,19 @@ class EpcPropertyDataMapper: if getattr(bp, "glazed_perimeter", None) is None ], sap_conservatory=_api_sap_conservatory(schema.sap_building_parts), + # 17.0 lodges `mechanical_ventilation` + `built_form` but passed no + # sap_ventilation, so the §2 cascade fell back to NATURAL and its + # default sheltered_sides=2. Mirror the 19.0/17.1 path exactly (same + # gov code lists — verified in-set across the corpus, strict-coverage + # raises on any divergence). For the current corpus the score-mover + # is sheltered_sides from built_form (every 17.0 cert lodges + # mechanical_ventilation=0 → NATURAL). + sap_ventilation=SapVentilation( + sheltered_sides=_api_sheltered_sides(schema.built_form), + mechanical_ventilation_kind=_api_mechanical_ventilation_kind( + schema.mechanical_ventilation + ), + ), ) @staticmethod @@ -742,9 +854,7 @@ class EpcPropertyDataMapper: # gov property_type code → str, mirroring from_rdsap_schema_17_1. # Feeds the calculator's house/flat heat-transmission split. property_type=( - str(schema.property_type) - if schema.property_type is not None - else None + str(schema.property_type) if schema.property_type is not None else None ), built_form=( str(schema.built_form) if schema.built_form is not None else None @@ -798,7 +908,10 @@ class EpcPropertyDataMapper: led_fixed_lighting_bulbs_count=0, incandescent_fixed_lighting_bulbs_count=( (schema.sap_energy_source.fixed_lighting_outlets_count or 0) - - (schema.sap_energy_source.low_energy_fixed_lighting_outlets_count or 0) + - ( + schema.sap_energy_source.low_energy_fixed_lighting_outlets_count + or 0 + ) ), low_energy_fixed_lighting_bulbs_count=( schema.sap_energy_source.low_energy_fixed_lighting_outlets_count or 0 @@ -865,12 +978,23 @@ class EpcPropertyDataMapper: # D5: mains_gas derived from the heating fuel (full SAP has no # explicit flag); tariff → meter_type; wind turbines pass through. sap_energy_source=SapEnergySource( - mains_gas=_sap_dwelling_on_mains_gas(schema), - meter_type=str(schema.sap_energy_source.electricity_tariff or ""), + gas_connection_available=_sap_dwelling_on_mains_gas(schema), + meter_type=_sap_17_1_meter_type( + schema.sap_energy_source.electricity_tariff + ), + photovoltaic_arrays=_sap_17_1_pv_arrays(schema), pv_battery_count=0, wind_turbines_count=schema.sap_energy_source.wind_turbines_count or 0, gas_smart_meter_present=False, - is_dwelling_export_capable=False, + # Top-level full-SAP export flag (SAP 10-era lodgements; None + # on the SAP 2012 family → False, unchanged). Appendix M1 zeroes + # the PV export credit without the flag, under-rating any + # export-capable PV cert (PRD #1435 WS3). `pv_connection` is NOT + # carried — the full-SAP enum disagrees with the RdSAP one the + # calculator's connection gate reads (see SapSchema17_1). + is_dwelling_export_capable=( + schema.is_dwelling_export_capable == "true" + ), wind_turbines_terrain_type=str( schema.sap_energy_source.wind_turbine_terrain_type or "" ), @@ -1198,7 +1322,7 @@ class EpcPropertyDataMapper: else _synthesise_17_1_sap_windows(schema) ), sap_energy_source=SapEnergySource( - mains_gas=es.mains_gas == "Y", + gas_connection_available=es.mains_gas == "Y", meter_type=str(es.meter_type), pv_battery_count=0, wind_turbines_count=es.wind_turbines_count, @@ -1383,30 +1507,32 @@ class EpcPropertyDataMapper: # ADR-0028: 990/1000 omit sap_windows -> synthesised from the # glazed_area band + TFA (added in a later slice). The 10 rich # certs keep their lodged per-window geometry. - sap_windows=[ - SapWindow( - frame_material=None, - glazing_gap=0, - orientation=w.orientation, - window_type=w.window_type, - glazing_type=w.glazing_type, - # ADR-0028: the 10 rich certs lodge a real per-window - # window_area (Measurement) -- use it directly as geometry - # (width = area, height = 1.0) rather than the placeholder - # windowless 0x0. - window_width=_measurement_value(w.window_area), - window_height=1.0, - draught_proofed=False, - window_location=w.window_location, - window_wall_type=0, - permanent_shutters_present=False, - ) - for w in schema.sap_windows - ] - if schema.sap_windows - else _synthesise_18_0_sap_windows(schema), + sap_windows=( + [ + SapWindow( + frame_material=None, + glazing_gap=0, + orientation=w.orientation, + window_type=w.window_type, + glazing_type=w.glazing_type, + # ADR-0028: the 10 rich certs lodge a real per-window + # window_area (Measurement) -- use it directly as geometry + # (width = area, height = 1.0) rather than the placeholder + # windowless 0x0. + window_width=_measurement_value(w.window_area), + window_height=1.0, + draught_proofed=False, + window_location=w.window_location, + window_wall_type=0, + permanent_shutters_present=False, + ) + for w in schema.sap_windows + ] + if schema.sap_windows + else _synthesise_18_0_sap_windows(schema) + ), sap_energy_source=SapEnergySource( - mains_gas=es.mains_gas == "Y", + gas_connection_available=es.mains_gas == "Y", meter_type=str(es.meter_type), pv_battery_count=0, wind_turbines_count=es.wind_turbines_count, @@ -1472,7 +1598,9 @@ class EpcPropertyDataMapper: ), sap_room_in_roof=( SapRoomInRoof( - floor_area=_measurement_value(bp.sap_room_in_roof.floor_area), + floor_area=_measurement_value( + bp.sap_room_in_roof.floor_area + ), construction_age_band=bp.sap_room_in_roof.construction_age_band, ) if bp.sap_room_in_roof @@ -1501,9 +1629,11 @@ class EpcPropertyDataMapper: dwelling_type=( schema.dwelling_type if isinstance(schema.dwelling_type, str) - else schema.dwelling_type.value - if hasattr(schema.dwelling_type, "value") - else str(schema.dwelling_type) + else ( + schema.dwelling_type.value + if hasattr(schema.dwelling_type, "value") + else str(schema.dwelling_type) + ) ), property_type=str(schema.property_type), built_form=str(schema.built_form), @@ -1528,12 +1658,25 @@ class EpcPropertyDataMapper: heated_rooms_count=schema.heated_room_count, wet_rooms_count=0, extensions_count=schema.extensions_count, - open_chimneys_count=0, + # Both lodged by the schema and read by the 17.1/18.0/20.0 paths. + # open_chimneys_count was hardcoded 0 (understating infiltration); + # percent_draughtproofed was omitted entirely. + open_chimneys_count=schema.open_fireplaces_count, insulated_door_count=schema.insulated_door_count, draughtproofed_door_count=None, + percent_draughtproofed=schema.percent_draughtproofed, + # ADR-0028: the schema lodges total + low-energy OUTLET counts, not a + # bulb split — mirror the 17.1/18.0/20.0 paths (low-energy outlets → + # low_energy bulbs; the remainder → incandescent). Hardcoding all + # bulb counts to 0 dropped the lodged lighting mix, understating the + # low-energy lighting credit in the SAP lighting-energy calc. led_fixed_lighting_bulbs_count=0, cfl_fixed_lighting_bulbs_count=0, - incandescent_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=( + schema.fixed_lighting_outlets_count + - schema.low_energy_fixed_lighting_outlets_count + ), + low_energy_fixed_lighting_bulbs_count=schema.low_energy_fixed_lighting_outlets_count, roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), @@ -1556,6 +1699,22 @@ class EpcPropertyDataMapper: ), sap_heating=SapHeating( instantaneous_wwhrs=InstantaneousWwhrs(), + # ADR-0028: derive HW-demand counts from the lodged bath/shower + # ROOM counts, mirroring the 17.1/18.0/20.0 paths. These were + # dropped (left None), so the water-heating demand fell back to + # defaults instead of the lodged bath/mixer-shower counts. + number_baths=( + schema.sap_heating.instantaneous_wwhrs.rooms_with_bath_and_or_shower + + schema.sap_heating.instantaneous_wwhrs.rooms_with_bath_and_mixer_shower + if schema.sap_heating.instantaneous_wwhrs is not None + else None + ), + mixer_shower_count=( + schema.sap_heating.instantaneous_wwhrs.rooms_with_mixer_shower_no_bath + + schema.sap_heating.instantaneous_wwhrs.rooms_with_bath_and_mixer_shower + if schema.sap_heating.instantaneous_wwhrs is not None + else None + ), main_heating_details=[ MainHeatingDetail( has_fghrs=d.has_fghrs == "Y", @@ -1594,26 +1753,28 @@ class EpcPropertyDataMapper: # glazed_area band + TFA via the shared reduced-field core. The 6 # rich certs keep their lodged per-window geometry (used directly: # window_width = area, height = 1.0). - sap_windows=[ - SapWindow( - frame_material=None, - glazing_gap=0, - orientation=w.orientation, - window_type=w.window_type, - glazing_type=w.glazing_type, - window_width=_measurement_value(w.window_area), - window_height=1.0, - draught_proofed=False, - window_location=w.window_location, - window_wall_type=0, - permanent_shutters_present=False, - ) - for w in schema.sap_windows - ] - if schema.sap_windows - else _synthesise_19_0_sap_windows(schema), + sap_windows=( + [ + SapWindow( + frame_material=None, + glazing_gap=0, + orientation=w.orientation, + window_type=w.window_type, + glazing_type=w.glazing_type, + window_width=_measurement_value(w.window_area), + window_height=1.0, + draught_proofed=False, + window_location=w.window_location, + window_wall_type=0, + permanent_shutters_present=False, + ) + for w in schema.sap_windows + ] + if schema.sap_windows + else _synthesise_19_0_sap_windows(schema) + ), sap_energy_source=SapEnergySource( - mains_gas=es.mains_gas == "Y", + gas_connection_available=es.mains_gas == "Y", meter_type=str(es.meter_type), pv_battery_count=0, wind_turbines_count=es.wind_turbines_count, @@ -1761,6 +1922,10 @@ class EpcPropertyDataMapper: insulated_door_count=schema.insulated_door_count, draughtproofed_door_count=None, percent_draughtproofed=schema.percent_draughtproofed, + # 20.0.0 lodges insulated_door_u_value but the mapper dropped it, + # unlike 21.0.1. heat_transmission reads it as the measured door + # U-value override; without it every door falls to the RdSAP default. + insulated_door_u_value=schema.insulated_door_u_value, # ADR-0027: 20.0.0 has no flue/fan/vent counts (calculator defaults # them via RdSAP Table 5), but sheltered_sides must come from # built_form — else the calculator assumes mid-terrace (2) for all. @@ -1881,7 +2046,7 @@ class EpcPropertyDataMapper: else _synthesise_20_0_0_sap_windows(schema) ), sap_energy_source=SapEnergySource( - mains_gas=es.mains_gas == "Y", + gas_connection_available=es.mains_gas == "Y", meter_type=str(es.meter_type), pv_battery_count=0, wind_turbines_count=es.wind_turbines_count, @@ -2022,6 +2187,23 @@ class EpcPropertyDataMapper: open_chimneys_count=schema.open_chimneys_count or 0, insulated_door_count=schema.insulated_door_count, draughtproofed_door_count=schema.draughtproofed_door_count, + # 21.0.0 lodges percent_draughtproofed but the mapper dropped it, + # unlike every other path (17.0/…/21.0.1). Understated the §2 + # draughtproofing/infiltration credit. + percent_draughtproofed=schema.percent_draughtproofed, + # 21.0.0 lodges the MVHR/MEV PCDF index + duct type but the mapper + # dropped both, unlike 21.0.1. The index feeds the PCDB Table 4f/329 + # heat-recovery/SFP lookup and duct_type selects the Table 329 in-use + # factor (1=Flexible / 2=Rigid) — without them an MVHR/MEV cert on the + # 21.0.0 path fell back to the SAP default efficiency. + mechanical_ventilation_index_number=( + schema.mechanical_ventilation_index_number + ), + mechanical_vent_duct_type=schema.mechanical_vent_duct_type, + # 21.0.0 lodges insulated_door_u_value but the mapper dropped it, + # unlike 21.0.1. heat_transmission reads it as the measured door + # U-value override; without it every door falls to the RdSAP default. + insulated_door_u_value=schema.insulated_door_u_value, led_fixed_lighting_bulbs_count=schema.led_fixed_lighting_bulbs_count or 0, cfl_fixed_lighting_bulbs_count=schema.cfl_fixed_lighting_bulbs_count or 0, incandescent_fixed_lighting_bulbs_count=schema.incandescent_fixed_lighting_bulbs_count, @@ -2131,7 +2313,7 @@ class EpcPropertyDataMapper: for w in schema.sap_windows ], sap_energy_source=SapEnergySource( - mains_gas=es.mains_gas == "Y", + gas_connection_available=es.mains_gas == "Y", meter_type=str(es.meter_type), pv_battery_count=es.pv_battery_count or 0, wind_turbines_count=es.wind_turbines_count, @@ -2236,11 +2418,14 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_1.wall_area, wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined, - wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_1.wall_construction), + wall_construction=_api_wall_construction_code( + bp.sap_alternative_wall_1.wall_construction + ), wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness, - is_sheltered=bp.sap_alternative_wall_1.sheltered_wall == "Y", + is_sheltered=bp.sap_alternative_wall_1.sheltered_wall + == "Y", ) if bp.sap_alternative_wall_1 else None @@ -2249,11 +2434,14 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_2.wall_area, wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined, - wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_2.wall_construction), + wall_construction=_api_wall_construction_code( + bp.sap_alternative_wall_2.wall_construction + ), wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness, - is_sheltered=bp.sap_alternative_wall_2.sheltered_wall == "Y", + is_sheltered=bp.sap_alternative_wall_2.sheltered_wall + == "Y", ) if bp.sap_alternative_wall_2 else None @@ -2460,7 +2648,7 @@ class EpcPropertyDataMapper: or None, # SAP energy source sap_energy_source=SapEnergySource( - mains_gas=es.mains_gas == "Y", + gas_connection_available=es.mains_gas == "Y", meter_type=str(es.meter_type), pv_battery_count=es.pv_battery_count or 0, wind_turbines_count=es.wind_turbines_count, @@ -2566,11 +2754,14 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_1.wall_area, wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined, - wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_1.wall_construction), + wall_construction=_api_wall_construction_code( + bp.sap_alternative_wall_1.wall_construction + ), wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness, - is_sheltered=bp.sap_alternative_wall_1.sheltered_wall == "Y", + is_sheltered=bp.sap_alternative_wall_1.sheltered_wall + == "Y", ) if bp.sap_alternative_wall_1 else None @@ -2579,11 +2770,14 @@ class EpcPropertyDataMapper: SapAlternativeWall( wall_area=bp.sap_alternative_wall_2.wall_area, wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined, - wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_2.wall_construction), + wall_construction=_api_wall_construction_code( + bp.sap_alternative_wall_2.wall_construction + ), wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type, wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured, wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness, - is_sheltered=bp.sap_alternative_wall_2.sheltered_wall == "Y", + is_sheltered=bp.sap_alternative_wall_2.sheltered_wall + == "Y", ) if bp.sap_alternative_wall_2 else None @@ -2892,7 +3086,14 @@ class EpcPropertyDataMapper: # Mirrored here read-only to back-solve a room count from full SAP's measured # living_area (single home is the calculator; this is the inverse lookup). _SAP_LIVING_AREA_FRACTION_BY_ROOMS: Final[Dict[int, float]] = { - 1: 0.75, 2: 0.50, 3: 0.30, 4: 0.25, 5: 0.21, 6: 0.18, 7: 0.16, 8: 0.14, + 1: 0.75, + 2: 0.50, + 3: 0.30, + 4: 0.25, + 5: 0.21, + 6: 0.18, + 7: 0.16, + 8: 0.14, } @@ -2932,6 +3133,61 @@ def _sap_dwelling_on_mains_gas(schema: SapSchema17_1) -> bool: ) +def _sap_17_1_pv_arrays( + schema: SapSchema17_1, +) -> Optional[List[PhotovoltaicArray]]: + """Map a full-SAP cert's lodged PV (`sap_energy_source.pv_arrays`) to the + domain `PhotovoltaicArray` list the calculator's Appendix-M generation + credit reads. Without this the PV is dropped and an all-electric dwelling + that the array lifts to A/B is mis-modelled down a band or more. An array + with no peak power generates nothing, so it's skipped; orientation honours + the ND/NA sentinel (None ⇒ zero-generation array).""" + arrays = schema.sap_energy_source.pv_arrays + if not arrays: + return None + mapped = [ + PhotovoltaicArray( + peak_power=array.peak_power, + pitch=array.pitch if array.pitch is not None else 0, + overshading=array.overshading if array.overshading is not None else 0, + orientation=_pv_orientation(array.orientation), + ) + for array in arrays + if array.peak_power is not None + ] + return mapped or None + + +def _sap_17_1_meter_type(electricity_tariff: Optional[int]) -> str: + """Translate a full-SAP ``energy_tariff`` code into the RdSAP ``meter_type`` + value the calculator's Table 12a tariff resolver consumes. + + The two code spaces *differ* (epc_codes.csv `energy_tariff` vs + `_METER_INT_TO_TARIFF`): full-SAP 1=standard / 2=off-peak-7hr / 3=off-peak- + 10hr / 4=24-hour / 5=off-peak-18hr, whereas RdSAP meter 1=dual-7hr / + 2=single / 3=unknown / 4=24-hour. Passing the full-SAP code straight + through (the prior bug) read a + standard-tariff cert as Economy 7 (over-rated) and an Economy-7 cert as + single (under-rated). Map onto the RdSAP word aliases so the resolved tariff + is correct; absent/ND → "" (the unknown→standard sentinel).""" + if electricity_tariff is None: + return "" + return _SAP_TARIFF_TO_RDSAP_METER_TYPE.get(electricity_tariff, "") + + +# full-SAP `energy_tariff` code → RdSAP `meter_type` word alias (consumed by +# `tariff_from_meter_type` / `_METER_STR_TO_INT`). 10-hour (3) has no dedicated +# RdSAP meter code — it maps to "dual", and the §12 dispatch resolves 7-/10-hour +# from the heating system. +_SAP_TARIFF_TO_RDSAP_METER_TYPE: dict[int, str] = { + 1: "single", # standard tariff + 2: "dual", # off-peak 7 hour (Economy 7) + 3: "dual", # off-peak 10 hour (§12 dispatch resolves 7/10hr) + 4: "dual (24 hour)", # 24-hour tariff + 5: "off-peak 18 hour", # off-peak 18 hour (Table 12a EIGHTEEN_HOUR) +} + + def _sap_17_1_heating(schema: SapSchema17_1) -> SapHeating: """D6: map full-SAP `sap_heating` onto the domain `SapHeating`. Field names differ from RdSAP — `is_flue_fan_present`→`fan_flue_present`, @@ -3682,7 +3938,9 @@ def _normalize_sap_schema_16_x(data: Dict[str, Any]) -> Dict[str, Any]: window: Any = d.get("window") description = "" if isinstance(window, dict): - description = str(cast(Dict[str, Any], window).get("description") or "").lower() + description = str( + cast(Dict[str, Any], window).get("description") or "" + ).lower() if "single" in description: d["multiple_glazing_type"] = 5 @@ -4165,28 +4423,47 @@ _API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = { # enum (1=Flat, 3=Pitched no-access, 5=Vaulted, etc.) is mapped as # best-effort against SAP10 nomenclature. # -# Codes 6 and 7 → None. This field is read ONLY for the sloping-ceiling -# inclined factor; the base roof U-value comes from the global -# roofs[].description, so a non-sloping code carries no information the -# cascade consumes here, and None correctly avoids the cos(30°) false- -# trigger: +# Code 6 → None. This field is read ONLY for the sloping-ceiling inclined +# factor; the base roof U-value comes from the global roofs[].description, +# so a non-sloping code carries no information the cascade consumes here: # 6 = "Thatched, with additional insulation" — its U is set by the -# global description; not a sloping ceiling. -# 7 = "(same dwelling above)" / "(another dwelling above)" — an -# internal ceiling with no roof heat loss (the roof-side analogue -# of floor_construction code 0). Heat loss is governed by the -# roof_heat_loss / description path, not this field. -# Empirically inert: roof W/K is identical whether 6/7 map to None or to -# an explicit pitched string across all code-6/7 certs in the 2026 -# sample (were raising UnmappedApiCode, blocking the cert). +# global description; not a sloping ceiling. None correctly avoids +# the cos(30°) false-trigger. +# +# Codes 7 and 9 → "(another dwelling above)", NOT None. RdSAP 10 +# Specification (10-06-2025) §5.2.5 (p.31): "If a dwelling or part of a +# dwelling has commercial premises above record as another dwelling +# above" — i.e. premises-above (code 9) gets the SAME treatment as +# dwelling-above (code 7), not the semi-exposed "partially heated space" +# treatment used for premises *below* (see `_API_FLOOR_HEAT_LOSS_ABOVE_ +# PARTIALLY_HEATED`) — the spec's above/below split is asymmetric. Per +# spec p.~55: "There is no heat loss through the roof of a building part +# that has the same dwelling above or another dwelling above" — zero +# heat loss, mirrored by the landlord-override system's independent +# `roof_party_ceiling_guard` (`domain/epc/property_overrides/`), which +# already resolves this exact "(another premises above)" marker to a +# ~0-heat-loss RoofType citing the same spec line. +# +# `heat_transmission.py`'s per-part suppression (`part_roof_is_party = +# "another dwelling above" in roof_type`) only fires when this field +# CONTAINS that literal substring — it existed already for the Elmhurst +# path (which sets it via `_strip_code(roof.roof_type)`) but the API path +# previously mapped 7 (and, before this fix, 9) to None, so the +# suppression never fired for ANY API-derived cert: `has_exposed_roof` +# (the dwelling_type-label-only fallback) was the sole gate, and it has +# no way to know about a per-part party-ceiling lodgement. Surfaced by +# live cert uprn 10013320122 ("44 Regent House", code 9); code 7 has the +# same latent gap (11 building parts in the RdSAP-21.0.1 corpus), fixed +# here too rather than left half-fixed. _API_ROOF_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = { 1: "Flat", 3: "Pitched (slates/tiles), no access to loft", 4: "Pitched (slates/tiles), access to loft", 5: "Pitched (vaulted ceiling)", 6: None, - 7: None, + 7: "(another dwelling above)", 8: "Pitched, sloping ceiling", + 9: "(another dwelling above)", } @@ -4481,6 +4758,55 @@ def _synthesise_reduced_field_windows( ] +# ADR-0028 (single-glazed honouring): a reduced-field cert whose numeric +# `multiple_glazing_type` is the "ND" (Not Defined) sentinel still carries two +# register signals for the *actual* glazing — the human `window.description` and +# `multiple_glazed_proportion`. When those say single-glazed, honour them rather +# than the DG-modal default: defaulting a genuinely single-glazed dwelling to +# double overstates its baseline SAP AND hides it from the glazing generator +# (which upgrades only single-glazed windows — the "no glazing recommendation" +# bug on cert 2780-3922-3202-6042-9200 / uprn 10033526327, a single-glazed flat +# lodging multiple_glazing_type="ND", window "Single glazed", proportion 0). +# `_normalize_sap_schema_16_x` already applies this inline for 16.x certs +# (rewriting multiple_glazing_type→5); this shared resolver extends the same rule +# to every reduced-field synthesis seam (17.0/17.1/18.0/19.0/20.0.0). Gov-API +# code 5 is single glazing → `_api_cascade_glazing_type(5) == 1` (SAP10 cascade +# single, Table 24 U≈4.8) — exactly the single code the glazing generator's +# single-glazed set {1, 15} matches. +_API_SINGLE_GLAZING_TYPE: int = 5 + + +def _reduced_field_is_single_glazed( + window_description: Union[str, DescriptionV1], + multiple_glazed_proportion: int, +) -> bool: + """Whether an "ND"-glazed reduced-field cert is really single-glazed, from the + two signals the gov register still lodges: a 0% multiple-glazed proportion, or + an explicit "single" in the window description.""" + if multiple_glazed_proportion == 0: + return True + text = ( + window_description + if isinstance(window_description, str) + else window_description.value + ) + return "single" in text.lower() + + +def _reduced_field_nd_glazing_type( + window_description: Union[str, DescriptionV1], + multiple_glazed_proportion: int, + dg_modal_default: int, +) -> int: + """Resolve the synthesised `SapWindow.glazing_type` for an "ND" (numeric- + undefined) reduced-field cert: the SAP10 cascade single code (1) when the + cert's own description / proportion say single-glazed, else the seam's + DG-modal default. See the note above.""" + if _reduced_field_is_single_glazed(window_description, multiple_glazed_proportion): + return _api_cascade_glazing_type(_API_SINGLE_GLAZING_TYPE) + return dg_modal_default + + # ADR-0028: multiple_glazing_type "ND" (Not Defined) → the DG-modal # default (cascade code 2 → daylight g_L 0.80), as for 18.0/19.0/17.x. _RDSAP20_ND_GLAZING_TYPE: int = 2 @@ -4497,7 +4823,11 @@ def _synthesise_20_0_0_sap_windows(schema: RdSapSchema20_0_0) -> List[SapWindow] glazing_type = ( _api_cascade_glazing_type(mgt) if isinstance(mgt, int) - else _RDSAP20_ND_GLAZING_TYPE + else _reduced_field_nd_glazing_type( + schema.window.description, + schema.multiple_glazed_proportion, + _RDSAP20_ND_GLAZING_TYPE, + ) ) return _synthesise_reduced_field_windows( schema.glazed_area, @@ -4523,7 +4853,11 @@ def _synthesise_18_0_sap_windows(schema: RdSapSchema18_0) -> List[SapWindow]: glazing_type = ( _api_cascade_glazing_type(mgt) if isinstance(mgt, int) - else _RDSAP18_ND_GLAZING_TYPE + else _reduced_field_nd_glazing_type( + schema.window.description, + schema.multiple_glazed_proportion, + _RDSAP18_ND_GLAZING_TYPE, + ) ) return _synthesise_reduced_field_windows( schema.glazed_area, schema.total_floor_area, glazing_type @@ -4551,7 +4885,11 @@ def _synthesise_19_0_sap_windows(schema: RdSapSchema19_0) -> List[SapWindow]: glazing_type = ( _api_cascade_glazing_type(mgt) if isinstance(mgt, int) - else _RDSAP19_0_ND_GLAZING_TYPE + else _reduced_field_nd_glazing_type( + schema.window.description, + schema.multiple_glazed_proportion, + _RDSAP19_0_ND_GLAZING_TYPE, + ) ) return _synthesise_reduced_field_windows( schema.glazed_area, schema.total_floor_area, glazing_type @@ -4580,7 +4918,11 @@ def _synthesise_17_0_sap_windows(schema: RdSapSchema17_0) -> List[SapWindow]: glazing_type = ( _api_cascade_glazing_type(mgt) if isinstance(mgt, int) - else _RDSAP17_0_ND_GLAZING_TYPE + else _reduced_field_nd_glazing_type( + schema.window.description, + schema.multiple_glazed_proportion, + _RDSAP17_0_ND_GLAZING_TYPE, + ) ) return _synthesise_reduced_field_windows( schema.glazed_area, schema.total_floor_area, glazing_type @@ -4602,7 +4944,11 @@ def _synthesise_17_1_sap_windows(schema: RdSapSchema17_1) -> List[SapWindow]: glazing_type = ( _api_cascade_glazing_type(mgt) if isinstance(mgt, int) - else _RDSAP17_1_ND_GLAZING_TYPE + else _reduced_field_nd_glazing_type( + schema.window.description, + schema.multiple_glazed_proportion, + _RDSAP17_1_ND_GLAZING_TYPE, + ) ) return _synthesise_reduced_field_windows( schema.glazed_area, schema.total_floor_area, glazing_type @@ -4636,20 +4982,20 @@ def _synthesise_17_1_sap_windows(schema: RdSapSchema17_1) -> List[SapWindow]: # is lodged, falling back to the type-only default for missing gaps. _API_GLAZING_TYPE_TO_TRANSMISSION: Dict[int, tuple[float, float, float]] = { # (u_value, solar_transmittance/g_⊥, frame_factor) - 1: (2.8, 0.76, 0.70), # Double glazed, pre-2002 / unknown install - # date — Table 24 row 2 (PVC/wooden), 12mm - # gap default. Schema sibling of code 3. - 2: (2.0, 0.72, 0.70), # Double glazed, England/Wales 2002+ (pre-2022) - 3: (2.8, 0.76, 0.70), # Double glazed, pre-2002 (12mm gap default) - 13: (1.4, 0.72, 0.70), # Double glazed, Argon-filled post-2022 - 14: (1.4, 0.72, 0.70), # Double or triple glazed, post-2022 - # (Table 24 last row: 2022+ E/W / 2023+ Sc - # / 2022+ NI — same U/g as code 13 per - # spec; the integer codes 13/14 are - # schema siblings within the post-2022 - # product family. Cert 0380 lodges code - # 14 on all windows; worksheet uses U=1.4 - # = post-curtain 1.3258.) + 1: (2.8, 0.76, 0.70), # Double glazed, pre-2002 / unknown install + # date — Table 24 row 2 (PVC/wooden), 12mm + # gap default. Schema sibling of code 3. + 2: (2.0, 0.72, 0.70), # Double glazed, England/Wales 2002+ (pre-2022) + 3: (2.8, 0.76, 0.70), # Double glazed, pre-2002 (12mm gap default) + 13: (1.4, 0.72, 0.70), # Double glazed, Argon-filled post-2022 + 14: (1.4, 0.72, 0.70), # Double or triple glazed, post-2022 + # (Table 24 last row: 2022+ E/W / 2023+ Sc + # / 2022+ NI — same U/g as code 13 per + # spec; the integer codes 13/14 are + # schema siblings within the post-2022 + # product family. Cert 0380 lodges code + # 14 on all windows; worksheet uses U=1.4 + # = post-curtain 1.3258.) # # SINGLE / SECONDARY / TRIPLE glazing — RdSAP 10 Table 24 (spec p.50). # Previously unmapped (`_api_glazing_transmission` returned None) so the @@ -4657,27 +5003,27 @@ _API_GLAZING_TYPE_TO_TRANSMISSION: Dict[int, tuple[float, float, float]] = { # all-None default 2.5 instead of its true 4.8 — over-rating single- # glazed dwellings (cert 0370-2933, 7 single windows, +17 SAP). Codes # per datatypes/epc/domain/epc_codes.csv `glazed_type` (RdSAP-Schema-21). - 4: (2.9, 0.85, 0.70), # Secondary glazing, unknown data → Table 24 - # Secondary "Normal emissivity" default (2.9). - 5: (4.8, 0.85, 0.70), # Single glazing — Table 24 "Single / Any - # period" (PVC/wooden 4.8, g 0.85). - 6: (2.1, 0.68, 0.70), # Triple glazed, unknown install date — Table - # 24 Triple pre-2002 12mm-gap default (2.1). - 7: (2.8, 0.76, 0.70), # Double glazed, known data — no measured U on - # the reduced-data path → double pre-2002 / - # unknown-date family default (2.8), as code 3. - 8: (2.1, 0.68, 0.70), # Triple glazed, known data → triple unknown- - # date family default (2.1), as code 6. - 9: (2.0, 0.72, 0.70), # Triple glazed, 2002-2022 — Table 24 "Double - # or triple, 2002+ (pre-2022), any gap" (2.0). - 10: (2.1, 0.68, 0.70), # Triple glazed, pre-2002 — Table 24 Triple - # pre-2002 12mm-gap default (2.1). - 11: (2.9, 0.85, 0.70), # Secondary glazing, normal emissivity — - # Table 24 Secondary "Normal emissivity" (2.9). - 12: (2.2, 0.85, 0.70), # Secondary glazing, low emissivity — Table 24 - # Secondary "Low emissivity" (2.2). - 15: (4.8, 0.85, 0.70), # Single glazing, known data → Single row - # (4.8) when no measured U is lodged, as code 5. + 4: (2.9, 0.85, 0.70), # Secondary glazing, unknown data → Table 24 + # Secondary "Normal emissivity" default (2.9). + 5: (4.8, 0.85, 0.70), # Single glazing — Table 24 "Single / Any + # period" (PVC/wooden 4.8, g 0.85). + 6: (2.1, 0.68, 0.70), # Triple glazed, unknown install date — Table + # 24 Triple pre-2002 12mm-gap default (2.1). + 7: (2.8, 0.76, 0.70), # Double glazed, known data — no measured U on + # the reduced-data path → double pre-2002 / + # unknown-date family default (2.8), as code 3. + 8: (2.1, 0.68, 0.70), # Triple glazed, known data → triple unknown- + # date family default (2.1), as code 6. + 9: (2.0, 0.72, 0.70), # Triple glazed, 2002-2022 — Table 24 "Double + # or triple, 2002+ (pre-2022), any gap" (2.0). + 10: (2.1, 0.68, 0.70), # Triple glazed, pre-2002 — Table 24 Triple + # pre-2002 12mm-gap default (2.1). + 11: (2.9, 0.85, 0.70), # Secondary glazing, normal emissivity — + # Table 24 Secondary "Normal emissivity" (2.9). + 12: (2.2, 0.85, 0.70), # Secondary glazing, low emissivity — Table 24 + # Secondary "Low emissivity" (2.2). + 15: (4.8, 0.85, 0.70), # Single glazing, known data → Single row + # (4.8) when no measured U is lodged, as code 5. } @@ -4699,19 +5045,19 @@ _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: Dict[ (3, "16+"): (2.7, 0.76, 0.70), # Double glazed, known data (code 7) — aliases the double pre-2002 / # unknown-date Table 24 row (same as codes 1/3) when no measured U. - (7, 6): (3.1, 0.76, 0.70), - (7, 12): (2.8, 0.76, 0.70), + (7, 6): (3.1, 0.76, 0.70), + (7, 12): (2.8, 0.76, 0.70), (7, "16+"): (2.7, 0.76, 0.70), # Triple glazed pre-2002 / unknown / known-data (codes 6/8/10) — # Table 24 Triple pre-2002 row varies by gap (6mm=2.4, 12mm=2.1, 16+=2.0). - (6, 6): (2.4, 0.68, 0.70), - (6, 12): (2.1, 0.68, 0.70), - (6, "16+"): (2.0, 0.68, 0.70), - (8, 6): (2.4, 0.68, 0.70), - (8, 12): (2.1, 0.68, 0.70), - (8, "16+"): (2.0, 0.68, 0.70), - (10, 6): (2.4, 0.68, 0.70), - (10, 12): (2.1, 0.68, 0.70), + (6, 6): (2.4, 0.68, 0.70), + (6, 12): (2.1, 0.68, 0.70), + (6, "16+"): (2.0, 0.68, 0.70), + (8, 6): (2.4, 0.68, 0.70), + (8, 12): (2.1, 0.68, 0.70), + (8, "16+"): (2.0, 0.68, 0.70), + (10, 6): (2.4, 0.68, 0.70), + (10, 12): (2.1, 0.68, 0.70), (10, "16+"): (2.0, 0.68, 0.70), } @@ -5109,10 +5455,16 @@ def _api_type_2_surfaces( ] surfaces: List[SapRoomInRoofSurface] = [] gable_specs = ( - (type_2.gable_wall_type_1, type_2.gable_wall_length_1, - type_2.gable_wall_height_1), - (type_2.gable_wall_type_2, type_2.gable_wall_length_2, - type_2.gable_wall_height_2), + ( + type_2.gable_wall_type_1, + type_2.gable_wall_length_1, + type_2.gable_wall_height_1, + ), + ( + type_2.gable_wall_type_2, + type_2.gable_wall_length_2, + type_2.gable_wall_height_2, + ), ) for gable_type, length, height in gable_specs: # Length is mandatory; H may be 0 for the §3.9.2 absent-gable @@ -5179,23 +5531,32 @@ def _api_rir_detailed_surfaces( # insulation_type is left None so the cascade defers to the Table 17 # column (a) mineral-wool default, mirroring the flat_ceiling branch. slope_specs = ( - (details.slope_length_1, details.slope_height_1, - details.slope_insulation_thickness_1), - (details.slope_length_2, details.slope_height_2, - details.slope_insulation_thickness_2), + ( + details.slope_length_1, + details.slope_height_1, + details.slope_insulation_thickness_1, + ), + ( + details.slope_length_2, + details.slope_height_2, + details.slope_insulation_thickness_2, + ), ) stud_specs = ( - (details.stud_wall_length_1, details.stud_wall_height_1, - details.stud_wall_insulation_thickness_1), - (details.stud_wall_length_2, details.stud_wall_height_2, - details.stud_wall_insulation_thickness_2), + ( + details.stud_wall_length_1, + details.stud_wall_height_1, + details.stud_wall_insulation_thickness_1, + ), + ( + details.stud_wall_length_2, + details.stud_wall_height_2, + details.stud_wall_insulation_thickness_2, + ), ) for kind, specs in (("slope", slope_specs), ("stud_wall", stud_specs)): for length, height, thickness_str in specs: - if ( - length is not None and height is not None - and length > 0 and height > 0 - ): + if length is not None and height is not None and length > 0 and height > 0: area = _round_half_up_2dp(float(length), float(height)) surfaces.append( SapRoomInRoofSurface( @@ -5216,10 +5577,7 @@ def _api_rir_detailed_surfaces( (details.common_wall_length_2, details.common_wall_height_2), ) for length, height in common_wall_specs: - if ( - length is not None and height is not None - and length > 0 and height > 0 - ): + if length is not None and height is not None and length > 0 and height > 0: surfaces.append( SapRoomInRoofSurface( kind="common_wall", @@ -7330,7 +7688,8 @@ def _elmhurst_cylinder_insulation_code( def _elmhurst_immersion_type_code( - immersion_type_label: Optional[str], cylinder_present: bool, + immersion_type_label: Optional[str], + cylinder_present: bool, ) -> Optional[int]: """Map an Elmhurst §15.1 "Immersion Heater" label ("Dual" / "Single") to the SAP10 `immersion_heating_type` cascade code (1 = dual, 2 = diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 07fc7ddb6..f6ce46bfb 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -201,8 +201,14 @@ class TestFromRdSapSchema20_0_0: # dict-tolerant array reader captures the arrays. data = load("20_0_0.json") data["sap_energy_source"]["photovoltaic_supply"] = [ - [{"pitch": 2, "peak_power": {"value": 1.14, "quantity": "kW"}, - "orientation": 5, "overshading": 1}], + [ + { + "pitch": 2, + "peak_power": {"value": 1.14, "quantity": "kW"}, + "orientation": 5, + "overshading": 1, + } + ], [{"pitch": 2, "peak_power": 1.14, "orientation": 5, "overshading": 1}], ] schema = from_dict(RdSapSchema20_0_0, data) @@ -263,18 +269,30 @@ class TestFromRdSapSchema21_0_0: # the only one those certs omit is roof_insulation_thickness.) data = load("21_0_0.json") for window in data.get("sap_windows", []): - for k in ("frame_factor", "glazing_gap", "pvc_frame", - "window_transmission_details"): + for k in ( + "frame_factor", + "glazing_gap", + "pvc_frame", + "window_transmission_details", + ): window.pop(k, None) - for k in ("wet_rooms_count", "open_chimneys_count", - "pressure_test_certificate_number", "windows_transmission_details", - "mechanical_ventilation_index_number", "mechanical_vent_duct_type", - "mechanical_vent_duct_placement", "mechanical_vent_duct_insulation", - "mechanical_vent_duct_insulation_level", - "mechanical_vent_measured_installation", "insulated_door_u_value", - "low_energy_fixed_lighting_bulbs_count", - "cfl_fixed_lighting_bulbs_count", "led_fixed_lighting_bulbs_count", - "suggested_improvements"): + for k in ( + "wet_rooms_count", + "open_chimneys_count", + "pressure_test_certificate_number", + "windows_transmission_details", + "mechanical_ventilation_index_number", + "mechanical_vent_duct_type", + "mechanical_vent_duct_placement", + "mechanical_vent_duct_insulation", + "mechanical_vent_duct_insulation_level", + "mechanical_vent_measured_installation", + "insulated_door_u_value", + "low_energy_fixed_lighting_bulbs_count", + "cfl_fixed_lighting_bulbs_count", + "led_fixed_lighting_bulbs_count", + "suggested_improvements", + ): data.pop(k, None) data["sap_energy_source"].pop("wind_turbine_details", None) data["sap_energy_source"].pop("pv_battery_count", None) @@ -440,7 +458,9 @@ class TestFromRdSapSchema21_0_1: assert result.open_chimneys_count == 0 - def test_omitted_cfl_fixed_lighting_bulbs_count_defaults_to_zero_not_none(self) -> None: + def test_omitted_cfl_fixed_lighting_bulbs_count_defaults_to_zero_not_none( + self, + ) -> None: data = load("21_0_1.json") data.pop("cfl_fixed_lighting_bulbs_count", None) schema = from_dict(RdSapSchema21_0_1, data) @@ -449,7 +469,9 @@ class TestFromRdSapSchema21_0_1: assert result.cfl_fixed_lighting_bulbs_count == 0 - def test_omitted_led_fixed_lighting_bulbs_count_defaults_to_zero_not_none(self) -> None: + def test_omitted_led_fixed_lighting_bulbs_count_defaults_to_zero_not_none( + self, + ) -> None: data = load("21_0_1.json") data.pop("led_fixed_lighting_bulbs_count", None) schema = from_dict(RdSapSchema21_0_1, data) @@ -542,7 +564,8 @@ class TestFromRdSapSchema21_0_1: schema, sap_building_parts=[ dataclasses.replace( - bps[0], roof_insulation_location=1, + bps[0], + roof_insulation_location=1, rafter_insulation_thickness="225mm", ), *bps[1:], @@ -555,9 +578,7 @@ class TestFromRdSapSchema21_0_1: # Assert assert result.sap_building_parts[0].rafter_insulation_thickness == "225mm" - def test_cylinder_heat_loss_threaded( - self, schema: RdSapSchema21_0_1 - ) -> None: + def test_cylinder_heat_loss_threaded(self, schema: RdSapSchema21_0_1) -> None: # Arrange — the gov API lodges the manufacturer's declared cylinder # loss factor (kWh/day) in `sap_heating.cylinder_heat_loss` (SAP # 10.2 §4 branch a). Previously undeclared → `from_dict` dropped it @@ -656,7 +677,9 @@ class TestFromRdSapSchema21_0_1: def test_lighting_element_description(self, result: EpcPropertyData) -> None: assert result.lighting is not None - assert result.lighting.description == "Low energy lighting in 50% of fixed outlets" + assert ( + result.lighting.description == "Low energy lighting in 50% of fixed outlets" + ) def test_hot_water_element_description(self, result: EpcPropertyData) -> None: assert result.hot_water is not None @@ -676,7 +699,7 @@ class TestFromRdSapSchema21_0_1: def test_mains_gas(self, result: EpcPropertyData) -> None: # mains_gas: "Y" - assert result.sap_energy_source.mains_gas is True + assert result.sap_energy_source.gas_connection_available is True def test_electricity_smart_meter(self, result: EpcPropertyData) -> None: # electricity_smart_meter_present: "true" @@ -784,16 +807,27 @@ class TestFromRdSapSchema21_0_1: assert len(result.sap_building_parts[0].sap_floor_dimensions) == 1 def test_floor_area(self, result: EpcPropertyData) -> None: - assert result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2 == 45.82 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].total_floor_area_m2 + == 45.82 + ) def test_floor_height(self, result: EpcPropertyData) -> None: - assert result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.45 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.45 + ) def test_heat_loss_perimeter(self, result: EpcPropertyData) -> None: - assert result.sap_building_parts[0].sap_floor_dimensions[0].heat_loss_perimeter_m == 19.5 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].heat_loss_perimeter_m + == 19.5 + ) def test_party_wall_length(self, result: EpcPropertyData) -> None: - assert result.sap_building_parts[0].sap_floor_dimensions[0].party_wall_length_m == 7.9 + assert ( + result.sap_building_parts[0].sap_floor_dimensions[0].party_wall_length_m + == 7.9 + ) # --- room-in-roof (sap_room_in_roof.room_in_roof_type_1) --- @@ -899,7 +933,6 @@ class TestFromRdSapSchema21_0_1: assert rhi.impact_of_solid_wall_insulation_kwh == -3560.0 - # --------------------------------------------------------------------------- # Measured wall insulation thickness (`wall_insulation_thickness == "measured"`) # --------------------------------------------------------------------------- @@ -915,7 +948,9 @@ class TestApiWallConstructionCode: def test_gov_api_cob_code_9_remaps_to_wall_cob_7(self) -> None: # Arrange - from datatypes.epc.domain.mapper import _api_wall_construction_code # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_wall_construction_code, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_wall_construction_code(9) @@ -927,7 +962,9 @@ class TestApiWallConstructionCode: # Arrange — codes 1-5 already align; gov 8 (system built) is left # for u_wall to resolve (calc WALL_PARK_HOME=8 is never dispatched); # gov 6 (basement) is left to the basement machinery. - from datatypes.epc.domain.mapper import _api_wall_construction_code # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_wall_construction_code, + ) # pyright: ignore[reportPrivateUsage] # Act / Assert for code in (1, 2, 3, 4, 5, 6, 8): @@ -1015,9 +1052,7 @@ class TestApiResolveSlopingCeilingThickness: # Act — code 8, no loft-joist thickness, age B (pre-1950), but the # sloping ceiling carries a lodged 100 mm. - resolved: object = _api_resolve_sloping_ceiling_thickness( - 8, None, "B", "100mm" - ) + resolved: object = _api_resolve_sloping_ceiling_thickness(8, None, "B", "100mm") # Assert — the lodged sloping-ceiling thickness wins over the # pre-1950 None → 0 mm fallback. @@ -1032,9 +1067,7 @@ class TestApiResolveSlopingCeilingThickness: ) # Act — code 8, no thickness anywhere, pre-1950 age. - resolved: object = _api_resolve_sloping_ceiling_thickness( - 8, None, "C", None - ) + resolved: object = _api_resolve_sloping_ceiling_thickness(8, None, "C", None) # Assert — existing Slice 57 behaviour preserved: 0 mm (U=2.30). assert resolved == 0 @@ -1047,9 +1080,7 @@ class TestApiResolveSlopingCeilingThickness: # Act — code 8, age B (pre-1950), sloping lodged "AB" (As Built — # categorical, NOT a measured thickness). - resolved: object = _api_resolve_sloping_ceiling_thickness( - 8, None, "B", "AB" - ) + resolved: object = _api_resolve_sloping_ceiling_thickness(8, None, "B", "AB") # Assert — "AB" is not a numeric thickness, so it must NOT win; the # Slice 57 pre-1950 None → 0 mm (U=2.30) rule still applies. @@ -1088,9 +1119,7 @@ class TestElmhurstGlazingTypeWrappedGap: from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code # Act - code = _elmhurst_glazing_type_code( - "Double between 2002 and 2021 16 mm or" - ) + code = _elmhurst_glazing_type_code("Double between 2002 and 2021 16 mm or") # Assert — clean "Double between 2002 and 2021" → SAP10 code 3 assert code == 3 @@ -1100,9 +1129,7 @@ class TestElmhurstGlazingTypeWrappedGap: from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code # Act - code = _elmhurst_glazing_type_code( - "Double between 2002 and 2021 16 mm or 1st" - ) + code = _elmhurst_glazing_type_code("Double between 2002 and 2021 16 mm or 1st") # Assert assert code == 3 @@ -1129,7 +1156,9 @@ class TestApiFloorTypeCode: def test_code_6_maps_to_another_dwelling_below(self) -> None: # Arrange - from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_floor_type_str, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_floor_type_str(6) @@ -1140,7 +1169,9 @@ class TestApiFloorTypeCode: def test_code_7_still_maps_to_ground_floor(self) -> None: # Arrange — regression guard: the ground-floor signal the §5 (12) # suspended-timber rule keys on is unchanged. - from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_floor_type_str, + ) # pyright: ignore[reportPrivateUsage] # Act / Assert assert _api_floor_type_str(7) == "Ground floor" @@ -1158,7 +1189,9 @@ class TestApiFloorTypeCode: # (consumed by heat_transmission's party-floor override); it is # != "Ground floor", so the §5 (12) suspended-timber rule stays # inert. Pre-this, code 8 raised UnmappedApiCode, blocking the cert. - from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_floor_type_str, + ) # pyright: ignore[reportPrivateUsage] # Act / Assert — no-heat-loss signal (not None, not "Ground floor"). assert _api_floor_type_str(8) == "(another dwelling below)" @@ -1171,7 +1204,9 @@ class TestApiFloorTypeCode: # constant U=0.7. The string is != "Ground floor" / "(another # dwelling below)", so it is inert metadata; the U-routing is driven # by the `is_above_partially_heated_space` floor-dimension flag. - from datatypes.epc.domain.mapper import _api_floor_type_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_floor_type_str, + ) # pyright: ignore[reportPrivateUsage] # Act / Assert assert _api_floor_type_str(3) == "(other premises below)" @@ -1182,7 +1217,9 @@ class TestApiFloorTypeCode: # only, so the cascade routes that floor to U=0.7 (§5.14) and the # heat-transmission step keeps its area even on a flat whose # dwelling-level exposure defaults has_exposed_floor=False. - from datatypes.epc.domain.mapper import _api_build_sap_floor_dimensions # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_build_sap_floor_dimensions, + ) # pyright: ignore[reportPrivateUsage] from datatypes.epc.schema.rdsap_schema_21_0_1 import ( SapFloorDimension as ApiSapFloorDimension, ) @@ -1217,7 +1254,9 @@ class TestApiFloorConstructionCode: def test_code_3_maps_to_suspended_not_timber(self) -> None: # Arrange - from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_floor_construction_str, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_floor_construction_str(3) @@ -1230,7 +1269,9 @@ class TestApiFloorConstructionCode: def test_code_2_still_maps_to_suspended_timber(self) -> None: # Arrange — regression guard: the timber code is unchanged. - from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_floor_construction_str, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_floor_construction_str(2) @@ -1249,7 +1290,9 @@ class TestApiFloorConstructionCode: # floor_construction. Empirically inert: floor W/K is identical to # any explicit construction string across all observed code-0 # certs (the heat loss is governed by floor_heat_loss, not this). - from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_floor_construction_str, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_floor_construction_str(0) @@ -1267,7 +1310,10 @@ class TestDefaultMissingPostTown: def test_absent_post_town_is_defaulted_to_empty_string(self) -> None: # Arrange - from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _default_missing_post_town, + ) # pyright: ignore[reportPrivateUsage] + doc: dict[str, object] = {"postcode": "EX31 2LE"} # Act @@ -1278,7 +1324,10 @@ class TestDefaultMissingPostTown: def test_present_post_town_is_untouched(self) -> None: # Arrange — regression guard: a lodged town passes through. - from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _default_missing_post_town, + ) # pyright: ignore[reportPrivateUsage] + doc: dict[str, object] = {"post_town": "BARNSTAPLE"} # Act @@ -1290,31 +1339,48 @@ class TestDefaultMissingPostTown: class TestApiRoofConstructionCode: """`_api_roof_construction_str` maps the GOV.UK API integer - roof_construction code to the string the cascade reads ONLY for the - "sloping ceiling" cos(30°) inclined-surface factor (Slice 89). Codes - 6 and 7 are neither sloping ceilings nor base-U drivers (the roof - U-value comes from the global roofs[].description), so both map to - None: code 6 = "Thatched" (its U is set by the description, not this - field) and code 7 = "(same/another dwelling above)" — an internal - ceiling with no roof heat loss, the roof-side analogue of - floor_construction code 0. Empirically inert: roof W/K is identical - whether 6/7 map to None or to an explicit pitched string across all - code-6/7 certs in the 2026 sample.""" + roof_construction code to the string `heat_transmission.py` reads for + two purposes: the "sloping ceiling" cos(30°) inclined-surface factor + (Slice 89), and — for codes 7/9 — the "another dwelling above" + substring that suppresses that building part's roof entirely (zero + heat loss). Code 6 = "Thatched" is neither: its U-value comes from + the global roofs[].description, not this field, so it maps to None + (avoids the cos(30°) false-trigger). Codes 7 and 9 both map to + "(another dwelling above)" per RdSAP 10 Specification (10-06-2025) + §5.2.5: commercial premises above (code 9) is recorded identically to + another dwelling above (code 7) — both zero heat loss.""" - def test_code_7_same_dwelling_above_maps_to_none(self) -> None: + def test_code_7_same_dwelling_above_suppresses_roof_heat_loss(self) -> None: # Arrange - from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_roof_construction_str, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_roof_construction_str(7) - # Assert — None: no sloping-ceiling signal (avoids the cos(30°) - # false-trigger); the internal ceiling has no roof heat loss. - assert result is None + # Assert — the party-ceiling marker, so heat_transmission.py's + # per-part "another dwelling above" suppression fires. + assert result == "(another dwelling above)" + + def test_code_9_another_premises_above_suppresses_roof_heat_loss(self) -> None: + # Arrange — RdSAP 10 Spec §5.2.5: commercial premises above is + # recorded as "another dwelling above", same as code 7. + from datatypes.epc.domain.mapper import ( + _api_roof_construction_str, + ) # pyright: ignore[reportPrivateUsage] + + # Act + result = _api_roof_construction_str(9) + + # Assert + assert result == "(another dwelling above)" def test_code_6_thatched_maps_to_none(self) -> None: # Arrange - from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_roof_construction_str, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_roof_construction_str(6) @@ -1325,7 +1391,9 @@ class TestApiRoofConstructionCode: def test_code_8_still_maps_to_sloping_ceiling(self) -> None: # Arrange — regression guard: the sloping-ceiling code is unchanged. - from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_roof_construction_str, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_roof_construction_str(8) @@ -1345,7 +1413,9 @@ class TestApiGlazingTransmissionTable24: def test_single_glazing_code_5_is_table_24_u_4p8(self) -> None: # Arrange — RdSAP 21 glazing_type 5 = "single glazing"; Table 24 # row "Single / Any period" → U 4.8 (PVC/wooden), g 0.85. - from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_glazing_transmission, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_glazing_transmission(5, None) @@ -1356,7 +1426,9 @@ class TestApiGlazingTransmissionTable24: def test_single_glazing_known_data_code_15_is_table_24_u_4p8(self) -> None: # Arrange — code 15 = "single glazing, known data"; same Table 24 # Single row when no measured U is lodged on the reduced-data path. - from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_glazing_transmission, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_glazing_transmission(15, None) @@ -1367,7 +1439,9 @@ class TestApiGlazingTransmissionTable24: def test_secondary_glazing_normal_emissivity_code_11_is_u_2p9(self) -> None: # Arrange — code 11 = "secondary glazing, normal emissivity"; # Table 24 Secondary "Normal emissivity" row → U 2.9, g 0.85. - from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_glazing_transmission, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_glazing_transmission(11, None) @@ -1378,7 +1452,9 @@ class TestApiGlazingTransmissionTable24: def test_secondary_glazing_low_emissivity_code_12_is_u_2p2(self) -> None: # Arrange — code 12 = "secondary glazing, low emissivity"; Table 24 # Secondary "Low emissivity" row → U 2.2, g 0.85. - from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_glazing_transmission, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_glazing_transmission(12, None) @@ -1389,7 +1465,9 @@ class TestApiGlazingTransmissionTable24: def test_triple_glazing_2002_to_2022_code_9_is_u_2p0(self) -> None: # Arrange — code 9 = "triple glazing, installed 2002-2022"; Table 24 # "Double or triple, 2002+ (pre-2022), any gap" → U 2.0, g 0.72. - from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_glazing_transmission, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_glazing_transmission(9, None) @@ -1400,7 +1478,9 @@ class TestApiGlazingTransmissionTable24: def test_triple_glazing_pre_2002_code_10_default_gap_is_u_2p1(self) -> None: # Arrange — code 10 = "triple glazing, pre-2002"; Table 24 Triple # pre-2002 12mm-gap default → U 2.1, g 0.68. - from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_glazing_transmission, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_glazing_transmission(10, None) @@ -1412,7 +1492,9 @@ class TestApiGlazingTransmissionTable24: # Arrange — regression guard: the already-mapped double-glazing # 2002+ entry (U 2.0, g 0.72) is untouched by the single/secondary/ # triple extension. - from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_glazing_transmission, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_glazing_transmission(2, None) @@ -1433,7 +1515,9 @@ class TestApiCascadeGlazingCodeDivergentRemap: def test_single_glazing_code_5_remaps_to_cascade_single_slot_1(self) -> None: # Arrange — RdSAP-21 code 5 = single glazing; cascade single slot # is 1 (g⊥ 0.85, g_L 0.90), not cascade 5 (secondary, 0.76/0.80). - from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_cascade_glazing_type, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_cascade_glazing_type(5) @@ -1444,7 +1528,9 @@ class TestApiCascadeGlazingCodeDivergentRemap: def test_secondary_glazing_code_4_remaps_to_cascade_secondary_slot_5(self) -> None: # Arrange — RdSAP-21 code 4 = secondary glazing; cascade secondary # slot is 5 (g⊥ 0.76, g_L 0.80), not cascade 4 (double low-E 0.63). - from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_cascade_glazing_type, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_cascade_glazing_type(4) @@ -1454,7 +1540,9 @@ class TestApiCascadeGlazingCodeDivergentRemap: def test_double_pre_2002_code_1_remap_unchanged(self) -> None: # Arrange — regression guard: the existing code-1 remap (→2) stands. - from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_cascade_glazing_type, + ) # pyright: ignore[reportPrivateUsage] # Act result = _api_cascade_glazing_type(1) @@ -1465,7 +1553,9 @@ class TestApiCascadeGlazingCodeDivergentRemap: def test_rdsap21_native_codes_pass_through(self) -> None: # Arrange — codes 9-15 already coincide with the g-table's RdSAP-21 # extension slots, so they must pass through untranslated. - from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_cascade_glazing_type, + ) # pyright: ignore[reportPrivateUsage] # Act / Assert assert _api_cascade_glazing_type(14) == 14 @@ -1644,7 +1734,9 @@ class TestRdSap20_0_0ReducedFieldSynthesis: # built_form. Build sap_ventilation with sheltered_sides from built_form # (else the calculator defaults every dwelling to mid-terrace=2). A cert # with an open fireplace. - from datatypes.epc.domain.mapper import _api_sheltered_sides # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_sheltered_sides, + ) # pyright: ignore[reportPrivateUsage] corpus = _load_20_0_0_corpus() if not corpus: @@ -1653,7 +1745,8 @@ class TestRdSap20_0_0ReducedFieldSynthesis: ( c for c in corpus - if not c.get("sap_windows") and (c.get("open_fireplaces_count") or 0) >= 1 + if not c.get("sap_windows") + and (c.get("open_fireplaces_count") or 0) >= 1 ), None, ) @@ -1691,12 +1784,13 @@ class TestRdSap20_0_0ReducedFieldSynthesis: if cert is None: pytest.skip("no corpus cert with instantaneous_wwhrs") iw = cert["sap_heating"]["instantaneous_wwhrs"] - expected_baths = iw["rooms_with_bath_and_or_shower"] + iw[ - "rooms_with_bath_and_mixer_shower" - ] - expected_mixers = iw["rooms_with_mixer_shower_no_bath"] + iw[ - "rooms_with_bath_and_mixer_shower" - ] + expected_baths = ( + iw["rooms_with_bath_and_or_shower"] + iw["rooms_with_bath_and_mixer_shower"] + ) + expected_mixers = ( + iw["rooms_with_mixer_shower_no_bath"] + + iw["rooms_with_bath_and_mixer_shower"] + ) # Act result = EpcPropertyDataMapper.from_api_response(cert) @@ -1724,8 +1818,7 @@ class TestRdSap20_0_0ReducedFieldSynthesis: c for c in corpus if any( - "identifier" not in part - for part in c.get("sap_building_parts", []) + "identifier" not in part for part in c.get("sap_building_parts", []) ) ), None, @@ -1984,7 +2077,9 @@ class TestRdSap18_0ReducedFieldSynthesis: # percent_draughtproofed, and built_form. Build sap_ventilation with # sheltered_sides from built_form (else the calculator defaults every # dwelling to mid-terrace=2). A cert with an open fireplace. - from datatypes.epc.domain.mapper import _api_sheltered_sides # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_sheltered_sides, + ) # pyright: ignore[reportPrivateUsage] corpus = _load_18_0_corpus() if not corpus: @@ -1993,7 +2088,8 @@ class TestRdSap18_0ReducedFieldSynthesis: ( c for c in corpus - if not c.get("sap_windows") and (c.get("open_fireplaces_count") or 0) >= 1 + if not c.get("sap_windows") + and (c.get("open_fireplaces_count") or 0) >= 1 ), None, ) @@ -2031,12 +2127,13 @@ class TestRdSap18_0ReducedFieldSynthesis: if cert is None: pytest.skip("no corpus cert with instantaneous_wwhrs") iw = cert["sap_heating"]["instantaneous_wwhrs"] - expected_baths = iw["rooms_with_bath_and_or_shower"] + iw[ - "rooms_with_bath_and_mixer_shower" - ] - expected_mixers = iw["rooms_with_mixer_shower_no_bath"] + iw[ - "rooms_with_bath_and_mixer_shower" - ] + expected_baths = ( + iw["rooms_with_bath_and_or_shower"] + iw["rooms_with_bath_and_mixer_shower"] + ) + expected_mixers = ( + iw["rooms_with_mixer_shower_no_bath"] + + iw["rooms_with_bath_and_mixer_shower"] + ) # Act result = EpcPropertyDataMapper.from_api_response(cert) @@ -2111,7 +2208,11 @@ class TestRdSap17_1ReducedFieldSynthesis: if not corpus: pytest.skip("no RdSAP-Schema-17.1 corpus harvested") cert = next( - (c for c in corpus if not c.get("sap_windows") and c.get("glazed_area") == 1), + ( + c + for c in corpus + if not c.get("sap_windows") and c.get("glazed_area") == 1 + ), None, ) if cert is None: @@ -2132,7 +2233,11 @@ class TestRdSap17_1ReducedFieldSynthesis: if not corpus: pytest.skip("no RdSAP-Schema-17.1 corpus harvested") cert = next( - (c for c in corpus if not c.get("sap_windows") and c.get("glazed_area") == 2), + ( + c + for c in corpus + if not c.get("sap_windows") and c.get("glazed_area") == 2 + ), None, ) if cert is None: @@ -2219,7 +2324,9 @@ class TestRdSap17_1ReducedFieldSynthesis: ) -> None: # ADR-0028: open_fireplaces_count -> chimneys, percent_draughtproofed, # sheltered_sides from built_form. - from datatypes.epc.domain.mapper import _api_sheltered_sides # pyright: ignore[reportPrivateUsage] + from datatypes.epc.domain.mapper import ( + _api_sheltered_sides, + ) # pyright: ignore[reportPrivateUsage] corpus = _load_17_1_corpus() if not corpus: @@ -2228,7 +2335,8 @@ class TestRdSap17_1ReducedFieldSynthesis: ( c for c in corpus - if not c.get("sap_windows") and (c.get("open_fireplaces_count") or 0) >= 1 + if not c.get("sap_windows") + and (c.get("open_fireplaces_count") or 0) >= 1 ), None, ) @@ -2261,12 +2369,13 @@ class TestRdSap17_1ReducedFieldSynthesis: if cert is None: pytest.skip("no corpus cert with instantaneous_wwhrs") iw = cert["sap_heating"]["instantaneous_wwhrs"] - expected_baths = iw["rooms_with_bath_and_or_shower"] + iw[ - "rooms_with_bath_and_mixer_shower" - ] - expected_mixers = iw["rooms_with_mixer_shower_no_bath"] + iw[ - "rooms_with_bath_and_mixer_shower" - ] + expected_baths = ( + iw["rooms_with_bath_and_or_shower"] + iw["rooms_with_bath_and_mixer_shower"] + ) + expected_mixers = ( + iw["rooms_with_mixer_shower_no_bath"] + + iw["rooms_with_bath_and_mixer_shower"] + ) result = EpcPropertyDataMapper.from_api_response(cert) @@ -2314,16 +2423,22 @@ class TestRoomInRoofDetailedSlopeAndStudWall: rir = cert["sap_building_parts"][0]["sap_room_in_roof"] rir.pop("room_in_roof_type_1", None) rir["room_in_roof_details"] = { - "slope_length_1": 7.0, "slope_height_1": 1.4, - "slope_length_2": 7.0, "slope_height_2": 1.4, + "slope_length_1": 7.0, + "slope_height_1": 1.4, + "slope_length_2": 7.0, + "slope_height_2": 1.4, "slope_insulation_thickness_1": "100mm", "slope_insulation_thickness_2": "100mm", - "stud_wall_length_1": 7.0, "stud_wall_height_1": 1.03, - "stud_wall_length_2": 7.0, "stud_wall_height_2": 1.03, + "stud_wall_length_1": 7.0, + "stud_wall_height_1": 1.03, + "stud_wall_length_2": 7.0, + "stud_wall_height_2": 1.03, "stud_wall_insulation_thickness_1": "75mm", "stud_wall_insulation_thickness_2": "75mm", - "common_wall_length_1": 8.6, "common_wall_height_1": 1.2, - "common_wall_length_2": 8.6, "common_wall_height_2": 1.2, + "common_wall_length_1": 8.6, + "common_wall_height_1": 1.2, + "common_wall_length_2": 8.6, + "common_wall_height_2": 1.2, } # Act @@ -2369,12 +2484,16 @@ class TestRoomInRoofType2SimplifiedQuadratic: rir = cert["sap_building_parts"][0]["sap_room_in_roof"] rir.pop("room_in_roof_type_1", None) rir["room_in_roof_type_2"] = { - "gable_wall_type_1": 1, "gable_wall_length_1": 10.0, + "gable_wall_type_1": 1, + "gable_wall_length_1": 10.0, "gable_wall_height_1": 2.0, - "gable_wall_type_2": 0, "gable_wall_length_2": 6.0, + "gable_wall_type_2": 0, + "gable_wall_length_2": 6.0, "gable_wall_height_2": 2.0, - "common_wall_length_1": 8.0, "common_wall_height_1": 1.0, - "common_wall_length_2": 8.0, "common_wall_height_2": 1.0, + "common_wall_length_1": 8.0, + "common_wall_height_1": 1.0, + "common_wall_length_2": 8.0, + "common_wall_height_2": 1.0, } # Act @@ -2505,7 +2624,9 @@ class TestNonSeparatedConservatoryApiMirror19_0: ) dwelling_part_count = len( - EpcPropertyDataMapper.from_api_response(load("19_0.json")).sap_building_parts + EpcPropertyDataMapper.from_api_response( + load("19_0.json") + ).sap_building_parts ) cert = load("19_0.json") @@ -2568,7 +2689,9 @@ class TestNonSeparatedConservatoryApiMirror20_0_0: ) dwelling_part_count = len( - EpcPropertyDataMapper.from_api_response(load("20_0_0.json")).sap_building_parts + EpcPropertyDataMapper.from_api_response( + load("20_0_0.json") + ).sap_building_parts ) cert = load("20_0_0.json") @@ -2637,7 +2760,12 @@ def test_gov_mappers_split_non_separated_conservatory(fixture: str) -> None: cert = load(fixture) cert["conservatory_type"] = 4 cert["sap_building_parts"].append( - {"floor_area": 12.0, "room_height": 1, "double_glazed": "Y", "glazed_perimeter": 9.0} + { + "floor_area": 12.0, + "room_height": 1, + "double_glazed": "Y", + "glazed_perimeter": 9.0, + } ) # Act @@ -2709,9 +2837,17 @@ def test_rdsap_mappers_carry_main_heating_controls_display( [ (RdSapSchema17_1, EpcPropertyDataMapper.from_rdsap_schema_17_1, "17_1.json"), (RdSapSchema18_0, EpcPropertyDataMapper.from_rdsap_schema_18_0, "18_0.json"), - (RdSapSchema20_0_0, EpcPropertyDataMapper.from_rdsap_schema_20_0_0, "20_0_0.json"), + ( + RdSapSchema20_0_0, + EpcPropertyDataMapper.from_rdsap_schema_20_0_0, + "20_0_0.json", + ), (RdSapSchema19_0, EpcPropertyDataMapper.from_rdsap_schema_19_0, "19_0.json"), - (RdSapSchema21_0_0, EpcPropertyDataMapper.from_rdsap_schema_21_0_0, "21_0_0.json"), + ( + RdSapSchema21_0_0, + EpcPropertyDataMapper.from_rdsap_schema_21_0_0, + "21_0_0.json", + ), ], ) def test_rdsap_mappers_map_mechanical_ventilation_kind( @@ -2738,7 +2874,11 @@ def test_rdsap_mappers_map_mechanical_ventilation_kind( "schema_cls, mapper, fixture", [ (RdSapSchema19_0, EpcPropertyDataMapper.from_rdsap_schema_19_0, "19_0.json"), - (RdSapSchema21_0_0, EpcPropertyDataMapper.from_rdsap_schema_21_0_0, "21_0_0.json"), + ( + RdSapSchema21_0_0, + EpcPropertyDataMapper.from_rdsap_schema_21_0_0, + "21_0_0.json", + ), ], ) def test_rdsap_19_0_21_0_0_construct_sap_ventilation_block( @@ -2755,3 +2895,109 @@ def test_rdsap_19_0_21_0_0_construct_sap_ventilation_block( result = mapper(schema) assert result.sap_ventilation.sheltered_sides == 1 + + +# --------------------------------------------------------------------------- +# ADR-0028 single-glazed honouring on the reduced-field "ND" fallback. +# +# A reduced-field cert whose numeric `multiple_glazing_type` is the "ND" (Not +# Defined) sentinel used to default to double glazing (cascade code 2) on every +# pre-SAP10 synthesis seam — over-rating a genuinely single-glazed dwelling AND +# hiding it from the glazing generator (which upgrades only single-glazed +# windows). The 16.x normaliser already honoured an explicit "Single glazed" +# window description; these tests pin that same rule now firing on the 17.0 / +# 17.1 / 18.0 / 19.0 / 20.0.0 seams. Motivating cert: 2780-3922-3202-6042-9200 +# (uprn 10033526327), a single-glazed community-heated flat lodging +# multiple_glazing_type="ND", window "Single glazed", multiple_glazed_proportion 0. +# --------------------------------------------------------------------------- + +_REDUCED_FIELD_SEAMS = [ + (RdSapSchema17_0, EpcPropertyDataMapper.from_rdsap_schema_17_0, "17_0.json"), + (RdSapSchema17_1, EpcPropertyDataMapper.from_rdsap_schema_17_1, "17_1.json"), + (RdSapSchema18_0, EpcPropertyDataMapper.from_rdsap_schema_18_0, "18_0.json"), + (RdSapSchema19_0, EpcPropertyDataMapper.from_rdsap_schema_19_0, "19_0.json"), + (RdSapSchema20_0_0, EpcPropertyDataMapper.from_rdsap_schema_20_0_0, "20_0_0.json"), +] + + +@pytest.mark.parametrize("schema_cls, mapper, fixture", _REDUCED_FIELD_SEAMS) +def test_nd_glazing_with_single_glazed_description_synthesises_single( + schema_cls: Any, mapper: Any, fixture: str +) -> None: + # Arrange — force the ND + single-glazed shape onto the fixture, dropping any + # per-window array so the reduced-field synthesis seam runs. + data = load(fixture) + data["multiple_glazing_type"] = "ND" + data["multiple_glazed_proportion"] = 0 + data["window"] = { + "description": "Single glazed", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1, + } + data.pop("sap_windows", None) + + # Act + result = mapper(from_dict(schema_cls, data)) + + # Assert — synthesised as single (SAP10 cascade code 1), the code the glazing + # generator's single-glazed set {1, 15} matches, NOT the old double default 2. + assert result.sap_windows + assert all(w.glazing_type == 1 for w in result.sap_windows) + + +@pytest.mark.parametrize("schema_cls, mapper, fixture", _REDUCED_FIELD_SEAMS) +def test_nd_glazing_without_single_signal_keeps_double_modal_default( + schema_cls: Any, mapper: Any, fixture: str +) -> None: + # Arrange — ND with a double-glazed description and a non-zero multiple-glazed + # proportion: the honouring must NOT fire, so the DG-modal default stands. + data = load(fixture) + data["multiple_glazing_type"] = "ND" + data["multiple_glazed_proportion"] = 100 + data["window"] = { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3, + } + data.pop("sap_windows", None) + + # Act + result = mapper(from_dict(schema_cls, data)) + + # Assert — unchanged DG-modal default (cascade code 2). + assert result.sap_windows + assert all(w.glazing_type == 2 for w in result.sap_windows) + + +class TestReducedFieldNdGlazingResolver: + """Unit-level pins for the shared ND resolver (schema-independent).""" + + def test_single_in_description_is_single_glazed(self) -> None: + from datatypes.epc.domain.mapper import ( + _reduced_field_is_single_glazed, # pyright: ignore[reportPrivateUsage] + ) + + assert _reduced_field_is_single_glazed("Single glazed", 50) is True + + def test_zero_multiple_glazed_proportion_is_single_glazed(self) -> None: + from datatypes.epc.domain.mapper import ( + _reduced_field_is_single_glazed, # pyright: ignore[reportPrivateUsage] + ) + + # 0% multiple-glazed = 100% single, even if the description is ambiguous. + assert _reduced_field_is_single_glazed("Not recorded", 0) is True + + def test_double_description_with_glazing_is_not_single(self) -> None: + from datatypes.epc.domain.mapper import ( + _reduced_field_is_single_glazed, # pyright: ignore[reportPrivateUsage] + ) + + assert _reduced_field_is_single_glazed("Fully double glazed", 100) is False + + def test_resolver_returns_cascade_single_or_the_seam_default(self) -> None: + from datatypes.epc.domain.mapper import ( + _reduced_field_nd_glazing_type, # pyright: ignore[reportPrivateUsage] + ) + + assert _reduced_field_nd_glazing_type("Single glazed", 0, 2) == 1 + assert _reduced_field_nd_glazing_type("Fully double glazed", 100, 2) == 2 diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index 08bff90a4..b561393cf 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -137,6 +137,71 @@ class TestFromSapSchema17_1RebaselineFields: assert result.assessment_type == "SAP" +class TestFullSapPhotovoltaics: + """Full-SAP certs lodge measured PV under `sap_energy_source.pv_arrays`; + the mapper must carry it to the domain `photovoltaic_arrays` so the + Appendix-M generation credit isn't silently dropped.""" + + def test_maps_the_lodged_pv_array(self) -> None: + # sap_17_1_house.json lodges one 1.62 kWp array (pitch 3, orientation 6, + # overshading 1). + schema = from_dict(SapSchema17_1, load("sap_17_1_house.json")) + + result = EpcPropertyDataMapper.from_sap_schema_17_1(schema) + + arrays = result.sap_energy_source.photovoltaic_arrays + assert arrays is not None + assert len(arrays) == 1 + assert arrays[0].peak_power == 1.62 + + +class TestFullSapElectricityTariffTranslation: + """The full-SAP `energy_tariff` code space differs from the RdSAP + `meter_type` one the calculator reads, so the mapper must translate it.""" + + def test_economy7_tariff_is_not_read_as_single_rate(self) -> None: + # full-SAP energy_tariff 2 = "off-peak 7 hour" (Economy 7). Passing the + # code straight through read it as RdSAP meter 2 = Single → no off-peak + # split (under-rated). It must resolve to the SEVEN_HOUR off-peak tariff. + from domain.sap10_calculator.tables.table_12a import ( + Tariff, + tariff_from_meter_type, + ) + + from datatypes.epc.domain.mapper import _sap_17_1_meter_type + + meter_type = _sap_17_1_meter_type(2) + + assert tariff_from_meter_type(meter_type) is Tariff.SEVEN_HOUR + + @pytest.mark.parametrize( + ("energy_tariff", "expected"), + [ + (1, "STANDARD"), # standard tariff → single-rate (was over-rated as E7) + (2, "SEVEN_HOUR"), # off-peak 7-hour → Economy 7 + (3, "SEVEN_HOUR"), # off-peak 10-hour → dual (§12 resolves 7/10hr) + ( + 4, + "TWENTY_FOUR_HOUR", + ), # 24-hour tariff (e.g. property 709874 — unchanged) + (5, "EIGHTEEN_HOUR"), # off-peak 18-hour — was dropped to "" → STANDARD + (None, "STANDARD"), # absent/ND → unknown → standard + ], + ) + def test_energy_tariff_resolves_to_the_correct_calculator_tariff( + self, energy_tariff: Any, expected: str + ) -> None: + from domain.sap10_calculator.tables.table_12a import ( + tariff_from_meter_type, + ) + + from datatypes.epc.domain.mapper import _sap_17_1_meter_type + + resolved = tariff_from_meter_type(_sap_17_1_meter_type(energy_tariff)) + + assert resolved.name == expected + + class TestFromSapSchema17_1DisplayElements: """Display EnergyElements the WIP mapper dropped, leaving the FE property-details panel "Unknown" for full-SAP certs (ADR-0037). Brings @@ -222,8 +287,12 @@ class TestFromSapSchema17_1FabricDescriptions: def test_wall_description_carries_measured_u(self, sample: EpcPropertyData) -> None: assert sample.walls[0].description == "Average thermal transmittance 0.17 W/m²K" - def test_floor_description_carries_measured_u(self, sample: EpcPropertyData) -> None: - assert sample.floors[0].description == "Average thermal transmittance 0.13 W/m²K" + def test_floor_description_carries_measured_u( + self, sample: EpcPropertyData + ) -> None: + assert ( + sample.floors[0].description == "Average thermal transmittance 0.13 W/m²K" + ) def test_roof_description_carries_other_premises( self, sample: EpcPropertyData @@ -443,7 +512,7 @@ class TestFromSapSchema17_1EnergySource: def test_mains_gas_derived_from_heating_fuel(self, sample: EpcPropertyData) -> None: # Main heating fuel is mains gas (code 1) → mains_gas True. - assert sample.sap_energy_source.mains_gas is True + assert sample.sap_energy_source.gas_connection_available is True def test_lighting_outlet_counts_to_bulbs(self, sample: EpcPropertyData) -> None: # 1 fixed outlet, 1 low-energy → 1 low-energy bulb, 0 incandescent. @@ -481,7 +550,9 @@ class TestFromSapSchema17_1Ventilation: schema = from_dict(SapSchema17_1, load("sap_17_1.json")) return EpcPropertyDataMapper.from_sap_schema_17_1(schema) - def test_measured_air_permeability_fed_as_ap50(self, sample: EpcPropertyData) -> None: + def test_measured_air_permeability_fed_as_ap50( + self, sample: EpcPropertyData + ) -> None: # The lodged `air_permeability` is a q50 Blower-Door result, so it feeds the # engine's AP50 path `(18) = AP50/20 + (8)` — NOT the AP4/Pulse formula # `0.263 × AP4^0.924` (the air-permeability AP50 fix, uprn_10093116528). @@ -492,7 +563,10 @@ class TestFromSapSchema17_1Ventilation: def test_ventilation_type_6_is_extract(self, sample: EpcPropertyData) -> None: # ventilation_type 6 = MEV decentralised → EXTRACT_OR_PIV_OUTSIDE. assert sample.sap_ventilation is not None - assert sample.sap_ventilation.mechanical_ventilation_kind == "EXTRACT_OR_PIV_OUTSIDE" + assert ( + sample.sap_ventilation.mechanical_ventilation_kind + == "EXTRACT_OR_PIV_OUTSIDE" + ) def test_sheltered_sides_and_wet_rooms(self, sample: EpcPropertyData) -> None: assert sample.sap_ventilation is not None @@ -587,14 +661,18 @@ class TestFromSapSchema17_1Perimeter: # measured exposed-wall area 87.85 m² (uniform per-storey split). result = self._map("sap_17_1_house.json") fds = result.sap_building_parts[0].sap_floor_dimensions - gross = sum((fd.heat_loss_perimeter_m or 0.0) * (fd.room_height_m or 0.0) for fd in fds) + gross = sum( + (fd.heat_loss_perimeter_m or 0.0) * (fd.room_height_m or 0.0) for fd in fds + ) assert gross == pytest.approx(87.85, abs=1e-2) def test_house_routes_party_walls_to_party_length(self) -> None: # Σ(party_length_i × height_i) must equal the measured party-wall area. result = self._map("sap_17_1_house.json") fds = result.sap_building_parts[0].sap_floor_dimensions - party = sum((fd.party_wall_length_m or 0.0) * (fd.room_height_m or 0.0) for fd in fds) + party = sum( + (fd.party_wall_length_m or 0.0) * (fd.room_height_m or 0.0) for fd in fds + ) assert party == pytest.approx(105.44, abs=1e-2) def test_unknown_wall_type_fails_loud(self) -> None: @@ -671,8 +749,9 @@ class TestFromSapSchema16_2: epc = EpcPropertyDataMapper.from_api_response(load("sap_17_0.json")) assert isinstance(epc, EpcPropertyData) assert epc.uprn == 10023444324 - # lodged 82; engine produces 80. - assert Sap10Calculator().calculate(epc).sap_score == 80 + # lodged 82; the engine now also produces 82 — the cert's lodged PV is + # credited (previously dropped by the full-SAP mapper, under-rating to 80). + assert Sap10Calculator().calculate(epc).sap_score == 82 def test_18_0_0_dispatches_via_full_sap_path(self) -> None: # SAP-Schema-18.0.0 is the full-SAP 17.1 shape; dispatched to @@ -807,6 +886,27 @@ class TestFullSapSchema16xRouting: assert epc.door_count == 1 assert epc.dwelling_type == "Detached house" + def test_full_sap_16_x_electricity_tariff_is_translated_not_passed_through( + self, + ) -> None: + # A full-SAP 16.x cert lodges the SAP `energy_tariff` code space, not the + # RdSAP `meter_type` one. Routing it through the full-SAP mapper (via the + # assessment_type=SAP gate) must apply `_sap_17_1_meter_type`: tariff 1 = + # standard → STANDARD. Were it raw-passed to the RdSAP normaliser, code 1 + # = "dual" → SEVEN_HOUR (the prime-lead over-rating bug this guards). + from domain.sap10_calculator.tables.table_12a import ( + Tariff, + tariff_from_meter_type, + ) + + data = load("sap_16_0_full.json") # electricity_tariff = 1 (standard) + + epc = EpcPropertyDataMapper.from_api_response(data) + + assert tariff_from_meter_type(epc.sap_energy_source.meter_type) is ( + Tariff.STANDARD + ) + def test_reduced_16_x_cert_unaffected_by_full_sap_routing(self) -> None: # Arrange — a reduced 16.2 cert (assessment_type RdSAP) must stay on the # RdSAP path, keeping its top-level property_type. @@ -925,5 +1025,7 @@ class TestFullSapSchema16xNoFloorDimensions: data.pop("sap_flat_details", None) # Act / Assert - with pytest.raises(ValueError, match="Wardalls Grove.*SE14 5FB.*sap_floor_dimensions"): + with pytest.raises( + ValueError, match="Wardalls Grove.*SE14 5FB.*sap_floor_dimensions" + ): EpcPropertyDataMapper.from_api_response(data) diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 289d415a2..5cb29b698 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -103,7 +103,7 @@ class TestFromSiteNotesExample1: def test_mains_gas_available(self, result: EpcPropertyData) -> None: # general.mains_gas_available: true - assert result.sap_energy_source.mains_gas is True + assert result.sap_energy_source.gas_connection_available is True def test_electricity_smart_meter(self, result: EpcPropertyData) -> None: # general.electricity_smart_meter: true @@ -393,7 +393,9 @@ class TestFromSiteNotesExample1: cylinder_insulation_type="Factory fitted", cylinder_insulation_thickness_mm=12, shower_outlets=ShowerOutlets( - shower_outlet=ShowerOutlet(shower_outlet_type="Non-Electric Shower"), + shower_outlet=ShowerOutlet( + shower_outlet_type="Non-Electric Shower" + ), ), ), # Windows @@ -453,7 +455,7 @@ class TestFromSiteNotesExample1: ], # Energy source sap_energy_source=SapEnergySource( - mains_gas=True, + gas_connection_available=True, meter_type="Single", pv_battery_count=0, wind_turbines_count=0, @@ -620,7 +622,10 @@ class TestFromSiteNotesFloorConstruction: def test_floor_construction_type(self, result: EpcPropertyData) -> None: # building_construction.floor.floor_construction: "Suspended, not timber" - assert result.sap_building_parts[0].floor_construction_type == "Suspended, not timber" + assert ( + result.sap_building_parts[0].floor_construction_type + == "Suspended, not timber" + ) def test_floor_insulation_type_str(self, result: EpcPropertyData) -> None: # building_construction.floor.floor_insulation_type: "As Built" @@ -657,7 +662,10 @@ class TestFromSiteNotesHeatingBoiler: def test_central_heating_pump_age_str(self, result: EpcPropertyData) -> None: # heating_and_hot_water.main_heating.central_heating_pump_age: "Unknown" - assert result.sap_heating.main_heating_details[0].central_heating_pump_age_str == "Unknown" + assert ( + result.sap_heating.main_heating_details[0].central_heating_pump_age_str + == "Unknown" + ) class TestFromSiteNotesMiscTopLevel: diff --git a/datatypes/epc/schema/rdsap_schema_17_0.py b/datatypes/epc/schema/rdsap_schema_17_0.py index d66f9c699..445d51a77 100644 --- a/datatypes/epc/schema/rdsap_schema_17_0.py +++ b/datatypes/epc/schema/rdsap_schema_17_0.py @@ -44,7 +44,9 @@ class SapHeating: instantaneous_wwhrs: Optional[InstantaneousWwhrs] main_heating_details: List[MainHeatingDetail] immersion_heating_type: Union[int, str] - has_fixed_air_conditioning: str + # Nested duplicate of the outer schema's has_fixed_air_conditioning + # (already Optional below) — some certs omit it here too. + has_fixed_air_conditioning: Optional[str] = None # ADR-0028: cylinder_insulation_type is absent in 308/1000 17.0 certs. cylinder_insulation_type: Optional[int] = None cylinder_thermostat: Optional[str] = None diff --git a/datatypes/epc/schema/rdsap_schema_17_1.py b/datatypes/epc/schema/rdsap_schema_17_1.py index bbd04b791..c0a4c8e62 100644 --- a/datatypes/epc/schema/rdsap_schema_17_1.py +++ b/datatypes/epc/schema/rdsap_schema_17_1.py @@ -44,7 +44,9 @@ class SapHeating: instantaneous_wwhrs: Optional[InstantaneousWwhrs] main_heating_details: List[MainHeatingDetail] immersion_heating_type: Union[int, str] - has_fixed_air_conditioning: str + # Nested duplicate of the outer schema's has_fixed_air_conditioning + # (also made Optional below) — some certs omit it here too. + has_fixed_air_conditioning: Optional[str] = None # ADR-0028: 325/1000 omit cylinder_insulation_type — default it. cylinder_insulation_type: Optional[int] = None cylinder_thermostat: Optional[str] = None @@ -247,7 +249,7 @@ class RdSapSchema17_1: hot_water_cost_potential: CostAmount renewable_heat_incentive: RenewableHeatIncentive energy_consumption_current: int - has_fixed_air_conditioning: str + has_fixed_air_conditioning: Optional[str] = None multiple_glazed_proportion: int calculation_software_version: str energy_consumption_potential: int diff --git a/datatypes/epc/schema/rdsap_schema_18_0.py b/datatypes/epc/schema/rdsap_schema_18_0.py index f35f0733c..a7a9d8702 100644 --- a/datatypes/epc/schema/rdsap_schema_18_0.py +++ b/datatypes/epc/schema/rdsap_schema_18_0.py @@ -44,7 +44,9 @@ class SapHeating: instantaneous_wwhrs: Optional[InstantaneousWwhrs] main_heating_details: List[MainHeatingDetail] immersion_heating_type: Union[int, str] - has_fixed_air_conditioning: str + # Nested duplicate of the outer schema's has_fixed_air_conditioning + # (also made Optional below) — some certs omit it here too. + has_fixed_air_conditioning: Optional[str] = None cylinder_insulation_type: Optional[int] = None cylinder_thermostat: Optional[str] = None secondary_fuel_type: Optional[int] = None @@ -266,7 +268,7 @@ class RdSapSchema18_0: hot_water_cost_potential: CostAmount renewable_heat_incentive: RenewableHeatIncentive energy_consumption_current: int - has_fixed_air_conditioning: str + has_fixed_air_conditioning: Optional[str] = None multiple_glazed_proportion: int calculation_software_version: str energy_consumption_potential: int diff --git a/datatypes/epc/schema/rdsap_schema_19_0.py b/datatypes/epc/schema/rdsap_schema_19_0.py index 2ba0f3cbb..4e0c21414 100644 --- a/datatypes/epc/schema/rdsap_schema_19_0.py +++ b/datatypes/epc/schema/rdsap_schema_19_0.py @@ -44,7 +44,9 @@ class SapHeating: instantaneous_wwhrs: Optional[InstantaneousWwhrs] main_heating_details: List[MainHeatingDetail] immersion_heating_type: Union[int, str] - has_fixed_air_conditioning: str + # Nested duplicate of the outer schema's has_fixed_air_conditioning + # (also made Optional below) — some certs omit it here too. + has_fixed_air_conditioning: Optional[str] = None cylinder_insulation_type: Optional[int] = None cylinder_thermostat: Optional[str] = None secondary_fuel_type: Optional[int] = None @@ -281,7 +283,7 @@ class RdSapSchema19_0: # 19.0-specific block, absent in 713/1000 — Optional + default. windows_transmission_details: Optional[WindowsTransmissionDetails] = None energy_consumption_current: int - has_fixed_air_conditioning: str + has_fixed_air_conditioning: Optional[str] = None multiple_glazed_proportion: int calculation_software_version: str energy_consumption_potential: int diff --git a/datatypes/epc/schema/rdsap_schema_20_0_0.py b/datatypes/epc/schema/rdsap_schema_20_0_0.py index 395fb9b92..8fae99371 100644 --- a/datatypes/epc/schema/rdsap_schema_20_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_20_0_0.py @@ -56,7 +56,9 @@ class SapHeating: instantaneous_wwhrs: Optional[InstantaneousWwhrs] main_heating_details: List[MainHeatingDetail] immersion_heating_type: Union[int, str] - has_fixed_air_conditioning: str + # Nested duplicate of the outer schema's has_fixed_air_conditioning + # (already Optional below) — some certs omit it here too. + has_fixed_air_conditioning: Optional[str] = None cylinder_insulation_type: Optional[int] = None cylinder_thermostat: Optional[str] = None secondary_fuel_type: Optional[int] = None diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 2f5c8e69b..4588ee7f6 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -63,7 +63,9 @@ class SapHeating: water_heating_fuel: int main_heating_details: List[MainHeatingDetail] immersion_heating_type: Union[int, str] - has_fixed_air_conditioning: str + # Nested duplicate of the outer schema's has_fixed_air_conditioning + # (also made Optional below) — some certs omit it here too. + has_fixed_air_conditioning: Optional[str] = None instantaneous_wwhrs: Optional[InstantaneousWwhrs] = None # Real-API certs carry shower_outlets as a list, not the synthetic # single-object form; list elements are normalised to the wrapped @@ -459,7 +461,7 @@ class RdSapSchema21_0_0: windows_transmission_details: Optional[WindowsTransmissionDetails] = None cfl_fixed_lighting_bulbs_count: Optional[int] = None energy_consumption_current: int - has_fixed_air_conditioning: str + has_fixed_air_conditioning: Optional[str] = None multiple_glazed_proportion: int calculation_software_version: str energy_consumption_potential: int diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 45f1ee465..4b814d7fb 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import List, Optional, Union from .common import DescriptionV1, Measurement @@ -64,7 +64,9 @@ class SapHeating: water_heating_fuel: int main_heating_details: List[MainHeatingDetail] immersion_heating_type: Union[int, str] - has_fixed_air_conditioning: str + # Nested duplicate of the outer schema's has_fixed_air_conditioning + # (also made Optional below) — some certs omit it here too. + has_fixed_air_conditioning: Optional[str] = None instantaneous_wwhrs: Optional[InstantaneousWwhrs] = None # Real-API certs carry shower_outlets as a list, not the synthetic single-object form; # accept both shapes so older fixtures keep parsing. List elements @@ -510,7 +512,9 @@ class RdSapSchema21_0_1: renewable_heat_incentive: RenewableHeatIncentive draughtproofed_door_count: int energy_consumption_current: int - has_fixed_air_conditioning: str + # kw_only on just this field: the class isn't kw_only overall, and several + # required fields (calculation_software_version etc.) already follow it. + has_fixed_air_conditioning: Optional[str] = field(default=None, kw_only=True) calculation_software_version: str energy_consumption_potential: int environmental_impact_current: int diff --git a/datatypes/epc/schema/sap_schema_17_1.py b/datatypes/epc/schema/sap_schema_17_1.py index 5cad983c2..656a28b25 100644 --- a/datatypes/epc/schema/sap_schema_17_1.py +++ b/datatypes/epc/schema/sap_schema_17_1.py @@ -107,6 +107,20 @@ class SapVentilation: mechanical_vent_duct_type: Optional[int] = None +@dataclass +class SapPvArray: + """One measured photovoltaic array lodged on a full-SAP cert (under + `sap_energy_source.pv_arrays`): peak power (kWp), pitch, SAP octant + orientation (1-8), overshading code, and the connection type. Mirrors the + domain `PhotovoltaicArray` the calculator's Appendix M generation uses.""" + + peak_power: Optional[float] = None + pitch: Optional[int] = None + orientation: Optional[int] = None + overshading: Optional[int] = None + pv_connection: Optional[int] = None + + @dataclass class SapEnergySource: """Electricity tariff, on-site generation and lighting. Lighting outlet @@ -118,6 +132,7 @@ class SapEnergySource: wind_turbine_terrain_type: Optional[int] = None fixed_lighting_outlets_count: Optional[int] = None low_energy_fixed_lighting_outlets_count: Optional[int] = None + pv_arrays: Optional[List[SapPvArray]] = None @dataclass @@ -232,3 +247,13 @@ class SapSchema17_1: has_hot_water_cylinder: Optional[str] = None # Present for flat-type dwellings; absence means not a flat. sap_flat_details: Optional[SapFlatDetails] = None + # Top-level PV export-capability flag (lodged by the SAP 10-era full-SAP + # schemas; absent on the SAP 2012 family, where SEG export did not exist). + # SAP 10.2 Appendix M1 zeroes the PV export credit unless the dwelling is + # connected to an export-capable meter, so dropping the flag under-rates + # any export-capable PV cert (~3 SAP on cert 0380-3044-6070-2305-5925). + # The sibling top-level `pv_connection` is deliberately NOT parsed: the + # full-SAP enum disagrees with the RdSAP/gov-API one the calculator gates + # on (full-SAP certs lodge 1 with a PV credit included; RdSAP 1 = "not + # connected" = no credit), so carrying it across would zero real arrays. + is_dwelling_export_capable: Optional[str] = None diff --git a/docs/adr/0004-baseline-performance-lodged-effective-pair.md b/docs/adr/0004-baseline-performance-lodged-effective-pair.md index fc27be7da..a8b4ba476 100644 --- a/docs/adr/0004-baseline-performance-lodged-effective-pair.md +++ b/docs/adr/0004-baseline-performance-lodged-effective-pair.md @@ -39,3 +39,58 @@ than churning the table twice. The SQLModel row is defined in `infrastructure/postgres/` so the ephemeral-Postgres tests build it via `create_all`; the production migration is FE-owned (Drizzle ORM) and tracked in `docs/migrations/`. + +### Amendment (2026-06-30, #1361 Class B): the Lodged half is absent for a predicted Property + +The original invariant — **every** `PropertyBaselinePerformance` populates **both** halves, even when +equal — assumed every Property has a record *of its own* performance to lodge: a public EPC cert (the +`epc_with_overlay` path) or a Site Notes survey. It does **not** hold for the **EPC Prediction** path +(ADR-0029/0031). A predicted Property has no record of itself — its `EpcPropertyData` is deterministic +neighbour synthesis that copies a representative comparable's structure wholesale, so the +`energy_rating_current` / band / CO2 / Primary Energy Intensity on the synthesised picture are a +*different dwelling's* lodged figures. Reading **Lodged Performance** off it manufactures a **phantom**: +a borrowed neighbour's SAP presented as this Property's government-register figure. This affected +**every** predicted Property (~12,236 rows estate-wide), not the 8 the `effective-lodged-divergence` +audit surfaced — that audit only fires when the borrowed figure lands ≥15 SAP from the Effective, so it +under-counts the phantom by three orders of magnitude. + +**Decision.** The Lodged half is **optional**. When `source_path == "predicted"` there is no Lodged +Performance: `PropertyBaselinePerformance.lodged` is `None` and the four `lodged_*` columns are `NULL`. +The **Effective half is unchanged and still persisted** — a predicted Property is a first-class modelled +output (it flows through Rebaselining, Bill Derivation, and Modelling like any other; its Effective +Performance and bill block are correct and load-bearing for the FE and the plan-vs-effective audit +checks). `rebaseline_reason` stays `physical_state_changed` / `both`, which already records that the +Effective figure was scored from a changed picture. + +**Principle (the boundary).** Lodged Performance requires a record **of this Property** — a lodged cert +*or* a Site Notes survey (the as-surveyed values are a real observation of this dwelling). EPC +Prediction borrows a neighbour's, so it has none. The branch is therefore `source_path == "predicted"` — +**not** `physical_state_changed` (true for Site Notes and Landlord Overrides too) and **not** "no public +EPC" (Site Notes has none yet keeps a legitimate Lodged Performance). Whether the Site Notes as-surveyed +values are truly "lodged" is a separate question, deferred. + +**Rejected — a sentinel (`lodged = 0`).** Considered, to make "no record" visibly present rather than an +empty cell. Rejected: `0` is a valid-looking SAP score that (a) re-trips `effective-lodged-divergence` +for every predicted Property (`|effective − 0| ≥ 15`), (b) poisons every `AVG(lodged_*)` aggregate that +`NULL` is correctly excluded from, and (c) has no coherent `lodged_epc_band` enum member. The "no lodged +record" signal belongs in `NULL` (honest absence) plus the **structural provenance** already carried by +the distinct predicted-EPC slot — which the FE renders as a predicted badge — not overloaded onto the +score column. + +**Consequences.** +- The four `lodged_*` columns become **nullable**. The production table is **FE-owned (Drizzle)**, so the + migration (`ALTER … DROP NOT NULL`) lands in the FE repo and **must precede** any backend write of a + `NULL` lodged, or the predicted-Property INSERT violates the constraint and aborts the batch + (ADR-0012). The SQLModel mirror in `infrastructure/postgres/` is updated to `Optional` so the + ephemeral-Postgres tests build the nullable shape. +- The fix lives in `PropertyBaselineOrchestrator.run()` — the single chokepoint both the First Run + pipeline and `applications/modelling_e2e/handler.py` call — so one change repairs both entry points. + The Rebaseliner port takes `Optional[Performance]`; for a predicted Property `physical_state_changed` + is always true, so `CalculatorRebaseliner` adopts the calculator output and never reads the absent + lodged half (the divergence-log path is the pristine-cert case only, where lodged is non-null). +- A one-time backfill (`scripts/`, dry-run default + `--apply`, idempotent) NULLs the four `lodged_*` + columns on existing predicted-source rows (~12,236). It corrects only the Lodged half; Effective, the + bill block, and `rebaseline_reason` are left intact. +- `effective-lodged-divergence` already short-circuits on `lodged_sap IS NULL`, so it goes green for + predicted Properties once backfilled. Class C of #1361 (a floor that ignores implausibly-low lodged + scores on *real* certs) is an additive guard, independent of this change. diff --git a/docs/adr/0041-flat-roofs-scored-by-insulation-thickness.md b/docs/adr/0041-flat-roofs-scored-by-insulation-thickness.md new file mode 100644 index 000000000..f5f7e4621 --- /dev/null +++ b/docs/adr/0041-flat-roofs-scored-by-insulation-thickness.md @@ -0,0 +1,30 @@ +# Flat roofs are scored by insulation thickness (RdSAP Table 16 col 1), not the age-band default + +## Context + +A **Landlord Override** naming a flat roof with a known insulation depth (`flat: 50mm` / `100mm` / `150mm`) is classified to the canonical `RoofType` value `"Flat, insulated (assumed)"`, which the roof **Simulation Overlay** resolves to `roof_construction_type='Flat'` with **no thickness**. The calculator then takes the U-value from the flat age-band default (`_FLAT_ROOF_BY_AGE`, RdSAP Table 18 column (3)). The stated depth is **discarded** — ~102 `property_overrides` rows. + +The overlay's own comment asserted *"No flat RoofType value carries an explicit mm depth"*. That is a **taxonomy artefact, not a truth about the data**: the `RoofType` enum has flat members only for insulation *state* (`FLAT_INSULATED / _ASSUMED / LIMITED / NO_INSULATION`) and **no flat-thickness members** — whereas pitched has the full `PITCHED_LOFT_12MM … 400MM` ladder. So a flat depth has nowhere to land and is lost at **classification**, before the overlay ever runs. + +Reviewing the **RdSAP 10 Specification (10-06-2025)** during grilling (issue #1361 override audit / #1376) settled the domain fact: **Table 16** ("Roof U-values when loft insulation thickness is known"), **column (1)**, is headed *"Insulation at joists at ceiling level **and flat roof**"*. A flat roof **is** scored by thickness — 50 mm → 0.68, 100 mm → 0.40, 150 mm → 0.30. Table 18 (the age-band default) is explicitly only the fallback *"used when thickness of insulation cannot be determined"*. The earlier assumption that flat roofs are age-band-only was wrong. + +The calculator already implements this correctly: `u_roof` routes a flat roof **with** a thickness through `_ROOF_BY_THICKNESS` (which *is* Table 16 col (1)). So a flat roof given its depth scores right today — the only defect is that the depth never reaches the calculator, because the taxonomy can't carry it. + +This is the opposite of the wall / flat "as built (assumed)" cases (ADR-0033), where **no** real datum exists and the age band is the correct lever. Here a **real measurement** exists and RdSAP scores it. + +## Decision + +Make a flat roof's known insulation thickness first-class, so it reaches Table 16 col (1): + +- Add **flat-thickness members** to the `RoofType` taxonomy — `FLAT_12MM … FLAT_400_PLUS_MM` (values like `"Flat, 150 mm insulation"` — **not** "loft"; a flat roof has no loft). Minimum set for today's data: 50, 100, 150; the full ladder mirrors the pitched members for symmetry. +- The roof **Simulation Overlay** emits `roof_insulation_thickness` for a flat roof with a known depth (reusing the existing mm regex), alongside `roof_construction_type='Flat'`, so `u_roof` reaches Table 16 col (1) instead of the age-band default. +- **Reclassify** the existing `flat: Nmm` rows off `"Flat, insulated (assumed)"` onto the new members. +- Correct the misleading overlay comment. + +The flat/pitched distinction is retained even though Table 16 col (1) gives the same U at a given thickness for both — it stays load-bearing for non-U concerns (measure eligibility, shape), so flat depths are **not** collapsed onto the pitched members. + +## Consequences + +- ~102 flat-roof properties re-score from the age-band default to their true thickness-based U-value — **both directions** (a newer-band flat roof with 150 mm improves; an old-band flat with 50 mm may worsen relative to its default). +- **Cross-repo (FE-owned pgEnum, Dan).** The `RoofType` `value` column is a Drizzle-owned Postgres enum (see `main-heating-system-pgenum-is-fe-owned`; PR #1361 Class A/B). The new members must be added to the pgEnum by the FE owner before the classifier-cache `value` writes; the `property_overrides` (TEXT) reclassify is immediate. So this is an **enum-dependent slice**, grouped with the other new-archetype slices of #1376 — not the no-enum roof/glazing resolver slice. +- The reclassify follows the established one-time-script shape (dry-run default, `--apply` in a transaction, idempotent), and surfaces any members the live enum does not yet carry as deferred, exactly as Class A did. diff --git a/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md b/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md new file mode 100644 index 000000000..47614fe56 --- /dev/null +++ b/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md @@ -0,0 +1,129 @@ +# Landlord-heating classification targets a complete, modellable taxonomy; unmapped input is no-override + +## Status + +accepted + +## Context + +Landlord supplementary data describes a dwelling's components in **unbounded +free-text** — a wall is "cavity insulated" / "CWI" / "Cav filled"; a heating +system is "communal gas boiler" / "Quantum storage" / a boiler make. An LLM +classifier maps that free-text onto a **closed internal taxonomy** of recognised +descriptions, each of which binds to a Simulation Overlay applied to +`EpcPropertyData` (ADR-0032). For heating the taxonomy is the +`MainHeatingSystemType` enum, mapped to a representative SAP Table 4a/4b code by +`main_heating_system_overlay`, with coherent companions dragged from the code +(ADR-0035). The LLM is the right tool for the unbounded-free-text problem; the +deterministic dicts in legacy `asset_list` were a *reviewed cache of LLM output*, +not a replacement, and the `landlord_*_overrides` table already serves as that +verified cache (keyed `portfolio_id + description`). + +The taxonomy was **too small** — 9 heating archetypes against a ~13-family RdSAP +main-heating taxonomy. With no correct target the classifier force-picked the +nearest wrong archetype, and treated **"Gas CPSU" as a garbage drawer**: oil and +solid-fuel room heaters, community heating, and `"boiler: a rated na"` all +classified to Gas CPSU; the word "convector" in `"electric (direct acting) room +heaters: panel, convector or radiant heaters"` pulled it to `"Electric storage +heaters, convector"` (code 403, off-peak storage) instead of `"Electric room +heaters"` (691, direct-acting, single-rate). + +Folded into the Effective EPC, a single-rate dwelling modelled as off-peak +storage scores **SAP ~10 (band G)**. This is the true cause of **PRD #1361 +Class A** — 14 properties lodged in band C/D rebaselining to band G — which had +been mis-attributed to a fabric "no insulation (assumed)" assumption and the +pre-SAP10 rebaseline. The walls are correct (landlord asserted as-built); the +heating is the crater. Exemplar **property 718066**: lodged SAP 57, landlord +described "electric (direct acting) room heaters: panel, convector or radiant" +(correct code 691, single-rate), mis-stored as code 403 → live SAP **9.9**. The +stored override was classified `source=classifier` on 2026-06-20, **before** the +691 "Electric room heaters" archetype existed (commit ada2bc07, 2026-06-29) — so +it is also stale. + +## Decision + +The fix is the **target taxonomy, not the mapper**. The LLM stays. + +1. **The classifier targets a complete, modellable taxonomy.** Expand + `MainHeatingSystemType` + `_MAIN_HEATING_CODES` to cover every RdSAP + main-heating family the calculator can score: room heaters by fuel + (gas / oil / solid), warm air, **heat pumps**, and **community heating**. Each + maps to a representative Table 4a/4b code; coherent companions drag from the + code per ADR-0035, so adding an archetype is still "just add its code". + +2. **Unmapped free-text → `None`, never a forced archetype.** The overlay already + returns `None` (no overlay → the lodged EPC heating stands) for any value not + in `_MAIN_HEATING_CODES`; the classifier must *emit* Unknown/`None` when it + cannot confidently place the text, rather than the nearest wrong archetype. + "Gas CPSU" (or any other archetype) is never a fallback. The `None` mapping is + persisted and surfaced to the user as "no suitable match" for later edit. A + forced archetype overwrites a correct lodged cert; `None` (keep lodged) is the + only safe and honest behaviour. + +3. **Heat pumps are modellable without a PCDB index** — a "model unknown" heat + pump maps to a default Table 4a heat-pump code (211–224) with the table's + default seasonal efficiency; no `main_heating_index_number` is required. + +4. **Community heating is modelled via its explicit codes** — 301 (boiler + community) / 302 (CHP + boilers) / 304 (electric heat-pump community), which + the catalogue strings disambiguate ("community boilers only" / "community CHP + and boilers" / "community heat pump"). The calculator (Appendix C §C3.2 + distribution loss, Table 12c DLF, Table 12 source factors) and Bill Derivation + (`HEAT_NETWORK` indicative rate) already support these. **Load-bearing + assumption:** when the landlord text does not supply them, the **DLF defaults + to Table 12c 1.50** and the source to a boiler — community SAP is *sensitive* + to the DLF (1.0 vs 1.5 is a large swing), so this default is documented here + and surfaced as an assumption. Coarse "communal heating" with **no named + source** falls to `None` rather than inventing a heat-network model. + +5. **Re-classify the stale `source=classifier` overrides** once the taxonomy is + complete, so the band-G properties pick up their correct code. + +## Considered Options + +- **Replace the LLM with a deterministic catalogue map** — rejected: landlord + input is unbounded free-text, not a closed vocabulary. The LLM is the right + front door; determinism belongs as a downstream *verified* cache (the override + table), built from LLM output + review, exactly as `asset_list` was. +- **Keep the 9-member enum, tune the LLM prompt** — rejected: with no correct + target the LLM must pick a wrong archetype regardless of prompt. Target + completeness is the fix; prompt quality is secondary. +- **Force unmapped input to a conservative archetype** (e.g. direct-acting + electric) — rejected: any forced archetype overwrites a correct lodged cert. +- **Defer community heating to `None`** (grill option b) — rejected in favour of + modelling the three explicit community codes, since calculator + bill already + support heat networks and the catalogue strings disambiguate the cases; only + coarse unnamed "communal heating" falls to `None`. + +This extends [ADR-0032](0032-landlord-override-epc-overlay.md) (the override +overlay) and [ADR-0035](0035-coherent-heating-system-synthesis.md) (coherent +companions drag from the code). The visible baseline shift it produces is +correct Rebaselining per [ADR-0039](0039-override-aware-rebaselining.md). It +**corrects the stated cause of PRD #1361 Class A** (classifier taxonomy gap, not +fabric/rebaseline). + +### Amendment: natural-fuel coherence + +A room-heater / direct-acting archetype names a *system*, not its fuel — and fuel +arrives as its own composable `main_fuel` override. Two rules keep the system +self-coherent without coupling the two overlays: + +- **Every archetype drags a natural fuel so the system self-coheres without a + `main_fuel` override.** Where the fuel is unambiguous it is exact — electric + room heaters / direct-acting / storage → electricity (RdSAP `main_fuel` 29), a + gas boiler → mains gas (26), an oil room heater → oil (28). **Solid fuel is + ambiguous** (coal / anthracite / smokeless / dual fuel / wood logs / pellets), + so it **defaults to house coal (33)** — the most common solid fuel — which a + specific `main_fuel` override then refines. (The `main_fuel` override's own + vocabulary must grow to carry the full solid-fuel list — a parallel taxonomy + gap, same shape as this one.) +- **A present `main_fuel` override wins** by the applicator's last-wins + composition (`apply_simulations` setattrs non-`None` fields in order), so the + `main_fuel` overlay is applied **after** the heating overlay. The agreeing + case (electric system + electricity fuel) is order-immaterial; only a + *disagreeing* one depends on the ordering. +- **A landlord fuel that contradicts the archetype's natural fuel is *logged*, + not raised** (e.g. "gas" fuel on a solid-fuel room heater) — an + override-plausibility signal (cf. the ADR-0039 scanner); what to do with it is + deferred. The override is still honoured (the landlord wins); we only record + the implausibility. diff --git a/docs/adr/0042-glazing-override-reconciles-against-cert-composition.md b/docs/adr/0042-glazing-override-reconciles-against-cert-composition.md new file mode 100644 index 000000000..0df0517ec --- /dev/null +++ b/docs/adr/0042-glazing-override-reconciles-against-cert-composition.md @@ -0,0 +1,42 @@ +# Landlord glazing override reconciles against the cert's per-window composition, not a whole-dwelling type + +## Context + +A **Landlord Override** for glazing is a whole-dwelling categorical (`GlazingType` — Single / Double pre- and post-2002 / Triple / Unknown). The glazing **Simulation Overlay** resolves it to one SAP10 `glazing_type` code, and `_fold_glazing` overwrites **every** window's `glazing_type` (and clears its lodged U-/g-value) — flattening the cert to a single type. + +The landlord descriptions are frequently **aggregate splits** ("40% double, 60% single"), and the LLM classifier collapses *any-double-present* to `"Double glazing"`. So ~319 `property_overrides` rows describing a **single-dominant** mix are scored as **fully double-glazed** — a material over-credit to the window U-values, hence to SAP. + +The Effective EPC already carries **per-window glazing**: `sap_windows`, each with its own `glazing_type` **and** `window_width`×`window_height` (so area is derivable), plus the dwelling-level `multiple_glazed_proportion`. This is **more granular** than any whole-dwelling summary, and a whole-dwelling percentage **cannot be faithfully assigned to specific windows** — "which windows are the single ones" is unknowable from an aggregate. `_fold_glazing` already holds the EPC at apply time, so the per-window data is available where the override is applied. + +Fixing this by "dominant type wins" would still **clobber real per-window variation** (a 60/40 dwelling flattened to one type). The faithful move is to treat the cert's per-window data as **authoritative** and the landlord aggregate as a **check** on it — the descriptions are, after all, aggregations of the same per-window split the cert records. + +## Decision + +The landlord glazing override **reconciles against the cert's per-window composition** instead of imposing a whole-dwelling type. + +- **Classifier emits a proportion, not just a dominant type.** The asserted glazing **proportion** (e.g. multiple-glazed %) is extracted by the **LLM** — which is there precisely because landlord inputs vary; we do **not** hard-parse one string format. +- **Compute the cert's actual composition** from `sap_windows`, **area-weighted** by window (bigger windows dominate the dwelling U-window). +- **Reconcile in `_fold_glazing`** (it holds the EPC), three outcomes: + - **Uniform assertion (~100% one type)** → apply the blanket type. Unambiguous, and a real correction (e.g. a full reglaze). Unchanged from today for clean cases. + - **Mixed, proportion within tolerance of the cert** → **no-op**. The cert already reflects it, per-window and more precisely — leave the better data alone. + - **Mixed, proportion materially different from the cert** → **no-op + flag**. The landlord genuinely disagrees, but per-window assignment is unknowable, so **do not fabricate a split** — surface it for review. +- **Tolerance** is a tunable band on the area-weighted multiple-glazed %, pinned against the real distribution, not guessed. + +## Consequences + +- The ~319 over-credited rows stop being flattened to double; a mix that matches the cert is left on its per-window data, and a clean uniform reglaze still applies. +- **No new enum.** A no-op reconciliation leaves the cert glazing untouched (as `Unknown` already does). But it needs a **classifier-output change** (emit a proportion) **plus** the reconcile in the **apply seam** (`_fold_glazing`) — so it is its **own slice**, distinct from the no-enum roof party-ceiling fix that ships first. +- Genuine landlord-vs-cert glazing disagreements are **surfaced** rather than silently trusted (old behaviour: flatten to double) or silently overwritten. +- **Alternatives rejected.** *Dominant type wins* — still clobbers per-window variation. *Carry a full landlord composition to re-derive per-window types* — per-window assignment from an aggregate is unknowable; we decline to fabricate it. *Deterministic regex of "X% double, Y% single"* — brittle against varied input, which is the LLM's job. +- Pairs with the per-window fidelity the calculator already relies on: the reconciliation is only correct on a faithful Effective EPC whose `sap_windows` round-trip (cf. ADR-0040). + +## Amendment (#1376 implementation): the reconcile collapses to a `MIXED` sentinel + +Implementing this surfaced that the full "carry a proportion → compare to the cert → flag when far" design buys less than its cost. The cert **already** carries the best-possible per-window mapping (`sap_windows`, each with its own `glazing_type`); a landlord aggregate ("40% double, 60% single") says *how much* is double but **not which windows**, so applying it would overwrite real per-window data with an arbitrary guess. Per-window assignment from an aggregate is therefore not just unknowable but strictly worse than the cert — so a mixed override **always defers**, regardless of the exact proportion. The proportion comparison only ever fed the "flag when far" branch, and carrying a proportion needs a new schema field (an FE/Drizzle dependency). + +**Realized decision.** Add a `GlazingType.MIXED` sentinel. The LLM classifies a genuine aggregate-mix to `MIXED` (the LLM handles the varied phrasings — no regex); `MIXED` is absent from the overlay's `_GLAZING_CODES`, so it resolves to **no overlay** and `_fold_glazing` never runs — the cert's per-window glazing is preserved. A **uniform** assertion (one type ≳ 90%) still resolves to that type and is applied to every window (unambiguous — no guess). Existing mixed-as-double rows are reclassified to `MIXED` (a deterministic parse of the `%X / %Y` format is acceptable for fixing *known* rows). The "uniform vs mixed" threshold (~90% one type) is tunable, pinned against the real distribution. + +**Consequences of the amendment.** +- The defer behaviour needs **no apply-seam logic and no proportion field** — it falls out of "no overlay". The slice is: add the `MIXED` member, give the classifier the option, reclassify the ~319 rows. +- `MIXED` is a **new FE-owned pgEnum value** (one member) — added by the FE owner (Dan); `property_overrides` (TEXT) reclassify runs immediately, the cache pgEnum write defers until the migration (the Class-A/B pattern). +- The **"flag when landlord disagrees with the cert" is dropped for now** — since a mix always defers, the flag was informational only. `MIXED` is the hook if a future slice wants to surface those disagreements. diff --git a/docs/adr/0043-water-heating-community-scored-by-main-individual-by-fuel.md b/docs/adr/0043-water-heating-community-scored-by-main-individual-by-fuel.md new file mode 100644 index 000000000..9f579d228 --- /dev/null +++ b/docs/adr/0043-water-heating-community-scored-by-main-individual-by-fuel.md @@ -0,0 +1,123 @@ +# Water-heating override: community DHW is scored by its heat-network main, individual DHW by its own fuel + +## Status + +accepted + +## Context + +A **Landlord Override** for water heating names a canonical `", "` +**Recognised Internal Description** (`WaterHeatingType`), which the water-heating +**Simulation Overlay** (`water_heating_overlay_for`) decomposes into the SAP +`water_heating_code` (Table 4a system) + `water_heating_fuel` int codes the +calculator reads (ADR-0032). The taxonomy was **too small** — it had no biomass / +wood / dual-fuel / biodiesel water fuel — so the LLM classifier funnelled every +such description into the nearest bucket, **`"From main system, house coal"`** +(fuel 33). An audit found **131** `property_overrides` rows on that value; only +**3** are genuinely house coal. This is the same missing-archetype defect as +ADR-0041 (heating) — a dumping ground standing in for a gap in the taxonomy. + +Investigating the mis-score revealed the fix is **not** a uniform "swap coal for +biomass". The 131 rows split by whether the dwelling's **main heating is a heat +network**, because the calculator scores hot water differently in the two cases: + +- **Community main** (SAP Table 4a 301/302/304). When DHW inherits from the main + (`water_heating_code` ∈ {901, 902, 914}) and the main is a costable heat + network, `cert_to_inputs._is_community_heating_hw_from_main` computes the hot- + water CO2/PE/cost from the **main's** heat-network fuel (via + `_heat_network_community_fuel_code`, e.g. biomass-community fuel 31 → Table 12 + code 43), scaled by heat-source efficiency — and **ignores + `water_heating_fuel` entirely**. Verified: HW CO2 is `0.036` whether the water + fuel is 33 (coal) or 31 (biomass). So the **84** community rows already score + their DHW as biomass-heat-network; the `"house coal"` value is a **cosmetic + display lie**, not a scoring error. +- **Individual main** (a real boiler / room heater). DHW inherits the explicitly + lodged `water_heating_fuel`, so `33` scores as **house coal (0.395 kgCO2/kWh)** — + a genuine ~14× carbon overstatement for a wood/biomass dwelling. **47** rows are + mis-scored this way (20 wood logs, 4 dual fuel, 23 "electric immersion + assumed"); **3** genuine house-coal rows score correctly. + +The community-fuel collision (API enum 30/31/32 collide in value with the Table-32 +electricity tariff codes 30/31/32) is **already handled** at the fuel-type +boundary, gated on heat-network context (`_heat_network_community_fuel_code`) — a +deliberate design that must **not** be globally canonicalised, because the cascade +uses bare code 30 internally as standard-rate grid electricity +(`table_32.py` comment). So **no calculator change is required**: community DHW is +scored correctly *given the coherent community main the override data already +carries* (`main_heating_system = "Community heating, boilers"`, +`main_fuel = "biomass (community)"`). + +## Decision + +Complete the water-heating taxonomy and reclassify the dumping-ground rows to +**faithful** archetypes. No calculator change. + +1. **Add the missing water archetypes**, each mapping to its RdSAP + `water_heating_fuel` code in `water_heating_overlay._WATER_HEATING_CODES`: + - `"From main system, wood logs"` → (901, **6**) + - `"From main system, biomass (community)"` → (901, **31**) + - `"From main system, dual fuel (mineral and wood)"` → (901, **9**) + - `"From main system, biodiesel (community)"` → (901, **34**) + +2. **Community DHW maps to its community fuel (31 / 34) and is score-neutral.** + Because the community main drives the HW score, mapping the 75 community + biomass/biodiesel rows to their honest community fuel corrects the **displayed + value** without changing the score — we do **not** flatten them to individual + wood (6), which would be a semantic lie. Correct labelling, zero scoring risk. + +3. **Individual DHW maps to its real fuel and moves the score.** Wood-logs → 6 + (0.028), dual fuel → 9, off the coal 0.395 — the genuine fix for the 47 + mis-scored individual-main rows. + +4. **"No hot water system present – electric immersion assumed" → the existing + `"Electric immersion, electricity"` archetype (903, 29).** The description + states the assessment's own assumption; honouring it is faithful. This corrects + the 23 individual-main rows (coal → electricity) and re-labels the 9 + community-main rows off the accidental biomass score onto the assessed + immersion — a small, defensible score change, surfaced here as the one non- + cosmetic community reclassification. + +5. **Genuine `"From main system, house coal"` (3 rows) is retained** — the taxonomy + keeps house coal for the dwellings that really burn it. + +6. **Deterministic guard + reclassify, TEXT-first / pgEnum-deferred.** A pure + `water_heating_guard(description) -> Optional[WaterHeatingType]` (mirroring + `glazing_mix_guard`) recognises the structured `": "` phrasings + and is wired via `GuardedColumnClassifier(guard, fallback=ChatGpt…)`; varied + phrasings stay with the LLM. A one-time `scripts/reclassify_water_heating.py` + reuses the same guard as its decision function (so backfill and live path can't + drift), updates `property_overrides.override_value` (TEXT) unconditionally, and + defers the `landlord_water_heating_overrides.value` pgEnum-cache write for any + value the live enum doesn't yet carry — printing the deferred set for the FE + owner (the `main-heating-system-pgenum-is-fe-owned` constraint). + +## Consequences + +- **FE-owned pgEnum additions (Dan).** Four new `landlord_water_heating_overrides` + values: `"From main system, wood logs"`, `"From main system, biomass + (community)"`, `"From main system, dual fuel (mineral and wood)"`, `"From main + system, biodiesel (community)"`. The TEXT read path is fixed immediately; the + cache write for these four defers until the migration lands. +- **No `cert_to_inputs.py` change**, so no risk to the thousands of lodged certs + that carry water fuel 31/33/34 directly. (A separate latent question — whether a + lodged *individual*-main cert with `water_heating_fuel = 31` or `34` mis-scores + as electricity because those codes are un-normalised outside heat-network + context — is **out of scope** here and tracked separately; it does not affect any + override row, all of which resolve correctly under this decision.) +- Extends [ADR-0041](0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md) + (complete taxonomy, no dumping ground) and [ADR-0035](0035-coherent-heating-system-synthesis.md) + (the community main and its DHW cohere — here the coherence is what makes the + community rows score correctly). The visible baseline shift on the 47 individual + rows is correct Rebaselining ([ADR-0039](0039-override-aware-rebaselining.md)). + +### Alternatives rejected + +- **Flatten community biomass to individual wood (code 6).** Scores ~identically + (0.028 vs 0.029) but mislabels a district-heating dwelling as burning its own + wood — a semantic lie for no benefit, since the community main already scores it. +- **A calculator "collision fix" normalising 31/33/34 globally.** Rejected: 33 is + already normalised (`canonical_fuel_code` 33→11); 31/32 must stay un-normalised + outside heat-network context or genuine grid electricity mis-prices as community + waste (`table_32.py`). No override row needs it. +- **Defer the community rows to `None`.** Rejected per ADR-0041 dec-4 (community is + modelled, not deferred) — and unnecessary, since they already score correctly. diff --git a/docs/adr/0044-hhrsh-baseline-archetype-drags-its-intrinsic-controls.md b/docs/adr/0044-hhrsh-baseline-archetype-drags-its-intrinsic-controls.md new file mode 100644 index 000000000..354844e20 --- /dev/null +++ b/docs/adr/0044-hhrsh-baseline-archetype-drags-its-intrinsic-controls.md @@ -0,0 +1,94 @@ +# A baseline HHRSH override drags its intrinsic SAP-409 controls, not the generic manual storage default + +## Status + +accepted + +## Context + +A **Landlord Override** naming *"High heat retention storage heaters"* (HHRSH) had +no home in the heating taxonomy: `MainHeatingSystemType` carried +`ELECTRIC_STORAGE_OLD` (SAP 401), `_SLIMLINE` (402), `_CONVECTOR` (403) and +`_FAN` (404), but no High-Heat-Retention member. So the LLM classifier funnelled +the **best modern storage** into **`"Electric storage heaters, old"` (401)** — the +**worst** large-volume type. An audit found **111** `property_overrides` rows on +that mapping (`'Electric Storage Systems: High heat retention storage heaters'` → +`'Electric storage heaters, old'`). Same missing-archetype defect as ADR-0041. + +HHRSH is a distinct, modellable RdSAP system: **SAP Table 4a code 409** (high heat +retention storage heaters), which the calculator already scores +(`sap_efficiencies` carries `409: 1.00`). The **HHRSH *measure*** already installs +this exact code (`_HHR_STORAGE_SAP_CODE = 409`, +`domain/modelling/generators/heating_recommendation.py`) on a **Dual** off-peak +meter with **control 2404**. + +The subtlety is the **charge control**. `main_heating_system_overlay._control_for` +drags the **conservative manual** charge control (`2401`) for *every* storage code +401–409, following ADR-0041's amendment ("an *unobserved* storage charge control +defaults to the conservative manual one"). Table 4e Group 4's mean-internal- +temperature adjustments are **manual +0.7 °C, automatic/Celect +0.4 °C, HHR 0 °C** +— so a manual default carries a penalty. Applying it to a 409 heater would swap +"scored as old-storage 401" for "scored as 409-but-penalised" — still under- +crediting the 111 dwellings, and pairing 409 with manual control is not a +combination RdSAP produces. + +**HHRSH has a single, intrinsic charge control (2404).** The control is therefore +*named by the archetype*, not *unobserved* — unlike a generic storage heater whose +control genuinely is unknown. So ADR-0041's manual-default rule does not apply to +409. + +## Decision + +Add the baseline HHRSH archetype and let it drag its **coherent, intrinsic** +companions — mirroring how a gas-boiler archetype drags full modern controls +(`_control_for`) rather than a conservative default. + +1. **`MainHeatingSystemType.ELECTRIC_STORAGE_HIGH_HEAT_RETENTION` = + `"Electric storage heaters, high heat retention"`**, mapped in + `_MAIN_HEATING_CODES` to **SAP Table 4a code 409**. +2. **Control 2404 (HHRSH controls), not the manual default.** `_control_for` + special-cases 409 → `2404` ahead of the generic storage → `2401`. HHRSH's single + intrinsic control is archetype-implied, an **exception to ADR-0041's + unobserved-storage manual default** (which stands for 401–404). +3. **Electricity fuel (RdSAP 29).** `_natural_fuel_for` drags electricity for 409, + per ADR-0041's "storage → electricity" — so a mis-lodged non-electric fuel can't + leave an electric-storage dwelling incoherent. Scoped to 409 ("scoped per family + as archetypes land"); the pre-existing 401–404 fuel-drag gap is not touched here. +4. **Dual/off-peak meter falls out of the code** — 409 is already in + `_ASSUMED_DUAL_METER_CODES`, so the off-peak meter drags automatically (ADR-0035 + coherent companions). +5. **Deterministic guard + reclassify, TEXT-first / pgEnum-deferred.** A + `main_heating_guard` recognises the structured `"Electric Storage Systems: High + heat retention storage heaters"` phrasing (wired via `GuardedColumnClassifier`); + `scripts/reclassify_hhrsh_storage.py` reuses the same guard as its decision + function, updates `property_overrides.override_value` (TEXT) unconditionally, and + defers the `landlord_main_heating_system_overrides.value` pgEnum-cache write for + the new value. + +## Consequences + +- The 111 HHRSH dwellings score as **409 + HHR control 2404 + off-peak meter + + electricity** — the efficient modern archetype — instead of the worst old + large-volume 401. Correct Rebaselining (ADR-0039). +- **FE-owned pgEnum addition (Dan):** one new `main_heating_system` value, + `"Electric storage heaters, high heat retention"` (memory + `main-heating-system-pgenum-is-fe-owned`). TEXT read path is fixed on merge; the + cache write defers until the migration lands. +- **Baseline vs measure stay distinct but consistent:** both use code 409 + + control 2404; the measure additionally installs a designed HW cylinder / immersion + end-state, which a baseline (existing-system) override does not. +- Extends [ADR-0041](0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md) + (complete taxonomy; **refines** its manual-default amendment for the single- + control HHRSH case) and [ADR-0035](0035-coherent-heating-system-synthesis.md) + (companions drag from the code). + +### Alternatives rejected + +- **409 + manual control (2401), holding to ADR-0041's default.** Rejected: HHRSH's + control is intrinsic (single option), not unobserved; the +0.7 °C manual penalty + under-credits, and 409+manual is not a real RdSAP combination. +- **Reuse an existing storage archetype (401–404).** Rejected: none carries the + HHR retention/control — the whole point of the mis-score. +- **Leave fuel unresolved (as 401–404 do).** Rejected for 409: an explicit + electricity drag keeps the system coherent if the lodged fuel is wrong; the older + archetypes' gap is a separate, out-of-scope cleanup. diff --git a/docs/adr/0045-gas-cpsu-dumping-ground-splits-into-solid-fuel-and-electric-boiler-archetypes.md b/docs/adr/0045-gas-cpsu-dumping-ground-splits-into-solid-fuel-and-electric-boiler-archetypes.md new file mode 100644 index 000000000..da1bdad85 --- /dev/null +++ b/docs/adr/0045-gas-cpsu-dumping-ground-splits-into-solid-fuel-and-electric-boiler-archetypes.md @@ -0,0 +1,96 @@ +# The "Gas CPSU" dumping ground splits into solid-fuel room-heater and electric-boiler archetypes + +## Status + +accepted + +## Context + +ADR-0041 named **"Gas CPSU" (SAP Table 4a 120, mains gas)** as the original +garbage-drawer archetype the LLM classifier over-used when the heating taxonomy +lacked a correct target. An audit of every `main_heating_system` override still +resolving to `"Gas CPSU"` found it standing in for three *different*, unrelated +systems — a mains-gas CPSU is neither solid fuel nor electric, so every one of +these is mis-scored (wrong system **and**, for the electric ones, wrong fuel): + +| description | rows | true system | +| --- | --- | --- | +| Solid fuel room heaters: Open fire in grate | 16 | open fire (solid) | +| Solid fuel room heaters: Open fire with back boiler (no radiators) | 19 | open fire + back boiler | +| Solid fuel room heaters: Closed room heater with boiler (no radiators) | 2 | closed room heater + boiler | +| Boiler: A rated NA | 58 | **electric** boiler | +| Boiler: A rated CPSU | 1 | electric CPSU | +| Boiler: C rated CPSU | 1 | *genuine* gas CPSU — correct | + +**The electric boilers are the sharpest mis-score.** All 58 `"Boiler: A rated NA"` +rows carry a `main_fuel = electricity` override — "NA" is the Hyde boiler-type +value meaning "not a gas combi / regular / CPSU", i.e. an **electric boiler**. +Classified as Gas CPSU they are scored as a mains-gas stored-water unit. A +lodged-cert check showed **defer-to-lodged is not viable**: 34 of the 58 lodge +**no** main-heating code at all (fuel 0), 11 lodge electric *room heaters* (691), +a few lodge community heating — the very gap the landlord override exists to fill. +So the faithful move is to model the electric boiler the landlord names, informed +by the electricity fuel we hold, not to defer to an empty cert. + +The correct SAP Table 4a codes are exact (confirmed against +`domain/sap10_ml/sap_efficiencies.py`): **631** open fire (32%), **632** open fire ++ back boiler (50%), **634** closed room heater + boiler (65%); **191** +direct-acting electric boiler (100%). All are already scored by the calculator, +and 631/632/634 already sit in the overlay's `_SOLID_FUEL_ROOM_HEATER_CODES` set, +so their coherent companions (house-coal fuel 33, room-heater control 2601, +category 10) drag automatically (ADR-0035). + +## Decision + +Add the missing archetypes and reclassify the bucket. No calculator change. + +1. **Three solid-fuel room-heater archetypes** in `_MAIN_HEATING_CODES`: + `"Solid fuel room heater, open fire"` → 631, + `"Solid fuel room heater, open fire with back boiler"` → 632, + `"Solid fuel room heater, closed with boiler"` → 634. Companions drag from the + code (already-covered 631–636 set) — no overlay-logic change. +2. **`"Electric boiler"` → SAP 191** and **`"Electric CPSU"` → SAP 192**, both + dragging **electricity (29)** via `_natural_fuel_for` — electric boilers are + unambiguously electric (ADR-0041 "direct-acting/storage → electricity"), also + closing the pre-existing gap where 191/192 dragged no fuel. 191 stays on a + Single meter; 192 (electric CPSU) is already in `_ASSUMED_DUAL_METER_CODES`, so + it drags an off-peak Dual meter (§12 Rule 1, 10-hour). +3. **Deterministic `main_heating_guard` for the description-determined cases + + reclassify**, TEXT-first / pgEnum-deferred. The guard resolves the structured + phrasings by description alone: `"…open fire … back boiler"` → 632 (tested + before the bare `"open fire"` → 631), `"…closed … with boiler"` → 634, and + `"Boiler: NA"` → `"Electric boiler"` (the NA type is the electric + signal). `scripts/reclassify_main_heating.py` reuses the guard as its decision + function. +4. **Electric CPSU is fuel-determined, not description-determined.** `"Boiler: A/C + rated CPSU"` is identical bar the efficiency band, so the electric/gas split + cannot be read from the description — the guard cannot see it. The reclassify + therefore resolves it with a **fuel-aware, per-property** step (`electric_cpsu_ + for(description, main_fuel)`): a CPSU boiler whose property carries a + `main_fuel = electricity` override → `"Electric CPSU"` (192); a gas / unknown + fuel stays Gas CPSU (120). Only the **TEXT** layer is updated for it — the + description-keyed classifier cache cannot represent a fuel-dependent value. The + **live** guard stays fuel-blind, so a new electric-CPSU intake (rare — 1 row in + the corpus) falls to the LLM rather than being silently mis-guarded. + +## Consequences + +- 96 rows leave the Gas CPSU garbage drawer for faithful archetypes: 37 solid + fuel scored as solid fuel (not mains gas), 58 electric boilers + 1 electric CPSU + scored as electric (not a mains-gas CPSU); the 1 genuine gas CPSU stays on 120. + Correct Rebaselining (ADR-0039). +- **FE-owned pgEnum additions (Dan):** five new `main_heating_system` values — + `"Solid fuel room heater, open fire"`, `"Solid fuel room heater, open fire with + back boiler"`, `"Solid fuel room heater, closed with boiler"`, `"Electric + boiler"`, `"Electric CPSU"`. TEXT read path fixed on merge; cache writes defer to + the migration. +- Extends [ADR-0041](0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md) + (complete taxonomy — retiring the Gas CPSU garbage drawer it named) and + [ADR-0035](0035-coherent-heating-system-synthesis.md) (companions drag from the + code). + +### Out of scope + +- **Whether "NA" ever denotes a non-electric boiler.** It is 100% electric in the + corpus and the guard treats it as electric; a future non-electric "NA" would be + a re-open signal, not a silent mis-map (the fuel override would disagree). diff --git a/docs/adr/0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md b/docs/adr/0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md new file mode 100644 index 000000000..f6550c123 --- /dev/null +++ b/docs/adr/0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md @@ -0,0 +1,92 @@ +# Electric underfloor is scored by its own SAP codes; the meter defers to the cert when the tariff is ambiguous + +## Status + +accepted + +## Context + +Electric-underfloor `main_heating_system` overrides had no archetype, so the LLM +classifier funnelled them into the nearest wrong bucket (#1376): + +| description | rows | mis-mapped to | +| --- | --- | --- | +| Electric Underfloor Heating: In concrete slab (off-peak only) | 15 | Direct-acting electric (191), **Single** meter | +| Electric Underfloor Heating: In screed above insulation (standard or off peak) | 63 | Direct-acting electric (191), **Single** meter | +| Electric Underfloor Heating: Integrated (storage+direct-acting) (off peak) | 12 | Electric storage heaters, old (401) | + +The **meter is the crux**. A Landlord heating override drags an assumed meter from +its SAP code (ADR-0035), and the `keep_existing_off_peak_meter` mitigation +([overlay_applicator.py](../../domain/modelling/scoring/overlay_applicator.py)) +protects a cert's lodged off-peak meter — but *only when the overlay's own desired +meter is off-peak*. Direct-acting (191) is a **Single**-meter system, so the +mitigation never fires and the overlay **forces Single**, overwriting the cert's +meter. For off-peak underfloor that bills overnight heat at the peak rate and +craters SAP — the exact Class-A meter defect. + +SAP Table 4a has **dedicated electric-underfloor codes** (labels from +`cert_to_inputs.py`): **421** in concrete slab (off-peak only), **422** integrated +(storage+direct-acting), **424** in screed above insulation (423 integrated-low- +off-peak and 425 timber-floor are not in the corpus). Per SAP 10.2 §12, **421/422 +are off-peak (Rule 2, 7-hour → Dual)**; **424 is not off-peak-implying (Single)**. + +A lodged-meter audit is decisive for the screed case: **51 of 63 (81%)** of the +`"…in screed (standard or off peak)"` dwellings lodge an **off-peak** meter (24-hour +dual or 7/10-hour), the rest single. The description itself says "standard **or** +off peak" — so the archetype genuinely cannot know the tariff; the cert does. +Forcing 424/Single would downgrade 81%; forcing Dual would over-credit the ~19% on +single. + +## Decision + +Add three electric-underfloor archetypes mapping to their own SAP codes, and treat +the meter per what the description actually asserts. + +1. **New archetypes** in `_MAIN_HEATING_CODES`: + `"Electric underfloor, in concrete slab (off-peak)"` → **421**, + `"Electric underfloor, integrated storage and direct-acting"` → **422**, + `"Electric underfloor, in screed above insulation"` → **424**. +2. **Electricity fuel (29)** drags for 421/422/424 via `_natural_fuel_for` — electric + underfloor is unambiguously electric (ADR-0041), closing the prior no-fuel gap. +3. **421 and 422 assert an off-peak Dual meter.** Their descriptions positively + claim off-peak ("off-peak only" / "off peak"), and both are already in + `_ASSUMED_DUAL_METER_CODES`; the `keep_existing_off_peak_meter` mitigation keeps + a more-specific cert off-peak meter and applies Dual to a single-rate cert. +4. **424 (screed) defers the meter to the cert.** Because "standard or off peak" is + tariff-agnostic and 81% lodge off-peak, `_meter_for` returns **`None`** for 424 + (a new meter-agnostic set), so the overlay leaves `meter_type` unset and the + lodged meter stands — each dwelling scores on its actual tariff. This is a + **targeted exception to the "always set the meter, never let it bleed" + invariant**: that invariant guards against a switched-*off* storage system + leaving a stale Dual meter, but an underfloor override *confirms* underfloor — + there is no switch-off, so deferring is faithful, not a bleed. +5. **Deterministic `main_heating_guard` + reclassify** (TEXT-first, pgEnum-deferred), + reusing the generalized `reclassify_main_heating.py`. All three underfloor + descriptions are description-determined, so the guard resolves them directly (no + fuel-awareness needed) and the reclassify follows. + +## Consequences + +- The 15 concrete-slab-off-peak rows stop being billed at the peak rate (191/Single + → 421/Dual); the 12 integrated rows get the correct underfloor code (401 → 422, + meter already Dual); the 63 screed rows keep their lodged (81% off-peak) meter and + score on code 424 with correct screed responsiveness (Table 4d R=0.75). Correct + Rebaselining (ADR-0039). +- **FE-owned pgEnum additions (Dan):** three new `main_heating_system` values — + `"Electric underfloor, in concrete slab (off-peak)"`, `"Electric underfloor, + integrated storage and direct-acting"`, `"Electric underfloor, in screed above + insulation"`. TEXT read path fixed on merge; cache writes defer to the migration. +- `_meter_for` becomes `Optional[str]` (a code may defer the meter). Every existing + code keeps its current Single/Dual answer; only 424 returns `None`. +- Extends [ADR-0041](0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md) + and [ADR-0035](0035-coherent-heating-system-synthesis.md); refines the + `keep_existing_off_peak_meter` behaviour ADR-0035 introduced. + +### Alternatives rejected + +- **Force Single (424 per spec).** Rejected: downgrades the 81% lodged off-peak, + reproducing the meter bug. +- **Force Dual for screed.** Rejected: over-credits the ~19% genuinely on single and + asserts a tariff the "standard or off peak" description does not claim. +- **Map underfloor to Direct-acting (191) / old storage (401) as today.** Rejected: + wrong code (responsiveness/meter) and the source of the mis-score. diff --git a/docs/adr/0047-roof-eligibility-resolves-sentinel-thickness-by-age-band.md b/docs/adr/0047-roof-eligibility-resolves-sentinel-thickness-by-age-band.md new file mode 100644 index 000000000..b582f99c5 --- /dev/null +++ b/docs/adr/0047-roof-eligibility-resolves-sentinel-thickness-by-age-band.md @@ -0,0 +1,119 @@ +# Roof Eligibility Resolves Sentinel Thickness by Age Band (vaulted joins sloping; ND ≠ NI) + +Property 742265 (uprn 10033526327, cert 2780-3922-3202-6042-9200) lodges a +`Pitched (vaulted ceiling)` roof (gov-API `roof_construction` 5) with +`roof_insulation_thickness = "ND"` and got **no roof Recommendation**, leaving +its plan short of the scenario's goal band on an unlimited budget. Two +generator gaps compound (ADR-0021's dispatch table has no vaulted row, and its +uninsulated trigger `!= 0` compares the `"ND"` *string* against int 0, so it +never fires), while the SAP10 Calculation already handles both: it treats +vaulted as a slope-following ceiling and resolves the `"ND"` sentinel via the +roof description / age-band defaults (`heat_transmission.py` — the +`is_sloping_ceiling` disjunction and the raw-sentinel inspection). The +generator diverged from the calculator — the exact split ADR-0021 set out to +prevent. + +## Decision + +**Amends ADR-0021.** The roof Recommendation Generator changes in two ways; +the mapper and the calculator change in none. + +**1. Vaulted is a sloping ceiling.** The dispatch matches the same substring +disjunction the calculator uses (`"sloping ceiling" in roof_type or "vaulted" +in roof_type`): a vaulted ceiling has no loft void, so its one applicable +Measure is `sloping_ceiling_insulation` (rafters, 100 mm) — never the loft +fallback. ADR-0021's dispatch table gains the row: + +| Roof type (`roof_construction_type` substring) | Measure | Uninsulated trigger | Overlay | +|---|---|---|---| +| `sloping ceiling` **or `vaulted`** | `sloping_ceiling_insulation` | resolved thickness 0 (below) | → 100 mm | + +**2. Sentinel-aware uninsulated trigger, resolved by age band — codes, not +descriptions.** `roof_insulation_thickness` is `Optional[Union[str, int]]`; a +lodged string sentinel is data, not noise. The generator resolves eligibility +for the two pitched branches (sloping/vaulted and loft) as: + +| Lodged value | Meaning (RdSAP 10) | Eligibility | +|---|---|---| +| int `0` | explicitly uninsulated | eligible (unchanged) | +| int `> 0` | measured thickness | not eligible (unchanged) | +| `"NI"` | insulation **present**, thickness unknown (§5.11.4: assume 50 mm) | **not eligible** | +| `"ND"` / `None` | not defined — resolve to the age band's **as-built** state | eligible iff as-built uninsulated (bands **A–D**) | + +Age-band as-built states (construction age code): **A–D uninsulated · E–F +limited insulation · G+ insulated.** Only A–D resolves to eligible. This +extends the codebase's existing precedent (`_PRE_1950_AGE_CODES = {A,B,C,D}` +in the mapper's code-8 rule; pinned by cert 001479 Ext2 at U=2.30) from the +scoring path to eligibility. + +**Sentinel resolution requires a KNOWN pitched/thatched roof type.** An +unlodged `roof_construction_type` reaching the loft fallback can be a **party +ceiling** — gov-API codes 6/7 ("(another dwelling above)" / thatch with +additional insulation) map the type to `None`, and real code-7 certs lodge +`"ND"` — with zero roof heat loss (RdSAP 10 Table 18), so insulating it +contradicts the dwelling. An unlodged type therefore keeps the explicit-0-only +trigger (the pre-ADR behaviour for that shape); only a type containing +`pitched`/`thatch` (or the sloping/vaulted branch, known by construction) +resolves sentinels by age band. + +**Coherence rule (load-bearing):** sentinel resolution must never make an +undefined cert *more* eligible than an explicitly-lodged one. An explicit +50 mm lodgement is not eligible today, so `"ND"` on a band-E/F ("limited +insulation") dwelling must also resolve to not-eligible — recommending a +top-up to the dwelling whose insulation we *don't* know while withholding it +from the identical dwelling whose thin insulation we *do* know would be +incoherent. Whether limited-insulation **top-up** should be a Measure at all +is a separate product question that applies equally to explicit thin +lodgements; it is out of scope here (see Consequences). + +**The mapper stays untouched — the `"ND"` passthrough is load-bearing.** The +calculator inspects the *raw* lodgement to distinguish explicit `int(0)` +(drop the description; uninsulated) from `"NI"` (keep the description; §5.11.4 +retrofit-50 mm) from `"ND"` (defer to description / age default). Normalising +sentinels at the mapper boundary would change scoring inputs for every ND-roof +cert in the corpus for zero eligibility benefit. + +**Two homes for sentinel resolution, deliberately.** Scoring asks "what +thickness should the U-cascade see" (`NI` → 50 mm); eligibility asks "is this +roof a candidate" (`NI` → no). Same sentinel, different questions, different +answers — a single shared resolver would just relocate the split into its call +sites. Each home carries a regression test pinning the *disagreement* as +intentional. Consolidate only if a third consumer appears (rule of three). + +## Considered options + +- **Normalise `ND` → 0 in the mapper** (extend the code-8 rule to code 5 and + to the `"ND"` string). Rejected: changes calculator inputs corpus-wide — + `roof_thickness_explicitly_zero` would fire and drop the roof description + the §5.11 resolution depends on — risking the pinned Elmhurst accuracy + corpus to fix an eligibility bug. +- **Defer to `roofs[].description`** ("no insulation (assumed)") for ND + resolution, mirroring the calculator exactly. Rejected: eligibility should + key on codes, not free-text description parsing; descriptions drift by + lodgement software and the age-band code carries the same as-built signal + deterministically. +- **Gate eligibility on the calculator's effective roof U-value.** Rejected: + no other generator gates on scored output (walls/floors gate on lodged + attributes), and it re-couples eligibility to scoring against ADR-0016's + separation. +- **Treat `"ND"` alone as uninsulated** (sentinel-literal). Rejected: ND means + *not defined*, not *none* — a band-G ND roof is almost certainly insulated + as built and would get a junk Recommendation. + +## Consequences + +- 742265 (band B, vaulted, ND) becomes eligible: `sloping_ceiling_insulation` + over 55.82 m² (~£2.2k, verified by local re-model). Any pitched-loft cert + lodging `"ND"` with age band A–D is likewise un-withheld. +- The **flat branch is deliberately untouched**: its inverted convention + (`None` = uninsulated, per the Elmhurst mapping) means an `"ND"` flat roof + is *not eligible* today and stays so — a separately-verifiable follow-up, + not silently folded into this change. +- **Open follow-up (product):** should limited-insulation dwellings (explicit + thin lodgements *and* ND + bands E–F alike) be offered an insulation + top-up? Today neither is; changing that is a trigger redesign, not a + sentinel question. +- ADR-0021's "one dispatching generator, one Measure per roof" and its + dispatch order (sloping → flat → no-access → loft) are unchanged; the + sloping test simply matches one more substring, ahead of the fallbacks as + before. diff --git a/docs/adr/0048-heating-override-forces-category-and-control-defers-fuel-and-meter.md b/docs/adr/0048-heating-override-forces-category-and-control-defers-fuel-and-meter.md new file mode 100644 index 000000000..5b2a1870f --- /dev/null +++ b/docs/adr/0048-heating-override-forces-category-and-control-defers-fuel-and-meter.md @@ -0,0 +1,132 @@ +# A system-replacing heating override forces category and control for coherence, and defers fuel and meter where multiple values are valid + +## Status + +accepted + +## Context + +A **Landlord Override** naming a main heating system resolves to a SAP Table 4a +code, and `main_heating_system_overlay` synthesises the **coherent companions** +that code implies (ADR-0035): heating category, charge control, natural fuel, +meter. The overlay is applied over the lodged cert by last-wins composition, so a +companion the overlay leaves **unset (`None`)** is *inherited from the replaced +system* — not cleared. + +That inheritance is a silent mis-rating when the archetype genuinely determines +the companion. A portfolio-796 audit found the case end-to-end on **86+ +properties**: a lodged **slimline storage heater** (SAP 402, category 7, control +2401, off-peak) overridden to **"Electric room heaters"** (SAP 691). The overlay +stamped code 691, fuel, and a Dual meter — but `_category_for(691)` and +`_control_for(691)` both returned `None`, because their room-heater branch keyed +on `_CATEGORY_10_ROOM_HEATER_CODES`, a set that held only the *fuel-burning* +(gas/oil/solid) room heaters and **excluded electric 691–701** despite electric +room heaters also being Table 4a **Category 10**. + +Two independent mis-ratings followed from the surviving storage companions: + +1. **Category 7 survived.** The SAP 10.2 Table 12a resolver keys on category + first, read `OTHER_STORAGE_HEATERS`, and — with the dwelling on the §12 Rule 3 + 10-hour tariff — hit `(OTHER_STORAGE_HEATERS, TEN_HOUR)`, a combination the + Grid-1 table does not carry, so it fell to the all-low-rate fallback. The + peaky room heaters were billed **100% at the night rate** — the precise + "all-low over-credit" the overlay's own Dual-meter comment claims to avoid. + Effect: baseline SAP over-rated **64.1 vs the spec-correct 56.4**; the HHRSH + upgrade measure showed a **negative** bill saving (−£13/yr) because the + baseline it was compared against was itself under-billed. +2. **Control 2401 survived.** `main_heating_control` fed Table 4e as the storage + **manual charge control → +0.7 °C** mean-internal-temperature adjustment — the + largest in the table — riding on a direct-acting room heater, which should + carry the Group 6 room-heater control **2601 → +0.3 °C**. + +The companions are **not uniform** in how strongly the archetype determines +them, which is the crux of the decision: + +- **Category is deterministic** — SAP Table 4a assigns exactly one category per + code (gas→2, heat pump→4, network→6, storage→7, **electric underfloor→8**, + warm-air→9, **room heater→10**). A `None` category is never valid. +- **Control is an assumption but always archetype-supplied** — the Table 4e + *Group* is fixed by category; the specific conservative default within the + group is a modelling choice (storage→manual 2401, HHRSH→2404 per + [ADR-0044](0044-hhrsh-baseline-archetype-drags-its-intrinsic-controls.md), + room heater→2601, gas boiler→full controls). It is never "inherit the replaced + system". +- **Fuel is sometimes genuinely ambiguous** — electric/gas/storage/heat-pump + archetypes pin their fuel, but **community heating** could be gas CHP, biomass, + or waste heat, so its fuel legitimately **defers** to a `main_fuel` override, + else the lodged value (ADR-0041 last-wins). +- **Meter is physics-locked for some systems, flexible for others** — storage / + CPSU / HHRSH **require** Dual (they charge overnight); non-electric systems are + stamped Single to stop an Economy-7 split bleeding onto a gas boiler + ([ADR-0035](0035-coherent-heating-system-synthesis.md)); screed underfloor + defers to the cert ([ADR-0046](0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md)). + +## Decision + +A system-replacing heating override must emit a **complete, coherent companion +set**. Companions split by whether the archetype *determines* the value: + +1. **Category and control are archetype-forced** — always written, never + inherited, because a wrong value mis-scores or mis-bills the cert. Electric + room heaters (Table 4a 691–701) join the Category-10 room-heater branch of + both `_category_for` (→ category **10**) and `_control_for` (→ control + **2601**, the Group 6 conservative no-thermostat default, matching the + gas/oil/solid siblings). Their fuel (electricity 29) and Dual meter were + already correct and are unchanged. + +2. **Fuel and meter defer where multiple values are coherent** — this is *not* + a gap. Fuel defers only for genuinely ambiguous carriers (community heating); + meter defers only for meter-flexible systems (room heaters run coherently on + either meter; screed underfloor per ADR-0046). Meter-locked systems still + force (storage/CPSU/HHRSH → Dual; non-electric → Single). + +3. **An incomplete companion set is logged, not raised (for now).** Where the + overlay cannot yet supply a confident category or control — heat pumps + (Group 2), community (Group 3), electric underfloor (Group 7), electric + boiler / CPSU (Group 1) — `main_heating_overlay_for` **logs an error** naming + the archetype and code and continues, matching the `flag_fuel_mismatch` + log-not-raise convention. Log-and-continue keeps a mid-portfolio run alive + and surfaces the gap (CloudWatch) for review, rather than turning a latent + inheritance bug into an outage. The intended end-state is to fill each + archetype's category/control with spec sight and then **flip the log to a + raise**, so the coherence invariant is enforced. + +Category expansion for the still-`None` archetypes (electric boiler/CPSU→2, +underfloor→8) is deterministic from Table 4a but **deferred** to its own change: +it flows into the category-keyed Table 12a resolver and needs a corpus check, so +it does not ride on the room-heater fix. + +## Consequences + +- The 86+ room-heater dwellings score against a correctly-billed baseline: the + space-heating high-rate fraction is the spec **0.50** (10-hour direct-acting), + not 0.0; baseline SAP corrects **64.1 → 56.4**; the HHRSH measure's bill saving + flips **−£13/yr → +£235/yr** (property 710788, uprn 22003310, scenario 1268). +- The `refetch_solar`-corrected portfolio's `negative-bill-savings` audit group + loses its ~86-property HHRSH-on-room-heater cluster after re-model. +- The overlay now emits a visible error for any archetype it cannot fully + companion — a standing signal (wire a CloudWatch metric filter on the log + string) for which control default to fill next. +- Extends [ADR-0035](0035-coherent-heating-system-synthesis.md) (companions drag + from the code) and [ADR-0041](0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md) + (complete taxonomy); sits beside + [ADR-0044](0044-hhrsh-baseline-archetype-drags-its-intrinsic-controls.md) + (a control named by the archetype) and + [ADR-0046](0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md) + (meter deferred where the tariff is ambiguous). + +### Alternatives rejected + +- **Make every companion total (force fuel and meter too).** Rejected: it breaks + the deliberate deferrals — community-heating fuel (ambiguous carrier) and the + ADR-0046 screed-underfloor meter — whose `None` is a load-bearing "defer to the + cert/override" signal, not a gap. +- **Fix the Table 12a resolver to prefer the SAP code over a stale category.** + A sound defence-in-depth follow-up, but it treats the symptom; the overlay + emitting an incoherent cert is the root cause, and category/control coherence + must hold for every consumer (predicted EPCs, raw lodged certs), not only the + billing resolver. +- **Raise on an incomplete companion set now.** Rejected for this change: heat + pump / community / underfloor overrides model today (by luck of inheritance), + so an immediate raise is a blast-radius change mid-campaign. Log now, raise once + the common archetypes are filled. diff --git a/docs/adr/0049-ashp-sized-to-the-dwelling-design-heat-loss.md b/docs/adr/0049-ashp-sized-to-the-dwelling-design-heat-loss.md new file mode 100644 index 000000000..ce53044a7 --- /dev/null +++ b/docs/adr/0049-ashp-sized-to-the-dwelling-design-heat-loss.md @@ -0,0 +1,98 @@ +# The ASHP recommendation sizes the heat pump to the dwelling's design heat loss + +## Status + +accepted + +## Context + +The ASHP measure (ADR-0024) installed **one fixed heat pump** — a 4.37 kW Vaillant +aroTHERM plus (PCDB Table 362 record 110257) — on every eligible dwelling, as a +"representative, contractor-installable" end-state. SAP 10.2 Appendix N reads a +heat pump's efficiency at the dwelling's **PSR** (Power-to-Space-heating-Ratio = +rated output / design heat loss): efficiency peaks around PSR 0.8 and, per +footnote 44, **collapses toward 100% below the record's smallest PSR**. + +A portfolio-796 audit found the fixed pump scoring the ASHP **below the baseline** +on high-heat-loss dwellings. Property 712794 (uprn 10002468116): a leaky dwelling +with a **17.5 kW design heat loss**, so a 4.37 kW pump lands at **PSR 0.25** — the +efficiency collapse zone. The calculator correctly applied ~195% SPF; combined +with pricing electricity at the standard tariff against an off-peak storage +baseline, the ASHP scored **SAP 10.33 vs the baseline 11.96 (−1.63)** and the +Optimiser never selected it. Every electric-heated dwelling too leaky for a 4.37 kW +pump was capped below the goal band by this — a modelling defect, not a calculator +one (SAP's PSR curve is correct: a grossly undersized heat pump *is* inefficient). + +The fix needs the dwelling's design heat loss, which the calculator computes +internally (annual-average HLC × the SAP design temperature difference, 24.2 K) +but did not surface. + +Sizing raised a second question the accredited data answered. MCS capacity sizing +targets **PSR ≈ 1.0** (output ≥ design heat loss). But the **relodged Elmhurst ASHP +cert** — a real dwelling our calculator gives a 6.40 kW design heat loss — was +fitted with the **5 kW** Vaillant (110257, 4.37 kW output), i.e. **PSR 0.68**, and +scores SAP 72.08. A PSR-1.0 rule picks the 7 kW pump for that dwelling → SAP 73.86, +**oversizing by a rung and inflating SAP by ~1.8**, and no longer reproduces the +accredited cert. SAP efficiency at PSR 0.68 and 1.0 is near-identical (~400%), so +the real installer's smaller, cheaper choice is the realistic one. + +## Decision + +Size the heat pump to the dwelling and expose what sizing needs: + +1. **`SapResult.design_heat_loss_kw`** — the calculator surfaces the design heat + loss (annual-average HLC × 24.2 K / 1000), the quantity Appendix N's PSR + divides the pump output by. + +2. **A curated aroTHERM plus ladder** — one representative PCDB record per nominal + size across the domestic range (`_ASHP_SIZING_LADDER`): 3.5 kW (110249), 5 kW + (110257, the prior anchor), 7 kW (110265), 10 kW (110273), 12 kW (110281). All + the same "& AI VIH RW" variant, so the ladder is a consistent product line. + +3. **Size to the Appendix-N efficiency peak, not MCS capacity.** + `select_ashp_pcdb_id` picks the rung whose rated output is nearest **0.8 × + design heat loss** (PSR ≈ 0.8, the efficiency peak). This reproduces the pump a + real installer fits — validated against the relodged Elmhurst cert at delta 0 — + and keeps the pump clear of the low-PSR collapse. A load beyond the 12 kW range + naturally selects the largest rung (an honest, capped undersize; those + dwellings need fabric-first). + +4. **Cost and efficiency share one figure.** `ashp_cost_inputs` takes the same + design heat loss (over the floor-area proxy), so the rate-sheet install band + (ADR-0025) and the pump's efficiency record are sized from one number. The + orchestrator reads `design_heat_loss_kw` off a baseline score and threads it + into the heating generator; the harness computes it via a calculator pass. + +## Consequences + +- Property 712794's ASHP goes from **−1.63 SAP (never selected)** to **+44.2 SAP + (selected)**; its plan lifts **G (12.0) → B (89.1)**. Electric-heated dwellings + previously capped below goal C by the undersized pump can now reach it — the + largest HIGH-severity mover on the portfolio-796 `plan-stops-short-of-goal` and + `plan-stuck-in-low-band` groups. +- The accredited relodged Elmhurst ASHP cert still reproduces at **delta 0** (the + 6.40 kW dwelling sizes to the 5 kW Vaillant it was actually fitted with). Two + self-snapshot cascade pins re-pin to their correctly-sized larger pumps (both + dwellings were undersized by the fixed unit, so their end-state SAP rises). +- Adding `design_heat_loss_kw` to `SapResult` is a required field; manual + constructions (test stubs) carry it. +- Extends [ADR-0024](0024-ashp-bundle-absolute-end-state.md) (the ASHP end-state) + and [ADR-0025](0025-ashp-cost-composed-from-the-rate-sheet.md) (size-banded + cost); the cost bands already scaled — they now receive the real heat loss. + +### Alternatives rejected + +- **MCS capacity sizing (PSR ≈ 1.0, output ≥ design heat loss).** Rejected: it + oversizes relative to real installs, inflating SAP ~1.8 per dwelling and + breaking the accredited-cert reproduction. SAP efficiency is equivalent at + PSR 0.68–1.0, so the realistic (installer) size is preferred. +- **Keep the fixed 4.37 kW pump.** Rejected: the collapse on high-heat-loss + dwellings is the defect under repair. +- **Fabric-first sequencing (score the ASHP only after insulation).** A real + concern — the Optimiser scores each measure independently against the + un-insulated baseline (ADR-0016) — but a separate change to measure scoring/ + sequencing, not pump sizing. Sizing the pump fixes the standalone score + regardless; noted for follow-up. +- **Nearest record from all ~10,000 Table 362 heat pumps.** Rejected: an opaque, + unauditable selection; a curated single-product ladder keeps ADR-0024's + "representative, contractor-installable" intent. diff --git a/docs/adr/0050-electric-underfloor-override-drags-category-8-and-conservative-group-7-control.md b/docs/adr/0050-electric-underfloor-override-drags-category-8-and-conservative-group-7-control.md new file mode 100644 index 000000000..1ed2b536c --- /dev/null +++ b/docs/adr/0050-electric-underfloor-override-drags-category-8-and-conservative-group-7-control.md @@ -0,0 +1,84 @@ +# An electric-underfloor override drags category 8 and the conservative Group 7 control + +## Status + +accepted + +## Context + +[ADR-0048](0048-heating-override-forces-category-and-control-defers-fuel-and-meter.md) +established that a system-replacing heating override must emit a **complete, +coherent companion set** — category and control are archetype-forced, never +inherited — and explicitly deferred the category expansion for the still-`None` +archetypes ("electric boiler/CPSU→2, underfloor→8 … flows into the +category-keyed Table 12a resolver and needs a corpus check") to its own change. + +The portfolio-796 audit (PRD #1435, WS2) found the underfloor half of that gap +end-to-end on property **711795** (uprn 10090343115): a lodged **community +heating** cert (SAP 301, category 6, control 2311) overridden to *"Electric +underfloor, in screed above insulation"* (SAP 424, +[ADR-0046](0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md)). +`_category_for(424)` and `_control_for(424)` both returned `None`, so the +effective cert kept the replaced heat network's **category 6** and community +control. Two independent mis-ratings followed: + +1. **No heating pathway.** `is_heat_network_main` keys on category 6, so the + heating Recommendation Generator excluded both HHRSH and ASHP by the + heat-network topology rule — `recommend_heating` returned `None` and the + flat's whole candidate menu was a no-op wall measure plus lighting. The + dwelling was unliftable from band E (the `plan-stuck-in-low-band` audit + group counts ~7 electric-underfloor dwellings). +2. **Baseline mis-rated.** The calculator scored the electric underfloor as + community heating: baseline SAP **51 (E)** vs **67 (D)** with coherent + companions. With the pathway restored, HHRSH scores **80 (C)** for ~£1k — + the dwelling reaches the scenario goal. + +## Decision + +The three electric-underfloor archetypes (SAP Table 4a **421 / 422 / 424**, +ADR-0046) join the forced-companion branches of `main_heating_system_overlay`: + +1. **Category 8** ("Electric underfloor heating") in `_category_for` — + deterministic from SAP Table 4a, per ADR-0048's category rule. +2. **Control 2701** (SAP Table 4e **Group 7**, "no time or thermostatic control + of room temperature", **+0.3 °C**) in `_control_for` — the conservative + default within the group the category fixes: the largest mean-internal- + temperature adjustment in Group 7, so an unobserved control is never + over-credited. Mirrors the storage manual-charge 2401 + ([ADR-0035](0035-coherent-heating-system-synthesis.md)) and room-heater 2601 + (ADR-0048) precedents. + +Fuel (electricity 29) and meter (Dual for 421/422; **deferred to the cert** for +tariff-ambiguous 424) are already correct per ADR-0046 and are unchanged. + +**Scope**: underfloor only. Electric boiler / CPSU (Group 1) remain +log-and-continue gaps tracked by #1444 — their category (2) feeds the wet-boiler +fallback classification in the generators and needs its own check. + +## Consequences + +- An underfloor override on any lodged system now yields a coherent cert: the + Table 12a resolver and the heat-network gates read the dwelling as electric + underfloor, the baseline re-rates correctly, and the HHRSH bundle is offered + where eligible (a flat's only heating pathway — ASHP stays house/bungalow-only + per ADR-0024). +- The "incomplete companion set" error log no longer fires for 421/422/424. +- **Known deferred gap (pre-existing, unchanged by this ADR):** the Table 12a + space-heating resolver's underfloor rows are wired in the fraction table but + `_table_12a_system_for_main` never returns `UNDERFLOOR_HEATING` ("Underfloor + heating (421-422) — TODO"), so an off-peak-metered underfloor dwelling falls + to the all-low-rate fallback. That affects genuinely-lodged underfloor certs + identically and needs spec sight (SAP 10.2 Table 12a Grid 1, PDF p.191) to + split slab (storage-like) from screed (direct-acting-like); tracked as a + follow-up, not ridden on this override fix. + +### Alternatives rejected + +- **A zero-adjustment Group 7 control (2703/2704).** Rejected: credits a room + thermostat the landlord never reported; the conservative default is the + ADR-0035/0048 pattern. +- **Completing electric boiler / CPSU (category 2) in the same change.** + Rejected: no evidence property in this workstream, and category 2 flows into + the generators' wet-boiler fallback (`_WET_BOILER_CATEGORIES`), which would + make an electric-boiler override newly eligible for tune-up options — a + behaviour change needing its own corpus check (#1444). diff --git a/docs/adr/0051-wall-insulation-offered-only-when-it-lowers-the-derived-wall-u-value.md b/docs/adr/0051-wall-insulation-offered-only-when-it-lowers-the-derived-wall-u-value.md new file mode 100644 index 000000000..8b63c4f0d --- /dev/null +++ b/docs/adr/0051-wall-insulation-offered-only-when-it-lowers-the-derived-wall-u-value.md @@ -0,0 +1,76 @@ +# Wall insulation is offered only when it lowers the derived wall U-value + +## Status + +accepted + +## Context + +The wall Recommendation Generators trigger on the wall's **insulation state** +alone: `recommend_cavity_wall` offers cavity fill for any cavity wall whose +`wall_insulation_type` is as-built (4), and `recommend_solid_wall` offers +EWI/IWI for any as-built suitable construction +([ADR-0019](0019-solid-wall-insulation-options.md)). Neither asks whether +adding insulation can actually **change the modelled wall**. + +For modern construction age bands it cannot: RdSAP's Table 6-10 cascade +(`u_wall` in `domain/sap10_ml/rdsap_uvalues.py`) assigns an as-built wall its +age band's at-regs U-value, and the post-measure state resolves to the **same +value**. Portfolio-796 evidence (PRD #1435, WS2): property **711795**, timber +frame at age band **L** (2012–2022) — `u_wall` returns **0.280 W/m²K both +as-built and with 100 mm IWI**, so the persisted plan offered +`internal_wall_insulation` at **£4,027 for +0.00 SAP**. Any modern-age-band +dwelling gets the same no-op fabric offer; on an unliftable dwelling it is the +*entire* remedial menu, which reads as "we recommend spending £4k for nothing". + +Scoring already prices the option at +0.00, but the Optimiser keeps +zero-uplift candidates in the persisted menu (they are legitimate for some +measure types, e.g. secondary-heating removal saves bills without SAP), so the +fix belongs at **generation eligibility**, not a blanket score filter. + +## Decision + +Both wall generators consult the **derived wall U-value** before offering: the +measure is offered only when the post-measure state's `u_wall` is **lower than +the current state's by more than 0.01 W/m²K** (a float-noise guard, not a +materiality threshold — economics stay with the Optimiser, ADR-0016/0024). + +- **Current state**: `u_wall` with the effective main wall's construction, age + band, lodged insulation type/thickness — a thin parameter mapping in the + `envelope.py` style, deliberately omitting the calculator's rich extras + (surveyor description, dry-lining, documentary-thickness routes). Omissions + bias the gate toward **offering** (both sides are computed identically, so a + no-op still compares equal), never toward suppressing a real measure. +- **Post-measure state**: the same call with the Option's overlay applied — + filled cavity (`wall_insulation_type=2`) for cavity fill; 100 mm + internal/external insulation for IWI/EWI. + +## Consequences + +- Age-band-L (and any at-regs age band) walls stop polluting candidate menus + with £4k no-ops; genuinely treatable walls (e.g. age-band-D solid brick, + U 2.1 → ~0.3) are offered exactly as before. +- The generators gain a read on `domain/sap10_ml/rdsap_uvalues.u_wall` — same + precedent as the heating generator reading the calculator's PCDB/Table 4b. +- **Deferred:** (a) a cert that lodges an explicit `wall_u_value` overrides the + cascade in the calculator, making fabric measures score +0.00 regardless of + this gate — the measure overlay should adjust the lodged U (probed on + portfolio 796: 0/300 sampled certs lodge one, so no live evidence yet); + (b) roof/floor generators have the same anti-pattern in principle (roof + age-band eligibility is partly covered by ADR-0047). **Amended:** the floor + arm landed as `floor_u_value_gate` (probed live: solid_floor_insulation at + £3-4k for +0.00 on age-band-L dwellings 709782/709790); a 40-dwelling + band-K/L probe found no roof no-ops, so the roof arm stays unimplemented + until evidence appears. + +### Alternatives rejected + +- **Filter zero-SAP candidates after scoring.** Rejected: zero-uplift options + are legitimate for some measure types (bill-driven), and the pollution would + survive in any consumer that reads Generator output before scoring. +- **Hard-code "age band ≥ K ⇒ no wall offer".** Rejected: duplicates the U + tables coarsely; the tables already encode exactly which + construction × age-band × insulation combinations move. +- **Full-fidelity U parity with the calculator (description, dry-lining, + lodged U).** Rejected for now: heavier coupling for no observed benefit — + the omissions only ever bias toward offering, which is the status quo. diff --git a/docs/migrations/property-baseline-performance-table.md b/docs/migrations/property-baseline-performance-table.md index 0aebba832..efd1ec10b 100644 --- a/docs/migrations/property-baseline-performance-table.md +++ b/docs/migrations/property-baseline-performance-table.md @@ -18,10 +18,10 @@ straight lift-and-shift of the columns below. |---|---|---| | `id` | serial PK | | | `property_id` | int, FK → `property.id`, **unique** | one Baseline Performance per Property | -| `lodged_sap_score` | int | Lodged Performance — gov register, off the Effective EPC | -| `lodged_epc_band` | text | the `Epc` enum, stored as its string value (e.g. `"C"`) | -| `lodged_co2_emissions_t_per_yr` | float | tonnes CO₂/yr (whole dwelling) | -| `lodged_primary_energy_intensity_kwh_per_m2_yr` | int | PEUI (kWh/m²/yr); **not** "heat demand" — see CONTEXT.md | +| `lodged_sap_score` | int, **nullable** | Lodged Performance — gov register, off the Effective EPC. **NULL for a predicted Property** (no lodged cert — #1361, see below) | +| `lodged_epc_band` | text, **nullable** | the `Epc` enum, stored as its string value (e.g. `"C"`) | +| `lodged_co2_emissions_t_per_yr` | float, **nullable** | tonnes CO₂/yr (whole dwelling) | +| `lodged_primary_energy_intensity_kwh_per_m2_yr` | int, **nullable** | PEUI (kWh/m²/yr); **not** "heat demand" — see CONTEXT.md | | `effective_sap_score` | int | Effective Performance — what modelling scored against | | `effective_epc_band` | text | | | `effective_co2_emissions_t_per_yr` | float | tonnes CO₂/yr (whole dwelling) | @@ -30,6 +30,21 @@ straight lift-and-shift of the columns below. | `space_heating_kwh` | float | EPC `renewable_heat_incentive` recorded demand. **Superseded** by `heating_kwh` (delivered) when the bill block populates; kept until then to avoid an empty-kWh gap, dropped in the population slice. | | `water_heating_kwh` | float | EPC `renewable_heat_incentive`; **superseded** by `hot_water_kwh`. | +### Lodged half is nullable (#1361 Class B, 2026-06-30) + +The four `lodged_*` columns are **nullable**. A **predicted** Property (EPC Prediction, ADR-0029/0031: +no lodged cert, its `EpcPropertyData` synthesised from neighbours) has **no Lodged Performance** — the +backend now writes `lodged_* = NULL` for it and persists only the Effective half (ADR-0004 amendment). +Reading the synthesised EPC's recorded fields as a lodged figure had been manufacturing a phantom (a +neighbour's SAP), which the migration + a one-time backfill remove. + +**FE-owned Drizzle migration required:** `ALTER TABLE property_baseline_performance ALTER COLUMN +lodged_sap_score DROP NOT NULL` (and the same for `lodged_epc_band`, `lodged_co2_emissions_t_per_yr`, +`lodged_primary_energy_intensity_kwh_per_m2_yr`). This **must land before** the backend deploys the +orchestrator change or runs `scripts/null_predicted_lodged_performance.py` — a `NULL` write against a +`NOT NULL` column aborts the batch (ADR-0012). The backfill NULLs the four columns on the ~12,236 +existing predicted-source rows; Effective, the bill block, and `rebaseline_reason` are left intact. + ### Bill block (ADR-0014) — the energy bill, composed per section Produced by **Bill Derivation**: the calculator's **delivered** kWh per end use priced at current diff --git a/domain/abri/__init__.py b/domain/abri/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/domain/abri/descriptions.py b/domain/abri/descriptions.py new file mode 100644 index 000000000..3a422e55f --- /dev/null +++ b/domain/abri/descriptions.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class JobDescriptions: + short_description: str + long_description: str + + +def build_job_descriptions(deal_name: str) -> JobDescriptions: + # Placeholder copy — final wording is owned by Abri's schedulers (issue #1455). + return JobDescriptions( + short_description=f"Domna condition survey - {deal_name}", + long_description=( + f"Domna condition survey visit at {deal_name}, " + "booked by Domna operations." + ), + ) diff --git a/domain/abri/models.py b/domain/abri/models.py new file mode 100644 index 000000000..e160c3e3b --- /dev/null +++ b/domain/abri/models.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from datetime import date +from typing import Literal, NewType, Union + +PlaceRef = NewType("PlaceRef", str) + +SlotCode = Literal["AM", "PM", "AD"] + + +@dataclass(frozen=True) +class LogJobRequest: + client_ref: str + place_ref: PlaceRef + appointment_date: date + appointment_time: SlotCode + short_description: str + long_description: str + + +@dataclass(frozen=True) +class JobLogged: + job_no: str + logged_info: str + + +@dataclass(frozen=True) +class AbriRequestRejected: + code: str + message: str + + +LogJobResult = Union[JobLogged, AbriRequestRejected] diff --git a/domain/abri/slots.py b/domain/abri/slots.py new file mode 100644 index 000000000..992426ee9 --- /dev/null +++ b/domain/abri/slots.py @@ -0,0 +1,13 @@ +from datetime import datetime, time +from typing import Optional + +from domain.abri.models import SlotCode + +_MIDDAY = time(12, 0) + + +def slot_for_confirmed_time(confirmed_time: Optional[str]) -> SlotCode: + if confirmed_time is None or confirmed_time.strip() == "": + return "AD" + parsed = datetime.strptime(confirmed_time.strip(), "%H:%M").time() + return "AM" if parsed < _MIDDAY else "PM" diff --git a/domain/data_transformation/guarded_column_classifier.py b/domain/data_transformation/guarded_column_classifier.py new file mode 100644 index 000000000..f84ab6ac2 --- /dev/null +++ b/domain/data_transformation/guarded_column_classifier.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from enum import Enum +from typing import Callable, Optional, TypeVar + +from domain.data_transformation.column_classifier import ColumnClassifier + +E = TypeVar("E", bound=Enum) + + +class GuardedColumnClassifier(ColumnClassifier[E]): + """A ``ColumnClassifier`` that resolves the descriptions a deterministic guard + is certain about, and delegates the rest to a fallback classifier. + + The ``guard`` maps a raw description to a category member when it recognises it + deterministically (e.g. a party-ceiling roof marker — #1376), else ``None``. + Guard hits never reach the fallback, so an unreliable classifier (the LLM) + cannot override a description the guard is sure of — and the LLM is not billed + for it. Every description still appears in the result (guarded or fallen-back). + """ + + def __init__( + self, + guard: Callable[[str], Optional[E]], + fallback: ColumnClassifier[E], + ) -> None: + self._guard = guard + self._fallback = fallback + + def classify(self, descriptions: set[str]) -> dict[str, E]: + guarded: dict[str, E] = {} + misses: set[str] = set() + for description in descriptions: + member = self._guard(description) + if member is not None: + guarded[description] = member + else: + misses.add(description) + # Only the misses reach the fallback — a fully-guarded batch never calls it. + if misses: + guarded.update(self._fallback.classify(misses)) + return guarded diff --git a/domain/epc/property_overlays/glazing_overlay.py b/domain/epc/property_overlays/glazing_overlay.py index d692181d8..81733461c 100644 --- a/domain/epc/property_overlays/glazing_overlay.py +++ b/domain/epc/property_overlays/glazing_overlay.py @@ -24,6 +24,7 @@ _GLAZING_CODES: dict[str, int] = { "Double glazing, pre-2002": 3, "Triple glazing, pre-2002": 6, "Triple glazing, 2002 or later": 9, + "Secondary glazing": 5, } diff --git a/domain/epc/property_overlays/main_fuel_overlay.py b/domain/epc/property_overlays/main_fuel_overlay.py index 25e28247f..a49b05e15 100644 --- a/domain/epc/property_overlays/main_fuel_overlay.py +++ b/domain/epc/property_overlays/main_fuel_overlay.py @@ -28,12 +28,16 @@ _FUEL_CODES: dict[str, int] = { "house coal": 33, "smokeless coal": 15, "dual fuel (mineral and wood)": 10, + # Individual wood-logs solid fuel — API fuel enum 6 (RdSAP Table 32 code 20, + # 0.028 kgCO2e/kWh), matching the water overlay's `wood logs` fuel. Distinct + # from biomass (community) 31; the LLM funnelled it there for want of a member. + "wood logs": 6, "biomass (community)": 31, } # A "mains gas" main fuel asserts the dwelling has a mains-gas connection, so the -# overlay must also flip `sap_energy_source.mains_gas` — not just the fuel code. -# Without it the effective EPC says "fuel = mains gas" while `mains_gas` stays +# overlay must also flip `sap_energy_source.gas_connection_available` — not just the fuel code. +# Without it the effective EPC says "fuel = mains gas" while `gas_connection_available` stays # False, which (a) gates out the gas-boiler-upgrade Measure and (b) makes the # heating Generator read the dwelling as off-gas and wrongly offer HHRSH storage # (property 728513). Only the **private** mains-gas connection (code 26) sets it; @@ -51,7 +55,7 @@ def fuel_overlay_for( code = _FUEL_CODES.get(main_fuel_value) if code is None: return None - mains_gas = True if main_fuel_value in _MAINS_GAS_FUEL_VALUES else None + gas_connection_available = True if main_fuel_value in _MAINS_GAS_FUEL_VALUES else None return EpcSimulation( - heating=HeatingOverlay(main_fuel_type=code, mains_gas=mains_gas) + heating=HeatingOverlay(main_fuel_type=code, gas_connection_available=gas_connection_available) ) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 94d4947c7..f99a24f5c 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -12,10 +12,11 @@ field-wise with the main_fuel / water_heating overlays. electricity tariff (meter) and, for storage heaters, its charge control. Rather than hand-attach those per archetype (easy to forget when a new system is added), they are **derived from the SAP code**: the off-peak meter from the -calculator's single off-peak classification (`OFF_PEAK_IMPLYING_HEATING_CODES`, -SAP §12), and the conservative manual charge control for storage heaters. So -adding a heating archetype is just adding its code — coherent companions fall -out. Synthesis owns coherence; the calculator never normalises a lodged cert. +overlay's assumed-Dual classification (`_ASSUMED_DUAL_METER_CODES` — the §12 +off-peak systems plus all-electric room-heater dwellings), and the conservative +manual charge control for storage heaters. So adding a heating archetype is just +adding its code — coherent companions fall out. Synthesis owns coherence; the +calculator never normalises a lodged cert. The SEDBUK A-G efficiency band the Hyde "Heating" column carries is NOT honoured yet (no efficiency slot on the overlay/MainHeatingDetail) -- archetypes map to @@ -27,6 +28,7 @@ are left UNKNOWN until modelled. Unresolvable values produce no overlay. from __future__ import annotations +import logging from typing import Optional from domain.modelling.simulation import EpcSimulation, HeatingOverlay @@ -34,6 +36,8 @@ from domain.sap10_calculator.tables.table_12a import ( OFF_PEAK_IMPLYING_HEATING_CODES, ) +logger = logging.getLogger(__name__) + # Off-peak (Economy 7) meter. Electric storage / CPSU systems charge overnight at # the low rate and cannot run economically on a single-rate meter; "Dual" lets # the §12 dispatch resolve the specific tariff (storage 7-hour, CPSU 10-hour). @@ -44,6 +48,23 @@ _OFF_PEAK_METER = "Dual" # meter bleed through and bill the new gas/direct-acting system on an Economy-7 # split (the mirror of the storage→Dual drag, ADR-0035). _SINGLE_RATE_METER = "Single" +# Meter-agnostic codes: the archetype cannot determine the tariff, so the overlay +# defers `meter_type` to the cert (`_meter_for` → None). Screed underfloor (424) is +# "standard or off peak" — 81% of the corpus lodge off-peak (ADR-0046). +_METER_AGNOSTIC_CODES = frozenset({424}) + +# Electric room heaters (SAP Table 4a 691). They don't *require* off-peak the way +# storage/CPSU do, so they're absent from the calculator's §12 +# `OFF_PEAK_IMPLYING_HEATING_CODES` (Rules 1-2). But a dwelling heated by them is +# all-electric and realistically billed on Economy 7 — its immersion hot water +# charges overnight and §12 Rule 3 gives the room heaters a 10-hour off-peak +# window. So when the landlord names only the system, the coherent meter to +# assume is Dual; the §12 dispatch then applies the realistic high/low split +# (not a single-rate over-penalty, nor an all-low over-credit). +_ROOM_HEATER_CODES = frozenset({691}) +# Codes for which the overlay assumes a Dual (off-peak) meter: the §12-mandated +# off-peak systems plus the all-electric room-heater dwellings above. +_ASSUMED_DUAL_METER_CODES = OFF_PEAK_IMPLYING_HEATING_CODES | _ROOM_HEATER_CODES # SAP Table 4e Group 4 storage charge-control code. Manual charge control is the # *conservative* assumption when the landlord didn't tell us the control: its @@ -53,6 +74,94 @@ _SINGLE_RATE_METER = "Single" # systems that take a charge control. _MANUAL_CHARGE_CONTROL = 2401 _STORAGE_HEATER_CODES = frozenset(range(401, 410)) +# SAP Table 4a "electric storage heaters" category. Set on a storage-system +# override so the overlaid cert is internally consistent (code 401-409 ↔ category +# 7) — mirroring the control/fuel/meter companions the overlay already drags. A +# storage code with a stale non-storage category is a latent trap: the SAP 10.2 +# Table 12a resolver classifies by category, so a leftover room-heater category +# 10 would price the off-peak storage heating at the peak rate. +_STORAGE_HEATER_CATEGORY = 7 +# High heat retention storage heaters (SAP Table 4a 409) take a SINGLE intrinsic +# charge control — Table 4e 2404 (HHR, 0 C adjustment). It is named by the +# archetype, not unobserved, so 409 drags 2404 rather than the manual default +# (ADR-0044) — the only storage code that overrides `_MANUAL_CHARGE_CONTROL`. +_HHR_STORAGE_CODE = 409 +_HHR_CHARGE_CONTROL = 2404 + +# SAP Table 4a category 10 ("Room heaters") and its conservative Table 4e Group 6 +# control. A landlord names a room heater, not its control; code 2601 ("no +# thermostatic control of room temperature") is the lowest-SAP room-heater +# control — the modal one real solid-fuel room-heater certs lodge — so it never +# over-credits an unobserved control (the room-heater mirror of the storage +# manual-charge default). Scoped per family as archetypes land; solid-fuel room +# heaters are Table 4a 631-636. +_ROOM_HEATER_CATEGORY = 10 +_ROOM_HEATER_CONTROL = 2601 +_SOLID_FUEL_ROOM_HEATER_CODES = frozenset(range(631, 637)) +# Oil room heaters (SAP Table 4a 621-625) — category 10, conservative room-heater +# control, natural fuel heating oil (RdSAP main_fuel 28). +_OIL_ROOM_HEATER_CODES = frozenset(range(621, 626)) +_OIL_FUEL = 28 +# Gas (incl. LPG) room heaters (SAP Table 4a 601-613) — category 10, conservative +# control, natural fuel mains gas (26); an LPG dwelling is refined by a main_fuel +# override (the overlay can't see the mains connection). +_GAS_ROOM_HEATER_CODES = frozenset(range(601, 614)) +# Fuel-burning room heaters (solid + oil + gas) share category 10 + the +# conservative room-heater control; only the natural fuel differs by family. +# (Distinct from `_ROOM_HEATER_CODES` above, the electric-room-heater dual-meter +# set.) +_CATEGORY_10_ROOM_HEATER_CODES = ( + _SOLID_FUEL_ROOM_HEATER_CODES | _OIL_ROOM_HEATER_CODES | _GAS_ROOM_HEATER_CODES +) + +# Natural-fuel coherence (ADR-0041): where an archetype unambiguously implies a +# fuel, the overlay drags it so a system-only override is self-coherent on a cert +# that lodged a different fuel. A later `main_fuel` override still wins (last-wins +# composition); a contradicting landlord fuel is logged, not silently overridden. +# Electric room heaters (Table 4a 691-701) are unambiguously electricity (RdSAP +# main_fuel code 29). Solid fuel is ambiguous (coal / anthracite / smokeless / +# dual fuel / wood logs / pellets), so it defaults to house coal (33) — the most +# common solid fuel — when the landlord gives no `main_fuel` override; a specific +# solid-fuel override still wins by last-wins composition. +_ELECTRICITY_FUEL = 29 +_HOUSE_COAL_FUEL = 33 +_ELECTRIC_ROOM_HEATER_CODES = frozenset(range(691, 702)) +# Electric boilers — SAP Table 4a 191 (direct-acting) and 192 (electric CPSU) — +# unambiguously electric, so they drag electricity like the electric room heaters +# (ADR-0045, closing the prior no-fuel gap on 191/192). +_ELECTRIC_BOILER_CODES = frozenset({191, 192}) +# Electric underfloor — SAP Table 4a 421/422 (off-peak) and 424 (screed) — also +# unambiguously electric (ADR-0046). +_ELECTRIC_UNDERFLOOR_CODES = frozenset({421, 422, 424}) + +# SAP Table 4a "electric underfloor heating" category. Stamped so an underfloor +# override never leaves the replaced system's category behind — property 711795 +# (PRD #1435 WS2) kept a replaced heat network's category 6, which the heating +# generator's `is_heat_network_main` gate read as community heating: no heating +# upgrade was offered at all and the baseline mis-rated 51 vs 67 (ADR-0050). +_UNDERFLOOR_CATEGORY = 8 +# SAP Table 4e Group 7 underfloor control: 2701 ("no time or thermostatic +# control of room temperature", +0.3 C) is the conservative default when the +# landlord named only the system — the largest mean-internal-temperature +# adjustment in the group, so an unobserved control is never over-credited +# (the underfloor mirror of storage-manual 2401 and room-heater 2601, ADR-0050). +_UNDERFLOOR_CONTROL = 2701 + +# Heat pumps (SAP Table 4a 211-224 wet, 521-527 warm-air) are category 4 and +# unambiguously electric (natural fuel 29). Modellable on the default code's SPF +# without a PCDB index (ADR-0041). +_HEAT_PUMP_CATEGORY = 4 +_HEAT_PUMP_CODES = frozenset(range(211, 225)) | frozenset(range(521, 528)) + +# Community / heat-network heating (SAP Table 4a 301-304) is category 6; the +# calculator's `_is_heat_network` keys off code OR category 6. The boiler-driven +# schemes (301/302/303) are dominantly mains gas (community) — RdSAP main_fuel 20 +# — per real community certs; the heat-pump scheme (304) is electricity +# (community), added with its archetype (ADR-0041). +_HEAT_NETWORK_CATEGORY = 6 +_HEAT_NETWORK_CODES = frozenset({301, 302, 303, 304}) +_COMMUNITY_BOILER_CODES = frozenset({301, 302, 303}) +_COMMUNITY_GAS_FUEL = 20 # SAP Table 4c full boiler-control code: programmer + room thermostat + TRVs. The # landlord names the boiler, not its controls — but a gas boiler installed under @@ -80,9 +189,11 @@ _FROM_MAIN_WATER_HEATING_CODE = 901 # Canonical system archetype → representative SAP `sap_main_heating_code`. Codes # map to the modern/condensing variant (A-G efficiency deferred): 102 regular # condensing, 104 condensing combi, 120 CPSU, 401-404 storage heaters, 191 -# direct-acting electric. Companion fields (meter / control / fuel / hot water) -# are NOT listed here — they are derived from the code below, so a new archetype -# is just a code (ADR-0035 drag-along). +# direct-acting electric, 691 panel/convector/radiant electric room heaters +# (Table 4a — direct-acting, so a single-rate meter, NOT off-peak storage). +# Companion fields (meter / control / fuel / hot water) are NOT listed here — +# they are derived from the code below, so a new archetype is just a code +# (ADR-0035 drag-along). _MAIN_HEATING_CODES: dict[str, int] = { "Gas boiler, combi": 104, "Gas boiler, regular": 102, @@ -91,15 +202,73 @@ _MAIN_HEATING_CODES: dict[str, int] = { "Electric storage heaters, slimline": 402, "Electric storage heaters, convector": 403, "Electric storage heaters, fan": 404, + # High heat retention storage heaters — SAP Table 4a 409. Its intrinsic HHR + # charge control (2404) and electricity fuel drag from the code below, not the + # generic manual-storage default (ADR-0044). + "Electric storage heaters, high heat retention": 409, "Direct-acting electric": 191, + # A-rated electric boiler off the Gas CPSU dumping ground — SAP Table 4a 191 + # (direct-acting electric boiler). Unambiguously electric, so it drags + # electricity below (ADR-0045). + "Electric boiler": 191, + # Electric CPSU — SAP Table 4a 192. Electric (drags 29), on an off-peak Dual + # meter (already in _ASSUMED_DUAL_METER_CODES; §12 Rule 1 10-hour) — ADR-0045. + "Electric CPSU": 192, + # Electric underfloor — dedicated SAP Table 4a codes, all electric (drag 29). + # 421 concrete slab / 422 integrated are off-peak (Dual, §12 Rule 2); 424 + # screed is tariff-ambiguous ("standard or off peak") so it DEFERS the meter to + # the cert (ADR-0046). + "Electric underfloor, in concrete slab (off-peak)": 421, + "Electric underfloor, integrated storage and direct-acting": 422, + "Electric underfloor, in screed above insulation": 424, + "Electric room heaters": 691, + "Solid fuel room heater, closed": 633, + # Solid-fuel room heaters off the Gas CPSU dumping ground (ADR-0045). SAP + # Table 4a: 631 open fire, 632 open fire + back boiler, 634 closed + boiler. + # Companions (house-coal fuel 33, room-heater control 2601, category 10) drag + # from the code — 631/632/634 are already in the 631-636 solid-fuel set. + "Solid fuel room heater, open fire": 631, + "Solid fuel room heater, open fire with back boiler": 632, + "Solid fuel room heater, closed with boiler": 634, + # Default air-source heat pump — SAP Table 4a 211 ("flow temp in other + # cases", default SPF 2.30). Modellable without a PCDB index; an actual + # product index would refine it (ADR-0041). + "Air source heat pump": 211, + # Boiler-driven community / heat-network heating — SAP Table 4a 301. The + # calculator derives the heat-source efficiency (80%) + DLF (1.50 default) + # from the code (cert_to_inputs). A generic "Community heating" with no named + # source stays unmapped (-> None) — the source can't be assumed (ADR-0041). + "Community heating, boilers": 301, + # Community CHP + boilers — SAP Table 4a 302 (heat network, category 6). + "Community heating, CHP and boilers": 302, + # Modern standalone oil room heater — SAP Table 4a 623 ("Oil room heater, + # 2000 or later", no boiler). Natural fuel heating oil (ADR-0041). + "Oil room heater, 2000 or later": 623, + # Gas room heaters — each catalogue variant to its own SAP Table 4a code + # (wide efficiency spread, so no single representative). Natural fuel mains + # gas; an LPG dwelling is refined by a `main_fuel` override (ADR-0041). + "Gas room heater, condensing fire": 611, + "Gas room heater, decorative fuel-effect": 612, + "Gas room heater, flush live-effect": 605, + "Gas room heater, open flue 1980 or later": 603, + "Gas room heater, open flue pre-1980": 601, } -def _meter_for(code: int) -> str: +def _meter_for(code: int) -> Optional[str]: """The coherent meter a heating code implies: an off-peak ("Dual") meter for - the calculator's §12 off-peak systems, an explicit single-rate ("Single") - meter for every other system. Always set — never left to bleed.""" - return _OFF_PEAK_METER if code in OFF_PEAK_IMPLYING_HEATING_CODES else _SINGLE_RATE_METER + the §12 off-peak systems and all-electric room-heater dwellings + (`_ASSUMED_DUAL_METER_CODES`), an explicit single-rate ("Single") meter for + every other system — set explicitly so it never bleeds. + + Exception: a **meter-agnostic** code (`_METER_AGNOSTIC_CODES` — screed + underfloor 424, "standard or off peak") returns ``None`` so the overlay leaves + `meter_type` unset and the cert's lodged tariff stands. The archetype genuinely + cannot know the tariff, and unlike a switched-off storage system it is not a + re-metering, so deferring is faithful, not a bleed (ADR-0046).""" + if code in _METER_AGNOSTIC_CODES: + return None + return _OFF_PEAK_METER if code in _ASSUMED_DUAL_METER_CODES else _SINGLE_RATE_METER def _control_for(code: int) -> Optional[int]: @@ -107,10 +276,61 @@ def _control_for(code: int) -> Optional[int]: conservative manual charge control for storage heaters, full modern controls for a gas boiler, None for systems that take neither (direct-acting electric). Overwrites a stale control inherited from the system being replaced.""" + if code == _HHR_STORAGE_CODE: + return _HHR_CHARGE_CONTROL if code in _STORAGE_HEATER_CODES: return _MANUAL_CHARGE_CONTROL if code in _GAS_BOILER_CODES: return _FULL_BOILER_CONTROL + if code in _CATEGORY_10_ROOM_HEATER_CODES or code in _ELECTRIC_ROOM_HEATER_CODES: + return _ROOM_HEATER_CONTROL + if code in _ELECTRIC_UNDERFLOOR_CODES: + return _UNDERFLOOR_CONTROL + return None + + +def _category_for(code: int) -> Optional[int]: + """The SAP Table 4a heating category a code implies, where the archetype + fixes it. Set so a system-only override reads as the new system's category, + not whatever category the replaced system carried — room heaters are 10, + electric storage heaters (incl. HHR 409) are 7, heat pumps 4, heat networks + 6. Setting storage → 7 keeps the overlaid cert consistent (code 401-409 ↔ + category 7); without it a storage override left a stale category behind, + which the SAP 10.2 Table 12a resolver (keyed on category) could misread as a + non-storage system and price the off-peak storage heating at the peak rate.""" + if code in _CATEGORY_10_ROOM_HEATER_CODES or code in _ELECTRIC_ROOM_HEATER_CODES: + return _ROOM_HEATER_CATEGORY + if code in _STORAGE_HEATER_CODES: + return _STORAGE_HEATER_CATEGORY + if code in _ELECTRIC_UNDERFLOOR_CODES: + return _UNDERFLOOR_CATEGORY + if code in _HEAT_PUMP_CODES: + return _HEAT_PUMP_CATEGORY + if code in _HEAT_NETWORK_CODES: + return _HEAT_NETWORK_CATEGORY + return None + + +def _natural_fuel_for(code: int) -> Optional[int]: + """The fuel an archetype unambiguously implies (a coherent default), or None + where the fuel is ambiguous and must come from the `main_fuel` override. A + present `main_fuel` override wins by last-wins composition.""" + if ( + code in _ELECTRIC_ROOM_HEATER_CODES + or code in _HEAT_PUMP_CODES + or code == _HHR_STORAGE_CODE + or code in _ELECTRIC_BOILER_CODES + or code in _ELECTRIC_UNDERFLOOR_CODES + ): + return _ELECTRICITY_FUEL + if code in _SOLID_FUEL_ROOM_HEATER_CODES: + return _HOUSE_COAL_FUEL + if code in _OIL_ROOM_HEATER_CODES: + return _OIL_FUEL + if code in _GAS_ROOM_HEATER_CODES: + return _MAINS_GAS_FUEL + if code in _COMMUNITY_BOILER_CODES: + return _COMMUNITY_GAS_FUEL return None @@ -123,7 +343,7 @@ def _gas_boiler_overlay(code: int) -> HeatingOverlay: sap_main_heating_code=code, main_heating_category=_GAS_BOILER_CATEGORY, main_fuel_type=_MAINS_GAS_FUEL, - mains_gas=True, + gas_connection_available=True, main_heating_control=_FULL_BOILER_CONTROL, fan_flue_present=True, meter_type=_SINGLE_RATE_METER, @@ -133,6 +353,16 @@ def _gas_boiler_overlay(code: int) -> HeatingOverlay: ) +def natural_fuel_for(main_heating_value: str) -> Optional[int]: + """The RdSAP `main_fuel` code a heating archetype unambiguously implies — its + natural fuel (ADR-0041) — or None for an unmapped archetype. Exposed so a + plausibility check can compare it against a landlord `main_fuel` override.""" + code = _MAIN_HEATING_CODES.get(main_heating_value) + if code is None: + return None + return _natural_fuel_for(code) + + def main_heating_overlay_for( main_heating_value: str, building_part: int ) -> Optional[EpcSimulation]: @@ -141,10 +371,36 @@ def main_heating_overlay_for( return None if code in _GAS_BOILER_CODES: return EpcSimulation(heating=_gas_boiler_overlay(code)) + category = _category_for(code) + control = _control_for(code) + if category is None or control is None: + # A system-replacing override should stamp a complete, coherent companion + # set; an unmapped category or control leaves the field unset, so the + # effective cert inherits the REPLACED system's value (a silent mis-rating + # — e.g. a room heater keeping a storage heater's category/control). We do + # not yet have confident defaults for every archetype, so log-and-continue + # (matching `flag_fuel_mismatch`) to make the gap visible for review + # rather than raise mid-run. + logger.error( + "Landlord main_heating_system %r (SAP code %d) overlaid with an " + "incomplete companion set (category=%s, control=%s); the effective " + "cert may inherit the replaced system's value — known gap, review", + main_heating_value, + code, + category, + control, + ) return EpcSimulation( heating=HeatingOverlay( sap_main_heating_code=code, + main_heating_category=category, + main_fuel_type=_natural_fuel_for(code), meter_type=_meter_for(code), - main_heating_control=_control_for(code), + main_heating_control=control, + # A landlord override describes the existing dwelling, so its assumed + # off-peak meter must not downgrade a more-off-peak cert meter + # (e.g. a 24-hour all-low tariff). Measures, which re-meter for real, + # build their HeatingOverlay directly and leave this False. + keep_existing_off_peak_meter=True, ) ) diff --git a/domain/epc/property_overlays/roof_type_overlay.py b/domain/epc/property_overlays/roof_type_overlay.py index aadcc3878..a2ba1811b 100644 --- a/domain/epc/property_overlays/roof_type_overlay.py +++ b/domain/epc/property_overlays/roof_type_overlay.py @@ -27,6 +27,8 @@ 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") def roof_overlay_for( @@ -49,8 +51,18 @@ def _overlay_for(roof_type_value: str) -> Optional[BuildingPartOverlay]: if match is not None: return BuildingPartOverlay(roof_insulation_thickness=int(match.group(1))) if roof_type_value.startswith("Flat,"): - # Flat roof: U-value is the age-band default; flag the shape and let the - # age-band overlay drive `_FLAT_ROOF_BY_AGE` (ADR-0033). + flat_depth = _FLAT_MM.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 + # depth (the calculator routes flat + thickness through Table 16, not + # the age-band default). ADR-0041. + return BuildingPartOverlay( + roof_construction_type="Flat", + roof_insulation_thickness=int(flat_depth.group(1)), + ) + # Flat roof with no stated depth: U-value is the age-band default; flag the + # shape and let the age-band overlay drive `_FLAT_ROOF_BY_AGE` (ADR-0033). return BuildingPartOverlay(roof_construction_type="Flat") if roof_type_value == "Pitched, Unknown loft insulation": # Unknown loft depth: U-value is the pitched age-band default. Assert the diff --git a/domain/epc/property_overlays/wall_type_overlay.py b/domain/epc/property_overlays/wall_type_overlay.py index 0620819cf..848ced77f 100644 --- a/domain/epc/property_overlays/wall_type_overlay.py +++ b/domain/epc/property_overlays/wall_type_overlay.py @@ -31,6 +31,9 @@ _MATERIAL_CONSTRUCTION: dict[str, int] = { "Curtain Wall": 9, } +# RdSAP `WALL_SYSTEM_BUILT` — shares code 6 with the gov-EPC basement sentinel. +_WALL_SYSTEM_BUILT = 6 + # RdSAP `wall_insulation_type` codes by insulation-state suffix # (domain/sap10_ml/rdsap_uvalues.py): external 1, filled-cavity 2, internal 3, # as-built/uninsulated 4, cavity+external 6, cavity+internal 7. @@ -66,11 +69,18 @@ def wall_overlay_for( if building_part == 0 else BuildingPartIdentifier.extension(building_part) ) + # System-built is RdSAP code 6, which collides with the gov-EPC code-6 + # basement sentinel that `main_wall_is_basement` falls back to. A landlord + # naming a System-built wall is asserting the material, not a basement — pin + # the flag False so the override isn't mis-read as one (ADR-0033 / the + # overlay mirror of the mapper's `_clear_basement_flag_when_system_built`). + wall_is_basement = False if construction == _WALL_SYSTEM_BUILT else None return EpcSimulation( building_parts={ identifier: BuildingPartOverlay( wall_construction=construction, wall_insulation_type=insulation, + wall_is_basement=wall_is_basement, ) } ) diff --git a/domain/epc/property_overlays/water_heating_overlay.py b/domain/epc/property_overlays/water_heating_overlay.py index 3ec952de2..61b6a159f 100644 --- a/domain/epc/property_overlays/water_heating_overlay.py +++ b/domain/epc/property_overlays/water_heating_overlay.py @@ -26,6 +26,18 @@ _WATER_HEATING_CODES: dict[str, tuple[int, int]] = { "From main system, LPG (bulk)": (901, 27), "From main system, bottled LPG": (901, 3), "From main system, house coal": (901, 33), + # Biomass / wood water fuels routed off the house-coal dumping ground + # (ADR-0043), all from main (901) with their own RdSAP water_heating_fuel: + # 6 wood logs (biomass ~0.028 kgCO2/kWh vs house coal 33 ~0.395) + # 9 dual fuel (mineral + wood) + # 31 biomass (community) — scored via the coherent community main; the + # fuel is still emitted so an individual main would score biomass + # 34 biodiesel (community) + # A wood-fired individual-main dwelling's DHW must never fall to coal 33. + "From main system, wood logs": (901, 6), + "From main system, dual fuel (mineral and wood)": (901, 9), + "From main system, biomass (community)": (901, 31), + "From main system, biodiesel (community)": (901, 34), "Electric immersion, electricity": (903, 29), # "boiler/circulator for water heating only" — SAP Table 4a code 911 (gas). "Gas boiler/circulator, mains gas": (911, 26), diff --git a/domain/epc/property_overrides/glazing_mix_guard.py b/domain/epc/property_overrides/glazing_mix_guard.py new file mode 100644 index 000000000..05b0fb2b0 --- /dev/null +++ b/domain/epc/property_overrides/glazing_mix_guard.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import re +from typing import Optional + +from domain.epc.property_overrides.glazing_type import GlazingType + +# "N% [era]" — the base type decides whether it is a mix; the +# era ("2002 or later" / "pre-2002") is captured too, so a dominant double/triple +# can be resolved deterministically when its era is stated (see the guard below). +# An absent or non-canonical era ("unknown age", "(SAP 9.94)") leaves the era group +# empty, so that dominant split stays the LLM's job. +_PERCENT_TYPE = re.compile( + r"(\d+)%\s*(single|double|triple|secondary)(?:\s*glazing)?" + r"(?:[\s,]*(pre-?2002|2002 or later))?" +) +# One base type at or above this share is a uniform assertion, not a mix — applied +# as that type (a near-uniform reglaze), not deferred. +_UNIFORM_THRESHOLD_PERCENT = 90 +# A dominant base type + stated era → the fully-determined canonical member. +# Single and secondary glazing are era-free, so they need no era. Double/triple +# with no (or non-canonical) era is absent here and falls through to the LLM. +_DOMINANT_MEMBER: dict[tuple[str, Optional[str]], GlazingType] = { + ("single", None): GlazingType.SINGLE, + ("secondary", None): GlazingType.SECONDARY, + ("double", "2002 or later"): GlazingType.DOUBLE_POST_2002, + ("double", "pre-2002"): GlazingType.DOUBLE_PRE_2002, + ("triple", "2002 or later"): GlazingType.TRIPLE_POST_2002, + ("triple", "pre-2002"): GlazingType.TRIPLE_PRE_2002, +} + + +def _canonical_era(era: str) -> Optional[str]: + """Normalise a matched era phrase to the key used in ``_DOMINANT_MEMBER``.""" + if not era: + return None + return "2002 or later" if "2002 or later" in era else "pre-2002" + + +def glazing_mix_guard(description: str) -> Optional[GlazingType]: + """Deterministically resolve an aggregate-mix glazing description to ``MIXED``. + + A landlord glazing description is often a whole-dwelling proportion split + ("40% double glazing 2002 or later, 60% single glazing"). Such a mix cannot be + faithfully applied per-window — it says *how much* is each type, not *which* + windows — and the cert already carries the true per-window glazing, so a mix + must resolve to ``GlazingType.MIXED`` (no overlay → the cert is kept), never to + a single dominant type that would flatten every window (ADR-0042). + + Recognises the structured ``"N% , M% "`` split and returns: + + * ``MIXED`` for a genuine mix — two or more glazing types present and none + dominant (≥ the uniform threshold); + * the fully-determined member for a dominant near-uniform split whose type + carries no era ambiguity — single or secondary glazing (era-free), or a + dominant double/triple whose era is stated ("pre-2002" / "2002 or later"); + the LLM would otherwise flatten such a split onto its *minority* type; + * ``None`` otherwise — an unparseable description, or a dominant double/triple + with an unstated/non-canonical era ("unknown age") that still carries genuine + era ambiguity — so the LLM classifier resolves it. + """ + matches = _PERCENT_TYPE.findall(description.lower()) + base_types = {base_type for _, base_type, _ in matches} + if len(base_types) < 2: + # One (or no) glazing type named — a uniform assertion, not a mix. + return None + dominant_percent, dominant_type, dominant_era = max( + ((int(percent), base_type, era) for percent, base_type, era in matches), + key=lambda triple: triple[0], + ) + if dominant_percent >= _UNIFORM_THRESHOLD_PERCENT: + # One type dominates — a near-uniform assertion, not a mix. A split is fully + # determined (and safe to apply deterministically) when the dominant type + # carries no era ambiguity: single glazing is era-free, and a dominant + # double/triple whose era is stated ("pre-2002" / "2002 or later") is + # equally pinned. In those cases claim the member ourselves — the LLM + # otherwise flattens the split onto the *minority* type. A dominant + # double/triple with an unstated or non-canonical era ("unknown age") still + # carries genuine era ambiguity, so it stays the LLM's job (falls through to + # ``None``). + return _DOMINANT_MEMBER.get((dominant_type, _canonical_era(dominant_era))) + return GlazingType.MIXED diff --git a/domain/epc/property_overrides/glazing_type.py b/domain/epc/property_overrides/glazing_type.py index ee3ad267b..4a4ef96b3 100644 --- a/domain/epc/property_overrides/glazing_type.py +++ b/domain/epc/property_overrides/glazing_type.py @@ -21,4 +21,15 @@ class GlazingType(Enum): DOUBLE_PRE_2002 = "Double glazing, pre-2002" TRIPLE_PRE_2002 = "Triple glazing, pre-2002" TRIPLE_POST_2002 = "Triple glazing, 2002 or later" + # An aggregate mix of glazing types across the dwelling ("40% double, 60% + # single"). Deliberately absent from the overlay's `_GLAZING_CODES`, so it + # resolves to no overlay and the cert's per-window `sap_windows` are kept + # rather than flattened to one type — a whole-dwelling proportion cannot say + # which windows are which (ADR-0042 amendment, #1376). The FE-owned pgEnum + # gains this member via a Drizzle migration. + MIXED = "Mixed glazing" + # Secondary glazing (a pane added inside the original window) is era-free like + # SINGLE — SAP10 glazing code 5, U = 2.9 regardless of install year. The + # FE-owned pgEnum gains this member via assessment-model#345. + SECONDARY = "Secondary glazing" UNKNOWN = "Unknown" diff --git a/domain/epc/property_overrides/main_fuel_guard.py b/domain/epc/property_overrides/main_fuel_guard.py new file mode 100644 index 000000000..5f499b352 --- /dev/null +++ b/domain/epc/property_overrides/main_fuel_guard.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Optional + +from domain.epc.property_overrides.main_fuel_type import MainFuelType + + +def main_fuel_guard(description: str) -> Optional[MainFuelType]: + """Deterministically resolve an individual wood-logs main fuel, which the LLM + funnelled into ``"biomass (community)"`` for want of a wood-logs member (#1376). + + A landlord main-fuel description arrives as ``": "`` (e.g. + ``"Solid Fuel: Wood Logs"``). With no individual wood-logs archetype the + classifier force-picked ``BIOMASS_COMMUNITY`` — inventing a community heat + network the dwelling isn't on (0.028 kgCO2e/kWh is right by luck, but the + community flag mis-costs it and mislabels the connection). Wood logs is a real + RdSAP solid fuel (API enum 6 / Table 32 code 20), so this guard claims it. + + Mirrors ``water_heating_guard`` (which already rescues the same fuel token for + DHW, ADR-0043). Returns ``None`` for fuels the LLM already classifies correctly + (mains gas, electricity, oil, dual fuel, genuine community biomass) and for + varied phrasings, so those still reach the LLM via ``GuardedColumnClassifier``. + ``"dual fuel: mineral and wood"`` contains ``"wood"`` but not ``"wood log"``, + so it keeps its own ``DUAL_FUEL_MINERAL_WOOD`` member. + """ + if "wood log" in description.lower(): + return MainFuelType.WOOD_LOGS + return None diff --git a/domain/epc/property_overrides/main_fuel_type.py b/domain/epc/property_overrides/main_fuel_type.py index 8427b9cf0..24224210b 100644 --- a/domain/epc/property_overrides/main_fuel_type.py +++ b/domain/epc/property_overrides/main_fuel_type.py @@ -25,5 +25,6 @@ class MainFuelType(Enum): HOUSE_COAL = "house coal" SMOKELESS_COAL = "smokeless coal" DUAL_FUEL_MINERAL_WOOD = "dual fuel (mineral and wood)" + WOOD_LOGS = "wood logs" BIOMASS_COMMUNITY = "biomass (community)" UNKNOWN = "Unknown" diff --git a/domain/epc/property_overrides/main_heating_guard.py b/domain/epc/property_overrides/main_heating_guard.py new file mode 100644 index 000000000..bf69d42c0 --- /dev/null +++ b/domain/epc/property_overrides/main_heating_guard.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Optional + +from domain.epc.property_overrides.main_heating_system_type import ( + MainHeatingSystemType, +) + + +def main_heating_guard(description: str) -> Optional[MainHeatingSystemType]: + """Deterministically resolve the structured main-heating descriptions the LLM + funnelled into a garbage-drawer archetype for want of a correct target (#1376). + + Two dumping grounds are rescued: + + - **HHRSH** (ADR-0044): ``"…High heat retention storage heaters"`` went to + ``"Electric storage heaters, old"`` (SAP 401) — the *worst* old type. HHRSH is + SAP Table 4a 409 with its own intrinsic charge control (2404). + - **Gas CPSU** (ADR-0045): the mains-gas CPSU garbage drawer also held + solid-fuel room heaters (open fire 631 / + back boiler 632 / closed + boiler + 634) and electric ``"NA"``-type boilers (SAP 191). "NA" is the boiler-type + value meaning "not a gas combi/regular/CPSU", i.e. an electric boiler. + - **Electric underfloor** (ADR-0046): dumped into Direct-acting (191) / old + storage (401) for want of the dedicated SAP underfloor codes — in concrete + slab (421, off-peak), integrated storage+direct-acting (422), in screed (424, + meter deferred to the cert). + + Returns ``None`` for descriptions the LLM already classifies correctly (genuine + gas boilers, other storage subtypes, a plain closed room heater) and for varied + phrasings, so they still reach the LLM via ``GuardedColumnClassifier``. Order + matters: the back-boiler open fire is tested before the bare open fire it + contains. + """ + text = description.lower() + if "high heat retention" in text: + return MainHeatingSystemType.ELECTRIC_STORAGE_HIGH_HEAT_RETENTION + if "underfloor" in text: + if "concrete slab" in text: + return MainHeatingSystemType.ELECTRIC_UNDERFLOOR_SLAB_OFF_PEAK + if "integrated" in text: + return MainHeatingSystemType.ELECTRIC_UNDERFLOOR_INTEGRATED + if "screed" in text: + return MainHeatingSystemType.ELECTRIC_UNDERFLOOR_SCREED + return None + if "rated na" in text: + return MainHeatingSystemType.ELECTRIC_BOILER + if "open fire" in text and "back boiler" in text: + return MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_OPEN_FIRE_BACK_BOILER + if "open fire" in text: + return MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_OPEN_FIRE + if "closed room heater" in text and "with boiler" in text: + return MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_CLOSED_WITH_BOILER + return None diff --git a/domain/epc/property_overrides/main_heating_system_type.py b/domain/epc/property_overrides/main_heating_system_type.py index bea14e6af..f8426fdcf 100644 --- a/domain/epc/property_overrides/main_heating_system_type.py +++ b/domain/epc/property_overrides/main_heating_system_type.py @@ -23,5 +23,35 @@ class MainHeatingSystemType(Enum): ELECTRIC_STORAGE_SLIMLINE = "Electric storage heaters, slimline" ELECTRIC_STORAGE_CONVECTOR = "Electric storage heaters, convector" ELECTRIC_STORAGE_FAN = "Electric storage heaters, fan" + ELECTRIC_STORAGE_HIGH_HEAT_RETENTION = ( + "Electric storage heaters, high heat retention" + ) DIRECT_ELECTRIC = "Direct-acting electric" + ELECTRIC_BOILER = "Electric boiler" + ELECTRIC_CPSU = "Electric CPSU" + ELECTRIC_UNDERFLOOR_SLAB_OFF_PEAK = ( + "Electric underfloor, in concrete slab (off-peak)" + ) + ELECTRIC_UNDERFLOOR_INTEGRATED = ( + "Electric underfloor, integrated storage and direct-acting" + ) + ELECTRIC_UNDERFLOOR_SCREED = "Electric underfloor, in screed above insulation" + ELECTRIC_ROOM_HEATERS = "Electric room heaters" + SOLID_FUEL_ROOM_HEATER_CLOSED = "Solid fuel room heater, closed" + SOLID_FUEL_ROOM_HEATER_OPEN_FIRE = "Solid fuel room heater, open fire" + SOLID_FUEL_ROOM_HEATER_OPEN_FIRE_BACK_BOILER = ( + "Solid fuel room heater, open fire with back boiler" + ) + SOLID_FUEL_ROOM_HEATER_CLOSED_WITH_BOILER = ( + "Solid fuel room heater, closed with boiler" + ) + AIR_SOURCE_HEAT_PUMP = "Air source heat pump" + COMMUNITY_BOILERS = "Community heating, boilers" + COMMUNITY_CHP_AND_BOILERS = "Community heating, CHP and boilers" + OIL_ROOM_HEATER_POST_2000 = "Oil room heater, 2000 or later" + GAS_FIRE_CONDENSING = "Gas room heater, condensing fire" + GAS_FIRE_DECORATIVE = "Gas room heater, decorative fuel-effect" + GAS_FIRE_FLUSH_LIVE_EFFECT = "Gas room heater, flush live-effect" + GAS_FIRE_OPEN_FLUE_POST_1980 = "Gas room heater, open flue 1980 or later" + GAS_FIRE_OPEN_FLUE_PRE_1980 = "Gas room heater, open flue pre-1980" UNKNOWN = "Unknown" diff --git a/domain/epc/property_overrides/property_type_guard.py b/domain/epc/property_overrides/property_type_guard.py new file mode 100644 index 000000000..bbf846cb5 --- /dev/null +++ b/domain/epc/property_overrides/property_type_guard.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Optional + +from domain.epc.property_overrides.property_type import PropertyType + +# The landlord property-type description is a ": : …" +# split (e.g. "Bungalow: EndTerrace"). The dwelling type is the leading token and +# is independent of the built-form tail — which the LLM sometimes over-reads, +# mislabelling a "Bungalow: EndTerrace" as House (#1376). +_LEADING_TYPE: dict[str, PropertyType] = { + "house": PropertyType.HOUSE, + "bungalow": PropertyType.BUNGALOW, + "flat": PropertyType.FLAT, + "maisonette": PropertyType.MAISONETTE, + "park home": PropertyType.PARK_HOME, +} + + +def property_type_guard(description: str) -> Optional[PropertyType]: + """Deterministically resolve the dwelling type from the leading token of a + landlord property-type description. + + The description is a structured ``": : "`` + split whose first token is the dwelling type; the tail (EndTerrace, Mid Floor, + …) is built form, not type. This guard claims the recognised leading tokens so + the built-form tail can never flip the type (the LLM read "Bungalow: EndTerrace" + as House). Returns ``None`` for an unrecognised leading token, so varied + phrasings still reach the LLM classifier via ``GuardedColumnClassifier``. + """ + leading = description.split(":", 1)[0].strip().lower() + return _LEADING_TYPE.get(leading) diff --git a/domain/epc/property_overrides/roof_party_ceiling_guard.py b/domain/epc/property_overrides/roof_party_ceiling_guard.py new file mode 100644 index 000000000..359cc219e --- /dev/null +++ b/domain/epc/property_overrides/roof_party_ceiling_guard.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import re +from typing import Optional + +from domain.epc.property_overrides.roof_type import RoofType + +# The roof-type token (before any ``: `` suffix), normalised to lower +# alphanumerics, → its party-ceiling RoofType member. Normalising strips spacing +# and case so both ``"another dwelling above"`` and ``"anotherdwellingabove"`` +# match the same marker. +_PARTY_CEILING_MARKERS: dict[str, RoofType] = { + "anotherdwellingabove": RoofType.ADJACENT_ANOTHER_DWELLING_ABOVE, + "samedwellingabove": RoofType.ADJACENT_SAME_DWELLING_ABOVE, + "otherpremisesabove": RoofType.ADJACENT_OTHER_PREMISES_ABOVE, + # Both the "(another premises above)" adjacency and the redundant "Another + # Premises Above" taxonomy member normalise here; map to the parenthesised + # family (both resolve to no overlay, so there is no scoring difference). + "anotherpremisesabove": RoofType.ADJACENT_ANOTHER_PREMISES_ABOVE, +} + + +def _normalise_roof_token(description: str) -> str: + token = description.split(":", 1)[0] + return re.sub(r"[^a-z0-9]", "", token.lower()) + + +def roof_party_ceiling_guard(description: str) -> Optional[RoofType]: + """Deterministically resolve a party-ceiling roof description to its RoofType. + + A building part whose top boundary is another (or the same) dwelling / premises + above has ~0 heat loss (RdSAP 10 Table 18: "There is no heat loss through the + roof of a building part that has the same dwelling or another dwelling above"), + so it must resolve to a party-ceiling `RoofType` member — which the roof + Simulation Overlay leaves as no overlay, keeping the lodged EPC — and never to + an external `Pitched` / `Flat` roof. + + Recognises the party-ceiling markers regardless of a trailing insulation token + (``: 100mm`` / ``: unknown``) or spacing/case, and returns ``None`` for anything + that is not a party-ceiling marker so the LLM classifier still handles it + (#1376). + """ + return _PARTY_CEILING_MARKERS.get(_normalise_roof_token(description)) diff --git a/domain/epc/property_overrides/roof_type.py b/domain/epc/property_overrides/roof_type.py index 56ef9e8ed..bc3073f86 100644 --- a/domain/epc/property_overrides/roof_type.py +++ b/domain/epc/property_overrides/roof_type.py @@ -22,6 +22,28 @@ class RoofType(Enum): FLAT_NO_INSULATION = "Flat, no insulation" FLAT_NO_INSULATION_ASSUMED = "Flat, no insulation (assumed)" + # A flat roof with a known insulation depth. RdSAP 10 Table 16 col (1) + # ("joists at ceiling level and flat roof") scores flat roofs by thickness, so + # these carry the depth into the overlay (mirroring the PITCHED_LOFT_* ladder; + # no "loft" — a flat roof has none). ADR-0041. The value column is an FE-owned + # pgEnum, so these members are added to the Drizzle enum by the FE owner. + FLAT_12MM = "Flat, 12 mm insulation" + FLAT_25MM = "Flat, 25 mm insulation" + FLAT_50MM = "Flat, 50 mm insulation" + FLAT_75MM = "Flat, 75 mm insulation" + FLAT_100MM = "Flat, 100 mm insulation" + FLAT_125MM = "Flat, 125 mm insulation" + FLAT_150MM = "Flat, 150 mm insulation" + FLAT_175MM = "Flat, 175 mm insulation" + FLAT_200MM = "Flat, 200 mm insulation" + FLAT_225MM = "Flat, 225 mm insulation" + FLAT_250MM = "Flat, 250 mm insulation" + FLAT_270MM = "Flat, 270 mm insulation" + FLAT_300MM = "Flat, 300 mm insulation" + FLAT_350MM = "Flat, 350 mm insulation" + FLAT_400MM = "Flat, 400 mm insulation" + FLAT_400_PLUS = "Flat, 400+ mm insulation" + PITCHED_INSULATED = "Pitched, insulated" PITCHED_INSULATED_ASSUMED = "Pitched, insulated (assumed)" PITCHED_INSULATED_AT_RAFTERS = "Pitched, insulated at rafters" diff --git a/domain/epc/property_overrides/water_heating_guard.py b/domain/epc/property_overrides/water_heating_guard.py new file mode 100644 index 000000000..1970f6bfd --- /dev/null +++ b/domain/epc/property_overrides/water_heating_guard.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Optional + +from domain.epc.property_overrides.water_heating_type import WaterHeatingType + + +def water_heating_guard(description: str) -> Optional[WaterHeatingType]: + """Deterministically resolve the structured water-heating descriptions the LLM + funnelled into the ``"From main system, house coal"`` dumping ground (#1376). + + Landlord water-heating descriptions arrive as ``": "`` (e.g. + ``"From main heating system: Wood Logs"``). Where the taxonomy had no biomass / + wood / dual-fuel / biodiesel water fuel, the classifier force-picked house coal, + scoring a biomass dwelling's DHW at ~14x its carbon (ADR-0043). This guard keys + on the **fuel token** and returns the faithful archetype — the ``"Hot-water only + community scheme"`` system flattens to ``"From main system, …"`` exactly as the + community mains-gas description already does. ``"No Heating or Hot Water"`` under + an ``"electric immersion assumed"`` system is the assessment's own immersion + assumption, so it resolves to ``ELECTRIC_IMMERSION``. + + Returns ``None`` for fuels the LLM already classifies correctly (mains gas, + electricity, oil, …) and for varied / unstructured phrasings, so those still + reach the LLM classifier via ``GuardedColumnClassifier``. Order matters: + biodiesel and dual fuel are tested before the bare biomass / wood tokens they + contain. + """ + text = description.lower() + if "biodiesel" in text: + return WaterHeatingType.FROM_MAIN_BIODIESEL_COMMUNITY + if "biomass" in text: + return WaterHeatingType.FROM_MAIN_BIOMASS_COMMUNITY + if "dual fuel" in text: + return WaterHeatingType.FROM_MAIN_DUAL_FUEL_MINERAL_WOOD + if "wood log" in text: + return WaterHeatingType.FROM_MAIN_WOOD_LOGS + if "house coal" in text: + return WaterHeatingType.FROM_MAIN_HOUSE_COAL + if "no heating or hot water" in text: + return WaterHeatingType.ELECTRIC_IMMERSION + return None diff --git a/domain/epc/property_overrides/water_heating_type.py b/domain/epc/property_overrides/water_heating_type.py index e85a195d8..92cd3a75b 100644 --- a/domain/epc/property_overrides/water_heating_type.py +++ b/domain/epc/property_overrides/water_heating_type.py @@ -21,6 +21,10 @@ class WaterHeatingType(Enum): FROM_MAIN_LPG_BULK = "From main system, LPG (bulk)" FROM_MAIN_BOTTLED_LPG = "From main system, bottled LPG" FROM_MAIN_HOUSE_COAL = "From main system, house coal" + FROM_MAIN_WOOD_LOGS = "From main system, wood logs" + FROM_MAIN_DUAL_FUEL_MINERAL_WOOD = "From main system, dual fuel (mineral and wood)" + FROM_MAIN_BIOMASS_COMMUNITY = "From main system, biomass (community)" + FROM_MAIN_BIODIESEL_COMMUNITY = "From main system, biodiesel (community)" ELECTRIC_IMMERSION = "Electric immersion, electricity" GAS_BOILER_CIRCULATOR_MAINS_GAS = "Gas boiler/circulator, mains gas" UNKNOWN = "Unknown" diff --git a/domain/magicplan/mapper.py b/domain/magicplan/mapper.py index 604e330a0..2befadd3a 100644 --- a/domain/magicplan/mapper.py +++ b/domain/magicplan/mapper.py @@ -99,8 +99,17 @@ def _map_window_ventilation( opening_type=_try_get_str_value("Opening Type"), num_openings=_try_get_int_value("Number of Openings (In Same Window)"), pct_openable=_try_get_int_value("% of Window Openable"), - trickle_vent_area_mm2=_try_get_int_value( - "Trickle Vent Effective Area (mm2) (No Code Then Width x Height)" + trickle_vent_area_mm2=next( + ( + v + for label in [ + "Total Trickle Vent Effective Area (mm2) (No Code Then Width x Height)", + "Individual Trickle Vent Effective Area (mm2) (No Code Then Width x Height)", + "Trickle Vent Effective Area (mm2) (No Code Then Width x Height)", + ] + if (v := _try_get_int_value(label)) is not None + ), + None, ), num_trickle_vents=_try_get_int_value("No. of Trickle Vents"), ) diff --git a/domain/magicplan/ventilation_audit.py b/domain/magicplan/ventilation_audit.py index 74fc66e1f..9751d770d 100644 --- a/domain/magicplan/ventilation_audit.py +++ b/domain/magicplan/ventilation_audit.py @@ -11,7 +11,7 @@ from domain.magicplan.models import Door, Plan, Room, Window _DATA_START_ROW = 6 _MAX_ROWS = 50 -_DOOR_AREA_COL = "U" +_DOOR_AREA_COL = "T" _DOOR_AREA_CF_RANGE = f"{_DOOR_AREA_COL}{_DATA_START_ROW}:{_DOOR_AREA_COL}{_DATA_START_ROW + _MAX_ROWS - 1}" _Y_THRESHOLD = 7600 _DOOR_AREA_HEADER = CellRichText( @@ -94,9 +94,9 @@ def populate_sheet(sheet: Any, plan: Plan) -> None: for i, (room_name, door) in enumerate(doors): row = _DATA_START_ROW + i vent = door.ventilation - _write_cell(sheet, row, "R", room_name) - _write_cell(sheet, row, "S", door.width_mm) - _write_cell(sheet, row, "T", vent.undercut_mm if vent else 0) - # U = formula =S*T — do not write + _write_cell(sheet, row, "Q", room_name) + _write_cell(sheet, row, "R", door.width_mm) + _write_cell(sheet, row, "S", vent.undercut_mm if vent else 0) + # T = formula =R*S — do not write _apply_column_y_formatting(sheet) diff --git a/domain/modelling/generators/floor_recommendation.py b/domain/modelling/generators/floor_recommendation.py index 9e3815ba5..386c2fd76 100644 --- a/domain/modelling/generators/floor_recommendation.py +++ b/domain/modelling/generators/floor_recommendation.py @@ -15,6 +15,7 @@ from datatypes.epc.domain.epc_property_data import ( SapBuildingPart, ) from domain.building_geometry import ground_floor_area +from domain.modelling.generators.floor_u_value_gate import floor_insulation_lowers_u from domain.modelling.measure_type import MeasureType from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation @@ -69,6 +70,16 @@ def recommend_floor_insulation( if measure_type is None: return None + # Floor U-Value Gate (ADR-0051, floor arm): withhold the Option when it + # cannot lower the floor's derived U — e.g. an at-regs modern age band, + # where the as-built and insulated cascades resolve to the same value. + if not floor_insulation_lowers_u( + main, + epc.country_code, + insulation_thickness_mm=_RECOMMENDED_FLOOR_THICKNESS_MM, + ): + return None + product = products.get(measure_type) area: float = ground_floor_area(epc, BuildingPartIdentifier.MAIN) cost = Cost( diff --git a/domain/modelling/generators/floor_u_value_gate.py b/domain/modelling/generators/floor_u_value_gate.py new file mode 100644 index 000000000..f7d2a5fb2 --- /dev/null +++ b/domain/modelling/generators/floor_u_value_gate.py @@ -0,0 +1,76 @@ +"""The Floor U-Value Gate — the ground-floor arm of ADR-0051. + +A floor insulation Option is offered only when it would actually lower the +ground floor's derived U-value: on a modern construction age band RdSAP's +BS EN ISO 13370 / Table 19 cascade assigns an as-built floor its at-regs +U-value and the post-measure state resolves to the same value — the measure +costs thousands for +0.00 SAP (portfolio 796: solid_floor_insulation offered +at £3-4k on age-band-L dwellings 709782/709790). + +Same thin-mapping philosophy as the Wall U-Value Gate (`wall_u_value_gate`): +the calculator's rich extras (surveyor description, lodged `floor_u_value`) +are deliberately omitted — both sides are computed identically, so a genuine +no-op still compares equal, and omissions only ever bias toward OFFERING. +""" + +from typing import Any, Final, Optional + +from datatypes.epc.domain.epc_property_data import SapBuildingPart +from domain.sap10_ml.rdsap_uvalues import Country, u_floor + +# Improvements below this are float noise, not a physical change. NOT a +# materiality threshold — economics stay with the Optimiser (ADR-0016/0024). +_NOISE_FLOOR_W_PER_M2K: Final[float] = 0.01 + + +def _int_or_none(value: Any) -> Optional[int]: + return value if isinstance(value, int) else None + + +def _parse_thickness_mm(value: Any) -> Optional[int]: + if value is None or isinstance(value, int): + return value + if isinstance(value, str) and value.strip().isdigit(): + return int(value.strip()) + return None # "NI" / free text — thickness not known + + +def floor_insulation_lowers_u( + main: SapBuildingPart, + country_code: Optional[str], + *, + insulation_thickness_mm: int, +) -> bool: + """Whether insulating the main part's ground floor to + ``insulation_thickness_mm`` lowers its derived U-value by more than float + noise — the Floor U-Value Gate (ADR-0051).""" + country: Country = Country.from_code(country_code) + age_band = main.construction_age_band + ground = next( + (fd for fd in main.sap_floor_dimensions if fd.floor == 0), + main.sap_floor_dimensions[0] if main.sap_floor_dimensions else None, + ) + construction: Optional[int] = ( + _int_or_none(ground.floor_construction) if ground is not None else None + ) + area = ground.total_floor_area_m2 if ground is not None else None + perimeter = ground.heat_loss_perimeter_m if ground is not None else None + current_u: float = u_floor( + country, + age_band, + construction, + _parse_thickness_mm(main.floor_insulation_thickness), + area, + perimeter, + main.wall_thickness_mm, + ) + post_u: float = u_floor( + country, + age_band, + construction, + insulation_thickness_mm, + area, + perimeter, + main.wall_thickness_mm, + ) + return current_u - post_u > _NOISE_FLOOR_W_PER_M2K diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index 9eae0353e..ebb7d1f5e 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -12,10 +12,11 @@ boiler bundles land in later slices. Detection + pricing only — impact is produced by scoring (ADR-0016). """ +from dataclasses import replace from typing import Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData, MainHeatingDetail -from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP +from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP, is_heat_network_main from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.products import ( AshpCostInputs, @@ -28,6 +29,7 @@ from domain.modelling.measure_type import MeasureType from domain.modelling.product import Product from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.simulation import EpcSimulation, HeatingOverlay +from domain.sap10_calculator.tables.pcdb import gas_oil_boiler_record from domain.sap10_calculator.tables.table_4b import ( table_4b_seasonal_efficiencies_pct, ) @@ -58,6 +60,14 @@ _HEAT_PUMP_CATEGORY = 4 _HHR_STORAGE_OVERLAY = HeatingOverlay( main_fuel_type=_ELECTRICITY_FUEL, sap_main_heating_code=_HHR_STORAGE_SAP_CODE, + # RdSAP electric-storage-heater category (7). Set so the overlaid EPC stays + # internally consistent: without it the pre-existing system's category (e.g. + # 10 = electric room heaters) survives alongside the new storage code 409, + # and the SAP 10.2 Table 12a resolver — which classifies by category — + # misreads the off-peak storage heating as direct-acting electric and prices + # 100% at the peak rate (verified: UPRN 100020942571 HHRSH SAP 48 vs + # accredited Elmhurst 71; category 7 -> 73). See ADR-0024. + main_heating_category=7, main_heating_control=2404, water_heating_code=903, water_heating_fuel=_ELECTRICITY_FUEL, @@ -124,10 +134,46 @@ _ASHP_OVERLAY = HeatingOverlay( cylinder_thermostat="Y", has_hot_water_cylinder=True, meter_type="Single", - mains_gas=False, + gas_connection_available=False, ) +# ASHP sizing ladder (ADR-0049): the Vaillant aroTHERM plus "& AI VIH RW" family, +# one representative PCDB Table 362 record per nominal size, ascending by rated +# max output (kW). The pump is sized to the dwelling's design heat loss so SAP +# 10.2 Appendix N reads the heat-pump efficiency near its PSR peak (~1.0), rather +# than a single fixed unit that is grossly undersized on high-heat-loss dwellings +# — where the PSR collapses and the ASHP scores at/below the resistance baseline. +# 110257 (the 5 kW rung) is the previously-fixed anchor, validated against the +# relodged after-cert. +_ASHP_SIZING_LADDER: tuple[tuple[float, int], ...] = ( + (3.76, 110249), # aroTHERM plus 3.5 kW + (4.37, 110257), # aroTHERM plus 5 kW + (6.40, 110265), # aroTHERM plus 7 kW + (7.93, 110273), # aroTHERM plus 10 kW + (11.48, 110281), # aroTHERM plus 12 kW +) +# SAP 10.2 Appendix-N heat-pump efficiency peaks around PSR 0.8 (output / design +# heat loss); sizing to it reproduces real installer choices (accredited-cert +# validated) rather than the MCS capacity target (PSR 1.0), which oversizes. +_EFFICIENCY_PEAK_PSR: float = 0.8 + + +def select_ashp_pcdb_id(design_heat_loss_kw: float) -> int: + """The PCDB heat-pump record to install for a dwelling with + `design_heat_loss_kw` (ADR-0049): the ladder rung whose rated output sits + nearest the SAP 10.2 Appendix-N efficiency peak — PSR ~= 0.8, i.e. a rated + output of ``0.8 x design_heat_loss`` — which reproduces the pump a real + installer fits (validated against the relodged Elmhurst ASHP cert) and keeps + the heat pump clear of the low-PSR efficiency collapse. A load beyond the + domestic aroTHERM plus range naturally selects the largest rung.""" + target_output_kw: float = _EFFICIENCY_PEAK_PSR * design_heat_loss_kw + return min( + _ASHP_SIZING_LADDER, + key=lambda rung: abs(rung[0] - target_output_kw), + )[1] + + # --- Gas boiler upgrade (Heating/HW expansion): replace an existing wet boiler # with a modern gas condensing boiler. Validated against Elmhurst before/after # re-lodgements (cert 001431): the upgrade always targets mains gas — gas->gas @@ -192,6 +238,19 @@ _WET_BOILER_SAP_CODE_RANGES: tuple[range, ...] = ( ) _ELECTRIC_BOILER_SAP_CODE_RANGE = range(191, 197) +# RdSAP main_heating_category values that denote a wet central-heating boiler: +# 1 ("central heating with separate HW") and 2 ("boiler and radiators"). Both +# imply a wet primary loop — the fuel (not the category) says gas/oil/electric. +# Used only for the fallback classification of a lodgement that carries a +# category but no `sap_main_heating_code` (mirrors the calculator's +# `cert_to_inputs._is_wet_boiler_main`). +_WET_BOILER_CATEGORIES: frozenset[int] = frozenset({1, 2}) +# Electricity main-fuel codes: the SAP Table 12 code (30, Elmhurst/cascade) and +# the raw gov-API enum (29). An electric-fuelled boiler is the electrification +# path, not a gas swap, so it is excluded from the gas-boiler upgrade even when +# the SAP code is absent (the code range can't identify it). +_ELECTRICITY_FUEL_CODES: frozenset[int] = frozenset({29, 30}) + # Cylinder jacket end-state (from the after-cert): an 80 mm jacket # (`cylinder_insulation_type=2`). The jacket is added only when the existing # cylinder is below this thickness — bringing every cylinder up to 80 mm and @@ -232,16 +291,25 @@ _OIL_FUEL_CODES = frozenset({28, 4, 71, 73, 75, 76}) _LPG_FUEL_CODES = frozenset({27, 2, 3, 5, 9}) -def ashp_cost_inputs(epc: EpcPropertyData) -> AshpCostInputs: +def ashp_cost_inputs( + epc: EpcPropertyData, design_heat_loss_kw: Optional[float] = None +) -> AshpCostInputs: """Read an `EpcPropertyData` into the typed inputs `Products.ashp_bundle_cost` - needs: the existing system, property-size band, design heat loss (floor-area - proxy), radiator count, and whether a wet system can be reused (ADR-0025).""" + needs: the existing system, property-size band, design heat loss, radiator + count, and whether a wet system can be reused (ADR-0025). ``design_heat_loss_kw`` + (the calculator's value, ADR-0049) sizes the cost band; when omitted it falls + back to the floor-area proxy so cost and pump size share one figure.""" system: AshpExistingSystem = _existing_system(epc) floor_area: float = epc.total_floor_area_m2 + heat_loss_kw: float = ( + design_heat_loss_kw + if design_heat_loss_kw is not None + else floor_area * _KW_PER_M2 + ) return AshpCostInputs( existing_system=system, is_small_property=floor_area <= _SMALL_PROPERTY_MAX_M2, - design_heat_loss_kw=floor_area * _KW_PER_M2, + design_heat_loss_kw=heat_loss_kw, radiator_count=_radiator_count(epc), has_reusable_wet_system=system in (AshpExistingSystem.GAS, AshpExistingSystem.OIL, AshpExistingSystem.LPG), @@ -285,17 +353,23 @@ def recommend_heating( products: ProductRepository, restrictions: PlanningRestrictions = PlanningRestrictions(), considered_measures: Optional[frozenset[MeasureType]] = None, + *, + design_heat_loss_kw: Optional[float] = None, ) -> Optional[Recommendation]: """Return a "Heating & Hot Water" Recommendation of competing whole-system bundles for the dwelling, else None when no bundle is eligible. ASHP is - additionally gated by the Property's planning protections (ADR-0024).""" + additionally gated by the Property's planning protections (ADR-0024). + + ``design_heat_loss_kw`` (the calculator's SAP design heat loss) sizes the + ASHP to the dwelling so SAP's Appendix-N PSR efficiency reads near its peak + (ADR-0049); when omitted, sizing falls back to the floor-area proxy.""" options: list[MeasureOption] = [] hhr_option = _hhr_storage_option(epc, products) if hhr_option is not None: options.append(hhr_option) - ashp_option = _ashp_option(epc, products, restrictions) + ashp_option = _ashp_option(epc, products, restrictions, design_heat_loss_kw) if ashp_option is not None: options.append(ashp_option) @@ -321,8 +395,7 @@ def _system_tune_up_options( absent). Each control option is offered only when it genuinely improves the existing controls — never a downgrade or a no-op (ADR-0024).""" main: MainHeatingDetail = epc.sap_heating.main_heating_details[0] - code: Optional[int] = main.sap_main_heating_code - if code is None or not any(code in r for r in _WET_BOILER_SAP_CODE_RANGES): + if not _is_wet_boiler(main): return [] control = main.main_heating_control control_code: Optional[int] = control if isinstance(control, int) else None @@ -455,31 +528,70 @@ def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool: instead); a non-gas boiler is a fuel switch, so it is never gated on efficiency.""" main: MainHeatingDetail = epc.sap_heating.main_heating_details[0] - code: Optional[int] = main.sap_main_heating_code - if code is None: + if not _is_wet_boiler(main): return False - if not any(code in r for r in _WET_BOILER_SAP_CODE_RANGES): + if _is_electric_boiler(main): return False - if code in _ELECTRIC_BOILER_SAP_CODE_RANGE: + if not epc.sap_energy_source.gas_connection_available: return False - if not epc.sap_energy_source.mains_gas: - return False - if main.main_fuel_type in _GAS_FUEL_CODES and _already_condensing(code): + if main.main_fuel_type in _GAS_FUEL_CODES and _already_condensing(main): return False return True -def _already_condensing(sap_main_heating_code: int) -> bool: - """Whether an existing gas boiler already meets the new condensing boiler's - winter efficiency (SAP 10.2 Table 4b). Non-Table-4b codes (e.g. solid fuel) - have no comparable efficiency and so are never treated as already-condensing.""" - efficiencies: Optional[tuple[float, float]] = table_4b_seasonal_efficiencies_pct( - sap_main_heating_code - ) - if efficiencies is None: +def _is_wet_boiler(main: MainHeatingDetail) -> bool: + """Whether the main system is a wet boiler the tune-up / boiler-upgrade + generators can evaluate. Identifies by the Table 4a/4b `sap_main_heating_code` + when lodged; when it is absent, falls back to the gov-EPC-API lodgement shape + (a PCDB Table 105 gas/oil boiler index, or an RdSAP `main_heating_category` + of 1/2 "central heating") — the same fallback the calculator's + `cert_to_inputs._is_wet_boiler_main` uses, so the generators evaluate the + same population the calculator already models as wet boilers (#1388 A#2). + Heat pumps (category 4) are never wet boilers.""" + if main.main_heating_category == _HEAT_PUMP_CATEGORY: return False - winter_efficiency_pct: float = efficiencies[0] - return winter_efficiency_pct >= _NEW_BOILER_WINTER_EFFICIENCY_PCT + code: Optional[int] = main.sap_main_heating_code + if code is not None: + return any(code in r for r in _WET_BOILER_SAP_CODE_RANGES) + index: Optional[int] = main.main_heating_index_number + if index is not None and gas_oil_boiler_record(index) is not None: + return True + return main.main_heating_category in _WET_BOILER_CATEGORIES + + +def _is_electric_boiler(main: MainHeatingDetail) -> bool: + """Whether the wet boiler runs on electricity — the electrification path, not + a gas swap, so it is excluded from the gas-boiler upgrade. Identified by the + Table 4a electric-boiler code range when lodged, else by an electricity main + fuel (the code range can't identify an electric boiler lodged with no SAP + code).""" + code: Optional[int] = main.sap_main_heating_code + if code is not None: + return code in _ELECTRIC_BOILER_SAP_CODE_RANGE + return main.main_fuel_type in _ELECTRICITY_FUEL_CODES + + +def _already_condensing(main: MainHeatingDetail) -> bool: + """Whether an existing gas boiler already meets the new condensing boiler's + winter efficiency. Uses the SAP 10.2 Table 4b figure for the lodged + `sap_main_heating_code`; when no code is lodged (the gov-EPC-API PCDB cohort), + reads the boiler's declared winter efficiency from its PCDB Table 105 record + instead. A boiler whose efficiency cannot be resolved (no Table 4b entry, no + PCDB record) is not treated as already-condensing — the upgrade is offered.""" + code: Optional[int] = main.sap_main_heating_code + if code is not None: + efficiencies: Optional[tuple[float, float]] = ( + table_4b_seasonal_efficiencies_pct(code) + ) + if efficiencies is None: + return False + return efficiencies[0] >= _NEW_BOILER_WINTER_EFFICIENCY_PCT + index: Optional[int] = main.main_heating_index_number + if index is not None: + record = gas_oil_boiler_record(index) + if record is not None and record.winter_efficiency_pct is not None: + return record.winter_efficiency_pct >= _NEW_BOILER_WINTER_EFFICIENCY_PCT + return False def _boiler_combi_overlay(epc: EpcPropertyData) -> HeatingOverlay: @@ -514,9 +626,7 @@ def _boiler_cylinder_overlay(epc: EpcPropertyData) -> HeatingOverlay: if _cylinder_under_insulated(sap_heating.cylinder_insulation_thickness_mm): jacket_type = _CYLINDER_JACKET_INSULATION_TYPE jacket_thickness_mm = _MIN_CYLINDER_INSULATION_MM - thermostat: Optional[str] = ( - "Y" if sap_heating.cylinder_thermostat != "Y" else None - ) + thermostat: Optional[str] = "Y" if sap_heating.cylinder_thermostat != "Y" else None return HeatingOverlay( main_fuel_type=_MAINS_GAS_FUEL, heat_emitter_type=_RADIATOR_EMITTER, @@ -584,9 +694,11 @@ def _control_features(main: MainHeatingDetail) -> tuple[bool, bool, bool]: the control is improvable anyway.""" control = main.main_heating_control code: Optional[int] = control if isinstance(control, int) else None - return _CONTROL_FEATURES_BY_CODE.get(code, (False, False, False)) if ( - code is not None - ) else (False, False, False) + return ( + _CONTROL_FEATURES_BY_CODE.get(code, (False, False, False)) + if (code is not None) + else (False, False, False) + ) def _cylinder_fix_needs(epc: EpcPropertyData) -> tuple[bool, bool]: @@ -642,23 +754,37 @@ def _ashp_option( epc: EpcPropertyData, products: ProductRepository, restrictions: PlanningRestrictions, + design_heat_loss_kw: Optional[float] = None, ) -> Optional[MeasureOption]: """The air-source heat-pump bundle, offered for any non-flat house/bungalow - that is not listed/heritage and not already a heat pump.""" + that is not listed/heritage and not already a heat pump. The pump is sized to + ``design_heat_loss_kw`` (ADR-0049), falling back to the floor-area proxy.""" if not _ashp_eligible(epc, restrictions): return None + # Size the pump to the dwelling: the calculator's design heat loss when the + # caller threaded it, else the floor-area proxy (ADR-0049). + heat_loss_kw: float = ( + design_heat_loss_kw + if design_heat_loss_kw is not None + else epc.total_floor_area_m2 * _KW_PER_M2 + ) + sized_overlay: HeatingOverlay = replace( + _ASHP_OVERLAY, main_heating_index_number=select_ashp_pcdb_id(heat_loss_kw) + ) # Cost is composed per-dwelling from the rate sheet (ADR-0025), not the # single catalogue scalar; the catalogue row is read only for its id, so an # absent ASHP row must not suppress the bundle — it just carries no id. product: Optional[Product] = products.get_optional(_ASHP_MEASURE_TYPE) - cost: Cost = Products().ashp_bundle_cost(ashp_cost_inputs(epc)) + cost: Cost = Products().ashp_bundle_cost( + ashp_cost_inputs(epc, design_heat_loss_kw=heat_loss_kw) + ) return MeasureOption( measure_type=_ASHP_MEASURE_TYPE, description=( "Replace the heating with an air-source heat pump, time-and-" "temperature-zone controls and a heat-pump hot-water cylinder" ), - overlay=EpcSimulation(heating=_ASHP_OVERLAY), + overlay=EpcSimulation(heating=sized_overlay), cost=cost, material_id=product.id if product is not None else None, ) @@ -670,10 +796,14 @@ def _ashp_eligible(epc: EpcPropertyData, restrictions: PlanningRestrictions) -> installability — the Optimiser owns the economics (ADR-0024), so floor area, built form, fuel, and fabric are deliberately not gates. A conservation area does not exclude ASHP (offered with a planning caveat); a listed/heritage - protection (`blocks_internal`) does.""" + protection (`blocks_internal`) does. Community heating (SAP Table 4a codes + 301–304 or category 6) is excluded by topology: replacing a shared heat + network with an individual ASHP is never appropriate.""" main: MainHeatingDetail = epc.sap_heating.main_heating_details[0] if main.main_heating_category == _HEAT_PUMP_CATEGORY: return False + if is_heat_network_main(main): + return False if restrictions.blocks_internal: return False return _is_house_or_bungalow(epc) @@ -714,14 +844,18 @@ def _hhr_storage_option( def _hhr_storage_eligible(epc: EpcPropertyData) -> bool: - """HHR storage suits an electrically-heated or off-gas dwelling, unless it is - already HHR or a heat pump (translated from legacy `HeatingRecommender. - is_high_heat_retention_valid`, which keyed on description strings).""" + """HHR storage suits a non-gas-fuel dwelling, unless it is already HHR or a + heat pump. Eligibility keys on the heating *fuel* (main_fuel_type not in + _GAS_FUEL_CODES), not the gas-connection flag — a dwelling on a gas street + with electric or oil heating has gas_connection_available=True but still + qualifies. Community heating (SAP Table 4a codes 301–304 or category 6) is + excluded by topology regardless of fuel: ripping out a shared heat network + to install storage heaters is never appropriate.""" main: MainHeatingDetail = epc.sap_heating.main_heating_details[0] if main.sap_main_heating_code == _HHR_STORAGE_SAP_CODE: return False if main.main_heating_category == _HEAT_PUMP_CATEGORY: return False - off_gas: bool = not epc.sap_energy_source.mains_gas - electric_main: bool = main.main_fuel_type == _ELECTRICITY_FUEL - return electric_main or off_gas + if is_heat_network_main(main): + return False + return main.main_fuel_type not in _GAS_FUEL_CODES diff --git a/domain/modelling/generators/roof_recommendation.py b/domain/modelling/generators/roof_recommendation.py index 2595e3f93..5b234a41d 100644 --- a/domain/modelling/generators/roof_recommendation.py +++ b/domain/modelling/generators/roof_recommendation.py @@ -1,16 +1,18 @@ """The roof Recommendation Generator. Dispatches the MAIN roof to its single applicable insulation Measure by roof -type (ADR-0021): a sloping ceiling (rafters, 100 mm), or — the fallback — a -loft / thatched / unlodged pitched roof (joists, 300 mm); a no-access roof gets -nothing. Each emits one "Roof" Recommendation whose Option carries the +type (ADR-0021): a sloping or vaulted ceiling (rafters, 100 mm), a flat roof +(200 mm), or — the fallback — a loft / thatched / unlodged pitched roof +(joists, 300 mm); a no-access roof gets nothing. The pitched branches resolve +a lodged sentinel thickness ("ND" / nothing) to the age band's as-built state +(ADR-0047). Each emits one "Roof" Recommendation whose Option carries the insulation Simulation Overlay (raising `roof_insulation_thickness`) and a priced Cost (roof area x the Product's fully-loaded unit cost, with its contingency). -Flat-roof and room-in-roof branches land in later slices. No scoring, no -persistence — impact is produced later by scoring (ADR-0016). +The room-in-roof branch lands in a later slice. No scoring, no persistence — +impact is produced later by scoring (ADR-0016). """ -from typing import Optional +from typing import Final, Optional, Union from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, @@ -24,15 +26,42 @@ from repositories.product.product_repository import ProductRepository _LOFT_MEASURE_TYPE = MeasureType.LOFT_INSULATION _SLOPING_CEILING_MEASURE_TYPE = MeasureType.SLOPING_CEILING_INSULATION -# RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft. The -# Elmhurst mapper resolves "As Built" to 0 for pitched/sloping/loft roofs. -_ROOF_UNINSULATED_MM = 0 # Recommended loft-insulation depth (mm). Elmhurst re-lodges a loft-insulation # measure at 300 mm; pinning the before→after cascade (000490/001431) requires # the overlay to match that depth exactly (see test_elmhurst_cascade_pins). _RECOMMENDED_LOFT_THICKNESS_MM = 300 # Recommended sloping-ceiling depth (mm); Elmhurst re-lodges 100 mm (ADR-0021). _RECOMMENDED_SLOPING_CEILING_THICKNESS_MM = 100 +# Age bands whose as-built pitched roof is uninsulated (ADR-0047: A-D +# uninsulated, E-F limited insulation, G+ insulated) — the resolution for a +# cert lodging the "ND" (Not Defined) sentinel or no thickness at all. +_AS_BUILT_UNINSULATED_AGE_BANDS: Final[frozenset[str]] = frozenset( + {"A", "B", "C", "D"} +) + + +def _pitched_roof_is_uninsulated( + thickness: Union[str, int, None], age_band: Optional[str] +) -> bool: + """The pitched-roof uninsulated trigger, sentinel-aware (ADR-0047). + + `roof_insulation_thickness` lodges numerics AND string sentinels; the + sentinels are data, not noise, and must never make an undefined cert MORE + eligible than an explicitly-lodged one. An explicit 0 is uninsulated; the + "ND" (Not Defined) sentinel — or nothing lodged — resolves to the age + band's as-built state (only A-D built without pitched-roof insulation). + Anything else ("NI" = insulation present at unknown thickness per RdSAP 10 + §5.11.4, or a measured depth) means insulation is there. Eligibility only + — the calculator resolves the same sentinels separately for the U-cascade + (deliberate two-home split, ADR-0047).""" + if isinstance(thickness, int): + return thickness == 0 + if thickness is None or thickness == "ND": + return ( + age_band is not None + and age_band.upper() in _AS_BUILT_UNINSULATED_AGE_BANDS + ) + return False _FLAT_ROOF_MEASURE_TYPE = MeasureType.FLAT_ROOF_INSULATION # Recommended flat-roof depth (mm); Elmhurst re-lodges 200 mm (ADR-0021). _RECOMMENDED_FLAT_ROOF_THICKNESS_MM = 200 @@ -68,8 +97,13 @@ def recommend_roof_insulation( # plain pitched loft, a thatched roof (the covering doesn't block insulating # the loft floor), and an unlodged roof type (the modal UK case), matching # the pre-dispatcher behaviour of firing on `roof_insulation_thickness == 0`. - if "sloping ceiling" in roof_type: - if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM: + # A vaulted ceiling follows the slope like a sloping ceiling (no loft + # void) — same disjunction the calculator's `is_sloping_ceiling` uses + # (heat_transmission.py), so generator and scorer read one story (ADR-0047). + if "sloping ceiling" in roof_type or "vaulted" in roof_type: + if not _pitched_roof_is_uninsulated( + main.roof_insulation_thickness, main.construction_age_band + ): return None return _roof_recommendation( epc, @@ -95,7 +129,20 @@ def recommend_roof_insulation( if "no access" in roof_type: return None # the roof void can't be reached to insulate it - if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM: + # Sentinel resolution needs a KNOWN pitched/thatched roof (ADR-0047). An + # unlodged type reaching this fallback can be a party ceiling — gov-API + # codes 6/7 ("(another dwelling above)" / insulated thatch) map the type + # to None and real code-7 certs lodge "ND" — with zero roof heat loss + # (RdSAP 10 Table 18), so only an explicit 0 may trigger it. + known_pitched: bool = "pitched" in roof_type or "thatch" in roof_type + uninsulated: bool = ( + _pitched_roof_is_uninsulated( + main.roof_insulation_thickness, main.construction_age_band + ) + if known_pitched + else main.roof_insulation_thickness == 0 + ) + if not uninsulated: return None return _roof_recommendation( epc, diff --git a/domain/modelling/generators/solid_wall_recommendation.py b/domain/modelling/generators/solid_wall_recommendation.py index dff55b669..32cee3437 100644 --- a/domain/modelling/generators/solid_wall_recommendation.py +++ b/domain/modelling/generators/solid_wall_recommendation.py @@ -26,6 +26,7 @@ from domain.building_geometry import gross_heat_loss_wall_area from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.measure_type import MeasureType from domain.modelling.recommendation import Cost, MeasureOption, Recommendation +from domain.modelling.generators.wall_u_value_gate import wall_insulation_lowers_u from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation from repositories.product.product_repository import ProductRepository @@ -158,6 +159,15 @@ def recommend_solid_wall( measure_type for measure_type in measure_types if _allowed(measure_type, restrictions, is_flat) + # Wall U-Value Gate (ADR-0051): withhold an Option that cannot lower + # the wall's derived U — e.g. an at-regs modern age band, where the + # as-built and insulated cascades resolve to the same value. + and wall_insulation_lowers_u( + main, + epc.country_code, + insulation_type=_INSULATION_TYPE[measure_type], + insulation_thickness_mm=_SOLID_WALL_INSULATION_MM, + ) ) if not allowed: return None diff --git a/domain/modelling/generators/wall_recommendation.py b/domain/modelling/generators/wall_recommendation.py index 2c5622413..b3182ba28 100644 --- a/domain/modelling/generators/wall_recommendation.py +++ b/domain/modelling/generators/wall_recommendation.py @@ -12,6 +12,7 @@ from datatypes.epc.domain.epc_property_data import ( EpcPropertyData, ) from domain.building_geometry import gross_heat_loss_wall_area +from domain.modelling.generators.wall_u_value_gate import wall_insulation_lowers_u from domain.modelling.measure_type import MeasureType from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation @@ -46,6 +47,14 @@ def recommend_cavity_wall( ): return None + # Wall U-Value Gate (ADR-0051): withhold the fill when it cannot lower the + # wall's derived U — e.g. an at-regs modern age band, where the as-built and + # filled-cavity cascades resolve to the same (or a no-better) value. + if not wall_insulation_lowers_u( + main, epc.country_code, insulation_type=_FILLED_CAVITY + ): + return None + product = products.get(_CAVITY_MEASURE_TYPE) wall_area: float = gross_heat_loss_wall_area(epc, BuildingPartIdentifier.MAIN) cost = Cost( diff --git a/domain/modelling/generators/wall_u_value_gate.py b/domain/modelling/generators/wall_u_value_gate.py new file mode 100644 index 000000000..fbbe3248c --- /dev/null +++ b/domain/modelling/generators/wall_u_value_gate.py @@ -0,0 +1,80 @@ +"""The Wall U-Value Gate (ADR-0051). + +A wall insulation Option is offered only when it would actually lower the main +wall's derived U-value: on a modern construction age band RdSAP's Table 6-10 +cascade assigns an as-built wall its at-regs U-value, and the post-measure +state resolves to the *same* value — the measure costs thousands for +0.00 SAP +(property 711795, PRD #1435 WS2: timber frame at age band L, 0.280 → 0.280). + +The comparison uses a deliberately thin `u_wall` parameter mapping (the +`envelope.py` style — construction, age band, insulation type/thickness, +documentary wall thickness), omitting the calculator's rich extras (surveyor +description, dry-lining, lodged `wall_u_value`). The omissions bias the gate +toward OFFERING — both sides are computed identically, so a genuine no-op still +compares equal — never toward suppressing a real measure. +""" + +from typing import Any, Final, Optional + +from datatypes.epc.domain.epc_property_data import SapBuildingPart +from domain.sap10_ml.rdsap_uvalues import WALL_UNKNOWN, Country, u_wall + +# Improvements below this are float noise, not a physical change. NOT a +# materiality threshold — how much improvement is worth the cost stays with the +# Optimiser (ADR-0016/0024). +_NOISE_FLOOR_W_PER_M2K: Final[float] = 0.01 + +# RdSAP `wall_insulation_type` 4 = as-built / assumed (no added insulation). +_WALL_INSULATION_NONE: Final[int] = 4 + + +def _int_or_none(value: Any) -> Optional[int]: + return value if isinstance(value, int) else None + + +def _parse_thickness_mm(value: Any) -> Optional[int]: + if value is None or isinstance(value, int): + return value + if isinstance(value, str) and value.strip().isdigit(): + return int(value.strip()) + return None # "NI" / free text — thickness not known + + +def wall_insulation_lowers_u( + main: SapBuildingPart, + country_code: Optional[str], + *, + insulation_type: int, + insulation_thickness_mm: Optional[int] = None, +) -> bool: + """Whether insulating the main wall to ``insulation_type`` (+ optional + thickness) lowers its derived U-value by more than float noise — the Wall + U-Value Gate (ADR-0051).""" + country: Country = Country.from_code(country_code) + age_band = main.construction_age_band + construction: Optional[int] = _int_or_none(main.wall_construction) + if construction == WALL_UNKNOWN: + construction = None + wall_thickness_mm = main.wall_thickness_mm + current_type: Optional[int] = _int_or_none(main.wall_insulation_type) + current_u: float = u_wall( + country, + age_band, + construction, + _parse_thickness_mm(main.wall_insulation_thickness), + insulation_present=( + current_type is not None and current_type != _WALL_INSULATION_NONE + ), + wall_insulation_type=current_type, + wall_thickness_mm=wall_thickness_mm, + ) + post_u: float = u_wall( + country, + age_band, + construction, + insulation_thickness_mm, + insulation_present=True, + wall_insulation_type=insulation_type, + wall_thickness_mm=wall_thickness_mm, + ) + return current_u - post_u > _NOISE_FLOOR_W_PER_M2K diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 93875bbfd..216463dab 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -149,7 +149,23 @@ _SAP_HEATING_FIELDS: tuple[str, ...] = ( "cylinder_thermostat", "immersion_heating_type", ) -_ENERGY_SOURCE_FIELDS: tuple[str, ...] = ("meter_type", "mains_gas") +_ENERGY_SOURCE_FIELDS: tuple[str, ...] = ("meter_type", "gas_connection_available") + + +def _is_off_peak_meter(meter_type: object) -> bool: + """True iff the meter resolves to an off-peak Table 12a tariff (not the + STANDARD single-rate column). Unparseable / absent meters count as not + off-peak so a coherent override meter still applies to them.""" + from domain.sap10_calculator.exceptions import UnmappedSapCode + from domain.sap10_calculator.tables.table_12a import ( + Tariff, + tariff_from_meter_type, + ) + + try: + return tariff_from_meter_type(meter_type) is not Tariff.STANDARD + except UnmappedSapCode: + return False def _fold_heating(epc: EpcPropertyData, overlay: HeatingOverlay) -> None: @@ -181,8 +197,25 @@ def _fold_heating(epc: EpcPropertyData, overlay: HeatingOverlay) -> None: epc.has_hot_water_cylinder = overlay.has_hot_water_cylinder for field_name in _ENERGY_SOURCE_FIELDS: value = getattr(overlay, field_name) - if value is not None: - setattr(epc.sap_energy_source, field_name, value) + if value is None: + continue + # A landlord heating override's assumed meter (Dual for off-peak + # systems) is a coherent default, not a re-metering of the cert: when it + # opts in (`keep_existing_off_peak_meter`), don't downgrade a cert that + # already lodges a MORE off-peak meter (e.g. a 24-hour all-low tariff) to + # the overlay's 7-hour E7 — keep the cert's (more specific) one. Single/ + # unknown existing meters still receive the off-peak meter, and a switch + # to single-rate still resets it (its desired value isn't off-peak). + # Heating MEASURES leave the flag False — they re-meter for real + # (Elmhurst re-lodges 18-hour → Dual on a storage install). + if ( + field_name == "meter_type" + and overlay.keep_existing_off_peak_meter + and _is_off_peak_meter(value) + and _is_off_peak_meter(epc.sap_energy_source.meter_type) + ): + continue + setattr(epc.sap_energy_source, field_name, value) # `SolarOverlay` fields all live on `sap_energy_source` (the home of the SAP diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 2f6fc94d5..79855940e 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -36,6 +36,14 @@ class BuildingPartOverlay: construction_age_band: Optional[str] = None wall_construction: Optional[int] = None wall_insulation_type: Optional[int] = None + # Disambiguates the RdSAP `wall_construction == 6` code collision: gov-EPC + # code 6 = "Basement wall" but RdSAP `WALL_SYSTEM_BUILT` is also 6, and + # `SapBuildingPart.main_wall_is_basement` falls back to the code-6 heuristic + # when the flag is `None`. A Landlord Override that sets a System-built wall + # (construction 6) must therefore set this `False` so the override isn't + # mis-read as a basement — the overlay-path mirror of the gov-API mapper's + # `_clear_basement_flag_when_system_built`. + wall_is_basement: Optional[bool] = None # Added solid-wall insulation depth (mm) — drives the calculator's Table 6 # bucket / §5.8 documentary U-value for EWI (`wall_insulation_type=1`) and # IWI (`wall_insulation_type=3`); λ defaults to 0.04 W/m·K in the calculator. @@ -146,7 +154,7 @@ class HeatingOverlay: (`water_heating_*`, cylinder size + insulation); - the top-level `EpcPropertyData` — `has_hot_water_cylinder`; - ``sap_energy_source`` — `meter_type` (an off-peak tariff for storage) and - `mains_gas` (cleared when the dwelling goes all-electric). + `gas_connection_available` (cleared when the dwelling goes all-electric). The values are **absolute target states**, not deltas (the bundle replaces the system regardless of the before). A `None` field means "leave the @@ -184,7 +192,14 @@ class HeatingOverlay: has_hot_water_cylinder: Optional[bool] = None # sap_energy_source meter_type: Optional[str] = None - mains_gas: Optional[bool] = None + gas_connection_available: Optional[bool] = None + # A landlord heating-system override DESCRIBES the existing dwelling, so its + # assumed off-peak (`meter_type`) is a coherent default, not a re-metering: + # it must not downgrade a cert that already lodges a MORE off-peak meter + # (e.g. a 24-hour all-low tariff → the overlay's 7-hour E7). A heating + # MEASURE re-meters for real (Elmhurst re-lodges 18-hour → Dual on a storage + # install), so it leaves this False. `_fold_heating` reads it. + keep_existing_off_peak_meter: bool = False @dataclass(frozen=True) diff --git a/domain/property_baseline/calculator_rebaseliner.py b/domain/property_baseline/calculator_rebaseliner.py index 04558ab75..0b9414fdf 100644 --- a/domain/property_baseline/calculator_rebaseliner.py +++ b/domain/property_baseline/calculator_rebaseliner.py @@ -57,7 +57,7 @@ class CalculatorRebaseliner(Rebaseliner): self, property_id: int, effective_epc: "EpcPropertyData", - lodged: Performance, + lodged: Optional[Performance], *, physical_state_changed: bool = False, ) -> RebaselineResult: @@ -87,6 +87,11 @@ class CalculatorRebaseliner(Rebaseliner): reason=reason, sap_result=result, ) + # The pristine-lodged path: not pre-SAP10 and not physical-state-changed, so + # there is a real lodged cert to validate against. ``lodged is None`` only + # happens for a predicted Property, which is physical_state_changed and so + # took the branch above — it never reaches here. + assert lodged is not None self._log_divergence( property_id=property_id, sap_version=sap_version, result=result, lodged=lodged ) diff --git a/domain/property_baseline/property_baseline_performance.py b/domain/property_baseline/property_baseline_performance.py index 6fee98582..a65289bca 100644 --- a/domain/property_baseline/property_baseline_performance.py +++ b/domain/property_baseline/property_baseline_performance.py @@ -15,7 +15,10 @@ class PropertyBaselinePerformance: Holds both halves — ``lodged`` (what the gov register says) and ``effective`` (what the modelling pipeline scored against) — plus the ``rebaseline_reason`` recording *why* they differ (``"none"`` when equal). - Both halves are always populated, even when equal. + Both halves are populated for a Property with a real record of itself; the + ``lodged`` half is ``None`` for a predicted Property, which has no lodged cert + to read a Lodged Performance off — only the Effective half is established then + (ADR-0004 amendment, #1361). Carries the part of the energy block that needs no derivation: annual ``space_heating_kwh`` / ``water_heating_kwh`` read off the EPC's RHI. @@ -25,7 +28,7 @@ class PropertyBaselinePerformance: ran (the stub path produced no ``SapResult`` to price). """ - lodged: Performance + lodged: Optional[Performance] effective: Performance rebaseline_reason: RebaselineReason space_heating_kwh: float diff --git a/domain/property_baseline/rebaseliner.py b/domain/property_baseline/rebaseliner.py index 9171b4bf6..daef0045c 100644 --- a/domain/property_baseline/rebaseliner.py +++ b/domain/property_baseline/rebaseliner.py @@ -62,11 +62,15 @@ class Rebaseliner(ABC): self, property_id: int, effective_epc: EpcPropertyData, - lodged: Performance, + lodged: Optional[Performance], *, physical_state_changed: bool = False, ) -> RebaselineResult: - """Produce Effective Performance. ``physical_state_changed`` is True when + """Produce Effective Performance. ``lodged`` is ``None`` for a predicted + Property — it has no lodged cert, so there is no Lodged Performance to + compare against (which is also why ``physical_state_changed`` is always + True there: the calculator output IS the Effective Performance). + ``physical_state_changed`` is True when the Effective EPC was assembled from something other than a pristine lodged cert — Landlord Overrides, Site Notes, or EPC Prediction moved the physical picture (Rebaselining trigger (b)/(c)) — so the accredited lodged @@ -91,7 +95,7 @@ class StubRebaseliner(Rebaseliner): self, property_id: int, effective_epc: EpcPropertyData, - lodged: Performance, + lodged: Optional[Performance], *, physical_state_changed: bool = False, ) -> RebaselineResult: @@ -108,4 +112,8 @@ class StubRebaseliner(Rebaseliner): "Property needs rebaselining (physical state changed by overrides " "/ prediction); this stub does not run the calculator" ) + # Only a pristine SAP10 cert reaches here, and that always has a Lodged + # Performance — ``lodged is None`` implies a predicted Property, which is + # ``physical_state_changed`` and so raised above. + assert lodged is not None return RebaselineResult(effective=lodged, reason="none", sap_result=None) diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index c30234a7c..34f0f1ce7 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -65,6 +65,11 @@ from domain.sap10_calculator.worksheet.rating import ( _AIR_HEAT_CAPACITY_WH_PER_M3_K: Final[float] = 0.33 _TIME_CONSTANT_DIVISOR_KJ_TO_WH: Final[float] = 3.6 +# SAP design temperature difference (21 C internal - -3.2 C external): the delta +# the design heat loss and Appendix N's PSR are evaluated at (matches +# `_SAP_DESIGN_HEAT_LOSS_DELTA_T_K` in rdsap.cert_to_inputs). +_DESIGN_HEAT_LOSS_DELTA_T_K: Final[float] = 24.2 +_WATTS_PER_KW: Final[float] = 1000.0 # §9a default — used as `CalculatorInputs.energy_requirements` default for # synthetic constructions that bypass cert_to_inputs. All-zero fuel; the @@ -403,6 +408,11 @@ class SapResult: space_heating_kwh_per_yr: float space_cooling_kwh_per_yr: float fabric_energy_efficiency_kwh_per_m2_yr: float + # The dwelling's SAP design heat loss (kW) — the annual-average heat loss + # coefficient at the design temperature difference (24.2 K). Output-only: + # it is the quantity Appendix N's PSR divides a heat pump's rated output by, + # so the ASHP recommendation sizes the pump to the dwelling from it. + design_heat_loss_kw: float main_heating_fuel_kwh_per_yr: float main_2_heating_fuel_kwh_per_yr: float secondary_heating_fuel_kwh_per_yr: float @@ -872,6 +882,9 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: space_heating_kwh_per_yr=space_heating_kwh, space_cooling_kwh_per_yr=space_cooling_kwh, fabric_energy_efficiency_kwh_per_m2_yr=inputs.fabric_energy_efficiency_kwh_per_m2_yr, + design_heat_loss_kw=(sum(monthly_hlc) / 12.0) + * _DESIGN_HEAT_LOSS_DELTA_T_K + / _WATTS_PER_KW, main_heating_fuel_kwh_per_yr=main_fuel_kwh, main_2_heating_fuel_kwh_per_yr=inputs.energy_requirements.main_2_fuel_kwh_per_yr, secondary_heating_fuel_kwh_per_yr=secondary_fuel_kwh, diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 1cdfd98e7..7ebf4d40e 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -62,6 +62,7 @@ from datatypes.epc.domain.epc_property_data import ( SapVentilation, SapWindow, ) +from datatypes.epc.domain.field_mappings import is_heat_network_main from domain.sap10_ml.demand import predicted_hot_water_kwh from domain.sap10_ml.rdsap_uvalues import Country, u_floor @@ -1076,15 +1077,6 @@ _HEAT_NETWORK_DLF_BY_AGE: Final[dict[str, float]] = { _HEAT_NETWORK_DLF_DEFAULT: Final[float] = 1.50 -# SAP 10.2 Table 4a codes for heat-network main heating systems: -# 301 = boiler-driven community heating -# 302 = boiler-driven community heating with CHP -# 303 = community CHP only -# 304 = electric heat-pump community heating -_HEAT_NETWORK_MAIN_CODES: Final[frozenset[int]] = frozenset({301, 302, 303, 304}) -_HEAT_NETWORK_CATEGORY: Final[int] = 6 - - # SAP 10.2 Table 4a (PDF p.164) heat-network heat-source efficiency by # SAP code. Verbatim: # 301 "Boilers (RdSAP)" → 80% @@ -1116,17 +1108,6 @@ _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY: Final[dict[int, float]] = { _HEAT_NETWORK_STANDING_CHARGE_GBP: Final[float] = 120.0 -def _is_heat_network_main(main: Optional[MainHeatingDetail]) -> bool: - """True when the cert's main heating is a heat network — either by - SAP code (Table 4a 301-304) or by `main_heating_category` (6).""" - if main is None: - return False - code = main.sap_main_heating_code - if isinstance(code, int) and code in _HEAT_NETWORK_MAIN_CODES: - return True - return main.main_heating_category == _HEAT_NETWORK_CATEGORY - - def _heat_network_heat_source_efficiency_scaling( main: Optional[MainHeatingDetail], ) -> float: @@ -1138,7 +1119,7 @@ def _heat_network_heat_source_efficiency_scaling( factor by 1/heat_source_eff. Returns 1.0 for code 302 (CHP+boilers — separate split-formula path) and non-heat-network mains. """ - if not _is_heat_network_main(main): + if not is_heat_network_main(main): return 1.0 code = main.sap_main_heating_code if main is not None else None if not isinstance(code, int): @@ -1159,7 +1140,7 @@ def _is_heat_network_electric_main(main: Optional[MainHeatingDetail]) -> bool: scaling. Non-electric heat networks (gas/oil/coal boilers, codes 51/53/54) have no monthly factor set and keep the annual Table 12 value.""" - if not _is_heat_network_main(main): + if not is_heat_network_main(main): return False return co2_monthly_factors_kg_per_kwh(_main_fuel_code(main)) is not None @@ -1293,7 +1274,7 @@ def _heat_network_distribution_electricity( per the §C3.2 text — verified line-by-line against the community- heating corpus worksheets. We mirror the spec text (space + water). """ - if not _is_heat_network_main(main) or efficiency <= 0.0: + if not is_heat_network_main(main) or efficiency <= 0.0: return None distribution_monthly_kwh = tuple( _HEAT_NETWORK_PUMPING_FRACTION_OF_HEAT * (sh + hw) / efficiency @@ -1995,7 +1976,7 @@ def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool: if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES: return False main = _water_heating_main(epc) - if not _is_heat_network_main(main): + if not is_heat_network_main(main): return False code = main.sap_main_heating_code if main is not None else None return isinstance(code, int) and ( @@ -2026,7 +2007,7 @@ def _heat_network_standing_charge_gbp( gas community) is not a Table-32 gas code, so `_is_gas_code` returned False and the standing came out £0 — cert 9390 lost the whole £120. """ - if _is_heat_network_main(main): + if is_heat_network_main(main): return _HEAT_NETWORK_STANDING_CHARGE_GBP if _is_community_heating_hw_from_main(epc): return _HEAT_NETWORK_STANDING_CHARGE_GBP / 2.0 @@ -2062,7 +2043,7 @@ def _main_heating_detail_efficiency( eff = pcdb_main.winter_efficiency_pct / 100.0 else: eff = seasonal_efficiency(main_code, main_category, main_fuel) - if _is_heat_network_main(main): + if is_heat_network_main(main): primary_age = _dwelling_age_band(epc) # Worksheet (307): heat required = demand × (305) × (306 DLF), so the # delivered-per-fuel efficiency carries 1 / ((305) charging factor × @@ -2307,7 +2288,7 @@ def _heat_network_community_fuel_code( 42/43/44 (the same rows the backwards-compat enum codes 11/12/13 map to). Cert 8536 (biomass community, SAP code 301) closed -17.2 → -6.5. - Gating on `_is_heat_network_main` keeps the bare Table-32 code 30 the + Gating on `is_heat_network_main` keeps the bare Table-32 code 30 the cascade uses internally as grid electricity untouched on non-community certs (e.g. cert 2211 whose whc=999 default writes `water_heating_fuel=30`). @@ -2317,7 +2298,7 @@ def _heat_network_community_fuel_code( gap loudly instead of silently mis-pricing it as grid electricity, per the strict-raise principle ([[reference-unmapped-sap-code]]). """ - if fuel not in _API_COMMUNITY_COLLISION_FUELS or not _is_heat_network_main(main): + if fuel not in _API_COMMUNITY_COLLISION_FUELS or not is_heat_network_main(main): return None translated = API_FUEL_TO_TABLE_12.get(fuel) if translated is None: @@ -2420,7 +2401,7 @@ def _heat_network_factor_fuel_code( (367) CO2 factor 0.2100, (467) PE factor 1.1300. """ fuel = _main_fuel_code(main) - if fuel is None or not _is_heat_network_main(main): + if fuel is None or not is_heat_network_main(main): return fuel return _table_12_factor_fuel_code(fuel) @@ -2593,6 +2574,17 @@ _ELECTRIC_ROOM_HEATER_SAP_CODES: Final[frozenset[int]] = frozenset( {691, 692, 693, 694, 699} ) +# SAP 10.2 Table 4a electric STORAGE-heater codes (PDF p.169, codes 401-409: +# old-large-volume / slimline / fan / integrated-storage-direct 408 / high-heat- +# retention 409, etc.). Mirrors the storage subset of `table_12a._RULE_2_STORAGE_ +# CODES` (heaters only — excludes underfloor 421/422 and storage boilers 193/195, +# which have their own Table 12a rows). Used to classify storage heaters by SAP +# code as well as RdSAP `main_heating_category == 7`, so a storage-coded system +# is priced off-peak even when its category is stale or inconsistent. +_ELECTRIC_STORAGE_HEATER_SAP_CODES: Final[frozenset[int]] = frozenset( + range(401, 410) +) + # SAP 10.2 Table 4a electric boilers (PDF p.170, codes 191-196) → their # Table 12a Grid 1 SH rows (PDF p.191). NOTE the boiler families do NOT all # share a row — read the spec exactly: @@ -2611,6 +2603,23 @@ _ELECTRIC_STORAGE_BOILER_CODES: Final[frozenset[int]] = frozenset( {193, 194, 195, 196} ) _ELECTRIC_CPSU_CODES: Final[frozenset[int]] = frozenset({192}) +# Electric underfloor (Table 4a 421-425) — SAP 10.2 Table 12a Grid 1 (PDF +# p.184) names the codes in its row labels: +# 422/423 "Integrated (storage+direct-acting) systems (applies to storage +# heaters 408 and underfloor heating 422 and 423)" → 7-hour 0.20 +# 424/425 "Underfloor heating (in screed above insulation, in timber floor +# or immediately below floor covering)" → 7-hour 0.90 / 10-hour 0.50 +# 421 "in concrete slab (off-peak only)" is named in NEITHER underfloor +# row — the slab is the storage medium, so it takes the "Other +# storage heaters" row (0.00 at 7-hour or 24-hour), consistent with +# §12 Rule 2 listing 421 among the off-peak-implying storage codes. +# Before these branches every underfloor code fell to the None all-low-rate +# fallback, over-crediting a 7-hour screed dwelling's space heat at 5.50p/kWh +# instead of the blended ~14.3p (ADR-0050 deferred follow-up; 22 live +# 7-hour-metered underfloor dwellings on portfolio 796). +_SLAB_UNDERFLOOR_CODES: Final[frozenset[int]] = frozenset({421}) +_INTEGRATED_UNDERFLOOR_CODES: Final[frozenset[int]] = frozenset({422, 423}) +_SCREED_OR_TIMBER_UNDERFLOOR_CODES: Final[frozenset[int]] = frozenset({424, 425}) def _table_12a_system_for_main( @@ -2630,7 +2639,9 @@ def _table_12a_system_for_main( - ASHP / GSHP (codes 211-224, 521-524, PCDB index) — wired - Storage heaters (cat 7): 408 → INTEGRATED_STORAGE_DIRECT (0.20), all others → OTHER_STORAGE_HEATERS (0.00) — wired - - Underfloor heating (421-422) — TODO + - Underfloor heating: 421 slab → OTHER_STORAGE_HEATERS (0.00), + 422/423 integrated → INTEGRATED_STORAGE_DIRECT (0.20), + 424/425 screed/timber → UNDERFLOOR_HEATING (0.90/0.50) — wired - Direct-acting electric boiler (191) → 0.90/0.50; electric storage boilers (193/194/195/196) → 0.00 — wired - Electric CPSU (192) — Appendix F high-rate cascade still TODO @@ -2642,6 +2653,39 @@ def _table_12a_system_for_main( main.main_heating_index_number is not None and heat_pump_record(main.main_heating_index_number) is not None ) + # Electric UNDERFLOOR heating — routed by the Table 4a code per the Grid 1 + # row labels (see `_SLAB_UNDERFLOOR_CODES` et al. above), checked BEFORE + # the storage branch so a stale category 7 cannot drag a screed system + # (0.90 at 7-hour) down to the all-night storage row — the code is more + # authoritative than the coarser category, mirroring the storage-over- + # room-heater precedent below. Gated on `_is_electric_main` belt-and-braces. + if _is_electric_main(main) and code is not None: + if code in _SCREED_OR_TIMBER_UNDERFLOOR_CODES: + return Table12aSystem.UNDERFLOOR_HEATING + if code in _INTEGRATED_UNDERFLOOR_CODES: + return Table12aSystem.INTEGRATED_STORAGE_DIRECT + if code in _SLAB_UNDERFLOOR_CODES: + return Table12aSystem.OTHER_STORAGE_HEATERS + # Electric STORAGE heaters — SAP 10.2 Table 12a Grid 1 (PDF p.191). + # Detected by RdSAP main_heating_category 7 OR, authoritatively, by a Table + # 4a electric-storage-heater SAP code (401-409). Keyed on the CODE as well as + # the category, and checked BEFORE the room-heater branch below, so a storage + # system whose category is stale or inconsistent — e.g. a recommendation + # `HeatingOverlay` that sets `sap_main_heating_code=409` (HHR storage) but + # leaves the pre-existing electric-room-heater `main_heating_category=10` — is + # still priced off-peak here instead of being misclassified as direct-acting + # (1.00 high-rate → 100% peak) by the category-10 gate. A specific Table 4a + # code is more authoritative than the coarser category. Code 408 is an + # "Integrated (storage + direct-acting) system" → 0.20 SH high-rate fraction + # at 7-hour; every other storage code → "Other storage heaters" → 0.00 + # (charged wholly off-peak). Gated on `_is_electric_main` belt-and-braces. + if _is_electric_main(main) and ( + main.main_heating_category == 7 + or (code is not None and code in _ELECTRIC_STORAGE_HEATER_SAP_CODES) + ): + if code == 408: + return Table12aSystem.INTEGRATED_STORAGE_DIRECT + return Table12aSystem.OTHER_STORAGE_HEATERS # Electric room heaters are direct-acting electric → SAP 10.2 Table # 12a Grid 1 (PDF p.191) "Other systems including direct-acting # electric" row (7-hour high-rate fraction 1.00, 10-hour 0.50). @@ -2653,9 +2697,9 @@ def _table_12a_system_for_main( # main_heating_category 1, NOT 10, so the category-only gate missed it # and it fell through to None → 100% off-peak LOW rate, billing # direct-acting heaters as if they charged overnight like storage - # (cert 2958 +32.2 SAP, the worst over-rate in the sample). Distinct - # from electric STORAGE heaters (category 7), which DO charge off-peak - # and correctly fall through to None here (→ 100% low rate). Gated on + # (cert 2958 +32.2 SAP, the worst over-rate in the sample). Electric + # STORAGE heaters (category 7 / codes 401-409) are handled by the + # storage-heater branch above, so they never reach here. Gated on # `_is_electric_main` so a non-electric room heater (gas / solid-fuel # cat 10) is excluded; all callers already pre-gate on electric. if _is_electric_main(main) and ( @@ -2684,16 +2728,6 @@ def _table_12a_system_for_main( 211 <= code <= 217 or 221 <= code <= 227 or 521 <= code <= 524 ): return Table12aSystem.ASHP_OTHER - # Electric STORAGE heaters (RdSAP main_heating_category 7) — SAP 10.2 - # Table 12a Grid 1 (PDF p.191). Code 408 is an "Integrated (storage + - # direct-acting) system" → 0.20 SH high-rate fraction at 7-hour; every - # other storage code is "Other storage heaters" → 0.00 (charged wholly - # off-peak, the same 100%-low-rate the None fallback already gave). - # Gated on `_is_electric_main` belt-and-braces (all callers pre-gate). - if main.main_heating_category == 7 and _is_electric_main(main): - if code == 408: - return Table12aSystem.INTEGRATED_STORAGE_DIRECT - return Table12aSystem.OTHER_STORAGE_HEATERS # Electric boilers (Table 4a codes 191-196) — resolve the misleading TODO # that lumped them as one "direct-acting" family. They split across THREE # distinct Table 12a Grid 1 rows (see `_DIRECT_ACTING_ELECTRIC_BOILER_CODES` @@ -2886,6 +2920,15 @@ def _hot_water_fuel_cost_gbp_per_kwh( ) blended = high_frac * high_rate + (1.0 - high_frac) * low_rate return blended * _PENCE_TO_GBP + if water_heating_code in _INSTANTANEOUS_WATER_CODES: + # SAP 10.2 §12 (PDF line 2680): the Table-13 off-peak on-peak HW + # split is keyed on the CYLINDER size — it presumes stored water + # charged overnight. An instantaneous point-of-use heater (WHC + # 907/909, no cylinder) heats on demand and cannot shift to the + # off-peak window, so 100% bills at the HIGH rate (Table 12a WH + # high-rate fraction 1.0), not the off-peak low rate. + high_rate, _low = _tariff_high_low_rates_p_per_kwh(tariff) + return high_rate * _PENCE_TO_GBP return _off_peak_low_rate_gbp_per_kwh(tariff) if water_heating_fuel is not None: return prices.unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP @@ -2940,6 +2983,11 @@ def _hot_water_high_rate_fraction( single_immersion=effective_single, tariff=tariff, ) + if water_heating_code in _INSTANTANEOUS_WATER_CODES: + # Instantaneous point-of-use electric DHW (WHC 907/909) has no + # cylinder to charge overnight — it heats on demand, wholly at the + # high/day rate (see `_hot_water_fuel_cost_gbp_per_kwh`). + return 1.0 return 0.0 @@ -5305,6 +5353,35 @@ _AGE_BANDS_A_TO_E: Final[frozenset[str]] = frozenset({"A", "B", "C", "D", "E"}) _SUSPENDED_TIMBER_FLOOR_TYPE: Final[str] = "Suspended timber" _GROUND_FLOOR_TYPE: Final[str] = "Ground floor" _FLOOR_U_SEALED_THRESHOLD: Final[float] = 0.5 +# Table 19 footnote (1): age bands whose default floor_construction is +# suspended timber when the construction is unknown. Mirrors +# `_SUSPENDED_TIMBER_DEFAULT_BANDS` in rdsap_uvalues.py — duplicated +# locally rather than cross-imported (private symbol) per this module's +# existing convention (see `_main_floor_u_value`). +_TABLE_19_FOOTNOTE_1_SUSPENDED_TIMBER_BANDS: Final[frozenset[str]] = frozenset({"A", "B"}) + + +def _effective_floor_description( + epc: EpcPropertyData, main: SapBuildingPart, +) -> Optional[str]: + """Mirror `heat_transmission_section_from_cert`'s + `effective_floor_description` rule: the per-bp `floor_construction_type` + lodgement ("Suspended timber" / "Solid") takes precedence over the + global `epc.floors[].description` since it's the explicit per-part + Elmhurst Summary §3/§9 lodgement. Falls back to the joined global + description when the per-bp field is unlodged (the gov-API mapper + often leaves it `None`). Inlined (vs importing from + heat_transmission) to keep cert_to_inputs free of cross-module + private symbol imports. + """ + if main.floor_construction_type: + return main.floor_construction_type + descs = [ + d for d in + (getattr(f, "description", None) for f in (epc.floors or [])) + if d + ] + return " | ".join(descs) if descs else None def _main_floor_u_value(epc: EpcPropertyData) -> Optional[float]: @@ -5341,21 +5418,6 @@ def _main_floor_u_value(epc: EpcPropertyData) -> Optional[float]: int(raw_floor_ins) if isinstance(raw_floor_ins, (int, float)) else (0 if raw_floor_ins == "NI" else None) ) - # Mirror heat_transmission's `effective_floor_description`: the per-bp - # `floor_construction_type` takes precedence over a joined - # `epc.floors[].description` since the per-part lodgement is the - # explicit Elmhurst Summary §3/§9 surface. Inline the join (vs - # importing from heat_transmission) to keep cert_to_inputs free of - # cross-module private symbol imports. - if main.floor_construction_type: - effective_floor_description = main.floor_construction_type - else: - descs = [ - d for d in - (getattr(f, "description", None) for f in (epc.floors or [])) - if d - ] - effective_floor_description = " | ".join(descs) if descs else None return u_floor( country=Country.from_code(epc.country_code) if epc.country_code else None, age_band=main.construction_age_band, @@ -5364,10 +5426,49 @@ def _main_floor_u_value(epc: EpcPropertyData) -> Optional[float]: area_m2=ground_fd.total_floor_area_m2, perimeter_m=ground_fd.heat_loss_perimeter_m, wall_thickness_mm=main.wall_thickness_mm, - description=effective_floor_description, + description=_effective_floor_description(epc, main), ) +def _floor_construction_is_suspended_timber( + epc: EpcPropertyData, main: SapBuildingPart, +) -> bool: + """RdSAP 10 Table 19 footnote (1) construction resolution for the §5 + (12) floor-infiltration rule. Tri-state, mirroring `u_floor`'s + `_floor_is_suspended_from_description` / `_SUSPENDED_TIMBER_DEFAULT_ + BANDS` cascade (rdsap_uvalues.py) so the (12) infiltration rule and + the floor U-value calc agree on which floors are suspended timber: + + 1. An explicit construction lodgement (`_effective_floor_description` + — per-bp `floor_construction_type` else the joined + `epc.floors[].description`) decides: "Suspended, ..." is timber + UNLESS it's "Suspended, not timber" (suspended concrete etc. has + no floorboard-gap infiltration); "Solid, ..." is not suspended. + 2. No description signal AND no lodged `floor_construction` code at + all → Table 19 footnote (1): unknown construction defaults to + suspended timber only for age bands A/B. + """ + desc = _effective_floor_description(epc, main) + if desc is not None: + d = desc.strip().lower() + if d.startswith("suspended"): + return "not timber" not in d + if d.startswith("solid"): + return False + ground_fd = next( + (fd for fd in main.sap_floor_dimensions if fd.floor == 0), + main.sap_floor_dimensions[0] if main.sap_floor_dimensions else None, + ) + construction_known = ( + ground_fd is not None + and _int_or_none(ground_fd.floor_construction) is not None + ) + if construction_known: + return False + age = (main.construction_age_band or "").strip().upper() + return age in _TABLE_19_FOOTNOTE_1_SUSPENDED_TIMBER_BANDS + + def _has_suspended_timber_floor_per_spec( epc: EpcPropertyData, ) -> tuple[bool, bool]: @@ -5393,9 +5494,13 @@ def _has_suspended_timber_floor_per_spec( infiltration 0.2. The rule only applies when the Main bp's lowest floor is a - "Ground floor" with "Suspended timber" construction. All other - combinations fall through to `(False, False)` and the cascade - enters 0 for (12). + "Ground floor" with "Suspended timber" construction. Construction is + resolved the same tri-state way `u_floor` resolves it (RdSAP 10 + Table 19 footnote 1): the explicit per-bp `floor_construction_type` / + joined `epc.floors[].description` lodgement, else — when neither + lodges a construction signal — the footnote-1 age-band default + (suspended timber for bands A/B only). All other combinations fall + through to `(False, False)` and the cascade enters 0 for (12). """ if not epc.sap_building_parts: return False, False @@ -5405,7 +5510,7 @@ def _has_suspended_timber_floor_per_spec( return True, False if main.floor_type != _GROUND_FLOOR_TYPE: return False, False - if main.floor_construction_type != _SUSPENDED_TIMBER_FLOOR_TYPE: + if not _floor_construction_is_suspended_timber(epc, main): return False, False age = (main.construction_age_band or "").strip().upper() if age in _AGE_BANDS_F_TO_M: @@ -5877,6 +5982,10 @@ _CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1 # which SAP 10.2 Table 2 Note 1 gives a SEPARATE (higher) loss factor # L = 0.005 + 1.76 / (t + 12.8) vs the factory L = 0.005 + 0.55 / (t+4). _CYLINDER_INSULATION_TYPE_LOOSE_JACKET: Final[int] = 2 +# RdSAP 10 field 7-11 code 0 = NO insulation. SAP 10.2 Table 2 has no +# separate uninsulated row — the loose-jacket formula at t = 0 IS the +# uninsulated factor (L = 0.005 + 1.76 / 12.8 = 0.1425 kWh/L/day). +_CYLINDER_INSULATION_TYPE_NONE: Final[int] = 0 def _cylinder_storage_loss_insulation_label( @@ -6009,7 +6118,7 @@ def _apply_heat_network_hiu_default_store( if epc.has_hot_water_cylinder: return epc dhw_main = _water_heating_main(epc) - if not _is_heat_network_main(dhw_main): + if not is_heat_network_main(dhw_main): return epc if dhw_main is not None and dhw_main.main_heating_index_number is not None: return epc @@ -6183,7 +6292,7 @@ def _table_2b_note_b_multiplier_applies( return False if main is None: return False - if _is_heat_network_main(main): + if is_heat_network_main(main): return False return True @@ -6234,6 +6343,10 @@ _SAP_DESIGN_HEAT_LOSS_DELTA_T_K: Final[float] = 24.2 _HP_SPACE_HEATING_IN_USE_FACTOR_N3_6: Final[float] = 0.95 _HP_IN_USE_FACTOR_CRITERIA_MET: Final[float] = 0.95 _HP_IN_USE_FACTOR_CRITERIA_FAIL: Final[float] = 0.60 +# SAP 10.2 Appendix N3.7 (PDF p.109): the heat-pump water-heating efficiency +# (in-use factor × η_water) is "subject to a minimum efficiency of 100%" — +# below that the direct-electric backup governs. +_HP_WATER_HEATING_MIN_EFFICIENCY: Final[float] = 1.0 def _heat_pump_cylinder_meets_pcdb_criteria( @@ -6325,7 +6438,12 @@ def _heat_pump_apm_efficiencies( main_heating_efficiency = ( _HP_SPACE_HEATING_IN_USE_FACTOR_N3_6 * eta_space_1_pct / 100.0 ) - water_efficiency_pct = in_use_water * eta_water_3_pct / 100.0 + # N3.7: in-use factor × η_water, subject to a minimum efficiency of 100% + # (the direct-electric backup floors the heat pump's water heating). + water_efficiency_pct = max( + in_use_water * eta_water_3_pct / 100.0, + _HP_WATER_HEATING_MIN_EFFICIENCY, + ) return (main_heating_efficiency, water_efficiency_pct) @@ -6535,7 +6653,7 @@ def _primary_loss_applies( # HIU/connection — Table 3 row 1 (heat generator connected to a # cylinder via primary pipework) applies. if ( - _is_heat_network_main(main) + is_heat_network_main(main) and water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES ): return True @@ -6977,7 +7095,7 @@ def _primary_loss_override( # pre-2007 stock. See `_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION`. pipework_p = ( _HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION - if _is_heat_network_main(main) + if is_heat_network_main(main) else _pipework_insulation_fraction_table_3(primary_age) ) base = primary_loss_monthly_kwh( @@ -6989,7 +7107,7 @@ def _primary_loss_override( # row applies regardless of the thermostat / separate-timing # lodgement (and so is robust to the community-fuel-as-electric # collision that would otherwise route DHW to the h=5 row). - heat_network=_is_heat_network_main(main), + heat_network=is_heat_network_main(main), ) # SAP 10.2 §12.4.4 (PDF p.36-37): for back-boiler combos summer DHW # comes from an electric immersion, not from the boiler — the boiler @@ -7028,7 +7146,7 @@ def _cylinder_thermostat_present( if epc.sap_heating.cylinder_thermostat == "Y": return True dhw_main = _water_heating_main(epc) - if _is_heat_network_main(dhw_main): + if is_heat_network_main(dhw_main): return True if epc.sap_heating.water_heating_code == _WHC_ELECTRIC_IMMERSION: return True @@ -7075,6 +7193,18 @@ def _cylinder_storage_loss_override( # 550 kWh/yr). The Table-2b temperature factor still applies (49)→(50). declared_loss = _float_or_none(getattr(sh, "cylinder_heat_loss", None)) volume_l = _cylinder_volume_l_from_code(epc) + if volume_l is None and _int_or_none(sh.cylinder_size) == 0: + # gov-API cylinder_size=0 = "size not determined" for a present + # cylinder (has_hot_water_cylinder=true). RdSAP 10 §10.5 Table 28: + # "If the actual size is not determined, the size ... is taken as + # according to Table 28" → the "Normal" 110 L baseline, which STILL + # incurs a storage loss. Without this the code lookup returned None + # and the `if volume_l is None: return None` below dropped the loss, + # under-costing the DHW (7 corpus certs; e.g. 200004017091 +1.75 -> + # +0.39). Gated on the EXPLICIT 0 so a full-SAP cert whose RdSAP + # cylinder_size is simply unlodged (None, e.g. 10091568921) keeps its + # own cylinder handling rather than a forced 110 L RdSAP default. + volume_l = _CYLINDER_SIZE_NOT_DETERMINED_L if declared_loss is not None: storage_56m = cylinder_storage_loss_monthly_kwh( volume_l=volume_l or 0.0, @@ -7095,9 +7225,38 @@ def _cylinder_storage_loss_override( insulation_label = _cylinder_storage_loss_insulation_label( sh.cylinder_insulation_type ) + thickness_mm: Optional[float] = ( + float(sh.cylinder_insulation_thickness_mm) + if sh.cylinder_insulation_thickness_mm is not None + else None + ) + # SAP 10.2 Table 2: an uninsulated cylinder takes the loose-jacket + # formula at t = 0 — the WORST loss SAP knows, never the zero-loss + # combi default (which over-rates the dwelling). Two lodgement shapes + # describe it: the EPB API's insulation type 0, and the Elmhurst + # site-notes path's type None + explicit 0 mm ("No Insulation" maps to + # no material; the lodged thickness carries the signal — see + # `_ELMHURST_CYLINDER_NO_INSULATION_LABELS` in the mapper). + if _int_or_none(sh.cylinder_insulation_type) == _CYLINDER_INSULATION_TYPE_NONE: + insulation_label, thickness_mm = "loose_jacket", 0.0 + if insulation_label is None and thickness_mm == 0.0: + insulation_label = "loose_jacket" + # RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not + # accessible": a lodged cylinder with NO insulation data at all takes + # the age-band default — the same row the §10.7 no-water-heating + # default uses. An ABSENT thickness means not accessible (Table 29); + # a lodged 0 mm means uninsulated (handled above). When the age band + # itself is unresolvable, fall through to the legacy None (zero loss) + # rather than raising mid-rebaseline. + if insulation_label is None and thickness_mm is None: + band = (_dwelling_age_band(epc) or "")[:1].upper() + table_29_default = _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE.get(band) + if table_29_default is not None: + default_code, default_mm = table_29_default + insulation_label = _cylinder_storage_loss_insulation_label(default_code) + thickness_mm = float(default_mm) if insulation_label is None: return None - thickness_mm = sh.cylinder_insulation_thickness_mm if thickness_mm is None: return None storage_56m = cylinder_storage_loss_monthly_kwh( @@ -7473,8 +7632,17 @@ def _fuel_cost( secondary_fuel = ( epc.sap_heating.secondary_fuel_type if epc.sap_heating else None ) + # Canonicalise the colliding gov-API secondary fuel enums (5 anthracite / + # 9 dual fuel / 33 coal — `_GOV_API_COLLISION_FUELS`) BEFORE the price + # lookup, exactly as the main-fuel boundary and the CO2/display + # `_secondary_fuel_cost_gbp_per_kwh` already do. Without this the raw + # same-value Table-32 lookup mis-priced e.g. anthracite (enum 5) at the + # bulk-LPG code-5 rate (12.19 vs 3.64 p/kWh) — a 3× over-cost that sank + # cert 100050355518 (lodged 36, engine 20.8). This inline cost path had + # diverged from the already-canonicalised display helper. secondary_high_rate_gbp_per_kwh = ( - table_32_unit_price_p_per_kwh(secondary_fuel) * _PENCE_TO_GBP + table_32_unit_price_p_per_kwh(canonical_fuel_code(secondary_fuel)) + * _PENCE_TO_GBP if secondary_fuel is not None else other_uses_gbp_per_kwh ) @@ -7819,7 +7987,7 @@ def cert_to_inputs( ): water_eff = apm_water_eff if ( - _is_heat_network_main(main) + is_heat_network_main(main) and epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES ): # HW from main on a heat-network cert: the DHW also incurs the diff --git a/domain/sap10_calculator/tables/pcdb/parser.py b/domain/sap10_calculator/tables/pcdb/parser.py index 4e34195ff..27e1b3d1a 100644 --- a/domain/sap10_calculator/tables/pcdb/parser.py +++ b/domain/sap10_calculator/tables/pcdb/parser.py @@ -263,6 +263,14 @@ _HP_PSR_GROUP_OFFSET_PSR: Final[int] = 0 _HP_PSR_GROUP_OFFSET_ETA_SPACE_1: Final[int] = 2 _HP_PSR_GROUP_OFFSET_ETA_WATER_3: Final[int] = 6 +# SAP 10.2 Appendix N2 (PDF p.101, footnotes 44/45): out-of-range PSR +# extension for air/ground/water source heat pumps. Above the record's +# largest PSR the efficiency is reciprocal-interpolated toward 100% at +# `_EXTENSION_PSR_MULTIPLE` × the largest PSR; below the smallest PSR, and +# beyond that multiple, the efficiency is the terminal 100%. +_EXTENSION_TERMINAL_EFFICIENCY_PCT: Final[float] = 100.0 +_EXTENSION_PSR_MULTIPLE: Final[float] = 2.0 + def _parse_psr_groups(raw: tuple[str, ...]) -> tuple[PsrEfficiencyGroup, ...]: """Decode the variable-length PSR-dependent block of a format-465 @@ -317,28 +325,60 @@ def interpolate_heat_pump_efficiency_at_psr( (not their reciprocals taken from PCDB), so the η_*_pct values must be strictly positive — every PCDB row in the cohort satisfies this. - Per spec PDF p.100 lines 7039-7072: clamp to the smallest PSR in - the record when `target_psr` is below it, and to the largest when - above ("if the PSR is greater than the largest PSR in the database - record then the heat pump space and water heating fractions for the - largest PSR should be used, and if the PSR is less than the - smallest PSR in the database record then the heat pump space and - water heating fractions for the smallest PSR should be used"). + Out-of-range PSR (spec PDF p.101, footnotes 44/45 — air/ground/water + source heat pumps): + + - Below the smallest PSR in the record: "an efficiency of 100% + should be used if the PSR is less than the smallest value in the + database record." + - Above the largest PSR in the record: "an efficiency may be + obtained from linear interpolation between that at the largest + PSR in the data record and efficiency 100% at PSR two times the + largest PSR in the data record. If the PSR is greater than two + times the largest PSR in the data record an efficiency of 100% + should be used." The interpolation is reciprocal-linear too + (footnote 43), with 100% as the upper anchor. + + Both space- and water-heating PSR-dependent efficiencies extend the + same way. (Exhaust-air heat pumps and combined heat-pump-and-boiler + packages instead use 100% directly above the largest PSR, and combined + packages clamp to the edge rows; neither is distinguished by the + current PCDB parse, so the air/ground/water rule is applied uniformly + — a documented limitation. The dominant RdSAP cohort is air source.) Cohort fixture: cert 3336-2825-9400-0512-8292 (Mitsubishi PUZ-WM50VHA, PCDB 104568) — PSR 1.40151 brackets PCDB rows PSR 1.2 (η_space_1 = 253.9) and PSR 1.5 (η_space_1 = 229.2). Linear (pre-slice): 237.31; reciprocal (spec-faithful): 236.74 — matches worksheet (206)/(210) at 1e-4 once the 0.95 in-use factor is applied. + + Out-of-range anchor: PCDB 100061 (golden fixture case 56), largest PSR + 2.0 (η_space_1=352.0). At dwelling PSR 3.10665 the extension to 100% + at PSR 4.0 gives η_space_1 = 147.011 → (206) = 139.660, matching the + accredited Elmhurst worksheet (vs the old clamp's 352.0 → 334.4%). """ if not psr_groups: raise ValueError("PSR groups required for interpolation") - if target_psr <= psr_groups[0].psr: - first = psr_groups[0] - return (first.eta_space_1_pct, first.eta_water_3_pct) - if target_psr >= psr_groups[-1].psr: + if target_psr < psr_groups[0].psr: + return (_EXTENSION_TERMINAL_EFFICIENCY_PCT, _EXTENSION_TERMINAL_EFFICIENCY_PCT) + if target_psr > psr_groups[-1].psr: last = psr_groups[-1] - return (last.eta_space_1_pct, last.eta_water_3_pct) + upper_psr = _EXTENSION_PSR_MULTIPLE * last.psr + if target_psr >= upper_psr: + return ( + _EXTENSION_TERMINAL_EFFICIENCY_PCT, + _EXTENSION_TERMINAL_EFFICIENCY_PCT, + ) + t = (target_psr - last.psr) / (upper_psr - last.psr) + eta_space_1 = 1.0 / ( + (1.0 - t) / last.eta_space_1_pct + + t / _EXTENSION_TERMINAL_EFFICIENCY_PCT + ) + eta_water_3 = 1.0 / ( + (1.0 - t) / last.eta_water_3_pct + + t / _EXTENSION_TERMINAL_EFFICIENCY_PCT + ) + return (eta_space_1, eta_water_3) for low_group, high_group in zip(psr_groups, psr_groups[1:]): if low_group.psr <= target_psr <= high_group.psr: span = high_group.psr - low_group.psr diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 32ae5bf75..789483e6c 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -922,6 +922,22 @@ def heat_transmission_from_cert( raw_rafter_thickness = getattr(part, "rafter_insulation_thickness", None) if raw_rafter_thickness is not None: raw_roof_thickness = raw_rafter_thickness + elif "flat" in (part.roof_construction_type or "").lower(): + # RdSAP 10 §5.11 — a FLAT roof lodges its insulation thickness in + # the dedicated gov-API `flat_roof_insulation_thickness` field + # (e.g. "75mm"), NOT `roof_insulation_thickness` (which stays None + # for flat roofs). Prefer it so a measured flat-roof thickness + # bills at its Table-16 U instead of the uninsulated age-band flat + # default. Mirrors the rafter branch above. Cert 47084930: flat + # roof "75mm" -> U 0.5 (accredited Elmhurst worksheet, "insulated + # flat roof") vs the age-E default 1.5 (roof 96.4 -> 32.1 W/K). An + # "AB"/"NI" (as-built/unknown) value parses to None and keeps the + # age-band default, unchanged. + raw_flat_thickness = getattr( + part, "flat_roof_insulation_thickness", None + ) + if raw_flat_thickness is not None: + raw_roof_thickness = raw_flat_thickness roof_thickness = _parse_thickness_mm(raw_roof_thickness) floor_ins_thickness = _parse_thickness_mm(getattr(part, "floor_insulation_thickness", None)) diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 6687491f0..be1ab2f62 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -157,6 +157,11 @@ _WALL_INSULATION_INTERNAL: Final[int] = 3 WALL_INSULATION_CAVITY_PLUS_EXTERNAL: Final[int] = 6 WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7 +# RdSAP 10 §5.4 (PDF p.32): "assume 100 mm if thickness is unknown" — the +# default added-insulation thickness for an External / Internal wall +# insulation lodgement (type 1/3) with no measured thickness. +_WALL_ADDED_INSULATION_UNKNOWN_THICKNESS_MM: Final[int] = 100 + # RdSAP 10 §4-6 (page 73): default thermal conductivity of insulation when # no documentary evidence is available. Used to convert the lodged # `wall_insulation_thickness` (mm) into thermal resistance (m²K/W) via @@ -348,7 +353,13 @@ def _insulation_bucket(thickness_mm: Optional[int], insulation_present: bool) -> to the 50 mm bucket when `insulation_present=True`; when not present, the as-built (bucket 0) row applies regardless. """ - if insulation_present and (thickness_mm is None or thickness_mm == 0): + # Insulation known to be present must never fall to the uninsulated + # (bucket 0) row. RdSAP 10 Table 6 footnote routes present-but-unknown + # thickness to the 50 mm row; a KNOWN thin lodgement (5-24 mm, below the + # smallest tabulated 50 mm column) must not read as WORSE (higher U) than + # the unknown case — so it too rounds up to the 50 mm row rather than to + # bucket 0. Only genuinely absent insulation (present=False) maps to 0. + if insulation_present and (thickness_mm is None or thickness_mm < 25): return 50 if thickness_mm is None: return 0 @@ -589,6 +600,28 @@ def u_wall( ctry = country if country is not None else Country.ENG age_idx = _age_index(age_band) band = _AGE_BANDS[age_idx] + # RdSAP 10 §5.4 (PDF p.32): "the U-value with additional insulation is + # based on the thickness of insulation of mineral wool type (assume + # 100 mm if thickness is unknown)". A SOLID-BRICK or STONE wall lodging + # External or Internal insulation (wall_insulation_type 1/3) but no + # measured insulation thickness (gov-API "NI" → None) takes the 100 mm + # default so the §5.8 Table-14 R-value adjustment (brick/stone branches + # below) fires instead of billing the wall at its uninsulated + # §5.6/Table-6 U (over-counting fabric loss → under-rate; corpus cert + # 200004296092, solid brick 360 mm, walls 101.7 W/K → PE +59.6). Scoped + # to brick/stone because only those constructions have a §5.8 added- + # insulation R-value path here — cavity uses the separate + # `WALL_INSULATION_CAVITY_PLUS_*` composite, and timber/system-built + # carry no §5.8 branch, so a thickness default there would be inert or + # mis-routed. + if ( + construction + in (WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_SOLID_BRICK) + and wall_insulation_type + in (_WALL_INSULATION_EXTERNAL, _WALL_INSULATION_INTERNAL) + and (insulation_thickness_mm is None or insulation_thickness_mm == 0) + ): + insulation_thickness_mm = _WALL_ADDED_INSULATION_UNKNOWN_THICKNESS_MM # RdSAP 10 Tables 6-10 stone rows + footnote (a) (PDF p.33-39), §5.6 # formula (PDF p.40), §5.8 + Table 14 (PDF p.41-42). A documentary wall # thickness (per §5.3) routes stone in age bands A-E off the §5.6 formula, @@ -998,13 +1031,30 @@ def u_roof( # (band J = 0.16), matching the cohort's "ND" (thickness None) # vaulted roofs which already reach col (1) by falling through. return _ROOF_BY_AGE.get(age_band.upper(), 0.4) + if ( + age_band is not None + and insulation_thickness_mm == 0 + and _described_as_insulated(description) + and description is not None + and "(assumed)" in description.lower() + ): + # RdSAP renders "insulated (assumed)" when insulation PRESENCE is + # itself assumed (age-band / as-built assumption), not an observed + # retrofit — so the §5.11.4 50 mm footnote (below) does not apply; + # the thickness "cannot be determined" and the Table 18 age-band + # default governs (§5.11.4 opening clause). Identical + # "Pitched, insulated (assumed)" corpus dwellings lodge thickness + # "ND" (None → Table 18 by fallthrough) or "NI" (0 → previously the + # 50 mm row) purely per lodging software; both take Table 18. + table_18 = _FLAT_ROOF_BY_AGE if is_flat_roof else roof_by_age + return table_18.get(age_band.upper(), 0.4) if insulation_thickness_mm == 0 and _described_as_insulated(description): # Spec §5.11.4 (page 44 footnote): "If retrofit insulation # present of unknown thickness use 50 mm". The cert encodes - # "thickness unknown but retrofit insulation present" as the - # "NI" sentinel which `_parse_thickness_mm` parses to 0. Without - # this override the Table 16 row-0 lookup below returns the - # uninsulated 2.30 W/m²K. + # "thickness unknown but OBSERVED retrofit insulation" as the + # "NI" sentinel (parsed to 0) + an unqualified "insulated" + # description (no "(assumed)"). Without this override the Table + # 16 row-0 lookup below returns the uninsulated 2.30 W/m²K. return 0.68 # Table 16 row 50, "Insulation at joists at ceiling level" if insulation_thickness_mm is not None: # nearest tabulated thickness <= supplied (Table 16 column (1) diff --git a/domain/sap10_ml/tests/_fixtures.py b/domain/sap10_ml/tests/_fixtures.py index d3fae7e21..53fbdc357 100644 --- a/domain/sap10_ml/tests/_fixtures.py +++ b/domain/sap10_ml/tests/_fixtures.py @@ -111,9 +111,11 @@ def make_sap_heating( """Build a SapHeating with SAP10 API defaults.""" return SapHeating( instantaneous_wwhrs=InstantaneousWwhrs(), - main_heating_details=main_heating_details - if main_heating_details is not None - else [make_main_heating_detail()], + main_heating_details=( + main_heating_details + if main_heating_details is not None + else [make_main_heating_detail()] + ), has_fixed_air_conditioning=has_fixed_air_conditioning, water_heating_code=water_heating_code, water_heating_fuel=water_heating_fuel, @@ -173,9 +175,11 @@ def make_building_part( party_wall_construction=party_wall_construction, roof_construction=roof_construction, roof_insulation_location=roof_insulation_location, - sap_floor_dimensions=floor_dimensions - if floor_dimensions is not None - else [make_floor_dimension()], + sap_floor_dimensions=( + floor_dimensions + if floor_dimensions is not None + else [make_floor_dimension()] + ), sap_room_in_roof=sap_room_in_roof, floor_type=floor_type, ) @@ -183,7 +187,9 @@ def make_building_part( def make_window( *, - orientation: Union[int, str] = 5, # SAP10: 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW + orientation: Union[ + int, str + ] = 5, # SAP10: 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW width: float = 1.0, height: float = 1.0, draught_proofed: bool = True, @@ -195,8 +201,8 @@ def make_window( permanent_shutters_present: Union[bool, str] = False, frame_material: Optional[str] = "PVC", frame_factor: Optional[float] = 0.7, # SAP10.2 Table 6c PVC default; - # mirrors the Elmhurst mapper's - # surfaced value from Summary §11. + # mirrors the Elmhurst mapper's + # surfaced value from Summary §11. window_transmission_details: Optional[WindowTransmissionDetails] = None, solar_transmittance: Optional[float] = None, u_value: float = 2.8, @@ -210,7 +216,9 @@ def make_window( """ if window_transmission_details is None and solar_transmittance is not None: window_transmission_details = WindowTransmissionDetails( - u_value=u_value, data_source=1, solar_transmittance=solar_transmittance, + u_value=u_value, + data_source=1, + solar_transmittance=solar_transmittance, ) return SapWindow( frame_material=frame_material, @@ -268,7 +276,7 @@ def make_minimal_sap10_epc( photovoltaic_arrays: Optional[list[PhotovoltaicArray]] = None, photovoltaic_supply_percent_roof_area: Optional[int] = None, pv_connection: Optional[int] = None, - mains_gas: bool = True, + gas_connection_available: bool = True, electricity_smart_meter_present: bool = False, gas_smart_meter_present: bool = False, is_dwelling_export_capable: bool = False, @@ -297,17 +305,21 @@ def make_minimal_sap10_epc( floors=[], main_heating=[], door_count=door_count, - sap_heating=sap_heating if sap_heating is not None else SapHeating( - instantaneous_wwhrs=InstantaneousWwhrs(), - main_heating_details=[], - has_fixed_air_conditioning=False, + sap_heating=( + sap_heating + if sap_heating is not None + else SapHeating( + instantaneous_wwhrs=InstantaneousWwhrs(), + main_heating_details=[], + has_fixed_air_conditioning=False, + ) ), sap_windows=list(sap_windows) if sap_windows is not None else [], sap_roof_windows=( list(sap_roof_windows) if sap_roof_windows is not None else None ), sap_energy_source=SapEnergySource( - mains_gas=mains_gas, + gas_connection_available=gas_connection_available, meter_type="Single", pv_connection=pv_connection, pv_battery_count=pv_battery_count, @@ -316,9 +328,9 @@ def make_minimal_sap10_epc( is_dwelling_export_capable=is_dwelling_export_capable, wind_turbines_terrain_type="Suburban", electricity_smart_meter_present=electricity_smart_meter_present, - photovoltaic_arrays=list(photovoltaic_arrays) - if photovoltaic_arrays is not None - else None, + photovoltaic_arrays=( + list(photovoltaic_arrays) if photovoltaic_arrays is not None else None + ), photovoltaic_supply=( PhotovoltaicSupply( none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( @@ -343,7 +355,9 @@ def make_minimal_sap10_epc( else None ), ), - sap_building_parts=list(sap_building_parts) if sap_building_parts is not None else [], + sap_building_parts=( + list(sap_building_parts) if sap_building_parts is not None else [] + ), solar_water_heating=solar_water_heating, has_hot_water_cylinder=has_hot_water_cylinder, has_fixed_air_conditioning=has_fixed_air_conditioning, diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 4fb51f422..95993be78 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -31,6 +31,7 @@ from domain.sap10_ml.rdsap_uvalues import ( WALL_STONE_SANDSTONE, WALL_SYSTEM_BUILT, WALL_TIMBER_FRAME, + _insulation_bucket, thermal_bridging_y, u_door, u_exposed_floor, @@ -114,6 +115,43 @@ def test_u_wall_solid_brick_with_ni_thickness_uses_50mm_row_per_table6_footnote( assert result == pytest.approx(0.55, abs=0.001) +def test_u_wall_solid_brick_present_insulation_thin_known_thickness_not_uninsulated() -> None: + # Arrange — a solid-brick wall lodging internal insulation (type 3) + # with a KNOWN thin thickness (10 mm) but no documentary wall + # thickness (so the §5.8 formula branch, which needs wall_thickness, + # cannot fire) falls to the Table-6 bucket. A known 10 mm value sits + # below the smallest tabulated 50 mm column, but insulation IS present + # — it must NOT read as WORSE (higher U) than the present-but-unknown + # case, which the footnote routes to the 50 mm row. Pre-fix, + # `_insulation_bucket(10, present=True)` returned 0 (uninsulated, + # U=1.7), under-crediting the wall (corpus cert 5117280: Δ −4.44). + + # Act + result = u_wall( + country=Country.ENG, + age_band="B", + construction=WALL_SOLID_BRICK, + insulation_thickness_mm=10, + insulation_present=True, + wall_insulation_type=3, + ) + + # Assert — present insulation → 50 mm row (U=0.55), never uninsulated 1.7. + assert result == pytest.approx(0.55, abs=0.001) + + +def test_insulation_bucket_present_thin_thickness_rounds_up_to_50_not_zero() -> None: + # Direct guard on the bucket: present insulation never maps to bucket 0. + assert _insulation_bucket(10, True) == 50 + assert _insulation_bucket(24, True) == 50 + assert _insulation_bucket(None, True) == 50 + assert _insulation_bucket(50, True) == 50 + assert _insulation_bucket(100, True) == 100 + # Genuinely absent insulation still routes thin/zero to bucket 0. + assert _insulation_bucket(10, False) == 0 + assert _insulation_bucket(None, False) == 0 + + def test_u_wall_cavity_as_built_insulated_assumed_routes_to_as_built_row() -> None: # Arrange — a cavity lodged "Cavity wall, as built, insulated (assumed)" # with wall_insulation_type=4 is in its AS-BUILT state, NOT a retrofit @@ -835,6 +873,34 @@ def test_u_wall_stone_granite_with_external_insulation_applies_5_8_table_14_r_va assert abs(result - 0.57) <= 1e-4 +def test_u_wall_solid_brick_external_insulation_unknown_thickness_assumes_100mm() -> None: + # Arrange — RdSAP 10 §5.4 (PDF p.32): "the U-value with additional + # insulation is based on the thickness of insulation of mineral wool + # type (assume 100 mm if thickness is unknown)". A solid brick / stone + # wall lodging External or Internal insulation (wall_insulation_type + # 1/3) but NO measured insulation thickness (gov-API "NI" → parsed to + # None) must take the 100 mm §5.4 default so the §5.8 Table-14 R-value + # fires — NOT fall through to the uninsulated §5.6 U. Corpus cert + # 200004296092 (age-B solid brick 360 mm, "with external insulation", + # thickness NI) was billed at the uninsulated 1.40 (walls 101.7 W/K, + # HLP 3.46) → PE +59.6, SAP −6.65. With 100 mm: U₀(§5.6, 360 mm brick) + # + R(100 mm, λ=0.04 → 2.5) → 0.29. + + # Act + result = u_wall( + country=Country.ENG, + age_band="B", + construction=WALL_SOLID_BRICK, + insulation_thickness_mm=None, # gov-API "NI" — thickness unknown + insulation_present=True, + wall_insulation_type=1, # External + wall_thickness_mm=360, + ) + + # Assert — 100 mm default applied, not the uninsulated 1.40. + assert abs(result - 0.29) <= 1e-4 + + def test_u_wall_stone_granite_age_g_with_wall_thickness_ignores_5_6_formula_per_age_a_to_e_gate() -> None: # Arrange — §5.6 (PDF p.40) heading explicitly scopes the formula # to "age bands A to E". For age F onwards Table 6 gives literal @@ -1059,16 +1125,41 @@ def test_u_roof_description_with_measured_transmittance_returns_parsed_value() - assert result == pytest.approx(0.11, abs=0.001) -def test_u_roof_ni_thickness_with_insulated_description_applies_50mm_per_section_5_11_4() -> None: - # Arrange — 346 corpus certs lodge roof_insulation_thickness="NI" - # (Not Indicated, parsed to 0 by _parse_thickness_mm). When the - # description also signals retrofit insulation ("Pitched, insulated - # (assumed)" / "Flat, insulated" / "Roof room(s), insulated - # (assumed)"), RdSAP 10 §5.11.4 (page 44) footnote applies: +def test_u_roof_ni_thickness_with_observed_insulated_description_applies_50mm_per_section_5_11_4() -> None: + # Arrange — a roof lodging thickness "NI" (parsed to 0) whose + # description signals OBSERVED insulation without the "(assumed)" + # qualifier ("Pitched, insulated" — RdSAP renders the bare form when + # the assessor saw the insulation but not its depth). RdSAP 10 + # §5.11.4 (page 44) footnote applies: # "If retrofit insulation present of unknown thickness use 50 mm". # That maps to Table 16 row "50 mm at joists at ceiling level" = 0.68 - # W/m²K — vs the current 2.30 we return when thickness=0 hits the - # Table 16 row-0 lookup. + # W/m²K — vs the 2.30 the Table 16 row-0 lookup would give. + + # Act + result = u_roof( + country=Country.ENG, + age_band="C", + insulation_thickness_mm=0, # parsed from "NI" + description="Pitched, insulated", + ) + + # Assert + assert result == pytest.approx(0.68, abs=0.01) + + +def test_u_roof_ni_thickness_with_assumed_insulated_description_uses_table18_default() -> None: + # Arrange — RdSAP renders "insulated (assumed)" when insulation + # presence is ASSUMED (age-band / as-built assumption), not an + # observed retrofit — so the §5.11.4 "retrofit insulation present of + # unknown thickness use 50 mm" footnote does NOT apply; the thickness + # "cannot be determined" and Table 18's age-band default governs + # (§5.11.4 opening clause). The corpus lodges the SAME description + # with thickness "ND" (102 certs → already Table 18 via the + # thickness-None fallthrough) or "NI" (27 certs → previously took the + # 50 mm row 0.68), purely per lodging software; both must land on the + # same Table 18 value (band C pitched → 0.40). The loc-4 NI cohort + # was systematically under-rated by ~1 SAP before this (e.g. corpus + # cert 10094975827 band B: Δ −1.87). # Act result = u_roof( @@ -1078,8 +1169,8 @@ def test_u_roof_ni_thickness_with_insulated_description_applies_50mm_per_section description="Pitched, insulated (assumed)", ) - # Assert - assert result == pytest.approx(0.68, abs=0.01) + # Assert — Table 18 column (1) band C. + assert result == pytest.approx(0.40, abs=0.01) def test_u_roof_ni_thickness_with_no_insulation_description_stays_at_2_30() -> None: @@ -1324,18 +1415,20 @@ def test_u_roof_vaulted_ni_unknown_band_j_uses_col1_age_band_not_50mm() -> None: assert abs(result - 0.16) <= 1e-4 -def test_u_roof_normal_pitched_ni_insulated_still_returns_50mm_per_5_11_4() -> None: +def test_u_roof_normal_pitched_ni_observed_insulated_still_returns_50mm_per_5_11_4() -> None: # Arrange — regression guard: the is_sloping_ceiling flag defaults - # False, so a NORMAL pitched roof (with loft) lodged NI + "insulated - # (assumed)" must STILL hit the §5.11.4 retrofit-50 mm row (U=0.68). - # Same inputs as the sloping test above minus is_sloping_ceiling. + # False, so a NORMAL pitched roof (with loft) lodged NI + an + # unqualified "insulated" description (observed retrofit, no + # "(assumed)") must STILL hit the §5.11.4 retrofit-50 mm row + # (U=0.68). The "(assumed)" form routes to Table 18 instead — see + # test_u_roof_ni_thickness_with_assumed_insulated_description_uses_table18_default. # Act result = u_roof( country=Country.ENG, age_band="J", insulation_thickness_mm=0, - description="Pitched, insulated (assumed)", + description="Pitched, insulated", ) # Assert diff --git a/domain/sap10_ml/tests/test_transform.py b/domain/sap10_ml/tests/test_transform.py index 8a217fd65..5079c684e 100644 --- a/domain/sap10_ml/tests/test_transform.py +++ b/domain/sap10_ml/tests/test_transform.py @@ -1020,7 +1020,7 @@ def test_to_row_extracts_energy_source_booleans() -> None: # Arrange — gas + electricity smart meters, export capable epc = make_minimal_sap10_epc( energy_rating_current=82, - mains_gas=True, + gas_connection_available=True, electricity_smart_meter_present=True, gas_smart_meter_present=True, is_dwelling_export_capable=True, diff --git a/domain/sap10_ml/transform.py b/domain/sap10_ml/transform.py index 964ea1beb..ee34fcd64 100644 --- a/domain/sap10_ml/transform.py +++ b/domain/sap10_ml/transform.py @@ -37,11 +37,13 @@ from domain.sap10_ml.ecf import ( ) from domain.sap10_ml.envelope import envelope_heat_loss_w_per_k from domain.sap10_ml.ventilation import ventilation_heat_loss_w_per_k -from domain.sap10_ml.sap_efficiencies import seasonal_efficiency, water_heating_efficiency +from domain.sap10_ml.sap_efficiencies import ( + seasonal_efficiency, + water_heating_efficiency, +) from domain.sap10_ml.schema import ColumnSpec, TransformSchema from domain.sap10_ml.ucl import apply_ucl_correction - # SAP10 orientation codes: 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW. # Anything else (0, "NR", etc.) is treated as unrecorded — it contributes to # `window_count` and `window_total_area_m2` but to no octant. @@ -60,7 +62,23 @@ _OCTANT_NAMES: dict[int, str] = { # datatypes/epc/domain/epc_codes.csv, schema RdSAP-21.0.x). Anything outside this set # (the documentation "ND" sentinel, future codes, or unexpected strings) falls into # the `_other` bucket so share columns always sum to 1.0 of total window area. -_GLAZED_TYPE_CODES: tuple[int, ...] = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15) +_GLAZED_TYPE_CODES: tuple[int, ...] = ( + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, +) _FEATURE_COLUMNS: dict[str, ColumnSpec] = { @@ -242,169 +260,227 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { # Building parts — Main Dwelling carve-out (none of these are populated if the # property has no part identified as 'Main Dwelling') "main_dwelling_heat_loss_perimeter_m": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Heat-loss perimeter (m) for the Main Dwelling only.", ), "main_dwelling_party_wall_length_m": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Party-wall length (m) for the Main Dwelling only.", ), "main_dwelling_total_floor_area_m2": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Total floor area (m²) for the Main Dwelling only.", ), "main_dwelling_avg_room_height_m": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Floor-area-weighted mean room height (m) for the Main Dwelling.", ), "main_dwelling_has_room_in_roof": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="True if the Main Dwelling carries a sap_room_in_roof block.", ), "main_dwelling_construction_age_band": ColumnSpec( - dtype=str, nullable=True, categorical=True, + dtype=str, + nullable=True, + categorical=True, description="Main Dwelling construction age band (A-M, '0', or 'NR').", ), "main_dwelling_wall_construction": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling wall construction SAP10 code.", ), "main_dwelling_roof_construction": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling roof construction SAP10 code.", ), # Main Dwelling fabric inputs — wall, roof, floor (model retrofit simulation surface). "main_dwelling_wall_insulation_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling wall insulation type SAP10 code.", ), "main_dwelling_wall_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Main Dwelling wall insulation thickness in mm. 'NI' (no insulation) maps to 0.", ), "main_dwelling_wall_dry_lined": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Main Dwelling wall_dry_lined flag.", ), "main_dwelling_wall_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Main Dwelling external wall thickness in mm.", ), "main_dwelling_party_wall_construction": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling party wall construction SAP10 code (str sentinels NA/NI -> None).", ), "main_dwelling_roof_insulation_location": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling roof insulation location SAP10 code (str sentinels -> None).", ), "main_dwelling_roof_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Main Dwelling roof insulation thickness in mm. 'NI' -> 0; non-numeric sentinels -> None.", ), "main_dwelling_floor_construction": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling ground-floor construction SAP10 code (from sap_floor_dimensions[floor==0]).", ), "main_dwelling_floor_insulation": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling ground-floor insulation SAP10 code (from sap_floor_dimensions[floor==0]).", ), "main_dwelling_floor_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Main Dwelling floor insulation thickness in mm. 'NI' -> 0; non-numeric sentinels -> None.", ), "main_dwelling_floor_heat_loss": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Main Dwelling floor heat-loss SAP10 code.", ), # Heating — count of main heating systems (usually 1) "main_heating_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of main heating systems declared on sap_heating.main_heating_details.", ), # Heating — primary (Top-1) slot from main_heating_details[0] "primary_main_fuel_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Primary heating main_fuel SAP10 code (per epc_codes.csv main_fuel enum).", ), "primary_heat_emitter_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Primary heating heat_emitter_type SAP10 code.", ), "primary_main_heating_control": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Primary heating main_heating_control SAP10 code.", ), "primary_main_heating_category": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Primary heating main_heating_category SAP10 code.", ), "primary_has_fghrs": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Primary heating has flue gas heat recovery system.", ), "primary_fan_flue_present": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Primary heating boiler has a fan flue.", ), "primary_boiler_flue_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Primary heating boiler flue type SAP10 code.", ), "primary_central_heating_pump_age": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Primary heating central-heating pump age band (SAP10 enum).", ), # Water heating — on sap_heating directly "water_heating_code": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Water heating SAP10 code.", ), "water_heating_fuel": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Water heating fuel SAP10 code (per epc_codes.csv water_heating_fuel enum).", ), "cylinder_size": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Hot water cylinder size SAP10 code (1=small, 2=normal, 3=large).", ), "cylinder_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Hot water cylinder insulation thickness (mm).", ), # Secondary heating — present when secondary_fuel_type is set "has_secondary_heating": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="True if sap_heating.secondary_fuel_type is populated.", ), "secondary_fuel_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Secondary heating fuel SAP10 code (shares main_fuel enum).", ), # PV — has-pv + measured-vs-estimated capacity + array aggregates "has_pv": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="True if the property has any photovoltaic system (measured or estimated).", ), "pv_capacity_source": ColumnSpec( - dtype=str, nullable=False, categorical=True, + dtype=str, + nullable=False, + categorical=True, description=( "How PV capacity is known: 'measured' (per-array peak_power available), " "'estimated_from_roof_area' (only percent_roof_area), or 'none'." ), ), "pv_array_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of measured PV arrays (0 unless capacity_source is 'measured').", ), "pv_total_peak_power_kw": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description="Sum of peak_power (kW) across measured PV arrays.", ), **{ f"pv_peak_power_kw_{name}": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( f"Sum of peak_power (kW) for measured PV arrays facing {name} " "(SAP orientation code)." @@ -413,359 +489,463 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { for name in _OCTANT_NAMES.values() }, "pv_avg_pitch": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Peak-power-weighted mean array pitch (SAP code); null when no measured arrays.", ), "pv_avg_overshading": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Peak-power-weighted mean overshading (SAP code); null when no measured arrays.", ), "pv_percent_roof_area": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Percent of roof covered by PV — populated only when capacity_source = 'estimated_from_roof_area'.", ), # PV battery, wind turbine, energy source flags "has_pv_battery": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="True if the property has at least one PV battery.", ), "pv_battery_count": ColumnSpec( dtype=int, nullable=False, description="Number of PV batteries." ), "pv_battery_capacity_kwh": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description=( "Total PV battery capacity (kWh) — pv_battery_count × per-unit capacity " "from sap_energy_source.pv_batteries. Null when count=0." ), ), "has_wind_turbine": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="True if the property has at least one wind turbine.", ), "wind_turbine_count": ColumnSpec( dtype=int, nullable=False, description="Number of wind turbines." ), "mains_gas": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="Property is connected to mains gas (strong fuel-deduction signal).", ), "electricity_smart_meter_present": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="Electricity smart meter installed.", ), "gas_smart_meter_present": ColumnSpec( dtype=bool, nullable=False, description="Gas smart meter installed." ), "is_dwelling_export_capable": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="Dwelling has an export-capable connection (eligible for SEG).", ), # Ventilation — flat fields direct off EpcPropertyData "mechanical_ventilation": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Mechanical ventilation SAP10 code (0=natural, 1-6 per epc_codes.csv enum).", ), "mechanical_vent_duct_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Mechanical ventilation duct type SAP10 code.", ), "blocked_chimneys_count": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Number of blocked / capped-off chimneys.", ), "pressure_test": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Air-tightness pressure-test SAP10 code.", ), # Dwelling-level fabric + demand inputs. "multiple_glazed_proportion": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Percent of glazed area that is multiple-glazed.", ), "number_baths": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Number of baths declared on sap_heating (hot-water demand proxy).", ), "number_baths_wwhrs": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Number of baths served by a WWHRS unit.", ), "extract_fans_count": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Number of extract fans (ventilation/heat-loss proxy).", ), # Heating — heating-system identity + flow temp + multi-system fraction. "primary_sap_main_heating_code": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="SAP10 main heating type code (canonical heating-system enum).", ), "primary_emitter_temperature": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Primary heating emitter temperature class (0=standard, 1=low-temp).", ), "primary_main_heating_fraction": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Fraction of space heating delivered by the primary main heating system.", ), # Hot water — immersion type + presence of shower outlet block. "immersion_heating_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Electric immersion heater type SAP10 code.", ), "shower_outlet_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="1 if any shower_outlet block is declared on sap_heating, else 0.", ), # Windows — per-window-type share aggregates. "window_pct_living": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Area share of windows with window_type == 1 (living room).", ), "window_pct_external": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Area share of windows with window_location == 0 (external).", ), "window_pct_permanent_shutters": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Area share of windows with permanent_shutters_present truthy.", ), # Dwelling — conservatory + flat-only block. "conservatory_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Conservatory SAP10 code (1=none, 2=heated, 3=unheated, ...).", ), "has_heated_separate_conservatory": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Whether the dwelling has a heated separate conservatory.", ), "flat_level": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Flat-only: floor number on which the flat sits.", ), "flat_top_storey": ColumnSpec( - dtype=str, nullable=True, categorical=True, + dtype=str, + nullable=True, + categorical=True, description="Flat-only: Y/N flag indicating whether this is the top storey.", ), "flat_storey_count": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Flat-only: storey count of the building containing the flat.", ), "flat_location": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Flat-only: location SAP10 code (corner/middle/...).", ), "flat_heat_loss_corridor": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Flat-only: heat-loss-corridor SAP10 code.", ), # Energy supply categoricals. "meter_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Electricity meter type SAP10 code (1=Standard, 2=Off-peak, ...).", ), "pv_connection": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="PV connection topology SAP10 code.", ), "wind_turbines_terrain_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Wind-turbine terrain type SAP10 code.", ), # Doors. "draughtproofed_door_count": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Number of draught-proofed doors.", ), "insulated_door_u_value": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="U-value of insulated doors (W/m^2K).", ), # Hot water extras. "cylinder_insulation_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Cylinder insulation type SAP10 code (string sentinels -> None).", ), "cylinder_thermostat": ColumnSpec( - dtype=str, nullable=True, categorical=True, + dtype=str, + nullable=True, + categorical=True, description="Cylinder-thermostat flag (Y/N/missing).", ), "secondary_heating_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Secondary heating type SAP10 code (distinct from secondary_fuel_type).", ), # Mechanical ventilation extras. "mechanical_vent_duct_placement": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Mechanical-vent duct placement SAP10 code.", ), "mechanical_vent_duct_insulation": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Mechanical-vent duct insulation SAP10 code.", ), "mechanical_vent_duct_insulation_level": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Mechanical-vent duct insulation level SAP10 code.", ), "mechanical_vent_measured_installation": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Whether mechanical ventilation was measured at installation.", ), # Lighting extras. "low_energy_fixed_lighting_bulbs_count": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Number of low-energy fixed-lighting bulbs (separate from CFL/LED).", ), "fixed_lighting_outlets_count": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Total number of fixed-lighting outlets.", ), "low_energy_fixed_lighting_outlets_count": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Number of low-energy fixed-lighting outlets.", ), # Window extras (per-window scalars area-weighted across windows). "window_avg_glazing_gap_mm": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Area-weighted average glazing gap in mm (non-numeric sentinels excluded).", ), "window_avg_frame_factor": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Area-weighted average frame factor across windows.", ), "window_pct_permanent_shutters_insulated": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Area share of windows with permanent_shutters_insulated == 'Y'.", ), # Main-dwelling extras: room-in-roof + alternative walls + flat-roof + measured flag. "main_dwelling_room_in_roof_floor_area_m2": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Floor area of main dwelling room-in-roof block (when present).", ), "main_dwelling_alternative_wall_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of sap_alternative_wall_* blocks on the main dwelling (0-2).", ), "main_dwelling_alternative_wall_area_m2": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description="Sum of sap_alternative_wall_*.wall_area for the main dwelling.", ), "main_dwelling_flat_roof_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Main dwelling flat-roof insulation thickness in mm (rare).", ), "main_dwelling_wall_thickness_measured": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Main dwelling wall_thickness_measured flag.", ), # Element list counts (split-fabric discriminator). "wall_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of entries in the top-level walls EnergyElement list.", ), "roof_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of entries in the top-level roofs EnergyElement list.", ), "floor_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of entries in the top-level floors EnergyElement list.", ), "main_heating_count_elements": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of entries in the top-level main_heating EnergyElement list.", ), "main_heating_controls_present": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="Whether the cert carries a main_heating_controls EnergyElement.", ), # Wind turbine geometry. "wind_turbine_hub_height_m": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Hub height of the (first) wind turbine, metres.", ), "wind_turbine_rotor_diameter_m": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Rotor diameter of the (first) wind turbine, metres.", ), # Flat extras. "flat_unheated_corridor_length_m": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Flat-only: length (m) of any unheated corridor adjacent to the dwelling.", ), # Addendum (~43% present). "addendum_stone_walls": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Addendum: stone-wall construction flagged by assessor.", ), "addendum_system_build": ColumnSpec( - dtype=bool, nullable=True, + dtype=bool, + nullable=True, description="Addendum: system-build construction flagged by assessor.", ), "addendum_numbers_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of addendum codes flagged.", ), # Low-carbon energy sources. "lzc_energy_sources_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of LZC energy-source codes declared (0 if none).", ), # Extension 1 (first non-main building part; ~36% of certs). "extension_1_present": ColumnSpec( - dtype=bool, nullable=False, + dtype=bool, + nullable=False, description="True if there is a building part beyond the Main Dwelling.", ), "extension_1_wall_construction": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Extension 1 wall construction SAP10 code.", ), "extension_1_wall_insulation_type": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Extension 1 wall insulation type SAP10 code.", ), "extension_1_wall_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Extension 1 wall insulation thickness in mm.", ), "extension_1_wall_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Extension 1 external wall thickness in mm.", ), "extension_1_roof_construction": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Extension 1 roof construction SAP10 code.", ), "extension_1_roof_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Extension 1 roof insulation thickness in mm.", ), "extension_1_floor_construction": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Extension 1 ground-floor construction SAP10 code.", ), "extension_1_floor_insulation": ColumnSpec( - dtype=int, nullable=True, categorical=True, + dtype=int, + nullable=True, + categorical=True, description="Extension 1 ground-floor insulation SAP10 code.", ), "extension_1_floor_insulation_thickness_mm": ColumnSpec( - dtype=int, nullable=True, + dtype=int, + nullable=True, description="Extension 1 floor insulation thickness in mm.", ), "extension_1_total_floor_area_m2": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Extension 1 total floor area (sum of its sap_floor_dimensions).", ), "extension_1_heat_loss_perimeter_m": ColumnSpec( - dtype=float, nullable=True, + dtype=float, + nullable=True, description="Extension 1 heat-loss perimeter (sum of its sap_floor_dimensions).", ), "other_building_parts_count": ColumnSpec( - dtype=int, nullable=False, + dtype=int, + nullable=False, description="Number of building parts beyond Main Dwelling and the secondary part.", ), "envelope_heat_loss_w_per_k": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Sum of U*A over walls / roof / floor / party walls / windows / doors " "plus thermal-bridging factor y times total exposed area, summed across " @@ -775,7 +955,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "ventilation_heat_loss_w_per_k": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "SAP10.2 §C ventilation heat-loss in W/K from structural infiltration " "(0.35 ACH masonry / 0.25 ACH timber) plus open chimneys (40 m³/h each) " @@ -785,7 +966,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "seasonal_efficiency_main_heating": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Space-heating seasonal efficiency as a decimal (e.g. 0.84 = 84%), " "from SAP10.2 Table 4a/4b keyed on primary_sap_main_heating_code. " @@ -793,7 +975,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "seasonal_efficiency_water_heating": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Water-heating seasonal efficiency as a decimal. Code 901 ('from main') " "inherits the main code's efficiency; unknown -> 0.78 (gas-combi). " @@ -801,7 +984,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "predicted_space_heating_kwh": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Crude annual delivered space-heating kWh: envelope_heat_loss_w_per_k * " "HDH_region * 1e-3 / seasonal_efficiency_main_heating. HDH from a 22-row " @@ -809,7 +993,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "predicted_hot_water_kwh": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Crude annual delivered hot-water kWh from SAP10.2 Appendix J simplified: " "occupancy from TFA, daily volume 25*N+36 L, delta-T 43 K, +10% losses, " @@ -817,14 +1002,16 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "predicted_lighting_kwh": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Crude annual lighting kWh from SAP10.2 Section L simplified: " "9.3 * TFA reduced by 50% LED share + 40% CFL share. ADR-0008." ), ), "predicted_pv_generation_kwh": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Annual PV generation kWh: pv_total_peak_power_kw * yield_factor " "(SAP10.2 Table 6e region-keyed; UK avg 850 kWh/kWp/yr). " @@ -833,7 +1020,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "predicted_total_fuel_cost_gbp": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "Annual regulated fuel cost (gbp/yr): space + DHW + lighting kWh " "multiplied by Table 32 unit prices. Standing charges omitted " @@ -842,7 +1030,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "predicted_ecf": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "SAP10 §20.1 Energy Cost Factor: 0.42 * predicted_total_fuel_cost / " "(TFA + 45). SAP score is a piecewise log/linear function of ECF. " @@ -850,7 +1039,8 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { ), ), "predicted_log10_ecf": ColumnSpec( - dtype=float, nullable=False, + dtype=float, + nullable=False, description=( "log10 of predicted_ecf. Monotone with sap_score so a tree-based " "model can use this as a near-target feature; the SAP rating's " @@ -961,7 +1151,9 @@ class EpcMlTransform: envelope_w_per_k = envelope_heat_loss_w_per_k( sap_building_parts=epc.sap_building_parts, country_code=epc.country_code, - window_total_area_m2=float(window_aggregates.get("window_total_area_m2") or 0.0), + window_total_area_m2=float( + window_aggregates.get("window_total_area_m2") or 0.0 + ), window_avg_u_value=window_aggregates.get("window_avg_u_value"), door_count=epc.door_count, insulated_door_count=epc.insulated_door_count, @@ -975,10 +1167,16 @@ class EpcMlTransform: window_dp_pct = window_aggregates.get("window_pct_draught_proofed") ventilation_w_per_k = ventilation_heat_loss_w_per_k( total_floor_area_m2=epc.total_floor_area_m2, - avg_room_height_m=float(avg_room_h) if isinstance(avg_room_h, (int, float)) else 2.5, + avg_room_height_m=( + float(avg_room_h) if isinstance(avg_room_h, (int, float)) else 2.5 + ), is_timber_frame=is_timber_frame, open_chimneys_count=epc.open_chimneys_count, - window_pct_draught_proofed=float(window_dp_pct) if isinstance(window_dp_pct, (int, float)) else None, + window_pct_draught_proofed=( + float(window_dp_pct) + if isinstance(window_dp_pct, (int, float)) + else None + ), ) main_heating_code = heating_aggregates.get("primary_sap_main_heating_code") water_code = heating_aggregates.get("water_heating_code") @@ -986,12 +1184,16 @@ class EpcMlTransform: main_fuel = heating_aggregates.get("primary_main_fuel_type") space_eff = seasonal_efficiency( main_heating_code if isinstance(main_heating_code, int) else None, - main_heating_category=main_category if isinstance(main_category, int) else None, + main_heating_category=( + main_category if isinstance(main_category, int) else None + ), main_fuel_type=main_fuel if isinstance(main_fuel, int) else None, ) water_eff = water_heating_efficiency( water_heating_code=water_code if isinstance(water_code, int) else None, - main_heating_code=main_heating_code if isinstance(main_heating_code, int) else None, + main_heating_code=( + main_heating_code if isinstance(main_heating_code, int) else None + ), ) pred_space_kwh = predicted_space_heating_kwh( envelope_heat_loss_w_per_k=envelope_w_per_k, @@ -1006,11 +1208,20 @@ class EpcMlTransform: pred_hw_kwh = predicted_hot_water_kwh( total_floor_area_m2=epc.total_floor_area_m2, seasonal_efficiency_water=water_eff, - cylinder_size=cylinder_size_val if isinstance(cylinder_size_val, int) else None, - cylinder_insulation_thickness_mm=cylinder_ins_thk if isinstance(cylinder_ins_thk, int) else None, - cylinder_insulation_type=cylinder_ins_type if isinstance(cylinder_ins_type, int) else None, + cylinder_size=( + cylinder_size_val if isinstance(cylinder_size_val, int) else None + ), + cylinder_insulation_thickness_mm=( + cylinder_ins_thk if isinstance(cylinder_ins_thk, int) else None + ), + cylinder_insulation_type=( + cylinder_ins_type if isinstance(cylinder_ins_type, int) else None + ), age_band=main_age if isinstance(main_age, str) else None, - has_wwhrs=bool(epc.sap_heating.number_baths_wwhrs and epc.sap_heating.number_baths_wwhrs > 0), + has_wwhrs=bool( + epc.sap_heating.number_baths_wwhrs + and epc.sap_heating.number_baths_wwhrs > 0 + ), has_solar_water_heating=epc.solar_water_heating, ) pred_light_kwh = predicted_lighting_kwh( @@ -1031,7 +1242,9 @@ class EpcMlTransform: predicted_hot_water_kwh=pred_hw_kwh, predicted_lighting_kwh=pred_light_kwh, main_fuel_code=main_fuel_code if isinstance(main_fuel_code, int) else None, - water_heating_fuel_code=water_fuel_code if isinstance(water_fuel_code, int) else None, + water_heating_fuel_code=( + water_fuel_code if isinstance(water_fuel_code, int) else None + ), predicted_pv_kwh=pred_pv_kwh, ) pred_ecf_v = predicted_ecf( @@ -1102,36 +1315,52 @@ class EpcMlTransform: "conservatory_type": epc.conservatory_type, "has_heated_separate_conservatory": epc.has_heated_separate_conservatory, "flat_level": ( - _int_or_none(epc.sap_flat_details.level) if epc.sap_flat_details else None + _int_or_none(epc.sap_flat_details.level) + if epc.sap_flat_details + else None ), "flat_top_storey": ( epc.sap_flat_details.top_storey if epc.sap_flat_details else None ), "flat_storey_count": ( - _int_or_none(epc.sap_flat_details.storey_count) if epc.sap_flat_details else None + _int_or_none(epc.sap_flat_details.storey_count) + if epc.sap_flat_details + else None ), "flat_location": ( - _int_or_none(epc.sap_flat_details.flat_location) if epc.sap_flat_details else None + _int_or_none(epc.sap_flat_details.flat_location) + if epc.sap_flat_details + else None ), "flat_heat_loss_corridor": ( - _int_or_none(epc.sap_flat_details.heat_loss_corridor) if epc.sap_flat_details else None + _int_or_none(epc.sap_flat_details.heat_loss_corridor) + if epc.sap_flat_details + else None ), # Features — energy supply categoricals "meter_type": _meter_type_int(epc.sap_energy_source.meter_type), "pv_connection": epc.sap_energy_source.pv_connection, - "wind_turbines_terrain_type": _wind_terrain_int(epc.sap_energy_source.wind_turbines_terrain_type), + "wind_turbines_terrain_type": _wind_terrain_int( + epc.sap_energy_source.wind_turbines_terrain_type + ), # Features — doors "draughtproofed_door_count": epc.draughtproofed_door_count, "insulated_door_u_value": epc.insulated_door_u_value, # Features — hot water extras - "cylinder_insulation_type": _int_or_none(epc.sap_heating.cylinder_insulation_type), + "cylinder_insulation_type": _int_or_none( + epc.sap_heating.cylinder_insulation_type + ), "cylinder_thermostat": epc.sap_heating.cylinder_thermostat, - "secondary_heating_type": _int_or_none(epc.sap_heating.secondary_heating_type), + "secondary_heating_type": _int_or_none( + epc.sap_heating.secondary_heating_type + ), # Features — mechanical ventilation extras "mechanical_vent_duct_placement": epc.mechanical_vent_duct_placement, "mechanical_vent_duct_insulation": epc.mechanical_vent_duct_insulation, "mechanical_vent_duct_insulation_level": epc.mechanical_vent_duct_insulation_level, - "mechanical_vent_measured_installation": _truthy_yn(epc.mechanical_vent_measured_installation), + "mechanical_vent_measured_installation": _truthy_yn( + epc.mechanical_vent_measured_installation + ), # Features — lighting extras "low_energy_fixed_lighting_bulbs_count": epc.low_energy_fixed_lighting_bulbs_count, "fixed_lighting_outlets_count": epc.fixed_lighting_outlets_count, @@ -1145,16 +1374,19 @@ class EpcMlTransform: # Features — wind turbine geometry "wind_turbine_hub_height_m": ( epc.sap_energy_source.wind_turbine_details.hub_height - if epc.sap_energy_source.wind_turbine_details is not None else None + if epc.sap_energy_source.wind_turbine_details is not None + else None ), "wind_turbine_rotor_diameter_m": ( epc.sap_energy_source.wind_turbine_details.rotor_diameter - if epc.sap_energy_source.wind_turbine_details is not None else None + if epc.sap_energy_source.wind_turbine_details is not None + else None ), # Features — flat unheated corridor length "flat_unheated_corridor_length_m": ( epc.sap_flat_details.unheated_corridor_length_m - if epc.sap_flat_details is not None else None + if epc.sap_flat_details is not None + else None ), # Features — addendum + LZC "addendum_stone_walls": ( @@ -1165,7 +1397,8 @@ class EpcMlTransform: ), "addendum_numbers_count": ( len(epc.addendum.addendum_numbers) - if epc.addendum is not None and epc.addendum.addendum_numbers is not None + if epc.addendum is not None + and epc.addendum.addendum_numbers is not None else 0 ), "lzc_energy_sources_count": ( @@ -1268,7 +1501,7 @@ def _energy_source_other_aggregates(es: SapEnergySource) -> dict[str, Any]: "pv_battery_capacity_kwh": battery_capacity_kwh, "has_wind_turbine": es.wind_turbines_count > 0, "wind_turbine_count": es.wind_turbines_count, - "mains_gas": es.mains_gas, + "mains_gas": es.gas_connection_available, "electricity_smart_meter_present": es.electricity_smart_meter_present, "gas_smart_meter_present": es.gas_smart_meter_present, "is_dwelling_export_capable": es.is_dwelling_export_capable, @@ -1336,7 +1569,9 @@ def _heating_aggregates(sap_heating: SapHeating) -> dict[str, Any]: primary.central_heating_pump_age ) aggregates["primary_sap_main_heating_code"] = primary.sap_main_heating_code - aggregates["primary_emitter_temperature"] = _int_or_none(primary.emitter_temperature) + aggregates["primary_emitter_temperature"] = _int_or_none( + primary.emitter_temperature + ) aggregates["primary_main_heating_fraction"] = primary.main_heating_fraction return aggregates @@ -1474,9 +1709,7 @@ def _building_part_aggregates(parts: list[SapBuildingPart]) -> dict[str, Any]: columns populate only when a part with `identifier == "Main Dwelling"` is present — otherwise None (we don't silently fall back to the first part). """ - main = next( - (p for p in parts if p.identifier is BuildingPartIdentifier.MAIN), None - ) + main = next((p for p in parts if p.identifier is BuildingPartIdentifier.MAIN), None) aggregates: dict[str, Any] = { "building_parts_count": len(parts), "total_heat_loss_perimeter_m": 0.0, @@ -1535,13 +1768,13 @@ def _building_part_aggregates(parts: list[SapBuildingPart]) -> dict[str, Any]: aggregates["main_dwelling_has_room_in_roof"] = main.sap_room_in_roof is not None aggregates["main_dwelling_construction_age_band"] = main.construction_age_band aggregates["main_dwelling_wall_construction"] = ( - main.wall_construction - if isinstance(main.wall_construction, int) - else None + main.wall_construction if isinstance(main.wall_construction, int) else None ) aggregates["main_dwelling_roof_construction"] = main.roof_construction # New fabric inputs: walls - aggregates["main_dwelling_wall_insulation_type"] = _int_or_none(main.wall_insulation_type) + aggregates["main_dwelling_wall_insulation_type"] = _int_or_none( + main.wall_insulation_type + ) aggregates["main_dwelling_wall_insulation_thickness_mm"] = _parse_thickness_mm( main.wall_insulation_thickness ) @@ -1564,7 +1797,9 @@ def _building_part_aggregates(parts: list[SapBuildingPart]) -> dict[str, Any]: ) ground_floor = _ground_floor(main) if ground_floor is not None: - aggregates["main_dwelling_floor_construction"] = ground_floor.floor_construction + aggregates["main_dwelling_floor_construction"] = ( + ground_floor.floor_construction + ) aggregates["main_dwelling_floor_insulation"] = ground_floor.floor_insulation # Main dwelling extras: room-in-roof, alternative walls, flat-roof, measured flag. if main.sap_room_in_roof is not None: @@ -1579,10 +1814,12 @@ def _building_part_aggregates(parts: list[SapBuildingPart]) -> dict[str, Any]: alt_area += float(alt.wall_area) aggregates["main_dwelling_alternative_wall_count"] = alt_count aggregates["main_dwelling_alternative_wall_area_m2"] = alt_area - aggregates["main_dwelling_flat_roof_insulation_thickness_mm"] = _parse_thickness_mm( - main.flat_roof_insulation_thickness + aggregates["main_dwelling_flat_roof_insulation_thickness_mm"] = ( + _parse_thickness_mm(main.flat_roof_insulation_thickness) + ) + aggregates["main_dwelling_wall_thickness_measured"] = ( + main.wall_thickness_measured ) - aggregates["main_dwelling_wall_thickness_measured"] = main.wall_thickness_measured # Extension 1 — first non-main entry in the list. secondary = next( @@ -1621,7 +1858,9 @@ def _building_part_aggregates(parts: list[SapBuildingPart]) -> dict[str, Any]: aggregates["extension_1_heat_loss_perimeter_m"] = sec_hlp # Anything beyond main + secondary just gets counted (extension chains, etc.). - aggregates["other_building_parts_count"] = max(0, len(parts) - (1 if main else 0) - (1 if secondary else 0)) + aggregates["other_building_parts_count"] = max( + 0, len(parts) - (1 if main else 0) - (1 if secondary else 0) + ) return aggregates @@ -1728,7 +1967,11 @@ def _window_aggregates(windows: list[SapWindow]) -> dict[str, Any]: weighted_solar_transmittance / transmission_area ) if glazing_gap_area > 0: - aggregates["window_avg_glazing_gap_mm"] = weighted_glazing_gap / glazing_gap_area + aggregates["window_avg_glazing_gap_mm"] = ( + weighted_glazing_gap / glazing_gap_area + ) if frame_factor_area > 0: - aggregates["window_avg_frame_factor"] = weighted_frame_factor / frame_factor_area + aggregates["window_avg_frame_factor"] = ( + weighted_frame_factor / frame_factor_area + ) return aggregates diff --git a/etl/hubspot/hubspotClient.py b/etl/hubspot/hubspotClient.py index 28abf3030..918701cac 100644 --- a/etl/hubspot/hubspotClient.py +++ b/etl/hubspot/hubspotClient.py @@ -294,6 +294,7 @@ class HubspotClient: "dealstage", "pipeline", "outcome", + "number_of_attempts", "outcome_notes", "booking_status", "project_code", diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index ae2daa491..f72564c69 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -211,6 +211,7 @@ class HubspotDataToDb: ), "uprn": listing.get("national_uprn", None) if listing else None, "outcome": deal_data.get("outcome"), + "number_of_attempts": deal_data.get("number_of_attempts"), "outcome_notes": deal_data.get("outcome_notes"), "booking_status": deal_data.get("booking_status"), "project_code": deal_data.get("project_code"), @@ -322,6 +323,7 @@ class HubspotDataToDb: ), uprn=listing.get("national_uprn") if listing else None, outcome=deal_data.get("outcome"), + number_of_attempts=deal_data.get("number_of_attempts"), outcome_notes=deal_data.get("outcome_notes"), booking_status=deal_data.get("booking_status"), project_code=deal_data.get("project_code"), diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 6d49cf590..b693cf7a4 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -1,7 +1,8 @@ from typing import Dict, List, Optional from backend.app.db.models.hubspot_deal_data import HubspotDealData -from etl.hubspot.utils import parse_hs_bool, parse_hs_date +from etl.hubspot.project_data import ProjectData +from etl.hubspot.utils import parse_hs_bool, parse_hs_date, parse_hs_int class HubspotDealDiffer: @@ -12,6 +13,16 @@ class HubspotDealDiffer: ] RETROFIT_DESIGN_COMPLETE = "uploaded" LODGEMENT_COMPLETE: List[str] = ["lodgement complete", "measures lodged"] + ABANDONMENT_ATTEMPTS_THRESHOLD = 3 + NEGATIVE_OUTCOMES: List[str] = [ + "no answer", + "cancelled", + "no show", + "tenant refusal", + "not viable", + ] + ABRI_CONDITION_ASSOCIATED_PROJECT_ID = "123456" + ABRI_CONDITION_PROJECT_CODE = "Abri Condition" @staticmethod def check_for_db_update_trigger( @@ -49,6 +60,7 @@ class HubspotDealDiffer: # --- Field mappings --- FIELD_MAP = { "outcome": "outcome", + "number_of_attempts": "number_of_attempts", "dealstage": "dealstage", "dealname": "dealname", "project_code": "project_code", @@ -170,6 +182,71 @@ class HubspotDealDiffer: return False + @staticmethod + def check_for_abri_job_logging( + new_deal: Dict[str, str], + new_project: Optional[ProjectData], + old_deal: HubspotDealData, + ) -> bool: + if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project): + return False + + if old_deal.confirmed_survey_date is not None: + return False + + return parse_hs_date(new_deal.get("confirmed_survey_date")) is not None + + @staticmethod + def check_for_abri_survey_amendment( + new_deal: Dict[str, str], + new_project: Optional[ProjectData], + old_deal: HubspotDealData, + ) -> bool: + if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project): + return False + + # A first-time survey date logs a new job, not an amendment. + if old_deal.confirmed_survey_date is None: + return False + + new_survey_date = parse_hs_date(new_deal.get("confirmed_survey_date")) + if old_deal.confirmed_survey_date != new_survey_date: + return True + + return old_deal.confirmed_survey_time != new_deal.get("confirmed_survey_time") + + @staticmethod + def check_for_abri_tenant_data_fetch( + new_deal: Dict[str, str], + new_project: Optional[ProjectData], + old_deal: HubspotDealData, + ) -> bool: + if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project): + return False + + if old_deal.expected_commencement_date is not None: + return False + + return parse_hs_date(new_deal.get("expected_commencement_date")) is not None + + @staticmethod + def check_for_abri_deal_abandonment( + new_deal: Dict[str, str], + new_project: Optional[ProjectData], + old_deal: HubspotDealData, + ) -> bool: + if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project): + return False + + if HubspotDealDiffer._is_abandoned( + old_deal.number_of_attempts, old_deal.outcome + ): + return False + + return HubspotDealDiffer._is_abandoned( + new_deal.get("number_of_attempts"), new_deal.get("outcome") + ) + @staticmethod def check_for_magicplan_trigger( new_deal: Dict[str, str], old_deal: HubspotDealData @@ -178,6 +255,32 @@ class HubspotDealDiffer: old_outcome = (old_deal.outcome or "").lower() return new_outcome == "surveyed" and old_outcome != "surveyed" + @staticmethod + def _is_abandoned( + number_of_attempts: Optional[str], outcome: Optional[str] + ) -> bool: + attempts: Optional[int] = parse_hs_int(number_of_attempts) + if ( + attempts is None + or attempts < HubspotDealDiffer.ABANDONMENT_ATTEMPTS_THRESHOLD + ): + return False + + return (outcome or "").lower() in HubspotDealDiffer.NEGATIVE_OUTCOMES + + @staticmethod + def _is_abri_condition_deal( + new_deal: Dict[str, str], new_project: Optional[ProjectData] + ) -> bool: + if new_project is not None: + return ( + new_project["project_id"] + == HubspotDealDiffer.ABRI_CONDITION_ASSOCIATED_PROJECT_ID + ) + + new_project_code = (new_deal.get("project_code") or "").lower() + return new_project_code == HubspotDealDiffer.ABRI_CONDITION_PROJECT_CODE.lower() + @staticmethod def _has_valid_pashub_link(new_pashub_link: str) -> bool: return bool(new_pashub_link) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 949524243..08e6c78a1 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -6,6 +6,7 @@ import pytest from backend.app.db.models.hubspot_deal_data import HubspotDealData from etl.hubspot.hubspot_deal_differ import HubspotDealDiffer +from etl.hubspot.project_data import ProjectData BASE_TIME = datetime(2025, 12, 1, 12, 0, 0) @@ -343,6 +344,733 @@ def test_magicplan_trigger__outcome_surveyed_uppercase__returns_true() -> None: assert result is True +# ============================== +# ABRI JOB LOGGING TRIGGER TESTS +# ============================== + + +def test_abri_job_logging__date_first_set_on_abri_deal__returns_true() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + confirmed_survey_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + confirmed_survey_date="2026-07-15", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_job_logging( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is True + + +def test_abri_job_logging__date_already_set__returns_false() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + confirmed_survey_date=datetime(2026, 7, 1, tzinfo=timezone.utc), + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + confirmed_survey_date="2026-07-15", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_job_logging( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +def test_abri_job_logging__non_abri_project_code__returns_false() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Southern Retrofit", + confirmed_survey_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Southern Retrofit", + confirmed_survey_date="2026-07-15", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_job_logging( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +def test_abri_job_logging__project_code_cased_differently__returns_true() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="ABRI CONDITION", + confirmed_survey_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="ABRI CONDITION", + confirmed_survey_date="2026-07-15", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_job_logging( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is True + + +@pytest.mark.parametrize( + "new_overrides", + [ + {}, + {"confirmed_survey_date": ""}, + {"confirmed_survey_date": "not-a-date"}, + ], +) +def test_abri_job_logging__no_parseable_new_date__returns_false( + new_overrides: Dict[str, str], +) -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + confirmed_survey_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + **new_overrides, + ) + + # Act + result = HubspotDealDiffer.check_for_abri_job_logging( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +def test_abri_job_logging__associated_abri_project_id__returns_true() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Southern Retrofit", + confirmed_survey_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Southern Retrofit", + confirmed_survey_date="2026-07-15", + ) + new_project = ProjectData( + project_id=HubspotDealDiffer.ABRI_CONDITION_ASSOCIATED_PROJECT_ID, + name="Abri Condition", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_job_logging( + new_deal=new_deal, + new_project=new_project, + old_deal=old_deal, + ) + + # Assert + assert result is True + + +def test_abri_job_logging__non_abri_project_id_with_abri_code__returns_false() -> ( + None +): + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + confirmed_survey_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + confirmed_survey_date="2026-07-15", + ) + new_project = ProjectData(project_id="999999", name="Southern Retrofit") + + # Act + result = HubspotDealDiffer.check_for_abri_job_logging( + new_deal=new_deal, + new_project=new_project, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +# =================================== +# ABRI SURVEY AMENDMENT TRIGGER TESTS +# =================================== + + +def test_abri_survey_amendment__date_changed_on_abri_deal__returns_true() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + confirmed_survey_date=datetime(2026, 7, 1, tzinfo=timezone.utc), + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + confirmed_survey_date="2026-07-15", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_survey_amendment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is True + + +def test_abri_survey_amendment__non_abri_deal__returns_false() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Southern Retrofit", + confirmed_survey_date=datetime(2026, 7, 1, tzinfo=timezone.utc), + ) + new_deal = make_new_deal( + deal_id, + project_code="Southern Retrofit", + confirmed_survey_date="2026-07-15", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_survey_amendment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +def test_abri_survey_amendment__date_and_time_unchanged__returns_false() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + confirmed_survey_date=datetime(2026, 7, 15, tzinfo=timezone.utc), + confirmed_survey_time="09:30", + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + confirmed_survey_date="2026-07-15", + confirmed_survey_time="09:30", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_survey_amendment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +def test_abri_survey_amendment__time_changed_on_abri_deal__returns_true() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + confirmed_survey_date=datetime(2026, 7, 15, tzinfo=timezone.utc), + confirmed_survey_time="09:30", + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + confirmed_survey_date="2026-07-15", + confirmed_survey_time="14:00", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_survey_amendment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is True + + +def test_abri_survey_amendment__date_first_set_on_abri_deal__returns_false() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + confirmed_survey_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + confirmed_survey_date="2026-07-15", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_survey_amendment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +# ==================================== +# ABRI TENANT DATA FETCH TRIGGER TESTS +# ==================================== + + +def test_abri_tenant_data_fetch__commencement_date_first_set_on_abri_deal__returns_true() -> ( + None +): + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + expected_commencement_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + expected_commencement_date="2026-08-01", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_tenant_data_fetch( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is True + + +def test_abri_tenant_data_fetch__non_abri_deal__returns_false() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Southern Retrofit", + expected_commencement_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Southern Retrofit", + expected_commencement_date="2026-08-01", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_tenant_data_fetch( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +def test_abri_tenant_data_fetch__commencement_date_already_set__returns_false() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + expected_commencement_date=datetime(2026, 7, 1, tzinfo=timezone.utc), + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + expected_commencement_date="2026-08-01", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_tenant_data_fetch( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +@pytest.mark.parametrize( + "new_overrides", + [ + {}, + {"expected_commencement_date": ""}, + {"expected_commencement_date": "not-a-date"}, + ], +) +def test_abri_tenant_data_fetch__no_parseable_new_commencement_date__returns_false( + new_overrides: Dict[str, str], +) -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + expected_commencement_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + **new_overrides, + ) + + # Act + result = HubspotDealDiffer.check_for_abri_tenant_data_fetch( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +def test_abri_tenant_data_fetch__associated_abri_project_id__returns_true() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Southern Retrofit", + expected_commencement_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Southern Retrofit", + expected_commencement_date="2026-08-01", + ) + new_project = ProjectData( + project_id=HubspotDealDiffer.ABRI_CONDITION_ASSOCIATED_PROJECT_ID, + name="Abri Condition", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_tenant_data_fetch( + new_deal=new_deal, + new_project=new_project, + old_deal=old_deal, + ) + + # Assert + assert result is True + + +def test_abri_tenant_data_fetch__non_abri_project_id_with_abri_code__returns_false() -> ( + None +): + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + expected_commencement_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + expected_commencement_date="2026-08-01", + ) + new_project = ProjectData(project_id="999999", name="Southern Retrofit") + + # Act + result = HubspotDealDiffer.check_for_abri_tenant_data_fetch( + new_deal=new_deal, + new_project=new_project, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +# ==================================== +# ABRI DEAL ABANDONMENT TRIGGER TESTS +# ==================================== + + +def test_abri_deal_abandonment__third_attempt_with_negative_outcome__returns_true() -> ( + None +): + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + outcome=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + number_of_attempts="3", + outcome="No Answer", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_deal_abandonment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is True + + +def test_abri_deal_abandonment__non_abri_deal__returns_false() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Southern Retrofit", + outcome=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Southern Retrofit", + number_of_attempts="3", + outcome="No Answer", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_deal_abandonment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +@pytest.mark.parametrize( + "new_overrides", + [ + {"number_of_attempts": "2"}, + {"number_of_attempts": ""}, + {"number_of_attempts": "not-a-number"}, + {}, + ], +) +def test_abri_deal_abandonment__fewer_than_three_parseable_attempts__returns_false( + new_overrides: Dict[str, str], +) -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + outcome=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + outcome="No Answer", + **new_overrides, + ) + + # Act + result = HubspotDealDiffer.check_for_abri_deal_abandonment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +@pytest.mark.parametrize( + "new_overrides", + [ + {"outcome": "Surveyed"}, + {"outcome": ""}, + {}, + ], +) +def test_abri_deal_abandonment__outcome_not_negative__returns_false( + new_overrides: Dict[str, str], +) -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + outcome=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + number_of_attempts="3", + **new_overrides, + ) + + # Act + result = HubspotDealDiffer.check_for_abri_deal_abandonment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +@pytest.mark.parametrize( + "outcome", + [ + "No Answer", + "Cancelled", + "No Show", + "Tenant Refusal", + "Not Viable", + "NO ANSWER", + ], +) +def test_abri_deal_abandonment__each_negative_outcome__returns_true( + outcome: str, +) -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + outcome=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + number_of_attempts="3", + outcome=outcome, + ) + + # Act + result = HubspotDealDiffer.check_for_abri_deal_abandonment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is True + + +def test_abri_deal_abandonment__deal_already_abandoned__returns_false() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + number_of_attempts="3", + outcome="No Answer", + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + number_of_attempts="4", + outcome="No Answer", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_deal_abandonment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= @@ -400,6 +1128,32 @@ def test_db_update_trigger__dealname_changed__returns_true() -> None: assert result is True +def test_db_update_trigger__number_of_attempts_changed__returns_true() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + number_of_attempts="2", + ) + new_deal = make_new_deal( + deal_id, + hs_object_id="1", + number_of_attempts="3", + ) + + # Act + result = HubspotDealDiffer.check_for_db_update_trigger( + new_deal=new_deal, + new_company=None, + new_listing=None, + old_deal=old_deal, + ) + + # Assert + assert result is True + + def test_db_update_trigger__company_changed__returns_true() -> None: deal_id = uuid.uuid4() diff --git a/etl/hubspot/utils.py b/etl/hubspot/utils.py index 6077727ee..dea173324 100644 --- a/etl/hubspot/utils.py +++ b/etl/hubspot/utils.py @@ -16,6 +16,15 @@ def parse_hs_date(value: Optional[str]) -> Optional[datetime]: return None +def parse_hs_int(value: Optional[str]) -> Optional[int]: + if not value: + return None + try: + return int(value) + except ValueError: + return None + + def parse_hs_bool(value: Optional[str]) -> Optional[bool]: if value is None or value == "": return None diff --git a/harness/console.py b/harness/console.py index b60528832..a9aa222b5 100644 --- a/harness/console.py +++ b/harness/console.py @@ -273,10 +273,13 @@ def candidate_recommendations( if solar_insights is not None and "solarPotential" in solar_insights else None ) + # The SAP design heat loss sizes the ASHP to the dwelling (ADR-0049). + design_heat_loss_kw: float = Sap10Calculator().calculate(epc).design_heat_loss_kw return _candidate_recommendations( epc, products or ProductJsonRepository(catalogue_path), planning_restrictions, solar_potential, considered_measures, + design_heat_loss_kw, ) diff --git a/harness/report.py b/harness/report.py index c9bd13a29..f138154cd 100644 --- a/harness/report.py +++ b/harness/report.py @@ -142,7 +142,7 @@ def _triggers_for(epc: EpcPropertyData, measure_type: str) -> dict[str, Any]: "sap_main_heating_code": ( epc.sap_heating.main_heating_details[0].sap_main_heating_code ), - "mains_gas": epc.sap_energy_source.mains_gas, + "mains_gas": epc.sap_energy_source.gas_connection_available, } if measure_type == "air_source_heat_pump": # heating_recommendation.py offers ASHP to any non-flat house/bungalow @@ -161,7 +161,7 @@ def _triggers_for(epc: EpcPropertyData, measure_type: str) -> dict[str, Any]: "sap_main_heating_code": ( epc.sap_heating.main_heating_details[0].sap_main_heating_code ), - "mains_gas": epc.sap_energy_source.mains_gas, + "mains_gas": epc.sap_energy_source.gas_connection_available, "has_hot_water_cylinder": epc.has_hot_water_cylinder, } if measure_type in ("system_tune_up", "system_tune_up_zoned"): @@ -223,7 +223,9 @@ def build_property_report( ) for measure in plan.measures ) - except Exception as error: # noqa: BLE001 — modelling raise must not abort the report + except ( + Exception + ) as error: # noqa: BLE001 — modelling raise must not abort the report plan_error = f"{type(error).__name__}: {error}" return PropertyReport( @@ -312,9 +314,7 @@ def _calculator_error_section(reports: list[PropertyReport]) -> list[str]: ) delta: str = "—" if report.sap_error is None else f"{report.sap_error:+.2f}" flag: str = "⚠ FLAG" if report.sap_error_exceeds_threshold else "" - lines.append( - f"| {report.name} | {lodged} | {calculated} | {delta} | {flag} |" - ) + lines.append(f"| {report.name} | {lodged} | {calculated} | {delta} | {flag} |") return lines @@ -436,9 +436,11 @@ def format_report_csv(reports: list[PropertyReport]) -> str: None if plan is None else plan.baseline_epc_rating.value, None if plan is None else plan.post_epc_rating.value, None if plan is None else len(plan.measures), - None - if plan is None - else ";".join(measure.measure_type for measure in plan.measures), + ( + None + if plan is None + else ";".join(measure.measure_type for measure in plan.measures) + ), None if plan is None else plan.cost_of_works, None if plan is None else plan.contingency_cost, None if plan is None else plan.energy_bill_savings, diff --git a/infrastructure/abri/__init__.py b/infrastructure/abri/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py new file mode 100644 index 000000000..72a3c4767 --- /dev/null +++ b/infrastructure/abri/abri_client.py @@ -0,0 +1,113 @@ +import xml.etree.ElementTree as ET +from typing import List, Tuple, Union + +import requests + +from domain.abri.models import ( + AbriRequestRejected, + JobLogged, + LogJobRequest, + LogJobResult, +) +from infrastructure.abri.config import AbriConfig +from infrastructure.abri.envelope import serialise_relay_request +from infrastructure.abri.errors import AbriResponseParseError, AbriTransportError + +STD_JOB_CODE = "SCSEXT" +CLIENT_CODE = "HSG" +RESOURCE_GROUP = "Surveyors" + + +class AbriClient: + def __init__(self, config: AbriConfig) -> None: + self._config = config + self._session = requests.Session() + + def log_job(self, request: LogJobRequest) -> LogJobResult: + outcome = self._exchange( + request_type="logjob", + parameters=[ + ("place_ref", request.place_ref), + ("std_job_code", STD_JOB_CODE), + ("client", CLIENT_CODE), + ("short_description", request.short_description), + ("long_description", request.long_description), + ("client_ref", request.client_ref), + ("appointment_date", request.appointment_date.strftime("%d/%m/%Y")), + ("appointment_time", request.appointment_time), + ("resource", self._config.default_resource), + ("resource_group", RESOURCE_GROUP), + ], + ) + + if isinstance(outcome, AbriRequestRejected): + return outcome + + return self._parse_job_logged(outcome) + + def _exchange( + self, request_type: str, parameters: List[Tuple[str, str]] + ) -> Union[ET.Element, AbriRequestRejected]: + envelope = serialise_relay_request( + request_type=request_type, + parameters=parameters, + username=self._config.username, + password=self._config.password, + ) + + reply = self._parse_reply(self._post(envelope)) + + if self._is_failure_document(reply): + return self._parse_rejection(reply) + + return reply + + def _post(self, envelope: bytes) -> bytes: + try: + response = self._session.post(self._config.endpoint_url, data=envelope) + response.raise_for_status() + except requests.RequestException as error: + raise AbriTransportError(str(error)) from error + + return response.content + + @staticmethod + def _parse_reply(body: bytes) -> ET.Element: + try: + return ET.fromstring(body) + except ET.ParseError as error: + raise AbriResponseParseError(str(error)) from error + + @staticmethod + def _is_failure_document(reply: ET.Element) -> bool: + return reply.tag == "response" + + @staticmethod + def _parse_job_logged(root: ET.Element) -> JobLogged: + job_logged = root.find("Jobs/Job_logged") + + if job_logged is None: + raise AbriResponseParseError( + "Job_logged element missing from relay response" + ) + + job_no = job_logged.get("job_no") + logged_info = job_logged.get("logged_info") + + if job_no is None or logged_info is None: + raise AbriResponseParseError( + "Job_logged element missing job_no or logged_info" + ) + + return JobLogged(job_no=job_no, logged_info=logged_info) + + @staticmethod + def _parse_rejection(root: ET.Element) -> AbriRequestRejected: + success = root.findtext("success") + code = root.findtext("code") + message = root.findtext("message") + + if success != "false" or code is None or message is None: + raise AbriResponseParseError("malformed relay failure response") + + return AbriRequestRejected(code=code, message=message) diff --git a/infrastructure/abri/config.py b/infrastructure/abri/config.py new file mode 100644 index 000000000..f2da18a77 --- /dev/null +++ b/infrastructure/abri/config.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import Mapping + + +@dataclass(frozen=True) +class AbriConfig: + endpoint_url: str + username: str + password: str + default_resource: str + + @classmethod + def from_env(cls, env: Mapping[str, str]) -> "AbriConfig": + return cls( + endpoint_url=env["ABRI_RELAY_URL"], + username=env["ABRI_RELAY_USERNAME"], + password=env["ABRI_RELAY_PASSWORD"], + default_resource=env["ABRI_RELAY_DEFAULT_RESOURCE"], + ) diff --git a/infrastructure/abri/envelope.py b/infrastructure/abri/envelope.py new file mode 100644 index 000000000..b7eeb89cd --- /dev/null +++ b/infrastructure/abri/envelope.py @@ -0,0 +1,23 @@ +import xml.etree.ElementTree as ET +from typing import Sequence, Tuple + + +def serialise_relay_request( + request_type: str, + parameters: Sequence[Tuple[str, str]], + username: str, + password: str, +) -> bytes: + message = ET.Element("message") + header = ET.SubElement(message, "Header") + ET.SubElement(header, "Security", username=username, password=password) + body = ET.SubElement(message, "Body") + request = ET.SubElement(body, "Request", request_type=request_type) + for attribute, attribute_value in parameters: + ET.SubElement( + request, + "Parameters", + attribute=attribute, + attribute_value=attribute_value, + ) + return ET.tostring(message, encoding="utf-8", xml_declaration=True) diff --git a/infrastructure/abri/errors.py b/infrastructure/abri/errors.py new file mode 100644 index 000000000..39499293a --- /dev/null +++ b/infrastructure/abri/errors.py @@ -0,0 +1,10 @@ +class AbriTransportError(Exception): + """A transport-level relay failure; the request may be retried.""" + + +class AbriResponseParseError(AbriTransportError): + """An unparseable or unexpectedly-shaped relay response. + + Transport-class (retriable): the job may or may not have been logged, + so the outcome must never be treated as success or rejection. + """ diff --git a/infrastructure/postgres/epc_property_table.py b/infrastructure/postgres/epc_property_table.py index 74443e7aa..1e36828ee 100644 --- a/infrastructure/postgres/epc_property_table.py +++ b/infrastructure/postgres/epc_property_table.py @@ -20,7 +20,9 @@ from datatypes.epc.domain.epc_property_data import ( class EpcPropertyModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_property" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_property" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) property_id: Optional[int] = Field(default=None) @@ -126,7 +128,7 @@ class EpcPropertyModel(SQLModel, table=True): windows_transmission_solar_transmittance: Optional[float] = Field(default=None) # Energy source - energy_mains_gas: bool + energy_gas_connection_available: bool energy_meter_type: str energy_pv_battery_count: int energy_wind_turbines_count: int @@ -264,9 +266,7 @@ class EpcPropertyModel(SQLModel, table=True): has_heated_separate_conservatory=data.has_heated_separate_conservatory, conservatory_type=data.conservatory_type, conservatory_floor_area_m2=( - data.sap_conservatory.floor_area_m2 - if data.sap_conservatory - else None + data.sap_conservatory.floor_area_m2 if data.sap_conservatory else None ), conservatory_glazed_perimeter_m=( data.sap_conservatory.glazed_perimeter_m @@ -333,7 +333,7 @@ class EpcPropertyModel(SQLModel, table=True): if data.windows_transmission_details else None ), - energy_mains_gas=es.mains_gas, + energy_gas_connection_available=es.gas_connection_available, energy_meter_type=str(es.meter_type), energy_pv_battery_count=es.pv_battery_count, energy_wind_turbines_count=es.wind_turbines_count, @@ -408,7 +408,9 @@ class EpcPropertyModel(SQLModel, table=True): class EpcPropertyEnergyPerformanceModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_property_energy_performance" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_property_energy_performance" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field( @@ -468,7 +470,9 @@ class EpcPropertyEnergyPerformanceModel(SQLModel, table=True): class EpcRenewableHeatIncentiveModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_renewable_heat_incentive" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_renewable_heat_incentive" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field( @@ -496,7 +500,9 @@ class EpcRenewableHeatIncentiveModel(SQLModel, table=True): class EpcFlatDetailsModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_flat_details" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_flat_details" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field( @@ -526,7 +532,9 @@ class EpcFlatDetailsModel(SQLModel, table=True): class EpcMainHeatingDetailModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_main_heating_detail" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_main_heating_detail" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False) @@ -589,7 +597,9 @@ class EpcMainHeatingDetailModel(SQLModel, table=True): class EpcBuildingPartModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_building_part" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_building_part" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False) @@ -715,7 +725,9 @@ class EpcBuildingPartModel(SQLModel, table=True): class EpcFloorDimensionModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_floor_dimension" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_floor_dimension" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_building_part_id: int = Field( @@ -754,7 +766,9 @@ class EpcFloorDimensionModel(SQLModel, table=True): class EpcWindowModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_window" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_window" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False) @@ -806,7 +820,9 @@ class EpcWindowModel(SQLModel, table=True): class EpcPhotovoltaicArrayModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_photovoltaic_array" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_photovoltaic_array" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False) @@ -837,7 +853,9 @@ class EpcPhotovoltaicArrayModel(SQLModel, table=True): class EpcEnergyElementModel(SQLModel, table=True): - __tablename__: ClassVar[str] = "epc_energy_element" # pyright: ignore[reportIncompatibleVariableOverride] + __tablename__: ClassVar[str] = ( + "epc_energy_element" # pyright: ignore[reportIncompatibleVariableOverride] + ) id: Optional[int] = Field(default=None, primary_key=True) epc_property_id: int = Field(foreign_key="epc_property.id", nullable=False) diff --git a/infrastructure/postgres/property_baseline_performance_table.py b/infrastructure/postgres/property_baseline_performance_table.py index 89019478b..ecbb3722e 100644 --- a/infrastructure/postgres/property_baseline_performance_table.py +++ b/infrastructure/postgres/property_baseline_performance_table.py @@ -48,12 +48,16 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True): # Postgres won't coerce to the enum. Bind the native enums explicitly # (``create_type=False``: the types already exist). Values stay plain # strings on the domain side (``Epc(...)`` / the RebaselineReason Literal). - lodged_sap_score: int - lodged_epc_band: str = Field( - sa_column=Column(SAEnum(*_EPC_BANDS, name="epc", create_type=False), nullable=False) + # The four lodged_* columns are nullable as a unit: a predicted Property has no + # lodged cert, so it has no Lodged Performance (ADR-0004 amendment, #1361). They + # are written all-set or all-NULL; lodged_sap_score is the read discriminator. + lodged_sap_score: Optional[int] = None + lodged_epc_band: Optional[str] = Field( + default=None, + sa_column=Column(SAEnum(*_EPC_BANDS, name="epc", create_type=False), nullable=True), ) - lodged_co2_emissions_t_per_yr: float - lodged_primary_energy_intensity_kwh_per_m2_yr: int + lodged_co2_emissions_t_per_yr: Optional[float] = None + lodged_primary_energy_intensity_kwh_per_m2_yr: Optional[int] = None effective_sap_score: int effective_epc_band: str = Field( @@ -102,12 +106,17 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True): def from_domain( cls, baseline: PropertyBaselinePerformance, property_id: int ) -> "PropertyBaselinePerformanceModel": + # A predicted Property has no Lodged Performance — the four lodged_* columns + # are all NULL then (ADR-0004 amendment, #1361). + lodged = baseline.lodged model = cls( property_id=property_id, - lodged_sap_score=baseline.lodged.sap_score, - lodged_epc_band=baseline.lodged.epc_band.value, - lodged_co2_emissions_t_per_yr=baseline.lodged.co2_emissions, - lodged_primary_energy_intensity_kwh_per_m2_yr=baseline.lodged.primary_energy_intensity, + lodged_sap_score=lodged.sap_score if lodged is not None else None, + lodged_epc_band=lodged.epc_band.value if lodged is not None else None, + lodged_co2_emissions_t_per_yr=lodged.co2_emissions if lodged is not None else None, + lodged_primary_energy_intensity_kwh_per_m2_yr=( + lodged.primary_energy_intensity if lodged is not None else None + ), effective_sap_score=baseline.effective.sap_score, effective_epc_band=baseline.effective.epc_band.value, effective_co2_emissions_t_per_yr=baseline.effective.co2_emissions, @@ -139,12 +148,7 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True): def to_domain(self) -> PropertyBaselinePerformance: return PropertyBaselinePerformance( - lodged=Performance( - sap_score=self.lodged_sap_score, - epc_band=Epc(self.lodged_epc_band), - co2_emissions=self.lodged_co2_emissions_t_per_yr, - primary_energy_intensity=self.lodged_primary_energy_intensity_kwh_per_m2_yr, - ), + lodged=self._read_lodged(), effective=Performance( sap_score=self.effective_sap_score, epc_band=Epc(self.effective_epc_band), @@ -157,6 +161,23 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True): bill=self._read_bill(), ) + def _read_lodged(self) -> Optional[Performance]: + """The Lodged half, or None for a predicted Property whose four lodged_* + columns are NULL (ADR-0004 amendment, #1361). They are written as a unit + (`from_domain`), so lodged_sap_score is the discriminator and the other + three are non-null alongside it.""" + if self.lodged_sap_score is None: + return None + assert self.lodged_epc_band is not None + assert self.lodged_co2_emissions_t_per_yr is not None + assert self.lodged_primary_energy_intensity_kwh_per_m2_yr is not None + return Performance( + sap_score=self.lodged_sap_score, + epc_band=Epc(self.lodged_epc_band), + co2_emissions=self.lodged_co2_emissions_t_per_yr, + primary_energy_intensity=self.lodged_primary_energy_intensity_kwh_per_m2_yr, + ) + def _read_bill(self) -> Optional[Bill]: """Reconstruct the Bill from the ``bill_*`` columns. The total is the not-None discriminator: a persisted bill always sets it, so its absence diff --git a/modelling_audit.md b/modelling_audit.md index c537bd981..38959ba08 100644 --- a/modelling_audit.md +++ b/modelling_audit.md @@ -1,463 +1,121 @@ # Modelling anomaly audit -Scanned **31919** properties · flagged **10563** anomalies across **10** checks. +Scanned **338** properties · flagged **360** anomalies across **6** checks. -## impossible-sap-over-100 (HIGH) — 1 +## plan-score-below-baseline (HIGH) — 3 -- property **726993** (uprn 100061757571): SAP > 100: effective 102.0 +- property **742302** (uprn 100022921559): post SAP 70.5 below effective baseline 72.0 (Δ-1.5) +- property **742367** (uprn 5300072135): post SAP 68.9 below effective baseline 70.0 (Δ-1.1) +- property **742368** (uprn 5300072136): post SAP 70.4 below effective baseline 71.0 (Δ-0.6) -## plan-below-baseline-band (HIGH) — 363 +## plan-stops-short-of-goal (HIGH) — 311 -- property **709810** (uprn 10096028301): post C (78.02813) worse than effective baseline B (85) -- property **709846** (uprn 10096399556): post C (79.7374) worse than effective baseline A (93) -- property **709847** (uprn 10096028354): post C (78.695816) worse than effective baseline B (91) -- property **709850** (uprn 10096028348): post C (78.750984) worse than effective baseline B (89) -- property **709959** (uprn 10096028306): post C (78.14567) worse than effective baseline B (84) -- property **710011** (uprn 10096028349): post C (78.750984) worse than effective baseline B (89) -- property **710071** (uprn 10002918889): post C (76.07915) worse than effective baseline B (82) -- property **710075** (uprn 10002918890): post C (79.84036) worse than effective baseline B (82) -- property **710117** (uprn 10096399566): post C (79.86282) worse than effective baseline A (93) -- property **710121** (uprn 10096028307): post C (78.790596) worse than effective baseline B (90) -- property **710131** (uprn 10096028350): post C (80.142784) worse than effective baseline A (94) -- property **710201** (uprn 10090944222): post C (74.58814) worse than effective baseline B (82) -- property **710222** (uprn 10096399567): post C (79.84616) worse than effective baseline A (93) -- property **710225** (uprn 10096028308): post C (78.78894) worse than effective baseline B (90) -- property **710241** (uprn 10096028351): post C (80.142784) worse than effective baseline A (94) -- property **710326** (uprn 10096028309): post C (78.610855) worse than effective baseline B (89) -- property **710335** (uprn 10096028352): post C (79.9757) worse than effective baseline A (94) -- property **710395** (uprn 10096028310): post C (79.46583) worse than effective baseline B (91) -- property **710474** (uprn 10096028355): post C (78.47655) worse than effective baseline B (91) -- property **710479** (uprn 10096028353): post C (80.291725) worse than effective baseline A (95) -- property **710537** (uprn 10096028311): post C (79.46583) worse than effective baseline B (91) -- property **710540** (uprn 100060714157): post D (68.24699) worse than effective baseline C (71) -- property **710760** (uprn 10096028302): post C (78.28315) worse than effective baseline B (85) -- property **710800** (uprn 10096399558): post C (79.7374) worse than effective baseline A (93) -- property **710802** (uprn 10096028356): post C (78.47655) worse than effective baseline B (91) -- property **710946** (uprn 44012846): post C (79.819626) worse than effective baseline B (81) -- property **711027** (uprn 10096028357): post C (77.96618) worse than effective baseline B (90) -- property **711178** (uprn 10091636026): post C (76.05113) worse than effective baseline B (81) -- property **711195** (uprn 10096028303): post C (80.00826) worse than effective baseline B (86) -- property **711236** (uprn 10096399560): post C (79.7374) worse than effective baseline A (93) -- property **711238** (uprn 10096028358): post C (78.695816) worse than effective baseline B (91) -- property **711312** (uprn 10096028346): post C (80.1876) worse than effective baseline A (93) -- property **711376** (uprn 10096028359): post C (78.47655) worse than effective baseline B (91) -- property **711489** (uprn 10096028304): post C (80.3187) worse than effective baseline B (86) -- property **711523** (uprn 10096399562): post C (79.7374) worse than effective baseline A (93) -- property **711524** (uprn 10096028360): post C (78.47655) worse than effective baseline B (91) -- property **711539** (uprn 68159801): post D (66.09142) worse than effective baseline C (71) -- property **711575** (uprn 10096028347): post C (80.1876) worse than effective baseline A (94) -- property **711639** (uprn 10096028361): post C (78.88584) worse than effective baseline B (91) -- property **711706** (uprn 10096028305): post C (78.71836) worse than effective baseline B (85) -- property **711715** (uprn 10013924857): post C (79.91303) worse than effective baseline B (81) -- property **711732** (uprn 100062190435): post C (77.935425) worse than effective baseline B (82) -- property **711795** (uprn 10090343115): post E (52.785263) worse than effective baseline C (73) -- property **711821** (uprn 10096399575): post C (80.209465) worse than effective baseline A (93) -- property **711824** (uprn 10094615095): post C (78.01443) worse than effective baseline A (92) -- property **711858** (uprn 22245014): post C (79.352325) worse than effective baseline B (81) -- property **711881** (uprn 10023444014): post C (79.504456) worse than effective baseline B (81) -- property **711897** (uprn 10012025246): post C (76.70494) worse than effective baseline B (81) -- property **711898** (uprn 10094615103): post C (79.97074) worse than effective baseline A (92) -- property **711917** (uprn 10012027840): post C (72.92677) worse than effective baseline B (81) -- … and 313 more (see CSV) +- property **742063** (uprn 10010200962): post D (60.29299) short of goal B on an unlimited-budget scenario (cost_of_works £10378) +- property **742064** (uprn 10010206037): post C (69.27856) short of goal B on an unlimited-budget scenario (cost_of_works £9339) +- property **742065** (uprn 10010206038): post C (78.81704) short of goal B on an unlimited-budget scenario (cost_of_works £1043) +- property **742066** (uprn 10010206039): post C (76.22735) short of goal B on an unlimited-budget scenario (cost_of_works £7876) +- property **742067** (uprn 10010206040): post C (72.775085) short of goal B on an unlimited-budget scenario (cost_of_works £7682) +- property **742068** (uprn 10010206041): post C (74.29278) short of goal B on an unlimited-budget scenario (cost_of_works £8510) +- property **742069** (uprn 10010206042): post C (70.24012) short of goal B on an unlimited-budget scenario (cost_of_works £7638) +- property **742070** (uprn 10010206043): post C (72.3706) short of goal B on an unlimited-budget scenario (cost_of_works £3702) +- property **742072** (uprn 34003067): post D (61.096947) short of goal B on an unlimited-budget scenario (cost_of_works £13391) +- property **742073** (uprn 34003068): post D (61.096947) short of goal B on an unlimited-budget scenario (cost_of_works £13391) +- property **742075** (uprn 34003070): post D (61.096947) short of goal B on an unlimited-budget scenario (cost_of_works £13391) +- property **742076** (uprn 34003071): post D (61.096947) short of goal B on an unlimited-budget scenario (cost_of_works £13391) +- property **742079** (uprn 12013027): post C (78.44673) short of goal B on an unlimited-budget scenario (cost_of_works £2892) +- property **742080** (uprn 12013028): post D (62.603214) short of goal B on an unlimited-budget scenario (cost_of_works £3372) +- property **742081** (uprn 12013029): post C (74.525345) short of goal B on an unlimited-budget scenario (cost_of_works £1029) +- property **742082** (uprn 12013030): post C (78.44673) short of goal B on an unlimited-budget scenario (cost_of_works £2892) +- property **742083** (uprn 12013031): post C (78.44673) short of goal B on an unlimited-budget scenario (cost_of_works £2892) +- property **742084** (uprn 12013032): post C (73.37248) short of goal B on an unlimited-budget scenario (cost_of_works £1057) +- property **742085** (uprn 12013033): post D (68.01724) short of goal B on an unlimited-budget scenario (cost_of_works £1057) +- property **742086** (uprn 12013034): post D (61.581734) short of goal B on an unlimited-budget scenario (cost_of_works £3610) +- property **742087** (uprn 12013038): post C (78.44673) short of goal B on an unlimited-budget scenario (cost_of_works £2892) +- property **742088** (uprn 12013042): post C (74.92009) short of goal B on an unlimited-budget scenario (cost_of_works £3404) +- property **742090** (uprn 12013044): post C (73.35894) short of goal B on an unlimited-budget scenario (cost_of_works £1057) +- property **742091** (uprn 12013045): post C (73.37248) short of goal B on an unlimited-budget scenario (cost_of_works £1057) +- property **742092** (uprn 12013046): post D (60.531925) short of goal B on an unlimited-budget scenario (cost_of_works £1029) +- property **742093** (uprn 12013047): post C (74.26961) short of goal B on an unlimited-budget scenario (cost_of_works £2899) +- property **742094** (uprn 12013048): post C (78.44673) short of goal B on an unlimited-budget scenario (cost_of_works £2892) +- property **742095** (uprn 100020233237): post C (68.751595) short of goal B on an unlimited-budget scenario (cost_of_works £0) +- property **742096** (uprn 100020233238): post C (68.751595) short of goal B on an unlimited-budget scenario (cost_of_works £0) +- property **742097** (uprn 100020233239): post C (72.782684) short of goal B on an unlimited-budget scenario (cost_of_works £7684) +- property **742098** (uprn 100020233240): post D (67.203026) short of goal B on an unlimited-budget scenario (cost_of_works £633) +- property **742099** (uprn 100020233241): post D (67.30785) short of goal B on an unlimited-budget scenario (cost_of_works £3400) +- property **742100** (uprn 100020233242): post C (68.658844) short of goal B on an unlimited-budget scenario (cost_of_works £35) +- property **742101** (uprn 100020233243): post D (66.59807) short of goal B on an unlimited-budget scenario (cost_of_works £3285) +- property **742102** (uprn 100020233244): post C (68.751595) short of goal B on an unlimited-budget scenario (cost_of_works £0) +- property **742103** (uprn 100020233204): post C (68.751595) short of goal B on an unlimited-budget scenario (cost_of_works £0) +- property **742104** (uprn 100020233205): post D (66.40878) short of goal B on an unlimited-budget scenario (cost_of_works £32) +- property **742105** (uprn 100020233206): post C (68.751595) short of goal B on an unlimited-budget scenario (cost_of_works £0) +- property **742106** (uprn 100020233207): post C (68.751595) short of goal B on an unlimited-budget scenario (cost_of_works £0) +- property **742107** (uprn 100020233208): post C (68.751595) short of goal B on an unlimited-budget scenario (cost_of_works £0) +- property **742108** (uprn 100020233209): post C (68.751595) short of goal B on an unlimited-budget scenario (cost_of_works £0) +- property **742109** (uprn 100020233210): post C (74.14777) short of goal B on an unlimited-budget scenario (cost_of_works £3070) +- property **742110** (uprn 100020233211): post C (68.751595) short of goal B on an unlimited-budget scenario (cost_of_works £0) +- property **742111** (uprn 100020233212): post C (68.751595) short of goal B on an unlimited-budget scenario (cost_of_works £0) +- property **742112** (uprn 100020233213): post C (68.751595) short of goal B on an unlimited-budget scenario (cost_of_works £0) +- property **742113** (uprn 100020233235): post C (69.19524) short of goal B on an unlimited-budget scenario (cost_of_works £4861) +- property **742114** (uprn 100020233236): post C (73.46247) short of goal B on an unlimited-budget scenario (cost_of_works £7126) +- property **742116** (uprn 100020942574): post D (67.459236) short of goal B on an unlimited-budget scenario (cost_of_works £28) +- property **742117** (uprn 100020942575): post D (67.459236) short of goal B on an unlimited-budget scenario (cost_of_works £28) +- property **742118** (uprn 100020942576): post D (67.459236) short of goal B on an unlimited-budget scenario (cost_of_works £28) +- … and 261 more (see CSV) -## plan-score-below-baseline (HIGH) — 1631 +## already-meets-goal-with-works (MEDIUM) — 4 -- property **709790** (uprn 10023443426): post SAP 74.2 below effective baseline 76.0 (Δ-1.8) -- property **709810** (uprn 10096028301): post SAP 78.0 below effective baseline 85.0 (Δ-7.0) -- property **709846** (uprn 10096399556): post SAP 79.7 below effective baseline 93.0 (Δ-13.3) -- property **709847** (uprn 10096028354): post SAP 78.7 below effective baseline 91.0 (Δ-12.3) -- property **709850** (uprn 10096028348): post SAP 78.8 below effective baseline 89.0 (Δ-10.2) -- property **709959** (uprn 10096028306): post SAP 78.1 below effective baseline 84.0 (Δ-5.9) -- property **709970** (uprn 10013924859): post SAP 79.2 below effective baseline 80.0 (Δ-0.8) -- property **709975** (uprn 200001466609): post SAP 72.3 below effective baseline 79.0 (Δ-6.7) -- property **710011** (uprn 10096028349): post SAP 78.8 below effective baseline 89.0 (Δ-10.2) -- property **710071** (uprn 10002918889): post SAP 76.1 below effective baseline 82.0 (Δ-5.9) -- property **710075** (uprn 10002918890): post SAP 79.8 below effective baseline 82.0 (Δ-2.2) -- property **710117** (uprn 10096399566): post SAP 79.9 below effective baseline 93.0 (Δ-13.1) -- property **710121** (uprn 10096028307): post SAP 78.8 below effective baseline 90.0 (Δ-11.2) -- property **710122** (uprn 100090187902): post SAP 72.4 below effective baseline 74.0 (Δ-1.6) -- property **710131** (uprn 10096028350): post SAP 80.1 below effective baseline 94.0 (Δ-13.9) -- property **710140** (uprn 10023302889): post SAP 76.9 below effective baseline 79.0 (Δ-2.1) -- property **710201** (uprn 10090944222): post SAP 74.6 below effective baseline 82.0 (Δ-7.4) -- property **710222** (uprn 10096399567): post SAP 79.8 below effective baseline 93.0 (Δ-13.2) -- property **710225** (uprn 10096028308): post SAP 78.8 below effective baseline 90.0 (Δ-11.2) -- property **710241** (uprn 10096028351): post SAP 80.1 below effective baseline 94.0 (Δ-13.9) -- property **710274** (uprn 10013924864): post SAP 74.8 below effective baseline 76.0 (Δ-1.2) -- property **710326** (uprn 10096028309): post SAP 78.6 below effective baseline 89.0 (Δ-10.4) -- property **710335** (uprn 10096028352): post SAP 80.0 below effective baseline 94.0 (Δ-14.0) -- property **710362** (uprn 10090317710): post SAP 75.5 below effective baseline 79.0 (Δ-3.5) -- property **710395** (uprn 10096028310): post SAP 79.5 below effective baseline 91.0 (Δ-11.5) -- property **710472** (uprn 10013918445): post SAP 76.9 below effective baseline 79.0 (Δ-2.1) -- property **710474** (uprn 10096028355): post SAP 78.5 below effective baseline 91.0 (Δ-12.5) -- property **710479** (uprn 10096028353): post SAP 80.3 below effective baseline 95.0 (Δ-14.7) -- property **710525** (uprn 10012028784): post SAP 69.6 below effective baseline 79.0 (Δ-9.4) -- property **710537** (uprn 10096028311): post SAP 79.5 below effective baseline 91.0 (Δ-11.5) -- property **710540** (uprn 100060714157): post SAP 68.2 below effective baseline 71.0 (Δ-2.8) -- property **710544** (uprn 10008052006): post SAP 81.1 below effective baseline 83.0 (Δ-1.9) -- property **710552** (uprn 100060706143): post SAP 83.0 below effective baseline 85.0 (Δ-2.0) -- property **710569** (uprn 200000546526): post SAP 70.8 below effective baseline 73.0 (Δ-2.2) -- property **710576** (uprn 100090187795): post SAP 72.4 below effective baseline 75.0 (Δ-2.6) -- property **710645** (uprn 10009433145): post SAP 82.4 below effective baseline 83.0 (Δ-0.6) -- property **710650** (uprn 200001530089): post SAP 72.9 below effective baseline 74.0 (Δ-1.1) -- property **710686** (uprn 10009433147): post SAP 82.1 below effective baseline 83.0 (Δ-0.9) -- property **710760** (uprn 10096028302): post SAP 78.3 below effective baseline 85.0 (Δ-6.7) -- property **710785** (uprn 44006007): post SAP 75.3 below effective baseline 76.0 (Δ-0.7) -- property **710792** (uprn 5300059024): post SAP 75.7 below effective baseline 79.0 (Δ-3.3) -- property **710800** (uprn 10096399558): post SAP 79.7 below effective baseline 93.0 (Δ-13.3) -- property **710802** (uprn 10096028356): post SAP 78.5 below effective baseline 91.0 (Δ-12.5) -- property **710841** (uprn 200003688154): post SAP 70.3 below effective baseline 72.0 (Δ-1.7) -- property **710895** (uprn 100020475290): post SAP 72.4 below effective baseline 73.0 (Δ-0.6) -- property **710911** (uprn 10010221820): post SAP 82.2 below effective baseline 84.0 (Δ-1.8) -- property **710946** (uprn 44012846): post SAP 79.8 below effective baseline 81.0 (Δ-1.2) -- property **710955** (uprn 10090844951): post SAP 78.1 below effective baseline 79.0 (Δ-0.9) -- property **710988** (uprn 10023371802): post SAP 73.3 below effective baseline 75.0 (Δ-1.7) -- property **711014** (uprn 5300088717): post SAP 72.5 below effective baseline 74.0 (Δ-1.5) -- … and 1581 more (see CSV) +- property **742077** (uprn 34156817): already B >= goal B but cost_of_works £4515 +- property **742078** (uprn 34156818): already B >= goal B but cost_of_works £4515 +- property **742207** (uprn 100081143001): already B >= goal B but cost_of_works £1029 +- property **742269** (uprn 100022769436): already B >= goal B but cost_of_works £1029 -## already-meets-goal-with-works (MEDIUM) — 1162 +## zero-works-post-differs (MEDIUM) — 2 -- property **709775** (uprn 100020933699): already C >= goal C but cost_of_works £32 -- property **709875** (uprn 100020973465): already C >= goal C but cost_of_works £38 -- property **709986** (uprn 200000539408): already C >= goal C but cost_of_works £32 -- property **709992** (uprn 100022908998): already C >= goal C but cost_of_works £555 -- property **710005** (uprn 100090178307): already C >= goal C but cost_of_works £21 -- property **710033** (uprn 100022920891): already C >= goal C but cost_of_works £855 -- property **710084** (uprn 200003444301): already C >= goal C but cost_of_works £505 -- property **710185** (uprn 100020650847): already C >= goal C but cost_of_works £455 -- property **710221** (uprn 6701328): already C >= goal C but cost_of_works £655 -- property **710247** (uprn 100020432467): already C >= goal C but cost_of_works £555 -- property **710298** (uprn 100020220397): already C >= goal C but cost_of_works £4515 -- property **710331** (uprn 100020492824): already C >= goal C but cost_of_works £855 -- property **710400** (uprn 100021011618): already C >= goal C but cost_of_works £3725 -- property **710484** (uprn 100060316083): already C >= goal C but cost_of_works £24 -- property **710517** (uprn 100020951618): already C >= goal C but cost_of_works £1029 -- property **710525** (uprn 10012028784): already C >= goal C but cost_of_works £11539 -- property **710539** (uprn 100021973960): already C >= goal C but cost_of_works £505 -- property **710540** (uprn 100060714157): already C >= goal C but cost_of_works £11600 -- property **710555** (uprn 100060718869): already C >= goal C but cost_of_works £28 -- property **710574** (uprn 100020947162): already C >= goal C but cost_of_works £28 -- property **710690** (uprn 200003688150): already C >= goal C but cost_of_works £505 -- property **710703** (uprn 100020438146): already C >= goal C but cost_of_works £32 -- property **710707** (uprn 100061820799): already C >= goal C but cost_of_works £32 -- property **710713** (uprn 200003497667): already C >= goal C but cost_of_works £11082 -- property **710828** (uprn 100060714174): already C >= goal C but cost_of_works £21 -- property **710889** (uprn 200003469113): already C >= goal C but cost_of_works £455 -- property **711058** (uprn 5300059062): already C >= goal C but cost_of_works £3962 -- property **711096** (uprn 100020455891): already C >= goal C but cost_of_works £555 -- property **711099** (uprn 100020229279): already C >= goal C but cost_of_works £1029 -- property **711334** (uprn 5300036435): already C >= goal C but cost_of_works £476 -- property **711390** (uprn 100023263012): already C >= goal C but cost_of_works £505 -- property **711539** (uprn 68159801): already C >= goal C but cost_of_works £2436 -- property **711679** (uprn 100021005287): already C >= goal C but cost_of_works £455 -- property **711694** (uprn 100062191221): already C >= goal C but cost_of_works £18 -- property **711795** (uprn 10090343115): already C >= goal C but cost_of_works £662 -- property **712249** (uprn 100021939292): already C >= goal C but cost_of_works £1029 -- property **712279** (uprn 10090343152): already C >= goal C but cost_of_works £1029 -- property **712426** (uprn 100062208849): already C >= goal C but cost_of_works £530 -- property **712446** (uprn 10090343167): already C >= goal C but cost_of_works £1029 -- property **712690** (uprn 10035061455): already C >= goal C but cost_of_works £2790 -- property **712697** (uprn 100060742520): already C >= goal C but cost_of_works £416 -- property **712764** (uprn 100060675900): already C >= goal C but cost_of_works £32 -- property **712766** (uprn 100021961011): already C >= goal C but cost_of_works £2566 -- property **712801** (uprn 100061734876): already C >= goal C but cost_of_works £555 -- property **712831** (uprn 100061736377): already C >= goal C but cost_of_works £1282 -- property **712864** (uprn 100061738178): already C >= goal C but cost_of_works £455 -- property **712865** (uprn 100061738325): already C >= goal C but cost_of_works £14266 -- property **712869** (uprn 100061738604): already C >= goal C but cost_of_works £18 -- property **712874** (uprn 100061757360): already C >= goal C but cost_of_works £755 -- property **712899** (uprn 100061739860): already C >= goal C but cost_of_works £28 -- … and 1112 more (see CSV) +- property **742302** (uprn 100022921559): £0 works but post SAP 70.5 != effective 72.0 +- property **742367** (uprn 5300072135): £0 works but post SAP 68.9 != effective 70.0 -## excessive-solar-sap (MEDIUM) — 51 +## effective-lodged-divergence (LOW) — 9 -- property **710374** (uprn 10090342188): solar PV alone earns 26.1 SAP points (likely oversized array) -- property **713238** (uprn 200004784741): solar PV alone earns 32.6 SAP points (likely oversized array) -- property **714227** (uprn 100061738603): solar PV alone earns 33.9 SAP points (likely oversized array) -- property **714257** (uprn 100061740105): solar PV alone earns 29.8 SAP points (likely oversized array) -- property **714511** (uprn 100061761168): solar PV alone earns 32.3 SAP points (likely oversized array) -- property **714977** (uprn 100061761946): solar PV alone earns 31.4 SAP points (likely oversized array) -- property **715563** (uprn 100061761947): solar PV alone earns 28.5 SAP points (likely oversized array) -- property **715595** (uprn 10002468656): solar PV alone earns 29.3 SAP points (likely oversized array) -- property **715740** (uprn 100061764300): solar PV alone earns 27.3 SAP points (likely oversized array) -- property **716734** (uprn 10002469029): solar PV alone earns 31.0 SAP points (likely oversized array) -- property **716740** (uprn 100061753334): solar PV alone earns 31.1 SAP points (likely oversized array) -- property **717201** (uprn 100091206047): solar PV alone earns 31.3 SAP points (likely oversized array) -- property **718323** (uprn 10002472608): solar PV alone earns 25.2 SAP points (likely oversized array) -- property **718371** (uprn 100061764075): solar PV alone earns 35.5 SAP points (likely oversized array) -- property **718890** (uprn 100062344151): solar PV alone earns 31.9 SAP points (likely oversized array) -- property **719064** (uprn 100021004675): solar PV alone earns 25.6 SAP points (likely oversized array) -- property **719080** (uprn 100091206033): solar PV alone earns 31.3 SAP points (likely oversized array) -- property **719829** (uprn 100091206052): solar PV alone earns 31.6 SAP points (likely oversized array) -- property **720039** (uprn 100061741993): solar PV alone earns 32.6 SAP points (likely oversized array) -- property **720104** (uprn 100061764078): solar PV alone earns 29.9 SAP points (likely oversized array) -- property **721044** (uprn 100061764081): solar PV alone earns 33.9 SAP points (likely oversized array) -- property **721222** (uprn 100062480566): solar PV alone earns 27.0 SAP points (likely oversized array) -- property **722986** (uprn 100061742571): solar PV alone earns 29.8 SAP points (likely oversized array) -- property **723096** (uprn 100061763625): solar PV alone earns 34.5 SAP points (likely oversized array) -- property **723516** (uprn 100061741124): solar PV alone earns 31.0 SAP points (likely oversized array) -- property **723528** (uprn 100061742003): solar PV alone earns 30.9 SAP points (likely oversized array) -- property **724609** (uprn 100061742008): solar PV alone earns 27.0 SAP points (likely oversized array) -- property **725224** (uprn 100062480573): solar PV alone earns 27.6 SAP points (likely oversized array) -- property **725266** (uprn 10034506076): solar PV alone earns 38.5 SAP points (likely oversized array) -- property **725603** (uprn 100061761161): solar PV alone earns 35.6 SAP points (likely oversized array) -- property **727422** (uprn 200001645537): solar PV alone earns 29.4 SAP points (likely oversized array) -- property **727688** (uprn 100061761162): solar PV alone earns 28.8 SAP points (likely oversized array) -- property **727699** (uprn 200004784746): solar PV alone earns 27.3 SAP points (likely oversized array) -- property **727968** (uprn 100061740165): solar PV alone earns 34.5 SAP points (likely oversized array) -- property **728344** (uprn 100061764582): solar PV alone earns 26.9 SAP points (likely oversized array) -- property **729028** (uprn 100061735428): solar PV alone earns 32.0 SAP points (likely oversized array) -- property **729329** (uprn 100061749297): solar PV alone earns 30.2 SAP points (likely oversized array) -- property **729415** (uprn 100091206037): solar PV alone earns 31.5 SAP points (likely oversized array) -- property **731052** (uprn 100021958934): solar PV alone earns 29.3 SAP points (likely oversized array) -- property **731481** (uprn 100021935669): solar PV alone earns 25.1 SAP points (likely oversized array) -- property **731812** (uprn 200001645541): solar PV alone earns 32.1 SAP points (likely oversized array) -- property **731994** (uprn 10023371767): solar PV alone earns 25.3 SAP points (likely oversized array) -- property **732041** (uprn 100061761165): solar PV alone earns 30.1 SAP points (likely oversized array) -- property **732050** (uprn 100061764295): solar PV alone earns 30.5 SAP points (likely oversized array) -- property **732860** (uprn 100061733678): solar PV alone earns 32.7 SAP points (likely oversized array) -- property **733394** (uprn 100061748753): solar PV alone earns 25.1 SAP points (likely oversized array) -- property **739556** (uprn 100061754547): solar PV alone earns 31.3 SAP points (likely oversized array) -- property **739814** (uprn 200003724253): solar PV alone earns 31.6 SAP points (likely oversized array) -- property **739998** (uprn 100090224018): solar PV alone earns 25.9 SAP points (likely oversized array) -- property **740214** (uprn 100061762820): solar PV alone earns 30.4 SAP points (likely oversized array) -- … and 1 more (see CSV) +- property **742131** (uprn 148013838): effective 62 vs lodged 47 (Δ+15, reason=pre_sap10) +- property **742132** (uprn 148013839): effective 62 vs lodged 47 (Δ+15, reason=pre_sap10) +- property **742204** (uprn 100081142998): effective 69 vs lodged 54 (Δ+15, reason=pre_sap10) +- property **742206** (uprn 100081143000): effective 69 vs lodged 54 (Δ+15, reason=pre_sap10) +- property **742208** (uprn 100081143002): effective 59 vs lodged 44 (Δ+15, reason=pre_sap10) +- property **742216** (uprn 100081143009): effective 69 vs lodged 54 (Δ+15, reason=pre_sap10) +- property **742355** (uprn 100022982934): effective 63 vs lodged 47 (Δ+16, reason=pre_sap10) +- property **742394** (uprn 100022909582): effective 71 vs lodged 86 (Δ-15, reason=pre_sap10) +- property **742395** (uprn 100022909583): effective 72 vs lodged 87 (Δ-15, reason=pre_sap10) -## low-solar-bill-savings (MEDIUM) — 83 +## negative-bill-savings (LOW) — 31 -- property **710178** (uprn 100020465019): solar PV bill saving only £49/yr — check self-consumption / SEG export -- property **711663** (uprn 100020481859): solar PV bill saving only £47/yr — check self-consumption / SEG export -- property **712943** (uprn 100061741910): solar PV bill saving only £47/yr — check self-consumption / SEG export -- property **713299** (uprn 100060331540): solar PV bill saving only £46/yr — check self-consumption / SEG export -- property **713607** (uprn 100090108855): solar PV bill saving only £46/yr — check self-consumption / SEG export -- property **713631** (uprn 10002470912): solar PV bill saving only £49/yr — check self-consumption / SEG export -- property **713743** (uprn 100060359906): solar PV bill saving only £36/yr — check self-consumption / SEG export -- property **715182** (uprn 100060726237): solar PV bill saving only £46/yr — check self-consumption / SEG export -- property **715701** (uprn 100021001715): solar PV bill saving only £50/yr — check self-consumption / SEG export -- property **716152** (uprn 100090108859): solar PV bill saving only £43/yr — check self-consumption / SEG export -- property **716268** (uprn 100061763778): solar PV bill saving only £36/yr — check self-consumption / SEG export -- property **716289** (uprn 200003688201): solar PV bill saving only £43/yr — check self-consumption / SEG export -- property **716659** (uprn 100061747951): solar PV bill saving only £41/yr — check self-consumption / SEG export -- property **716693** (uprn 100061733911): solar PV bill saving only £39/yr — check self-consumption / SEG export -- property **717392** (uprn 100020450723): solar PV bill saving only £45/yr — check self-consumption / SEG export -- property **717661** (uprn 6176751): solar PV bill saving only £42/yr — check self-consumption / SEG export -- property **718073** (uprn 100021017285): solar PV bill saving only £47/yr — check self-consumption / SEG export -- property **718298** (uprn 100061741870): solar PV bill saving only £43/yr — check self-consumption / SEG export -- property **718567** (uprn 100091206050): solar PV bill saving only £50/yr — check self-consumption / SEG export -- property **719647** (uprn 100020590930): solar PV bill saving only £49/yr — check self-consumption / SEG export -- property **720335** (uprn 100020594071): solar PV bill saving only £36/yr — check self-consumption / SEG export -- property **720677** (uprn 100020969500): solar PV bill saving only £30/yr — check self-consumption / SEG export -- property **721018** (uprn 202140958): solar PV bill saving only £44/yr — check self-consumption / SEG export -- property **721529** (uprn 100060696301): solar PV bill saving only £43/yr — check self-consumption / SEG export -- property **721664** (uprn 100061765249): solar PV bill saving only £46/yr — check self-consumption / SEG export -- property **721895** (uprn 100060726253): solar PV bill saving only £48/yr — check self-consumption / SEG export -- property **722155** (uprn 100061758903): solar PV bill saving only £47/yr — check self-consumption / SEG export -- property **722390** (uprn 100020944918): solar PV bill saving only £47/yr — check self-consumption / SEG export -- property **722910** (uprn 10009428749): solar PV bill saving only £41/yr — check self-consumption / SEG export -- property **723298** (uprn 202140961): solar PV bill saving only £45/yr — check self-consumption / SEG export -- property **723578** (uprn 100020986231): solar PV bill saving only £47/yr — check self-consumption / SEG export -- property **723748** (uprn 100061757556): solar PV bill saving only £48/yr — check self-consumption / SEG export -- property **723965** (uprn 100061761860): solar PV bill saving only £50/yr — check self-consumption / SEG export -- property **724311** (uprn 100061765331): solar PV bill saving only £30/yr — check self-consumption / SEG export -- property **724331** (uprn 100022008224): solar PV bill saving only £22/yr — check self-consumption / SEG export -- property **724850** (uprn 100060719545): solar PV bill saving only £50/yr — check self-consumption / SEG export -- property **725287** (uprn 100062187008): solar PV bill saving only £46/yr — check self-consumption / SEG export -- property **725585** (uprn 100061752475): solar PV bill saving only £44/yr — check self-consumption / SEG export -- property **725807** (uprn 100020996065): solar PV bill saving only £40/yr — check self-consumption / SEG export -- property **725969** (uprn 202141567): solar PV bill saving only £31/yr — check self-consumption / SEG export -- property **726419** (uprn 100061809723): solar PV bill saving only £46/yr — check self-consumption / SEG export -- property **726507** (uprn 100061752706): solar PV bill saving only £48/yr — check self-consumption / SEG export -- property **726627** (uprn 100061152652): solar PV bill saving only £42/yr — check self-consumption / SEG export -- property **726735** (uprn 100061809725): solar PV bill saving only £46/yr — check self-consumption / SEG export -- property **726998** (uprn 100061809727): solar PV bill saving only £46/yr — check self-consumption / SEG export -- property **727114** (uprn 100061809728): solar PV bill saving only £47/yr — check self-consumption / SEG export -- property **727143** (uprn 202141571): solar PV bill saving only £45/yr — check self-consumption / SEG export -- property **727820** (uprn 100061809729): solar PV bill saving only £47/yr — check self-consumption / SEG export -- property **727994** (uprn 202141572): solar PV bill saving only £45/yr — check self-consumption / SEG export -- property **728027** (uprn 10090265602): solar PV bill saving only £45/yr — check self-consumption / SEG export -- … and 33 more (see CSV) - -## zero-works-post-differs (MEDIUM) — 5624 - -- property **709772** (uprn 10093116528): £0 works but post SAP 80.3 != effective 79.0 -- property **709773** (uprn 10093116543): £0 works but post SAP 78.5 != effective 77.0 -- property **709774** (uprn 10093116529): £0 works but post SAP 76.8 != effective 75.0 -- property **709777** (uprn 10094601392): £0 works but post SAP 77.2 != effective 74.0 -- property **709778** (uprn 10023444324): £0 works but post SAP 76.9 != effective 75.0 -- property **709779** (uprn 10092970673): £0 works but post SAP 70.9 != effective 61.0 -- property **709780** (uprn 10094601287): £0 works but post SAP 77.7 != effective 75.0 -- property **709783** (uprn 10094601162): £0 works but post SAP 78.1 != effective 74.0 -- property **709784** (uprn 10090844948): £0 works but post SAP 76.4 != effective 73.0 -- property **709786** (uprn 10093114053): £0 works but post SAP 81.3 != effective 76.0 -- property **709787** (uprn 10091568921): £0 works but post SAP 79.4 != effective 77.0 -- property **709788** (uprn 10093718424): £0 works but post SAP 80.0 != effective 78.0 -- property **709790** (uprn 10023443426): £0 works but post SAP 74.2 != effective 76.0 -- property **709791** (uprn 10093412452): £0 works but post SAP 80.2 != effective 79.0 -- property **709792** (uprn 6199384): £0 works but post SAP 80.5 != effective 78.0 -- property **709793** (uprn 10014314798): £0 works but post SAP 73.8 != effective 72.0 -- property **709794** (uprn 10094601294): £0 works but post SAP 78.1 != effective 76.0 -- property **709795** (uprn 10090343335): £0 works but post SAP 84.0 != effective 82.0 -- property **709796** (uprn 10093115480): £0 works but post SAP 78.8 != effective 77.0 -- property **709798** (uprn 10094601226): £0 works but post SAP 77.3 != effective 75.0 -- property **709800** (uprn 6701369): £0 works but post SAP 82.5 != effective 78.0 -- property **709801** (uprn 202211152): £0 works but post SAP 81.8 != effective 79.0 -- property **709803** (uprn 10093394010): £0 works but post SAP 79.9 != effective 78.0 -- property **709806** (uprn 10090341811): £0 works but post SAP 80.2 != effective 78.0 -- property **709808** (uprn 10093117227): £0 works but post SAP 77.6 != effective 76.0 -- property **709809** (uprn 10023444170): £0 works but post SAP 80.3 != effective 78.0 -- property **709810** (uprn 10096028301): £0 works but post SAP 78.0 != effective 85.0 -- property **709813** (uprn 10094601280): £0 works but post SAP 77.9 != effective 75.0 -- property **709814** (uprn 10093386418): £0 works but post SAP 78.8 != effective 76.0 -- property **709817** (uprn 10094895444): £0 works but post SAP 79.6 != effective 77.0 -- property **709818** (uprn 10092973960): £0 works but post SAP 77.6 != effective 74.0 -- property **709819** (uprn 10012028763): £0 works but post SAP 83.3 != effective 82.0 -- property **709820** (uprn 10093049867): £0 works but post SAP 78.7 != effective 75.0 -- property **709821** (uprn 10093116336): £0 works but post SAP 79.9 != effective 78.0 -- property **709823** (uprn 10093116334): £0 works but post SAP 79.0 != effective 78.0 -- property **709824** (uprn 44042992): £0 works but post SAP 79.9 != effective 79.0 -- property **709825** (uprn 10014314853): £0 works but post SAP 72.0 != effective 70.0 -- property **709828** (uprn 10091636116): £0 works but post SAP 75.8 != effective 71.0 -- property **709829** (uprn 10094601381): £0 works but post SAP 78.4 != effective 74.0 -- property **709830** (uprn 10093049853): £0 works but post SAP 81.5 != effective 78.0 -- property **709831** (uprn 10093390790): £0 works but post SAP 74.9 != effective 72.0 -- property **709832** (uprn 10093116330): £0 works but post SAP 80.1 != effective 79.0 -- property **709833** (uprn 10093116326): £0 works but post SAP 80.1 != effective 79.0 -- property **709834** (uprn 10094601351): £0 works but post SAP 79.0 != effective 76.0 -- property **709835** (uprn 10090317693): £0 works but post SAP 77.5 != effective 76.0 -- property **709836** (uprn 10090034872): £0 works but post SAP 81.4 != effective 79.0 -- property **709837** (uprn 10093115985): £0 works but post SAP 79.5 != effective 77.0 -- property **709842** (uprn 202211170): £0 works but post SAP 82.1 != effective 79.0 -- property **709845** (uprn 6701311): £0 works but post SAP 81.4 != effective 78.0 -- property **709846** (uprn 10096399556): £0 works but post SAP 79.7 != effective 93.0 -- … and 5574 more (see CSV) - -## effective-lodged-divergence (LOW) — 1527 - -- property **709779** (uprn 10092970673): effective 61 vs lodged 86 (Δ-25, reason=pre_sap10) -- property **709802** (uprn 100020665611): effective 52 vs lodged 37 (Δ+15, reason=pre_sap10) -- property **709804** (uprn 10093388044): effective 33 vs lodged 93 (Δ-60, reason=pre_sap10) -- property **709815** (uprn 100090108846): effective 57 vs lodged 79 (Δ-22, reason=pre_sap10) -- property **709828** (uprn 10091636116): effective 71 vs lodged 88 (Δ-17, reason=pre_sap10) -- property **709860** (uprn 100061086424): effective 60 vs lodged 83 (Δ-23, reason=pre_sap10) -- property **709863** (uprn 10090342180): effective 39 vs lodged 78 (Δ-39, reason=pre_sap10) -- property **709874** (uprn 10093388053): effective 35 vs lodged 94 (Δ-59, reason=pre_sap10) -- property **709892** (uprn 10090343767): effective 75 vs lodged 91 (Δ-16, reason=pre_sap10) -- property **709945** (uprn 10090342181): effective 42 vs lodged 81 (Δ-39, reason=pre_sap10) -- property **709993** (uprn 15043874): effective 68 vs lodged 49 (Δ+19, reason=pre_sap10) -- property **710003** (uprn 10091636410): effective 71 vs lodged 86 (Δ-15, reason=pre_sap10) -- property **710009** (uprn 100022895379): effective 62 vs lodged 80 (Δ-18, reason=pre_sap10) -- property **710019** (uprn 10090342182): effective 44 vs lodged 80 (Δ-36, reason=pre_sap10) -- property **710025** (uprn 10093388055): effective 55 vs lodged 93 (Δ-38, reason=pre_sap10) -- property **710037** (uprn 100090108857): effective 56 vs lodged 86 (Δ-30, reason=pre_sap10) -- property **710091** (uprn 100061086427): effective 60 vs lodged 83 (Δ-23, reason=pre_sap10) -- property **710114** (uprn 10096026315): effective 74 vs lodged 89 (Δ-15, reason=pre_sap10) -- property **710134** (uprn 10096026271): effective 73 vs lodged 88 (Δ-15, reason=pre_sap10) -- property **710139** (uprn 10090342183): effective 41 vs lodged 77 (Δ-36, reason=pre_sap10) -- property **710144** (uprn 10014314832): effective 63 vs lodged 83 (Δ-20, reason=pre_sap10) -- property **710148** (uprn 10093388056): effective 57 vs lodged 94 (Δ-37, reason=pre_sap10) -- property **710191** (uprn 10090342184): effective 49 vs lodged 79 (Δ-30, reason=pre_sap10) -- property **710198** (uprn 10012138502): effective 65 vs lodged 85 (Δ-20, reason=pre_sap10) -- property **710199** (uprn 10093388057): effective 38 vs lodged 95 (Δ-57, reason=pre_sap10) -- property **710202** (uprn 10090342864): effective 68 vs lodged 84 (Δ-16, reason=pre_sap10) -- property **710244** (uprn 10010249676): effective 74 vs lodged 90 (Δ-16, reason=pre_sap10) -- property **710246** (uprn 100061086430): effective 59 vs lodged 83 (Δ-24, reason=pre_sap10) -- property **710250** (uprn 10090342185): effective 62 vs lodged 81 (Δ-19, reason=pre_sap10) -- property **710260** (uprn 10093388058): effective 47 vs lodged 95 (Δ-48, reason=pre_sap10) -- property **710261** (uprn 10090341825): effective 75 vs lodged 91 (Δ-16, reason=pre_sap10) -- property **710300** (uprn 10090341826): effective 75 vs lodged 91 (Δ-16, reason=pre_sap10) -- property **710342** (uprn 10090342187): effective 37 vs lodged 77 (Δ-40, reason=pre_sap10) -- property **710348** (uprn 10090341827): effective 76 vs lodged 91 (Δ-15, reason=pre_sap10) -- property **710429** (uprn 10093388045): effective 49 vs lodged 93 (Δ-44, reason=pre_sap10) -- property **710449** (uprn 100020993501): effective 56 vs lodged 5 (Δ+51, reason=pre_sap10) -- property **710491** (uprn 10014314835): effective 69 vs lodged 84 (Δ-15, reason=pre_sap10) -- property **710536** (uprn 100062482536): effective 38 vs lodged 53 (Δ-15, reason=pre_sap10) -- property **710640** (uprn 10014316004): effective 71 vs lodged 88 (Δ-17, reason=pre_sap10) -- property **710658** (uprn 10014316005): effective 70 vs lodged 88 (Δ-18, reason=pre_sap10) -- property **710659** (uprn 22061763): effective 50 vs lodged 70 (Δ-20, reason=pre_sap10) -- property **710664** (uprn 10096026322): effective 74 vs lodged 89 (Δ-15, reason=pre_sap10) -- property **710672** (uprn 200003378407): effective 48 vs lodged 28 (Δ+20, reason=pre_sap10) -- property **710683** (uprn 10014316006): effective 70 vs lodged 88 (Δ-18, reason=pre_sap10) -- property **710704** (uprn 10014316007): effective 71 vs lodged 88 (Δ-17, reason=pre_sap10) -- property **710745** (uprn 10093386244): effective 50 vs lodged 83 (Δ-33, reason=pre_sap10) -- property **710752** (uprn 10093388046): effective 47 vs lodged 94 (Δ-47, reason=pre_sap10) -- property **710779** (uprn 10091636118): effective 71 vs lodged 88 (Δ-17, reason=pre_sap10) -- property **710814** (uprn 10014316008): effective 71 vs lodged 88 (Δ-17, reason=pre_sap10) -- property **710837** (uprn 10014316009): effective 71 vs lodged 88 (Δ-17, reason=pre_sap10) -- … and 1477 more (see CSV) - -## negative-bill-savings (LOW) — 100 - -- property **712847** (uprn 100061737192): energy_bill_savings £-30/yr on £16110 of works -- property **713453** (uprn 100061739869): energy_bill_savings £-68/yr on £14533 of works -- property **713721** (uprn 100061753373): energy_bill_savings £-18/yr on £14554 of works -- property **713743** (uprn 100060359906): energy_bill_savings £-98/yr on £18823 of works -- property **713791** (uprn 10013528635): energy_bill_savings £-130/yr on £1029 of works -- property **713970** (uprn 10013528640): energy_bill_savings £-130/yr on £1029 of works -- property **714001** (uprn 10013528641): energy_bill_savings £-130/yr on £1029 of works -- property **714038** (uprn 10013528642): energy_bill_savings £-130/yr on £1029 of works -- property **714373** (uprn 100062185281): energy_bill_savings £-113/yr on £14800 of works -- property **714420** (uprn 10010242243): energy_bill_savings £-162/yr on £1029 of works -- property **715227** (uprn 100061753375): energy_bill_savings £-18/yr on £14554 of works -- property **715561** (uprn 100061739874): energy_bill_savings £-69/yr on £14533 of works -- property **715999** (uprn 100061737204): energy_bill_savings £-13/yr on £14533 of works -- property **716169** (uprn 100061747915): energy_bill_savings £-53/yr on £14533 of works -- property **716251** (uprn 100061753377): energy_bill_savings £-18/yr on £14554 of works -- property **716548** (uprn 100061765632): energy_bill_savings £-70/yr on £14533 of works -- property **716811** (uprn 22106963): energy_bill_savings £-3/yr on £4785 of works -- property **717209** (uprn 100061753379): energy_bill_savings £-20/yr on £14554 of works -- property **717675** (uprn 6176752): energy_bill_savings £-75/yr on £15067 of works -- property **717998** (uprn 100061749651): energy_bill_savings £-7/yr on £14533 of works -- property **719002** (uprn 10002476550): energy_bill_savings £-101/yr on £14533 of works -- property **719091** (uprn 100061753365): energy_bill_savings £-46/yr on £14533 of works -- property **719449** (uprn 100061753383): energy_bill_savings £-18/yr on £14554 of works -- property **719680** (uprn 100061741246): energy_bill_savings £-34/yr on £17072 of works -- property **719811** (uprn 100060327562): energy_bill_savings £-92/yr on £14533 of works -- property **720349** (uprn 100021812910): energy_bill_savings £-122/yr on £1029 of works -- property **720364** (uprn 100061741682): energy_bill_savings £-51/yr on £15070 of works -- property **720800** (uprn 100061753387): energy_bill_savings £-20/yr on £14554 of works -- property **720889** (uprn 10023224588): energy_bill_savings £-172/yr on £1029 of works -- property **720989** (uprn 100061764553): energy_bill_savings £-143/yr on £14533 of works -- property **721034** (uprn 100061758484): energy_bill_savings £-145/yr on £15105 of works -- property **721070** (uprn 100061749663): energy_bill_savings £-69/yr on £14800 of works -- property **722402** (uprn 100061737194): energy_bill_savings £-73/yr on £14266 of works -- property **722448** (uprn 100061739862): energy_bill_savings £-68/yr on £14533 of works -- property **722732** (uprn 100061750203): energy_bill_savings £-155/yr on £16445 of works -- property **722808** (uprn 100062186150): energy_bill_savings £-61/yr on £14533 of works -- property **723367** (uprn 100061753394): energy_bill_savings £-56/yr on £14533 of works -- property **723503** (uprn 10010242632): energy_bill_savings £-112/yr on £1029 of works -- property **723540** (uprn 100061743541): energy_bill_savings £-35/yr on £14533 of works -- property **723771** (uprn 200001645519): energy_bill_savings £-58/yr on £14533 of works -- property **723782** (uprn 100061743542): energy_bill_savings £-30/yr on £17032 of works -- property **725039** (uprn 100061748331): energy_bill_savings £-4/yr on £14800 of works -- property **725350** (uprn 100061743145): energy_bill_savings £-189/yr on £15067 of works -- property **725715** (uprn 100061736694): energy_bill_savings £-42/yr on £14533 of works -- property **725731** (uprn 100061763962): energy_bill_savings £-71/yr on £14533 of works -- property **725988** (uprn 100061749671): energy_bill_savings £-13/yr on £14533 of works -- property **726184** (uprn 100061752891): energy_bill_savings £-66/yr on £14533 of works -- property **726268** (uprn 100061741268): energy_bill_savings £-76/yr on £14266 of works -- property **726271** (uprn 100061741692): energy_bill_savings £-63/yr on £14533 of works -- property **726302** (uprn 202141568): energy_bill_savings £-73/yr on £15721 of works -- … and 50 more (see CSV) - -## unusually-high-post-sap (LOW) — 21 - -- property **714511** (uprn 100061761168): post SAP 100.0 (near band A) — confirm not over-credited -- property **716734** (uprn 10002469029): post SAP 100.0 (near band A) — confirm not over-credited -- property **716740** (uprn 100061753334): post SAP 100.0 (near band A) — confirm not over-credited -- property **717201** (uprn 100091206047): post SAP 100.0 (near band A) — confirm not over-credited -- property **718890** (uprn 100062344151): post SAP 100.0 (near band A) — confirm not over-credited -- property **719080** (uprn 100091206033): post SAP 100.0 (near band A) — confirm not over-credited -- property **719829** (uprn 100091206052): post SAP 100.0 (near band A) — confirm not over-credited -- property **721222** (uprn 100062480566): post SAP 98.6 (near band A) — confirm not over-credited -- property **723516** (uprn 100061741124): post SAP 100.0 (near band A) — confirm not over-credited -- property **725224** (uprn 100062480573): post SAP 95.3 (near band A) — confirm not over-credited -- property **725440** (uprn 100061746447): post SAP 100.0 (near band A) — confirm not over-credited -- property **725627** (uprn 10008885879): post SAP 99.6 (near band A) — confirm not over-credited -- property **726993** (uprn 100061757571): post SAP 100.0 (near band A) — confirm not over-credited -- property **727688** (uprn 100061761162): post SAP 97.6 (near band A) — confirm not over-credited -- property **727699** (uprn 200004784746): post SAP 100.0 (near band A) — confirm not over-credited -- property **729329** (uprn 100061749297): post SAP 99.2 (near band A) — confirm not over-credited -- property **729415** (uprn 100091206037): post SAP 100.0 (near band A) — confirm not over-credited -- property **732041** (uprn 100061761165): post SAP 97.9 (near band A) — confirm not over-credited -- property **732050** (uprn 100061764295): post SAP 98.1 (near band A) — confirm not over-credited -- property **740214** (uprn 100061762820): post SAP 99.4 (near band A) — confirm not over-credited -- property **740234** (uprn 100061761814): post SAP 100.0 (near band A) — confirm not over-credited +- property **742065** (uprn 10010206038): energy_bill_savings £-454/yr on £1043 of works +- property **742066** (uprn 10010206039): energy_bill_savings £-417/yr on £7876 of works +- property **742068** (uprn 10010206041): energy_bill_savings £-442/yr on £8510 of works +- property **742145** (uprn 5300022461): energy_bill_savings £-799/yr on £3196 of works +- property **742146** (uprn 5300022470): energy_bill_savings £-799/yr on £3196 of works +- property **742147** (uprn 5300022471): energy_bill_savings £-799/yr on £3196 of works +- property **742148** (uprn 5300022472): energy_bill_savings £-685/yr on £3270 of works +- property **742149** (uprn 5300022473): energy_bill_savings £-799/yr on £3196 of works +- property **742150** (uprn 5300022474): energy_bill_savings £-799/yr on £3196 of works +- property **742151** (uprn 5300022475): energy_bill_savings £-799/yr on £3196 of works +- property **742153** (uprn 5300022477): energy_bill_savings £-746/yr on £3196 of works +- property **742155** (uprn 5300022479): energy_bill_savings £-799/yr on £3196 of works +- property **742156** (uprn 5300022462): energy_bill_savings £-799/yr on £3196 of works +- property **742157** (uprn 5300022480): energy_bill_savings £-517/yr on £11607 of works +- property **742158** (uprn 5300022481): energy_bill_savings £-799/yr on £3196 of works +- property **742159** (uprn 5300022482): energy_bill_savings £-799/yr on £3196 of works +- property **742160** (uprn 5300022483): energy_bill_savings £-799/yr on £3196 of works +- property **742161** (uprn 5300022484): energy_bill_savings £-799/yr on £3196 of works +- property **742162** (uprn 5300022485): energy_bill_savings £-799/yr on £3196 of works +- property **742163** (uprn 5300022486): energy_bill_savings £-799/yr on £3196 of works +- property **742164** (uprn 5300022487): energy_bill_savings £-799/yr on £3196 of works +- property **742166** (uprn 5300022489): energy_bill_savings £-799/yr on £3196 of works +- property **742167** (uprn 5300022463): energy_bill_savings £-799/yr on £3196 of works +- property **742168** (uprn 5300022490): energy_bill_savings £-799/yr on £3196 of works +- property **742169** (uprn 5300022491): energy_bill_savings £-799/yr on £3196 of works +- property **742170** (uprn 5300022492): energy_bill_savings £-799/yr on £3196 of works +- property **742171** (uprn 5300022464): energy_bill_savings £-799/yr on £3196 of works +- property **742172** (uprn 5300022465): energy_bill_savings £-799/yr on £3196 of works +- property **742174** (uprn 5300022467): energy_bill_savings £-922/yr on £1057 of works +- property **742176** (uprn 5300022469): energy_bill_savings £-799/yr on £3196 of works +- property **742265** (uprn 10033526327): energy_bill_savings £-896/yr on £10682 of works diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index e3180a4d2..7a30d7e82 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -264,6 +264,7 @@ def _candidate_recommendations( planning_restrictions: PlanningRestrictions, solar_potential: Optional[SolarPotential], considered_measures: Optional[frozenset[MeasureType]], + design_heat_loss_kw: Optional[float] = None, ) -> list[Recommendation]: """Run the applicable Recommendation Generators; keep the ones that apply. Solid-wall insulation, glazing, heating and solar are additionally gated by @@ -331,7 +332,13 @@ def _candidate_recommendations( MeasureType.SYSTEM_TUNE_UP, MeasureType.SYSTEM_TUNE_UP_ZONED, ), - lambda: recommend_heating(effective_epc, products, planning_restrictions, considered_measures), + lambda: recommend_heating( + effective_epc, + products, + planning_restrictions, + considered_measures, + design_heat_loss_kw=design_heat_loss_kw, + ), ), ( admitted(MeasureType.SECONDARY_HEATING_REMOVAL), @@ -384,9 +391,20 @@ def _scored_candidate_groups( ) -> list[list[ScoredOption]]: """One group per Recommendation: each Option scored independently against the baseline (role-1 warm-start signal, ADR-0016).""" + # The SAP design heat loss sizes the ASHP to the dwelling (ADR-0049); read it + # off a baseline score, which the group scoring computes anyway. + baseline_result = scorer.score(effective_epc, []).sap_result + design_heat_loss_kw: Optional[float] = ( + baseline_result.design_heat_loss_kw if baseline_result is not None else None + ) groups: list[list[ScoredOption]] = [] for recommendation in _candidate_recommendations( - effective_epc, products, planning_restrictions, solar_potential, considered_measures + effective_epc, + products, + planning_restrictions, + solar_potential, + considered_measures, + design_heat_loss_kw, ): options = list(recommendation.options) impacts: list[MeasureImpact] = independent_option_impacts( diff --git a/orchestration/property_baseline_orchestrator.py b/orchestration/property_baseline_orchestrator.py index bc77605ff..0ee523479 100644 --- a/orchestration/property_baseline_orchestrator.py +++ b/orchestration/property_baseline_orchestrator.py @@ -8,7 +8,7 @@ from domain.billing.bill import EnergyBreakdown from domain.sap10_calculator.calculator import SapResult from domain.billing.bill_derivation import BillDerivation from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance -from domain.property_baseline.performance import lodged_performance +from domain.property_baseline.performance import Performance, lodged_performance from domain.property_baseline.rebaseliner import Rebaseliner from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository from repositories.unit_of_work import UnitOfWork @@ -50,7 +50,15 @@ class PropertyBaselineOrchestrator: properties = uow.property.get_many(property_ids) for property_id, prop in zip(property_ids, properties, strict=True): effective_epc = prop.effective_epc - lodged = lodged_performance(effective_epc) + # A predicted Property has no lodged cert — its Effective EPC is a + # neighbour-synthesised picture, so its recorded performance fields + # are a different dwelling's. There is no Lodged Performance to read + # (ADR-0004 amendment, #1361); only the Effective half is established. + lodged: Optional[Performance] = ( + None + if prop.source_path == "predicted" + else lodged_performance(effective_epc) + ) rebaselined = self._rebaseliner.rebaseline( property_id, effective_epc, diff --git a/orchestration/sharepoint_renamer_orchestrator.py b/orchestration/sharepoint_renamer_orchestrator.py index 42b0e803c..3c7d3e8d3 100644 --- a/orchestration/sharepoint_renamer_orchestrator.py +++ b/orchestration/sharepoint_renamer_orchestrator.py @@ -11,6 +11,7 @@ BASE_PATH = ( "Sero Project Documents/Property Folders" ) ASSESSMENT_SUBFOLDER = "A. Assessment" +BATCH_FOLDER_PREFIX = "_Sero Batch" logger = setup_logger() @@ -65,7 +66,18 @@ class SharepointRenamerOrchestrator: self._csv_path = csv_path self._dry_run = dry_run + def _discover_roots(self) -> list[str]: + contents = self._sp_client.get_folders_in_path(BASE_PATH) + batch_roots = sorted( + f"{BASE_PATH}/{item['name']}" + for item in contents.get("value", []) + if "folder" in item + and item["name"].lower().startswith(BATCH_FOLDER_PREFIX.lower()) + ) + return [BASE_PATH, *batch_roots] + def run(self) -> None: + roots = self._discover_roots() with open(self._csv_path, newline="", encoding="utf-8-sig") as f: reader = csv.DictReader(f) required = {"UPRN", "Address", "Postcode"} @@ -75,23 +87,37 @@ class SharepointRenamerOrchestrator: ) for row in reader: - uprn = row["UPRN"].strip() - address = row["Address"].strip() - postcode = row["Postcode"].strip() - folder_path = ( - f"{BASE_PATH}/{address}, {postcode}" - f"/{SharepointSubfolders.ASSESSMENT.value}/{ASSESSMENT_SUBFOLDER}" + self._process_row( + roots, + uprn=row["UPRN"].strip(), + address=row["Address"].strip(), + postcode=row["Postcode"].strip(), ) - self._process_folder(folder_path, uprn, address, postcode) + + def _process_row( + self, roots: list[str], uprn: str, address: str, postcode: str + ) -> None: + # Batch folders name properties "{uprn}_{address}"; filenames must not + # repeat the UPRN, so the folder lookup and the canonical name diverge. + display_address = address.removeprefix(f"{uprn}_") + for root in roots: + folder_path = ( + f"{root}/{address}, {postcode}" + f"/{SharepointSubfolders.ASSESSMENT.value}/{ASSESSMENT_SUBFOLDER}" + ) + if self._process_folder(folder_path, uprn, display_address, postcode): + return + logger.warning( + f"Missing folder for UPRN {uprn} in any root: {address}, {postcode}" + ) def _process_folder( self, folder_path: str, uprn: str, address: str, postcode: str - ) -> None: + ) -> bool: try: contents = self._sp_client.get_folders_in_path(folder_path) except ValueError: - logger.warning(f"Missing folder for UPRN {uprn}: {folder_path}") - return + return False for item in contents.get("value", []): if "folder" in item: @@ -123,3 +149,4 @@ class SharepointRenamerOrchestrator: logger.error( f'Failed to rename "{original_name}" → "{new_name}" (UPRN: {uprn}): {e}' ) + return True diff --git a/repositories/epc/epc_postgres_repository.py b/repositories/epc/epc_postgres_repository.py index 1c92e1bff..39f240376 100644 --- a/repositories/epc/epc_postgres_repository.py +++ b/repositories/epc/epc_postgres_repository.py @@ -64,7 +64,9 @@ class EpcSaveRequest: source: EpcSource = field(default="lodged") -def _col_values(model: SQLModel, exclude: frozenset[str] = frozenset()) -> dict[str, Any]: +def _col_values( + model: SQLModel, exclude: frozenset[str] = frozenset() +) -> dict[str, Any]: """Extract column-keyed values from a SQLModel instance for Core INSERT.""" return { c.name: getattr(model, c.name) # type: ignore[union-attr] @@ -130,7 +132,9 @@ class EpcPostgresRepository(EpcRepository): portfolio_id: Optional[int] = None, source: EpcSource = "lodged", ) -> int: - return self.save_batch([EpcSaveRequest(data, property_id, portfolio_id, source)])[0] + return self.save_batch( + [EpcSaveRequest(data, property_id, portfolio_id, source)] + )[0] def save_batch(self, requests: list[EpcSaveRequest]) -> list[int]: """Insert all EPCs in `requests` in one pass per table, returning one @@ -158,7 +162,10 @@ class EpcPostgresRepository(EpcRepository): parent_rows = [ _col_values( EpcPropertyModel.from_epc_property_data( - r.data, property_id=r.property_id, portfolio_id=r.portfolio_id, source=r.source + r.data, + property_id=r.property_id, + portfolio_id=r.portfolio_id, + source=r.source, ), exclude=frozenset({"id"}), ) @@ -185,23 +192,33 @@ class EpcPostgresRepository(EpcRepository): d = r.data perf_rows.append( _col_values( - EpcPropertyEnergyPerformanceModel.from_epc_property_data(d, epc_pid), + EpcPropertyEnergyPerformanceModel.from_epc_property_data( + d, epc_pid + ), exclude=frozenset({"id"}), ) ) for detail in d.sap_heating.main_heating_details: heating_rows.append( - _col_values(EpcMainHeatingDetailModel.from_domain(detail, epc_pid), frozenset({"id"})) + _col_values( + EpcMainHeatingDetailModel.from_domain(detail, epc_pid), + frozenset({"id"}), + ) ) for part in d.sap_building_parts: parts_ordered.append((part, epc_pid)) for window in d.sap_windows: window_rows.append( - _col_values(EpcWindowModel.from_domain(window, epc_pid), frozenset({"id"})) + _col_values( + EpcWindowModel.from_domain(window, epc_pid), frozenset({"id"}) + ) ) for idx, array in enumerate(d.sap_energy_source.photovoltaic_arrays or []): pv_rows.append( - _col_values(EpcPhotovoltaicArrayModel.from_domain(array, idx, epc_pid), frozenset({"id"})) + _col_values( + EpcPhotovoltaicArrayModel.from_domain(array, idx, epc_pid), + frozenset({"id"}), + ) ) for etype, els in ( ("roof", d.roofs), @@ -211,7 +228,10 @@ class EpcPostgresRepository(EpcRepository): ): for el in els: element_rows.append( - _col_values(EpcEnergyElementModel.from_domain(el, etype, epc_pid), frozenset({"id"})) + _col_values( + EpcEnergyElementModel.from_domain(el, etype, epc_pid), + frozenset({"id"}), + ) ) for el, etype in ( (d.window, "window"), @@ -222,15 +242,26 @@ class EpcPostgresRepository(EpcRepository): ): if el is not None: element_rows.append( - _col_values(EpcEnergyElementModel.from_domain(el, etype, epc_pid), frozenset({"id"})) + _col_values( + EpcEnergyElementModel.from_domain(el, etype, epc_pid), + frozenset({"id"}), + ) ) if d.sap_flat_details is not None: flat_rows.append( - _col_values(EpcFlatDetailsModel.from_domain(d.sap_flat_details, epc_pid), frozenset({"id"})) + _col_values( + EpcFlatDetailsModel.from_domain(d.sap_flat_details, epc_pid), + frozenset({"id"}), + ) ) if d.renewable_heat_incentive is not None: rhi_rows.append( - _col_values(EpcRenewableHeatIncentiveModel.from_domain(d.renewable_heat_incentive, epc_pid), frozenset({"id"})) + _col_values( + EpcRenewableHeatIncentiveModel.from_domain( + d.renewable_heat_incentive, epc_pid + ), + frozenset({"id"}), + ) ) # Bulk-insert all simple child tables (no downstream FK dependency). @@ -256,7 +287,9 @@ class EpcPostgresRepository(EpcRepository): # Positional zip is safe because PostgreSQL preserves VALUES order in RETURNING. if parts_ordered: bp_rows = [ - _col_values(EpcBuildingPartModel.from_domain(part, epc_pid), frozenset({"id"})) + _col_values( + EpcBuildingPartModel.from_domain(part, epc_pid), frozenset({"id"}) + ) for part, epc_pid in parts_ordered ] returned_bps = self._session.execute( # type: ignore[deprecated] @@ -264,7 +297,10 @@ class EpcPostgresRepository(EpcRepository): bp_rows, ).all() floor_rows: list[dict[str, Any]] = [ - _col_values(EpcFloorDimensionModel.from_domain(dim, bp_row[0]), frozenset({"id"})) + _col_values( + EpcFloorDimensionModel.from_domain(dim, bp_row[0]), + frozenset({"id"}), + ) for (part, _), bp_row in zip(parts_ordered, returned_bps) for dim in part.sap_floor_dimensions ] @@ -273,7 +309,9 @@ class EpcPostgresRepository(EpcRepository): return epc_property_ids - def _delete_for_properties(self, property_ids: list[int], source: EpcSource) -> None: + def _delete_for_properties( + self, property_ids: list[int], source: EpcSource + ) -> None: """Batch-delete every EPC graph for the given property_ids and source in one pass per child table (IN queries), replacing the per-property flush loop that drove RDS CPU to saturation during bulk modelling runs.""" @@ -370,9 +408,7 @@ class EpcPostgresRepository(EpcRepository): def get_for_property(self, property_id: int) -> Optional[EpcPropertyData]: return self._get_for_property(property_id, source="lodged") - def get_predicted_for_property( - self, property_id: int - ) -> Optional[EpcPropertyData]: + def get_predicted_for_property(self, property_id: int) -> Optional[EpcPropertyData]: return self._get_for_property(property_id, source="predicted") def _get_for_property( @@ -388,9 +424,7 @@ class EpcPostgresRepository(EpcRepository): return None return self.get(row.id) - def get_for_properties( - self, property_ids: list[int] - ) -> dict[int, EpcPropertyData]: + def get_for_properties(self, property_ids: list[int]) -> dict[int, EpcPropertyData]: """Bulk-hydrate a batch's LODGED EPCs, keyed by property_id.""" return self._for_properties(property_ids, source="lodged") @@ -481,10 +515,7 @@ class EpcPostgresRepository(EpcRepository): ).all() ) part_ids = [ - bp.id - for parts in parts_by.values() - for bp in parts - if bp.id is not None + bp.id for parts in parts_by.values() for bp in parts if bp.id is not None ] floor_dims_by_part = self._floor_dims_by_part(part_ids) @@ -593,7 +624,11 @@ class EpcPostgresRepository(EpcRepository): rhi_row: Optional[EpcRenewableHeatIncentiveModel], ) -> EpcPropertyData: def _elements(element_type: str) -> list[EnergyElement]: - return [self._to_energy_element(e) for e in elements if e.element_type == element_type] + return [ + self._to_energy_element(e) + for e in elements + if e.element_type == element_type + ] def _single(element_type: str) -> Optional[EnergyElement]: found = _elements(element_type) @@ -786,9 +821,7 @@ class EpcPostgresRepository(EpcRepository): ) @private - def _pv_arrays( - self, epc_property_id: int - ) -> list[EpcPhotovoltaicArrayModel]: + def _pv_arrays(self, epc_property_id: int) -> list[EpcPhotovoltaicArrayModel]: return list( self._session.exec( select(EpcPhotovoltaicArrayModel) @@ -1018,7 +1051,7 @@ class EpcPostgresRepository(EpcRepository): else None ), pv_diverter_present=p.energy_pv_diverter_present, - mains_gas=p.energy_mains_gas, + gas_connection_available=p.energy_gas_connection_available, meter_type=p.energy_meter_type, pv_battery_count=p.energy_pv_battery_count, wind_turbines_count=p.energy_wind_turbines_count, diff --git a/repositories/property/landlord_override_overlays.py b/repositories/property/landlord_override_overlays.py index b65cf5d29..29ebcea00 100644 --- a/repositories/property/landlord_override_overlays.py +++ b/repositories/property/landlord_override_overlays.py @@ -28,6 +28,7 @@ values are live vs no-op before any write. from __future__ import annotations +import logging from typing import Callable, Optional from domain.epc.property_overlays.attribute_overlay import ( @@ -41,6 +42,7 @@ from domain.epc.property_overlays.glazing_overlay import glazing_overlay_for from domain.epc.property_overlays.main_fuel_overlay import fuel_overlay_for from domain.epc.property_overlays.main_heating_system_overlay import ( main_heating_overlay_for, + natural_fuel_for, ) from domain.epc.property_overlays.water_heating_overlay import ( water_heating_overlay_for, @@ -50,6 +52,8 @@ from domain.epc.property_overlays.wall_type_overlay import wall_overlay_for from domain.modelling.simulation import EpcSimulation from repositories.property.property_overrides_reader import ResolvedPropertyOverrides +logger = logging.getLogger(__name__) + # Each override component maps its value (+ building part) to an overlay, or None # when the value isn't resolvable. Fabric (wall/roof) folds onto building parts; # property_type / built_form_type are whole-dwelling categorical corrections @@ -67,9 +71,22 @@ _COMPONENT_OVERLAYS: dict[str, Callable[[str, int], Optional[EpcSimulation]]] = } +# Components whose overlay must be applied LAST so an explicit value wins a +# default another overlay dragged. `apply_simulations` is last-wins and override +# rows arrive in arbitrary order, so a `main_fuel` override must be applied after +# the `main_heating_system` archetype, whose natural-fuel default it overrides +# (ADR-0041) — e.g. "smokeless coal" must beat a solid-fuel room heater's coal +# default. +_APPLY_LAST: frozenset[str] = frozenset({"main_fuel"}) + + def overlays_from(overrides: ResolvedPropertyOverrides) -> list[EpcSimulation]: overlays: list[EpcSimulation] = [] - for row in overrides.rows: + # Stable sort: non-`_APPLY_LAST` rows keep their order, `main_fuel` goes last. + ordered_rows = sorted( + overrides.rows, key=lambda row: row.override_component in _APPLY_LAST + ) + for row in ordered_rows: mapper = _COMPONENT_OVERLAYS.get(row.override_component) if mapper is None: continue @@ -77,3 +94,55 @@ def overlays_from(overrides: ResolvedPropertyOverrides) -> list[EpcSimulation]: if overlay is not None: overlays.append(overlay) return overlays + + +# Coarse fuel family per RdSAP `main_fuel` code (main_fuel_overlay._FUEL_CODES), +# for the plausibility check. The natural fuel a solid-fuel archetype drags +# (house coal) is a *default* across the ambiguous solid family, so a same-family +# override (smokeless / dual fuel / biomass) is a refinement, not a contradiction +# — only a different family (gas/electric on a solid heater) is flagged. +_FUEL_FAMILY: dict[int, str] = { + 26: "gas", 20: "gas", + 27: "lpg", 3: "lpg", 17: "lpg", + 28: "oil", + 29: "electricity", 25: "electricity", + 33: "solid", 15: "solid", 10: "solid", 31: "solid", +} + + +def _override_value(overrides: ResolvedPropertyOverrides, component: str) -> Optional[str]: + for row in overrides.rows: + if row.override_component == component: + return row.override_value + return None + + +def flag_fuel_mismatch(overrides: ResolvedPropertyOverrides) -> None: + """Log (not raise) when a landlord `main_fuel` override contradicts the + `main_heating_system` archetype's natural fuel — a plausibility signal (e.g. + a gas fuel on an electric room heater). The override is still honoured (it + wins, ADR-0041); we only record the implausibility. Deferred handling.""" + heating_value = _override_value(overrides, "main_heating_system") + fuel_value = _override_value(overrides, "main_fuel") + if heating_value is None or fuel_value is None: + return + natural = natural_fuel_for(heating_value) + fuel_overlay = fuel_overlay_for(fuel_value, 0) + override_fuel = ( + fuel_overlay.heating.main_fuel_type + if fuel_overlay is not None and fuel_overlay.heating is not None + else None + ) + if natural is None or not isinstance(override_fuel, int): + return + natural_family = _FUEL_FAMILY.get(natural) + override_family = _FUEL_FAMILY.get(override_fuel) + if natural_family is None or override_family is None: + return + if natural_family != override_family: + logger.warning( + "Landlord main_fuel %r contradicts the natural fuel of heating " + "system %r (override is honoured; flagged for review)", + fuel_value, + heating_value, + ) diff --git a/repositories/property/property_postgres_repository.py b/repositories/property/property_postgres_repository.py index dec92814e..1072c18d8 100644 --- a/repositories/property/property_postgres_repository.py +++ b/repositories/property/property_postgres_repository.py @@ -14,7 +14,10 @@ from domain.property.properties import Properties from domain.property.property import Property, PropertyIdentity from infrastructure.postgres.property_table import PropertyRow from repositories.epc.epc_repository import EpcRepository -from repositories.property.landlord_override_overlays import overlays_from +from repositories.property.landlord_override_overlays import ( + flag_fuel_mismatch, + overlays_from, +) from repositories.property.property_overrides_reader import PropertyOverridesReader from repositories.property.property_repository import ( PropertyIdentityInsert, @@ -63,7 +66,9 @@ class PropertyPostgresRepository(PropertyRepository): no reader is wired (the overlay stays off) or the Property has none.""" if self._overrides_reader is None: return [] - return overlays_from(self._overrides_reader.overrides_for(property_id)) + overrides = self._overrides_reader.overrides_for(property_id) + flag_fuel_mismatch(overrides) + return overlays_from(overrides) def get(self, property_id: int) -> Property: row = self._session.get(PropertyRow, property_id) diff --git a/scripts/audit/anomalies.py b/scripts/audit/anomalies.py index 7b1ac62e2..0ee0386d4 100644 --- a/scripts/audit/anomalies.py +++ b/scripts/audit/anomalies.py @@ -34,7 +34,7 @@ from dataclasses import dataclass from enum import IntEnum from typing import Callable, Optional -from sqlalchemy import text +from sqlalchemy import Connection, text from datatypes.epc.domain.epc import Epc from scripts.e2e_common import build_engine, load_env @@ -55,6 +55,26 @@ def _band_of(score: Optional[float]) -> Optional[str]: return Epc.from_sap_score(round(score)).value +def _int_or_none(value: object) -> Optional[int]: + """Coerce a jsonb-backed scalar (int / digit-string / None) to int.""" + if value is None: + return None + try: + return int(str(value)) + except ValueError: + return None + + +def _float_or_none(value: object) -> Optional[float]: + """Coerce a jsonb-backed scalar (number / numeric-string / None) to float.""" + if value is None: + return None + try: + return float(str(value)) + except ValueError: + return None + + class Severity(IntEnum): LOW = 1 MEDIUM = 2 @@ -74,6 +94,7 @@ class PropertyAudit: portfolio_id: int scenario_id: Optional[int] scenario_goal_band: Optional[str] + scenario_budget: Optional[float] # None == unlimited budget lodged_sap: Optional[float] lodged_band: Optional[str] effective_sap: Optional[float] @@ -88,6 +109,12 @@ class PropertyAudit: solar_sap_points: Optional[float] # max SAP a single solar_pv measure earns solar_bill_savings: Optional[float] # the solar_pv measure's £/yr bill saving n_measures: int + # Hot-water-cylinder fields from the latest lodged EPC row (None when the + # Property has no epc_property row). Insulation type codes are the RdSAP + # lodgement codes: 0 = none, 1 = factory foam, 2 = loose jacket. + has_hot_water_cylinder: Optional[bool] + cylinder_insulation_type: Optional[int] + cylinder_insulation_thickness_mm: Optional[float] @dataclass(frozen=True) @@ -148,6 +175,76 @@ def _already_meets_goal_with_works(a: PropertyAudit) -> Optional[str]: return f"already {a.effective_band} >= goal {a.scenario_goal_band} but cost_of_works £{a.cost_of_works:.0f}" +@check("plan-stops-short-of-goal", Severity.HIGH) +def _plan_stops_short_of_goal(a: PropertyAudit) -> Optional[str]: + """The default plan ends BELOW the scenario's goal band on a scenario that is + NOT budget-capped — with an unlimited budget a plan should reach the goal + unless it is physically impossible for that dwelling, so a shortfall is either + a measure wrongly withheld (a bug) or a "sensible reason" the engine should be + able to explain. + + This is the deterministic worklist for Khalim's "why didn't this get + recommended " class (portfolio 814, pid 742121 — a dwelling left + short of C with no HHRSH bundle). It only flags candidates; the deep-dive + (``run_modelling_e2e`` + the candidates CSV) decides withheld-measure vs + physically-unreachable. Budget-capped scenarios are excluded because there a + shortfall is expected — the money simply ran out.""" + goal, post = _band_rank(a.scenario_goal_band), _band_rank(a.post_band) + if goal is None or post is None or post <= goal: + return None + if a.scenario_budget is not None: + # Budget-capped: falling short is expected once the money runs out. + return None + return ( + f"post {a.post_band} ({a.post_sap}) short of goal {a.scenario_goal_band} " + f"on an unlimited-budget scenario (cost_of_works £{a.cost_of_works or 0:.0f})" + ) + + +@check("plan-stuck-in-low-band", Severity.HIGH) +def _plan_stuck_in_low_band(a: PropertyAudit) -> Optional[str]: + """The plan leaves the dwelling STUCK in a low band (E or worse) with the band + UNCHANGED from baseline — the engine spent (or spent nothing) yet moved the + dwelling nowhere off the floor. + + Complements ``plan-stops-short-of-goal``. That check fires on anything below + the goal band — 322/338 on portfolio 814, mostly flats a single band short of + B, which is expected and drowns the real hits. This one isolates the + genuinely-stuck tail: the handful still rated E/F/G *after* works, where a + wrongly-withheld measure or a physically-trapped dwelling actually shows up. + It is the "E→E, no big movement" worklist the tech team asked for. + + Motivated by portfolio 814 (scenario 1271, goal B, unlimited budget): only 4 + properties land E-or-worse and all 4 stay at E — pid 742121 (electric + maisonette, £0 works: HHRSH *is* offered as a candidate but scores −6.3 SAP, + so the Optimiser correctly drops it, and the fabric is already done — no lever + left); pid 742265 (community-heated flat, SAP code 301: HHRSH and ASHP are + gated out by the heat-network topology, leaving IWI its only real lever); pids + 742210 / 742347 (flats that move ~5-8 SAP but never clear E). Legitimate-but- + trapped and real-bug cases both surface here — the deep-dive + (``run_modelling_e2e`` + the candidates CSV) tells them apart. Budget-capped + scenarios are excluded: staying low is expected once the money runs out. + + Band-keyed, not a SAP-points threshold, so it needs no tuned magic number — + "post band E/F/G and no band improvement" is the whole rule. The strictly- + worse case (post below baseline) is left to ``plan-below-baseline-band`` so + the two partition cleanly.""" + post, base = _band_rank(a.post_band), _band_rank(a.effective_band) + if post is None or base is None: + return None + if post < _BANDS.index("E"): # A–D: out of the low tail, not "stuck" + return None + if post != base: # band moved (a lift, or the worse case above) — not stuck + return None + if a.scenario_budget is not None: # budget-capped: staying low is expected + return None + return ( + f"still {a.post_band} ({a.post_sap}) after works — no band lift from " + f"baseline {a.effective_band} ({a.effective_sap}) on an unlimited-budget " + f"scenario (cost_of_works £{a.cost_of_works or 0:.0f})" + ) + + @check("post-band-score-mismatch", Severity.MEDIUM) def _post_band_score_mismatch(a: PropertyAudit) -> Optional[str]: """The persisted post band disagrees with the band the post SAP implies — a @@ -175,15 +272,47 @@ def _zero_works_post_differs(a: PropertyAudit) -> Optional[str]: def _effective_lodged_divergence(a: PropertyAudit) -> Optional[str]: """The Effective baseline is far from the lodged accredited figure (≥15 SAP). Often legitimate (overrides / pre-SAP10 rebaseline), but worth a look — a big - gap can also mean a bad override or a calculator divergence.""" + gap can also mean a bad override or a calculator divergence. + + Lodged scores below 13 are handed off to ``implausible-lodged-score`` — they + indicate corrupt upstream EPC register data, not a modelling divergence.""" if a.effective_sap is None or a.lodged_sap is None: return None + if a.lodged_sap < 13: + return None gap = a.effective_sap - a.lodged_sap if abs(gap) < 15: return None return f"effective {a.effective_sap:.0f} vs lodged {a.lodged_sap:.0f} (Δ{gap:+.0f}, reason={a.rebaseline_reason})" +@check("implausible-lodged-score", Severity.LOW) +def _implausible_lodged_score(a: PropertyAudit) -> Optional[str]: + """Lodged SAP score is below 13 with a large effective-vs-lodged gap — the + lodged figure is implausible upstream EPC register data, not a modelling bug. + + Floor of 13 is justified against the portfolio-796 distribution: every lodged + score below 13 either matches a known Class C exemplar (SAP 1: pids 727752, + 727008, 731205) or shares the same bad-data fingerprint — implausibly low + lodged paired with a reasonable effective in band D/E (pids 710449 SAP 5, + 722562 SAP 7, 725377 SAP 9, 727457 SAP 10, 723364 SAP 12). SAP 13 and above + are left in scope as ambiguous. + + The gap guard (≥15) mirrors ``effective-lodged-divergence`` so the two checks + partition the same population without overlap.""" + if a.lodged_sap is None or a.effective_sap is None: + return None + if a.lodged_sap >= 13: + return None + gap = a.effective_sap - a.lodged_sap + if abs(gap) < 15: + return None + return ( + f"lodged SAP {a.lodged_sap:.0f} is implausible (< 13) — upstream EPC data," + f" not a modelling bug (effective {a.effective_sap:.0f}, Δ{gap:+.0f})" + ) + + @check("impossible-sap-over-100", Severity.HIGH) def _impossible_sap_over_100(a: PropertyAudit) -> Optional[str]: """A SAP score above 100 is impossible (SAP caps at 100) — a calculator / @@ -239,21 +368,81 @@ def _negative_bill_savings(a: PropertyAudit) -> Optional[str]: return f"energy_bill_savings £{a.energy_bill_savings:.0f}/yr on £{a.cost_of_works:.0f} of works" +@check("cylinder-storage-loss-dropped", Severity.HIGH) +def _cylinder_storage_loss_dropped(a: PropertyAudit) -> Optional[str]: + """A lodged hot-water cylinder whose insulation fields cannot resolve a SAP + 10.2 Table 2 loss branch — insulation type is not factory foam (1) or loose + jacket (2), or the thickness is missing — so the calculator's + ``_cylinder_storage_loss_override`` (domain/sap10_calculator/rdsap/ + cert_to_inputs.py) returns None and the worksheet keeps the zero-storage-loss + default meant for combi / instantaneous systems. The baseline then + OVER-RATES: an UNINSULATED cylinder (type 0, the worst case SAP knows) scores + ~+13 SAP better than a properly insulated one instead of much worse. + + Provenance: portfolio 814 / scenario 1271 audit (2026-07-03). All 26 + properties with ``has_hot_water_cylinder`` and insulation type 0 (23) or NULL + (3) over-rate vs their lodged score — Δ+1..+18, avg +12.7 for type 0 — while + the type-1/2 population averages Δ−2. Same-lodged neighbours split by up to + 19 effective points (WD5 0DP: pid 742216 lodged 54 → effective 69 vs pid + 742215 lodged 54 → effective 50, identical building parts), and the inflated + members distorted neighbour-divergence cohort medians (E17 6EB pid 742355 + Δ+18; WD5 0DP pushed honest pid 742210 over the flag threshold). Fix is in + the calculator (apply the uninsulated Table 2 loss for type 0, or the RdSAP + Table 29 age-band default when unknown), not in the data. + + Fires on the input pattern (deterministic — the loss IS dropped for every + such row), not on a tuned delta threshold; the reason string reports the + effective-vs-lodged gap where a lodged score exists so triage can rank by + materiality. A full-SAP cert lodging a manufacturer's declared loss instead + of type/thickness would bypass the bug and false-positive here — none exist + on the motivating portfolio; revisit if one appears.""" + if a.has_hot_water_cylinder is not True: + return None + if ( + a.cylinder_insulation_type in (1, 2) + and a.cylinder_insulation_thickness_mm is not None + ): + return None + gap = ( + f", effective {a.effective_sap:.0f} vs lodged {a.lodged_sap:.0f} " + f"(Δ{a.effective_sap - a.lodged_sap:+.0f})" + if a.effective_sap is not None and a.lodged_sap is not None + else "" + ) + return ( + f"cylinder lodged with insulation_type={a.cylinder_insulation_type} " + f"thickness={a.cylinder_insulation_thickness_mm} — storage loss dropped " + f"to zero (combi default), baseline over-rates{gap}" + ) + + # ─────────────────────── runner ─────────────────────── _QUERY = text( """ SELECT p.id, p.uprn, p.portfolio_id, - pl.scenario_id, s.goal_value AS goal_band, + pl.scenario_id, s.goal_value AS goal_band, s.budget AS scenario_budget, pbp.lodged_sap_score, pbp.lodged_epc_band, pbp.effective_sap_score, pbp.effective_epc_band, pbp.rebaseline_reason, pl.post_sap_points, pl.post_epc_rating, pl.cost_of_works, - pl.energy_bill_savings, pl.energy_consumption_savings + pl.energy_bill_savings, pl.energy_consumption_savings, + ep.has_hot_water_cylinder, ep.cylinder_insulation_type, + ep.cylinder_insulation_thickness_mm FROM property p LEFT JOIN property_baseline_performance pbp ON pbp.property_id = p.id LEFT JOIN plan pl ON pl.property_id = p.id AND pl.is_default = TRUE AND (:scenario_id IS NULL OR pl.scenario_id = :scenario_id) LEFT JOIN scenario s ON s.id = pl.scenario_id + LEFT JOIN LATERAL ( + SELECT e.has_hot_water_cylinder, + e.heating_cylinder_insulation_type AS cylinder_insulation_type, + e.heating_cylinder_insulation_thickness_mm + AS cylinder_insulation_thickness_mm + FROM epc_property e + WHERE e.property_id = p.id + ORDER BY e.id DESC + LIMIT 1 + ) ep ON TRUE WHERE (:portfolio_id IS NULL OR p.portfolio_id = :portfolio_id) AND (:property_id IS NULL OR p.id = :property_id) ORDER BY p.id @@ -261,8 +450,22 @@ _QUERY = text( ) -_ROLLUP_QUERY = text( - """ +# Bound every audit query so a bad plan aborts instead of saturating the shared +# DB. The `recommendation` table (~26m rows) has no index on `plan_id`, so any +# query reaching it via `plan_id` seq-scans the whole table — exactly what +# blocked the DB during the portfolio-796 audit. The timeout is the hard ceiling; +# `_guard_recommendation_scan` is the smarter gate that refuses such a plan up +# front rather than running it for the full timeout window. +_STATEMENT_TIMEOUT_MS = 120_000 + + +# The rollup is kept as a string (not a pre-built ``text()``) so we can prepend +# ``EXPLAIN`` and inspect the plan before executing it. It reaches +# ``recommendation`` via the indexed ``property_id`` (never ``plan_id``); on a +# large portfolio the planner may still pick a seq-scan, which the EXPLAIN gate +# catches. Only the ``solar_pv`` rollups + ``n_measures`` come from here, feeding +# the two opt-in recommendation checks (excessive-solar-sap, low-solar-bill). +_ROLLUP_SQL = """ SELECT r.property_id, MAX(r.sap_points) FILTER (WHERE r.type = 'solar_pv') AS solar_sap, MAX(r.energy_cost_savings) FILTER (WHERE r.type = 'solar_pv') AS solar_bill, @@ -275,28 +478,69 @@ _ROLLUP_QUERY = text( AND (:property_id IS NULL OR p.id = :property_id) GROUP BY r.property_id """ -) +_ROLLUP_QUERY = text(_ROLLUP_SQL) + + +class RecommendationScanError(RuntimeError): + """Raised when a recommendation query would seq-scan the 26m-row table. + + The fix is operational, not a retry: add ``idx_recommendation_plan_id`` (see + the audit skill's query-safety notes) or run without ``--with-recommendations`` + so the two solar checks are simply skipped. + """ + + +def _guard_recommendation_scan( + conn: Connection, params: dict[str, Optional[int]] +) -> None: + """Refuse to run the rollup if its plan seq-scans ``recommendation``. + + EXPLAIN (no ANALYZE) executes nothing, so this is free. A full seq-scan of + the 26m-row table is the pattern that blocked the DB; aborting here is far + cheaper than discovering it via the statement timeout. + """ + plan = "\n".join( + str(row[0]) for row in conn.execute(text("EXPLAIN " + _ROLLUP_SQL), params) + ) + if "Seq Scan on recommendation" in plan: + raise RecommendationScanError( + "rollup would seq-scan the 26m-row `recommendation` table " + f"(no index on plan_id; portfolio likely too large). Plan:\n{plan}" + ) def _load( portfolio_id: Optional[int], property_id: Optional[int], scenario_id: Optional[int], + with_recommendations: bool = False, ) -> list[PropertyAudit]: + """Join one row per Property for the checks to run over. + + ``with_recommendations`` is OFF by default: the ``recommendation`` rollup is + the only query that touches the 26m-row table and is the one that blocked the + DB, so it is opt-in and EXPLAIN-gated. With it off, the two solar checks see + ``None`` and are inert — every other check is bounded by the portfolio. + """ engine = build_engine() out: list[PropertyAudit] = [] - params = { + params: dict[str, Optional[int]] = { "portfolio_id": portfolio_id, "property_id": property_id, "scenario_id": scenario_id, } with engine.connect() as conn: - rollups: dict[int, tuple[Optional[float], Optional[float], int]] = { - m["property_id"]: (m["solar_sap"], m["solar_bill"], m["n_measures"]) - for m in ( - row._mapping for row in conn.execute(_ROLLUP_QUERY, params) - ) - } + # Hard ceiling: a runaway plan aborts instead of hammering the shared DB. + conn.execute(text(f"SET statement_timeout = {_STATEMENT_TIMEOUT_MS}")) + rollups: dict[int, tuple[Optional[float], Optional[float], int]] = {} + if with_recommendations: + _guard_recommendation_scan(conn, params) + rollups = { + m["property_id"]: (m["solar_sap"], m["solar_bill"], m["n_measures"]) + for m in ( + row._mapping for row in conn.execute(_ROLLUP_QUERY, params) + ) + } for r in conn.execute(_QUERY, params): m = r._mapping solar_sap, solar_bill, n_measures = rollups.get(m["id"], (None, None, 0)) @@ -307,6 +551,7 @@ def _load( portfolio_id=m["portfolio_id"], scenario_id=m["scenario_id"], scenario_goal_band=m["goal_band"], + scenario_budget=m["scenario_budget"], lodged_sap=m["lodged_sap_score"], lodged_band=m["lodged_epc_band"], effective_sap=m["effective_sap_score"], @@ -320,6 +565,13 @@ def _load( solar_sap_points=solar_sap, solar_bill_savings=solar_bill, n_measures=n_measures, + has_hot_water_cylinder=m["has_hot_water_cylinder"], + cylinder_insulation_type=_int_or_none( + m["cylinder_insulation_type"] + ), + cylinder_insulation_thickness_mm=_float_or_none( + m["cylinder_insulation_thickness_mm"] + ), ) ) return out @@ -382,13 +634,27 @@ def main() -> None: default="low", help="minimum severity to report (default: low — all)", ) + parser.add_argument( + "--with-recommendations", + action="store_true", + help="run the recommendation rollup (the solar checks). OFF by default: " + "it scans the 26m-row `recommendation` table and needs " + "idx_recommendation_plan_id to be bounded — EXPLAIN-gated when on.", + ) args = parser.parse_args() min_severity = Severity[args.severity.upper()] - audits = _load(args.portfolio, args.property, args.scenario) + audits = _load( + args.portfolio, args.property, args.scenario, args.with_recommendations + ) anomalies = run(audits, min_severity) _write_reports(anomalies, len(audits)) + if not args.with_recommendations: + print( + "NOTE: recommendation rollup skipped (--with-recommendations off) — " + "the solar checks (excessive-solar-sap, low-solar-bill-savings) are inert." + ) print(f"scanned {len(audits)} properties · {len(anomalies)} anomalies " f"(>= {min_severity.name})") counts: dict[str, int] = {} diff --git a/scripts/audit/neighbour_divergence.py b/scripts/audit/neighbour_divergence.py new file mode 100644 index 000000000..864e2485c --- /dev/null +++ b/scripts/audit/neighbour_divergence.py @@ -0,0 +1,281 @@ +"""Flag neighbouring dwellings that were modelled *differently despite looking +the same* — the SAP half of the "neighbours should agree" heuristic. + +Motivation (portfolio 814): Khalim found a dwelling that gets an HHRSH bundle +while its next-door neighbour of the same type does not. Neighbours in one +postcode, of the same property type and built form, are usually near-identical +stock; a big split in their *effective baseline SAP* is a strong tell that one of +the pair was mis-mapped, mis-overridden, or mis-rebaselined — and a SAP split is +what then drives a recommendation split. This script surfaces those pairs +cheaply so the deep-dive (``run_modelling_e2e`` on both neighbours) can compare +their actual measure sets and root-cause the divergence. + +It reaches only ``property`` + ``epc_property`` + ``property_baseline_performance``, +all via the indexed ``property_id`` — it NEVER touches the 26m-row +``recommendation`` table, so it is safe to run portfolio-wide (unlike the +measure-set comparison, which the skill does per flagged cohort in the deep-dive). + +Run: + python -m scripts.audit.neighbour_divergence --portfolio 814 + python -m scripts.audit.neighbour_divergence --portfolio 814 --min-gap 15 + +Writes ``neighbour_divergence.md`` + ``neighbour_divergence.csv`` and prints a +summary. Read-only: never writes to the DB. + +A cohort is the set of a portfolio's properties sharing (postcode, property_type, +built_form, main-heating fuel class). Within a cohort of >= 2, any member whose +effective SAP is >= ``--min-gap`` points from the cohort median is flagged. +Heating **fuel** is part of the key because electricity scores materially lower +in SAP than mains gas for identical fabric — mixing fuels in one cohort produces +cross-fuel false outliers (an electric dwelling flagged only for being electric +among gas neighbours). Keying on fuel ensures like-vs-like, so every flagged +divergence is a same-fuel one worth investigating (on portfolio 814 this refined +20 raw divergences to 17 same-fuel comparisons). Floor area is reported, not +keyed on, so the reviewer can discount a genuinely bigger/smaller neighbour; +promoting an area guard into the cohort key is a clean future change once the +false-positive rate is measured on a real portfolio (skill Phase 6). +""" + +from __future__ import annotations + +import argparse +import csv +from dataclasses import dataclass +from statistics import median +from typing import Optional + +from sqlalchemy import text + +from scripts.e2e_common import build_engine, load_env + +# Hard ceiling so a bad plan aborts instead of saturating the shared DB, mirroring +# scripts/audit/anomalies.py. Every table here is reached via the indexed +# property_id and scoped to one portfolio, so this is a backstop, not a crutch. +_STATEMENT_TIMEOUT_MS = 120_000 + +# Default SAP gap (points from the cohort median) at which a neighbour is flagged. +# 12 is a starting threshold, not a tuned one — a full band is ~10-11 SAP points, +# so >= 12 means the neighbour sits a clear band apart from otherwise-identical +# stock. Re-justify against a real portfolio's distribution before trusting it +# (skill Phase 6); expose it as --min-gap so a run can sweep the threshold. +_DEFAULT_MIN_GAP = 12.0 + + +@dataclass(frozen=True) +class Neighbour: + """One modelled property placed in its postcode/type/form cohort.""" + + property_id: int + uprn: Optional[int] + postcode: Optional[str] + property_type: Optional[str] + built_form: Optional[str] + floor_area_m2: Optional[float] + effective_sap: Optional[float] + effective_band: Optional[str] + main_fuel_type: Optional[int] # gov-API main-heating fuel code (None if unmapped) + + +@dataclass(frozen=True) +class Divergence: + property_id: int + uprn: Optional[int] + cohort_key: str + cohort_size: int + detail: str + + +# DISTINCT ON picks the latest ingested epc_property row per property (its +# property_id is NOT unique — ingestion keeps history), ordered by id DESC. +_QUERY = text( + """ + SELECT p.id, p.uprn, p.postcode, + ep.property_type, ep.built_form, ep.total_floor_area_m2, + pbp.effective_sap_score, pbp.effective_epc_band, + mh.main_fuel_type + FROM property p + JOIN property_baseline_performance pbp ON pbp.property_id = p.id + LEFT JOIN LATERAL ( + SELECT id, property_type, built_form, total_floor_area_m2 + FROM epc_property e + WHERE e.property_id = p.id + ORDER BY e.id DESC + LIMIT 1 + ) ep ON TRUE + LEFT JOIN LATERAL ( + SELECT main_fuel_type + FROM epc_main_heating_detail m + WHERE m.epc_property_id = ep.id + ORDER BY m.id + LIMIT 1 + ) mh ON TRUE + WHERE p.portfolio_id = :portfolio_id + ORDER BY p.id + """ +) + + +def _load(portfolio_id: int) -> list[Neighbour]: + engine = build_engine() + out: list[Neighbour] = [] + with engine.connect() as conn: + conn.execute(text(f"SET statement_timeout = {_STATEMENT_TIMEOUT_MS}")) + for row in conn.execute(_QUERY, {"portfolio_id": portfolio_id}): + m = row._mapping # noqa: SLF001 — SQLAlchemy row mapping, mirrors anomalies.py + out.append( + Neighbour( + property_id=m["id"], + uprn=m["uprn"], + postcode=m["postcode"], + property_type=m["property_type"], + built_form=m["built_form"], + floor_area_m2=m["total_floor_area_m2"], + effective_sap=m["effective_sap_score"], + effective_band=m["effective_epc_band"], + main_fuel_type=_coerce_fuel(m["main_fuel_type"]), + ) + ) + return out + + +def _coerce_fuel(raw: object) -> Optional[int]: + """The main-heating fuel code as an int, or None. ``main_fuel_type`` is a + JSONB column (int or str depending on the source mapper), so coerce defensively + — a non-numeric value groups as "unmapped" rather than raising.""" + if raw is None: + return None + try: + return int(raw) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + + +# Electricity main-heating fuel codes (gov-API / SAP Table 12): standard tariff +# (30), the off-peak tariffs (31-40), and code 29, which real electric certs in +# the corpus lodge for "room heaters, electric". Collapsing these to one class +# keeps all-electric dwellings comparable to each other; every OTHER fuel keeps +# its raw code as its own bucket (we don't guess a taxonomy for gas/oil/community). +_ELECTRICITY_FUEL_CODES: frozenset[int] = frozenset({29, 30, 31, 32, 33, 34, 35, 38, 40}) + + +def _fuel_class(fuel: Optional[int]) -> str: + """A coarse main-heating-fuel label for the cohort key. Electricity variants + collapse to one class; any other code is its own bucket; None is "unmapped".""" + if fuel is None: + return "fuel?" + if fuel in _ELECTRICITY_FUEL_CODES: + return "electric" + return f"fuel{fuel}" + + +def _cohort_key(n: Neighbour) -> Optional[str]: + """A stable cohort label, or None when the neighbour can't be placed (no + postcode or no property type — it has no comparable peers to diverge from). + + The main-heating **fuel** is part of the key: electricity scores materially + lower in SAP than mains gas for identical fabric, so mixing fuels in a cohort + produces cross-fuel false outliers. Keying on fuel keeps like compared with + like, so a flagged divergence is a genuine same-fuel anomaly, not just an + electric dwelling sitting among gas neighbours.""" + if not n.postcode or not n.property_type: + return None + form = n.built_form or "?" + return ( + f"{n.postcode.strip().upper()} · {n.property_type} · {form} " + f"· {_fuel_class(n.main_fuel_type)}" + ) + + +def find_divergences( + neighbours: list[Neighbour], min_gap: float +) -> list[Divergence]: + cohorts: dict[str, list[Neighbour]] = {} + for n in neighbours: + key = _cohort_key(n) + if key is None or n.effective_sap is None: + continue + cohorts.setdefault(key, []).append(n) + + found: list[Divergence] = [] + for key, members in cohorts.items(): + if len(members) < 2: + continue + saps = [m.effective_sap for m in members if m.effective_sap is not None] + cohort_median = median(saps) + for m in members: + if m.effective_sap is None: + continue + gap = m.effective_sap - cohort_median + if abs(gap) < min_gap: + continue + area = f"{m.floor_area_m2:.0f}m²" if m.floor_area_m2 is not None else "?m²" + found.append( + Divergence( + property_id=m.property_id, + uprn=m.uprn, + cohort_key=key, + cohort_size=len(members), + detail=( + f"effective SAP {m.effective_sap:.0f} ({m.effective_band}) " + f"vs cohort median {cohort_median:.0f} (Δ{gap:+.0f}, " + f"{area}, {len(members)} neighbours)" + ), + ) + ) + found.sort(key=lambda d: (d.cohort_key, d.property_id)) + return found + + +def _write_reports(divergences: list[Divergence], scanned: int) -> None: + with open("neighbour_divergence.csv", "w", newline="") as f: + w = csv.writer(f) + w.writerow(["property_id", "uprn", "cohort", "cohort_size", "detail"]) + for d in divergences: + w.writerow([d.property_id, d.uprn, d.cohort_key, d.cohort_size, d.detail]) + + by_cohort: dict[str, list[Divergence]] = {} + for d in divergences: + by_cohort.setdefault(d.cohort_key, []).append(d) + lines = [ + "# Neighbour SAP-divergence audit", + "", + f"Scanned **{scanned}** properties · flagged **{len(divergences)}** " + f"divergent neighbours across **{len(by_cohort)}** cohorts.", + "", + ] + for key in sorted(by_cohort): + rows = by_cohort[key] + lines.append(f"## {key} — {len(rows)} divergent") + lines.append("") + for d in rows: + lines.append(f"- property **{d.property_id}** (uprn {d.uprn}): {d.detail}") + lines.append("") + with open("neighbour_divergence.md", "w") as f: + f.write("\n".join(lines)) + + +def main() -> None: + load_env() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--portfolio", type=int, required=True, help="portfolio_id") + parser.add_argument( + "--min-gap", + type=float, + default=_DEFAULT_MIN_GAP, + help=f"SAP points from cohort median to flag (default {_DEFAULT_MIN_GAP})", + ) + args = parser.parse_args() + + neighbours = _load(args.portfolio) + divergences = find_divergences(neighbours, args.min_gap) + _write_reports(divergences, len(neighbours)) + + print( + f"scanned {len(neighbours)} properties · {len(divergences)} divergent " + f"neighbours (>= {args.min_gap:.0f} SAP from cohort median)" + ) + print("wrote neighbour_divergence.md / neighbour_divergence.csv") + + +if __name__ == "__main__": + main() diff --git a/scripts/corpus_1000/build_worklist.py b/scripts/corpus_1000/build_worklist.py new file mode 100644 index 000000000..55df6586d --- /dev/null +++ b/scripts/corpus_1000/build_worklist.py @@ -0,0 +1,243 @@ +"""Build the RdSAP-21.0.1 corpus campaign ledger (scripts/corpus_1000/worklist.md). + +Runs all 1000 corpus certs through the gauge's own path +(from_api_response -> Sap10Calculator.calculate, mirroring +tests/infrastructure/epc_client/test_sap_accuracy_corpus.py), ranks by +|engine - lodged| descending, attaches a short dwelling signature per cert, +and groups same-signature certs into named clusters. + +USAGE +----- + PYTHONPATH=/workspaces/model python scripts/corpus_1000/build_worklist.py + +Rewrites worklist.md in place, PRESERVING existing per-cert statuses +([x] / 🔧 / ⚠ / ⛔ and trailing `<-` notes) keyed by uprn, so it doubles as +the re-rank step after each fix lands. +""" +from __future__ import annotations + +import json +import re +from collections import defaultdict +from pathlib import Path +from typing import Any, Optional + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import Sap10Calculator + +ROOT = Path(__file__).resolve().parents[2] +CORPUS = ROOT / "backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl" +WORKLIST = ROOT / "scripts/corpus_1000/worklist.md" + +# Table 12 fuel names (abbreviated) for the signature column. +_FUEL = { + 1: "gas", 2: "gas-bulk", 3: "biogas", 4: "biogas", 7: "bLPG", 9: "LPG", + 17: "LPG-sc", 10: "oil", 15: "bioliquid", 16: "B30K", 18: "rapeseed", + 58: "biodiesel", 51: "housecoal", 20: "coal-anth", 21: "smokeless", + 22: "anthracite", 23: "manuf-smokeless", 12: "wood-log", 13: "wood-pellet", + 75: "wood-pellet-bag", 71: "wood-chip", 48: "dual-mineral-wood", + 26: "gas", 25: "elec", 29: "elec", 30: "elec7hr", 31: "elec10hr", + 32: "elec18hr", 33: "elec24hr", 34: "elec-heatnet", 35: "elec", + 36: "elec", 37: "elec", 47: "heatnet", 6: "heatnet", +} + + +def _fuel_name(code: Optional[int]) -> str: + if code is None: + return "?" + return _FUEL.get(code, f"fuel{code}") + + +def _lead(desc: Optional[str], n: int = 3) -> str: + """First n comma/word tokens of a register description, lowercased.""" + if not desc: + return "?" + return " ".join(desc.lower().replace(",", " ").split()[:n]) + + +def signature(doc: dict[str, Any]) -> tuple[str, str]: + """(cluster_key, human signature) for one cert.""" + h = doc.get("sap_heating") or {} + mhd = (h.get("main_heating_details") or [{}]) + m0 = mhd[0] if mhd else {} + code = m0.get("sap_main_heating_code") + idx = m0.get("main_heating_index_number") + fuel = _fuel_name(m0.get("main_fuel_type")) + heat = f"h{code or ('pcdb' + str(idx) if idx else '?')}/{fuel}" + if len(mhd) > 1: + heat += "+2mains" + + walls = doc.get("walls") or [] + wall = _lead(walls[0].get("description") if walls else None, 2) + roofs = doc.get("roofs") or [] + roof = _lead(roofs[0].get("description") if roofs else None, 2) + + es = doc.get("sap_energy_source") or {} + sec = (doc.get("secondary_heating") or {}).get("description") or "None" + feats: list[str] = [] + pv = es.get("photovoltaic_supply") or {} + if es.get("pv_connection") not in (0, None) or not pv.get("none_or_no_details"): + feats.append("PV") + if (es.get("wind_turbines_count") or 0) > 0: + feats.append("wind") + if sec != "None": + feats.append("sec:" + _lead(sec, 2)) + if (doc.get("extensions_count") or 0) > 0: + feats.append(f"ext{doc.get('extensions_count')}") + if (doc.get("conservatory_type") or 1) != 1: + feats.append(f"consv{doc.get('conservatory_type')}") + for bp in doc.get("sap_building_parts") or []: + if bp.get("sap_room_in_roof") is not None: + feats.append("RR") + break + mv = doc.get("mechanical_ventilation") + if mv not in (0, None): + feats.append(f"mv{mv}") + if (h.get("water_heating_code") or 901) != 901: + feats.append(f"whw{h.get('water_heating_code')}") + + key = f"{heat} | {wall} | {roof}" + (" | " + ",".join(sorted(set(feats))) if feats else "") + sig = key + return key, sig + + +_LINE_RE = re.compile( + r"^- \[(?P[ x])\] |^- (?P🔧|⚠|⛔) " +) + + +def load_existing_statuses() -> dict[str, tuple[str, str]]: + """uprn -> (status token, trailing note) from the current worklist.""" + out: dict[str, tuple[str, str]] = {} + if not WORKLIST.exists(): + return out + for line in WORKLIST.read_text().splitlines(): + m = re.match(r"^- (?:\[(?P[ x])\]|(?P🔧|⚠|⛔)) `(?P\w+)`", line) + if not m: + continue + status = f"[{m.group('st')}]" if m.group("st") is not None else m.group("emoji") + note = "" + if "<-" in line: + note = "<-" + line.split("<-", 1)[1] + out[m.group("uprn")] = (status, note) + return out + + +def main() -> None: + docs = [json.loads(l) for l in CORPUS.read_text().splitlines() if l.strip()] + calc = Sap10Calculator() + rows: list[dict[str, Any]] = [] + skipped: list[tuple[str, str]] = [] + sap_abs: list[float] = [] + + for idx, doc in enumerate(docs, 1): + # Some corpus docs lodge no top-level uprn; fall back to the corpus + # line number so ledger statuses don't collide on "None". + uprn = str(doc.get("uprn") or f"line{idx}") + lodged = doc.get("energy_rating_current") + if lodged is None: + skipped.append((uprn, "no lodged SAP")) + continue + try: + epc = EpcPropertyDataMapper.from_api_response(doc) + result = calc.calculate(epc) + except Exception as e: + skipped.append((uprn, f"{type(e).__name__}: {str(e)[:70]}")) + continue + eng = result.sap_score_continuous + delta = eng - lodged + sap_abs.append(abs(delta)) + key, sig = signature(doc) + rows.append({ + "uprn": uprn, "lodged": lodged, "eng": eng, "delta": delta, + "key": key, "sig": sig, + }) + + n = len(sap_abs) + within = sum(1 for e in sap_abs if e < 0.5) / n + mae = sum(sap_abs) / n + + # Cluster: same signature key, only certs still outside 0.5. + clusters: dict[str, list[dict[str, Any]]] = defaultdict(list) + for r in rows: + if abs(r["delta"]) >= 0.5: + clusters[r["key"]].append(r) + ranked = sorted( + clusters.items(), + key=lambda kv: -sum(abs(r["delta"]) for r in kv[1]), + ) + + prev = load_existing_statuses() + + lines: list[str] = [] + lines.append("# RdSAP-21.0.1 corpus campaign ledger") + lines.append("") + lines.append( + f"**Gauge:** {n} computed / {len(skipped)} skipped · " + f"SAP within-0.5 = **{within:.1%}** · MAE = **{mae:.3f}** " + f"(floors in test_sap_accuracy_corpus.py)" + ) + lines.append("") + lines.append( + "Statuses: `[ ]` todo · `[x]` resolved ≤0.5 · 🔧 fix landed · " + "⚠ xfail engine bug · ⛔ unbuildable. " + "Line: `uprn · lodged L / eng E / Δ · signature`. " + "Rebuild/re-rank: `PYTHONPATH=. python scripts/corpus_1000/build_worklist.py` " + "(preserves statuses & `<-` notes)." + ) + lines.append("") + lines.append(f"## Clusters (certs with |Δ| ≥ 0.5, ranked by summed |Δ|) — {sum(len(v) for v in clusters.values())} certs in {len(ranked)} clusters") + lines.append("") + + for i, (key, members) in enumerate(ranked, 1): + members.sort(key=lambda r: -abs(r["delta"])) + tot = sum(abs(r["delta"]) for r in members) + lines.append( + f"### C{i:03d} · Σ|Δ| {tot:.1f} · {len(members)} certs · `{key}`" + ) + for r in members: + st, note = prev.get(r["uprn"], ("[ ]", "")) + frag = ( + f"- {st} `{r['uprn']}` · L {r['lodged']} / E {r['eng']:.2f} / " + f"Δ {r['delta']:+.2f}" + ) + if note: + frag += f" {note}" + lines.append(frag) + lines.append("") + + if skipped: + lines.append(f"## Skipped ({len(skipped)})") + for uprn, why in skipped: + st, note = prev.get(uprn, ("⛔", "")) + lines.append(f"- {st} `{uprn}` · {why} {note}".rstrip()) + lines.append("") + + resolved = [r for r in rows if abs(r["delta"]) < 0.5] + lines.append(f"## Resolved ≤0.5 ({len(resolved)} certs — no action)") + lines.append("") + lines.append("
expand") + lines.append("") + for r in sorted(resolved, key=lambda r: -abs(r["delta"])): + st, note = prev.get(r["uprn"], ("[x]", "")) + if st == "[ ]": + st = "[x]" + lines.append( + f"- {st} `{r['uprn']}` · L {r['lodged']} / E {r['eng']:.2f} / " + f"Δ {r['delta']:+.2f} · `{r['key']}` {note}".rstrip() + ) + lines.append("") + lines.append("
") + lines.append("") + + WORKLIST.write_text("\n".join(lines)) + print(f"wrote {WORKLIST}") + print(f"gauge: {n} computed / {len(skipped)} skipped, within-0.5 {within:.1%}, MAE {mae:.3f}") + print(f"clusters: {len(ranked)}, worst 5:") + for key, members in ranked[:5]: + tot = sum(abs(r["delta"]) for r in members) + print(f" Σ{tot:7.1f} n={len(members):3d} {key}") + + +if __name__ == "__main__": + main() diff --git a/scripts/corpus_1000/demand_cohort.py b/scripts/corpus_1000/demand_cohort.py new file mode 100644 index 000000000..cffa01e9e --- /dev/null +++ b/scripts/corpus_1000/demand_cohort.py @@ -0,0 +1,58 @@ +"""Demand-driven divergence analyzer. Flags corpus certs where the SAP miss is +driven by FABRIC/DEMAND (PE & CO2 move with SAP), not cost — the fixable class. +Tags nbp (building parts) and room-in-roof so multi-part / RR bugs surface.""" +from __future__ import annotations +import json +from pathlib import Path +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + SAP_10_2_SPEC_PRICES, cert_to_demand_inputs, cert_to_inputs) + +CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") + +def has_rr(doc): + for bp in doc.get("sap_building_parts") or []: + r = bp.get("room_in_roof") or bp.get("rooms_in_roof") + if r: return True + for r in doc.get("roofs") or []: + if "room in roof" in (r.get("description") or "").lower(): return True + return False + +rows = [] +for line in CORPUS.read_text().splitlines(): + doc = json.loads(line) + L = doc.get("energy_rating_current"); peL = doc.get("energy_consumption_current") + co2L = doc.get("co2_emissions_current") + if L is None: continue + try: + epc = EpcPropertyDataMapper.from_api_response(doc) + r = calculate_sap_from_inputs(cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)) + d = calculate_sap_from_inputs(cert_to_demand_inputs(epc, prices=SAP_10_2_SPEC_PRICES)) + except Exception: + continue + dsap = r.sap_score_continuous - L + if abs(dsap) < 0.5: continue + peE = getattr(d, "primary_energy_kwh_per_m2", None) + dpe = (peE - peL) if (peE is not None and peL is not None) else None + co2E = d.co2_kg_per_yr/1000 if d.co2_kg_per_yr is not None else None + dco2 = (co2E - co2L) if (co2E is not None and co2L is not None) else None + nbp = len(doc.get("sap_building_parts") or []) + rr = has_rr(doc) + # demand-driven: PE moves same direction as SAP-miss inverse (PE up -> SAP down) + # i.e. dpe and dsap have OPPOSITE signs, and |dpe| is material + demand = (dpe is not None and abs(dpe) >= 4 and (dpe > 0) == (dsap < 0)) + rows.append((doc.get("uprn"), L, round(r.sap_score_continuous,1), round(dsap,2), + round(dpe,1) if dpe is not None else None, + round(dco2,3) if dco2 is not None else None, nbp, rr, demand)) + +demand_rows = [r for r in rows if r[8]] +demand_rows.sort(key=lambda x:-abs(x[3])) +print(f"diverging certs: {len(rows)} | demand-driven: {len(demand_rows)}") +mp = [r for r in demand_rows if r[6] > 1] +rr = [r for r in demand_rows if r[7]] +print(f" demand+multipart(nbp>1): {len(mp)} | demand+RR: {len(rr)}") +print(f"\n=== top demand-driven divergences ===") +print(f"{'uprn':13s} {'L':>3} {'E':>5} {'dSAP':>6} {'dPE':>6} {'dCO2':>6} {'nbp':>3} {'RR':>3}") +for r in demand_rows[:32]: + print(f"{str(r[0]):13s} {r[1]:3d} {r[2]:5.1f} {r[3]:+6.2f} {str(r[4]):>6} {str(r[5]):>6} {r[6]:3d} {'Y' if r[7] else '':>3}") diff --git a/scripts/corpus_1000/elec_cohort.py b/scripts/corpus_1000/elec_cohort.py new file mode 100644 index 000000000..ad5fb44b1 --- /dev/null +++ b/scripts/corpus_1000/elec_cohort.py @@ -0,0 +1,61 @@ +"""Electric-main cohort analysis: for every corpus cert whose main heating is +electric, dump heating SAP code / tariff (meter_type) / lodged vs engine SAP / +cost-vs-demand signature. Used to steer a spec-grounded Table-12a tariff fix +without regressing in-band certs.""" +from __future__ import annotations +import json +from pathlib import Path +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + SAP_10_2_SPEC_PRICES, cert_to_demand_inputs, cert_to_inputs, _is_electric_main, + _first_main_heating, +) + +CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") + +def hcode(doc): + sh = doc.get("sap_heating") or {} + ds = sh.get("main_heating_details") or [{}] + return ds[0].get("main_heating_category"), ds[0].get("main_fuel_type") + +rows = [] +for line in CORPUS.read_text().splitlines(): + doc = json.loads(line) + try: + epc = EpcPropertyDataMapper.from_api_response(doc) + except Exception: + continue + main = _first_main_heating(epc) + if not _is_electric_main(main): + continue + try: + r = calculate_sap_from_inputs(cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)) + d = calculate_sap_from_inputs(cert_to_demand_inputs(epc, prices=SAP_10_2_SPEC_PRICES)) + except Exception as e: + continue + L = doc.get("energy_rating_current"); pe_l = doc.get("energy_consumption_current") + if L is None: continue + dsap = r.sap_score_continuous - L + dpe = (d.pe_per_m2 - pe_l) if (pe_l is not None and hasattr(d,'pe_per_m2')) else None + cat, fuel = hcode(doc) + mt = (doc.get("sap_energy_source") or {}).get("meter_type") + sh = doc.get("sap_heating") or {} + code = (sh.get("main_heating_details") or [{}])[0].get("main_heating_code") \ + or (sh.get("main_heating_details") or [{}])[0].get("main_heating_index_number") + rows.append((doc.get("uprn"), code, cat, mt, sh.get("water_heating_code"), + L, round(r.sap_score_continuous,1), round(dsap,2))) + +rows.sort(key=lambda x:-abs(x[7])) +print(f"electric-main certs: {len(rows)}") +print(f"{'uprn':14s} {'hcode':>6} {'cat':>4} {'mtr':>4} {'whc':>5} {'L':>3} {'E':>6} {'dSAP':>6}") +for r in rows: + print(f"{str(r[0]):14s} {str(r[1]):>6} {str(r[2]):>4} {str(r[3]):>4} {str(r[4]):>5} {r[5]:3d} {r[6]:6.1f} {r[7]:+6.2f}") +# summary by (hcode, meter) +from collections import defaultdict +agg = defaultdict(lambda:[0,0.0,0,0]) +for r in rows: + k=(r[1], r[3]); a=agg[k]; a[0]+=1; a[1]+=r[7]; a[2]+=(abs(r[7])>0.5); a[3]+=(abs(r[7])<=0.5) +print("\n=== by (hcode, meter): n, meanΔ, #div>0.5, #inband ===") +for k,a in sorted(agg.items(), key=lambda x:-abs(x[1][1])): + print(f" code {str(k[0]):>6} meter {str(k[1]):>4}: n={a[0]:3d} meanΔ={a[1]/a[0]:+6.2f} div={a[2]:3d} inband={a[3]:3d}") diff --git a/scripts/corpus_1000/field_bias.py b/scripts/corpus_1000/field_bias.py new file mode 100644 index 000000000..8331780f7 --- /dev/null +++ b/scripts/corpus_1000/field_bias.py @@ -0,0 +1,46 @@ +"""Field-level systematic-bias scan: mean Δ(SAP) grouped by each mapped +attribute. A subgroup with large |meanΔ| AND enough n is a systematic mapping +error for that attribute (would cancel in the overall ~0 bias).""" +from __future__ import annotations +import json, statistics +from collections import defaultdict +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs +CORPUS="backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl" +G=defaultdict(lambda: defaultdict(list)) +def gv(x): return x.get('value') if isinstance(x,dict) else x +for line in open(CORPUS): + d=json.loads(line); L=d.get('energy_rating_current') + if L is None: continue + try: + epc=EpcPropertyDataMapper.from_api_response(d) + r=calculate_sap_from_inputs(cert_to_inputs(epc,prices=SAP_10_2_SPEC_PRICES)) + except Exception: continue + dsap=r.sap_score_continuous-L + bp=(d.get('sap_building_parts') or [{}])[0] + sh=d.get('sap_heating') or {}; md=(sh.get('main_heating_details') or [{}])[0] + G['wall_con'][bp.get('wall_construction')].append(dsap) + G['wall_ins'][bp.get('wall_insulation_type')].append(dsap) + G['wall_con+ins'][(bp.get('wall_construction'),bp.get('wall_insulation_type'))].append(dsap) + G['roof_con'][bp.get('roof_construction')].append(dsap) + G['floor_hl'][bp.get('floor_heat_loss')].append(dsap) + G['age'][bp.get('construction_age_band')].append(dsap) + G['main_cat'][md.get('main_heating_category')].append(dsap) + G['whc'][sh.get('water_heating_code')].append(dsap) + G['meter'][(d.get('sap_energy_source') or {}).get('meter_type')].append(dsap) + G['country'][d.get('country_code')].append(dsap) + G['dwelling'][d.get('dwelling_type')].append(dsap) + G['nbp'][len(d.get('sap_building_parts') or [])].append(dsap) + G['glz'][tuple(sorted(set(w.get('glazing_type') for w in (d.get('sap_windows') or []))))[:1]].append(dsap) +print("=== subgroups with |meanΔ| >= 0.5 and n >= 8 (systematic-bias candidates) ===") +hits=[] +for field, groups in G.items(): + for key, vals in groups.items(): + if len(vals) >= 8: + m=statistics.mean(vals) + if abs(m) >= 0.5: + hits.append((abs(m), field, key, len(vals), m, statistics.median(vals))) +for am,field,key,n,m,med in sorted(hits, reverse=True): + print(f" {field:12s} = {str(key):20s} n={n:4d} meanΔ={m:+.2f} medianΔ={med:+.2f}") +if not hits: print(" (none — no systematic subgroup bias)") diff --git a/scripts/corpus_1000/mapper_audit.py b/scripts/corpus_1000/mapper_audit.py new file mode 100644 index 000000000..6d82c5f1d --- /dev/null +++ b/scripts/corpus_1000/mapper_audit.py @@ -0,0 +1,56 @@ +"""Mapper faithfulness audit: for identity pass-through fields (dims, counts, +thicknesses) compare the RAW cert value to what EpcPropertyDataMapper produced. +Any mismatch is a mapper bug silently feeding the SAP engine wrong inputs.""" +from __future__ import annotations +import json +from collections import defaultdict +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +CORPUS="backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl" +def gv(x): return x.get('value') if isinstance(x,dict) else x +def num(x): + x=gv(x) + if x is None: return None + s=str(x).lower().replace('mm','').replace('m2','').strip() + try: return float(s) + except: return s # keep strings like 'measured'/'NI' for equality +mism=defaultdict(list) +n=0 +for line in open(CORPUS): + d=json.loads(line) + try: epc=EpcPropertyDataMapper.from_api_response(d) + except Exception: continue + n+=1; u=d.get('uprn') + # top-level counts + checks=[('door_count', d.get('door_count'), epc.door_count), + ('habitable', d.get('habitable_room_count'), getattr(epc,'habitable_room_count',None)), + ('led', d.get('led_fixed_lighting_bulbs_count'), epc.led_fixed_lighting_bulbs_count), + ('cfl', d.get('cfl_fixed_lighting_bulbs_count'), epc.cfl_fixed_lighting_bulbs_count), + ('incand', d.get('incandescent_fixed_lighting_bulbs_count'), epc.incandescent_fixed_lighting_bulbs_count)] + for name,rv,ev in checks: + if rv is not None and ev is not None and num(rv)!=num(ev): + mism[name].append((u,rv,ev)) + # per-building-part fabric + rbps=d.get('sap_building_parts') or []; ebps=epc.sap_building_parts or [] + for i,rbp in enumerate(rbps): + if i>=len(ebps): + mism['DROPPED_building_part'].append((u,len(rbps),len(ebps))); break + ebp=ebps[i] + for name,rv,ev in [('wall_thick',rbp.get('wall_thickness'),getattr(ebp,'wall_thickness_mm',None)), + ('wall_ins_thick',rbp.get('wall_insulation_thickness'),getattr(ebp,'wall_insulation_thickness',None)), + ('roof_ins_thick',rbp.get('roof_insulation_thickness'),getattr(ebp,'roof_insulation_thickness',None)), + ('wall_con',rbp.get('wall_construction'),getattr(ebp,'wall_construction',None)), + ('wall_ins_type',rbp.get('wall_insulation_type'),getattr(ebp,'wall_insulation_type',None))]: + if rv is not None and ev is not None and num(rv)!=num(ev): + mism[name].append((u,rv,ev)) + # windows: count + total area + rw=d.get('sap_windows') or []; ew=epc.sap_windows or [] + if len(rw)!=len(ew): mism['window_count'].append((u,len(rw),len(ew))) + ra=sum((gv(w.get('window_width')) or 0)*(gv(w.get('window_height')) or 0) for w in rw) + ea=sum((float(w.window_width) if w.window_width else 0)*(float(w.window_height) if w.window_height else 0) for w in ew) + if abs(ra-ea)>0.02: mism['window_area'].append((u,round(ra,2),round(ea,2))) +print(f"audited {n} certs") +print("=== field mismatches (raw cert vs EpcPropertyData) ===") +for k,v in sorted(mism.items(), key=lambda x:-len(x[1])): + print(f"\n[{len(v):4d}] {k}") + for u,rv,ev in v[:5]: print(f" {u}: raw={rv} epc={ev}") +if not mism: print(" (no mismatches — mapper is identity-faithful on all checked fields)") diff --git a/scripts/corpus_1000/why_diverge.py b/scripts/corpus_1000/why_diverge.py new file mode 100644 index 000000000..cf2aef86b --- /dev/null +++ b/scripts/corpus_1000/why_diverge.py @@ -0,0 +1,76 @@ +"""Categorize every corpus cert outside +-0.5 SAP by the REASON it diverges, +combining the computed cost-vs-demand signature, the electric heating code / +meter, and the ledger's RR/multipart tokens.""" +from __future__ import annotations +import json, re +from pathlib import Path +from collections import defaultdict +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + SAP_10_2_SPEC_PRICES, cert_to_demand_inputs, cert_to_inputs, _is_electric_main, + _first_main_heating) + +CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") +# ledger signatures (for RR / multipart tokens) +sig_of={}; cur="" +for l in Path("scripts/corpus_1000/worklist.md").read_text().splitlines(): + if l.startswith('### C'): + m=re.search(r'`([^`]*)`',l); cur=m.group(1) if m else "" + m=re.match(r'^- .*?`([^`]+)`',l) + if m: sig_of[m.group(1)]=cur + +def bucket(doc, dsap, dpe, dco2, code, mt, sig): + rr = 'RR' in (sig or '') + elec = code in range(191,600) if code else False + demand = dpe is not None and abs(dpe) >= 4 and (dpe>0)==(dsap<0) + cost = dpe is not None and abs(dpe) < 4 + # 1. electric storage-boiler / CPSU on off-peak (spec §12 Rule 2/1) + if code in (192,193,194,195,196) and dsap>0: + return "elec storage/CPSU boiler → §12 off-peak (spec-faithful; lodged over-billed)" + # 2. electric direct + storage heaters, tariff/cost + if elec and cost: + return "electric tariff/cost (meter-column & high-rate fraction; spec-faithful vs lodged)" + if elec and demand: + return "electric + fabric/demand mismatch" + if elec: + return "electric other" + # 3. RR + if rr: + return "room-in-roof surface geometry (spec-mandated, worksheet-validated; high variance)" + # 4. non-elec demand/fabric + if demand: + return "non-electric fabric/demand (per-cert: glazing area, lodged geometry, mixed walls)" + if cost: + return "non-electric cost (secondary fuel / cylinder / tariff)" + return "unclassified" + +cnt=defaultdict(list) +band=defaultdict(int) +for line in CORPUS.read_text().splitlines(): + doc=json.loads(line); L=doc.get('energy_rating_current') + if L is None: continue + try: + epc=EpcPropertyDataMapper.from_api_response(doc) + r=calculate_sap_from_inputs(cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)) + d=calculate_sap_from_inputs(cert_to_demand_inputs(epc, prices=SAP_10_2_SPEC_PRICES)) + except Exception: continue + dsap=r.sap_score_continuous-L + if abs(dsap)<0.5: continue + peL=doc.get('energy_consumption_current'); co2L=doc.get('co2_emissions_current') + dpe=(d.primary_energy_kwh_per_m2-peL) if peL is not None else None + dco2=(d.co2_kg_per_yr/1000-co2L) if co2L is not None else None + sh=doc.get('sap_heating') or {}; det=(sh.get('main_heating_details') or [{}])[0] + code=det.get('sap_main_heating_code'); mt=(doc.get('sap_energy_source') or {}).get('meter_type') + b=bucket(doc,dsap,dpe,dco2,code,mt,sig_of.get(str(doc.get('uprn')),'')) + cnt[b].append((doc.get('uprn'),L,round(r.sap_score_continuous,1),round(dsap,1))) + ad=abs(dsap); band['0.5-1' if ad<1 else '1-2' if ad<2 else '2-4' if ad<4 else '4+']+=1 + +total=sum(len(v) for v in cnt.values()) +print(f"TOTAL diverging (|Δ|≥0.5): {total}\n") +print("by |Δ| band:", dict(band), "\n") +print("=== by REASON (count, mean|Δ|, examples) ===") +for b,rows in sorted(cnt.items(), key=lambda x:-len(x[1])): + mabs=sum(abs(x[3]) for x in rows)/len(rows) + ex=", ".join(f"{x[0]}({x[3]:+.1f})" for x in sorted(rows,key=lambda x:-abs(x[3]))[:3]) + print(f"\n[{len(rows):3d}] mean|Δ|={mabs:.1f} {b}\n e.g. {ex}") diff --git a/scripts/corpus_1000/worklist.md b/scripts/corpus_1000/worklist.md new file mode 100644 index 000000000..66fc7797b --- /dev/null +++ b/scripts/corpus_1000/worklist.md @@ -0,0 +1,1446 @@ +# RdSAP-21.0.1 corpus campaign ledger + +**Gauge:** 1000 computed / 0 skipped · SAP within-0.5 = **77.8%** · MAE = **0.636** (floors in test_sap_accuracy_corpus.py) + +Statuses: `[ ]` todo · `[x]` resolved ≤0.5 · 🔧 fix landed · ⚠ xfail engine bug · ⛔ unbuildable. Line: `uprn · lodged L / eng E / Δ · signature`. Rebuild/re-rank: `PYTHONPATH=. python scripts/corpus_1000/build_worklist.py` (preserves statuses & `<-` notes). + +## Clusters (certs with |Δ| ≥ 0.5, ranked by summed |Δ|) — 222 certs in 216 clusters + +### C001 · Σ|Δ| 23.9 · 1 certs · `hpcdb100053/elec | solid brick | (another dwelling | sec:room heaters` +- ⚠ `4510053280` · L 51 / E 74.94 / Δ +23.94 <- lodged software billed out-of-range-PSR (2.031 > 2.0) PCDB 100053 as 100% direct electric; engine follows SAP10.2 N2 extension (305%), Elmhurst-validated on case 56. Pinned engine 75 in test_real_cert_sap_accuracy. + +### C002 · Σ|Δ| 19.7 · 1 certs · `h301/coal-anth | cavity wall | pitched unknown | whw903` +- [ ] `103001004` · L 50 / E 69.69 / Δ +19.69 <- needs Elmhurst arbitration: lodged hw £1054 vs ours £113 (single-immersion on-peak fraction + cylinder declared-loss 1.8 kWh/day vs Table-2-sized loss); register data can't decompose. Other 14 declared-loss certs match lodged; only sibling outlier is 100121325434 (C008). + +### C003 · Σ|Δ| 13.2 · 1 certs · `h195/elec | system built | (another dwelling` +- ⚠ `10012334488` · L 72 / E 85.20 / Δ +13.20 <- SPEC-FAITHFUL: SAP code 195 = electric water-storage boiler (Table 4a p.170); §12 Rule 2 bills space+water at 7-hr off-peak (5.5p). Pure cost gap (PE/CO2 match). Lodged over-billed a storage system at peak rate. Pinned engine 85 in test_real_cert_sap_accuracy. + +### C004 · Σ|Δ| 13.1 · 1 certs · `hpcdb10326/gas | solid brick | pitched 75 | ext1,sec:room heaters` +- ⛔ `12109191` · L 58 / E 71.10 / Δ +13.10 <- LODGED DATA ERROR: BP0 floor-1 area 365.67 m² (10× its 36.77 ground floor; TFA 407 for a 4-habitable mid-terrace) — a decimal misplacement. Engine faithfully computes the near-wall-less huge floor → low HTC/m² → over-rate. Garbage-in, not an engine bug. + +### C005 · Σ|Δ| 10.3 · 4 certs · `h691/elec | cavity wall | (another dwelling | whw909` +- ⚠ `line279` · L 81 / E 78.44 / Δ -2.56 <- same as 10093237426 +- ⚠ `line301` · L 81 / E 78.44 / Δ -2.56 <- same as 10093237426 +- ⚠ `line318` · L 81 / E 78.44 / Δ -2.56 <- same as 10093237426 +- ⚠ `10093237426` · L 81 / E 78.44 / Δ -2.56 <- LIG-21.0 software (calc 10.2.2.0) honours lodged 0 extract fans literally; Elmhurst-validated convention (case 46) treats 0 as unknown -> Table 5 default. Fans-as-0 experiment closes 1.42 of 2.56; residual -1.14 unexplained, same-software quadruplet. + +### C006 · Σ|Δ| 10.1 · 1 certs · `h691/elec | granite or | pitched 175 | RR,ext1,whw903` +- [ ] `100040550095` · L 40 / E 29.87 / Δ -10.13 <- needs Elmhurst arbitration: PE+39/CO2+0.20 both over -> demand/fabric over-count (granite+solid-brick mixed walls + simplified RR type-2), not a cost bug + +### C007 · Σ|Δ| 8.7 · 1 certs · `h125/fuel28 | cavity wall | pitched 250 | consv2,sec:room heaters` +- [ ] `100121325434` · L 48 / E 56.75 / Δ +8.75 + +### C008 · Σ|Δ| 8.1 · 1 certs · `hpcdb17958/gas | system built | pitched 300 | PV,ext1,mv1` +- [ ] `100022294004` · L 75 / E 83.13 / Δ +8.13 + +### C009 · Σ|Δ| 7.8 · 1 certs · `h195/elec | cavity wall | (another dwelling` +- ⚠ `10091578598` · L 71 / E 78.81 / Δ +7.81 <- SPEC-FAITHFUL: code 195 electric water-storage boiler → §12 Rule 2 7-hr off-peak. Pure cost gap (PE/CO2 match). Same as 10012334488; lodged over-billed storage at peak. Pinned engine 79. + +### C010 · Σ|Δ| 7.1 · 1 certs · `h408/elec | cavity wall | (another dwelling | whw941` +- [ ] `200000322700` · L 58 / E 65.05 / Δ +7.05 <- electric storage cat7 E7 over-rate; see 74061136 + +### C011 · Σ|Δ| 6.7 · 1 certs · `h691/elec | solid brick | pitched 100 | RR,ext1,whw909` +- [ ] `100032223878` · L 11 / E 17.67 / Δ +6.67 + +### C012 · Σ|Δ| 6.5 · 1 certs · `h191/elec | cavity wall | (another dwelling` +- [ ] `10002272664` · L 79 / E 72.47 / Δ -6.53 + +### C013 · Σ|Δ| 6.5 · 1 certs · `h408/elec | granite or | (another dwelling | sec:portable electric,whw903` +- [ ] `100121397382` · L 69 / E 75.49 / Δ +6.49 + +### C014 · Σ|Δ| 6.5 · 1 certs · `h691/elec | solid brick | (another dwelling | ext1,sec:room heaters,whw903` +- [ ] `100062100640` · L 54 / E 47.51 / Δ -6.49 + +### C015 · Σ|Δ| 6.0 · 1 certs · `h301/coal-anth | cavity wall | pitched 225` +- [ ] `100050844968` · L 71 / E 65.02 / Δ -5.98 + +### C016 · Σ|Δ| 5.8 · 1 certs · `hpcdb17777/gas+2mains | cavity wall | pitched insulated | ext1,sec:room heaters,whw914` +- [ ] `line804` · L 52 / E 57.76 / Δ +5.76 + +### C017 · Σ|Δ| 5.8 · 1 certs · `h408/elec+2mains | sandstone as | (another dwelling | sec:room heaters,whw903` +- [ ] `80838` · L 41 / E 35.25 / Δ -5.75 + +### C018 · Σ|Δ| 4.6 · 1 certs · `hpcdb18560/gas | solid brick | pitched no | RR` +- [ ] `100051233481` · L 65 / E 69.64 / Δ +4.64 + +### C019 · Σ|Δ| 4.5 · 1 certs · `h691/elec | timber frame | pitched limited | RR,ext2,whw903` +- [ ] `100061646773` · L 68 / E 63.46 / Δ -4.54 + +### C020 · Σ|Δ| 4.5 · 1 certs · `hpcdb18786/fuel27 | solid brick | pitched 100 | RR,consv3` +- [ ] `100071010014` · L 55 / E 50.47 / Δ -4.53 + +### C021 · Σ|Δ| 4.5 · 1 certs · `h301/coal-anth | system built | (another dwelling | mv4` +- [ ] `10093342182` · L 82 / E 77.51 / Δ -4.49 + +### C022 · Σ|Δ| 4.4 · 1 certs · `h191/elec | solid brick | (another dwelling | ext2` +- [ ] `217060420` · L 42 / E 37.58 / Δ -4.42 + +### C023 · Σ|Δ| 4.4 · 1 certs · `h402/elec | cavity wall | (another dwelling | sec:room heaters,whw903` +- [ ] `72399971` · L 69 / E 64.60 / Δ -4.40 + +### C024 · Σ|Δ| 4.4 · 1 certs · `hpcdb19258/fuel28 | granite or | pitched no | ext2,sec:room heaters` +- [ ] `10007016314` · L 57 / E 52.64 / Δ -4.36 + +### C025 · Σ|Δ| 4.1 · 1 certs · `h633/heatnet | sandstone with | pitched insulated | sec:room heaters,whw903` +- [ ] `100052159386` · L 56 / E 51.92 / Δ -4.08 + +### C026 · Σ|Δ| 4.0 · 1 certs · `h408/elec | cavity wall | pitched 100 | ext1,sec:room heaters,whw903` +- [ ] `100040767294` · L 43 / E 47.04 / Δ +4.04 + +### C027 · Σ|Δ| 3.6 · 1 certs · `hpcdb15445/gas | granite or | pitched no | RR,ext3,sec:room heaters` +- [ ] `10002188054` · L 68 / E 64.42 / Δ -3.58 + +### C028 · Σ|Δ| 3.5 · 1 certs · `hpcdb18234/gas | cavity wall | pitched insulated | PV,ext2,sec:room heaters` +- [ ] `39066537` · L 78 / E 81.49 / Δ +3.49 + +### C029 · Σ|Δ| 3.5 · 1 certs · `hpcdb8387/gas | cavity wall | pitched 150` +- [ ] `100100137438` · L 69 / E 72.48 / Δ +3.48 + +### C030 · Σ|Δ| 3.4 · 1 certs · `hpcdb17507/gas | cavity wall | pitched insulated | RR,sec:room heaters` +- [ ] `100080122910` · L 60 / E 63.45 / Δ +3.45 + +### C031 · Σ|Δ| 3.3 · 1 certs · `h691/elec | cavity wall | pitched 300 | mv5,sec:portable electric,whw903` +- [ ] `452010022` · L 42 / E 38.67 / Δ -3.33 + +### C032 · Σ|Δ| 3.3 · 1 certs · `h402/elec | cavity wall | pitched 150 | ext1,sec:room heaters,whw903` +- [ ] `200004818752` · L 34 / E 30.73 / Δ -3.27 + +### C033 · Σ|Δ| 3.2 · 1 certs · `hpcdb1770/fuel28 | solid brick | pitched no | ext3,sec:room heaters` +- [ ] `200004522639` · L 37 / E 33.77 / Δ -3.23 + +### C034 · Σ|Δ| 3.2 · 1 certs · `hpcdb18759/gas | sandstone as | pitched insulated | RR,ext1,sec:room heaters` +- [ ] `72351021` · L 64 / E 60.85 / Δ -3.15 + +### C035 · Σ|Δ| 3.1 · 1 certs · `h633/heatnet | timber frame | roof room(s) | RR,ext1,sec:room heaters,whw909` +- [ ] `22077224` · L 37 / E 33.90 / Δ -3.10 + +### C036 · Σ|Δ| 3.0 · 1 certs · `hpcdb18498/gas | solid brick | pitched 250 | RR,ext2` +- [ ] `10011740578` · L 70 / E 67.00 / Δ -3.00 + +### C037 · Σ|Δ| 2.9 · 1 certs · `h402/elec | cavity wall | pitched 250 | sec:portable electric,whw903` +- [ ] `100110680822` · L 79 / E 76.07 / Δ -2.93 + +### C038 · Σ|Δ| 2.9 · 1 certs · `h301/coal-anth | solid brick | pitched 250` +- [ ] `452032915` · L 75 / E 72.09 / Δ -2.91 + +### C039 · Σ|Δ| 2.9 · 1 certs · `h301/coal-anth | cavity wall | (another dwelling | whw909` +- [ ] `100071306639` · L 73 / E 70.14 / Δ -2.86 + +### C040 · Σ|Δ| 2.9 · 1 certs · `hpcdb18435/gas+2mains | cavity wall | pitched 100 | ext3,sec:room heaters,whw903` +- [ ] `100061346049` · L 55 / E 52.15 / Δ -2.85 + +### C041 · Σ|Δ| 2.7 · 1 certs · `hpcdb8443/fuel28 | cavity wall | pitched 100 | RR,ext2,sec:room heaters` +- [ ] `10013008238` · L 41 / E 38.26 / Δ -2.74 + +### C042 · Σ|Δ| 2.7 · 3 certs · `h691/elec | cavity wall | (another dwelling | whw903` +- [ ] `10033187652` · L 70 / E 71.21 / Δ +1.21 <- non-electric cost (secondary fuel/cylinder/tariff); spec-faithful vs lodged +- [ ] `200002730` · L 65 / E 64.13 / Δ -0.87 +- [ ] `10023328117` · L 78 / E 78.64 / Δ +0.64 + +### C043 · Σ|Δ| 2.7 · 1 certs · `h633/heatnet | cavity wall | pitched 200 | RR,sec:room heaters,whw903` +- [ ] `10004873253` · L 65 / E 62.29 / Δ -2.71 + +### C044 · Σ|Δ| 2.7 · 1 certs · `h691/elec | cavity wall | (another dwelling | mv1,whw903` +- [ ] `10090793715` · L 73 / E 75.71 / Δ +2.71 + +### C045 · Σ|Δ| 2.7 · 1 certs · `hpcdb17964/gas+2mains | cavity wall | pitched 100 | ext1,sec:room heaters` +- [ ] `100120745600` · L 65 / E 67.69 / Δ +2.69 + +### C046 · Σ|Δ| 2.7 · 1 certs · `hpcdb17757/gas | granite or | pitched no | RR,ext1` +- [ ] `100051178830` · L 63 / E 65.66 / Δ +2.66 + +### C047 · Σ|Δ| 2.6 · 1 certs · `hpcdb10321/gas | sandstone as | pitched 100 | ext2` +- [ ] `100120018282` · L 64 / E 61.36 / Δ -2.64 + +### C048 · Σ|Δ| 2.6 · 1 certs · `hpcdb17968/gas | cavity wall | pitched 300 | ext2,sec:room heaters` +- [ ] `83035979` · L 65 / E 67.60 / Δ +2.60 + +### C049 · Σ|Δ| 2.5 · 1 certs · `h524/elec | solid brick | (another dwelling | consv2,sec:room heaters,whw909` +- [ ] `10014073761` · L 75 / E 72.52 / Δ -2.48 + +### C050 · Σ|Δ| 2.5 · 1 certs · `hpcdb10328/gas | solid brick | pitched insulated | RR` +- [ ] `100050964834` · L 60 / E 62.47 / Δ +2.47 + +### C051 · Σ|Δ| 2.4 · 1 certs · `h694/elec | cavity wall | (another dwelling | whw941` +- [ ] `100120752995` · L 71 / E 73.42 / Δ +2.42 + +### C052 · Σ|Δ| 2.4 · 1 certs · `h691/elec | solid brick | roof room(s) | RR,ext2,sec:room heaters,whw903` +- [ ] `100062174575` · L 46 / E 48.40 / Δ +2.40 + +### C053 · Σ|Δ| 2.3 · 1 certs · `hpcdb15753/gas | solid brick | pitched 150 | RR` +- [ ] `100000034023` · L 73 / E 70.71 / Δ -2.29 + +### C054 · Σ|Δ| 2.2 · 1 certs · `h506/gas | cavity wall | flat limited | sec:room heaters` +- [ ] `100020377671` · L 62 / E 64.23 / Δ +2.23 + +### C055 · Σ|Δ| 2.2 · 1 certs · `h104/gas | solid brick | pitched insulated | RR,ext1` +- [ ] `22027335` · L 76 / E 73.77 / Δ -2.23 + +### C056 · Σ|Δ| 2.1 · 1 certs · `hpcdb18833/gas | solid brick | pitched insulated | RR,ext1,sec:room heaters` +- [ ] `48056153` · L 65 / E 62.89 / Δ -2.11 + +### C057 · Σ|Δ| 2.0 · 1 certs · `hpcdb19008/gas | solid brick | pitched insulated | sec:room heaters` +- [ ] `100021924710` · L 63 / E 65.00 / Δ +2.00 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C058 · Σ|Δ| 1.9 · 1 certs · `hpcdb18774/gas | solid brick | pitched 100 | RR,ext1` +- [ ] `100031270375` · L 69 / E 70.93 / Δ +1.93 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C059 · Σ|Δ| 1.9 · 1 certs · `hpcdb106417/elec | cavity wall | pitched 250 | RR,ext2,sec:room heaters` +- [ ] `10001118522` · L 80 / E 78.07 / Δ -1.93 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C060 · Σ|Δ| 1.9 · 1 certs · `h192/elec | cavity wall | (another dwelling` +- [ ] `10010234711` · L 81 / E 79.08 / Δ -1.92 <- electric other (per-cert cost noise) + +### C061 · Σ|Δ| 1.9 · 1 certs · `hpcdb18250/gas | solid brick | pitched 100 | consv2,sec:room heaters` +- [ ] `100031768368` · L 61 / E 59.12 / Δ -1.88 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C062 · Σ|Δ| 1.8 · 1 certs · `h301/elec10hr | cavity wall | (another dwelling` +- [ ] `4510729926` · L 73 / E 71.15 / Δ -1.85 <- electric + fabric/demand mismatch (per-cert; not a coherent cluster) + +### C063 · Σ|Δ| 1.8 · 1 certs · `hpcdb17138/fuel28 | granite or | pitched 200 | ext1,sec:room heaters` +- [ ] `100091329112` · L 49 / E 50.82 / Δ +1.82 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C064 · Σ|Δ| 1.8 · 1 certs · `h691/elec | timber frame | pitched insulated | RR,ext1,sec:portable electric,whw908` +- [ ] `10011797347` · L 47 / E 48.77 / Δ +1.77 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C065 · Σ|Δ| 1.8 · 1 certs · `hpcdb17570/gas+2mains | solid brick | (another dwelling | RR,ext3,sec:room heaters` +- [ ] `100022668387` · L 68 / E 66.25 / Δ -1.75 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C066 · Σ|Δ| 1.7 · 1 certs · `hpcdb10354/gas+2mains | sandstone as | pitched 300 | ext1` +- [ ] `100100892216` · L 69 / E 67.28 / Δ -1.72 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C067 · Σ|Δ| 1.7 · 1 certs · `hpcdb8119/gas | cavity wall | pitched insulated | RR,ext1` +- [ ] `100060878876` · L 49 / E 50.71 / Δ +1.71 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C068 · Σ|Δ| 1.7 · 1 certs · `h402/elec | solid brick | (another dwelling | sec:portable electric,whw903` +- [ ] `12126160` · L 69 / E 70.71 / Δ +1.71 <- electric + fabric/demand mismatch (per-cert; not a coherent cluster) + +### C069 · Σ|Δ| 1.7 · 1 certs · `h401/elec | solid brick | (another dwelling | sec:portable electric,whw950` +- [ ] `22032926` · L 61 / E 62.71 / Δ +1.71 <- electric + fabric/demand mismatch (per-cert; not a coherent cluster) + +### C070 · Σ|Δ| 1.7 · 1 certs · `hpcdb18560/gas | cavity wall | pitched insulated | RR,ext1` +- [ ] `100010942905` · L 72 / E 73.70 / Δ +1.70 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C071 · Σ|Δ| 1.6 · 1 certs · `hpcdb18119/gas | solid brick | pitched 100 | ext1` +- [ ] `100030307976` · L 71 / E 69.37 / Δ -1.63 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C072 · Σ|Δ| 1.6 · 1 certs · `hpcdb18909/gas | cavity wall | pitched 100 | whw903` +- [ ] `100061404488` · L 70 / E 68.38 / Δ -1.62 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C073 · Σ|Δ| 1.6 · 1 certs · `h402/elec | system built | (another dwelling | sec:room heaters,whw903` +- [ ] `21067296` · L 67 / E 68.60 / Δ +1.60 <- electric + fabric/demand mismatch (per-cert; not a coherent cluster) + +### C074 · Σ|Δ| 1.6 · 1 certs · `hpcdb10164/gas | solid brick | pitched insulated | sec:room heaters` +- [ ] `5870047440` · L 58 / E 59.58 / Δ +1.58 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C075 · Σ|Δ| 1.6 · 1 certs · `hpcdb8051/gas | cavity wall | (same dwelling | ext2` +- [ ] `100070450818` · L 65 / E 63.43 / Δ -1.57 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C076 · Σ|Δ| 1.6 · 1 certs · `hpcdb17741/gas | cavity wall | pitched 225` +- [ ] `100050881708` · L 70 / E 71.55 / Δ +1.55 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C077 · Σ|Δ| 1.5 · 1 certs · `h301/elec | system built | (another dwelling | whw950` +- [ ] `10010254955` · L 79 / E 77.46 / Δ -1.54 <- electric other (per-cert cost noise) + +### C078 · Σ|Δ| 1.5 · 1 certs · `hpcdb16839/gas | cavity wall | pitched 400+ | ext1,mv2,sec:room heaters` +- [ ] `3455011834` · L 69 / E 67.48 / Δ -1.52 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C079 · Σ|Δ| 1.5 · 1 certs · `hpcdb15502/gas | granite or | pitched 150 | RR,ext1,sec:room heaters` +- [ ] `100100274789` · L 60 / E 61.51 / Δ +1.51 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C080 · Σ|Δ| 1.5 · 1 certs · `hpcdb16453/gas | cavity wall | pitched 300 | consv2,ext1` +- [ ] `100050852755` · L 65 / E 66.49 / Δ +1.49 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C081 · Σ|Δ| 1.5 · 1 certs · `hpcdb17679/gas | solid brick | pitched 125 | ext1` +- [ ] `100021326422` · L 70 / E 68.51 / Δ -1.49 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C082 · Σ|Δ| 1.5 · 1 certs · `hpcdb15029/gas | solid brick | pitched insulated | RR,ext1` +- [ ] `100050988345` · L 61 / E 59.51 / Δ -1.49 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C083 · Σ|Δ| 1.5 · 1 certs · `hpcdb17998/gas | solid brick | pitched insulated | RR,consv2` +- [ ] `100030285758` · L 54 / E 55.48 / Δ +1.48 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C084 · Σ|Δ| 1.4 · 1 certs · `hpcdb17978/gas | solid brick | flat no | RR,ext1` +- [ ] `line80` · L 72 / E 70.56 / Δ -1.44 + +### C085 · Σ|Δ| 1.4 · 1 certs · `hpcdb10356/gas | solid brick | pitched 150 | RR,ext1,sec:room heaters` +- [ ] `100080518673` · L 57 / E 58.42 / Δ +1.42 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C086 · Σ|Δ| 1.4 · 1 certs · `hpcdb18561/gas | solid brick | pitched 250 | ext2` +- [ ] `235025284` · L 69 / E 67.59 / Δ -1.41 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C087 · Σ|Δ| 1.4 · 1 certs · `hpcdb18767/gas | sandstone as | (another dwelling` +- [ ] `250063088` · L 73 / E 71.60 / Δ -1.40 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C088 · Σ|Δ| 1.4 · 1 certs · `hpcdb16210/gas | cavity wall | pitched 300 | sec:room heaters` +- [ ] `100110097281` · L 64 / E 65.37 / Δ +1.37 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C089 · Σ|Δ| 1.4 · 1 certs · `h691/elec | solid brick | (another dwelling | whw903` +- [ ] `10091630692` · L 58 / E 56.63 / Δ -1.37 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C090 · Σ|Δ| 1.3 · 1 certs · `hpcdb19227/fuel28 | solid brick | pitched 100 | RR,ext3,sec:room heaters` +- [ ] `100012368622` · L 39 / E 40.32 / Δ +1.32 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C091 · Σ|Δ| 1.3 · 1 certs · `hpcdb19007/gas | solid brick | pitched insulated | RR,ext2,sec:room heaters` +- [ ] `90077194` · L 65 / E 63.69 / Δ -1.31 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C092 · Σ|Δ| 1.3 · 1 certs · `hpcdb19083/gas | cavity wall | pitched 75 | RR,consv2` +- [ ] `100031461986` · L 70 / E 71.29 / Δ +1.29 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C093 · Σ|Δ| 1.3 · 1 certs · `h691/elec | granite or | pitched insulated | PV,ext3,whw903` +- [ ] `30095054` · L 46 / E 44.74 / Δ -1.26 <- non-electric cost (secondary fuel/cylinder/tariff); spec-faithful vs lodged + +### C094 · Σ|Δ| 1.3 · 1 certs · `hpcdb18335/gas | cavity wall | pitched 100 | RR,ext1,sec:room heaters` +- [ ] `100100055589` · L 58 / E 59.26 / Δ +1.26 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C095 · Σ|Δ| 1.2 · 1 certs · `h409/elec | solid brick | (another dwelling | consv2,sec:portable electric,whw909` +- [ ] `74061136` · L 73 / E 71.75 / Δ -1.25 <- electric storage cat7 E7: over-rate = off-peak rate/fraction too generous; needs Elmhurst tariff arbitration + +### C096 · Σ|Δ| 1.2 · 1 certs · `hpcdb15101/gas | cavity wall | pitched 270 | consv3,sec:room heaters` +- [ ] `100091435353` · L 65 / E 66.23 / Δ +1.23 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C097 · Σ|Δ| 1.2 · 2 certs · `h301/coal-anth | system built | (another dwelling` +- [ ] `10091070164` · L 69 / E 69.60 / Δ +0.60 +- [ ] `10090927185` · L 79 / E 79.60 / Δ +0.60 + +### C098 · Σ|Δ| 1.2 · 1 certs · `hpcdb19110/gas | cavity wall | pitched no | ext3` +- [ ] `100100614412` · L 53 / E 51.81 / Δ -1.19 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C099 · Σ|Δ| 1.2 · 1 certs · `hpcdb18658/gas | solid brick | flat no` +- [ ] `217091901` · L 62 / E 60.82 / Δ -1.18 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C100 · Σ|Δ| 1.2 · 1 certs · `hpcdb17115/gas | sandstone as | (another dwelling | ext2,sec:room heaters` +- [x] `line768` · L 57 / E 55.84 / Δ -1.16 + +### C101 · Σ|Δ| 1.2 · 1 certs · `h102/gas | solid brick | (same dwelling | ext1,sec:portable electric` +- [ ] `217061895` · L 61 / E 59.84 / Δ -1.16 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C102 · Σ|Δ| 1.1 · 1 certs · `hpcdb15708/gas | cavity wall | pitched insulated | RR,sec:room heaters` +- [ ] `10002349227` · L 76 / E 77.14 / Δ +1.14 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C103 · Σ|Δ| 1.1 · 1 certs · `hpcdb15451/gas | timber frame | pitched insulated | RR,ext1,sec:room heaters` +- [ ] `200001634005` · L 66 / E 67.11 / Δ +1.11 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C104 · Σ|Δ| 1.1 · 1 certs · `hpcdb16407/gas | cavity wall | pitched 250 | ext1,sec:room heaters` +- [ ] `100071059081` · L 63 / E 64.09 / Δ +1.09 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C105 · Σ|Δ| 1.1 · 1 certs · `hpcdb17973/gas | solid brick | (another dwelling` +- [ ] `5117280` · L 76 / E 77.08 / Δ +1.08 <- 🔧 thin-internal-insulation bucket-0 fix: present insulation + known <25mm thickness was mapped to uninsulated (U 1.7); now routes to 50mm row like unknown-thickness. Δ-4.44 -> +1.08. + +### C106 · Σ|Δ| 1.1 · 1 certs · `hpcdb19007/gas+2mains | cavity wall | pitched 350 | ext2,mv5,sec:room heaters,whw914` +- [ ] `100011820199` · L 55 / E 56.06 / Δ +1.06 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C107 · Σ|Δ| 1.1 · 1 certs · `h691/elec | system built | (another dwelling | whw903` +- [ ] `90214753` · L 83 / E 81.95 / Δ -1.05 <- non-electric cost (secondary fuel/cylinder/tariff); spec-faithful vs lodged + +### C108 · Σ|Δ| 1.0 · 1 certs · `h102/gas | cavity wall | pitched insulated | consv2` +- [ ] `100051051866` · L 70 / E 71.04 / Δ +1.04 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C109 · Σ|Δ| 1.0 · 1 certs · `hpcdb18737/gas | cavity wall | pitched insulated | PV,sec:room heaters` +- [ ] `10013836852` · L 100 / E 98.97 / Δ -1.03 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C110 · Σ|Δ| 1.0 · 1 certs · `hpcdb17985/gas | solid brick | (another dwelling` +- [ ] `line899` · L 73 / E 71.97 / Δ -1.03 + +### C111 · Σ|Δ| 1.0 · 1 certs · `hpcdb18524/gas+2mains | cavity wall | pitched 400+ | ext4` +- [ ] `40037847` · L 81 / E 79.98 / Δ -1.02 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C112 · Σ|Δ| 1.0 · 1 certs · `hpcdb19012/gas | cavity wall | pitched insulated` +- [ ] `10070056075` · L 80 / E 78.99 / Δ -1.01 <- non-electric fabric/demand: per-cert lodged-geometry noise (field_bias scan clean, no coherent subgroup) + +### C113 · Σ|Δ| 1.0 · 1 certs · `hpcdb16843/gas+2mains | cavity wall | pitched insulated | PV,RR,ext2` +- [ ] `200131078` · L 86 / E 85.00 / Δ -1.00 <- room-in-roof surface geometry (spec-mandated, worksheet-validated; see PRs #1440/#1441) + +### C114 · Σ|Δ| 1.0 · 1 certs · `hpcdb17489/gas | cavity wall | pitched 270 | PV,consv2,ext1,sec:room heaters` +- [ ] `10000245212` · L 101 / E 100.00 / Δ -1.00 <- non-electric cost (secondary fuel/cylinder/tariff); spec-faithful vs lodged + +### C115 · Σ|Δ| 1.0 · 1 certs · `h191/elec | solid brick | (another dwelling | ext2,whw909` +- [ ] `15129162` · L 62 / E 61.00 / Δ -1.00 + +### C116 · Σ|Δ| 1.0 · 1 certs · `h301/coal-anth | cavity wall | pitched 270` +- [ ] `23032873` · L 73 / E 72.00 / Δ -1.00 + +### C117 · Σ|Δ| 1.0 · 1 certs · `hpcdb16930/gas | cavity wall | pitched unknown | RR,ext1,sec:room heaters` +- [ ] `45019020` · L 62 / E 61.01 / Δ -0.99 + +### C118 · Σ|Δ| 1.0 · 1 certs · `hpcdb17939/gas | solid brick | (another dwelling` +- [ ] `42065028` · L 59 / E 58.02 / Δ -0.98 + +### C119 · Σ|Δ| 1.0 · 1 certs · `hpcdb10071/gas | cavity wall | pitched 150 | RR` +- [ ] `10032928147` · L 77 / E 76.04 / Δ -0.96 + +### C120 · Σ|Δ| 1.0 · 1 certs · `hpcdb17045/gas | cavity wall | pitched 300` +- [ ] `10010678937` · L 78 / E 77.04 / Δ -0.96 + +### C121 · Σ|Δ| 1.0 · 1 certs · `hpcdb15247/gas | cavity wall | pitched insulated | RR` +- [ ] `100011010151` · L 62 / E 62.96 / Δ +0.96 + +### C122 · Σ|Δ| 1.0 · 1 certs · `hpcdb16406/gas | solid brick | pitched insulated | RR,consv2,ext2,sec:room heaters` +- [ ] `100050477371` · L 65 / E 64.04 / Δ -0.96 + +### C123 · Σ|Δ| 0.9 · 1 certs · `hpcdb17529/fuel28 | cavity wall | pitched insulated | RR,ext2,sec:room heaters` +- [ ] `10002945522` · L 61 / E 61.94 / Δ +0.94 + +### C124 · Σ|Δ| 0.9 · 1 certs · `h691/elec+2mains | cob as | pitched 300 | whw903` +- [ ] `10032957680` · L 45 / E 45.94 / Δ +0.94 + +### C125 · Σ|Δ| 0.9 · 1 certs · `hpcdb18204/gas | solid brick | pitched 400+ | PV,ext2` +- [ ] `2465057465` · L 81 / E 81.93 / Δ +0.93 + +### C126 · Σ|Δ| 0.9 · 1 certs · `hpcdb19019/gas | solid brick | pitched 100 | ext3,sec:room heaters` +- [ ] `100090616327` · L 63 / E 63.93 / Δ +0.93 + +### C127 · Σ|Δ| 0.9 · 1 certs · `hpcdb17612/gas | cavity wall | pitched 270 | ext1` +- [ ] `100012753833` · L 74 / E 73.07 / Δ -0.93 + +### C128 · Σ|Δ| 0.9 · 1 certs · `hpcdb19006/gas | solid brick | pitched 75 | ext1,sec:room heaters` +- [ ] `100031269525` · L 53 / E 53.91 / Δ +0.91 + +### C129 · Σ|Δ| 0.9 · 1 certs · `hpcdb17964/gas | cavity wall | pitched insulated | consv3` +- [ ] `200003725358` · L 75 / E 74.09 / Δ -0.91 + +### C130 · Σ|Δ| 0.9 · 1 certs · `hpcdb17507/gas | solid brick | pitched insulated | RR,ext3` +- [ ] `100050986162` · L 62 / E 61.10 / Δ -0.90 + +### C131 · Σ|Δ| 0.9 · 1 certs · `hpcdb17985/gas | cavity wall | (another dwelling | mv2` +- [ ] `5300000141` · L 78 / E 77.10 / Δ -0.90 + +### C132 · Σ|Δ| 0.9 · 1 certs · `hpcdb18616/gas | cavity wall | pitched 250 | sec:room heaters` +- [ ] `100050618404` · L 73 / E 73.89 / Δ +0.89 + +### C133 · Σ|Δ| 0.9 · 1 certs · `hpcdb17507/gas | cavity wall | pitched 250 | PV,ext1` +- [ ] `100090476016` · L 81 / E 81.89 / Δ +0.89 + +### C134 · Σ|Δ| 0.9 · 1 certs · `h691/elec | timber frame | pitched 200 | RR,ext3,whw903` +- [ ] `10012119141` · L 45 / E 45.89 / Δ +0.89 + +### C135 · Σ|Δ| 0.9 · 1 certs · `h524/elec | cavity wall | pitched 300 | PV` +- [ ] `100040938206` · L 75 / E 75.89 / Δ +0.89 + +### C136 · Σ|Δ| 0.9 · 1 certs · `hpcdb18234/gas | cavity wall | pitched insulated | RR,consv2` +- [ ] `38188474` · L 76 / E 76.87 / Δ +0.87 + +### C137 · Σ|Δ| 0.9 · 1 certs · `hpcdb16777/gas | cavity wall | pitched 100 | ext1,sec:room heaters` +- [ ] `100061194957` · L 69 / E 69.87 / Δ +0.87 + +### C138 · Σ|Δ| 0.9 · 1 certs · `hpcdb18559/gas+2mains | solid brick | pitched 200 | whw914` +- [ ] `100021236527` · L 72 / E 71.13 / Δ -0.87 + +### C139 · Σ|Δ| 0.9 · 1 certs · `hpcdb18907/gas | cavity wall | pitched 270 | consv2` +- [ ] `100040193752` · L 71 / E 71.86 / Δ +0.86 + +### C140 · Σ|Δ| 0.9 · 1 certs · `hpcdb10327/gas | cavity wall | (another dwelling` +- [ ] `100060701552` · L 74 / E 74.86 / Δ +0.86 + +### C141 · Σ|Δ| 0.9 · 1 certs · `h691/elec | system built | flat insulated | whw903` +- [ ] `10070396146` · L 61 / E 60.14 / Δ -0.86 + +### C142 · Σ|Δ| 0.8 · 1 certs · `hpcdb8587/gas | cavity wall | (another dwelling` +- [ ] `100030528930` · L 74 / E 73.16 / Δ -0.84 + +### C143 · Σ|Δ| 0.8 · 1 certs · `hpcdb18212/gas | solid brick | (another dwelling` +- [ ] `100020982570` · L 72 / E 71.17 / Δ -0.83 + +### C144 · Σ|Δ| 0.8 · 1 certs · `hpcdb16888/gas | cavity wall | pitched 350 | consv2` +- [ ] `394024487` · L 75 / E 74.17 / Δ -0.83 + +### C145 · Σ|Δ| 0.8 · 1 certs · `hpcdb17503/gas | solid brick | pitched insulated | RR,ext1,mv4` +- [ ] `217070698` · L 78 / E 78.83 / Δ +0.83 + +### C146 · Σ|Δ| 0.8 · 1 certs · `hpcdb15709/gas | cavity wall | (another dwelling | ext1` +- [ ] `77022508` · L 69 / E 68.18 / Δ -0.82 + +### C147 · Σ|Δ| 0.8 · 1 certs · `hpcdb18496/gas | cavity wall | pitched 250 | RR,consv4` +- [ ] `100040259104` · L 68 / E 68.82 / Δ +0.82 + +### C148 · Σ|Δ| 0.8 · 1 certs · `hpcdb18559/gas | solid brick | pitched 270 | ext1` +- [ ] `100031399026` · L 70 / E 70.81 / Δ +0.81 + +### C149 · Σ|Δ| 0.8 · 1 certs · `hpcdb16374/gas | cavity wall | pitched unknown` +- [ ] `10009645698` · L 67 / E 67.81 / Δ +0.81 + +### C150 · Σ|Δ| 0.8 · 1 certs · `hpcdb16395/gas | timber frame | thatched with | ext1,sec:room heaters` +- [ ] `100080818887` · L 56 / E 55.19 / Δ -0.81 + +### C151 · Σ|Δ| 0.8 · 1 certs · `hpcdb16835/gas | solid brick | pitched insulated | RR,ext1` +- [ ] `100012374381` · L 69 / E 69.80 / Δ +0.80 + +### C152 · Σ|Δ| 0.8 · 1 certs · `hpcdb17560/gas | cavity wall | pitched 250 | ext2` +- [ ] `100012350270` · L 78 / E 77.20 / Δ -0.80 + +### C153 · Σ|Δ| 0.8 · 1 certs · `hpcdb15166/gas+2mains | sandstone as | pitched no | PV,ext4,sec:room heaters,whw914` +- [ ] `100051109723` · L 60 / E 60.78 / Δ +0.78 + +### C154 · Σ|Δ| 0.8 · 1 certs · `hpcdb18911/gas | cavity wall | pitched 50 | consv4` +- [ ] `100061616915` · L 63 / E 62.22 / Δ -0.78 + +### C155 · Σ|Δ| 0.8 · 1 certs · `h691/elec | cavity wall | pitched 200 | whw903` +- [ ] `line71` · L 74 / E 74.77 / Δ +0.77 + +### C156 · Σ|Δ| 0.8 · 1 certs · `hpcdb17986/gas | cavity wall | (another dwelling` +- [ ] `100011603934` · L 72 / E 71.23 / Δ -0.77 + +### C157 · Σ|Δ| 0.8 · 1 certs · `hpcdb10244/gas | cavity wall | pitched 100` +- [ ] `24030987` · L 73 / E 73.76 / Δ +0.76 + +### C158 · Σ|Δ| 0.8 · 1 certs · `h193/elec | solid brick | (another dwelling | whw909` +- [ ] `100032043038` · L 74 / E 73.24 / Δ -0.76 + +### C159 · Σ|Δ| 0.8 · 1 certs · `hpcdb17513/gas | cavity wall | pitched unknown` +- [ ] `100010866608` · L 71 / E 70.24 / Δ -0.76 + +### C160 · Σ|Δ| 0.7 · 1 certs · `hpcdb17965/gas | cavity wall | pitched insulated | RR,ext1,sec:room heaters` +- [ ] `100090399615` · L 67 / E 67.75 / Δ +0.75 + +### C161 · Σ|Δ| 0.7 · 1 certs · `hpcdb16137/gas | solid brick | pitched 150` +- [ ] `200004296092` · L 71 / E 71.74 / Δ +0.74 + +### C162 · Σ|Δ| 0.7 · 1 certs · `hpcdb17576/gas | cavity wall | pitched insulated | RR,consv2` +- [ ] `10012341135` · L 79 / E 79.73 / Δ +0.73 + +### C163 · Σ|Δ| 0.7 · 1 certs · `h409/elec | cavity wall | (another dwelling | sec:portable electric,whw903` +- [ ] `100091005853` · L 70 / E 69.27 / Δ -0.73 + +### C164 · Σ|Δ| 0.7 · 1 certs · `hpcdb18908/gas | cavity wall | pitched 175` +- [ ] `100021533514` · L 71 / E 71.73 / Δ +0.73 + +### C165 · Σ|Δ| 0.7 · 1 certs · `h301/coal-anth | solid brick | (another dwelling` +- [ ] `5062075` · L 69 / E 69.72 / Δ +0.72 + +### C166 · Σ|Δ| 0.7 · 1 certs · `hpcdb18112/gas | cavity wall | pitched 100 | RR,ext1` +- [ ] `100040813308` · L 62 / E 62.72 / Δ +0.72 + +### C167 · Σ|Δ| 0.7 · 1 certs · `h104/gas | solid brick | pitched unknown | ext2` +- [ ] `38105600` · L 68 / E 67.28 / Δ -0.72 + +### C168 · Σ|Δ| 0.7 · 1 certs · `h104/gas | solid brick | pitched 200` +- [ ] `line255` · L 72 / E 71.29 / Δ -0.71 + +### C169 · Σ|Δ| 0.7 · 1 certs · `h304/elec | cavity wall | (another dwelling | whw950` +- [ ] `5300047580` · L 69 / E 68.30 / Δ -0.70 + +### C170 · Σ|Δ| 0.7 · 1 certs · `h104/gas | cavity wall | pitched 250 | ext1` +- [ ] `100110433403` · L 69 / E 68.32 / Δ -0.68 + +### C171 · Σ|Δ| 0.7 · 1 certs · `hpcdb16835/gas | cavity wall | pitched insulated` +- [ ] `10093416558` · L 79 / E 78.32 / Δ -0.68 + +### C172 · Σ|Δ| 0.7 · 1 certs · `hpcdb18251/gas | solid brick | pitched 100` +- [ ] `100050958217` · L 68 / E 68.68 / Δ +0.68 + +### C173 · Σ|Δ| 0.7 · 1 certs · `hpcdb18224/gas | solid brick | pitched 225 | ext1` +- [ ] `100022667797` · L 68 / E 67.32 / Δ -0.68 + +### C174 · Σ|Δ| 0.7 · 1 certs · `hpcdb10243/gas | cavity wall | pitched insulated | RR,sec:room heaters` +- [ ] `100030528925` · L 78 / E 78.65 / Δ +0.65 + +### C175 · Σ|Δ| 0.6 · 1 certs · `hpcdb17657/gas | cavity wall | (another dwelling` +- [ ] `100110300037` · L 74 / E 73.35 / Δ -0.65 + +### C176 · Σ|Δ| 0.6 · 1 certs · `hpcdb2044/gas | solid brick | (another dwelling | ext1` +- [ ] `217092450` · L 52 / E 52.64 / Δ +0.64 + +### C177 · Σ|Δ| 0.6 · 1 certs · `hpcdb15101/gas | solid brick | pitched 300` +- [ ] `100061164545` · L 72 / E 71.36 / Δ -0.64 + +### C178 · Σ|Δ| 0.6 · 1 certs · `hpcdb10328/gas | cavity wall | pitched unknown` +- [ ] `100051075936` · L 71 / E 70.36 / Δ -0.64 + +### C179 · Σ|Δ| 0.6 · 1 certs · `hpcdb17507/gas | cavity wall | roof room(s) | RR,sec:room heaters` +- [ ] `100011565830` · L 72 / E 72.64 / Δ +0.64 + +### C180 · Σ|Δ| 0.6 · 1 certs · `hpcdb15700/gas | sandstone with | pitched 100 | RR,sec:room heaters` +- [ ] `61010956` · L 75 / E 74.37 / Δ -0.63 + +### C181 · Σ|Δ| 0.6 · 1 certs · `hpcdb17115/gas+2mains | cavity wall | pitched insulated | RR,ext4,sec:room heaters` +- [ ] `100110752514` · L 67 / E 67.62 / Δ +0.62 + +### C182 · Σ|Δ| 0.6 · 1 certs · `h404/elec | cavity wall | pitched 175 | sec:room heaters,whw909` +- [ ] `100070295212` · L 75 / E 74.38 / Δ -0.62 + +### C183 · Σ|Δ| 0.6 · 1 certs · `hpcdb18766/gas+2mains | sandstone as | pitched 100 | RR` +- [ ] `100012534027` · L 65 / E 64.38 / Δ -0.62 + +### C184 · Σ|Δ| 0.6 · 1 certs · `hpcdb9058/fuel28 | solid brick | pitched 100 | RR,ext2,sec:room heaters` +- [x] `10009812472` · L 40 / E 39.40 / Δ -0.60 + +### C185 · Σ|Δ| 0.6 · 1 certs · `hpcdb18218/gas | solid brick | (another dwelling` +- [ ] `34080035` · L 77 / E 76.41 / Δ -0.59 + +### C186 · Σ|Δ| 0.6 · 1 certs · `hpcdb16137/gas | cavity wall | pitched 150 | RR,ext1` +- [ ] `21032732` · L 69 / E 69.59 / Δ +0.59 + +### C187 · Σ|Δ| 0.6 · 1 certs · `h101/gas | cavity wall | pitched 100 | RR,ext1` +- [ ] `100060562056` · L 66 / E 65.41 / Δ -0.59 + +### C188 · Σ|Δ| 0.6 · 1 certs · `hpcdb17514/fuel27 | cavity wall | pitched 400+ | PV,ext2` +- [ ] `100091086558` · L 79 / E 79.58 / Δ +0.58 + +### C189 · Σ|Δ| 0.6 · 1 certs · `hpcdb18908/gas | solid brick | (another dwelling` +- [ ] `line599` · L 74 / E 73.42 / Δ -0.58 + +### C190 · Σ|Δ| 0.6 · 1 certs · `hpcdb17973/gas | cavity wall | pitched insulated` +- [ ] `100020407741` · L 72 / E 71.42 / Δ -0.58 + +### C191 · Σ|Δ| 0.6 · 1 certs · `hpcdb19007/gas | cavity wall | pitched 200 | consv2,ext1,sec:room heaters` +- [ ] `100050839457` · L 70 / E 70.57 / Δ +0.57 + +### C192 · Σ|Δ| 0.6 · 1 certs · `hpcdb19007/gas | solid brick | pitched insulated | RR,sec:room heaters` +- [ ] `42105250` · L 59 / E 59.57 / Δ +0.57 + +### C193 · Σ|Δ| 0.6 · 1 certs · `hpcdb1917/gas | timber frame | pitched insulated` +- [ ] `77200509` · L 69 / E 69.57 / Δ +0.57 + +### C194 · Σ|Δ| 0.6 · 1 certs · `hpcdb10356/gas | cavity wall | pitched insulated | ext2` +- [ ] `200040534` · L 65 / E 65.56 / Δ +0.56 + +### C195 · Σ|Δ| 0.6 · 1 certs · `hpcdb17507/gas | solid brick | pitched 150 | ext1,mv2` +- [ ] `200049463` · L 69 / E 69.56 / Δ +0.56 + +### C196 · Σ|Δ| 0.6 · 1 certs · `h126/fuel28+2mains | cavity wall | pitched 300 | RR,consv2,ext3,sec:room heaters` +- [ ] `100062527568` · L 61 / E 61.56 / Δ +0.56 + +### C197 · Σ|Δ| 0.6 · 1 certs · `hpcdb15700/gas | cavity wall | pitched limited | RR,ext1` +- [ ] `100100268900` · L 69 / E 69.56 / Δ +0.56 + +### C198 · Σ|Δ| 0.6 · 1 certs · `hpcdb17004/fuel27+2mains | sandstone as | pitched no | ext3,sec:room heaters` +- [x] `200003110548` · L 29 / E 28.45 / Δ -0.55 + +### C199 · Σ|Δ| 0.5 · 1 certs · `hpcdb9464/gas | cavity wall | pitched 150 | ext1` +- [ ] `90036914` · L 60 / E 60.55 / Δ +0.55 + +### C200 · Σ|Δ| 0.5 · 1 certs · `h409/elec | cavity wall | pitched 250 | mv5,sec:room heaters,whw903` +- [ ] `10004742671` · L 70 / E 69.46 / Δ -0.54 + +### C201 · Σ|Δ| 0.5 · 1 certs · `hpcdb18251/gas | cavity wall | roof room(s) | RR` +- [ ] `100010657450` · L 75 / E 75.54 / Δ +0.54 + +### C202 · Σ|Δ| 0.5 · 1 certs · `hpcdb17507/gas | cavity wall | pitched 150 | ext1` +- [ ] `100061834344` · L 71 / E 70.46 / Δ -0.54 + +### C203 · Σ|Δ| 0.5 · 1 certs · `h113/gas | cavity wall | pitched 200` +- [ ] `4510097194` · L 70 / E 70.53 / Δ +0.53 + +### C204 · Σ|Δ| 0.5 · 1 certs · `hpcdb18907/gas | cavity wall | pitched 125 | ext1` +- [ ] `100010913220` · L 72 / E 72.53 / Δ +0.53 + +### C205 · Σ|Δ| 0.5 · 1 certs · `hpcdb18908/gas | solid brick | pitched 300` +- [ ] `207032630` · L 74 / E 73.47 / Δ -0.53 + +### C206 · Σ|Δ| 0.5 · 1 certs · `hpcdb18242/gas | sandstone as | pitched 150 | ext2,sec:room heaters` +- [ ] `43074051` · L 63 / E 62.48 / Δ -0.52 + +### C207 · Σ|Δ| 0.5 · 1 certs · `h104/gas | cavity wall | pitched 300` +- [ ] `100010723814` · L 69 / E 69.52 / Δ +0.52 + +### C208 · Σ|Δ| 0.5 · 1 certs · `hpcdb16137/gas | cavity wall | (another dwelling` +- [ ] `100020955792` · L 77 / E 77.52 / Δ +0.52 + +### C209 · Σ|Δ| 0.5 · 1 certs · `hpcdb19079/gas | cavity wall | pitched 200 | ext1,sec:room heaters` +- [ ] `77172223` · L 65 / E 65.52 / Δ +0.52 + +### C210 · Σ|Δ| 0.5 · 1 certs · `hpcdb10242/gas | granite or | pitched insulated | RR,sec:room heaters` +- [ ] `100051245439` · L 62 / E 61.48 / Δ -0.52 + +### C211 · Σ|Δ| 0.5 · 1 certs · `hpcdb10328/gas | cavity wall | (another dwelling` +- [ ] `100012553958` · L 67 / E 66.48 / Δ -0.52 + +### C212 · Σ|Δ| 0.5 · 1 certs · `hpcdb10033/gas | cavity wall | pitched 200 | ext2,sec:room heaters` +- [ ] `100110108632` · L 63 / E 63.51 / Δ +0.51 + +### C213 · Σ|Δ| 0.5 · 1 certs · `h109/gas | cavity wall | pitched 270 | consv2,ext1,sec:room heaters` +- [ ] `100091102581` · L 65 / E 65.51 / Δ +0.51 + +### C214 · Σ|Δ| 0.5 · 1 certs · `hpcdb19008/gas | cavity wall | pitched 150 | ext1,sec:room heaters` +- [ ] `10002326688` · L 69 / E 68.50 / Δ -0.50 + +### C215 · Σ|Δ| 0.5 · 1 certs · `h129/fuel28 | granite or | pitched 200 | ext2,sec:room heaters` +- [ ] `10023132869` · L 46 / E 45.50 / Δ -0.50 + +### C216 · Σ|Δ| 0.5 · 1 certs · `hpcdb9007/fuel28 | sandstone as | pitched insulated | RR,ext4,sec:room heaters` +- [ ] `10091707271` · L 41 / E 41.50 / Δ +0.50 + +## Resolved ≤0.5 (778 certs — no action) + +
expand + +- [x] `100090733462` · L 67 / E 67.50 / Δ +0.50 · `hpcdb17974/gas | solid brick | pitched insulated | ext1` +- [x] `100050567367` · L 64 / E 64.50 / Δ +0.50 · `hpcdb19020/gas | solid brick | pitched insulated | RR,sec:room heaters` +- [x] `10034815294` · L 78 / E 77.51 / Δ -0.49 · `hpcdb18559/gas | timber frame | pitched 125` +- [x] `100110206000` · L 57 / E 56.51 / Δ -0.49 · `h111/gas | cavity wall | pitched 175 | ext1,sec:room heaters` +- [x] `100090478973` · L 86 / E 86.49 / Δ +0.49 · `hpcdb18907/gas | cavity wall | pitched 150 | PV` +- [x] `217039192` · L 70 / E 69.51 / Δ -0.49 · `hpcdb17975/gas | solid brick | (another dwelling | mv4` +- [x] `100050839462` · L 69 / E 68.51 / Δ -0.49 · `hpcdb19007/gas | cavity wall | pitched 150 | ext1` +- [x] `100021753513` · L 77 / E 76.51 / Δ -0.49 · `hpcdb17511/gas | cavity wall | pitched insulated` +- [x] `100010402433` · L 70 / E 70.49 / Δ +0.49 · `hpcdb15705/gas | cavity wall | pitched insulated | PV,RR` +- [x] `100031059396` · L 75 / E 74.51 / Δ -0.49 · `hpcdb17982/gas | cavity wall | (another dwelling` +- [x] `100040387235` · L 72 / E 71.51 / Δ -0.49 · `hpcdb18907/gas | sandstone as | pitched insulated | ext1,sec:room heaters` +- [x] `100121020262` · L 63 / E 63.49 / Δ +0.49 · `hpcdb18432/gas | cavity wall | pitched insulated | sec:room heaters` +- [x] `100060596060` · L 68 / E 68.49 / Δ +0.49 · `hpcdb4048/gas | cavity wall | pitched 150 | ext1` +- [x] `100110101713` · L 73 / E 72.51 / Δ -0.49 · `hpcdb100061/elec | cavity wall | (another dwelling | PV` +- [x] `100052163521` · L 73 / E 72.51 / Δ -0.49 · `h104/gas | cavity wall | pitched 300` +- [x] `202030317` · L 74 / E 74.49 / Δ +0.49 · `hpcdb18820/gas | solid brick | (another dwelling` +- [x] `10022829122` · L 82 / E 82.49 / Δ +0.49 · `hpcdb104568/elec | cavity wall | (another dwelling` +- [x] `10034812814` · L 65 / E 65.49 / Δ +0.49 · `hpcdb17978/gas | solid brick | pitched 200 | ext1,sec:room heaters` +- [x] `72103702` · L 70 / E 70.48 / Δ +0.48 · `hpcdb18759/gas | sandstone as | pitched insulated | sec:room heaters` +- [x] `100091572782` · L 74 / E 74.48 / Δ +0.48 · `hpcdb19168/gas | cavity wall | pitched 100 | ext1` +- [x] `28012066` · L 71 / E 71.48 / Δ +0.48 · `hpcdb16212/gas | cavity wall | pitched 400+` +- [x] `74057326` · L 73 / E 72.52 / Δ -0.48 · `h691/elec | solid brick | pitched 270 | whw909` +- [x] `34087745` · L 68 / E 67.52 / Δ -0.48 · `hpcdb10243/gas | solid brick | (another dwelling | ext1` +- [x] `33042105` · L 54 / E 54.48 / Δ +0.48 · `h691/elec | cavity wall | pitched 100 | whw903` +- [x] `100040286465` · L 65 / E 64.52 / Δ -0.48 · `hpcdb16210/gas | cavity wall | pitched 175 | ext2,sec:room heaters` +- [x] `10034663764` · L 74 / E 74.48 / Δ +0.48 · `h104/gas | cavity wall | pitched 350 | ext1` +- [x] `100060858571` · L 67 / E 66.52 / Δ -0.48 · `h104/gas | cavity wall | pitched 100` +- [x] `100090444184` · L 69 / E 68.52 / Δ -0.48 · `hpcdb15032/gas | solid brick | pitched 270 | RR,ext1` +- [x] `100110139702` · L 69 / E 68.52 / Δ -0.48 · `hpcdb18684/gas | cavity wall | pitched 200 | consv2,sec:room heaters` +- [x] `100070365977` · L 78 / E 77.52 / Δ -0.48 · `hpcdb19001/gas | solid brick | pitched 400+ | PV` +- [x] `100080323739` · L 67 / E 67.48 / Δ +0.48 · `hpcdb10327/gas | cavity wall | pitched 150` +- [x] `100100840201` · L 60 / E 60.48 / Δ +0.48 · `hpcdb10244/gas | granite or | pitched 100 | ext1` +- [x] `100071135609` · L 68 / E 68.48 / Δ +0.48 · `hpcdb18229/gas | solid brick | pitched 200 | ext2` +- [x] `100121043224` · L 77 / E 77.48 / Δ +0.48 · `hpcdb1758/gas | cavity wall | pitched 300` +- [x] `30025401` · L 70 / E 69.52 / Δ -0.48 · `hpcdb18433/gas | cavity wall | pitched 270 | ext2` +- [x] `100021037247` · L 71 / E 70.52 / Δ -0.48 · `hpcdb10112/gas | solid brick | (another dwelling` +- [x] `10013778285` · L 80 / E 79.52 / Δ -0.48 · `hpcdb16138/gas | cavity wall | (another dwelling` +- [x] `100010348502` · L 76 / E 75.52 / Δ -0.48 · `hpcdb16839/gas | cavity wall | pitched 175` +- [x] `100040227808` · L 58 / E 58.48 / Δ +0.48 · `hpcdb15502/gas | solid brick | pitched 270 | sec:room heaters` +- [x] `100050355518` · L 36 / E 35.53 / Δ -0.47 · `h117/gas | cavity wall | pitched 200 | ext2,sec:room heaters` +- [x] `100051118081` · L 73 / E 73.47 / Δ +0.47 · `hpcdb18907/gas | cavity wall | pitched 300 | PV` +- [x] `100110190943` · L 67 / E 66.53 / Δ -0.47 · `hpcdb16777/gas | cavity wall | pitched 200` +- [x] `545523` · L 59 / E 59.47 / Δ +0.47 · `hpcdb16840/gas | cavity wall | pitched 100` +- [x] `100091241575` · L 70 / E 69.53 / Δ -0.47 · `hpcdb18908/gas | cavity wall | (another dwelling | mv2` +- [x] `100091265662` · L 63 / E 63.47 / Δ +0.47 · `h104/gas | cavity wall | pitched no` +- [x] `32066846` · L 59 / E 59.47 / Δ +0.47 · `hpcdb17507/gas | solid brick | pitched insulated | ext2,sec:room heaters` +- [x] `100030896498` · L 51 / E 51.47 / Δ +0.47 · `hpcdb10244/gas | solid brick | pitched no | ext1,sec:room heaters` +- [x] `100120636023` · L 72 / E 71.53 / Δ -0.47 · `hpcdb19009/gas | cavity wall | pitched 200` +- [x] `2465112185` · L 73 / E 73.47 / Δ +0.47 · `hpcdb18511/gas | cavity wall | pitched 270` +- [x] `200000569282` · L 69 / E 69.47 / Δ +0.47 · `hpcdb19076/gas | cavity wall | (another dwelling` +- [x] `10008075105` · L 77 / E 76.53 / Δ -0.47 · `hpcdb16810/gas | cavity wall | pitched 270` +- [x] `100010677806` · L 70 / E 70.47 / Δ +0.47 · `hpcdb10354/gas | cavity wall | pitched 150 | RR,ext1,sec:room heaters` +- [x] `100100607883` · L 67 / E 67.46 / Δ +0.46 · `hpcdb17679/gas | cavity wall | pitched 250` +- [x] `40032301` · L 72 / E 72.46 / Δ +0.46 · `hpcdb18234/gas | cavity wall | pitched 100 | sec:room heaters` +- [x] `21035507` · L 72 / E 71.54 / Δ -0.46 · `hpcdb16137/gas | cavity wall | (another dwelling` +- [x] `100071073344` · L 73 / E 73.46 / Δ +0.46 · `hpcdb10244/gas | cavity wall | (another dwelling | sec:room heaters` +- [x] `100091287350` · L 67 / E 67.46 / Δ +0.46 · `hpcdb17507/gas | solid brick | pitched insulated` +- [x] `10023324935` · L 72 / E 72.46 / Δ +0.46 · `h104/gas | cavity wall | pitched insulated` +- [x] `1775072187` · L 70 / E 69.54 / Δ -0.46 · `hpcdb15273/gas | timber frame | flat limited` +- [x] `5870069932` · L 73 / E 72.54 / Δ -0.46 · `hpcdb17483/gas | cavity wall | pitched insulated | PV` +- [x] `10033057673` · L 77 / E 76.54 / Δ -0.46 · `hpcdb18204/gas | cavity wall | pitched 250` +- [x] `42027366` · L 73 / E 72.54 / Δ -0.46 · `hpcdb10446/gas | cavity wall | pitched 150 | PV,sec:room heaters` +- [x] `100031607789` · L 75 / E 75.46 / Δ +0.46 · `hpcdb10457/gas | system built | pitched 250 | ext1` +- [x] `100061423798` · L 75 / E 74.54 / Δ -0.46 · `hpcdb18820/gas | cavity wall | pitched 100` +- [x] `100110090714` · L 71 / E 71.46 / Δ +0.46 · `hpcdb8587/gas | cavity wall | pitched 175` +- [x] `1775121297` · L 79 / E 78.55 / Δ -0.45 · `hpcdb16400/gas | cavity wall | pitched 300 | PV` +- [x] `100070304824` · L 76 / E 76.45 / Δ +0.45 · `hpcdb18119/gas | cavity wall | pitched insulated` +- [x] `41229335` · L 76 / E 75.55 / Δ -0.45 · `hpcdb16839/gas | cavity wall | pitched 200` +- [x] `200000571972` · L 64 / E 63.55 / Δ -0.45 · `hpcdb17986/gas | solid brick | pitched 250 | ext1` +- [x] `600039` · L 71 / E 70.55 / Δ -0.45 · `h104/gas | cavity wall | pitched 200` +- [x] `100050635265` · L 68 / E 67.55 / Δ -0.45 · `hpcdb17956/gas | cavity wall | pitched 250 | sec:room heaters` +- [x] `100040685184` · L 59 / E 59.45 / Δ +0.45 · `hpcdb18432/gas | cavity wall | pitched 300 | sec:room heaters` +- [x] `100070537862` · L 97 / E 96.55 / Δ -0.45 · `hpcdb10244/gas | cavity wall | pitched 400+ | PV,sec:room heaters` +- [x] `100100607333` · L 65 / E 64.55 / Δ -0.45 · `hpcdb15752/gas | granite or | pitched 175 | ext1` +- [x] `100090385996` · L 90 / E 90.45 / Δ +0.45 · `h409/elec | solid brick | pitched 270 | PV,mv2,sec:room heaters,whw903` +- [x] `200051823` · L 83 / E 82.55 / Δ -0.45 · `hpcdb17505/gas | cavity wall | (another dwelling` +- [x] `100040647307` · L 67 / E 66.55 / Δ -0.45 · `hpcdb10326/gas | cavity wall | pitched 250 | sec:room heaters` +- [x] `10091709997` · L 80 / E 79.55 / Δ -0.45 · `h301/coal-anth | cavity wall | (another dwelling` +- [x] `422000130066` · L 77 / E 77.45 / Δ +0.45 · `hpcdb18225/gas | cavity wall | pitched 200` +- [x] `200004196713` · L 69 / E 68.55 / Δ -0.45 · `hpcdb19083/gas | cavity wall | pitched 200 | ext1` +- [x] `100020988934` · L 70 / E 69.55 / Δ -0.45 · `hpcdb17974/gas | solid brick | pitched 100` +- [x] `10013448474` · L 80 / E 79.55 / Δ -0.45 · `h408/elec | cavity wall | (another dwelling | whw903` +- [x] `100010542167` · L 65 / E 65.45 / Δ +0.45 · `hpcdb15445/gas | cavity wall | pitched 100 | consv3` +- [x] `100050721946` · L 73 / E 72.56 / Δ -0.44 · `hpcdb15024/gas | cavity wall | (another dwelling | ext1` +- [x] `10002589494` · L 70 / E 70.44 / Δ +0.44 · `hpcdb8642/gas | cavity wall | pitched 200` +- [x] `100010460076` · L 67 / E 66.56 / Δ -0.44 · `hpcdb17515/gas | cavity wall | pitched 100` +- [x] `100051178837` · L 63 / E 63.44 / Δ +0.44 · `h104/gas | sandstone as | pitched no | RR` +- [x] `63017340` · L 74 / E 73.56 / Δ -0.44 · `hpcdb17956/gas | solid brick | pitched insulated` +- [x] `50101945` · L 75 / E 74.56 / Δ -0.44 · `hpcdb10243/gas | timber frame | (another dwelling | ext1` +- [x] `10032778242` · L 65 / E 65.44 / Δ +0.44 · `hpcdb9209/gas | sandstone as | pitched 100` +- [x] `40002036` · L 64 / E 63.56 / Δ -0.44 · `hpcdb17904/gas | cavity wall | pitched no` +- [x] `100090412407` · L 71 / E 70.56 / Δ -0.44 · `hpcdb18908/gas | cavity wall | pitched 200` +- [x] `10070623452` · L 84 / E 84.44 / Δ +0.44 · `hpcdb18616/gas | cavity wall | (another dwelling` +- [x] `100000059044` · L 73 / E 72.56 / Δ -0.44 · `hpcdb17983/gas | cavity wall | (another dwelling` +- [x] `10012437206` · L 67 / E 67.44 / Δ +0.44 · `hpcdb16136/gas | cavity wall | pitched 200 | sec:room heaters` +- [x] `100070391608` · L 65 / E 64.56 / Δ -0.44 · `hpcdb18283/gas | solid brick | pitched 225 | ext2` +- [x] `100050849874` · L 70 / E 70.44 / Δ +0.44 · `hpcdb19007/gas | cavity wall | pitched 200 | ext1,sec:room heaters` +- [x] `10094494653` · L 76 / E 75.57 / Δ -0.43 · `hpcdb18559/gas | solid brick | (another dwelling` +- [x] `10070088126` · L 74 / E 73.57 / Δ -0.43 · `hpcdb16210/gas | cavity wall | pitched 270 | consv3` +- [x] `39036600` · L 72 / E 72.43 / Δ +0.43 · `hpcdb18225/gas | cavity wall | pitched 400 | mv2` +- [x] `100021219619` · L 69 / E 68.57 / Δ -0.43 · `hpcdb18559/gas | solid brick | pitched 100` +- [x] `100100579892` · L 71 / E 71.43 / Δ +0.43 · `hpcdb10244/gas | cavity wall | pitched 400+` +- [x] `5128014` · L 67 / E 66.57 / Δ -0.43 · `hpcdb18250/gas | solid brick | pitched insulated` +- [x] `200000795879` · L 58 / E 57.57 / Δ -0.43 · `h402/elec | cavity wall | pitched 150 | ext1,sec:room heaters,whw903` +- [x] `39096576` · L 82 / E 81.57 / Δ -0.43 · `hpcdb17617/gas | timber frame | pitched 400+` +- [x] `1775045933` · L 69 / E 69.43 / Δ +0.43 · `hpcdb8180/gas | cavity wall | pitched 150` +- [x] `83105081` · L 67 / E 67.43 / Δ +0.43 · `hpcdb18119/gas | cavity wall | pitched 250 | ext1,sec:room heaters` +- [x] `21072782` · L 66 / E 65.57 / Δ -0.43 · `hpcdb18045/gas | cavity wall | pitched 175 | sec:room heaters` +- [x] `100031468327` · L 69 / E 69.43 / Δ +0.43 · `hpcdb19083/gas | cavity wall | pitched 300 | consv2` +- [x] `100100253270` · L 71 / E 71.43 / Δ +0.43 · `hpcdb18496/gas | cavity wall | pitched 250 | ext1` +- [x] `100071116347` · L 62 / E 62.43 / Δ +0.43 · `h104/gas | solid brick | pitched 150` +- [x] `422000030203` · L 75 / E 75.42 / Δ +0.42 · `hpcdb10328/gas | cavity wall | pitched 225` +- [x] `100010399776` · L 63 / E 63.42 / Δ +0.42 · `hpcdb10462/gas | cavity wall | pitched 270 | RR,ext1,sec:room heaters` +- [x] `100080945063` · L 70 / E 70.42 / Δ +0.42 · `hpcdb10326/gas | cavity wall | (another dwelling | ext1` +- [x] `10034692050` · L 82 / E 81.58 / Δ -0.42 · `hpcdb18250/gas | sandstone as | pitched 350 | PV,ext2,sec:room heaters` +- [x] `100100284086` · L 69 / E 69.42 / Δ +0.42 · `hpcdb17983/gas | cavity wall | pitched 300 | sec:room heaters` +- [x] `100031674118` · L 66 / E 66.42 / Δ +0.42 · `hpcdb17986/gas | cavity wall | pitched 125 | consv2,sec:room heaters` +- [x] `10023449405` · L 59 / E 58.58 / Δ -0.42 · `hpcdb18219/gas | granite or | pitched insulated | consv3,ext1,sec:room heaters` +- [x] `47237825` · L 79 / E 79.42 / Δ +0.42 · `hpcdb15707/gas | cavity wall | (another dwelling` +- [x] `100010265441` · L 69 / E 69.42 / Δ +0.42 · `hpcdb16839/gas | cavity wall | pitched 200` +- [x] `666329` · L 69 / E 69.42 / Δ +0.42 · `hpcdb10599/fuel28 | sandstone as | pitched insulated | ext2,sec:room heaters` +- [x] `100010197780` · L 65 / E 65.42 / Δ +0.42 · `hpcdb1839/gas | cavity wall | pitched 75 | sec:room heaters` +- [x] `100100991435` · L 74 / E 74.42 / Δ +0.42 · `hpcdb18907/gas | cavity wall | (another dwelling` +- [x] `100070537860` · L 91 / E 91.42 / Δ +0.42 · `hpcdb18225/gas | cavity wall | pitched 400+ | PV,sec:room heaters` +- [x] `100051145841` · L 70 / E 70.42 / Δ +0.42 · `hpcdb18559/gas | cavity wall | pitched 150` +- [x] `100030488548` · L 62 / E 62.42 / Δ +0.42 · `hpcdb1848/gas | solid brick | pitched insulated | ext1,sec:room heaters` +- [x] `100030538980` · L 68 / E 68.42 / Δ +0.42 · `hpcdb19109/gas | solid brick | pitched insulated | ext1` +- [x] `4510015395` · L 70 / E 69.58 / Δ -0.42 · `hpcdb19007/gas | cavity wall | pitched 200` +- [x] `100021798076` · L 56 / E 55.58 / Δ -0.42 · `hpcdb17505/gas | cavity wall | pitched 12 | ext1,sec:room heaters` +- [x] `200002597778` · L 73 / E 72.58 / Δ -0.42 · `hpcdb15282/gas | cavity wall | pitched 200` +- [x] `200004402879` · L 74 / E 73.58 / Δ -0.42 · `hpcdb17983/gas | cavity wall | pitched 200` +- [x] `10091186955` · L 82 / E 82.42 / Δ +0.42 · `hpcdb17505/gas | cavity wall | (another dwelling` +- [x] `100000054651` · L 70 / E 70.42 / Δ +0.42 · `hpcdb17983/gas | cavity wall | pitched 125` +- [x] `100030769969` · L 58 / E 57.59 / Δ -0.41 · `h130/fuel28 | cavity wall | pitched 250` +- [x] `10070086972` · L 18 / E 17.59 / Δ -0.41 · `hpcdb710/fuel28+2mains | sandstone as | pitched insulated | RR,ext4,sec:room heaters` +- [x] `100050397944` · L 65 / E 64.59 / Δ -0.41 · `hpcdb17681/gas | cavity wall | pitched 250 | ext1,sec:room heaters` +- [x] `63105357` · L 74 / E 74.41 / Δ +0.41 · `hpcdb17507/gas | cavity wall | pitched 270 | sec:room heaters` +- [x] `100090989765` · L 70 / E 70.41 / Δ +0.41 · `hpcdb16408/gas | timber frame | pitched 300 | consv3,ext1,sec:room heaters` +- [x] `30523770` · L 72 / E 71.60 / Δ -0.40 · `hpcdb8191/fuel28 | granite or | pitched 200 | sec:room heaters` +- [x] `200004700463` · L 65 / E 65.40 / Δ +0.40 · `hpcdb18559/gas | sandstone as | pitched 75` +- [x] `100020675575` · L 60 / E 60.40 / Δ +0.40 · `h102/gas | cavity wall | pitched 12` +- [x] `100110694244` · L 72 / E 72.40 / Δ +0.40 · `hpcdb18512/gas | granite or | pitched 200 | sec:room heaters` +- [x] `10003910753` · L 64 / E 63.60 / Δ -0.40 · `h409/elec | granite or | pitched 300 | sec:room heaters,whw903` +- [x] `100040881207` · L 73 / E 73.40 / Δ +0.40 · `hpcdb16839/gas | solid brick | pitched 100 | ext1` +- [x] `100070601575` · L 68 / E 68.40 / Δ +0.40 · `hpcdb18560/gas | solid brick | pitched 250` +- [x] `100061125812` · L 75 / E 74.60 / Δ -0.40 · `hpcdb17983/gas | solid brick | pitched 50 | PV,ext2,sec:room heaters` +- [x] `100010596378` · L 67 / E 67.40 / Δ +0.40 · `hpcdb16839/gas | cavity wall | pitched 350 | RR,consv3,ext1,sec:room heaters` +- [x] `100051185130` · L 67 / E 66.60 / Δ -0.40 · `hpcdb18156/gas | cavity wall | pitched 250 | ext1,sec:room heaters` +- [x] `38198539` · L 80 / E 80.40 / Δ +0.40 · `hpcdb18229/gas | cavity wall | (another dwelling` +- [x] `10008501568` · L 76 / E 76.40 / Δ +0.40 · `hpcdb16909/gas | cavity wall | (another dwelling` +- [x] `10090849417` · L 73 / E 72.60 / Δ -0.40 · `hpcdb17000/fuel27 | cavity wall | (another dwelling` +- [x] `100110494926` · L 66 / E 66.40 / Δ +0.40 · `h104/gas | cavity wall | pitched 250` +- [x] `100050553337` · L 70 / E 69.60 / Δ -0.40 · `hpcdb16839/gas | cavity wall | pitched 270 | sec:room heaters` +- [x] `100071356612` · L 69 / E 68.61 / Δ -0.39 · `h301/coal-anth | solid brick | (another dwelling` +- [x] `74041417` · L 79 / E 79.39 / Δ +0.39 · `hpcdb17507/gas | cavity wall | pitched 300 | PV` +- [x] `100110452552` · L 67 / E 67.39 / Δ +0.39 · `hpcdb15068/gas | cavity wall | pitched 175 | mv2,sec:room heaters` +- [x] `100022607800` · L 73 / E 72.61 / Δ -0.39 · `hpcdb18119/gas | solid brick | pitched 270` +- [x] `200004017091` · L 71 / E 71.39 / Δ +0.39 · `hpcdb17803/gas | timber frame | pitched 100` +- [x] `100022277442` · L 58 / E 58.39 / Δ +0.39 · `h409/elec | cavity wall | pitched insulated | whw903` +- [x] `250029611` · L 73 / E 72.61 / Δ -0.39 · `hpcdb17553/gas | cavity wall | pitched 250 | consv2,ext1` +- [x] `100022887979` · L 72 / E 72.39 / Δ +0.39 · `hpcdb18861/gas | solid brick | (another dwelling` +- [x] `44006508` · L 78 / E 78.39 / Δ +0.39 · `hpcdb17683/gas | cavity wall | pitched 150` +- [x] `38081666` · L 69 / E 68.61 / Δ -0.39 · `hpcdb17919/gas | solid brick | pitched 400+ | sec:room heaters` +- [x] `10090034244` · L 80 / E 80.39 / Δ +0.39 · `h691/elec | system built | (another dwelling | whw903` +- [x] `100010692849` · L 74 / E 73.61 / Δ -0.39 · `hpcdb16455/gas | cavity wall | pitched 300 | consv4,ext2,sec:room heaters` +- [x] `100090148760` · L 67 / E 67.39 / Δ +0.39 · `hpcdb15032/gas | cavity wall | pitched 100 | consv2` +- [x] `72304070` · L 76 / E 75.61 / Δ -0.39 · `hpcdb18225/gas | cavity wall | pitched 200` +- [x] `100070668967` · L 76 / E 76.39 / Δ +0.39 · `hpcdb17816/gas | cavity wall | pitched 200 | consv2` +- [x] `100040892555` · L 71 / E 71.39 / Δ +0.39 · `hpcdb16840/gas | cavity wall | pitched insulated` +- [x] `100061274293` · L 71 / E 71.39 / Δ +0.39 · `hpcdb17553/gas | cavity wall | pitched 300 | ext1` +- [x] `11064046` · L 65 / E 64.61 / Δ -0.39 · `h104/gas | solid brick | pitched insulated | ext2` +- [x] `77069148` · L 71 / E 71.39 / Δ +0.39 · `hpcdb17507/gas | cavity wall | pitched 200` +- [x] `148002976` · L 72 / E 71.61 / Δ -0.39 · `hpcdb16840/gas | cavity wall | pitched insulated | ext1` +- [x] `10096536318` · L 66 / E 65.62 / Δ -0.38 · `h301/coal-anth | cavity wall | (another dwelling` +- [x] `100030585620` · L 59 / E 59.38 / Δ +0.38 · `h102/gas | cavity wall | pitched insulated | ext4,sec:room heaters` +- [x] `10008325030` · L 82 / E 81.62 / Δ -0.38 · `h301/coal-anth | system built | (another dwelling` +- [x] `100050869353` · L 75 / E 74.62 / Δ -0.38 · `hpcdb10547/gas | solid brick | pitched 300` +- [x] `45034352` · L 70 / E 70.38 / Δ +0.38 · `hpcdb18684/gas | cavity wall | pitched 250 | ext1` +- [x] `72274934` · L 66 / E 66.38 / Δ +0.38 · `hpcdb17985/gas | cavity wall | pitched 300 | ext1,sec:room heaters` +- [x] `100030893211` · L 70 / E 69.62 / Δ -0.38 · `hpcdb17546/gas | cavity wall | pitched 200 | ext1` +- [x] `21018821` · L 67 / E 67.38 / Δ +0.38 · `hpcdb19014/gas | cavity wall | pitched 200 | sec:room heaters` +- [x] `100080145093` · L 69 / E 69.38 / Δ +0.38 · `hpcdb18907/gas | cavity wall | pitched 225 | ext1` +- [x] `100021434831` · L 74 / E 74.38 / Δ +0.38 · `h102/gas | cavity wall | pitched insulated | RR,ext1` +- [x] `100090160202` · L 78 / E 77.62 / Δ -0.38 · `hpcdb18120/gas | cavity wall | pitched 300 | PV` +- [x] `100000001899` · L 70 / E 70.38 / Δ +0.38 · `hpcdb17507/gas | cavity wall | pitched 250 | ext1,sec:room heaters` +- [x] `63015070` · L 69 / E 69.38 / Δ +0.38 · `hpcdb19082/gas | cavity wall | pitched 300 | ext1,sec:room heaters` +- [x] `100032207872` · L 71 / E 71.38 / Δ +0.38 · `hpcdb17505/gas | cavity wall | (another dwelling` +- [x] `90089745` · L 62 / E 61.62 / Δ -0.38 · `hpcdb10243/gas | solid brick | pitched 150` +- [x] `100090379526` · L 67 / E 66.62 / Δ -0.38 · `hpcdb17992/gas | cavity wall | pitched 150 | ext2` +- [x] `100031755813` · L 73 / E 73.37 / Δ +0.37 · `hpcdb15706/gas | cavity wall | pitched 150` +- [x] `100040668971` · L 14 / E 13.63 / Δ -0.37 · `h631/LPG | cavity wall | pitched 250 | consv2,sec:portable electric,whw908` +- [x] `10013258835` · L 78 / E 77.63 / Δ -0.37 · `hpcdb16138/gas | timber frame | pitched 270 | ext1` +- [x] `10008774426` · L 77 / E 77.37 / Δ +0.37 · `h402/elec | cavity wall | pitched 250 | sec:room heaters,whw903` +- [x] `100090183995` · L 70 / E 70.37 / Δ +0.37 · `hpcdb15018/gas | cavity wall | pitched insulated` +- [x] `100100904720` · L 82 / E 81.63 / Δ -0.37 · `hpcdb19023/gas+2mains | cavity wall | flat insulated | RR,ext1,sec:room heaters` +- [x] `100050111553` · L 39 / E 39.37 / Δ +0.37 · `h191/elec | cavity wall | pitched 200 | whw903` +- [x] `100052105637` · L 59 / E 58.63 / Δ -0.37 · `hpcdb17741/gas | solid brick | pitched 200 | ext1,sec:room heaters` +- [x] `1775071220` · L 74 / E 73.63 / Δ -0.37 · `hpcdb10326/gas | cavity wall | (another dwelling` +- [x] `10000452538` · L 77 / E 76.63 / Δ -0.37 · `h104/gas | timber frame | pitched insulated` +- [x] `100120650297` · L 77 / E 76.63 / Δ -0.37 · `hpcdb17507/gas | cavity wall | (another dwelling` +- [x] `1775087250` · L 75 / E 74.63 / Δ -0.37 · `hpcdb18861/gas | cavity wall | pitched 250 | ext1` +- [x] `100061253556` · L 60 / E 59.63 / Δ -0.37 · `hpcdb18907/gas | cavity wall | pitched 100 | sec:room heaters` +- [x] `6083595` · L 76 / E 76.37 / Δ +0.37 · `hpcdb17685/gas | cavity wall | pitched insulated` +- [x] `200033141` · L 76 / E 75.64 / Δ -0.36 · `hpcdb16738/gas | cavity wall | flat insulated` +- [x] `100030357741` · L 72 / E 72.36 / Δ +0.36 · `hpcdb18560/gas | cavity wall | pitched 300 | consv2,ext1` +- [x] `100080707176` · L 72 / E 71.64 / Δ -0.36 · `hpcdb10243/gas | cavity wall | pitched 100` +- [x] `100062132377` · L 81 / E 80.64 / Δ -0.36 · `h214/elec | cavity wall | (another dwelling` +- [x] `100062132384` · L 81 / E 80.64 / Δ -0.36 · `h214/elec | cavity wall | (another dwelling` +- [x] `100031679582` · L 66 / E 66.36 / Δ +0.36 · `hpcdb10243/gas | cavity wall | pitched insulated` +- [x] `100031300973` · L 70 / E 70.36 / Δ +0.36 · `hpcdb10328/gas | cavity wall | pitched insulated` +- [x] `100090373553` · L 50 / E 49.64 / Δ -0.36 · `h102/gas | cavity wall | pitched insulated | ext1` +- [x] `100110122381` · L 70 / E 70.36 / Δ +0.36 · `hpcdb18119/gas | solid brick | pitched 300 | ext1` +- [x] `100110084287` · L 69 / E 68.64 / Δ -0.36 · `hpcdb17513/gas | cavity wall | pitched 270 | sec:room heaters` +- [x] `10010610915` · L 69 / E 69.36 / Δ +0.36 · `hpcdb18559/gas | cavity wall | pitched 75 | consv2,sec:room heaters` +- [x] `100050858546` · L 58 / E 58.36 / Δ +0.36 · `hpcdb19030/gas | cavity wall | pitched 150 | sec:room heaters` +- [x] `100061152006` · L 66 / E 66.36 / Δ +0.36 · `hpcdb17503/gas | cavity wall | pitched 270 | ext1,sec:room heaters` +- [x] `100070649980` · L 69 / E 68.64 / Δ -0.36 · `hpcdb17505/gas | solid brick | pitched 200` +- [x] `100121236760` · L 58 / E 58.36 / Δ +0.36 · `hpcdb18044/gas | sandstone as | pitched insulated | RR,ext3,sec:room heaters` +- [x] `64024859` · L 54 / E 53.64 / Δ -0.36 · `hpcdb8358/gas | cavity wall | pitched 125 | consv3,ext1,sec:room heaters` +- [x] `100031166296` · L 58 / E 57.64 / Δ -0.36 · `hpcdb15100/gas | cavity wall | pitched unknown | sec:room heaters` +- [x] `100110061827` · L 71 / E 70.64 / Δ -0.36 · `hpcdb16688/gas | cavity wall | pitched 150 | ext2` +- [x] `100110394521` · L 73 / E 72.64 / Δ -0.36 · `hpcdb10328/gas | cavity wall | pitched 300 | ext1,sec:room heaters` +- [x] `10023253242` · L 72 / E 72.36 / Δ +0.36 · `h104/gas | cavity wall | pitched insulated` +- [x] `90090727` · L 67 / E 66.64 / Δ -0.36 · `h104/gas | cavity wall | pitched 200 | ext1` +- [x] `21055668` · L 61 / E 61.36 / Δ +0.36 · `hpcdb17546/gas | cavity wall | pitched no` +- [x] `100110496019` · L 76 / E 75.65 / Δ -0.35 · `hpcdb18560/gas | cavity wall | pitched 200` +- [x] `422000037738` · L 72 / E 72.35 / Δ +0.35 · `hpcdb17975/gas | cavity wall | pitched 400+` +- [x] `77102428` · L 78 / E 77.65 / Δ -0.35 · `hpcdb17558/gas | cavity wall | pitched 300 | ext1` +- [x] `100071034423` · L 72 / E 71.65 / Δ -0.35 · `hpcdb18908/gas | cavity wall | pitched 100` +- [x] `100071026195` · L 71 / E 71.35 / Δ +0.35 · `hpcdb18907/gas | solid brick | pitched insulated | ext1` +- [x] `200003757985` · L 72 / E 71.65 / Δ -0.35 · `h301/coal-anth | cavity wall | (another dwelling | PV` +- [x] `100051029525` · L 67 / E 67.35 / Δ +0.35 · `hpcdb17986/gas | cavity wall | pitched 200 | sec:room heaters` +- [x] `100051332624` · L 66 / E 65.65 / Δ -0.35 · `h301/coal-anth | cavity wall | (another dwelling` +- [x] `10094297916` · L 80 / E 80.35 / Δ +0.35 · `hpcdb17929/gas | timber frame | pitched unknown` +- [x] `2465002794` · L 64 / E 64.35 / Δ +0.35 · `h104/gas | solid brick | pitched insulated` +- [x] `28018668` · L 58 / E 58.35 / Δ +0.35 · `hpcdb17115/gas | solid brick | pitched 250 | ext1,sec:room heaters` +- [x] `100020275185` · L 67 / E 67.35 / Δ +0.35 · `hpcdb17975/gas | solid brick | pitched 100` +- [x] `100000320896` · L 71 / E 70.65 / Δ -0.35 · `hpcdb18559/gas | cavity wall | (another dwelling | ext1,sec:room heaters` +- [x] `4510706229` · L 71 / E 71.35 / Δ +0.35 · `h402/elec | solid brick | (another dwelling | sec:room heaters,whw903` +- [x] `10023179506` · L 77 / E 76.65 / Δ -0.35 · `hpcdb16400/gas | cavity wall | pitched 300 | sec:room heaters` +- [x] `32039356` · L 72 / E 72.34 / Δ +0.34 · `hpcdb17983/gas | cavity wall | pitched 200` +- [x] `100071281443` · L 72 / E 71.66 / Δ -0.34 · `h301/coal-anth | cavity wall | (another dwelling` +- [x] `100050971905` · L 46 / E 46.34 / Δ +0.34 · `hpcdb19059/fuel27 | cavity wall | flat limited` +- [x] `50120779` · L 76 / E 75.66 / Δ -0.34 · `hpcdb16848/gas | solid brick | (another dwelling` +- [x] `23018106` · L 66 / E 66.34 / Δ +0.34 · `hpcdb18250/gas | cavity wall | pitched 150 | sec:room heaters` +- [x] `100110515241` · L 64 / E 63.66 / Δ -0.34 · `hpcdb18559/gas | solid brick | pitched insulated | ext1,sec:room heaters` +- [x] `100010779964` · L 72 / E 72.34 / Δ +0.34 · `hpcdb17683/gas | sandstone as | pitched 75` +- [x] `64106486` · L 72 / E 71.66 / Δ -0.34 · `hpcdb17926/fuel27 | timber frame | pitched 270 | ext1` +- [x] `100060109253` · L 62 / E 62.34 / Δ +0.34 · `hpcdb18435/gas | solid brick | pitched insulated | RR,ext2,sec:room heaters` +- [x] `47084930` · L 74 / E 74.34 / Δ +0.34 · `hpcdb17815/gas | cavity wall | flat insulated` +- [x] `200002819281` · L 72 / E 71.66 / Δ -0.34 · `h104/gas | solid brick | pitched insulated | RR,ext1` +- [x] `100051237659` · L 65 / E 64.67 / Δ -0.33 · `hpcdb18560/gas | sandstone as | pitched insulated | sec:room heaters` +- [x] `12027355` · L 70 / E 70.33 / Δ +0.33 · `hpcdb1838/gas | solid brick | pitched insulated | ext1` +- [x] `100120926050` · L 71 / E 70.67 / Δ -0.33 · `hpcdb18514/gas | cavity wall | pitched 175 | ext2` +- [x] `100012548450` · L 74 / E 74.33 / Δ +0.33 · `hpcdb10338/gas | cavity wall | (another dwelling` +- [x] `100061275133` · L 76 / E 76.33 / Δ +0.33 · `hpcdb18908/gas | solid brick | pitched 300` +- [x] `10012184174` · L 75 / E 74.67 / Δ -0.33 · `hpcdb19006/gas | solid brick | pitched insulated` +- [x] `100031680815` · L 61 / E 60.67 / Δ -0.33 · `h402/elec | cavity wall | pitched 150 | sec:room heaters,whw903` +- [x] `100070057052` · L 66 / E 65.67 / Δ -0.33 · `hpcdb10200/gas | cavity wall | pitched 250 | ext1` +- [x] `100060240863` · L 75 / E 75.33 / Δ +0.33 · `hpcdb15032/gas | solid brick | pitched 150` +- [x] `10008295210` · L 75 / E 74.67 / Δ -0.33 · `hpcdb18224/gas | system built | (another dwelling | ext1` +- [x] `83171858` · L 74 / E 73.67 / Δ -0.33 · `hpcdb18119/gas | system built | pitched 250` +- [x] `72304132` · L 67 / E 67.33 / Δ +0.33 · `hpcdb17986/gas | cavity wall | pitched 250 | sec:room heaters` +- [x] `12160542` · L 81 / E 81.32 / Δ +0.32 · `hpcdb18737/gas | cavity wall | (another dwelling` +- [x] `100062035546` · L 75 / E 74.68 / Δ -0.32 · `h104/gas | cavity wall | pitched 100` +- [x] `100071033825` · L 63 / E 63.32 / Δ +0.32 · `hpcdb17505/gas | cavity wall | pitched 225 | sec:room heaters` +- [x] `100040027178` · L 65 / E 64.68 / Δ -0.32 · `hpcdb19262/fuel28 | system built | pitched 270 | sec:room heaters` +- [x] `10091482156` · L 74 / E 74.32 / Δ +0.32 · `h104/gas | cavity wall | flat insulated` +- [x] `5182876` · L 80 / E 79.68 / Δ -0.32 · `hpcdb16841/gas | cavity wall | (another dwelling` +- [x] `100031703043` · L 63 / E 62.68 / Δ -0.32 · `hpcdb17284/gas | cavity wall | pitched 100 | ext1` +- [x] `200001480500` · L 75 / E 74.68 / Δ -0.32 · `h409/elec | cavity wall | pitched 100 | whw903` +- [x] `21037089` · L 68 / E 68.32 / Δ +0.32 · `hpcdb19007/gas | cavity wall | pitched insulated | ext1,mv5,sec:room heaters` +- [x] `100031318648` · L 79 / E 78.68 / Δ -0.32 · `hpcdb18907/gas | timber frame | pitched 400+ | PV,sec:room heaters` +- [x] `21089432` · L 64 / E 63.68 / Δ -0.32 · `hpcdb18045/gas | cavity wall | pitched no | ext1` +- [x] `93099` · L 63 / E 63.32 / Δ +0.32 · `hpcdb18250/gas | solid brick | pitched 200 | ext2` +- [x] `100120765933` · L 57 / E 57.32 / Δ +0.32 · `hpcdb15921/fuel28 | cavity wall | pitched 200 | sec:room heaters` +- [x] `100051053673` · L 66 / E 66.32 / Δ +0.32 · `hpcdb17660/gas | cavity wall | pitched 300 | ext1,sec:room heaters` +- [x] `200002919291` · L 59 / E 59.32 / Δ +0.32 · `h691/elec | system built | (another dwelling | whw903` +- [x] `21082835` · L 78 / E 77.69 / Δ -0.31 · `hpcdb16137/gas | system built | pitched 270` +- [x] `100070126788` · L 58 / E 57.69 / Δ -0.31 · `hpcdb18090/gas | system built | pitched 75 | sec:room heaters` +- [x] `100010997844` · L 71 / E 71.31 / Δ +0.31 · `hpcdb18651/gas | cavity wall | pitched insulated` +- [x] `100051275257` · L 61 / E 61.31 / Δ +0.31 · `hpcdb19007/gas | sandstone as | pitched insulated | RR,ext1` +- [x] `72207515` · L 71 / E 70.69 / Δ -0.31 · `hpcdb17973/gas | cavity wall | (another dwelling | sec:room heaters` +- [x] `100070312027` · L 72 / E 71.69 / Δ -0.31 · `hpcdb18908/gas | solid brick | pitched 270 | ext2,sec:room heaters` +- [x] `100071105301` · L 72 / E 72.31 / Δ +0.31 · `hpcdb10312/gas | solid brick | pitched 150` +- [x] `10008275334` · L 76 / E 76.31 / Δ +0.31 · `h691/elec | system built | (another dwelling | whw903` +- [x] `100110174472` · L 74 / E 74.31 / Δ +0.31 · `hpcdb8368/gas | solid brick | pitched 175 | ext2,mv5` +- [x] `100071450760` · L 55 / E 54.69 / Δ -0.31 · `h691/elec | cavity wall | (another dwelling | whw903` +- [x] `207096040` · L 69 / E 69.31 / Δ +0.31 · `hpcdb18219/gas | solid brick | pitched insulated | ext1` +- [x] `200004695006` · L 69 / E 69.31 / Δ +0.31 · `hpcdb10243/gas | sandstone as | pitched insulated` +- [x] `100100800577` · L 64 / E 64.31 / Δ +0.31 · `h301/coal-anth | system built | pitched 200 | whw903` +- [x] `100110234047` · L 71 / E 70.70 / Δ -0.30 · `hpcdb16136/gas | cavity wall | pitched 300` +- [x] `10007966883` · L 64 / E 64.30 / Δ +0.30 · `hpcdb17785/gas | solid brick | pitched 100 | ext1` +- [x] `100091292858` · L 59 / E 59.30 / Δ +0.30 · `h691/elec | solid brick | (another dwelling | whw903` +- [x] `100120820445` · L 72 / E 71.70 / Δ -0.30 · `hpcdb10267/gas | solid brick | pitched insulated | RR,ext2,sec:room heaters` +- [x] `100010719461` · L 56 / E 56.30 / Δ +0.30 · `hpcdb16930/gas | cavity wall | pitched no` +- [x] `100070487183` · L 66 / E 66.30 / Δ +0.30 · `h691/elec | timber frame | pitched 150 | whw909` +- [x] `116895` · L 73 / E 72.70 / Δ -0.30 · `hpcdb18119/gas | cavity wall | pitched 250 | sec:room heaters` +- [x] `10008031947` · L 79 / E 78.70 / Δ -0.30 · `hpcdb16406/gas+2mains | cavity wall | pitched 250 | ext2` +- [x] `100050834171` · L 73 / E 72.70 / Δ -0.30 · `hpcdb10547/gas | cavity wall | pitched 100` +- [x] `10010159639` · L 65 / E 65.30 / Δ +0.30 · `hpcdb18759/gas | sandstone with | pitched 150 | ext1,mv5,sec:room heaters` +- [x] `100110455808` · L 67 / E 66.70 / Δ -0.30 · `hpcdb10242/gas | cavity wall | pitched no` +- [x] `100070968292` · L 72 / E 71.70 / Δ -0.30 · `h104/gas | cavity wall | pitched insulated` +- [x] `100011754031` · L 69 / E 69.30 / Δ +0.30 · `hpcdb16839/gas | cavity wall | pitched 150 | ext1` +- [x] `61008954` · L 67 / E 67.30 / Δ +0.30 · `hpcdb18559/gas | sandstone as | pitched insulated` +- [x] `100061379950` · L 32 / E 31.70 / Δ -0.30 · `h104/fuel27+2mains | solid brick | pitched unknown | ext1,sec:room heaters,whw907` +- [x] `100050549118` · L 69 / E 68.70 / Δ -0.30 · `hpcdb15282/gas | cavity wall | pitched 100 | RR` +- [x] `100021754159` · L 57 / E 57.30 / Δ +0.30 · `hpcdb17503/gas | solid brick | pitched 100 | consv2,ext1,sec:room heaters` +- [x] `200000323155` · L 74 / E 74.30 / Δ +0.30 · `hpcdb17974/gas | timber frame | pitched 100 | consv2,ext1` +- [x] `422000091523` · L 71 / E 70.70 / Δ -0.30 · `hpcdb18767/gas | solid brick | pitched 50` +- [x] `100031351512` · L 65 / E 65.30 / Δ +0.30 · `hpcdb10265/gas | solid brick | pitched insulated | RR,ext1,sec:room heaters` +- [x] `100040531566` · L 70 / E 69.71 / Δ -0.29 · `hpcdb15706/gas | cavity wall | pitched 75` +- [x] `100120850835` · L 59 / E 59.29 / Δ +0.29 · `hpcdb108793/elec | solid brick | pitched insulated | ext2` +- [x] `100010159140` · L 77 / E 77.29 / Δ +0.29 · `hpcdb17688/gas | cavity wall | pitched 300 | PV,ext1,sec:room heaters` +- [x] `77110396` · L 76 / E 75.71 / Δ -0.29 · `hpcdb18737/gas | cavity wall | pitched 200` +- [x] `100080908496` · L 81 / E 80.71 / Δ -0.29 · `hpcdb19006/gas | cavity wall | (another dwelling` +- [x] `100031831649` · L 67 / E 67.29 / Δ +0.29 · `hpcdb17663/gas | cavity wall | pitched 100 | ext1,sec:room heaters` +- [x] `100052098782` · L 70 / E 69.71 / Δ -0.29 · `hpcdb15706/gas | cavity wall | pitched 150` +- [x] `10014035608` · L 79 / E 78.71 / Δ -0.29 · `h301/elec10hr | system built | (another dwelling` +- [x] `100022206341` · L 66 / E 66.29 / Δ +0.29 · `hpcdb15100/gas | solid brick | pitched insulated | RR,ext1` +- [x] `10091185672` · L 84 / E 84.29 / Δ +0.29 · `hpcdb16400/gas | cavity wall | (another dwelling` +- [x] `32049938` · L 63 / E 62.71 / Δ -0.29 · `hpcdb17983/gas | solid brick | pitched 350 | sec:room heaters` +- [x] `100110433442` · L 70 / E 69.71 / Δ -0.29 · `hpcdb16839/gas | cavity wall | pitched 400+ | ext1` +- [x] `100070532674` · L 76 / E 76.29 / Δ +0.29 · `hpcdb17983/gas | cavity wall | pitched insulated` +- [x] `100032230128` · L 55 / E 54.71 / Δ -0.29 · `hpcdb1245/gas | solid brick | pitched insulated | ext1` +- [x] `10024298029` · L 72 / E 72.29 / Δ +0.29 · `hpcdb17503/gas | solid brick | pitched 270 | ext1,sec:room heaters` +- [x] `5079850` · L 39 / E 39.29 / Δ +0.29 · `h301/coal-anth | system built | (another dwelling | ext1,whw903` +- [x] `10001235167` · L 74 / E 73.71 / Δ -0.29 · `hpcdb17645/gas | solid brick | pitched 225` +- [x] `100080531660` · L 64 / E 63.71 / Δ -0.29 · `hpcdb16408/gas | cavity wall | pitched 50 | ext1` +- [x] `100110515199` · L 91 / E 91.28 / Δ +0.28 · `hpcdb17741/gas | cavity wall | pitched 400+ | PV` +- [x] `61016813` · L 72 / E 72.28 / Δ +0.28 · `hpcdb17590/gas | sandstone as | pitched insulated` +- [x] `200001186113` · L 82 / E 82.28 / Δ +0.28 · `h409/elec | cavity wall | pitched 270 | PV,mv2,sec:room heaters,whw903` +- [x] `3040022036` · L 69 / E 69.28 / Δ +0.28 · `hpcdb17986/gas | cavity wall | (another dwelling` +- [x] `100070536189` · L 75 / E 75.28 / Δ +0.28 · `hpcdb10244/gas | cavity wall | pitched insulated | PV` +- [x] `90204248` · L 41 / E 40.72 / Δ -0.28 · `h691/elec | cavity wall | pitched 100 | whw909` +- [x] `28054454` · L 74 / E 73.72 / Δ -0.28 · `hpcdb102431/elec | cavity wall | pitched 200` +- [x] `303001211` · L 72 / E 72.28 / Δ +0.28 · `h301/coal-anth | cavity wall | pitched 250` +- [x] `202215644` · L 80 / E 79.72 / Δ -0.28 · `hpcdb17560/gas | cavity wall | (another dwelling | ext1` +- [x] `202207892` · L 77 / E 77.28 / Δ +0.28 · `h301/coal-anth | system built | (another dwelling` +- [x] `100120836206` · L 73 / E 73.28 / Δ +0.28 · `hpcdb17762/gas | cavity wall | pitched 100` +- [x] `100010450242` · L 70 / E 70.28 / Δ +0.28 · `h104/gas | sandstone as | pitched 270 | ext1` +- [x] `100031416239` · L 70 / E 70.27 / Δ +0.27 · `h104/gas | cavity wall | pitched 125` +- [x] `100061093506` · L 73 / E 72.73 / Δ -0.27 · `hpcdb18276/gas | cavity wall | pitched 125 | ext1` +- [x] `100000488045` · L 76 / E 75.73 / Δ -0.27 · `hpcdb15709/gas | cavity wall | pitched 250` +- [x] `100000025104` · L 76 / E 75.73 / Δ -0.27 · `hpcdb19007/gas | system built | (another dwelling` +- [x] `10093494727` · L 69 / E 68.73 / Δ -0.27 · `h691/elec | cavity wall | pitched insulated | whw903` +- [x] `1775073252` · L 76 / E 76.27 / Δ +0.27 · `h104/gas | cavity wall | (another dwelling` +- [x] `1775121299` · L 81 / E 81.27 / Δ +0.27 · `hpcdb17044/gas | cavity wall | pitched 300 | PV` +- [x] `100110136322` · L 66 / E 66.27 / Δ +0.27 · `hpcdb16882/gas | cavity wall | pitched insulated | RR,ext1` +- [x] `100022243530` · L 61 / E 61.27 / Δ +0.27 · `hpcdb17959/gas | solid brick | pitched 250 | sec:room heaters` +- [x] `83159458` · L 77 / E 76.73 / Δ -0.27 · `hpcdb17683/gas | system built | pitched 250 | sec:room heaters` +- [x] `100031193650` · L 70 / E 69.73 / Δ -0.27 · `hpcdb15100/gas | cavity wall | pitched insulated | consv2,sec:room heaters` +- [x] `100110435186` · L 75 / E 74.73 / Δ -0.27 · `hpcdb15709/gas | cavity wall | pitched 300 | PV,ext1,mv2,sec:room heaters` +- [x] `38327244` · L 81 / E 80.73 / Δ -0.27 · `hpcdb17507/gas | timber frame | pitched 300 | ext1` +- [x] `100031388370` · L 71 / E 71.27 / Δ +0.27 · `hpcdb16931/gas | solid brick | pitched 300 | ext2` +- [x] `45115812` · L 75 / E 74.73 / Δ -0.27 · `h304/elec | system built | (another dwelling` +- [x] `207084872` · L 60 / E 59.74 / Δ -0.26 · `h114/gas | solid brick | pitched 75 | ext1` +- [x] `100060061070` · L 70 / E 69.74 / Δ -0.26 · `hpcdb17986/gas | cavity wall | pitched 300 | ext1,sec:room heaters` +- [x] `100021026327` · L 72 / E 71.74 / Δ -0.26 · `hpcdb15502/gas | solid brick | (another dwelling` +- [x] `50016716` · L 62 / E 62.26 / Δ +0.26 · `hpcdb18431/gas | cavity wall | pitched 100` +- [x] `100040724662` · L 73 / E 73.26 / Δ +0.26 · `hpcdb18229/gas | cavity wall | pitched insulated | ext1` +- [x] `72155875` · L 68 / E 67.74 / Δ -0.26 · `hpcdb17038/gas | cavity wall | pitched 150 | ext1,sec:room heaters` +- [x] `5870012976` · L 62 / E 62.26 / Δ +0.26 · `hpcdb17501/gas | solid brick | pitched 200` +- [x] `100010049310` · L 70 / E 70.26 / Δ +0.26 · `hpcdb15706/gas | cavity wall | pitched 200 | ext1,sec:room heaters` +- [x] `100070642087` · L 68 / E 68.26 / Δ +0.26 · `hpcdb18560/gas | cavity wall | pitched 100 | ext1` +- [x] `100050429574` · L 70 / E 70.26 / Δ +0.26 · `hpcdb19110/gas | solid brick | pitched 250 | sec:room heaters` +- [x] `10012816887` · L 65 / E 65.26 / Δ +0.26 · `h402/elec | cavity wall | pitched 50 | sec:portable electric,whw903` +- [x] `100051080419` · L 69 / E 68.74 / Δ -0.26 · `hpcdb10328/gas | cavity wall | pitched 200` +- [x] `68144811` · L 80 / E 79.74 / Δ -0.26 · `hpcdb8867/gas | cavity wall | (another dwelling` +- [x] `100050424781` · L 69 / E 69.26 / Δ +0.26 · `hpcdb17507/gas | cavity wall | pitched 175 | mv5` +- [x] `50011586` · L 69 / E 68.74 / Δ -0.26 · `hpcdb10328/gas | cavity wall | pitched 225 | consv2` +- [x] `100010680786` · L 73 / E 73.26 / Δ +0.26 · `hpcdb18907/gas | granite or | pitched 400+ | ext1` +- [x] `422000127372` · L 79 / E 78.75 / Δ -0.25 · `hpcdb17741/gas | cavity wall | pitched 400+` +- [x] `200000339841` · L 72 / E 72.25 / Δ +0.25 · `hpcdb18736/gas | cavity wall | (another dwelling` +- [x] `72380835` · L 82 / E 82.25 / Δ +0.25 · `hpcdb17507/gas | cavity wall | pitched 200 | PV,sec:room heaters` +- [x] `38193503` · L 65 / E 65.25 / Δ +0.25 · `hpcdb18907/gas | cavity wall | pitched 100 | ext2,sec:room heaters` +- [x] `100070155960` · L 53 / E 53.25 / Δ +0.25 · `hpcdb10462/gas | cavity wall | pitched 150 | ext2` +- [x] `83147775` · L 71 / E 71.25 / Δ +0.25 · `hpcdb16839/gas | cavity wall | pitched insulated` +- [x] `72303880` · L 63 / E 62.75 / Δ -0.25 · `hpcdb17507/gas | cavity wall | pitched 150 | sec:room heaters` +- [x] `100022711136` · L 72 / E 71.75 / Δ -0.25 · `hpcdb16495/gas | cavity wall | (another dwelling` +- [x] `100090536443` · L 86 / E 86.25 / Δ +0.25 · `hpcdb17974/gas | cavity wall | pitched 175 | PV,ext1` +- [x] `83147745` · L 75 / E 75.25 / Δ +0.25 · `hpcdb16839/gas | cavity wall | (another dwelling | sec:room heaters` +- [x] `200002598542` · L 61 / E 61.25 / Δ +0.25 · `h402/elec | cavity wall | pitched 175 | ext1,sec:room heaters,whw903` +- [x] `100010572385` · L 62 / E 62.25 / Δ +0.25 · `hpcdb18817/gas | cavity wall | pitched 250 | ext2,sec:room heaters` +- [x] `100120874299` · L 72 / E 71.75 / Δ -0.25 · `hpcdb107234/elec | cavity wall | pitched 200 | mv2` +- [x] `100030332130` · L 51 / E 50.76 / Δ -0.24 · `h402/elec | cavity wall | pitched 100 | ext1,sec:room heaters,whw903` +- [x] `100060721835` · L 68 / E 67.76 / Δ -0.24 · `hpcdb16841/gas | system built | pitched 250` +- [x] `100011764055` · L 72 / E 71.76 / Δ -0.24 · `hpcdb15823/gas | cavity wall | pitched 200 | ext4,sec:room heaters` +- [x] `5067734` · L 67 / E 67.24 / Δ +0.24 · `hpcdb17726/gas | solid brick | (another dwelling` +- [x] `100021942272` · L 69 / E 69.24 / Δ +0.24 · `hpcdb17501/gas | solid brick | pitched insulated | RR` +- [x] `100050587694` · L 70 / E 70.24 / Δ +0.24 · `hpcdb18516/gas | cavity wall | pitched 270` +- [x] `100062195466` · L 73 / E 72.76 / Δ -0.24 · `h104/gas | cavity wall | (another dwelling` +- [x] `100100880740` · L 69 / E 69.24 / Δ +0.24 · `hpcdb17733/gas | cavity wall | pitched 250 | sec:room heaters` +- [x] `10090015178` · L 77 / E 76.76 / Δ -0.24 · `hpcdb16138/gas | cavity wall | pitched 300 | sec:room heaters` +- [x] `200001745207` · L 59 / E 59.24 / Δ +0.24 · `hpcdb1761/fuel28 | cavity wall | pitched 250 | ext1` +- [x] `250021965` · L 71 / E 71.24 / Δ +0.24 · `hpcdb10244/gas | cavity wall | pitched 200 | RR` +- [x] `100051346364` · L 63 / E 62.77 / Δ -0.23 · `hpcdb17983/gas | sandstone as | pitched no | sec:room heaters` +- [x] `100110203348` · L 66 / E 65.77 / Δ -0.23 · `hpcdb18559/gas | solid brick | pitched insulated | ext2` +- [x] `90164142` · L 80 / E 79.77 / Δ -0.23 · `h402/elec | cavity wall | (another dwelling | sec:portable electric,whw903` +- [x] `5870055814` · L 72 / E 72.23 / Δ +0.23 · `hpcdb15100/gas | cavity wall | pitched 125 | ext1` +- [x] `100040118353` · L 74 / E 73.77 / Δ -0.23 · `h402/elec | cavity wall | (another dwelling | ext1,sec:portable electric,whw903` +- [x] `100011419346` · L 69 / E 68.77 / Δ -0.23 · `hpcdb16839/gas | cavity wall | pitched insulated` +- [x] `10091678339` · L 95 / E 94.77 / Δ -0.23 · `hpcdb110399/elec | cavity wall | pitched insulated | PV,RR` +- [x] `100031779857` · L 64 / E 64.23 / Δ +0.23 · `hpcdb16210/gas | cavity wall | pitched 100 | ext1,sec:room heaters` +- [x] `100010470853` · L 67 / E 66.77 / Δ -0.23 · `hpcdb17505/gas | sandstone as | pitched 300 | ext1` +- [x] `10002147299` · L 71 / E 71.23 / Δ +0.23 · `hpcdb16810/gas | cavity wall | pitched 200 | consv2` +- [x] `200003864157` · L 73 / E 72.77 / Δ -0.23 · `hpcdb17039/gas | cavity wall | pitched 100 | ext1` +- [x] `6040073` · L 72 / E 71.77 / Δ -0.23 · `h301/coal-anth | solid brick | (another dwelling` +- [x] `100062014047` · L 76 / E 75.77 / Δ -0.23 · `hpcdb10547/gas | cavity wall | pitched 200` +- [x] `14003893` · L 69 / E 69.22 / Δ +0.22 · `hpcdb16931/gas | cavity wall | pitched 150` +- [x] `6721183` · L 82 / E 81.78 / Δ -0.22 · `h301/coal-anth | system built | (another dwelling` +- [x] `100040914059` · L 72 / E 72.22 / Δ +0.22 · `hpcdb10244/gas | solid brick | pitched 400+` +- [x] `10034523788` · L 69 / E 69.22 / Δ +0.22 · `hpcdb10459/gas+2mains | cavity wall | pitched 250 | RR,ext4,sec:room heaters,whw914` +- [x] `41223299` · L 76 / E 76.22 / Δ +0.22 · `h301/coal-anth | cavity wall | (another dwelling` +- [x] `100091119847` · L 79 / E 78.78 / Δ -0.22 · `hpcdb17105/fuel28 | cavity wall | pitched 300 | PV` +- [x] `100020453894` · L 63 / E 63.22 / Δ +0.22 · `hpcdb10321/gas | solid brick | pitched 200` +- [x] `100080166105` · L 57 / E 56.78 / Δ -0.22 · `hpcdb17507/gas | solid brick | pitched insulated | consv2,mv2` +- [x] `10013855655` · L 78 / E 78.22 / Δ +0.22 · `hpcdb10071/gas | cavity wall | pitched insulated | RR` +- [x] `100010842519` · L 69 / E 68.78 / Δ -0.22 · `hpcdb17507/gas | cavity wall | pitched insulated | RR` +- [x] `100061696040` · L 72 / E 71.78 / Δ -0.22 · `hpcdb19015/gas | cavity wall | pitched 300 | PV,ext1` +- [x] `100071098291` · L 59 / E 59.22 / Δ +0.22 · `hpcdb9571/gas | cavity wall | pitched 100 | sec:room heaters` +- [x] `10012437388` · L 62 / E 62.22 / Δ +0.22 · `hpcdb19014/gas | cavity wall | pitched no` +- [x] `100080154263` · L 70 / E 69.78 / Δ -0.22 · `hpcdb10444/gas | cavity wall | pitched 125` +- [x] `100030564080` · L 67 / E 66.79 / Δ -0.21 · `hpcdb15282/gas | solid brick | pitched 200 | ext1` +- [x] `100023491276` · L 74 / E 74.21 / Δ +0.21 · `hpcdb19051/gas | solid brick | (another dwelling` +- [x] `100071320610` · L 75 / E 74.79 / Δ -0.21 · `h104/gas | system built | (another dwelling` +- [x] `10090835500` · L 83 / E 82.79 / Δ -0.21 · `hpcdb16321/gas | cavity wall | pitched 270 | PV` +- [x] `10090383598` · L 76 / E 76.21 / Δ +0.21 · `h301/coal-anth | cavity wall | (another dwelling` +- [x] `100050841010` · L 69 / E 69.21 / Δ +0.21 · `hpcdb10546/gas | cavity wall | pitched 100 | ext1` +- [x] `10090481366` · L 77 / E 76.79 / Δ -0.21 · `h691/elec | cavity wall | (another dwelling | whw909` +- [x] `100070308464` · L 65 / E 65.21 / Δ +0.21 · `h104/gas | cavity wall | pitched 250 | PV` +- [x] `100012547948` · L 75 / E 74.79 / Δ -0.21 · `hpcdb17931/gas | cavity wall | pitched 100` +- [x] `100061316828` · L 69 / E 69.21 / Δ +0.21 · `hpcdb18403/gas | cavity wall | pitched 250 | mv2` +- [x] `83067520` · L 65 / E 64.79 / Δ -0.21 · `hpcdb18119/gas | cavity wall | pitched 200` +- [x] `100120954950` · L 66 / E 65.79 / Δ -0.21 · `hpcdb10243/gas | cavity wall | pitched 250` +- [x] `68100715` · L 39 / E 39.20 / Δ +0.20 · `h691/elec | cavity wall | (another dwelling | whw903` +- [x] `10090641681` · L 82 / E 82.20 / Δ +0.20 · `hpcdb17045/gas | cavity wall | (another dwelling` +- [x] `100070189144` · L 49 / E 49.20 / Δ +0.20 · `h402/elec | cavity wall | pitched 150 | consv2,sec:portable electric,whw903` +- [x] `6007002` · L 76 / E 76.20 / Δ +0.20 · `hpcdb17702/gas | system built | (another dwelling` +- [x] `100040926648` · L 70 / E 69.80 / Δ -0.20 · `hpcdb10244/gas | cavity wall | pitched 250 | ext1` +- [x] `100100299483` · L 67 / E 66.80 / Δ -0.20 · `hpcdb17507/gas | granite or | pitched 50 | ext3` +- [x] `100010391723` · L 66 / E 66.20 / Δ +0.20 · `hpcdb18760/gas | solid brick | pitched 100 | RR,ext2,sec:room heaters` +- [x] `100110431858` · L 84 / E 83.80 / Δ -0.20 · `hpcdb18119/gas | cavity wall | pitched 400+ | PV` +- [x] `30102683` · L 78 / E 77.80 / Δ -0.20 · `hpcdb9969/gas | cavity wall | roof room(s) | RR,consv2` +- [x] `200000800903` · L 75 / E 74.80 / Δ -0.20 · `hpcdb2040/gas | timber frame | pitched 150 | ext2` +- [x] `100060370575` · L 70 / E 69.80 / Δ -0.20 · `hpcdb17760/gas | cavity wall | (another dwelling` +- [x] `10022972987` · L 11 / E 11.20 / Δ +0.20 · `h691/elec | sandstone as | pitched 200 | ext1,sec:room heaters,whw921` +- [x] `10023219663` · L 78 / E 77.80 / Δ -0.20 · `h301/coal-anth | system built | (another dwelling` +- [x] `100020603440` · L 74 / E 73.80 / Δ -0.20 · `h104/gas | cavity wall | pitched insulated` +- [x] `45040195` · L 75 / E 75.19 / Δ +0.19 · `hpcdb18250/gas | cavity wall | pitched 100` +- [x] `653958` · L 79 / E 78.81 / Δ -0.19 · `hpcdb10071/gas | cavity wall | pitched 250` +- [x] `10096575045` · L 44 / E 44.19 / Δ +0.19 · `h691/elec | cavity wall | (another dwelling | whw909` +- [x] `217058896` · L 68 / E 67.81 / Δ -0.19 · `h301/coal-anth | solid brick | (another dwelling` +- [x] `100050823534` · L 73 / E 73.19 / Δ +0.19 · `hpcdb18204/gas | cavity wall | pitched 75 | consv2,ext1` +- [x] `100061736655` · L 74 / E 73.81 / Δ -0.19 · `hpcdb18911/gas | cavity wall | pitched 400+ | PV,ext1,mv2` +- [x] `100040006258` · L 74 / E 74.19 / Δ +0.19 · `hpcdb15834/gas | granite or | pitched 300` +- [x] `100070051157` · L 72 / E 72.19 / Δ +0.19 · `hpcdb17998/gas | solid brick | pitched 75` +- [x] `100050722945` · L 67 / E 66.81 / Δ -0.19 · `hpcdb19158/gas | cavity wall | pitched insulated | ext2,sec:room heaters` +- [x] `100110491552` · L 70 / E 70.19 / Δ +0.19 · `h301/coal-anth | cavity wall | (another dwelling` +- [x] `100061721679` · L 71 / E 71.19 / Δ +0.19 · `hpcdb10462/gas | cavity wall | pitched 250 | sec:room heaters` +- [x] `565298` · L 68 / E 67.82 / Δ -0.18 · `hpcdb15049/gas | cavity wall | pitched insulated` +- [x] `100090243980` · L 68 / E 68.18 / Δ +0.18 · `hpcdb10327/gas | cavity wall | pitched 100 | ext1` +- [x] `100100328304` · L 62 / E 61.82 / Δ -0.18 · `hpcdb18768/gas | cavity wall | pitched 270 | ext1` +- [x] `10001131873` · L 79 / E 78.82 / Δ -0.18 · `h402/elec | granite or | (another dwelling | sec:portable electric,whw903` +- [x] `63123708` · L 64 / E 63.82 / Δ -0.18 · `hpcdb16977/gas | cavity wall | pitched 300 | ext2,sec:room heaters` +- [x] `90100875` · L 57 / E 56.82 / Δ -0.18 · `hpcdb17507/gas | solid brick | pitched insulated | ext2,sec:room heaters` +- [x] `128012524` · L 56 / E 56.18 / Δ +0.18 · `hpcdb10265/gas | cavity wall | pitched insulated | ext1,sec:room heaters` +- [x] `100010830948` · L 69 / E 68.82 / Δ -0.18 · `hpcdb18559/gas | cavity wall | pitched 150 | consv3` +- [x] `21060468` · L 67 / E 67.18 / Δ +0.18 · `hpcdb10112/gas | system built | pitched 270 | mv2` +- [x] `200004250345` · L 72 / E 72.18 / Δ +0.18 · `hpcdb4035/gas | cavity wall | pitched unknown | ext1,sec:room heaters` +- [x] `11038944` · L 68 / E 67.83 / Δ -0.17 · `h104/gas | cavity wall | pitched insulated` +- [x] `100090656436` · L 72 / E 72.17 / Δ +0.17 · `h104/gas | cavity wall | pitched unknown | ext2` +- [x] `100121044742` · L 72 / E 72.17 / Δ +0.17 · `hpcdb17505/gas | cavity wall | pitched 100 | ext1` +- [x] `100031540900` · L 68 / E 68.17 / Δ +0.17 · `hpcdb17501/gas | cavity wall | pitched 250` +- [x] `50010028` · L 87 / E 87.17 / Δ +0.17 · `hpcdb16987/gas | cavity wall | pitched 250 | PV,ext2` +- [x] `100061741005` · L 74 / E 73.83 / Δ -0.17 · `hpcdb16210/gas | cavity wall | pitched 200 | RR,ext1` +- [x] `100110589642` · L 72 / E 72.17 / Δ +0.17 · `hpcdb16137/gas | cavity wall | pitched 200` +- [x] `100041131293` · L 76 / E 76.17 / Δ +0.17 · `hpcdb15502/gas | cavity wall | (another dwelling` +- [x] `21072798` · L 72 / E 71.83 / Δ -0.17 · `hpcdb19007/gas | cavity wall | (another dwelling` +- [x] `10094975827` · L 66 / E 65.83 / Δ -0.17 · `hpcdb18559/gas | solid brick | pitched insulated` +- [x] `10009830624` · L 78 / E 77.83 / Δ -0.17 · `hpcdb15706/gas | cavity wall | pitched 300` +- [x] `39023204` · L 68 / E 68.17 / Δ +0.17 · `hpcdb18155/gas | solid brick | pitched insulated | ext2` +- [x] `100060428487` · L 71 / E 71.17 / Δ +0.17 · `hpcdb18496/gas | cavity wall | pitched 100 | consv2,ext1` +- [x] `100121053407` · L 73 / E 72.83 / Δ -0.17 · `hpcdb16919/gas | cavity wall | pitched 300 | consv3` +- [x] `202229245` · L 67 / E 66.83 / Δ -0.17 · `hpcdb18224/gas | solid brick | pitched unknown | ext1` +- [x] `10000208277` · L 75 / E 75.17 / Δ +0.17 · `hpcdb16135/gas | cavity wall | pitched 225 | consv2` +- [x] `10094257000` · L 55 / E 55.16 / Δ +0.16 · `h691/elec | timber frame | pitched insulated | ext1,sec:room heaters,whw909` +- [x] `100040411293` · L 64 / E 64.16 / Δ +0.16 · `hpcdb16986/gas | cavity wall | pitched 150` +- [x] `200003712915` · L 73 / E 72.84 / Δ -0.16 · `hpcdb16396/gas | cavity wall | pitched 400+ | mv2` +- [x] `4510002139` · L 73 / E 72.84 / Δ -0.16 · `hpcdb18204/gas | solid brick | pitched insulated | RR,ext2` +- [x] `200000920341` · L 69 / E 69.16 / Δ +0.16 · `h104/gas | cavity wall | pitched 270` +- [x] `100021038893` · L 92 / E 91.84 / Δ -0.16 · `hpcdb18907/gas | cavity wall | pitched 300 | PV` +- [x] `100020435702` · L 64 / E 64.16 / Δ +0.16 · `hpcdb10328/gas | cavity wall | pitched insulated` +- [x] `200004517412` · L 74 / E 73.84 / Δ -0.16 · `hpcdb18560/gas | cavity wall | pitched 400+ | sec:room heaters` +- [x] `100020443300` · L 69 / E 68.84 / Δ -0.16 · `hpcdb17507/gas | cavity wall | pitched 100 | ext1` +- [x] `200000387611` · L 71 / E 71.16 / Δ +0.16 · `hpcdb15019/gas | cavity wall | pitched 100 | consv3,sec:room heaters` +- [x] `5870031233` · L 75 / E 74.84 / Δ -0.16 · `h401/elec | cavity wall | (another dwelling | sec:portable electric,whw903` +- [x] `100120733015` · L 65 / E 65.16 / Δ +0.16 · `hpcdb16093/gas | solid brick | pitched 125 | ext1,sec:room heaters` +- [x] `100120629168` · L 70 / E 70.16 / Δ +0.16 · `hpcdb9897/gas | cavity wall | pitched 100 | consv2,ext2` +- [x] `100100713371` · L 65 / E 65.16 / Δ +0.16 · `hpcdb17507/gas | cavity wall | pitched 125 | consv4` +- [x] `10033058144` · L 65 / E 65.16 / Δ +0.16 · `hpcdb18560/gas | cavity wall | pitched 200 | ext1` +- [x] `200002626191` · L 63 / E 63.16 / Δ +0.16 · `hpcdb17505/gas | cavity wall | pitched 200 | sec:room heaters` +- [x] `148007532` · L 61 / E 60.84 / Δ -0.16 · `hpcdb1396/gas | solid brick | pitched insulated | RR,consv2,ext1` +- [x] `100010043608` · L 56 / E 56.16 / Δ +0.16 · `h401/elec | cavity wall | pitched 200 | PV,RR,consv2,ext1,sec:room heaters,whw903` +- [x] `100022232588` · L 56 / E 56.16 / Δ +0.16 · `h102/gas | solid brick | pitched 25 | ext1` +- [x] `100110450961` · L 69 / E 68.84 / Δ -0.16 · `h104/gas | sandstone as | pitched 250 | ext1` +- [x] `11071248` · L 78 / E 77.84 / Δ -0.16 · `h402/elec | solid brick | (another dwelling | sec:portable electric,whw903` +- [x] `10014540481` · L 81 / E 81.15 / Δ +0.15 · `hpcdb16400/gas | timber frame | roof room(s) | RR` +- [x] `100061860491` · L 69 / E 68.85 / Δ -0.15 · `hpcdb18824/gas | cavity wall | pitched 250 | consv3` +- [x] `100121065133` · L 81 / E 80.85 / Δ -0.15 · `hpcdb17511/gas | cavity wall | (another dwelling` +- [x] `231757` · L 73 / E 73.15 / Δ +0.15 · `hpcdb17505/gas | sandstone as | pitched 75 | ext1` +- [x] `200001694377` · L 45 / E 44.85 / Δ -0.15 · `hpcdb18429/fuel28 | granite or | pitched 250 | ext3,sec:room heaters` +- [x] `100062331529` · L 81 / E 81.15 / Δ +0.15 · `hpcdb18908/gas | cavity wall | (another dwelling` +- [x] `10033267429` · L 57 / E 56.85 / Δ -0.15 · `h691/elec | system built | (another dwelling | whw903` +- [x] `100090372586` · L 71 / E 71.15 / Δ +0.15 · `h102/gas | cavity wall | pitched 300 | PV,consv3` +- [x] `100060587363` · L 62 / E 62.15 / Δ +0.15 · `hpcdb10462/gas | solid brick | pitched 250 | consv2,ext2,sec:room heaters` +- [x] `207037318` · L 59 / E 58.85 / Δ -0.15 · `h699/fuel0 | cavity wall | (another dwelling | sec:portable electric,whw903` +- [x] `100051048523` · L 67 / E 67.15 / Δ +0.15 · `hpcdb17507/gas | cavity wall | pitched 250 | ext1,sec:room heaters` +- [x] `551000` · L 72 / E 72.15 / Δ +0.15 · `h104/gas | cavity wall | pitched 150` +- [x] `202137997` · L 69 / E 68.85 / Δ -0.15 · `hpcdb10443/gas | cavity wall | (another dwelling` +- [x] `10090368756` · L 80 / E 79.85 / Δ -0.15 · `hpcdb17033/gas | cavity wall | pitched insulated | RR,ext3,sec:room heaters` +- [x] `100120881623` · L 53 / E 53.15 / Δ +0.15 · `h158/heatnet+2mains | solid brick | pitched insulated | ext4,sec:room heaters,whw903` +- [x] `5870106813` · L 78 / E 78.15 / Δ +0.15 · `hpcdb17505/gas | solid brick | pitched 300` +- [x] `100000056169` · L 62 / E 61.85 / Δ -0.15 · `hpcdb18559/gas | cavity wall | pitched 150 | consv2,ext1` +- [x] `21029519` · L 67 / E 66.85 / Δ -0.15 · `hpcdb17983/gas | cavity wall | pitched 270 | sec:room heaters` +- [x] `100110469288` · L 67 / E 67.15 / Δ +0.15 · `hpcdb18155/gas | cavity wall | pitched 100 | sec:room heaters` +- [x] `10022949096` · L 74 / E 73.85 / Δ -0.15 · `hpcdb17507/gas | cavity wall | pitched 200` +- [x] `100000341714` · L 64 / E 64.15 / Δ +0.15 · `hpcdb15709/gas | system built | pitched 270` +- [x] `100021046998` · L 69 / E 69.15 / Δ +0.15 · `hpcdb10327/gas | solid brick | pitched 100 | ext1` +- [x] `100080902546` · L 79 / E 78.85 / Δ -0.15 · `hpcdb17696/gas | solid brick | pitched insulated | RR,ext1` +- [x] `100021286876` · L 66 / E 66.15 / Δ +0.15 · `hpcdb17503/gas | solid brick | pitched insulated` +- [x] `100070056707` · L 72 / E 72.14 / Δ +0.14 · `hpcdb17505/gas | system built | (another dwelling` +- [x] `72513535` · L 70 / E 70.14 / Δ +0.14 · `h691/elec | system built | (another dwelling | whw903` +- [x] `100080166923` · L 68 / E 67.86 / Δ -0.14 · `hpcdb18826/gas | cavity wall | pitched 75 | consv3,ext1` +- [x] `100023483089` · L 77 / E 76.86 / Δ -0.14 · `h409/elec | system built | (another dwelling | sec:portable electric,whw903` +- [x] `10096537371` · L 37 / E 37.14 / Δ +0.14 · `h691/elec | solid brick | pitched 200 | ext1,whw909` +- [x] `100011476438` · L 57 / E 56.86 / Δ -0.14 · `h402/elec | cavity wall | pitched 125 | sec:room heaters,whw903` +- [x] `100090106879` · L 70 / E 69.86 / Δ -0.14 · `hpcdb16836/gas | cavity wall | pitched 300 | sec:room heaters` +- [x] `10003800267` · L 72 / E 71.86 / Δ -0.14 · `h301/coal-anth | cavity wall | (another dwelling` +- [x] `100060412840` · L 66 / E 66.14 / Δ +0.14 · `h102/gas | cavity wall | pitched 50 | ext1` +- [x] `2465082925` · L 66 / E 66.14 / Δ +0.14 · `hpcdb16848/gas | cavity wall | pitched 250` +- [x] `100030001912` · L 69 / E 68.86 / Δ -0.14 · `hpcdb19080/gas | cavity wall | pitched 270 | sec:room heaters` +- [x] `100010757788` · L 68 / E 68.14 / Δ +0.14 · `hpcdb18119/gas | cavity wall | pitched 100 | sec:portable electric` +- [x] `200001601290` · L 61 / E 60.86 / Δ -0.14 · `hpcdb16394/gas | cavity wall | pitched 270 | sec:room heaters` +- [x] `100100085877` · L 72 / E 72.14 / Δ +0.14 · `hpcdb10122/gas | cavity wall | pitched 270 | ext2,sec:room heaters` +- [x] `10002473454` · L 74 / E 73.86 / Δ -0.14 · `hpcdb19006/gas | cavity wall | pitched 300` +- [x] `100121097651` · L 72 / E 72.14 / Δ +0.14 · `hpcdb18433/gas | cavity wall | pitched 350 | sec:room heaters` +- [x] `100040638671` · L 80 / E 80.14 / Δ +0.14 · `hpcdb10356/gas | cavity wall | pitched 200 | PV,ext4,sec:room heaters` +- [x] `10002096859` · L 79 / E 79.14 / Δ +0.14 · `hpcdb16835/gas | cavity wall | pitched 300 | ext2` +- [x] `100071033049` · L 62 / E 61.86 / Δ -0.14 · `hpcdb18908/gas | cavity wall | pitched 150 | sec:room heaters` +- [x] `100022619802` · L 79 / E 79.13 / Δ +0.13 · `hpcdb16375/gas | cavity wall | flat insulated` +- [x] `100081154129` · L 65 / E 64.87 / Δ -0.13 · `h408/elec | cavity wall | pitched 100 | consv2,whw903` +- [x] `10009274535` · L 31 / E 30.87 / Δ -0.13 · `h126/fuel28 | sandstone as | pitched 50 | consv4,ext2,sec:room heaters` +- [x] `1710040599` · L 70 / E 70.13 / Δ +0.13 · `h102/gas | cavity wall | pitched 100 | RR,sec:room heaters` +- [x] `100050824478` · L 72 / E 71.87 / Δ -0.13 · `hpcdb17615/gas | cavity wall | pitched 200 | ext1` +- [x] `100060005933` · L 71 / E 70.87 / Δ -0.13 · `h102/gas | cavity wall | pitched 175` +- [x] `100080706341` · L 62 / E 61.87 / Δ -0.13 · `hpcdb18045/gas | system built | pitched 75` +- [x] `200003644880` · L 59 / E 58.87 / Δ -0.13 · `hpcdb15290/gas | solid brick | pitched insulated | ext2,sec:room heaters` +- [x] `46083795` · L 78 / E 78.13 / Δ +0.13 · `hpcdb16930/gas | cavity wall | (another dwelling` +- [x] `10090342779` · L 81 / E 80.87 / Δ -0.13 · `hpcdb10327/gas | timber frame | pitched 300` +- [x] `77068182` · L 68 / E 68.13 / Δ +0.13 · `hpcdb10242/gas | cavity wall | pitched 75 | ext2` +- [x] `11077408` · L 67 / E 67.13 / Δ +0.13 · `hpcdb10356/gas | cavity wall | pitched 100 | sec:room heaters` +- [x] `100100869589` · L 62 / E 62.13 / Δ +0.13 · `hpcdb17983/gas | cavity wall | pitched 270 | mv5,sec:room heaters` +- [x] `77018431` · L 65 / E 64.87 / Δ -0.13 · `hpcdb17987/gas | cavity wall | pitched insulated | ext1` +- [x] `63064761` · L 72 / E 72.13 / Δ +0.13 · `hpcdb18907/gas | cavity wall | (another dwelling` +- [x] `100011790131` · L 60 / E 60.13 / Δ +0.13 · `hpcdb15709/gas | solid brick | pitched 270 | ext1,sec:room heaters` +- [x] `100011756441` · L 73 / E 73.13 / Δ +0.13 · `hpcdb18511/gas | cavity wall | pitched 75` +- [x] `202028377` · L 66 / E 66.13 / Δ +0.13 · `hpcdb17548/gas | solid brick | pitched 75` +- [x] `100090533643` · L 85 / E 85.13 / Δ +0.13 · `hpcdb17507/gas | cavity wall | pitched insulated | PV,ext1` +- [x] `100090335261` · L 70 / E 70.13 / Δ +0.13 · `hpcdb17974/gas | cavity wall | (another dwelling` +- [x] `100050048691` · L 58 / E 57.87 / Δ -0.13 · `hpcdb18991/fuel28 | solid brick | pitched 250 | sec:room heaters` +- [x] `100061898123` · L 70 / E 70.13 / Δ +0.13 · `hpcdb17511/gas | solid brick | pitched 100` +- [x] `38149813` · L 74 / E 73.87 / Δ -0.13 · `h104/gas | solid brick | pitched 350 | ext1` +- [x] `10014619006` · L 77 / E 77.13 / Δ +0.13 · `hpcdb17045/gas | cavity wall | pitched 350 | mv2` +- [x] `100091201592` · L 71 / E 70.87 / Δ -0.13 · `hpcdb10464/gas | cavity wall | pitched 150` +- [x] `100040888018` · L 75 / E 75.12 / Δ +0.12 · `hpcdb10265/gas | cavity wall | pitched 250` +- [x] `100030350883` · L 72 / E 72.12 / Δ +0.12 · `hpcdb17660/gas | solid brick | pitched 300 | ext1` +- [x] `12151106` · L 71 / E 70.88 / Δ -0.12 · `hpcdb17929/gas | solid brick | (another dwelling | ext1` +- [x] `22151573` · L 69 / E 68.88 / Δ -0.12 · `hpcdb17991/gas | solid brick | (another dwelling | ext1,sec:room heaters` +- [x] `10008151417` · L 75 / E 75.12 / Δ +0.12 · `hpcdb18619/gas | cavity wall | pitched 225 | ext1,sec:room heaters` +- [x] `100031658267` · L 67 / E 66.88 / Δ -0.12 · `hpcdb19019/gas | cavity wall | pitched 300` +- [x] `100010155093` · L 72 / E 71.88 / Δ -0.12 · `hpcdb15884/gas | cavity wall | pitched 200` +- [x] `10023692117` · L 71 / E 70.88 / Δ -0.12 · `hpcdb9228/gas | system built | flat insulated | ext3,mv3` +- [x] `36046440` · L 78 / E 77.88 / Δ -0.12 · `hpcdb16327/gas | cavity wall | pitched 250` +- [x] `10012226756` · L 79 / E 79.12 / Δ +0.12 · `hpcdb16682/gas | cavity wall | pitched 250` +- [x] `77068828` · L 69 / E 69.12 / Δ +0.12 · `hpcdb10265/gas | cavity wall | pitched 200 | ext1,sec:room heaters` +- [x] `100031461590` · L 62 / E 62.12 / Δ +0.12 · `hpcdb2038/gas | cavity wall | pitched 100 | sec:room heaters` +- [x] `261969` · L 75 / E 75.12 / Δ +0.12 · `hpcdb17683/gas | solid brick | (another dwelling | ext1` +- [x] `100090986049` · L 65 / E 65.12 / Δ +0.12 · `hpcdb18830/gas | cavity wall | pitched 200 | sec:room heaters` +- [x] `10003926370` · L 62 / E 62.12 / Δ +0.12 · `hpcdb860/fuel28 | cavity wall | pitched 100 | PV,consv2,sec:room heaters` +- [x] `37015003` · L 69 / E 69.11 / Δ +0.11 · `hpcdb18218/gas | cavity wall | pitched unknown | ext1` +- [x] `4510733560` · L 77 / E 76.89 / Δ -0.11 · `h113/gas | cavity wall | (another dwelling` +- [x] `303001209` · L 70 / E 69.89 / Δ -0.11 · `h301/coal-anth | cavity wall | pitched 225` +- [x] `217041293` · L 83 / E 83.11 / Δ +0.11 · `h404/elec | cavity wall | (another dwelling | sec:portable electric,whw903` +- [x] `100030534734` · L 5 / E 4.89 / Δ -0.11 · `h631/LPG | sandstone as | pitched insulated | RR,ext1,sec:portable electric,whw903` +- [x] `10008651218` · L 78 / E 78.11 / Δ +0.11 · `hpcdb10243/gas | cavity wall | pitched 250` +- [x] `100091151352` · L 72 / E 71.89 / Δ -0.11 · `hpcdb10122/gas | solid brick | pitched 270 | ext1` +- [x] `452029430` · L 72 / E 71.89 / Δ -0.11 · `hpcdb10241/gas | cavity wall | pitched 100` +- [x] `100050435392` · L 69 / E 68.89 / Δ -0.11 · `hpcdb18047/gas | cavity wall | pitched 200 | ext1` +- [x] `100060864327` · L 75 / E 75.11 / Δ +0.11 · `hpcdb19006/gas | cavity wall | (another dwelling` +- [x] `100011385976` · L 71 / E 71.11 / Δ +0.11 · `hpcdb16839/gas | cavity wall | pitched 300` +- [x] `10090929166` · L 64 / E 63.89 / Δ -0.11 · `h691/elec | cavity wall | pitched insulated | whw909` +- [x] `100062012` · L 72 / E 72.11 / Δ +0.11 · `hpcdb17117/gas | solid brick | (another dwelling` +- [x] `100030293609` · L 60 / E 59.89 / Δ -0.11 · `hpcdb10328/gas | cavity wall | pitched 200 | sec:room heaters` +- [x] `200164795` · L 82 / E 81.89 / Δ -0.11 · `hpcdb16400/gas | cavity wall | pitched insulated | PV,RR,ext1` +- [x] `100022628872` · L 74 / E 74.11 / Δ +0.11 · `hpcdb18908/gas | cavity wall | (another dwelling` +- [x] `10014460343` · L 79 / E 78.89 / Δ -0.11 · `hpcdb15754/gas | cavity wall | pitched 250` +- [x] `10001142950` · L 69 / E 69.11 / Δ +0.11 · `h301/coal-anth | sandstone as | (another dwelling` +- [x] `10014526109` · L 59 / E 59.11 / Δ +0.11 · `h126/fuel28 | solid brick | flat insulated | consv2,ext1,sec:room heaters` +- [x] `38137278` · L 69 / E 69.11 / Δ +0.11 · `hpcdb18907/gas | cavity wall | pitched 400+ | ext2,mv2,sec:room heaters` +- [x] `100040565259` · L 66 / E 66.11 / Δ +0.11 · `hpcdb18221/gas | cavity wall | pitched 300 | ext1` +- [x] `100110437510` · L 87 / E 87.11 / Δ +0.11 · `hpcdb15709/gas | cavity wall | pitched 270 | PV,ext1,mv2,sec:room heaters` +- [x] `100051116259` · L 70 / E 70.11 / Δ +0.11 · `hpcdb15520/gas | cavity wall | pitched insulated | consv4,ext1,sec:room heaters` +- [x] `100031466637` · L 52 / E 51.89 / Δ -0.11 · `h409/elec | solid brick | pitched no | ext3,sec:room heaters,whw903` +- [x] `10070089948` · L 64 / E 64.11 / Δ +0.11 · `hpcdb19094/gas | sandstone as | pitched 270 | ext2,sec:room heaters` +- [x] `100090866399` · L 79 / E 78.89 / Δ -0.11 · `hpcdb18435/gas | cavity wall | pitched 200 | PV,ext2,sec:room heaters` +- [x] `100060052603` · L 58 / E 57.89 / Δ -0.11 · `hpcdb17553/gas | cavity wall | pitched 100 | consv2,sec:room heaters` +- [x] `100010170141` · L 61 / E 60.89 / Δ -0.11 · `hpcdb4045/gas | cavity wall | pitched 250 | ext1,sec:room heaters` +- [x] `100031245724` · L 69 / E 68.90 / Δ -0.10 · `hpcdb17660/gas | cavity wall | pitched 200` +- [x] `24029210` · L 75 / E 75.10 / Δ +0.10 · `hpcdb15704/gas | cavity wall | pitched 250 | sec:room heaters` +- [x] `217079598` · L 77 / E 77.10 / Δ +0.10 · `hpcdb18821/gas | system built | (another dwelling` +- [x] `10008290975` · L 79 / E 79.10 / Δ +0.10 · `hpcdb17973/gas | cavity wall | (another dwelling` +- [x] `100040140820` · L 61 / E 61.10 / Δ +0.10 · `hpcdb1768/fuel28 | cavity wall | pitched 225 | ext1` +- [x] `100090121779` · L 73 / E 73.10 / Δ +0.10 · `hpcdb18089/gas | cavity wall | pitched 125` +- [x] `42180235` · L 77 / E 77.10 / Δ +0.10 · `hpcdb18559/gas | cavity wall | (another dwelling` +- [x] `100061414159` · L 65 / E 65.10 / Δ +0.10 · `hpcdb17313/gas | solid brick | pitched 100 | RR,consv4,ext4` +- [x] `207156543` · L 69 / E 69.10 / Δ +0.10 · `hpcdb18773/gas | solid brick | pitched insulated` +- [x] `10071154302` · L 78 / E 77.90 / Δ -0.10 · `h301/coal-anth | system built | (another dwelling` +- [x] `10004779730` · L 67 / E 67.10 / Δ +0.10 · `hpcdb16398/gas | cavity wall | pitched 300 | ext1,sec:room heaters` +- [x] `10001005083` · L 65 / E 65.10 / Δ +0.10 · `hpcdb16840/gas | solid brick | (another dwelling` +- [x] `100040960726` · L 89 / E 88.90 / Δ -0.10 · `hpcdb16212/gas | cavity wall | pitched 250 | PV,consv3,ext4,sec:room heaters` +- [x] `100100694586` · L 69 / E 68.90 / Δ -0.10 · `hpcdb10315/gas | solid brick | pitched 75 | ext1` +- [x] `100061607458` · L 69 / E 68.90 / Δ -0.10 · `hpcdb18909/gas | cavity wall | pitched 100` +- [x] `10023410348` · L 68 / E 68.10 / Δ +0.10 · `h301/coal-anth | cavity wall | flat limited` +- [x] `207104025` · L 78 / E 78.10 / Δ +0.10 · `h104/gas | solid brick | pitched unknown` +- [x] `1775077083` · L 72 / E 72.10 / Δ +0.10 · `hpcdb17974/gas | cavity wall | pitched insulated | ext1` +- [x] `100120918121` · L 83 / E 83.10 / Δ +0.10 · `hpcdb18618/gas | cavity wall | pitched 200 | PV,ext1` +- [x] `200000766345` · L 70 / E 70.10 / Δ +0.10 · `hpcdb18827/gas | sandstone as | pitched 270 | ext1,sec:room heaters` +- [x] `100050413774` · L 63 / E 63.10 / Δ +0.10 · `h104/gas | sandstone as | pitched insulated | ext1` +- [x] `100021531772` · L 68 / E 68.10 / Δ +0.10 · `hpcdb18560/gas | solid brick | pitched 100 | ext1` +- [x] `100020596099` · L 50 / E 49.90 / Δ -0.10 · `hpcdb17550/gas | solid brick | pitched insulated | sec:room heaters` +- [x] `38191704` · L 73 / E 73.10 / Δ +0.10 · `hpcdb18107/gas | cavity wall | pitched 125 | consv3` +- [x] `10014478958` · L 77 / E 76.91 / Δ -0.09 · `hpcdb16137/gas | timber frame | pitched 250` +- [x] `100051276007` · L 63 / E 63.09 / Δ +0.09 · `hpcdb16212/gas | cavity wall | pitched 225 | sec:room heaters` +- [x] `100021503967` · L 68 / E 67.91 / Δ -0.09 · `hpcdb18509/gas | solid brick | pitched 100 | ext1` +- [x] `100080331733` · L 72 / E 71.91 / Δ -0.09 · `hpcdb17816/gas | cavity wall | pitched 100 | ext1` +- [x] `100100480670` · L 67 / E 67.09 / Δ +0.09 · `h104/gas | sandstone as | pitched 300` +- [x] `23014695` · L 78 / E 77.91 / Δ -0.09 · `hpcdb18560/gas | system built | pitched 250` +- [x] `10003971523` · L 78 / E 78.09 / Δ +0.09 · `hpcdb17683/gas | cavity wall | flat insulated` +- [x] `523424` · L 23 / E 22.91 / Δ -0.09 · `h104/biogas | sandstone as | pitched insulated | ext1,sec:portable electric` +- [x] `10023385904` · L 77 / E 76.91 / Δ -0.09 · `hpcdb15239/gas | cavity wall | pitched 300 | consv2` +- [x] `100070621229` · L 72 / E 71.91 / Δ -0.09 · `hpcdb15709/gas | solid brick | pitched unknown` +- [x] `5870046999` · L 73 / E 73.09 / Δ +0.09 · `hpcdb17973/gas | cavity wall | (another dwelling` +- [x] `100010384681` · L 75 / E 74.91 / Δ -0.09 · `hpcdb19080/gas | cavity wall | pitched 400+ | PV,ext1,sec:room heaters` +- [x] `100022536385` · L 71 / E 70.92 / Δ -0.08 · `hpcdb17505/gas | solid brick | pitched 300` +- [x] `100031433810` · L 70 / E 70.08 / Δ +0.08 · `hpcdb19094/gas | cavity wall | (another dwelling | ext1,sec:room heaters` +- [x] `1710029098` · L 69 / E 69.08 / Δ +0.08 · `hpcdb17958/gas | cavity wall | pitched 270 | sec:room heaters` +- [x] `100040884417` · L 70 / E 70.08 / Δ +0.08 · `hpcdb10328/gas | cavity wall | (another dwelling` +- [x] `100080706339` · L 65 / E 65.08 / Δ +0.08 · `hpcdb16396/gas | system built | pitched 250` +- [x] `10090018805` · L 77 / E 76.92 / Δ -0.08 · `hpcdb15017/gas | cavity wall | pitched 250` +- [x] `100034336` · L 60 / E 60.08 / Δ +0.08 · `hpcdb17815/gas | solid brick | pitched 100` +- [x] `line979` · L 71 / E 71.08 / Δ +0.08 · `hpcdb15710/gas | cavity wall | pitched insulated` +- [x] `100070431508` · L 64 / E 63.92 / Δ -0.08 · `hpcdb18119/gas | solid brick | pitched insulated | sec:room heaters` +- [x] `100110515214` · L 73 / E 72.92 / Δ -0.08 · `hpcdb17741/gas | cavity wall | pitched 400+ | mv2` +- [x] `100100415869` · L 67 / E 67.08 / Δ +0.08 · `h104/gas | cavity wall | pitched 270` +- [x] `1775078613` · L 72 / E 71.92 / Δ -0.08 · `h104/gas | cavity wall | pitched 150` +- [x] `100091367939` · L 62 / E 62.08 / Δ +0.08 · `h102/gas | cavity wall | pitched 150 | ext2,sec:room heaters` +- [x] `100110175491` · L 74 / E 74.08 / Δ +0.08 · `h102/gas | cavity wall | pitched 150 | ext1` +- [x] `10011830488` · L 75 / E 75.08 / Δ +0.08 · `h102/gas | cavity wall | pitched insulated | consv2` +- [x] `line621` · L 78 / E 78.07 / Δ +0.07 · `hpcdb17679/gas | solid brick | pitched insulated` +- [x] `11063823` · L 66 / E 66.07 / Δ +0.07 · `hpcdb17507/gas | cavity wall | pitched unknown` +- [x] `21093278` · L 73 / E 72.93 / Δ -0.07 · `hpcdb16573/gas | cavity wall | pitched 100` +- [x] `100051035527` · L 74 / E 73.93 / Δ -0.07 · `hpcdb16842/gas | cavity wall | pitched 300 | sec:room heaters` +- [x] `100010835776` · L 69 / E 68.93 / Δ -0.07 · `hpcdb17942/gas | cavity wall | pitched 270 | sec:room heaters` +- [x] `10090019843` · L 76 / E 75.93 / Δ -0.07 · `hpcdb17615/gas | cavity wall | pitched 100` +- [x] `10013351333` · L 70 / E 69.93 / Δ -0.07 · `hpcdb18759/gas | cavity wall | pitched insulated | consv4` +- [x] `100080359994` · L 72 / E 72.07 / Δ +0.07 · `hpcdb15462/gas | cavity wall | pitched insulated | ext1` +- [x] `100080794353` · L 73 / E 73.07 / Δ +0.07 · `hpcdb19027/gas | cavity wall | pitched 200 | ext1` +- [x] `1710007756` · L 76 / E 76.07 / Δ +0.07 · `h104/gas | cavity wall | pitched 250 | RR,ext1,sec:room heaters` +- [x] `100080985468` · L 71 / E 71.07 / Δ +0.07 · `hpcdb17505/gas | cavity wall | pitched 125` +- [x] `32067719` · L 67 / E 66.93 / Δ -0.07 · `h104/gas | cavity wall | pitched 150 | consv2,ext1` +- [x] `100011465293` · L 47 / E 46.93 / Δ -0.07 · `h115/gas | cavity wall | pitched insulated | ext1` +- [x] `line77` · L 76 / E 76.07 / Δ +0.07 · `h401/elec | cavity wall | (another dwelling | sec:portable electric,whw903` +- [x] `100061690837` · L 75 / E 75.07 / Δ +0.07 · `hpcdb18862/gas | cavity wall | pitched 50` +- [x] `100010434688` · L 72 / E 72.07 / Δ +0.07 · `hpcdb17956/gas | sandstone as | pitched insulated` +- [x] `6077577` · L 83 / E 82.93 / Δ -0.07 · `hpcdb16840/gas | cavity wall | (another dwelling` +- [x] `48102004` · L 71 / E 70.94 / Δ -0.06 · `hpcdb17501/gas | solid brick | pitched insulated` +- [x] `10032999260` · L 76 / E 76.06 / Δ +0.06 · `hpcdb18619/gas | cavity wall | pitched 150` +- [x] `100061778601` · L 68 / E 67.94 / Δ -0.06 · `hpcdb18861/gas | cavity wall | pitched 200 | ext2,mv5` +- [x] `10034910386` · L 74 / E 74.06 / Δ +0.06 · `h113/gas | solid brick | (another dwelling` +- [x] `5039119` · L 70 / E 70.06 / Δ +0.06 · `h409/elec | system built | (another dwelling | sec:portable electric,whw903` +- [x] `50034918` · L 77 / E 77.06 / Δ +0.06 · `hpcdb16777/gas | cavity wall | pitched 200` +- [x] `100061621130` · L 25 / E 24.94 / Δ -0.06 · `h102/fuel27 | timber frame | pitched 100 | RR,ext2,sec:room heaters` +- [x] `100121228675` · L 60 / E 60.06 / Δ +0.06 · `h301/coal-anth | sandstone as | (another dwelling` +- [x] `100051248025` · L 57 / E 56.94 / Δ -0.06 · `hpcdb19038/gas | cavity wall | pitched insulated | sec:room heaters` +- [x] `100040663826` · L 67 / E 66.95 / Δ -0.05 · `hpcdb17001/gas | cavity wall | pitched 200` +- [x] `100110441268` · L 75 / E 75.05 / Δ +0.05 · `hpcdb10444/gas | solid brick | pitched 200` +- [x] `310012948` · L 74 / E 74.05 / Δ +0.05 · `hpcdb16454/gas | cavity wall | pitched 200` +- [x] `100010129331` · L 70 / E 69.95 / Δ -0.05 · `hpcdb17843/gas | solid brick | pitched insulated | ext2` +- [x] `10090880711` · L 75 / E 74.95 / Δ -0.05 · `hpcdb16138/gas | cavity wall | pitched 200 | consv4` +- [x] `100070333776` · L 74 / E 73.95 / Δ -0.05 · `hpcdb19082/gas | cavity wall | (another dwelling` +- [x] `100040882044` · L 73 / E 72.95 / Δ -0.05 · `hpcdb16839/gas | system built | pitched 200` +- [x] `207102835` · L 64 / E 64.05 / Δ +0.05 · `hpcdb18908/gas | solid brick | pitched 125 | ext2` +- [x] `83122910` · L 60 / E 59.95 / Δ -0.05 · `hpcdb17985/gas | sandstone as | pitched 100 | sec:room heaters` +- [x] `100050869764` · L 71 / E 70.95 / Δ -0.05 · `hpcdb10315/gas | cavity wall | pitched 150` +- [x] `64107249` · L 66 / E 66.05 / Δ +0.05 · `hpcdb15706/gas | cavity wall | (another dwelling | consv2,ext1,sec:room heaters` +- [x] `10012785474` · L 75 / E 74.96 / Δ -0.04 · `hpcdb10243/gas | solid brick | (another dwelling` +- [x] `100062342550` · L 62 / E 61.96 / Δ -0.04 · `h301/elec10hr | cavity wall | pitched 350` +- [x] `10008919006` · L 51 / E 51.04 / Δ +0.04 · `hpcdb17055/gas | granite or | pitched no | ext1` +- [x] `83166253` · L 72 / E 72.04 / Δ +0.04 · `hpcdb18119/gas | cavity wall | pitched 150` +- [x] `100121099490` · L 75 / E 74.96 / Δ -0.04 · `hpcdb19016/gas | cavity wall | pitched 250` +- [x] `100031412745` · L 62 / E 61.96 / Δ -0.04 · `hpcdb18251/gas | solid brick | pitched 200 | ext2` +- [x] `3040036578` · L 64 / E 64.04 / Δ +0.04 · `hpcdb17553/gas | solid brick | pitched 250 | ext2` +- [x] `100031665275` · L 63 / E 63.04 / Δ +0.04 · `hpcdb17932/gas | solid brick | pitched 50 | ext4,sec:room heaters` +- [x] `1775039958` · L 71 / E 70.96 / Δ -0.04 · `hpcdb16841/gas | cavity wall | pitched 200` +- [x] `100100694867` · L 62 / E 62.04 / Δ +0.04 · `h119/gas | cavity wall | pitched insulated` +- [x] `100031723834` · L 54 / E 53.96 / Δ -0.04 · `hpcdb18560/gas | cavity wall | pitched 100 | consv2,ext1,sec:room heaters` +- [x] `10008662860` · L 69 / E 69.04 / Δ +0.04 · `hpcdb17505/gas | solid brick | pitched 100` +- [x] `100061845289` · L 68 / E 67.96 / Δ -0.04 · `h409/elec | cavity wall | pitched 350 | sec:portable electric,whw903` +- [x] `14046362` · L 77 / E 77.04 / Δ +0.04 · `h104/gas | cavity wall | (another dwelling` +- [x] `100036648` · L 60 / E 60.04 / Δ +0.04 · `h104/gas | solid brick | pitched 100 | ext1` +- [x] `5072574` · L 66 / E 65.96 / Δ -0.04 · `hpcdb16840/gas | cavity wall | flat no` +- [x] `15041433` · L 70 / E 69.96 / Δ -0.04 · `hpcdb17507/gas | cavity wall | pitched 300` +- [x] `10096767959` · L 73 / E 73.04 / Δ +0.04 · `h421/elec | cavity wall | pitched 300 | sec:portable electric,whw903` +- [x] `100100357016` · L 75 / E 74.96 / Δ -0.04 · `hpcdb17507/gas | cavity wall | pitched 250` +- [x] `100060926936` · L 44 / E 44.04 / Δ +0.04 · `hpcdb1135/gas | cavity wall | pitched unknown` +- [x] `100031375565` · L 70 / E 69.97 / Δ -0.03 · `hpcdb18907/gas | cavity wall | pitched 300` +- [x] `100050859732` · L 57 / E 57.03 / Δ +0.03 · `h104/gas | solid brick | pitched insulated | ext3` +- [x] `100010663631` · L 67 / E 67.03 / Δ +0.03 · `hpcdb17974/gas | cavity wall | pitched 100 | RR,consv4` +- [x] `100040268694` · L 58 / E 57.97 / Δ -0.03 · `h127/fuel28 | granite or | pitched insulated | RR,ext1,sec:room heaters` +- [x] `100011594820` · L 70 / E 69.97 / Δ -0.03 · `hpcdb16136/gas | cavity wall | pitched insulated` +- [x] `100031288765` · L 62 / E 61.97 / Δ -0.03 · `hpcdb18203/gas | cavity wall | pitched 100 | ext1,sec:room heaters` +- [x] `10013290770` · L 54 / E 53.97 / Δ -0.03 · `h691/elec | cavity wall | (another dwelling | whw903` +- [x] `100080918720` · L 69 / E 68.97 / Δ -0.03 · `h104/gas | cavity wall | pitched 250` +- [x] `4510035778` · L 71 / E 71.03 / Δ +0.03 · `hpcdb15705/gas | cavity wall | pitched 250` +- [x] `100071152554` · L 72 / E 71.97 / Δ -0.03 · `hpcdb18559/gas | solid brick | pitched 100 | ext1,sec:room heaters` +- [x] `100110568321` · L 72 / E 71.97 / Δ -0.03 · `hpcdb15030/gas | cavity wall | pitched 270 | ext1` +- [x] `100021283678` · L 70 / E 69.97 / Δ -0.03 · `h102/gas | solid brick | pitched insulated | RR,ext1` +- [x] `100031023432` · L 65 / E 65.03 / Δ +0.03 · `hpcdb10200/gas | cavity wall | pitched 50 | ext1` +- [x] `100080699036` · L 67 / E 67.03 / Δ +0.03 · `hpcdb19014/gas | system built | pitched 300` +- [x] `43049701` · L 70 / E 69.97 / Δ -0.03 · `hpcdb19110/gas | cavity wall | pitched 350` +- [x] `100030742978` · L 14 / E 14.03 / Δ +0.03 · `h699/fuel0 | solid brick | pitched 300 | consv2,ext2,sec:portable electric,whw903` +- [x] `24113231` · L 74 / E 73.98 / Δ -0.02 · `hpcdb8787/gas | cavity wall | pitched 250` +- [x] `100120472369` · L 69 / E 68.98 / Δ -0.02 · `hpcdb16453/gas | cavity wall | pitched 75` +- [x] `100020394076` · L 71 / E 70.98 / Δ -0.02 · `hpcdb17115/gas | cavity wall | pitched 150` +- [x] `100061141934` · L 72 / E 71.98 / Δ -0.02 · `hpcdb18433/gas | cavity wall | pitched 100 | consv2` +- [x] `83159875` · L 74 / E 74.02 / Δ +0.02 · `hpcdb10328/gas | cavity wall | pitched 250 | PV,sec:room heaters` +- [x] `15042002` · L 62 / E 61.98 / Δ -0.02 · `hpcdb10327/gas | cavity wall | pitched 125 | ext1,sec:room heaters` +- [x] `1775087633` · L 72 / E 72.02 / Δ +0.02 · `h104/gas | cavity wall | (another dwelling` +- [x] `362182` · L 77 / E 77.02 / Δ +0.02 · `hpcdb17501/gas | sandstone with | pitched 200 | ext1` +- [x] `100050737075` · L 69 / E 69.02 / Δ +0.02 · `hpcdb16841/gas | cavity wall | pitched 250 | sec:room heaters` +- [x] `10093200879` · L 71 / E 70.98 / Δ -0.02 · `hpcdb16401/gas | cavity wall | pitched insulated | mv2` +- [x] `10003500583` · L 70 / E 69.98 / Δ -0.02 · `hpcdb18042/gas | cavity wall | pitched 250 | consv3,ext1,sec:room heaters` +- [x] `100051199928` · L 67 / E 66.98 / Δ -0.02 · `hpcdb19094/gas | cavity wall | pitched 300` +- [x] `100031526911` · L 71 / E 71.02 / Δ +0.02 · `hpcdb15502/gas | solid brick | pitched 250 | consv3,ext1,sec:room heaters` +- [x] `100060516940` · L 68 / E 67.98 / Δ -0.02 · `hpcdb9587/gas | cavity wall | pitched 200` +- [x] `100050345781` · L 66 / E 65.99 / Δ -0.01 · `hpcdb17985/gas | cavity wall | pitched 200 | ext2` +- [x] `100031601615` · L 73 / E 73.01 / Δ +0.01 · `hpcdb19080/gas | cavity wall | pitched insulated | RR,ext1` +- [x] `100032122827` · L 65 / E 64.99 / Δ -0.01 · `hpcdb18432/gas | cavity wall | pitched 250 | ext1,sec:room heaters` +- [x] `15068830` · L 74 / E 73.99 / Δ -0.01 · `hpcdb18768/gas | cavity wall | pitched 50 | sec:room heaters` +- [x] `32015803` · L 75 / E 75.01 / Δ +0.01 · `hpcdb18155/gas | cavity wall | (another dwelling` +- [x] `43002062` · L 72 / E 72.01 / Δ +0.01 · `hpcdb15707/gas | sandstone as | pitched 200` +- [x] `40057815` · L 78 / E 77.99 / Δ -0.01 · `hpcdb18225/gas | timber frame | pitched 175 | ext1` +- [x] `28052680` · L 76 / E 75.99 / Δ -0.01 · `hpcdb19080/gas | cavity wall | pitched 400+` +- [x] `77163938` · L 69 / E 68.99 / Δ -0.01 · `hpcdb17683/gas | solid brick | pitched insulated | ext1` +- [x] `100110766495` · L 79 / E 79.01 / Δ +0.01 · `hpcdb16839/gas | cavity wall | (another dwelling` +- [x] `100040446690` · L 72 / E 71.99 / Δ -0.01 · `h104/gas | system built | pitched 400+ | ext1,sec:room heaters` +- [x] `100090834242` · L 53 / E 52.99 / Δ -0.01 · `hpcdb16842/gas | cavity wall | pitched no | ext1,sec:room heaters` +- [x] `10033241196` · L 78 / E 78.01 / Δ +0.01 · `h691/elec | solid brick | pitched 100 | whw909` +- [x] `100061583852` · L 71 / E 71.01 / Δ +0.01 · `hpcdb17507/gas | cavity wall | pitched 300` +- [x] `10009446250` · L 43 / E 42.99 / Δ -0.01 · `h402/elec | cavity wall | pitched 250 | sec:portable electric,whw903` +- [x] `line996` · L 75 / E 74.99 / Δ -0.01 · `h104/gas | cavity wall | flat insulated` +- [x] `10014235074` · L 76 / E 75.99 / Δ -0.01 · `hpcdb18516/gas | cavity wall | pitched 200 | ext1` +- [x] `83182597` · L 71 / E 71.00 / Δ +0.00 · `hpcdb18225/gas | cavity wall | (another dwelling` +- [x] `100100122657` · L 61 / E 61.00 / Δ -0.00 · `hpcdb1514/gas | cavity wall | pitched 100 | ext1,sec:room heaters` +- [x] `10007364829` · L 74 / E 74.00 / Δ +0.00 · `hpcdb18432/gas | cavity wall | pitched 200` +- [x] `4510136787` · L 76 / E 76.00 / Δ +0.00 · `h409/elec | solid brick | (another dwelling | whw903` +- [x] `422000111926` · L 1 / E 1.00 / Δ +0.00 · `h699/fuel0 | solid brick | pitched no | sec:portable electric,whw999` + +
diff --git a/scripts/data_exports.py b/scripts/data_exports.py new file mode 100644 index 000000000..102b1b820 --- /dev/null +++ b/scripts/data_exports.py @@ -0,0 +1,414 @@ +"""Principal-pitch data export — new DDD model edition. + +Replaces sfr/principal_pitch/2_export_data.py, which read the retired +``plan_recommendations`` m2m and ``recommendation_materials`` table. In the +current model: + * a Recommendation links to its Plan directly (``recommendation.plan_id``), + * materials are inline on the Recommendation (``material_id`` etc.), + * the chosen Plan per (scenario, property) is the one with ``is_default``, + * post-works SAP/EPC + savings live on the Plan row (the new SAP calculator's + output), so we read them directly rather than summing recommendation uplifts. + +Give it a portfolio id; it resolves every *modelled* scenario for that portfolio +(scenarios that have plans) and writes ONE workbook with a ``properties`` sheet +per scenario. EPC descriptive fields (walls/roof/heating/windows/floor area/ +lodgement) come live from the EPC service, because ``property_details_epc`` is +dead under the new backend. + + python scripts/data_exports.py --portfolio 814 + python scripts/data_exports.py --portfolio 814 --out "sfr/principal_pitch/Durkan.xlsx" + +Reads DB_* + OPEN_EPC_API_TOKEN from backend/.env. Run from the worktree root. +""" + +from __future__ import annotations + +import argparse +import re +from datetime import date, datetime +from pathlib import Path +from typing import Any, Optional + +import numpy as np +import pandas as pd +from sqlalchemy import text +from sqlalchemy.engine import Engine + +import sys + +_REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_REPO_ROOT)) + +from backend.app.utils import sap_to_epc # noqa: E402 +from infrastructure.epc_client.epc_client_service import EpcClientService # noqa: E402 +from scripts.e2e_common import ENV_PATH, build_engine, load_env # noqa: E402 +from backend.app.config import get_settings # noqa: E402 + +# Measure columns always present in the wide sheet (stable column set across runs). +EXPECTED_MEASURE_COLUMNS: tuple[str, ...] = ( + "suspended_floor_insulation", + "solid_floor_insulation", + "external_wall_insulation", + "internal_wall_insulation", + "cavity_wall_insulation", + "loft_insulation", + "flat_roof_insulation", + "room_roof_insulation", + "secondary_glazing", + "double_glazing", + "solar_pv", + "high_heat_retention_storage_heaters", + "air_source_heat_pump", + "boiler_upgrade", + "gas_boiler_upgrade", + "roomstat_programmer_trvs", + "time_temperature_zone_control", + "low_energy_lighting", + "mechanical_ventilation", + "system_tune_up", + "system_tune_up_zoned", +) + + +# --------------------------------------------------------------------------- # +# EPC descriptive fields (live from the EPC service) +# --------------------------------------------------------------------------- # +def _description_text(item: Any) -> str: + if not isinstance(item, dict): + return "" + desc = item.get("description") + if isinstance(desc, dict): + desc = desc.get("value") + return str(desc or "") + + +def _join_descriptions(value: Any) -> str: + if isinstance(value, list): + return "; ".join(t for t in (_description_text(d) for d in value) if t) + return _description_text(value) + + +# Gov RdSAP property-type codes (the raw cert stores a code, not a word). +_PROPERTY_TYPE_CODES: dict[str, str] = { + "0": "House", "1": "Bungalow", "2": "Flat", "3": "Maisonette", "4": "Park home", +} + + +def _decode_property_type(value: Any) -> Optional[str]: + if value is None: + return None + s = str(value).strip() + if s in _PROPERTY_TYPE_CODES: + return _PROPERTY_TYPE_CODES[s] + return s or None + + +def _is_expired(registration_date: Optional[str]) -> Optional[bool]: + if not registration_date: + return None + try: + lodged = datetime.fromisoformat(registration_date[:10]).date() + except ValueError: + return None + return (date.today() - lodged).days > 365 * 10 + + +def epc_details_from_service(svc: EpcClientService, uprn: Optional[int]) -> dict[str, Any]: + """Flatten the UPRN's latest raw certificate into the descriptive fields the + export needs. Returns ``{}`` when the UPRN has no EPC (blank columns).""" + if uprn is None: + return {} + results = svc._search(uprn=uprn) # pyright: ignore[reportPrivateUsage] + if not results: + return {} + latest = max(results, key=lambda r: r.registration_date) + raw = svc._fetch_certificate(latest.certificate_number) # pyright: ignore[reportPrivateUsage] + + def _to_int(value: Any) -> Optional[int]: + try: + return int(value) + except (TypeError, ValueError): + return None + + current_sap = _to_int(raw.get("energy_rating_current")) + return { + "property_type": _decode_property_type(raw.get("property_type")), + "walls": _join_descriptions(raw.get("walls")), + "roof": _join_descriptions(raw.get("roofs")), + "floor": _join_descriptions(raw.get("floors")), + "windows": _join_descriptions(raw.get("window")), + "heating": _join_descriptions(raw.get("main_heating")), + "hot_water": _join_descriptions(raw.get("hot_water")), + "lighting": _join_descriptions(raw.get("lighting")), + "total_floor_area": raw.get("total_floor_area"), + "lodgement_date": raw.get("registration_date"), + "is_expired": _is_expired(raw.get("registration_date")), + "current_epc_rating": raw.get("current_energy_efficiency_band"), + "current_sap_points": current_sap, + "original_sap_points": current_sap, + } + + +# --------------------------------------------------------------------------- # +# DB reads (new model: scenario -> plan(is_default) -> recommendation) +# --------------------------------------------------------------------------- # +def modelled_scenarios(engine: Engine, portfolio_id: int) -> list[dict[str, Any]]: + """Scenarios for the portfolio that actually have plans, newest first.""" + with engine.connect() as conn: + rows = conn.execute( + text( + """ + SELECT s.id, s.name + FROM scenario s + WHERE s.portfolio_id = :p + AND EXISTS (SELECT 1 FROM plan pl WHERE pl.scenario_id = s.id) + ORDER BY s.id + """ + ), + {"p": portfolio_id}, + ).mappings().all() + return [dict(r) for r in rows] + + +def load_properties(engine: Engine, portfolio_id: int, svc: EpcClientService) -> pd.DataFrame: + """Base property identity (property_type falls back to the landlord override) + plus live EPC descriptive fields.""" + with engine.connect() as conn: + rows = conn.execute( + text( + """ + SELECT p.id AS property_id, p.id AS id, p.uprn, p.address, p.postcode, + p.landlord_property_id, p.number_of_rooms, + COALESCE(p.property_type, po.override_value) AS property_type + FROM property p + LEFT JOIN property_overrides po + ON po.property_id = p.id + AND po.override_component = 'property_type' + AND po.building_part = 0 + WHERE p.portfolio_id = :p + ORDER BY p.id + """ + ), + {"p": portfolio_id}, + ).mappings().all() + + records: list[dict[str, Any]] = [] + for i, r in enumerate(rows, 1): + base: dict[str, Any] = dict(r) + uprn = int(base["uprn"]) if base.get("uprn") is not None else None + for key, value in epc_details_from_service(svc, uprn).items(): + if base.get(key) is None: + base[key] = value + records.append(base) + if i % 50 == 0: + print(f" EPC fetched {i}/{len(rows)}") + df = pd.DataFrame(records) + df["uprn"] = df["uprn"].astype("string") + return df + + +def load_recommendations(engine: Engine, scenario_id: int) -> pd.DataFrame: + """Default, not-already-installed recommendations on each property's default + plan for the scenario, with the material type/battery flag joined.""" + with engine.connect() as conn: + rows = conn.execute( + text( + """ + SELECT pl.property_id, + r.measure_type, + r.description, + r.estimated_cost, + r.sap_points, + r.co2_equivalent_savings, + r.kwh_savings, + r.energy_cost_savings, + m.type AS material_type, + COALESCE(m.includes_battery, FALSE) AS includes_battery + FROM recommendation r + JOIN plan pl ON pl.id = r.plan_id + LEFT JOIN material m ON m.id = r.material_id + WHERE pl.scenario_id = :s + AND pl.is_default = TRUE + AND r.default = TRUE + AND r.already_installed = FALSE + """ + ), + {"s": scenario_id}, + ).mappings().all() + return pd.DataFrame([dict(r) for r in rows]) + + +def load_default_plans(engine: Engine, scenario_id: int) -> pd.DataFrame: + """The chosen (is_default) plan per property — the new SAP calculator's + post-works results.""" + with engine.connect() as conn: + rows = conn.execute( + text( + """ + SELECT property_id, post_sap_points, post_epc_rating, + cost_of_works, contingency_cost, co2_savings, + energy_bill_savings, energy_consumption_savings, + valuation_increase + FROM plan + WHERE scenario_id = :s AND is_default = TRUE + """ + ), + {"s": scenario_id}, + ).mappings().all() + return pd.DataFrame([dict(r) for r in rows]) + + +# --------------------------------------------------------------------------- # +# Sheet building +# --------------------------------------------------------------------------- # +def _apply_battery_suffix(recs: pd.DataFrame) -> pd.DataFrame: + """solar_pv recommendations that carry a battery material become + solar_pv_with_battery (mirrors the old export).""" + if recs.empty: + return recs + is_solar_battery = (recs["material_type"] == "solar_pv") & (recs["includes_battery"]) + recs = recs.copy() + recs["measure_type"] = np.where( + is_solar_battery, + recs["measure_type"].astype(str) + "_with_battery", + recs["measure_type"], + ) + return recs + + +def build_scenario_sheet( + properties_df: pd.DataFrame, recs: pd.DataFrame, plans: pd.DataFrame +) -> pd.DataFrame: + recs = _apply_battery_suffix(recs) + + # Pivot: one column per measure_type holding its estimated_cost. + if not recs.empty: + deduped = recs.drop_duplicates(subset=["property_id", "measure_type"], keep="first") + cost_pivot = deduped.pivot( + index="property_id", columns="measure_type", values="estimated_cost" + ).reset_index() + sap_uplift = ( + recs.groupby("property_id")["sap_points"].sum().reset_index(name="sap_points") + ) + savings = ( + recs.groupby("property_id")[ + ["co2_equivalent_savings", "kwh_savings", "energy_cost_savings"] + ] + .sum() + .reset_index() + ) + else: + cost_pivot = pd.DataFrame({"property_id": []}) + sap_uplift = pd.DataFrame({"property_id": [], "sap_points": []}) + savings = pd.DataFrame( + {"property_id": [], "co2_equivalent_savings": [], "kwh_savings": [], "energy_cost_savings": []} + ) + + id_cols = [ + c + for c in [ + "landlord_property_id", "property_id", "uprn", "address", "postcode", + "property_type", "walls", "roof", "heating", "windows", + "current_epc_rating", "current_sap_points", "original_sap_points", + "total_floor_area", "number_of_rooms", "lodgement_date", "is_expired", "id", + ] + if c in properties_df.columns + ] + + df = ( + properties_df[id_cols] + .merge(cost_pivot, how="left", on="property_id") + .merge(sap_uplift, how="left", on="property_id") + .merge(savings, how="left", on="property_id") + .merge(plans, how="left", on="property_id") + ) + + # total retrofit cost = sum of the per-measure cost columns + measure_cols_present = [c for c in df.columns if c in set(EXPECTED_MEASURE_COLUMNS) + or c.endswith("_with_battery")] + df["total_retrofit_cost"] = df[measure_cols_present].sum(axis=1) if measure_cols_present else 0.0 + + df["sap_points"] = df["sap_points"].fillna(0) + # Post-works SAP/EPC straight from the new SAP calculator's plan row; + # fall back to current + uplift / sap_to_epc only when the plan lacks them. + df["predicted_post_works_sap"] = df["post_sap_points"].where( + df["post_sap_points"].notna(), df.get("current_sap_points", 0) + df["sap_points"] + ) + df["predicted_post_works_epc"] = df["post_epc_rating"].where( + df["post_epc_rating"].notna(), + df["predicted_post_works_sap"].apply(lambda x: sap_to_epc(x) if pd.notna(x) else None), + ) + + # ensure the stable measure column set exists + for col in EXPECTED_MEASURE_COLUMNS: + if col not in df.columns: + df[col] = "" + return df + + +def _safe_sheet_name(name: str, used: set[str]) -> str: + clean = re.sub(r"[:\\/?*\[\]]", "", name or "scenario").strip() or "scenario" + clean = clean[:31] + base, i = clean, 1 + while clean in used: + suffix = f" ({i})" + clean = base[: 31 - len(suffix)] + suffix + i += 1 + used.add(clean) + return clean + + +def export_portfolio(portfolio_id: int, out_path: Path) -> None: + load_env(ENV_PATH) + settings = get_settings() + engine = build_engine() + svc = EpcClientService(auth_token=settings.OPEN_EPC_API_TOKEN) + + with engine.connect() as conn: + pname = conn.execute( + text("SELECT name FROM portfolio WHERE id = :p"), {"p": portfolio_id} + ).scalar() + + scenarios = modelled_scenarios(engine, portfolio_id) + if not scenarios: + raise SystemExit(f"No modelled scenarios (with plans) for portfolio {portfolio_id}.") + print(f"Portfolio {portfolio_id} ({pname}) — {len(scenarios)} modelled scenario(s): " + f"{[s['name'] for s in scenarios]}") + + print("Loading properties + EPC descriptive fields…") + properties_df = load_properties(engine, portfolio_id, svc) + + out_path.parent.mkdir(parents=True, exist_ok=True) + used_names: set[str] = set() + with pd.ExcelWriter(out_path) as writer: + for s in scenarios: + recs = load_recommendations(engine, s["id"]) + plans = load_default_plans(engine, s["id"]) + sheet_df = build_scenario_sheet(properties_df, recs, plans) + sheet = _safe_sheet_name(s["name"] or f"scenario_{s['id']}", used_names) + sheet_df.to_excel(writer, sheet_name=sheet, index=False) + print(f" sheet {sheet!r}: {len(sheet_df)} properties, " + f"{0 if recs.empty else len(recs)} recommendations") + print(f"Wrote {out_path}") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--portfolio", type=int, required=True) + ap.add_argument("--out", type=Path, default=None, + help="output xlsx path (default: sfr/principal_pitch/.xlsx)") + args = ap.parse_args() + + out = args.out + if out is None: + load_env(ENV_PATH) + with build_engine().connect() as conn: + nm = conn.execute(text("SELECT name FROM portfolio WHERE id=:p"), {"p": args.portfolio}).scalar() + safe = re.sub(r"[\\/:*?\"<>|]", "_", str(nm or f"portfolio_{args.portfolio}")) + out = _REPO_ROOT / "sfr" / "principal_pitch" / f"{safe}.xlsx" + export_portfolio(args.portfolio, out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/elmhurst_input_sheet.py b/scripts/elmhurst_input_sheet.py index 5626c5be3..e2c5022d9 100644 --- a/scripts/elmhurst_input_sheet.py +++ b/scripts/elmhurst_input_sheet.py @@ -22,6 +22,7 @@ Certs are read from the cache built by `fetch_2026_epc_sample.py` (default `/tmp/epc_2026_sample`, overridable via `EPC_SAMPLE_CACHE`). A bare cert number resolves to `/.json`; an explicit path is also accepted. """ + from __future__ import annotations import json @@ -33,7 +34,10 @@ from typing import Any, Optional from datatypes.epc.domain.mapper import EpcPropertyDataMapper from domain.sap10_calculator.calculator import calculate_sap_from_inputs -from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + SAP_10_2_SPEC_PRICES, + cert_to_inputs, +) CACHE = Path(os.environ.get("EPC_SAMPLE_CACHE", "/tmp/epc_2026_sample")) @@ -59,7 +63,9 @@ def _resolve(cert_arg: str) -> Path: def _our_sap(epc: Any) -> str: """Our continuous SAP, or the exception that blocks it.""" try: - result = calculate_sap_from_inputs(cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)) + result = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) cont: float = result.sap_score_continuous return f"{cont:.4f}" if math.isfinite(cont) else f"non-finite ({cont})" except Exception as e: # debugging tool — surface, don't swallow @@ -113,7 +119,9 @@ def render(cert: str, doc: dict[str, Any]) -> str: # --- element descriptions (lodged) --------------------------------- w("\n## Element descriptions (lodged)") for label, elems in ( - ("WALL", epc.walls), ("ROOF", epc.roofs), ("FLOOR", epc.floors), + ("WALL", epc.walls), + ("ROOF", epc.roofs), + ("FLOOR", epc.floors), ): for el in elems or []: w(f" {label}: {el.description}") @@ -135,7 +143,9 @@ def render(cert: str, doc: dict[str, Any]) -> str: # types the mapper emits (NOT the `schema` dataclasses). w("\n## Building parts / dimensions") for bp in epc.sap_building_parts or []: - w(f"### {bp.identifier} (part {bp.building_part_number}, age {bp.construction_age_band})") + w( + f"### {bp.identifier} (part {bp.building_part_number}, age {bp.construction_age_band})" + ) w( f" wall_construction={bp.wall_construction} " f"insulation_type={bp.wall_insulation_type} " @@ -226,7 +236,7 @@ def render(cert: str, doc: dict[str, Any]) -> str: ) es = epc.sap_energy_source w( - f" ENERGY SOURCE: mains_gas={es.mains_gas} " + f" ENERGY SOURCE: mains_gas={es.gas_connection_available} " f"meter_type={es.meter_type} " f"wind_turbines={es.wind_turbines_count} " f"pv_raw={json.dumps(doc.get('sap_energy_source', {}).get('photovoltaic_supply'))}" diff --git a/scripts/hyde/build_100021924710.py b/scripts/hyde/build_100021924710.py new file mode 100644 index 000000000..ec06d8427 --- /dev/null +++ b/scripts/hyde/build_100021924710.py @@ -0,0 +1,231 @@ +"""Elmhurst build for UPRN 100021924710 (RdSAP-21.0.1, MID-TERRACE HOUSE, band B, +SOLID BRICK 250 mm uninsulated, pitched insulated (assumed) loft, floor to +UNHEATED space, mains-gas COMBI (PCDB 19008) + gas room-heater SECONDARY, DUAL +meter, 11 DG windows ~18.32 m², TFA 116. Lodged SAP 63; engine 65.0 (Δ +2.0). + +Per-cert Elmhurst validation. Uses set_single_window (deletes the shared +assessment's carried-over window rows) + reset_transient_state at the end so the +assessment is left neutral. Run: + DISPLAY=:99 python scripts/hyde/build_100021924710.py +""" +from __future__ import annotations +import sys +from pathlib import Path +import elmhurst_lib as E + +DIM = "TabContainer_TabPanelMain_WebUserControlDimensionsMain_" +WALL = ("TabContainer_TabPanelMain_InnerTabContainerMain_" + "TabPanelExternalWallMain_WebUserControlWallMain_") +PWALL = ("TabContainer_TabPanelMain_InnerTabContainerMain_" + "TabPanelPartyWallMain_WebUserControlPartyWallMain_") +ROOF = "TabContainer_TabPanelMain_WebUserControlRoofMain_" +FLOOR = "TabContainer_TabPanelMain_WebUserControlFloorsMain_" +DP = "TabContainer_TabPanelDoorsPanel_" +VP = "TabContainer_TabPanelVentilationPanel_" +APT = "TabContainer_TabPanelAirPressureTest_" +LP = "TabContainer_TabPanelLighting_" +MV = "TabContainer_TabPanelMechVent_" +WH = "TabContainer_TabPanelWaterHeating_" +MH1B = "TabContainer_TabPanelMainHeating1_WebUserControlMainHeating1_" +_SAMPLE_DIR = (Path(__file__).parent.parent.parent + / "backend/epc_api/json_samples/real_life_examples" + / "RdSAP-Schema-21.0.1/uprn_100021924710") + + +def _pick(page, suffix, contains): + val = page.evaluate( + """(a)=>{const s=document.getElementById(a[0]);if(!s)return null; + for(const o of s.options){if(o.text.toLowerCase().includes(a[1].toLowerCase()))return o.value;}return null;}""", + [f"{E.FP}{suffix}", contains]) + if val is not None: + E.set_select(page, suffix, val) + return val + + +def property_description(page): + E.goto(page, "PropertyDescription", "WebFormPropertyDescription.aspx") + E.set_select(page, "DropDownListPropertyType1", "H House") + _pick(page, "DropDownListPropertyType2", "mid-terrace") or \ + _pick(page, "DropDownListPropertyType2", "mid terrace") + E.set_text(page, "TextBoxStoreys", "2") + E.set_text(page, "TextBoxHabitableRooms", "5") + E.set_text(page, "TextBoxHeatedHabitableRooms", "5") + print("date ->", _pick(page, "DropDownListDateBuiltMain", "1900-1929")) + E.set_select(page, "DropDownListDateBuiltFirst", "") + E.set_select(page, "DropDownListRoomInRoofMain", "") + E.save_close(page) + + +def dimensions(page): + E.goto(page, "Dimensions", "WebFormDimensions.aspx") + E.set_text(page, f"{DIM}TextBoxFloorAreaLowestFloor", "59.20") + E.set_text(page, f"{DIM}TextBoxRoomHeightLowestFloor", "2.74") + E.set_text(page, f"{DIM}TextBoxWallPerimeterLowestFloor", "16.0") + E.set_text(page, f"{DIM}TextBoxPartyWallLengthLowestFloor", "21.1") + E.set_text(page, f"{DIM}TextBoxFloorArea1stFloor", "57.10") + E.set_text(page, f"{DIM}TextBoxRoomHeight1stFloor", "2.74") + E.set_text(page, f"{DIM}TextBoxWallPerimeter1stFloor", "15.7") + E.set_text(page, f"{DIM}TextBoxPartyWallLength1stFloor", "21.7") + E.save_close(page) + + +def walls(page): + E.goto(page, "Walls", "WebFormWalls.aspx") + _pick(page, f"{WALL}DropDownListType", "solid brick") + page.wait_for_timeout(500) + print("insulation ->", _pick(page, f"{WALL}DropDownListInsulation", "as built")) + print("party ->", _pick(page, f"{PWALL}DropDownListPartyWallType", "determine") + or _pick(page, f"{PWALL}DropDownListPartyWallType", "masonry")) + E.save_close(page) + + +def roofs(page): + E.goto(page, "Roofs", "WebFormRoofs.aspx") + _pick(page, f"{ROOF}DropDownListType", "access to loft") or \ + _pick(page, f"{ROOF}DropDownListType", "loft") + _pick(page, f"{ROOF}DropDownListInsulationAt", "joists") + _pick(page, f"{ROOF}DropDownListThickness", "unknown") or \ + _pick(page, f"{ROOF}DropDownListThickness", "assumed") + E.save_close(page) + + +def floors(page): + E.goto(page, "Floors", "WebFormFloors.aspx") + _pick(page, f"{FLOOR}DropDownListLocation", "unheated") + page.wait_for_timeout(400) + _pick(page, f"{FLOOR}DropDownListType", "solid") + ins = page.locator(f"#{E.FP}{FLOOR}DropDownListInsulation") + if ins.count(): + E.set_select(page, f"{FLOOR}DropDownListInsulation", "A As built") + E.save_close(page) + + +def openings(page): + E.goto(page, "Openings", "WebFormOpenings.aspx") + E.click_tab(page, "TabContainer_TabPanelWindowsPanel") + page.wait_for_timeout(500) + E.set_single_window(page, 18.32, "North", + _glazing(page) if False else "Double post or during 2022") + E.click_tab(page, "TabContainer_TabPanelDoorsPanel") + E.set_text(page, f"{DP}TextBoxDoors", "1") + E.set_text(page, f"{DP}TextBoxDoorsInsulated", "0") + E.set_text(page, f"{DP}TextBoxDraughtProofedDoors", "0") + E.save_close(page) + + +def _glazing(page): + return "Double with unknown install date" + + +def ventilation(page): + E.goto(page, "VentilationAndCooling", "WebFormVentilationAndCooling.aspx") + E.click_tab(page, "TabContainer_TabPanelVentilationPanel") + E.set_text(page, f"{VP}TextBoxIntermittentFans", "1") + cool = page.locator(f"#{E.FP}{VP}CheckBoxFixedSpaceCooling") + if cool.count() and cool.is_checked(): + E.commit(page, cool.uncheck) + E.click_tab(page, "TabContainer_TabPanelMechVent") + mv = page.locator(f"#{E.FP}{MV}CheckBoxMechanicalVentilation") + if mv.count() and mv.is_checked(): + E.commit(page, mv.uncheck) + E.click_tab(page, "TabContainer_TabPanelAirPressureTest") + E.set_select(page, f"{APT}DropDownListTestMethod", "Not available") + E.click_tab(page, "TabContainer_TabPanelLighting") + E.set_text(page, f"{LP}TextBoxLightsTotal", "8") + E.set_text(page, f"{LP}TextBoxLedLightsTotal", "0") + E.set_text(page, f"{LP}TextBoxCflLightsTotal", "0") + E.save_close(page) + + +def space_heating(page): + E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx") + page.wait_for_timeout(1000) + mhc = page.locator(f"#{E.MH1}TextBoxMainHeatingCode") + code = mhc.input_value() if mhc.count() else "" + if code and code not in ("0",): + print(f"clearing leftover MainHeatingCode {code}") + page.evaluate("""(id)=>{const e=document.getElementById(id);if(e){e.value=''; + e.dispatchEvent(new Event('change',{bubbles:true}));}}""", f"{E.MH1}TextBoxMainHeatingCode") + page.wait_for_timeout(400) + E.save_close(page) + return + E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingCode", + "^Gas", "Mains gas", "Boilers", "Post 1998", "Condensing", + "Combi condens") + print("code:", page.locator(f"#{E.MH1}TextBoxMainHeatingCode").input_value()) + E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingControls", + "^Boilers", "^Standard", "CBE Programmer, room thermostat and TRVs") + print("control:", page.locator(f"#{E.MH1}TextBoxMainHeatingControls").input_value()) + # dual meter (cert meter_type 1) + E.click_tab(page, "TabContainer_TabPanelMeters") + try: + E.set_select(page, "TabContainer_TabPanelMeters_RadioButtonListElectricityType", "Dual") + except Exception: + pass + E.save_close(page) + + +def secondary(page): + E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx") + page.wait_for_timeout(600) + E.set_select(page, "DropDownListSecondaryHeatingPresent", "Yes") + page.wait_for_timeout(900) + E.set_heating_dialog(page, "ButtonSecondaryHeatingCode", + "^Gas", "Mains gas", "Room Heater") + tb = page.locator(f"#{E.FP}TextBoxSecondaryHeatingCode") + print("secondary code:", tb.input_value() if tb.count() else "?") + E.save_close(page) + + +def water_heating(page): + E.goto(page, "WaterHeating", "WebFormWaterHeating.aspx") + E.click_tab(page, "TabContainer_TabPanelWaterHeating") + page.wait_for_timeout(400) + E.clear_hot_water_cylinder(page) + E.set_heating_dialog(page, f"{WH}ButtonWaterHeatingCode", + "From Space Heating", "From the primary heating system") + print("water code:", page.locator(f"#{E.FP}{WH}TextBoxWaterHeatingCode").input_value()) + E.save_close(page) + + +def download(page): + _SAMPLE_DIR.mkdir(parents=True, exist_ok=True) + E.goto(page, "Recommendations", "WebFormRecommendations.aspx") + page.wait_for_timeout(1500) + with page.expect_navigation(wait_until="networkidle", timeout=40000): + page.click("#ContentBody_buttonActionSummary_Link", timeout=15000) + page.wait_for_timeout(2000) + for btn, fname in (("ContentBody_ContentPlaceHolder1_LinkButtonSummary", "elmhurst_summary.pdf"), + ("ContentBody_ContentPlaceHolder1_ButtonDebugInforPdf", "elmhurst_worksheet.pdf")): + try: + with page.expect_download(timeout=60000) as dl: + page.click(f"#{btn}", timeout=15000) + dl.value.save_as(str(_SAMPLE_DIR / fname)) + print("saved", fname) + except Exception as e: + print("!! download failed", fname, type(e).__name__, str(e)[:120]) + + +def cleanup(page): + """Leave the shared assessment neutral for the next build.""" + E.reset_transient_state(page) + print("reset transient state (RR/secondary/meter)") + + +_ORDER = ["property_description", "dimensions", "walls", "roofs", "floors", + "openings", "ventilation", "space_heating", "secondary", + "water_heating", "download", "cleanup"] + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] not in _ORDER: + print("usage: build_100021924710.py <" + "|".join(_ORDER) + ">") + return 2 + with E.session() as (ctx, page): + globals()[sys.argv[1]](page) + print("done:", sys.argv[1], "->", page.url) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hyde/build_100050881708.py b/scripts/hyde/build_100050881708.py new file mode 100644 index 000000000..e61ddbd8d --- /dev/null +++ b/scripts/hyde/build_100050881708.py @@ -0,0 +1,223 @@ +"""Elmhurst build for UPRN 100050881708 (RdSAP-21.0.1, MID-TERRACE HOUSE, band B, +SOLID BRICK 250 mm uninsulated, pitched insulated (assumed) loft, floor to +UNHEATED space, mains-gas COMBI (PCDB 19008) + gas room-heater SECONDARY, DUAL +meter, 11 DG windows ~18.32 m², TFA 116. Lodged SAP 63; engine 65.0 (Δ +2.0). + +Per-cert Elmhurst validation. Uses set_single_window (deletes the shared +assessment's carried-over window rows) + reset_transient_state at the end so the +assessment is left neutral. Run: + DISPLAY=:99 python scripts/hyde/build_100050881708.py +""" +from __future__ import annotations +import sys +from pathlib import Path +import elmhurst_lib as E + +DIM = "TabContainer_TabPanelMain_WebUserControlDimensionsMain_" +WALL = ("TabContainer_TabPanelMain_InnerTabContainerMain_" + "TabPanelExternalWallMain_WebUserControlWallMain_") +PWALL = ("TabContainer_TabPanelMain_InnerTabContainerMain_" + "TabPanelPartyWallMain_WebUserControlPartyWallMain_") +ROOF = "TabContainer_TabPanelMain_WebUserControlRoofMain_" +FLOOR = "TabContainer_TabPanelMain_WebUserControlFloorsMain_" +DP = "TabContainer_TabPanelDoorsPanel_" +VP = "TabContainer_TabPanelVentilationPanel_" +APT = "TabContainer_TabPanelAirPressureTest_" +LP = "TabContainer_TabPanelLighting_" +MV = "TabContainer_TabPanelMechVent_" +WH = "TabContainer_TabPanelWaterHeating_" +MH1B = "TabContainer_TabPanelMainHeating1_WebUserControlMainHeating1_" +_SAMPLE_DIR = (Path(__file__).parent.parent.parent + / "backend/epc_api/json_samples/real_life_examples" + / "RdSAP-Schema-21.0.1/uprn_100050881708") + + +def _pick(page, suffix, contains): + val = page.evaluate( + """(a)=>{const s=document.getElementById(a[0]);if(!s)return null; + for(const o of s.options){if(o.text.toLowerCase().includes(a[1].toLowerCase()))return o.value;}return null;}""", + [f"{E.FP}{suffix}", contains]) + if val is not None: + E.set_select(page, suffix, val) + return val + + +def property_description(page): + E.goto(page, "PropertyDescription", "WebFormPropertyDescription.aspx") + E.set_select(page, "DropDownListPropertyType1", "H House") + _pick(page, "DropDownListPropertyType2", "semi") + E.set_text(page, "TextBoxStoreys", "2") + E.set_text(page, "TextBoxHabitableRooms", "3") + E.set_text(page, "TextBoxHeatedHabitableRooms", "3") + print("date ->", _pick(page, "DropDownListDateBuiltMain", "1900-1929")) + E.set_select(page, "DropDownListDateBuiltFirst", "") + E.set_select(page, "DropDownListRoomInRoofMain", "") + E.save_close(page) + + +def dimensions(page): + E.goto(page, "Dimensions", "WebFormDimensions.aspx") + E.set_text(page, f"{DIM}TextBoxFloorAreaLowestFloor", "31.62") + E.set_text(page, f"{DIM}TextBoxRoomHeightLowestFloor", "2.40") + E.set_text(page, f"{DIM}TextBoxWallPerimeterLowestFloor", "16.4") + E.set_text(page, f"{DIM}TextBoxPartyWallLengthLowestFloor", "6.2") + E.set_text(page, f"{DIM}TextBoxFloorArea1stFloor", "31.62") + E.set_text(page, f"{DIM}TextBoxRoomHeight1stFloor", "2.40") + E.set_text(page, f"{DIM}TextBoxWallPerimeter1stFloor", "16.4") + E.set_text(page, f"{DIM}TextBoxPartyWallLength1stFloor", "6.2") + E.save_close(page) + + +def walls(page): + E.goto(page, "Walls", "WebFormWalls.aspx") + _pick(page, f"{WALL}DropDownListType", "cavity") + page.wait_for_timeout(500) + print("insulation ->", _pick(page, f"{WALL}DropDownListInsulation", "filled")) + print("party ->", _pick(page, f"{PWALL}DropDownListPartyWallType", "determine") + or _pick(page, f"{PWALL}DropDownListPartyWallType", "masonry")) + E.save_close(page) + + +def roofs(page): + E.goto(page, "Roofs", "WebFormRoofs.aspx") + _pick(page, f"{ROOF}DropDownListType", "access to loft") or \ + _pick(page, f"{ROOF}DropDownListType", "loft") + _pick(page, f"{ROOF}DropDownListInsulationAt", "joists") + _pick(page, f"{ROOF}DropDownListThickness", "225") + E.save_close(page) + + +def floors(page): + E.goto(page, "Floors", "WebFormFloors.aspx") + _pick(page, f"{FLOOR}DropDownListLocation", "unheated") + page.wait_for_timeout(400) + _pick(page, f"{FLOOR}DropDownListType", "solid") + ins = page.locator(f"#{E.FP}{FLOOR}DropDownListInsulation") + if ins.count(): + E.set_select(page, f"{FLOOR}DropDownListInsulation", "A As built") + E.save_close(page) + + +def openings(page): + E.goto(page, "Openings", "WebFormOpenings.aspx") + E.click_tab(page, "TabContainer_TabPanelWindowsPanel") + page.wait_for_timeout(500) + E.set_single_window(page, 9.56, "North", + _glazing(page) if False else "Double post or during 2022") + E.click_tab(page, "TabContainer_TabPanelDoorsPanel") + E.set_text(page, f"{DP}TextBoxDoors", "2") + E.set_text(page, f"{DP}TextBoxDoorsInsulated", "0") + E.set_text(page, f"{DP}TextBoxDraughtProofedDoors", "0") + E.save_close(page) + + +def _glazing(page): + return "Double with unknown install date" + + +def ventilation(page): + E.goto(page, "VentilationAndCooling", "WebFormVentilationAndCooling.aspx") + E.click_tab(page, "TabContainer_TabPanelVentilationPanel") + E.set_text(page, f"{VP}TextBoxIntermittentFans", "2") + cool = page.locator(f"#{E.FP}{VP}CheckBoxFixedSpaceCooling") + if cool.count() and cool.is_checked(): + E.commit(page, cool.uncheck) + E.click_tab(page, "TabContainer_TabPanelMechVent") + mv = page.locator(f"#{E.FP}{MV}CheckBoxMechanicalVentilation") + if mv.count() and mv.is_checked(): + E.commit(page, mv.uncheck) + E.click_tab(page, "TabContainer_TabPanelAirPressureTest") + E.set_select(page, f"{APT}DropDownListTestMethod", "Not available") + E.click_tab(page, "TabContainer_TabPanelLighting") + E.set_text(page, f"{LP}TextBoxLightsTotal", "8") + E.set_text(page, f"{LP}TextBoxLedLightsTotal", "0") + E.set_text(page, f"{LP}TextBoxCflLightsTotal", "0") + E.save_close(page) + + +def space_heating(page): + E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx") + page.wait_for_timeout(1000) + mhc = page.locator(f"#{E.MH1}TextBoxMainHeatingCode") + code = mhc.input_value() if mhc.count() else "" + if code and code not in ("0",): + print(f"clearing leftover MainHeatingCode {code}") + page.evaluate("""(id)=>{const e=document.getElementById(id);if(e){e.value=''; + e.dispatchEvent(new Event('change',{bubbles:true}));}}""", f"{E.MH1}TextBoxMainHeatingCode") + page.wait_for_timeout(400) + E.save_close(page) + return + E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingCode", + "^Gas", "Mains gas", "Boilers", "Post 1998", "Condensing", + "Combi condens") + print("code:", page.locator(f"#{E.MH1}TextBoxMainHeatingCode").input_value()) + E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingControls", + "^Boilers", "^Standard", "CBE Programmer, room thermostat and TRVs") + print("control:", page.locator(f"#{E.MH1}TextBoxMainHeatingControls").input_value()) + E.set_select(page, "DropDownListSecondaryHeatingPresent", "No") + E.save_close(page) + + +def secondary(page): + E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx") + page.wait_for_timeout(600) + E.set_select(page, "DropDownListSecondaryHeatingPresent", "Yes") + page.wait_for_timeout(900) + E.set_heating_dialog(page, "ButtonSecondaryHeatingCode", + "^Gas", "Mains gas", "Room Heater") + tb = page.locator(f"#{E.FP}TextBoxSecondaryHeatingCode") + print("secondary code:", tb.input_value() if tb.count() else "?") + E.save_close(page) + + +def water_heating(page): + E.goto(page, "WaterHeating", "WebFormWaterHeating.aspx") + E.click_tab(page, "TabContainer_TabPanelWaterHeating") + page.wait_for_timeout(400) + E.clear_hot_water_cylinder(page) + E.set_heating_dialog(page, f"{WH}ButtonWaterHeatingCode", + "From Space Heating", "From the primary heating system") + print("water code:", page.locator(f"#{E.FP}{WH}TextBoxWaterHeatingCode").input_value()) + E.save_close(page) + + +def download(page): + _SAMPLE_DIR.mkdir(parents=True, exist_ok=True) + E.goto(page, "Recommendations", "WebFormRecommendations.aspx") + page.wait_for_timeout(1500) + with page.expect_navigation(wait_until="networkidle", timeout=40000): + page.click("#ContentBody_buttonActionSummary_Link", timeout=15000) + page.wait_for_timeout(2000) + for btn, fname in (("ContentBody_ContentPlaceHolder1_LinkButtonSummary", "elmhurst_summary.pdf"), + ("ContentBody_ContentPlaceHolder1_ButtonDebugInforPdf", "elmhurst_worksheet.pdf")): + try: + with page.expect_download(timeout=60000) as dl: + page.click(f"#{btn}", timeout=15000) + dl.value.save_as(str(_SAMPLE_DIR / fname)) + print("saved", fname) + except Exception as e: + print("!! download failed", fname, type(e).__name__, str(e)[:120]) + + +def cleanup(page): + """Leave the shared assessment neutral for the next build.""" + E.reset_transient_state(page) + print("reset transient state (RR/secondary/meter)") + + +_ORDER = ["property_description", "dimensions", "walls", "roofs", "floors", + "openings", "ventilation", "space_heating", "water_heating", "download", "cleanup"] + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] not in _ORDER: + print("usage: build_100050881708.py <" + "|".join(_ORDER) + ">") + return 2 + with E.session() as (ctx, page): + globals()[sys.argv[1]](page) + print("done:", sys.argv[1], "->", page.url) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hyde/build_100061275133.py b/scripts/hyde/build_100061275133.py new file mode 100644 index 000000000..106488c79 --- /dev/null +++ b/scripts/hyde/build_100061275133.py @@ -0,0 +1,230 @@ +"""Elmhurst build for UPRN 100061275133 (RdSAP-21.0.1, SEMI-DETACHED HOUSE, band B, +SOLID BRICK 400 mm with EXTERNAL insulation, pitched 300 mm loft, ground floor +SUSPENDED, no insulation (assumed) — no per-bp floor_construction_type lodged, +mains-gas COMBI (PCDB 18908, Worcester Greenstar 4000 88.70%), no secondary +heating, single meter, 12 DG windows ~15.32 m², TFA 78. Lodged SAP 76; engine +76 (76.33, exact match after the §5 (12) floor-infiltration fix). + +Per-cert Elmhurst validation. Uses set_single_window (deletes the shared +assessment's carried-over window rows) + reset_transient_state at the end so the +assessment is left neutral. Run: + DISPLAY=:99 python scripts/hyde/build_100061275133.py +""" +from __future__ import annotations +import sys +from pathlib import Path +import elmhurst_lib as E + +DIM = "TabContainer_TabPanelMain_WebUserControlDimensionsMain_" +WALL = ("TabContainer_TabPanelMain_InnerTabContainerMain_" + "TabPanelExternalWallMain_WebUserControlWallMain_") +PWALL = ("TabContainer_TabPanelMain_InnerTabContainerMain_" + "TabPanelPartyWallMain_WebUserControlPartyWallMain_") +ROOF = "TabContainer_TabPanelMain_WebUserControlRoofMain_" +FLOOR = "TabContainer_TabPanelMain_WebUserControlFloorsMain_" +DP = "TabContainer_TabPanelDoorsPanel_" +VP = "TabContainer_TabPanelVentilationPanel_" +APT = "TabContainer_TabPanelAirPressureTest_" +LP = "TabContainer_TabPanelLighting_" +MV = "TabContainer_TabPanelMechVent_" +WH = "TabContainer_TabPanelWaterHeating_" +MH1B = "TabContainer_TabPanelMainHeating1_WebUserControlMainHeating1_" +_SAMPLE_DIR = (Path(__file__).parent.parent.parent + / "backend/epc_api/json_samples/real_life_examples" + / "RdSAP-Schema-21.0.1/uprn_100061275133") + + +def _pick(page, suffix, contains): + val = page.evaluate( + """(a)=>{const s=document.getElementById(a[0]);if(!s)return null; + for(const o of s.options){if(o.text.toLowerCase().includes(a[1].toLowerCase()))return o.value;}return null;}""", + [f"{E.FP}{suffix}", contains]) + if val is not None: + E.set_select(page, suffix, val) + return val + + +def property_description(page): + E.goto(page, "PropertyDescription", "WebFormPropertyDescription.aspx") + E.set_select(page, "DropDownListPropertyType1", "H House") + _pick(page, "DropDownListPropertyType2", "semi") + E.set_text(page, "TextBoxStoreys", "2") + E.set_text(page, "TextBoxHabitableRooms", "3") + E.set_text(page, "TextBoxHeatedHabitableRooms", "3") + print("date ->", _pick(page, "DropDownListDateBuiltMain", "1900-1929")) + E.set_select(page, "DropDownListDateBuiltFirst", "") + E.set_select(page, "DropDownListRoomInRoofMain", "") + E.save_close(page) + + +def dimensions(page): + E.goto(page, "Dimensions", "WebFormDimensions.aspx") + E.set_text(page, f"{DIM}TextBoxFloorAreaLowestFloor", "38.99") + E.set_text(page, f"{DIM}TextBoxRoomHeightLowestFloor", "2.40") + E.set_text(page, f"{DIM}TextBoxWallPerimeterLowestFloor", "17.92") + E.set_text(page, f"{DIM}TextBoxPartyWallLengthLowestFloor", "5.09") + E.set_text(page, f"{DIM}TextBoxFloorArea1stFloor", "38.99") + E.set_text(page, f"{DIM}TextBoxRoomHeight1stFloor", "2.40") + E.set_text(page, f"{DIM}TextBoxWallPerimeter1stFloor", "17.92") + E.set_text(page, f"{DIM}TextBoxPartyWallLength1stFloor", "5.09") + E.save_close(page) + + +def walls(page): + E.goto(page, "Walls", "WebFormWalls.aspx") + _pick(page, f"{WALL}DropDownListType", "solid brick") + page.wait_for_timeout(500) + print("insulation ->", _pick(page, f"{WALL}DropDownListInsulation", "external")) + print("party ->", _pick(page, f"{PWALL}DropDownListPartyWallType", "determine") + or _pick(page, f"{PWALL}DropDownListPartyWallType", "masonry")) + # wall thickness (cert lodges 400 mm; Elmhurst else defaults 260) + for suf in (f"{WALL}TextBoxThickness", f"{WALL}TextBoxWallThickness"): + if page.locator(f"#{E.FP}{suf}").count(): + E.set_text(page, suf, "400"); print("wall thickness set 400 via", suf); break + else: + print("thickness field not found; wall fields:", [i for i in page.evaluate("()=>Array.from(document.querySelectorAll('[id*=Wall]')).map(e=>e.id)")][:20]) + E.save_close(page) + + +def roofs(page): + E.goto(page, "Roofs", "WebFormRoofs.aspx") + _pick(page, f"{ROOF}DropDownListType", "access to loft") or \ + _pick(page, f"{ROOF}DropDownListType", "loft") + _pick(page, f"{ROOF}DropDownListInsulationAt", "joists") + _pick(page, f"{ROOF}DropDownListThickness", "300") + E.save_close(page) + + +def floors(page): + E.goto(page, "Floors", "WebFormFloors.aspx") + _pick(page, f"{FLOOR}DropDownListLocation", "ground floor") + page.wait_for_timeout(400) + _pick(page, f"{FLOOR}DropDownListType", "suspended") + ins = page.locator(f"#{E.FP}{FLOOR}DropDownListInsulation") + if ins.count(): + E.set_select(page, f"{FLOOR}DropDownListInsulation", "A As built") + E.save_close(page) + + +def openings(page): + E.goto(page, "Openings", "WebFormOpenings.aspx") + E.click_tab(page, "TabContainer_TabPanelWindowsPanel") + page.wait_for_timeout(500) + E.set_single_window(page, 15.32, "North", _glazing(page)) + E.click_tab(page, "TabContainer_TabPanelDoorsPanel") + E.set_text(page, f"{DP}TextBoxDoors", "2") + E.set_text(page, f"{DP}TextBoxDoorsInsulated", "0") + E.set_text(page, f"{DP}TextBoxDraughtProofedDoors", "2") # cert: percent_draughtproofed=100 + E.save_close(page) + + +def _glazing(page): + # glazed_type 13 -> double glazed, 2022 or later (U 1.4) + return "Double post or during 2022" + + +def ventilation(page): + E.goto(page, "VentilationAndCooling", "WebFormVentilationAndCooling.aspx") + E.click_tab(page, "TabContainer_TabPanelVentilationPanel") + # Cert lodges no extract-fan count; RdSAP 10 Table 5 age-band default for + # band B (A-E) is 0, and our engine's ventilation_from_cert computes 0 — + # was wrongly entered as 1 here, inflating Elmhurst's (8)/(21) ACH vs ours. + E.set_text(page, f"{VP}TextBoxIntermittentFans", "0") + cool = page.locator(f"#{E.FP}{VP}CheckBoxFixedSpaceCooling") + if cool.count() and cool.is_checked(): + E.commit(page, cool.uncheck) + E.click_tab(page, "TabContainer_TabPanelMechVent") + mv = page.locator(f"#{E.FP}{MV}CheckBoxMechanicalVentilation") + if mv.count() and mv.is_checked(): + E.commit(page, mv.uncheck) + E.click_tab(page, "TabContainer_TabPanelAirPressureTest") + E.set_select(page, f"{APT}DropDownListTestMethod", "Not available") + E.click_tab(page, "TabContainer_TabPanelLighting") + E.set_text(page, f"{LP}TextBoxLightsTotal", "5") + E.set_text(page, f"{LP}TextBoxLedLightsTotal", "5") + E.set_text(page, f"{LP}TextBoxCflLightsTotal", "0") + E.save_close(page) + + +def space_heating(page): + # PCDB exact-boiler entry (elmhurst_lib.set_pcdb_boiler): the cert's + # main_heating_index_number 18908 resolves to Worcester Greenstar 4000 + # GR4700iW 25 C NG, 88.70% winter efficiency — matching the SAP engine's + # PCDB lookup exactly, vs the generic "Combi condens" SAP-Table code + # (84%) the old dialog-driven entry carried a keep-hot artifact from. + E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx") + page.wait_for_timeout(1000) + E.clear_main_heating_code(page) + desc = E.set_pcdb_boiler(page, 18908) + print("PCDB boiler ->", desc) + E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingControls", + "^Boilers", "^Standard", "CBE Programmer, room thermostat and TRVs") + print("control:", page.locator(f"#{E.MH1}TextBoxMainHeatingControls").input_value()) + E.set_select(page, "DropDownListSecondaryHeatingPresent", "No") + E.save_close(page) + + +def _unused_secondary(page): + E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx") + page.wait_for_timeout(600) + E.set_select(page, "DropDownListSecondaryHeatingPresent", "Yes") + page.wait_for_timeout(900) + E.set_heating_dialog(page, "ButtonSecondaryHeatingCode", + "^Gas", "Mains gas", "Room Heater") + tb = page.locator(f"#{E.FP}TextBoxSecondaryHeatingCode") + print("secondary code:", tb.input_value() if tb.count() else "?") + E.save_close(page) + + +def water_heating(page): + E.goto(page, "WaterHeating", "WebFormWaterHeating.aspx") + E.click_tab(page, "TabContainer_TabPanelWaterHeating") + page.wait_for_timeout(400) + E.clear_hot_water_cylinder(page) + E.set_heating_dialog(page, f"{WH}ButtonWaterHeatingCode", + "From Space Heating", "From the primary heating system") + print("water code:", page.locator(f"#{E.FP}{WH}TextBoxWaterHeatingCode").input_value()) + E.save_close(page) + + +def download(page): + _SAMPLE_DIR.mkdir(parents=True, exist_ok=True) + E.goto(page, "Recommendations", "WebFormRecommendations.aspx") + page.wait_for_timeout(1500) + with page.expect_navigation(wait_until="networkidle", timeout=40000): + page.click("#ContentBody_buttonActionSummary_Link", timeout=15000) + page.wait_for_timeout(2000) + for btn, fname in (("ContentBody_ContentPlaceHolder1_LinkButtonSummary", "elmhurst_summary.pdf"), + ("ContentBody_ContentPlaceHolder1_ButtonDebugInforPdf", "elmhurst_worksheet.pdf")): + try: + with page.expect_download(timeout=60000) as dl: + page.click(f"#{btn}", timeout=15000) + dl.value.save_as(str(_SAMPLE_DIR / fname)) + print("saved", fname) + except Exception as e: + print("!! download failed", fname, type(e).__name__, str(e)[:120]) + + +def cleanup(page): + """Leave the shared assessment neutral for the next build.""" + E.reset_transient_state(page) + print("reset transient state (RR/secondary/meter)") + + +_ORDER = ["property_description", "dimensions", "walls", "roofs", "floors", + "openings", "ventilation", "space_heating", + "water_heating", "download", "cleanup"] + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] not in _ORDER: + print("usage: build_100061275133.py <" + "|".join(_ORDER) + ">") + return 2 + with E.session() as (ctx, page): + globals()[sys.argv[1]](page) + print("done:", sys.argv[1], "->", page.url) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hyde/build_200004296092.py b/scripts/hyde/build_200004296092.py new file mode 100644 index 000000000..926b6809c --- /dev/null +++ b/scripts/hyde/build_200004296092.py @@ -0,0 +1,297 @@ +"""Elmhurst build for UPRN 200004296092 (RdSAP-Schema-21.0.1, END-TERRACE HOUSE, +2-storey, band B, SOLID BRICK 360 mm WITH EXTERNAL INSULATION (thickness lodged +"NI" = unknown), pitched 150 mm loft, solid uninsulated ground floor, mains-gas +COMBI (PCDB 16137 Ideal Logic combi 30 ~ generic Post-98 condensing combi), +control 2106 (CBE), double glazed ~4.31 m², 2 doors, TFA ~62, party wall 6.79 m. +Engine 71.74 = lodged 71 AFTER the §5.4 "assume 100 mm if unknown" wall fix +(slice 5); this build validates that against the accredited Elmhurst worksheet. + +The KEY page is `walls`: it prints Elmhurst's insulation dropdown so we can see +how the accredited tool handles External insulation of unknown thickness. Run: + DISPLAY=:99 python scripts/hyde/build_200004296092.py +""" +from __future__ import annotations +import sys +from pathlib import Path +import elmhurst_lib as E + +DIM = "TabContainer_TabPanelMain_WebUserControlDimensionsMain_" +WALL = ("TabContainer_TabPanelMain_InnerTabContainerMain_" + "TabPanelExternalWallMain_WebUserControlWallMain_") +PWALL = ("TabContainer_TabPanelMain_InnerTabContainerMain_" + "TabPanelPartyWallMain_WebUserControlPartyWallMain_") +ROOF = "TabContainer_TabPanelMain_WebUserControlRoofMain_" +FLOOR = "TabContainer_TabPanelMain_WebUserControlFloorsMain_" +WP = "TabContainer_TabPanelWindowsPanel_" +DP = "TabContainer_TabPanelDoorsPanel_" +VP = "TabContainer_TabPanelVentilationPanel_" +APT = "TabContainer_TabPanelAirPressureTest_" +LP = "TabContainer_TabPanelLighting_" +MV = "TabContainer_TabPanelMechVent_" +WH = "TabContainer_TabPanelWaterHeating_" +MH1B = "TabContainer_TabPanelMainHeating1_WebUserControlMainHeating1_" + +_SAMPLE_DIR = (Path(__file__).parent.parent.parent + / "backend/epc_api/json_samples/real_life_examples" + / "RdSAP-Schema-21.0.1/uprn_200004296092") + + +def _pick(page, suffix, contains): + val = page.evaluate( + """(a)=>{const s=document.getElementById(a[0]);if(!s)return null; + for(const o of s.options){if(o.text.toLowerCase().includes(a[1].toLowerCase()))return o.value;}return null;}""", + [f"{E.FP}{suffix}", contains]) + if val is not None: + E.set_select(page, suffix, val) + return val + + +def _options(page, suffix): + return page.evaluate( + """(id)=>{const s=document.getElementById(id);if(!s)return []; + return Array.from(s.options).map(o=>o.text);}""", f"{E.FP}{suffix}") + + +def property_description(page): + E.goto(page, "PropertyDescription", "WebFormPropertyDescription.aspx") + E.set_select(page, "DropDownListPropertyType1", "H House") + print("built_form ->", _pick(page, "DropDownListPropertyType2", "end-terrace") + or _pick(page, "DropDownListPropertyType2", "end terrace")) + E.set_text(page, "TextBoxStoreys", "2") + E.set_text(page, "TextBoxHabitableRooms", "3") + E.set_text(page, "TextBoxHeatedHabitableRooms", "3") + print("date ->", _pick(page, "DropDownListDateBuiltMain", "1900-1929") + or _pick(page, "DropDownListDateBuiltMain", "1900")) + E.set_select(page, "DropDownListDateBuiltFirst", "") + E.set_select(page, "DropDownListRoomInRoofMain", "") + E.save_close(page) + + +def dimensions(page): + E.goto(page, "Dimensions", "WebFormDimensions.aspx") + E.set_text(page, f"{DIM}TextBoxFloorAreaLowestFloor", "31.16") + E.set_text(page, f"{DIM}TextBoxRoomHeightLowestFloor", "2.40") + E.set_text(page, f"{DIM}TextBoxWallPerimeterLowestFloor", "15.97") + E.set_text(page, f"{DIM}TextBoxPartyWallLengthLowestFloor", "6.79") + E.set_text(page, f"{DIM}TextBoxFloorArea1stFloor", "31.16") + E.set_text(page, f"{DIM}TextBoxRoomHeight1stFloor", "2.40") + E.set_text(page, f"{DIM}TextBoxWallPerimeter1stFloor", "15.97") + E.set_text(page, f"{DIM}TextBoxPartyWallLength1stFloor", "6.79") + E.save_close(page) + + +def walls(page): + E.goto(page, "Walls", "WebFormWalls.aspx") + print("WALL TYPE options:", _options(page, f"{WALL}DropDownListType")) + _pick(page, f"{WALL}DropDownListType", "solid brick") \ + or _pick(page, f"{WALL}DropDownListType", "brick") + page.wait_for_timeout(600) + print("INSULATION options:", _options(page, f"{WALL}DropDownListInsulation")) + ins = (_pick(page, f"{WALL}DropDownListInsulation", "external") + or _pick(page, f"{WALL}DropDownListInsulation", "ext")) + print("insulation ->", ins) + page.wait_for_timeout(600) + # If a thickness dropdown appears for external insulation, show it (this is + # the slice-5 question: what does Elmhurst do with UNKNOWN thickness?). + for suf in (f"{WALL}DropDownListInsulationThickness", + f"{WALL}DropDownListExternalInsulationThickness"): + if page.locator(f"#{E.FP}{suf}").count(): + print("INSULATION THICKNESS options:", _options(page, suf), + "current:", page.locator(f"#{E.FP}{suf}").input_value()) + print("party wall ->", + _pick(page, f"{PWALL}DropDownListPartyWallType", "determine") + or _pick(page, f"{PWALL}DropDownListPartyWallType", "masonry")) + E.save_close(page) + + +def roofs(page): + E.goto(page, "Roofs", "WebFormRoofs.aspx") + _pick(page, f"{ROOF}DropDownListType", "access to loft") \ + or _pick(page, f"{ROOF}DropDownListType", "loft") + _pick(page, f"{ROOF}DropDownListInsulationAt", "joists") + E.set_select(page, f"{ROOF}DropDownListThickness", "150 mm") + E.save_close(page) + + +def floors(page): + E.goto(page, "Floors", "WebFormFloors.aspx") + E.set_select(page, f"{FLOOR}DropDownListLocation", "G Ground floor") + _pick(page, f"{FLOOR}DropDownListType", "solid") + E.set_select(page, f"{FLOOR}DropDownListInsulation", "A As built") + E.save_close(page) + + +def openings(page): + E.goto(page, "Openings", "WebFormOpenings.aspx") + E.click_tab(page, "TabContainer_TabPanelWindowsPanel") + _add_window(page, 4.31, "North", _glazing(page)) + _delete_zero_rows(page) + E.click_tab(page, "TabContainer_TabPanelDoorsPanel") + E.set_text(page, f"{DP}TextBoxDoors", "2") + E.set_text(page, f"{DP}TextBoxDoorsInsulated", "0") + E.set_text(page, f"{DP}TextBoxDraughtProofedDoors", "0") + E.save_close(page) + + +def _glazing(page): + for needle in ("unknown install date", "before 2002", "pre 2002"): + for opt in _options(page, f"{WP}DropDownListExtGlazing"): + low = opt.lower() + if needle in low and "triple" not in low and "single" not in low and "known data" not in low: + return opt + return "Double post or during 2022" + + +def _add_window(page, area, orientation, glazing): + print("glazing ->", glazing) + E.set_select(page, f"{WP}DropDownListExtGlazing", glazing) + page.wait_for_timeout(400) + ft = page.locator(f"#{E.FP}{WP}DropDownListExtFrameType") + if ft.count(): + ft.select_option("PVC") + gg = page.locator(f"#{E.FP}{WP}DropDownListExtGlazingGap") + if gg.count(): + gg.select_option("12 mm") + wid = f"{E.FP}{WP}TextBoxExtWidth" + page.evaluate( + """(a)=>{const e=document.getElementById(a[0]);if(e){e.value=a[1]; + e.dispatchEvent(new Event('input',{bubbles:true})); + e.dispatchEvent(new Event('change',{bubbles:true})); + e.dispatchEvent(new Event('blur',{bubbles:true}));}}""", [wid, str(area)]) + page.locator(f"#{E.FP}{WP}TextBoxExtHeight").fill("1.00") + page.locator(f"#{E.FP}{WP}DropDownListExtOrientation").select_option(orientation) + page.locator(f"#{E.FP}{WP}DropDownListExtBuildingPartId").select_option("Main") + page.locator(f"#{E.FP}{WP}DropDownListExtLocation").select_option("External wall") + page.wait_for_timeout(300) + before = E.window_row_count(page) + page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", f"{E.FP}{WP}ButtonAddWindow") + for _ in range(25): + page.wait_for_timeout(200) + if E.window_row_count(page) > before: + break + + +def _grid_rows(page): + return page.evaluate( + """()=>{const t=document.querySelector("[id*=GridViewExtendedWidows]"); + if(!t)return[];return Array.from(t.querySelectorAll('tr')).slice(1) + .map(r=>Array.from(r.querySelectorAll('td')).map(c=>c.innerText.trim()));}""") + + +def _delete_zero_rows(page): + g = 0 + while g < 6 and E.window_row_count(page) > 1: + g += 1 + rows = _grid_rows(page) + bad = next((i for i, c in enumerate(rows) if len(c) > 1 and c[1] in ("0.00", "0", "0.0")), None) + if bad is None: + break + _delete_row(page, bad) + page.wait_for_timeout(400) + + +def _delete_row(page, idx): + before = E.window_row_count(page) + btn = page.evaluate( + """(i)=>{const b=document.querySelectorAll("[id*='GridViewExtendedWidows_DeleteButton_']");return b[i]?b[i].id:null;}""", idx) + if not btn: + return + page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", btn) + page.wait_for_selector(f"#{E.FP}DeleteWindowDialog_LinkButtonYes", state="visible", timeout=5000) + page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", f"{E.FP}DeleteWindowDialog_LinkButtonYes") + for _ in range(20): + page.wait_for_timeout(200) + if E.window_row_count(page) < before: + break + + +def ventilation(page): + E.goto(page, "VentilationAndCooling", "WebFormVentilationAndCooling.aspx") + E.click_tab(page, "TabContainer_TabPanelVentilationPanel") + E.set_text(page, f"{VP}TextBoxIntermittentFans", "0") + cool = page.locator(f"#{E.FP}{VP}CheckBoxFixedSpaceCooling") + if cool.count() and cool.is_checked(): + E.commit(page, cool.uncheck) + E.click_tab(page, "TabContainer_TabPanelMechVent") + mv = page.locator(f"#{E.FP}{MV}CheckBoxMechanicalVentilation") + if mv.count() and mv.is_checked(): + E.commit(page, mv.uncheck) + E.click_tab(page, "TabContainer_TabPanelAirPressureTest") + E.set_select(page, f"{APT}DropDownListTestMethod", "Not available") + E.click_tab(page, "TabContainer_TabPanelLighting") + E.set_text(page, f"{LP}TextBoxLightsTotal", "6") + E.set_text(page, f"{LP}TextBoxLedLightsTotal", "0") + E.set_text(page, f"{LP}TextBoxCflLightsTotal", "0") + E.save_close(page) + + +def space_heating(page): + E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx") + page.wait_for_timeout(1000) + mhc = page.locator(f"#{E.MH1}TextBoxMainHeatingCode") + code = mhc.input_value() if mhc.count() else "" + if code and code not in ("0",): + print(f"clearing leftover MainHeatingCode {code}") + page.evaluate("""(id)=>{const e=document.getElementById(id);if(e){e.value=''; + e.dispatchEvent(new Event('change',{bubbles:true}));}}""", f"{E.MH1}TextBoxMainHeatingCode") + page.wait_for_timeout(400) + E.save_close(page) + return + # Generic Post-1998 condensing combi (~89% SAP 4b, ≈ PCDB 16137 Ideal Logic + # combi 30). combi vs regular is set on the water page (from primary + no + # cylinder). Control 2106 = CBE (programmer, room thermostat and TRVs). + E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingCode", + "^Gas", "Mains gas", "Boilers", "Post 1998", "Condensing", + "Combi condens") + print("code:", page.locator(f"#{E.MH1}TextBoxMainHeatingCode").input_value()) + E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingControls", + "^Boilers", "^Standard", "CBE Programmer, room thermostat and TRVs") + print("control:", page.locator(f"#{E.MH1}TextBoxMainHeatingControls").input_value()) + E.set_select(page, "DropDownListSecondaryHeatingPresent", "No") + E.save_close(page) + + +def water_heating(page): + E.goto(page, "WaterHeating", "WebFormWaterHeating.aspx") + E.click_tab(page, "TabContainer_TabPanelWaterHeating") + page.wait_for_timeout(400) + E.clear_hot_water_cylinder(page) + E.set_heating_dialog(page, f"{WH}ButtonWaterHeatingCode", + "From Space Heating", "From the primary heating system") + print("water code:", page.locator(f"#{E.FP}{WH}TextBoxWaterHeatingCode").input_value()) + E.save_close(page) + + +def download(page): + _SAMPLE_DIR.mkdir(parents=True, exist_ok=True) + print("navigating via Recommendations ...") + with page.expect_navigation(wait_until="networkidle", timeout=60_000): + page.click("#ContentBody_buttonActionRecommendations_Link", timeout=15_000) + page.wait_for_timeout(1500) + for btn, fname in (("ContentBody_ContentPlaceHolder1_LinkButtonSummary", "elmhurst_summary.pdf"), + ("ContentBody_ContentPlaceHolder1_ButtonDebugInforPdf", "elmhurst_worksheet.pdf")): + try: + with page.expect_download(timeout=60_000) as dl: + page.click(f"#{btn}", timeout=15_000) + dl.value.save_as(str(_SAMPLE_DIR / fname)) + print("saved", fname) + except Exception as e: + print("!! download failed", fname, type(e).__name__, str(e)[:120]) + + +_ORDER = ["property_description", "dimensions", "walls", "roofs", "floors", + "openings", "ventilation", "space_heating", "water_heating", "download"] + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] not in _ORDER: + print("usage: build_200004296092.py <" + "|".join(_ORDER) + ">") + return 2 + with E.session() as (ctx, page): + globals()[sys.argv[1]](page) + print("done:", sys.argv[1], "->", page.url) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hyde/build_217091901.py b/scripts/hyde/build_217091901.py new file mode 100644 index 000000000..f487d973c --- /dev/null +++ b/scripts/hyde/build_217091901.py @@ -0,0 +1,287 @@ +"""Elmhurst build for UPRN 217091901 (RdSAP-21.0.1, TOP-FLOOR FLAT, band A, +SOLID BRICK 280 mm UNINSULATED, FLAT ROOF no insulation (As Built), floor over +ANOTHER DWELLING below (zero loss), mains-gas COMBI (PCDB 18658), 6 DG windows +~8.62 m², no doors, TFA 63. Lodged SAP 62; engine 60.82 (Δ −1.18, PE +8.7). + +Per-cert worksheet validation. The roof dominates loss (145.6 W/K at flat-default +U 2.30); this build confirms the accredited uninsulated flat-roof + solid-brick +wall U against the engine. Run: + DISPLAY=:99 python scripts/hyde/build_217091901.py +""" +from __future__ import annotations +import sys +from pathlib import Path +import elmhurst_lib as E + +DIM = "TabContainer_TabPanelMain_WebUserControlDimensionsMain_" +WALL = ("TabContainer_TabPanelMain_InnerTabContainerMain_" + "TabPanelExternalWallMain_WebUserControlWallMain_") +ROOF = "TabContainer_TabPanelMain_WebUserControlRoofMain_" +FLOOR = "TabContainer_TabPanelMain_WebUserControlFloorsMain_" +WP = "TabContainer_TabPanelWindowsPanel_" +DP = "TabContainer_TabPanelDoorsPanel_" +VP = "TabContainer_TabPanelVentilationPanel_" +APT = "TabContainer_TabPanelAirPressureTest_" +LP = "TabContainer_TabPanelLighting_" +MV = "TabContainer_TabPanelMechVent_" +WH = "TabContainer_TabPanelWaterHeating_" +MH1B = "TabContainer_TabPanelMainHeating1_WebUserControlMainHeating1_" +_SAMPLE_DIR = (Path(__file__).parent.parent.parent + / "backend/epc_api/json_samples/real_life_examples" + / "RdSAP-Schema-21.0.1/uprn_217091901") + + +def _pick(page, suffix, contains): + val = page.evaluate( + """(a)=>{const s=document.getElementById(a[0]);if(!s)return null; + for(const o of s.options){if(o.text.toLowerCase().includes(a[1].toLowerCase()))return o.value;}return null;}""", + [f"{E.FP}{suffix}", contains]) + if val is not None: + E.set_select(page, suffix, val) + return val + + +def _options(page, suffix): + return page.evaluate( + """(id)=>{const s=document.getElementById(id);if(!s)return []; + return Array.from(s.options).map(o=>o.text);}""", f"{E.FP}{suffix}") + + +def property_description(page): + E.goto(page, "PropertyDescription", "WebFormPropertyDescription.aspx") + _pick(page, "DropDownListPropertyType1", "flat") or \ + E.set_select(page, "DropDownListPropertyType1", "F Flat") + E.set_text(page, "TextBoxStoreys", "1") + E.set_text(page, "TextBoxHabitableRooms", "3") + E.set_text(page, "TextBoxHeatedHabitableRooms", "3") + print("date ->", _pick(page, "DropDownListDateBuiltMain", "before 1900")) + E.set_select(page, "DropDownListDateBuiltFirst", "") + E.save_close(page) + + +def flats(page): + E.goto(page, "Flats", "WebFormFlats.aspx") + print("flat positions:", _options(page, "" if False else "DropDownListPositionOfFlat")) + _pick(page, "DropDownListPositionOfFlat", "top") or \ + E.set_select(page, "DropDownListPositionOfFlat", "Top Floor") + E.set_text(page, "TextBoxFloor", "3") + E.set_select(page, "RadioButtonListFlatCoridor", "None") + E.save_close(page) + + +def dimensions(page): + E.goto(page, "Dimensions", "WebFormDimensions.aspx") + E.set_text(page, f"{DIM}TextBoxFloorAreaLowestFloor", "63.29") + E.set_text(page, f"{DIM}TextBoxRoomHeightLowestFloor", "2.85") + E.set_text(page, f"{DIM}TextBoxWallPerimeterLowestFloor", "14.0") + E.set_text(page, f"{DIM}TextBoxPartyWallLengthLowestFloor", "22.8") + E.save_close(page) + + +def walls(page): + E.goto(page, "Walls", "WebFormWalls.aspx") + _pick(page, f"{WALL}DropDownListType", "solid brick") + page.wait_for_timeout(500) + print("insulation opts:", _options(page, f"{WALL}DropDownListInsulation")) + _pick(page, f"{WALL}DropDownListInsulation", "as built") or \ + _pick(page, f"{WALL}DropDownListInsulation", "none") + E.save_close(page) + + +def roofs(page): + E.goto(page, "Roofs", "WebFormRoofs.aspx") + print("ROOF TYPE opts:", _options(page, f"{ROOF}DropDownListType")) + _pick(page, f"{ROOF}DropDownListType", "flat") + page.wait_for_timeout(600) + _pick(page, f"{ROOF}DropDownListInsulationAt", "flat roof insulation") + page.wait_for_timeout(600) + # discover the thickness/additional field that appears for flat-roof insulation + for suf in (f"{ROOF}DropDownListThickness", f"{ROOF}DropDownListInsulation", + f"{ROOF}DropDownListAdditionalInsulation"): + if page.locator(f"#{E.FP}{suf}").count(): + print(f" {suf.split('_')[-1]} opts:", _options(page, suf)) + _pick(page, suf, "as built") or _pick(page, suf, "unknown") or \ + _pick(page, suf, "none") or _pick(page, suf, "nil") + E.save_close(page) + + +def floors(page): + E.goto(page, "Floors", "WebFormFloors.aspx") + print("FLOOR LOCATION opts:", _options(page, f"{FLOOR}DropDownListLocation")) + # "(other premises below)" = above a partially-heated space + _pick(page, f"{FLOOR}DropDownListLocation", "another dwelling below") + E.save_close(page) + + +def openings(page): + E.goto(page, "Openings", "WebFormOpenings.aspx") + E.click_tab(page, "TabContainer_TabPanelWindowsPanel") + E.set_single_window(page, 8.62, "North", _glazing(page)) + _delete_zero_rows(page) + E.click_tab(page, "TabContainer_TabPanelDoorsPanel") + E.set_text(page, f"{DP}TextBoxDoors", "0") + E.set_text(page, f"{DP}TextBoxDoorsInsulated", "0") + E.set_text(page, f"{DP}TextBoxDraughtProofedDoors", "0") + E.save_close(page) + + +def _glazing(page): + # Cert glazed_type=5 → SINGLE glazing (epc_codes.csv, RdSAP-Schema-21). + for opt in _options(page, f"{WP}DropDownListExtGlazing"): + if "single" in opt.lower(): + return opt + return "Single glazed" + + +def _add_window(page, area, orientation, glazing): + print("glazing ->", glazing) + E.set_select(page, f"{WP}DropDownListExtGlazing", glazing) + page.wait_for_timeout(400) + ft = page.locator(f"#{E.FP}{WP}DropDownListExtFrameType") + if ft.count(): + ft.select_option("PVC") + gg = page.locator(f"#{E.FP}{WP}DropDownListExtGlazingGap") + if gg.count(): + gg.select_option("12 mm") + wid = f"{E.FP}{WP}TextBoxExtWidth" + page.evaluate( + """(a)=>{const e=document.getElementById(a[0]);if(e){e.value=a[1]; + e.dispatchEvent(new Event('input',{bubbles:true})); + e.dispatchEvent(new Event('change',{bubbles:true})); + e.dispatchEvent(new Event('blur',{bubbles:true}));}}""", [wid, str(area)]) + page.locator(f"#{E.FP}{WP}TextBoxExtHeight").fill("1.00") + page.locator(f"#{E.FP}{WP}DropDownListExtOrientation").select_option(orientation) + page.locator(f"#{E.FP}{WP}DropDownListExtBuildingPartId").select_option("Main") + page.locator(f"#{E.FP}{WP}DropDownListExtLocation").select_option("External wall") + page.wait_for_timeout(300) + before = E.window_row_count(page) + page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", f"{E.FP}{WP}ButtonAddWindow") + for _ in range(25): + page.wait_for_timeout(200) + if E.window_row_count(page) > before: + break + + +def _grid_rows(page): + return page.evaluate( + """()=>{const t=document.querySelector("[id*=GridViewExtendedWidows]"); + if(!t)return[];return Array.from(t.querySelectorAll('tr')).slice(1) + .map(r=>Array.from(r.querySelectorAll('td')).map(c=>c.innerText.trim()));}""") + + +def _delete_zero_rows(page): + g = 0 + while g < 6 and E.window_row_count(page) > 1: + g += 1 + rows = _grid_rows(page) + bad = next((i for i, c in enumerate(rows) if len(c) > 1 and c[1] in ("0.00", "0", "0.0")), None) + if bad is None: + break + _delete_row(page, bad) + page.wait_for_timeout(400) + + +def _delete_row(page, idx): + before = E.window_row_count(page) + btn = page.evaluate( + """(i)=>{const b=document.querySelectorAll("[id*='GridViewExtendedWidows_DeleteButton_']");return b[i]?b[i].id:null;}""", idx) + if not btn: + return + page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", btn) + page.wait_for_selector(f"#{E.FP}DeleteWindowDialog_LinkButtonYes", state="visible", timeout=5000) + page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", f"{E.FP}DeleteWindowDialog_LinkButtonYes") + for _ in range(20): + page.wait_for_timeout(200) + if E.window_row_count(page) < before: + break + + +def ventilation(page): + E.goto(page, "VentilationAndCooling", "WebFormVentilationAndCooling.aspx") + E.click_tab(page, "TabContainer_TabPanelVentilationPanel") + E.set_text(page, f"{VP}TextBoxIntermittentFans", "0") + cool = page.locator(f"#{E.FP}{VP}CheckBoxFixedSpaceCooling") + if cool.count() and cool.is_checked(): + E.commit(page, cool.uncheck) + E.click_tab(page, "TabContainer_TabPanelMechVent") + mv = page.locator(f"#{E.FP}{MV}CheckBoxMechanicalVentilation") + if mv.count() and mv.is_checked(): + E.commit(page, mv.uncheck) + E.click_tab(page, "TabContainer_TabPanelAirPressureTest") + E.set_select(page, f"{APT}DropDownListTestMethod", "Not available") + E.click_tab(page, "TabContainer_TabPanelLighting") + E.set_text(page, f"{LP}TextBoxLightsTotal", "6") + E.set_text(page, f"{LP}TextBoxLedLightsTotal", "0") + E.set_text(page, f"{LP}TextBoxCflLightsTotal", "0") + E.save_close(page) + + +def space_heating(page): + E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx") + page.wait_for_timeout(1000) + mhc = page.locator(f"#{E.MH1}TextBoxMainHeatingCode") + code = mhc.input_value() if mhc.count() else "" + if code and code not in ("0",): + print(f"clearing leftover MainHeatingCode {code}") + page.evaluate("""(id)=>{const e=document.getElementById(id);if(e){e.value=''; + e.dispatchEvent(new Event('change',{bubbles:true}));}}""", f"{E.MH1}TextBoxMainHeatingCode") + page.wait_for_timeout(400) + E.save_close(page) + return + E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingCode", + "^Gas", "Mains gas", "Boilers", "Post 1998", "Condensing", + "Combi condens") + print("code:", page.locator(f"#{E.MH1}TextBoxMainHeatingCode").input_value()) + E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingControls", + "^Boilers", "^Standard", "CBE Programmer, room thermostat and TRVs") + print("control:", page.locator(f"#{E.MH1}TextBoxMainHeatingControls").input_value()) + E.set_select(page, "DropDownListSecondaryHeatingPresent", "No") + E.save_close(page) + + +def water_heating(page): + E.goto(page, "WaterHeating", "WebFormWaterHeating.aspx") + E.click_tab(page, "TabContainer_TabPanelWaterHeating") + page.wait_for_timeout(400) + E.clear_hot_water_cylinder(page) + E.set_heating_dialog(page, f"{WH}ButtonWaterHeatingCode", + "From Space Heating", "From the primary heating system") + print("water code:", page.locator(f"#{E.FP}{WH}TextBoxWaterHeatingCode").input_value()) + E.save_close(page) + + +def download(page): + _SAMPLE_DIR.mkdir(parents=True, exist_ok=True) + E.goto(page, "Recommendations", "WebFormRecommendations.aspx") + page.wait_for_timeout(1500) + with page.expect_navigation(wait_until="networkidle", timeout=40000): + page.click("#ContentBody_buttonActionSummary_Link", timeout=15000) + page.wait_for_timeout(2000) + for btn, fname in (("ContentBody_ContentPlaceHolder1_LinkButtonSummary", "elmhurst_summary.pdf"), + ("ContentBody_ContentPlaceHolder1_ButtonDebugInforPdf", "elmhurst_worksheet.pdf")): + try: + with page.expect_download(timeout=60000) as dl: + page.click(f"#{btn}", timeout=15000) + dl.value.save_as(str(_SAMPLE_DIR / fname)) + print("saved", fname) + except Exception as e: + print("!! download failed", fname, type(e).__name__, str(e)[:120]) + + +_ORDER = ["property_description", "flats", "dimensions", "walls", "roofs", + "floors", "openings", "ventilation", "space_heating", "water_heating", + "download"] + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] not in _ORDER: + print("usage: build_217091901.py <" + "|".join(_ORDER) + ">") + return 2 + with E.session() as (ctx, page): + globals()[sys.argv[1]](page) + print("done:", sys.argv[1], "->", page.url) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hyde/build_47084930.py b/scripts/hyde/build_47084930.py new file mode 100644 index 000000000..679a9e56e --- /dev/null +++ b/scripts/hyde/build_47084930.py @@ -0,0 +1,299 @@ +"""Elmhurst build for UPRN 47084930 (RdSAP-21.0.1, TOP-FLOOR FLAT, band E, +CAVITY wall with INTERNAL insulation (100 mm), FLAT ROOF insulated 75 mm, floor +over OTHER PREMISES below, mains-gas COMBI (PCDB 17815), 5 DG windows ~9.9 m², +no doors, TFA 64. Lodged SAP 74; engine 69.51 (Δ −4.49, PE +47 over-count). + +Per-cert worksheet validation. Suspected engine gap: the flat-roof insulation +thickness is lodged in `flat_roof_insulation_thickness` (75 mm) but the U-value +calc reads `roof_insulation_thickness` (None) → bills the roof at the age-E flat +default 1.5 (96 W/K) instead of the 75 mm insulated ~0.5 (~30 W/K). This build +gets the accredited flat-roof + floor U. Run: + DISPLAY=:99 python scripts/hyde/build_47084930.py +""" +from __future__ import annotations +import sys +from pathlib import Path +import elmhurst_lib as E + +DIM = "TabContainer_TabPanelMain_WebUserControlDimensionsMain_" +WALL = ("TabContainer_TabPanelMain_InnerTabContainerMain_" + "TabPanelExternalWallMain_WebUserControlWallMain_") +ROOF = "TabContainer_TabPanelMain_WebUserControlRoofMain_" +FLOOR = "TabContainer_TabPanelMain_WebUserControlFloorsMain_" +WP = "TabContainer_TabPanelWindowsPanel_" +DP = "TabContainer_TabPanelDoorsPanel_" +VP = "TabContainer_TabPanelVentilationPanel_" +APT = "TabContainer_TabPanelAirPressureTest_" +LP = "TabContainer_TabPanelLighting_" +MV = "TabContainer_TabPanelMechVent_" +WH = "TabContainer_TabPanelWaterHeating_" +MH1B = "TabContainer_TabPanelMainHeating1_WebUserControlMainHeating1_" +_SAMPLE_DIR = (Path(__file__).parent.parent.parent + / "backend/epc_api/json_samples/real_life_examples" + / "RdSAP-Schema-21.0.1/uprn_47084930") + + +def _pick(page, suffix, contains): + val = page.evaluate( + """(a)=>{const s=document.getElementById(a[0]);if(!s)return null; + for(const o of s.options){if(o.text.toLowerCase().includes(a[1].toLowerCase()))return o.value;}return null;}""", + [f"{E.FP}{suffix}", contains]) + if val is not None: + E.set_select(page, suffix, val) + return val + + +def _options(page, suffix): + return page.evaluate( + """(id)=>{const s=document.getElementById(id);if(!s)return []; + return Array.from(s.options).map(o=>o.text);}""", f"{E.FP}{suffix}") + + +def property_description(page): + E.goto(page, "PropertyDescription", "WebFormPropertyDescription.aspx") + _pick(page, "DropDownListPropertyType1", "flat") or \ + E.set_select(page, "DropDownListPropertyType1", "F Flat") + E.set_text(page, "TextBoxStoreys", "1") + E.set_text(page, "TextBoxHabitableRooms", "3") + E.set_text(page, "TextBoxHeatedHabitableRooms", "3") + print("date ->", _pick(page, "DropDownListDateBuiltMain", "1967-1975")) + E.set_select(page, "DropDownListDateBuiltFirst", "") + E.save_close(page) + + +def flats(page): + E.goto(page, "Flats", "WebFormFlats.aspx") + print("flat positions:", _options(page, "" if False else "DropDownListPositionOfFlat")) + _pick(page, "DropDownListPositionOfFlat", "top") or \ + E.set_select(page, "DropDownListPositionOfFlat", "Top Floor") + E.set_text(page, "TextBoxFloor", "3") + E.set_select(page, "RadioButtonListFlatCoridor", "None") + E.save_close(page) + + +def dimensions(page): + E.goto(page, "Dimensions", "WebFormDimensions.aspx") + E.set_text(page, f"{DIM}TextBoxFloorAreaLowestFloor", "64.26") + E.set_text(page, f"{DIM}TextBoxRoomHeightLowestFloor", "2.32") + E.set_text(page, f"{DIM}TextBoxWallPerimeterLowestFloor", "18.15") + E.set_text(page, f"{DIM}TextBoxPartyWallLengthLowestFloor", "18.15") + E.save_close(page) + + +def walls(page): + E.goto(page, "Walls", "WebFormWalls.aspx") + E.set_select(page, f"{WALL}DropDownListType", "CA Cavity") + page.wait_for_timeout(500) + print("insulation opts:", _options(page, f"{WALL}DropDownListInsulation")) + _pick(page, f"{WALL}DropDownListInsulation", "internal") + page.wait_for_timeout(500) + for suf in (f"{WALL}DropDownListInsulationThickness",): + if page.locator(f"#{E.FP}{suf}").count(): + print("wall ins thickness opts:", _options(page, suf)) + _pick(page, suf, "100") + E.save_close(page) + + +def roofs(page): + E.goto(page, "Roofs", "WebFormRoofs.aspx") + print("ROOF TYPE opts:", _options(page, f"{ROOF}DropDownListType")) + _pick(page, f"{ROOF}DropDownListType", "flat") + page.wait_for_timeout(600) + # discover flat-roof insulation fields + for suf in (f"{ROOF}DropDownListInsulationAt", f"{ROOF}DropDownListThickness", + f"{ROOF}DropDownListInsulation"): + if page.locator(f"#{E.FP}{suf}").count(): + print(f" {suf.split('_')[-1]} opts:", _options(page, suf)) + # flat roof insulated 75 mm + if page.locator(f"#{E.FP}{ROOF}DropDownListThickness").count(): + _pick(page, f"{ROOF}DropDownListThickness", "75") + E.save_close(page) + + +def floors(page): + E.goto(page, "Floors", "WebFormFloors.aspx") + print("FLOOR LOCATION opts:", _options(page, f"{FLOOR}DropDownListLocation")) + # "(other premises below)" = above a partially-heated space + (_pick(page, f"{FLOOR}DropDownListLocation", "partially heated") + or _pick(page, f"{FLOOR}DropDownListLocation", "other premises") + or _pick(page, f"{FLOOR}DropDownListLocation", "unheated")) + page.wait_for_timeout(400) + tsel = page.locator(f"#{E.FP}{FLOOR}DropDownListType") + if tsel.count(): + _pick(page, f"{FLOOR}DropDownListType", "solid") + E.save_close(page) + + +def openings(page): + E.goto(page, "Openings", "WebFormOpenings.aspx") + E.click_tab(page, "TabContainer_TabPanelWindowsPanel") + _add_window(page, 9.9, "North", _glazing(page)) + _delete_zero_rows(page) + E.click_tab(page, "TabContainer_TabPanelDoorsPanel") + E.set_text(page, f"{DP}TextBoxDoors", "0") + E.set_text(page, f"{DP}TextBoxDoorsInsulated", "0") + E.set_text(page, f"{DP}TextBoxDraughtProofedDoors", "0") + E.save_close(page) + + +def _glazing(page): + for needle in ("unknown install date", "before 2002", "pre 2002"): + for opt in _options(page, f"{WP}DropDownListExtGlazing"): + low = opt.lower() + if needle in low and "triple" not in low and "single" not in low and "known data" not in low: + return opt + return "Double post or during 2022" + + +def _add_window(page, area, orientation, glazing): + print("glazing ->", glazing) + E.set_select(page, f"{WP}DropDownListExtGlazing", glazing) + page.wait_for_timeout(400) + ft = page.locator(f"#{E.FP}{WP}DropDownListExtFrameType") + if ft.count(): + ft.select_option("PVC") + gg = page.locator(f"#{E.FP}{WP}DropDownListExtGlazingGap") + if gg.count(): + gg.select_option("12 mm") + wid = f"{E.FP}{WP}TextBoxExtWidth" + page.evaluate( + """(a)=>{const e=document.getElementById(a[0]);if(e){e.value=a[1]; + e.dispatchEvent(new Event('input',{bubbles:true})); + e.dispatchEvent(new Event('change',{bubbles:true})); + e.dispatchEvent(new Event('blur',{bubbles:true}));}}""", [wid, str(area)]) + page.locator(f"#{E.FP}{WP}TextBoxExtHeight").fill("1.00") + page.locator(f"#{E.FP}{WP}DropDownListExtOrientation").select_option(orientation) + page.locator(f"#{E.FP}{WP}DropDownListExtBuildingPartId").select_option("Main") + page.locator(f"#{E.FP}{WP}DropDownListExtLocation").select_option("External wall") + page.wait_for_timeout(300) + before = E.window_row_count(page) + page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", f"{E.FP}{WP}ButtonAddWindow") + for _ in range(25): + page.wait_for_timeout(200) + if E.window_row_count(page) > before: + break + + +def _grid_rows(page): + return page.evaluate( + """()=>{const t=document.querySelector("[id*=GridViewExtendedWidows]"); + if(!t)return[];return Array.from(t.querySelectorAll('tr')).slice(1) + .map(r=>Array.from(r.querySelectorAll('td')).map(c=>c.innerText.trim()));}""") + + +def _delete_zero_rows(page): + g = 0 + while g < 6 and E.window_row_count(page) > 1: + g += 1 + rows = _grid_rows(page) + bad = next((i for i, c in enumerate(rows) if len(c) > 1 and c[1] in ("0.00", "0", "0.0")), None) + if bad is None: + break + _delete_row(page, bad) + page.wait_for_timeout(400) + + +def _delete_row(page, idx): + before = E.window_row_count(page) + btn = page.evaluate( + """(i)=>{const b=document.querySelectorAll("[id*='GridViewExtendedWidows_DeleteButton_']");return b[i]?b[i].id:null;}""", idx) + if not btn: + return + page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", btn) + page.wait_for_selector(f"#{E.FP}DeleteWindowDialog_LinkButtonYes", state="visible", timeout=5000) + page.evaluate("(id)=>{const e=document.getElementById(id); if(e)e.click();}", f"{E.FP}DeleteWindowDialog_LinkButtonYes") + for _ in range(20): + page.wait_for_timeout(200) + if E.window_row_count(page) < before: + break + + +def ventilation(page): + E.goto(page, "VentilationAndCooling", "WebFormVentilationAndCooling.aspx") + E.click_tab(page, "TabContainer_TabPanelVentilationPanel") + E.set_text(page, f"{VP}TextBoxIntermittentFans", "0") + cool = page.locator(f"#{E.FP}{VP}CheckBoxFixedSpaceCooling") + if cool.count() and cool.is_checked(): + E.commit(page, cool.uncheck) + E.click_tab(page, "TabContainer_TabPanelMechVent") + mv = page.locator(f"#{E.FP}{MV}CheckBoxMechanicalVentilation") + if mv.count() and mv.is_checked(): + E.commit(page, mv.uncheck) + E.click_tab(page, "TabContainer_TabPanelAirPressureTest") + E.set_select(page, f"{APT}DropDownListTestMethod", "Not available") + E.click_tab(page, "TabContainer_TabPanelLighting") + E.set_text(page, f"{LP}TextBoxLightsTotal", "6") + E.set_text(page, f"{LP}TextBoxLedLightsTotal", "0") + E.set_text(page, f"{LP}TextBoxCflLightsTotal", "0") + E.save_close(page) + + +def space_heating(page): + E.goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx") + page.wait_for_timeout(1000) + mhc = page.locator(f"#{E.MH1}TextBoxMainHeatingCode") + code = mhc.input_value() if mhc.count() else "" + if code and code not in ("0",): + print(f"clearing leftover MainHeatingCode {code}") + page.evaluate("""(id)=>{const e=document.getElementById(id);if(e){e.value=''; + e.dispatchEvent(new Event('change',{bubbles:true}));}}""", f"{E.MH1}TextBoxMainHeatingCode") + page.wait_for_timeout(400) + E.save_close(page) + return + E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingCode", + "^Gas", "Mains gas", "Boilers", "Post 1998", "Condensing", + "Combi condens") + print("code:", page.locator(f"#{E.MH1}TextBoxMainHeatingCode").input_value()) + E.set_heating_dialog(page, f"{MH1B}ButtonMainHeatingControls", + "^Boilers", "^Standard", "CBE Programmer, room thermostat and TRVs") + print("control:", page.locator(f"#{E.MH1}TextBoxMainHeatingControls").input_value()) + E.set_select(page, "DropDownListSecondaryHeatingPresent", "No") + E.save_close(page) + + +def water_heating(page): + E.goto(page, "WaterHeating", "WebFormWaterHeating.aspx") + E.click_tab(page, "TabContainer_TabPanelWaterHeating") + page.wait_for_timeout(400) + E.clear_hot_water_cylinder(page) + E.set_heating_dialog(page, f"{WH}ButtonWaterHeatingCode", + "From Space Heating", "From the primary heating system") + print("water code:", page.locator(f"#{E.FP}{WH}TextBoxWaterHeatingCode").input_value()) + E.save_close(page) + + +def download(page): + _SAMPLE_DIR.mkdir(parents=True, exist_ok=True) + E.goto(page, "Recommendations", "WebFormRecommendations.aspx") + page.wait_for_timeout(1500) + with page.expect_navigation(wait_until="networkidle", timeout=40000): + page.click("#ContentBody_buttonActionSummary_Link", timeout=15000) + page.wait_for_timeout(2000) + for btn, fname in (("ContentBody_ContentPlaceHolder1_LinkButtonSummary", "elmhurst_summary.pdf"), + ("ContentBody_ContentPlaceHolder1_ButtonDebugInforPdf", "elmhurst_worksheet.pdf")): + try: + with page.expect_download(timeout=60000) as dl: + page.click(f"#{btn}", timeout=15000) + dl.value.save_as(str(_SAMPLE_DIR / fname)) + print("saved", fname) + except Exception as e: + print("!! download failed", fname, type(e).__name__, str(e)[:120]) + + +_ORDER = ["property_description", "flats", "dimensions", "walls", "roofs", + "floors", "openings", "ventilation", "space_heating", "water_heating", + "download"] + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] not in _ORDER: + print("usage: build_47084930.py <" + "|".join(_ORDER) + ">") + return 2 + with E.session() as (ctx, page): + globals()[sys.argv[1]](page) + print("done:", sys.argv[1], "->", page.url) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hyde/elmhurst_lib.py b/scripts/hyde/elmhurst_lib.py index 71d1ba792..72f2fb9bc 100644 --- a/scripts/hyde/elmhurst_lib.py +++ b/scripts/hyde/elmhurst_lib.py @@ -235,24 +235,129 @@ def add_combined_window( ) -> None: """Add ONE window row of `total_area_m2` (width=area, height=1.0). Sets the required per-row fields (orientation/location/building-part) so the - Recommendations validation passes. Caller then deletes the prior rows.""" + Recommendations validation passes. Caller then deletes the prior rows. + + Glazing MUST be selected first (it AutoPostBacks and re-renders the form); + width/height are then set via a Playwright `.fill()` + Tab so the ASP.NET + AutoPostBack fires and the server-side Area (w×h) commits — a JS-set of the + value bypasses the postback and Elmhurst persists a 0.00-area row. + Frame Type + Glazing Gap are REQUIRED by the Recommendations validation + gate (else "Frame Type/Glazing Gap must be entered" blocks the worksheet).""" set_select(page, f"{_WP}DropDownListExtGlazing", glazing) - page.locator(f"#{FP}{_WP}TextBoxExtWidth").fill(str(total_area_m2)) - h = page.locator(f"#{FP}{_WP}TextBoxExtHeight") - h.fill("1.00") - commit(page, lambda: h.press("Tab")) - page.locator(f"#{FP}{_WP}DropDownListExtOrientation").select_option(orientation) - page.locator(f"#{FP}{_WP}DropDownListExtBuildingPartId").select_option("Main") - page.locator(f"#{FP}{_WP}DropDownListExtLocation").select_option("External wall") - page.wait_for_timeout(200) + page.wait_for_timeout(SETTLE_MS) + + def _fill_tab(suffix: str, value: str) -> None: + loc = page.locator(f"#{FP}{suffix}") + loc.fill(value) + loc.press("Tab") + page.wait_for_timeout(1200) # AutoPostBack settle + + _fill_tab(f"{_WP}TextBoxExtWidth", str(total_area_m2)) + _fill_tab(f"{_WP}TextBoxExtHeight", "1.00") + # Frame Type (PVC) + Glazing Gap (12 mm) are validation-required per row. + for suffix, opt in ( + (f"{_WP}DropDownListExtFrameType", "PVC"), + (f"{_WP}DropDownListExtGlazingGap", "12 mm"), + (f"{_WP}DropDownListExtOrientation", orientation), + (f"{_WP}DropDownListExtBuildingPartId", "Main"), + (f"{_WP}DropDownListExtLocation", "External wall"), + ): + loc = page.locator(f"#{FP}{suffix}") + if loc.count(): + try: + loc.select_option(opt) + except Exception: + pass + page.wait_for_timeout(250) + page.wait_for_timeout(300) before = window_row_count(page) _jsclick(page, f"{FP}{_WP}ButtonAddWindow") for _ in range(20): - page.wait_for_timeout(200) + page.wait_for_timeout(250) if window_row_count(page) > before: break +def _delete_window_at_index(page: Page, i: int) -> bool: + """Delete the window grid row at DOM index `i` via its per-row Delete + image button + the Yes modal (JS-clicked to bypass the modal background + that intercepts real clicks). Returns True if a delete fired.""" + bid = ( + "ContentBody_ContentPlaceHolder1_TabContainer_TabPanelWindowsPanel_" + f"GridViewExtendedWidows_DeleteButton_{i}" + ) + if not page.evaluate("(id)=>!!document.getElementById(id)", bid): + return False + page.evaluate("(id)=>{const e=document.getElementById(id);if(e)e.click();}", bid) + page.wait_for_timeout(2000) + yid = "ContentBody_ContentPlaceHolder1_DeleteWindowDialog_LinkButtonYes" + if not page.evaluate( + "(id)=>{const e=document.getElementById(id);return e?e.offsetParent!==null:false;}", + yid, + ): + return False + page.evaluate("(id)=>{const e=document.getElementById(id);if(e)e.click();}", yid) + page.wait_for_timeout(2500) + return True + + +def set_single_window( + page: Page, total_area_m2: float, orientation: str = "North", + glazing: str = "Double post or during 2022", +) -> None: + """Leave the window grid holding EXACTLY one correct row of + `total_area_m2`, cleaning any rows carried over from a prior build of the + SHARED assessment. + + The grid can't be emptied (Elmhurst refuses to delete the last row) and it + has no inline edit, so per [[window-grid]] the reliable pattern is: ADD the + fresh window (it inserts at DOM index 0), then delete the STALE rows BELOW + it (indices high->low, never index 0) so the freshly-added row survives as + the single remaining window. Deleting index 0 (delete_first_window) would + drop the good row and strand a 0.00-area junk row that then blocks the + Recommendations gate.""" + add_combined_window(page, total_area_m2, orientation, glazing) + page.wait_for_timeout(500) + guard = 0 + while window_row_count(page) > 1 and guard < 25: + guard += 1 + i = window_row_count(page) - 1 # bottom-most (oldest) stale row + if not _delete_window_at_index(page, i): + break + + +def reset_transient_state(page: Page) -> None: + """Return the shared assessment to a NEUTRAL state after a run so the next + build (or Khalim's) is not polluted by this cert's carried-over settings. + Clears the room-in-roof age band, forces secondary-heating present = No, + and the electricity meter back to Single. The window grid is left to the + next build's `set_single_window` (Elmhurst can't empty it to zero rows). + Call once at the end of a build session.""" + goto(page, "PropertyDescription", "WebFormPropertyDescription.aspx") + rr = page.locator("#ContentBody_ContentPlaceHolder1_DropDownListRoomInRoofMain") + if rr.count() and rr.input_value(): + rr.select_option("") + page.wait_for_timeout(SETTLE_MS) + save_close(page) + goto(page, "SpaceHeating", "WebFormSpaceHeating.aspx") + page.wait_for_timeout(SETTLE_MS) + sec = page.locator(f"#{FP}DropDownListSecondaryHeatingPresent") + if sec.count(): + try: + set_select(page, "DropDownListSecondaryHeatingPresent", "No") + except Exception: + pass + meters = "TabContainer_TabPanelMeters" + click_tab(page, meters) + et = page.locator(f"#{FP}{meters}_RadioButtonListElectricityType") + if et.count(): + try: + set_select(page, f"{meters}_RadioButtonListElectricityType", "Single") + except Exception: + pass + save_close(page) + + def delete_first_window(page: Page) -> None: """Delete the first window row via the Yes/No modal. Will refuse to drop the LAST row (Elmhurst blocks it) — so add the replacement BEFORE deleting.""" @@ -362,6 +467,63 @@ def select_boiler(page: Page, query: str, ref: str) -> str: return "ok" +def clear_main_heating_code(page: Page) -> None: + """Empty Main Heating 1's SAP code via a real server postback (set the + cascade's top-level dropdown to '' + OK). REQUIRED before `set_pcdb_boiler`: + while any code is set, the PCDF reference textbox and the boiler-search + magnifying glass both render `aspNetDisabled`. They only enable when the + code is empty server-side (a JS-clear alone flips the class cosmetically but + leaves the postback unwired).""" + page.evaluate( + "(id)=>{const e=document.getElementById(id); if(e)e.click();}", + f"{MH1}ButtonMainHeatingCode", + ) + page.wait_for_timeout(SETTLE_MS * 3) + page.evaluate( + """(id)=>{const s=document.getElementById(id);if(s){s.value=''; + s.dispatchEvent(new Event('change',{bubbles:true}));}}""", + f"{HEATING_DIALOG}DropDownList1", + ) + page.wait_for_timeout(SETTLE_MS * 3) + dialog_commit(page, "OK") + page.wait_for_timeout(SETTLE_MS * 2) + + +def set_pcdb_boiler(page: Page, pcdb_index: "int | str") -> str: + """Set Main Heating 1 to the EXACT PCDB boiler by its EPC + `main_heating_index_number` (e.g. 18908 = Worcester Greenstar 4000, + GR4700iW 25 C NG, 88.70%). Elmhurst resolves it to the product's PCDB + winter efficiency — the same value the SAP engine uses — so the worksheet + and our calculator compare like-for-like (not against the generic BGW + SAP-Table 84%). Returns the resolved boiler description. + + Hard-won mechanism (supersedes the old "PCDB disabled" note): + 1. The PCDF reference textbox is only writable while the main heating CODE + is empty — call `clear_main_heating_code(page)` first. + 2. The index must be entered via REAL keyboard + Tab. A JS-set `.value` + does NOT survive the field's AutoPostBack, so the boiler never + resolves (the original failure mode). Ctrl-A + type + Tab works. + 3. The `TextBoxPCDFBoilerReference` direct-entry path is far simpler than + the magnifying-glass grid (whose modal has no layout box, so + mouse/Playwright clicks can't target rows). The grid search DOES work + (JS-focus the FullSearch box + `keyboard.type` + fire + HyperLinkActionBoilerSearch → "Ref No" column == the PCDB index) if a + name lookup is ever needed, but direct entry is preferred.""" + fld = f"{MH1}TextBoxPCDFBoilerReference" + page.evaluate( + "(id)=>{const e=document.getElementById(id);if(e){e.focus();e.select();}}", fld + ) + page.wait_for_timeout(SETTLE_MS) + page.keyboard.press("Control+a") + page.keyboard.type(str(pcdb_index), delay=40) + page.keyboard.press("Tab") + page.wait_for_timeout(SETTLE_MS * 5) # AutoPostBack resolves the PCDB record + return page.evaluate( + """()=>{const e=document.querySelector('[id*=LabelMHSSapCodeDescription]'); + return e?e.innerText.trim():'';}""" + ) + + def read_default_uvalue( page: Page, suffix: Optional[str] = None, label_regex: str = r"Default U-value" ) -> Optional[float]: diff --git a/scripts/lisasrequest/reclassify_dominant_glazing.py b/scripts/lisasrequest/reclassify_dominant_glazing.py new file mode 100644 index 000000000..95dd59c9f --- /dev/null +++ b/scripts/lisasrequest/reclassify_dominant_glazing.py @@ -0,0 +1,125 @@ +"""Backfill glazing overrides where a dominant (>= 90%) split was flattened onto +its *minority* type. + +Sibling to ``scripts/reclassify_mixed_glazing.py``. That script rescued genuine +mixes (no type >= 90%) to ``MIXED``; it deliberately left near-uniform rows to the +LLM classifier. But for a dominant split the LLM sometimes latched onto the +minority type — a dominant-*single* dwelling ("4% Double glazing 2002 or later, +96% Single Glazing") written as "Double glazing…", or the symmetric dominant-double +case written as "Single glazing". ``glazing_mix_guard`` now resolves any +era-unambiguous dominant split deterministically — SINGLE (era-free), or a +double/triple whose era is stated — so this backfill fixes the rows written before +that guard change. + +Uses the SAME guard as the live path, so the backfill and the classifier cannot +drift. SCOPED TO ONE PORTFOLIO (``--portfolio``, default 796 = Hyde) and only +touches ``property_overrides.override_value`` (TEXT — what the modelling reads); +the global ``landlord_glazing_overrides`` classifier cache is left alone. + +DRY-RUN BY DEFAULT: prints what it would change and writes nothing. Pass +``--apply`` to execute inside a transaction; it also writes an audit CSV of every +row changed (property_id, uprn, old value, new value) so the change is reversible. +Idempotent — only rows whose stored value differs from the guard's target member +are touched. + + python -m scripts.lisasrequest.reclassify_dominant_glazing # dry run + python -m scripts.lisasrequest.reclassify_dominant_glazing --apply # write +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from collections import Counter +from pathlib import Path + +from sqlalchemy import text + +_REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(_REPO_ROOT)) + +from domain.epc.property_overrides.glazing_mix_guard import glazing_mix_guard # noqa: E402 +from scripts.e2e_common import ENV_PATH, build_engine, load_env # noqa: E402 + +_SELECT = text( + """ + SELECT po.property_id, pr.uprn, + po.original_spreadsheet_description AS description, + po.override_value AS value + FROM property_overrides po + JOIN property pr ON pr.id = po.property_id + WHERE po.portfolio_id = :portfolio + AND po.override_component = 'glazing' + """ +) +_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE portfolio_id = :portfolio + AND override_component = 'glazing' + AND property_id = :property_id + AND override_value <> :new_value + """ +) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--portfolio", type=int, default=796) + parser.add_argument( + "--apply", + action="store_true", + help="execute the updates (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + load_env(ENV_PATH) + engine = build_engine() + + audit: list[tuple[int, object, str, str, str]] = [] # pid, uprn, descr, old, new + tally: Counter[str] = Counter() + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + for property_id, uprn, description, value in conn.execute( + _SELECT, {"portfolio": args.portfolio} + ): + member = glazing_mix_guard(description or "") + if member is None or value == member.value: + continue + tally[f"{value!r} -> {member.value!r}"] += 1 + audit.append((property_id, uprn, description, value, member.value)) + if args.apply: + conn.execute( + _UPDATE, + { + "portfolio": args.portfolio, + "property_id": property_id, + "new_value": member.value, + }, + ) + if not args.apply: + conn.rollback() + + verb = "re-classified" if args.apply else "would re-classify" + print(f"portfolio {args.portfolio}: {verb} {len(audit)} glazing override row(s)") + for change, n in tally.most_common(): + print(f" {n:5d} {change}") + + if args.apply and audit: + out = _REPO_ROOT / "scripts" / "lisasrequest" / ( + f"reclassify_dominant_glazing_{args.portfolio}_audit.csv" + ) + with out.open("w", newline="") as fh: + w = csv.writer(fh) + w.writerow(["property_id", "uprn", "description", "old_value", "new_value"]) + w.writerows(audit) + print(f"\naudit trail: {out}") + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lisasrequest/reclassify_property_type.py b/scripts/lisasrequest/reclassify_property_type.py new file mode 100644 index 000000000..0725bce4d --- /dev/null +++ b/scripts/lisasrequest/reclassify_property_type.py @@ -0,0 +1,125 @@ +"""Backfill property_type overrides the LLM mislabelled off their leading dwelling +type (e.g. "Bungalow: EndTerrace" stored as House). + +The property-type description is a ": : " split +whose leading token IS the dwelling type; the built-form tail is not. The LLM +occasionally over-read the tail and flipped the type. ``property_type_guard`` now +resolves the leading token deterministically, so this fixes the rows written +before that guard. + +Uses the SAME guard as the live path, so the backfill and the classifier cannot +drift. SCOPED TO ONE PORTFOLIO (``--portfolio``, default 796 = Hyde) and only +touches ``property_overrides.override_value`` (TEXT). No enum migration is needed — +the target values (House / Bungalow / Flat / Maisonette) are original members — +but the description-keyed classifier cache is left alone here (a property-level +data fix, not a cache rewrite). + +DRY-RUN BY DEFAULT: prints what it would change and writes nothing. Pass +``--apply`` to execute inside a transaction; it also writes an audit CSV of every +row changed (property_id, uprn, old value, new value) so the change is reversible. +Idempotent — only rows whose stored value differs from the guard's target member +are touched. + + python -m scripts.lisasrequest.reclassify_property_type # dry run + python -m scripts.lisasrequest.reclassify_property_type --apply # write +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from collections import Counter +from pathlib import Path + +from sqlalchemy import text + +_REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(_REPO_ROOT)) + +from domain.epc.property_overrides.property_type_guard import ( # noqa: E402 + property_type_guard, +) +from scripts.e2e_common import ENV_PATH, build_engine, load_env # noqa: E402 + +_SELECT = text( + """ + SELECT po.property_id, pr.uprn, + po.original_spreadsheet_description AS description, + po.override_value AS value + FROM property_overrides po + JOIN property pr ON pr.id = po.property_id + WHERE po.portfolio_id = :portfolio + AND po.override_component = 'property_type' + """ +) +_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE portfolio_id = :portfolio + AND override_component = 'property_type' + AND property_id = :property_id + AND override_value <> :new_value + """ +) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--portfolio", type=int, default=796) + parser.add_argument( + "--apply", + action="store_true", + help="execute the updates (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + load_env(ENV_PATH) + engine = build_engine() + + audit: list[tuple[int, object, str, str, str]] = [] # pid, uprn, descr, old, new + tally: Counter[str] = Counter() + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + for property_id, uprn, description, value in conn.execute( + _SELECT, {"portfolio": args.portfolio} + ): + member = property_type_guard(description or "") + if member is None or value == member.value: + continue + tally[f"{description!r}: {value!r} -> {member.value!r}"] += 1 + audit.append((property_id, uprn, description, value, member.value)) + if args.apply: + conn.execute( + _UPDATE, + { + "portfolio": args.portfolio, + "property_id": property_id, + "new_value": member.value, + }, + ) + if not args.apply: + conn.rollback() + + verb = "re-classified" if args.apply else "would re-classify" + print(f"portfolio {args.portfolio}: {verb} {len(audit)} property_type override row(s)") + for change, n in tally.most_common(): + print(f" {n:5d} {change}") + + if args.apply and audit: + out = _REPO_ROOT / "scripts" / "lisasrequest" / ( + f"reclassify_property_type_{args.portfolio}_audit.csv" + ) + with out.open("w", newline="") as fh: + w = csv.writer(fh) + w.writerow(["property_id", "uprn", "description", "old_value", "new_value"]) + w.writerows(audit) + print(f"\naudit trail: {out}") + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lisasrequest/reclassify_secondary_glazing.py b/scripts/lisasrequest/reclassify_secondary_glazing.py new file mode 100644 index 000000000..984d75c86 --- /dev/null +++ b/scripts/lisasrequest/reclassify_secondary_glazing.py @@ -0,0 +1,197 @@ +"""Backfill uniform secondary-glazing overrides misfiled as ``Single glazing``. + +Hyde-796 landlords assert ``100% secondary glazing (sap 9.94)``; before +``GlazingType.SECONDARY`` existed the classifier's least-bad option was +``Single glazing``, which models the windows at U = 4.8 instead of secondary's +2.9 (SAP10 glazing code 5) — a material distortion (Model#1416). + +Fixes BOTH stores in one pass: + +* ``property_overrides.override_value`` (TEXT — what the modelling reads), so + re-modelling picks the correct code; and +* the ``landlord_glazing_overrides`` classifier cache, so the stale value can't + be re-served to new properties. The cache column is the FE-owned ``glazing`` + pgEnum — this phase requires the ``Secondary glazing`` label + (assessment-model#345 + ``drizzle-kit migrate``) and is skipped with a warning + until the label exists. + +A *uniform* secondary assertion has exactly one glazing type at 100%, so it is +fully determined and safe to fix by description match (the live +``glazing_mix_guard`` deliberately leaves uniform assertions to the LLM, which +can now target SECONDARY). Dominant secondary *splits* are the guard's job and +are already covered by ``reclassify_dominant_glazing``. + +DRY-RUN BY DEFAULT: prints what it would change and writes nothing. Pass +``--apply`` to execute inside a transaction; writes audit CSVs of every row +changed so the change is reversible. Idempotent. + + python -m scripts.lisasrequest.reclassify_secondary_glazing # dry run + python -m scripts.lisasrequest.reclassify_secondary_glazing --apply # write +""" + +from __future__ import annotations + +import argparse +import csv +import re +import sys +from pathlib import Path +from typing import Any, Optional + +from sqlalchemy import text +from sqlalchemy.engine import Connection + +_REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(_REPO_ROOT)) + +from domain.epc.property_overrides.glazing_type import GlazingType # noqa: E402 +from scripts.e2e_common import ENV_PATH, build_engine, load_env # noqa: E402 + +# "100% secondary glazing", optionally trailed by "(sap 9.94)" or similar noise. +# One type, 100% — a fully-determined uniform assertion. +_UNIFORM_SECONDARY = re.compile(r"^\s*100%\s*secondary\s*glazing\b", re.IGNORECASE) + +_SELECT_OVERRIDES = text( + """ + SELECT po.property_id, pr.uprn, + po.original_spreadsheet_description AS description, + po.override_value AS value + FROM property_overrides po + JOIN property pr ON pr.id = po.property_id + WHERE po.portfolio_id = :portfolio + AND po.override_component = 'glazing' + """ +) +_UPDATE_OVERRIDE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE portfolio_id = :portfolio + AND override_component = 'glazing' + AND property_id = :property_id + AND override_value <> :new_value + """ +) +_SELECT_CACHE = text( + """ + SELECT id, description, value::text AS value + FROM landlord_glazing_overrides + WHERE portfolio_id = :portfolio + """ +) +_UPDATE_CACHE = text( + """ + UPDATE landlord_glazing_overrides + SET value = CAST(:new_value AS glazing), + updated_at = now() + WHERE id = :id + AND value::text <> :new_value + """ +) +_ENUM_HAS_LABEL = text( + """ + SELECT EXISTS ( + SELECT 1 FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + WHERE t.typname = 'glazing' AND e.enumlabel = :label + ) + """ +) + + +def _target(description: Optional[str]) -> Optional[str]: + if description and _UNIFORM_SECONDARY.match(description): + return GlazingType.SECONDARY.value + return None + + +def _write_audit(name: str, portfolio: int, header: list[str], rows: list[Any]) -> None: + out = _REPO_ROOT / "scripts" / "lisasrequest" / f"{name}_{portfolio}_audit.csv" + with out.open("w", newline="") as fh: + w = csv.writer(fh) + w.writerow(header) + w.writerows(rows) + print(f"audit trail: {out}") + + +def _fix_property_overrides(conn: Connection, portfolio: int, apply: bool) -> int: + audit: list[tuple[int, object, str, str, str]] = [] + for property_id, uprn, description, value in conn.execute( + _SELECT_OVERRIDES, {"portfolio": portfolio} + ): + new_value = _target(description) + if new_value is None or value == new_value: + continue + audit.append((property_id, uprn, description, value, new_value)) + if apply: + conn.execute( + _UPDATE_OVERRIDE, + {"portfolio": portfolio, "property_id": property_id, "new_value": new_value}, + ) + verb = "re-classified" if apply else "would re-classify" + print(f"property_overrides: {verb} {len(audit)} row(s)") + if apply and audit: + _write_audit( + "reclassify_secondary_glazing", + portfolio, + ["property_id", "uprn", "description", "old_value", "new_value"], + audit, + ) + return len(audit) + + +def _fix_cache(conn: Connection, portfolio: int, apply: bool) -> int: + label = GlazingType.SECONDARY.value + if not conn.execute(_ENUM_HAS_LABEL, {"label": label}).scalar(): + print( + f"cache: SKIPPED — the glazing pgEnum has no {label!r} label yet; " + "merge assessment-model#345 and run drizzle-kit migrate first, then re-run" + ) + return 0 + audit: list[tuple[str, str, str, str]] = [] + for row_id, description, value in conn.execute(_SELECT_CACHE, {"portfolio": portfolio}): + new_value = _target(description) + if new_value is None or value == new_value: + continue + audit.append((str(row_id), description, value, new_value)) + if apply: + conn.execute(_UPDATE_CACHE, {"id": row_id, "new_value": new_value}) + verb = "re-classified" if apply else "would re-classify" + print(f"cache: {verb} {len(audit)} row(s)") + if apply and audit: + _write_audit( + "reclassify_secondary_glazing_cache", + portfolio, + ["id", "description", "old_value", "new_value"], + audit, + ) + return len(audit) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--portfolio", type=int, default=796) + parser.add_argument( + "--apply", + action="store_true", + help="execute the updates (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + load_env(ENV_PATH) + engine = build_engine() + + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + changed = _fix_property_overrides(conn, args.portfolio, args.apply) + changed += _fix_cache(conn, args.portfolio, args.apply) + if not args.apply: + conn.rollback() + + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lisasrequest/reclassify_wood_logs_main_fuel.py b/scripts/lisasrequest/reclassify_wood_logs_main_fuel.py new file mode 100644 index 000000000..8d95b72d9 --- /dev/null +++ b/scripts/lisasrequest/reclassify_wood_logs_main_fuel.py @@ -0,0 +1,124 @@ +"""Backfill main-fuel overrides where an individual wood-logs dwelling was funnelled +into "biomass (community)". + +The ``MainFuelType`` taxonomy had no individual wood-logs member (only +``biomass (community)``), so the LLM force-picked the community fuel for +``"Solid Fuel: Wood Logs"`` — inventing a community heat network the dwelling +isn't on. ``MainFuelType.WOOD_LOGS`` (API fuel enum 6 / RdSAP Table 32 code 20) +now exists and ``main_fuel_guard`` resolves the wood-logs token deterministically, +so this fixes the rows written before that change. + +Uses the SAME guard as the live path, so the backfill and the classifier cannot +drift. SCOPED TO ONE PORTFOLIO (``--portfolio``, default 796 = Hyde) and only +touches ``property_overrides.override_value`` (TEXT — what the modelling reads); +the global ``landlord_main_fuel_overrides`` classifier cache is left alone (its +``value`` is the Drizzle-owned pgEnum, which needs the FE migration to add the +member first — the Class-A/B deferral Khalim's scripts use). + +DRY-RUN BY DEFAULT: prints what it would change and writes nothing. Pass +``--apply`` to execute inside a transaction; it also writes an audit CSV of every +row changed (property_id, uprn, old value, new value) so the change is reversible. +Idempotent — only rows whose stored value differs from the guard's target member +are touched. + + python -m scripts.lisasrequest.reclassify_wood_logs_main_fuel # dry run + python -m scripts.lisasrequest.reclassify_wood_logs_main_fuel --apply # write +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from collections import Counter +from pathlib import Path + +from sqlalchemy import text + +_REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(_REPO_ROOT)) + +from domain.epc.property_overrides.main_fuel_guard import main_fuel_guard # noqa: E402 +from scripts.e2e_common import ENV_PATH, build_engine, load_env # noqa: E402 + +_SELECT = text( + """ + SELECT po.property_id, pr.uprn, + po.original_spreadsheet_description AS description, + po.override_value AS value + FROM property_overrides po + JOIN property pr ON pr.id = po.property_id + WHERE po.portfolio_id = :portfolio + AND po.override_component = 'main_fuel' + """ +) +_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE portfolio_id = :portfolio + AND override_component = 'main_fuel' + AND property_id = :property_id + AND override_value <> :new_value + """ +) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--portfolio", type=int, default=796) + parser.add_argument( + "--apply", + action="store_true", + help="execute the updates (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + load_env(ENV_PATH) + engine = build_engine() + + audit: list[tuple[int, object, str, str, str]] = [] # pid, uprn, descr, old, new + tally: Counter[str] = Counter() + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + for property_id, uprn, description, value in conn.execute( + _SELECT, {"portfolio": args.portfolio} + ): + member = main_fuel_guard(description or "") + if member is None or value == member.value: + continue + tally[f"{value!r} -> {member.value!r}"] += 1 + audit.append((property_id, uprn, description, value, member.value)) + if args.apply: + conn.execute( + _UPDATE, + { + "portfolio": args.portfolio, + "property_id": property_id, + "new_value": member.value, + }, + ) + if not args.apply: + conn.rollback() + + verb = "re-classified" if args.apply else "would re-classify" + print(f"portfolio {args.portfolio}: {verb} {len(audit)} main-fuel override row(s)") + for change, n in tally.most_common(): + print(f" {n:5d} {change}") + + if args.apply and audit: + out = _REPO_ROOT / "scripts" / "lisasrequest" / ( + f"reclassify_wood_logs_main_fuel_{args.portfolio}_audit.csv" + ) + with out.open("w", newline="") as fh: + w = csv.writer(fh) + w.writerow(["property_id", "uprn", "description", "old_value", "new_value"]) + w.writerows(audit) + print(f"\naudit trail: {out}") + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/null_predicted_lodged_performance.py b/scripts/null_predicted_lodged_performance.py new file mode 100644 index 000000000..d25104132 --- /dev/null +++ b/scripts/null_predicted_lodged_performance.py @@ -0,0 +1,111 @@ +"""One-time backfill: NULL the phantom Lodged Performance on predicted Properties. + +#1361 Class B: ``PropertyBaselineOrchestrator`` used to read Lodged Performance +off a predicted Property's neighbour-synthesised EPC, persisting a phantom set of +``lodged_*`` figures on ``property_baseline_performance`` — a *different +dwelling's* SAP / band / carbon / Primary Energy Intensity presented as this +Property's government-register record. The orchestrator no longer does this +(``lodged`` is ``None`` when ``source_path == "predicted"``, ADR-0004 amendment), +but the ~12k rows written before the fix still carry the phantom. + +This NULLs the four ``lodged_*`` columns on every predicted-source baseline row, +leaving the **Effective** half, the **bill** block, and ``rebaseline_reason`` +untouched (a predicted Property's Effective Performance is correct — it is a +first-class modelled output). + +A predicted-source Property is identified exactly as the orchestrator's +``source_path == "predicted"``: it has a **predicted** EPC and **no lodged** EPC. +Site Notes keep their as-surveyed Lodged Performance and so are excluded — though +no Site-Notes-sourced EPC exists in ``epc_property`` today, the predicate (a +predicted EPC, no lodged EPC) holds regardless. + +DRY-RUN BY DEFAULT: prints the count it would change and writes nothing. Pass +``--apply`` to execute inside a transaction. **Idempotent** — only rows whose +``lodged_sap_score`` is still non-NULL are touched, so a second run is a no-op. + +Requires the FE-owned Drizzle ``ALTER ... DROP NOT NULL`` on the four ``lodged_*`` +columns to have landed first; without it the UPDATE to NULL violates the +constraint. +""" + +from __future__ import annotations + +import argparse + +from sqlalchemy import Connection, text + +from scripts.e2e_common import build_engine, load_env + +# A predicted-source baseline row: a predicted EPC exists for the property and no +# lodged one does (``source_path == "predicted"``). ``lodged_sap_score IS NOT +# NULL`` makes it idempotent — a row already nulled is skipped on a re-run. +_PREDICTED_PHANTOM_PREDICATE = """ + pbp.lodged_sap_score IS NOT NULL + AND EXISTS ( + SELECT 1 FROM epc_property e + WHERE e.property_id = pbp.property_id AND e.source = 'predicted' + ) + AND NOT EXISTS ( + SELECT 1 FROM epc_property e + WHERE e.property_id = pbp.property_id AND e.source = 'lodged' + ) +""" + +_COUNT = text( + f""" + SELECT count(*) FROM property_baseline_performance pbp + WHERE {_PREDICTED_PHANTOM_PREDICATE} + """ +) + +_NULL_LODGED = text( + f""" + UPDATE property_baseline_performance AS pbp + SET lodged_sap_score = NULL, + lodged_epc_band = NULL, + lodged_co2_emissions_t_per_yr = NULL, + lodged_primary_energy_intensity_kwh_per_m2_yr = NULL + WHERE {_PREDICTED_PHANTOM_PREDICATE} + """ +) + + +def backfill(conn: Connection, *, apply: bool) -> int: + """NULL the four ``lodged_*`` columns on predicted-source baseline rows. + + Returns the number of phantom rows found (those that ``--apply`` would / did + null). Reads the count first so the dry-run reports it without writing. + """ + found = conn.execute(_COUNT).scalar() or 0 + if apply: + conn.execute(_NULL_LODGED) + return found + + +def main() -> None: + load_env() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--apply", + action="store_true", + help="execute the update (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + engine = build_engine() + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + found = backfill(conn, apply=args.apply) + + verb = "NULLed" if args.apply else "would NULL" + print( + f"{verb} the Lodged Performance (lodged_* → NULL) on {found} " + "predicted-source baseline row(s); Effective / bill / rebaseline_reason " + "left intact." + ) + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + + +if __name__ == "__main__": + main() diff --git a/scripts/reclassify_flat_roof_thickness.py b/scripts/reclassify_flat_roof_thickness.py new file mode 100644 index 000000000..487ee88bc --- /dev/null +++ b/scripts/reclassify_flat_roof_thickness.py @@ -0,0 +1,169 @@ +"""One-time re-classification of flat-roof overrides that discarded a known depth. + +#1376 / ADR-0041: a landlord flat-roof override carrying a real insulation depth +(``flat: 150mm``) was classified to ``"Flat, insulated (assumed)"`` — which the +roof Simulation Overlay resolves to the flat **age-band default**, discarding the +depth. RdSAP 10 Table 16 col (1) ("joists at ceiling level and flat roof") scores +flat roofs **by thickness**, so this re-maps those rows onto the new +``"Flat, N mm insulation"`` members (nearest tabulated rung ≤ the stated depth, +per Table 16), which the overlay now carries into the calculator. + +Only rows whose raw description is a flat roof with a numeric depth are touched; +``flat: unknown`` / ``flat: as built`` / ``flat: no insulation`` (no depth) keep +their age-band-default classification. + +Updates the TEXT ``property_overrides.override_value`` (what the modelling reads — +the immediate fix) always. The ``landlord_roof_type_overrides.value`` classifier +cache is a ``roof_type`` **pgEnum**: the new ``FLAT_*MM`` values are FE-owned and +must be added to the Drizzle enum first, so cache writes for a value the live enum +does not yet carry are **deferred** and reported (the Class-A/B pattern). + +DRY-RUN BY DEFAULT: prints the counts it would change and writes nothing. Pass +``--apply`` to execute inside a transaction. Idempotent — only rows whose stored +value differs from the target member are touched. +""" + +from __future__ import annotations + +import argparse +import re +from collections.abc import Iterable + +from sqlalchemy import Connection, text + +from scripts.e2e_common import build_engine, load_env + +# The flat-roof thickness rungs that have a taxonomy member (RdSAP 10 Table 16), +# ascending; a depth above the top maps to the "400+" member. +_FLAT_RUNGS: tuple[int, ...] = ( + 12, 25, 50, 75, 100, 125, 150, 175, 200, 225, 250, 270, 300, 350, 400, +) +_FLAT_DEPTH_RE = re.compile(r"^flat:\s*(\d+)\+?\s*mm") + + +def _flat_member_value(depth_mm: int) -> str: + """The ``"Flat, N mm insulation"`` member value for a stated depth — the + nearest tabulated rung ≤ the depth (Table 16), or the "400+" member above 400.""" + if depth_mm > 400: + return "Flat, 400+ mm insulation" + rung = _FLAT_RUNGS[0] + for candidate in _FLAT_RUNGS: + if depth_mm >= candidate: + rung = candidate + return f"Flat, {rung} mm insulation" + + +def flat_thickness_corrections( + stored: Iterable[tuple[str, str]], +) -> dict[str, str]: + """``(description, stored override_value)`` → the corrected flat-thickness member + value, for descriptions that are a flat roof with a numeric depth whose stored + value is not already that member. Depthless flat descriptions and non-flat + descriptions are omitted, so re-running against corrected data is a no-op.""" + corrections: dict[str, str] = {} + for description, value in stored: + match = _FLAT_DEPTH_RE.match(description.strip().lower()) + if match is None: + continue + target = _flat_member_value(int(match.group(1))) + if value != target: + corrections[description] = target + return corrections + + +_DISTINCT = text( + """ + SELECT DISTINCT lower(original_spreadsheet_description) AS description, + override_value AS value + FROM property_overrides + WHERE override_component = 'roof_type' + """ +) +_OVERRIDES_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE override_component = 'roof_type' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_OVERRIDES_COUNT = text( + """ + SELECT count(*) FROM property_overrides + WHERE override_component = 'roof_type' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_CACHE_UPDATE = text( + """ + UPDATE landlord_roof_type_overrides + SET value = :new_value, updated_at = now() + WHERE lower(description) = :description + AND value::text <> :new_value + """ +) +_ENUM_VALUES = text( + "SELECT e.enumlabel FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid " + "WHERE t.typname = 'roof_type'" +) + + +def reclassify(conn: Connection, *, apply: bool) -> tuple[int, set[str]]: + """Re-map flat-depth roof overrides onto their thickness member. Returns the + number of ``property_overrides`` rows found and the set of target values the + live ``roof_type`` pgEnum does not yet carry (cache-deferred until the FE + migration).""" + stored = [(r.description, r.value) for r in conn.execute(_DISTINCT)] + enum_values = {r[0] for r in conn.execute(_ENUM_VALUES)} + total = 0 + deferred: set[str] = set() + for description, new_value in flat_thickness_corrections(stored).items(): + params = {"description": description, "new_value": new_value} + total += conn.execute(_OVERRIDES_COUNT, params).scalar() or 0 + in_enum = new_value in enum_values + if not in_enum: + deferred.add(new_value) + if apply: + conn.execute(_OVERRIDES_UPDATE, params) + if in_enum: + conn.execute(_CACHE_UPDATE, params) + return total, deferred + + +def main() -> None: + load_env() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--apply", + action="store_true", + help="execute the updates (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + engine = build_engine() + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + total, deferred = reclassify(conn, apply=args.apply) + + verb = "re-classified" if args.apply else "would re-classify" + print( + f"{verb} {total} flat-roof override row(s) from the age-band default onto " + "their Table 16 thickness member (property_overrides / TEXT)." + ) + if deferred: + print( + f"\n{len(deferred)} target value(s) NOT yet in the roof_type pgEnum — " + "their classifier-cache rows are deferred until the FE-repo enum " + "migration adds these members (property_overrides was still updated, " + "which is what the modelling reads):" + ) + for value in sorted(deferred): + print(f" {value!r}") + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + + +if __name__ == "__main__": + main() diff --git a/scripts/reclassify_heating_overrides.py b/scripts/reclassify_heating_overrides.py new file mode 100644 index 000000000..002ebef9b --- /dev/null +++ b/scripts/reclassify_heating_overrides.py @@ -0,0 +1,176 @@ +"""One-time re-classification of stale landlord main-heating-system overrides. + +PRD #1361 Class A: the LLM landlord-description classifier ran against a +too-small `MainHeatingSystemType` taxonomy (ADR-0041), so canonical RdSAP +heating descriptions were stored against the wrong archetype — community heating +and oil/solid room heaters dumped into "Gas CPSU", and direct-acting +"panel, convector or radiant" room heaters mis-read as off-peak storage (the +band-G crater). The taxonomy is now complete, so these *canonical* descriptions +re-resolve deterministically — no LLM, no nondeterminism. + +This rewrites the resolved archetype on every affected row in BOTH the +per-Property fact layer (`property_overrides.override_value`) and the +per-portfolio classifier cache (`landlord_main_heating_system_overrides.value`), +keyed on the raw description (`original_spreadsheet_description` / `description`). + +DRY-RUN BY DEFAULT: prints the row counts it would change and writes nothing. +Pass `--apply` to execute inside a transaction. Idempotent — only rows whose +stored value differs from the corrected archetype are touched, so a second run +is a no-op. Descriptions with no confident archetype yet (solid-fuel open-fire / +with-boiler, warm air, electric underfloor) are deliberately NOT remapped here. +""" + +from __future__ import annotations + +import argparse + +from sqlalchemy import text + +from domain.epc.property_overrides.main_heating_system_type import ( + MainHeatingSystemType, +) +from scripts.e2e_common import build_engine, load_env + +# Canonical RdSAP main-heating description (lowercased, as stored in +# `original_spreadsheet_description` / `description`) → the corrected archetype. +# Only descriptions whose correct archetype now exists in the taxonomy appear +# here; the value is a `MainHeatingSystemType` so a typo can't slip a non-archetype +# string into the DB (the enum is the single source of truth — ADR-0041). +REMAP: dict[str, MainHeatingSystemType] = { + "community heating systems: community boilers only (rdsap)": ( + MainHeatingSystemType.COMMUNITY_BOILERS + ), + "community heating systems: community chp and boilers (rdsap)": ( + MainHeatingSystemType.COMMUNITY_CHP_AND_BOILERS + ), + "electric (direct acting) room heaters: panel, convector or radiant heaters": ( + MainHeatingSystemType.ELECTRIC_ROOM_HEATERS + ), + "oil room heaters: room heater, 2000 or later": ( + MainHeatingSystemType.OIL_ROOM_HEATER_POST_2000 + ), + "solid fuel room heaters: closed room heater": ( + MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_CLOSED + ), + "gas (including lpg) room heaters: condensing gas fire": ( + MainHeatingSystemType.GAS_FIRE_CONDENSING + ), + "gas (including lpg) room heaters: decorative fuel effect gas fire, " + "open to chimney": MainHeatingSystemType.GAS_FIRE_DECORATIVE, + "gas (including lpg) room heaters: flush fitting live fuel effect gas fire " + "(open fronted), sealed to fireplace opening": ( + MainHeatingSystemType.GAS_FIRE_FLUSH_LIVE_EFFECT + ), + "gas (including lpg) room heaters: gas fire, open flue, 1980 or later " + "(open fronted), sitting proud of, and sealed to, fireplace opening": ( + MainHeatingSystemType.GAS_FIRE_OPEN_FLUE_POST_1980 + ), + "gas (including lpg) room heaters: gas fire, open flue, pre-1980 " + "(open fronted)": MainHeatingSystemType.GAS_FIRE_OPEN_FLUE_PRE_1980, +} + +# property_overrides keys the raw text on `original_spreadsheet_description`; the +# classifier cache keys it on `description`. Both compared case-insensitively. +_PROPERTY_OVERRIDES_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE override_component = 'main_heating_system' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +# The cache `value` column is the Drizzle-owned `main_heating_system` pgEnum; +# compare/update it as text (no CAST) so a target value the DB enum does not yet +# carry doesn't error the read. The UPDATE is only issued for values the live +# enum already has — `_db_enum_values` gates it (see main()). +_CACHE_UPDATE = text( + """ + UPDATE landlord_main_heating_system_overrides + SET value = :new_value, updated_at = now() + WHERE lower(description) = :description + AND value::text <> :new_value + """ +) +_PROPERTY_OVERRIDES_COUNT = text( + """ + SELECT count(*) FROM property_overrides + WHERE override_component = 'main_heating_system' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_CACHE_COUNT = text( + """ + SELECT count(*) FROM landlord_main_heating_system_overrides + WHERE lower(description) = :description + AND value::text <> :new_value + """ +) +_ENUM_VALUES = text( + "SELECT e.enumlabel FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid " + "WHERE t.typname = 'main_heating_system'" +) + + +def main() -> None: + load_env() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--apply", + action="store_true", + help="execute the updates (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + engine = build_engine() + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + # The cache `value` is the Drizzle-owned pgEnum; only update it for + # archetypes the live enum already carries. The rest need the Drizzle + # migration (FE repo) before the cache — and the classifier — can store + # them; property_overrides (TEXT, what the modelling reads) is updated + # regardless, which is the actual band-G fix. + enum_values = {r[0] for r in conn.execute(_ENUM_VALUES)} + prop_total = 0 + cache_total = 0 + deferred: list[str] = [] + for description, archetype in REMAP.items(): + params = {"description": description, "new_value": archetype.value} + prop_n = conn.execute(_PROPERTY_OVERRIDES_COUNT, params).scalar() or 0 + prop_total += prop_n + in_enum = archetype.value in enum_values + cache_n = conn.execute(_CACHE_COUNT, params).scalar() or 0 if in_enum else 0 + cache_total += cache_n + if not in_enum and prop_n: + deferred.append(archetype.value) + if prop_n or cache_n: + tag = "" if in_enum else " [cache deferred: enum value missing]" + print( + f" {prop_n:>6} property_overrides + {cache_n} cache " + f"-> {archetype.value!r} <= {description!r}{tag}" + ) + if args.apply: + conn.execute(_PROPERTY_OVERRIDES_UPDATE, params) + if in_enum: + conn.execute(_CACHE_UPDATE, params) + verb = "updated" if args.apply else "would update" + print( + f"\n{verb} {prop_total} property_overrides rows + {cache_total} " + f"classifier-cache rows across {len(REMAP)} descriptions." + ) + if deferred: + print( + f"\n{len(set(deferred))} archetype(s) NOT in the Drizzle " + "`main_heating_system` pgEnum — their cache rows are deferred " + "until the FE-repo enum migration lands (property_overrides was " + "still updated, which is what the modelling reads):" + ) + for v in sorted(set(deferred)): + print(f" {v!r}") + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + + +if __name__ == "__main__": + main() diff --git a/scripts/reclassify_main_heating.py b/scripts/reclassify_main_heating.py new file mode 100644 index 000000000..b21f9b78a --- /dev/null +++ b/scripts/reclassify_main_heating.py @@ -0,0 +1,205 @@ +"""One-time re-classification of main-heating overrides funnelled into a garbage +drawer for want of a correct archetype (#1376). + +Covers the dumping grounds the ``main_heating_guard`` now rescues: +- **HHRSH** (ADR-0044): "High heat retention storage heaters" → "Electric storage + heaters, old" (SAP 401), the worst old type — 111 rows. +- **Gas CPSU** (ADR-0045): solid-fuel room heaters (open fire / + back boiler / + closed + boiler) and electric "NA"-type boilers → "Gas CPSU" (120, mains gas). +- **Electric underfloor** (ADR-0046): concrete slab (421) / integrated (422) / + screed (424) dumped into Direct-acting (191) / old storage (401). + +The live classifier now applies ``main_heating_guard`` deterministically (so new +intakes are correct); this fixes the rows written before it. The **same guard** +decides the correction here, so the backfill and the live path cannot drift. + +Updates the TEXT ``property_overrides.override_value`` (what the modelling reads — +the immediate fix) unconditionally. The ``landlord_main_heating_system_overrides`` +``value`` classifier cache is a ``main_heating_system`` **pgEnum**; the new +archetypes are FE-owned values, so their cache writes are **deferred** until the +Drizzle migration adds them (the Class-A/B pattern). + +DRY-RUN BY DEFAULT: prints the counts it would change and writes nothing. Pass +``--apply`` to execute inside a transaction. Idempotent — only rows whose stored +value differs from the target member are touched. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterable +from typing import Optional + +from sqlalchemy import Connection, text + +from domain.epc.property_overrides.main_heating_guard import main_heating_guard +from domain.epc.property_overrides.main_heating_system_type import ( + MainHeatingSystemType, +) +from scripts.e2e_common import build_engine, load_env + + +def electric_cpsu_for(description: str, main_fuel: Optional[str]) -> Optional[str]: + """``"Electric CPSU"`` for a CPSU-boiler override whose property's ``main_fuel`` + override is electricity, else ``None`` (ADR-0045). ``"Boiler: A/C rated CPSU"`` + is identical bar the band — the electric/gas split is *fuel*-determined, so this + keys on the fuel the guard cannot see; a gas / unknown fuel keeps Gas CPSU + (120), and a non-CPSU description is left to the guard.""" + if "cpsu" in description.lower() and main_fuel == "electricity": + return MainHeatingSystemType.ELECTRIC_CPSU.value + return None + + +def main_heating_corrections( + stored: Iterable[tuple[str, str]], +) -> dict[str, str]: + """``(description, stored override_value)`` → the faithful archetype value, for + the descriptions the main-heating guard resolves (HHRSH, solid-fuel room + heaters, electric boiler) whose stored value differs. Descriptions the guard + defers and rows already on the target are omitted, so re-running against + corrected data is a no-op.""" + corrections: dict[str, str] = {} + for description, value in stored: + member = main_heating_guard(description) + if member is not None and value != member.value: + corrections[description] = member.value + return corrections + + +_DISTINCT = text( + """ + SELECT DISTINCT lower(original_spreadsheet_description) AS description, + override_value AS value + FROM property_overrides + WHERE override_component = 'main_heating_system' + """ +) +_OVERRIDES_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE override_component = 'main_heating_system' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_OVERRIDES_COUNT = text( + """ + SELECT count(*) FROM property_overrides + WHERE override_component = 'main_heating_system' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_CACHE_UPDATE = text( + """ + UPDATE landlord_main_heating_system_overrides + SET value = :new_value, updated_at = now() + WHERE lower(description) = :description + AND value::text <> :new_value + """ +) +_ENUM_VALUES = text( + "SELECT e.enumlabel FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid " + "WHERE t.typname = 'main_heating_system'" +) +# CPSU boilers still on Gas CPSU, each with its property's main_fuel override — +# the electric/gas split the description can't carry (ADR-0045). +_CPSU_ROWS = text( + """ + SELECT mh.property_id AS property_id, + lower(mh.original_spreadsheet_description) AS description, + mf.override_value AS main_fuel + FROM property_overrides mh + LEFT JOIN property_overrides mf + ON mf.property_id = mh.property_id AND mf.override_component = 'main_fuel' + WHERE mh.override_component = 'main_heating_system' + AND mh.override_value = 'Gas CPSU' + AND lower(mh.original_spreadsheet_description) LIKE '%cpsu%' + """ +) +_CPSU_PROPERTY_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE property_id = :property_id + AND override_component = 'main_heating_system' + AND override_value = 'Gas CPSU' + """ +) + + +def reclassify(conn: Connection, *, apply: bool) -> tuple[int, set[str]]: + """Re-map main-heating overrides off their garbage-drawer archetypes onto the + faithful ones the guard resolves. Returns the number of ``property_overrides`` + rows found and the set of target values the live ``main_heating_system`` pgEnum + does not yet carry (cache-deferred until the FE migration).""" + stored = [(r.description, r.value) for r in conn.execute(_DISTINCT)] + enum_values = {r[0] for r in conn.execute(_ENUM_VALUES)} + total = 0 + deferred: set[str] = set() + for description, new_value in main_heating_corrections(stored).items(): + params = {"description": description, "new_value": new_value} + total += conn.execute(_OVERRIDES_COUNT, params).scalar() or 0 + in_enum = new_value in enum_values + if not in_enum: + deferred.add(new_value) + if apply: + conn.execute(_OVERRIDES_UPDATE, params) + if in_enum: + conn.execute(_CACHE_UPDATE, params) + + # Fuel-aware phase: an electric CPSU (fuel-determined, per-property) — update + # only the TEXT layer. The description-keyed classifier cache cannot represent + # a fuel-dependent value, and "Electric CPSU" is a deferred pgEnum value anyway. + for row in conn.execute(_CPSU_ROWS): + new_value = electric_cpsu_for(row.description, row.main_fuel) + if new_value is None: + continue + total += 1 + deferred.add(new_value) + if apply: + conn.execute( + _CPSU_PROPERTY_UPDATE, + {"property_id": row.property_id, "new_value": new_value}, + ) + return total, deferred + + +def main() -> None: + load_env() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--apply", + action="store_true", + help="execute the updates (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + engine = build_engine() + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + total, deferred = reclassify(conn, apply=args.apply) + + verb = "re-classified" if args.apply else "would re-classify" + print( + f"{verb} {total} main-heating override row(s) off their garbage-drawer " + "archetypes (old storage / Gas CPSU) onto faithful HHRSH / solid-fuel / " + "electric-boiler archetypes — property_overrides / TEXT, what the " + "modelling reads." + ) + if deferred: + print( + f"\n{len(deferred)} target value(s) NOT yet in the main_heating_system " + "pgEnum — their classifier-cache rows are deferred until the FE-repo " + "enum migration adds these members (property_overrides was still " + "updated, which is what the modelling reads):" + ) + for value in sorted(deferred): + print(f" {value!r}") + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + + +if __name__ == "__main__": + main() diff --git a/scripts/reclassify_mixed_glazing.py b/scripts/reclassify_mixed_glazing.py new file mode 100644 index 000000000..3cfc507ce --- /dev/null +++ b/scripts/reclassify_mixed_glazing.py @@ -0,0 +1,147 @@ +"""One-time re-classification of aggregate-mix glazing overrides flattened to Double. + +#1376 / ADR-0042: a landlord glazing description that is an aggregate mix +("40% double glazing 2002 or later, 60% single glazing") was classified to a single +``"Double glazing…"`` value whenever any double was present, and the overlay then +overwrote **every** window to double — over-crediting a predominantly-single dwelling +and clobbering the cert's per-window glazing. A whole-dwelling proportion cannot say +*which* windows are which, so a mix must resolve to ``GlazingType.MIXED`` (no overlay +→ the cert's per-window ``sap_windows`` are kept). + +The live classifier now applies ``glazing_mix_guard`` deterministically (so new +intakes of the structured split are correct); this fixes the rows written before it. +The **same guard** decides the correction here, so the backfill and the live path +cannot drift. + +Updates the TEXT ``property_overrides.override_value`` (what the modelling reads — +the immediate fix). The ``landlord_glazing_overrides.value`` classifier cache is a +``glazing`` **pgEnum**; ``MIXED`` is a new FE-owned value, so cache writes are +**deferred** until the Drizzle migration adds it (the Class-A/B pattern). + +DRY-RUN BY DEFAULT: prints the counts it would change and writes nothing. Pass +``--apply`` to execute inside a transaction. Idempotent — only rows whose stored +value differs from the target member are touched. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterable + +from sqlalchemy import Connection, text + +from domain.epc.property_overrides.glazing_mix_guard import glazing_mix_guard +from scripts.e2e_common import build_engine, load_env + + +def mixed_glazing_corrections( + stored: Iterable[tuple[str, str]], +) -> dict[str, str]: + """``(description, stored override_value)`` → the ``MIXED`` value, for the + descriptions the glazing mix guard resolves to a mix whose stored value is not + already ``MIXED``. Uniform / unparseable descriptions and rows already on + ``MIXED`` are omitted, so re-running against corrected data is a no-op.""" + corrections: dict[str, str] = {} + for description, value in stored: + member = glazing_mix_guard(description) + if member is not None and value != member.value: + corrections[description] = member.value + return corrections + + +_DISTINCT = text( + """ + SELECT DISTINCT lower(original_spreadsheet_description) AS description, + override_value AS value + FROM property_overrides + WHERE override_component = 'glazing' + """ +) +_OVERRIDES_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE override_component = 'glazing' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_OVERRIDES_COUNT = text( + """ + SELECT count(*) FROM property_overrides + WHERE override_component = 'glazing' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_CACHE_UPDATE = text( + """ + UPDATE landlord_glazing_overrides + SET value = :new_value, updated_at = now() + WHERE lower(description) = :description + AND value::text <> :new_value + """ +) +_ENUM_VALUES = text( + "SELECT e.enumlabel FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid " + "WHERE t.typname = 'glazing'" +) + + +def reclassify(conn: Connection, *, apply: bool) -> tuple[int, set[str]]: + """Re-map aggregate-mix glazing overrides onto MIXED. Returns the number of + ``property_overrides`` rows found and the set of target values the live + ``glazing`` pgEnum does not yet carry (cache-deferred until the FE migration).""" + stored = [(r.description, r.value) for r in conn.execute(_DISTINCT)] + enum_values = {r[0] for r in conn.execute(_ENUM_VALUES)} + total = 0 + deferred: set[str] = set() + for description, new_value in mixed_glazing_corrections(stored).items(): + params = {"description": description, "new_value": new_value} + total += conn.execute(_OVERRIDES_COUNT, params).scalar() or 0 + in_enum = new_value in enum_values + if not in_enum: + deferred.add(new_value) + if apply: + conn.execute(_OVERRIDES_UPDATE, params) + if in_enum: + conn.execute(_CACHE_UPDATE, params) + return total, deferred + + +def main() -> None: + load_env() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--apply", + action="store_true", + help="execute the updates (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + engine = build_engine() + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + total, deferred = reclassify(conn, apply=args.apply) + + verb = "re-classified" if args.apply else "would re-classify" + print( + f"{verb} {total} aggregate-mix glazing override row(s) from a flattened " + "Double onto MIXED (property_overrides / TEXT); the cert's per-window " + "glazing is kept." + ) + if deferred: + print( + f"\n{len(deferred)} target value(s) NOT yet in the glazing pgEnum — " + "their classifier-cache rows are deferred until the FE-repo enum " + "migration adds these members (property_overrides was still updated, " + "which is what the modelling reads):" + ) + for value in sorted(deferred): + print(f" {value!r}") + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + + +if __name__ == "__main__": + main() diff --git a/scripts/reclassify_party_ceiling_roofs.py b/scripts/reclassify_party_ceiling_roofs.py new file mode 100644 index 000000000..3727fa7ef --- /dev/null +++ b/scripts/reclassify_party_ceiling_roofs.py @@ -0,0 +1,132 @@ +"""One-time re-classification of party-ceiling roof overrides mis-read as roofs. + +#1376: a landlord roof description that is a **party-ceiling** marker ("another / +same dwelling or premises above") was occasionally classified by the LLM to an +external ``Pitched, N mm loft insulation`` value when it carried a trailing depth — +inventing roof heat loss where a party ceiling has ~0 (RdSAP 10 Table 18: "There +is no heat loss through the roof of a building part that has the same dwelling or +another dwelling above"). ~106 ``property_overrides`` rows (party-ceiling markers +on any non-party-ceiling value), inconsistent with the ~13k of the same family +already resolving to the party-ceiling member. + +The live classifier now applies ``roof_party_ceiling_guard`` deterministically +(so new intakes are correct); this fixes the rows written before it. The **same +guard** decides the correction here, so the backfill and the live path cannot +drift. + +Updates the TEXT ``property_overrides.override_value`` (what the modelling reads — +the actual fix) and the ``landlord_roof_type_overrides.value`` classifier cache. +The party-ceiling members already exist in the roof pgEnum (13k rows store them), +so no FE migration is needed. + +DRY-RUN BY DEFAULT: prints the row count it would change and writes nothing. Pass +``--apply`` to execute inside a transaction. Idempotent — only rows whose stored +value differs from the guard's member are touched, so a second run is a no-op. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterable + +from sqlalchemy import Connection, text + +from domain.epc.property_overrides.roof_party_ceiling_guard import ( + roof_party_ceiling_guard, +) +from scripts.e2e_common import build_engine, load_env + + +def party_ceiling_corrections( + stored: Iterable[tuple[str, str]], +) -> dict[str, str]: + """``(description, stored override_value)`` → the corrected value, for the + descriptions the party-ceiling guard resolves whose stored value is not already + the guard's member. Descriptions the guard leaves to the LLM (``None``) and + rows already on the right member are omitted — so the result is exactly the set + to fix, and re-running against corrected data yields an empty map (idempotent). + """ + corrections: dict[str, str] = {} + for description, value in stored: + member = roof_party_ceiling_guard(description) + if member is not None and value != member.value: + corrections[description] = member.value + return corrections + + +_DISTINCT = text( + """ + SELECT DISTINCT lower(original_spreadsheet_description) AS description, + override_value AS value + FROM property_overrides + WHERE override_component = 'roof_type' + """ +) +_OVERRIDES_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE override_component = 'roof_type' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_CACHE_UPDATE = text( + """ + UPDATE landlord_roof_type_overrides + SET value = :new_value, updated_at = now() + WHERE lower(description) = :description + AND value::text <> :new_value + """ +) +_OVERRIDES_COUNT = text( + """ + SELECT count(*) FROM property_overrides + WHERE override_component = 'roof_type' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) + + +def reclassify(conn: Connection, *, apply: bool) -> int: + """NULL-free re-map: set every mis-classified party-ceiling roof override to the + guard's member. Returns the number of ``property_overrides`` rows found (that + ``--apply`` would / did correct).""" + stored = [(r.description, r.value) for r in conn.execute(_DISTINCT)] + total = 0 + for description, new_value in party_ceiling_corrections(stored).items(): + params = {"description": description, "new_value": new_value} + total += conn.execute(_OVERRIDES_COUNT, params).scalar() or 0 + if apply: + conn.execute(_OVERRIDES_UPDATE, params) + conn.execute(_CACHE_UPDATE, params) + return total + + +def main() -> None: + load_env() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--apply", + action="store_true", + help="execute the updates (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + engine = build_engine() + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + total = reclassify(conn, apply=args.apply) + + verb = "re-classified" if args.apply else "would re-classify" + print( + f"{verb} {total} party-ceiling roof override row(s) from an external " + "roof value to the party-ceiling member." + ) + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + + +if __name__ == "__main__": + main() diff --git a/scripts/reclassify_water_heating.py b/scripts/reclassify_water_heating.py new file mode 100644 index 000000000..40ecb4dd0 --- /dev/null +++ b/scripts/reclassify_water_heating.py @@ -0,0 +1,148 @@ +"""One-time re-classification of water-heating overrides funnelled into house coal. + +#1376 / ADR-0043: with no biomass / wood / dual-fuel / biodiesel water fuel in the +taxonomy, the LLM classified those DHW descriptions (and the "no system / electric +immersion assumed" case) to ``"From main system, house coal"`` (fuel 33). On an +individual-main dwelling that scores hot water at ~14x the carbon of biomass; on a +community-main dwelling the score is already right but the stored value lies. + +The live classifier now applies ``water_heating_guard`` deterministically (so new +intakes are correct); this fixes the rows written before it. The **same guard** +decides the correction here, so the backfill and the live path cannot drift. + +Updates the TEXT ``property_overrides.override_value`` (what the modelling reads — +the immediate fix) unconditionally. The ``landlord_water_heating_overrides.value`` +classifier cache is a ``water_heating`` **pgEnum**; the four new archetypes are +FE-owned values, so their cache writes are **deferred** until the Drizzle migration +adds them (the Class-A/B pattern). ``Electric immersion, electricity`` and +``From main system, house coal`` are existing enum members and update the cache. + +DRY-RUN BY DEFAULT: prints the counts it would change and writes nothing. Pass +``--apply`` to execute inside a transaction. Idempotent — only rows whose stored +value differs from the target member are touched. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterable + +from sqlalchemy import Connection, text + +from domain.epc.property_overrides.water_heating_guard import water_heating_guard +from scripts.e2e_common import build_engine, load_env + + +def water_heating_corrections( + stored: Iterable[tuple[str, str]], +) -> dict[str, str]: + """``(description, stored override_value)`` → the faithful archetype value, for + the descriptions the water-heating guard resolves whose stored value differs. + Descriptions the guard defers (mains gas, electricity, …), genuine house-coal + rows, and rows already on the target are omitted, so re-running against + corrected data is a no-op.""" + corrections: dict[str, str] = {} + for description, value in stored: + member = water_heating_guard(description) + if member is not None and value != member.value: + corrections[description] = member.value + return corrections + + +_DISTINCT = text( + """ + SELECT DISTINCT lower(original_spreadsheet_description) AS description, + override_value AS value + FROM property_overrides + WHERE override_component = 'water_heating' + """ +) +_OVERRIDES_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE override_component = 'water_heating' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_OVERRIDES_COUNT = text( + """ + SELECT count(*) FROM property_overrides + WHERE override_component = 'water_heating' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_CACHE_UPDATE = text( + """ + UPDATE landlord_water_heating_overrides + SET value = :new_value, updated_at = now() + WHERE lower(description) = :description + AND value::text <> :new_value + """ +) +_ENUM_VALUES = text( + "SELECT e.enumlabel FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid " + "WHERE t.typname = 'water_heating'" +) + + +def reclassify(conn: Connection, *, apply: bool) -> tuple[int, set[str]]: + """Re-map house-coal water-heating overrides onto their faithful archetypes. + Returns the number of ``property_overrides`` rows found and the set of target + values the live ``water_heating`` pgEnum does not yet carry (cache-deferred + until the FE migration).""" + stored = [(r.description, r.value) for r in conn.execute(_DISTINCT)] + enum_values = {r[0] for r in conn.execute(_ENUM_VALUES)} + total = 0 + deferred: set[str] = set() + for description, new_value in water_heating_corrections(stored).items(): + params = {"description": description, "new_value": new_value} + total += conn.execute(_OVERRIDES_COUNT, params).scalar() or 0 + in_enum = new_value in enum_values + if not in_enum: + deferred.add(new_value) + if apply: + conn.execute(_OVERRIDES_UPDATE, params) + if in_enum: + conn.execute(_CACHE_UPDATE, params) + return total, deferred + + +def main() -> None: + load_env() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--apply", + action="store_true", + help="execute the updates (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + engine = build_engine() + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + total, deferred = reclassify(conn, apply=args.apply) + + verb = "re-classified" if args.apply else "would re-classify" + print( + f"{verb} {total} water-heating override row(s) off the house-coal dumping " + "ground onto faithful biomass / wood / dual-fuel / biodiesel / immersion " + "archetypes (property_overrides / TEXT — what the modelling reads)." + ) + if deferred: + print( + f"\n{len(deferred)} target value(s) NOT yet in the water_heating " + "pgEnum — their classifier-cache rows are deferred until the FE-repo " + "enum migration adds these members (property_overrides was still " + "updated, which is what the modelling reads):" + ) + for value in sorted(deferred): + print(f" {value!r}") + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_modelling_e2e.py b/scripts/run_modelling_e2e.py index 1a1964cec..6b4225be6 100644 --- a/scripts/run_modelling_e2e.py +++ b/scripts/run_modelling_e2e.py @@ -910,10 +910,12 @@ def main() -> None: print_table=False, ) # The full candidate menu (every Generator Option + its cost), so - # measures the Optimiser did not select are still visible. A predicted - # Property has no lodged cert, so the synthesised Effective EPC is used. + # measures the Optimiser did not select are still visible. Generated + # from the Effective EPC — the same picture the plan above modelled; + # the lodged cert would print a menu the Optimiser never saw (e.g. + # cavity fill on a wall an override moved to timber frame). candidates: list[Recommendation] = candidate_recommendations( - epc if epc is not None else effective_epc, + effective_epc, planning_restrictions=restrictions, solar_insights=solar_insights, considered_measures=considered, diff --git a/tests/abri/abri_relay_logjob_relayerror_response.xml b/tests/abri/abri_relay_logjob_relayerror_response.xml new file mode 100644 index 000000000..ac6647828 --- /dev/null +++ b/tests/abri/abri_relay_logjob_relayerror_response.xml @@ -0,0 +1,6 @@ + + + false + RelayError + No property was found with UPRN/place_ref '1007176aa', street name '', house number '0', house name '', post code '' + diff --git a/tests/abri/abri_relay_logjob_request_example.xml b/tests/abri/abri_relay_logjob_request_example.xml new file mode 100644 index 000000000..9fe160990 --- /dev/null +++ b/tests/abri/abri_relay_logjob_request_example.xml @@ -0,0 +1,20 @@ + + +
+ +
+ + + + + + + + + + + + + + +
diff --git a/tests/abri/abri_relay_logjob_success_response.xml b/tests/abri/abri_relay_logjob_success_response.xml new file mode 100644 index 000000000..aabd14b40 --- /dev/null +++ b/tests/abri/abri_relay_logjob_success_response.xml @@ -0,0 +1,6 @@ + + + + AD0226519 + + diff --git a/tests/applications/modelling_e2e/test_handler.py b/tests/applications/modelling_e2e/test_handler.py index a09fe266a..80c44aeeb 100644 --- a/tests/applications/modelling_e2e/test_handler.py +++ b/tests/applications/modelling_e2e/test_handler.py @@ -124,6 +124,18 @@ def _baseline_orchestrator() -> Iterator[MagicMock]: yield orchestrator +@pytest.fixture(autouse=True) +def _solar_repository() -> Iterator[MagicMock]: + """Patch the stored-Solar repository for every test — the handler reads + stored insights through it whatever refetch_solar says. Default: no stored + rows; solar tests override get_many with real payloads.""" + with patch( + "applications.modelling_e2e.handler.SolarPostgresRepository" + ) as repository: + repository.return_value.get_many.return_value = {} + yield repository + + # --------------------------------------------------------------------------- # Solar throttle: per-container call gap resolved from env (quota/N safety) # --------------------------------------------------------------------------- @@ -208,6 +220,96 @@ def test_trigger_body_new_flags_default_to_true() -> None: assert result.refetch_solar is True +# --------------------------------------------------------------------------- +# refetch_solar semantics — False skips the LIVE fetch only, never the measure +# --------------------------------------------------------------------------- + + +def test_refetch_solar_false_models_with_stored_solar_and_never_calls_google( + _solar_repository: MagicMock, +) -> None: + """refetch_solar=False means "don't pay for a Google call", NOT "model + without solar": the stored Solar insights are read from the DB and handed to + the Modelling stage, and the live Google client is never invoked. + + Provenance: portfolio 814's 2026-07-02 re-model ran with refetch_solar=False + and every plan lost its solar candidates (pids 742071/742077/742397/742399 — + houses stopped short of goal B with bill-negative ASHP-only plans) even + though the `solar` table held healthy payloads for their UPRNs.""" + # Arrange + stored_insights = {"solarPotential": {"maxArrayPanelsCount": 58}} + mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) + mock_uow = MagicMock() + + with ExitStack() as stack: + stack.enter_context( + patch("applications.modelling_e2e.handler.os.environ", _ENV) + ) + stack.enter_context( + patch( + "applications.modelling_e2e.handler._get_engine", + return_value=mock_engine, + ) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.EpcClientService") + ).return_value.get_by_uprn.return_value = MagicMock() + stack.enter_context( + patch("applications.modelling_e2e.handler.GeospatialS3Repository") + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.GoogleSolarApiClient") + ) + stack.enter_context( + patch("applications.modelling_e2e.handler._spatial_for", return_value=None) + ) + mock_live_fetch = stack.enter_context( + patch( + "applications.modelling_e2e.handler._solar_insights_for", + return_value=None, + ) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.overlays_from", return_value=[]) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader") + ).return_value.overrides_for_many.return_value = {} + stack.enter_context( + patch("applications.modelling_e2e.handler.ScenarioPostgresRepository") + ).return_value.get_many.return_value = [MagicMock()] + stack.enter_context( + patch( + "applications.modelling_e2e.handler.catalogue_snapshot_with_off_catalogue_overrides" + ) + ) + stack.enter_context(patch("applications.modelling_e2e.handler.Session")) + mock_run_modelling = stack.enter_context( + patch( + "applications.modelling_e2e.handler.run_modelling", + return_value=_plan_mock(), + ) + ) + MockUoW = stack.enter_context( + patch("applications.modelling_e2e.handler.PostgresUnitOfWork") + ) + MockUoW.return_value.__enter__.return_value = mock_uow + MockUoW.return_value.__exit__.return_value = False + _solar_repository.return_value.get_many.return_value = { + UPRN: stored_insights + } + + # Act — _BODY carries refetch_solar=False + _call_handler(_BODY) + + # Assert — the stored insights reach the Modelling stage unchanged, and the + # paid Google leg is never exercised. + assert ( + mock_run_modelling.call_args.kwargs["solar_insights"] == stored_insights + ) + mock_live_fetch.assert_not_called() + + # --------------------------------------------------------------------------- # Child SubTask creation # --------------------------------------------------------------------------- diff --git a/tests/datatypes/epc/domain/test_mapper_building_part_fabric_17_0.py b/tests/datatypes/epc/domain/test_mapper_building_part_fabric_17_0.py new file mode 100644 index 000000000..1a929b28f --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_building_part_fabric_17_0.py @@ -0,0 +1,66 @@ +"""Regression: the RdSAP-Schema-17.0 mapper path must carry the lodged +building-part fabric fields, like the 19.0/20.0/21.x paths. + +PRD #1385 (mapper normalization), building-part fabric family. 17.0 hardcoded +`floor_insulation_thickness` / `sap_room_in_roof` to None and omitted +`flat_roof_insulation_thickness`, all of which the 17.0 schema lodges and the +sibling paths read. (17.1 is NOT affected — its SapBuildingPart schema has none +of these fields, so its None there is correct.) `roof_insulation_thickness` is +intentionally still passed raw; the sloping-ceiling resolve helper is a separate +roof-U-moving follow-up. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# Lodges a room-in-roof (floor_area 48.83, age band K). +_FIXTURE_RIR = Path("tests/fixtures/epc_prediction/BD24JG/cert-75aa70bdd22a.json") +# Lodges a flat-roof insulation thickness code ("AB"). +_FIXTURE_FLAT_ROOF = Path("tests/fixtures/epc_prediction/BD24JG/cert-01f1488000e8.json") + + +def _load(path: Path) -> dict[str, Any]: + data: dict[str, Any] = json.loads(path.read_text()) + assert data["schema_type"] == "RdSAP-Schema-17.0" + return data + + +def test_17_0_maps_lodged_room_in_roof() -> None: + epc = EpcPropertyDataMapper.from_api_response(_load(_FIXTURE_RIR)) + + rirs = [bp.sap_room_in_roof for bp in epc.sap_building_parts if bp.sap_room_in_roof] + assert rirs, "room-in-roof was dropped (mapped to None)" + assert rirs[0].floor_area == 48.83 + assert rirs[0].construction_age_band == "K" + + +def test_17_0_maps_lodged_flat_roof_insulation_thickness() -> None: + epc = EpcPropertyDataMapper.from_api_response(_load(_FIXTURE_FLAT_ROOF)) + + vals = [ + bp.flat_roof_insulation_thickness + for bp in epc.sap_building_parts + if bp.flat_roof_insulation_thickness is not None + ] + assert "AB" in vals + + +def test_17_0_floor_insulation_thickness_flows_and_ni_maps_to_none() -> None: + # A measured thickness carries through (schema lodges None on the real certs; + # inject to prove the field is no longer hardcoded to None). + data = _load(_FIXTURE_RIR) + data["sap_building_parts"][0]["floor_insulation_thickness"] = "150" + epc = EpcPropertyDataMapper.from_api_response(data) + assert epc.sap_building_parts[0].floor_insulation_thickness == "150" + + # The "NI" sentinel (no measured thickness) maps to None so u_floor reaches + # its age-band default, matching the 19.0 path. + data_ni = _load(_FIXTURE_RIR) + data_ni["sap_building_parts"][0]["floor_insulation_thickness"] = "NI" + epc_ni = EpcPropertyDataMapper.from_api_response(data_ni) + assert epc_ni.sap_building_parts[0].floor_insulation_thickness is None diff --git a/tests/datatypes/epc/domain/test_mapper_insulated_door_u_value.py b/tests/datatypes/epc/domain/test_mapper_insulated_door_u_value.py new file mode 100644 index 000000000..80b33aa4c --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_insulated_door_u_value.py @@ -0,0 +1,49 @@ +"""Regression: the 20.0.0 and 21.0.0 mappers must carry the lodged +insulated_door_u_value, like the 21.0.1 path. + +PRD #1385 phase 2. Both dropped `insulated_door_u_value` (top-level Optional +float). heat_transmission reads it as the measured door U-value override +(`cert_to_inputs` → `heat_transmission`), so dropping it forced every door onto +the RdSAP default U-value. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# 21.0.0 sample lodges insulated_door_u_value=3. +_FIXTURE_21_0_0 = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.0/epc.json") +# 20.0.0 certs don't lodge it in the corpus (latent) — inject to prove the path. +_FIXTURE_20_0_0 = Path("tests/fixtures/epc_prediction/PE71NT/cert-460c2d58495c.json") + + +def test_21_0_0_carries_insulated_door_u_value() -> None: + data: dict[str, Any] = json.loads(_FIXTURE_21_0_0.read_text()) + assert data["schema_type"] == "RdSAP-Schema-21.0.0" + assert data["insulated_door_u_value"] == 3 + + epc = EpcPropertyDataMapper.from_api_response(data) + assert epc.insulated_door_u_value == 3 + + +def test_20_0_0_carries_injected_insulated_door_u_value() -> None: + data: dict[str, Any] = json.loads(_FIXTURE_20_0_0.read_text()) + assert data["schema_type"] == "RdSAP-Schema-20.0.0" + data["insulated_door_u_value"] = 1.5 + + epc = EpcPropertyDataMapper.from_api_response(data) + # Was hardcoded away (None); now carried through. + assert epc.insulated_door_u_value == 1.5 + + +def test_20_0_0_absent_insulated_door_u_value_maps_to_none() -> None: + # The real cert lodges no value → None (no phantom U-value). + data: dict[str, Any] = json.loads(_FIXTURE_20_0_0.read_text()) + assert data.get("insulated_door_u_value") is None + + epc = EpcPropertyDataMapper.from_api_response(data) + assert epc.insulated_door_u_value is None diff --git a/tests/datatypes/epc/domain/test_mapper_lighting_parity.py b/tests/datatypes/epc/domain/test_mapper_lighting_parity.py new file mode 100644 index 000000000..c3e3316fd --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_lighting_parity.py @@ -0,0 +1,57 @@ +"""Regression: the degraded RdSAP paths (17.0, 19.0) must carry the lodged +fixed-lighting outlet counts, like the reference-complete 17.1/18.0/20.0 paths. + +PRD #1385 (mapper normalization), lighting family. Both paths hardcoded every +bulb count to 0, dropping the lodged lighting mix. The schema lodges total + +low-energy OUTLET counts (ADR-0028); the reference paths split them as +low-energy → low_energy bulbs and the remainder → incandescent, feeding the SAP +lighting-energy calc. (21.0.x use a different bulb-based lighting schema and are +out of scope for this slice.) +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# 17.0 cert: fixed_lighting_outlets_count=10, low_energy=5 → incandescent 5. +_FIXTURE_17_0 = Path("tests/fixtures/epc_prediction/PE71NT/cert-26619d8e7f8e.json") +# 19.0 cert: fixed_lighting_outlets_count=8, low_energy=5 → incandescent 3. +_FIXTURE_19_0 = Path("tests/fixtures/epc_prediction/PE71NT/cert-21b634bc4b59.json") + + +def _load(path: Path, schema_type: str) -> dict[str, Any]: + data: dict[str, Any] = json.loads(path.read_text()) + assert data["schema_type"] == schema_type + return data + + +def test_17_0_path_carries_lighting_outlet_counts() -> None: + data = _load(_FIXTURE_17_0, "RdSAP-Schema-17.0") + assert data["fixed_lighting_outlets_count"] == 10 + assert data["low_energy_fixed_lighting_outlets_count"] == 5 + + epc = EpcPropertyDataMapper.from_api_response(data) + + # Was hardcoded 0/0/0. Low-energy outlets carry through; the remainder is + # incandescent; led/cfl stay 0 (reduced-field certs don't split those). + assert epc.low_energy_fixed_lighting_bulbs_count == 5 + assert epc.incandescent_fixed_lighting_bulbs_count == 5 + assert epc.led_fixed_lighting_bulbs_count == 0 + assert epc.cfl_fixed_lighting_bulbs_count == 0 + + +def test_19_0_path_carries_lighting_outlet_counts() -> None: + data = _load(_FIXTURE_19_0, "RdSAP-Schema-19.0") + assert data["fixed_lighting_outlets_count"] == 8 + assert data["low_energy_fixed_lighting_outlets_count"] == 5 + + epc = EpcPropertyDataMapper.from_api_response(data) + + assert epc.low_energy_fixed_lighting_bulbs_count == 5 + assert epc.incandescent_fixed_lighting_bulbs_count == 3 + assert epc.led_fixed_lighting_bulbs_count == 0 + assert epc.cfl_fixed_lighting_bulbs_count == 0 diff --git a/tests/datatypes/epc/domain/test_mapper_main_heating_controls_17_0.py b/tests/datatypes/epc/domain/test_mapper_main_heating_controls_17_0.py new file mode 100644 index 000000000..3bb1a6d63 --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_main_heating_controls_17_0.py @@ -0,0 +1,48 @@ +"""Regression: the RdSAP-Schema-17.0 mapper path must carry the lodged +main_heating_controls, like every other API path (19.0/20.0/21.x). + +PRD #1385 (mapper normalization). 17.0 lodges `main_heating_controls` +(List[EnergyElement]) but the mapper omitted it, leaving the domain field None +while the 19.0/21.0.1 paths map the first control via `_map_energy_element`. The +mapped element is the same EnergyElement type 17.0 already carries for +`main_heating` / `window`, so this is straight parity. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# 17.0 cert lodging a main-heating control ("Programmer, TRVs and bypass"). +_FIXTURE = Path("tests/fixtures/epc_prediction/PE71NT/cert-26619d8e7f8e.json") + + +def _load() -> dict[str, Any]: + data: dict[str, Any] = json.loads(_FIXTURE.read_text()) + assert data["schema_type"] == "RdSAP-Schema-17.0" + return data + + +def test_17_0_maps_lodged_main_heating_controls() -> None: + data = _load() + assert data["main_heating_controls"], "fixture must lodge a control" + + epc = EpcPropertyDataMapper.from_api_response(data) + + # Was None on the 17.0 path; now carries the first lodged control element. + assert epc.main_heating_controls is not None + assert epc.main_heating_controls.description == "Programmer, TRVs and bypass" + assert epc.main_heating_controls.energy_efficiency_rating == 3 + + +def test_17_0_no_controls_maps_to_none() -> None: + # An empty control list maps to None (no phantom element), like the siblings. + data = _load() + data["main_heating_controls"] = [] + + epc = EpcPropertyDataMapper.from_api_response(data) + + assert epc.main_heating_controls is None diff --git a/tests/datatypes/epc/domain/test_mapper_mvhr_ducts_21_0_0.py b/tests/datatypes/epc/domain/test_mapper_mvhr_ducts_21_0_0.py new file mode 100644 index 000000000..8cad4021e --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_mvhr_ducts_21_0_0.py @@ -0,0 +1,40 @@ +"""Regression: the RdSAP-Schema-21.0.0 mapper must carry the lodged MVHR/MEV PCDF +index + duct type, like the 21.0.1 path. + +PRD #1385 phase 2. 21.0.0 dropped `mechanical_ventilation_index_number` and +`mechanical_vent_duct_type`, so an MVHR/MEV cert on this path lost its PCDB +Table 4f/329 heat-recovery lookup (`cert_to_inputs` reads both). Only the two +fields 21.0.1 actually maps are carried; the other four +`mechanical_vent_duct_*` schema fields have no mapper or calculator consumer even +on 21.0.1, so they are deliberately left alone. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# The harvested 21.0.0 sample lodges index_number=12, duct_type=3. +_FIXTURE = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.0/epc.json") + + +def _load() -> dict[str, Any]: + data: dict[str, Any] = json.loads(_FIXTURE.read_text()) + assert data["schema_type"] == "RdSAP-Schema-21.0.0" + return data + + +def test_21_0_0_carries_mvhr_index_and_duct_type() -> None: + data = _load() + assert data["mechanical_ventilation_index_number"] == 12 + assert data["mechanical_vent_duct_type"] == 3 + + epc = EpcPropertyDataMapper.from_api_response(data) + + # Both were dropped to None on the 21.0.0 path; now carried through so + # cert_to_inputs can drive the PCDB heat-recovery lookup. + assert epc.mechanical_ventilation_index_number == 12 + assert epc.mechanical_vent_duct_type == 3 diff --git a/tests/datatypes/epc/domain/test_mapper_percent_draughtproofed_21_0_0.py b/tests/datatypes/epc/domain/test_mapper_percent_draughtproofed_21_0_0.py new file mode 100644 index 000000000..ba8319d0e --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_percent_draughtproofed_21_0_0.py @@ -0,0 +1,34 @@ +"""Regression: the RdSAP-Schema-21.0.0 mapper must carry the lodged +percent_draughtproofed, like every other API path (17.0/…/21.0.1). + +PRD #1385 (mapper normalization). 21.0.0 was the last path still dropping +`percent_draughtproofed` (top-level int, lodged by the schema), understating the +§2 draughtproofing/infiltration credit. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# The harvested 21.0.0 sample lodges percent_draughtproofed=100. +_FIXTURE = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.0/epc.json") + + +def _load() -> dict[str, Any]: + data: dict[str, Any] = json.loads(_FIXTURE.read_text()) + assert data["schema_type"] == "RdSAP-Schema-21.0.0" + return data + + +def test_21_0_0_carries_percent_draughtproofed() -> None: + data = _load() + assert data["percent_draughtproofed"] == 100 + + epc = EpcPropertyDataMapper.from_api_response(data) + + # Was dropped to None on the 21.0.0 path; now carried through. + assert epc.percent_draughtproofed == 100 diff --git a/tests/datatypes/epc/domain/test_mapper_sap_heating_17_0_drops.py b/tests/datatypes/epc/domain/test_mapper_sap_heating_17_0_drops.py new file mode 100644 index 000000000..ee7fc7afe --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_sap_heating_17_0_drops.py @@ -0,0 +1,77 @@ +"""Regression: the RdSAP-Schema-17.0 mapper path must preserve the whole lodged +`sap_heating` block, like every other API path (19.0/20.0/…). + +`from_rdsap_schema_17_0` was an older copy that hardcoded a cluster of lodged +`sap_heating` fields to None while the 19.0+ paths read them from +`schema.sap_heating`: + + * MainHeatingDetail.boiler_flue_type / fan_flue_present / central_heating_pump_age + / main_heating_index_number (the last is the PCDB efficiency index) + * SapHeating.cylinder_thermostat — gates the water-heating ×1.3 penalty + and `recommend_cylinder_thermostat` + * SapHeating.cylinder_insulation_thickness — drives the cylinder-loss cascade + +The 17.0 schema lodges all of them (see RdSapSchema17_0), so the hardcode silently +degraded both the SAP water-heating figure and the hot-water recommendations for +17.0 certs. This guard makes sure the block stays mapped. (The `secondary_*_type` +pair of the same block is covered by its own dedicated secondary-heating test.) +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# A real RdSAP-Schema-17.0 cert that lodges a cylinder thermostat, a measured +# cylinder-insulation thickness and flue/pump fields on its main heating. +_FIXTURE = Path("tests/fixtures/epc_prediction/PE71NT/cert-26619d8e7f8e.json") + +# A PCDB main-heating index the fixture happens not to carry; injected to prove +# the field flows through (it used to be dropped to None unconditionally). +_MAIN_HEATING_INDEX = 4321 + + +def _load_17_0_cert() -> dict[str, Any]: + data: dict[str, Any] = json.loads(_FIXTURE.read_text()) + assert data["schema_type"] == "RdSAP-Schema-17.0" + return data + + +def test_17_0_path_preserves_lodged_cylinder_fields() -> None: + # Arrange — the real cert lodges a cylinder thermostat + measured thickness. + data = _load_17_0_cert() + assert data["sap_heating"]["cylinder_thermostat"] == "Y" + assert data["sap_heating"]["cylinder_insulation_thickness"] == 25 + + # Act + epc = EpcPropertyDataMapper.from_api_response(data) + + # Assert — both survive the 17.0 path now (they used to be dropped to None), + # so the water-heating worksheet and cylinder-thermostat recommendation see + # the real dwelling. + assert epc.sap_heating.cylinder_thermostat == "Y" + assert epc.sap_heating.cylinder_insulation_thickness_mm == 25 + + +def test_17_0_path_preserves_lodged_main_heating_detail_fields() -> None: + # Arrange — the fixture lodges flue/pump on the main heating; inject a PCDB + # index (absent on this cert) to cover the efficiency-lookup field too. + data = _load_17_0_cert() + detail = data["sap_heating"]["main_heating_details"][0] + assert detail["boiler_flue_type"] == 2 + assert detail["fan_flue_present"] == "Y" + assert detail["central_heating_pump_age"] == 1 + detail["main_heating_index_number"] = _MAIN_HEATING_INDEX + + # Act + epc = EpcPropertyDataMapper.from_api_response(data) + + # Assert — the whole MainHeatingDetail block carries through (was all None). + mapped = epc.sap_heating.main_heating_details[0] + assert mapped.boiler_flue_type == 2 + assert mapped.fan_flue_present is True + assert mapped.central_heating_pump_age == 1 + assert mapped.main_heating_index_number == _MAIN_HEATING_INDEX diff --git a/tests/datatypes/epc/domain/test_mapper_secondary_heating_17_0.py b/tests/datatypes/epc/domain/test_mapper_secondary_heating_17_0.py new file mode 100644 index 000000000..0d94df908 --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_secondary_heating_17_0.py @@ -0,0 +1,61 @@ +"""Regression: the RdSAP-Schema-17.0 mapper path must preserve a lodged secondary +heating system, like every other API path. + +`from_rdsap_schema_17_0` previously hardcoded `sap_heating.secondary_heating_type` +(and `secondary_fuel_type`) to None, unlike the 19.0/20.0/… paths which read +`schema.sap_heating.secondary_heating_type`. The gov EPC API *does* populate that +field for a genuine FIXED secondary (SAP codes such as 691 "electric room +heaters" / 694), so the hardcode silently dropped it — and because +`recommend_secondary_heating_removal` gates purely on +`sap_heating.secondary_heating_type`, a 17.0 cert with a fixed secondary would +never get its removal recommendation. Portfolio 814 happened not to contain such +a cert, so the bug was latent; this guard makes sure it stays fixed. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# A real RdSAP-Schema-17.0 cert whose lodged secondary is absent (maps to None); +# we inject a fixed secondary to prove the 17.0 path now carries it through. +_FIXTURE = Path("tests/fixtures/epc_prediction/PE71NT/cert-26619d8e7f8e.json") + +# SAP 10.2 secondary-heating code for fixed electric room heaters, exactly as the +# gov API lodges it (seen live on portfolio-814 certs), + its off-peak fuel code. +_FIXED_SECONDARY_TYPE = 691 +_SECONDARY_FUEL = 29 + + +def _load_17_0_cert() -> dict[str, Any]: + data: dict[str, Any] = json.loads(_FIXTURE.read_text()) + assert data["schema_type"] == "RdSAP-Schema-17.0" + return data + + +def test_17_0_path_preserves_lodged_fixed_secondary() -> None: + # Arrange — a 17.0 cert carrying a lodged fixed secondary in sap_heating. + data = _load_17_0_cert() + data["sap_heating"]["secondary_heating_type"] = _FIXED_SECONDARY_TYPE + data["sap_heating"]["secondary_fuel_type"] = _SECONDARY_FUEL + + # Act + epc = EpcPropertyDataMapper.from_api_response(data) + + # Assert — the 17.0 mapper carries the code through (it used to be dropped to + # None), so secondary_heating_removal can fire on this dwelling. + assert epc.sap_heating.secondary_heating_type == _FIXED_SECONDARY_TYPE + + +def test_17_0_path_leaves_absent_secondary_as_none() -> None: + # Arrange — the unmodified cert has no lodged secondary. + data = _load_17_0_cert() + + # Act + epc = EpcPropertyDataMapper.from_api_response(data) + + # Assert — no secondary lodged still maps to None (no phantom removal). + assert epc.sap_heating.secondary_heating_type is None diff --git a/tests/datatypes/epc/domain/test_mapper_ventilation_parity.py b/tests/datatypes/epc/domain/test_mapper_ventilation_parity.py new file mode 100644 index 000000000..fb31fdfea --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_ventilation_parity.py @@ -0,0 +1,67 @@ +"""Regression: the degraded RdSAP paths (17.0, 19.0) must carry the lodged +ventilation fields, like the reference-complete 17.1/18.0/20.0 paths. + +PRD #1385 (mapper normalization). Both paths dropped ventilation lodgements the +schema carries and the SAP §2 cascade reads: + + * `open_fireplaces_count` → `open_chimneys_count` — was hardcoded 0 on both, + understating infiltration. + * `percent_draughtproofed` — was omitted on both. + * 17.0 additionally passed no `sap_ventilation` block at all, so the cascade + fell back to NATURAL + its default `sheltered_sides=2`. Restored from the + lodged `mechanical_ventilation` + `built_form` (same gov code lists the + 19.0/17.1 paths already use; strict-coverage raises on any divergence). 19.0 + already carried `sap_ventilation`, so only its two counts are restored here. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# 17.0 cert: open_fireplaces=1, percent_draughtproofed=100, built_form=2 +# (Semi-Detached → sheltered_sides=1, i.e. NOT the cascade default of 2), +# mechanical_ventilation=0 (natural → kind None). +_FIXTURE_17_0 = Path("tests/fixtures/epc_prediction/BD24JG/cert-f326c2524ab3.json") +# 19.0 cert: open_fireplaces=1, percent_draughtproofed=100. +_FIXTURE_19_0 = Path("tests/fixtures/epc_prediction/RM143YU/cert-720d5771bfd7.json") + + +def _load(path: Path, schema_type: str) -> dict[str, Any]: + data: dict[str, Any] = json.loads(path.read_text()) + assert data["schema_type"] == schema_type + return data + + +def test_17_0_path_carries_ventilation_lodgements() -> None: + epc = EpcPropertyDataMapper.from_api_response( + _load(_FIXTURE_17_0, "RdSAP-Schema-17.0") + ) + + # Counts that were hardcoded 0 / omitted. + assert epc.open_chimneys_count == 1 + assert epc.percent_draughtproofed == 100 + + # sap_ventilation block was absent entirely — now built from built_form + + # mechanical_ventilation. built_form 2 (Semi-Detached) → sheltered_sides 1, + # which differs from the cascade's default of 2 (the real score-mover here). + assert epc.sap_ventilation is not None + assert epc.sap_ventilation.sheltered_sides == 1 + # mechanical_ventilation 0 is natural → no mechanical kind. + assert epc.sap_ventilation.mechanical_ventilation_kind is None + + +def test_19_0_path_carries_ventilation_counts() -> None: + epc = EpcPropertyDataMapper.from_api_response( + _load(_FIXTURE_19_0, "RdSAP-Schema-19.0") + ) + + # The two counts 19.0 previously dropped. + assert epc.open_chimneys_count == 1 + assert epc.percent_draughtproofed == 100 + # 19.0 already carried sap_ventilation; confirm it still resolves built_form. + assert epc.sap_ventilation is not None + assert epc.sap_ventilation.sheltered_sides == 1 diff --git a/tests/datatypes/epc/domain/test_mapper_wwhrs_parity.py b/tests/datatypes/epc/domain/test_mapper_wwhrs_parity.py new file mode 100644 index 000000000..4d9519cd0 --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_wwhrs_parity.py @@ -0,0 +1,47 @@ +"""Regression: the degraded RdSAP paths (17.0, 19.0) must derive the hot-water +bath/shower demand counts from the lodged WWHRS room counts, like the +reference-complete 17.1/18.0/20.0 paths. + +PRD #1385 (mapper normalization), shower/WWHRS family. Both paths left +`sap_heating.number_baths` / `mixer_shower_count` as None, so the water-heating +demand (`cert_to_inputs`) fell back to defaults. The reference derivation: + number_baths = rooms_with_bath_and_or_shower + rooms_with_bath_and_mixer_shower + mixer_shower_count = rooms_with_mixer_shower_no_bath + rooms_with_bath_and_mixer_shower +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# 17.0: rooms bath_or_shower=4, mixer_no_bath=0, bath_and_mixer=4 +# → number_baths 8, mixer_shower_count 4. +_FIXTURE_17_0 = Path("tests/fixtures/epc_prediction/W104HL/cert-f96b9b6f471b.json") +# 19.0: rooms bath_or_shower=2, mixer_no_bath=1, bath_and_mixer=1 +# → number_baths 3, mixer_shower_count 2. +_FIXTURE_19_0 = Path("tests/fixtures/epc_prediction/LE113PX/cert-ba628e4f9cbf.json") + + +def _load(path: Path, schema_type: str) -> dict[str, Any]: + data: dict[str, Any] = json.loads(path.read_text()) + assert data["schema_type"] == schema_type + return data + + +def test_17_0_derives_hot_water_demand_from_wwhrs_rooms() -> None: + epc = EpcPropertyDataMapper.from_api_response( + _load(_FIXTURE_17_0, "RdSAP-Schema-17.0") + ) + assert epc.sap_heating.number_baths == 8 + assert epc.sap_heating.mixer_shower_count == 4 + + +def test_19_0_derives_hot_water_demand_from_wwhrs_rooms() -> None: + epc = EpcPropertyDataMapper.from_api_response( + _load(_FIXTURE_19_0, "RdSAP-Schema-19.0") + ) + assert epc.sap_heating.number_baths == 3 + assert epc.sap_heating.mixer_shower_count == 2 diff --git a/tests/domain/abri/__init__.py b/tests/domain/abri/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/domain/abri/test_descriptions.py b/tests/domain/abri/test_descriptions.py new file mode 100644 index 000000000..593f62191 --- /dev/null +++ b/tests/domain/abri/test_descriptions.py @@ -0,0 +1,15 @@ +from domain.abri.descriptions import build_job_descriptions + + +def test_job_descriptions_identify_the_visit_as_a_domna_condition_survey() -> None: + # Arrange + deal_name = "49 Admers Crescent, Liphook" + + # Act + descriptions = build_job_descriptions(deal_name) + + # Assert + assert "Domna condition survey" in descriptions.short_description + assert deal_name in descriptions.short_description + assert "Domna condition survey" in descriptions.long_description + assert deal_name in descriptions.long_description diff --git a/tests/domain/abri/test_slots.py b/tests/domain/abri/test_slots.py new file mode 100644 index 000000000..fa5283691 --- /dev/null +++ b/tests/domain/abri/test_slots.py @@ -0,0 +1,47 @@ +from typing import Optional + +import pytest + +from domain.abri.slots import slot_for_confirmed_time + + +@pytest.mark.parametrize("confirmed_time", [None, "", " "]) +def test_a_booking_with_no_confirmed_time_is_an_all_day_appointment( + confirmed_time: Optional[str], +) -> None: + # Act + slot = slot_for_confirmed_time(confirmed_time) + + # Assert + assert slot == "AD" + + +@pytest.mark.parametrize("confirmed_time", ["08:00", "09:30", "11:59"]) +def test_a_confirmed_time_before_midday_books_the_morning_slot( + confirmed_time: str, +) -> None: + # Act + slot = slot_for_confirmed_time(confirmed_time) + + # Assert + assert slot == "AM" + + +@pytest.mark.parametrize("confirmed_time", ["12:00", "13:15", "16:59"]) +def test_a_confirmed_time_at_or_after_midday_books_the_afternoon_slot( + confirmed_time: str, +) -> None: + # Act + slot = slot_for_confirmed_time(confirmed_time) + + # Assert + assert slot == "PM" + + +@pytest.mark.parametrize("confirmed_time", ["half nine", "25:00", "9.30am"]) +def test_an_unparseable_confirmed_time_fails_loudly_rather_than_booking_all_day( + confirmed_time: str, +) -> None: + # Act / Assert + with pytest.raises(ValueError): + slot_for_confirmed_time(confirmed_time) diff --git a/tests/domain/billing/test_energy_breakdown.py b/tests/domain/billing/test_energy_breakdown.py index f754bc662..630c314b1 100644 --- a/tests/domain/billing/test_energy_breakdown.py +++ b/tests/domain/billing/test_energy_breakdown.py @@ -40,6 +40,7 @@ def _sap_result( space_heating_kwh_per_yr=0.0, space_cooling_kwh_per_yr=0.0, fabric_energy_efficiency_kwh_per_m2_yr=0.0, + design_heat_loss_kw=0.0, main_heating_fuel_kwh_per_yr=main_heating_fuel_kwh_per_yr, main_2_heating_fuel_kwh_per_yr=main_2_heating_fuel_kwh_per_yr, secondary_heating_fuel_kwh_per_yr=secondary_heating_fuel_kwh_per_yr, diff --git a/tests/domain/data_transformation/__init__.py b/tests/domain/data_transformation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/domain/data_transformation/test_guarded_column_classifier.py b/tests/domain/data_transformation/test_guarded_column_classifier.py new file mode 100644 index 000000000..a6ec38b3a --- /dev/null +++ b/tests/domain/data_transformation/test_guarded_column_classifier.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from enum import Enum +from typing import Optional + +from domain.data_transformation.column_classifier import ColumnClassifier +from domain.data_transformation.guarded_column_classifier import ( + GuardedColumnClassifier, +) + + +class _Category(Enum): + GUARDED = "guarded" + FALLBACK = "fallback" + UNKNOWN = "unknown" + + +class _RecordingFallback(ColumnClassifier[_Category]): + """Stands in for the LLM: records what it was asked and maps everything it + sees to FALLBACK, so the test can see which descriptions reached it.""" + + def __init__(self) -> None: + self.asked: set[str] = set() + + def classify(self, descriptions: set[str]) -> dict[str, _Category]: + self.asked = set(descriptions) + return {d: _Category.FALLBACK for d in descriptions} + + +def _guard(description: str) -> Optional[_Category]: + return _Category.GUARDED if description == "marker" else None + + +def test_guard_hits_win_and_misses_fall_through_to_the_fallback() -> None: + # Arrange + fallback = _RecordingFallback() + classifier = GuardedColumnClassifier(guard=_guard, fallback=fallback) + + # Act + result = classifier.classify({"marker", "other"}) + + # Assert — the guarded description takes the guard's member and never reaches + # the fallback; the unrecognised one is resolved by the fallback. + assert result == {"marker": _Category.GUARDED, "other": _Category.FALLBACK} + assert fallback.asked == {"other"} diff --git a/tests/domain/epc/test_glazing_mix_guard.py b/tests/domain/epc/test_glazing_mix_guard.py new file mode 100644 index 000000000..8d6e349cd --- /dev/null +++ b/tests/domain/epc/test_glazing_mix_guard.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import pytest + +from domain.epc.property_overrides.glazing_type import GlazingType +from domain.epc.property_overrides.glazing_mix_guard import glazing_mix_guard + + +def test_a_genuine_percentage_mix_classifies_as_mixed() -> None: + # Arrange — the bug: an aggregate mix the LLM would flatten to "Double". + description = "40% double glazing 2002 or later, 60% single glazing" + + # Act + result = glazing_mix_guard(description) + + # Assert — a mix defers to the cert's per-window glazing, not a whole-dwelling + # type. + assert result is GlazingType.MIXED + + +@pytest.mark.parametrize( + "description", + [ + "4% double glazing 2002 or later, 96% single glazing", + "10% double glazing 2002 or later, 90% single glazing", # exactly at threshold + "5% double glazing pre-2002, 95% single glazing", + ], +) +def test_dominant_single_glazing_resolves_to_single(description: str) -> None: + # The bug: the LLM flattened a >= 90%-single dwelling onto its *minority* double + # type. Single glazing is era-free, so a dominant-single split is fully + # determined — the guard must claim it (SINGLE) rather than defer to the LLM. + + # Act + result = glazing_mix_guard(description) + + # Assert + assert result is GlazingType.SINGLE + + +@pytest.mark.parametrize( + ("description", "expected"), + [ + # >= 90% double with a *stated* era is era-pinned — the LLM otherwise + # flattens it onto the 4% minority single (the symmetric bug to the + # dominant-single case above). + ("96% double glazing 2002 or later, 4% single glazing", GlazingType.DOUBLE_POST_2002), + ("95% double glazing pre-2002, 5% single glazing", GlazingType.DOUBLE_PRE_2002), + ("90% double glazing 2002 or later, 10% single glazing", GlazingType.DOUBLE_POST_2002), + # Triple is era-bearing too — resolve when the era is stated. + ("92% triple glazing pre-2002, 8% single glazing", GlazingType.TRIPLE_PRE_2002), + ("91% triple glazing 2002 or later, 9% single glazing", GlazingType.TRIPLE_POST_2002), + ], +) +def test_dominant_era_stated_double_or_triple_resolves_deterministically( + description: str, expected: GlazingType +) -> None: + # Act + result = glazing_mix_guard(description) + + # Assert — a dominant double/triple whose era is stated is fully determined, so + # the guard claims it rather than trusting the LLM. + assert result is expected + + +@pytest.mark.parametrize( + "description", + [ + "95% secondary glazing (sap 9.94), 5% single glazing", + "90% secondary glazing, 10% double glazing 2002 or later", + ], +) +def test_dominant_secondary_glazing_resolves_to_secondary(description: str) -> None: + # Secondary glazing is era-free like single (SAP10 code 5, U = 2.9 regardless + # of install year), so a dominant-secondary split is fully determined — the + # guard must claim it rather than let the LLM flatten it onto the minority + # type (Model#1416). + + # Act + result = glazing_mix_guard(description) + + # Assert + assert result is GlazingType.SECONDARY + + +@pytest.mark.parametrize( + "description", + [ + "100% double glazing 2002 or later", # uniform — one type + "100% secondary glazing (sap 9.94)", # uniform — one type + "90% double glazing unknown age, 10% single glazing", # dominant but era unstated + "80% double glazing 2002 or later, 20% double glazing pre-2002", # same base type + "some double glazing and a bit of single", # unparseable — no percentages + "", + ], +) +def test_uniform_or_ambiguous_glazing_defers_to_the_llm(description: str) -> None: + # The guard only claims a genuine mix or a fully-determined dominant split. A + # uniform assertion, a dominant split whose era is unstated ("unknown age" — the + # era is genuinely ambiguous), or a varied phrasing is left for the LLM. + + # Act + result = glazing_mix_guard(description) + + # Assert + assert result is None diff --git a/tests/domain/epc/test_glazing_overlay.py b/tests/domain/epc/test_glazing_overlay.py index 3182d79cf..1c58fe590 100644 --- a/tests/domain/epc/test_glazing_overlay.py +++ b/tests/domain/epc/test_glazing_overlay.py @@ -48,6 +48,21 @@ def test_glazing_types_decode_to_their_sap_codes( assert simulation.glazing.glazing_type == code +def test_secondary_glazing_overlays_its_glazing_code() -> None: + # Hyde-796 landlords assert "100% secondary glazing (sap 9.94)"; flattening it + # to Single applies U = 4.8 instead of secondary's 2.9 (Model#1416). The + # calculator already understands secondary glazing — SAP10 glazing_type code 5 + # (`_GLAZING_CODE_TO_UWINDOW` in heat_transmission). + + # Act + simulation = glazing_overlay_for("Secondary glazing", 0) + + # Assert + assert simulation is not None + assert simulation.glazing is not None + assert simulation.glazing.glazing_type == 5 + + @pytest.mark.parametrize("glazing_value", ["Unknown", ""]) def test_unresolvable_glazing_produces_no_overlay(glazing_value: str) -> None: # Act @@ -57,6 +72,18 @@ def test_unresolvable_glazing_produces_no_overlay(glazing_value: str) -> None: assert simulation is None +def test_a_glazing_mix_produces_no_overlay_so_the_cert_is_kept() -> None: + # A MIXED aggregate resolves to no overlay: a whole-dwelling proportion cannot + # say which windows are which, so the cert's per-window `sap_windows` are kept + # rather than flattened to one type (ADR-0042 amendment, #1376). + + # Act + simulation = glazing_overlay_for(GlazingType.MIXED.value, 0) + + # Assert + assert simulation is None + + def test_glazing_override_remaps_every_window_and_clears_lodged_u() -> None: # Arrange — baseline windows are double glazed (code 2, lodged U 2.8); the # landlord corrects the whole dwelling to single glazing. @@ -74,14 +101,18 @@ def test_glazing_override_remaps_every_window_and_clears_lodged_u() -> None: assert all(w.window_transmission_details is None for w in result.sap_windows) +_NO_OVERLAY_SENTINELS = {GlazingType.UNKNOWN, GlazingType.MIXED} + + @pytest.mark.parametrize( - "member", [m for m in GlazingType if m is not GlazingType.UNKNOWN] + "member", [m for m in GlazingType if m not in _NO_OVERLAY_SENTINELS] ) def test_every_resolvable_glazing_value_decodes_to_a_code( member: GlazingType, ) -> None: # A classifier emits a GlazingType value; if the overlay can't decode it the - # override silently no-ops. Every non-UNKNOWN member must resolve. + # override silently no-ops. Every member except the deliberate no-overlay + # sentinels (UNKNOWN, MIXED) must resolve to a code. # Act simulation = glazing_overlay_for(member.value, 0) diff --git a/tests/domain/epc/test_main_fuel_guard.py b/tests/domain/epc/test_main_fuel_guard.py new file mode 100644 index 000000000..deac813ba --- /dev/null +++ b/tests/domain/epc/test_main_fuel_guard.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import pytest + +from domain.epc.property_overrides.main_fuel_guard import main_fuel_guard +from domain.epc.property_overrides.main_fuel_type import MainFuelType + + +def test_guard_resolves_individual_wood_logs() -> None: + # Arrange — the bug: with no individual wood-logs member, the LLM funnelled a + # solid-fuel wood-logs dwelling into "biomass (community)" (inventing a + # community heat network it isn't on). + description = "Solid Fuel: Wood Logs" + + # Act + result = main_fuel_guard(description) + + # Assert — an individual wood-logs fuel, never community biomass. + assert result is MainFuelType.WOOD_LOGS + + +@pytest.mark.parametrize( + "description", + [ + # Fuels the LLM already classifies correctly — the guard only rescues the + # wood-logs gap and leaves the rest to the fallback classifier. + "Gas: Mains Gas", + "Electricity: Electricity", + "Oil and Liquid Fuels: Oil", + # Dual fuel contains "wood" but not "wood log" — it has its own member (10) + # and must not be stolen by the wood-logs rule. + "Solid Fuel: Dual Fuel: Mineral and Wood", + # Genuine community biomass keeps its community member. + "Other Heat: Biomass (Community)", + "", + ], +) +def test_guard_defers_other_fuels_to_the_llm(description: str) -> None: + # Act + result = main_fuel_guard(description) + + # Assert + assert result is None diff --git a/tests/domain/epc/test_main_fuel_overlay.py b/tests/domain/epc/test_main_fuel_overlay.py index 7de2a5e50..87376acc6 100644 --- a/tests/domain/epc/test_main_fuel_overlay.py +++ b/tests/domain/epc/test_main_fuel_overlay.py @@ -56,6 +56,9 @@ def test_fuels_decode_to_their_modern_not_community_codes( ("biomass (community)", 31), ("dual fuel (mineral and wood)", 10), ("smokeless coal", 15), + # Wood logs — API fuel enum 6 (RdSAP Table 32 code 20). An individual + # solid-fuel dwelling, distinct from biomass (community) 31. + ("wood logs", 6), ], ) def test_more_fuels_decode_to_their_codes(main_fuel_value: str, code: int) -> None: @@ -80,14 +83,14 @@ def test_community_mains_gas_is_a_distinct_fuel_code() -> None: def test_mains_gas_fuel_sets_the_mains_gas_connection_flag() -> None: # A "mains gas" fuel means the dwelling has a mains-gas connection, so the - # overlay must set sap_energy_source.mains_gas too — not only the fuel code. - # Without it the effective EPC says "fuel = mains gas" yet mains_gas=False, + # overlay must set sap_energy_source.gas_connection_available too — not only the fuel code. + # Without it the effective EPC says "fuel = mains gas" yet gas_connection_available=False, # which suppresses the gas-boiler-upgrade path and wrongly offers HHRSH # storage (the off-gas path). (Property 728513.) simulation = fuel_overlay_for("mains gas", 0) assert simulation is not None assert simulation.heating is not None - assert simulation.heating.mains_gas is True + assert simulation.heating.gas_connection_available is True @pytest.mark.parametrize( @@ -104,7 +107,7 @@ def test_non_mains_gas_fuel_leaves_the_mains_gas_flag_unchanged( simulation = fuel_overlay_for(main_fuel_value, 0) assert simulation is not None assert simulation.heating is not None - assert simulation.heating.mains_gas is None + assert simulation.heating.gas_connection_available is None @pytest.mark.parametrize("main_fuel_value", ["Unknown", "", "no heating or hot water"]) @@ -141,4 +144,3 @@ def test_every_resolvable_fuel_value_decodes_to_a_code(member: MainFuelType) -> # Assert assert simulation is not None - diff --git a/tests/domain/epc/test_main_heating_guard.py b/tests/domain/epc/test_main_heating_guard.py new file mode 100644 index 000000000..cec0ca2df --- /dev/null +++ b/tests/domain/epc/test_main_heating_guard.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import pytest + +from domain.epc.property_overrides.main_heating_guard import main_heating_guard +from domain.epc.property_overrides.main_heating_system_type import ( + MainHeatingSystemType, +) + + +def test_guard_resolves_high_heat_retention_storage() -> None: + # Arrange — the structured phrasing the LLM funnelled into "old storage" (401). + description = "Electric Storage Systems: High heat retention storage heaters" + + # Act + result = main_heating_guard(description) + + # Assert — HHRSH has its own archetype (SAP 409), never old storage (ADR-0044). + assert result is MainHeatingSystemType.ELECTRIC_STORAGE_HIGH_HEAT_RETENTION + + +@pytest.mark.parametrize( + ("description", "expected"), + [ + # Electric underfloor dumped into Direct-acting / old storage (ADR-0046). + ( + "Electric Underfloor Heating: In concrete slab (off-peak only)", + MainHeatingSystemType.ELECTRIC_UNDERFLOOR_SLAB_OFF_PEAK, + ), + ( + "Electric Underfloor Heating: Integrated (storage+direct-acting) (off peak)", + MainHeatingSystemType.ELECTRIC_UNDERFLOOR_INTEGRATED, + ), + ( + "Electric Underfloor Heating: In screed above insulation (standard or off peak)", + MainHeatingSystemType.ELECTRIC_UNDERFLOOR_SCREED, + ), + ], +) +def test_guard_resolves_electric_underfloor( + description: str, expected: MainHeatingSystemType +) -> None: + # Act + result = main_heating_guard(description) + + # Assert + assert result is expected + + +@pytest.mark.parametrize( + ("description", "expected"), + [ + # The Gas CPSU dumping ground (ADR-0045): solid-fuel room heaters and + # electric ("NA" type) boilers the LLM sent to Gas CPSU (120, mains gas). + ( + "Solid fuel room heaters: Open fire in grate", + MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_OPEN_FIRE, + ), + ( + "Solid fuel room heaters: Open fire with back boiler (no radiators)", + MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_OPEN_FIRE_BACK_BOILER, + ), + ( + "Solid fuel room heaters: Closed room heater with boiler (no radiators)", + MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_CLOSED_WITH_BOILER, + ), + # "NA" is the boiler-type value meaning "not a gas combi/regular/CPSU" — + # i.e. an electric boiler. + ("Boiler: A rated NA", MainHeatingSystemType.ELECTRIC_BOILER), + ], +) +def test_guard_resolves_the_gas_cpsu_dumping_ground( + description: str, expected: MainHeatingSystemType +) -> None: + # Act + result = main_heating_guard(description) + + # Assert + assert result is expected + + +@pytest.mark.parametrize( + "description", + [ + # Other storage subtypes the LLM already classifies correctly — the guard + # only rescues the HHRSH gap, leaving the rest to the fallback classifier. + "Electric Storage Systems: Old (large volume) storage heaters", + "Electric Storage Systems: Modern (slimline) storage heaters", + "Electric Storage Systems: Fan storage heaters", + # Genuinely-gas boilers the LLM already classifies correctly (not "NA"). + "Boiler: A rated Combi", + "Boiler: C rated Regular Boiler", + # A plain closed room heater (no boiler) is the existing 633 archetype — + # the LLM keeps it; only "…with boiler" (634) is guarded. + "Solid fuel room heaters: Closed room heater (no boiler)", + # Unrelated / varied phrasings are the LLM's job. + "Gas boiler", + "", + ], +) +def test_guard_defers_non_hhrsh_descriptions_to_the_llm(description: str) -> None: + # Act + result = main_heating_guard(description) + + # Assert + assert result is None diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index e4bd544cc..5c1b680d2 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -6,17 +6,18 @@ calculator reads from the primary system; the overlay is whole-dwelling. from __future__ import annotations +import logging + import pytest from domain.epc.property_overrides.main_heating_system_type import MainHeatingSystemType from domain.epc.property_overlays.main_fuel_overlay import fuel_overlay_for from domain.epc.property_overlays.main_heating_system_overlay import ( + _ASSUMED_DUAL_METER_CODES, _MAIN_HEATING_CODES, + _METER_AGNOSTIC_CODES, main_heating_overlay_for, ) -from domain.sap10_calculator.tables.table_12a import ( - OFF_PEAK_IMPLYING_HEATING_CODES, -) from domain.epc.property_overlays.water_heating_overlay import ( water_heating_overlay_for, ) @@ -36,6 +37,91 @@ def test_gas_combi_overlays_the_primary_heating_code() -> None: assert simulation.heating.sap_main_heating_code == 104 +def test_electric_room_heaters_overlay_the_direct_acting_room_heater_code() -> None: + # Act — panel/convector/radiant direct-acting electric room heaters + simulation = main_heating_overlay_for("Electric room heaters", 0) + + # Assert — SAP Table 4a code 691, NOT convector storage (403). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 691 + + +def test_electric_room_heaters_assume_a_dual_economy7_meter() -> None: + # A dwelling on electric room heaters is all-electric and realistically + # billed on Economy 7 (its immersion hot water charges overnight; §12 Rule 3 + # gives the room heaters a 10-hour off-peak window). When the landlord names + # only the system, the coherent meter to assume is Dual — the §12 dispatch + # then applies the realistic high/low split, not a single-rate over-penalty. + simulation = main_heating_overlay_for("Electric room heaters", 0) + + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.meter_type == "Dual" + + +def test_electric_room_heaters_overlay_the_room_heater_category() -> None: + # A landlord "Electric room heaters" override replaces the lodged system, so + # the overlay must stamp the room-heater heating category (SAP Table 4a + # Category 10). Leaving it unset lets a replaced storage heater's category 7 + # survive onto the effective cert, which the SAP 10.2 Table 12a resolver + # (keyed on category) mis-reads as off-peak storage and bills the peaky room + # heaters at the all-night low rate. + # Act + simulation = main_heating_overlay_for("Electric room heaters", 0) + + # Assert — SAP Table 4a Category 10 (Room heaters). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_heating_category == 10 + + +def test_electric_room_heaters_overlay_the_room_heater_charge_control() -> None: + # A system-replacing override must stamp its own control, not inherit the + # replaced system's. Electric room heaters take the Table 4e Group 6 + # room-heater control 2601 (+0.3 C, the conservative no-thermostat default, + # matching the gas/oil/solid room heaters). Leaving it unset lets a replaced + # storage heater's manual charge control 2401 (+0.7 C) survive and over-warm + # the modelled dwelling. + # Act + simulation = main_heating_overlay_for("Electric room heaters", 0) + + # Assert — SAP Table 4e Group 6 room-heater control. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_heating_control == 2601 + + +def test_overlay_logs_when_it_cannot_complete_a_companion_set( + caplog: pytest.LogCaptureFixture, +) -> None: + # An archetype the overlay cannot yet fully companion — here an air-source + # heat pump, whose Table 4e Group 2 conservative control is not yet mapped — + # is still overlaid (log-and-continue), but the incomplete companion set is + # logged as an error so the known gap is visible (CloudWatch) rather than + # silently inheriting the replaced system's stale value. + # Act + with caplog.at_level(logging.ERROR): + main_heating_overlay_for("Air source heat pump", 0) + + # Assert — the gap is reported and names the archetype. + assert "Air source heat pump" in caplog.text + + +def test_complete_room_heater_overlay_does_not_log_a_companion_gap( + caplog: pytest.LogCaptureFixture, +) -> None: + # Electric room heaters now resolve a full companion set (category 10 + + # control 2601), so the overlay must NOT log an incomplete-companion gap — + # guarding that the fix keeps the set complete. + # Act + with caplog.at_level(logging.ERROR): + main_heating_overlay_for("Electric room heaters", 0) + + # Assert + assert "incomplete companion set" not in caplog.text + + @pytest.mark.parametrize( ("main_heating_value", "code"), [ @@ -57,6 +143,183 @@ def test_heating_archetypes_decode_to_their_sap_codes( assert simulation.heating.sap_main_heating_code == code +@pytest.mark.parametrize( + ("main_heating_value", "code"), + [ + # Solid-fuel room heaters the LLM funnelled into "Gas CPSU" (120, mains + # gas). SAP Table 4a: 631 open fire, 632 open fire + back boiler, 634 + # closed room heater + boiler (ADR-0045). + ("Solid fuel room heater, open fire", 631), + ("Solid fuel room heater, open fire with back boiler", 632), + ("Solid fuel room heater, closed with boiler", 634), + ], +) +def test_solid_fuel_room_heaters_decode_off_gas_cpsu( + main_heating_value: str, code: int +) -> None: + # Act + simulation = main_heating_overlay_for(main_heating_value, 0) + + # Assert — its own solid-fuel code, not mains-gas CPSU (120). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == code + + +def test_electric_boiler_decodes_to_191_and_drags_electricity() -> None: + # An "A rated NA" boiler (all-electric per its main_fuel override) was funnelled + # into Gas CPSU (120, mains gas). It is a direct-acting electric boiler (SAP 191) + # and unambiguously electric, so the archetype drags electricity (29) — never + # leaving a mains-gas fuel to bleed through (ADR-0045). + simulation = main_heating_overlay_for("Electric boiler", 0) + + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 191 + assert simulation.heating.main_fuel_type == 29 + + +def test_electric_cpsu_decodes_to_192_electric_on_an_off_peak_meter() -> None: + # An electric "Boiler: A rated CPSU" was funnelled into mains-gas Gas CPSU + # (120). Electric CPSU is SAP Table 4a 192, electric (29), charging overnight on + # a Dual off-peak meter (§12 Rule 1, 10-hour) — ADR-0045. + simulation = main_heating_overlay_for("Electric CPSU", 0) + + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 192 + assert simulation.heating.main_fuel_type == 29 + assert simulation.heating.meter_type == "Dual" + + +@pytest.mark.parametrize( + ("main_heating_value", "code"), + [ + # Electric underfloor funnelled into Direct-acting (191) / old storage (401) + # for want of the dedicated SAP Table 4a underfloor codes (ADR-0046). + ("Electric underfloor, in concrete slab (off-peak)", 421), + ("Electric underfloor, integrated storage and direct-acting", 422), + ("Electric underfloor, in screed above insulation", 424), + ], +) +def test_electric_underfloor_decodes_to_its_own_code_dragging_electricity( + main_heating_value: str, code: int +) -> None: + # Act + simulation = main_heating_overlay_for(main_heating_value, 0) + + # Assert — its own underfloor code, electric (29). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == code + assert simulation.heating.main_fuel_type == 29 + + +@pytest.mark.parametrize( + "main_heating_value", + [ + # "off-peak only" / "off peak" positively claim off-peak. + "Electric underfloor, in concrete slab (off-peak)", + "Electric underfloor, integrated storage and direct-acting", + ], +) +def test_off_peak_underfloor_asserts_a_dual_meter(main_heating_value: str) -> None: + # Act + simulation = main_heating_overlay_for(main_heating_value, 0) + + # Assert — an off-peak underfloor must not bill overnight heat at the peak rate. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.meter_type == "Dual" + + +@pytest.mark.parametrize( + "main_heating_value", + [ + "Electric underfloor, in concrete slab (off-peak)", + "Electric underfloor, integrated storage and direct-acting", + "Electric underfloor, in screed above insulation", + ], +) +def test_electric_underfloor_overlays_the_underfloor_category( + main_heating_value: str, +) -> None: + # A system-replacing override must stamp the underfloor heating category + # (SAP Table 4a Category 8). Leaving it unset lets the replaced system's + # category survive onto the effective cert — property 711795's community + # heating (category 6) survived an underfloor override, so the heating + # generator read the dwelling as a heat network and offered no heating + # upgrade at all, and the baseline mis-rated 51 vs 67 (ADR-0050). + # Act + simulation = main_heating_overlay_for(main_heating_value, 0) + + # Assert — SAP Table 4a Category 8 (Electric underfloor heating). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_heating_category == 8 + + +@pytest.mark.parametrize( + "main_heating_value", + [ + "Electric underfloor, in concrete slab (off-peak)", + "Electric underfloor, integrated storage and direct-acting", + "Electric underfloor, in screed above insulation", + ], +) +def test_electric_underfloor_drags_the_conservative_group_7_control( + main_heating_value: str, +) -> None: + # The landlord names the system, not its control, so the overlay assumes the + # conservative Table 4e Group 7 default: 2701 ("no time or thermostatic + # control", +0.3 C — the largest adjustment in the group, so an unobserved + # control is never over-credited). Mirrors storage-manual 2401 / room-heater + # 2601; also overwrites a stale control inherited from the replaced system + # (711795 kept a community control 2311) — ADR-0050. + # Act + simulation = main_heating_overlay_for(main_heating_value, 0) + + # Assert — SAP Table 4e Group 7 conservative underfloor control. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_heating_control == 2701 + + +@pytest.mark.parametrize( + "main_heating_value", + [ + "Electric underfloor, in concrete slab (off-peak)", + "Electric underfloor, integrated storage and direct-acting", + "Electric underfloor, in screed above insulation", + ], +) +def test_complete_underfloor_overlay_does_not_log_a_companion_gap( + main_heating_value: str, caplog: pytest.LogCaptureFixture +) -> None: + # Electric underfloor now resolves a full companion set (category 8 + + # control 2701, ADR-0050), so the overlay must NOT log an + # incomplete-companion gap — guarding that the set stays complete. + # Act + with caplog.at_level(logging.ERROR): + main_heating_overlay_for(main_heating_value, 0) + + # Assert + assert "incomplete companion set" not in caplog.text + + +def test_screed_underfloor_defers_the_meter_to_the_cert() -> None: + # "In screed above insulation (standard or off peak)" is tariff-ambiguous (81% + # of the corpus lodge off-peak), so the archetype cannot know the meter — it + # leaves meter_type unset so the cert's lodged meter stands (ADR-0046). + simulation = main_heating_overlay_for( + "Electric underfloor, in screed above insulation", 0 + ) + + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.meter_type is None + + @pytest.mark.parametrize( ("main_heating_value", "code"), [ @@ -77,6 +340,38 @@ def test_storage_heater_subtypes_decode_to_their_codes( assert simulation.heating.sap_main_heating_code == code +def test_high_heat_retention_storage_decodes_to_its_own_code_not_old_storage() -> None: + # Act + simulation = main_heating_overlay_for( + "Electric storage heaters, high heat retention", 0 + ) + + # Assert — HHRSH is SAP Table 4a code 409, NOT old large-volume storage (401) + # the classifier funnelled it into (ADR-0044). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 409 + + +def test_high_heat_retention_storage_drags_its_intrinsic_companions() -> None: + # HHRSH has a single intrinsic charge control (Table 4e 2404, 0 C adjustment), + # so — unlike a generic storage heater whose control is unobserved — it must + # NOT drag the conservative manual default (2401, +0.7 C penalty), which would + # under-credit it. It is electric, on an off-peak (Dual) meter (ADR-0044). + simulation = main_heating_overlay_for( + "Electric storage heaters, high heat retention", 0 + ) + + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_heating_control == 2404 + assert simulation.heating.main_fuel_type == 29 + assert simulation.heating.meter_type == "Dual" + # Storage category (7) — consistent with storage code 409, so the SAP 10.2 + # Table 12a resolver prices the off-peak storage heating off-peak, not peak. + assert simulation.heating.main_heating_category == 7 + + @pytest.mark.parametrize( "main_heating_value", [ @@ -134,6 +429,9 @@ def test_storage_heaters_drag_along_conservative_manual_charge_control( assert simulation is not None assert simulation.heating is not None assert simulation.heating.main_heating_control == 2401 + # ...and the storage category (7), consistent with the storage code, so the + # SAP 10.2 Table 12a resolver prices these off-peak, not at the peak rate. + assert simulation.heating.main_heating_category == 7 def test_direct_acting_electric_does_not_drag_along_a_control() -> None: @@ -175,7 +473,7 @@ def test_gas_boiler_drags_a_coherent_mains_gas_system(main_heating_value: str) - assert simulation is not None assert simulation.heating is not None heating = simulation.heating - assert heating.mains_gas is True + assert heating.gas_connection_available is True assert heating.main_fuel_type == 26 assert heating.main_heating_category == 2 assert heating.fan_flue_present is True @@ -214,23 +512,31 @@ def test_off_peak_archetypes_drag_dual_others_drag_single() -> None: # the calculator's single off-peak classification, so any archetype whose # code implies off-peak MUST synthesise a Dual meter and every other code # MUST synthesise a Single meter — a system switch can never silently leave - # the previous system's meter in place. + # the previous system's meter in place. The one exception is a meter-agnostic + # code (screed underfloor, tariff-ambiguous), which defers to the cert's + # lodged meter (None) rather than assert one (ADR-0046). for value, code in _MAIN_HEATING_CODES.items(): simulation = main_heating_overlay_for(value, 0) assert simulation is not None and simulation.heating is not None - expected = "Dual" if code in OFF_PEAK_IMPLYING_HEATING_CODES else "Single" + if code in _METER_AGNOSTIC_CODES: + expected = None + elif code in _ASSUMED_DUAL_METER_CODES: + expected = "Dual" + else: + expected = "Single" assert simulation.heating.meter_type == expected, value @pytest.mark.parametrize( "main_heating_value", - ["Unknown", "", "Air source heat pump", "Community heating"], + ["Unknown", "", "Community heating"], ) def test_unresolvable_or_unmodelled_heating_produces_no_overlay( main_heating_value: str, ) -> None: - # Heat pumps (main_heating_index_number) and community heating (community - # codes) don't map to a Table 4b sap_main_heating_code yet — no overlay. + # Genuinely unrecognised values (Unknown / empty) and not-yet-modelled + # community heating produce no overlay — the lodged EPC heating stands + # (ADR-0041). Air source heat pumps ARE now modelled (Table 4a 211). # Act simulation = main_heating_overlay_for(main_heating_value, 0) @@ -278,7 +584,7 @@ def test_gas_boiler_override_onto_a_storage_baseline_leaves_no_stale_fields() -> assert main.main_fuel_type == 26 assert main.main_heating_category == 2 assert main.main_heating_control == 2106 - assert result.sap_energy_source.mains_gas is True + assert result.sap_energy_source.gas_connection_available is True assert result.sap_energy_source.meter_type == "Single" assert result.has_hot_water_cylinder is False assert result.sap_heating.water_heating_code == 901 @@ -308,6 +614,48 @@ def test_the_three_heating_overrides_compose_without_conflict() -> None: assert result.sap_heating.water_heating_fuel == 29 +def test_room_heaters_preserve_an_existing_more_off_peak_cert_meter() -> None: + # The overlay's assumed Dual (7-hour E7) meter is a coherent *default* for a + # single/unknown-meter dwelling — it must NOT downgrade a cert that already + # lodges a more-off-peak meter (here a 24-hour all-low tariff, code "4"). + # Clobbering it to E7 would bill the heating on a high/low split it doesn't + # have, under-rating the dwelling. + baseline = build_epc() + baseline.sap_energy_source.meter_type = "4" # 24-hour tariff + overlay = main_heating_overlay_for("Electric room heaters", 0) + assert overlay is not None + + result = apply_simulations(baseline, [overlay]) + + assert result.sap_energy_source.meter_type == "4" + + +def test_room_heaters_set_dual_when_the_cert_meter_is_single() -> None: + # The flip side: a single-rate dwelling DOES get the assumed Dual meter — + # off-peak heating can't be billed on a single-rate meter (ADR-0035 drag). + baseline = build_epc() + baseline.sap_energy_source.meter_type = "Single" + overlay = main_heating_overlay_for("Electric room heaters", 0) + assert overlay is not None + + result = apply_simulations(baseline, [overlay]) + + assert result.sap_energy_source.meter_type == "Dual" + + +def test_electric_room_heaters_member_decodes_to_the_room_heater_code() -> None: + # Arrange — the canonical landlord archetype for direct-acting room heaters + member = MainHeatingSystemType.ELECTRIC_ROOM_HEATERS + + # Act + simulation = main_heating_overlay_for(member.value, 0) + + # Assert — member value stays in lock-step with the overlay (code 691) + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 691 + + @pytest.mark.parametrize( "member", [m for m in MainHeatingSystemType if m is not MainHeatingSystemType.UNKNOWN], @@ -320,3 +668,240 @@ def test_every_resolvable_main_heating_value_decodes( # Assert assert simulation is not None + + +def test_solid_fuel_room_heater_decodes_to_the_closed_room_heater_code() -> None: + # A landlord-named solid-fuel room heater (e.g. a closed stove) is a + # recognised archetype, not a gas wet system — it must decode to its SAP + # Table 4a code (633, closed solid-fuel room heater), not overflow into the + # nearest wrong archetype the way the under-populated taxonomy did, sending + # "solid fuel room heaters: closed room heater" to Gas CPSU (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Solid fuel room heater, closed", 0) + + # Assert — SAP Table 4a code 633 (closed solid-fuel room heater). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 633 + + +def test_solid_fuel_room_heater_drags_its_coherent_room_heater_companions() -> None: + # The landlord names the system, not its category/control/meter. A solid-fuel + # room heater is a room-heater system (Table 4a category 10) on a single-rate + # meter (not off-peak storage), under the conservative room-heater control + # real certs lodge (Table 4e Group 6 code 2601 — "no thermostatic control", + # the lowest-SAP room-heater control, so it never over-credits an unobserved + # control, mirroring the storage manual-charge default). ADR-0035. + + # Act + simulation = main_heating_overlay_for("Solid fuel room heater, closed", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 10 + assert heating.main_heating_control == 2601 + assert heating.meter_type == "Single" + + +def test_electric_room_heaters_drag_their_natural_electricity_fuel() -> None: + # A landlord names the system; an electric room heater's natural fuel is + # unambiguously electricity (RdSAP main_fuel code 29). The overlay drags it so + # a system-only override is self-coherent even on a cert that lodged a + # different fuel — a later `main_fuel` override still wins (last-wins + # composition), and a contradicting fuel is logged, not silently kept + # (ADR-0041 natural-fuel coherence). + + # Act + simulation = main_heating_overlay_for("Electric room heaters", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_fuel_type == 29 + + +def test_solid_fuel_room_heater_defaults_to_house_coal_as_its_natural_fuel() -> None: + # Solid fuel is fuel-ambiguous (coal / anthracite / smokeless / dual fuel / + # wood logs / pellets), but the system must still self-cohere when no + # main_fuel override is given. Default to house coal (RdSAP main_fuel code + # 33), the most common solid fuel; a main_fuel override naming a specific + # solid fuel still wins by last-wins composition (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Solid fuel room heater, closed", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_fuel_type == 33 + + +def test_air_source_heat_pump_decodes_to_the_default_ashp_code() -> None: + # A landlord names "air source heat pump" without a PCDB model. It is + # modellable via the SAP Table 4a default ASHP code (211, "flow temp in other + # cases", default SPF 2.30) — no main_heating_index_number needed — so it must + # decode to 211, not produce no overlay / fall to Unknown (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Air source heat pump", 0) + + # Assert — SAP Table 4a code 211 (default air-source heat pump). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 211 + + +def test_air_source_heat_pump_drags_its_coherent_companions() -> None: + # A heat pump is unambiguously electric (natural fuel 29) and SAP Table 4a + # category 4. Unlike storage/CPSU it does NOT imply an off-peak tariff (SAP + # §12 Rule 3 is conditional, not forcing), so the coherent default meter is + # single-rate — forcing Dual would assume an off-peak split it may not have + # and mis-bill it. The overlay drags these code-derived companions so a + # system-only override is self-coherent (ADR-0035 / ADR-0041). + + # Act + simulation = main_heating_overlay_for("Air source heat pump", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 4 + assert heating.main_fuel_type == 29 + assert heating.meter_type == "Single" + + +def test_community_boilers_decode_to_the_heat_network_boiler_code() -> None: + # A community/heat-network scheme driven by boilers is SAP Table 4a code 301 + # (the calculator derives the heat-source efficiency + DLF from it). It must + # decode to 301, not the Gas CPSU (120) the under-populated taxonomy forced — + # a single-dwelling gas wet boiler is the wrong picture for a heat network + # (ADR-0041). A generic "Community heating" with no named source stays None. + + # Act + simulation = main_heating_overlay_for("Community heating, boilers", 0) + + # Assert — SAP Table 4a code 301 (boiler-driven community heating). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 301 + + +def test_community_boilers_drag_heat_network_category_and_a_community_gas_fuel() -> ( + None +): + # A community boiler scheme is SAP main_heating_category 6 (heat network), so + # the calculator treats it as a heat network (cert_to_inputs `_is_heat_network` + # checks code OR category 6). Its natural fuel is mains gas (community) — RdSAP + # main_fuel code 20, the dominant fuel real community-boiler certs lodge. A + # specific main_fuel override (e.g. biomass community) still wins (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Community heating, boilers", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 6 + assert heating.main_fuel_type == 20 + + +def test_community_chp_and_boilers_decode_to_their_heat_network_code() -> None: + # A community scheme with CHP + boilers is SAP Table 4a code 302, still a heat + # network (category 6) on community mains gas (20). Real audit data: 10 + # properties carry this, today mis-bucketed to Gas CPSU (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Community heating, CHP and boilers", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.sap_main_heating_code == 302 + assert heating.main_heating_category == 6 + assert heating.main_fuel_type == 20 + + +def test_oil_room_heater_decodes_to_the_post_2000_code() -> None: + # A modern standalone oil room heater is SAP Table 4a code 623 ("Oil room + # heater, 2000 or later", no boiler). It must decode to 623, not the Gas CPSU + # the under-populated taxonomy forced (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Oil room heater, 2000 or later", 0) + + # Assert — SAP Table 4a code 623. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 623 + + +def test_oil_room_heater_drags_its_coherent_room_heater_companions() -> None: + # An oil room heater is a room-heater system (category 10) on a single-rate + # meter, under the conservative room-heater control (2601), with its natural + # fuel heating oil (RdSAP main_fuel 28). The overlay drags these so a + # system-only override is self-coherent (ADR-0035 / ADR-0041). + + # Act + simulation = main_heating_overlay_for("Oil room heater, 2000 or later", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 10 + assert heating.main_heating_control == 2601 + assert heating.main_fuel_type == 28 + assert heating.meter_type == "Single" + + +@pytest.mark.parametrize( + ("value", "code"), + [ + ("Gas room heater, condensing fire", 611), + ("Gas room heater, decorative fuel-effect", 612), + ("Gas room heater, flush live-effect", 605), + ("Gas room heater, open flue 1980 or later", 603), + ("Gas room heater, open flue pre-1980", 601), + ], +) +def test_gas_room_heater_variants_decode_to_their_sap_codes( + value: str, code: int +) -> None: + # Gas room heaters span a wide efficiency range (decorative 0.20 -> condensing + # 0.85), so each catalogue variant maps to its own SAP Table 4a code rather + # than collapsing to one representative — which would over-credit a decorative + # fire and under-credit a condensing one. Today they mis-bucket to "Gas + # boiler, regular" (ADR-0041). + + # Act + simulation = main_heating_overlay_for(value, 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == code + + +def test_gas_room_heater_drags_its_coherent_companions() -> None: + # A gas room heater is a room-heater system (category 10) on a single-rate + # meter, under the conservative room-heater control (2601). Its natural fuel + # is mains gas (26) — an LPG dwelling is refined by a main_fuel override + # (the overlay can't see the mains connection) (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Gas room heater, condensing fire", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 10 + assert heating.main_heating_control == 2601 + assert heating.main_fuel_type == 26 + assert heating.meter_type == "Single" diff --git a/tests/domain/epc/test_property_type_guard.py b/tests/domain/epc/test_property_type_guard.py new file mode 100644 index 000000000..c1f0b9969 --- /dev/null +++ b/tests/domain/epc/test_property_type_guard.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import pytest + +from domain.epc.property_overrides.property_type import PropertyType +from domain.epc.property_overrides.property_type_guard import property_type_guard + + +@pytest.mark.parametrize( + ("description", "expected"), + [ + # The bug: the LLM read the "EndTerrace/MidTerrace" tail as house-like and + # mislabelled a handful of bungalows as House. The dwelling type is the + # leading token, independent of the built-form tail. + ("Bungalow: EndTerrace", PropertyType.BUNGALOW), + ("Bungalow: MidTerrace", PropertyType.BUNGALOW), + ("House: SemiDetached", PropertyType.HOUSE), + ("Flat: Mid Terrace: Mid Floor", PropertyType.FLAT), + ("Maisonette: Mid Terrace: Top Floor", PropertyType.MAISONETTE), + # Bare type with no built-form tail. + ("Flat", PropertyType.FLAT), + ("Maisonette", PropertyType.MAISONETTE), + ], +) +def test_guard_resolves_the_leading_dwelling_type( + description: str, expected: PropertyType +) -> None: + # Act + result = property_type_guard(description) + + # Assert + assert result is expected + + +@pytest.mark.parametrize( + "description", + [ + # No recognised leading dwelling type — left to the LLM classifier. + "Studio apartment", + "Converted barn", + "", + ], +) +def test_guard_defers_unrecognised_descriptions_to_the_llm(description: str) -> None: + # Act + result = property_type_guard(description) + + # Assert + assert result is None diff --git a/tests/domain/epc/test_roof_party_ceiling_guard.py b/tests/domain/epc/test_roof_party_ceiling_guard.py new file mode 100644 index 000000000..8ac91add4 --- /dev/null +++ b/tests/domain/epc/test_roof_party_ceiling_guard.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import pytest + +from domain.epc.property_overrides.roof_type import RoofType +from domain.epc.property_overrides.roof_party_ceiling_guard import ( + roof_party_ceiling_guard, +) + + +def test_party_ceiling_marker_with_a_trailing_depth_resolves_to_the_party_ceiling_member() -> None: + # Arrange — the bug: a party ceiling ("another dwelling above") carrying a + # trailing loft depth the LLM misreads as an external pitched roof. + description = "another dwelling above: 100mm" + + # Act + result = roof_party_ceiling_guard(description) + + # Assert — it is a party ceiling (~0 heat loss), not a Pitched roof. + assert result is RoofType.ADJACENT_ANOTHER_DWELLING_ABOVE + + +@pytest.mark.parametrize( + ("description", "expected"), + [ + ("same dwelling above: unknown", RoofType.ADJACENT_SAME_DWELLING_ABOVE), + ("other premises above", RoofType.ADJACENT_OTHER_PREMISES_ABOVE), + ("another premises above: 150mm", RoofType.ADJACENT_ANOTHER_PREMISES_ABOVE), + # the unspaced source form and the "Another Premises Above" member value + # both normalise to the same marker. + ("Another Premises Above", RoofType.ADJACENT_ANOTHER_PREMISES_ABOVE), + ("samedwellingabove: 300mm", RoofType.ADJACENT_SAME_DWELLING_ABOVE), + ], +) +def test_every_party_ceiling_marker_variant_resolves_to_its_member( + description: str, expected: RoofType +) -> None: + # Act + result = roof_party_ceiling_guard(description) + + # Assert + assert result is expected + + +@pytest.mark.parametrize( + "description", + [ + "pitched, normal loft access: 100mm", + "flat: 150mm", + "pitched with sloping ceiling: unknown", + "roof room(s), insulated", + ], +) +def test_a_genuine_roof_description_is_left_to_the_llm(description: str) -> None: + # A non-party-ceiling roof must return None so the LLM classifier still + # resolves it — the guard only claims the markers it is certain about. + + # Act + result = roof_party_ceiling_guard(description) + + # Assert + assert result is None diff --git a/tests/domain/epc/test_roof_type_overlay.py b/tests/domain/epc/test_roof_type_overlay.py index 050d76fa2..1c39ec9f7 100644 --- a/tests/domain/epc/test_roof_type_overlay.py +++ b/tests/domain/epc/test_roof_type_overlay.py @@ -11,6 +11,26 @@ 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 + +_FLAT_THICKNESS_MEMBERS: list[tuple[RoofType, int]] = [ + (RoofType.FLAT_12MM, 12), + (RoofType.FLAT_25MM, 25), + (RoofType.FLAT_50MM, 50), + (RoofType.FLAT_75MM, 75), + (RoofType.FLAT_100MM, 100), + (RoofType.FLAT_125MM, 125), + (RoofType.FLAT_150MM, 150), + (RoofType.FLAT_175MM, 175), + (RoofType.FLAT_200MM, 200), + (RoofType.FLAT_225MM, 225), + (RoofType.FLAT_250MM, 250), + (RoofType.FLAT_270MM, 270), + (RoofType.FLAT_300MM, 300), + (RoofType.FLAT_350MM, 350), + (RoofType.FLAT_400MM, 400), + (RoofType.FLAT_400_PLUS, 400), +] def test_pitched_loft_depth_maps_to_roof_insulation_thickness() -> None: @@ -42,6 +62,38 @@ def test_each_loft_depth_is_parsed(roof_type_value: str, expected_mm: int) -> No ].roof_insulation_thickness == expected_mm +def test_flat_roof_depth_maps_to_roof_insulation_thickness() -> None: + # Act — a flat roof with a known depth. RdSAP 10 Table 16 col (1) scores flat + # roofs by insulation thickness ("joists at ceiling level and flat roof"), so + # the depth must reach the overlay, not be discarded for the age-band default + # (ADR-0041). + simulation = roof_overlay_for("Flat, 150 mm insulation", 0) + + # Assert — the flat shape and the real depth both ride on the overlay. + assert simulation is not None + overlay = simulation.building_parts[BuildingPartIdentifier.MAIN] + assert overlay.roof_construction_type == "Flat" + assert overlay.roof_insulation_thickness == 150 + + +@pytest.mark.parametrize(("member", "expected_mm"), _FLAT_THICKNESS_MEMBERS) +def test_every_flat_thickness_member_resolves_to_its_depth( + member: RoofType, expected_mm: int +) -> None: + # Each FLAT_*MM taxonomy member must stay lock-step with the overlay: its value + # resolves to the depth it names (so a landlord-stored member scores via Table + # 16 col (1), not the age-band default). ADR-0041. + + # 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 == "Flat" + assert overlay.roof_insulation_thickness == expected_mm + + @pytest.mark.parametrize( "roof_type_value", [ diff --git a/tests/domain/epc/test_wall_type_overlay.py b/tests/domain/epc/test_wall_type_overlay.py index 5b1cc9a82..36015d6fd 100644 --- a/tests/domain/epc/test_wall_type_overlay.py +++ b/tests/domain/epc/test_wall_type_overlay.py @@ -25,6 +25,30 @@ def test_solid_brick_with_internal_insulation_overlays_main_wall() -> None: assert overlay.wall_insulation_type == 3 +def test_system_built_override_is_not_mis_read_as_a_basement() -> None: + # A System-built wall is RdSAP code 6, which collides with the gov-EPC + # code-6 basement sentinel. The overlay must set wall_is_basement=False so + # main_wall_is_basement doesn't fire the code-6 heuristic (phantom basement). + simulation = wall_overlay_for( + "System built, as built, no insulation (assumed)", 0 + ) + + assert simulation is not None + overlay = simulation.building_parts[BuildingPartIdentifier.MAIN] + assert overlay.wall_construction == 6 + assert overlay.wall_is_basement is False + + +def test_non_system_built_override_leaves_basement_flag_untouched() -> None: + # Cavity (code 4) can't collide with the basement sentinel, so the overlay + # must not assert a basement verdict either way — leave the flag None. + simulation = wall_overlay_for("Cavity wall, with internal insulation", 0) + + assert simulation is not None + overlay = simulation.building_parts[BuildingPartIdentifier.MAIN] + assert overlay.wall_is_basement is None + + @pytest.mark.parametrize( ("wall_type_value", "construction", "insulation"), [ diff --git a/tests/domain/epc/test_water_heating_guard.py b/tests/domain/epc/test_water_heating_guard.py new file mode 100644 index 000000000..227ed3f4e --- /dev/null +++ b/tests/domain/epc/test_water_heating_guard.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import pytest + +from domain.epc.property_overrides.water_heating_guard import water_heating_guard +from domain.epc.property_overrides.water_heating_type import WaterHeatingType + + +@pytest.mark.parametrize( + ("description", "expected"), + [ + # The biomass/wood family the LLM funnelled into "house coal" (#1376). + ( + "From main heating system: Wood Logs", + WaterHeatingType.FROM_MAIN_WOOD_LOGS, + ), + ( + "From main heating system: Dual Fuel: Mineral and Wood", + WaterHeatingType.FROM_MAIN_DUAL_FUEL_MINERAL_WOOD, + ), + # "Hot-water only community scheme" flattens to "from main", per the + # existing convention (community mains-gas already resolves that way). + ( + "From main heating system: Biomass (Community)", + WaterHeatingType.FROM_MAIN_BIOMASS_COMMUNITY, + ), + ( + "Hot-water only community scheme - boilers: Biomass (Community)", + WaterHeatingType.FROM_MAIN_BIOMASS_COMMUNITY, + ), + ( + "Hot-water only community scheme - boilers: Heat from Boilers " + "using Biodiesel from any source (Community)", + WaterHeatingType.FROM_MAIN_BIODIESEL_COMMUNITY, + ), + # "No Heating or Hot Water" under "electric immersion assumed" is the + # assessment's own immersion assumption — honour it (ADR-0043). + ( + "No hot water system present - electric immersion assumed: " + "No Heating or Hot Water", + WaterHeatingType.ELECTRIC_IMMERSION, + ), + # A genuinely coal DHW stays house coal. + ( + "From main heating system: House Coal", + WaterHeatingType.FROM_MAIN_HOUSE_COAL, + ), + ], +) +def test_guard_resolves_the_structured_biomass_and_immersion_descriptions( + description: str, expected: WaterHeatingType +) -> None: + # Act + result = water_heating_guard(description) + + # Assert + assert result is expected + + +@pytest.mark.parametrize( + "description", + [ + # Fuels the LLM already classifies correctly — the guard must not claim + # them (it only rescues the previously-misclassified structured cases). + "From main heating system: Mains Gas", + "Electric immersion: Electricity", + "From main heating system: Oil", + # A varied / unstructured phrasing is the LLM's job. + "hot water from the wood burner", + "", + ], +) +def test_guard_defers_recognised_and_unstructured_descriptions_to_the_llm( + description: str, +) -> None: + # Act + result = water_heating_guard(description) + + # Assert + assert result is None diff --git a/tests/domain/epc/test_water_heating_overlay.py b/tests/domain/epc/test_water_heating_overlay.py index a1955aa73..f71ddcee4 100644 --- a/tests/domain/epc/test_water_heating_overlay.py +++ b/tests/domain/epc/test_water_heating_overlay.py @@ -73,6 +73,46 @@ def test_more_water_heating_combos_decode_to_their_codes( assert simulation.heating.water_heating_fuel == fuel +def test_from_main_wood_logs_decodes_to_the_wood_fuel_not_house_coal() -> None: + # Act + simulation = water_heating_overlay_for("From main system, wood logs", 0) + + # Assert — wood logs is RdSAP water_heating_fuel 6, from main system (901). + # A wood-fired dwelling's DHW must not fall to house coal (33), which scores + # ~14x the carbon (ADR-0043). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.water_heating_code == 901 + assert simulation.heating.water_heating_fuel == 6 + + +@pytest.mark.parametrize( + ("water_heating_value", "fuel"), + [ + # Biomass (community) — RdSAP fuel 31. Under a coherent community main + # the calculator scores DHW from the main, so this is a faithful, + # score-neutral relabel off house coal; the fuel is still emitted so an + # individual-main dwelling would score biomass, not coal (ADR-0043). + ("From main system, biomass (community)", 31), + # Dual fuel (mineral + wood) — RdSAP fuel 9. + ("From main system, dual fuel (mineral and wood)", 9), + # Biodiesel (community) — RdSAP fuel 34. + ("From main system, biodiesel (community)", 34), + ], +) +def test_biomass_family_water_heating_decodes_off_house_coal( + water_heating_value: str, fuel: int +) -> None: + # Act + simulation = water_heating_overlay_for(water_heating_value, 0) + + # Assert — from main system (901), each to its own RdSAP fuel, not coal 33. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.water_heating_code == 901 + assert simulation.heating.water_heating_fuel == fuel + + @pytest.mark.parametrize("water_heating_value", ["Unknown", ""]) def test_unresolvable_water_heating_produces_no_overlay( water_heating_value: str, diff --git a/tests/domain/epc_prediction/test_component_accuracy_gate.py b/tests/domain/epc_prediction/test_component_accuracy_gate.py index cc262c17b..ce7105ef6 100644 --- a/tests/domain/epc_prediction/test_component_accuracy_gate.py +++ b/tests/domain/epc_prediction/test_component_accuracy_gate.py @@ -56,6 +56,15 @@ _FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "epc_prediction" # new-build-vs-old-stock service mismatch on 1-2 targets each (heating_main_fuel # 0.9722->0.9394, water_heating_fuel ->0.9495, cylinder_insulation_type 0.6667-> # 0.3333) plus floor_area (+0.31 MAE). Tighten-only resumes from these values. +# +# has_pv re-baselined 0.9798->0.9697 when full-SAP lodged PV mapping landed +# (datatypes/epc/domain/mapper.py `_sap_17_1_pv_arrays`): full-SAP certs lodge +# their measured array under `sap_energy_source.pv_arrays`, which the schema +# dropped at parse, so the leave-one-out scorer's *actual* has_pv read False for +# every full-SAP PV dwelling. Carrying the array now reads the true has_pv=True, +# and one full-SAP target the similarity-weighted donors don't predict as PV +# tips the agreement 32/33 (the held-out actual is now correct — a ground-truth- +# method change, not a prediction-logic loosening). Tighten-only resumes here. _RATE_FLOORS: dict[str, float] = { "wall_construction": 0.9091, "wall_insulation_type": 0.8687, @@ -76,7 +85,7 @@ _RATE_FLOORS: dict[str, float] = { "floor_insulation": 0.9375, "has_room_in_roof": 0.9495, "modal_glazing_type": 0.8384, - "has_pv": 0.9798, + "has_pv": 0.9697, "solar_water_heating": 1.0000, } diff --git a/tests/domain/magicplan/test_mapper.py b/tests/domain/magicplan/test_mapper.py index 419a66fc8..b429df70f 100644 --- a/tests/domain/magicplan/test_mapper.py +++ b/tests/domain/magicplan/test_mapper.py @@ -8,6 +8,7 @@ import domain.magicplan.api.response as api from domain.magicplan.api.response import MagicPlanPlan, Symbol, Vec3, WallItem from domain.magicplan.mapper import ( _map_window, # pyright: ignore[reportPrivateUsage] + _map_window_ventilation, # pyright: ignore[reportPrivateUsage] map_address, map_plan, ) @@ -229,3 +230,42 @@ def test_map_address_city_absent_is_omitted() -> None: def test_map_address_none_returns_none() -> None: assert map_address(None) is None + + +def _make_survey_field(label: str, value: str) -> api.SurveyField: + return api.SurveyField( + id="test", + type=5, + type_as_string="float", + is_required=False, + label=label, + value=api.FieldValue(index=-1, has_value=True, is_array=False, value=value), + ) + + +def test_trickle_vent_area_read_from_legacy_field_label() -> None: + # Arrange — old MagicPlan label, not yet renamed in the API response + fields = [_make_survey_field( + "Trickle Vent Effective Area (mm2) (No Code Then Width x Height)", "1700" + )] + + # Act + vent = _map_window_ventilation(fields) + + # Assert + assert vent is not None + assert vent.trickle_vent_area_mm2 == 1700 + + +def test_trickle_vent_area_read_from_individual_field_label() -> None: + # Arrange — "Individual" variant used in some MagicPlan templates + fields = [_make_survey_field( + "Individual Trickle Vent Effective Area (mm2) (No Code Then Width x Height)", "900" + )] + + # Act + vent = _map_window_ventilation(fields) + + # Assert + assert vent is not None + assert vent.trickle_vent_area_mm2 == 900 diff --git a/tests/domain/magicplan/test_ventilation_audit.py b/tests/domain/magicplan/test_ventilation_audit.py index 6ebb39362..eb30b98dc 100644 --- a/tests/domain/magicplan/test_ventilation_audit.py +++ b/tests/domain/magicplan/test_ventilation_audit.py @@ -148,7 +148,7 @@ def test_writes_trickle_vent_data_to_columns_N_and_O() -> None: assert sheet["O6"].value == 2 -def test_writes_door_room_name_to_column_R() -> None: +def test_writes_door_room_name_to_column_Q() -> None: # Arrange plan = _make_plan(num_rooms=1, num_windows_per_room=0, num_doors_per_room=1) sheet = _blank_sheet() @@ -157,10 +157,10 @@ def test_writes_door_room_name_to_column_R() -> None: populate_sheet(sheet, plan) # Assert - assert sheet["R6"].value == "Room 0" + assert sheet["Q6"].value == "Room 0" -def test_writes_door_dimensions_to_columns_S_and_T() -> None: +def test_writes_door_dimensions_to_columns_R_and_S() -> None: # Arrange plan = _make_plan(num_rooms=1, num_windows_per_room=0, num_doors_per_room=1) sheet = _blank_sheet() @@ -169,8 +169,8 @@ def test_writes_door_dimensions_to_columns_S_and_T() -> None: populate_sheet(sheet, plan) # Assert - assert sheet["S6"].value == 800.0 - assert sheet["T6"].value == 10.0 + assert sheet["R6"].value == 800.0 + assert sheet["S6"].value == 10.0 def test_writes_zeros_when_window_has_no_ventilation() -> None: @@ -200,7 +200,7 @@ def test_writes_zeros_when_door_has_no_ventilation() -> None: populate_sheet(sheet, plan) # Assert - assert sheet["T6"].value == 0 + assert sheet["S6"].value == 0 _TEMPLATE_PATH = ( @@ -220,4 +220,4 @@ def test_real_template_survives_18_doors() -> None: populate_sheet(sheet, plan) - assert sheet["R23"].value == "Room 17" + assert sheet["Q23"].value == "Room 17" diff --git a/tests/domain/modelling/test_ashp_cost_inputs.py b/tests/domain/modelling/test_ashp_cost_inputs.py index c598f9ede..ce528f1bb 100644 --- a/tests/domain/modelling/test_ashp_cost_inputs.py +++ b/tests/domain/modelling/test_ashp_cost_inputs.py @@ -58,7 +58,7 @@ def test_classification_keys_on_fuel_not_the_mains_gas_flag() -> None: ) # Act / Assert - assert epc.sap_energy_source.mains_gas is True + assert epc.sap_energy_source.gas_connection_available is True inputs: AshpCostInputs = ashp_cost_inputs(epc) assert inputs.existing_system is AshpExistingSystem.ELECTRIC_STORAGE assert inputs.has_reusable_wet_system is False @@ -73,7 +73,7 @@ def test_oil_and_lpg_dwellings_are_reusable_wet_systems() -> None: def _with_fuel(code: int) -> EpcPropertyData: clone: EpcPropertyData = copy.deepcopy(base) - clone.sap_energy_source.mains_gas = False + clone.sap_energy_source.gas_connection_available = False clone.sap_heating.main_heating_details[0].main_fuel_type = code clone.sap_heating.main_heating_details[0].sap_main_heating_code = 199 return clone @@ -82,3 +82,19 @@ def test_oil_and_lpg_dwellings_are_reusable_wet_systems() -> None: assert ashp_cost_inputs(_with_fuel(28)).existing_system is AshpExistingSystem.OIL assert ashp_cost_inputs(_with_fuel(27)).existing_system is AshpExistingSystem.LPG assert ashp_cost_inputs(_with_fuel(28)).has_reusable_wet_system is True + + +def test_threaded_design_heat_loss_drives_the_cost_band_over_the_proxy() -> None: + # When the caller threads the calculator's real design heat loss (ADR-0049), + # the ASHP cost sizes to it — not the floor-area proxy — so a leaky dwelling's + # bigger pump is priced on the right band. + # Arrange + epc = parse_recommendation_summary( + "ashp_from_system_boiler_with_cylinder_001431_before.pdf" + ) + + # Act + inputs: AshpCostInputs = ashp_cost_inputs(epc, design_heat_loss_kw=17.5) + + # Assert — the threaded value, not the 4.5 kW proxy. + assert inputs.design_heat_loss_kw == 17.5 diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index c99f7b6f0..a52cf9787 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -664,7 +664,11 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler() -> None: before: EpcPropertyData = parse_recommendation_summary( "ashp_from_gas_boiler_001431_before.pdf" ) - recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + recommendation: Recommendation | None = recommend_heating( + before, + _AnyProduct(), + design_heat_loss_kw=Sap10Calculator().calculate(before).design_heat_loss_kw, + ) assert recommendation is not None option = next( o for o in recommendation.options if o.measure_type == "air_source_heat_pump" @@ -678,12 +682,15 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler() -> None: # CO2/PE are the postcode DEMAND cascade now that `Sap10Calculator. # calculate` computes EPC emissions/PE on local weather (SAP 10.2 # Appendix U p.124); SAP is unchanged (UK-average rating cascade). + # Re-pinned after ADR-0049 sizes the pump to this dwelling's 9.69 kW design + # heat loss (the 10 kW aroTHERM plus, 110273) instead of the fixed 5 kW unit, + # which was undersized here — the correctly-sized pump raises the end-state SAP. _assert_overlay_scores( before, option.overlay, - sap=51.99820176096402, - co2=1065.7593506066496, - pe=10995.781557709413, + sap=69.6498827017577, + co2=698.896585317197, + pe=7271.044741124457, ) @@ -697,7 +704,11 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler_instant_hw before: EpcPropertyData = parse_recommendation_summary( "ashp_from_gas_boiler_instant_hw_001431_before.pdf" ) - recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + recommendation: Recommendation | None = recommend_heating( + before, + _AnyProduct(), + design_heat_loss_kw=Sap10Calculator().calculate(before).design_heat_loss_kw, + ) assert recommendation is not None option = next( o for o in recommendation.options if o.measure_type == "air_source_heat_pump" @@ -709,12 +720,15 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler_instant_hw # see `test_gas_boiler_instant_hw_before_baselines`. # CO2/PE are the postcode DEMAND cascade now (see the boiler-1 pin above); # SAP is unchanged (UK-average rating cascade). + # Re-pinned after ADR-0049 sizes the pump to this dwelling's 12.87 kW design + # heat loss (the capped 12 kW aroTHERM plus, 110281) instead of the fixed 5 kW + # unit, which was badly undersized here — the sized pump raises the SAP. _assert_overlay_scores( before, option.overlay, - sap=39.00740809309464, - co2=1845.8588018295509, - pe=18944.42568846759, + sap=72.22779402266684, + co2=870.0854694592007, + pe=9025.363011043328, ) @@ -734,7 +748,11 @@ def test_ashp_overlay_reproduces_the_relodged_after_from_a_system_boiler_with_cy after: EpcPropertyData = parse_recommendation_summary( "ashp_from_system_boiler_with_cylinder_001431_after.pdf" ) - recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + recommendation: Recommendation | None = recommend_heating( + before, + _AnyProduct(), + design_heat_loss_kw=Sap10Calculator().calculate(before).design_heat_loss_kw, + ) assert recommendation is not None option = next( o for o in recommendation.options if o.measure_type == "air_source_heat_pump" diff --git a/tests/domain/modelling/test_floor_recommendation.py b/tests/domain/modelling/test_floor_recommendation.py index 1ce5bd7f3..59ad21889 100644 --- a/tests/domain/modelling/test_floor_recommendation.py +++ b/tests/domain/modelling/test_floor_recommendation.py @@ -66,6 +66,28 @@ def test_uninsulated_solid_floor_yields_solid_insulation() -> None: assert recommendation.options[0].measure_type == "solid_floor_insulation" +def test_at_regs_age_band_floor_yields_no_recommendation() -> None: + # Arrange — a solid ground floor lodged as-built at construction age band + # L (2012-2022). RdSAP's BS EN ISO 13370 cascade assigns it the at-regs + # U-value and 100 mm of added insulation resolves to the same (or a + # no-better) value — the measure cannot change the modelled floor, so + # offering it sells £3-4k of insulation for +0.00 SAP (portfolio 796: + # properties 709782/709790). The Floor U-Value Gate withholds it — the + # floor arm of ADR-0051's deferred follow-up. + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _main(baseline) + main.floor_construction_type = "Solid" + main.construction_age_band = "L" + + # Act + recommendation: Recommendation | None = recommend_floor_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + def test_already_insulated_floor_yields_no_recommendation() -> None: # Arrange baseline: EpcPropertyData = build_epc() diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index 4f1a321a6..470ff51aa 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -7,7 +7,10 @@ later slices. Detection + pricing only; impact is produced by scoring (ADR-0016) from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.geospatial.planning_restrictions import PlanningRestrictions -from domain.modelling.generators.heating_recommendation import recommend_heating +from domain.modelling.generators.heating_recommendation import ( + recommend_heating, + select_ashp_pcdb_id, +) from domain.modelling.measure_type import MeasureType from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation @@ -23,12 +26,22 @@ from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) -# Electricity main-fuel code (Elmhurst → SAP10) and the Table 4a SAP code an -# existing (non-HHR) electric storage system lodges. +# Electricity fuel codes and the Table 4a SAP code an existing (non-HHR) +# electric storage system lodges. 30 is the SAP Table 12 code (used by +# Elmhurst / cascade); 29 is the raw gov-API enum that some certs carry. _ELECTRICITY = 30 +_API_ELECTRICITY = 29 _OLD_STORAGE_SAP_CODE = 402 +def _hhr_measure_type_in(rec: Recommendation | None) -> bool: + if rec is None: + return False + return MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS in { + o.measure_type for o in rec.options + } + + def _electric_storage_baseline() -> EpcPropertyData: """A 000490 dwelling re-cast as an existing (non-HHR) electric storage system: electric main fuel, Table 4a code 402.""" @@ -61,9 +74,17 @@ def test_electric_storage_dwelling_yields_an_hhr_storage_bundle() -> None: assert recommendation.surface == "Heating & Hot Water" options = {o.measure_type.value: o for o in recommendation.options} assert "high_heat_retention_storage_heaters" in options - assert options["high_heat_retention_storage_heaters"].overlay.heating == HeatingOverlay( + assert options[ + "high_heat_retention_storage_heaters" + ].overlay.heating == HeatingOverlay( main_fuel_type=30, sap_main_heating_code=409, + # Storage category (7) pins the fix for the off-peak-pricing bug: the + # overlay must set it so the Table 12a resolver classifies the off-peak + # storage heating as storage (0.00 high-rate) and not direct-acting + # electric (1.00) via a stale category. See test_cert_to_inputs + # `test_storage_heater_classified_by_sap_code_over_stale_category`. + main_heating_category=7, main_heating_control=2404, water_heating_code=903, water_heating_fuel=30, @@ -138,6 +159,38 @@ def test_hhr_storage_bundle_carries_the_product_cost_and_contingency() -> None: assert abs(option.cost.contingency_rate - 0.26) <= 1e-9 +def test_on_gas_street_electric_dwelling_yields_hhr_storage_bundle() -> None: + # Arrange — electrically heated dwelling whose cert carries the raw API + # fuel code (29) instead of the SAP Table 12 code (30), AND has a mains- + # gas connection at the street (mains_gas=True, the default for 000490). + # Bug: off_gas = not True = False; electric_main = 29 == 30 = False → + # HHRSH incorrectly blocked. Fix: key on main_fuel_type not in _GAS_FUEL_CODES. + baseline: EpcPropertyData = _electric_storage_baseline() + baseline.sap_heating.main_heating_details[0].main_fuel_type = _API_ELECTRICITY + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — dwelling heats electrically (not with gas) → HHRSH must be offered. + assert recommendation is not None + options = {o.measure_type.value: o for o in recommendation.options} + assert "high_heat_retention_storage_heaters" in options + + +def test_community_heated_dwelling_yields_no_hhr_storage_bundle() -> None: + # Arrange — community boiler network (SAP code 301, category 6) with + # electricity as the upstream heat source fuel (code 41): not gas, so the + # fuel gate alone would pass — the topology gate must block it. + epc = build_epc() + main = epc.sap_heating.main_heating_details[0] + main.sap_main_heating_code = 301 + main.main_heating_category = 6 + main.main_fuel_type = 41 + + # Act / Assert + assert _hhr_measure_type_in(recommend_heating(epc, _StubProducts())) is False + + def _gas_boiler_house() -> EpcPropertyData: """A 000490 mains-gas combi dwelling, explicitly a House — ASHP-eligible.""" epc: EpcPropertyData = build_epc() @@ -150,8 +203,11 @@ def test_gas_boiler_house_yields_an_ashp_bundle() -> None: # bungalow regardless of current system or efficiency (ADR-0024). baseline: EpcPropertyData = _gas_boiler_house() - # Act - recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + # Act — a 5.5 kW design heat loss sizes to the 5 kW aroTHERM plus (110257): + # PSR-0.8 target ~4.4 kW, nearest the 4.37 kW rung. + recommendation: Recommendation | None = recommend_heating( + baseline, _StubProducts(), design_heat_loss_kw=5.5 + ) # Assert — the ASHP bundle carries the absolute heat-pump end-state. assert recommendation is not None @@ -170,7 +226,7 @@ def test_gas_boiler_house_yields_an_ashp_bundle() -> None: cylinder_thermostat="Y", has_hot_water_cylinder=True, meter_type="Single", - mains_gas=False, + gas_connection_available=False, ) @@ -234,6 +290,8 @@ def test_ashp_bundle_carries_the_composite_per_dwelling_cost() -> None: def test_listed_building_yields_no_ashp_bundle() -> None: # Arrange — a listed building protects the fabric; an external ASHP unit is # not auto-offered (ADR-0024). The dwelling is on gas, so HHRSH is also out. + # It IS a wet gas combi (PCDB index 10328, no SAP code), so a control tune-up + # is legitimately offered — but never ASHP or HHRSH. baseline: EpcPropertyData = _gas_boiler_house() # Act @@ -241,8 +299,11 @@ def test_listed_building_yields_no_ashp_bundle() -> None: baseline, _StubProducts(), PlanningRestrictions(is_listed=True) ) - # Assert - assert recommendation is None + # Assert — no whole-system replacement (ASHP / HHRSH); a tune-up is fine. + if recommendation is not None: + measure_types = {o.measure_type for o in recommendation.options} + assert "air_source_heat_pump" not in measure_types + assert "high_heat_retention_storage_heaters" not in measure_types def test_conservation_area_still_yields_an_ashp_bundle() -> None: @@ -292,6 +353,22 @@ def test_existing_heat_pump_yields_no_ashp_bundle() -> None: } +def test_community_heated_house_yields_no_ashp_bundle() -> None: + # Arrange — community boiler network (SAP code 301, category 6) on a house: + # topology alone must block ASHP regardless of fuel. + epc = build_epc() + main = epc.sap_heating.main_heating_details[0] + main.sap_main_heating_code = 301 + main.main_heating_category = 6 + + # Act / Assert + recommendation: Recommendation | None = recommend_heating(epc, _StubProducts()) + if recommendation is not None: + assert MeasureType.AIR_SOURCE_HEAT_PUMP not in { + o.measure_type for o in recommendation.options + } + + # --- Gas boiler upgrade (Heating/HW expansion) ---------------------------- @@ -435,7 +512,99 @@ def test_off_gas_boiler_yields_no_gas_boiler_upgrade() -> None: # state is gated on a mains-gas connection). baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 130 - baseline.sap_energy_source.mains_gas = False + baseline.sap_energy_source.gas_connection_available = False + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + if recommendation is not None: + assert "gas_boiler_upgrade" not in { + o.measure_type for o in recommendation.options + } + + +# --- PCDB-lodged gas boiler with no SAP code (#1388 Work item A#2) ---------- + + +def _pcdb_lodged_gas_boiler_baseline() -> EpcPropertyData: + """A mains-gas wet boiler lodged the way the gov EPC API cohort does — a + `main_heating_category` (2, "boiler + radiators") and a PCDB Table 105 + `main_heating_index_number`, but NO `sap_main_heating_code`. Index 5928 = + Ideal Classic, 77.1% winter (a pre-condensing 2001 boiler). Reproduces + portfolio-814 pids 742304 / 742306 / 742322 / 742325 / 742336, which model + to £0 works and a null SAP heating code because the generators keyed on + `sap_main_heating_code` alone and never evaluated these boilers.""" + epc: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + main = epc.sap_heating.main_heating_details[0] + main.sap_main_heating_code = None + main.main_heating_category = 2 + main.main_heating_index_number = 5928 + main.main_fuel_type = 26 # mains gas + return epc + + +def test_pcdb_lodged_gas_boiler_without_sap_code_yields_a_boiler_upgrade() -> None: + # Arrange — a non-condensing (77.1%) mains-gas boiler lodged with only a PCDB + # index + category (no SAP code). The calculator already treats this as a wet + # boiler (cert_to_inputs._is_wet_boiler_main); the generator must too, so the + # inefficient boiler is offered an upgrade instead of silently getting nothing. + baseline: EpcPropertyData = _pcdb_lodged_gas_boiler_baseline() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — the boiler upgrade is offered, targeting the code-102 end-state. + assert recommendation is not None + options = {o.measure_type.value: o for o in recommendation.options} + assert "gas_boiler_upgrade" in options + overlay = options["gas_boiler_upgrade"].overlay.heating + assert overlay is not None + assert overlay.sap_main_heating_code == 102 + + +def test_pcdb_lodged_condensing_gas_boiler_without_sap_code_yields_no_upgrade() -> None: + # Arrange — same lodgement shape but a condensing PCDB index (126 = Broag + # Remeha, 91% winter ≥ the new boiler's 84%). A like-for-like gas swap gains + # nothing, so no boiler upgrade — the dwelling gets a tune-up instead (the + # PCDB efficiency stands in for the absent Table 4b code). + baseline: EpcPropertyData = _pcdb_lodged_gas_boiler_baseline() + baseline.sap_heating.main_heating_details[0].main_heating_index_number = 126 + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — no boiler upgrade; a control tune-up is still offered (the + # fixture's controls are already standard 2106, so the zone tune-up is the + # offered variant). + assert recommendation is not None + measure_types = {o.measure_type for o in recommendation.options} + assert "gas_boiler_upgrade" not in measure_types + assert "system_tune_up_zoned" in measure_types + + +def test_pcdb_lodged_gas_boiler_without_sap_code_is_tune_up_eligible() -> None: + # Arrange — the same non-condensing PCDB boiler must also be recognised as a + # wet boiler by the tune-up gate (controls/cylinder), not just the upgrade. + baseline: EpcPropertyData = _pcdb_lodged_gas_boiler_baseline() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — a tune-up option is offered (zone variant; controls are 2106). + assert recommendation is not None + assert "system_tune_up_zoned" in {o.measure_type for o in recommendation.options} + + +def test_category_2_electric_boiler_without_sap_code_yields_no_gas_upgrade() -> None: + # Arrange — a category-2 boiler with no SAP code and no PCDB index, on + # electricity (an electric "boiler and radiators, electric" cohort lodges + # category 2). Electrification, not a gas swap, is its path, so no gas boiler + # upgrade even on a gas street. + baseline: EpcPropertyData = _pcdb_lodged_gas_boiler_baseline() + main = baseline.sap_heating.main_heating_details[0] + main.main_heating_index_number = None + main.main_fuel_type = 30 # electricity # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) @@ -601,3 +770,48 @@ def test_boiler_upgrade_leaves_adequate_controls_unchanged() -> None: ).overlay.heating assert overlay is not None assert overlay.main_heating_control is None + + +def test_ashp_sizing_targets_the_appendix_n_efficiency_peak() -> None: + # A 6 kW design heat loss sizes to the SAP Appendix-N efficiency peak + # (PSR ~= 0.8, so ~4.8 kW rated output): the 5 kW aroTHERM plus (PCDB 110257, + # ~4.37 kW) — what a real installer fits — not the oversized 7 kW unit, and + # well clear of the low-PSR efficiency collapse. + # Act + pcdb_id = select_ashp_pcdb_id(design_heat_loss_kw=6.0) + + # Assert — the 5 kW aroTHERM plus rung. + assert pcdb_id == 110257 + + +def test_ashp_sizing_caps_at_the_largest_pump_for_a_high_heat_loss_dwelling() -> None: + # A very leaky dwelling (17.5 kW design heat loss, e.g. UPRN 10002468116) + # exceeds the domestic aroTHERM plus range, so sizing caps at the largest + # rung — the 12 kW aroTHERM plus (PCDB 110281, ~11.48 kW) — an honest, still + # far better than the fixed 5 kW pump whose PSR collapsed to ~0.25. + # Act + pcdb_id = select_ashp_pcdb_id(design_heat_loss_kw=17.5) + + # Assert — capped at the 12 kW rung, not left undersized at 5 kW. + assert pcdb_id == 110281 + + +def test_ashp_bundle_sizes_the_pump_to_the_design_heat_loss() -> None: + # A high-heat-loss house (17.5 kW) must get a pump sized to it — the capped + # 12 kW aroTHERM plus (PCDB 110281) — so SAP's Appendix-N PSR efficiency reads + # near its peak, not the fixed 5 kW unit (110257) whose PSR collapses. + # Arrange + baseline: EpcPropertyData = _gas_boiler_house() + + # Act + recommendation: Recommendation | None = recommend_heating( + baseline, _StubProducts(), design_heat_loss_kw=17.5 + ) + + # Assert — the ASHP overlay carries the sized 12 kW record, not the fixed 5 kW. + assert recommendation is not None + ashp = next( + o for o in recommendation.options if o.measure_type == "air_source_heat_pump" + ) + assert ashp.overlay.heating is not None + assert ashp.overlay.heating.main_heating_index_number == 110281 diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py index 8f6c960ff..07abf10d5 100644 --- a/tests/domain/modelling/test_overlay_applicator.py +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -173,8 +173,7 @@ def test_apply_writes_dwelling_ventilation_onto_sap_ventilation() -> None: # Assert assert result.sap_ventilation is not None assert ( - result.sap_ventilation.mechanical_ventilation_kind - == "EXTRACT_OR_PIV_OUTSIDE" + result.sap_ventilation.mechanical_ventilation_kind == "EXTRACT_OR_PIV_OUTSIDE" ) @@ -194,8 +193,7 @@ def test_ventilation_overlay_creates_sap_ventilation_when_baseline_has_none() -> # Assert assert isinstance(result.sap_ventilation, SapVentilation) assert ( - result.sap_ventilation.mechanical_ventilation_kind - == "EXTRACT_OR_PIV_OUTSIDE" + result.sap_ventilation.mechanical_ventilation_kind == "EXTRACT_OR_PIV_OUTSIDE" ) @@ -216,7 +214,9 @@ def test_ventilation_overlay_leaves_building_parts_and_baseline_untouched() -> N # Assert — ventilation overlay touches only sap_ventilation; the baseline # is never mutated. - assert _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == main_before + assert ( + _part(result, BuildingPartIdentifier.MAIN).wall_insulation_type == main_before + ) assert baseline.sap_ventilation is not None assert baseline.sap_ventilation.mechanical_ventilation_kind is None @@ -243,9 +243,7 @@ def test_baseline_is_not_mutated() -> None: ) # Assert - assert ( - _part(baseline, BuildingPartIdentifier.MAIN).wall_insulation_type == original - ) + assert _part(baseline, BuildingPartIdentifier.MAIN).wall_insulation_type == original def test_apply_folds_a_window_overlay_by_index_into_transmission_details() -> None: @@ -293,9 +291,7 @@ def test_baseline_windows_are_not_mutated_by_a_window_overlay() -> None: # Assert assert baseline.sap_windows[0].window_transmission_details is not None - assert ( - baseline.sap_windows[0].window_transmission_details.u_value == original_u - ) + assert baseline.sap_windows[0].window_transmission_details.u_value == original_u def test_apply_writes_dwelling_lighting_onto_top_level_bulb_counts() -> None: @@ -340,7 +336,7 @@ def test_apply_folds_a_heating_overlay_across_all_five_locations() -> None: cylinder_insulation_thickness_mm=120, has_hot_water_cylinder=True, meter_type="18 Hour", - mains_gas=False, + gas_connection_available=False, ) ) @@ -360,7 +356,7 @@ def test_apply_folds_a_heating_overlay_across_all_five_locations() -> None: assert result.has_hot_water_cylinder is True assert result.sap_energy_source is not None assert result.sap_energy_source.meter_type == "18 Hour" - assert result.sap_energy_source.mains_gas is False + assert result.sap_energy_source.gas_connection_available is False def test_secondary_heating_overlay_clears_the_lodged_secondary() -> None: @@ -404,7 +400,7 @@ def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None: original_wh_code: int | None = baseline.sap_heating.water_heating_code original_cylinder = baseline.has_hot_water_cylinder assert baseline.sap_energy_source is not None - original_mains_gas = baseline.sap_energy_source.mains_gas + original_mains_gas = baseline.sap_energy_source.gas_connection_available # Act — fold an HHR storage bundle. _: EpcPropertyData = apply_simulations( @@ -417,7 +413,7 @@ def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None: main_heating_control=2404, water_heating_code=903, has_hot_water_cylinder=True, - mains_gas=False, + gas_connection_available=False, ) ) ], @@ -431,7 +427,7 @@ def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None: ) assert baseline.sap_heating.water_heating_code == original_wh_code assert baseline.has_hot_water_cylinder == original_cylinder - assert baseline.sap_energy_source.mains_gas == original_mains_gas + assert baseline.sap_energy_source.gas_connection_available == original_mains_gas def test_heating_index_overlay_clears_a_stale_sap_main_heating_code() -> None: diff --git a/tests/domain/modelling/test_roof_recommendation.py b/tests/domain/modelling/test_roof_recommendation.py index 63f28ce68..6759341ed 100644 --- a/tests/domain/modelling/test_roof_recommendation.py +++ b/tests/domain/modelling/test_roof_recommendation.py @@ -83,6 +83,139 @@ def test_room_in_roof_yields_no_recommendation_pending_a_dedicated_branch() -> N assert recommendation is None +def test_vaulted_ceiling_with_nd_thickness_on_pre_1950_dwelling_yields_sloping_ceiling_insulation() -> None: + # Arrange — the 742265 shape (ADR-0047): a "Pitched (vaulted ceiling)" MAIN + # roof lodging the "ND" sentinel on an age-band-B dwelling. ND resolves to + # the age band's as-built state (A-D: uninsulated), and a vaulted ceiling + # has no loft void, so the one applicable Measure is rafter insulation. + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) + main.roof_construction_type = "Pitched (vaulted ceiling)" + main.roof_insulation_thickness = "ND" + main.construction_age_band = "B" + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + assert recommendation.surface == "Roof" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "sloping_ceiling_insulation" + simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay]) + assert ( + _part(simulated, BuildingPartIdentifier.MAIN).roof_insulation_thickness == 100 + ) + + +def test_vaulted_ceiling_with_nd_thickness_on_limited_insulation_age_band_yields_no_recommendation() -> None: + # Arrange — same vaulted-ND shape, but age band E: as-built carries limited + # insulation (ADR-0047), and ND must never make an undefined cert MORE + # eligible than an explicit thin lodgement (which is not eligible today). + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) + main.roof_construction_type = "Pitched (vaulted ceiling)" + main.roof_insulation_thickness = "ND" + main.construction_age_band = "E" + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_vaulted_ceiling_with_ni_thickness_yields_no_recommendation() -> None: + # Arrange — "NI" means insulation PRESENT at unknown thickness (RdSAP 10 + # §5.11.4: score as 50 mm), never "no insulation" — even on an age band + # whose as-built state is uninsulated (ADR-0047, the ND ≠ NI distinction). + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) + main.roof_construction_type = "Pitched (vaulted ceiling)" + main.roof_insulation_thickness = "NI" + main.construction_age_band = "B" + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_vaulted_ceiling_with_measured_thickness_yields_no_recommendation() -> None: + # Arrange — an explicitly-lodged depth means the rafters are already + # insulated; the sentinel-aware trigger must not loosen the numeric rule. + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) + main.roof_construction_type = "Pitched (vaulted ceiling)" + main.roof_insulation_thickness = 100 + main.construction_age_band = "B" + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_loft_roof_with_nd_thickness_on_pre_1950_dwelling_yields_loft_insulation() -> None: + # Arrange — the loft fallback shares the sentinel semantics (ADR-0047): + # "ND" on an age-band-C pitched loft resolves to as-built uninsulated, so + # the joist measure fires just as it does for an explicit 0. The lodged + # type is the gov-API code-4 string — sentinel resolution needs a KNOWN + # pitched roof (an unlodged type could be a party ceiling; see below). + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) + main.roof_construction_type = "Pitched (slates/tiles), access to loft" + main.roof_insulation_thickness = "ND" + main.construction_age_band = "C" + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + option = recommendation.options[0] + assert option.measure_type == "loft_insulation" + simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay]) + assert ( + _part(simulated, BuildingPartIdentifier.MAIN).roof_insulation_thickness == 300 + ) + + +def test_unlodged_roof_type_with_nd_thickness_yields_no_recommendation() -> None: + # Arrange — a part with NO lodged roof type reaching the loft fallback can + # be a party ceiling ("(another dwelling above)", gov-API codes 6/7 map the + # type to None and real code-7 certs lodge "ND"): zero roof heat loss, so + # insulating it contradicts the dwelling. Sentinel resolution therefore + # requires a KNOWN pitched/thatched roof; an unlodged type keeps the + # explicit-0-only trigger (ADR-0047). + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) + main.roof_construction_type = None + main.roof_insulation_thickness = "ND" + main.construction_age_band = "B" + + # Act + recommendation: Recommendation | None = recommend_roof_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + def test_loft_option_carries_cost_from_roof_area_and_product() -> None: # Arrange baseline: EpcPropertyData = build_epc() # MAIN roof area 14.85 m^2 diff --git a/tests/domain/modelling/test_wall_recommendation.py b/tests/domain/modelling/test_wall_recommendation.py index b47d9eb16..4734314c9 100644 --- a/tests/domain/modelling/test_wall_recommendation.py +++ b/tests/domain/modelling/test_wall_recommendation.py @@ -90,6 +90,69 @@ def test_non_cavity_main_wall_yields_no_recommendation() -> None: assert recommendation is None +def test_at_regs_age_band_cavity_yields_no_cavity_fill_recommendation() -> None: + # Arrange — a cavity wall lodged as-built at construction age band L + # (2012-2022): the as-built cascade already assigns the at-regs U-value, so + # filling the cavity cannot lower it. The Wall U-Value Gate withholds the + # Option (ADR-0051); the legacy insulation-state trigger alone would offer + # a paid no-op. + baseline: EpcPropertyData = build_epc() # MAIN: cavity (4), uninsulated (4) + _part(baseline, BuildingPartIdentifier.MAIN).construction_age_band = "L" + + # Act + recommendation: Recommendation | None = recommend_cavity_wall( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_at_regs_age_band_wall_yields_no_solid_wall_recommendation() -> None: + # Arrange — a timber-frame wall lodged as-built at construction age band L + # (2012-2022). RdSAP's cascade assigns it the at-regs U-value (0.28), and + # 100 mm IWI resolves to the SAME value — the measure cannot change the + # modelled wall, so offering it sells £4k of insulation for +0.00 SAP + # (property 711795, PRD #1435 WS2). The Wall U-Value Gate withholds it + # (ADR-0051). + baseline: EpcPropertyData = build_epc() + main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) + main.wall_construction = 5 # timber frame — IWI-only construction + main.wall_insulation_type = 4 # as-built / uninsulated — the legacy trigger + main.construction_age_band = "L" + + # Act + recommendation: Recommendation | None = recommend_solid_wall( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_treatable_old_band_solid_wall_keeps_its_insulation_options() -> None: + # Arrange — solid brick as-built at age band B: the cascade U (~2.1) far + # exceeds what 100 mm EWI/IWI achieves, so the Wall U-Value Gate must let + # both Options through unchanged (the gate is physical possibility only — + # ADR-0051). + baseline: EpcPropertyData = build_epc() # age band B + main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN) + main.wall_construction = 3 # solid brick — EWI + IWI constructable + main.wall_insulation_type = 4 + + # Act + recommendation: Recommendation | None = recommend_solid_wall( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + assert {option.measure_type for option in recommendation.options} == { + "external_wall_insulation", + "internal_wall_insulation", + } + + def test_park_home_wall_yields_no_solid_wall_recommendation() -> None: # Arrange — a park home (wall_construction code 8) with an uninsulated # as-built wall. Code 8 is NOT system-built (ADR-0019); a park home's diff --git a/tests/domain/property_baseline/test_calculator_rebaseliner.py b/tests/domain/property_baseline/test_calculator_rebaseliner.py index f6002bd48..49a2364dc 100644 --- a/tests/domain/property_baseline/test_calculator_rebaseliner.py +++ b/tests/domain/property_baseline/test_calculator_rebaseliner.py @@ -40,6 +40,7 @@ def _sap_result( space_heating_kwh_per_yr=0.0, space_cooling_kwh_per_yr=0.0, fabric_energy_efficiency_kwh_per_m2_yr=0.0, + design_heat_loss_kw=0.0, main_heating_fuel_kwh_per_yr=0.0, main_2_heating_fuel_kwh_per_yr=0.0, secondary_heating_fuel_kwh_per_yr=0.0, diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index b643bf0d3..109dabaed 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -27,6 +27,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import ( ) from datatypes.epc.domain.epc_property_data import ( + EnergyElement, EpcPropertyData, MainHeatingDetail, PhotovoltaicArray, @@ -54,6 +55,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _apply_heat_network_hiu_default_store, # pyright: ignore[reportPrivateUsage] _cylinder_thermostat_present, # pyright: ignore[reportPrivateUsage] _has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage] + _heat_pump_apm_efficiencies, # pyright: ignore[reportPrivateUsage] _heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage] _heat_network_community_fuel_code, # pyright: ignore[reportPrivateUsage] _heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage] @@ -737,6 +739,30 @@ def test_cylinder_present_but_size_not_determined_defaults_to_normal_110l() -> N assert volume_l is not None and abs(volume_l - 110.0) <= 1e-9 +def test_cylinder_size_not_determined_still_incurs_storage_loss() -> None: + # Arrange — the size-0 (not determined) cylinder's Table 28 default of + # 110 L must flow into the STORAGE-LOSS override too, not just the Table + # 13 high-rate fraction. `_cylinder_storage_loss_override` read the + # volume via `_cylinder_volume_l_from_code` (None for size 0) and bailed + # `if volume_l is None: return None`, dropping the loss entirely — while + # the rest of the DHW cascade used `_hot_water_cylinder_volume_l` + # (110 L). A present cylinder with no storage loss under-costs the DHW + # and over-rates the dwelling (7 corpus certs lodge cylinder_size=0 + + # has_cylinder=true). RdSAP 10 §10.5 Table 28: a present-but-unsized + # cylinder is a 110 L cylinder and incurs the Table 2 storage loss. + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _cylinder_storage_loss_override, # pyright: ignore[reportPrivateUsage] + ) + epc = _cylinder_epc(cylinder_size=0) + + # Act + storage_56m = _cylinder_storage_loss_override(epc, None) + + # Assert — a real 110 L cylinder loss series, not the dropped None. + assert storage_56m is not None + assert sum(storage_56m) > 0.0 + + def test_cylinder_size_inaccessible_code_5_solid_fuel_boiler_uses_160l() -> None: # Arrange — RdSAP 10 §10.5 Table 28: an "Inaccessible" cylinder (code 5) # heated "from a solid fuel boiler" uses 160 litres. @@ -1053,6 +1079,117 @@ def test_electric_boilers_191_195_map_to_distinct_table_12a_grid1_rows() -> None assert space_heating_high_rate_fraction(storage, Tariff.SEVEN_HOUR) == 0.00 +def test_electric_underfloor_codes_map_to_their_table_12a_grid1_rows() -> None: + # Arrange — SAP 10.2 Table 12a Grid 1 (PDF p.184/191) names the underfloor + # codes explicitly: "Integrated (storage+direct-acting) systems (applies to + # storage heaters 408 and underfloor heating 422 and 423)" → 7-hour 0.20; + # "Underfloor heating (in screed above insulation, in timber floor or + # immediately below floor covering)" (424/425) → 7-hour 0.90 / 10-hour + # 0.50. Code 421 ("in concrete slab, off-peak only") is named in NEITHER + # underfloor row — the slab is the storage medium, so it takes "Other + # storage heaters" (0.00 at 7-hour or 24-hour), consistent with §12 Rule 2 + # listing 421 among the off-peak-implying storage codes. Before this + # resolver branch every underfloor code fell to the None fallback and + # billed space heat 100% at the night rate — on portfolio 796, 22 of the + # 90 underfloor-override dwellings sit on a 7-hour Dual meter and were + # over-credited (e.g. screed 424 at 5.50p/kWh instead of the blended + # ~14.3p). + from domain.sap10_calculator.tables.table_12a import ( + Table12aSystem, + Tariff, + space_heating_high_rate_fraction, + ) + + def _underfloor_main(code: int) -> MainHeatingDetail: + return MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + heat_emitter_type=0, + emitter_temperature=1, + main_heating_control=2701, + main_heating_category=8, # electric underfloor + sap_main_heating_code=code, + ) + + # Act + slab = _table_12a_system_for_main(_underfloor_main(421)) + integrated = [_table_12a_system_for_main(_underfloor_main(c)) for c in (422, 423)] + screed = [_table_12a_system_for_main(_underfloor_main(c)) for c in (424, 425)] + + # Assert — the three spec rows, with their published fractions. + assert slab is Table12aSystem.OTHER_STORAGE_HEATERS + assert all(s is Table12aSystem.INTEGRATED_STORAGE_DIRECT for s in integrated) + assert all(s is Table12aSystem.UNDERFLOOR_HEATING for s in screed) + assert space_heating_high_rate_fraction(slab, Tariff.SEVEN_HOUR) == 0.00 + assert ( + space_heating_high_rate_fraction( + Table12aSystem.INTEGRATED_STORAGE_DIRECT, Tariff.SEVEN_HOUR + ) + == 0.20 + ) + assert ( + space_heating_high_rate_fraction( + Table12aSystem.UNDERFLOOR_HEATING, Tariff.SEVEN_HOUR + ) + == 0.90 + ) + assert ( + space_heating_high_rate_fraction( + Table12aSystem.UNDERFLOOR_HEATING, Tariff.TEN_HOUR + ) + == 0.50 + ) + + +def test_storage_heater_classified_by_sap_code_over_stale_category() -> None: + # Regression: electric STORAGE heaters must resolve to a storage Table 12a + # Grid 1 row from their Table 4a SAP code (401-409) even when the RdSAP + # `main_heating_category` is stale/inconsistent (== 10, electric room + # heaters). This is exactly the state the HHRSH recommendation overlay + # produces — it sets `sap_main_heating_code=409` on a dwelling that was + # electric room heaters (category 10) — and the category-10 room-heater + # branch used to win, mispricing the off-peak storage heating as + # direct-acting electric (1.00 high-rate → 100% peak). Verified real-cert + # impact: UPRN 100020942571 HHRSH SAP 48 (peak-priced) vs accredited + # Elmhurst 71; code-authoritative classification restores it to 73. + from domain.sap10_calculator.tables.table_12a import ( + Table12aSystem, + Tariff, + space_heating_high_rate_fraction, + ) + + def _storage_main(code: int, category: int) -> MainHeatingDetail: + return MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity + heat_emitter_type=0, + emitter_temperature=1, + main_heating_control=2404, # HHRSH controls + main_heating_category=category, + sap_main_heating_code=code, + ) + + # HHR storage (409) with a stale room-heater category (10) — the overlay case. + hhr_stale = _table_12a_system_for_main(_storage_main(409, category=10)) + assert hhr_stale is Table12aSystem.OTHER_STORAGE_HEATERS + assert space_heating_high_rate_fraction(hhr_stale, Tariff.SEVEN_HOUR) == 0.00 + + # Integrated storage/direct-acting (408) is still its own row, code-driven. + integrated = _table_12a_system_for_main(_storage_main(408, category=10)) + assert integrated is Table12aSystem.INTEGRATED_STORAGE_DIRECT + assert space_heating_high_rate_fraction(integrated, Tariff.SEVEN_HOUR) == 0.20 + + # The canonical consistent case (category 7) is unchanged. + hhr_consistent = _table_12a_system_for_main(_storage_main(409, category=7)) + assert hhr_consistent is Table12aSystem.OTHER_STORAGE_HEATERS + + # A genuine electric room heater (code 691, category 10) is NOT swept into + # the storage branch — it stays direct-acting (1.00 high-rate at 7-hour). + room_heater = _table_12a_system_for_main(_storage_main(691, category=10)) + assert room_heater is Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC + assert space_heating_high_rate_fraction(room_heater, Tariff.SEVEN_HOUR) == 1.00 + + def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> None: # Arrange — heat-network main (Table 4a code 301 = community heating, # category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution @@ -1817,6 +1954,79 @@ def test_main_floor_u_value_routes_suspended_timber_via_floor_construction_type( assert sealed is False +def test_has_suspended_timber_floor_per_spec_falls_back_to_floors_description() -> None: + """RdSAP 10 §5 (12) reads the Main bp's `floor_construction_type` + lodgement, but the gov-API mapper often leaves that field `None` + while the global `epc.floors[].description` still carries the + assessor's "Suspended, ..." observation (cert 100061275133: Main bp + `floor_construction_type=None`, `epc.floors[0].description= + "Suspended, no insulation (assumed)"`). `_main_floor_u_value` already + falls back to this joined description (`effective_floor_description`); + the (12) infiltration rule must apply the same fallback so a + suspended-timber ground floor doesn't silently enter (12)=0.""" + from dataclasses import replace + # Arrange + main = replace( + make_building_part( + construction_age_band="B", + floor_dimensions=[make_floor_dimension(floor=0)], + ), + floor_type="Ground floor", + floor_construction_type=None, + ) + epc = make_minimal_sap10_epc(sap_building_parts=[main]) + epc.floors = [ + EnergyElement( + description="Suspended, no insulation (assumed)", + energy_efficiency_rating=0, + environmental_efficiency_rating=0, + ), + ] + + # Act + has_susp, sealed = _has_suspended_timber_floor_per_spec(epc) + + # Assert — description signal routes to the suspended-timber branch; + # band B is A-E with no supplied U-value and no retrofit insulation + # signal, so rule (b) applies "unsealed" → (12) = 0.2. + assert has_susp is True + assert sealed is False + + +def test_has_suspended_timber_floor_per_spec_excludes_suspended_not_timber() -> None: + """"Suspended, not timber" (e.g. suspended concrete) starts with the + same "Suspended" prefix as timber floors but must NOT trigger the + §5 (12) rule — the rule is specifically about air infiltration + through timber floorboard gaps (see the API mapper's + `_API_FLOOR_CONSTRUCTION_TO_STR` comment: "only 'Suspended timber' + triggers the §5 (12)... adjustment").""" + from dataclasses import replace + # Arrange + main = replace( + make_building_part( + construction_age_band="B", + floor_dimensions=[make_floor_dimension(floor=0)], + ), + floor_type="Ground floor", + floor_construction_type=None, + ) + epc = make_minimal_sap10_epc(sap_building_parts=[main]) + epc.floors = [ + EnergyElement( + description="Suspended, not timber", + energy_efficiency_rating=0, + environmental_efficiency_rating=0, + ), + ] + + # Act + has_susp, sealed = _has_suspended_timber_floor_per_spec(epc) + + # Assert + assert has_susp is False + assert sealed is False + + def test_rdsap_extract_fans_default_per_table_5() -> None: # Arrange — RdSAP 10 §4.1 Table 5 (PDF p.28) "Extract fans" default # when the lodged number is unknown. The Summary §12.0 "No. of @@ -2636,6 +2846,46 @@ def test_dual_fuel_secondary_api_enum_9_prices_as_dual_fuel_not_lpg() -> None: assert secondary_factor_code == 10 +def test_fuel_cost_secondary_colliding_fuel_priced_at_canonical_rate() -> None: + # Arrange — the SAP-DRIVING `_fuel_cost` cascade prices the secondary + # via its own inline `secondary_high_rate_gbp_per_kwh`, distinct from + # the CO2/display `_secondary_fuel_cost_gbp_per_kwh`. It must apply the + # SAME `_GOV_API_COLLISION_FUELS` canonicalisation, else an ANTHRACITE + # secondary (gov-API `secondary_fuel_type=5`) resolves to the same-value + # Table-32 code 5 = bulk LPG (12.19 p/kWh) instead of anthracite + # (3.64 p/kWh) — a 3× over-cost that sank corpus cert 100050355518 + # (lodged 36, engine 20.8). Anthracite room-heater secondary on a + # mains-gas boiler main. + gas_boiler_main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=26, heat_emitter_type=1, + emitter_temperature=1, main_heating_control=2106, + main_heating_category=2, sap_main_heating_code=102, + ) + anthracite_secondary_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[make_building_part(construction_age_band="D")], + sap_heating=make_sap_heating( + main_heating_details=[gas_boiler_main], + secondary_fuel_type=5, # gov-API enum: anthracite + secondary_heating_type=631, # room heater + ), + ) + + # Act + inputs = cert_to_inputs(anthracite_secondary_epc) + result = Sap10Calculator().calculate(anthracite_secondary_epc) + fc = inputs.fuel_cost + secondary_kwh = result.secondary_heating_fuel_kwh_per_yr + + # Assert — anthracite 3.64 p/kWh (0.0364), NOT the collision LPG + # 12.19 p/kWh the raw same-value Table-32 lookup returns. + assert secondary_kwh > 0.0 + assert abs(fc.secondary_total_cost_gbp - secondary_kwh * 0.0364) <= 1e-6 + assert abs(fc.secondary_total_cost_gbp - secondary_kwh * 0.1219) > 1.0 + + def test_is_electric_water_dual_fuel_table_32_code_10_is_not_electric() -> None: # Arrange — same API/Table 32 collision as `_is_electric_main` per # S0380.136 docstring. @@ -4841,6 +5091,93 @@ def test_hot_water_from_pcdb_heat_pump_bills_at_app_n_wh_high_rate() -> None: assert abs(rate_immersion - 0.0750) <= 1e-6 +def test_instantaneous_electric_dhw_off_peak_bills_at_high_rate() -> None: + # Arrange — SAP 10.2 §12 (PDF line 2680): the off-peak on-peak HW + # proportion (Table 13) is "dependent on the total floor area and the + # CYLINDER SIZE" — it presumes a stored-water cylinder that can be + # charged overnight. An INSTANTANEOUS point-of-use electric heater + # (WHC 907/909, no cylinder) heats water on demand and CANNOT shift to + # the off-peak window, so 100% of its consumption is at the high rate + # (Table 12a high-rate fraction 1.0). The generic off-peak else-branch + # billed it at 100% off-peak LOW rate (5.50 p/kWh at 7-hour), 3× under- + # costing the DHW and over-rating the dwelling — corpus cert 74061136 + # (HHR-storage flat, WHC 909) lodged 73, engine 80.7. + from domain.sap10_calculator.tables.table_12a import Tariff + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _hot_water_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] + _hot_water_high_rate_fraction, # pyright: ignore[reportPrivateUsage] + ) + storage_main = MainHeatingDetail( + has_fghrs=False, main_fuel_type=29, heat_emitter_type=0, + emitter_temperature="NA", main_heating_control=2404, + main_heating_category=7, sap_main_heating_code=409, + ) + + # Act — instantaneous electric DHW (WHC 909) on a 7-hour off-peak tariff. + rate = _hot_water_fuel_cost_gbp_per_kwh( + 29, storage_main, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES, + water_heating_code=909, + ) + fraction = _hot_water_high_rate_fraction( + 29, storage_main, Tariff.SEVEN_HOUR, water_heating_code=909, + ) + + # Assert — 100% at the 7-hour HIGH rate (15.29 p/kWh), NOT the low 5.50. + assert abs(rate - 0.1529) <= 1e-6 + assert fraction == 1.0 + + +def test_heat_pump_water_efficiency_is_floored_at_100pct_per_app_n3_7() -> None: + # Arrange — SAP 10.2 Appendix N3.7 ("Thermal efficiency for water + # heating – heat pumps", PDF p.109): "multiply the thermal efficiency + # (ηwater) for water heating by the in-use factor in Table N8; subject + # to a MINIMUM EFFICIENCY OF 100%." Our `_heat_pump_apm_efficiencies` + # applied the in-use factor but omitted the floor, so an oversized heat + # pump whose PSR-extended ηwater × 0.60 in-use fell below 100% billed + # water heating at that sub-100% efficiency (over-counting HW fuel). + # + # Accredited anchor: golden fixture case 56 (PCDB 100061, the config of + # cert 100110101713). At HLC 107.82 W/K the PSR is 3.107, above the + # record's largest PSR 2.0, so the Appendix N2 extension takes ηwater,3 + # from 198.9% toward 100% at 2 x 2.0 = 4.0 → 128.55%; × the 0.60 in-use + # factor (Open-EPC certs never lodge cylinder HX area → criteria fail) + # = 77.13% < 100% → the worksheet (216) reads 100.0000. In-range PSR + # (case 54, HLC large) keeps 0.60 × 198.9 = 119.34% (worksheet case 54 + # (216) = 112.5% for its 187.5% record — both above the floor, unchanged). + from domain.sap10_calculator.tables.pcdb import heat_pump_record + + record = heat_pump_record(100061) + assert record is not None + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, # electricity (heat pump) + heat_emitter_type=1, # radiators + emitter_temperature=0, + main_heating_control=2210, + main_heating_category=4, + sap_main_heating_code=None, + main_heating_index_number=100061, + ) + epc = _typical_semi_detached_epc() # no specified cylinder → in-use 0.60 + + # Act — oversized PSR (extension region) vs an in-range PSR. + _space_ext, water_ext = _heat_pump_apm_efficiencies( + main=hp_main, hp_record=record, + hlc_annual_avg_w_per_k=107.82, # PSR 3.107 > largest 2.0 + epc=epc, + ) or (None, None) + _space_in, water_in = _heat_pump_apm_efficiencies( + main=hp_main, hp_record=record, + hlc_annual_avg_w_per_k=400.0, # PSR 0.837, in range + epc=epc, + ) or (None, None) + + # Assert — extended HP water efficiency is floored at 100% (1.0); the + # in-range PSR keeps the un-floored 0.60 × 198.9% = 119.34%. + assert water_ext is not None and abs(water_ext - 1.0) < 1e-9 + assert water_in is not None and abs(water_in - 0.60 * 198.9 / 100.0) < 1e-9 + + def test_hot_water_immersion_off_peak_bills_at_table_13_blend() -> None: # Arrange — SAP 10.2 Table 12a (PDF p.191) "Immersion water heater" # row routes the WH column to Table 13 (PDF p.197). For an electric @@ -6264,6 +6601,237 @@ def test_loose_jacket_cylinder_computes_storage_loss_via_table2_loose_jacket_bra assert got_jan_kwh > 36.9530 # loose jacket loses more than factory +def test_uninsulated_cylinder_computes_storage_loss_via_table2_jacket_t0() -> None: + """SAP 10.2 Table 2 (PDF p.158): an UNINSULATED cylinder takes the + loose-jacket loss factor at t = 0 — L = 0.005 + 1.76 / 12.8 = 0.1425 + kWh/L/day, the worst storage loss SAP knows. The EPB API lodges + cylinder_insulation_type=0 = no insulation (1 = factory, 2 = loose + jacket) with no thickness field. Before this fix + `_cylinder_storage_loss_override` returned None for type 0, so the + worksheet kept the zero-storage-loss combi default and the dwelling + OVER-RATED — the opposite of what "no insulation" means. Portfolio + 814's audit (2026-07-03) found all 26 such properties over-rated + +1..+18 SAP vs lodged (avg +12.7 for type 0); same-lodged WD5 0DP + neighbours (pids 742215/742216, both lodged 54, identical building + parts) split effective 50 vs 69 because one lodged a 12 mm jacket and + the other lodged no insulation. + """ + # Arrange — identical to the loose-jacket storage-loss test but + # cylinder_insulation_type=0 (none) and no lodged thickness. + from domain.sap10_calculator.worksheet.water_heating import ( + cylinder_storage_loss_factor_table_2, + cylinder_temperature_factor_table_2b, + cylinder_volume_factor_table_2a, + ) + + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2206, + main_heating_category=4, + sap_main_heating_code=None, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_building_parts=[make_building_part()], + sap_heating=make_sap_heating( + main_heating_details=[hp_main], + water_heating_code=901, + cylinder_size=3, # Medium → 160 L + cylinder_insulation_type=0, # no insulation + cylinder_insulation_thickness_mm=None, + cylinder_thermostat="Y", + ), + ) + # Expected (56)m Jan from the Table 2 loose-jacket branch at t=0 — + # the SAP uninsulated-cylinder factor (same V / VF / TF as the + # loose-jacket test; only the thickness differs). + loss_factor = cylinder_storage_loss_factor_table_2( + insulation_type="loose_jacket", thickness_mm=0.0 + ) + vol_factor = cylinder_volume_factor_table_2a(160.0) + temp_factor = cylinder_temperature_factor_table_2b( + has_cylinder_thermostat=True, separately_timed_dhw=True + ) + expected_jan_kwh = 160.0 * loss_factor * vol_factor * temp_factor * 31 + + # Act + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=1.7, + is_instantaneous=False, + primary_age="D", + pcdb_record=None, + ) + + # Assert — non-None (was the zero-loss combi default) and equal to + # the loose-jacket branch at t=0; an uninsulated cylinder must lose + # MORE than the 50 mm loose-jacket case, never less. + assert wh_result is not None + got_jan_kwh = wh_result.solar_storage_monthly_kwh[0] + assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4 + jacket_50mm_jan_kwh = ( + 160.0 + * cylinder_storage_loss_factor_table_2( + insulation_type="loose_jacket", thickness_mm=50.0 + ) + * vol_factor + * temp_factor + * 31 + ) + assert got_jan_kwh > jacket_50mm_jan_kwh + + +def test_elmhurst_no_insulation_cylinder_thickness_zero_takes_uninsulated_loss() -> None: + """The Elmhurst site-notes mapper lodges a "No Insulation" cylinder as + `cylinder_insulation_type=None` + `cylinder_insulation_thickness_mm=0` + — per its own comment "the lodged §15.1 Insulation Thickness (0 mm) + carries the storage-loss signal the cascade's SAP 10.2 Table 2 + dispatch needs" (`_ELMHURST_CYLINDER_NO_INSULATION_LABELS`, + datatypes/epc/domain/mapper.py). The cascade must honour that signal: + a lodged 0 mm with no insulation type is the uninsulated cylinder — + Table 2 loose-jacket branch at t = 0 — not the zero-loss combi + default it previously fell to (the type-None guard returned None + before the thickness was ever read). + """ + # Arrange — same dwelling as the type-0 test; only the lodgement + # shape differs (type None + explicit 0 mm, the Elmhurst path). + from domain.sap10_calculator.worksheet.water_heating import ( + cylinder_storage_loss_factor_table_2, + cylinder_temperature_factor_table_2b, + cylinder_volume_factor_table_2a, + ) + + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2206, + main_heating_category=4, + sap_main_heating_code=None, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_building_parts=[make_building_part()], + sap_heating=make_sap_heating( + main_heating_details=[hp_main], + water_heating_code=901, + cylinder_size=3, # Medium → 160 L + cylinder_insulation_type=None, # Elmhurst "No Insulation" + cylinder_insulation_thickness_mm=0, + cylinder_thermostat="Y", + ), + ) + expected_jan_kwh = ( + 160.0 + * cylinder_storage_loss_factor_table_2( + insulation_type="loose_jacket", thickness_mm=0.0 + ) + * cylinder_volume_factor_table_2a(160.0) + * cylinder_temperature_factor_table_2b( + has_cylinder_thermostat=True, separately_timed_dhw=True + ) + * 31 + ) + + # Act + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=1.7, + is_instantaneous=False, + primary_age="D", + pcdb_record=None, + ) + + # Assert — identical series to the gov-API type-0 lodgement: the two + # front-ends describe the same uninsulated cylinder. + assert wh_result is not None + got_jan_kwh = wh_result.solar_storage_monthly_kwh[0] + assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4 + + +def test_cylinder_with_unknown_insulation_defaults_per_table_29_age_band() -> None: + """RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not + accessible": when a cylinder is lodged but its insulation is unknown + (type absent AND no thickness — ADR-0028 records ~308/1000 17.0 certs + omitting `cylinder_insulation_type`), the age-band default applies: + bands A-F → 12 mm loose jacket, G/H → 25 mm factory, I-M → 38 mm + factory. This is the same Table 29 row the §10.7 no-water-heating + default already uses. Previously the cascade returned None (zero + storage loss), silently treating an unknown cylinder as a combi. + Distinct from the uninsulated case: a lodged 0 mm means NO insulation + (t = 0), an absent thickness means NOT ACCESSIBLE (Table 29). + """ + # Arrange — same dwelling, insulation fields both absent; the + # fixture's building part lodges construction_age_band "B" → the + # Table 29 A-F row (12 mm loose jacket). + from domain.sap10_calculator.worksheet.water_heating import ( + cylinder_storage_loss_factor_table_2, + cylinder_temperature_factor_table_2b, + cylinder_volume_factor_table_2a, + ) + + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2206, + main_heating_category=4, + sap_main_heating_code=None, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_building_parts=[make_building_part()], # age band B + sap_heating=make_sap_heating( + main_heating_details=[hp_main], + water_heating_code=901, + cylinder_size=3, # Medium → 160 L + cylinder_insulation_type=None, # not lodged + cylinder_insulation_thickness_mm=None, # not lodged + cylinder_thermostat="Y", + ), + ) + expected_jan_kwh = ( + 160.0 + * cylinder_storage_loss_factor_table_2( + insulation_type="loose_jacket", thickness_mm=12.0 + ) + * cylinder_volume_factor_table_2a(160.0) + * cylinder_temperature_factor_table_2b( + has_cylinder_thermostat=True, separately_timed_dhw=True + ) + * 31 + ) + + # Act + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=1.7, + is_instantaneous=False, + primary_age="D", + pcdb_record=None, + ) + + # Assert — the Table 29 band-B default (12 mm loose jacket), not the + # zero-loss combi default and not the harsher uninsulated t=0. + assert wh_result is not None + got_jan_kwh = wh_result.solar_storage_monthly_kwh[0] + assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4 + + def test_no_water_heating_default_age_a_to_f_uses_12mm_loose_jacket_per_table_29() -> None: """RdSAP 10 §10.7 + Table 29 (PDF p.55-56): when no water heating system is lodged, the default cylinder takes the age-band insulation, diff --git a/tests/domain/sap10_calculator/test_calculator.py b/tests/domain/sap10_calculator/test_calculator.py index 7ab7d3c0c..12157d172 100644 --- a/tests/domain/sap10_calculator/test_calculator.py +++ b/tests/domain/sap10_calculator/test_calculator.py @@ -815,3 +815,20 @@ def test_split_tariff_charges_space_heating_at_off_peak_rate() -> None: + (r_e7.pumps_fans_kwh_per_yr + r_e7.lighting_kwh_per_yr) * 0.1319 ) assert r_e7.total_fuel_cost_gbp == pytest.approx(expected_cost, rel=1e-6) + + +def test_calculate_exposes_the_design_heat_loss_for_heat_pump_sizing() -> None: + # The ASHP recommendation sizes the pump to the dwelling's SAP design heat + # loss — the annual-average HLC at the design temperature difference (24.2 K), + # the quantity Appendix N's PSR divides the pump's rated output by. The + # calculator surfaces it as a typed output, derived from the HLC it already + # computes. + # Arrange + inputs = _baseline_inputs() + + # Act + result = calculate_sap_from_inputs(inputs) + + # Assert — HLC (W/K) x 24.2 K / 1000 = design heat loss (kW). + hlc = result.intermediate["heat_transfer_coefficient_w_per_k"] + assert result.design_heat_loss_kw == pytest.approx(hlc * 24.2 / 1000) diff --git a/tests/domain/sap10_calculator/test_pcdb_table_362_lookup.py b/tests/domain/sap10_calculator/test_pcdb_table_362_lookup.py index fe3d40b61..58e97446a 100644 --- a/tests/domain/sap10_calculator/test_pcdb_table_362_lookup.py +++ b/tests/domain/sap10_calculator/test_pcdb_table_362_lookup.py @@ -180,3 +180,88 @@ def test_interpolate_heat_pump_efficiency_at_cert_0380_psr_per_sap_app_n() -> No # ≈ 0.0035077 → eta_water_3 ≈ 285.0861 assert abs(eta_space_1 - 234.5235) < 1e-3 assert abs(eta_water_3 - 285.0861) < 1e-3 + + +def test_interpolate_extends_above_largest_psr_toward_100pct_per_app_n() -> None: + """SAP 10.2 Appendix N2 (PDF p.101, footnote 44/45) — PSR above the + record's largest value extends the efficiency toward 100%, it is NOT + clamped to the top-of-table value. + + "in the case of a heat pump (ground, water or air source), where + the PSR is greater than the largest value in the data record, an + efficiency may be obtained from linear interpolation between that + at the largest PSR in the data record and efficiency 100% at PSR + two times the largest PSR in the data record. If the PSR is + greater than two times the largest PSR in the data record an + efficiency of 100% should be used." + + Interpolation is reciprocal-linear (footnote 43). Accredited anchor: + Elmhurst worksheet for cert 100110101713 / "golden fixture debugging" + case 56 (PCDB 100061, ECODAN 8.5 kW, largest PSR row η_space,1=352.0). + The dwelling HLC (39) = 107.8199 W/K and max output 8.106 kW give + + PSR = 8.106 × 1000 / (107.8199 × 24.2) = 3.106650 + + which exceeds the record's largest PSR (2.0). The spec extension to + 100% at 2 × 2.0 = 4.0 yields, at t = (3.106650 − 2.0)/(4.0 − 2.0): + + 1/η = (1 − t)/352.0 + t/100.0 → η_space,1 = 147.011 + + so that (206) = 0.95 × 147.011 = 139.660 — matching the accredited + worksheet exactly. The previous top-of-table clamp returned 352.0 + (→ 334.4%), over-rating the dwelling by +18 SAP. + """ + from domain.sap10_calculator.tables.pcdb.parser import ( + interpolate_heat_pump_efficiency_at_psr, + ) + + record = heat_pump_record(100061) + assert record is not None + assert record.psr_groups[-1].psr == 2.0 + assert record.psr_groups[-1].eta_space_1_pct == 352.0 + + eta_space_1, _eta_water_3 = interpolate_heat_pump_efficiency_at_psr( + record.psr_groups, target_psr=3.106649864134083, + ) + + assert abs(eta_space_1 - 147.011) < 1e-2 + assert abs(0.95 * eta_space_1 - 139.6604) < 1e-2 + + +def test_interpolate_above_twice_largest_psr_is_100pct_per_app_n() -> None: + """SAP 10.2 Appendix N2 — beyond twice the largest PSR the efficiency + is exactly 100% (the upper terminus of the extension), for both space + and water heating PSR-dependent results.""" + from domain.sap10_calculator.tables.pcdb.parser import ( + interpolate_heat_pump_efficiency_at_psr, + ) + + record = heat_pump_record(100061) + assert record is not None + + eta_space_1, eta_water_3 = interpolate_heat_pump_efficiency_at_psr( + record.psr_groups, target_psr=9.0, # > 2 × 2.0 + ) + + assert eta_space_1 == 100.0 + assert eta_water_3 == 100.0 + + +def test_interpolate_below_smallest_psr_is_100pct_per_app_n() -> None: + """SAP 10.2 Appendix N2 (PDF p.101) — "For all heat pumps, an + efficiency of 100% should be used if the PSR is less than the smallest + value in the database record." (Not clamped to the smallest row.)""" + from domain.sap10_calculator.tables.pcdb.parser import ( + interpolate_heat_pump_efficiency_at_psr, + ) + + record = heat_pump_record(100061) + assert record is not None + assert record.psr_groups[0].psr == 0.2 + + eta_space_1, eta_water_3 = interpolate_heat_pump_efficiency_at_psr( + record.psr_groups, target_psr=0.1, # < 0.2 + ) + + assert eta_space_1 == 100.0 + assert eta_water_3 == 100.0 diff --git a/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py b/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py index 5575384f4..f792c519a 100644 --- a/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py +++ b/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py @@ -122,12 +122,18 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( # (engine uses the cert's measured 0.19/0.11/0.11 U-values; Elmhurst uses # age-band L proxies + party-wall default) plus FGHRS (cert idx 60031) omitted # on BOTH sides (the engine can't yet model full-SAP FGHRS). PINNED TO THE - # OBSERVED 82, not lodged 84 — mapping deliberately untuned. + # OBSERVED 83 (was 82), not lodged 84 — mapping deliberately untuned. + # WAS 82 until the full-SAP electricity-tariff → RdSAP meter_type fix: this + # cert lodges energy_tariff=1 (standard), which the mapper previously passed + # through untranslated as RdSAP meter_type "1" — wrongly read as dual/Economy 7 + # and priced on the off-peak high/low split. Translating it to "single" (the + # correct standard tariff) re-prices its electricity at the flat rate, lifting + # this gas semi 82→83. No PV (sap_energy_source.pv_arrays absent). RealCertExpectation( schema="SAP-Schema-17.1", sample="uprn_10093116528", cert_num="8000-8495-2839-2607-9683", - sap_score=82, + sap_score=83, ), # UPRN 10093116543 → cert 8358-7436-5620-6889-0906. SAP-Schema-17.1 — a # FULL-SAP cert (2017 mains-gas COMBI semi, Emsworth), forced through the @@ -290,13 +296,46 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( # control 2106 (CBE); water from primary (combi); MEV on; AP50 Blower Door 3.5. # The −3 vs lodged 85 is the documented full-SAP→RdSAP gap: the engine uses the # cert's MEASURED U (wall 0.24 / floor 0.13, WORSE than RdSAP band-M defaults) - # + MEV priced as extract loss not heat recovery. PINNED to the observed 82 — - # mapping untuned; engine == Elmhurst. + # + MEV priced as extract loss not heat recovery. PINNED to the observed 84 + # (was 82), still −1 vs lodged 85 — mapping untuned. + # WAS 82 until full-SAP lodged PV mapping landed: this cert lodges a 0.38 kWp + # array under sap_energy_source.pv_arrays (SE-facing, pitch 30°, unshaded) that + # the schema dropped at parse, so the Appendix-M generation credit was lost. + # Carrying it (mapper `_sap_17_1_pv_arrays`) credits the generation and lifts + # this flat 82→84, closing most of the gap to the lodged 85 the array explains. + # WAS 84 until the full-SAP export-capable mapping landed (PRD #1435 WS3): + # this cert lodges `is_dwelling_export_capable: true`, which the mapper + # hardcoded False, so Appendix M1 zeroed the array's export credit. Carrying + # the lodged flag restores it and the engine now reproduces the lodged 85 + # EXACTLY — the last −1 was the missing export credit, not a methodology gap. RealCertExpectation( schema="SAP-Schema-19.1.0", sample="uprn_10096028301", cert_num="0390-3321-6060-2405-7985", - sap_score=82, + sap_score=85, + ), + # UPRN 10094615101 → cert 0380-3044-6070-2305-5925. SAP-Schema-19.1.0 — a + # FULL-SAP TOP-FLOOR FLAT (2023 new build, St Leonards-on-Sea, TFA 76 m², + # PRD #1435 WS3 evidence property 741840): PCDB HHRSH (230003 ×4) on an + # off-peak tariff, hot-water heat pump (code 914) + WWHRS, MEV (PCDB + # 500298), measured AP50 4.65, calculated per-junction thermal bridges, and + # a 1.26 kWp EXPORT-CAPABLE PV array (`is_dwelling_export_capable: true`, + # pv_connection 2). Lodged under SAP 10.2 itself (Design SAP 10 2.21.9), so + # the lodged 92 is the same-spec accredited ground truth — no Elmhurst + # rebuild needed. The engine produced 89 until the full-SAP export-capable + # mapping landed: `from_sap_schema_17_1` hardcoded + # `is_dwelling_export_capable=False` (the flag and `pv_connection` are + # top-level full-SAP fields the schema dataclass never parsed), so SAP 10.2 + # Appendix M1 zeroed the PV export credit (~460 kWh × 13.19p ≈ £61/yr). + # Carrying the lodged flag restores the credit and the engine reproduces + # the lodged 92 EXACTLY. Residual CO2/PE deltas (~252 vs ~200 kg CO2/yr; + # PE 41.7 vs 35) are demand-side follow-ups (lodged TMP 64 vs default 250, + # WWHRS, MEV PCDB fan power) — within SAP tolerance, tracked separately. + RealCertExpectation( + schema="SAP-Schema-19.1.0", + sample="uprn_10094615101", + cert_num="0380-3044-6070-2305-5925", + sap_score=92, ), # UPRN 44012843 → cert 0775-2898-6628-9594-8005. SAP-Schema-16.3 — a # reduced-field (RdSAP-shaped) ground-floor FLAT, band K (2007-2011), cavity @@ -326,14 +365,20 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( # worksheet SAP 80 — engine EXACTLY matches (80.13 vs 80); engine-on-Elmhurst's- # own-parsed-inputs 81.03 ≈ 80 → calculator faithful. Boiler set to the cert's # exact PCDB 16211 via the search dialog; control 2106 (CBE); water from primary - # (combi); MEV on; AP50 Blower Door 3.2; party wall 6.43 m entered. The −2 vs - # lodged 82 is the documented full-SAP→RdSAP gap (measured U 0.2/0.1 + MEV - # extract loss). PINNED to the observed 80 — mapping untuned; engine == Elmhurst. + # (combi); MEV on; AP50 Blower Door 3.2; party wall 6.43 m entered. + # WAS 80 (engine == Elmhurst, both built WITHOUT PV) until full-SAP lodged PV + # mapping landed: this cert lodges a 0.48 kWp array under + # sap_energy_source.pv_arrays (SE-facing, pitch 30°, unshaded) the schema + # dropped at parse. Crediting it (mapper `_sap_17_1_pv_arrays`) closes the + # −2 gap exactly — the engine now reproduces the accredited lodged 82. The + # Elmhurst worksheet (80) omitted the PV (not entered in the RdSAP build), so + # the +2 over Elmhurst is the now-credited array, not a calculator drift. + # PINNED to the observed 82 == lodged 82 — mapping untuned. RealCertExpectation( schema="SAP-Schema-17.0", sample="uprn_10023444324", cert_num="8501-5064-6739-1407-0163", - sap_score=80, + sap_score=82, ), # UPRN 10023444320 → cert 0868-6045-7331-4376-0914. SAP-Schema-17.0 — FULL-SAP # MID-FLOOR FLAT (sibling of 10023444324, same block / combi PCDB 16211 / MEV), @@ -342,12 +387,24 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( # worksheet 82 — engine within ~1 (81.38 vs 82); engine-on-Elmhurst-inputs 82.46 # ≈ 82 → calculator faithful. Boiler PCDB 16211 via search; control 2106 (CBE); # water from primary (combi); MEV on; AP50 Blower Door 3.09; mid-floor (floor = - # another dwelling below). PINNED to the observed 81 — mapping untuned. + # another dwelling below). + # WAS 81 until full-SAP lodged PV mapping landed: this cert lodges the SAME + # 0.48 kWp array as its ground-floor sibling 10023444324 under + # sap_energy_source.pv_arrays (the block's roof PV apportioned to the flat on + # the lodged cert). Crediting it faithfully (mapper `_sap_17_1_pv_arrays`) + # lifts this flat 81→83. NOTE this lands +2 OVER the lodged 81 (and +1 over the + # Elmhurst worksheet 82) — unlike the ground-floor sibling whose pre-PV engine + # was 2 UNDER lodged so the same array closed the gap exactly. The mid-floor's + # pre-PV engine already matched lodged, so the credited array now overshoots: + # the lodged 81 does not appear to carry the array's full generation credit + # that SAP Appendix-M awards it. This is the documented full-SAP→RdSAP residual + # (faithful to the cert's lodged PV, not tuned to the lodged integer). PINNED + # to the observed 83 — mapping untuned. RealCertExpectation( schema="SAP-Schema-17.0", sample="uprn_10023444320", cert_num="0868-6045-7331-4376-0914", - sap_score=81, + sap_score=83, ), # UPRN 10090844932 → cert 0646-3008-6208-0619-6204. RdSAP-Schema-20.0.0 — # END-TERRACE HOUSE, 2-storey, band L (2012-2022), cavity insulated, pitched @@ -649,6 +706,334 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( cert_num="6102-6227-8000-0083-2292", sap_score=66, ), + # UPRN 100020942571 → cert 0018-2049-6232-4431-6934. RdSAP-Schema-18.0 — + # 8 Booth Close SE28 8BW, an END-TERRACE GROUND-FLOOR MAISONETTE (portfolio + # 814 pid 742121): band E, system-built walls internally insulated, roof + # "another dwelling above", ground+external-air solid floors, 1 extension, + # fully double glazed, ELECTRIC ROOM HEATERS (SAP 691) on a SINGLE-rate meter + # + GAS-MULTIPOINT hot water (no cylinder), mains gas available. Lodged 50; + # engine produces 54. Built in Elmhurst RdSAP10 on the lodged inputs (evidence + # saved: elmhurst_summary.pdf / elmhurst_worksheet.pdf): Elmhurst worksheet + # SAP 53, HTC ~167 W/K — engine 54 = Elmhurst 53 within 1 point → calculator + # faithful. (The first build read 50 only because the reused Elmhurst test + # assessment carried 2 phantom open chimneys + a Dual/10-hour meter; the cert + # lodges open_fireplaces_count=0 and meter_type=2 Single. Cleared → 53.) + # This is the BASELINE pin. The HHRSH recommendation for this dwelling is the + # subject of the storage-heater off-peak-pricing fix (separate PR): engine + # HHRSH 48→73 vs accredited Elmhurst 71. PINNED engine 54. + RealCertExpectation( + schema="RdSAP-Schema-18.0", + sample="uprn_100020942571", + cert_num="0018-2049-6232-4431-6934", + sap_score=54, + ), + # UPRN 4510053280 (corpus-1000 C001; the corpus doc lodges no cert number). + # RdSAP-Schema-21.0.1 native — GROUND-FLOOR FLAT 47 m², band B, solid brick + # 520 mm uninsulated (+ 8.12 m² sheltered alternative wall), roof "(another + # dwelling above)", ASHP radiators PCDB 100053 (Mitsubishi Ecodan 5 kW, + # 2010 record: PSR table 0.2–2.0, η_space 332.8% @ 2.0, η_water 187.5%, + # duration "24"), no cylinder, electric room-heater secondary, no PV. + # Lodged 51 / engine 74.94 (Δ +23.9, the corpus' worst cert). Dwelling PSR + # = 4.62 kW / (94.0 W/K × 24.2 K) = 2.031, just past the record's largest + # 2.0 → SAP 10.2 N2 (PDF p.101 footnotes 44/45) heat-pump extension: + # reciprocal-interpolate η_space toward 100% at PSR 4.0 → 321% × 0.95 = + # 305%. That is what the engine does, and what accredited Elmhurst did on + # the SAME record family / same PSR shape (golden case 56, record 100061, + # PSR 3.107 → 139.66%). The LODGED software instead treated the + # out-of-range record as invalid and billed direct electric: forcing + # η_space = η_water = 100% + the standard 9-h schedule reproduces the + # lodged CO2 to 1% (0.808 vs 0.800 t) and PE within 6% (178 vs 190), + # whereas the engine's spec path gives 0.444 t / 98.8. A lodged-software + # methodology gap (worklist ⚠), NOT an engine bug — do not tune. PINNED to + # the observed engine 75. + RealCertExpectation( + schema="RdSAP-Schema-21.0.1", + sample="uprn_4510053280", + cert_num="uprn-4510053280", + sap_score=75, + ), + # UPRN 10094975827 (corpus; no cert number lodged). RdSAP-Schema-21.0.1 + # native — TOP-FLOOR FLAT, band B, solid brick, pitched roof lodged + # thickness "NI" + description "Pitched, insulated (assumed)", electric + # room heaters. The "insulated (assumed)" Table-18 cert: presence-assumed + # insulation is NOT the §5.11.4 observed-retrofit 50 mm row (0.68) — it + # takes the Table 18 age-band default (band B pitched → 0.40), the same + # value the "ND" lodgement of this description reaches. Was engine 64.13 + # (Δ −1.87 vs lodged 66); the fix lands 65.83 → rounds to 66 = lodged. + # PINNED to the observed engine 66 = lodged. + RealCertExpectation( + schema="RdSAP-Schema-21.0.1", + sample="uprn_10094975827", + cert_num="uprn-10094975827", + sap_score=66, + ), + # UPRN 100050355518 (corpus C003; no cert number lodged). RdSAP-21.0.1 + # native — SEMI-DETACHED HOUSE, 122 m², 2 extensions (3 building parts), + # mains-gas boiler main (SAP 117) + ANTHRACITE room-heater secondary + # (gov-API secondary_fuel_type=5, SAP 631). The secondary-fuel collision + # cert: enum 5 collides with Table-32 code 5 (bulk LPG 12.19 p/kWh); the + # SAP-driving _fuel_cost path priced the anthracite secondary at the LPG + # rate instead of anthracite 3.64 p/kWh — a 3× over-cost on ~1431 kWh + # (+£100/yr → ECF 6.24 vs 4.67). Engine was 20.8 (Δ −15.2 vs lodged 36); + # canonicalising the secondary fuel lands 35.5 = lodged 36. CO2/PE + # already matched lodged (0.055 t / 1.7 kWh) — a pure cost-side gap, the + # signature that localised it. PINNED to the observed engine 36 = lodged. + RealCertExpectation( + schema="RdSAP-Schema-21.0.1", + sample="uprn_100050355518", + cert_num="uprn-100050355518", + sap_score=36, + ), + # UPRN 74061136 (corpus; no cert number lodged). RdSAP-21.0.1 native — + # MID-FLOOR FLAT 24 m², band B, HHR electric storage heaters (SAP 409) on + # an Unknown meter (type 3 → off-peak 7-hour per RdSAP 10 §12 Rule 2), + # INSTANTANEOUS electric point-of-use DHW (WHC 909, no cylinder). The + # instantaneous-DHW off-peak cert: its DHW was billed 100% at the 7-hour + # LOW rate (5.50 p) but SAP 10.2 §12 (Table-13 on-peak split is keyed on + # CYLINDER size) presumes stored water — an instantaneous heater has no + # cylinder, heats on demand, so 100% bills at the HIGH rate (15.29 p). + # Mid-floor flat → roof/floor both zero-loss, so PE matched lodged (+1.6) + # and the +7.72 over-rate isolated cleanly to the DHW tariff. Fix lands + # 71.75 (Δ −1.25 vs lodged 73); the tariff error is gone, the residual is + # a separate small fabric effect. PINNED to the observed engine 72. + RealCertExpectation( + schema="RdSAP-Schema-21.0.1", + sample="uprn_74061136", + cert_num="uprn-74061136", + sap_score=72, + ), + # UPRN 200004296092 (corpus; no cert number lodged). RdSAP-21.0.1 native — + # END-TERRACE HOUSE, TFA 62, age B, mains-gas COMBI (PCDB 16137), SOLID + # BRICK 360 mm "with external insulation" (wall_insulation_type 1) but + # thickness lodged "NI" (unknown). The wall added-insulation unknown- + # thickness cert: the §5.8 R-value gate needs thickness > 0, so the + # externally-insulated wall fell through to its UNINSULATED §5.6 U (1.40, + # walls 101.7 W/K, HLP 3.46) → PE +59.6, engine 64.35 (Δ −6.65 vs lodged + # 71). RdSAP 10 §5.4 (PDF p.32) "assume 100 mm if thickness is unknown" + # applies the 100 mm §5.8 default → U ~0.29, engine 71.74 = lodged. PINNED + # to the observed engine 72. + RealCertExpectation( + schema="RdSAP-Schema-21.0.1", + sample="uprn_200004296092", + cert_num="uprn-200004296092", + sap_score=72, + ), + # UPRN 200004017091 (corpus; no cert number lodged). RdSAP-21.0.1 native — + # END-TERRACE HOUSE, mains-gas boiler, hot-water cylinder lodged with + # `cylinder_size=0` (present but "size not determined") + has_cylinder=true. + # The unsized-cylinder storage-loss cert: `_cylinder_storage_loss_override` + # read the volume via `_cylinder_volume_l_from_code` → None for size 0 → + # bailed and DROPPED the cylinder's Table-2 storage loss, under-costing the + # DHW → engine 72.75 (Δ +1.75 vs lodged 71). RdSAP 10 §10.5 Table 28: an + # unsized present cylinder is the 110 L "Normal" default and still incurs + # the loss → engine 71.39 = lodged 71. PINNED to the observed engine 71. + RealCertExpectation( + schema="RdSAP-Schema-21.0.1", + sample="uprn_200004017091", + cert_num="uprn-200004017091", + sap_score=71, + ), + # UPRN 47084930 (corpus; no cert number lodged). RdSAP-21.0.1 native — + # TOP-FLOOR FLAT, band E, cavity wall + 100 mm internal insulation, FLAT + # ROOF insulated 75 mm, floor over other premises, mains-gas combi. The + # flat-roof insulation-thickness cert: the 75 mm is lodged in + # `flat_roof_insulation_thickness`, but heat_transmission read + # `roof_insulation_thickness` (None) → billed the flat roof at the + # uninsulated age-E default 1.5 (roof 96.4 W/K) → PE +47, engine 69.51 + # (Δ −4.49 vs lodged 74). Preferring the flat field → U 0.5 (roof 32.1 + # W/K), engine 74.34 = lodged 74. VALIDATED against the accredited + # Elmhurst worksheet (built on the lodged inputs, evidence saved: + # elmhurst_summary.pdf / elmhurst_worksheet.pdf): "insulated flat roof" + # U 0.5, floor 0.70 and wall 0.25 both matching the engine. PINNED 74. + RealCertExpectation( + schema="RdSAP-Schema-21.0.1", + sample="uprn_47084930", + cert_num="uprn-47084930", + sap_score=74, + ), + # UPRN 100050881708 (corpus; no cert number lodged). RdSAP-21.0.1 native — + # SEMI-DETACHED HOUSE, band B, cavity FILLED wall, pitched 225 mm loft, + # SUSPENDED ground floor "to unheated space", mains-gas combi, no cylinder. + # Lodged 70 / engine 72 (71.55). Built in accredited Elmhurst RdSAP10 + # (evidence saved: elmhurst_summary.pdf / elmhurst_worksheet.pdf): the WALL + # (cavity, U 0.70, 69.56 m²) and ROOF (insulated slope 225 mm, U 0.19) + # match the engine EXACTLY (walls 48.8 W/K, roof 6.0 W/K). The engine is + # faithful on the checkable fabric; the +1.55 vs lodged is a small + # methodology gap. (Elmhurst''s worksheet SAP 61 is NOT comparable — the + # build entered the suspended ground floor as an upper "above unheated + # space" floor, which Elmhurst bills via Table-20 exposed 1.20; the cert''s + # §3.12 suspended-ground-floor U is 0.74, which the engine uses.) PINNED to + # the observed engine 72. + RealCertExpectation( + schema="RdSAP-Schema-21.0.1", + sample="uprn_100050881708", + cert_num="uprn-100050881708", + sap_score=72, + ), + # UPRN 217091901 (corpus; no cert number lodged). RdSAP-21.0.1 native — + # TOP-FLOOR FLAT, band A (before 1900), SOLID BRICK 280 mm uninsulated, + # FLAT ROOF no insulation (As Built), floor over ANOTHER DWELLING below + # (zero loss), mains-gas COMBI, SINGLE glazing (glazed_type 5), TFA 63. + # Lodged 62 / engine 61 (60.82). Built in accredited Elmhurst RdSAP10 + # (evidence saved: elmhurst_summary.pdf / elmhurst_worksheet.pdf): EVERY + # heat-loss element matches the engine EXACTLY — walls (solid U 1.70, + # 53.18 W/K), flat roof (uninsulated U 2.30, 145.57 W/K), floor (0), + # party (0), and — with the faithful SINGLE glazing entered — WINDOWS + # (8.62 m² × effective U 4.0268 = 34.7114 W/K, identical to the engine). + # This confirms the mapper''s glazed_type 5 → single (raw U 4.8) is + # spec-correct (epc_codes.csv: glazed_type 5 = single glazing), NOT the + # double it superficially resembles. The −1.18 vs lodged is a cost-side + # methodology residual (PE +8.7 kWh/m², CO2/PE-driven), not a fabric bug. + # PINNED to the observed engine 61. + RealCertExpectation( + schema="RdSAP-Schema-21.0.1", + sample="uprn_217091901", + cert_num="uprn-217091901", + sap_score=61, + ), + # UPRN 100061275133 (corpus; no cert number lodged). RdSAP-21.0.1 native — + # 2-storey SEMI-DETACHED HOUSE, band B, SOLID BRICK 400 mm with EXTERNAL + # insulation, pitched 300 mm loft, SUSPENDED uninsulated ground floor, + # mains-gas COMBI, double glazing (glazed_type 13, 2022+), TFA 78. + # Lodged 76 / engine 76 (76.33, was 77.24 before the §5 (12) fix below). + # Built in accredited Elmhurst RdSAP10 (evidence saved: elmhurst_summary.pdf + # / elmhurst_worksheet.pdf): the checkable FABRIC matches the engine + # closely — ROOF (300 mm loft, U 0.14) and WINDOWS (15.32 m² × eff U + # 1.3258 = 20.3106 W/K) match EXACTLY; the externally-insulated WALL is + # U 0.29 (engine) vs 0.30 (Elmhurst) — which VALIDATES the slice-5 + # external-insulation default (100 mm added over solid brick, + # `_WALL_ADDED_INSULATION_UNKNOWN_THICKNESS_MM`); the suspended ground + # floor is U 0.68 (engine) vs 0.68 (Elmhurst) — EXACT after rebuilding + # with 0 extract fans + PCDB 18908 (was 0.70 pre-rebuild). Ventilation + # (21) shelter-adjusted ACH is EXACT too (0.6938 = 0.6938), and dwelling + # volume (5) 196.8995 m³ matches (confirming the mapper's +0.25 m + # upper-floor height convention mirrors Elmhurst's own internal one). + # §5 (12) fix: the Main bp lodges no `floor_construction_type` (gov-API + # leaves it None), but `epc.floors[0].description` = "Suspended, no + # insulation (assumed)" — `_has_suspended_timber_floor_per_spec` was + # only reading the per-bp field and silently entered (12)=0 for a + # genuinely suspended-timber floor. Now falls back to the joined + # description (mirroring `_main_floor_u_value`'s existing fallback), + # applying (12)=0.2 unsealed; engine converges EXACTLY on lodged (76). + # Elmhurst's OWN overall worksheet SAP (72) still differs from ours + # (76.33) even on these now-identical inputs — traced to SAP 10.2 + # Table 11: Elmhurst's (201)/(202)/(215) show it assumes 10% of space + # heat comes from a portable-electric secondary (658 kWh/yr @ the + # electric rate) purely from the main system's category (gas boiler), + # with NO secondary lodged (`secondary_heating.description == "None"`). + # `_secondary_fraction` only applies Table 11 when a secondary is + # actually lodged or forced by §A.2.2 — deliberately, per the S-B30 + # note a few lines below: applying Table 11 unconditionally regressed + # the full 1000-cert corpus MAE by +0.16 (lodged software doesn't + # apply Elmhurst's blanket convention). Verified: forcing Table 11 on + # for just this cert moves 76.33 -> 73.62, ~62% of the 76.33-vs-72 gap. + # The remaining ~1.6 pts is a second, separate cause: (6) Solar gains — + # the BUILD entered all 12 windows as one 15.32 m2 "North" group + # (E.set_single_window), but the cert's real sap_windows lodge 3 + # orientations (NW 7.05 m2, NE 1.21 m2, SE 7.06 m2 incl. the largest + # single window) — collapsing to due-north understates solar gain + # (Elmhurst Jan (83)=56.9 W vs our real-orientation 123.4 W). Verified: + # forcing our engine to ALSO use a single synthetic North window (i.e. + # replicating the build's simplification, not a cert or engine fact) + # plus the forced Table 11 above reproduces 72.62 (precise Elmhurst + # value is 72.0681, not the rounded "72" -- true residual after Table + # 11 + windows was ~0.55, not ~1.6). Third cause found by walking the + # rest of the worksheet monthly: (43) Average daily hot water use is + # 117.4056 L/day (Elmhurst) vs our 110.7281 L/day -- SAME occupancy + # (42)=2.4234 both sides, so the gap is in shower/bath demand, not + # occupancy. Root cause: cert's `mixer_shower_count` is unlodged (raw + # `shower_outlets=None`), which `_mixer_shower_flow_rates_from_cert` + # maps to 0 showers (see its docstring: gov-API "no shower_outlets + # entry" convention, validated on cert 2225) -- but Elmhurst's build + # shows a "Non-electric shower" fixture (1 vented mixer @ Table J4), + # since its UI silently defaults one when the build script never + # explicitly configured "0 showers" to match the cert. Verified: a + # single 7 L/min vented mixer reproduces Elmhurst's (42a) monthly + # series EXACTLY (4 d.p., all 12 months) and closes hot water to + # 2616.04 vs Elmhurst's 2616.03 (Δ0.005). Corpus-wide check (170 + # certs sharing "bath present, no shower data lodged") shows NO + # systematic bias (mean dSAP +0.12, median -0.04, 46.5% over-rated) -- + # confirms the mapper's 0-showers convention is correct on average; + # this is a THIRD build-entry artifact, not a mapper bug. Combining + # all three (windows + Table 11 + 1 shower): SAP 72.4567 vs Elmhurst's + # precise 72.0681 -- residual 0.39, ~91% of the original 4.26-point + # gap (76.33 vs 72.07) now precisely explained. Conclusion: the + # calculator is faithful (matches lodged 76 exactly using the cert's + # REAL windows and REAL shower data); all three residuals are Elmhurst + # -reconciliation-build artifacts or deliberate spec choices, not + # mapping or calculator bugs. PINNED to the observed exact match vs + # lodged. + RealCertExpectation( + schema="RdSAP-Schema-21.0.1", + sample="uprn_100061275133", + cert_num="uprn-100061275133", + sap_score=76, + ), + # UPRN 100051051866 (corpus; no cert number lodged). RdSAP-21.0.1 native — + # DETACHED BUNGALOW, band I, CAVITY wall as-built insulated, pitched + # insulated loft, SUSPENDED limited-insulation floor, mains-gas regular + # boiler + cylinder (size 2, insulation type 1, 50 mm — a properly + # insulated cylinder, so NOT the type-0/NULL zero-storage-loss bug), TFA 60. + # Lodged 70 / engine 71 (71.04). Reduced-field cavity fabric already + # Elmhurst-validated on sibling certs; the +1.04 is a cost-side residual + # (PE −13.6 kWh/m² under-count) with no fabric anomaly — walls 29.9, roof + # 15.6, floor 29.4, windows 15.6 W/K all register-explained. PINNED to the + # observed engine 71; the register data fully accounts for the engine output. + RealCertExpectation( + schema="RdSAP-Schema-21.0.1", + sample="uprn_100051051866", + cert_num="uprn-100051051866", + sap_score=71, + ), + # UPRN 10070056075 (corpus; no cert number lodged). RdSAP-21.0.1 native — + # DETACHED HOUSE, band K, CAVITY wall as-built insulated, pitched insulated + # loft, SUSPENDED insulated floor, mains-gas regular boiler + cylinder + # (size 4, insulation type 1, 80 mm — properly insulated), dual meter, + # TFA 139. Lodged 80 / engine 79 (78.99). Cavity fabric already + # Elmhurst-validated on sibling certs; the −1.01 is a cost-side residual + # (PE +5.4 kWh/m² over-count) with no fabric anomaly — walls 50.0, roof + # 20.1, floor 16.7, windows 26.7 W/K all register-explained. PINNED to the + # observed engine 79; the register data fully accounts for the engine output. + RealCertExpectation( + schema="RdSAP-Schema-21.0.1", + sample="uprn_10070056075", + cert_num="uprn-10070056075", + sap_score=79, + ), + # UPRN 10012334488 (corpus; no cert number lodged). RdSAP-21.0.1 native — + # mid-floor FLAT, mains-electric with SAP heating code 195 ("Electric water + # storage boiler", Table 4a PDF p.170) serving underfloor heating, on an + # off-peak (7-hour) meter, TFA 62. Lodged 72 / engine 85 (85.20). This is a + # PURE COST gap, NOT a fabric bug: PE (−1.4 kWh/m²) and CO2 (+0.01 t) both + # match lodged, so demand is right. Code 195 is a STORAGE boiler → SAP 10.2 + # §12 Rule 2 routes it to the 7-hour tariff and bills space+water heat mostly + # at the off-peak low rate (5.5 p/kWh) — the spec-faithful treatment. The + # lodged software billed it far higher (implying peak-rate billing of a + # storage system), the same lodged-outlier pattern as the §12/PSR xfail + # cohort. PINNED to the spec-faithful engine 85 — the engine follows SAP + # §12/Table-4a; matching lodged 72 would require billing a storage boiler at + # peak rate, against spec. (Not Elmhurst-verified: my RdSAP10 builds carry a + # combi/heating artifact that makes them unreliable on the E7 storage side.) + RealCertExpectation( + schema="RdSAP-Schema-21.0.1", + sample="uprn_10012334488", + cert_num="uprn-10012334488", + sap_score=85, + ), + # UPRN 10091578598 (corpus; no cert number lodged). RdSAP-21.0.1 native — + # FLAT, mains-electric SAP code 195 ("Electric water storage boiler"), + # off-peak meter, TFA 60. Lodged 71 / engine 79 (78.81). Same signature as + # 10012334488: PURE COST gap (PE +1.6, CO2 −0.04 both match lodged → demand + # right); code 195 is a §12-Rule-2 storage system billed at the 7-hour + # off-peak rate per spec. Lodged over-billed the storage boiler at a higher + # rate. PINNED to the spec-faithful engine 79 — matching lodged would tune + # against SAP §12/Table-4a. + RealCertExpectation( + schema="RdSAP-Schema-21.0.1", + sample="uprn_10091578598", + cert_num="uprn-10091578598", + sap_score=79, + ), ) diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 213bf2ffb..2df247a2d 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -245,15 +245,18 @@ def test_part_geometry_floorless_part_honours_full_key_contract() -> None: assert early["cantilever_floor_area_m2"] == 0.0 -def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4() -> None: - # Arrange — 346 corpus certs lodge roof_insulation_thickness="NI" - # with descriptions like "Pitched, insulated (assumed)". The - # retrofit-insulation signal in the description must flow from - # epc.roofs[0].description through heat_transmission_from_cert - # into u_roof so the 50 mm assumption fires per RdSAP 10 §5.11.4. - # Geometry: 100 m² ground floor → top floor area also 100 m² → - # roof_area = 100 m². roof_w_per_k expected = 0.68 × 100 = 68 - # W/K (vs uninsulated 2.30 × 100 = 230 W/K). +def test_roof_insulated_assumed_with_ni_thickness_uses_table18_default() -> None: + # Arrange — corpus certs lodge roof_insulation_thickness="NI" with + # descriptions like "Pitched, insulated (assumed)". The description + # must flow from epc.roofs[0].description through + # heat_transmission_from_cert into u_roof, where "(assumed)" + # (presence assumed, not observed retrofit) routes to the Table 18 + # age-band default per RdSAP 10 §5.11.4 — band C pitched → 0.40 — + # matching the "ND" lodgement of the same description. (The bare + # "insulated" form keeps the §5.11.4 50 mm row; unit-pinned in + # test_rdsap_uvalues.) Geometry: 100 m² ground floor → top floor + # area also 100 m² → roof_area = 100 m². roof_w_per_k expected = + # 0.40 × 100 = 40 W/K (vs uninsulated 2.30 × 100 = 230 W/K). main = make_building_part( identifier="Main Dwelling", construction_age_band="C", @@ -287,7 +290,7 @@ def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4() result = heat_transmission_from_cert(epc) # Assert - assert result.roof_w_per_k == pytest.approx(68.0, abs=2.0) + assert result.roof_w_per_k == pytest.approx(40.0, abs=2.0) def test_roof_insulation_location_rafters_drives_table16_column_2_api_int_path() -> None: @@ -367,6 +370,46 @@ def test_rafter_insulation_thickness_field_drives_table16_column_2() -> None: assert abs(result.roof_w_per_k - 25.0) <= 1e-4 +def test_flat_roof_insulation_thickness_field_drives_insulated_u() -> None: + # Arrange — a FLAT roof lodges its insulation thickness in the DEDICATED + # gov-EPC API `flat_roof_insulation_thickness` field (e.g. "75mm"), + # leaving `roof_insulation_thickness` None (that field is for pitched-loft + # joists). heat_transmission must prefer the flat field when the part is a + # flat roof so the measured thickness bills at its RdSAP 10 §5.11 Table-16 + # U (75 mm → 0.5) instead of the uninsulated age-band flat default (age E + # → 1.5). Cert 47084930 (top-floor flat, flat roof "75mm"): roof 96.4 -> + # 32.1 W/K, engine 69.51 -> 74.34 = lodged 74, matching the accredited + # Elmhurst worksheet ("insulated flat roof", U 0.5). Mirrors the rafter + # field. Geometry: 100 m² plan → roof 100 m². 0.5 × 100 = 50 W/K. + main = make_building_part( + construction_age_band="E", + wall_construction=4, + wall_insulation_type=4, + party_wall_construction=1, + roof_construction=1, # flat + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=100.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0, + ), + ], + ) + main.roof_construction_type = "Flat" + main.roof_insulation_thickness = None # gov leaves this None for flat roofs + main.flat_roof_insulation_thickness = "75mm" # the thickness lives here + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — 75 mm insulated flat roof at U 0.5, not the age-E default 1.5. + assert abs(result.roof_w_per_k - 50.0) <= 1e-4 + + def test_lodged_roof_u_value_overrides_construction_default() -> None: # Arrange — RdSAP 10 §5.1: where an element's U-value is known from the # assessment (documentary evidence / the lodged RdSAP output) it is used diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py index 304f89408..84330584e 100644 --- a/tests/harness/test_report.py +++ b/tests/harness/test_report.py @@ -30,9 +30,13 @@ _GOLDEN = ( _WITHIN_TOLERANCE = "0036-6325-1100-0063-1226" _DIVERGENT = "0240-0200-5706-2365-8010" -# 0330 fires all three trigger kinds: an uninsulated cavity wall (cavity fill), -# its dependent mechanical ventilation, and an uninsulated solid floor. -_THREE_MEASURES = "0330-2249-8150-2326-4121" +# 0390 fires three measures — an uninsulated solid floor, low-energy lighting, +# and the ASHP bundle — so every fired measure's trigger attributes are +# exercised together. (0330, the previous fixture, now reaches band C on the +# correctly-sized ASHP alone: ADR-0049 sizes the pump to the dwelling's design +# heat loss, so the undersized-pump-era companion solid_floor_insulation is no +# longer needed there.) +_THREE_MEASURES = "0390-2254-6420-2126-5561" def _triggers_by_measure(report: PropertyReport) -> dict[str, MeasureTrigger]: @@ -80,17 +84,12 @@ def test_each_fired_measure_carries_the_attributes_that_triggered_it() -> None: # Assert — the Plan ran and every fired measure names its trigger fields. assert report.plan is not None assert report.plan_error is None - # The gain-maximising package: the efficient representative ASHP (ADR-0025) - # plus solid-floor insulation. The cavity wall + its forced mechanical - # ventilation (ADR-0016) are NOT selected — the wall earns +SAP alone but - # the forced-ventilation penalty makes the pair net-negative, so the - # Optimiser correctly leaves them out (see test_measure_dependency / - # test_optimiser for the forced-edge unit coverage; cavity_wall + - # mechanical_ventilation trigger fields are exercised in - # test_cavity_wall_recommendation / the ventilation generator tests). + # The selected package: the dwelling-sized ASHP (ADR-0025/ADR-0049) plus + # solid-floor insulation and low-energy lighting. triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report) assert set(triggers) == { "solid_floor_insulation", + "low_energy_lighting", "air_source_heat_pump", } # Solid-floor insulation fired off an uninsulated solid ground floor. @@ -98,6 +97,12 @@ def test_each_fired_measure_carries_the_attributes_that_triggered_it() -> None: "floor_insulation_thickness": None, "floor_construction_type": "Solid", } + # Low-energy lighting fired off the dwelling's lodged bulb counts. + assert triggers["low_energy_lighting"].triggers == { + "incandescent_fixed_lighting_bulbs_count": 0, + "cfl_fixed_lighting_bulbs_count": 0, + "low_energy_fixed_lighting_bulbs_count": 9, + } # The ASHP bundle fired off the gas-dwelling main it replaces. assert triggers["air_source_heat_pump"].triggers == { "property_type": "0", @@ -169,10 +174,17 @@ def test_few_measure_cert_surfaces_only_its_fired_measures_triggers() -> None: # Assert — 0036's gain-maximising package is solid-floor insulation plus the # low-energy-lighting upgrade (it lodges 7 low-energy + 0 incandescent fixed - # bulbs, so the LED top-up is a cheap positive-SAP lever, ADR-0023), and - # nothing else. + # bulbs, so the LED top-up is a cheap positive-SAP lever, ADR-0023) plus a + # zoned system tune-up: 0036 lodges its gas boiler as a PCDB main-heating + # index with no SAP code, which the heating generators now classify as a wet + # boiler eligible for a tune-up (#1399), so the boiler's controls upgrade + # joins the package. triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report) - assert set(triggers) == {"solid_floor_insulation", "low_energy_lighting"} + assert set(triggers) == { + "solid_floor_insulation", + "low_energy_lighting", + "system_tune_up_zoned", + } assert triggers["solid_floor_insulation"].triggers == { "floor_insulation_thickness": None, "floor_construction_type": "Solid", diff --git a/tests/infrastructure/abri/__init__.py b/tests/infrastructure/abri/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py new file mode 100644 index 000000000..133c74d8f --- /dev/null +++ b/tests/infrastructure/abri/test_abri_client.py @@ -0,0 +1,194 @@ +import xml.etree.ElementTree as ET +from datetime import date +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from domain.abri.models import ( + AbriRequestRejected, + JobLogged, + LogJobRequest, + PlaceRef, +) +from infrastructure.abri.abri_client import AbriClient +from infrastructure.abri.config import AbriConfig +from infrastructure.abri.errors import AbriResponseParseError, AbriTransportError + +FIXTURE_DIR = Path(__file__).parents[2] / "abri" +ENDPOINT_URL = "https://relay.example.test/api/DomnaRelay?code=test-function-key" + +CONFIG = AbriConfig( + endpoint_url=ENDPOINT_URL, + username="DomnaWeb", + password="", + default_resource="NAULKH", +) + + +def _load_fixture(name: str) -> bytes: + return (FIXTURE_DIR / name).read_bytes() + + +def _make_client(mock_session: MagicMock) -> AbriClient: + with patch( + "infrastructure.abri.abri_client.requests.Session", + return_value=mock_session, + ): + return AbriClient(config=CONFIG) + + +def _spec_example_request() -> LogJobRequest: + return LogJobRequest( + client_ref="DOM51111", + place_ref=PlaceRef("1007165"), + appointment_date=date(2026, 6, 18), + appointment_time="PM", + short_description="Christian's SCS External Test.", + long_description="Christian's SCS External Test.", + ) + + +@pytest.fixture() +def mock_session() -> MagicMock: + return MagicMock() + + +@pytest.fixture() +def client(mock_session: MagicMock) -> AbriClient: + return _make_client(mock_session) + + +# --- log_job: success --- + + +def test_log_job_returns_job_logged_with_openhousing_job_number( + client: AbriClient, mock_session: MagicMock +) -> None: + # Arrange + mock_session.post.return_value.content = _load_fixture( + "abri_relay_logjob_success_response.xml" + ) + + # Act + result = client.log_job(_spec_example_request()) + + # Assert + assert result == JobLogged( + job_no="AD0226519", + logged_info=( + "Job number AD0226519 has been successfully logged for " + "49 Admers Crescent, Liphook, Midsomer, XX99 IOP - " + "Short Description example job." + ), + ) + + +def test_log_job_sends_the_recorded_logjob_envelope_to_the_relay_endpoint( + client: AbriClient, mock_session: MagicMock +) -> None: + # Arrange + mock_session.post.return_value.content = _load_fixture( + "abri_relay_logjob_success_response.xml" + ) + + # Act + client.log_job(_spec_example_request()) + + # Assert + # Fixture is the spec's recorded request example, except resource_group is + # "Surveyors" (the documented default) where the example used "surveyors". + (url,) = mock_session.post.call_args.args + sent_body: bytes = mock_session.post.call_args.kwargs["data"] + expected_body = _load_fixture("abri_relay_logjob_request_example.xml") + assert url == ENDPOINT_URL + assert ET.canonicalize(xml_data=sent_body, strip_text=True) == ET.canonicalize( + xml_data=expected_body, strip_text=True + ) + + +# --- log_job: rejection --- + + +def test_log_job_returns_rejection_with_openhousing_error_code_and_message_verbatim( + client: AbriClient, mock_session: MagicMock +) -> None: + # Arrange + mock_session.post.return_value.content = _load_fixture( + "abri_relay_logjob_relayerror_response.xml" + ) + + # Act + result = client.log_job(_spec_example_request()) + + # Assert + assert result == AbriRequestRejected( + code="RelayError", + message=( + "No property was found with UPRN/place_ref '1007176aa', " + "street name '', house number '0', house name '', post code ''" + ), + ) + + +# --- log_job: transport failures --- + + +def test_log_job_raises_transport_error_on_http_error_status( + client: AbriClient, mock_session: MagicMock +) -> None: + # Arrange + mock_session.post.return_value.content = b"Server Error" + mock_session.post.return_value.raise_for_status.side_effect = requests.HTTPError( + "500 Server Error" + ) + + # Act / Assert + with pytest.raises(AbriTransportError): + client.log_job(_spec_example_request()) + + +def test_log_job_raises_transport_error_when_the_relay_is_unreachable( + client: AbriClient, mock_session: MagicMock +) -> None: + # Arrange + mock_session.post.side_effect = requests.ConnectionError("connection refused") + + # Act / Assert + with pytest.raises(AbriTransportError): + client.log_job(_spec_example_request()) + + +# --- log_job: ambiguous responses are retriable, never success or rejection --- + + +def test_log_job_raises_retriable_parse_error_on_a_malformed_response_body( + client: AbriClient, mock_session: MagicMock +) -> None: + # Arrange + mock_session.post.return_value.content = b"not xml at all <<<" + + # Act / Assert + with pytest.raises(AbriResponseParseError): + client.log_job(_spec_example_request()) + + +@pytest.mark.parametrize( + "body", + [ + b"", + b'AD1', + b"true", + b"", + ], +) +def test_log_job_raises_retriable_parse_error_on_an_unexpectedly_shaped_response( + client: AbriClient, mock_session: MagicMock, body: bytes +) -> None: + # Arrange + mock_session.post.return_value.content = body + + # Act / Assert + with pytest.raises(AbriResponseParseError): + client.log_job(_spec_example_request()) diff --git a/tests/infrastructure/abri/test_abri_config.py b/tests/infrastructure/abri/test_abri_config.py new file mode 100644 index 000000000..4e1d8c87d --- /dev/null +++ b/tests/infrastructure/abri/test_abri_config.py @@ -0,0 +1,22 @@ +from infrastructure.abri.config import AbriConfig + + +def test_config_hydrates_relay_credentials_and_defaults_from_environment() -> None: + # Arrange + env = { + "ABRI_RELAY_URL": "https://relay.example.test/api/DomnaRelay?code=key", + "ABRI_RELAY_USERNAME": "DomnaWeb", + "ABRI_RELAY_PASSWORD": "secret", + "ABRI_RELAY_DEFAULT_RESOURCE": "CBRYAN", + } + + # Act + config = AbriConfig.from_env(env) + + # Assert + assert config == AbriConfig( + endpoint_url="https://relay.example.test/api/DomnaRelay?code=key", + username="DomnaWeb", + password="secret", + default_resource="CBRYAN", + ) diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index cc57d28d9..ff14c1fa7 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -193,7 +193,53 @@ _CORPUS = Path( # within-0.5 71.6% -> 72.5%, MAE 0.819 -> 0.815. Surfaced by Khalim's Elmhurst # stress worksheet (simulated case 46): closed its last ventilation residual # (our Jan ACH 9.14 -> 9.0748 exact; SAP 29 -> 30 = accredited Elmhurst). -_MIN_WITHIN_HALF_SAP = 0.74 +# 0.74 -> 0.742 via the heat-pump water-heating 100% floor (App N3.7): cert +# 100110101713 moves inside +-0.5 (|err| 4.97 -> 0.49). See the _MAX_SAP_MAE +# log below for the paired space-heating PSR-extension + water-floor slices. +# 0.742 -> 0.755 (MAE 0.722 -> 0.708, CO2 0.09 -> 0.075, PE 3.5 -> 3.2) via +# the "insulated (assumed)" roof Table-18 fix (RdSAP 10 §5.11.4): a pitched +# roof lodging thickness "NI" + description "insulated (assumed)" was hitting +# the §5.11.4 retrofit-50 mm row (0.68) while the SAME description lodged +# with "ND" fell through to the Table 18 age-band default (e.g. 0.40 A-G) — +# the sentinel choice is lodging-software noise, and "(assumed)" means the +# insulation PRESENCE is an age-band assumption, not an observed retrofit, +# so Table 18's "thickness cannot be determined" clause governs both forms. +# The bare "insulated" description (observed retrofit) keeps the 50 mm row. +# The 27-cert loc4+NI cohort was systematically under-rated ~1 SAP (e.g. +# 10094975827 band B -1.87 -> inside). Unit-pinned in test_rdsap_uvalues. +# 0.755 -> 0.770 (MAE 0.708 -> 0.668) via the secondary-fuel collision fix +# on the SAP-DRIVING cost path (`_fuel_cost`): the gov-API secondary fuel +# enums 5 (anthracite) / 9 (dual fuel) / 33 (coal) collide with same-valued +# Table-32 codes for OTHER fuels, and `_fuel_cost`'s inline secondary rate +# passed the raw enum to the price lookup (unlike the already-canonicalised +# main-fuel boundary + CO2/display helper). Anthracite priced at the bulk-LPG +# code-5 rate (12.19 vs 3.64 p/kWh) 3x over-cost the secondary -> under-rate. +# Worst: cert 100050355518 (2 extensions, anthracite room-heater 2ndary) +# lodged 36 / engine 20.8 -> 35.9. 55 corpus certs carry a colliding +# secondary. Unit-pinned in test_cert_to_inputs (fuel_cost cost path, not +# just the display helper the prior enum-9 test guarded). +# 0.770 -> 0.773 via the unsized-cylinder storage-loss fix: a present +# cylinder lodging gov-API cylinder_size=0 ("size not determined") resolved +# to None volume in `_cylinder_storage_loss_override`, so `if volume_l is +# None: return None` DROPPED its storage loss (under-costing the DHW -> +# over-rate). RdSAP 10 §10.5 Table 28: an unsized present cylinder is the +# 110 L "Normal" default and still incurs the Table-2 loss (the value the +# Table-13 fraction path already used). Gated on the explicit 0 so a +# full-SAP unlodged size (None) keeps its own handling. 7 corpus certs; +# within 77.0% -> 77.3%, PE 3.2 -> 3.05, CO2 0.074 -> 0.073. +# 0.773 -> 0.777 via the flat-roof insulation-thickness fix: a FLAT roof +# lodges its thickness in the dedicated `flat_roof_insulation_thickness` +# field (e.g. "75mm"), but heat_transmission read `roof_insulation_thickness` +# (None for flat roofs) -> billed the roof at the uninsulated age-band flat +# default (age E 1.5) instead of the insulated Table-16 U. Prefer the flat +# field when the part is flat (mirrors the rafter-thickness branch). Cert +# 47084930 (top-floor flat, flat roof 75mm): roof 96.4 -> 32.1 W/K, engine +# 69.51 -> 74.34 = lodged 74, VALIDATED against the accredited Elmhurst +# worksheet ("insulated flat roof" U 0.5). within 77.3% -> 77.7%, PE 3.1 -> +# 2.97, CO2 0.074 -> 0.072. +# 77.7% -> 77.8% via the §5 (12) suspended-timber floor-infiltration +# fallback (see the `_MAX_SAP_MAE` note below for the mechanism). +_MIN_WITHIN_HALF_SAP = 0.778 # 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak # trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion) # -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57, @@ -248,9 +294,49 @@ _MIN_WITHIN_HALF_SAP = 0.74 # an identical dwelling rates SAP 87 with "Connected to Dwelling = Yes" (credit # -£167) vs SAP 74 with "No" (credit £0). Enum decoded empirically: 0 = no PV, # 1 = not connected, 2 = connected (the gov-API does not expose it elsewhere). -_MAX_SAP_MAE = 0.740 -_MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current -_MAX_PE_PER_M2_MAE = 3.5 # kWh / m2 / yr vs energy_consumption_current +# Then 0.740 -> 0.726 via the heat-pump PSR-extension fix (SAP 10.2 Appendix N2, +# PDF p.101 footnotes 44/45): an air/ground/water source heat pump whose plant +# size ratio exceeds the PCDB record's largest PSR is no longer clamped to the +# top-of-table COP — its efficiency is reciprocal-interpolated toward 100% at +# twice the largest PSR (and 100% below the smallest PSR). Accredited Elmhurst +# worksheet for cert 100110101713 (golden fixture case 56, PCDB 100061, PSR +# 3.107 over largest 2.0): (206) 334.4% -> 139.66% = Elmhurst exact. Only two +# certs move (both oversized-PSR heat pumps): 100110101713 +18.32 -> -4.97 and +# 4510053280 -0.61; within-0.5 holds at 74.1%. +# Then 0.726 -> 0.722 (within-0.5 74.1% -> 74.2%) via the heat-pump water- +# heating 100% floor (SAP 10.2 Appendix N3.7, PDF p.109: in-use x eta_water +# subject to a minimum efficiency of 100%). Only 100110101713 moves: its +# oversized-PSR water eff 0.60 x 128.55% = 77.13% is floored to 100% (= +# accredited Elmhurst (216)), taking the cert 68.03 -> 72.51 (|err| 4.97 -> +# 0.49, now inside +-0.5). In-range heat pumps keep their > 100% water COP. +# 0.669 -> 0.659 via the instantaneous-electric-DHW off-peak fix: WHC 907/909 +# (point-of-use, no cylinder) on an off-peak tariff was billed 100% at the +# off-peak LOW rate, but SAP 10.2 §12 (Table 13 on-peak proportion is keyed on +# CYLINDER size) presumes stored water; an instantaneous heater has no cylinder +# and heats on demand -> 100% high rate. Cert 74061136 (HHR-storage flat, +# WHC 909) +7.72 -> -1.25. Both the cost rate and the ADR-0014/CO2-PE fraction +# fixed (fraction 0 -> 1.0). Unit-pinned in test_cert_to_inputs. +# 0.659 -> 0.648 via the wall added-insulation unknown-thickness fix: a +# SOLID-BRICK/STONE wall lodging External/Internal insulation (type 1/3) with +# thickness "NI" (unknown) was billed UNINSULATED (§5.8 R-value gate needs a +# thickness > 0), over-counting wall heat loss. RdSAP 10 §5.4 (PDF p.32): +# "assume 100 mm if thickness is unknown" -> apply the 100 mm §5.8 default. +# Cert 200004296092 (age-B solid brick 360 mm + external, NI): walls 101.7 W/K +# (U~1.4) -> ~0.29, PE +59.6 -> ~0, SAP 64.35 -> 71.74 (lodged 71). Scoped to +# brick/stone (the only §5.8 R-value path); within-0.5 held at 77.0%, MAE +# 0.658 -> 0.647, PE 3.2 -> 3.1. Unit-pinned in test_rdsap_uvalues. +# 77.7% -> 77.8% (MAE 0.637 -> 0.636) via the §5 (12) suspended-timber +# floor-infiltration fallback: `_has_suspended_timber_floor_per_spec` +# only read the Main bp's per-part `floor_construction_type` lodgement, +# which the gov-API mapper frequently leaves `None` even when the global +# `epc.floors[].description` carries an explicit "Suspended, ..." +# observation (`_main_floor_u_value` already fell back to this joined +# description for the U-value calc — the infiltration rule did not). +# Cert 100061275133 (Elmhurst-validated build): SAP 77.24 -> 76.33, now +# an EXACT match to lodged (76). Unit-pinned in test_cert_to_inputs. +_MAX_SAP_MAE = 0.637 +_MAX_CO2_MAE_TONNES = 0.072 # t CO2 / yr vs co2_emissions_current +_MAX_PE_PER_M2_MAE = 3.0 # kWh / m2 / yr vs energy_consumption_current def _load_corpus() -> list[dict[str, Any]]: diff --git a/tests/magic_plan/magicplan_api_plan_response.json b/tests/magic_plan/magicplan_api_plan_response.json index bdf8b1bd3..16956f42d 100644 --- a/tests/magic_plan/magicplan_api_plan_response.json +++ b/tests/magic_plan/magicplan_api_plan_response.json @@ -1 +1 @@ -{"message":"OK","data":{"plan":{"id":"72efd2e0-b2b9-48cd-b82e-41f5b3166c9a","project_id":"eb23b4b9-19a4-4874-9a8d-554e350b7e70","name":"20 Larch Way BR2 8DU","address":{"street":"20 Larch Way","street_number":null,"postal_code":"BR2 8DU","city":"Bromley","country":"GB","longitude":0.0573414,"latitude":51.382861},"creation_date":"2026-05-25T15:48:48+00:00","update_date":"2026-06-03T08:51:53+00:00","thumbnail_url":"https:\/\/s3.amazonaws.com\/prod.plans.sensopia.com\/72efd2e0-b2b9-48cd-b82e-41f5b3166c9a\/plan.thumb","public_url":"https:\/\/cloud.magicplan.app\/plan\/72efd2e0-b2b9-48cd-b82e-41f5b3166c9a","cloud_url":"https:\/\/cloud.magicplan.app\/projects\/72efd2e0-b2b9-48cd-b82e-41f5b3166c9a","3d_url":"https:\/\/3d.magicplan.app\/#embed\/?key=OWJiNDI5YmM1ZWI5ODJlYmFiNDYyZmUxOTM4ZTc2NmQzZGYzNjA5MjZlMDQzNDNlMzIxOTk4MTFjNDY5M2ZkYcYjZ6i4MQhzK3HNkFrpRYw%2FPSRN%2FvCLu%2BKMjClzi4BYxQEtpulZnzfzAAV4feMAtqc%2FvajEeEiU419uOp2%2FtjPXiexQVxJJMWawfBWzStVG","workgroup_id":"677d01685458a","team_id":null,"created_by":{"id":"0e165295-6f60-443b-b3ac-779a6bc3a278","firstname":null,"lastname":null,"email":"jane.cooper@domna.homes"}},"plan_detail":{"magicplan_format_xml":"\nJane Cooper<\/value>2026-05-25<\/value>Sero<\/value>2.134<\/value>100<\/value>0<\/value>0<\/value><\/values>Sero<\/notes>Ground Floor<\/name>2.43<\/value>0.28<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>outdoors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>30.Hinged.Pivot.Window<\/value>2<\/value>70<\/value>1700<\/value>2<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.0<\/value>1<\/value>0.9<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>hvac<\/value>m3<\/value>0.1500<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>External.Door<\/value>80<\/value>1700<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>30.Hinged.Pivot.Window<\/value>2<\/value>80<\/value>1700<\/value>2<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.0<\/value>1.14<\/value>0.54<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>30.Hinged.Pivot.Window<\/value>2<\/value>80<\/value>1700<\/value>2<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.0<\/value>1.14<\/value>0.54<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>0.9<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>30.Hinged.Pivot.Window<\/value>2<\/value>80<\/value>1700<\/value>2<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.0<\/value>1<\/value>0.9<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>hvac<\/value>m3<\/value>0.1500<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>hvac<\/value>m3<\/value>0.1500<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>70<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>hvac<\/value>m3<\/value>0.2500<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>30.Hinged.Pivot.Window<\/value>1<\/value>50<\/value>1700<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.0<\/value>0.5<\/value>0.4<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>plumbing<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>0.9<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>70<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>hvac<\/value>m3<\/value>0.1500<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>structure<\/value>1<\/value>0<\/value><\/values><\/symbolInstance>3.19<\/value><\/values><\/point><\/floorRoom>