Commit graph

69 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
86b875af35 review: clearer room-in-roof area variable names in heat_transmission
PR feedback (dancafc): the simplified room-in-roof branch used cryptic
locals. Rename for clarity (behaviour-unchanged; the geom dict keys and
the builder-function locals are untouched):

  rr_a_rr      -> rr_roof_area          (the worksheet's simplified A_RR)
  rr_common    -> rr_common_wall_area
  rr_gable     -> rr_gable_area
  a_rr_final   -> rr_residual_roof_area  (leftover roof-going area after
                                          deducting perimeter walls/gables
                                          /rooflights — takes the roof U)

Names now mirror the `rr_*_area_m2` geom keys they read from and say
"area of what". Added a one-line note that `rr_roof_area` is the RdSAP 10
§3.10.1 A_RR. Pyright unchanged; 1087 heat-transmission/cascade-pin tests
pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:18:58 +00:00
Khalim Conn-Kowlessar
69fdbf9f1d S0380.223: complete _part_geometry early-return key contract (RR KeyError)
5 certs in a 2026 API sample raised `KeyError: 'rr_common_wall_area_m2'`
and were blocked from computing. Root cause: `_part_geometry`'s early
return (taken when a building part lodges no sap_floor_dimensions —
e.g. a party-wall-only or RR-only extension as bp[0]) returned only 6 of
the 9 keys the full return exposes, omitting rr_common_wall_area_m2,
rr_gable_area_m2 and cantilever_floor_area_m2. The §3.9 RR contribution
block reads geom["rr_common_wall_area_m2"] / ["rr_gable_area_m2"] for
EVERY part, so the floorless part's truncated dict raised KeyError at
heat_transmission.py:974.

Fix: the early return now exposes all 9 keys, the three RR/cantilever
geometry values defaulting to 0.0 — correct, since a part with no floor
dimensions has no derivable RR shell or cantilever (no floor area).
Pure contract-completion bug; no spec/U-value change.

Regression test pins the invariant directly: a floorless part's
_part_geometry keys must equal a with-floors part's keys. Validated: all
5 certs now compute (4 within ~2 SAP of lodged; the 5th, 8536, has a
separate residual). §4 suite 2393 passed; heat_transmission.py pyright
unchanged at 12, test file at 71.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:33:52 +00:00
Khalim Conn-Kowlessar
f895dd3ab7 S0380.217: capture wall_insulation_thermal_conductivity (was dropped)
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>
2026-06-04 11:57:00 +00:00
Khalim Conn-Kowlessar
ac7f510ccb S0380.214: as-built sloping-ceiling roof → Table 18 col (3)
A "Pitched, sloping ceiling" roof (roof_construction code 8) lodged with
"As Built" insulation (no measured thickness → None) was wrongly routed to
RdSAP 10 Table 18 column (1) "insulation between joists or unknown". A
sloping ceiling has no joist void, so per RdSAP 10 §5.11 roof-input item
5-5 ("Sloping ceiling insulation … unknown / as built → Table 18") and
Table 18 note (b) ("Applies also to roof with sloping ceiling") it takes
column (3) — band F = 0.68, band L = 0.18 (vs col 1 0.40 / 0.16).

Discriminator is the code-8 "sloping ceiling" string only: code-5 vaulted
ceilings stay on column (1) per the 33 cohort-2 "ND" vaulted certs
(S0380.211), and the "NI"/"ND" unknown case is untouched. New
`is_pitched_sloping_ceiling` flag threaded from heat_transmission to
`u_roof`; pre-1950 bands already reach the same col (3) value (2.30) via
the mapper's thickness=0 → Table 16 row-0 override, so the new branch
carries the post-1950 bands where col 1 ≠ col 3.

Worksheet-validated by simulated case 15 (the 7536 replica): our cascade
on its Summary matches the P960 worksheet exactly — roof HLC 29.17 W/K,
cont SAP 65.04 vs 65. Re-pins golden cert 7536: roof 26.77 → 29.17, cont
SAP 69.071 → 68.924, PE -7.0776 → -6.1952, CO2 -0.1875 → -0.1639 (SAP
integer 68, resid +1 unchanged — the remaining +0.92 is a diffuse demand
under-count needing a fully-faithful worksheet). Blast radius: 7536 only.

Suite: 2388 passed, 1 skipped (main); sap10_ml 233 passed + 2 pre-existing
stone-formula failures (out of scope). Zero new pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:58:37 +00:00
Khalim Conn-Kowlessar
90f6720cae S0380.211: vaulted/sloping roof NI insulation → Table 18 col (1), not 50 mm
Closes the Ext1 vaulted-roof over-count that S0380.209 exposed on golden
cert 0240-0200-5706. BP2 lodges roof_construction=5 (vaulted ceiling),
roof_insulation_thickness="NI" (parsed to 0), description "Pitched,
insulated (assumed)", band J. The cascade returned U=0.68 — the RdSAP 10
§5.11.4 (p.44) retrofit-50 mm "insulation at joists" row. A vaulted /
sloping ceiling has no ceiling-joist void, so that row does not apply; per
RdSAP 10 §5.11 Table 18 (p.45) it takes the column (1) age-band default
(band J = 0.16).

The arbiter is the cohort, not the spec text alone: 33 cohort-2 certs
lodge "ND" (thickness None) vaulted roofs (roof_construction=5, band D)
that already pin to their dr87 worksheets at U=0.40 = Table 18 col (1) by
falling through the age-band default. 0240's only difference is the "NI"
sentinel (insulation present, unknown thickness) which uniquely hit the
0.68 override. (The S0380.209 note's predicted "cont ≈ 72.31" assumed a
col-3 0.25 value; the cohort's ND vaulted roofs disprove that — they use
col (1), so 0240 lands at cont 72.4617.)

Implementation: new `u_roof(is_sloping_ceiling=...)` flag, threaded from
heat_transmission for roof_construction_type containing "sloping ceiling"
(code 8) or "vaulted" (code 5). It fires only for the NI case
(thickness 0 + "insulated (assumed)"), routing to the col (1) age-band
default; the "ND"/None path is untouched (already col 1) and a NORMAL
pitched-with-loft roof still takes the §5.11.4 50 mm row (flag defaults
False). roof 76.93 → ~68 W/K → 0240 PE +5.5044 → +1.5181, CO2 +0.2757 →
+0.0728 (SAP integer 72 unchanged — the true value; lodged 73 needs the
unpreserved 2013+ pump).

Also corrects test_u_wall_cavity_as_built_partial_insulation_routes_to_
filled_cavity_row → ..._routes_to_as_built_row: a missed S0380.210
follow-up. That test (in domain/sap10_ml/tests/, which the AGENT_GUIDE §4
suite command does not run) asserted the pre-S0380.210 "partial insulation
→ filled" behavior on legacy-map parity, not worksheet evidence; S0380.210
corrected it to the as-built row per RdSAP 10 Table 6 + golden cert 0390's
four-metric closure.

Suite: 2614 passed, 1 skipped; the 2 remaining failures in
test_rdsap_uvalues.py (stone §5.6 thin-wall formula vs Table-6 1.7 cap)
are pre-existing (fail at HEAD 58ff7d88, before this branch's work).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:57:22 +00:00
Khalim Conn-Kowlessar
844fc22f67 S0380.209: API-path wall U — as-built "insulated (assumed)" uses age-band row, not 50mm
The EPC renders a recent-band as-built wall as "<material>, as built,
insulated (assumed)". The API mapper populates epc.walls with that string,
and heat_transmission's wall_ins_present gate keyed off the "insulated"
substring → routed the wall to the RdSAP 50 mm "insulation of unknown
thickness" bucket (e.g. sandstone band J U=0.25) instead of the as-built
age-band row (U=0.35).

Per RdSAP 10 Table 8/9 footnote the 50 mm row applies ONLY when insulation
is "known to have been increased subsequently (otherwise 'as built'
applies)". An "as built ... (assumed)" description is the EPC's age-band
assumption — it only renders on RECENT bands (an old band renders "no
insulation (assumed)"), so the as-built row applies. Genuine retrofit is
signalled by wall_insulation_type (External/Internal/Filled), which the
gate still checks independently.

Worksheet-validated by two new Elmhurst worksheets, both As Built band J:
  - simulated case 9: sandstone   → (29a) U 0.35
  - simulated case 10: solid brick → (29a) U 0.35
both the as-built row, NOT 50 mm (0.25).

Fix: restrict the description-based gate to genuine retrofit via the new
local `_described_as_retrofit_insulated` (excludes "as built"/"(assumed)").
The cavity filled-row routing inside `u_wall` (which uses
`_described_as_insulated` directly) is untouched — the 3 cavity API certs
(0390/0535/7536) are unaffected.

test_heat_transmission: the old `..._uses_50mm_row` test asserted 50 mm via
an IMPOSSIBLE band-B + "insulated (assumed)" combination; corrected to a
valid recent-band (J) scenario asserting the as-built row (35 W/K).

Golden 0240: walls 24.45 → 34.23 W/K (U 0.25 → 0.35). SAP integer 72
unchanged; PE residual re-pinned +1.8687 → +5.5044, CO2 +0.0907 → +0.2757.
This spec-correct fix REMOVED the wall under-count that was masking the
Ext1 vaulted-roof over-count (cascade U 0.68 via the same "insulated
(assumed)" description vs case-9 sloping-ceiling 0.25) — that roof
over-count is the next slice; fixing both lands SAP cont ≈ 72.31 (=
Elmhurst case 9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:42:18 +00:00
Khalim Conn-Kowlessar
e440e2df2e S0380.205: SAP 10.2 p.186 two-systems-different-parts MIT (weighted R + elsewhere blend)
When two main heating systems heat different parts of a dwelling, SAP
10.2 §7 (PDF p.186) adapts the mean-internal-temperature calculation:
- Table 9b weighted responsiveness: R = (1−(203))·R_sys1 + (203)·R_sys2.
- Rest-of-dwelling temperature (90)m = weighted average of T2 computed
  under EACH system's control schedule, weights (203)/[1−(91)] for sys2
  and [1−(203)−(91)]/[1−(91)] for sys1 (or sys2's control alone when
  (203) ≥ 1−(91)).

The cascade used Main 1's control + R=1.0 for the whole dwelling,
over-stating MIT by +0.037 °C on simulated case 6 (Main 1 radiators/2106
type 2 living + Main 2 underfloor/2110 type 3 elsewhere, R 1.0/0.75). That
inflated (97) heat loss by ~11 W → demand +61 kWh/yr.

`mean_internal_temperature_monthly` gains `main_2_control_type`,
`main_2_fraction`, `main_2_responsiveness`; cert_to_inputs derives them
from the second main detail (gated on main_heating_fraction > 0, so
single-main / DHW-only second mains pass the defaults → unchanged).
Case 6: (87) living, (90) elsewhere, (98c) demand 11991.96 and per-system
fuel (211)=7741.6458 / (213)=6995.3106 all match the worksheet to 1e-4.

Re-pin: golden 0240 (same 2106/2110 archetype, API-only) — PE +2.1519 →
+1.6893, CO2 +0.1051 → +0.0815 (both closer to zero; SAP 72 unchanged).
Single-main certs unchanged (2360 pass + 0 fail).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:02:56 +00:00
Khalim Conn-Kowlessar
a42e03529c S0380.203: RdSAP 10 §3.7 — "Roof of Room" rooflights deduct from the RR residual
A rooflight deducts from the gross area of the roof element it pierces
(RdSAP 10 §3.7, PDF p.19). A "Roof of Room" rooflight (window_wall_type=4
/ site-notes "Roof of Room") sits on the room-in-roof sloped ceiling, so
its area must deduct from the §3.10.1 RR residual roof — not the flat /
loft external roof.

The cascade deducted every rooflight from the regular roof (heat_
transmission line 814). Simulated case 6's worksheet is the first
worksheet evidence for "Roof of Room" rooflight billing: "Roof room Main
remaining area" net 55.54 = gross 61.73 − 6.19 rooflights (U_RR=0.30),
while "External roof Main" 14.52 carries no opening. New
`_bp_rr_roof_absorbs_rooflight` routes the rooflight area to the RR roof
(simplified A_RR_final or detailed §3.10.1 residual) ONLY when the BP's
RR contributes such a shell AND lodges no explicit roof surface (slope /
flat_ceiling / stud_wall). Case 6 roof (30) 20.2284 → 19.0523 EXACT;
demand gap +153 → +61 kWh/yr.

Preserved: certs 000565 (Ext2 stud walls) and 000516 (slopes) lodge
explicit roof surfaces → rooflight keeps deducting from the regular roof
(their 1e-4 worksheet pins hold). Simplified Type 1 RR is excluded too.

Re-pin (uniform spec application per [[feedback-software-no-special-
handling]] + worksheet-is-truth): API certs 6035 and 0240 are detailed-RR
gables-only like case 6 (no worksheet of their own for rooflights), so
their "Roof of Room" rooflights now deduct from the RR residual too. This
SUPERSEDES the unvalidated S0380.198 "deduct from loft" assumption.
- 6035: roof 78.0648 → 73.9176; the previously-"unexplained" +1.37 PE
  residual COLLAPSES to -0.14 (CO2 -0.0004 → -0.0362; SAP exact 70) —
  strong corroboration the rooflight-on-RR treatment is correct.
- 0240: PE +2.5812 → +2.1519, CO2 +0.1269 → +0.1051 (SAP 72 unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:19:37 +00:00
Khalim Conn-Kowlessar
3581513b7e S0380.202: SAP 10.2 Table 5a note a) second main-system pump gain (70)
The §5 (70) internal-gains mirror of S0380.201's Table 4f (230c). SAP
10.2 Table 5a note a) (PDF p.177) verbatim: "Where there are two main
heating systems serving different parts of the dwelling, assume each has
its own circulation pump and therefore include two figures from this
table. ... Where two main systems serve the same space a single pump is
assumed."

Simulated case 6 (dual oil, 51% radiators + 49% underfloor) lodges Main
1 "2013 or later" (3 W) + Main 2 unknown date (7 W) → worksheet (70) =
10 W in the 8 heating months. The cascade billed a single Main 1 pump
(3 W). New `_second_main_central_heating_pump_gain_w` adds the second
main's gain (at its own pump-age bucket), gated on a lodged
main_heating_fraction > 0 — the same genuine-second-space-heating-main
test as S0380.201, so DHW-only second mains (cert 000565 Main 2 combi via
WHC 914, fraction 0) keep a single pump (70)=3. Refactored the per-detail
pump predicate (`_main_detail_has_central_heating_pump`) and date bucket
(`_pump_date_category_for_detail`) out of the orchestrator.

Re-pin: golden 0240 (dual-main oil combi, both unknown date) (70) 7 → 14
W; the extra internal gain lowers space-heating demand → SAP cont 72.18 →
72.24 (integer 72 unchanged), PE +2.8092 → +2.5812, CO2 +0.1385 →
+0.1269 (both closer to zero). Validated against the case-6 worksheet.

This closes the (70) leg of case 6's space-demand gap. Remaining for full
case-6 closure: roof fabric (37) +1.176 W/K (room-in-roof shell) and HW
(216) Eq-D1 water efficiency −1.6%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:35:08 +00:00
Khalim Conn-Kowlessar
8ae978a646 S0380.200: SAP 10.2 §9a two-main-heating split (203)/(205)/(207)/(213)
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>
2026-06-03 13:09:43 +00:00
Khalim Conn-Kowlessar
d7d5084f90 Move sap10_calculator tests to tests/domain/sap10_calculator/ for CI
The calculator tests lived under domain/sap10_calculator/{tests,worksheet/
tests,rdsap/tests,climate/tests,validation/tests}, none of which are in
pytest.ini testpaths — so CI (which collects tests/) never ran them. Relocate
all five dirs to tests/domain/sap10_calculator/{,worksheet,rdsap,climate,
validation}, mirroring the tests/domain/property_baseline/ convention, so the
cascade-pin / golden / e2e conformance suites run in CI.

Mechanics:
- git mv preserves history (110 files).
- Flattening the trailing /tests keeps each file's depth-to-repo-root
  identical, so all 16 repo-root parents[4] fixture refs stay valid. Only
  test_pcdb_etl.py's parents[1] (→ pcdb data) and one hardcoded absolute
  golden-fixture path in test_cert_to_inputs.py needed rebasing.
- Cross-imports rewritten domain.sap10_calculator.worksheet.tests →
  tests.domain.sap10_calculator.worksheet (21 files incl. the external
  importer backend/documents_parser/tests/test_summary_pdf_mapper_chain.py).
- Golden-fixture path strings in test_summary_pdf_mapper_chain.py +
  scripts/fetch_cohort2_api_jsons.py updated to the new location (the JSONs
  moved with the rdsap tests).

load_cells / gitignored worksheet xlsx: the xlsx-pinned tests (test_dimensions
/ ventilation / water_heating) read 2026-05-19-17-18 RdSap10Worksheet.xlsx,
which is gitignored (.gitignore `*.xlsx`) and so absent in CI. _xlsx_loader.
load_cells now pytest.skip()s when the file is absent, so those tests run
locally and skip cleanly in CI instead of erroring — no new CI failures from
the move, and the gitignore policy is respected.

Verified: tests/domain/sap10_calculator + backend/documents_parser +
tests/domain/property_baseline = 2248 pass, 1 skipped; pyright resolves the
new import paths with zero import-resolution errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:58:00 +00:00
Khalim Conn-Kowlessar
2f039aeb39 Thread appliances + cooking annual kWh onto SapResult for ADR-0014 bills
ADR-0014 BillDerivation prices a per-end-use EnergyBreakdown
(HEATING / HOT_WATER / LIGHTING / PUMPS_FANS / APPLIANCES / COOKING).
SapResult already carried the first four but not appliances or cooking,
so a downstream SapResult→EnergyBreakdown adapter had to stub those two
at 0 kWh — understating the bill by the whole unregulated electricity
load. Surface them so the property_baseline side can wire the sections.

Adds two output-only fields to CalculatorInputs + SapResult, threaded
exactly like lighting_kwh_per_yr:
  appliances_kwh_per_yr  — SAP 10.2 Appendix L L13/L14/L16a annual E_A
                           (sum of the §5 (68) monthly appliances kWh)
  cooking_kwh_per_yr     — SAP 10.2 Appendix L L20 (p.91) ELECTRICITY
                           estimate E_cook = 138 + 28×N

Both values already existed in cert_to_inputs.py (appliances_monthly_kwh,
cooking_monthly_kwh) — reused, not recomputed.

Fuel attribution: cooking_kwh_per_yr is the L20 ELECTRICITY figure (the
field docstring says so), distinct from the L18 cooking heat GAIN
(35 + 7N W) the §5 internal-gains cascade uses. The bill adapter should
treat cooking as an electricity carrier; a gas-cooker split, if ever
needed, is a separate follow-up.

HARD CONSTRAINT honoured — output-only, zero rating drift. Appliances +
cooking are unregulated and are NOT fed into ECF / total_fuel_cost /
CO2 / primary energy / sap_score. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical (1165 rated pins green). The synthetic CalculatorInputs
fixtures set the new fields non-zero on purpose so the existing cost/PE
reconciliation assertions act as leak detectors.

New focused test asserts both fields are populated (non-zero) and
threaded unchanged onto SapResult, with cooking equal to the L20
electricity figure (138 + 28×occupancy) to 1e-9. pyright net-zero
111 → 111.

Note: 11 pre-existing failures in test_appendix_u.py / test_table_32.py
arrived with the recently absorbed PR and are unrelated to this change
(they fail identically on the clean branch); flagged separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:00:10 +00:00
Khalim Conn-Kowlessar
8d465d973f Slice S0380.162: SAP 10.2 Appendix N3.1 default pump gain for electric HPs
SAP 10.2 Appendix N3.1 (PDF p.105) "Circulation pump and fan":
"For electric heat pumps: The electricity used by the water
circulation pump or fan is included within the calculated annual
space and hot water heating efficiency and is not included in
worksheet (230c). **The default heat gain from Table 5a is included
via worksheet (70).**"

This rule applies the Table 5a row "Central heating pump in heated
space" GAIN (3 / 10 / 7 W per pump-age bucket) to electric heat
pumps even though the pump ELECTRICITY is hidden in the COP and
excluded from (230c). The "Not applicable for electric heat pumps
from database" clause in Table 5a footnote a) scopes only to the
PCDB-Table-362 cascade case (Appendix N1.2.1: "For heat pumps held
in the PCDB ... a single water circulation pump serving the heat
emitters is sufficient" — pump kWh AND gain embedded in COP).

S0380.160 over-stripped the gain by zeroing pump_w for every HP
category-4 main, conflating the PCDB-Table-362 case with the Table-4a
default cascade. This slice refines the HP gate in
`_any_main_system_has_central_heating_pump`:
  - Cat 4 HP WITH `main_heating_index_number` lodged (PCDB Table
    362) → continue (skip; pump in COP per N1.2.1);
  - Cat 4 HP with SAP code in `_TABLE_4A_WARM_AIR_SAP_CODES` (Cat 5
    warm-air HPs distribute via ducted air, no water circulation
    pump; warm-air fan handled separately by Table 5a "Warm air
    heating system fans" row, S0380.161) → continue;
  - Otherwise (Cat 4 HP, Table 4a default cascade, water-emitter)
    → apply Table 5a default per Appendix N3.1.

Per-line walk on ashp (SAP code 214 air-to-water HP, Cat 4, no PCDB,
"Post 2013" pump age):
  worksheet (70)[Jan] = 3.0000 W
  cascade pre-slice    = 0.0000 W      delta = -3.000 W
The -3 W winter gain shortfall over-stated cascade (84) Total gains
by -3 W in heating months → cascade SH demand +12.27 kWh/yr
(cascade 9302 vs worksheet 9290), pushing continuous SAP down 0.024
because the cost residual was driven by the +1.5 kWh × 12 month
shortfall flowing through the £0.0741 low-rate cost.

Closures:
  ashp:  ΔSAP -0.0240 → +0.0000 EXACT, Δcost +£0.55 → +£0.00 EXACT
  gshp:  ΔSAP -0.0178 → -0.0000 EXACT, Δcost +£0.41 → -£0.00 EXACT

ΔPE +36 → +25.51 (and ΔCO2 +7.33 → +6.31) — residuals narrow to the
Elmhurst-vs-spec HW PE annual-vs-monthly Table 12e/12d quirk only
(same pattern as the 16-variant lighting-PE deferred cohort,
scaled by HW kWh = 1138 vs 2384 → 25.51 vs 48.66). Cohort
Σ |ΔSAP_c| 0.07 → 0.03; all 25 cascade-OK variants now SAP+cost EXACT.

Cohort-1 (cert 0380 et al.) golden fixtures unaffected — those certs
lodge `main_heating_index_number` (PCDB Table 362) → HP gate skips
correctly → (70) = 0 preserved. Cert 000565 (HP main 1 + gas boiler
main 2) unaffected — wet-boiler branch fires for main 2.

Verbatim spec quote (SAP 10.2 Appendix N3.1, PDF p.105):
  "For electric heat pumps: The electricity used by the water
   circulation pump or fan is included within the calculated annual
   space and hot water heating efficiency and is not included in
   worksheet (230c). The default heat gain from Table 5a is
   included via worksheet (70)."

Tests: 906 pass (+1), 0 fail. Pyright net-zero (35 → 35).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:59:29 +00:00
Khalim Conn-Kowlessar
482ce88b55 Slice S0380.161: SAP 10.2 Table 5a warm-air fan gain (SFP × 0.04 × V)
SAP 10.2 Table 5a (PDF p.177) row "Warm air heating system fans
a) c)" computes the gain as SFP × 0.04 × V (W). Footnote c) sets
the default SFP to 1.5 W/(l/s) when no PCDB warm-air-unit record
is lodged; footnote a) applies the heating-season-only mask
(zero in summer months). Footnote c) further omits the gain when
the dwelling has balanced whole-house mechanical ventilation
(MVHR / MV) — same omission as the Table 4f kWh-side footnote e).

