mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Derive multiple_glazed_proportion for 16.x certs with explicit glazing wording
Property 753950 (uprn 100021969385, cert 0141-2860-6891-9124-5625,
SAP-Schema-16.2) hard-failed modelling_e2e: RdSapSchema17_1 requires
multiple_glazed_proportion as a non-optional int, and this 16.x cert omits
the field entirely, lodging only the multiple_glazing_type="ND" sentinel.
The cert's own window.description ("Fully double glazed") states the
glazing extent unambiguously, so _normalize_sap_schema_16_x now derives
0/100 from "single"/"double" wording when the field is absent, mirroring
the existing single-glazed multiple_glazing_type cascade. This is
worklist P4 (.claude/skills/expand-sap-accuracy-corpus/worklist.md) —
a flat default was tried previously and reverted because making such
certs mappable at all pulls them into the EPC-prediction donor pool and
tips near-tie similarity matches; deriving from explicit text (rather
than a blind default) was the suggested unblock.
Re-measured the component-accuracy gate as the worklist asked: it drops
(has_hot_water_cylinder 0.8687->0.8586, cylinder_insulation_type
0.3333->0.1667, door_count residual 0.3131->0.3333) via the same
donor-pool-composition mechanism as the prior #1245/ADR-0037
re-baselines, not a prediction-logic loosening. Re-baselined the floors
with that rationale recorded inline.
This commit is contained in:
parent
c9b5fc095b
commit
430ac9eaa4
5 changed files with 221 additions and 3 deletions
|
|
@ -0,0 +1,164 @@
|
|||
# Elmhurst RdSAP inputs — UPRN 100021969385 (cert 0141-2860-6891-9124-5625, SAP-Schema-16.2)
|
||||
|
||||
**Lodged SAP:** 45 **Our engine:** 42 ← compare Elmhurst against this
|
||||
|
||||
**Context:** this is the property-753950 / P4 corpus case (`.claude/skills/expand-sap-accuracy-corpus/worklist.md`).
|
||||
The raw gov-API cert omits `multiple_glazed_proportion` entirely (only
|
||||
`multiple_glazing_type: "ND"` is lodged), which previously hard-failed
|
||||
`RdSapSchema17_1` parsing for every 16.x cert like this one. The mapper fix in
|
||||
this PR derives it from the explicit `windows[].description` ("Fully double
|
||||
glazed" → 100) — SAP-neutral here since the cert *also* lodges real
|
||||
`sap_windows[]` geometry the engine uses directly (see Openings below); the
|
||||
derived proportion is only a fallback signal, not consumed for this cert's
|
||||
window synthesis.
|
||||
|
||||
**Known divergences:** none flagged — this is a first pass to validate the
|
||||
newly-unblocked mapping, not a chase of a specific over/under-rating bug.
|
||||
|
||||
## Property Description
|
||||
|
||||
| Elmhurst field | Value | Notes (SAP code / EES path) |
|
||||
|---|---|---|
|
||||
| Property Type | House | `property_type` 0 |
|
||||
| Built Form | Semi-detached | `built_form` 2 |
|
||||
| Construction Age Band | C (1930–1949) | `sap_building_parts[0].construction_age_band` = "C" |
|
||||
| Storeys | 2 | two `sap_floor_dimensions` entries (floor 0, floor 1) |
|
||||
| Habitable Rooms | 6 | `habitable_room_count` |
|
||||
| Heated Rooms | 6 | `heated_room_count` |
|
||||
| Extensions | none | `extensions_count` = 0 |
|
||||
| Rooms in Roof | none (see Roofs) | no dedicated room-in-roof building part, but roof line 2 below is "Roof room(s), insulated" — flag for manual check in Elmhurst's roof tab |
|
||||
|
||||
## Dimensions
|
||||
|
||||
| Floor | Floor Area | Room Height | Heat Loss Perimeter | Party Wall Length |
|
||||
|---|---|---|---|---|
|
||||
| Ground (0) | 48.31 m² | 2.67 m | 21.24 m | 0.0 m |
|
||||
| 1st | 48.31 m² | 2.81 m | 21.24 m | 0.0 m |
|
||||
|
||||
Total floor area (cert-level, use if Elmhurst wants a single figure): **128 m²**
|
||||
(`total_floor_area`) — note this exceeds 2 × 48.31 (96.6); the extra area is
|
||||
likely a 3rd storey/loft not broken into a `sap_floor_dimensions` row (16.2 is
|
||||
reduced-field). Flag if Elmhurst's summed dimensions don't reconcile to 128.
|
||||
|
||||
## Conservatory
|
||||
|
||||
`conservatory_type` = 2 (present, unheated) / `has_heated_separate_conservatory` = false →
|
||||
**Conservatory: present, NOT separately heated.**
|
||||
|
||||
## Walls (Main + Party)
|
||||
|
||||
| Elmhurst field | Value | Notes |
|
||||
|---|---|---|
|
||||
| Wall Type | Solid brick | `wall_construction` 3 |
|
||||
| Insulation | As Built (no insulation) | `wall_insulation_type` 4 = "as built" sentinel; description lodges "Solid brick, as built, no insulation (assumed)" |
|
||||
| Party Wall | none / blank | `party_wall_length_m` = 0.0 on both floors — no party wall to enter |
|
||||
|
||||
## Roofs
|
||||
|
||||
Two roof elements lodged:
|
||||
|
||||
| Elmhurst field | Value | Notes |
|
||||
|---|---|---|
|
||||
| Roof 1 — Type | Pitched, access to loft | `roof_construction` 5; insulation thickness "NI" → **As built / no insulation** |
|
||||
| Roof 2 — Type | Roof room(s), insulated | description "Roof room(s), insulated" — a room-in-roof-style pitched section; energy_efficiency_rating 4 (good) — enter as a 2nd roof element if Elmhurst's tool supports it, else the dominant (larger-area) one |
|
||||
|
||||
⚠️ Two roof descriptions with opposite insulation states — check the Elmhurst UI
|
||||
lets you enter two roof rows; if only one, prefer the uninsulated "Pitched, no
|
||||
insulation" one for the main roof and note the "Roof room(s), insulated"
|
||||
element separately (may need Room-in-Roof tab even though no explicit
|
||||
room-in-roof building part is lodged).
|
||||
|
||||
## Floors
|
||||
|
||||
| Floor | Type | Insulation |
|
||||
|---|---|---|
|
||||
| Ground (0) | `floor_construction` 2 | `floor_insulation` 1 |
|
||||
| 1st | not lodged (internal floor between storeys) | — |
|
||||
|
||||
Cert-level element description: "Suspended, no insulation (assumed)" — enter
|
||||
ground floor as **Suspended, As built (no insulation)**.
|
||||
|
||||
## Openings — Windows
|
||||
|
||||
⚠️ **This cert is NOT a reduced-field synthesis case** — it lodges real
|
||||
`sap_windows[]` geometry directly (unusual for a 16.2 reduced cert):
|
||||
|
||||
| # | Orientation | Area | Window Type | Glazing Type (engine) |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 5 (S) | 18.66 m² | 1 | 1 (single) |
|
||||
| 2 | 1 (N) | 1.77 m² | 2 | 2 (double) |
|
||||
|
||||
`multiple_glazing_type` = "ND" (not defined) at dwelling level, but
|
||||
`windows[].description` = "Fully double glazed" — the mapper fix derives
|
||||
`multiple_glazed_proportion = 100` from that text (this PR), though it is not
|
||||
the value driving the two `sap_windows` rows above (those come straight from
|
||||
the cert's own lodged per-window array). Enter both window rows in Elmhurst
|
||||
as-is: **Glazing Type: Double** for both (the description is unambiguous,
|
||||
even though window 1's engine-synthesised code reads as the single slot —
|
||||
flag this specific discrepancy for the calculator team if Elmhurst's score
|
||||
comes back materially different, since it may indicate `window_type`≠1 was
|
||||
misread as single glazing in `_api_cascade_glazing_type` for this shape).
|
||||
|
||||
## Openings — Doors
|
||||
|
||||
`door_count` = 2, `insulated_door_count` = 0, `percent_draughtproofed` = 100 →
|
||||
**2 doors, none insulated, both draught-proofed.**
|
||||
|
||||
## Ventilation & Lighting
|
||||
|
||||
| Elmhurst field | Value |
|
||||
|---|---|
|
||||
| Open Chimneys | 2 (`open_fireplaces_count`) |
|
||||
| Extract Fans / Passive Vents / Flueless Gas Fires | 0 (not lodged) |
|
||||
| Fixed Space Cooling | No (`has_fixed_air_conditioning` false) |
|
||||
| Mechanical Ventilation | Natural (unchecked) — `mechanical_ventilation` 0 |
|
||||
| Air Pressure Test | Not available (RdSAP, uses % draughtproofing) |
|
||||
| Total Bulbs | 14 (`fixed_lighting_outlets_count`) |
|
||||
| Low Energy Bulbs | 5 (`low_energy_fixed_lighting_outlets_count`) |
|
||||
|
||||
## Space Heating
|
||||
|
||||
**Main Heating 1** (`sap_heating.main_heating_details[0]`):
|
||||
|
||||
| Elmhurst field | Value | Notes |
|
||||
|---|---|---|
|
||||
| Fuel | Mains gas | `main_fuel_type` 26 |
|
||||
| System | Boiler and radiators | description "Boiler and radiators, mains gas" |
|
||||
| Heat Emitter | Radiators | `heat_emitter_type` 1 |
|
||||
| Controls | SAP main_heating_control code **2102** | EES: look up 2102 in the boiler-controls table |
|
||||
| PCDB Index | 8108 | `main_heating_index_number` — enter in Elmhurst's boiler database lookup for the exact efficiency |
|
||||
| Percentage of Heat | 100% | `main_heating_fraction` 1 |
|
||||
| Flue | Fanned, Type 2 | `fan_flue_present` true, `boiler_flue_type` 2 |
|
||||
|
||||
**Secondary Heating**: "Room heaters, smokeless fuel" — enter a secondary
|
||||
heater, fuel = smokeless solid fuel, type = room heater (not open fire).
|
||||
|
||||
## Water Heating
|
||||
|
||||
`has_hot_water_cylinder` = **false** → **no cylinder** — water heating is from
|
||||
the main system (boiler), not a separate immersion/cylinder. Leave the
|
||||
cylinder tab empty/none.
|
||||
|
||||
## New Technologies
|
||||
|
||||
No PV, wind, hydro, solar water heating (`solar_water_heating` = "N"), WWHRS,
|
||||
or FGHRS lodged — leave all New Technologies pages empty/unchecked.
|
||||
|
||||
## Fields to clear in Elmhurst (do NOT map)
|
||||
|
||||
| Elmhurst field | Set to | Why absent |
|
||||
|---|---|---|
|
||||
| Property Description · 1st–4th Extension age band | (blank) | `extensions_count` = 0 |
|
||||
| Property Description · Room-in-Roof age band | (blank) | no dedicated room-in-roof building part (see Roofs caveat above) |
|
||||
| Dimensions · 1st–4th Ext. sub-tabs | 0 / blank | single building part, no extensions |
|
||||
| Walls · Party wall | none/blank | `party_wall_length_m` = 0 on both floors |
|
||||
| Water Heating · cylinder | none | `has_hot_water_cylinder` = false |
|
||||
| Conservatory · Heated | unchecked | `has_heated_separate_conservatory` = false |
|
||||
| PV / Wind / Hydro / WWHRS / FGHRS / Solar Water Heating | none | not lodged |
|
||||
|
||||
## Operator: next steps
|
||||
|
||||
Key this into Elmhurst, report the SAP score (and heating cost £ if shown).
|
||||
If it diverges materially from our engine's **42** (lodged **45**), flag it —
|
||||
especially check the two-roof-element and the window-1-single-glazing-code
|
||||
notes above, they're the most likely sources of any gap.
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -3944,6 +3944,22 @@ def _normalize_sap_schema_16_x(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||
if "single" in description:
|
||||
d["multiple_glazing_type"] = 5
|
||||
|
||||
# P4 (worklist): 16.x certs sometimes omit `multiple_glazed_proportion`
|
||||
# entirely (RdSapSchema17_1 requires it, no default) even though the
|
||||
# human `window.description` states the glazing extent unambiguously —
|
||||
# e.g. cert 0141-2860-6891-9124-5625 (uprn 100021969385, SAP-16.2):
|
||||
# multiple_glazing_type="ND", proportion absent, description "Fully
|
||||
# double glazed". Derive from that explicit text rather than a flat
|
||||
# default (a flat `100` for every ND-missing cert previously regressed
|
||||
# the component-accuracy donor pool — see worklist P4): "single" → 0,
|
||||
# "double" → 100. Genuinely ambiguous certs (no double/single wording)
|
||||
# are left unset and still fail loud — correct, data is insufficient.
|
||||
if "multiple_glazed_proportion" not in d:
|
||||
if "single" in description:
|
||||
d["multiple_glazed_proportion"] = 0
|
||||
elif "double" in description:
|
||||
d["multiple_glazed_proportion"] = 100
|
||||
|
||||
sap_heating: Any = d.get("sap_heating")
|
||||
if isinstance(sap_heating, dict):
|
||||
heating: Dict[str, Any] = cast(Dict[str, Any], sap_heating)
|
||||
|
|
|
|||
|
|
@ -838,6 +838,25 @@ class TestFromSapSchema16_2:
|
|||
):
|
||||
EpcPropertyDataMapper.from_api_response(data)
|
||||
|
||||
def test_16_x_missing_multiple_glazed_proportion_derived_from_double_description(
|
||||
self,
|
||||
) -> None:
|
||||
# Cert 0141-2860-6891-9124-5625 (uprn 100021969385, SAP-16.2, property
|
||||
# 753950): multiple_glazing_type="ND", `multiple_glazed_proportion`
|
||||
# entirely absent, but windows[].description explicitly says "Fully
|
||||
# double glazed" — an unambiguous signal, unlike the truly ambiguous
|
||||
# ND-only certs the fail-loud test above still guards. The normaliser
|
||||
# derives 100 rather than leaving the cert unmappable.
|
||||
data = load("sap_16_2.json")
|
||||
data["windows"][0]["description"] = "Fully double glazed"
|
||||
del data["multiple_glazed_proportion"]
|
||||
|
||||
epc = EpcPropertyDataMapper.from_api_response(data)
|
||||
|
||||
assert isinstance(epc, EpcPropertyData)
|
||||
assert epc.sap_windows
|
||||
assert all(w.glazing_type == 2 for w in epc.sap_windows) # DG-modal slot
|
||||
|
||||
def test_recorded_co2_as_measurement_dict_is_coerced_not_crashed(self) -> None:
|
||||
# Some certs (e.g. 16.x cert 2308-4997-7262-0137-9930) lodge
|
||||
# `co2_emissions_current` as a Measurement dict {'value': 3.5, 'quantity':
|
||||
|
|
|
|||
|
|
@ -65,6 +65,24 @@ _FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "epc_prediction"
|
|||
# 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.
|
||||
#
|
||||
# has_hot_water_cylinder 0.8687->0.8586, cylinder_insulation_type 0.3333->0.1667,
|
||||
# door_count residual 0.3131->0.3333 re-baselined when the mapper started
|
||||
# deriving `multiple_glazed_proportion` (single->0 / double->100) from an
|
||||
# unambiguous window.description for 16.x certs whose "ND" multiple_glazing_type
|
||||
# previously left the numeric field entirely absent — RdSapSchema17_1 requires
|
||||
# it, so the cert failed loud and was excluded from the donor pool wholesale
|
||||
# (worklist P4; see the mapper comment at `_normalize_sap_schema_16_x`).
|
||||
# Motivating case: cert 0141-2860-6891-9124-5625 / uprn 100021969385 (property
|
||||
# 753950), lodging "Fully double glazed" with the proportion field missing —
|
||||
# previously hard-failed the whole modelling_e2e batch it was in. Admitting
|
||||
# these certs is a ground-truth-method change (more real lodged EPCs enter the
|
||||
# comparable pool), not a prediction-logic loosening; the tip is donor-pool
|
||||
# composition on a handful of near-tie matches, same mechanism as the #1245 /
|
||||
# ADR-0037 re-baselines above. A flat `multiple_glazed_proportion=100` default
|
||||
# was tried earlier and reverted for the same reason without a derivation
|
||||
# rationale; deriving it from explicit "single"/"double" wording is the
|
||||
# targeted fix worklist P4 called for. Tighten-only resumes from these values.
|
||||
_RATE_FLOORS: dict[str, float] = {
|
||||
"wall_construction": 0.9091,
|
||||
"wall_insulation_type": 0.8687,
|
||||
|
|
@ -77,8 +95,8 @@ _RATE_FLOORS: dict[str, float] = {
|
|||
"heating_main_control": 0.9091,
|
||||
"water_heating_fuel": 0.9495,
|
||||
"water_heating_code": 0.9798,
|
||||
"has_hot_water_cylinder": 0.8687,
|
||||
"cylinder_insulation_type": 0.3333,
|
||||
"has_hot_water_cylinder": 0.8586,
|
||||
"cylinder_insulation_type": 0.1667,
|
||||
"secondary_heating_type": 0.0000,
|
||||
"roof_insulation_thickness": 0.4118,
|
||||
"roof_insulation_thickness_pm1": 0.4118,
|
||||
|
|
@ -110,7 +128,7 @@ _RESIDUAL_CEILINGS: dict[str, float] = {
|
|||
"floor_area": 12.0586,
|
||||
"total_window_area": 3.7184,
|
||||
"building_parts": 0.1212,
|
||||
"door_count": 0.3131,
|
||||
"door_count": 0.3333,
|
||||
}
|
||||
|
||||
_TOLERANCE = 1e-3
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue