Merge branch 'main' into feature/abri-api-integration

This commit is contained in:
Daniel Roth 2026-07-03 16:00:18 +00:00
commit 6a7cc68c9a
96 changed files with 14897 additions and 350 deletions

View file

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

View file

@ -103,6 +103,10 @@ _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)**:
@ -313,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, 20122022) 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**:

View file

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

View file

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

1 UPRN Address Postcode
2 U1027392 U1041316 26 Silverdale Road, Oprington U1041316_58 Ravensworth Road, Mottingham BR5 2LT SE9 4LW
3 U1003906 U2022716 54 Barnsdale Crescent, Oprington U2022716_306 St Helier Avenue, Morden BR5 2AX SM4 6JX
4 U1034479 U2018001 90 Mead Way, Bromley U2018001_37 Leominster Road, Morden BR2 9EU SM4 6HN
5 U1005549 U2019131 79 Lower Gravel Road, Bromley U2019131_34 Middleton Road, Morden BR2 8LP SM4 6RT
6 U1016743 U1035331 16 Princes Plain, Bromley 104 Whitebeam Avenue, Bromley BR2 8LE BR2 8DW
7 U1041937 U1010255 75 Turpington Lane, Bromley 7 Boughton Avenue, Bromley BR2 8JD BR2 7PL
8 U1034805 U1024581 38 Narrow Way, Bromley 30 Parkfield Way, Bromley BR2 8JB BR2 8AF
9 U1041933 U1032130 31 Turpington Lane, Bromley 86 Faringdon Avenue, Bromley BR2 8JA BR2 8BU
10 U1037833 U1014077 3 Stiles Close, Bromley 32 Aylesbury Road, Bromley BR2 8EQ BR2 0QP
11 U1042734 U1007728 86 Whitebeam Avenue, Bromley 2 Hazel Walk, Bromley BR2 8DW BR2 8DF
12 U1042575 U1020327 90 Whitebeam Avenue, Bromley 5 Kingsdown Way, Bromley BR2 8DW BR2 7PT
13 U1033177 U1032134 30 Larch Way, Bromley 125 Faringdon Avenue, Bromley BR2 8DU BR2 8BT
14 U1027989 U1053411 5 Larch Way, Bromley U1053411_78 Windsor Drive, Orpington BR2 8DT BR6 6HF
15 U1012309 U2022615 13 Almond Close, Bromley U2022615_56 St Helier Avenue, Morden BR2 8DS SM4 6LF
16 U1022525 U1022991 30 Lovelace Avenue, Bromley 5 Link Way, Bromley BR2 8DQ BR2 8JH
17 U1047613 U1010811 13 Whitebeam Avenue, Bromley 13 Gilbert Road, Bromley BR2 8DJ BR1 3QP
18 U1027549 U1020328 14 Thorn Close, Bromley 31 Kingsdown Way, Bromley BR2 8DH BR2 7PT
19 U1022726 U1005131 31 Hornbeam Way, Bromley 10 Boughton Avenue, Bromley BR2 8DB BR2 7PL
20 U1021308 U1021353 70 Faringdon Avenue, Bromley 66 George Lane, Bromley BR2 8BU BR2 7LQ
21 U1026958 U1034017 77 Faringdon Avenue, Bromley 48 Princes Plain, Bromley BR2 8BT BR2 8LE
22 U1007553 U1036758 115d Faringdon Avenue, Bromley 46 Newbury Road, Bromley BR2 8BT BR2 0QW
23 U1032132 U1020351 115f Faringdon Avenue, Bromley 27 Laburnum Way, Bromley BR2 8BT BR2 8BY
24 U1014627 U1035052 81 Faringdon Avenue, Bromley 1 Sudbury Crescent, Bromley BR2 8BT BR1 4PY
25 U1009607 U1027073 20 Parkfield Way, Bromley U1027073_29 Haddon Road, St Mary Cray BR2 8AF BR5 4BU
26 U1027838 U1034887 15 Holmcroft Way, Bromley U1034887_2 Saltwood Close, Orpington BR2 8AD BR6 9BU
27 U1052376 U1035329 3 Shoreham Way, Bromley U1035329_79 Whitebeam Avenue, Bromley BR2 7PU BR2 8DN
28 U1015499 U1042571 25 Boughton Avenue, Bromley U1042571_19 Whitebeam Avenue, Bromley BR2 7PL BR2 8DJ
29 U1005741 U1035330 2 Malling Way, Bromley U1035330_87 Whitebeam Avenue, Bromley BR2 7PJ BR2 8DN
30 U1019906 U1047521 26 Eastry Avenue, Bromley U1047521_19 Walnuts Road, Orpington BR2 7PF BR6 0RG
31 U1021769 U1033037 49 Eastry Avenue, Bromley U1033037_73 Homefield Rise, Orpington BR2 7PE BR6 0RL
32 U1031121 U1027845 32 Laburnum Way, Bromley U1027845_92 Homefield Rise, Orpington BR2 8BZ BR6 0RW
33 U1022281 U1031691 35 Kingsdown Way, Bromley U1031691_46 Boyland Road, Bromley BR2 7PT BR1 4QF
34 U1035115 U1026916 30 Thorn Close, Bromley U1026916_19 Elizabeth Way, St Mary Cray BR2 8DH BR5 4BJ
35 U1027493 U1015363 35 Sudbury Crescent, Bromley U1015363_44a Birch Row, Bromley BR1 4PY BR2 8DA
36 U1016746 U1010400 68 Princes Plain, Bromley U1010400_42 Brook Lane, Bromley BR2 8LE BR1 4PU
37 U1005168 U1052426 38 Holbrook Way, Bromley U1052426_49 Southover, Bromley BR2 8EE BR1 4SL
38 U1036446 U1019591 14 Meath Close, Oprington U1019591_18 Buckland Road, Orpington BR5 2HF BR6 9SR
39 U1010452 U1011434 5 Canbury Path, Oprington U1011434_24 Upper Elmers End Road, Beckenham BR5 2EU BR3 4AL
40 U1019785 U1023417 50 Cray Valley Road, Oprington U1023417_44 Malling Way, Hayes, Bromley BR5 2EZ BR2 7PJ
41 U1024065 U1014671 64 Marion Crescent, Oprington U1014671_61 George Lane, Hayes, Bromley BR5 2HD BR2 7LG
42 U1042248 U1037550 16 Stanley Way, Oprington U1037550_228 Repton Road, Orpington BR5 2HE BR6 9JB
43 U1029229 U1014433 2 Meath Close, Oprington U1014433_57 Cowden Road, Orpington BR5 2HF BR6 0TP
44 U1037768 U1015779 13 Silverdale Road, Oprington U1015779_21 Beck Lane, Beckenham BR5 2LU BR3 4RD
45 U1014589 U1042236 71 Empress Drive, Chislehurst U1042236_250 Southover, Bromley BR7 5BQ BR1 4SE
46 U1024698 U1033036 4 Palace View, Bromley U1033036_61 Homefield Rise, Orpington BR1 3EL BR6 0RL
47 U1052536 U1027951 12 Thorn Close, Bromley U1027951_2 Kingsdown Way, Hayes, Bromley BR2 8DH BR2 7PT
48 U1022018 U1032770 12 Hazel Walk, Bromley U1032770_18 Pembury Close, Hayes, Bromley BR2 8DF BR2 7PS
49 U1007728 U1011129 2 Hazel Walk, Bromley U1011129_40 Saltwood Close, Orpington BR2 8DF BR6 9BU
50 U1002456 U1023267 54 Birch Row, Bromley U1023267_47a Mackenzie Road, Beckenham BR2 8DA BR3 4RT
51 U1020349 U1016998 21 Laburnum Way, Bromley U1016998_68 Arundel Drive, Orpington BR2 8BY BR6 9JG
52 U1032129 U1026576 78 Faringdon Avenue, Bromley U1026576_1 Burwash Court, St Mary Cray BR2 8BU BR5 4BG
53 U1032130 U1037867 86 Faringdon Avenue, Bromley U1037867_45 Stowe Road, Orpington BR2 8BU BR6 9HG
54 U1021824 U1027839 115g Faringdon Avenue, Bromley U1027839_30 Holmcroft Way, Bromley BR2 8BT BR2 8AD
55 U1021827 U1005351 121 Faringdon Avenue, Bromley U1005351_2 Leaveland Close, Beckenham BR2 8BT BR3 3PL
56 U1026960 U1041968 105 Faringdon Avenue, Bromley U1041968_59 Uplands Road, Orpington BR2 8BT BR6 0RH
57 U1010578 U1032149 6 Cranbrook Close, Bromley U1032149_12 Fordwich Close, Orpington BR2 7QA BR6 0TT
58 U1024709 U1047347 3 Parkfield Way, Bromley U1047347_30 Sudbury Crescent, Bromley BR2 8AE BR1 4PZ
59 U1024580 U1004249 24 Parkfield Way, Bromley U1004249_6 Beck Lane, Beckenham BR2 8AF BR3 4RE
60 U1011190 U1005772 11 Shoreham Way, Bromley U1005772_10 Manor Park Road, West Wickham BR2 7PU BR4 0JZ
61 U1011191 U1010810 32 Shoreham Way, Bromley U1010810_79 George Lane, Hayes, Bromley BR2 7PU BR2 7LG
62 U1021535 U1011008 4 Chilham Way, Bromley U1011008_170 Repton Road, Orpington BR2 7PR BR6 9JA
63 U1007556 U1026969 11 Farleigh Avenue, Bromley U1026969_2 Fordwich Close, Orpington BR2 7PP BR6 0TT
64 U1010255 U1032000 7 Boughton Avenue, Bromley U1032000_16 Detling Road, Bromley BR2 7PL BR1 4SH
65 U1034810 U1013268 24 Newbury Road, Bromley U1013268_9 Bell Gardens, St Mary Cray BR2 0QW BR5 4DA
66 U1009032 U1020217 10 Malling Way, Bromley U1020217_71 Homefield Rise, Orpington BR2 7PJ BR6 0RL
67 U1004686 U1033287 55 Baston Road, Bromley U1033287_12 Lincoln Green Road, St Mary Cray BR2 7BD BR5 2DX
68 U1032061 U1041997 30 Eastry Avenue, Bromley U1041997_31 Walnuts Road, Orpington BR2 7PF BR6 0RG
69 U1010582 U1047520 13 Cranworth Cottages, Keston U1047520_2 Walnuts Road, Orpington BR2 6DB BR6 0RQ
70 U1009511 U1019606 27 Newbury Road, Bromley U1019606_20 Burrfield Drive, St Mary Cray BR2 0QN BR5 4BZ
71 U1037954 U1007354 28 Treewall Gardens, Bromley U1007354_30 Cowden Road, Orpington BR1 5BT BR6 0TR
72 U1014793 U1015603 59 Headcorn Road, Bromley U1015603_1 Barwood Avenue, West Wickham BR1 4SQ BR4 0LB
73 U1005123 U1035393 24 Bonville Road, Bromley U1035393_18 Yalding Grove, St Mary Cray BR1 4QA BR5 3SE
74 U1037872 U1000177 20 Sudbury Crescent, Bromley U1000177_79 Arundel Drive, Orpington BR1 4PZ BR6 9JQ
75 U1022305 U1011428 18a Lansdowne Road, Bromley U1011428_21 Uplands Road, Orpington BR1 3LZ BR6 0RH
76 U1035052 U1024671 1 Sudbury Crescent, Bromley U1024671_115 Oregon Square, Orpington BR1 4PY BR6 8BE
77 U1042298 U1021513 39 Sudbury Crescent, Bromley U1021513_18 Chamberlain Crescent, West Wickham BR1 4PY BR4 0LL
78 U1019564 U1024663 32 Brook Lane, Bromley U1024663_32b Okemore Gardens, Orpington BR1 4PU BR5 3PJ
79 U1024511 U1020879 81 Nightingale Lane, Bromley U1020879_8 Brockwell Close, St Mary Cray BR1 2SA BR5 1RJ
80 U1032133 U1022713 119 Faringdon Avenue, Bromley U1022713_53 Homefield Rise, Orpington BR2 8BT BR6 0RL
81 U1032134 U1031157 125 Faringdon Avenue, Bromley U1031157_94 Leesons Hill, St Pauls Cray BR2 8BT BR5 2NH
82 U1032131 U1013666 93 Faringdon Avenue, Bromley U1013666_11 Ampleforth Close, Orpington BR2 8BT BR6 9ES
83 U1021825 U1052617 117a Faringdon Avenue, Bromley U1052617_44 Uplands Road, Orpington BR2 8BT BR6 0RJ
84 U1010107 U1010783 42 Birch Row, Bromley U1010783_32 Foxbury Drive, Chelsfield, Orpington BR2 8DA BR6 6EL
85 U1016880 U1021700 25 Almond Way, Bromley U1021700_25 Daleside, Orpington BR2 8DR BR6 6EG
86 U1038107 U1011163 152 Whitebeam Avenue, Bromley U1011163_35 Selwyn Place, St Pauls Cray BR2 8DW BR5 3BA
87 U1052455 U1022175 10 Stiles Close, Bromley U1022175_54 Homefield Rise, Orpington BR2 8EQ BR6 0RW
88 U1028328 U1021461 76 Magpie Hall Lane, Bromley U1021461_27 Burrfield Drive, St Mary Cray BR2 8ER BR5 4BZ
89 U1020064 U1007184 28 Green Way, Bromley U1007184_7 Buckland Road, Orpington BR2 8EY BR6 9SR
90 U1041934 U1023112 51 Turpington Lane, Bromley U1023112_97 Lower Gravel Road, Bromley BR2 8JA BR2 8LP
91 U1005696 U1024516 158 Magpie Hall Lane, Bromley U1024516_18 Northolme Rise, Off Tubbenden Lane, Orpington BR2 8JG BR6 9RF
92 U1030892 U1023910 140 Poverest Road, Oprington U1023910_40 Moorfield Road, Orpington BR5 1RH BR6 0HQ
93 U1011072 U1031342 45 Rookery Gardens, Oprington U1031342_55 Lower Gravel Road, Bromley BR5 4BA BR2 8LP
94 U1031058 U1037905 303 Keedonwood Road, Bromley U1037905_37 Sweeps Lane, St Mary Cray BR1 4QR BR5 3PE
95 U1052429 U2019260 76 Southover, Bromley U2019260_88 Montacute Road, Morden BR1 4RY SM4 6RL
96 U1042153 U2019214 4 Scotts Road, Bromley U2019214_11 Missenden Gardens, Morden BR1 3QD SM4 6HW
97 U1037814 U2022891 42 Stanley Road, Bromley U2022891_20 Swains Road, Tooting, London BR2 9JH SW17 9HS
98 U1008158 U2022684 71 Lower Gravel Road, Bromley U2022684_231 St Helier Avenue, Morden BR2 8LP SM4 6JH
99 U1032062 U2023196 46 Eastry Avenue, Bromley U2023196_210 Tudor Drive, Morden BR2 7PF SM4 4PQ
100 U1016742 U2019671 4 Princes Plain, Bromley U2019671_16 Netley Gardens, Morden BR2 8LE SM4 6JW
101 U1038106 U2018944 84 Whitebeam Avenue, Bromley U2018944_79 Malmesbury Road, Morden BR2 8DW SM4 6HG
102 U1013831 U2015873 33 Ash Row, Bromley U2015873_1 Furness Road, Morden BR2 8DZ SM4 6PS
103 U1005742 U1041503 18 Malling Way, Bromley U1041503_103 Ravensworth Road, Mottingham BR2 7PJ SE9 4LX
104 U1042572 U2023185 37 Whitebeam Avenue, Bromley U2023185_157 Tudor Drive, Morden BR2 8DJ SM4 4PJ
105 U1005167 U1053395 30 Holbrook Way, Bromley U1053395_90 Widecombe Road, Mottingham BR2 8EE SE9 4HJ
106 U1024581 U2018009 30 Parkfield Way, Bromley U2018009_23 Leominster Walk, Morden BR2 8AF SM4 6HB
107 U1024050 U2018941 10 Marden Avenue, Bromley U2018941_74 Malmesbury Road, Morden BR2 7PX SM4 6HE
108 U1020328 U2013950 31 Kingsdown Way, Bromley U2013950_2 Buckland Walk, Morden BR2 7PT SM4 5RZ
109 U1020327 U2018160 5 Kingsdown Way, Bromley U2018160_6 Llanthony Road, Morden BR2 7PT SM4 6DX
110 U1005131 U2015790 10 Boughton Avenue, Bromley U2015790_12 Flaxley Road, Morden BR2 7PL SM4 6LJ
111 U1023415 U2013914 27 Malling Way, Bromley U2013914_36 Bruton Road, Morden BR2 7PJ SM4 5RY
112 U1052580 U2022611 25 Trentham Drive, Oprington U2022611_50 St Helier Avenue, Morden BR5 2EP SM4 6LF
113 U1026447 U2015270 50 Princes Plain, Bromley U2015270_39 Dorchester Road, Morden BR2 8LE SM4 6QE
114 U1007432 U1009098 21 Detling Road, Bromley U1009098_37b Maple Road, Penge BR1 4SH SE20 8LA
115 U1053388 U2018926 52 Whitebeam Avenue, Bromley U2018926_19 Malmesbury Road, Morden BR2 8DL SM4 6HD
116 U1022307 U2023192 20 Larch Way, Bromley U2023192_195 Tudor Drive, Morden BR2 8DU SM4 4PJ
117 U1027743 U1027362 17 Whitebeam Avenue, Bromley U1027362_187 Seymour Villas, Penge BR2 8DJ SE20 8TP
118 U1022533 U2018973 81 Lower Gravel Road, Bromley U2018973_26 Marham Gardens, Morden BR2 8LP SM4 6JJ
119 U1014630 U2013913 118 Faringdon Avenue, Bromley U2013913_30 Bruton Road, Morden BR2 8BU SM4 5RY
120 U1030897 U1042214 60 Princes Plain, Bromley U1042214_25 Snowdown Close, London BR2 8LE SE20 7RU
121 U1022931 U2013911 15 Lennard Road, Bromley U2013911_23 Bruton Road, Morden BR2 8LN SM4 5RY
122 U1042735 U2013583 108 Whitebeam Avenue, Bromley U2013583_300 Bishopsford Road, Morden BR2 8DW SM4 6BY
123 U1013830 U2018925 31 Ash Row, Bromley U2018925_14 Malmesbury Road, Morden BR2 8DZ SM4 6HD
124 U1020368 U1037938 11 Larch Way, Bromley U1037938_5 Torr Road, Penge BR2 8DT SE20 7PS
125 U1020369 U1038117 13 Larch Way, Bromley U1038117_40 Widecombe Road, Mottingham BR2 8DT SE9 4HQ
126 U1042576 U1052365 112 Whitebeam Avenue, Bromley U1052365_127 Seymour Villas, Penge BR2 8DW SE20 8TP
127 U1027830 U1024726 26 Holbrook Way, Bromley U1024726_2 Penge Lane, Penge BR2 8EE SE20 7DU
128 U1016744 U1026505 18 Princes Plain, Bromley U1026505_7 Bridlington Close, Biggin Hill BR2 8LE TN16 3PD
129 U1041772 U2018913 13 Stanley Road, Bromley U2018913_23 Malling Gardens, Morden BR2 9JE SM4 6JG
130 U1042573 U2015708 47 Whitebeam Avenue, Bromley U2015708_46 Faversham Road, Morden BR2 8DJ SM4 6RE
131 U1008151 U2019530 61 Lovelace Avenue, Bromley U2019530_30 Neath Gardens, Morden BR2 8EA SM4 6JN
132 U1034985 U2015710 272 Southborough Lane, Bromley U2015710_49 Faversham Road, Morden BR2 8AS SM4 6RE
133 U1007947 U2014478 8 Laburnum Way, Bromley U2014478_222 Central Road, Morden BR2 8BZ SM4 5PP
134 U1027744 U2018922 33 Whitebeam Avenue, Bromley U2018922_11 Malmesbury Road, Morden BR2 8DJ SM4 6HD
135 U1038102 U2013615 14 Whitebeam Avenue, Bromley U2013615_47 Blanchland Road, Morden BR2 8DL SM4 5ND
136 U1027745 U2022641 148 Whitebeam Avenue, Bromley U2022641_127 St Helier Avenue, Morden BR2 8DW SM4 6JD
137 U1020208 U2019268 16 Holbrook Way, Bromley U2019268_97 Montacute Road, Morden BR2 8EE SM4 6RJ
138 U1023463 U2013947 30 MANOR WAY, Bromley U2013947_95 Buckfast Road, Morden BR2 8ES SM4 5NA
139 U1032647 U2018025 32 Narrow Way, Bromley U2018025_21 Lessness Road, Morden BR2 8JB SM4 6HP
140 U1033406 U2013415 67 Lower Gravel Road, Bromley U2013415_51 Bodnant Gardens, Raynes Park, London BR2 8LP SW20 0UD
141 U1000649 U2022728 42 Barnsdale Crescent, Oprington U2022728_340 St Helier Avenue, Morden BR5 2AX SM4 6JU
142 U1036332 U1009623 50 Marion Crescent, Oprington U1009623_23 Pawleyne Close, Penge BR5 2HD SE20 8JH
143 U1038103 U2015884 29 Whitebeam Avenue, Bromley U2015884_30 Furness Road, Morden BR2 8DJ SM4 6PR
144 U1042574 U2019728 66 Whitebeam Avenue, Bromley U2019728_17 Newhouse Walk, Morden BR2 8DL SM4 6BS
145 U1042256 U1024719 2 Stiles Close, Bromley U1024719_14 Pawleyne Close, Penge BR2 8EQ SE20 8JH
146 U1034017 U2013935 48 Princes Plain, Bromley U2013935_43 Buckfast Road, Morden BR2 8LE SM4 5NA
147 U1041435 U2013623 64 Princes Plain, Bromley U2013623_4 Bodmin Grove, Morden BR2 8LE SM4 5LU
148 U1053386 U2018811 27 Whitebeam Avenue, Bromley U2018811_354 Lynmouth Avenue, Morden BR2 8DJ SM4 4RT
149 U1038104 U2019672 32 Whitebeam Avenue, Bromley U2019672_19 Netley Gardens, Morden BR2 8DL SM4 6JW
150 U1019567 U2018927 8 Broom Close, Bromley U2018927_29 Malmesbury Road, Morden BR2 8EU SM4 6HD
151 U1023539 U2019237 2 Marsham Close, Chislehurst U2019237_37 Montacute Road, Morden BR7 6JD SM4 6RJ
152 U1037465 U2019730 6 Princes Plain, Bromley U2019730_23 Newhouse Walk, Morden BR2 8LE SM4 6BS
153 U1042733 U1038118 68 Whitebeam Avenue, Bromley U1038118_46 Widecombe Road, Mottingham BR2 8DL SE9 4HQ
154 U1004442 U1000359 7 Birch Row, Bromley U1000359_5 Avenue Road, Penge BR2 8BX SE20 7RT
155 U1005006 U2018164 44 Birch Row, Bromley U2018164_24 Llanthony Road, Morden BR2 8DA SM4 6DX
156 U1035331 U2013982 104 Whitebeam Avenue, Bromley U2013982_18 Bury Grove, Morden BR2 8DW SM4 5NG
157 U1024017 U1030964 13 Manor Way, Bromley U1030964_13 Ravensworth Road, Mottingham BR2 8ES SE9 4LN
158 U1014078 U2015882 43 Aylesbury Road, Bromley U2015882_25 Furness Road, Morden BR2 0QR SM4 6PS
159 U1052186 U1031105 4 Ravensleigh Gardens, Bromley U1031105_21 Kingsmead, Biggin Hill, Westerham BR1 5SN TN16 3UB
160 U1037450 U1014008 70 Pontefract Road, Bromley U1014008_9 Avenue Road, Penge BR1 4RB SE20 7RT
161 U1031294 U2015779 70 London Lane, Bromley U2015779_57 Flanders Crescent, Tooting, London BR1 4HE SW17 9JB
162 U1014077 U2013413 32 Aylesbury Road, Bromley U2013413_49 Bodnant Gardens, Raynes Park, London BR2 0QP SW20 0UD
163 U1030962 U1021199 7 Ravensleigh Gardens, Bromley U1021199_7 Dittisham Road, Mottingham BR1 5SN SE9 4BJ
164 U1042232 U1041705 154 Southover, Bromley U1041705_87 Seymour Villas, Penge BR1 4RZ SE20 8TR
165 U1024484 U1041706 55 Mounthurst Road, Bromley U1041706_91 Seymour Villas, Penge BR2 7PG SE20 8TR
166 U1007701 U1042213 46 Harwood Avenue, Bromley U1042213_9 Snowdown Close, London BR1 3DU SE20 7RU
167 U1020199 U2014444 78 Hillside Road, Bromley U2014444_86 Central Road, Morden BR2 0ST SM4 5RJ
168 U1036758 U2013940 46 Newbury Road, Bromley U2013940_63 Buckfast Road, Morden BR2 0QW SM4 5NA
169 U1009369 U1037789 17 Minster Road, Bromley U1037789_5 Snowdown Close, London BR1 4DY SE20 7RU
170 U1009194 U2013630 84 Mays Hill Road, Bromley U2013630_16 Bodmin Grove, Morden BR2 0HT SM4 5LU
171 U1013358 U2014445 3 Bird In Hand Lane, Bromley U2014445_88 Central Road, Morden BR1 2NA SM4 5RJ
172 U1009202 U2013602 63 Mead Way, Bromley U2013602_5 Blanchland Road, Morden BR2 9ER SM4 5ND
173 U1022991 U1017178 5 Link Way, Bromley U1017178_11 Avenue Road, Penge BR2 8JH SE20 7RT
174 U1025820 U1013690 46 Princes Plain, Bromley U1013690_74 Anerley Vale, London BR2 8LE SE19 2BG
175 U1020237 U2013676 33 Hornbeam Way, Bromley U2013676_52 Bordesley Road, Morden BR2 8DB SM4 5LR
176 U1021310 U1042212 126 Faringdon Avenue, Bromley U1042212_1 Snowdown Close, London BR2 8BU SE20 7RU
177 U1021353 U2018810 66 George Lane, Bromley U2018810_331 Lynmouth Avenue, Morden BR2 7LQ SM4 4RY
178 U1033165 U2013476 2 Laburnum Way, Bromley U2013476_1 Bristol Road, Morden BR2 8BZ SM4 5SA
179 U1010811 U1026660 13 Gilbert Road, Bromley U1026660_40 Chilham Road, Mottingham BR1 3QP SE9 4BG
180 U1027449 U1036877 11 Station Road, Bromley U1036877_2 Pawleyne Close, Penge BR1 3LP SE20 8JH
181 U1035326 U2018814 2 Whitebeam Avenue, Bromley U2018814_364 Lynmouth Avenue, Morden BR2 8DL SM4 4RT
182 U1020351 U2013573 27 Laburnum Way, Bromley U2013573_264 Bishopsford Road, Morden BR2 8BY SM4 6BZ
183 U1007236 U1007236_6 Castledine Road, Penge SE20 8PL
184 U1027701 U1027701_4 Wayside Grove, Mottingham SE9 4ND
185 U1047208 U1047208_83 Seymour Villas, Penge SE20 8TR
186 U2013695 U2013695_111 Bordesley Road, Morden SM4 5LW
187 U1004961 U1004961_36 Beverley Road, Penge SE20 8SJ
188 U2015795 U2015795_24 Flaxley Road, Morden SM4 6LJ
189 U2014451 U2014451_102 Central Road, Morden SM4 5RJ
190 U2016606 U2016606_3 Grayswood Gardens, Raynes Park, London SW20 0UF
191 U1035025 U1035025_29 Steyning Grove, Mottingham SE9 4NG
192 U1027588 U1027588_94 Tovil Close, Penge SE20 8SZ
193 U2022632 U2022632_112 St Helier Avenue, Morden SM4 6LB
194 U1037462 U1037462_68 Prestbury Square, Mottingham SE9 4NA
195 U2018027 U2018027_29 Lessness Road, Morden SM4 6HP
196 U2018909 U2018909_12 Malling Gardens, Morden SM4 6JG
197 U1001247 U1001247_13 Pawleyne Close, Penge SE20 8JH
198 U1018547 U1018547_9b Avenue Road, Penge SE20 7RT
199 U1008780 U1008780_7b Avenue Road, Penge SE20 7RT
200 U1007212 U1007212_2 Calcott Walk, Mottingham SE9 4BN
201 U1003650 U1003650_5b Avenue Road, Penge SE20 7RT
202 U1010359 U1010359_2 Bredhurst Close, Penge SE20 7BG
203 U1041772 13 Stanley Road, Bromley BR2 9JE
204 U1011072 45 Rookery Gardens, Oprington BR5 4BA
205 U1020208 16 Holbrook Way, Bromley BR2 8EE
206 U1052580 25 Trentham Drive, Oprington BR5 2EP
207 U1038106 84 Whitebeam Avenue, Bromley BR2 8DW
208 U1010107 42 Birch Row, Bromley BR2 8DA
209 U1022307 20 Larch Way, Bromley BR2 8DU
210 U1020064 28 Green Way, Bromley BR2 8EY
211 U1023463 30 MANOR WAY, Bromley BR2 8ES
212 U1027745 148 Whitebeam Avenue, Bromley BR2 8DW
213 U1008151 61 Lovelace Avenue, Bromley BR2 8EA
214 U1019785 50 Cray Valley Road, Oprington BR5 2EZ
215 U1029229 2 Meath Close, Oprington BR5 2HF
216 U1005696 158 Magpie Hall Lane, Bromley BR2 8JG
217 U1036332 50 Marion Crescent, Oprington BR5 2HD
218 U1027830 26 Holbrook Way, Bromley BR2 8EE
219 U1052455 10 Stiles Close, Bromley BR2 8EQ
220 U1023539 2 Marsham Close, Chislehurst BR7 6JD
221 U1030962 7 Ravensleigh Gardens, Bromley BR1 5SN
222 U1037954 28 Treewall Gardens, Bromley BR1 5BT
223 U1020368 11 Larch Way, Bromley BR2 8DT
224 U1027743 17 Whitebeam Avenue, Bromley BR2 8DJ
225 U1021825 117a Faringdon Avenue, Bromley BR2 8BT
226 U1028328 76 Magpie Hall Lane, Bromley BR2 8ER
227 U1025820 46 Princes Plain, Bromley BR2 8LE
228 U1024580 24 Parkfield Way, Bromley BR2 8AF
229 U1010452 5 Canbury Path, Oprington BR5 2EU
230 U1022018 12 Hazel Walk, Bromley BR2 8DF
231 U1037814 42 Stanley Road, Bromley BR2 9JH
232 U1016744 18 Princes Plain, Bromley BR2 8LE
233 U1021310 126 Faringdon Avenue, Bromley BR2 8BU
234 U1035115 30 Thorn Close, Bromley BR2 8DH
235 U1033165 2 Laburnum Way, Bromley BR2 8BZ
236 U1000649 42 Barnsdale Crescent, Oprington BR5 2AX
237 U1052536 12 Thorn Close, Bromley BR2 8DH
238 U1042298 39 Sudbury Crescent, Bromley BR1 4PY
239 U1053388 52 Whitebeam Avenue, Bromley BR2 8DL
240 U1007556 11 Farleigh Avenue, Bromley BR2 7PP
241 U1007947 8 Laburnum Way, Bromley BR2 8BZ
242 U1019567 8 Broom Close, Bromley BR2 8EU
243 U1013358 3 Bird In Hand Lane, Bromley BR1 2NA
244 U1022533 81 Lower Gravel Road, Bromley BR2 8LP
245 U1021824 115g Faringdon Avenue, Bromley BR2 8BT
246 U1009369 17 Minster Road, Bromley BR1 4DY
247 U1022931 15 Lennard Road, Bromley BR2 8LN
248 U1024065 64 Marion Crescent, Oprington BR5 2HD
249 U1005168 38 Holbrook Way, Bromley BR2 8EE
250 U1038104 32 Whitebeam Avenue, Bromley BR2 8DL
251 U1021827 121 Faringdon Avenue, Bromley BR2 8BT
252 U1030892 140 Poverest Road, Oprington BR5 1RH
253 U1013831 33 Ash Row, Bromley BR2 8DZ
254 U1004442 7 Birch Row, Bromley BR2 8BX
255 U1019564 32 Brook Lane, Bromley BR1 4PU
256 U1032131 93 Faringdon Avenue, Bromley BR2 8BT
257 U1026960 105 Faringdon Avenue, Bromley BR2 8BT
258 U1005006 44 Birch Row, Bromley BR2 8DA
259 U1005742 18 Malling Way, Bromley BR2 7PJ
260 U1022281 35 Kingsdown Way, Bromley BR2 7PT
261 U1042574 66 Whitebeam Avenue, Bromley BR2 8DL
262 U1011191 32 Shoreham Way, Bromley BR2 7PU
263 U1032129 78 Faringdon Avenue, Bromley BR2 8BU
264 U1009032 10 Malling Way, Bromley BR2 7PJ
265 U1031121 32 Laburnum Way, Bromley BR2 8BZ
266 U1022305 18a Lansdowne Road, Bromley BR1 3LZ
267 U1042248 16 Stanley Way, Oprington BR5 2HE
268 U1038102 14 Whitebeam Avenue, Bromley BR2 8DL
269 U1007432 21 Detling Road, Bromley BR1 4SH
270 U1041934 51 Turpington Lane, Bromley BR2 8JA
271 U1032647 32 Narrow Way, Bromley BR2 8JB
272 U1052429 76 Southover, Bromley BR1 4RY
273 U1007701 46 Harwood Avenue, Bromley BR1 3DU
274 U1042572 37 Whitebeam Avenue, Bromley BR2 8DJ
275 U1042576 112 Whitebeam Avenue, Bromley BR2 8DW
276 U1020349 21 Laburnum Way, Bromley BR2 8BY
277 U1021535 4 Chilham Way, Bromley BR2 7PR
278 U1004686 55 Baston Road, Bromley BR2 7BD
279 U1027744 33 Whitebeam Avenue, Bromley BR2 8DJ
280 U1016880 25 Almond Way, Bromley BR2 8DR
281 U1023415 27 Malling Way, Bromley BR2 7PJ
282 U1011190 11 Shoreham Way, Bromley BR2 7PU
283 U1042256 2 Stiles Close, Bromley BR2 8EQ
284 U1053386 27 Whitebeam Avenue, Bromley BR2 8DJ
285 U1020237 33 Hornbeam Way, Bromley BR2 8DB
286 U1005167 30 Holbrook Way, Bromley BR2 8EE
287 U1014630 118 Faringdon Avenue, Bromley BR2 8BU
288 U1034810 24 Newbury Road, Bromley BR2 0QW
289 U1037768 13 Silverdale Road, Oprington BR5 2LU
290 U1024709 3 Parkfield Way, Bromley BR2 8AE
291 U1010582 13 Cranworth Cottages, Keston BR2 6DB
292 U1010578 6 Cranbrook Close, Bromley BR2 7QA
293 U1031058 303 Keedonwood Road, Bromley BR1 4QR
294 U1026447 50 Princes Plain, Bromley BR2 8LE
295 U1016746 68 Princes Plain, Bromley BR2 8LE
296 U1038107 152 Whitebeam Avenue, Bromley BR2 8DW
297 U1036446 14 Meath Close, Oprington BR5 2HF
298 U1014589 71 Empress Drive, Chislehurst BR7 5BQ
299 U1002456 54 Birch Row, Bromley BR2 8DA
300 U1020369 13 Larch Way, Bromley BR2 8DT
301 U1034985 272 Southborough Lane, Bromley BR2 8AS
302 U1041435 64 Princes Plain, Bromley BR2 8LE
303 U1031294 70 London Lane, Bromley BR1 4HE
304 U1033406 67 Lower Gravel Road, Bromley BR2 8LP
305 U1032061 30 Eastry Avenue, Bromley BR2 7PF
306 U1042573 47 Whitebeam Avenue, Bromley BR2 8DJ
307 U1032133 119 Faringdon Avenue, Bromley BR2 8BT
308 U1013830 31 Ash Row, Bromley BR2 8DZ
309 U1009511 27 Newbury Road, Bromley BR2 0QN
310 U1042735 108 Whitebeam Avenue, Bromley BR2 8DW
311 U1024511 81 Nightingale Lane, Bromley BR1 2SA
312 U1024050 10 Marden Avenue, Bromley BR2 7PX
313 U1035326 2 Whitebeam Avenue, Bromley BR2 8DL
314 U1009202 63 Mead Way, Bromley BR2 9ER
315 U1020199 78 Hillside Road, Bromley BR2 0ST
316 U1042232 154 Southover, Bromley BR1 4RZ
317 U1030897 60 Princes Plain, Bromley BR2 8LE
318 U1027989 5 Larch Way, Bromley BR2 8DT
319 U1021308 70 Faringdon Avenue, Bromley BR2 8BU
320 U1019906 26 Eastry Avenue, Bromley BR2 7PF
321 U1022726 31 Hornbeam Way, Bromley BR2 8DB
322 U1024698 4 Palace View, Bromley BR1 3EL
323 U1005549 79 Lower Gravel Road, Bromley BR2 8LP
324 U1052376 3 Shoreham Way, Bromley BR2 7PU
325 U1041937 75 Turpington Lane, Bromley BR2 8JD
326 U1005123 24 Bonville Road, Bromley BR1 4QA
327 U1008158 71 Lower Gravel Road, Bromley BR2 8LP
328 U1042575 90 Whitebeam Avenue, Bromley BR2 8DW
329 U1014793 59 Headcorn Road, Bromley BR1 4SQ
330 U1012309 13 Almond Close, Bromley BR2 8DS
331 U1009194 84 Mays Hill Road, Bromley BR2 0HT
332 U1027838 15 Holmcroft Way, Bromley BR2 8AD
333 U1027449 11 Station Road, Bromley BR1 3LP
334 U1014627 81 Faringdon Avenue, Bromley BR2 8BT
335 U1027493 35 Sudbury Crescent, Bromley BR1 4PY
336 U1032132 115f Faringdon Avenue, Bromley BR2 8BT
337 U1037833 3 Stiles Close, Bromley BR2 8EQ
338 U1016743 16 Princes Plain, Bromley BR2 8LE
339 U1007553 115d Faringdon Avenue, Bromley BR2 8BT
340 U1009607 20 Parkfield Way, Bromley BR2 8AF
341 U1034805 38 Narrow Way, Bromley BR2 8JB
342 U1032062 46 Eastry Avenue, Bromley BR2 7PF
343 U1047613 13 Whitebeam Avenue, Bromley BR2 8DJ
344 U1024484 55 Mounthurst Road, Bromley BR2 7PG
345 U1038103 29 Whitebeam Avenue, Bromley BR2 8DJ
346 U1022525 30 Lovelace Avenue, Bromley BR2 8DQ
347 U1042153 4 Scotts Road, Bromley BR1 3QD
348 U1037465 6 Princes Plain, Bromley BR2 8LE
349 U1041933 31 Turpington Lane, Bromley BR2 8JA
350 U1021769 49 Eastry Avenue, Bromley BR2 7PE
351 U1042734 86 Whitebeam Avenue, Bromley BR2 8DW
352 U1026958 77 Faringdon Avenue, Bromley BR2 8BT
353 U1037872 20 Sudbury Crescent, Bromley BR1 4PZ
354 U1027549 14 Thorn Close, Bromley BR2 8DH
355 U1037450 70 Pontefract Road, Bromley BR1 4RB
356 U1016742 4 Princes Plain, Bromley BR2 8LE
357 U1042733 68 Whitebeam Avenue, Bromley BR2 8DL
358 U1003906 54 Barnsdale Crescent, Oprington BR5 2AX
359 U1027392 26 Silverdale Road, Oprington BR5 2LT
360 U1052186 4 Ravensleigh Gardens, Bromley BR1 5SN
361 U1034479 90 Mead Way, Bromley BR2 9EU
362 U1005741 2 Malling Way, Bromley BR2 7PJ
363 U1024017 13 Manor Way, Bromley BR2 8ES
364 U1014078 43 Aylesbury Road, Bromley BR2 0QR
365 U1015499 25 Boughton Avenue, Bromley BR2 7PL
366 U1033177 30 Larch Way, Bromley BR2 8DU