Pre-slice the cascade's `internal_gains_from_cert` only wired the
central-heating-pump row of Table 5a; the warm-air-fan gain helper
(`warm_air_heating_fan_w`) existed but was unwired. The kWh-side
parallel (Table 4f, 136.35 kWh/yr) was wired in S0380.158 — this
slice closes the symmetry on the gain side.

Per-line walk on electric 2 (SAP code 524 = Cat 5 ASHP with
warm-air distribution, V = 227.25 m³, no balanced MV):

  worksheet (70)[Jan] = 13.6350 W
  cascade (70)[Jan]   = 0.0000 W      delta = -13.635 W
  worksheet (98c)[Jan] = 1600.43 kWh
  cascade (98c)[Jan]  = 1608.12 kWh   delta = +7.69 kWh

13.635 W = 1.5 × 0.04 × 227.25 exactly. The -13.6 W winter gain
shortfall propagates through the §7 utilisation cascade and over-
states cascade SH demand by ~57 kWh/yr (cascade 9483 vs worksheet
9426), under-charging cost by ~£2.50 with opposite sign to the
S0380.156-.158 closures.

Fix: new `_any_main_system_has_warm_air_distribution(epc)` +
`_has_balanced_mechanical_ventilation(epc)` predicates in
`internal_gains.py`, mirroring `cert_to_inputs._TABLE_4A_WARM_AIR_SAP_CODES`
+ `_BALANCED_MV_KIND_NAMES` (kept here as siblings so the worksheet
layer stays free of rdsap deps). Orchestrator wires
`warm_air_heating_fan_w(sfp=1.5, dwelling_volume_m3)` into the
heating-season term of `pumps_fans_monthly_w` when warm-air
distribution is present and balanced MV is not.

Closures electric 2:
  ΔSAP_c -0.1087 → -0.0000 EXACT
  Δcost  +£2.50 → -£0.00 EXACT
  ΔCO2   +16.54 → +11.95 (joins lighting-PE deferred cohort)
  ΔPE    +97.69 → +48.66 (joins lighting-PE deferred cohort)

Electric 2 joins the 15-variant lighting-PE deferred cohort
(electric 1 + electric 3/5/6/7/8/9 + solid fuel 5/6/7/8 + solid
fuel 4/9/10/11 + electric 2) where SAP/cost are EXACT but PE/CO2
carry an Elmhurst-vs-spec MONTHLY-factor offset (cohort uses
Table 12 annual factors on the off-peak HW immersion line; spec
mandates Table 12d/12e monthly per the header).

Verbatim spec quote (SAP 10.2 Table 5a row "Warm air heating
system fans a) c)", PDF p.177):
  "Warm air heating system fans a) c)  SFP × 0.04 × V"
  Footnote c): "SFP is the specific fan power from the database
    record for the warm air unit if applicable; otherwise
    1.5 W/(l/s). These values of SFP include an in-use factor.
    If the heating system is a warm air unit and there is balanced
    whole house mechanical ventilation, the gains for the warm air
    system should not be included."
  Footnote a): "... Set to zero in summer months. ..."

Σ |ΔSAP_c| across 25-variant cohort: 0.18 → 0.07 (~60% reduction).
No regressions on the other 24 variants or any golden fixture —
gate keyed on Table 4a warm-air SAP code frozenset (only electric
2 in the corpus has a code in that set).

Tests: 905 pass (+1), 0 fail. Pyright net-zero (35 → 35).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:16:57 +00:00
Khalim Conn-Kowlessar
af34ad9846 Slice S0380.160: SAP 10.2 Table 5a wet-pump gate for central heating gain
SAP 10.2 Table 5a (PDF p.177) row "Central heating pump in heated
space" only applies to mains with a water-loop circulation pump.
Footnote a) names two exclusions verbatim ("Does not apply if a
heating system used solely for domestic hot water. ... Not applicable
for electric heat pumps from database."), and the row's name carries
the implicit third: dry mains with no central heating pump (electric
storage heaters, electric direct-acting, solid-fuel room heaters
without back-boilers) — the row simply doesn't list them.

Pre-slice `internal_gains_from_cert` gated only on Note a) (HP
exclusion), applying `central_heating_pump_w(date_category=...)` to
every non-HP main. The default UNKNOWN-date branch added 7 W of pump
gain to (70)m for every dry-system fixture in the controlled-variable
corpus, even though the worksheet (70)m = 0 every month.

Per-line walk on electric 3 (SAP code 401 "Manual charge control"):

  cascade (73)[Jan] = 640.21 W
  worksheet (73)[Jan] = 633.21 W      delta = +7.00 W
  cascade (70)[Jan] = 7.00 W
  worksheet (70)[Jan] = 0.00 W        Table 5a inapplicable

The +7 W winter-month gain lowered cascade SH demand by ~38 kWh/yr
(cascade 11050 vs worksheet 11088). At Table 32 18-hour low-rate
~7.4 p/kWh that's £2.50/yr under-charging — matching the cluster's
uniform Δcost = -£1.96..-£2.80 pattern. Continuous SAP rose ~+0.10
because cost dominates the ECF.

Fix: new `_any_main_system_has_central_heating_pump(epc)` predicate
in `internal_gains.py`, mirroring `cert_to_inputs._is_wet_boiler_main`
(S0380.149 — Table 4f kWh side). Wet if any non-HP main lodges:
  - sap_main_heating_code in {101-141, 151-161, 191-196} (gas/oil/
    solid-fuel/electric boilers per Table 4a/4b),
  - main_heating_index_number (PCDB Table 322 record),
  - main_heating_category in {1, 2} (RdSAP central heating), OR
  - heat_emitter_type in {1, 3} (radiators / fan-coil per Table 4d).

Dead `_all_main_systems_are_heat_pumps` helper removed (the new
predicate subsumes its role).

Cluster closures (10 variants):
  electric 3:    SAP +0.1215 → -0.0000, cost -£2.80 → -£0.00
  electric 5:    SAP +0.1081 → -0.0000, cost -£2.49 → -£0.00
  electric 6:    SAP +0.1081 → -0.0000, cost -£2.49 → -£0.00
  electric 7:    SAP +0.1017 → -0.0000, cost -£2.34 → -£0.00
  electric 8:    SAP +0.0941 → -0.0000, cost -£2.17 → -£0.00
  electric 9:    SAP +0.1199 → -0.0000, cost -£2.76 → -£0.00
  solid fuel 4:  SAP +0.0850 → -0.0000, cost -£1.96 → -£0.00
  solid fuel 9:  SAP +0.1072 → -0.0000, cost -£2.47 → -£0.00
  solid fuel 10: SAP +0.1134 → +0.0000, cost -£2.61 → -£0.00
  solid fuel 11: SAP +0.0912 → +0.0000, cost -£2.10 → +£0.00

Σ |ΔSAP_c| across 25-variant cohort: 1.24 → 0.18. All 10 cluster
variants now join the lighting-PE +48.66 / CO2 +11.95 deferred
cohort (Elmhurst-vs-spec monthly factor quirk, same shape as
electric 1 + solid fuel 5/6/7/8 from prior closures).

Verbatim spec quote (SAP 10.2 Table 5a row 1, PDF p.177):
  "Central heating pump in heated space, 2013 or later  3 a)"
  "Central heating pump in heated space, 2012 or earlier  10 a)"
  "Central heating pump in heated space, unknown date  7 a)"

The row name ("Central heating pump") gates by construction: dry
systems have no central heating pump and the row's three sub-rows
don't apply.

No regressions on the other 31 variants or any golden fixture; the
6 Elmhurst U985 fixtures lodge PCDB index → the new predicate
returns True → pump_w unchanged.

Tests: 904 pass (+1), 0 fail. Pyright net-zero (35 → 35).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:02:42 +00:00
Khalim Conn-Kowlessar
1e69bd3979 Slice S0380.124: tighten dimensions + rating arithmetic pins
`test_dimensions.py`:
- gross_wall_area_m2 synthetic test (40×2.5+16×2.4 = 138.4): abs=0.05 → 1e-12 (exact arithmetic).
- Cohort cert LINE_4 TFA / LINE_5 volume pins: abs=0.01/0.05 → 1e-4 (PDF 4-d.p. display floor; actual cohort diff is 1e-14).

`test_rating.py`:
- `test_net_energy_exporter` SAP=100−13.95×(−0.3)=104.185 exact arithmetic — abs=0.05 → 1e-12.

Tests: 29 pass for the two files; 775 pass on extended suite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:48:29 +00:00
Khalim Conn-Kowlessar
49f87160c7 Slice S0380.123: pin Table U5 share-column solar fluxes at exact equality
`test_ne_and_nw_share_table_u5_constants` asserts NE == NW, E == W,
SE == SW orientation-pairs share the same flux value per Appendix U
Table U5's column-sharing convention. The cascade looks up both via
the same dictionary key — the values are bit-identical, not
approximately equal. Tightened from `pytest.approx(..., abs=0.01)` to
exact `==` equality; abs=0.01 masked the fact that the cascade
returns the same float object.

