Add SAP-16.2 schema coverage + single-glazing fix; flat party-wall fix; pin 2 certs

SAP-Schema-16.2 (datatypes/epc/domain/mapper.py):
- 16.2 is structurally an RdSAP-17.1 cert under a different name; add
  _normalize_sap_schema_16_2 (field renames + defaults) and dispatch to the
  tested from_rdsap_schema_17_1 mapper. uprn_100020933699 maps → SAP 71.
- Honour a "Single glazed" windows description when multiple_glazing_type="ND"
  (was defaulting to double) → RdSAP-21 code 5; eng 72→71 (lodged 70).
- 4 regression tests + sap_16_2.json fixture; 0 new pyright errors.

Flat party-wall fix (domain/sap10_calculator/worksheet/heat_transmission.py):
- Full-SAP flats carry flatness in dwelling_type, not property_type, so the
  party-wall default fell through to the 0.25 house value instead of the RdSAP
  Table-15 flat 0.0. Add _is_flat_or_maisonette_dwelling fallback + regression
  test. uprn_10093116529 80→81 (matches the cert's lodged party u_value 0).

Accuracy corpus pins (tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py):
- uprn_10093116543 (SAP-17.1 gas-combi semi): engine 81 (Elmhurst 77; documented
  full-SAP→RdSAP residual — measured wall/floor U + PCDB boiler vs RdSAP defaults).
- uprn_10093116529 (SAP-17.1 g/f flat): engine 81 (Elmhurst 78).

devcontainer: add poppler-utils (pdfinfo) for the documents-parser PDF fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-16 18:53:00 +00:00
parent 928fbbc33a
commit 4d14607e7e
16 changed files with 1460 additions and 6 deletions

View file

@ -10,12 +10,26 @@ UPRN, tick it and annotate: `— <schema> · eng <X> / elm <Y> · <note>`.
2020 new-build flat) — full loop proven: eng 77 / elm 78, engine-on-Elmhurst-
inputs 79 (calculator faithful within ~1). Use it to sanity-check the pipeline.
## E2E testing set
UPRNs needed for end-to-end testing (also tracked in The 100 below).
| UPRN | Schema | Status | Engine SAP | Notes |
|---|---|---|---|---|
| 10093116528 | SAP-17.1 | ✅ pinned | 82 (elm 81) | full-SAP semi; AP50 fix |
| 10093116543 | SAP-17.1 | ✅ pinned | 81 (elm 77) | 2017 gas-combi semi |
| 10093116529 | SAP-17.1 | ✅ pinned | 81 (elm 78) | 2017 gas-combi g/f flat; party-wall fix |
| 100020933699 | SAP-16.2 | 🔧 mapper + glazing fix | 71 (lodged 70) | RdSAP-shaped; single-glazing fix; Elmhurst validation pending |
| 44012843 | — | ☐ todo | — | not yet processed |
| 10023444324 | — | ☐ todo | — | not yet processed |
| 10023444320 | — | ☐ todo | — | not yet processed |
## The 100
- [x] 🔧 10093116528 — SAP-17.1 (full-SAP semi) · eng 82 / elm 81 · 🔧 air-perm AP50 fix: q50 Blower-Door routed to (18)=AP50/20 not the AP4/Pulse formula (was eng 78). Residual +1 = lodged-U vs RdSAP age-band-U; FGHRS (60031) omitted both sides. Worked-ref 10092973954 re-pinned 77→80 by same fix.
- [ ] 10093116543
- [ ] 10093116529
- [ ] 100020933699
- [x] 10093116543 — SAP-17.1 (2017 gas-combi semi) · eng 81 / elm 77 (lodged 82) · PINNED engine 81. +4 = documented full-SAP→RdSAP residual, NOT a mapper bug: ~1.5 floor-U (cert lodges measured 0.11 vs Elmhurst RdSAP solid default 0.23; U-known override disabled) + ~1 boiler-eff (cert PCDB 17644 88.5% vs Elmhurst generic BGW combi 84%; PCDB search disabled, 89% cascade option is a regular boiler needing a cylinder) + ~0.5 roof band-L/infil. Conservatory leftover from prior cert cleared (worksheet 73→77). No mapper change.
- [x] 🔧 10093116529 — SAP-17.1 (2017 gas-combi ground-floor FLAT, TFA 49) · eng 81 / elm 78 (lodged 81) · PINNED engine 81. 🔧 FIXED a real calc bug: full-SAP flats took the 0.25 house party-wall default instead of the RdSAP Table-15 flat 0.0 (flatness is in dwelling_type, not property_type) — heat_transmission._is_flat_or_maisonette_dwelling; +regression test. Cert lodges party u_value 0; Elmhurst worksheet 0.0; fix 80→81. Residual +3 vs Elmhurst = documented full-SAP→RdSAP gap (measured wall 0.184/floor 0.12 + PCDB 88.5% vs generic 84%). Calculator faithful: fed Elmhurst's Us, HTC 93.4 vs ~94. House→Flat Elmhurst switch (storeys→1, roof→another-dwelling-above). No mapper change.
- [ ] 🔧 100020933699 — SAP-16.2 SCHEMA COVERAGE ADDED (end-terrace house, band G). 16.2 is structurally RdSAP-17.1 (reduced fields, glazed_area band, construction-code building parts) under a different name; mapped via `_normalize_sap_schema_16_2` (renames windows→window, main_gas→mains_gas, boiler_index_number→main_heating_index_number, wwhrs→instantaneous_wwhrs + defaults) → reuses from_rdsap_schema_17_1. 🔧 Also fixed: "Single glazed" description honoured when multiple_glazing_type="ND" (was defaulting to double; RdSAP-21 code 5) → eng 72→71. +4 regression tests, sap_16_2.json fixture, 0 new pyright errors. eng 71 / lodged 70. ⚠ Known gap: 16.2 lodges no party_wall_length → end-terrace party wall unmodelled (likely the residual +1). ⏳ Elmhurst build (partial: PropDesc/Dims/Walls/Roofs done) + pin still pending.
- [ ] 44012843
- [ ] 10023444324
- [ ] 10092970673

View file

