The API `floor_heat_loss` code is authoritative — confirmed by joining each
single-BP cert's code to its independent `floors[].description` (which the
gov register publishes alongside the code):
code 1 ↔ "To external air" (exposed, 9/9)
code 2 ↔ "To unheated space" (semi-exposed, 6/6)
code 3 ↔ "(other premises below)" (partially htd, 9/9)
code 6 ↔ "(another dwelling below)" (party, 176/176)
code 7 ↔ "Solid"/"Suspended …" (ground, all)
Code 3 was mis-mapped to "To unheated space" (semi-exposed) and, on
mid-/top-floor flats, had its floor area zeroed entirely by the
dwelling-level exposure heuristic. RdSAP 10 §3.12 (PDF p.25) classes a
flat's floor over non-domestic "other premises … heated, but at different
times" as "above a partially heated space" → the §5.14 (PDF p.47) constant
U=0.7 W/m²K — distinct from semi-exposed (Table 20) and party (no loss).
Fix: the mapper sets `is_above_partially_heated_space` on the floor=0
dimension for code 3 (string → "(other premises below)" for fidelity), and
the heat-transmission step lets that per-BP lodgement override the flat
suppression upward (mirroring the existing exposed / "another dwelling
below" overrides). The cascade already routes is_above_partial → U=0.7.
Re-pins golden cert 7536-3827: its Ext2 (bp3) lodges code 3, but the cert's
lossy `floors[]` summary dropped that description, so a prior agent guessed
"code 3 = ground" (U=1.12) and concluded the residual was an irreducible
"register-rounding" artifact. It was this bug: Ext2 floor U 1.12 → 0.70,
PE -6.1952 → -5.6414, CO2 -0.1639 → -0.1492 (both toward 0), SAP unchanged.
Eval: 909 computed, 45.1% → 45.3% within 0.5, mean|err| 1.702 → 1.659,
<1.0 59.5% → 60.2%. 13 code-3 certs improve (0380 +3.71 → -0.63, 0350
+7.82 → +0.83, 2610 +7.47 → -1.29); the few that overshoot were already
failing and carry independent fabric bugs (9763's walls = 8 W/K for 60 m²).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
API floor_heat_loss=8 is observed on EXTENSION building parts whose
floor sits over a heated space within the SAME dwelling (an upper-storey
extension over a heated room). RdSAP 10 §3 gives an internal floor
between heated storeys no floor heat loss — mechanically identical to a
code-6 party floor. `_api_floor_type_str` had no entry for 8, raising
UnmappedApiCode and blocking certs 0370-2254-6520-2426-5971 and
0997-1206-9806-0715-2904.
Map code 8 to the code-6 no-heat-loss string "(another dwelling below)"
(consumed by heat_transmission's party-floor suppression; != "Ground
floor" so the §5 (12) suspended-timber rule stays inert). Empirically
confirmed against both certs: the no-heat-loss treatment lands them
within 0.5 of lodged (0370-2254 68.92 vs 69; 0997-1206 40.68 vs 41),
whereas Ground-floor / unheated / external mappings miss 0997 by ~4 SAP.
Eval computed 906→908. Regression green (only the pre-existing
test_total_floor_area fails); pyright net-zero (38=38).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A floor lodged API floor_heat_loss=6 ("another dwelling below") sits over
another heated dwelling, so it is a party floor with no heat loss (RdSAP
10 §3). The mapper mapped code 6 → None and the heat-transmission step
drove floor exposure solely from the dwelling-level `has_exposed_floor`
flag — which is keyed only on the dwelling_type label and defaults a
"Ground-floor flat" to an exposed floor. So a ground-floor flat above a
basement dwelling kept its full ground-floor heat-loss area.
Map code 6 → "(another dwelling below)" (still != "Ground floor", so the
§5 (12) suspended-timber rule stays inert) and have the cascade suppress
that BP's floor when its floor_type carries the signal, mirroring the
roof's existing "another dwelling above" per-BP party override.
Cert 2115-4121-4711-9361-3686 (ground-floor flat, floor_heat_loss=6):
floor_w_per_k 47.85 → 0; SAP -23.44 → -4.41. Cert 0350-…-6435 -12.38 →
-0.55; 0926-…-9024 -2.35 → -0.82. Eval mean |err| 1.982 → 1.944.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Summary §14.0 Table 4b gas boiler (SAP code 101-119) lodges no §14.0
"Fuel Type" string in the newer Elmhurst export. The carrier was resolved
only from §15.0 "Water Heating Fuel Type" — fine when the same boiler
heats the water, but a gas boiler paired with a SEPARATE electric
immersion lodges §15.0 "Electricity", so `_elmhurst_gas_boiler_main_fuel`
returned None and the cascade strict-raised MissingMainFuelType.
Cert 001431 boiler-1/boiler-2 "before" variants are exactly this config:
§14.0 SAP code 102/104 (mains-gas boiler), §15.0 electric immersion
(code 909), §14.2 Meters "Main gas: Yes". The meter flag is the
authoritative carrier signal — a 101-119 boiler on mains gas burns mains
gas — so adopt it (SAP10 main_fuel 26 per _ELMHURST_MAIN_FUEL_TO_SAP10
"Mains gas") when §15.0 can't disambiguate. §15.0 gas/LPG still wins when
present (keeps LPG-vs-mains-gas precision); no mains-gas meter + non-gas
§15.0 still strict-raises rather than guessing.
Spec: SAP 10.2 Table 4b "Seasonal efficiency for gas and liquid fuel
boilers" (PDF p.168), rows 101-119. Both certs now resolve main_fuel=26
and compute (was: hard raise).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A "Pitched, sloping ceiling" (roof_construction == 8) lodges its
insulation in the dedicated `sloping_ceiling_insulation_thickness` field,
not `roof_insulation_thickness` (which stays None — the loft-joist field
is meaningless for a slope-following ceiling). The schema dataclasses
dropped that field, so `from_dict` discarded it and the cascade treated
the slope as uninsulated; worse, the pre-1950 None-fallback forced 0 mm
(U=2.30), over-stating roof heat loss ~74%.
Surface the field on SapBuildingPart (schemas 21.0.0 / 21.0.1) and prefer
it in `_api_resolve_sloping_ceiling_thickness` when it carries a NUMERIC
thickness: "100mm" now reaches Table 17 column (1a) "Insulated slope –
sloping ceiling, mineral wool/EPS" (RdSAP 10 §5.11.3 p.44 — 100 mm →
U=0.40) instead of 2.30. Categorical lodgements ("AB" As Built / "NI")
are not measured thicknesses, so they fall through to the existing
as-built rule (Table 18 col (3) via is_pitched_sloping_ceiling).
Cert 9884-3059-9202-7506 (code 8, age B, sloping 100 mm): SAP −5.54 → +0.06.
Cert 8036-2925-6600-0202: −4.94 → +1.55. No regressions in the roof-8
cohort (the "AB" certs are unchanged). Eval headline 43.8% → 44.3% within
0.5; golden fixtures incl. 6035 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 2026 API sample raised UnmappedApiCode on `gable_wall_type` 2 (10 certs)
and 3 (4 certs) — the two RR gable variants beyond Party(0)/Exposed(1).
Sim case 21 (an Elmhurst replica of API cert 2818-3053-3203-2655-9204:
gable_wall_type_1=2, gable_wall_type_2=3) lodges them as "Sheltered" and
"Connected", confirming **2=Sheltered, 3=Connected**.
- Mapper: `_API_TYPE_1_GABLE_TYPE_TO_KIND` gains 2 → `gable_wall_sheltered`,
3 → `connected_wall` (U=0, area deducts — already handled).
- Calculator: new `gable_wall_sheltered` branch. The API path lodges no
per-gable U, so the cascade DERIVES it as RdSAP 10 Table 4 (p.22)
Sheltered = 1/(1/U_wall + 0.5) — back-solved + validated against case 21
(U_wall 1.10 → 0.71) and case 20 (1.70 → 0.92). A lodged U (Summary path)
still rides through as an override.
API sample: 14 raises clear → `computed` 882 → 896, `raise:ValueError` 16 → 2.
Summary path unchanged (Sheltered stays `gable_wall_external` + lodged U, so
cert 000487's hand-built fixture is untouched). 2861 pass (lone
test_total_floor_area pre-existing); pyright strict net-zero (32=32 / 12=12).
NOTE: the derived Sheltered U on cert 2818 lands at 0.92 not 0.71 because the
cascade computes its 440 mm solid-brick wall U as 1.70 (the 220 mm default) —
a SEPARATE wall-U-vs-thickness bug (next slice, validated by case 21's 1.10).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Detailed room-in-roof lodges "Stud Wall" surfaces, but the cascade billed
every one through Table 17 from its insulation — over-counting fabric on
internal studs that carry no heat loss. sim case 20's two studs lodge §8.1
Default U-value 0.00 and the P960 worksheet omits them from BOTH fabric heat
loss (§3: (33)=285.9847) and total exposed area (31)=239.68; the cascade
computed ~0.52 each → (33) +4.16 W/K and continuous SAP 43.05 vs 43.6322.
Gate the drop on the lodged Default U-value: 0.00 → internal knee wall,
return None (no heat loss, no area); positive → a real exposed knee wall
(cert 000565 Ext2 Detailed: 0.31 / 0.10) that still falls through to the
Table-17 path. The earlier over-broad "drop all studs" zeroed 000565's
genuine studs — this keeps them.
Pins test_summary_001431_case20_fabric_heat_loss_matches_worksheet_line_33
((33)=285.9847 at 1e-4); case 20 continuous SAP now EXACT (43.6322). 2850
pass (the lone test_total_floor_area failure is pre-existing on base);
pyright strict net-zero (32=32).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice f68cea27 (re-homed here as 97f44b53) added a guard to
_is_elmhurst_roof_window — "a window lodged on a wall is vertical by
definition" — to keep 001431's two "Double pre 2002" External-wall units in
the vertical sap_windows list for the Modelling draught-proofing count. But
that guard fires on the §11 `location` string, which is an unreliable
lodging artifact: every one of cert 000516's six §11 rows reads "External
wall", and only the U-value separates the five vertical panes (U 2.8) from
the one genuine rooflight (U 3.1, area 1.18, lifted to 3.40 by the Table 24
lookup). Elmhurst's own worksheet routes that U 3.1 "External wall" unit
through (27a) Roof Windows — so location is NOT a vertical signal and the
U > 3.0 backstop (RdSAP 10 §3.7.1) is what matches the worksheet.
Removing the guard restores both 000516 pins
(test_summary_000516_full_chain_sap_matches_worksheet_pdf_exactly,
test_from_elmhurst_site_notes_matches_hand_built_000516) with no other
regression (2879 pass; the lone test_total_floor_area failure is
pre-existing on the branch base, unrelated to window classification).
The extractor half of 97f44b53 (capturing the standalone "BFRC data" §11
row) is retained — it is independent of this classifier and harmless here.
The 001431 Modelling draught-proofing count must instead include roof
windows (the draught_proofed-on-SapRoofWindow approach noted in the glazing
handover), which is feature/bill-derivation's front, not this branch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cert 001431's §11 lodges 17 windows but only 14 surfaced, via two distinct gaps:
1. Extractor (_extract_windows_from_layout): the one "Double glazing, known
data" row whose §11 Data-Source cell is "BFRC data" was rejected — it is
laid out as a standalone keyword line with the U-value on the next line
and lodges no Frame Type/Factor/Gap cells, so it never matched the joined
"<source> <U>" Manufacturer-line shape. Now anchored by a standalone
data-source form, with the RdSAP 10 §3.7 default frame factor (0.7) for
the absent frame cell.
2. Mapper (_is_elmhurst_roof_window): the two "Double pre 2002" rows
(U 3.1 / 3.4 > 3.0) were reclassified as roof windows by the U-value
backstop even though both are lodged on an "External wall". A window
lodged on a wall is vertical by definition; guard the U-value backstop so
it only fires when location/BP give no roof signal.
With both closed: 17 sap_windows, 0 misrouted to sap_roof_windows.
Re-homed onto the mapper-validation line from feature/bill-derivation
(orig f68cea27); the modelling-only regression test
(tests/domain/modelling/test_window_extraction_001431.py) stays on
bill-derivation. KNOWN: the mapper guard breaks cert 000516's
test_summary_pdf_mapper_chain pins (W6 U=3.10 routing) — must be resolved
before this PRs to main.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`test_epc_property_data_round_trips[RdSAP-Schema-21.0.1]` failed with
`sap_roof_windows: None != []` — a normalization mismatch, not lost data.
The 21.0.1 fixture has no roof windows, but the 21.0.1 API mapper emitted
an empty list `[]` while the domain field defaults to None
(`Optional[List[SapRoofWindow]] = None`), the 21.0.0 path yields None, and
the persistence reload yields None (roof windows aren't stored yet — doc
§2.4). Append `or None` so "no roof windows" has one canonical
representation across mapper paths and the round-trip.
No data-loss change: a cert WITH roof windows still produces the
populated list (test_golden_fixtures pins a 6-roof-window cert), and the
§2.4 roof-window persistence gap remains separately tracked. Full
sap10_calculator + documents_parser + epc-repository suites pass (2420);
pyright unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR feedback (dancafc): the `_api_resolve_wall_insulation_thickness` tests
passed literals straight into the Act call. Bind them as named variables
in Arrange (`lodged_thickness`, `measured_value_mm`, `ni_lodgement`) and
have the asserts reference those names, so the Act line reads
declaratively and the inputs/expectations are stated once. Applied to all
three tests in the class. No behaviour change; tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the secondary-glazing family. S0380.235 mapped the unknown-data
(7) and normal-emissivity (11) secondary variants; the RdSAP-21.0.1
`glazed_type` enum also defines code 12 "secondary glazing, low
emissivity", whose Elmhurst §11 label "Secondary glazing - Low
emissivity" was unmapped and would strict-raise. Cascade code 12 carries
the same daylight/solar bucket as 7/11 (g_L=0.80, g⊥=0.76); the lodged
manufacturer U/g drive §3/§6. With this the double family (codes 1/2/3/
7/13 via their Elmhurst phrasings) and the secondary family (4/11/12) are
fully covered. Coverage test extended.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The double_glazing recommendation fixture (Summary_001431) exercises every
RdSAP-21 §11 glazing lodging in one cert; five labels were missing from
`_ELMHURST_GLAZING_LABEL_TO_SAP10` and strict-raised `UnmappedElmhurstLabel`:
"Secondary glazing" -> 7 (Table 6b "secondary glazing", g_L 0.80)
"Secondary glazing - Normal emissivity" -> 11 (RdSAP-21 secondary normal-E, g_L 0.80)
"Triple pre 2002" -> 10 (triple pre-2002, g_L 0.70)
"Triple with unknown install date" -> 6 (generic triple glazed, g_L 0.70)
"Single glazing, known data" -> 15 (single known-data, g_L 0.90)
The glazing code's only cascade effect is the §5 (66)..(67) daylight factor
g_L in `_G_LIGHT_BY_GLAZING_CODE` (single 0.90 / double+secondary 0.80 /
triple 0.70); the lodged manufacturer U-value and solar_transmittance drive
§3 / §6 directly (`_g_perpendicular` prefers the lodged value). Codes are the
semantically-exact RdSAP-21 rows within the correct g_L bucket, kept distinct
for the strict-raise audit trail. Adds a full-coverage test over all 13
distinct labels. Suite 2413 pass.
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>
The gov-EPC API mapper sets BOTH roof_construction (int) and
roof_construction_type (str, derived via _API_ROOF_CONSTRUCTION_TO_STR),
but the Elmhurst mapper set only the string — leaving roof_construction
None on every site-notes cert. The SAP cascade reads the STRING (so SAP
cross-mapper parity always held), but consumers of the int (e.g.
domain/sap10_ml/transform.py ML aggregates `main_dwelling_roof_
construction`) silently saw None on the Elmhurst path.
New `_elmhurst_roof_construction_int` maps the Elmhurst roof-type code to
the same SAP10 int the API lodges (F→1, PN→3, PA→4, PS→8, S/A→7),
harvested from the committed Summary fixtures. Unlike the wall map it
returns None (not a strict-raise) for unmapped codes: the int is not
cascade-load-bearing, so an unknown roof must not block the cert (vaulted
5 / thatched 6 / NR omitted until a fixture surfaces them).
The 6 hand-built U985 reference fixtures gain the matching
roof_construction int (4/4/3 etc.) so test_from_elmhurst_site_notes_
matches_hand_built_* still asserts structural parity. SAP output is
unchanged (cascade reads the string). §4 suite green (2407 passed); the
two pre-existing stone-§5.6 sap10_ml failures are unrelated/out of scope.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RdSAP10 `wall_construction == 6` is canonically WALL_SYSTEM_BUILT — a
WALL TYPE — but the gov-EPC basement heuristic hijacked it: Elmhurst
lodges both "SY System build" and "B Basement wall" as code 6, and the
API lodges basements as code 6 too, so a system-built wall was
mis-flagged `main_wall_is_basement` → wrong RdSAP §5.17 / Table 23
u_basement_wall/u_basement_floor overrides, and downstream the solid-wall
Recommendation Generator couldn't offer EWI/IWI on system-built walls.
System-built stays the wall type on its canonical code 6; the basement
signal moves OFF code 6 to a dedicated `is_basement` (SapAlternativeWall)
/ `wall_is_basement` (SapBuildingPart) Optional[bool] flag:
- Elmhurst: `_elmhurst_wall_is_basement` sets it from the distinct
"SY"/"B" labels (False for SY, True for B, None otherwise).
- gov-EPC API: per-wall code 6 can't be told apart at lodging time, so
`from_api_response` post-processes via `_clear_basement_flag_when_
system_built` — when the cert addendum marks the dwelling system-built,
the code-6 basement heuristic is cleared. A genuine basement (no
addendum signal) keeps the code-6 fallback.
- `main_wall_is_basement` / `is_basement_wall` honour the flag when set,
else fall back to the code-6 heuristic — so untouched API basements and
the cert 000565 "B" cohort are unchanged.
`EpcPropertyData.system_build` is a derived property over the wall type:
the MAIN wall is system-built iff `wall_construction == 6` and it is not
flagged basement. System-built lives on `wall_construction`; the basement
attribute is separate.
Acceptance: a system-built main wall (Elmhurst SY, or API addendum
system_build) → wall_construction == 6, main_wall_is_basement is False,
system_build is True; a genuine basement main wall → main_wall_is_basement
is True, system_build is False. Full §4 suite green (2404 passed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Summary-path mapper raised UnmappedElmhurstLabel for a §15.1
"Cylinder Insulation Type: Jacket" lodging — only "Foam" (→1, factory)
was mapped. SAP10 cylinder_insulation_type uses 2 for loose jacket
(matching the GOV.UK API codes), and SAP 10.2 Table 2 Note 1 gives it a
separate ~2× storage-loss factor that the cascade now handles
(S0380.224). Add "Jacket" → 2 for cross-mapper parity with the API path
and so the loose-jacket storage-loss branch fires on the Summary path.
Surfaced by simulated case 19 (a 210 L jacket cylinder + electric storage
heaters), which previously couldn't extract at all. §4 suite 2397 passed;
mapper.py pyright unchanged at 32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
above) → None
The 2026 sample lodges roof_construction=6 (1 cert, "Thatched, with
additional insulation") and =7 (6 certs, "(same dwelling above)" /
"(another dwelling above)"), both raising UnmappedApiCode and blocking
the cert. roof_construction_type is read ONLY for the §3 "sloping
ceiling" cos(30°) inclined-surface factor (Slice 89); the base roof
U-value comes from the global roofs[].description. Neither code is a
sloping ceiling:
- 6 = thatched — U set by the description, not this field;
- 7 = same/another dwelling above — an internal ceiling with no roof
heat loss (the roof-side analogue of floor_construction code 0,
governed by the roof_heat_loss / description path).
Map both to None: carries no information the cascade consumes here and
correctly avoids the cos(30°) false-trigger. Empirically inert and
validated — roof W/K is byte-identical whether 6/7 map to None or to an
explicit pitched string across all code-6/7 certs in the sample. 5 of
the 7 now compute (e.g. thatched cert 2276 SAP 62.8 vs lodged 63); the
other 2 also carry a gable_wall_type 2/3 raise (separate, worksheet-
backed slice).
Dict value type widened to Optional[str]. §4 suite 2392 passed; mapper.py
pyright unchanged at 32; new tests suppress reportPrivateUsage (net-zero).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 2026-register cert (4519-9056-4002-0222-4802) omits the top-level
post_town entirely (its town sits only in address_line_3 "BARNSTAPLE").
RdSapSchema21_0_x declares post_town as a required no-default field, so
from_dict raised "missing required field 'post_town'" and blocked the
whole cert from computing.
post_town is address metadata the SAP cascade never reads (no consumer
in domain/sap10_calculator/), so default an absent post_town to "" in a
from_api_response pre-processor (mirroring _normalize_shower_outlets) —
inert for the calculation, keeps the cert mappable. The schema dataclass
can't simply give post_town a default: it is a plain (non-kw_only)
dataclass with 57 required fields after post_town, so a mid-list default
would break field ordering.
Validated: cert 4519 now maps (post_town="") and computes SAP cont 74.68
vs lodged 75. §4 suite 2392 passed; mapper.py pyright unchanged at 32;
new tests suppress reportPrivateUsage (net-zero).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 2026 sample's second-largest mapper raise: 37 certs lodge
sap_floor_dimensions.floor_construction=0, which raised UnmappedApiCode
and blocked the cert. Code 0 is the "not recorded / not applicable"
sentinel — 33/37 pair it with floor_heat_loss=6 ("another dwelling
below", an upper-floor flat with no ground floor to describe); the rest
carry mixed Solid / unheated-space descriptions. There is no single
construction to assert.
Map code 0 → None, which defers to RdSAP 10 Table 19 ("where floor
construction is unknown" → age-band default) — identical to how an
unlodged floor_construction (the 993 None certs) is already handled, and
honest about the absence (cf. the no-misleading-insulation_type rule).
Empirically inert and validated: across all 37 code-0 certs the cascade
floor W/K is byte-identical whether code 0 maps to None or to an explicit
"Solid" string — the another-dwelling-below floors compute to 0.0 W/K
(handled via floor_heat_loss + property_type=Flat + floors[].description,
per the _API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE code-6 note), and the few
genuine ground/unheated floors hit the same age-band default either way.
All 37 now compute (were raising).
Dict value type widened to Optional[str] for the None entry; helper
already returns Optional[str]. §4 suite + schema-mapper tests green
(pre-existing test_total_floor_area failure unrelated); mapper.py pyright
unchanged at 32; new test suppresses reportPrivateUsage (net-zero).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A random 1000-cert Jan–May 2026 EPB-register sample surfaced 53 certs
lodging sap_floor_dimensions.floor_construction=3, which raised
UnmappedApiCode and blocked the whole cert from computing (~44 of the
sample's mapper raises). RdSAP 10 field 3-1 "Floor construction"
enumerates the lowest-floor construction as solid / suspended timber /
suspended, not timber, and the spec's "Suspended not timber (structural
infiltration 0)" makes the split load-bearing.
Map code 3 to the canonical "Suspended, not timber" string (the same
value the site-notes mapper already emits — cross-mapper parity):
- u_floor takes the suspended BS EN ISO 13370 branch via the
"Suspended" prefix (_floor_is_suspended_from_description), and
- _has_suspended_timber_floor_per_spec's exact-match
`!= "Suspended timber"` gate correctly does NOT fire, so the §5 (12)
0.1/0.2 floor-infiltration adjustment is skipped (structural
infiltration 0) — exactly the spec rule for not-timber suspended.
Validated: all 5 sampled code-3 certs now compute (e.g.
0340-2877-5570-2606-5965 floor_construction_type="Suspended, not
timber", SAP cont 60.12 vs lodged 60). Confirmed against the cert's own
global floor descriptions ("Suspended, …", floor_heat_loss=7).
Code semantics established from the RdSAP 10 spec + the lodged certs'
human-readable floor descriptions (the EPB /api/codes endpoint carries
no floor_construction enum). §4 suite + schema-mapper tests green
(the pre-existing test_total_floor_area failure is unrelated). mapper.py
pyright unchanged at 32; new test suppresses reportPrivateUsage to keep
net-zero new errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second silently-dropped field from the 2130 audit: the schema-21
SapBuildingPart never declared `wall_insulation_thermal_conductivity`, so
`from_dict` discarded it. Captured it through schema 21.0.0/21.0.1 → domain
SapBuildingPart → API mapper, and wired it into u_wall's RdSAP 10 §5.8
documentary-evidence R-value calc (both the solid-brick §5.7/§5.8 path and
the cavity-composite path), replacing the bare 0.04 λ constant with a
resolved λ.
Resolver: absent / "Unknown" → the §5.8 default 0.04 W/m·K (mineral wool /
EPS); a mapped code → its λ; an unmapped integer code RAISES so the enum is
confirmed against a worksheet rather than silently mis-factored (same
incremental-coverage discipline as the glazing-type map). Only code 1
(= the default 0.04) is mapped — the sole observed value (cert 2130 Ext1).
Zero cascade effect today: the λ path fires only for solid-brick/cavity
walls with a *measured* wall thickness, and 2130 Ext1 lodges no wall
thickness, so its conductivity is captured-but-unused; all existing §5.8
certs lodge no conductivity → 0.04 default unchanged. The point is to stop
dropping lodged data and make λ correct when a future cert exercises it.
Suite: 2523 passed (1 pre-existing TFA fail); sap10_ml 237 passed (2
pre-existing stone-formula fails). Zero new pyright errors (46=46).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pdftotext dumps of hand-entered Elmhurst worksheets wrap the §11 glazing-
GAP column ("16 mm or more") onto the glazing-TYPE token, yielding labels
like "Double between 2002 and 2021 16 mm or [1st]" that
`_elmhurst_glazing_type_code` didn't recognise → UnmappedElmhurstLabel,
blocking the whole Summary from parsing.
Added a fallback: when the lightly-cleaned label isn't a known key, strip a
trailing wrapped gap descriptor (`\s+\d+\s*mm\b.*$`) and retry. Applied
AFTER the direct lookup so explicitly-mapped interleaved variants (e.g.
"Double with unknown 16 mm or install date more", where the gap splits into
the middle) are unaffected. The gap drives the API-path U-value lookup, not
the site-notes glazing-type enum, so dropping it is loss-free for the
cascade.
Unblocks running our cascade on hand-entered worksheet Summaries — used to
validate the PV β-split against simulated case 18 (our split matches the
P960 worksheet exactly: gen 2684.17, onsite 970.77, export 1713.40).
Suite: 2391 passed, 1 skipped. Zero new pyright errors (mapper 32=32).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The schema-21 SapBuildingPart never declared `wall_insulation_thickness_
measured`, so `from_dict` silently discarded it. When a cert lodges
`wall_insulation_thickness == "measured"` the actual value (mm) lives in
that dropped field, so the cascade fell back to the 50 mm "insulation
present, unknown thickness" default instead of the lodged measurement.
Cert 2130 Ext1 lodges solid brick band B + INTERNAL insulation
"measured"/100 mm. Per RdSAP 10 §5.7 Table 8 (insulated-wall U by age band
+ insulation thickness) the 100 mm row gives U=0.32; the unknown-thickness
fallback gave 0.55. New `_api_resolve_wall_insulation_thickness` substitutes
the measured value for the "measured" sentinel; the existing
`_insulation_bucket`/Table-8 path then computes the correct U. Field added
to schema 21.0.0/21.0.1 SapBuildingPart; domain field widened to
Union[str, int] to match `roof_insulation_thickness`. Isolated: 2130 Ext1
is the only bp lodging "measured" across all 47 fixtures.
This spec-correct fix EXPOSED an offsetting under-count it had been masking
(per the repo's no-special-handling rule — the pre-fix +1 was two bugs
cancelling): 2130 cont SAP 83.35 → 83.78 (resid +1 → +2), PE -7.56 →
-11.72, CO2 -0.045 → -0.095. The exposed -11.72 PE (~-746 kWh/yr) is the
deferred gas-combi-PE + PV-β-credit under-count from S0380.45/.49, now
un-masked — the next slice. Re-pinned 2130 with the cause documented.
Suite: 2391 passed, 1 skipped. Zero new pyright errors (mapper 32=32).
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>
The cascade lumped a dwelling with two main heating systems into one:
`space_heating_fuel_monthly_kwh` hard-coded (203)=0 (a documented
scope-A placeholder) and the calculator's per-month fuel read only
main_1, so the full §8 space-heat demand billed against system 1's
efficiency. Simulated case 6 (one oil boiler feeding radiators 51% +
underfloor 49%) exposed it: main fuel ≈ demand/eff1 instead of the
worksheet's (211)+(213) per-system split.
Implements the SAP 10.2 §9a two-main model:
(204) = (202) × (1 − (203)) → system 1 share of total heat
(205) = (202) × (203) → system 2 share of total heat
(211)m = (98c)m × (204) × 100 / (206)
(213)m = (98c)m × (205) × 100 / (207)
(203) = the second system's lodged `main_heating_fraction`; (207) = its
own seasonal efficiency via the new per-detail `_main_heating_detail_
efficiency` (the core of `_main_heating_efficiency`, now reused for
system 2). Calculator `_solve_month` aggregates main_1 + main_2 into
`main_heating_fuel_kwh`. Cost (§10a 241), CO2 (§12 262) and PE (§13 276)
main_2 paths were already wired and now activate.
Site-notes gap also fixed: §14.1 Main Heating2 omits the "Fuel Type"
cell when the second system shares Main 1's fuel (case 6: one oil boiler,
two emitters). `_map_elmhurst_main_heating_2` now inherits Main 1's
resolved fuel as a fallback.
Blast radius: only dual-main certs. 0240 (2× oil code 130, identical
Eq-D1 efficiency) is unchanged — its split collapses to the lumped total.
Suite: 2355 passed, 1 skipped. New code: 0 pyright errors.
NOTE: case 6 is not yet fully pinnable end-to-end — its two systems have
DIFFERENT efficiencies (radiators 55°C → 79%, underfloor 35°C → 84%), a
flow-temperature boiler-efficiency adjustment not yet modelled, and its
dual-system auxiliary pumps ((230c)+(230d)=356) differ from the cascade.
Both are separate follow-on features; this slice is the §9a fuel split.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Elmhurst extractor crashed parsing simulated-case-6's room-in-roof
window rows: the §11 "Location" cell "Roof of Room in Roof" wraps across
the layout prefix/suffix blocks and leaked into the glazing-type phrase
("Double between 2002 Roof of Room and 2021 in Roof" → UnmappedElmhurst-
Label). Fix (`_parse_window_from_anchors`): detect the roof-of-room
location tokens, strip them from the before/after blocks so the glazing
phrase reconstructs cleanly, and set location="Roof of Room".
Mapper: `_is_elmhurst_roof_window` gains a "Roof of Room" location branch
(highest-confidence rooflight signal, above the BP-roof-type / U>3.0
gates); `_ELMHURST_ROOF_WINDOW_U_BY_GLAZING` gains "Double between 2002
and 2021" → 2.30 (case 6 lodges the already-inclined roof-window U, so
the +0.30 inclination adjustment must not double-apply).
This is the site-notes mirror of S0380.198 (API window_wall_type=4):
both paths now route room-in-roof rooflights to (27a) at the inclined U.
Validated against the case-6 P960 worksheet at abs=1e-4:
(27) Windows = 22.7408 (cascade 22.7407)
(27a) Roof Windows = 13.0375 (cascade 13.0375, EXACT)
(31) ext area = 336.13
Case 6 is pinned only on the §3 window line refs (new standalone test,
not added to the section-pin `_FIXTURES`) because its DUAL main heating
(51% rads + 49% underfloor, oil) makes the §10/§12 per-system lines
non-comparable to SapResult's aggregated fields — documented in the
fixture module. Summary mirrored to Summary_001431_case6.pdf.
Suite: 2355 passed, 1 skipped. New code: 0 pyright errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cert 0240's SAP residual (-1) and a chunk of its PE/CO2 was an API-mapper
bug: it flattened ALL windows into sap_windows, so the 6 windows lodged
with window_wall_type=4 — the RdSAP code for a roof window ("Roof of Room"
rooflight / inclined glazing) — were billed as vertical wall glazing on
worksheet (27) at U=2.0, instead of roof windows on (27a) at the Table 6e
Note 2 inclination-adjusted U (DG 2002+ vertical 2.0 + 0.30 = 2.30) with
45°-inclined solar gains.
window_wall_type=4 is the discriminator, NOT window_type=2 (certs 0390 /
7536 lodge window_type=2 on ordinary main-wall windows). Fix: partition
the 21.0.1 API window list into sap_windows (wall_type≠4) + sap_roof_
windows (wall_type=4); `_api_sap_roof_window` mirrors the site-notes
`_map_elmhurst_roof_window` (vertical U from the glazing Table-24 lookup +
0.30 inclination; 45° pitch; g/FF from the same lookup).
Validated against the simulated-case-6 worksheet, which bills these
identical windows on (27a) at U_eff 2.1062 (= 2.30 with the §3.2 R=0.04
curtain transform). The inclined solar gain dominates the higher U-loss,
RAISING the SAP:
- 0240: SAP cont 72.14 → 72.55 (resid -1 → +0 EXACT), PE +3.91 → +1.95,
CO2 +0.22 → +0.12
- 6035: 2 wall_type=4 rooflights — SAP still +0 exact, PE +1.84 → +1.37,
CO2 +0.01 → -0.0004
Blast radius is exactly these two certs (only golden fixtures with
wall_type=4). Suite: 2354 passed, 1 skipped. New code: 0 pyright errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Promotes user-simulated "case 5" (detached, sandstone-walled, room-in-roof
cousin of golden cert 0240) to an e2e worksheet fixture pinning the WHOLE
extractor → mapper → calculator pipeline at abs=1e-4 on all 11 Block-1
line refs. Its worksheet prints the exact RR-gable routing S0380.196
implements, validating that fix against ground truth:
Roof room Main Gable Wall 1 15.68 U=0.35 (29a) Exposed → walls @ main-wall U
Roof room Main remaining area 61.73 U=0.30 (30) A_RR shell − Σ gables
External roof Main 14.52 U=0.11 (30) loft residual
Roof room Main Gable Wall 2 15.68 U=0.25 (32) Party → party @ 0.25
gable area = 6.40 × 2.45 (§3.9.1 default RR storey height); A_RR remaining
= 12.5√(83.2/1.5) − 2×15.68 = 93.09 − 31.36 = 61.73 (RdSAP 10 §3.9.1(e)).
Confirms a DETACHED dwelling can lodge a Party RR gable (Table 4 p.22
row 2) — so my S0380.196 mapping (gable_wall_type 0=Party, 1=Exposed) is
correct; do not flip it.
Two extractor/mapper gaps surfaced and fixed (case 5 is the forcing test):
- Sandstone wall label "SS Stone: sandstone or limestone" had no
`_ELMHURST_WALL_CODE_TO_SAP10` entry (raised UnmappedElmhurstLabel).
Added "SS" → 2 (WALL_STONE_SANDSTONE), matching 0240's API
wall_construction=2 (cross-mapper parity).
- Roof "Insulation Thickness 400+ mm" was silently dropped: the four
thickness parsers used `.split()[0].isdigit()`, which rejects the
trailing "+" → None → u_roof fell back to the age-J default 0.16
instead of 0.11 (+1.09 W/K roof, the whole 0.12 SAP gap). Added
`_parse_thickness_mm` (strips to leading digits) and applied it at all
four sites (walls / alt-wall / roof / floor). The only existing fixture
with "400+ mm" (000565 Stud Wall) routes via the RIR regex, unaffected.
Result: case 5 cascade ≡ worksheet at 1e-4 on SAP/ECF/cost/CO2 + every
energy stream. Neither gap affects 0240 (its API path captures both the
sandstone code and "400mm+"); 0240's residual is therefore non-fabric.
Suite: 2353 passed, 1 skipped. New code: 0 pyright errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Golden cert 6035's residual (SAP -2 / PE +19.16 / CO2 +0.42t) was a real
API-mapper bug, NOT lodged divergence (prior claim retracted).
The API `room_in_roof_type_1` block lodges gable walls by length only (no
height). The mapper carried just the scalar `gable_*_length_m`, and the
cascade's `_part_geometry` gable formula silently drops height-less gables
(needs a height) -> the whole A_RR shell `12.5√(A_RR_floor/1.5)` billed as
roof at U_RR=2.30 instead of the §3.9.1(e) residual
`A_RR − Σ gables`. On 6035 that over-counted roof by 22.78 m² × 2.30 =
+52.4 W/K (roof 130.73 -> 78.33, matching the site-notes case-4 replica at
1e-4 — cross-mapper parity).
RdSAP 10 §3.9.1(e) (PDF p.21): "the area of the room-in-roof gable walls
... is deducted from A_RR to give the residual roof area." Fix: route the
Type 1 gables through `detailed_surfaces` (gable area = L × the §3.9.1
default RR storey height 2.45 m; gable_wall_type 0=Party->gable_wall U=0.25,
1=Exposed->gable_wall_external "as common wall" per Table 4 p.22) so the
cascade's Detailed-RR residual fires — the same path the site-notes mapper
already uses.
Re-pinned golden residuals:
- 6035: SAP -2 -> +0 (exact), PE +19.16 -> +1.84, CO2 +0.42 -> +0.01
- 0240: same fix applies (2 Party gables L=6.4); PE +5.80 -> +3.91,
CO2 +0.32 -> +0.22, SAP integer unchanged
Also corrected the stale "gable_wall_type 0 = external" schema comment
(6035's Summary proves 0=Party, 1=Exposed) and added a strict
UnmappedApiCode raise for unknown gable_wall_type codes.
Suite: 2342 passed, 1 skipped. New code: 0 pyright errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Simplified room-in-roof (RdSAP 10 §3.9.1, PDF p.21) does NOT measure
its slope / flat-ceiling / stud-wall surfaces — the Elmhurst Summary
lodges placeholder Length/Height cells (a 40 m flat-ceiling height, a
32 m slope on a 4.65 m-wide gable). The spec instead derives one
timber-framed "remaining area" from the floor area:
A_RR = 12.5 × √(A_RR_floor / 1.5) §3.9.1(d)
A_RR_final = A_RR − ΣA_RR_gable/other §3.9.1(e)
The cascade already computes A_RR_final itself (heat_transmission.py:
`12.5 × √(A_RR_floor/1.5) − rr_walls_in_a_rr_area` residual), but only
when `detailed_surfaces` carries no roof-going kind (`has_roof_lodgement`
gate). `_map_elmhurst_rir_surface` emitted the placeholder slope/ceiling
rows as raw L×H for every assessment type, flipping that gate and billing
1024 m² + 160 m² of explicit roof area — a 7.5× fabric-heat-loss
explosion (cert 001431 sim case 2: SAP −14.6 vs worksheet 69, space
heating 114 378 vs ~15 000 kWh).
Fix: for a Simplified assessment, drop the roof-going surfaces in the
mapper so the cascade's residual formula fires. This matches how the API
path already (correctly) handles the same Simplified RR — scalar gable
fields, no roof-going detailed_surfaces (golden cert 6035) — and the
gables-only cert 000565. Detailed (§3.10) assessments still measure these
surfaces and keep them.
With the fix, sim case 2 total external area = 232.94 (worksheet exact),
roof 78.33 (was 2725.89), SAP 69.29 → worksheet integer 69. A small
residual (~450 kWh main fuel) remains — a separate fabric gap to walk
next. 2308 passed (+2), 0 failed; pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The newer Elmhurst Summary export lodges a gas combi as §14.0 "Fuel Type"
empty + "Main Heating SAP Code" 104 (EES "BGW"), with no fuel string. The
site-notes mapper left `main_fuel_type=''`, so `cert_to_inputs` raised
`MissingMainFuelType` — blocking the whole gas-combi Summary path
(reproduced on the simulated 001431 case).
SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas boilers (including
mains gas, LPG and biogas)": the code fixes the boiler type/efficiency but
NOT the carrier, so 104 alone can't distinguish mains gas from LPG. The
disambiguator is §15.0 "Water Heating Fuel Type" — a combi/boiler heats
space + water from one appliance — exactly mirroring the existing
liquid-fuel (codes 120-141) fallback. `_elmhurst_gas_boiler_main_fuel`
adopts the §15.0 carrier only when the SAP code is in 101-119 AND §15.0
resolves to a gas/LPG fuel, so a regular boiler + electric immersion
(§15.0 = "Electricity") still strict-raises rather than mis-billing gas
as electric.
2291 passed (+1), 0 failed; pyright net-zero on both files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Table 4e Group 3 (PDF p.173) — heat-network control codes
2301-2314 dispatch to control_type 1, 2, or 3. Code 2306 = "Charging
system linked to use of heating, programmer and TRVs" →
control_type=3, temperature_adjustment=0. Per Table 9 the elsewhere-
zone off-hours depend on control_type: type 1/2 → (7, 8); type 3 →
(9, 8). The two extra off-hours change the §7 (90) T_rest mean by
~0.6 K → (92) MIT by ~0.4 K → (98) SH demand by ~390 kWh/yr.
Pre-slice diagnosis: cascade defaulted `main_heating_control=2`
(modal RdSAP) when the §14.0 "Main Heating Controls Sap" field was
empty. The 5 community heating corpus variants ALL lodge the SAP
code in §14.1 Community Heating "Heating Controls SAP" instead
(format: bare 4-digit integer, e.g. "2306"). The extractor was
storing this in `CommunityHeating.heating_controls_sap` but the
mapper only read `mh.heating_controls_sap` (§14.0).
Two changes:
1. `_elmhurst_sap_control_code` extended to accept bare 4-digit form
("2306") in addition to the §14.0 narrative form ("SAP code 2106,
Programmer, room thermostat and TRVs"). Empty-string returns None
instead of swallowing through the original `re.match` regex.
2. `_map_elmhurst_sap_heating` falls through to
`mh.community_heating.heating_controls_sap` when the §14.0 main
block leaves `heating_controls_sap` empty.
Closures (heating-systems corpus 001431):
CH1 ΔSAP_c -1.0572 → +0.0000 EXACT
Δcost +£24.36 → -£0.00 EXACT
CH3 ΔSAP_c -1.0572 → +0.0000 EXACT
Δcost +£24.36 → -£0.00 EXACT
CH2/CH4 SAP-side flip ±0.42 → ±0.53 (CHP-split blend reacts to
the now-lower SH demand × CHP rate)
CH6 ΔSAP_c -8.4406 → -7.4942 (DLF=1.0 P960 quirk untouched)
Remaining CH1/CH3 ΔCO2 -23.60 / ΔPE -208.23 is the §13a (372)
"Electrical energy for heat distribution" line (118.38 kWh × electric
factors 0.1993 CO2 / 1.760 PE). Cascade doesn't currently meter this
electricity overhead separately from heat-network heat — next slice.
932 pass + 0 fail (+5 new mapper tests). No regressions on the other
36 corpus variants — the mapper change is gated on `mh.community_
heating is not None` and only fires when §14.0 leaves the control
field empty. Pyright net-zero on mapper.py + corpus test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the +£104 cost / +4.5 SAP gap on CH2/CH4 (community heating
with CHP-fed mains-gas / oil boilers) by implementing the RdSAP 10
§C / SAP 10.2 Appendix C (PDF p.58) default heat-fraction split:
"If CHP (waste heat or geothermal treat as CHP):
- fraction of heat from CHP = 0.35
- CHP overall efficiency 75%
- heat to power ratio = 2.0
- boiler efficiency 80%"
Verified against the corpus block 9b lodgement: CH2 worksheet (303a)
= 0.3500 + (303b) = 0.6500 + (305) = 1.00 + (306) DLF = 1.45. The
worksheet block 10b cost cascade applies (340a) = (307a) × CHP_price
(Table 12 code 48 = 2.97 p/kWh) + (340b) = (307b) × boiler_price
(Table 12 codes 51-58 = 4.24 p/kWh) with (307a) = 0.35 × (307),
(307b) = 0.65 × (307).
Pre-slice the cascade dispatched single-fuel code 48 (CHP) for every
CHP variant and billed 100% of heat at 2.97 p/kWh, under-charging by
~£104/yr versus the worksheet's 35% × 2.97 + 65% × 4.24 = 3.7945
p/kWh blended rate.
Three layers wired:
1. Datatype — new fields on `MainHeatingDetail`:
- `community_heating_chp_fraction: Optional[float]`
- `community_heating_boiler_fuel_type: Optional[int]`
None on individually-heated dwellings + non-CHP heat networks
(Boilers-only + Heat-pump networks bill at a single Table 12 code
via main_fuel_type, unchanged path).
2. Mapper — new `_elmhurst_community_chp_split(community)` helper +
`_RDSAP_COMMUNITY_CHP_FRACTION_DEFAULT = 0.35` constant. When the
§14.1 Community Heat Source is "Combined Heat and Power": returns
(0.35, boiler_fuel_code) where boiler_fuel_code is resolved from
the §14.1 Community Fuel Type via the existing
`_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12` dispatch (gas → 51,
oil → 53, coal → 54).
3. Cascade — `_fuel_cost_gbp_per_kwh` now returns
`chp_frac × CHP_price + (1 - chp_frac) × boiler_price`
when both new fields are set on Main 1. Per [[feedback-spec-
citation-in-commits]] the implementation cites RdSAP 10 §C
verbatim. Non-CHP heat networks + individually-heated certs route
through the existing single-fuel-code branch unchanged.
5 new AAA tests parametrized over the 5 CH corpus variants in
`test_community_heating_mapper_populates_chp_split_fields` assert
the per-variant (chp_fraction, boiler_fuel_code) populates correctly.
Closures vs pre-S0380.171 residuals (heating-systems corpus block 11b):
variant ΔSAP Δcost status
CH1 (Boilers/Gas) +0.5915 -£13.63 unchanged (no CHP split)
CH2 (CHP/Gas) +4.50→-0.0076 -£104→+£0.17 ✓ CLOSED
CH3 (HP/Elec) +0.5915 -£13.63 unchanged (no CHP split)
CH4 (CHP/Oil) +4.50→-0.0076 -£104→+£0.17 ✓ CLOSED
CH6 (CHP/Coal) -3.52→-8.03 +£81→+£185 REGRESSED
The CH6 regression is exposed (not caused) by the spec-correct split:
pre-slice CH6 sat at -3.52 SAP / +£81 by coincidence — the cascade's
CHP-only pricing (2.97 p/kWh) cancelled with cascade DLF=1.45
(Table 12c age G default) against the CH6 worksheet's lodged DLF=1.0.
Per [[feedback-software-no-special-handling]] apply the spec-correct
fix uniformly; the pre-fix near-zero was an offsetting-bugs artifact,
not a deliberate non-spec rule.
The CH6 worksheet (306) DLF=1.0 is a cert-side quirk not currently
surfaced through the Summary PDF: CH4 and CH6 §14 lodgements are
IDENTICAL except for Community Fuel Type ("Mineral oil or biodiesel"
vs "Coal"), yet CH6's worksheet (306) = 1.0000 while CH4's = 1.4500.
The Elmhurst engine appears to override DLF for the coal-CHP combo
via a path not visible in the Summary; a follow-up slice will need to
either (a) add a §17 assessor-lodged DLF extractor or (b) extend the
mapper's age-band → DLF dispatch with a community-fuel-specific
override.
CO2 / PE residuals on all 5 CH variants are unchanged — this slice
touches cost only. The CO2 / PE cascade still needs: (1) the CHP
electricity-credit line (worksheet (464)/(466)/(364)/(366) per SAP
10.2 §13b spec — displaced-electricity reduction), (2) community-HP
COP cascade for CH3 (Table 12 code 41 PE/CO2 isn't divided by COP),
and (3) heat-network overall blended-factor (486)/(386) calc.
Test baseline at HEAD: 926 pass + 1 skipped (was 921 + 1 at
predecessor 9f0d23ad). Pyright net-zero on affected files
(epc_property_data.py, mapper.py, cert_to_inputs.py,
test_heating_systems_corpus.py + elmhurst_site_notes.py): 65 → 65.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>