SAP 10.2 §2 (17)-(18): a measured/design air permeability at 50 Pa from a
Blower Door test routes infiltration via `(18) = AP50/20 + (8)`, in
preference to the components-based (16) estimate. The Elmhurst extractor
read only the AP4 ("Pulse") column of §12.2, so a Blower Door result
(§12.2 "Pressure Test Result (AP50)") fell through to the structural-
infiltration default — over-counting ventilation heat loss.
Surfaced by simulated case 44 (AP50 4.50): effective air change rate was
0.81 vs the worksheet's 0.58 (+38% ventilation loss). The cascade already
supports `air_permeability_ap50` (preferred over AP4); this wires the read
end to end (extractor → ElmhurstSiteNotes → SapVentilation → cert_to_inputs).
Pinned against the case-44 P960 §2 at abs=1e-4: (18) infiltration 0.3417
(= 4.5/20 + 0.1167) and (25) Jan effective ach 0.5812. Worksheet harness
stays 47/47 0-raised.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RdSAP 10 §6.1 (PDF p.49) models a non-separated (heated) conservatory as
part of the dwelling. Until now the Summary §5 block was reduced to an
inert `has_conservatory` bool and the geometry (floor area, glazed
perimeter, glazing, storey height) was dropped on both paths.
Plumbing only — no cascade consumer yet (Slices B/C/D wire §3/§6):
- ElmhurstSiteNotesExtractor reads the §5 Conservatory block into a new
`Conservatory` site-notes record (scoped to §5 so the generic
"Floor Area"/"Room Height" labels can't collide with §4 dimensions);
- domain gains a frozen `SapConservatory` (floor area, glazed perimeter,
double/single glazing, thermally-separated guard, equivalent storey
count) on `EpcPropertyData.sap_conservatory`;
- the Elmhurst mapper threads it through, dropping SEPARATED
conservatories per §6.2 ("A separated conservatory ... is disregarded").
Verified against the simulated case-44 Summary (RefNo 001431): extracts
floor_area=12.0, glazed_perimeter=9.0, double_glazed=True, 1 storey.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two compensating Summary-extractor bugs surfaced by simulated case 43 (a
2-BP mid-terrace with a detailed room-in-roof + a dry-lined extension wall).
Their fabric errors nearly cancelled (walls net −0.76 W/K), hiding both
behind a deceptively small +0.05 SAP delta.
Bug 1 — main/extension wall dry-lining never read. The §7 "Dry-lining:
Yes/No" line was parsed only for ALTERNATIVE walls; the main/extension
WallDetails dropped it, so a dry-lined solid wall was billed at its
un-adjusted base U. RdSAP 10 §5.8 + Table 14: a dry-lined uninsulated wall
adds R=0.17 → U = 1/(1/U_base + 0.17). Case 43 Ext1: solid brick 1.70 →
1.32. Added `WallDetails.dry_lined`, read it in the extractor (both the
main-wall builder and the As-Main copy), threaded it to the domain
`wall_dry_lined` (emit None when undried — cascade-equivalent to False,
keeps the field absent for the non-dry-lined majority).
Bug 2 — the LAST room-in-roof surface row's U over-read. The per-row token
scan stops at the next RIR-row name; the final surface (no successor) over-
read into the following section, shifting the trailing-token slotting and
silently zeroing its `default_u` (case 43 Common Wall 2: 1.90 → 0.00 → the
2.4 m² common wall billed at U=0 instead of the main-wall 1.90). Stop the
scan at the row's natural end — the "Yes"/"No" u_value_known flag plus the
trailing u_value numeric.
Case 43 now reproduces the P960 EXACTLY: (29a) walls 74.5800, (33) fabric
172.7844, continuous SAP 73.2332 = (258), CO2 3518.30 = (272), all <1e-4
(was SAP +0.0455 / CO2 −8.04). Harness 47/47 0 raised; regression = the 3
pre-existing fails; pyright net-zero (51=51).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Elmhurst Summary §15.1 lodges "Cylinder Size: Value known" with the
measured volume in the "Cylinder Volume (l)" line — the Summary-path
equivalent of the gov-API "Exact" descriptor. The mapper had no entry for
"Value known" so `_elmhurst_cylinder_size_code` raised UnmappedElmhurstLabel,
and even once mapped the measured volume was never threaded through, so the
cascade dropped the cylinder storage loss (~468 kWh/yr) from (219) water
heating on every measured-volume-cylinder Summary.
Per RdSAP 10 §10.5 Table 28 (p.55) a measured cylinder volume is used
directly. Map "Value known" → cascade code 6 (Exact) and thread the §15.1
"Cylinder Volume (l)" value into SapHeating.cylinder_volume_measured_l, which
`_cylinder_volume_l_from_code` (cert_to_inputs.py:5281) already reads for
code 6 — mirroring the gov-API path (mapper.py:1575/1885).
Pins simulated case 39 (P960-0001-001431): an age-A mid-terrace on direct-
acting electric room heaters (SAP code 691, cat 10, control 2602) with
electric-immersion DHW off a 117 L "Value known" cylinder. The full
extractor→mapper→calculator cascade now reproduces the worksheet's SAP-rating
block EXACTLY — SAP value 36.6365 (band F) and (272) CO2 2056.0731 kg/yr,
with (219) water heating 2637.5049 and (255) total energy cost 1802.0039.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Elmhurst Summary §7 lodges "Alternative Wall N Sheltered Wall: Yes" for
a sub-area adjacent to an unheated buffer (e.g. a flat's corridor wall),
but the extractor dropped it and _map_elmhurst_alternative_wall never set
SapAlternativeWall.is_sheltered — so the cascade billed the sub-area at its
full exposed U instead of the RdSAP 10 Table 4 (p.22) sheltered U =
1/(1/U + 0.5).
The calculator already applies is_sheltered (_alt_wall_w_per_k) and the
gov-API path already wires sheltered_wall=="Y"; this brings the Elmhurst
front-end to parity. Three-part change: AlternativeWall.sheltered field +
_alternative_walls_from_lines parse ("Alternative Wall N Sheltered Wall") +
_map_elmhurst_alternative_wall is_sheltered=a.sheltered.
Surfaced by simulated case 34 (cert 001431 electric-storage flat): the
6.02 m² corridor wall billed at full U=1.50 (9.03 W/K) instead of the
sheltered 0.86 (5.18 W/K) — +3.85 W/K, -1.61 SAP. Post-fix the alt wall
matches the worksheet's (29a) 5.177 and case 34 closes from -1.61 to -0.30
(remaining residual is a separate window/wall area-allocation thread).
Elmhurst-mapper only: API SAP gauge unchanged (57.6% within 0.5); worksheet
harness 47/47 unaffected; regression gate green (3 pre-existing fails
unrelated); pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Elmhurst Summary section 15.1 "Hot Water Cylinder" block lodges
"Immersion Heater: Dual" / "Single"; the extractor dropped it, so the
Summary path left immersion_heating_type = None while the API path already
captured it. Capturing it drives SAP Table 13's high-rate-fraction
DHW-cost split (RdSAP 10 section 10.5 p.54: 1 = dual, 2 = single) and
brings the two front-ends to parity.
Three-file change: WaterHeating.immersion_type field +
_extract_water_heating parse (scoped to the 15.1..15.2 slice) +
_elmhurst_immersion_type_code mapper (strict-raise on an unmapped label,
mirroring _elmhurst_cylinder_insulation_code).
Safe to land now that the preceding commit zeroes the high-rate fraction
for 18-/24-hour tariffs: the 20 solid-fuel corpus certs (solid fuel 4-11:
WHC 903 dual immersion, 18-hour meter, 110 L) carry a dual immersion, but
their 18-hour tariff bills 100% low-rate per Table 12a's 7-/10-hour scope
— so they stay EXACT instead of regressing to the 10-hour-column ~0.10.
7-/10-hour Summary immersion certs now correctly cost the Table 13
high-rate fraction instead of falling to the immersion=None 100%-low
default.
Regression gate green (3 pre-existing fails unrelated); API gauge
unchanged (Summary-path-only): 57.6% within 0.5, mean|err| 1.185.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Appendix G4 (PDF p.72-73). A PV diverter routes surplus PV
generation (the would-be export EPV,m × (1 − βm)) to an immersion heater
in the hot-water cylinder. Per G4 step 4:
SPV,diverter,m = EPV,m × (1 − βm) × 0.8 × fPV,diverter,storageloss
(0.8 = cylinder heat-acceptance; fPV,diverter,storageloss = 0.9 for the
higher storage temperature), clamped to ≤ (62)m + (63a)m, and entered as
the negative worksheet (63b)m (step 5). The β factor is computed on the
PRE-diverter (219) per the §3a note (lines 5485-5486). Effects:
- (64)m = (62)m + (63b)m → less main-system water-heating fuel (219);
- export drops to EPV,ex,m = EPV,m(1 − βm) + (63b)m / 0.9 (§4 p.94
line 5501); the onsite dwelling portion EPV,m × βm is unchanged.
Inclusion (G4 step 1) requires ALL of: a PV system connected to the
dwelling; a cylinder larger than (43) average daily HW use; no solar
water heating; no battery — else the diverter is disregarded.
Three layers:
- extractor reads Summary §19 "Diverter present"; schema 21.0.0/21.0.1
SapEnergySource gains `pv_diverter` (API `sap_energy_source.pv_diverter`);
- `Renewables.pv_diverter_present` + domain `SapEnergySource.pv_diverter_present`,
set in both the Elmhurst and API mapper paths;
- `_pv_diverter_monthly_kwh` applies the G4 math after the β split;
`cert_to_inputs` recomputes (219) and the PV export.
On simulated case 19 (electric storage heaters, 7-hour, PV + diverter):
SAP continuous 50.33 → 51.34 (worksheet 51.2221; both round to the
lodged 51), cost (255) 1847.5 → 1812.3 (ws 1816.6), CO2 (272) 3331 →
3120 (ws 3126), with (233a) dwelling 1280.6 (ws 1280.4). The residual
+0.11 SAP is an upstream winter Appendix-M monthly-EPV-shape gap +
fabric (33) +1.0, tracked as the next case-19 cause. Suite: 2412 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prerequisite for the SAP 10.2 p.186 two-systems-different-parts MIT.
When two main systems heat different parts of a dwelling, §14.1 Main
Heating2 lodges its OWN "Heat Emitter" + "Main Heating Controls Sap"
(simulated case 6: Main 1 radiators / control 2106 serving the living
area, Main 2 underfloor / control 2110 serving elsewhere). The extractor
+ mapper dropped both — `MainHeatingDetail.heat_emitter_type` and
`main_heating_control` came through as empty-string sentinels, so the
cascade saw system 2 as having no responsiveness (defaulted R=1.0) and no
control type.
- `MainHeating2` datatype gains `heat_emitter` + `heating_controls_sap`.
- The extractor reads them from the §14.1 block.
- `_map_elmhurst_main_heating_2` maps them via the same helpers as Main 1
(`_elmhurst_heat_emitter_int` → underfloor-in-screed = emitter 2, Table
4d R=0.75; `_elmhurst_sap_control_code` → 2110, Table 4e type 3),
threading the dwelling floor + age band for the underfloor subtype.
Empty-string fallback preserved for the legacy DHW-only Main 2 (cert
000565 §14.1 omits emitter/control). No cascade output changes yet — the
MIT consumer lands in S0380.205. Full suite 2358 pass + 0 fail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the 5 community-heating variants in the heating-systems corpus
(community heating 1/2/3/4/6 on property 001431). Pre-slice the
mapper returned `MainHeatingDetail.main_fuel_type=''` for every
community-heating cert because §14.0 lodges no Fuel Type — only EES
'COM' + a Table 4a heat-network SAP code (301/302/304). The cascade
strict-raised `MissingMainFuelType` per S0380.132. The actual fuel
that bills the cascade lives in the §14.1 Community Heating/Heat
Network block, which the extractor was skipping entirely.
SAP 10.2 Table 12 (PDF p.189) defines the heat-network fuel codes:
Boilers + Mains Gas → 51 (heat from boilers — mains gas)
Boilers + Mineral oil → 53 (heat from boilers — oil)
Boilers + Coal → 54 (heat from boilers — coal)
Boilers + Biomass → 43 (heat from boilers — biomass)
Combined Heat and Power → 48 (heat from CHP; fuel-agnostic)
Heat pump + Electricity → 41 (heat from electric heat pump)
Per spec text the upstream fuel determines the boiler-side code; CHP
is fuel-agnostic at the Table 12 cost / CO2 / PE level.
Three layers wired:
1. Survey schema — new `CommunityHeating` dataclass alongside
`MainHeating2` carrying the §14.1 fields (heating_type,
community_heat_source, community_fuel_type, heating_controls_ees,
heating_controls_sap, chp_fuel_factor). Mutually exclusive with
`main_heating_2` at the §14.1 level. Attached as
`MainHeating.community_heating: Optional[CommunityHeating] = None`.
2. Extractor — new `_extract_community_heating()` method bracketed by
"14.1 Community Heating/Heat Network" / "14.2 Meters". Returns
None on individually-heated dwellings (no Community Heat Source
lodged). Wired into `_extract_main_heating()`.
3. Mapper — new `_resolve_community_heating_fuel_code(heat_source,
fuel)` dispatch helper + `_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12`
constant for the boiler upstream-fuel split. Wired in
`_map_elmhurst_sap_heating` after the EES-code-to-fuel dispatch
and before the strict-raise on absent SAP code.
Per the standard slice workflow + [[feedback-aaa-test-convention]]:
- 5 new AAA tests in `test_community_heating_mapper_resolves_table_12_
fuel_code` parametrized over the 5 corpus variants, asserting the
mapper resolves the expected Table 12 code per variant.
- The existing parametrized residual-pin test in
`test_heating_systems_corpus_residual_matches_pin` picks up the
5 community-heating variants with cascade-side residuals pinned as
forcing functions for follow-up slices:
variant dSAP dcost dCO2 dPE
CH1 (Boilers/Gas) +0.59 -£14 -787 -3827
CH2 (CHP/Gas) +4.50 -£104 -1430 +1506
CH3 (HP/Elec) +0.59 -£14 +1614 +11879
CH4 (CHP/Oil) +4.50 -£104 -4397 +495
CH6 (CHP/Coal) -3.52 +£81 -2935 +7865
These reflect open cascade-side work (SAP 10.2 Appendix C CHP/
boiler heat-fraction split missing — cascade treats CHP+Boilers as
100% CHP; community-HP COP cascade missing — cascade doesn't divide
delivered heat by COP for Table 12 code 41; heat-network overall
CO2/PE blended-factor cascade missing — cascade doesn't compute
worksheet rows (386)/(486)). Pinned per [[feedback-zero-error-strict]];
follow-up slices close gaps and re-pin smaller residuals.
- `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` tuple now empty; the
blocked-tier test pytest-skipped via `pytest.mark.skipif` with a
reason naming this slice.
Test baseline at HEAD: 921 pass + 1 skipped (was 916 + 0 at
predecessor 7e08e7af). Pyright net-zero on affected files
(elmhurst_site_notes.py, elmhurst_extractor.py, mapper.py,
test_heating_systems_corpus.py): 32 → 32.
Per [[feedback-spec-citation-in-commits]] the dispatch is grounded
in SAP 10.2 Table 12 (PDF p.189). Per
[[feedback-bigger-slices-for-uniform-work]] all 5 variants land in
one slice — the work is uniform (single Elmhurst label dict + single
dispatch helper) and the per-variant residuals surface together
because of cascade-side gaps, not mapper-side variation.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Elmhurst Summary §14.0 "Main Heating EES Code" is a three-letter
identifier that resolves to the specific fuel for solid-fuel main
heating systems. The §14.0 "Main Heating SAP Code" alone can't
disambiguate because Table 4a categorises solid-fuel systems by
appliance type rather than fuel — SAP code 160 ("Closed room heater
with boiler") is shared by anthracite, wood chips, dual fuel and
smokeless across the heating-systems corpus.
Three changes land together:
1. `MainHeating` dataclass (`elmhurst_site_notes.py`) gains a
`main_heating_ees: str = ""` field for the §14.0 EES code.
2. `ElmhurstSiteNotesExtractor._extract_main_heating` reads "Main
Heating EES Code" from §14.0.
3. `_map_elmhurst_sap_heating` adds a fourth fuel-derivation
fallback (after the existing electric-SAP-code + §15.0-liquid-
fuel branches): when `main_fuel_int is None` and the §14.0 EES
code is in `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE`, use that
dict's value as the main fuel.
Dict (corpus-derived, 10 entries → 7 distinct Table 32 fuels):
BAF, BAI, RAM → 15 anthracite (3.64 / 0.395 / 1.064)
BCC → 11 house coal (3.67 / 0.395 / 1.064)
BDI → 10 dual fuel (3.99 / 0.087 / 1.049)
BKI → 12 smokeless (4.61 / 0.366 / 1.261)
BQI → 21 wood chips (3.07 / 0.023 / 1.046)
RPS → 22 wood pellets bags (5.81 / 0.053 / 1.325)
RUN → 23 bulk pellets (5.26 / 0.053 / 1.325)
RWN → 20 wood logs (4.23 / 0.028 / 1.046)
Dict values are Table 32 fuel codes, NOT API `main_fuel` enum codes
— the API codes 1-9 collide with Table 32 codes for unrelated fuels
(e.g. API 5 = "anthracite" vs Table 32 5 = "bottled LPG main
heating"). `unit_price_p_per_kwh` / `co2_factor_kg_per_kwh` /
`primary_energy_factor` all check the Table 32 dict before falling
through to the API translation, so using Table 32 codes here avoids
the collision and routes cost/CO2/PE through the correct fuel row.
Heating-systems corpus impact — all 10 solid-fuel variants move
from `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` (assert-on-raise) back
onto the residual-pin grid in `_EXPECTATIONS`:
variant ΔSAP Δcost ΔCO2 ΔPE
solid fuel 2 +4.79 -£110 -484 kg +441 kWh anthracite
solid fuel 3 +4.43 -£102 -1206 +1452 anthracite
solid fuel 4 +4.13 -£95 -714 +1655 anthracite
solid fuel 5 +2.71 -£62 -301 +2360 house coal — smallest
solid fuel 6 -7.38 +£168 -154 +2519 dual fuel — only negative
solid fuel 7 +5.82 -£131 -758 +2968 smokeless
solid fuel 8 +4.24 -£98 -15 +2513 wood chips
solid fuel 9 +3.44 -£79 -8 +2428 wood pellets bags
solid fuel 10 +5.14 -£118 -53 +1849 wood pellets bulk
solid fuel 11 +4.35 -£100 -9 +1536 wood logs
Remaining residuals trace to heating-system efficiency / control
type — separate slices. 16 variants still in `_BLOCKED`: community
heating ×5, electric storage ×4, no system, oil non-Heating-oil ×5,
Bulk LPG ×1. Each is its own derivation slice.
Extended handover suite at HEAD post-slice: 876 pass / 0 fail (was
875 + 1 new EES wiring AAA test).
Pyright net-zero on touched files (45 → 45 — all pre-existing).
No golden fixture impact — no golden cert lodges an EES code via
the Elmhurst path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4f line (230a) annual electricity for mechanical
ventilation fans, decentralised MEV branch:
E_fans_kwh = SFPav × 1.22 × V
where SFPav is the §2.6.4 equation (1) flow-weighted average SFP
across every fan in the installation, with PCDB Table 322 supplying
per-configuration (flow, SFP) and PCDB Table 329 supplying the
ducting-type IUF.
This slice composes the foundation slices S0380.98 (Table 322),
S0380.99 (Table 329), S0380.100 (SFPav helper) into a cert-driven
cascade — `_mev_decentralised_kwh_per_yr_from_cert(epc)` reads:
MV PCDF Reference Number → PCDB Table 322 record (per-config SFP)
Duct Type (Flexible/Rigid) → PCDB Table 329 in-use factor
Wet Rooms count → per-fan-type count distribution
Three coupled changes:
1. Elmhurst extractor + schema — `_extract_ventilation` parses §12.1
"MV PCDF Reference Number", "Wet Rooms", "Duct Type", "Approved
Installation". New fields on `VentilationAndCooling`.
2. Mapper — plumbs the lodgements through to
`EpcPropertyData.mechanical_ventilation_index_number`,
`.wet_rooms_count`, `.mechanical_vent_duct_type`. New
`_elmhurst_mv_duct_type_int` helper (Flexible→1, Rigid→2 per PCDF
Spec §A.20 field 12 convention) with strict-raise on unknown
labels per [[unmapped-elmhurst-label]].
3. Cascade — `_table_4f_additive_components` calls the new
`_mev_decentralised_kwh_per_yr_from_cert(epc)` to add the (230a)
contribution alongside the existing flue-fan + solar-HW pump
additions.
Per-fan count convention (reverse-engineered from cert 000565):
- Each PCDB-defined configuration (1..6) contributes 1 baseline fan.
- Through-wall configurations scale with wet-rooms count:
through-wall kitchen (5): wet_rooms_count fans
through-wall other wet (6): wet_rooms_count + 1 fans
- Configurations with blank SFP (e.g. record 500755 in-duct codes 3,
4) contribute 0 to the numerator but their flow rate to the
denominator per SAP §2.6.4 "summation is over all the fans".
For cert 000565 (wet_rooms=2) this yields the worksheet's observed
fan distribution (1, 1, 1, 1, 2, 3) → SFPav = 11.7205 / 92.0 =
0.12740 W/(l/s), and (230a) = 0.12740 × 1.22 × 820.4385 = 127.5159
kWh/year ✓ matches worksheet line (230a) at 1e-4.
TODO: validate the count convention against a second MEV
decentralised fixture; the rule above fits cert 000565 alone.
Cert 000565 closure state at HEAD:
- pumps_fans_kwh_per_yr: 125.0 → 252.5159 ✓ EXACT (was 255.0 pre-arc;
the MEV +127.5 contribution closes the residual)
- sap_score (int): 29 ✓ EXACT preserved
- sap_score_continuous: 28.69 (S0380.101 transient) → 28.5043 vs
ws 28.5087 (Δ -0.0044). Was -0.0001 pre-arc — the MEV fix revealed
a pre-existing residual elsewhere in the cost cascade (likely
Table 12a HP-on-E7 high-rate split per the original TODO at
mapper.py:4039-4040; deferred to a separate slice).
Test count: 603 pass + 7 expected 000565 fails (was 8 —
pumps_fans_kwh_per_yr flipped FAIL→PASS, removed from work queue).
Cohort safety: only cert 000565 lodges a non-None MV PCDF Reference
Number across the Summary fixture set; cohort certs return 0 from
`_mev_decentralised_kwh_per_yr_from_cert` (no MEV system).
Pyright net-zero per touched file.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RdSAP 10 Specification §5.13 "U-values of exposed and semi-exposed
upper floors" (PDF p.47) + Table 20:
"Otherwise, to simplify data collection no distinction is made in
terms of U-value between an exposed floor (to outside air below)
and a semi-exposed floor (to an enclosed but unheated space
below) and the U-values in Table 20 are used."
Table 20 (excerpt, age bands A-G | H or I):
Age band Unknown/as built 50mm 100mm 150mm
A to G 1.20 0.50 0.30 0.22
H or I 0.51 0.50 0.30 0.22
Cert 000565 Summary §9 2nd Extension lodges:
Location: U Above unheated space
Type: N Suspended, not timber
Insulation: R Retro-fitted
Insulation Thickness: 200 mm
Default U-value: 0.22
Pre-slice the extractor's `_floor_details_from_lines` did NOT read
the "Insulation Thickness" cell (only the §8 roof extractor had the
field). FloorDetails carried no thickness → mapper plumbed
`SapBuildingPart.floor_insulation_thickness=None` → cascade
`u_exposed_floor(age=H, ins=None)` returned U=0.51 (Table 20 row[0]
unknown/as-built) vs worksheet 0.22 (Table 20 150 mm column for
age H) — over-counting BP[2] floor by (0.51-0.22) × 30 m² = +8.70
W/K.
Three-layer fix:
1. Schema (`elmhurst_site_notes.py:FloorDetails`) — add
`insulation_thickness_mm: Optional[int] = None` (mirror of
`RoofDetails`).
2. Extractor (`elmhurst_extractor.py:_floor_details_from_lines`) —
parse "Insulation Thickness" via existing `_local_val` (mirror of
`_roof_details_from_lines` pattern at line 333).
3. Mapper (`mapper.py:_map_elmhurst_building_part`) — translate
`floor.insulation_thickness_mm` to `SapBuildingPart.floor_
insulation_thickness=f"{n}mm"` (digit-prefix string convention
matching the API mapper + the wall pattern at line 3125-3129).
Cascade no-op: existing `_parse_thickness_mm` accepts "200mm" → 200;
`u_exposed_floor(age=H, ins=200)` returns 0.22 (clamps thickness ≥
125 mm to Table 20 row[3]) ✓.
Movement at HEAD (cert 000565):
- BP[2] Ext2 floor cascade U: 0.51 → 0.22 ✓ EXACT vs ws 0.22
- floor_w_per_k: 70.37 → 61.67 ✓ EXACT vs ws 61.67 (closed +8.70)
- sap_score (int): 28 → 29 ✓ EXACT vs ws 29
- sap_score_continuous: 28.31 → 28.5086 vs ws 28.5087 (Δ -0.20 →
-0.0001 — within 1e-4 strict floor!)
- SH: -38 kWh vs ws (was +218 → essentially closed)
Test count: 587 → 590 pass (+2 new AAA tests + sap_score integer
pin flipped from FAIL to PASS) + 8 expected 000565 fails (sap_score
integer pin removed from the work queue).
Cohort safety: only cert 000565 §9 lodges "Insulation Thickness"
(grep audit across Summary fixtures); cohort certs lodge "As built"
or omit the line. Pyright net-zero per touched file.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 §2 lines (17a)/(18) "Air permeability value, AP4 (m³/h/m²)"
(PDF p.12-13):
> "The air permeability at 4 Pa (AP4) measured with the low-pressure
> pulse technique [...] is used in the following formula to estimate
> of the air infiltration rate at typical pressure differences.
> In this case (9) to (16) of the worksheet are not used."
>
> Air infiltration rate (ach) = 0.263 × AP4^0.924
>
> If based on air permeability value at 4 Pa,
> then (18) = [0.263 × (17a)^0.924] + (8)
SAP 10.2 §2 lines (23a)/(24c)/(25) "MEV" + "Whole-house extract
ventilation" (PDF p.13/133):
> "The SAP calculation is based on a throughput of 0.5 air changes per
> hour through the mechanical system." (23a) = 0.5
>
> If whole house extract ventilation or positive input ventilation
> from outside:
> if (22b)m < 0.5 × (23b), then (24c) = (23b)
> otherwise (24c) = (22b)m + 0.5 × (23b)
Cert 000565 lodges:
- Summary §12.1 "Mechanical Ventilation Type: Mechanical extract,
decentralised (MEV dc)" (PCDF 500755)
- Summary §12.2 "Test Method: Pulse" + "Pressure Test Result (AP4): 2.00"
Pre-slice both lodgements were silently dropped by the Elmhurst
extractor / mapper / `cert_to_inputs` cascade:
- AP4 had no schema field on `VentilationAndCooling` or `SapVentilation`
even though `ventilation.py:ventilation_from_inputs(air_permeability_
ap4=...)` already implemented the spec formula.
- Mechanical Ventilation Type had no schema field; `cert_to_inputs.
ventilation_from_cert` hardcoded `mv_kind=MechanicalVentilationKind.
NATURAL` regardless of the lodgement, routing cert 000565 through
the (24d) natural-vent formula instead of (24c).
These bugs are coupled: AP4 alone would close (18) but the cascade's
(25) NATURAL pass-through would then *under*-count the effective ach
by 0.25 (the missing MEV contribution). MEV alone would over-count
because the (18) over-count remains. Per [[feedback-bigger-slices-
for-uniform-work]] + handover precedent on coupling-aware reverts,
these land together.
Slice span (5 layers):
1. **Schema** — `VentilationAndCooling.air_permeability_ap4_m3_h_m2` +
`VentilationAndCooling.mechanical_ventilation_type` (site-notes);
`SapVentilation.air_permeability_ap4_m3_h_m2` +
`SapVentilation.mechanical_ventilation_kind` (domain).
2. **Extractor** — `_extract_ventilation` parses "Pressure Test Result
(AP4)" scoped to §12.2 and "Mechanical Ventilation Type" scoped to
§12.1. Both default to None when the cert lodges no MV / no Pulse
test (cohort modal case).
3. **Mapper** — `_map_elmhurst_ventilation` plumbs AP4 through; new
`_ELMHURST_MV_TYPE_TO_KIND` dispatch with strict-raise on unmapped
labels (per [[reference-unmapped-elmhurst-label]] mirror pattern).
4. **cert_to_inputs** — `ventilation_from_cert` reads AP4 and resolves
`mechanical_ventilation_kind` name → `MechanicalVentilationKind`
enum. MEV/MV/MVHR kinds set `mv_system_ach=0.5` per spec (23a).
5. **Tests** — 4 in test_summary_pdf_mapper_chain.py (extractor + mapper
for both AP4 and MEV kind), 2 in test_cert_to_inputs.py (cascade
AP4 formula + MEV kind dispatch). All AAA-structured.
Cert 000565 movement (HEAD `83218630` → this slice):
- cascade (18) pressure_test_ach: 2.4037 → 2.0287 ✓ EXACT vs ws 2.0287
- cascade (21) shelter-adj: 2.0431 → 1.7244 ✓ EXACT vs ws 1.7244
- cascade mean (25)m: 2.2347 → 2.1360 vs ws 2.086 (+0.05)
- **sap_score (integer): 28 → 29 ✓ EXACT vs ws 29** (Δ−1 → Δ 0)
- sap_score_continuous: 27.99 → 28.77 (Δ−0.52 → +0.26)
- ecf: 5.44 → 5.36 (Δ+0.05 → −0.03)
- total_fuel_cost_gbp: 4726.75 → 4657.37 (Δ+46 → Δ−23)
- co2_kg_per_yr: 6506.48 → 6415.56 (Δ+59 → Δ−32)
- **space_heating_kwh: +631 → −367** (~75% closed)
- main_heating_fuel: +371 → −216 (~58% closed)
- hot_water_kwh: ✓ 0 EXACT unchanged
- lighting / pumps_fans: sub-spec residuals unchanged
The residual cascade-over-by-0.05 ach on (25)m is the cascade using
the cert-agnostic Table U2 wind tuple instead of the cert's regional
wind lookup; future ventilation_from_cert wires a `postcode_climate`
arg through which `cert_to_demand_inputs` already does for the demand
cascade, but the SAP-rating cascade keeps the Table U2 default.
Cohort safety:
- All 21 other Elmhurst cohort fixtures lodge `pressure_test_method=
"Not available"` and `mechanical_ventilation=False` → both new
fields default to None → cascade behaviour unchanged.
- 9 golden + 38 cohort-2 API certs route through `_map_sap_ventilation`
(the API mapper variant), which leaves both new SapVentilation
fields at their None default → cascade behaviour unchanged.
Test baseline: 582 pass + 8 expected `000565` fails (was 575 + 9; +6
new tests + sap_score reclassified from fail to pass). 1763 pass in
broader sap10_ml + worksheet + epc.domain suites + 3 pre-existing
fails unchanged. Pyright net-zero per touched file (1/0/0/32/34→32/13/
11 → 1/0/0/32/32/13/11, cert_to_inputs.py improved −2).
Per [[project-sap10_ml-deprecation]] the new fields live on the
existing `SapVentilation` domain type; no new modules under sap10_ml.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RdSAP 10 §5.18 (PDF p.48) "Curtain wall - U-value and other parameters":
"If documentary evidence is available, use calculated U-value of the
whole curtain wall. Otherwise for the purpose of RdSAP, U= 2.0 W/m²K
for pre-2023 curtain walls, And for post-2023 (2024 in Scotland)
U-values as for windows given in Notes below Table 24."
Table 24 row "Double or triple glazed England/Wales: 2022 or later"
PVC/wood column = 1.4 W/m²K. Whole-wall curtain walls use Frame
Factor=1 per the §5.18 closer.
Pre-S0380.85 `WALL_CURTAIN=9` was defined at rdsap_uvalues.py:116 but
NOT included in `known_types`, so `u_wall(construction=9)` fell through
to `_DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY)` → cavity table at age
H = 0.60. Cert 000565 BP[2] Ext2 lodges `Type: CW Curtain Wall` +
`Curtain Wall Age: Post 2023` per Summary PDF §7; worksheet pins U=1.40
(matching the §5.18 Post-2023 PVC/wood row). Cascade under-counted
walls by Δ U=0.80 × area = −112.2 W/K on this BP — 70% of the
post-S0380.84 BP main-wall residual (−161 W/K total).
§5.18 keys the curtain-wall U-value on the per-BP installation age,
NOT on the dwelling-wide `construction_age_band` — cert 000565 is
age H (1991-1995) but the curtain wall itself was installed
Post-2023. Plumb a new optional field through the extractor → datatype
→ mapper → cascade so the §5.18 dispatch sees it.
Files touched (5-layer slice span):
- backend/documents_parser/elmhurst_extractor.py:
`_wall_details_from_lines` reads "Curtain Wall Age" via
`_local_val` so absent lines stay None (not "").
- datatypes/epc/surveys/elmhurst_site_notes.py:WallDetails:
`curtain_wall_age: Optional[str] = None` field added.
- datatypes/epc/domain/epc_property_data.py:SapBuildingPart:
`curtain_wall_age: Optional[str] = None` field added.
- datatypes/epc/domain/mapper.py:_map_elmhurst_building_part:
threads `walls.curtain_wall_age` onto SapBuildingPart.
- domain/sap10_ml/rdsap_uvalues.py:
new `_u_curtain_wall(curtain_wall_age)` helper + WALL_CURTAIN
dispatch in `u_wall` before the `known_types` lookup.
"Post 2023" / "Post-2023" → 1.4; everything else (incl. None)
→ 2.0 per §5.18 fallback.
- domain/sap10_calculator/worksheet/heat_transmission.py:
passes `curtain_wall_age=part.curtain_wall_age` to `u_wall`
on the main-wall path. (Alt-wall path unchanged — cert 000565
lodges CW only as a main wall, never as an alt sub-area; alt
coverage is a follow-up slice if a future cert exercises it.)
Tests (6 new, AAA-structure):
- 3 in domain/sap10_ml/tests/test_rdsap_uvalues.py — `u_wall` direct
unit tests for Post 2023 (1.4), Pre 2023 (2.0), and absent
lodging fallback (2.0).
- 3 in backend/documents_parser/tests/test_summary_pdf_mapper_chain
.py — extractor pin (BP[2] Ext2 surfaces "Post 2023", non-CW BPs
stay None), mapper pin (curtain_wall_age threaded to BP[2]
SapBuildingPart), cascade pin (`heat_transmission_from_cert`
walls subtotal ≥ 540 W/K — pre-S0380.85 was 443).
Cert 000565 cascade walls: 443 → 555.93 W/K (worksheet 604.07; 70%
closer). Test baseline: 558 pass (was 555 + 3 new) + 9 expected
`test_sap_result_pin[000565-*]` fails unchanged.
Per [[feedback-verify-handover-claims]]: the post-S0380.84 handover
predicted SH residual would close +2591 → ~+800 kWh after this slice,
but the cascade is actually OVER-counting SH despite walls being
UNDER-counted. Closing the wall under-count makes the SH residual
*larger* (+2591 → +6348). The wall fix is spec-correct; the SH
over-count is a separate channel that surfaces more sharply now. Per
[[feedback-spec-citation-in-commits]] + [[feedback-spec-floor-skepticism]]
+ the S0380.84 precedent, ship the spec-correct change and document
the surfaced gap for the next slice rather than reverting to the
compensating-bugs state.
Pyright net-zero on every touched file (existing pre-existing errors
unchanged). Cohort + golden + cert 9501 unaffected — curtain_wall_age
defaults to None on those certs and `u_wall` ignores it unless
`construction == WALL_CURTAIN`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per SAP 10.2 §4 line (64)m: `(64)m = max(0, (62)m + (63a)m + (63b)m
+ (63c)m + (63d)m)` where (63c)m is the solar HW credit lodged as a
negative quantity. The cascade hardcoded (63c)m = 0 since S0380.66
when the Appendix H orchestrator landed without integration, pending
the 1.81× over-count resolution (closed in S0380.74).
This slice plumbs the orchestrator into `water_heating_from_cert`
via a new `solar_water_heating_monthly_kwh_override` parameter, and
adds `_solar_hw_monthly_override` in cert_to_inputs.py that drives
the orchestrator from RdSAP 10 §10.11 Table 29 defaults +
cert-lodged collector geometry on Elmhurst Summary §16.0.
RdSAP 10 §10.11 Table 29 row "Solar panel" (p.58, verbatim):
"If solar panel present, the parameters for the calculation not
provided in the RdSAP data set are:
- panel aperture area 3 m²
- flat panel, η₀ = 0.80, a₁ = 4.0, a₂ = 0.01
- facing South, pitch 30°, modest overshading
- …
- pump for solar-heated water is electric (75 kWh/year)
- showers are both electric and non-electric"
Lodged collector orientation / pitch / overshading on the Summary
§16.0 ("Are details known? Yes" branch) override South / 30° /
Modest. Aperture, η₀, a₁, a₂, IAM stay at Table 29 defaults — the
deeper thermal parameter lodgement (P960 worksheet) isn't yet in
the Summary extractor surface.
For (H17)m to include storage + primary + combi losses, the cascade
runs a `demand_pass` call without solar (gets (62)m) before sizing
the solar credit. The final call then uses all overrides.
Files:
- datatypes/epc/surveys/elmhurst_site_notes.py: Renewables gains
`solar_hw_collector_orientation` / `_pitch_deg` / `_overshading`
optional fields.
- datatypes/epc/domain/epc_property_data.py: same three fields
added at the end of the dataclass.
- datatypes/epc/domain/mapper.py: from_elmhurst_site_notes
propagates the three new fields.
- backend/documents_parser/elmhurst_extractor.py: §16.0 section
parsing reads "Collector orientation" / "Collector elevation" /
"Overshading" rows; `_parse_solar_pitch_deg` strips the degree
glyph.
- domain/sap10_calculator/worksheet/water_heating.py: new
`solar_water_heating_monthly_kwh_override` param on
`water_heating_from_cert`; threaded into `output_from_water_
heater_monthly_kwh(solar_monthly_kwh=...)`.
- domain/sap10_calculator/rdsap/cert_to_inputs.py: Table 29
constants + `_solar_hw_monthly_override` helper +
`_orientation_from_summary_string` mapper. Added the demand_pass
intermediate call so (H17)m sees the full (62)m. Negates the
orchestrator output at the boundary (spec convention: heat
displaced from boiler is negative on line (63c)m).
Cert 000565 cascade pin shifts:
- hot_water_kwh_per_yr: +271.84 → −68.96 (4× closer)
- sap_score_continuous: +0.6334 → +0.7732 (drift downstream of HW)
- ecf: −0.0643 → −0.0784 (drift)
- total_fuel_cost: −56.08 → −68.36 (drift)
- co2: −19.77 → −22.66 (drift)
- sap_score (int): 29 EXACT (unchanged)
- space_heating / main_heating_fuel / lighting / pumps_fans:
unchanged
The remaining −69 kWh HW residual is the gap between Table 29
defaults (H12 = 75 L separate tank) and cert 000565's lodged H12 =
53 L + combined cylinder 160 L. Closing this requires extracting
solar storage volume + combined-cylinder routing from the cert (P960
worksheet block lodges these explicitly; Summary doesn't). That's
the follow-on slice.
Test baseline: 547 pass + 9 expected `test_sap_result_pin[000565-*]`
fails preserved. Cohort-2 + ASHP cohort + all golden fixtures
untouched (no certs other than 000565 lodge `solar_water_heating =
True`).
Pyright net-zero on touched files (68 errors at baseline = 68 errors
post-change).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cert 000565 surfaced a per-extension Room(s) in Roof coverage gap.
§4 Dimensions lodges an RR floor area for every BP (Main + each
extension) and §8.1 lodges full construction details per BP. The
old extractor parsed RR from §4 + §8.1 for Main only — the 4
extensions' RR areas (34 + 5 + 32 + 2 = 73 m²) were silently
dropped, leaving TFA at 246.91 m² vs the worksheet's 319.91 m²
(23% deficit).
Schema:
- `ExtensionPart.room_in_roof: Optional[RoomInRoof] = None` field.
None for single-storey extensions (no RR lodged); populated for
every extension that lodges a §4 RR floor area > 0.
Extractor:
- `_room_in_roof_from_bodies(dim_body, rir_body, age_band)`
parameterises the previously Main-only `_extract_room_in_roof`
so the same parsing applies to each extension.
- `_extract_extensions` now slices §8.1 by BP (alongside the
existing §4/§7/§8/§9 slicing) and reads each extension's RR age
band from §3's "<N>th Ext. Room(s) in Roof <band>" line via a
new regex.
- A new defensive "§4 lodges RR area but §8.1 has no construction
details" branch returns a partial `RoomInRoof` with empty surfaces
so the cascade still attributes the floor area to TFA. (Not
triggered on 000565 — all 5 BPs lodge construction details — but
needed for older Elmhurst variants per the existing extractor
comment style.)
Mapper:
- `_map_elmhurst_building_parts` now passes each extension's
`room_in_roof` through `_map_elmhurst_room_in_roof` to the
extension's `SapBuildingPart.sap_room_in_roof`. Previously the
loop hardcoded the field as None.
- `total_floor_area_m2` derivation now also sums each extension's
`room_in_roof.floor_area_m2`. Without this, the per-BP RR floor
area is lodged on the BP but the cert's top-level TFA stays at
the pre-fix value.
Cert 000565 cascade impact:
- TFA: 246.91 → 319.91 ✓ (matches U985-0001-000565.pdf Block 1)
- space_heating_kwh_per_yr: Δ −9,107.71 → −1,099.50 (88% reduction)
- main_heating_fuel_kwh_per_yr: Δ −5,357.47 → −646.76 (88% reduction;
space_heating × 1/HP COP — main_heating tracks space_heating)
- lighting_kwh_per_yr: Δ −236.19 → +2.18 (essentially closed —
RdSAP §12-1 lighting is TFA-proportional)
- hot_water_kwh_per_yr: Δ +214.50 → +271.84
- co2_kg_per_yr: Δ −1,438.16 → −751.06
- total_fuel_cost_gbp: Δ −1,055.62 → −564.05
- sap_score_continuous: Δ +1.70 → +6.75 (cost/TFA dropped because
cost rose ~14% but TFA rose ~30% — the remaining −564 cost gap
has to close before SAP catches up)
Single-storey-extension certs: `room_in_roof=None` for each extension
(no §4 RR lodgement), no behavioural change. Cohort regression check:
415 pass + 10 expected 000565 fails — no regression on the 14 Summary
fixtures + JSON fixtures that don't carry per-extension RR.
Pyright net-zero on all 3 touched files (32 / 0 / 0).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cert 000565 lodges §14.1 Main Heating2 as PCDB 15100 (Vaillant Ecotec
plus 415, 88%, mains gas, 0% space heat) — this is the system that
services DHW via `Water Heating SapCode 914` ("from second main
system"). The previous extractor / mapper shape supported only ONE
main heating system, dropping Main 2 entirely.
New shape:
- `MainHeating2` dataclass (slim §14.1-shaped: PCDB ref, fuel type,
flue type, fan_assisted_flue, percentage_of_heat, SAP code)
- `MainHeating.main_heating_2: Optional[MainHeating2]` — None when
§14.1 is absent OR lodges only placeholder zeros (the PCDB-only
convention; the two JSON fixtures + 14 existing Summary fixtures
all lodge "0 / 0" for an absent Main 2)
- `_extract_main_heating_2` parses §14.1; returns None when neither
PCDB ref nor SAP code identifies Main 2
- `_map_elmhurst_main_heating_2` builds `MainHeatingDetail` from the
Main 2 lodgement with `main_heating_number=2` and `main_heating_
fraction=percentage_of_heat`; strict-raises `UnmappedElmhurstLabel`
(mirroring Slice S0380.53's Main 1 raise) when Main 2 has neither
identifier — surfaces coverage gaps at extraction time
Per RdSAP convention "0%" is lodged without a space (vs Main 1's
"100 %" with a space) — robust percentage parse via `rstrip("%")` so
both forms thread through.
Cohort impact:
- 14 existing Summary PDF fixtures + 2 JSON fixtures: Main 2 returns
None (placeholder zeros) → no 2nd MainHeatingDetail produced → no
cascade behaviour change (regression-tested: 415 pass + 10 expected
000565 fails, identical to S0380.53 baseline)
- Cert 000565: 2nd MainHeatingDetail now lodged with sap_code=None,
pcdb=15100 (Table 105 gas-boiler 88% efficiency), category=2,
fuel=26 (mains gas), fraction=0
Cascade still uses Main 1 for water-heating efficiency in the WHC
914 branch — that routing fix is the next slice. This commit is
the plumbing-only half; the SAP-result pin residuals are unchanged
at HEAD because the cascade hasn't been wired to read Main 2 yet.
Pyright net-zero on all 3 touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cert 000565 surfaced an Elmhurst extractor schema gap. §14.0 lodges
"Main Heating SAP Code 224" identifying Main 1 as an Air Source Heat
Pump (SAP 10.2 Table 4a row 224: "Air source heat pump, 2013 or
later") — but the extractor was dropping the line. The mapper
therefore produced a `MainHeatingDetail` with `sap_main_heating_code
= None` AND `main_heating_index_number = None` (because `PCDF boiler
Reference = 0` for HP certs), leaving the cascade to fall back to
the 0.80 gas-boiler default efficiency.
Cascade impact on cert 000565 main_heating_fuel_kwh_per_yr pin:
- Before: actual 62,375.80 kWh/yr (= 59,008 / 0.80 wrong default)
Δ +27,665.01 vs U985-0001-000565.pdf expected 34,710.79
- After: actual 29,353.32 kWh/yr (= 59,008 / 1.70 HP COP via §A4.1)
Δ −5,357.47 (remaining gap is on the space_heating side, not
heating efficiency)
The strict-raise mirrors [[unmapped-api-code]] (Slice S0380.51) and
[[unmapped-elmhurst-label]] (cylinder size / glazing type) — when
neither the §14.0 SAP code nor the PCDB boiler reference identifies
Main 1, the mapper raises `UnmappedElmhurstLabel("main_heating",
...)` so the coverage gap surfaces at extraction time instead of as
an opaque downstream SAP delta. Per user end-of-S0380.52 directive:
"if we're missing mapping on EpcPropertyDataMapper - let's raise an
exception".
Spec source: SAP 10.2 §A4 Appendix A "Heat pump cascade", Table 4a
row 224 (Air source heat pump, 2013 or later) — `seasonal_efficiency`
reads the SAP code when no PCDB Table 105/362 record overrides.
Touched:
- datatypes/epc/surveys/elmhurst_site_notes.py: `MainHeating.
main_heating_sap_code: Optional[int]` field added (treat 0 as None
per Elmhurst convention — PCDB-listed boilers lodge §14.0 SAP code
as 0 and identify themselves via the PCDB index instead)
- backend/documents_parser/elmhurst_extractor.py:
`_extract_main_heating` reads §14.0 "Main Heating SAP Code" via the
existing `_local_val` slice helper; 0/absent → None
- datatypes/epc/domain/mapper.py: `_map_elmhurst_sap_heating` passes
`sap_main_heating_code=mh.main_heating_sap_code` to
`MainHeatingDetail`, and raises `UnmappedElmhurstLabel` when
neither identifier resolves
Cohort regression check: 415 pass + 10 expected 000565 failures
(unchanged from S0380.52 — same pins, different residuals). Pyright
net-zero on all 3 touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per RdSAP10 §5.8 final note + Table 14 page 41:
"For drylining including laths and plaster use Rinsulation = 0.17 m²K/W."
Applied additively to the base U-value of an otherwise-uninsulated wall:
U_adjusted = 1 / (1/U_base + 0.17) — rounded to 2 d.p. half-up.
Closed form for the cohort fixture (cavity-as-built age C, U_base=1.5):
1 / (1/1.5 + 0.17) = 1.19522... → 1.20 ✓ matches worksheet
Cert 7700-3362-0922-7022-3563 (Summary_000905.pdf / dr87-0001-000905.pdf)
is an End-Terrace house age C lodging:
- Main wall: CavityWallDensePlasterDenseBlock, Filled Cavity, U=0.70
- Alt wall 1: 14.44 m² Cavity As-Built, Dry-lining: Yes (worksheet
`CavityWallPlasterOnDabsDenseBlock`, U=1.20)
Pre-slice the Elmhurst alt-wall mapper hard-coded `wall_dry_lined="N"`
and the cascade ignored the field everywhere — alt-wall U routed to the
cavity-as-built default (1.50), giving fabric (33) 148.72 W/K vs
worksheet 144.38 (Δ +4.33 W/K = ~+0.44 SAP). Worksheet "SAP value" line
lodges unrounded SAP 63.4425.
Implementation:
1. `AlternativeWall.dry_lined: bool = False` on the Elmhurst surveys
dataclass.
2. Elmhurst extractor reads "Alternative Wall N Dry-lining: Yes/No"
into the new field.
3. `_map_elmhurst_alternative_wall` propagates `wall_dry_lined="Y"`
instead of the hard-coded "N".
4. `u_wall` gains a `dry_lined: bool = False` kwarg and a single
§5.8 adjustment site at the as-built bucket (bucket=0). Insulated
buckets already absorb the dry-lining R via Table 14.
5. `_alt_wall_w_per_k` passes `dry_lined=alt_wall.wall_dry_lined == "Y"`.
Scope is the alt-wall path only — main BPs in the corpus all lodge
`wall_dry_lined="N"` (or the Summary PDF omits the field for the main
wall), so the main-wall call site is untouched. Conservative regression
posture per the user's strict cohort-pin convention.
Cohort-2 outcome (38 certs, Summary path):
exact (<1e-4): 22 → **23** (+1: cert 7700 -0.44 → +4.87e-05)
0.07..0.5: 1 → **0** (-1: cert 7700 closes out)
0.5..1: 1 → 1 (cert 9796 unchanged — MIT precision floor)
RAISES: 0 → 0
Cohort-1 ASHP cohort untouched: all certs lodge wall_dry_lined="N", so
the alt-wall call site short-circuits to the original cascade. Verified
no regressions across the 22 previously-exact cohort-2 certs either.
Pyright net-zero on all 8 touched files (183 → 183).
Tests: 704 → 708 pass (+4 new: u_wall §5.8 adjustment fires
correctly; cavity-as-built unchanged without flag; insulated bucket
unaffected by flag; heat_transmission alt-wall delta = 14.44 × 0.30
W/K; cert 7700 full chain hits worksheet 63.4425 at < 1e-4),
10 expected fails unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RdSAP 10 specification page 60 §11.1 b) (Photovoltaics): "If the kWp
(or DNC) is not known use the following: PV area is roof area for
heat loss (before amendment for any room-in-roof), times percent of
roof area covered by PVs, and if pitched roof divided by cos(35°).
If there is an extension, the roof area is adjusted by the cosine
factor only for those parts having a pitched roof. kWp is 0.12 ×
PV area. If not provided in the RdSAP data set then facing South,
pitch 30°, modest overshading."
Wire-through:
1. `Renewables.pv_percent_roof_area: Optional[int]` — new field on
the Elmhurst site-notes dataclass.
2. Elmhurst extractor `_extract_renewables` parses Summary §19.0
row "Proportion of roof area" (cert 6835: "40").
3. Elmhurst mapper `from_elmhurst_site_notes` surfaces it through
`epc.sap_energy_source.photovoltaic_supply.none_or_no_details
.percent_roof_area` — mirrors the API mapper's lodgement shape.
4. `cert_to_inputs._synthesize_pv_arrays_from_percent_roof_area`
synthesizes a single PV array via the spec formula when
`photovoltaic_arrays` is empty AND a `percent_roof_area > 0`
lodgement is present. Fires inside
`_pv_generation_kwh_per_yr`, so both rating + demand cascades
pick it up.
Cohort-2 outcome (38 certs, Summary path):
exact (<1e-4): 20 → 20
±0.07..0.5: 1 → 1
±0.5..1: 1 → **2** (cert 6835 closes -13.37 → +0.72)
±1..5: 1 → 1
±5+: 2 → **1** (-1: cert 6835 moves out of big-gap band)
Cert 6835 verified end-to-end:
- kWp = 0.12 × 36.9 × 0.40 / cos(35°) = 2.1622
(worksheet "Cells Peak = 2.16, Orientation = South, Elevation =
30°, Overshading = Modest")
- Cascade PV generation = 1493.88 kWh/yr vs worksheet 1492.33
(<0.1% delta — kWp-rounding artefact).
- Cascade SAP 80.92 vs worksheet 80.20 (+0.72, in the ±0.5..1 band).
The residual +0.72 likely traces to the PV-cost cascade's
used-in-dwelling / exported split rather than the synthesis — the
kWh figure is within rounding of the worksheet.
Pyright per-file: net-zero
- cert_to_inputs.py 35 → 35
- test_cert_to_inputs.py 13 → 13
- mapper.py 32 → 32
- elmhurst_site_notes.py 0 → 0
- elmhurst_extractor.py 0 → 0
Tests: 702 → 703 pass (+1 new RdSAP §11.1 b synthesis test), 10
expected fails unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Refactors Elmhurst `Renewables` PV detail from four scalar fields
(pv_peak_power_kw / pv_orientation / pv_elevation_deg / pv_overshading
— single-array shape) to `pv_arrays: List[ElmhurstPvArray]`, then
walks the §19.0 PV Panel block in 4-tuples so dwellings with multiple
PV arrays surface every array.
Forced by cert 0350-2968-2650-2796-5255 (Summary_000903.pdf), the
second ASHP cohort cert through the Summary path and first to lodge
multiple PV arrays — the dr87 worksheet pins 2 arrays at 1.50 kWp
each (one SE at 45°, one NW at 45°). Pre-slice the extractor's
hardcoded "break at len(values) == 4" capped output at one array
regardless of how many the PDF lodged.
Three-layer end-to-end change:
1. `datatypes/epc/surveys/elmhurst_site_notes.py` — add
`ElmhurstPvArray` dataclass (kw, orientation, elevation_deg,
overshading); replace four `Renewables.pv_*` scalars with
`pv_arrays: List[ElmhurstPvArray] = field(default_factory=list)`.
2. `backend/documents_parser/elmhurst_extractor.py` — rename
`_extract_pv_array_detail` → `_extract_pv_arrays`; walk values
after the "Photovoltaic panel details" anchor in 4-tuples until a
stop token ("batteries"/"export"/etc.) or a §-header closes the
block. §-header regex tightened to `\d{1,2}\.\d\s+\w` so kWp
values like "1.50" don't trip the close (without the `\s+\w` the
regex matched both "20.0 Wind Turbine" AND "1.50").
3. `datatypes/epc/domain/mapper.py` — `_elmhurst_pv_arrays` iterates
the list and emits one `PhotovoltaicArray` per row; collapses
empty list → None so the cascade keeps its no-PV fallback.
Forcing function: cert 0350 first-attempt Summary SAP closes from
Δ -4.5829 (Slice 8 baseline) to Δ **+0.0458** — within the ±0.07
ASHP-cohort spec-precision floor. PV export credit GBP moves from
158.91 (one array surfaced) to 265.99 (both arrays surfaced) — the
extra ~107 GBP of avoided cost lifts cert 0350's SAP by ~4.6 points.
This validates the structural-debt-amortizes hypothesis: cert 0350
needed only TWO new slices (S0380.8 inheritance + S0380.9 multi-PV)
beyond the cert 0380 closure work, vs cert 0380's 6 slices from
scratch. Subsequent cohort certs should converge similarly fast as
fixture-specific gaps are paid down.
Added two tests:
- `test_summary_0350_surfaces_two_pv_arrays` — unit test pinning
the multi-array contract on the mapper boundary.
- `test_summary_0350_full_chain_sap_within_spec_floor_of_worksheet`
— chain test pinning Δ < ±0.07 (matches cert 0380's chain test).
Cert 0380 (single-array, 3 kWp) continues to pass its chain test +
all 6 unit-level pins — the refactor preserves single-array behaviour.
Pyright net-zero across all four edited files:
datatypes/epc/domain/mapper.py: 32 (baseline)
datatypes/epc/surveys/elmhurst_site_notes.py: 0
backend/documents_parser/elmhurst_extractor.py: 0
backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0
Regression suite: 677 pass + 10 fail (= handover baseline 669 + 10
+ 8 new GREEN unit+chain tests across Slices S0380.2..S0380.9).
Fixtures added: `backend/documents_parser/tests/fixtures/Summary_
000903.pdf` (copied from `sap worksheets/Additional data with api/
0350-2968-2650-2796-5255/`).
Spec refs:
- SAP 10.2 Appendix M (PDF p.103) — multiple PV arrays sum to total
electricity generation per Equation M-1 (each array's surface flux
computed independently per Appendix U3.3).
- SAP 10.2 Appendix U3.3 (PDF p.124) — per-array surface flux keyed
on orientation + tilt + overshading.
- Cert 0350 worksheet `dr87-0001-000903.pdf` (29a Main 19.4575 W/K
+ Ext1 1.3025 W/K = 20.7600 ≡ Summary cascade walls_w_per_k; (39)
avg HTC 173.4202 ≡ Summary cascade; (64) HW 2084.66 ÷ (216) HW eff
1.7285 = 1206.04 ≡ Summary cascade hot_water_kwh_per_yr).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the entire §15.1 Hot Water Cylinder lodging end-to-end and
collapses cert 0380's Summary path to the API path at the documented
HP-cohort spec-precision floor: SAP **88.5698 (Δ +0.0594)** — exactly
matching the API path's spec-floor closure. `hot_water_kwh_per_yr`
hits **878.0519** vs worksheet (64) 1502.16 ÷ (216) HW eff 1.7107 =
**878.05** — exact match at 1e-4.
Four §15.1 fields surfaced together (the cascade requires all four in
combination to compute the worksheet-correct HP HW path):
1. `cylinder_size_label` (Summary "Medium" → SAP10 cascade enum 3 =
160 L per `_CYLINDER_SIZE_CODE_TO_LITRES`)
2. `cylinder_insulation_label` (Summary "Foam" → cascade enum 1 =
factory, per SAP 10.2 Table 2 Note 2)
3. `cylinder_insulation_thickness_mm` (Summary "50 mm" → 50)
4. `cylinder_thermostat` (Summary "Yes" → bool True → mapper emits 'Y'
for the cascade's `sh.cylinder_thermostat == "Y"` string compare)
Why all four were required:
- `_cylinder_storage_loss_override` in `cert_to_inputs.py:2238-2253`
gates on `cylinder_size`, `cylinder_insulation_type ==
_CYLINDER_INSULATION_TYPE_FACTORY (1)`, AND
`cylinder_insulation_thickness_mm`. Missing any → no override →
zero storage loss (62)m miscalculated.
- `cylinder_thermostat` keys the SAP 10.2 Table 2b temperature factor
(53): with-stat 0.5400 vs no-stat ~0.9 → without 'Y' storage loss
over-counts by ~300 kWh/yr (the precise diff between the bundled-
fields-only attempt at SAP 86.5 vs the fully-bundled attempt at
SAP 88.57).
Three-layer end-to-end change:
1. `datatypes/epc/surveys/elmhurst_site_notes.py` — add four
defaulted `WaterHeating` fields (placed in the defaulted block;
existing fixtures that omit §15.1 still construct unchanged).
2. `backend/documents_parser/elmhurst_extractor.py` — extend
`_extract_water_heating` to read the §15.1 block via
`_section_lines("15.1 Hot Water Cylinder", "15.2 Community Hot
Water")` + `_local_val`. Section-scoping is required because the
"Insulation Thickness" label collides with §7 Walls / §8 Roofs /
§9 Floors lodgings on the same Summary PDF (cert 0380 has §7
"Insulation Thickness 100 mm" for the FE wall — the global
`_next_val` would return the wrong value).
3. `datatypes/epc/domain/mapper.py` — add
`_elmhurst_cylinder_size_code` + `_elmhurst_cylinder_insulation_code`
label-to-enum helpers; replace the broken
`cylinder_size = water_heating.water_heating_code` (which was
passing the §15 "Water Heating Code" string "HWP" into the
numeric `cylinder_size` field, defeating the cascade) with the
real `cylinder_size_label`-derived enum.
Pre-Slice 6, the Summary path was producing `cylinder_size='HWP'`
which `_int_or_none` reduced to None, silently routing the cascade
off the HP-with-cylinder HW path entirely. Surfacing the §15.1
block in full lets `_heat_pump_apm_efficiencies` use the spec-
correct HW efficiency (1.7107) and `_cylinder_storage_loss_override`
contribute the spec-correct (56) 435 kWh/yr storage loss.
Pyright net-zero across all four edited files:
datatypes/epc/domain/mapper.py: 32 (baseline)
datatypes/epc/surveys/elmhurst_site_notes.py: 0
backend/documents_parser/elmhurst_extractor.py: 0
backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0
Regression suite: 674 pass + 11 fail (vs handover baseline 669 + 10
— net +5 pass for the new GREEN unit tests S0380.2..S0380.6; the +1
fail vs baseline is still S0380.1's chain test which pins at 1e-4 vs
worksheet 88.5104 and now lands at Δ +0.0594, the same Appendix N3.6
PSR-interpolation precision floor that the API path closes to and
that the cohort's 7 ASHP fixtures already track at ±0.07).
Tolerance disposition: the +0.0594 residual is identical to the
cohort's documented HP-path precision floor. Closing further requires
work on the calculator's Appendix N3.6 PSR interpolation step
(boilers already match worksheet at 1e-4 via the same cascade —
ground-truthed in closed-boiler precedents 001479, 0330), not on
the Summary mapper. The S0380.1 chain test should be re-pinned to
the ±0.07 ASHP-cohort tolerance in the next slice — same disposition
the API-path cohort received in slice 102f (commit c0086660).
Spec refs:
- SAP 10.2 §4 Table 2 (PDF p.135) — cylinder storage loss factor
for foam-insulated cylinders (51) keyed on insulation thickness.
- SAP 10.2 §4 Table 2a (PDF p.135) — cylinder volume factor (52).
- SAP 10.2 §4 Table 2b (PDF p.135) — cylinder temperature factor
(53) keyed on cylinder thermostat + separately-timed DHW.
- SAP 10.2 Appendix N3.7(a) (PDF p.6097) — HP HW in-use factor
cylinder-criteria, footnote 53 (cert HX area unknown for Open EPC
schema → criteria fail → 0.60 in-use factor; the worksheet's
closed HW path uses this same factor).
- Cert 0380 worksheet `dr87-0001-000899.pdf` lodgings:
(47) Cylinder Volume 160.00 L; "Cylinder Insulation Type Foam";
"Cylinder Insulation Thickness 50 mm"; "Cylinder Stat Yes";
(51)..(56) cylinder storage loss chain; (64) HW output 1502.16;
(216) HW efficiency 171.0746%.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the three-layer gap that left the Summary mapper producing
`insulated_door_u_value=None` even though Summary §10 lodges
"Average U-value" / "1.20" explicitly on cert 0380:
1. `datatypes/epc/surveys/elmhurst_site_notes.py` — add
`ElmhurstSiteNotes.insulated_door_u_value: Optional[float] = None`,
placed in the defaulted-field block so existing fixtures that
omit the field still construct without changes.
2. `backend/documents_parser/elmhurst_extractor.py` — add
`_extract_door_u_value` that section-scopes the lookup to
`_section_lines("10.0 Doors:", "11.0 Windows:")` so the bare
"Average U-value" label cannot be shadowed by global U-value
lookups in §7 Walls / §8 Roofs / §9 Floors.
3. `datatypes/epc/domain/mapper.py` — surface
`insulated_door_u_value=survey.insulated_door_u_value` on the
`from_elmhurst_site_notes` path. The comment in
`epc_property_data.py:585` ("Not available in site notes") is now
outdated for Elmhurst Summary PDFs that lodge the explicit value.
Worksheet anchor (dr87-0001-000899.pdf line ref (26)):
Doors insulated 1 NetArea 3.7000 U-value 1.2000 A×U 4.4400 W/K
Forcing function (Slice S0380.1): cert 0380 Summary cascade
`doors_w_per_k` moves from 5.1800 to **4.4400 W/K — exact match
against worksheet line ref (26)**. The +0.74 W/K mis-attribution
was the default door-U fall-through that the lodged 1.20 value
silences. SAP moves 88.1981 (Δ -0.3123) → 88.2746 (Δ -0.2358).
Added focused unit test
`test_summary_0380_surfaces_insulated_door_u_value_1_2` that pins
the mapper boundary directly to the worksheet's lodged U-value 1.2,
so future debuggers can localise regressions in the new extractor /
field / mapper path before walking the full chain.
Pyright net-zero across all four edited files:
datatypes/epc/domain/mapper.py: 32 (baseline)
datatypes/epc/surveys/elmhurst_site_notes.py: 0
backend/documents_parser/elmhurst_extractor.py: 0
backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0
Regression suite: 673 pass + 11 fail (vs handover baseline 669 + 10
— net +4 pass for the four GREEN unit tests across Slices S0380.2-5;
the +1 fail vs baseline is the S0380.1 chain test which this slice
moves to Δ -0.2358 but does not yet fully close).
Spec refs:
- SAP 10.2 Table 14 (door U-values: composite-construction default
cascade is silenced when the assessor lodges an explicit measured
U on the cert; routed via `insulated_door_u_value`).
- Cert 0380 worksheet dr87-0001-000899.pdf line ref (26) — the
A×U=4.4400 W/K spec value that this slice closes the Summary
cascade to exactly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the three-layer gap that left the Summary mapper producing
`wall_insulation_thickness=None` even though Summary §7.0 lodges
"Insulation Thickness" / "100 mm" explicitly on cert 0380. Three
small co-ordinated edits ship the field end-to-end:
1. `datatypes/epc/surveys/elmhurst_site_notes.py` — add
`WallDetails.insulation_thickness_mm: Optional[int] = None`,
mirroring the existing `RoofDetails.insulation_thickness_mm`.
2. `backend/documents_parser/elmhurst_extractor.py` — extend
`_wall_details_from_lines` to read the `_local_val(lines,
"Insulation Thickness")` label inside the §7 Walls block (the
"Insulation Thickness" label is local-scoped per block, so it
does not collide with §8 Roofs / §9 Floors).
3. `datatypes/epc/domain/mapper.py` — surface
`wall_insulation_thickness=f"{walls.insulation_thickness_mm}mm"`
on `SapBuildingPart`. Mirrors the API mapper's string-with-unit
shape (`'100mm'`) so cert-to-cert parity tests (Summary EPC ≡
API EPC) compare equal; the cascade's `_parse_thickness_mm`
accepts either form.
Forcing function (Slice S0380.1): cert 0380 Summary cascade SAP
moves from 86.8671 (Δ -1.6433 — i.e. after Slice S0380.3 only) to
88.1981 (Δ -0.3123) — closes ~81% of the remaining gap. Critically,
`walls_w_per_k` now hits API parity exactly (Summary 11.6150 ≡ API
11.6150) — the composite filled-cavity-plus-external U-value calc
is now keyed off the lodged 100 mm thickness rather than its
internal default.
Residual -0.31 SAP vs worksheet is comparable to the documented HP
cohort's API-path residual of +0.06 (cert 0380 API path closes at
+0.0594). Summary path is now within ±0.37 of API path. Remaining
diffs to investigate (per the next-step diagnostic): hot-water
cascade (Summary 1002.74 kWh vs API 878.05 kWh, +124.69 kWh), HLC
parameters (heat_transfer_coefficient still differs slightly through
secondary terms), and possibly secondary-heating routing. The
worksheet vs API +0.06 residual is the documented Appendix N3.6
PSR-interpolation precision floor and out of scope for Summary-path
closure.
Added focused unit test
`test_summary_0380_surfaces_wall_insulation_thickness_100mm` that
pins the mapper boundary directly (Summary "100 mm" line pair →
EPC `wall_insulation_thickness="100mm"`), so future debuggers can
localise regressions in the new extractor / field / mapper path
before walking the full chain.
Pyright net-zero across all four edited files:
datatypes/epc/domain/mapper.py: 32 (baseline)
datatypes/epc/surveys/elmhurst_site_notes.py: 0
backend/documents_parser/elmhurst_extractor.py: 0
backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0
Regression suite: 672 pass + 11 fail (vs handover baseline 669 + 10
— net +3 pass for the three Slices S0380.2-4 GREEN unit tests; the
+1 fail vs baseline is still the S0380.1 chain test which this slice
moves from Δ -1.6433 to Δ -0.3123 but does not yet fully close).
Spec refs:
- SAP 10.2 §3.7 / Appendix S Table S5 (composite filled-cavity-plus-
external U-value calc — series-resistance form keyed off lodged
insulation thickness)
- Cert 0380 Summary PDF §7.0 lines 121-122 ("Insulation Thickness"
/ "100 mm" — the missing extractor read this slice adds)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cert 9501 lodges measured PV: 2.36 kWp South-West, 45° pitch, "None
Or Little" overshading. The worksheet's §10a credit (-250.02 GBP =
PV used in dwelling £-129.49 + PV exported £-120.53) depends on the
Appendix M / Appendix U3.3 cascade reading these from
`SapEnergySource.photovoltaic_arrays`. The prior extractor only
captured the `photovoltaic_panel: "Panel details"` label — the
actual kW / orientation / elevation / overshading were silently
dropped, so the cascade computed total cost ~£250 too high → ECF
2.92 vs worksheet 2.26 → SAP 59.26 vs 68.53 (Δ -9.27).
Changes:
- Extend `surveys.elmhurst_site_notes.Renewables` with 4 new
optional fields: pv_peak_power_kw / pv_orientation /
pv_elevation_deg / pv_overshading.
- Add `ElmhurstSiteNotesExtractor._extract_pv_array_detail` —
anchors on "Photovoltaic panel details" then reads the 4
consecutive value lines (kWp, orientation, elevation, overshading).
- Add `_elmhurst_pv_arrays` mapper helper to build the
`[PhotovoltaicArray(...)]` list when all 4 values are present;
return None for the "PV absent" path the cascade already handles.
- Add `_ELMHURST_PV_OVERSHADING_TO_RDSAP` map: "None Or Little" → 1
(ZPV=1.0 per cert_to_inputs._PV_OVERSHADING_FACTOR), "Modest" →
2, "Significant" → 3, "Heavy" → 4. RdSAP omits SAP10.2 Table M1's
5th "Severe" bucket.
- Wire `photovoltaic_arrays=_elmhurst_pv_arrays(survey.renewables)`
into `from_elmhurst_site_notes`'s `SapEnergySource(...)` call.
Effect on cert 9501 Summary path:
- sap_continuous 59.2585 → 68.7577 (target 68.5252; Δ +0.23)
- total_fuel_cost £1099 → £843 (worksheet £849; -£6 over-credit)
- ECF 2.92 → 2.24 (worksheet 2.26; -0.02 over-credit)
The remaining +0.23 SAP / +£6 cost drift is a precision gap in the
Appendix M cost-offset cascade for measured PV (not a missing-data
gap); next slice closes it to 1e-4.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three extensions closing the last 0.05 SAP residual on 000487 — and
with it, all 6 Elmhurst Summary PDFs match their U985 worksheets to
1e-4 unrounded SAP.
1. Alternative-wall extraction. `WallDetails` gains an
`alternative_walls: List[AlternativeWall]` field; the extractor
parses §7's "Alternative Wall N Area / Type / Insulation /
Thickness / Thickness Unknown / U-value Known" prefixed labels.
Even when an extension lodges "As Main Wall: Yes" we still pull
alt walls from the extension's own subsection (they don't
inherit) — the main wall fields are merged with the extension's
alt-wall list.
2. Alt-wall mapper plumbing. `_map_elmhurst_alternative_wall` builds
a `SapAlternativeWall` per lodged Elmhurst entry; the building-
part mapper attaches up to two via `sap_alternative_wall_1/_2`
per `SapBuildingPart`. When the surveyor flags `Thickness
Unknown: Yes` (cohort's only example — 000487 Ext1's
"TimberWallOneLayer" entry) we route the cascade with
thickness=None so `u_wall` falls through to the age-band-and-
construction default — Timber Frame age B uninsulated → U=1.9,
matching the full-cert-text U=1.90 the handbuilt fixture lodges
for the same 9-mm thin timber wall.
3. "TI" wall-construction code mapping. The §7 "Alternative Wall 1
Type: TI Timber Frame" uses leading code "TI" rather than the
"TF" code seen on the primary wall types — both alias to SAP10
wall_construction=5 (Timber Frame).
Final cohort state — all 6 closed at 1e-4:
000474 0.0000 ✓ Slice 47
000477 0.0000 ✓ Slice 52
000480 0.0000 ✓ Slice 50
000487 0.0000 ✓ THIS SLICE
000490 0.0000 ✓ Slice 49
000516 0.0000 ✓ Slice 51
758 tests pass; pyright net-zero (35 baseline).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four mapper extensions, validated by 000480 closing to 1e-4 and large
gap reductions across 000477/000487/000516.
1. Room-in-Roof support. `ElmhurstSiteNotes` gains `RoomInRoof` +
`RoomInRoofSurface` dataclasses; extractor parses §8.1 (Flat
Ceiling / Stud Wall / Slope / Gable Wall / Common Wall) with
Length × Height + insulation + gable-type + measured-U cells.
Mapper produces a `SapRoomInRoof` with `detailed_surfaces`
attached to the Main bp: Stud Walls / Slopes / Flat Ceilings
route through Table 17 insulation thickness; Gable Walls split
between `gable_wall` (Party → Table 4 U=0.25) and
`gable_wall_external` (Sheltered → assessor-lodged U-value
override, e.g. 000487 Gable Wall 2 at U=0.86). Empty surfaces
(0×0 — the cohort lodges a full 5-pair table) and Common Walls
(handled by cascade's Simplified Type 2 geometry) are dropped.
`total_floor_area_m2` now includes the RR floor area.
2. Party-wall construction mapping. 000516 lodges "S Solid masonry /
timber / system build" which routes to SAP10 wall_construction=3
(Solid Brick → U=0.0 via Table 4). The previous mapper used the
same wall-type table as `wall_construction`, which lacked the
"S" code and fell through to None (cascade default 0.25). Split
into a dedicated `_elmhurst_party_wall_construction_int` keyed
on the party-wall category codes.
3. Roof "None" insulation. When the §8.0 Roofs subsection lodges
"Insulation N None" without a separate "Insulation Thickness"
line, treat thickness as 0 mm so the cascade picks Table 16
row 0 (U=2.30) rather than the age-band default. Closes the
29 W/K roof-loss gap on 000516.
4. `number_baths` lodgement. `SapHeating.number_baths` now reads
`survey.baths_and_showers.number_of_baths`. The cascade defaults
`None → has-bath` for the modal UK case, but explicit `0` lodged
on 000477/000480 (bathless dwellings, rare) drops the bath HW
demand line per Table 1b. Closes 000480's last ~0.3 SAP gap.
Cohort state after this slice (target 1e-4):
000474 0.0000 ✓ Slice 47
000477 +1.1161 Elmhurst floor_ach quirk (true vs false despite
"T Suspended timber" lodged on all certs)
000480 0.0000 ✓ THIS SLICE
000487 +1.1844 extractor still drops most §11 windows on this
layout variant
000490 0.0000 ✓ Slice 49
000516 +0.1774 roof-window separation by U-value heuristic
3/6 certs now closed at 1e-4. Pyright net-zero (35 baseline). Tests
756 pass (added `test_summary_000480_full_chain_sap_matches_worksheet_
pdf_exactly`).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two mapper extensions, both validated by 000490 closing to 1e-4:
1. Secondary heating extraction. Elmhurst Summary PDFs lodge the
secondary heating SAP code in the §14.1 Main Heating2 sub-section
(between "14.1 Main Heating2" and "14.1 Community Heating") — not
in the §14.0 Main Heating1 block where the main system lives.
`ElmhurstMainHeating` gains a `secondary_heating_sap_code` field;
the extractor reads it from the right section; the mapper threads
it through to `SapHeating.secondary_heating_type`. The cascade
then applies Table 11's 10% secondary fraction.
2. Sheltered-sides derivation per RdSAP §S5. The Summary PDF doesn't
lodge per-dwelling sheltered-sides; the value is derived from
built-form (Detached=0, Semi-Detached=1, End-Terrace=1, Mid-
Terrace=2, Enclosed Mid-Terrace=3, Enclosed End-Terrace=2).
`_map_elmhurst_ventilation` now takes built_form and populates
`SapVentilation.sheltered_sides`. The table is cross-checked
against U985-0001-NNNNNN.pdf line (19) across the 6 worksheet
fixtures.
Cohort SAP deltas after this slice (target 1e-4):
000474 0.0000 ✓ Slice 47
000477 +2.6555 diagnosis pending (lighting bulb count diff)
000480 +4.1955 diagnosis pending
000487 +4.4553 extractor still drops most windows
000490 0.0000 ✓ THIS SLICE
000516 +1.5162 roof-window separation
Pyright net-zero on touched files (35 errors, same baseline). 755
tests pass (up from 754 — new `test_summary_000490_full_chain_sap_
matches_worksheet_pdf_exactly`).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ElmhurstSiteNotes had no representation for extensions: singular dimensions / walls / roof / floor fields could only describe the main bp. Summary PDFs lodge "1st Extension" / "2nd Extension" subsections in §4, §7, §8, §9 with optional "As Main: Yes" inheritance. This slice:
- Adds `ExtensionPart` dataclass and `ElmhurstSiteNotes.extensions: List[ExtensionPart]`.
- Adds `_split_section_by_bp` helper + per-bp parsing of dimensions / walls / roof / floor in the extractor; "As Main" inherits from the main bp.
- Refactors `_map_elmhurst_building_part` into a parameterised builder; adds `_map_elmhurst_building_parts` that yields Main + one SapBuildingPart per extension (capped at 4 per RdSAP10 §1.2).
- Scaffold test `test_summary_000474_mapper_produces_three_building_parts` flips from strict-xfail to passing.
Single-bp behaviour is unchanged (empty extensions list defaults). 752 existing tests stay green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>