A pre-SAP10 RdSAP cert that omits per-window records lodges glazing as a
`multiple_glazing_type` code plus a `window.description` and a
`multiple_glazed_proportion`. When the numeric code is the "ND" (Not
Defined) sentinel, the reduced-field synthesis defaulted to the DG-modal
code 2 (double) — ignoring the two register signals that say otherwise.
`_normalize_sap_schema_16_x` already honours an explicit "Single glazed"
description for 16.x certs (rewriting multiple_glazing_type -> 5), but that
rule was never propagated to the 17.0 / 17.1 / 18.0 / 19.0 / 20.0.0
synthesis seams — they all blindly applied ND -> 2. So a genuinely
single-glazed dwelling was:
1. scored as double-glazed, overstating its baseline SAP, and
2. hidden from the glazing generator (which upgrades only single-glazed
windows), producing no glazing recommendation.
Reproduced on cert 2780-3922-3202-6042-9200 (uprn 10033526327), a
single-glazed community-heated flat lodging multiple_glazing_type="ND",
window "Single glazed", multiple_glazed_proportion 0 — mapped to four
double-glazed windows, no glazing measure offered.
Extend the 16.x rule to all five reduced-field seams via one shared
resolver: on the ND fallback, when the description says "single" or the
multiple-glazed proportion is 0, synthesise single glazing
(`_api_cascade_glazing_type(5) == 1`, SAP10 cascade single, Table 24
U~4.8) — the exact single code the glazing generator's {1, 15} set
matches — else keep each seam's DG-modal default. Refines ADR-0028's ND
handling.
Tests: all five seams synthesise single from a "Single glazed" + 0%
proportion cert; the double-glazed control keeps the DG-modal default;
plus unit pins on the resolver.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PRD #1385 phase 2. The 20.0.0 and 21.0.0 mappers dropped insulated_door_u_value
(top-level Optional float), unlike 21.0.1. heat_transmission reads it as the
measured door U-value override (cert_to_inputs -> heat_transmission:809), so
dropping it forced every door onto the RdSAP default U-value.
Verification: new test 3 passed (21.0.0 sample lodges 3; 20.0.0 latent -> inject
1.5 + absent -> None); corpus 6002 passed; SAP-accuracy regressions pass; pyright
unchanged (39 -> 39).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PRD #1385 phase 2. The 21.0.0 mapper dropped mechanical_ventilation_index_number
and mechanical_vent_duct_type, unlike 21.0.1. cert_to_inputs reads both (PCDB
Table 4f/329 heat-recovery/SFP lookup + the Table 329 in-use factor), so an
MVHR/MEV cert on the 21.0.0 path fell back to the SAP default efficiency.
Mirrors 21.0.1 exactly -- only the two fields it actually maps. The other four
mechanical_vent_duct_* schema fields have no mapper OR calculator consumer even
on 21.0.1 (verified), so they're deliberately left alone rather than populating
dead domain fields.
Verification: new test 1 passed (harvested 21.0.0 sample lodges index=12,
duct_type=3); corpus 6002 passed; SAP-accuracy regressions pass; pyright
unchanged (39 -> 39).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PRD #1385 phase 2. 21.0.0 was the last mapper still dropping the lodged
percent_draughtproofed (top-level int) — every other path (17.0/…/21.0.1) reads
it. Dropping it understated the SAP §2 draughtproofing/infiltration credit for
21.0.0 certs.
Verification: new test 1 passed (harvested 21.0.0 sample lodges 100); corpus
6002 passed; SAP-accuracy regressions pass; pyright unchanged (39 -> 39).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PRD #1385, building-part fabric family. The 17.0 mapper hardcoded
floor_insulation_thickness / sap_room_in_roof to None and omitted
flat_roof_insulation_thickness, all of which the 17.0 schema lodges and the
19.0/20.0/21.x paths read:
* floor_insulation_thickness: "NI" (no measured thickness) -> None so u_floor
reaches its age-band default, else pass through (mirrors the 19.0 note).
* flat_roof_insulation_thickness: pass through (was dropped).
* sap_room_in_roof: map to SapRoomInRoof(floor_area, construction_age_band),
None if absent (mirrors 19.0).
17.0-only: the 17.1 SapBuildingPart schema has none of these fields, so its None
there is correct (verified against the dataclass). roof_insulation_thickness is
intentionally left raw -- the sloping-ceiling resolve helper moves roof U-values
and is a separate follow-up.
Verification: new test 3 passed; test_mapper_corpus 6002 passed; SAP-accuracy /
RealCertExpectation regressions pass; pyright unchanged (39 -> 39, pre-existing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PRD #1385, shower/WWHRS family. The 17.0 and 19.0 API mappers left
sap_heating.number_baths / mixer_shower_count as None, so the water-heating
demand (cert_to_inputs) fell back to defaults instead of the lodged bath/shower
room counts. Derive them from schema.sap_heating.instantaneous_wwhrs, mirroring
the 17.1/18.0/20.0 paths:
number_baths = rooms_with_bath_and_or_shower + rooms_with_bath_and_mixer_shower
mixer_shower_count = rooms_with_mixer_shower_no_bath + rooms_with_bath_and_mixer_shower
Guarded on instantaneous_wwhrs is not None (Optional in the schema), like 17.1.
Counts, same meaning across the reduced-field family.
Verification: new test 2 passed; test_mapper_corpus 6002 passed; SAP-accuracy /
RealCertExpectation regressions pass; pyright unchanged (39 -> 39, pre-existing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PRD #1385, heating-controls family. The 17.0 API mapper omitted
main_heating_controls entirely, leaving the domain field None while the
19.0/20.0/21.x paths map the first lodged control via _map_energy_element. 17.0
lodges main_heating_controls (List[EnergyElement]) in all 23 local fixtures.
The mapped element is the same EnergyElement type 17.0 already carries for
main_heating / window, so this is straight parity (not new logic). Guarded on a
non-empty list -> None, matching the siblings.
Verification: new test 2 passed; test_mapper_corpus 6002 passed; recommendation
+ heating-control tests 85 passed (the domain EnergyElement field is distinct
from the subscriptable dict HeatingControlRecommender reads via the Property
wrapper, so no recommender destabilisation); SAP-accuracy regressions pass;
pyright unchanged (39 -> 39, pre-existing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PRD #1385, lighting family. The 17.0 and 19.0 API mappers hardcoded every
fixed-lighting bulb count to 0, dropping the lodged lighting mix that the
reference-complete 17.1/18.0/20.0 paths read. The schema lodges total +
low-energy OUTLET counts (ADR-0028); mirror the reference split — low-energy
outlets → low_energy bulbs, the remainder → incandescent — so the SAP
lighting-energy calc sees the real low-energy credit instead of assuming none.
led/cfl stay 0 (reduced-field certs don't split those); 21.0.x use a different
bulb-based lighting schema and are out of scope. The `total - low_energy`
subtraction is safe: all 37 local 17.0/19.0 fixtures have low_energy <= total
(no negative incandescent), matching the reference paths that already do this
unclamped.
Verification: new test 2 passed; test_mapper_corpus 6002 passed; SAP-accuracy /
RealCertExpectation regressions 56 passed (no pinned shifts); pyright unchanged
(39 -> 39, pre-existing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PRD #1385, ventilation family. The 17.0 and 19.0 API mappers dropped lodged
ventilation fields that the reference-complete 17.1/18.0/20.0 paths read:
* open_fireplaces_count -> open_chimneys_count: hardcoded 0 on both
(understating §2 infiltration).
* percent_draughtproofed: omitted on both.
* 17.0 additionally passed no sap_ventilation block, so the §2 cascade fell
back to NATURAL + its default sheltered_sides=2. Restored from the lodged
mechanical_ventilation + built_form via the existing _api_* helpers.
Semantic parity verified (not just type-equality): built_form and
mechanical_ventilation use the same gov code lists the 17.1/18.0/19.0/20.0/21.x
paths already map through these helpers; all 244 RdSAP fixtures resolve with zero
UnmappedApiCode, and the strict-coverage guard raises on any future divergence.
For the current corpus the 17.0 score-mover is sheltered_sides from built_form
(every 17.0 cert lodges mechanical_ventilation=0 -> NATURAL).
Adds a regression test asserting both paths carry the fields through
from_api_response on real certs.
Verification: new test 2 passed; test_mapper_corpus 6002 passed; SAP-accuracy /
RealCertExpectation regressions pass; pyright unchanged (39 -> 39, pre-existing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`from_rdsap_schema_17_0` was an older copy that hardcoded a cluster of
lodged `sap_heating` fields to None while every other API path
(19.0/20.0/…) reads them from `schema.sap_heating`. PR #1380 restored the
secondary-heating pair; this restores the rest of the same block, all of
which `RdSapSchema17_0` lodges:
MainHeatingDetail: boiler_flue_type, fan_flue_present,
central_heating_pump_age, main_heating_index_number (PCDB efficiency index)
SapHeating: cylinder_thermostat, cylinder_insulation_thickness
Impact — cylinder_thermostat (12/23 local 17.0 fixtures) read as "no
thermostat", so the water-heating worksheet over-applied its ×1.3 penalty
and `recommend_cylinder_thermostat` could fire on a dwelling that already
has one; cylinder_insulation_thickness (13/23) discarded the measured value
so the cylinder-loss cascade fell back to age-band defaults;
main_heating_index_number lost the PCDB efficiency lookup.
Mirrors the 19.0 path exactly. secondary_*_type left untouched so this PR
stays independent of #1380 (no overlapping lines). Adds a regression test
asserting the whole block survives `from_api_response` on a real 17.0 cert.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `from_rdsap_schema_17_0` path hardcoded `sap_heating.secondary_heating_type`
and `secondary_fuel_type` to None, unlike every other API path (19.0/20.0/…),
which read them from `schema.sap_heating`. The gov EPC API *does* populate a
secondary heating SAP code for a genuine fixed secondary (e.g. 691 "electric
room heaters", 694), so the hardcode silently discarded it.
Because `recommend_secondary_heating_removal` gates purely on
`sap_heating.secondary_heating_type`, any 17.0 cert carrying a fixed secondary
would never get its removal recommendation. Surfaced while auditing portfolio
814's recommendations; 814 has 17.0 certs but none with a fixed secondary, so
the defect was latent there — this restores parity with the other paths so it
cannot bite.
Mirrors the 19.0 path exactly (secondary_fuel_type via `_api_secondary_fuel_type`).
Adds a regression guard that injects a fixed secondary into a real 17.0 cert and
asserts the code survives the mapping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Confirming regression test for the prime-lead suspicion (SAP-16.x meter
over-rating). A full-SAP-shaped 16.x cert lodges the SAP energy_tariff code
space; the assessment_type=SAP gate routes it to the full-SAP mapper, which
translates the code (1=standard → STANDARD) rather than raw-passing it to the
RdSAP normaliser (where 1="dual" → SEVEN_HOUR, the over-rating bug). No fix
needed — the gate already prevents the drop; this pins that behaviour.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add code 5 → "off-peak 18 hour" to _SAP_TARIFF_TO_RDSAP_METER_TYPE so the
full-SAP meter translation covers the whole energy_tariff code space, not
just 1-4. Table 12a resolves the word alias to Tariff.EIGHTEEN_HOUR.
Real SAP-19.1.0/19.2.0 sample certs lodge tariff 5 and now resolve to the
18-hour off-peak split instead of defaulting to STANDARD (under/over-rated).
The same fix covers 17.0/17.1/18.0.0/19.0.0 — all route through the full-SAP
mapper via the assessment_type=SAP gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The full-SAP energy_tariff code space includes 5=off-peak-18-hour, but
_sap_17_1_meter_type only maps 1-4, dropping code 5 to "" → STANDARD.
Real SAP-19.1.0/19.2.0 certs lodge tariff 5, so the 18-hour off-peak
split is silently lost.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Crediting the full-SAP lodged PV closes the 2-point under-rating on uprn
10023444324 — the engine now reproduces the accredited lodged value exactly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full-SAP certs lodge measured PV under sap_energy_source.pv_arrays, but the
SAP-Schema-17.1 SapEnergySource modelled only wind, so from_dict dropped it at
parse and the mapper hardcoded no PV. A new SapPvArray schema field + the
_sap_17_1_pv_arrays mapper helper carry it to the domain photovoltaic_arrays the
Appendix-M generation credit reads — without it an all-electric dwelling the
array lifts to A/B is mis-modelled down a band or more.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full-SAP energy_tariff (1=standard/2=7hr/3=10hr/4=24hr) and RdSAP meter_type
(1=dual/2=single/3=unknown/4=24hr) are different code spaces; the mapper passed
the code through untranslated, reading standard-tariff full-SAP certs as Economy 7
(over-rated) and Economy-7 certs as single (under-rated). Map onto the RdSAP word
aliases so the resolved Table 12a tariff is correct; absent/ND -> unknown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0 = no PV, 1 = not connected to dwelling meter, 2 = connected. Decoded
empirically from the corpus + accredited Elmhurst (the gov RdSAP schema
types it as a bare int). Points readers to the crediting gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_is_elmhurst_roof_window routed a vertical window (Location "External wall")
to sap_roof_windows whenever its building part's roof type was a party
ceiling (A "Another dwelling above" / NR "Non-residential space above").
That is right for an EXTENSION (cert 000565 Ext2/Ext4 score on worksheet
(27a)) but wrong for the MAIN BP: a Main "A"/"NR" roof is the dwelling's own
party ceiling — no external roof — so its windows are vertical.
Gate the BP-roof-type rooflight rule to non-Main building parts. On a mid-
floor flat (case 53 / cert 000565 re-keyed) the Main External-wall window is
now vertical: its area deducts from the wall (was over-counted ~7 W/K) and it
prices as a window. Summary-path SAP 74.0 -> 75.17, matching Elmhurst's
accredited worksheet (75; space-heat 3408.6 vs 3408.58) — confirming the
calculator is correct; the gap was the misclassification.
000565's extension rooflights, the "Roof of Room" branch, and the U>3.0
room-in-roof backstop are unchanged. gov-API path unaffected (it routes roof
windows via window_wall_type==4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both passed no sap_ventilation= → EpcPropertyData default None, dropping the
whole §2 block so MEV/MVHR certs scored as natural. Mirror 21.0.1's block for
the only fields each schema lodges: mechanical_ventilation_kind (§2 MV-kind
dispatch) + sheltered_sides (§S5, from built_form). Neither schema lodges the
fan/flue/vent counts (RdSAP Table-5 age defaults), so those stay unset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19.0 + 21.0.0 pass no sap_ventilation= at all → EpcPropertyData default None,
dropping the entire §2 block (sheltered_sides + mechanical_ventilation_kind).
Extend the MVHR mapper test to both and add a sheltered_sides regression,
mirroring 21.0.1 (remaining ADR-0037 ventilation follow-up).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
17.1/18.0/20.0.0 built SapVentilation(sheltered_sides=…) and dropped the cert's
mechanical_ventilation, mis-scoring MEV/MVHR dwellings as natural. Mirror 21.0.1's
_api_mechanical_ventilation_kind. Natural certs (code 0/5 → None) unchanged
(corpus 73.3%/0.774, mapper corpus green, 726605 SAP 68.058 unchanged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>