Net pyright: unchanged.
Tests: 17 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:46:22 +00:00
Khalim Conn-Kowlessar
9f0dd64570 Slice S0380.122: tighten test_ventilation tolerances
17 hand-crafted ventilation tests had abs=0.001-0.01 tolerances that
masked the actual diff (always 0 or 1e-16 for these direct-arithmetic
formulas). Tightened to abs=1e-12 (essentially exact).

10 cohort cert pins (`LINE_8`/`LINE_10`/.../`LINE_25` against U985 PDF)
had mixed abs=0.0001-0.0005; standardised to abs=1e-4 (PDF 4-d.p.
display floor per [[feedback-e2e-validation-philosophy]]). The looser
0.0005 pins on (8), (16), (18), (21), (22b), (25) admitted up to half
a 4-d.p. unit of drift that the cascade isn't producing — actual
cascade diffs are ~5e-5 (one notch under display precision).

Test movement: all 26 tests pass at the new tolerances. Net pyright
change: 69 → 69.

Per [[feedback-zero-error-strict]] tolerance widening is forbidden;
this slice goes the other way — every pin tightened to its actual
precision floor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:44:43 +00:00
Khalim Conn-Kowlessar
f0305d5452 Slice S0380.120: distinguish NI from explicit int(0) roof_insulation_thickness per RdSAP 10 §5.11.4
RdSAP 10 §5.11.4 (PDF p.44):

  "If retrofit insulation present of unknown thickness use 50 mm."

The cascade encoded "unknown thickness" via the cert's "NI" (Not-
Indicated) sentinel which `_parse_thickness_mm` collapses to int(0).
But that conflates two structurally different signals:

  (a) explicit int(0) — `_api_resolve_sloping_ceiling_thickness`
      returns this for cert 001479 Ext2 PS sloping ceiling age C, a
      per-BP "uninsulated" override of the dwelling-level description
      ("Pitched, insulated" from another BP).
  (b) string "NI" — the cert lodgement marker for "thickness not
      indicated; defer to description"; §5.11.4 should fire when the
      description carries an "insulated" signal.

Pre-slice the heat_transmission cascade dropped `roof_description`
whenever `roof_thickness == 0`, killing the §5.11.4 path in `u_roof`
(line 711) for the (b) case. 346 corpus certs lodge the NI +
"insulated (assumed)" pattern per the §5.11.4 test's arrange comment.

Fix: inspect the raw `part.roof_insulation_thickness` value (pre-
parse) — drop the description only when the lodgement is the literal
int(0), keep it for the "NI" string sentinel so `u_roof`'s §5.11.4
branch fires (`_described_as_insulated` + thickness=0 → return 0.68).

Test movement:
  test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4 → PASS
  test_summary_001479_full_chain_sap_matches_worksheet_pdf_exactly → PASS (cohort safe)
  cert 000565 e2e — 11/11 PASS (unaffected — explicit per-BP thicknesses)

Golden corpus impact: cert 0240 had this exact pattern (BP[1] NI + global
description includes "Pitched, insulated (assumed)"). The fix drops its
roof U from 2.30 → 0.68 for that BP, closing massive mapper-gap residuals:

  expected_sap_resid:                 -14    → -10     (Δ +4 SAP)
  expected_pe_resid_kwh_per_m2:    +12.49   → +0.054   (Δ −12.43 kWh/m²)
  expected_co2_resid_tonnes_per_yr:  +0.696 → +0.063   (Δ −0.633 t/yr)

Re-pinned per [[feedback-golden-residuals-near-zero]]: "Re-pin to the
new (smaller) value when a gap closes". The remaining 0240 residuals
(SAP -10 / PE +0.05 / CO2 +0.06) are tiny — the bulk of 0240's mapper
gap is now closed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:10:00 +00:00
Khalim Conn-Kowlessar
a77f1a284d Slice S0380.119: propagate sap_roof_windows in _build_section_5_epc
The §5 test EPC builder threaded sap_windows from the fixture but
discarded `sap_roof_windows` — passing them through `make_minimal_sap10
_epc(...)`. Pre-S0380.110 the `_daylight_factor_from_cert` cascade
read a single aggregate `rooflight_total_area_m2` kwarg + bulk g_L,
so the test EPC builder's omission was masked. Post-S0380.110 the
cascade reads per-rooflight glazing via `epc.sap_roof_windows`
(Appendix L §L2a per-window g_L sum) — Triple / Double / Single
distinctions matter.

For cohort 000516 (the only cohort fixture with a lodged rooflight,
a Double-glazed 1.18 m² × g_L=0.80 × FF=0.70 × Z_L=1.0), the empty
sap_roof_windows on the test EPC undercut the daylight factor →
cascade lighting (67) Jan 33.78 W vs ws 32.68 W (+1.1 W/month) →
lighting_kwh_per_yr 238.65 vs ws 230.88 (+7.77 kWh/yr).

Fix: thread `fixture.build_epc().sap_roof_windows` through the
minimal EPC. Cohorts 000474/477/480/487/490 have no rooflights →
list is None → cascade unchanged for those certs.

Test movement: 000516 (67) Jan 33.78 → 32.68 ✓ EXACT. 000516
lighting_kwh_per_yr 238.65 → 230.88 ✓ EXACT.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:53:33 +00:00
Khalim Conn-Kowlessar
55a29f5a1c Slice S0380.118: cohort LINE_xx pins → abs=1e-4 + §15-rounded RR test expecteds
Two changes bundled (same file, same RdSAP 10 §15 spec citation):

1. Tighten cohort cert (000474 / 000490) heat_transmission LINE_xx
   pins from abs=0.01 / 0.1 → abs=1e-4 (4 pins). Pre-slice the cohort
   landed at 1e-4 of the U985 PDF but the test pins were holdovers
   from when the cascade was less precise. Per [[feedback-e2e-
   validation-philosophy]]:

     "per-component tests pin against U985 worksheet line refs at
      <1e-3 tolerance ... 1e-4 since PDF lodges 4 d.p."

   Probe data at HEAD post-§15:
     000474 LINE_33  cascade=209.108439 ws=209.1084 Δ=+4e-5
     000474 LINE_37  cascade=232.116939 ws=232.1169 Δ=+4e-5
     000490 LINE_33  cascade=211.893610 ws=211.8936 Δ=+1e-5
     000490 LINE_37  cascade=236.621110 ws=236.6211 Δ=+1e-5

2. Update `test_room_in_roof_simplified_type_1` and `..._type_2`
   expected-value formulas to round A_RR_shell to 2 d.p. per RdSAP
   10 §15 (p.66) — matching the cascade behaviour now enforced by
   Slice S0380.116. The unrounded expected was 100.9156 / 71.857;
   spec-correct rounded is 100.919 (39.5285 → 39.53) and 71.846
   (32.2749 → 32.27). Same abs=1e-4 pin enforces both arithmetic
   and rounding correctness.

   New import: `_round_half_up` from heat_transmission (the same
   helper the cascade uses for §15 rounding).

Net pyright change: 71 → 71. Net test change: 4 newly-tight pins,
2 newly-passing RR synthetic tests, 670 → 670 passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:39:36 +00:00
Khalim Conn-Kowlessar
f2e8b657ce Slice S0380.116: A_RR_shell rounded to 2 d.p. per RdSAP 10 §15 (p.66)
RdSAP 10 Specification §15 "Rounding of data" (PDF p.66):

  "For consistency of application, after expanding the RdSAP data into
   SAP data using the rules in this Appendix, the data are rounded
   before being passed to the SAP calculator. The rounding rules are:
     U-values: 2 d.p.
     All element areas (gross) including window areas and conservatory
     wall area: 2 d.p."

The §3.9.1 / §3.10.1 shell formula A_RR_shell = 12.5 × √(A_RR_floor /
1.5) produces a gross element area for the room-in-roof. Pre-slice the
cascade kept the raw float (e.g. cert 000565 BP[0]: 12.5 × √30 =
68.46532...), then subtracted lodged wall surfaces to obtain the (30)
residual roof area. The worksheet rounds A_RR_shell to 2 d.p. (68.47)
BEFORE the subtraction — per §15 above.

Cert 000565 has three BPs that fire this path (Main, Ext1, Ext3 — all
have detailed wall surfaces with no `slope` / `flat_ceiling` /
`stud_wall` lodgement, so §3.10.1 residual fires). Each contributes a
sub-rounding residual that the unrounded cascade was missing:

  BP[0] Main: 68.4653 → 68.47; residual 43.9653 → 43.97 (+0.0016 W/K)
  BP[1] Ext1: 59.5119 → 59.51; residual 18.2519 → 18.25 (−0.0007 W/K)
  BP[3] Ext3: 57.7350 → 57.74; residual 17.3450 → 17.35 (+0.0017 W/K)

Movement (HEAD `d0268a5b` → this slice) for cert 000565:
  roof_w_per_k        51.3768 → 51.3795 ✓ EXACT (Δ −0.0027 → 0.0)
  thermal_bridging   128.6448 → 128.6460 ✓ EXACT (Δ −0.0012 → 0.0)
  total_external_a   857.6323 → 857.6400 ✓ EXACT (Δ −0.0077 → 0.0)
  space_heating_kwh 59008.2363 → 59008.3499 ✓ EXACT (Δ −0.1136 → 0.0)
  main_fuel_kwh     34710.7272 → 34710.7941 ✓ EXACT (Δ −0.0669 → 0.0)
  total_fuel_cost    4680.2515 → 4680.2593 ✓ EXACT (Δ −0.0078 → 0.0)
  co2_kg_per_yr      6447.6161 → 6447.6263 ✓ EXACT (Δ −0.0102 → 0.0)
  sap_score_cont       28.5087 → 28.5087 ✓ EXACT (Δ +4.2e-5 → −4.7e-5)
  sap_score (int)           29 ✓ EXACT (preserved)
  ecf                  5.38682 → 5.38683 (vs ws 5.3868, Δ +3.2e-5)

Cert 000565 truly closes — every SAP-result field within 1e-4 of the
worksheet PDF.

Cohort safety: 6 cohort certs (000474..000516) unchanged — cohort
000516's roof routes through the Detailed branch with `slope` /
`flat_ceiling` / `stud_wall` lodgements, so `has_roof_lodgement=True`
short-circuits the §3.10.1 residual block. Cohort certs 000474/477/
480/487/490 are pre-S0380.103 hand-built fixtures whose RR fields don't
exercise the simplified A_RR_shell path (rir.floor_area=0 or
detailed_surfaces only).

Test added: `test_summary_000565_a_rr_shell_rounded_2_dp_closes_roof_
w_per_k_per_rdsap_10_section_15` pins the cascade roof_w_per_k = 51.3795
exactly (Δ ≤ 1e-4 vs worksheet (30) Σ).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:18:53 +00:00
Khalim Conn-Kowlessar
d0268a5b5c Slice S0380.115: fixture pin ECF 5.3866 → 5.3868 per worksheet (PDF line 593)
The cert 000565 ECF pin was a transcription typo. U985-0001-000565.pdf
line 593 (Block 1, 11a SAP rating individual heating systems) reads:

  Energy cost factor (ECF)  [(255) x (256)] / [(4) + 45.0] =  5.3868 (257)

The pin captured 5.3866 — likely a mis-copy from line 871 / 873 (Nov
MIT (92)m = 15.3866). The cascade output 5.386823 matches the worksheet
PDF at 4 d.p.; the pin was always 0.0002 wrong against the source.

Per [[feedback-verify-handover-claims]], handover narratives are verified
against the source PDF; the cascade is correct and the pin was wrong.

Test movement: `test_sap_result_pin[000565-ecf]` now passes (diff
0.000023 against the corrected pin 5.3868, within abs=1e-4). Four
expected fails remain (cost / CO2 / SH / main_fuel) — closed in the
next slice (A_RR_shell rounding per RdSAP 10 §15).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:13:35 +00:00
Khalim Conn-Kowlessar
cc70e55917 Slice S0380.114: pump gain via Table 5a Note a) (SAP 10.2 p.177)
SAP 10.2 Table 5a (PDF p.177) verbatim:

  "Central heating pump in heated space, 2013 or later: 3 W"

  Note a): "Where there are two main heating systems serving
  different parts of the dwelling, assume each has its own
  circulation pump and therefore include two figures from this
  table. ... Set to zero in summer months. **Not applicable for
  electric heat pumps from database.** Where two main systems serve
  the same space a single pump is assumed."

The Note a) "not applicable for electric heat pumps" rule zeros the
pump GAIN only for HP-category systems themselves. Where a cert
lodges a non-HP main system alongside an HP, the non-HP system's
circulation pump still operates and dissipates 3/7/10 W into the
dwelling as an internal gain.

Pre-slice the cascade conflated TWO different spec rules:

  Table 4f (ELECTRICITY)  — HP pump electricity is in the COP, so
                             worksheet line 230b = 0 for HP certs.
  Table 5a (GAIN)         — HP-from-database pump gain is omitted
                             ONLY for that HP system, not for any
                             non-HP system in the same cert.

`_main_heating_category_from_cert(epc)` returned `details[0].
main_heating_category` and the caller zeroed pump_w whenever that
was category 4. This dropped the 3 W gain for any cert whose first
main system was an HP — even when system 2 was a non-HP boiler with
its own pump.

Cert 000565 lodges TWO main systems:
  [0] HP        (category 4)  pump_age "2013 or later"
  [1] Gas boiler (category 2)  pump_age None

Per spec the system [1] gas boiler's pump contributes 3 W (post-2013
date from [0]'s lodgement). Worksheet (70) confirms:

  Pumps, fans  3.0 3.0 3.0 3.0 3.0 0.0 0.0 0.0 0.0 3.0 3.0 3.0  (70)

Pre-slice cascade returned 0 every month, missing 24 W·months of
winter internal gains. Downstream: +10 kWh space heating, +£0.71
fuel cost, +0.90 kg CO2, -0.008 continuous SAP.

Cert 0380 (cohort-1 ASHP, HP-only):
  [0] HP (category 4)  pump_age unknown
  (no [1])

Worksheet (70) = 0 every month. Cascade post-slice: every main
system is HP → pump_w = 0 ✓ unchanged.

Fix:

`domain/sap10_calculator/worksheet/internal_gains.py`:
- Replace `_main_heating_category_from_cert` + the {4} set-membership
  check with `_all_main_systems_are_heat_pumps(epc)`. Returns True
  iff every lodged `main_heating_details[i].main_heating_category`
  equals 4. Pump gain is zeroed only in that case.