View file

@ -1,2 +0,0 @@
UPRN,Address,Postcode
U1014630,"118 Faringdon Avenue, Bromley",BR2 8BU
1 UPRN Address Postcode
2 U1014630 118 Faringdon Avenue, Bromley BR2 8BU

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -986,7 +986,15 @@ class EpcPropertyDataMapper:
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 ""
),
@ -4415,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)",
}

View file

@ -1339,18 +1339,18 @@ 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,
@ -1359,9 +1359,22 @@ class TestApiRoofConstructionCode:
# 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 691701** 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 691701) 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.

View file

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

View file

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

View file

@ -0,0 +1,71 @@
# 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** (20122022) — `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; (b) roof/floor
generators have the same anti-pattern in principle (roof age-band eligibility
is partly covered by ADR-0047); both tracked as follow-ups.
### 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.

View file

@ -28,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
@ -35,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).
@ -131,6 +134,19 @@ _ELECTRIC_BOILER_CODES = frozenset({191, 192})
# 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).
@ -266,8 +282,10 @@ def _control_for(code: int) -> Optional[int]:
return _MANUAL_CHARGE_CONTROL
if code in _GAS_BOILER_CODES:
return _FULL_BOILER_CONTROL
if code in _CATEGORY_10_ROOM_HEATER_CODES:
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
@ -280,10 +298,12 @@ def _category_for(code: int) -> Optional[int]:
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:
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:
@ -351,13 +371,32 @@ 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_for(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,

View file

@ -12,6 +12,7 @@ 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
@ -137,6 +138,42 @@ _ASHP_OVERLAY = HeatingOverlay(
)
# 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
@ -254,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),
@ -307,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)
@ -702,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,
)