@ -6,11 +6,14 @@ ARG USER_UID=1000
ARG USER_GID=1000
ARG DEBIAN_FRONTEND=noninteractive
# 1) Toolchain + utilities for building libpostal, plus LazyVim deps
# 1) Toolchain + utilities for building libpostal, plus LazyVim deps.
# poppler-utils provides `pdfinfo`, required by backend/documents_parser (the
# Elmhurst Input-Summary / SAP-worksheet PDF fixtures in the SAP corpus tests
# shell out to it).
RUN apt-get update && apt-get install -y --no-install-recommends \
sudo jq vim curl git ca-certificates wget \
build-essential pkg-config automake autoconf libtool \
ripgrep fd-find make unzip bash-completion \
ripgrep fd-find make unzip bash-completion poppler-utils \
&& rm -rf /var/lib/apt/lists/*
# 1b) Headed-browser viewer stack for the hyde Elmhurst automation.

View file

@ -0,0 +1,86 @@
# Elmhurst RdSAP inputs — UPRN 100020933699 (cert 9548-2053-6299-9947-0950, SAP-Schema-16.2)
**Lodged SAP:** 70 **Our engine:** 72 (continuous 72.29) ← compare Elmhurst against the engine
**Property:** End-terrace HOUSE, age band G (19831990), mains-gas regular boiler + cylinder, TFA 62 m²
**Schema note:** SAP-Schema-16.2 is RdSAP-shaped (reduced-field) — mapped via `_normalize_sap_schema_16_2` → RdSAP-17.1 mapper. This is a REDUCED-FIELD cert (descriptions + glazed-area band, no measured U-values), so unlike the 17.1 new-builds the engine and Elmhurst should both use RdSAP age-band/description U-values — expect a TIGHTER agreement.
**Known divergences / gaps to watch:**
- **Party wall:** 16.2 does not lodge `party_wall_length`; the normalizer defaults it to 0, so the engine currently models NO party wall. An end-terrace HAS one party wall — Elmhurst will model it. **This is the likely main divergence; check the worksheet party-wall line.**
- Age band G → Elmhurst on-screen band **G 1983-1990**.
## Property Description
| Elmhurst field | Value | Notes |
|---|---|---|
| Property Type | **House** | dwelling_type "End-terrace house" (switch from FLAT — prior cert) |
| Built form | **End-Terrace** | built_form 3 |
| Age band | **G 1983-1990** | construction_age_band G |
| Storeys | 2 | floor 0 + floor 1 |
| Habitable rooms | 4 | habitable_room_count 4 |
## Dimensions
| Elmhurst field | Value | Notes |
|---|---|---|
| Ground floor area | 31.06 m² | floor 0 |
| First floor area | 31.06 m² | floor 1 |
| Room height (ground / first) | 2.40 / 2.65 m | room_height |
| Heat-loss perimeter (each) | 15.8 m | heat_loss_perimeter |
| Party-wall length (each) | **see note** | 16.2 lodges none; for an end-terrace enter the real party-wall length if Elmhurst derives it from built-form, else note the divergence |
## Walls
| Elmhurst field | Value | Notes |
|---|---|---|
| Construction | Cavity | wall_construction 4 |
| Insulation | **Filled** | wall_insulation_type 2 = filled cavity ("Cavity wall, filled cavity") |
| Thickness | 280 mm | wall_thickness |
| Party wall | Present (End-Terrace) — Unable to determine | built_form 3 |
## Roofs
| Elmhurst field | Value | Notes |
|---|---|---|
| Type | Pitched, access to loft | roof_construction 4 |
| Insulation at | Joists | roof_insulation_location 2 |
| Thickness | 100 mm | roof_insulation_thickness "100mm" |
## Floors
| Elmhurst field | Value | Notes |
|---|---|---|
| Location / type | Ground floor / Solid | floor_construction 1 |
| Insulation | **None** | "Solid, no insulation (assumed)" |
## Openings
| Elmhurst field | Value | Notes |
|---|---|---|
| Windows | **Single glazed**, glazed-area band Normal | glazed_area 1, multiple_glazing_type ND, "Single glazed" |
| Doors | **2 doors, uninsulated** | door_count 2, insulated_door_count 0 (high U — solid/older doors) |
## Ventilation & Lighting
| Elmhurst field | Value | Notes |
|---|---|---|
| Ventilation | Natural | no MV; no air-permeability test lodged (ap50/ap4 None) |
| Extract fans / flues | 0 / 0 | none lodged; open chimneys 0 |
| Sheltered sides | 1 | sheltered_sides 1 |
| Air Pressure Test | **None** (do NOT enter Blower Door) | no test lodged — leave method blank |
| Lighting | 22% low-energy | low_energy 2 / total 9 outlets |
## Space Heating
| Elmhurst field | Value | Notes |
|---|---|---|
| Main heating | Mains gas **regular boiler + radiators** | fuel 26, emitter 1, control 2106, fan-assisted flue, PCDB 10321. NOT a combi — has a cylinder → use the BGB regular condensing/non-condensing cascade option (per age band G, likely non-condensing) |
| Secondary | None | secondary "None" |
## Water Heating
| Elmhurst field | Value | Notes |
|---|---|---|
| Water heating | From main (901), gas | water_heating_fuel 26 |
| Hot water cylinder | **Present** — 110 L (Normal), Foam, 25 mm | has_hot_water_cylinder True, cylinder_size 2, insulation type 1 (foam) 25 mm |
| Solar / WWHRS / FGHRS | None | — |
## Fields to clear in Elmhurst (do NOT map)
| Elmhurst field | Set to | Why absent |
|---|---|---|
| Flats page | (gone — House now) | switch from prior FLAT cert |
| Conservatory | none / uncheck | not lodged |
| Air Pressure Test method | **blank** | no test lodged (prior certs had Blower Door — CLEAR it) |
| Main Heating 2 / Secondary | none | single system |
| PV / Wind / Hydro | none | none lodged |

View file

@ -0,0 +1,310 @@
{
"uprn": 100020933699,
"roofs": [
{
"description": "Pitched, 100 mm 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,
"windows": [
{
"description": "Single glazed",
"energy_efficiency_rating": 1,
"environmental_efficiency_rating": 1
}
],
"lighting": {
"description": "Low energy lighting in 22% of fixed outlets",
"energy_efficiency_rating": 2,
"environmental_efficiency_rating": 2
},
"postcode": "SE18 2PE",
"hot_water": {
"description": "From main system",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"post_town": "LONDON",
"built_form": 3,
"created_at": "2013-01-14 15:52:33.000000",
"door_count": 2,
"glazed_area": 1,
"region_code": 17,
"report_type": 2,
"sap_heating": {
"wwhrs": {
"rooms_with_bath_and_or_shower": 1,
"rooms_with_mixer_shower_no_bath": 0,
"rooms_with_bath_and_mixer_shower": 0
},
"cylinder_size": 2,
"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,
"boiler_index_number": 10321,
"main_heating_number": 1,
"main_heating_control": 2106,
"main_heating_category": 2,
"main_heating_fraction": 1,
"main_heating_data_source": 1
}
],
"cylinder_insulation_type": 1,
"has_fixed_air_conditioning": "false",
"cylinder_insulation_thickness": 25
},
"sap_version": 9.91,
"schema_type": "SAP-Schema-16.2",
"uprn_source": "Energy Assessor",
"country_code": "EAW",
"main_heating": [
{
"description": "Boiler and radiators, mains gas",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"dwelling_type": "End-terrace house",
"language_code": 1,
"property_type": 0,
"address_line_1": "1, Acland Close",
"schema_version": "LIG-16.1",
"assessment_type": "RdSAP",
"completion_date": "2013-01-14",
"inspection_date": "2013-01-14",
"extensions_count": 0,
"measurement_type": 1,
"total_floor_area": 62,
"transaction_type": 8,
"conservatory_type": 1,
"heated_room_count": 4,
"registration_date": "2013-01-14",
"restricted_access": 0,
"sap_energy_source": {
"main_gas": "Y",
"meter_type": 2,
"photovoltaic_supply": {
"percent_roof_area": 0
},
"wind_turbines_count": 0,
"wind_turbines_terrain_type": 2
},
"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": 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": 31.06,
"floor_construction": 1,
"heat_loss_perimeter": 15.8
},
{
"floor": 1,
"room_height": 2.4,
"total_floor_area": 31.06,
"heat_loss_perimeter": 15.8
}
],
"wall_insulation_type": 2,
"construction_age_band": "G",
"wall_thickness_measured": "Y",
"roof_insulation_location": 2,
"roof_insulation_thickness": "100mm"
}
],
"low_energy_lighting": 22,
"solar_water_heating": "N",
"bedf_revision_number": 333,
"habitable_room_count": 4,
"heating_cost_current": {
"value": 370,
"currency": "GBP"
},
"insulated_door_count": 0,
"co2_emissions_current": 2.2,
"energy_rating_average": 60,
"energy_rating_current": 70,
"lighting_cost_current": {
"value": 66,
"currency": "GBP"
},
"main_heating_controls": [
{
"description": "Programmer, room thermostat and TRVs",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"multiple_glazing_type": "ND",
"open_fireplaces_count": 0,
"has_hot_water_cylinder": "true",
"heating_cost_potential": {
"value": 296,
"currency": "GBP"
},
"hot_water_cost_current": {
"value": 106,
"currency": "GBP"
},
"mechanical_ventilation": 0,
"percent_draughtproofed": 100,
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": {
"value": 27,
"currency": "GBP"
},
"indicative_cost": "\u00a3800 - \u00a31,200",
"improvement_type": "W",
"improvement_details": {
"improvement_number": 47
},
"improvement_category": 5,
"energy_performance_rating": 71,
"environmental_impact_rating": 73
},
{
"sequence": 2,
"typical_saving": {
"value": 26,
"currency": "GBP"
},
"indicative_cost": "\u00a335",
"improvement_type": "E",
"improvement_details": {
"improvement_number": 35
},
"improvement_category": 5,
"energy_performance_rating": 72,
"environmental_impact_rating": 74
},
{
"sequence": 3,
"typical_saving": {
"value": 33,
"currency": "GBP"
},
"indicative_cost": "\u00a34,000 - \u00a36,000",
"improvement_type": "N",
"improvement_details": {
"improvement_number": 19
},
"improvement_category": 5,
"energy_performance_rating": 74,
"environmental_impact_rating": 76
},
{
"sequence": 4,
"typical_saving": {
"value": 52,
"currency": "GBP"
},
"indicative_cost": "\u00a33,300 - \u00a36,500",
"improvement_type": "O",
"improvement_details": {
"improvement_number": 8
},
"improvement_category": 5,
"energy_performance_rating": 77,
"environmental_impact_rating": 80
},
{
"sequence": 5,
"typical_saving": {
"value": 236,
"currency": "GBP"
},
"indicative_cost": "\u00a39,000 - \u00a314,000",
"improvement_type": "U",
"improvement_details": {
"improvement_number": 34
},
"improvement_category": 5,
"energy_performance_rating": 89,
"environmental_impact_rating": 92
},
{
"sequence": 6,
"typical_saving": {
"value": 19,
"currency": "GBP"
},
"indicative_cost": "\u00a31,500 - \u00a34,000",
"improvement_type": "V",
"improvement_details": {
"improvement_number": 44
},
"improvement_category": 5,
"energy_performance_rating": 90,
"environmental_impact_rating": 92
}
],
"co2_emissions_potential": 0.5,
"energy_rating_potential": 90,
"lighting_cost_potential": {
"value": 37,
"currency": "GBP"
},
"hot_water_cost_potential": {
"value": 72,
"currency": "GBP"
},
"renewable_heat_incentive": {
"water_heating": 2577,
"impact_of_loft_insulation": -309,
"space_heating_existing_dwelling": 5864
},
"seller_commission_report": "Y",
"energy_consumption_current": 189,
"has_fixed_air_conditioning": "false",
"multiple_glazed_proportion": 0,
"calculation_software_version": 8.0,
"energy_consumption_potential": 36,
"environmental_impact_current": 71,
"fixed_lighting_outlets_count": 9,
"current_energy_efficiency_band": "C",
"environmental_impact_potential": 92,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "B",
"co2_emissions_current_per_floor_area": 36,
"low_energy_fixed_lighting_outlets_count": 2
}

View file

@ -0,0 +1,88 @@
# Elmhurst RdSAP inputs — UPRN 10093116529 (cert 8178-7436-5600-9809-0906, SAP-Schema-17.1)
**Lodged SAP:** 81 **Our engine:** 80 (continuous 80.22) ← compare Elmhurst against the engine
**Property:** ground-floor FLAT, 2017 build, mains-gas combi, TFA 49.0 m²
**Known divergences (flag, don't tune) — same full-SAP→RdSAP pattern as uprn_10093116543:**
- **Walls/floor measured U:** cert lodges measured U (wall 0.184, floor 0.12); engine uses them, Elmhurst RdSAP forces band-L defaults (~0.28 / ~0.23). Expected residual.
- **Boiler:** cert PCDB 17644 (88.5%); Elmhurst PCDB search disabled → generic BGW combi 84%.
- **Age band:** engine 'M', build year 2017 → enter Elmhurst band **L (20122022)**.
## Property Description
| Elmhurst field | Value | Notes |
|---|---|---|
| Property Type | **Flat** | dwelling_type "Ground-floor flat" — SWITCH from House (prior cert) |
| Built form | (flat — N/A) | — |
| Age band | **L (20122022)** | construction_year 2017 |
| Habitable rooms | 2 | habitable_rooms_count 2 |
| Storeys | 1 | single-storey flat |
## Flats
| Elmhurst field | Value | Notes |
|---|---|---|
| Position of flat | Ground Floor | ground-floor flat |
| Floor level | 0 | ground = storey 0 |
| Corridor | **None** (all-exposed) | the "to corridor" wall is keyed as a semi-exposed wall element instead — see Walls. Avoids the sheltered-alt-wall validation trap. |
## Dimensions
| Elmhurst field | Value | Notes |
|---|---|---|
| Lowest-floor area | 49.03 m² | floor dim[0] |
| Room height | 2.60 m | room_height_m |
| Heat-loss perimeter | 28.45 m | heat_loss_perimeter_m |
| Party-wall length | 4.34 m | party_wall_length_m |
## Walls
| Elmhurst field | Value | Notes |
|---|---|---|
| Main wall | Cavity / As Built | wall_construction 4; cert lodges measured U 0.184 (Elmhurst RdSAP default ~0.28) |
| Wall area (external) | 56.6 m² | Wall 1 "External" |
| "To corridor" wall | semi-exposed, 17.37 m², U 0.15 | Wall 3 (type 3). Heat loss to unheated corridor — key as an alternative/sheltered wall if Elmhurst allows; else note divergence. |
| Party wall | Present, **U Unable to determine** | Wall 2 (type 4) area 11.28; party_walls_w_per_k 2.82 |
## Roofs
| Elmhurst field | Value | Notes |
|---|---|---|
| Roof type | **A Another dwelling above** | ground-floor flat → roof_w_per_k = 0 |
## Floors
| Elmhurst field | Value | Notes |
|---|---|---|
| Location | Ground floor | floor_type 2 |
| Type / insulation | Solid / As built | cert lodges measured U 0.12 (Elmhurst RdSAP ~0.23) |
## Openings
| Elmhurst field | Value | Notes |
|---|---|---|
| Windows (combined) | ~24.9 m², East, Double post-2022 | 7 synth windows; U 1.4 / g 0.72 / frame 0.7 |
| Doors | 1 insulated, U 1.6 | door_count 1 |
## Ventilation & Lighting
| Elmhurst field | Value | Notes |
|---|---|---|
| Ventilation | Natural + 2 intermittent extract fans | extract_fans_count 2 |
| Air Pressure Test | Blower Door, **3.45** (+ cert number) | air_permeability_ap50 3.45 |
| Sheltered sides | 2 | sheltered_sides 2 |
| Lighting | 100% low-energy | 10 bulbs, all low-energy |
## Space Heating
| Elmhurst field | Value | Notes |
|---|---|---|
| Main heating | Mains gas condensing **combi** (generic BGW 84% substitute) | fuel 1, emitter radiators, control 2110, fan-assisted balanced flue, PCDB 17644 (88.5% — can't enter) |
| Secondary | None | — |
## Water Heating
| Elmhurst field | Value | Notes |
|---|---|---|
| Water heating | From main (combi), gas | code 901; no cylinder |
| Solar / WWHRS / FGHRS | None | — |
## Fields to clear in Elmhurst (do NOT map)
| Elmhurst field | Set to | Why absent |
|---|---|---|
| Property Description · extensions / room-in-roof | blank | none |
| Dimensions · 1st-floor row | clear | single-storey flat (was 2-storey house) |
| Conservatory | none / uncheck | not lodged (CLEAR — prior-cert leftover risk) |
| Space Heating · Main Heating 2 / Secondary | none | single main system |
| Water Heating · cylinder | none | combi |
| New Tech · PV / Wind / Hydro | none | none lodged |

View file

@ -0,0 +1,390 @@
{
"uprn": 10093116529,
"roofs": [
{
"description": "(other premises above)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
}
],
"walls": [
{
"description": "Average thermal transmittance 0.17 W/m\u00b2K",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
}
],
"floors": [
{
"description": "Average thermal transmittance 0.12 W/m\u00b2K",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
}
],
"status": "entered",
"tenure": "ND",
"windows": {
"description": "High performance glazing",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
},
"lighting": {
"description": "Low energy lighting in all fixed outlets",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
},
"postcode": "PO10 8BG",
"data_type": 2,
"hot_water": {
"description": "From main system, flue gas heat recovery",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
},
"post_town": "EMSWORTH",
"built_form": 2,
"created_at": "2018-06-04 10:56:20",
"living_area": 21.77,
"orientation": 0,
"region_code": 16,
"report_type": 3,
"sap_heating": {
"thermal_store": 1,
"water_fuel_type": 1,
"water_heating_code": 901,
"main_heating_details": [
{
"has_fghrs": "true",
"main_fuel_type": 1,
"heat_emitter_type": 1,
"fghrs_index_number": 60031,
"emitter_temperature": 1,
"is_flue_fan_present": "true",
"main_heating_number": 1,
"main_heating_control": 2110,
"is_interlocked_system": "true",
"main_heating_category": 2,
"main_heating_fraction": 1,
"main_heating_flue_type": 2,
"central_heating_pump_age": 2,
"main_heating_data_source": 1,
"main_heating_index_number": 17644,
"has_separate_delayed_start": "true",
"load_or_weather_compensation": 0,
"is_central_heating_pump_in_heated_space": "true"
}
],
"has_hot_water_cylinder": "false",
"has_fixed_air_conditioning": "false",
"secondary_heating_category": 1
},
"sap_version": 9.92,
"schema_type": "SAP-Schema-17.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": "Air permeability 3.5 m\u00b3/h.m\u00b2 (as tested)",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"dwelling_type": "Ground-floor flat",
"language_code": 1,
"property_type": 2,
"address_line_1": "6, Woodfield Park Road",
"assessment_date": "2018-06-01",
"assessment_type": "SAP",
"completion_date": "2018-06-04",
"inspection_date": "2018-06-01",
"sap_ventilation": {
"psv_count": 0,
"pressure_test": 1,
"air_permeability": 3.45,
"open_flues_count": 0,
"ventilation_type": 1,
"extract_fans_count": 2,
"open_fireplaces_count": 0,
"sheltered_sides_count": 2,
"flueless_gas_fires_count": 0
},
"design_water_use": 1,
"sap_data_version": 9.92,
"sap_flat_details": {
"level": 1
},
"total_floor_area": 49,
"transaction_type": 6,
"conservatory_type": 1,
"registration_date": "2018-06-04",
"sap_energy_source": {
"electricity_tariff": 1,
"wind_turbines_count": 0,
"wind_turbine_terrain_type": 2,
"fixed_lighting_outlets_count": 10,
"low_energy_fixed_lighting_outlets_count": 10,
"low_energy_fixed_lighting_outlets_percentage": 100
},
"sap_opening_types": [
{
"name": 1,
"type": 4,
"u_value": 1.4,
"data_source": 2,
"description": "Window",
"frame_factor": 0.7,
"glazing_type": 4,
"solar_transmittance": 0.72
},
{
"name": 8,
"type": 1,
"u_value": 1.6,
"data_source": 2,
"description": "Front Door",
"glazing_type": 1
}
],
"secondary_heating": {
"description": "None",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"sap_building_parts": [
{
"sap_walls": [
{
"name": "Wall 1",
"u_value": 0.184,
"wall_type": 2,
"description": "External",
"total_wall_area": 56.6,
"is_curtain_walling": "false"
},
{
"name": "Wall 3",
"u_value": 0.15,
"wall_type": 3,
"description": "To corridor",
"total_wall_area": 17.37,
"is_curtain_walling": "false"
},
{
"name": "Wall 2",
"u_value": 0,
"wall_type": 4,
"description": "To flat",
"total_wall_area": 11.28
}
],
"identifier": "Main Dwelling",
"overshading": 2,
"sap_openings": [
{
"name": 1,
"type": 1,
"width": 0.72,
"height": 2.5,
"location": "Wall 1",
"orientation": 3
},
{
"name": 2,
"type": 1,
"width": 1.6,
"height": 2.5,
"location": "Wall 1",
"orientation": 3
},
{
"name": 3,
"type": 1,
"width": 1,
"height": 2.5,
"location": "Wall 1",
"orientation": 7
},
{
"name": 4,
"type": 1,
"width": 2.5,
"height": 2.5,
"location": "Wall 1",
"orientation": 7
},
{
"name": 5,
"type": 1,
"width": 1.15,
"height": 2.5,
"location": "Wall 1",
"orientation": 1
},
{
"name": 6,
"type": 1,
"width": 1.5,
"height": 2.5,
"location": "Wall 1",
"orientation": 1
},
{
"name": 7,
"type": 1,
"width": 1.5,
"height": 2.5,
"location": "Wall 1",
"orientation": 1
},
{
"name": 8,
"type": 8,
"width": 1,
"height": 2.1,
"location": "Wall 3",
"orientation": 0
}
],
"construction_year": 2017,
"sap_thermal_bridges": {
"thermal_bridges": [
{
"length": 10.97,
"psi_value": 0.3,
"psi_value_source": 2,
"thermal_bridge_type": "E2"
},
{
"length": 9.97,
"psi_value": 0.04,
"psi_value_source": 2,
"thermal_bridge_type": "E3"
},
{
"length": 39.2,
"psi_value": 0.05,
"psi_value_source": 2,
"thermal_bridge_type": "E4"
},
{
"length": 21.96,
"psi_value": 0.16,
"psi_value_source": 2,
"thermal_bridge_type": "E5"
},
{
"length": 21.96,
"psi_value": 0.07,
"psi_value_source": 2,
"thermal_bridge_type": "E7"
},
{
"length": 10.4,
"psi_value": 0.09,
"psi_value_source": 2,
"thermal_bridge_type": "E16"
},
{
"length": 2.6,
"psi_value": -0.09,
"psi_value_source": 2,
"thermal_bridge_type": "E17"
},
{
"length": 2.4,
"psi_value": 0.06,
"psi_value_source": 2,
"thermal_bridge_type": "E18"
},
{
"length": 6.68,
"psi_value": 0.16,
"psi_value_source": 4,
"thermal_bridge_type": "P1"
},
{
"length": 6.68,
"psi_value": 0,
"psi_value_source": 4,
"thermal_bridge_type": "P3"
}
],
"thermal_bridge_code": 5
},
"building_part_number": 1,
"sap_floor_dimensions": [
{
"storey": 0,
"u_value": 0.12,
"floor_type": 2,
"description": "Floor 1",
"storey_height": 2.6,
"heat_loss_area": 49.03,
"total_floor_area": 49.03
}
],
"thermal_mass_parameter": 100
}
],
"heating_cost_current": {
"value": 185,
"currency": "GBP"
},
"co2_emissions_current": 0.9,
"energy_rating_average": 60,
"energy_rating_current": 81,
"lighting_cost_current": {
"value": 37,
"currency": "GBP"
},
"main_heating_controls": [
{
"description": "Time and temperature zone control",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
}
],
"has_hot_water_cylinder": "false",
"heating_cost_potential": {
"value": 185,
"currency": "GBP"
},
"hot_water_cost_current": {
"value": 58,
"currency": "GBP"
},
"co2_emissions_potential": 0.9,
"energy_rating_potential": 81,
"lighting_cost_potential": {
"value": 37,
"currency": "GBP"
},
"schema_version_original": "LIG-17.0",
"hot_water_cost_potential": {
"value": 58,
"currency": "GBP"
},
"is_in_smoke_control_area": "unknown",
"renewable_heat_incentive": {
"rhi_new_dwelling": {
"space_heating": 1927,
"water_heating": 1458
}
},
"seller_commission_report": "Y",
"energy_consumption_current": 108,
"has_fixed_air_conditioning": "false",
"multiple_glazed_percentage": 100,
"calculation_software_version": 6.2,
"energy_consumption_potential": 108,
"environmental_impact_current": 85,
"current_energy_efficiency_band": "B",
"environmental_impact_potential": 85,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "B",
"co2_emissions_current_per_floor_area": 19
}

View file

@ -1,3 +1,4 @@
import copy
import re
from dataclasses import replace
from datetime import date
@ -2552,6 +2553,19 @@ class EpcPropertyDataMapper:
return EpcPropertyDataMapper.from_sap_schema_17_1(
from_dict(SapSchema17_1, data)
)
if schema == "SAP-Schema-16.2":
# SAP-Schema-16.2 is structurally an RdSAP-17.1 cert (reduced fields,
# glazed_area band, construction-code building parts) under a different
# name + a handful of renamed/omitted fields — normalise it onto the
# RdSAP-17.1 shape and reuse that tested mapper. See
# `_normalize_sap_schema_16_2`.
from datatypes.epc.schema.rdsap_schema_17_1 import RdSapSchema17_1
return _clear_basement_flag_when_system_built(
EpcPropertyDataMapper.from_rdsap_schema_17_1(
from_dict(RdSapSchema17_1, _normalize_sap_schema_16_2(data))
)
)
raise ValueError(f"Unsupported EPC schema: {schema!r}")
@ -3008,6 +3022,81 @@ def _default_missing_post_town(data: Dict[str, Any]) -> Dict[str, Any]:
return {**data, "post_town": ""}
def _normalize_sap_schema_16_2(data: Dict[str, Any]) -> Dict[str, Any]:
"""Rewrite a `SAP-Schema-16.2` API doc onto the `RdSAP-Schema-17.1` shape so
it can reuse the tested `from_rdsap_schema_17_1` mapper.
Despite the "SAP-Schema" name, 16.2 is structurally an RdSAP cert (reduced
fields: a `glazed_area` *band* not measured openings, construction-code
building parts, `main_gas`/`meter_type` energy source, a PCDB boiler index).
It differs from RdSAP-17.1 only by a handful of field names plus three
fields it omits:
* top-level `windows` (list) `window` (first element);
`schema_version` `schema_version_original`
* sap_heating `wwhrs` `instantaneous_wwhrs` (identical shape);
`immersion_heating_type` defaulted (16.2 omits it gas cert)
* main_heating_details[] `boiler_index_number` `main_heating_index_number`
(preserves PCDB efficiency); `emitter_temperature` defaulted
* sap_floor_dimensions[] `party_wall_length` defaulted to 0 (16.2 does not
lodge it note: party-wall heat loss is then unmodelled)
* sap_energy_source `main_gas` `mains_gas`
Mirrors `_normalize_shower_outlets` mutates a deep copy, never the
caller's dict. Defaults use setdefault so a 16.2 cert that *does* carry the
RdSAP-17.1 name is left untouched.
"""
d: Dict[str, Any] = copy.deepcopy(data)
def _dicts(value: Any) -> List[Dict[str, Any]]:
"""The dict elements of `value` when it is a list, else []."""
if not isinstance(value, list):
return []
items: List[Any] = cast(List[Any], value)
return [cast(Dict[str, Any], x) for x in items if isinstance(x, dict)]
windows: Any = d.get("windows")
if isinstance(windows, list) and windows:
window_list: List[Any] = cast(List[Any], windows)
d.setdefault("window", window_list[0])
d.setdefault("schema_version_original", d.get("schema_version", ""))
# 16.2 lodges glazing in BOTH `multiple_glazing_type` (frequently the "ND"
# not-defined sentinel) AND the windows[].description. When the numeric field
# is undefined, honour an explicit "Single glazed" description so it is not
# defaulted to double — RdSAP-21 code 5 = single glazing (cascade single
# slot, U≈4.8). Otherwise keep the ND→double-modal default the cascade uses.
glazing_type: Any = d.get("multiple_glazing_type")
if not isinstance(glazing_type, int):
window: Any = d.get("window")
description = ""
if isinstance(window, dict):
description = str(cast(Dict[str, Any], window).get("description") or "").lower()
if "single" in description:
d["multiple_glazing_type"] = 5
sap_heating: Any = d.get("sap_heating")
if isinstance(sap_heating, dict):
heating: Dict[str, Any] = cast(Dict[str, Any], sap_heating)
if "wwhrs" in heating:
heating.setdefault("instantaneous_wwhrs", heating["wwhrs"])
heating.setdefault("immersion_heating_type", None)
for mh in _dicts(heating.get("main_heating_details")):
if "boiler_index_number" in mh:
mh.setdefault("main_heating_index_number", mh["boiler_index_number"])
mh.setdefault("emitter_temperature", None)
for bp in _dicts(d.get("sap_building_parts")):
for fd in _dicts(bp.get("sap_floor_dimensions")):
fd.setdefault("party_wall_length", 0)
energy_source: Any = d.get("sap_energy_source")
if isinstance(energy_source, dict):
source: Dict[str, Any] = cast(Dict[str, Any], energy_source)
if "main_gas" in source:
source.setdefault("mains_gas", source["main_gas"])
return d
def _count_shower_outlets_by_type(
schema_shower_outlets: Any,
target_type: int,

View file

@ -376,3 +376,54 @@ class TestFromSapSchema17_1Perimeter:
schema = from_dict(SapSchema17_1, data)
with pytest.raises(UnmappedApiCode):
EpcPropertyDataMapper.from_sap_schema_17_1(schema)
class TestFromSapSchema16_2:
"""SAP-Schema-16.2 — structurally an RdSAP-17.1 cert under a different name
+ a few renamed/omitted fields. `from_api_response` normalises it onto the
RdSAP-17.1 shape (`_normalize_sap_schema_16_2`) and reuses that mapper.
Regression for uprn_100020933699 (cert 9548--0950, End-terrace house)."""
def test_16_2_dispatches_and_maps_via_rdsap_17_1(self) -> None:
# Arrange / Act — the raw 16.2 doc must dispatch (no "Unsupported
# schema") and map all the renamed seams: windows→window,
# main_gas→mains_gas, boiler_index_number→main_heating_index_number,
# wwhrs→instantaneous_wwhrs.
epc = EpcPropertyDataMapper.from_api_response(load("sap_16_2.json"))
# Assert — identity + the seams that would silently drop on a bad rename.
assert isinstance(epc, EpcPropertyData)
assert epc.uprn == 100020933699
assert epc.total_floor_area_m2 == 62.0
assert epc.dwelling_type == "End-terrace house"
mh = epc.sap_heating.main_heating_details[0]
assert mh.main_fuel_type == 26 # mains gas
# boiler_index_number must survive as the PCDB index (efficiency source).
assert mh.main_heating_index_number == 10321
def test_16_2_produces_a_sap_score(self) -> None:
# The mapped cert must run end-to-end through the SAP-10 engine.
from domain.sap10_calculator.calculator import Sap10Calculator
epc = EpcPropertyDataMapper.from_api_response(load("sap_16_2.json"))
result = Sap10Calculator().calculate(epc)
# Lodged 70; engine produces 71 (Elmhurst validation still pending).
assert result.sap_score == 71
def test_16_2_single_glazed_description_honoured_over_nd(self) -> None:
# 16.2 lodges multiple_glazing_type "ND" but a "Single glazed" windows
# description; the normaliser must route to single glazing (cascade slot
# 1, U≈4.8) not the ND→double default — else single-glazed older certs
# over-rate. Regression for uprn_100020933699.
epc = EpcPropertyDataMapper.from_api_response(load("sap_16_2.json"))
assert epc.sap_windows, "expected synthesised windows"
assert epc.sap_windows[0].glazing_type == 1 # cascade single-glazing slot
def test_16_2_normalizer_does_not_mutate_caller_dict(self) -> None:
# Mirror _normalize_shower_outlets' contract: the caller's dict is
# untouched (deep copy), so a re-dispatch sees the original shape.
data = load("sap_16_2.json")
before_keys = set(data.keys())
EpcPropertyDataMapper.from_api_response(data)
assert "window" not in data # the rename landed only on the copy
assert set(data.keys()) == before_keys

View file

@ -0,0 +1,310 @@
{
"uprn": 100020933699,
"roofs": [
{
"description": "Pitched, 100 mm 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,
"windows": [
{
"description": "Single glazed",
"energy_efficiency_rating": 1,
"environmental_efficiency_rating": 1
}
],
"lighting": {
"description": "Low energy lighting in 22% of fixed outlets",
"energy_efficiency_rating": 2,
"environmental_efficiency_rating": 2
},
"postcode": "SE18 2PE",
"hot_water": {
"description": "From main system",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"post_town": "LONDON",
"built_form": 3,
"created_at": "2013-01-14 15:52:33.000000",
"door_count": 2,
"glazed_area": 1,
"region_code": 17,
"report_type": 2,
"sap_heating": {
"wwhrs": {
"rooms_with_bath_and_or_shower": 1,
"rooms_with_mixer_shower_no_bath": 0,
"rooms_with_bath_and_mixer_shower": 0
},
"cylinder_size": 2,
"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,
"boiler_index_number": 10321,
"main_heating_number": 1,
"main_heating_control": 2106,
"main_heating_category": 2,
"main_heating_fraction": 1,
"main_heating_data_source": 1
}
],
"cylinder_insulation_type": 1,
"has_fixed_air_conditioning": "false",
"cylinder_insulation_thickness": 25
},
"sap_version": 9.91,
"schema_type": "SAP-Schema-16.2",
"uprn_source": "Energy Assessor",
"country_code": "EAW",
"main_heating": [
{
"description": "Boiler and radiators, mains gas",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"dwelling_type": "End-terrace house",
"language_code": 1,
"property_type": 0,
"address_line_1": "1, Acland Close",
"schema_version": "LIG-16.1",
"assessment_type": "RdSAP",
"completion_date": "2013-01-14",
"inspection_date": "2013-01-14",
"extensions_count": 0,
"measurement_type": 1,
"total_floor_area": 62,
"transaction_type": 8,
"conservatory_type": 1,
"heated_room_count": 4,
"registration_date": "2013-01-14",
"restricted_access": 0,
"sap_energy_source": {
"main_gas": "Y",
"meter_type": 2,
"photovoltaic_supply": {
"percent_roof_area": 0
},
"wind_turbines_count": 0,
"wind_turbines_terrain_type": 2
},
"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": 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": 31.06,
"floor_construction": 1,
"heat_loss_perimeter": 15.8
},
{
"floor": 1,
"room_height": 2.4,
"total_floor_area": 31.06,
"heat_loss_perimeter": 15.8
}
],
"wall_insulation_type": 2,
"construction_age_band": "G",
"wall_thickness_measured": "Y",
"roof_insulation_location": 2,
"roof_insulation_thickness": "100mm"
}
],
"low_energy_lighting": 22,
"solar_water_heating": "N",
"bedf_revision_number": 333,
"habitable_room_count": 4,
"heating_cost_current": {
"value": 370,
"currency": "GBP"
},
"insulated_door_count": 0,
"co2_emissions_current": 2.2,
"energy_rating_average": 60,
"energy_rating_current": 70,
"lighting_cost_current": {
"value": 66,
"currency": "GBP"
},
"main_heating_controls": [
{
"description": "Programmer, room thermostat and TRVs",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"multiple_glazing_type": "ND",
"open_fireplaces_count": 0,
"has_hot_water_cylinder": "true",
"heating_cost_potential": {
"value": 296,
"currency": "GBP"
},
"hot_water_cost_current": {
"value": 106,
"currency": "GBP"
},
"mechanical_ventilation": 0,
"percent_draughtproofed": 100,
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": {
"value": 27,
"currency": "GBP"
},
"indicative_cost": "\u00a3800 - \u00a31,200",
"improvement_type": "W",
"improvement_details": {
"improvement_number": 47
},
"improvement_category": 5,
"energy_performance_rating": 71,
"environmental_impact_rating": 73
},
{
"sequence": 2,
"typical_saving": {
"value": 26,
"currency": "GBP"
},
"indicative_cost": "\u00a335",
"improvement_type": "E",
"improvement_details": {
"improvement_number": 35
},
"improvement_category": 5,
"energy_performance_rating": 72,
"environmental_impact_rating": 74
},
{
"sequence": 3,
"typical_saving": {
"value": 33,
"currency": "GBP"
},
"indicative_cost": "\u00a34,000 - \u00a36,000",
"improvement_type": "N",
"improvement_details": {
"improvement_number": 19
},
"improvement_category": 5,
"energy_performance_rating": 74,
"environmental_impact_rating": 76
},
{
"sequence": 4,
"typical_saving": {
"value": 52,
"currency": "GBP"
},
"indicative_cost": "\u00a33,300 - \u00a36,500",
"improvement_type": "O",
"improvement_details": {
"improvement_number": 8
},
"improvement_category": 5,
"energy_performance_rating": 77,
"environmental_impact_rating": 80
},
{
"sequence": 5,
"typical_saving": {
"value": 236,
"currency": "GBP"
},
"indicative_cost": "\u00a39,000 - \u00a314,000",
"improvement_type": "U",
"improvement_details": {
"improvement_number": 34
},
"improvement_category": 5,
"energy_performance_rating": 89,
"environmental_impact_rating": 92
},
{
"sequence": 6,
"typical_saving": {
"value": 19,
"currency": "GBP"
},
"indicative_cost": "\u00a31,500 - \u00a34,000",
"improvement_type": "V",
"improvement_details": {
"improvement_number": 44
},
"improvement_category": 5,
"energy_performance_rating": 90,
"environmental_impact_rating": 92
}
],
"co2_emissions_potential": 0.5,
"energy_rating_potential": 90,
"lighting_cost_potential": {
"value": 37,
"currency": "GBP"
},
"hot_water_cost_potential": {
"value": 72,
"currency": "GBP"
},
"renewable_heat_incentive": {
"water_heating": 2577,
"impact_of_loft_insulation": -309,
"space_heating_existing_dwelling": 5864
},
"seller_commission_report": "Y",
"energy_consumption_current": 189,
"has_fixed_air_conditioning": "false",
"multiple_glazed_proportion": 0,
"calculation_software_version": 8.0,
"energy_consumption_potential": 36,
"environmental_impact_current": 71,
"fixed_lighting_outlets_count": 9,
"current_energy_efficiency_band": "C",
"environmental_impact_potential": 92,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "B",
"co2_emissions_current_per_floor_area": 36,
"low_energy_fixed_lighting_outlets_count": 2
}

View file

@ -231,6 +231,20 @@ def _is_flat_or_maisonette(property_type: Optional[str]) -> bool:
return property_type in _PROPERTY_TYPES_FLAT_OR_MAISONETTE
def _is_flat_or_maisonette_dwelling(dwelling_type: Optional[str]) -> bool:
"""Fallback flat/maisonette detection from `epc.dwelling_type` for certs
where `property_type` is absent. Full-SAP / SAP-Schema certs carry the
flatness only in dwelling_type (e.g. "Ground-floor flat", "Mid-floor
maisonette"), so without this the RdSAP 10 Table 15 footnote * party-wall
U=0 default never fires for them and they wrongly take the 0.25 house
default. Houses/bungalows ("Detached house", "Mid-Terrace house") don't
contain these tokens, so they stay on the house default."""
if dwelling_type is None:
return False
dt = dwelling_type.lower()
return "flat" in dt or "maisonette" in dt
@dataclass(frozen=True)
class HeatTransmission:
"""SAP 10.2 §3 conduction HLC broken down per element type, summed
@ -1029,7 +1043,10 @@ def heat_transmission_from_cert(
# branch (they're houses for party-wall purposes per the spec).
upw = u_party_wall(
party_wall_construction=party_construction,
is_flat=_is_flat_or_maisonette(epc.property_type),
is_flat=(
_is_flat_or_maisonette(epc.property_type)
or _is_flat_or_maisonette_dwelling(epc.dwelling_type)
),
)
# Per-bp `y` for backwards compat: when the bp's own age band
# differs from the dwelling's primary, the cascade applies the

View file

@ -129,6 +129,62 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
cert_num="8000-8495-2839-2607-9683",
sap_score=82,
),
# 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
# RdSAP SAP-10 engine. Lodged 82; engine produces 81. Built in Elmhurst
# RdSAP10 on the lodged inputs (evidence saved: elmhurst_summary.pdf /
# elmhurst_worksheet.pdf): Elmhurst worksheet SAP 77. The +4 (81 vs 77) is
# the inherent full-SAP-lodged-value vs RdSAP-default methodology gap — the
# engine faithfully uses the cert's MEASURED full-SAP data that Elmhurst
# RdSAP structurally cannot:
# • Ground floor: cert lodges measured U=0.11 (raw floor u_value 0.11,
# "Average thermal transmittance 0.11 W/m²K"); engine uses 0.11, Elmhurst
# RdSAP solid-floor default = 0.23 (~1.5 SAP). Elmhurst's U-value-known
# override can't be set via the entry tool.
# • Boiler: cert lodges PCDB index 17644 (88.5%); engine uses 88.5%,
# Elmhurst's PCDB/SEDBUK boiler search is disabled in this UI state so the
# only automatable combi is the generic SAP Table 4b "BGW condensing
# combi" = 84% (~1 SAP). The 89% cascade option is a *regular* boiler
# (needs a cylinder) — not valid for this combi/no-cylinder dwelling.
# • Remainder: roof band-L 0.16 vs engine band-M 0.15, infiltration /
# sheltered-sides 1-vs-2 (~0.5).
# Calculator confirmed faithful on the prior sibling cert (engine on
# Elmhurst's own parsed inputs ≈ worksheet). A leftover conservatory from the
# reused assessment was cleared during the build (worksheet 73→77). PINNED TO
# THE OBSERVED 81, not lodged 82 — mapping deliberately untuned; the Elmhurst
# delta is the documented full-SAP→RdSAP residual, NOT a mapper bug.
RealCertExpectation(
schema="SAP-Schema-17.1",
sample="uprn_10093116543",
cert_num="8358-7436-5620-6889-0906",
sap_score=81,
),
# UPRN 10093116529 → cert 8178-7436-5600-9809-0906. SAP-Schema-17.1 — a
# FULL-SAP cert (2017 mains-gas combi GROUND-FLOOR FLAT, Emsworth, TFA 49 m²),
# forced through the RdSAP SAP-10 engine. Lodged 81; engine produces 81. Built
# in Elmhurst RdSAP10 on the lodged inputs (evidence saved: elmhurst_summary.pdf
# / elmhurst_worksheet.pdf): Elmhurst worksheet SAP 78. Calculator confirmed
# faithful — fed Elmhurst's own U-values the engine reproduces its HTC to ~0.6
# W/K (93.4 vs ~94). The +3 (81 vs 78) is the full-SAP→RdSAP methodology gap:
# cert lodges measured U (wall 0.184, floor 0.12), engine uses them, Elmhurst
# RdSAP forces band-L defaults; plus the generic 84% combi vs the cert's PCDB
# 17644 (88.5%). The "to corridor" wall (17.37 m², U 0.15) is already inside the
# heat-loss perimeter (28.45 m × 2.6 = 73.97 m² external), so Elmhurst derives
# it as part of the main cavity wall.
# This cert surfaced + drove the FLAT PARTY-WALL FIX (heat_transmission.py
# `_is_flat_or_maisonette_dwelling`): full-SAP flats carry their flatness in
# `dwelling_type` not `property_type`, so the party-wall default was wrongly
# the 0.25 house value instead of the RdSAP Table 15 footnote-* flat 0.0. The
# cert lodges party `u_value: 0` and Elmhurst's worksheet uses 0.0; the fix
# lifts this flat 80→81 (and matches the lodged/Elmhurst party wall).
# PINNED TO THE OBSERVED 81, not lodged 81-coincidence — mapping untuned; the
# Elmhurst delta is the documented full-SAP residual, not a bug.
RealCertExpectation(
schema="SAP-Schema-17.1",
sample="uprn_10093116529",
cert_num="8178-7436-5600-9809-0906",
sap_score=81,
),
# UPRN 10002468137 → cert 0215-2818-7357-9703-2145. RdSAP-Schema-17.1,
# all-electric high-heat-retention storage heaters on Economy 7, solid-
# brick uninsulated end-terrace. Validated against Elmhurst RdSAP10 on

View file

@ -1150,6 +1150,46 @@ def test_ground_floor_flat_exposure_keeps_floor_drops_roof() -> None:
assert ground.roof_w_per_k == 0.0
def test_full_sap_flat_party_wall_uses_flat_zero_default_via_dwelling_type() -> None:
# Arrange — a full-SAP / SAP-Schema cert carries its flatness in
# `dwelling_type` ("Ground-floor flat"), NOT `property_type` (which is
# None). The party-wall default must still fire the RdSAP 10 Table 15
# footnote * flat value U=0.0 (party wall between two heated dwellings is
# not a heat-loss element), not the 0.25 house default. Regression for
# uprn_10093116529: without the dwelling_type fallback the engine charged
# 2.82 W/K of phantom party-wall loss (cert lodged party u_value 0;
# Elmhurst's worksheet uses 0.0). `party_wall_construction=None` =
# "unknown" → the is_flat-dependent default branch.
def _build(dwelling_type: str) -> HeatTransmission:
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="L",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=None, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=49.0, room_height_m=2.6,
party_wall_length_m=5.0, heat_loss_perimeter_m=28.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=49.0, country_code="ENG",
dwelling_type=dwelling_type, property_type=None,
sap_building_parts=[main],
)
return heat_transmission_from_cert(epc)
# Act
flat = _build("Ground-floor flat")
house = _build("Mid-terrace house")
# Assert — flat party wall is a non-heat-loss element (U=0); the same
# geometry as a house takes the 0.25 unknown-house default (> 0).
assert flat.party_walls_w_per_k == 0.0
assert house.party_walls_w_per_k > 0.0
def test_floor_over_another_dwelling_below_zeroes_floor_despite_exposed_flag() -> None:
# Arrange — a "Ground-floor flat" lodged with floor_heat_loss=6
# ("another dwelling below") sits over a heated dwelling (e.g. a