- Existing `_pump_date_category_from_cert` (reads [0]'s pump_age)
  unchanged — Elmhurst lodges the dwelling's pump_age on detail[0]
  regardless of which system the pump serves.

Cohort safety: all 6 cohort certs have a single main system (gas
boiler, category 2) → `all_main_systems_are_heat_pumps` returns
False → pump_w applies, same as the prior `else` branch. Cert 0380
(ASHP) has a single HP main → True → pump_w = 0, unchanged.

Cert 000565 cascade snapshot (HEAD 59de805e → this):
  (70)m pumps_fans gain   [0]*12  → [3,3,3,3,3,0,0,0,0,3,3,3] ✓ EXACT
  sap_score (int)             29 ✓ EXACT (preserved)
  sap_score_continuous   28.5007 → 28.508742  (Δ -0.0080 → +0.000042)
                                  **← essentially exact at 4.2e-5**
  ecf                     5.3876 →  5.386823  (Δ +0.0010 → +0.0002)
  total_fuel_cost_gbp    4680.97 → 4680.2515  (Δ +0.71 → -0.008)
  co2_kg_per_yr          6448.53 → 6447.6161  (Δ +0.90 → -0.010)
  space_heating_kwh     59018.52 → 59008.2363 (Δ +10.17 → -0.114)
  main_heating_fuel     34716.78 → 34710.7272 (Δ +5.98 → -0.067)

**Cert 000565 continuous SAP now exact at 1e-4 tolerance.** Every
intermediate (66-73, 83-84, 93-98, fuel/cost/CO2) closes the
worksheet at ≤1e-3 relative error.

Pyright net-zero (17 → 17 errors across touched files).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:46:46 +00:00
Khalim Conn-Kowlessar
59de805e63 Slice S0380.113: H=0 gable lodgement deducts per RdSAP 10 §3.9.2 step (b)
RdSAP 10 §3.9.2 step (b) (PDF p.23) verbatim:

  "Software calculates the area of each gable or adjacent wall by
  using the equation:
         A_RR_gable = L_gable × (0.25 + H_gable) − [(H_gable − H_common_1)² / 2
                                                   + (H_gable − H_common_2)² / 2]"

Step (d):
  A_RR_final = A_RR_wall − (Σ A_common + Σ A_gable + Σ A_party
                            + Σ A_sheltered + Σ A_connected)

The spec equation is signed and applies for all L > 0 — including
H_gable = 0. When the gable is shorter than the common walls the
correction term `(H_gable − H_common)² / 2` exceeds the
L × (0.25 + H_gable) term, producing a negative A_RR_gable.
Elmhurst's worksheet evaluates the equation literally; the negative
value adjusts A_RR_final upward via step (d) without billing a
physical wall area.

Cert 000565 §8.1 lodges Ext3's RR (Simplified Type 2) with an
absent Gable Wall 2:

  Gable Wall 1   L=9.00  H=7.00   Exposed     U=0.45
  Gable Wall 2   L=4.00  H=0.00               U=0.00   ← lodged but H=0
  Common Wall 1  L=5.00  H=1.50               U=0.45
  Common Wall 2  L=7.50  H=0.30               U=0.45

Spec equation for Gable Wall 2:
  A_gable_2 = 4 × (0.25 + 0) − (0 − 1.5)²/2 − (0 − 0.30)²/2
            = 1.0 − 1.125 − 0.045 = −0.17 m²

Worksheet (30) Ext3 residual = 17.35 m² back-solves exactly:
  A_RR_shell = 12.5 × √(32.0 / 1.5)                = 57.7350
  Σ walls (incl. -0.17 absent gable)               = 40.3850
  residual = shell − walls                         = 17.3500  ✓ 4 d.p.

Pre-slice the mapper had two clamps that together dropped the
spec-computed −0.17 m² adjustment:

  mapper.py:3350  `if length_m <= 0 or height_m <= 0: return None`
                  → filtered out any H=0 surface
  mapper.py:3443  `area_m2 = max(0.0, length_m * (0.25 + H) − correction)`
                  → clamped negative gable areas at 0

Combined the cascade computed residual = 17.18 m² (cascade UNDER
by 0.17). Plus a related secondary `if height_m > h` filter on the
correction sum that masked the all-common-walls-taller case.

3-layer fix:

1. `datatypes/epc/domain/mapper.py` `_map_elmhurst_rir_surface`:
   - Split the early-return filter: drop only when L<=0 (no wall),
     OR when H<=0 AND not (Simplified Type 2 with common walls).
   - Apply the spec gable-area formula to BOTH `gable_wall` (party
     default) and `gable_wall_external` kinds in Simplified Type 2
     (the U-value routing differs by kind, but the area equation
     is the same).
   - Remove `max(0.0, ...)` clamp so the signed result reaches the
     cascade.
   - Remove `if height_m > h` correction-sum filter (spec applies
     the full square unconditionally).

2. `domain/sap10_calculator/worksheet/heat_transmission.py` per-
   surface loop:
   - `gable_wall` branch: skip `party += 0.25 × area` when area < 0
     (wall doesn't exist physically) but still add the signed area
     to `rr_walls_in_a_rr_area` so the residual deduction in step (d)
     grows by |area|.
   - `gable_wall_external` branch: same skip pattern for `walls +=
     u × area` and `rr_detailed_area += area`.

Cohort safety: only cert 000565 Ext3 hits this in the corpus. All
other cohort certs are Type 1 RR (no common walls, formula gives
the same answer) or have all gables H > 0. The cascade's per-element
test pins (Ext1's Connected gable + Exposed gable, Ext4's Detailed
RR) unchanged.

Cert 000565 cascade snapshot (HEAD a461b70d → this):
  roof_w_per_k         51.3185 → 51.3768  ✓ EXACT (Δ -0.06 → -0.003)
  total_external_area 857.46  → 857.6323  ✓ EXACT (Δ -0.18 → -0.008)
  thermal_bridging    128.62  → 128.6448  ✓ EXACT (Δ -0.03 → -0.005)
  total_w_per_k       936.97  → 937.0563  ✓ EXACT (Δ -0.09 → -0.004)

  sap_score (int)         29 ✓ EXACT (preserved)
  sap_score_continuous 28.5027 → 28.5007 (Δ -0.0060 → -0.0080)
  ecf                   5.3877 →  5.3876
  total_fuel_cost_gbp  4681.01 → 4680.97
  co2_kg_per_yr        6448.59 → 6448.53
  space_heating_kwh   59019.21 → 59018.52
  main_heating_fuel   34715.31 → 34716.78

**Cert 000565 fabric cascade now essentially exact** (HTC −0.004 W/K
total residual across all 8 fabric components). The remaining
continuous SAP -0.0080 / cost +£0.71 / SH +10 kWh residuals come
from non-fabric upstream (likely ventilation or appliances) —
candidates for a future audit.

Pyright net-zero (57 → 57 errors across touched files).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:23:12 +00:00
Khalim Conn-Kowlessar
a461b70d19 Slice S0380.112: per-BP rooflight allocation (RdSAP 10 §3.7 p.19)
RdSAP 10 §3.7 (PDF p.19) verbatim:

  "for each building part, software will deduct window/door areas
  contained in the relevant wall areas"

The same per-BP deduction applies to roof windows / rooflights
piercing each BP's roof. Pre-slice the cascade lumped every
rooflight's area onto BP[0] Main's `rw_area_part` (S0380.106-era
convention), leaving the actual host BP's gross roof un-deducted.

Cert 000565 §11 Openings lodges:
  Roof Windows 1(Ext2)  External roof Ext2, 1.20 m²
  Roof Windows 2(Ext4)  External roof Ext4, 0.50 m²

Worksheet (30) ground truth — each rooflight deducts from its
host BP's gross roof:
  Ext2: 25.00 − 1.20 = 23.80 net × 0.30 = 7.1400 W/K
  Ext4:  3.00 − 0.50 =  2.50 net × 0.00 = 0.0000 W/K

Pre-slice cascade:
  Ext2: 25.00 (un-deducted) × 0.30 = 7.5000 (+0.36 W/K over)
  Plus 1.70 m² of RW area lumped onto Main's external aggregate
  → +1.20 m² double-count (Ext2 gross + Main rw_area_part)

3-layer fix:
1. `datatypes/epc/domain/epc_property_data.py`: add `window_location:
   Union[int, str] = 0` to SapRoofWindow (mirror of
   `SapWindow.window_location` shape).
2. `datatypes/epc/domain/mapper.py` `_map_elmhurst_roof_window`:
   thread `w.building_part` through (mirror of
   `_map_elmhurst_window`'s pass-through).
3. `domain/sap10_calculator/worksheet/heat_transmission.py`: pre-loop
   compute `rw_area_by_bp[i]` from each `SapRoofWindow.window_location`
   via the existing `_window_bp_index` resolver; per-BP loop reads
   `rw_area_by_bp[i]` instead of allocating everything to BP[0].

Cohort safety: cert 000516's lone rooflight is on the Main BP
(Summary §11 row "Main, External wall"), so the per-BP allocation
returns Main = 0 = same as the prior lump-on-Main convention. The
000516 hand-built fixture's SapRoofWindow now sets
`window_location="Main"` to mirror the Elmhurst mapper string-form.

Cert 000565 cascade snapshot (HEAD 794ef7ed → this):
  roof_w_per_k          51.6773 → 51.3185 (Δ +0.30 → -0.06)
  total_external_area  858.66  → 857.46  (Δ +1.02 → -0.18)
  thermal_bridging_w/k 128.80  → 128.62  (Δ +0.15 → -0.03)
  sap_score (int)          28 → 29 ✓ EXACT (recovered)
  sap_score_continuous 28.4903 → 28.5027  (Δ -0.0184 → -0.0060)
  ecf                   5.3887 →  5.3877
  total_fuel_cost_gbp  4681.89 → 4681.01
  co2_kg_per_yr        6449.73 → 6448.59
  space_heating_kwh   59031.86 → 59019.21
  main_heating_fuel   34724.63 → 34715.31

Closes the +1.20 m² Ext2 rooflight double-count. Remaining
residuals (Ext3 -0.17 m² + -0.06 W/K) closed by S0380.113 (H=0
gable retention).

Pyright net-zero (58 → 58 errors across touched files).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:15:16 +00:00
Khalim Conn-Kowlessar
9461e657a5 Slice S0380.110: per-rooflight g_L in Appendix L L2a (SAP 10.2 p.88)
SAP 10.2 Appendix L §L2a (PDF p.88) verbatim:

    GL = 0.9 × Σ (Aw × gL × FF × ZL) / TFA                  (L2a)

    where
      FF is the frame factor (fraction of window that is glazed) for
          the actual window or from Table 6c
      Aw is the area of a window, m²
      gL is the light transmittance factor from Table 6b
      ZL is the light access factor from Table 6d

Table 6b gL (PDF p.178) — light transmittance column:
  Single glazed                     0.90
  Double glazed (any variant)       0.80
  Triple glazed (any variant)       0.70

Table 6d note 2 (PDF p.178): "A solar access factor of 1.0 and a light
access factor of 1.0 should be used for roof windows/rooflights."

Pre-slice `_daylight_factor_from_cert` collapsed every rooflight into
a single `rooflight_total_area_m2 × _G_LIGHT_DEFAULT (0.80) ×
_FRAME_FACTOR_DEFAULT (0.70)` product, overcounting any Triple-glazed
rooflight (gL=0.70) or any non-default frame factor.

Cert 000565 §11 lodges 2 rooflights (per S0380.107 routing):
  Item 2 (Ext2 NR rooflight): 1.2 m², "Triple between 2002 and 2021",
    PVC FF=0.70 → gL=0.70 (Table 6b Triple). Correct numerator
    contribution 1.2 × 0.70 × 0.70 = 0.588; pre-slice cascade used
    1.2 × 0.80 × 0.70 = 0.672 (+0.084 over).
  Item 5 (Ext4 A rooflight): 0.5 m², "Double between 2002 and 2021",
    Wood FF=0.70 → gL=0.80 (Table 6b Double). Already matched.

The +0.084 numerator delta lowered GL → lowered C_daylight → lowered
worksheet (232) by 2.17 kWh/yr.

3-layer fix:
1. `datatypes/epc/domain/epc_property_data.py`: add `glazing_type:
   int = 3` to SapRoofWindow (default = Double 2002-2021, the cohort
   modal).
2. `datatypes/epc/domain/mapper.py` `_map_elmhurst_roof_window`:
   populate `glazing_type` via `_elmhurst_glazing_type_code(w.
   glazing_type)` — mirror of `_map_elmhurst_window`.
3. `domain/sap10_calculator/worksheet/internal_gains.py`
   `_daylight_factor_from_cert`: iterate `epc.sap_roof_windows` for
   the rooflight g_L numerator, dispatching via existing
   `_G_LIGHT_BY_GLAZING_CODE` + `rw.frame_factor`. Z_L = 1.0 per
   Table 6d note 2.

Test coverage:
- AAA test `test_summary_000565_rooflight_per_window_g_l_routes_via_
  glazing_type_per_sap_10_2_appendix_l_l2a` pins both per-rooflight
  glazing codes (9 Triple / 3 Double) AND `inputs.lighting_kwh_per_
  yr` at 1384.8353 ±1e-4.
- 000516 hand-built fixture updated to explicitly set glazing_type=2
  ("Double pre 2002") matching the lodged label.

Cert 000565 cascade snapshot (HEAD 98a4b5b9 → this):
  sap_score (int)             29       ✓ EXACT (preserved)
  lighting_kwh_per_yr     1382.6657 → 1384.8353  ✓ EXACT (-2.17 → 0)
  sap_score_continuous     28.5028  →  28.5002   (Δ -0.0059 → -0.0085)
  ecf                       5.3874  →   5.3877   (Δ +0.0008 → +0.0011)
  total_fuel_cost_gbp    4680.78    → 4681.01    (+0.52 → +0.75)
  co2_kg_per_yr          6448.34    → 6448.59    (+0.72 → +0.96)
  space_heating_kwh     59020.02    → 59019.18   (+11.67 → +10.83)
  main_heating_fuel     34717.66    → 34717.16   (+6.87  → +6.37)

Lighting closure exposes a previously-cancelling residual elsewhere —
continuous SAP magnitude widens slightly (-0.0059 → -0.0085) but the
spec-correct path is now in place, per [[feedback-spec-floor-
skepticism]]. SH + main_heating_fuel improve (added lighting energy
contributes internal gains, reducing SH demand). Integer SAP 29 ✓
EXACT preserved.

Cohort safety: 6 cohort certs have at most 1 rooflight each
(000516 W6 only, lodged "Double pre 2002" → code 2). Their gL still
resolves to 0.80 via the existing `_G_LIGHT_BY_GLAZING_CODE` table,
so the per-rooflight dispatch produces the same numerator as the
old default branch.

Pyright net-zero (50 → 50 errors across touched files).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 18:31:35 +00:00
Khalim Conn-Kowlessar
efb203f7ad Slice S0380.109: Solid brick + insulation via §5.7 Table 13 + §5.8 Table 14 (RdSAP 10)
Closes the remaining cert 000565 BP[0] Main wall residual (-1.54 W/K
under ws) by routing solid-brick walls with documentary wall
thickness + lodged insulation through the RdSAP 10 §5.7 + §5.8
formula chain. Adds a Table-6 footnote (a) cap on the §5.6 stone
formula to handle thin uninsulated stone walls (Ext1 BP[1] Granite
W=50 mm).

RdSAP 10 §5.7 Table 13 (PDF p.41) verbatim:

  "Default U-values of brick walls
   Wall thickness, mm   U-value, W/m²K
   Up to 200 mm         2.5
   200 to 280 mm        1.7
   280 to 420 mm        1.4    ← cert 000565 Main W = 300 mm
   More than 420 mm     1.1"

RdSAP 10 §5.8 step 2 (PDF p.41-42) verbatim:

  "The U-value of the insulated wall is U = 1 / (1/U₀ + R_insulation)
   ...
   Where R_insulation comes from Table 14: Insulation thickness and
   corresponding resistance.
   ...
   R = 0.025 × T + 0.25 when λ = 0.04 W/m·K
   R = 0.0333 × T + 0.248 when λ = 0.03 W/m·K
   R = 0.040 × T + 0.25 when λ = 0.025 W/m·K
   Where T is thickness of insulation in mm"

Cert 000565 Main lodgement (Summary §7.0):
  Type SO Solid Brick (wall_construction = 3)
  Insulation E External (wall_insulation_type = 1)
  Insulation Thickness 75 mm
  Wall Thickness 300 mm (measured)
  Conductivity Known No  → λ defaults to 0.04 (§5.8 final note)
  Age band A

Formula chain:
  U₀ = 1.4 (§5.7 Table 13 row "280 to 420 mm")
  R  = 0.025 × 75 + 0.25 = 2.125 m²K/W
  U  = 1 / (1/1.4 + 2.125) = 1 / 2.8393 = 0.3522 → 0.35 (2 d.p.)

Pre-slice the cascade bucketed 75 mm into the Table-6 "100 mm
external/internal insulation" row → 0.32 for age A. The -0.03 U
delta on Main's 51.72 m² external wall is the entire -1.54 W/K
under-count driving the cohort's remaining fabric residual.

RdSAP 10 Table 6 footnote (a) (PDF p.34) verbatim:

  "Or from equations in 5.6 if the calculated U-value is less than
   1.7."

Applies only to the AS-BUILT (no insulation, no dry-line) Table 6
row. For thin walls where §5.6 gives U ≥ 1.7 the Table 6 row
default of 1.7 caps the result. Verified empirically against cert
000565 Main alt_wall_1 (granite W=120 mm dry-lined): raw §5.6 →
3.879 + dry-line → 2.34 matches worksheet, NOT capped 1.7 + dry-
line → 1.32. The cap therefore only fires when neither dry-lining
nor insulation is present (cert 000565 BP[1] Ext1: granite W=50 mm
"Insulation Unknown" → §5.6 = 6.09 → capped to 1.7, matches ws).

3-layer fix:
1. `domain/sap10_ml/rdsap_uvalues.py`:
   - Add `_u_brick_thin_wall_age_a_to_e(W_mm)` per §5.7 Table 13
   - Add `_r_insulation_table_14(T_mm, λ)` per §5.8 Table 14
     interpolation rule (handles all 3 λ columns)
   - Wire §5.7+§5.8 chain into `u_wall` for WALL_SOLID_BRICK + age
     A-E + lodged thickness + (External | Internal) insulation +
     thickness > 0
   - Add Table 6 footnote (a) cap to `_u_stone_thin_wall_age_a_to_e`
     (cap at 1.7 only when not dry-lined)
   - Round dry-lined §5.6 result to 2 d.p. (worksheet A×U precision)
2. `domain/sap10_calculator/worksheet/heat_transmission.py` passes
   `wall_thickness_mm=part.wall_thickness_mm` through to `u_wall`
   for the per-BP main wall U (previously passed only for alt walls).
3. AAA test pins cert 000565 walls_w_per_k = 604.07 within 1e-4.

Movement at HEAD `9159e91f` → post-slice (cert 000565):

Fabric (cascade vs ws):
  walls         602.53 → 604.08 (Δ -1.54 → +0.01 W/K — sub-spec
                                  alt-wall float rounding artifact)
  total W/K     935.54 → 937.09 (Δ -1.52 → +0.03 W/K — essentially
                                  zero net fabric HTC residual)

End-result pins:
  sap_score (int)    29 ✓ EXACT  (unchanged)
  sap_score_continuous 28.5380 → 28.5028  (Δ +0.0293 → -0.0059;
                                          80% magnitude reduction)
  ecf              5.3838 →  5.3874 (Δ -0.0028 → +0.0008)
  total_fuel_cost_gbp 4677.64 → 4680.78 (Δ -2.62 → +0.52)
  co2_kg_per_yr   6444.27  → 6448.34 (Δ -3.35 → +0.72)
  space_heating  58974.84  → 59020.02 (Δ -33.5 → +11.7)
  main_heating_fuel 34691.09 → 34717.66 (Δ -19.7 → +6.87)
  lighting_kwh    1382.67 (unchanged)
  pumps_fans_kwh ✓ EXACT (unchanged)

Continuous SAP magnitude improved 80% (0.0293 → 0.0059). All
SH-driven downstream residuals (cost, co2, SH kwh, main_heating
fuel) magnitude-reduced 65-80%. Integer SAP stays exact at 29.

Cohort safety verified: 6 cohort certs (000474-000516) lodge wc=4
(cavity) + wit=4 (as-built) — neither precondition for the new
§5.7+§5.8 path. §5.6 cap only fires when not dry-lined (cohort
certs don't trigger). All 11 cert→inputs and 6 sap_result_pin
cohort tests pass unchanged.

Golden cert 6035-7729-2309-0879-2296 (mid-terrace age A solid
brick) sees the §5.7+§5.8 chain fire on its Main wall:
  PE  +46.7562 → +46.0936 kWh/m² (cascade closer to actual EPC)
  CO2 +1.0652  → +1.0495 tonnes/yr (cascade closer to actual EPC)
Per [[feedback-golden-residuals-near-zero]] the expected pin is
updated to track the improvement (target → ~0 as mapper closes).

Test count: 608 pass + 7 expected 000565 fails → **608 pass + 7
expected 000565 fails** (new §5.7+§5.8 formula test green; golden
cert 6035 pin re-pinned; integer SAP stays at 29). Pyright net-zero
per touched file (27 baseline → 27 post-change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 18:10:33 +00:00
Khalim Conn-Kowlessar
9159e91fbc Slice S0380.108: Connected-to-heated-space RR gables deduct from A_RR (RdSAP 10 §3.9.2 + Table 4 row 4)
Closes the largest single localised fabric residual on cert 000565
(roof +1.59 W/K over, area +4.70 m² over) by routing
Connected-gable surfaces through a new `connected_wall` kind that
deducts area from the residual A_RR per the spec but contributes
0 W/K per RdSAP 10 Table 4 row 4.

RdSAP 10 §3.9.2 step (d) (PDF p.23) verbatim:

  "The areas of gable walls are deducted from the calculated total
   RR area, and the remaining area of RR, ARR_final is then
   calculated. This area is treated as roof structure.
       ARR_final = ARR_wall − (ΣARR_common_wall + ΣARR_gable +
                               ΣARR_party + ΣARR_sheltered +
                               ΣARR_connected)"

RdSAP 10 Table 4 row 4 (PDF p.22):

  "ARR_connected — Adjacent to heated space — U-value = 0"

The U=0 means no heat-loss contribution, but the area STILL appears
in the deduction equation as ΣARR_connected. Pre-slice the mapper's
`_map_elmhurst_rir_surface` returned None for Connected gables,
dropping them entirely from `detailed_surfaces` so the cascade
neither billed them nor deducted them. The residual A_RR was
therefore over by their lodged area.

Cert 000565 Ext1 §8.1 lodges (Simplified Type 2):
  Gable Wall 1   L=4.00  H=6.00  Connected  U=0
  Gable Wall 2   L=8.00  H=9.00  Exposed    U=1.70
  Common Wall 1  L=9.00  H=1.00  U=1.70
  Common Wall 2  L=5.00  H=1.80  U=1.70

Gable Wall 1 area via §3.9.2 quadratic:
  A_gable_1 = 4 × (0.25 + 6)
              − (6 − 1)²/2   ← subtract triangle above Common Wall 1
              − (6 − 1.8)²/2 ← subtract triangle above Common Wall 2
            = 25.0 − 12.5 − 8.82
            = 3.68 m²

Pre-slice:
  A_RR shell = 12.5 × √(34 / 1.5) = 59.51 m²
  Σ wall areas = 11.25 + 10.25 + 16.08 = 37.58 m²
  Residual    = 21.93 m² (worksheet: 18.25; over by +3.68)
  Roof W/K = 21.93 × 0.35 = 7.68 (worksheet: 6.39; over by +1.29)

3-layer fix:
1. Mapper `_map_elmhurst_rir_surface` (datatypes/epc/domain/mapper.py)
   now routes "Connected" gable_type to kind="connected_wall" with
   u_value=0 and area via the Simplified Type 2 quadratic correction.
2. Heat transmission `heat_transmission_from_cert` (domain/sap10_
   calculator/worksheet/heat_transmission.py) adds a connected_wall
   branch that deducts area from rr_walls_in_a_rr_area but skips
   walls/party W/K contribution.
3. AAA test pins Ext1 Connected gable area at 3.68 m² and U=0.

Movement at HEAD `b7fa5f74` → post-slice (cert 000565):

Fabric (cascade vs ws):
  walls           602.53 → 602.53 (Δ -1.54 W/K; unchanged)
  roof             52.97 →  51.68 (Δ +1.59 → +0.30 W/K; closes 81%)
  TB              129.35 → 128.80 (Δ +0.70 → +0.15 W/K; closes 79%)
  total area      862.34 → 858.66 (Δ +4.70 → +1.02 m²; closes 78%)
  total W/K       937.40 → 935.54 (Δ +0.33 → -1.52 W/K; sign flips)

End-result pins:
  **sap_score (int)   28 → 29 ✓ EXACT vs ws 29**  (RECOVERED from
                                                   S0380.107 transient
                                                   rounding flip)
  sap_score_continuous 28.4959 → 28.5380 (Δ -0.0128 → +0.0293)
  ecf                   5.3881 →  5.3838 (Δ +0.0015 → -0.0028)
  total_fuel_cost_gbp 4681.39  → 4677.64 (Δ +1.13 → -2.62)
  co2_kg_per_yr      6449.13  → 6444.27 (Δ +1.51 → -3.35)
  space_heating_kwh 59028.80  → 58974.84 (Δ +20.5 → -33.5)
  main_heating_fuel 34722.83  → 34691.09 (Δ +12.0 → -19.7)
  lighting_kwh       1382.67  → 1382.67 (unchanged)
  pumps_fans_kwh ✓ EXACT (unchanged)

Continuous SAP and downstream pins SIGN-FLIPPED again
(cascade was over post-.107, now under post-.108). Per user
direction: transient drift acceptable while closing a true
intermediate-value bug. The remaining net HTC -1.52 W/K is
mostly walls (-1.54 W/K) — closing the Detailed-RR walls
residual is the next leverage front.

Cohort safety: none of the 6 cohort certs lodge a Connected
gable (grep audit across all Summary fixtures). The new
`connected_wall` branch only fires for the cert 000565 Ext1 BP.

Test count: 606 pass + 8 expected 000565 fails → **608 pass +
7 expected 000565 fails** (sap_score back to exact + new
Connected-gable test green). Pyright net-zero per touched
file (57 baseline → 57 post-change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 17:40:42 +00:00
Khalim Conn-Kowlessar
44fb8c0724 Slice S0380.100: MEV SFPav + (230a) cascade helpers (SAP 10.2 §2.6.4 + Table 4f)
SAP 10.2 specification (14-03-2025) §2.6.4 (PDF p.16):

  "In the case of decentralised MEV the specific fan power is provided
   for each fan and an average value is calculated for the purposes of
   the SAP calculations. There are two types of fan, one for kitchens
   and one for other wet rooms, and three types of fan location (in
   room with ducting, in duct, or through wall with no duct). [...]
   The average SFP, including adjustments for the in-use factors, is
   given by:

       SFPav = Σ(SFP_j × FR_j × IUF_j) / Σ(FR_j)             (1)

   where the summation is over all the fans, j represents each
   individual fan, FR is the flow rate which is 13 l/s for kitchens
   and 8 l/s for all other wet rooms, and IUF is the applicable
   in-use factor."

And SAP 10.2 §5 Table 4f line (230a):

  "Annual electricity for mechanical ventilation fans (kWh/year) =
   IUF × SFP × 1.22 × V"

This slice lands the two pure-function cascade primitives:

  mev_sfp_av(fan_entries) -> float        # equation (1)
  mev_decentralised_kwh_per_yr(*, sfp_av, V) -> float   # (230a)

`MevFanEntry` carries the per-fan resolved (SFP_w_per_l_per_s, flow_l_
per_s, IUF) triple. Callers (PCDB Table 322 + Table 329 + cert
lodgement of duct type) compose the entries upstream; the cascade
helper does no PCDB resolution itself.

Cert 000565 worksheet line (230a) pinned at 1e-4:
  Σ FR = 92.0 l/s  (matches worksheet "total flow")
  Σ SFP×FR×IUF = 11.7205 W  (matches worksheet "total watage")
  SFPav = 11.7205 / 92.0 = 0.1274 W/(l/s) ✓ vs ws 0.1274
  (230a) = 0.1274 × 1.22 × 820.4385 = 127.5159 ✓ vs ws 127.5159

Pure-function helpers; no cascade integration yet. Next slice
S0380.101 wires HP category mapper; S0380.102 wires cert→inputs
to invoke the cascade. Pyright net-zero per touched file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:28:59 +00:00
Khalim Conn-Kowlessar
fa6974bdd9 Slice S0380.95: Detailed-RR residual area cascade per RdSAP 10 §3.10.1
RdSAP 10 §3.10.1 (PDF p.24) "Default U-values of the roof rooms":

> "The residual area (area of roof less the floor area of room(s)-in-
>  roof) has a U-value from Table 16 : Roof U-values when loft
>  insulation thickness is known according to its insulation thickness
>  if at least half the area concerned is accessible, otherwise it is
>  the default for the age band of the original property or extension."

Plus RdSAP 10 §3.9.1 step (d-e) (PDF p.21-22) — the Simplified A_RR
formula `12.5 × √(A_RR_floor / 1.5)` is the empirical estimator for
the total RR exposed shell; residual = A_RR − Σ lodged walls. The
worksheet applies this same formula to Detailed mode when the lodged
surface set has no roof-going entries (cert 000565 BP[0]:
12.5 × √(45/1.5) − (9.8 + 14.7) = 43.96 ≈ ws 43.97).

Pre-slice the cascade computed residual area ONLY in the Simplified
RR branch (via `_part_geometry`'s `rr_simplified_a_rr_m2` − rr_common
− rr_gable subtractions). The Detailed-RR branch in
`heat_transmission` iterated `rir.detailed_surfaces` and missed the
residual entirely. Cert 000565 routes all 5 BPs through Detailed mode
(the Elmhurst mapper translates Summary "Simplified" lodgements to
`SapRoomInRoofSurface` records when per-surface L×H is present), so
cascade total_external_element_area_m2 was 779.27 m² vs worksheet
(31) = 857.64 m² (Δ −78.37 m² → thermal_bridging cascade −11.76 W/K
under).

Slice span (1 file):
- `heat_transmission.py`: Detailed-RR branch adds residual area via
  the §3.9.1 A_RR formula minus wall-going lodgements (gable_wall,
  gable_wall_external, common_wall). Residual area contributes to
  `rr_detailed_area` (→ part_external_area → (31) → thermal_bridging
  multiplier) and to `roof` at `u_rr_default_all_elements`.
- Discriminator: residual fires only when no roof-going surface kinds
  (slope, flat_ceiling, stud_wall) are lodged — true Detailed-mode
  lodgements (cohort fixture 000516) lodge the entire roof shell
  explicitly and have no residual.

Cert 000565 movement (HEAD `78c57c0d` → this slice):
  - thermal_bridging_w_per_k:    116.89 → 129.35 ✓ vs ws 128.65 (Δ +0.70)
  - total_external_area_m2:      779.27 → 862.34 ✓ vs ws 857.64 (Δ +4.70)
  - roof_w_per_k:                34.64  → 63.72 (Δ −16.74 → +12.34)
  - sap_score_continuous:        29.02  → 28.07 (Δ +0.51 → −0.44)
  - sap_score (integer):         29 → 28        (temp regression
                                                  past 28.5 threshold)
  - space_heating_kwh:           −685   → +533
  - main_heating_fuel:           −403   → +321
  - hot_water_kwh:               ✓ 0 EXACT unchanged

Per user direction temporary continuous-SAP drift is acceptable when
fixing real spec-correct sub-component bugs; the absolute continuous-
SAP residual is now −0.44 (was +0.51) — slightly closer to zero
overall. The roof overshoot localises to:
  - BP[4] Flat Ceiling 1 "Unknown PUR or PIR" lodgement (cascade 2.30
    vs ws 0.15, over by +10.75 W/K) — Elmhurst-specific "Unknown +
    known material" convention not yet wired
  - BP[1] residual formula gives +3.68 m² over worksheet (Δ +1.29 W/K)
    — Detailed-mode residual is spec-ambiguous for extensions with
    non-2.45 m RR height; future slice may add a height-aware formula

Cohort safety: discriminator `has_roof_lodgement` filters out true
Detailed-mode lodgements (cohort fixtures 000474/000477/000480/
000487/000490/000516 all lodge slope/flat_ceiling/stud_wall surfaces).
Initial implementation broke 41 cohort pins; the discriminator
restores cohort behaviour exactly. Test baseline: 585 pass + 9
expected `000565` fails (was 585 + 8 — sap_score moved from passing
to failing during the slice's transient overshoot; expected per
user direction).

Pyright net-zero per touched file (test_summary_pdf_mapper_chain.py
13 → 13 preserved; heat_transmission.py 13 → 12 improved by −1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:21:59 +00:00
Khalim Conn-Kowlessar
23aaa4fa66 Slice S0380.93: floor above partially-heated space U=0.7 (RdSAP 10 §5.14)
RdSAP 10 §5.14 (PDF p.47) "U-value of floor above a partially heated
space":

> "The U-value of a floor above partially heated premises is taken as
>  0.7 W/m²K. This applies typically for a flat above non-domestic
>  premises that are not heated to the same extent or duration as the
>  flat."

Cert 000565 Ext1 lodges Summary §9 "Location: P Above partially
heated space" + "Default U-value: 0.70". Worksheet line (28b) confirms
"Exposed floor Ext1 ... 34.0000 0.7000 23.8000".

Pre-slice the cascade routed BP[1] floor through the BS EN ISO 13370
ground-floor formula (the "else" branch of the floor U-value dispatch
in `heat_transmission.py`) — producing cascade U=0.76 vs spec 0.70.
Over-counted floor heat loss by (0.76 − 0.70) × 34 m² = +2.04 W/K on
the part subtotal and on the total HTC.

Slice span (4 layers):
1. **Helper** — `u_floor_above_partially_heated_space()` in
   `domain/sap10_ml/rdsap_uvalues.py`, verbatim spec constant 0.7
   (no age-band / insulation-thickness inputs). Lives in `sap10_ml`
   per [[project-sap10_ml-deprecation]] (edit existing file fine).
2. **Schema** — `SapFloorDimension.is_above_partially_heated_space:
   bool = False` (parallel to existing `is_exposed_floor`). Mutually
   exclusive with the exposed-floor / basement-floor branches.
3. **Mapper** — new `_is_floor_above_partially_heated_space(location)`
   helper detecting "above partially heated" in the Elmhurst §9 floor
   location string. Plumbed into `_map_elmhurst_building_part` floor-
   dim construction; only applies to the ground floor (i==0).
4. **Cascade** — `heat_transmission.py` adds a new branch between
   the exposed-floor and ground-floor branches: `is_above_partial →
   u_floor_above_partially_heated_space()`.

Cert 000565 movement (HEAD `a7894b11` → this slice):
  - cascade floor_w_per_k:    72.41 → 70.37 (Δ +10.74 → Δ +8.70)
  - cascade BP[1] floor U:    0.76  → 0.70  (✓ EXACT vs ws 0.70)
  - sap_score (integer):      29 ✓ EXACT (unchanged — at goal)
  - sap_score_continuous:     28.7663 → 28.8131 (+0.0468 drift)
  - space_heating_kwh:        −367 → −427 (small drift further under)
  - main_heating_fuel:        −216 → −251 (downstream of SH)
  - co2_kg_per_yr:            −32   → −37
  - total_fuel_cost_gbp:      −23   → −27
  - hot_water_kwh:            ✓ 0 EXACT unchanged

The small continuous-SAP drift is the expected arithmetic of closing
a single component when adjacent components remain unclosed (floor
+10.74 was cancelling thermal_bridging −11.76 + roof −7.94 at the
net-HTC level). Per [[feedback-zero-error-strict]] + [[feedback-
spec-citation-in-commits]] the spec-correct slice ships regardless
of transient continuous-SAP drift; remaining residual components
(floor +8.70 from BP[2] Ext2 lodged 200 mm insulation thickness;
roof −7.94; thermal_bridging −11.76; walls −1.67) each get their own
spec-cited slice.

Cohort safety: only cert 000565 Ext1 in the cohort lodges "Above
partially heated space". All other Elmhurst cohort fixtures + 9
golden + 38 cohort-2 API certs default to `is_above_partially_
heated_space=False` so cascade behaviour is unchanged.

Test baseline: 583 pass + 8 expected `000565` fails (was 582 + 8;
+1 new mapper-chain test). Pyright net-zero per touched file
(1/65/1/32/13/13 preserved).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:53:28 +00:00
Khalim Conn-Kowlessar
6c8bbbc9e2 Slice S0380.86: §5.6 thin-wall stone + §5.8 dry-line closes BP[0] alt1 cascade gap
RdSAP 10 §5.6 (PDF p.40) "U-values of uninsulated stone walls, age
bands A to E":

  Table 12 — Default U-values of stone walls
    Sandstone or limestone:    U = 54.876 × W^(-0.561)
    Granite or whinstone:      U = 45.315 × W^(-0.513)
  Where W is wall thickness in mm.

  "Apply the adjustment according to Table 14: Insulation thickness
   and corresponding resistance if wall is insulated or dry-lined
   including lath and plaster."

Combined with §5.8 (PDF p.40) + Table 14 (PDF p.41) dry-line R = 0.17
m²K/W: U = 1 / (1/U₀ + 0.17).

Cert 000565 BP[0] Main alt1 is the cohort fixture: Stone Granite, age
band A (inherited from Main), 120 mm wall thickness, dry-lined.
§5.6 formula: U₀ = 45.315 × 120^(-0.513) ≈ 3.8871.
§5.8 + Table 14 dry-line: U = 1/(1/3.8871 + 0.17) ≈ **2.3405**.
→ matches worksheet U985-0001-000565 line (29a) "External walls Main
alt.1 ... SolidWallDensePlasterInsul, Solid, 0.0, 2.34" EXACT.

Pre-S0380.86 two coupled bugs blocked this path:

  1. Mapper mis-name per [[feedback-no-misleading-insulation-type]]:
     `_map_elmhurst_alternative_wall` routed the Elmhurst Summary §7
     "Alternative Wall N Thickness" lodging (the WALL thickness)
     onto `SapAlternativeWall.wall_insulation_thickness="120"`. The
     cascade then mis-bucketed it as 100 mm insulation (bucket=100
     → _BRICK_INS_100 row at age A → U=0.32). The Elmhurst Summary
     schema has no "Alternative Wall N Insulation Thickness" line at
     all — `wall_insulation_thickness` on alts was always
     semantically the wall thickness, never insulation.

  2. `u_wall` had no §5.6 thin-wall stone branch. Stone constructions
     fell through to Table 6 row values (designed for typical-
     thickness ~300mm+ walls), which dramatically under-state heat
     loss for sub-200mm stone.

Fix span:

  - datatypes/epc/domain/epc_property_data.py:SapAlternativeWall:
      new `wall_thickness_mm: Optional[int] = None` field, mirroring
      `SapBuildingPart.wall_thickness_mm`.
  - datatypes/epc/domain/mapper.py:_map_elmhurst_alternative_wall:
      routes Elmhurst `a.thickness_mm` (Wall thickness) onto
      `wall_thickness_mm`; leaves `wall_insulation_thickness=None`
      on this path (no Elmhurst Summary alt-wall insulation-thickness
      line exists).
  - domain/sap10_ml/rdsap_uvalues.py:
      new `_u_stone_thin_wall_age_a_to_e(construction, W)` helper
      implements §5.6 Table 12 formulas. `u_wall` accepts a new
      `wall_thickness_mm: Optional[int] = None` param; dispatches
      §5.6 formula when (a) wall thickness lodged, (b) age band ∈
      A-E, (c) construction ∈ {STONE_GRANITE, STONE_SANDSTONE}.
      §5.8 + Table 14 R=0.17 applied on top when dry_lined=True.
  - domain/sap10_calculator/worksheet/heat_transmission.py:
      `_alt_wall_contribution_w_per_k` passes
      `wall_thickness_mm=alt_wall.wall_thickness_mm` to `u_wall`.

Tests (7 new, AAA-structure):

  - 5 in domain/sap10_ml/tests/test_rdsap_uvalues.py — granite at
    120 mm with dry-line (U=2.34); granite raw formula (U=3.89);
    sandstone (U=3.74); age-G gate (Table 6 row, NOT formula); no
    wall_thickness fallback (Table 6 row 1.7).
  - 2 in backend/documents_parser/tests/test_summary_pdf_mapper_chain
    .py — mapper pin (wall_thickness_mm=120 on BP[0] alt1;
    wall_insulation_thickness=None) and cascade pin (walls_w_per_k
    ≥ 595, post-S0380.85 was 555.93).

**Cert 000565 cascade walls: 555.93 → 602.40 W/K (worksheet 604.07;
0.27% residual).** BP[0] alt1 cascade U: 0.32 → 2.34. Cascade walls
within 2 W/K of worksheet target across S0380.85+.86 closure cycle.

Test baseline: 560 pass (was 558 + 7 new − 5 already passing pins
that moved) + 9 expected `test_sap_result_pin[000565-*]` fails
unchanged. Cohort + golden + cert 9501 unaffected: of the 6 cohort
fixtures only cert 000565 alt1 lodged a `wall_insulation_thickness`
value on `SapAlternativeWall` (audit confirmed) — and that value was
always semantically the wall thickness, so the rename is a fix not
a behaviour change. The API mapper path defaults `wall_thickness_mm`
to None (API schema doesn't yet surface alt-wall thickness; safe
forward-compat).

Per [[feedback-verify-handover-claims]]: the post-S0380.84 handover
predicted SH residual would close after the wall fixes. Empirically
SH grew +2591 → +6348 → +7924 across S0380.84/.85/.86 — confirming a
SEPARATE SH-channel over-count that's independent of fabric (each
+1 W/K of spec-correct walls adds ~33.5 kWh of cascade SH, vs the
worksheet's ~38.96 kWh/W/K rate). The walls fixes are spec-correct;
the SH over-count is now a single isolated open work-item for the
next slice (~+8 k kWh structural).

Pyright net-zero per touched file (test_rdsap_uvalues.py error count
actually decreased by 1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:37:46 +00:00
Khalim Conn-Kowlessar
647c1aad0e Slice S0380.85: Curtain Wall §5.18 dispatch closes BP[2] Ext2 cascade gap
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>
2026-05-29 23:55:49 +00:00
Khalim Conn-Kowlessar
49622f5525 Slice S0380.84: RR mapper spec-correct routing + cascade common_wall handling per RdSAP 10 §3.9.2/§3.10
Cascades the spec-correct §3.10 Room-in-Roof routing through the
mapper + heat-transmission section. Three coupled changes:

1. **Mapper drops "Connected" gables** — per RdSAP 10 Table 4 (PDF p.22)
   row 4 a gable wall "Connected to heated space" is an internal
   partition, NOT a heat-loss surface. The Elmhurst Summary §8.1 PDF
   may lodge the short form "Connected" or the verbose "Connected to
   heated space"; both route to `return None` in
   `_map_elmhurst_rir_surface`.

2. **Mapper routes "Exposed" gables → `gable_wall_external` with the
   lodged U** — per Table 4 row 1 an exposed RR gable wall bills at the
   lodged U-value (or the storey-below main-wall U). For non-flat
   dwellings the `default_u_value` rides through as `u_value` override
   so the cascade uses the lodged figure directly. Flats preserve their
   legacy no-override routing so the cascade falls through to main-wall
   U (cert 9501).

3. **Mapper surfaces Common Wall surfaces + applies spec area formula**
   per RdSAP 10 §3.9.2 + Table 4:

       Detailed assessment           → raw L × H per surface
       Simplified + Common Walls     → L × (0.25 + H) for common walls;
                                        L × (0.25 + H_gable)
                                          − Σ_n (H_gable − H_common,n)² / 2
                                        for gables
       Simplified + no Common Walls  → raw L × H for gables

   The 0.25-m structural-gap offset accounts for the space between the
   RR floor and the storey-below ceiling. The gable correction
   subtracts the triangular slice above each common wall.

4. **Cascade adds `common_wall` kind** in `heat_transmission.py` — mirror
   of `gable_wall_external`: walls += area × (`surf.u_value` or main-wall
   U). Mapper precomputes the spec area so the cascade reads `area_m2`
   directly.

Verified against the cert 000565 U985 worksheet PDF "External Walls"
section per BP:

  | BP | Surface             | Formula                                   | Worksheet | Cascade |
  |----|---------------------|-------------------------------------------|-----------|---------|
  | 0  | Main GW1 (Exposed)  | 4 × 2.45 (Simplified, no CW)              | 9.80      | 9.80 ✓ |
  | 0  | Main GW2 (Sheltered)| 6 × 2.45                                  | 14.70     | 14.70 ✓|
  | 1  | Ext1 CW1            | 9 × (0.25 + 1.0)        (Simplified + CW) | 11.25     | 11.25 ✓|
  | 1  | Ext1 CW2            | 5 × (0.25 + 1.8)                          | 10.25     | 10.25 ✓|
  | 1  | Ext1 GW2 (Exposed)  | 8 × (0.25 + 9) − ((9−1)²+(9−1.8)²)/2      | 16.08     | 16.08 ✓|
  | 2  | Ext2 GW2 (Exposed)  | 3 × 8                  (Detailed)         | 24.00     | 24.00 ✓|
  | 3  | Ext3 CW1            | 5 × (0.25 + 1.5)        (Simplified + CW) | 8.75      | 8.75 ✓ |
  | 3  | Ext3 CW2            | 7.5 × (0.25 + 0.3)                        | 4.13      | 4.13 ✓ |
  | 3  | Ext3 GW1 (Exposed)  | 9 × (0.25+7) − ((7−1.5)²+(7−0.3)²)/2      | 27.68     | 27.68 ✓|
  | 4  | Ext4 CW1            | 4 × 1                  (Detailed)         | 4.00      | 4.00 ✓ |
  | 4  | Ext4 CW2            | 3.5 × 0.6                                 | 2.10      | 2.10 ✓ |

Cohort impact:
  - Cert 9501 (top-floor flat with Detailed RR + Exposed gables) —
    PASSES (the flat-RR elif still routes; gables stay at main-wall U
    via cascade fall-through).
  - All other cohort fixtures: unaffected (no RR or fully-Detailed RR
    where raw L × H is also the spec answer).

Cert 000565 cascade subtotals close substantially:
  walls       322.21 → 443.51  (worksheet 604.07, Δ −282 → Δ −161, 43% closed)
  party walls 153.46 →  93.26  (worksheet  65.13, Δ  +88 → Δ  +28, 68% closed)
  HTC fabric  716.43 → 795.24  (Δ +79 W/K — cascade closer to worksheet)

The remaining 161 W/K under-count in walls + 28 W/K over-count in
party walls localise to the BP main-wall cascade (NOT RR). The cert
000565 sap_score e2e pin regresses from EXACT (29) to Δ−3 (26) because
the previous compensating cascade gaps are now exposed — the
spec-correct fix is real, the residual is real, and the next slice
closes the BP main-wall gap (likely the "External walls Main alt.1"
basement-override at 23 m² × U=2.34 = 53.82 W/K + per-BP main-wall
U/area refinements). Per [[feedback-spec-citation-in-commits]] +
[[feedback-spec-floor-skepticism]] the spec-correct fix ships even
when the test pin temporarily regresses; the diagnostic signal is
sharper now.

Test baseline: 555 pass + 9 expected `test_sap_result_pin[000565-*]`
fails (was 555 + 8; sap_score now in the failing set with cascade-
exposed BP main-wall gap surfaced). Cohort + golden fixtures
unaffected. Pyright net-zero on touched files (59 errors, matches
baseline).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:16:34 +00:00
Khalim Conn-Kowlessar
a9143d0921 Slice S0380.75: Wire Appendix H orchestrator into cascade; cert 000565 HW +272 → −69
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>
2026-05-29 18:37:56 +00:00
Khalim Conn-Kowlessar
3bf728ce2f Slice S0380.74: Appendix H (H7) U3.3 monthly-integrated convention closes 1.81× over-count
Root cause: SAP 10.2 has an internal unit-convention ambiguity for
(H7)m between page 75 (Equation H1 implies W/m² 24-hour-average flux)
and page 76 (verbatim "Monthly solar radiation per m² from U3.3 in
Appendix U", i.e. kWh/m²/month monthly integrated). Page 77 (H23)
formula's `× hours / 1000` term double-converts when (H7) is W/m².

The cascade's `surface_solar_flux_w_per_m2` returns the §U3.2 24h-avg
flux in W/m² (verified bit-exact vs Elmhurst worksheet line 295: SE
90° Jan region 0 = 36.7938 W/m²). The (H9) helper was using this
directly without applying the U3.3 conversion that page 76's "from
U3.3" cross-reference calls for. Elmhurst-certified software follows
the U3.3 reading.

SAP 10.2 spec p.76 line (H7): "Monthly solar radiation per m² from
U3.3 in Appendix U". Appendix U §U3.3 (p.130) defines the conversion
S_monthly = 0.024 × n_m × S(orient,p,m), where S(orient,p,m) is the
§U3.2 24-hour-average flux in W/m². Therefore:

  (H7)m_U3.3 [kWh/m²/month] = flux_U3.2 [W/m²] × hours / 1000

Option A fix (per ChatGPT-mediated research): apply the U3.3
conversion inside the (H9) helper, so (H9) is in kWh/month rather
than W. Spec p.77 (H23) formula then carries the conversion's
dimensional residue correctly without double-counting.

Diagnostic that closed the trap: back-solving poly(X_cas, Y_eff) =
ws_H24/H17 at fixed X across 24 worksheet-positive observations from
4 cert fixtures (000565 + new A/B/C at sap worksheets/Solar PV tests/)
revealed Y_eff/Y_cascade took ONLY two distinct values:
- 0.7200 (exact) for every 30-day month observation
- 0.7440 (exact) for every 31-day month observation
i.e. exactly days × 24 / 1000. No utilizability function, no missing
constant — a per-month unit-conversion factor that the polynomial
non-linearity had been masking.

Closure metrics (HEAD post-fix):
- 000565 (W-30, modest): annual Δ −0.0000 kWh (every month exact)
- A-baseline (S-30, modest): annual Δ +0.0001 kWh
- B-highY (S-30, none): annual Δ −0.0000 kWh (incl Oct 10.5905)
- C-lowY (N-60, signif): annual Δ −4.36 kWh (polynomial zero-clamp
  boundary; worksheet poly = 0.0024 → 0.41 kWh, cascade poly =
  −0.04 → 0)

47/48 month-observations pin at <1e-4 kWh.

Test baseline: 547 pass + 9 expected `test_sap_result_pin[000565-*]`
cascade-gap fails (unchanged — orchestrator still NOT integrated
into water_heating.py:943; that's the follow-on slice that closes
cert 000565's HW pin +272 → ~0).

Pyright net-zero on both touched files.

Files:
- domain/sap10_calculator/worksheet/appendix_h_solar.py: rename
  `monthly_solar_energy_available_h9_w` → `_h9_kwh_per_month`,
  add `hours_in_month` param, apply U3.3 conversion. Y23 param
  renamed accordingly. Orchestrator updated.
- domain/sap10_calculator/worksheet/tests/test_appendix_h_solar.py:
  add cert 000565 (H24)m monthly magnitude pin at abs < 1e-3 kWh;
  update H9 + Y23 unit tests for new kWh/month units.
- BRIEF_APPENDIX_H_EN_15316_RESEARCH.md: new "Closure" section with
  the days-in-month diagnostic, root cause, and lessons.
- HANDOVER_POST_4_CERT_EMPIRICAL.md: NEW — closure handover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 18:02:35 +00:00
Khalim Conn-Kowlessar
f0ab7446b2 Slice S0380.68: Appendix H (H7)m flux helper + top-level orchestrator
Builds on S0380.66 (Appendix H pure helpers) + S0380.67 (W·h → kWh
unit fix) to assemble the spec-ordered H7 → H9 → … → H24 cascade
into a single entry point. Cert 000565's complete Appendix H input
set now flows through one call:

    h24 = solar_water_heating_input_monthly_kwh(
        collector_orientation=Orientation.W, collector_pitch_deg=30.0,
        region=0,                               # UK average per rating
        aperture_area_m2=3.0,                   # (H1)
        zero_loss_efficiency=0.8,               # (H2)
        linear_heat_loss_a1=4.0,                # (H3)
        second_order_heat_loss_a2=0.01,         # (H4)
        loop_efficiency=0.9,                    # (H5)
        incidence_angle_modifier=0.94,          # (H6)
        overshading_factor=0.8,                 # (H8) Table H2 "Modest"
        overall_heat_loss_coefficient_from_test=6.5,  # (H10) override
        dedicated_solar_storage_volume_l=53.0,  # (H12)
        combined_cylinder_total_volume_l=160.0, # (H13)
        hot_water_demand_monthly_kwh=...,       # (62)m
        wwhrs_monthly_kwh=(0,)*12,              # (63a)m
        cold_water_temperatures_monthly_c=TABLE_J1_TCOLD_FROM_MAINS_C,
        external_temperatures_monthly_c=...,    # (96)m
        solar_hot_water_only=True,
    )

New module surface:
- `monthly_collector_solar_flux_w_per_m2` — thin 12-month wrapper over
  the existing `surface_solar_flux_w_per_m2` (Appendix U §U3.2
  orientation + tilt polynomial). Cert 000565 collector: W, 30° pitch,
  Thames Valley.
- `solar_water_heating_input_monthly_kwh` — chains all line-ref
  helpers in spec order; returns (H24)m as a 12-tuple.

Tests:
- `test_monthly_collector_solar_flux_h7_returns_twelve_values_
  matching_appendix_u` — smoke test pinning Jan < May < Jun shape
  for the W-facing 30°-pitched collector.
- `test_solar_water_heating_input_monthly_kwh_returns_winter_zero_
  summer_peak_shape` — orchestrator shape pin: 12-month tuple, all
  non-negative, winter clamp to zero (Jan/Feb/Nov/Dec via Equation
  H1's negative-X dominance), monotone Mar < May, Sep < Jun.

Magnitude pin against worksheet line 415 (Σ(H24)1..12 = 281.3478)
is DEFERRED to the next slice: current orchestrator output is
~510 kWh annual (1.8× the worksheet), traced to a spec ambiguity
between the top-level Equation H1 Y formula
(Y = Px · Aap · IAM · η0 · ηloop · Im · Hm / (1000 · Dm) — excludes
overshading H8) and the line-ref (H23) formulation
(Y = [(H18) · (H6) · (H5) · (H9) · ((41) · 24)] / [1000 · (H17)],
where (H9) = (H1) · (H2) · (H7) · (H8) includes H8). Both are
present in SAP 10.2 spec page 75-76 and differ by a factor of H8
(0.8 for cert 000565). Picking the spec-correct branch requires
either a worksheet trace of one cert's (H22)/(H23) intermediates or
a confirmed errata; the next slice runs that down and pins the
magnitude.

Test suite: 279 pass + 9 expected 000565 cascade-gap fails (unchanged
— orchestrator is not yet wired into `water_heating_from_cert`).
Pyright net-zero on both touched files.

Spec source: SAP 10.2 specification (14-03-2025) Appendix H pp.74-78
+ Appendix U §U3.2 page 127.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 10:02:29 +00:00
Khalim Conn-Kowlessar
2795e2569d Slice S0380.67: Appendix H (H9) helper + fix (H23) W·h → kWh units
S0380.66 landed (H23) with an incorrect unit treatment: the spec
formula on SAP 10.2 p.76 is

    Y_HW = [(H18)m × (H6) × (H5) × (H9)m × (41)m × 24] ÷ [1000 × (H17)m]

and Appendix U (per `domain/sap10_calculator/climate/appendix_u.
horizontal_solar_irradiance_w_per_m2`) returns (H7)m as a monthly-
average flux in W/m². That makes (H9)m = (H1) × (H2) × (H7)m × (H8)
an instantaneous power in W — the `× hours × 24 / 1000` factor in
the (H23) formula is what time-integrates W·h → kWh so the Y_HW
ratio lands dimensionless against (H17)m (kWh/month).

S0380.66's (H23) elided the time integration by absorbing it into
the input parameter (a kWh/m²/month name) — that broke unit
consistency with the downstream Appendix U integration this module
will consume in the next slice.

Changes:
- New `monthly_solar_energy_available_h9_w` — pure (H9)m calculator
  taking aperture, η₀, (H7)m flux tuple, and overshading. Returns W.
- `hot_water_factor_y_monthly_h23`: parameter renamed
  `monthly_solar_energy_available_h9_w` (was `..._kwh_per_m2`); new
  `hours_in_month` parameter; formula now includes the spec's
  `× hours / 1000` time integration explicitly.

Tests:
- `test_monthly_solar_energy_available_h9_applies_spec_formula` —
  cert 000565 H1/H2/H8 with flat 100 W/m² flux → 192 W (the spec
  multiplicand 3 × 0.8 × 100 × 0.8).
- `test_hot_water_factor_y_h23_applies_w_to_kwh_time_integration` —
  unit-consistency pin: H9=1000 W, hours=744, H17=744 kWh → Y=1.0.
- `test_hot_water_factor_y_h23_clamps_lower_bound_at_zero` updated
  to the new parameter name and supplies `hours_in_month`.

Test suite: 277 pass + 9 expected 000565 cascade-gap fails. Pyright
net-zero on both touched files.

Spec source: SAP 10.2 specification (14-03-2025) Appendix H p.76.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 09:46:12 +00:00
Khalim Conn-Kowlessar
db4f1b3167 Slice S0380.66: SAP 10.2 Appendix H solar HW pure math module (HW path)
New module `domain/sap10_calculator/worksheet/appendix_h_solar.py`
implements line refs (H10), (H11), (H14)..(H16), (H17)..(H24) for the
hot-water solar contribution path. The space-heating path (H25)..(H29)
is deferred until a fixture exercises it — cert 000565 lodges solar
HW only (worksheet line 414 H29=0 across all months).

Algorithm per SAP 10.2 specification §Appendix H pages 74-78
(14-03-2025 revision). The monthly procedure follows EN 15316-4-3:2017:
the collector + cylinder + demand parameters feed dimensionless X and
Y ratios into Equation H1 with Table H3 (p.78) correlation factors:

    Q_s,w = (Ca·Y + Cb·X + Cc·Y² + Cd·X² + Ce·Y³ + Cf·X³) × (H17)m

clamped to [0, (H17)m] per spec p.76. Cert 000565 worksheet line 415
shows total Q_s,w = 281.3478 kWh/year delivered to HW from a 3 m²
flat-plate collector + 53 L dedicated solar storage in a 160 L
combined cylinder, W orientation, 30° pitch, Thames Valley region.

Helpers implemented:
- `overall_heat_loss_coefficient_h10`     — 5 + 0.5×(H1) or test-cert
- `loop_heat_loss_coefficient_h11`        — (H3) + 40·(H4) + (H10)/(H1)
- `effective_solar_volume_h14`            — separate / combined cylinder
- `reference_volume_h15`                  — 75 × (H1)
- `storage_tank_correction_coefficient_h16` — [(H15)/(H14)]^0.25
- `hot_water_demand_monthly_h17_kwh`      — (62)m − (63a)m
- `proportion_solar_to_hot_water_monthly_h18` — HW-only/SH-only/blend
- `hot_water_reference_temperature_h20_c` — 55 + 3.86·Tcold − 1.32·Te
- `hot_water_reference_temperature_difference_h21_c` — (H20) − (96)
- `hot_water_factor_x_monthly_h22`        — clamp [0, 18]
- `hot_water_factor_y_monthly_h23`        — clamp ≥ 0
- `heat_delivered_to_hot_water_monthly_h24_kwh` — Equation H1 + clamp
  [0, (H17)m]

18 unit tests cover:
- Spec-default vs test-certificate (H10)
- Cert 000565 worksheet pinned (H11) ≈ 6.5667 (line 407)
- Cert 000565 worksheet pinned (H14) = 85.1 (line 410)
- Cert 000565 worksheet pinned (H15) = 225 (line 411)
- Cert 000565 worksheet pinned (H16) ≈ 1.2752 (line 412)
- Separate-tank vs combined-cylinder branches of (H14)
- All three branches of (H18) (HW-only, SH-only, blend formula)
- (H20)/(H21) spec formulas verbatim
- (H22) zero-demand short-circuit + upper clamp at 18
- (H23) negative-input lower clamp at 0
- (H24) Equation H1 polynomial with Table H3 factors
- (H24) demand-cap clamp when Y dominates positive
- (H24) zero-floor clamp when X dominates negative

Scope EXCLUDES (deferred to follow-on slices):
- Appendix U §U3.3 monthly solar radiation lookup for the collector's
  orientation/tilt → (H7)m → (H9)m
- Solar SH path (H25-H29)
- Appendix H §H2 primary-loss reduction Table H4
- EpcPropertyData solar collector field schema additions
- Elmhurst + API extractor / mapper updates
- Cascade integration via `water_heating_from_cert.solar_monthly_kwh`

Pyright: 0 errors on both touched files. 275 pass + 9 expected 000565
cascade-gap fails on the handover test suite (unchanged from S0380.65).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 09:41:22 +00:00
Khalim Conn-Kowlessar
e51fcb74ca Slice S0380.52: cert 000565 Elmhurst-only mapper-driven cascade pin + glazing-label coverage
User pivot at end of prior session: don't hand-build EpcPropertyData
fixtures — route Summary PDFs through `EpcPropertyDataMapper.from_
elmhurst_site_notes` so the pin grid exercises extractor + mapper +
calculator, and each new Elmhurst doc grows mapper coverage instead
of bespoke fixture code.

New fixture cert 000565 is a stress-test cert (5 building parts, age
mix A→J, conservatory with heaters, curtain wall, basement walls,
mixed party-wall constructions) that surfaces many uncommon cascade
paths absent from the cohort-2 + ASHP corpus.

Mapper coverage extended for 3 Elmhurst §11 glazing labels surfaced
on this cert (per RdSAP-Schema-21.0.1, `datatypes/epc/domain/
epc_codes.csv` glazed_type rows):

  "Triple between 2002 and 2021": 9  (RdSAP-21 schema row 9 — triple
       glazing, installed 2002-2022 in EAW; `_G_PERPENDICULAR_BY_
       GLAZING_TYPE[9] = 0.68`, `_G_LIGHT_BY_GLAZING_CODE[9] = 0.70`)
  "Single glazing": 1                (alias of bare "Single"; cascade
       g_L = 0.90, g⊥ = 0.85 per SAP 10.2 Table 6b)
  "Double glazing, known data": 3    (Elmhurst lodgement of RdSAP-21
       schema row 7 "double, known data"; manufacturer U-value and
       g-value lodged via WindowTransmissionDetails override the
       cascade's defaults — grouped under code 3 with other unknown-
       date DG variants for cascade-equivalence on g_L/g⊥)

Per [[feedback-e2e-validation-philosophy]] + [[feedback-zero-error-
strict]]: pin tolerances are abs=1e-4 against U985-0001-000565.pdf
Block 1 line refs (pinned: SAP int + SAP continuous + ECF + total
fuel cost + CO2 + space heating + main 1 fuel + secondary fuel +
hot water + lighting + pumps/fans).

Outcome: 1/11 pin green (`secondary_heating_fuel_kwh_per_yr = 0`);
10 pins are now named calculator-gap residuals to fix in subsequent
slices:

  main_heating_fuel_kwh_per_yr  +27,665.01 kWh/yr  (heat-pump SAP code
      224 + gas combi via WHC 914 "from second main"; cascade probably
      runs ASHP for DHW instead of routing through gas combi)
  hot_water_kwh_per_yr             +164.88 kWh/yr  (FGHRS / solar HW /
      Table 3a no-keep-hot for the gas combi DHW path)
  lighting_kwh_per_yr              -236.19 kWh/yr  (RdSAP §12-1 bulb-
      count cascade; 27 total / 7 low-energy / 20 incandescent lodged)
  pumps_fans_kwh_per_yr            -122.52 kWh/yr  (cascade defaults
      to 130; expected 252.52 = MEV PCDF 500755 + flue + solar pump)

Cohort regression check: 472 pass + 10 expected 000565 failures.
Pyright net-zero (32 errors before, 32 after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:03:52 +00:00
Khalim Conn-Kowlessar
42ed38f77d Slice S0380.47: wire β-split into cost cascade per SAP 10.2 Appendix M1 §6
SAP 10.2 Appendix M1 §6 (p.94): "When calculating the fuel cost
benefits ... apply the normal import electricity price to PV energy
used within the dwelling and the 'electricity sold to grid, PV' price
from Table 12 to the energy exported."

Adds the third leg of the β-factor split (PE was S0380.45, CO2 was
S0380.46). Now uniform across all three cascades:
  PE   → IMPORT PEF × E_dw + EXPORT PEF × E_ex
  CO2  → IMPORT CO2 × E_dw + EXPORT CO2 × E_ex
  Cost → IMPORT £   × E_dw + EXPORT £   × E_ex

Mechanism:
- `worksheet/fuel_cost.py`: optional `pv_dwelling_kwh_per_yr` +
  `pv_exported_kwh_per_yr` + `pv_dwelling_import_price_gbp_per_kwh`
  keyword args; when all three are set, split the credit; otherwise
  fall back to legacy single-rate-EXPORT (preserves synthetic test
  constructions).
- `rdsap/cert_to_inputs.py`: new `_pv_dwelling_import_price_gbp_per_kwh`
  helper that pulls Table 32 code 30 (standard electricity = 13.19
  p/kWh) for standard tariff; off-peak branch uses
  `prices.e7_low_rate_p_per_kwh` as the natural extension point when
  the first off-peak PV cert lands (currently short-circuited by the
  `Tariff != STANDARD` guard at line 2710).
- `calculator.py`: new `pv_dwelling_import_price_gbp_per_kwh` field on
  `CalculatorInputs` with synthetic-fallback split logic mirroring the
  precomputed-fuel_cost path. Maintains the cross-cascade architecture
  documented in the prior handover.

Cohort impact: **none**. Per ADR-0010 RdSAP10 amendment, Table 32
collapses code 30 (standard electricity import) and code 60
(electricity sold to grid, PV) to the SAME 13.19 p/kWh rate. So the
β-split's E_dw × 13.19 + E_ex × 13.19 == E_total × 13.19, matching the
legacy single-rate credit at 1e-4 — 763 pass + 0 fail across the
full chain test suite (Elmhurst U985, cohort-1 ASHP, cohort-2 38-cert
sweep, 15-cert golden fixtures). The β-split shape is now in place
for the off-peak case (where weighted Table 12a high/low rates would
diverge) and any future amendment that splits import/export prices.

Pyright net-zero on touched files (34 errors before, 34 after — all
pre-existing).
2026-05-28 19:01:38 +00:00
Khalim Conn-Kowlessar
5344bc8920 Slice S0380.44: SAP 10.2 Appendix M1 §3-4 PV β-factor calculator (no wiring)
Pure-function module + 13 unit tests for the photovoltaic onsite/export
split. No cascade wiring yet — Slices S0380.45..47 will wire β into the
PE / CO2 / cost cascades respectively (which currently all over-credit
the exported PV portion at the IMPORT factor).

Module: `domain/sap10_calculator/worksheet/photovoltaic.py`
  - `PhotovoltaicSplit` frozen dataclass — monthly β + (E_PV,dw,m,
    E_PV,ex,m) with annual-sum properties matching worksheet line
    refs (233a) and (233b).
  - `pv_beta_coefficients(Cbat)` — three coefficients keyed on battery
    capacity (kWh), capped at 15 per §3c:
      CPV1 = 1.610 - 0.0973 × Cbat
      CPV2 = 0.415 - 0.00776 × Cbat
      CPV3 = 0.511 + 0.0866 × Cbat
  - `pv_split_monthly(epv, dpv, battery_kwh)` — per §3d-4:
      R_PV,m = E_PV,m / D_PV,m
      β_m = min(exp(-CPV1 × (R_PV,m × CPV2)^CPV3), D_PV,m / E_PV,m)
      E_PV,dw,m = E_PV,m × β_m;  E_PV,ex,m = E_PV,m × (1 - β_m)

Edge cases (not in spec but implied by physics):
  - E_PV,m = 0 → β = 0; both onsite and exported = 0
  - D_PV,m = 0 → cap forces β = 0; all PV exports

Unit-test coverage (13 tests, AAA convention, `abs(diff) <= tol`):
  - β coefficient constants at Cbat=0, 5 (ASHP cohort), 15 (cap)
  - Cbat>15 clamps to 15; Cbat<0 clamps to 0 (defensive)
  - Hand-computed β worked example (no battery): β≈0.4864 at E_PV=100,
    D_PV=200 — pinned at 1e-7 against precomputed value AND at 1e-9
    against the live formula recomputation (load-bearing math pin)
  - Edge cases: E_PV=0 → no split; D_PV=0 → full export
  - Battery monotonicity: β increases with Cbat for fixed (E_PV, D_PV)
  - Energy conservation: E_PV,dw + E_PV,ex = E_PV per month + annually
  - Tuple length validation (raises on != 12 months)
  - Return shape pinned to `PhotovoltaicSplit` dataclass contract

Test suite: 750 → 763 pass + 0 fail. Pyright net-zero on new files.

Spec citation: SAP 10.2 specification Appendix M1 §3-4 (p.93-94).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:11:56 +00:00
Khalim Conn-Kowlessar
e1b7b30c40 Slice S0380.42: Decimal HALF_UP per-window areas per RdSAP10 §15 — closes cert 1536
Cert 1536 lodged window dimensions including (0.65 × 0.70) × 3
windows. In float arithmetic 0.65 × 0.70 = 0.45499999999999996,
which the `_round_half_up(float, dp)` helper snaps to 0.45 vs the
spec answer 0.46 (Decimal: 0.65 × 0.70 = 0.4550 exact, HALF_UP at
2 d.p. = 0.46). The shortfall of 0.01 m² × 3 windows = 0.03 m²
under-counted as ~0.073 W/K of conduction loss vs the worksheet's
windows_w_per_k = 25.6354 — closing the cert 1536 residual at
+0.00152 to <2e-6.

Same class of bug as the S0380.34/35 living-area / gross-wall /
party-wall closures (Decimal HALF_UP at the 0.005 boundary that
float drops). RdSAP10 §15 (p.66) lists "all element areas (gross)
including window areas: 2 d.p." — Decimal is the only arithmetic
that matches that boundary deterministically.

Three cascade sites now use Decimal HALF_UP for per-window areas:

- heat_transmission.py: `_decimal_round_half_up_product(W, H, 2)`
  replaces `_round_half_up(W × H, 2)` at the windows_w_per_k cascade
  AND at the per-bp window-area accumulation (the wall-net deduction
  branch must agree with the conduction branch for cascade-internal
  consistency, per the existing comment at line 575-583).

- internal_gains.py: `_decimal_window_area_2dp(W, H)` replaces the
  inline `_round_area_2dp(W × H)` in the daylight factor `g_l`
  sum so §5 (66)..(67) sees the same per-window areas as §3 (27).

- solar_gains.py: same Decimal helper replaces `_round_area_2dp` in
  `_wall_window_solar_gain_monthly_w` so §6 (74)..(81) area = (27).

The `_round_area_2dp` helpers were inlined per-module in pre-S0380.42
work; this slice deletes them since the Decimal-aware product
replaces all call sites. `_round_half_up` stays in heat_transmission
for non-product per-element area calls (single-value rounds).

Test impact:
  - Cohort-2 cert 1536 API path: +0.00152 → -1e-6 (<1e-4 ✓).
    Moves from _COHORT_2_API_OPEN to _COHORT_2_API_CLOSED. Cohort
    distribution: 37/38 exact (was 34/38 at start of session);
    only cert 2102 (-6.30 secondary-heating routing) remains open.
  - Cohort-2 cert 0300/9380 unchanged (already <1e-4 after S0380.41).
  - Cohort-1 ASHP 9/9 unchanged: <1e-4 on both paths.
  - Elmhurst 6-cert worksheet sweep: unchanged (lodges
    `window_width=area, window_height=1.0` per the Elmhurst lodging
    convention — Decimal(area) × Decimal(1.0) = Decimal(area), no
    rounding shift).

Test suite: 750 pass + 0 fail. Pyright net-zero per touched file
(heat_transmission 13/13; internal_gains 4/4 pre-existing; solar_gains
0/0; chain test 0/0).

Spec citation: RdSAP 10 Specification §15 "Rounding of data" p.66 —
"All element areas (gross) including window areas and conservatory
wall area: 2 d.p." Decimal is the float-precision-stable arithmetic
that matches this rule at the .005 boundary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:11:39 +00:00
Khalim Conn-Kowlessar
1cea73df7c Slice S0380.37: drop cert 001479 hand-built fixture — covered by passing production-path chain tests
Cert 001479 was added in ee98dbe0 as "skeleton + 11 RED pins" — a
hand-built EpcPropertyData intended to cascade to worksheet
P960-0001-001479.pdf at 1e-4 for 9 SapResult fields. The skeleton
was never finished; the 9 _FIXTURE_PINS pin-checks have been red
the entire time (at HEAD: sap_score 65 vs 69, space_heating
9715 vs 8104 kWh, etc.).

Meanwhile the production-path chain tests for the same cert have
landed at 1e-4 vs the worksheet's continuous SAP 69.0094 and are
GREEN at HEAD:
  - test_summary_001479_full_chain_sap_matches_worksheet_pdf_exactly
    (Summary PDF -> extractor -> mapper -> calc, 1e-4 vs worksheet)
  - test_api_001479_full_chain_sap_matches_worksheet_pdf_exactly
    (API JSON -> mapper -> calc, 1e-4 vs worksheet)
  - 5 test_summary_001479_*_<detail> mapper-shape unit tests

These exercise the actual from_elmhurst_site_notes /
from_api_response code paths the production runtime uses, which
is strictly stronger coverage than a hand-built mirror.

Drops 001479 from _FIXTURE_PINS / _FIXTURE_MODULES and deletes the
stub _elmhurst_worksheet_001479.py. Also fixes the stale "Slice
62 iteration" reference in test_summary_pdf_mapper_chain.py.

Test baseline: 9 fewer fails (10 -> 1; remaining FEE-round-trip
1e-9 noise to be fixed in S0380.38).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:22:04 +00:00
Khalim Conn-Kowlessar
d61a27e0ff Slice S0380.35: round gross-wall and party-wall areas in Decimal arithmetic per RdSAP10 §15 — closes cohort-2 cert 2800 / 4800 +0.0007 SAP residuals
RdSAP10 §15 p.66 (Rounding of data):
    "All element areas (gross) including window areas and
     conservatory wall area: 2 d.p."

Certs 2800 and 4800 lodge heat_loss_perimeter = 21.25 m and
room_height = 2.30 m. The exact-decimal products
    21.25 * 2.30 = 48.8750 (gross wall area)
     6.25 * 2.30 = 14.3750 (party wall area)
sit ON the HALF_UP rounding boundary and must round to 48.88
and 14.38 m^2. Float representation drops them BELOW the
boundary:
    21.25 (float) * 2.30 (float) ~= 48.87499...
                       HALF_UP 2 d.p. = 48.87
     6.25 (float) * 2.30 (float) ~= 14.37499...
                       HALF_UP 2 d.p. = 14.37
The 0.01 m^2 area shortfall feeds into (29a) net wall area and
(32) party wall area, and into (31) total external area for
(36) thermal bridging — propagating a +0.0007 SAP residual via
the U-weighted heat-loss sums.

Adds `_decimal_round_half_up_sum` helper and routes both
gross-wall and party-wall sums through it, mirroring the
S0380.34 fix on `_living_area_fraction`. Certs that sit off
the .005 boundary (i.e. nearly all) are unaffected; certs
that land on it close from +0.0007 → <5e-5.

Cohort-2 distribution after S0380.31..S0380.35:
    38 exact (was 36 exact + 2 <=0.07).
Cohort-1 ASHP cohort: 9/9 <1e-4 (unchanged).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:07:38 +00:00
Khalim Conn-Kowlessar
396907f46a Slice S0380.32: route bare \"Extension\" window location to BP[1] per RdSAP10 §3 — closes cert 9380 +0.027 residual
RdSAP10 §3 p.17:
    "When specifying windows and doors, for each building part
     assessor allocates windows and doors to the corresponding
     wall (the appropriate main wall or each alternative wall).
     For each building part, software will deduct window/door
     areas contained in the relevant wall areas."

SAP 10.2 §3 p.16:
    "Wall area is the net area of walls after subtracting the
     area of windows and doors."

Cert 9380's Summary PDF lodges 2 windows on its single extension,
but pdftotext wraps "1st" onto a preceding layout line while
"Extension" lands on a separate line — the Elmhurst extractor
captures only the second token. `_window_bp_index` previously
matched "main" / "1st"-"4th" prefixes but fell through bare
"Extension" to BP[0] (main), causing the cascade to deduct ext1
windows from the main wall:
    Worksheet (29a): main 60.60 × 0.70 + ext1 18.25 × 0.53 = 52.0925
    Pre-fix cascade: main 59.01 × 0.70 + ext1 19.84 × 0.53 = 51.8222
                     Δ -0.27 W/K → SAP +0.027

This slice adds bare "extension" (when num_parts >= 2) as a sibling
to the ordinal-prefix matches. Closes cert 9380 +0.027 → -4.8e-6.

Cohort-2 distribution after S0380.31 + S0380.32:
    34 exact + 4 ≤0.07 (was 33 exact + 5 ≤0.07).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:59:37 +00:00
Khalim Conn-Kowlessar
86226ebdb6 Slice S0380.31: deduct alt-wall window opening from (31) net external area — closes cert 2636 cantilever residual -0.015 → -2.4e-6
SAP 10.2 Appendix K eqn (K2) p.84:
    HTB = y × Σ(Aexp)
where Aexp is "the total area of external elements calculated at
worksheet (31)". The worksheet (31) column header reads "Total NET
area of external elements" — net of openings.

Cert 2636 (dr87-0001-000898 line 187): (31) = 160.33 m² =
47.70 main net + 11.57 alt net + 42.92 roof + 39.18 ground floor
+ 3.74 cantilever + 11.52 windows + 3.70 doors.

Pre-fix cascade summed the alt-wall at its 12.76 m² gross (no
opening deduction) — (31) was 161.52, driving (36) to 24.228 vs
worksheet 24.0495 (Δ +0.1785 W/K). That drift propagated through
(39) HTC → MIT → space heating, leaving cert 2636 at Δ -0.015
SAP — the only ASHP cohort cert above the 1e-4 floor.

`alt_walls_total_area` aggregates per-alt-wall gross at line 736;
this slice subtracts `alt_window_area` from it in the (31) sum so
the alt-wall contribution is net, matching the (29a) net-area
convention already applied per-element to the A×U sums.

Cohort-1 ASHP cohort: 9/9 certs < 1e-4 Summary path (was 8/9 with
cert 2636 at -0.015). Cert 2636 API path also closes to < 1e-4 —
the bug was path-symmetric in the cascade, not in either mapper.
Cohort-2 unchanged at 33 exact + 5 ≤0.07.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:10:11 +00:00
Khalim Conn-Kowlessar
faf116bd70 Slice S0380.30: extend g_L + g⊥ Table 6b to RdSAP 21 codes 8-15 — closes API path cohort residual cluster
Per the RdSAP 21 schema in [datatypes/epc/domain/epc_codes.csv][1], the
`glazing_type` enum extends to 15 codes; the legacy SAP 10.2 Table 6b
cascade lookups in `internal_gains.py:106` and `solar_gains.py:178`
only knew codes 1-7. Every API-path cert in the cohort lodges
`glazing_type` via the RdSAP 21 numbering, and triple-glazed
lodgements surface as **code 14** ("triple glazing, installed 2022+").

Pre-slice the cascade fell through to the 0.80 / 0.76 double-glazed
defaults for codes 8-15:

  Internal gains g_L (Table 6b):
    code 14 → default 0.80 (DG)        vs spec 0.70 (TG)
    → daylight factor over-bonused → lighting kWh under-counted

  Solar gains g⊥ (Table 6b):
    code 14 → default 0.76 (DG)        vs spec 0.68 (TG)
    → solar gains over-counted

For cert 0350-2968-2650-2796-5255 (semi-detached, 9 triple-glazed
windows lodged as code 14), this drove:
  lighting_kwh_per_yr: cascade 221.79 vs Summary-path 228.44
    (-6.65 kWh/yr — daylight bonus too generous → lighting too low)
  space_heating_kwh_per_yr: cascade 7000.21 vs Summary-path 6996.94
    (+3.28 kWh/yr — extra solar gains lower HP demand)
  net ECF: -0.0022 vs Summary-path → SAP +0.031

Same mechanism on the other 5 cohort-1 ASHP API certs.

Fix: extend both lookup tables with the RdSAP 21 additions per the
schema CSV semantics:

  | code | description (RdSAP 21)          | g_L  | g⊥   |
  |------|----------------------------------|------|------|
  |  8   | triple glazing, known data      | 0.70 | 0.68 |
  |  9   | triple glazing, 2002-2022       | 0.70 | 0.68 |
  | 10   | triple glazing, pre-2002         | 0.70 | 0.68 |
  | 11   | secondary glazing, normal-E      | 0.80 | 0.76 |
  | 12   | secondary glazing, low-E         | 0.80 | 0.76 |
  | 13   | double glazing, 2022+            | 0.80 | 0.76 |
  | 14   | triple glazing, 2022+            | 0.70 | 0.68 |
  | 15   | single glazing, known data       | 0.90 | 0.85 |

Solar gains also adds code 7 (double known data) for
`_G_PERPENDICULAR_BY_GLAZING_TYPE` to align with the existing
`_G_LIGHT_BY_GLAZING_CODE` code-7 entry (which already mapped to
0.80 = double).

Outcome — Cohort-1 ASHP cohort API path:
  cert 0380:  +0.025  →  +1e-6     (close to exact)
  cert 0350:  +0.031  →  +2.2e-5   (close to exact)
  cert 2225:  +0.029  →  -4.8e-5   (close to exact)
  cert 2636:  +0.015  →  -0.015    (sign flip; cantilever-specific
                                    residual surfaces; same |Δ| as Summary)
  cert 3800:  +0.023  →  -2e-5     (close to exact)
  cert 9285:  +0.029  →  -3.4e-5   (close to exact)

5 of 6 API path certs now sit at <1e-4 vs worksheet. Cert 2636
matches its Summary-path residual (-0.015) — the cantilever fixture
has its own non-glazing residual to be diagnosed separately.

Cohort-2 Summary path unchanged (33 exact + 5 ≤0.07) — the cohort-2
certs lodge glazing codes 1-7 (RdSAP 17 numbering still surfaces in
Elmhurst Summary PDF lookups), so codes 8-15 only affect the
RdSAP-21-schema API path.

Golden API fixture pins updated to reflect the tightened cascade-vs-API
alignment (7 certs: 0380, 0350, 2225, 2636, 3800, 9285, 9418). SAP
integer residuals unchanged (all sit at +0).

Pyright net-zero on touched files (22 → 22).

Tests: 710 → **711** pass (+1 new: cert 0350 fixture-shape test for
glazing_type=14 routing to g⊥=0.68 with `total_solar_gains_monthly_w[0]
≈ 67.00 W` (vs pre-slice 74.88 W at the DG default), proving code 14
hits the triple-glazed Table 6b row.) 10 expected fails unchanged.

[1]: datatypes/epc/domain/epc_codes.csv (RdSAP-Schema-21.0.1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:01:34 +00:00
Khalim Conn-Kowlessar
c144d444e2 Slice S0380.26: RdSAP10 §5.8 dry-lining adjustment on alt walls — closes cert 7700 -0.44 → +5e-5
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>
2026-05-28 10:56:11 +00:00