View file

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

View file

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

View file

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

View file

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

View file

@ -2888,6 +2888,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
@ -2942,6 +2951,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
@ -5307,6 +5321,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]:
@ -5343,21 +5386,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,
@ -5366,10 +5394,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]:
@ -5395,9 +5462,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
@ -5407,7 +5478,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:
@ -7090,6 +7161,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,
@ -7517,8 +7600,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
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<st>[ x])\] |^- (?P<emoji>🔧|⚠|⛔) "
)
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<st>[ x])\]|(?P<emoji>🔧|⚠|⛔)) `(?P<uprn>\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("<details><summary>expand</summary>")
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("</details>")
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()

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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 , 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 <page>
"""
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())

View file

@ -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 , 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 <page>
"""
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())

View file

@ -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 , 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 <page>
"""
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())

View file

@ -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 , 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 <page>
"""
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())

View file

@ -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 , 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 <page>
"""
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())

View file

@ -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 ,
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 <page>
"""
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())

View file

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

View file

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

View file

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

View file

@ -6,6 +6,8 @@ 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
@ -58,6 +60,68 @@ def test_electric_room_heaters_assume_a_dual_economy7_meter() -> 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"),
[
@ -169,6 +233,80 @@ def test_off_peak_underfloor_asserts_a_dual_meter(main_heating_value: str) -> No
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

View file

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

View file

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

View file

@ -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
@ -200,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
@ -764,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

View file

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

View file

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

View file

@ -27,6 +27,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import (
)
from datatypes.epc.domain.epc_property_data import (
EnergyElement,
EpcPropertyData,
MainHeatingDetail,
PhotovoltaicArray,
@ -738,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.
@ -1867,6 +1892,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
@ -2686,6 +2784,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.
@ -4891,6 +5029,42 @@ 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

View file

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

View file

@ -303,11 +303,39 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
# 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=84,
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
@ -699,6 +727,313 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
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.22.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,
),
)

View file

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

View file

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

View file

@ -196,7 +196,50 @@ _CORPUS = Path(
# 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.
_MIN_WITHIN_HALF_SAP = 0.742
# 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,
@ -266,9 +309,34 @@ _MIN_WITHIN_HALF_SAP = 0.742
# 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.
_MAX_SAP_MAE = 0.722
_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
# 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]]:

View file

@ -0,0 +1,60 @@
"""The ModellingOrchestrator threads the dwelling's SAP design heat loss into
the heating Recommendation Generator so the ASHP is sized to the dwelling
(ADR-0049), mirroring how planning_restrictions and the solar potential are
threaded. Tests the candidate-wiring seam directly; the end-to-end run-through-
repos path is covered by the DB integration tests.
"""
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.measure_type import MeasureType
from domain.modelling.product import Product
from domain.modelling.recommendation import Recommendation
from orchestration.modelling_orchestrator import (
_candidate_recommendations, # pyright: ignore[reportPrivateUsage]
)
from repositories.product.product_repository import ProductRepository
from tests.domain.modelling._elmhurst_recommendation import (
parse_recommendation_summary,
)
class _StubProducts(ProductRepository):
def get(self, measure_type: str) -> Product:
return Product(
measure_type=measure_type,
unit_cost_per_m2=0.0,
contingency_rate=0.15,
id=909,
)
def _ashp_eligible_house() -> EpcPropertyData:
epc = parse_recommendation_summary(
"ashp_from_system_boiler_with_cylinder_001431_before.pdf"
)
epc.property_type = "House"
return epc
def test_candidate_recommendations_sizes_the_ashp_to_the_threaded_design_heat_loss() -> (
None
):
# Arrange — an ASHP-eligible house and a high (17.5 kW) design heat loss.
epc = _ashp_eligible_house()
# Act
recommendations: list[Recommendation] = _candidate_recommendations(
epc, _StubProducts(), PlanningRestrictions(), None, None, design_heat_loss_kw=17.5
)
# Assert — the ASHP is sized to the load (capped 12 kW aroTHERM plus 110281),
# not the fixed 5 kW / floor-area-proxy unit.
heating = next(r for r in recommendations if r.surface == "Heating & Hot Water")
ashp = next(
o
for o in heating.options
if o.measure_type == MeasureType.AIR_SOURCE_HEAT_PUMP
)
assert ashp.overlay.heating is not None
assert ashp.overlay.heating.main_heating_index_number == 110281

View file

@ -128,6 +128,7 @@ def _sap_result_with_lighting() -> SapResult:
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,

View file

@ -1,14 +1,32 @@
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
from backend.pashub_fetcher.sharepoint_subfolders import SharepointSubfolders
from orchestration.sharepoint_renamer_orchestrator import (
ASSESSMENT_SUBFOLDER,
BASE_PATH,
SharepointRenamerOrchestrator,
build_canonical_filename,
)
def _assessment_path(root: str, address: str, postcode: str) -> str:
return (
f"{root}/{address}, {postcode}"
f"/{SharepointSubfolders.ASSESSMENT.value}/{ASSESSMENT_SUBFOLDER}"
)
def _write_csv(tmp_path: Path, rows: list[tuple[str, str, str]]) -> str:
lines = ["UPRN,Address,Postcode"] + [",".join(row) for row in rows]
csv_file = tmp_path / "addresses.csv"
csv_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
return str(csv_file)
def _make_file(name: str, item_id: str = "id-1") -> dict[str, Any]:
return {"name": name, "id": item_id, "file": {}}
@ -47,6 +65,92 @@ def test_no_prefix_still_canonical() -> None:
assert result == "100_1 High St AB1 2CD_Survey.pdf"
# ---------------------------------------------------------------------------
# _discover_roots — batch subfolders become extra roots
# ---------------------------------------------------------------------------
def test_discover_roots_includes_sero_batch_folders() -> None:
# Arrange
sp = MagicMock()
sp.get_folders_in_path.return_value = {
"value": [
_make_folder("1 High St, AB1 2CD"),
_make_folder("_Sero Batch 3"),
_make_folder("_Sero Batch 2"),
_make_file("stray.pdf"),
]
}
# Act
roots = _make_orchestrator(sp)._discover_roots()
# Assert
assert roots == [
BASE_PATH,
f"{BASE_PATH}/_Sero Batch 2",
f"{BASE_PATH}/_Sero Batch 3",
]
sp.get_folders_in_path.assert_called_once_with(BASE_PATH)
# ---------------------------------------------------------------------------
# run — properties under a batch root are still found and renamed
# ---------------------------------------------------------------------------
def test_run_renames_files_for_property_under_batch_root(tmp_path: Path) -> None:
# Arrange
csv_path = _write_csv(tmp_path, [("100", "1 High St", "AB1 2CD")])
batch_assessment = _assessment_path(
f"{BASE_PATH}/_Sero Batch 2", "1 High St", "AB1 2CD"
)
sp = MagicMock()
def fake_get(path: str) -> dict[str, Any]:
if path == BASE_PATH:
return {"value": [_make_folder("_Sero Batch 2")]}
if path == batch_assessment:
return {"value": [_make_file("Survey.pdf", "id-1")]}
raise ValueError("not found")
sp.get_folders_in_path.side_effect = fake_get
# Act
SharepointRenamerOrchestrator(sp, csv_path).run()
# Assert
sp.rename_file.assert_called_once_with("id-1", "100_1 High St AB1 2CD_Survey.pdf")
def test_run_uprn_prefixed_address_yields_single_uprn_filename(
tmp_path: Path,
) -> None:
# Arrange
csv_path = _write_csv(tmp_path, [("100", "100_1 High St", "AB1 2CD")])
batch_assessment = _assessment_path(
f"{BASE_PATH}/_Sero Batch 2", "100_1 High St", "AB1 2CD"
)
sp = MagicMock()
def fake_get(path: str) -> dict[str, Any]:
if path == BASE_PATH:
return {"value": [_make_folder("_Sero Batch 2")]}
if path == batch_assessment:
return {"value": [_make_file("Survey.pdf", "id-1")]}
raise ValueError("not found")
sp.get_folders_in_path.side_effect = fake_get
# Act
SharepointRenamerOrchestrator(sp, csv_path).run()
# Assert
sp.rename_file.assert_called_once_with("id-1", "100_1 High St AB1 2CD_Survey.pdf")
# ---------------------------------------------------------------------------
# _process_folder — files only at root level
# ---------------------------------------------------------------------------
@ -121,14 +225,46 @@ def test_ignores_package_items() -> None:
# ---------------------------------------------------------------------------
def test_missing_folder_logs_warning_and_returns(caplog: pytest.LogCaptureFixture) -> None:
def test_missing_folder_returns_false_without_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
# Arrange
sp = MagicMock()
sp.get_folders_in_path.side_effect = ValueError("not found")
_make_orchestrator(sp)._process_folder("missing/path", "400", "4 Elm St", "AA2 2BB")
# Act
found = _make_orchestrator(sp)._process_folder(
"missing/path", "400", "4 Elm St", "AA2 2BB"
)
# Assert
assert found is False
sp.rename_file.assert_not_called()
assert any("Missing folder" in r.message and "400" in r.message for r in caplog.records)
assert not any("Missing folder" in r.message for r in caplog.records)
def test_run_warns_once_when_property_missing_from_all_roots(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# Arrange
csv_path = _write_csv(tmp_path, [("400", "4 Elm St", "AA2 2BB")])
sp = MagicMock()
def fake_get(path: str) -> dict[str, Any]:
if path == BASE_PATH:
return {"value": [_make_folder("_Sero Batch 2")]}
raise ValueError("not found")
sp.get_folders_in_path.side_effect = fake_get
# Act
SharepointRenamerOrchestrator(sp, csv_path).run()
# Assert
sp.rename_file.assert_not_called()
warnings = [r for r in caplog.records if "Missing folder" in r.message]
assert len(warnings) == 1
assert "400" in warnings[0].message
# ---------------------------------------------------------------------------

0
tests/utils/__init__.py Normal file
View file

View file

@ -0,0 +1,81 @@
import logging
from typing import Any, Callable, Optional, cast
import pytest
from utils.sharepoint.sharepoint_client import (
ResourceNotFoundError,
api_call_decorator, # pyright: ignore[reportUnknownVariableType]
)
# The legacy decorator is untyped; cast so this module stays pyright-strict.
_decorate = cast(
Callable[[Callable[..., Any]], Callable[..., Any]], api_call_decorator
)
class _FakeResponse:
def __init__(self, status_code: int, error_code: str, message: str) -> None:
self.status_code = status_code
self._error_code = error_code
self._message = message
self.headers: dict[str, str] = {}
def json(self) -> dict[str, Any]:
return {"error": {"code": self._error_code, "message": self._message}}
class _FakeClient:
headers: Optional[dict[str, str]] = {}
def is_access_token_expired(self) -> bool:
return False
@_decorate
def fetch(self, path: str) -> Any:
return "GET", f"https://graph.example.invalid/root:/{path}:/children", None
def _patch_response(
monkeypatch: pytest.MonkeyPatch, response: _FakeResponse
) -> None:
def fake_request(*args: Any, **kwargs: Any) -> _FakeResponse:
return response
monkeypatch.setattr(
"utils.sharepoint.sharepoint_client.requests.request", fake_request
)
def test_404_raises_resource_not_found_without_error_logs(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
# Arrange
_patch_response(
monkeypatch,
_FakeResponse(404, "itemNotFound", "The resource could not be found."),
)
# Act
with pytest.raises(ResourceNotFoundError):
_FakeClient().fetch("missing/folder")
# Assert
error_records = [r for r in caplog.records if r.levelno >= logging.ERROR]
assert error_records == []
def test_other_client_errors_still_log_at_error_level(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
# Arrange
_patch_response(
monkeypatch, _FakeResponse(403, "accessDenied", "Access denied.")
)
# Act
with pytest.raises(ValueError):
_FakeClient().fetch("forbidden/folder")
# Assert
assert any(r.levelno >= logging.ERROR for r in caplog.records)

View file

@ -21,6 +21,10 @@ from utils.logger import setup_logger
logger = setup_logger()
class ResourceNotFoundError(ValueError):
"""Raised when the API returns 404 — an expected miss, not a failure."""
def handle_error(response):
"""
Handle errors based on HTTP status codes and log detailed information.
@ -35,6 +39,12 @@ def handle_error(response):
inner_error = error_json.get("innererror", {})
details = error_json.get("details", [])
if response.status_code == 404:
logger.debug(f"Not found ({error_code}): {error_message}")
raise ResourceNotFoundError(
f"API request failed with status code 404 - {error_message}"
)
logger.error(f"Error Code: {error_code}")
logger.error(f"Error Message: {error_message}")
if inner_error:
@ -46,8 +56,6 @@ def handle_error(response):
logger.error("Unauthorized. Token might be invalid.")
elif response.status_code == 403:
logger.error("Forbidden. Access denied to the requested resource.")
elif response.status_code == 404:
logger.error("Not Found. The requested resource doesnt exist.")
elif response.status_code == 429:
retry_after = int(
response.headers.get("Retry-After", 5)
@ -121,6 +129,8 @@ def api_call_decorator(func):
return response_data
except ResourceNotFoundError:
raise
except Exception as e:
logger.exception("An error occurred during the API call.")
raise e