Commit graph

6685 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
00a27efd87 Slice 48: Elmhurst extractor handles 3 new layout quirks; 5 fixture PDFs added
The §11 Windows table in the Summary PDF doesn't lay out identically
across the cohort. Three new quirks added to the layout-style parser
so the remaining 5 certs can be debugged with windows actually
extracted:

1. `Wood 0.70` combined frame_type+frame_factor line — previously the
   parser expected them on separate lines (data+1 / data+2) and
   rejected the window when the joined form appeared.
2. Trailing glazing-type on the data line — `1.22 1.76 2.15 Double
   pre 2002` is the joined-cell variant in 000516; the W/H/Area
   anchor now captures the trailing phrase as an optional 4th group
   and feeds it through as `inline_glazing_type`, bypassing the
   separate-line glazing-prefix scan.
3. Cross-window gap with no glazing marker — `_partition_after_manuf`
   now falls back to "second orientation token in gap" when no
   glazing-type-prefix word appears. Covers the 000516 layout where
   each window has prefix+suffix orient tokens (no inline orient)
   and the glazing-type is joined-to-data.

The 5 remaining Summary PDFs are copied into
`backend/documents_parser/tests/fixtures/` ready for per-cert mapper
work. Mirror pin tests deferred — each cert still has its own diff
to close (handover in NEXT_AGENT_PROMPT.md documents the per-cert
state, e.g. 000477 needs secondary-heating extraction, 000516 needs
roof-window separation).

Current cohort SAP deltas vs the U985 worksheet PDFs (target 1e-4):

  000474   0.0000  ✓
  000477  +6.3655     secondary heating + lighting
  000480  +8.2695     diagnosis pending
  000487  +8.1433     extractor still drops windows
  000490  +5.6551     diagnosis pending
  000516  +5.9812     roof-window separation

Wider regression stays green (754 pass). Pyright net-zero on
touched files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 19:17:59 +00:00
Khalim Conn-Kowlessar
29ab80b0e5 Slice 47: Summary_000474 chain pins SAP at 1e-4 vs worksheet PDF
Two diffs closed against the hand-built `_elmhurst_worksheet_000474`
target (SAP 62.2584):

1. `pumps_fans_kwh_per_yr` (130 → 160). The cascade keys §4f pumps+fans
   electricity on `MainHeatingDetail.main_heating_category` (gas-fired
   boilers = cat 2 → 160 kWh/yr). `from_elmhurst_site_notes` wasn't
   populating the field, so it fell through to the default 130. Added
   `_elmhurst_main_heating_category` deriving cat 2 for the gas/LPG-
   PCDB-boiler branch; other categories deferred until a fixture
   exercises them (consistent with the cascade lookup).

2. Window [4] orientation `East-South` → `East` and window [5]
   orientation `''` → `South-East`. The layout-style parser's
   `before_start = prev_manuf + 7` / `after_end = next_data` rule was
   over-grabbing prefix tokens of W_{k+1} as suffix tokens of W_k
   ('South' from W_5's prefix bled into W_4's suffix). Replaced with
   a symmetric partition on the first glazing-type-start token
   (`Single`/`Double`/`Triple`/`Secondary`) within the cross-window
   gap, used as the upper bound of W_k's suffix and the lower bound
   of W_{k+1}'s prefix. Same boundary on both sides — prefix tokens
   of the next window can no longer be attributed as suffix of the
   current one.

After both fixes, Summary_000474 → ElmhurstSiteNotes → EpcPropertyData
→ cascade → SAP matches the worksheet PDF's unrounded line 257 value
to 1e-4 tolerance. All 754 datatypes/epc/ + backend/documents_parser/
tests green; pyright net-zero on touched files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 19:01:38 +00:00
Khalim Conn-Kowlessar
b6544e1cd1 Handover: tighten Summary→SAP chain pin to 1e-4 + brief next agent
Slice 46c left the chain at SAP Δ=0.26 vs the Elmhurst worksheet PDF's 62.2584. The user rejected the 0.5 tolerance: because the cascade reproduces Elmhurst exactly on hand-built inputs and the Summary PDF carries the same source-of-truth data, the mapped path must hit 1e-4 like every other Elmhurst worksheet pin.

This commit:
- Tightens `test_summary_000474_full_chain_sap_matches_worksheet_pdf_exactly` from 0.5 to 1e-4. Currently fails with Δ=0.2611 — the forcing function for the next slice.
- Replaces the stale `docs/sap-spec/NEXT_AGENT_PROMPT.md` with a fresh handover identifying the two remaining diffs:
  * pumps_fans_kwh_per_yr 130 vs 160 (30 kWh; likely `central_heating_pump_age` not plumbed)
  * Window [4] mis-classified as SE (4) instead of E (3); `_compose_window_descriptors` over-joins suffix tokens
- Documents the architectural smell (3-schema chain ElmhurstSiteNotes → EpcPropertyData → CalculatorInputs may be over-engineered).
- Lists end-goal: API-path < 0.5 SAP (rounded integers), Elmhurst-path < 1e-4 SAP (unrounded worksheet pins), then replicate for the other 5 Summary PDFs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:43:14 +00:00
Khalim Conn-Kowlessar
256a5afee5 Slice 46c: Elmhurst mapper produces calculator-equivalent EpcPropertyData — Summary_000474 SAP within 0.5 of worksheet PDF
The full Summary→ElmhurstSiteNotes→EpcPropertyData→cascade→SAP chain now produces unrounded SAP 62.52 for cert U985-0001-000474 vs the worksheet PDF's 62.2584 — inside the 0.5 tolerance the user accepts on the API-cert residual cohort. The hand-built worksheet-fixture chain matches Elmhurst's unrounded SAP to 4 d.p. (62.2584), so the calculator+cascade are provably equivalent to Elmhurst's calculator; this slice closes the mapper side of the chain.

Mapper changes drop the string-versus-int impedance mismatch that prevented the cascade from consuming Elmhurst-coded values:
- construction_age_band: `_strip_code('B 1900-1929')` → 'B' (was '1900-1929')
- wall_construction: `_elmhurst_wall_construction_int('CA Cavity')` → 4 (was string 'Cavity')
- wall_insulation_type: `'A As Built'` → 4 (was string 'As Built')
- party_wall_construction: same int-mapping treatment
- main_fuel_type: `_elmhurst_main_fuel_int('Mains gas')` → 26 (the Table 12 fuel code; was string)
- heat_emitter_type: `'Radiators'` → 1 (was string)
- main_heating_control: `_elmhurst_sap_control_code('SAP code 2106, ...')` → 2106 (the SAP code int; was the trailing description)
- main_heating_index_number: parsed leading int from `pcdf_boiler_reference` ('16839 Vaillant…' → 16839) + `main_heating_data_source=1` so the PCDB cascade fires
- window orientation: `_elmhurst_orientation_int('North-West')` → 8 (the SAP10 octant; was string — solar gains were dropping to 0 W/m² as a result)

Floor handling also re-aligned with the SAP convention: floors sorted with the lowest as floor=0 (Elmhurst lodges 1st-floor entries first in the PDF); zero-area entries filtered out (single-storey extensions); non-ground room heights get the +0.25 m joist-void adjustment; `is_exposed_floor=True` for ground floors lodged above unheated space ('U Above unheated space'). `total_floor_area_m2` now sums across main + extensions.

Three regression pins on the new path:
- sap_building_parts == 3 (multi-bp)
- sap_windows == 7 (layout-style window parser)
- unrounded SAP within 0.5 of 62.2584 (worksheet PDF line 257)

Existing end-to-end test assertions updated to reflect the spec-correct int codes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:32:20 +00:00
Khalim Conn-Kowlessar
066dce19e3 Slice 46b: Elmhurst extractor parses windows from layout-style Summary PDFs
The legacy `_extract_windows` regex anchors on "Permanent Shutters\n" which is broken across lines by the pdftotext-layout preprocessor. New fallback `_extract_windows_from_layout` anchors on the two stable per-window markers — a "W H Area" data line and the "Manufacturer <U_value>" line a few lines further down — and tolerates the variable-order optional fields (glazing_gap, inline building_part, inline orientation) between them. Prefix/suffix tokens around the data block are re-joined into glazing_type / building_part / orientation strings.

Cert U985-0001-000474's 7 windows across Main + 2 extensions now flow through the mapper to EpcPropertyData.sap_windows (was 0). Textract-style extraction (existing fixture) is unchanged — the legacy path runs first and only falls through when its regex misses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:03:29 +00:00
Khalim Conn-Kowlessar
36f2c7bbdf Slice 46a: Elmhurst mapper handles multi-bp Summary PDFs — Summary_000474 chain test flips green
ElmhurstSiteNotes had no representation for extensions: singular dimensions / walls / roof / floor fields could only describe the main bp. Summary PDFs lodge "1st Extension" / "2nd Extension" subsections in §4, §7, §8, §9 with optional "As Main: Yes" inheritance. This slice:

- Adds `ExtensionPart` dataclass and `ElmhurstSiteNotes.extensions: List[ExtensionPart]`.
- Adds `_split_section_by_bp` helper + per-bp parsing of dimensions / walls / roof / floor in the extractor; "As Main" inherits from the main bp.
- Refactors `_map_elmhurst_building_part` into a parameterised builder; adds `_map_elmhurst_building_parts` that yields Main + one SapBuildingPart per extension (capped at 4 per RdSAP10 §1.2).
- Scaffold test `test_summary_000474_mapper_produces_three_building_parts` flips from strict-xfail to passing.

Single-bp behaviour is unchanged (empty extensions list defaults). 752 existing tests stay green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:55:13 +00:00
Khalim Conn-Kowlessar
ccf7aa2118 Scaffold: end-to-end Summary→EpcPropertyData chain test for 000474 (xfail)
The 6 worksheet fixtures build EpcPropertyData by hand, validating the cascade in isolation from the mapper. This commit lands the first half of the OTHER validation: Summary_000474.pdf → ElmhurstSiteNotesExtractor → from_elmhurst_site_notes → EpcPropertyData, asserting it produces the same shape as the hand-built fixture. Test is strict-xfail on sap_building_parts count (mapper produces 1, cert lodges 3). Includes a pdftotext-layout preprocessor that converts spatial label/value layout into the Textract-style sequence the existing extractor expects (test-only). Full punch list of 28 mapper-output diffs captured in project memory.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:40:06 +00:00
Khalim Conn-Kowlessar
8ac548ca2a Audit: pin u_floor §5.12 formula cascade for cert 0240 cohort geometry
Floor U is formula-driven (BS EN ISO 13370 + RdSAP10 §5.12), not a table lookup, so cohort pins assert per-geometry values derived by hand from the spec formula. Cert 0240's main + extension building parts cover both the dt < B and dt > B branches of the solid-floor cascade with age J → Table 19 default 75 mm insulation. Hand-derivation matches calculator output to 2 d.p.; the formula cascade is correct on this cohort case. Suspended-floor + Table 19 footnote (2) overrides remain unpinned until cohort coverage demands them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:20:01 +00:00
Khalim Conn-Kowlessar
acc6331dc3 Audit: pin u_roof description cascade against RdSAP10 Table 16 for golden cert cohort
Mirror of the wall cohort pin. Worksheet fixtures lodge roofs=[] so the description-driven branch of u_roof was never validated at cascade level. New parametrised test pins 8 (description, age, thickness) tuples from the golden certs against the Table 16 col-1 (loft insulation thickness known) value. All 8 cases match spec: u_roof is correct on the thickness-known path even when joined-description from multiple roof rows contains noise.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:16:17 +00:00
Khalim Conn-Kowlessar
15789f5acf Audit: pin u_wall description cascade against RdSAP10 Table 6 (England) for golden cert cohort
Worksheet fixtures lodge walls=[] so the description-driven branches of u_wall — the codepath real API certs trigger — were never validated at cascade level. New parametrised test pins each (description, age) pair seen in the 8 golden certs against the Table 6 value the spec mandates. All 7 clean cases match spec: the description cascade is correct where Table 6 gives a direct value. Cases routing through §5.7 / §5.8 formulas are excluded pending separate pinning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:10:43 +00:00
Khalim Conn-Kowlessar
5acbecc514 Slice 45c: PV demand cascade uses postcode-specific climate (PCDB Table 172) per Appendix U
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:44:31 +00:00
Khalim Conn-Kowlessar
24f35f8b80 Slice 45b: PV pitch dimension + real Appendix U3.3 S(orient, p) integral — replaces 45a 30°-pitch stub
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:37:37 +00:00
Khalim Conn-Kowlessar
f08252dc06 Slice 45a: PV generation per-array Appendix M yield — cert 2130 SAP +9 → +2, PE −69.57 → −48.81
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:31:29 +00:00
Khalim Conn-Kowlessar
ea6d426349 Slice 44: flat_roof_insulation_thickness mapper fix — surface lodged value on SapBuildingPart
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 15:28:10 +00:00
Khalim Conn-Kowlessar
a05ecacd67 Slice 43: percent_draughtproofed mapper fix — surface lodged value on EpcPropertyData
Mapper-drop audit across the 9-fixture cohort: `percent_draughtproofed`
is lodged on 9/9 certs (raw values 85-100) but the schema-21.0.1
mapper never set it on EpcPropertyData. The site-notes mappers always
have (line 312 of mapper.py); only the API path was missing.

cert_to_inputs reads `epc.percent_draughtproofed` for the §2
ventilation cascade (window draught loss); with None → 0 default, the
calc was treating every API-routed cert as fully draughty —
over-counting draught infiltration on every fixture in the cohort.

Fix: `percent_draughtproofed=schema.percent_draughtproofed` in
`from_rdsap_schema_21_0_1`.

Cohort SAP / PE / CO2 shifts (all 9 fixtures move; many shift one
SAP point because the continuous SAP was near a rounding boundary):

  cert                              old SAP  new SAP   PE shift   CO2 shift
  0240-0200-5706-2365-8010          -12      -10        -7.63      -0.39
  0300-2747-7640-2526-2135          -9       -7         -6.36      -0.55
  0390-2254-6420-2126-5561 (LN12)    0       +1         -9.10      -0.13
  0390-2954-3640-2196-4175          -7       -4         -4.87      -0.44
  2130-1033-4050-5007-8395 (DE22)   +8       +9         -3.67      -0.04
  6035-7729-2309-0879-2296          -6       -5         -8.90      -0.21
  7536-3827-0600-0600-0276          +3       +4         -9.19      -0.24
  8135-1728-8500-0511-3296          +1       +1 (cont   -7.48      -0.14
                                              72.7→73.5)
  9390-2722-3520-2105-8715          +2       +3         -7.32      -0.01

LN12 lost its exact-SAP-match (0 → +1, continuous 65.47 → 66.28); the
other fixtures' rounded SAP residuals tightened or worsened by 1
depending on which side of the rounding boundary they sit. This is
spec-correctness over residual-tightness: the lodged value is correct,
our calc now reads it.

930/930 Elmhurst cascade green. 78/78 mapper tests + 14/14 golden
cohort + PCDB chain green. Pyright net-zero.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 15:06:32 +00:00
Khalim Conn-Kowlessar
6836aed004 Slice 42: golden-cohort PE pin uses demand cascade via calculate_sap_from_inputs
Slice 37's per-cert pin refactor pinned PE residuals against
`result.primary_energy_kwh_per_m2` from the rating cascade (UK-avg
climate). But per SAP10.2 Appendix U + the codebase's own
SAP_CALCULATOR.md docs, the EPC's published `energy_consumption_current`
is a postcode-climate value — same as CO2. The CO2 pin was already
correct; PE was an oversight.

Fix: use the public `calculate_sap_from_inputs` entry point twice —
once with `cert_to_inputs` (rating cascade) for SAP, once with
`cert_to_demand_inputs` (demand cascade) for PE + CO2. This drops
the four section-helper imports and reads everything off SapResult,
keeping the test surface minimal.

PE residuals shift on every fixture (sometimes toward zero, sometimes
away — the rating cascade was masking the real gap):

  cert                              old PE     new PE     Δ
  0240-0200-5706-2365-8010          +0.74      +5.58      worse — known RR gap
  0300-2747-7640-2526-2135          +17.34     +4.45      tighter
  0390-2254-6420-2126-5561 (LN12)   -3.14      +0.18      tighter ← bread-and-butter cert now within 0.2 kWh/m²
  0390-2954-3640-2196-4175          -27.64     -26.68     ~same
  2130-1033-4050-5007-8395 (DE22)   -61.25     -65.89     worse — PV PE-offset now correctly accounted
  6035-7729-2309-0879-2296          +34.62     +45.05     worse — known wall-insulation + RR gap
  7536-3827-0600-0600-0276          -27.45     -17.98     tighter
  8135-1728-8500-0511-3296          -14.37     -9.50      tighter

The "worse" certs (0240, 6035, DE22) were never close — the rating
cascade had been coincidentally masking the real PE gap on the certs
with documented mapper gaps. Demand cascade now exposes the real
residual for each; the documented gaps' fixes will close them.

LN12 (bread-and-butter, gas combi, no PV) now reads:
  SAP   resid +0       (exact match)
  PE    resid +0.18    (within 0.2 kWh/m² of lodged 241)
  CO2   resid +0.04    (within 0.05 t/yr of lodged 3.5)
First cert in the cohort within target ±0.5 on SAP and ±1 on PE/CO2.

930/930 Elmhurst cascade unchanged. 14/14 golden cohort + PCDB chain
green. Pyright net-zero (2 errors before and after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 14:42:55 +00:00
Khalim Conn-Kowlessar
81392208c4 Slice 41: schema-21.0.1 ventilation completeness — 7 vent / draught fields plumbed
Audit of raw-JSON keys vs RdSapSchema21_0_1 across the 9-fixture
golden cohort surfaced 7 vent / draught fields silently dropped at
deserialization: blocked_chimneys_count, open_flues_count,
closed_flues_count, boilers_flues_count, other_flues_count, psv_count,
has_draught_lobby. cert_to_inputs reads all of them for the §2
infiltration cascade; without them the calc treats every dwelling as
flue-free / vent-free / no draught lobby and under-counts ACH.

Fix: declare the 7 fields on RdSapSchema21_0_1; extend the mapper to
surface blocked_chimneys_count on EpcPropertyData top-level (already
declared) and the other 6 on SapVentilation (extends the slice 37
extract_fans_count work). has_draught_lobby coerces "true"/"false"
strings to bool to match the SapVentilation type.

Cohort residual shifts after re-pinning:
- LN12 (0390-2254) — SAP +1 → 0 (FIRST CERT TO HIT LODGED SAP EXACTLY).
  blocked_chimneys=2 reduces infiltration, tightens both SAP and PE
  (PE −10.62 → −3.14, CO2 −0.11 → +0.04).
- 0300 — PE +18.92 → +17.34, CO2 −0.43 → −0.54 (open_flues=1 +
  has_draught_lobby=true cross-cancel near-zero).
- 0390-2954 — PE −25.62 → −27.64, CO2 −2.45 → −2.58 (has_draught_lobby=true).
- 8135 — PE −17.58 → −14.37, CO2 −0.22 → −0.15 (blocked_chimneys=1).
- Other 5 fixtures (0240, DE22, 6035, 7536, plus retired 9390): no shift
  — their certs lodge zeros or no vent fields beyond what Slice 37 plumbed.

Rounded-SAP cohort distribution post-slice:
  0 (LN12), +1 (8135), +2 (9390), +3 (7536), +8 (DE22, spec-drift),
  -6 (6035), -7 (0390-2954), -9 (0300), -12 (0240, RR-driven).

Schema scope: 21.0.1 only. 21.0.0 schema's SapBuildingPart shares the
same mapper code but no 21.0.0 fixtures live in the cohort to anchor
against; defer to a future slice if needed.

930/930 Elmhurst cascade green. 14/14 golden cohort green at new
pinned residuals. 77/77 mapper tests green. Pyright net-zero (34
errors before and after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 14:27:32 +00:00
Khalim Conn-Kowlessar
fb3973457a Slice 40: room_in_roof_type_1 gable lengths flow through schema-21 to EpcPropertyData
Schema-21.0.0/0.1's SapRoomInRoof dataclass declared only floor_area
and construction_age_band. Real certs lodge gable wall lengths under
sap_room_in_roof.room_in_roof_type_1 (RdSAP §3.9.1 Simplified Type 1).
from_dict silently dropped the whole block at deserialization, so the
mapper never had a chance to surface the lengths on EpcPropertyData.

Fix: add RoomInRoofType1 dataclass to both schema-21 variants;
extend SapRoomInRoof with `room_in_roof_type_1: Optional[...]`;
update the mapper to populate EpcPropertyData.SapRoomInRoof
gable_1_length_m / gable_2_length_m from the new field.

Calculator behaviour unchanged this slice: heat_transmission.py:243
requires BOTH length AND height to contribute gable area, and the
cert lodges length only (RdSAP §3.9.1 uses a default 2.45 m storey
height — not yet plumbed). Cert 0240's −12 SAP residual unchanged.

Schema scope: both 21.0.0 and 21.0.1 schemas (identical SapBuildingPart
mapper code, kept consistent). Older schemas (17/18/19/20) don't carry
this RR shape on their dataclasses and are out of scope per the prior
cohort scope decision.

Unblocks the follow-up slices that close the RR cascade: default
H_gable in calculator or mapper, parse "Roof room(s), insulated
(assumed)" description for the U-value override, etc.

930/930 Elmhurst cascade green. 14/14 golden cohort green at pinned
residuals (no shift, as expected). 76/76 mapper tests green.
Pyright net-zero (32 errors before and after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 14:14:25 +00:00
Khalim Conn-Kowlessar
1d7c13b995 Slice 39: PV credit input boundary uses RdSAP10 Table 32 + DE22 PV fixture
`_pv_export_credit_gbp_per_kwh` previously read from `prices.unit_price`
(SAP10.2 Table 12 code 60 = 5.59 p/kWh) while the actual rating
cascade inside _fuel_cost reads from `table_32_unit_price_p_per_kwh`
(RdSAP10 Table 32 code 60 = 13.19 p/kWh, same as standard electricity).
The exposed CalculatorInputs.pv_export_credit_gbp_per_kwh therefore
misled about what the cascade applied. The calculator's fallback path
at calculator.py:442 fires for synthetic inputs without `fuel_cost`
and would compute the wrong PV credit by reading the misleading input.

Per ADR-0010 §10 the rating cascade uses Table 32 prices. Unified
both code paths on Table 32 so the input boundary reports the same
13.19 p/kWh the cascade applies. Cert-path math unchanged (cert path
always sets fuel_cost). Synthetic/fallback path now consistent with
cert path.

Also adds cert 2130-1033-4050-5007-8395 (DE22, end-terrace + 1 ext,
gas combi PCDB 17505, 2× 2.04 kWp PV) as 9th golden fixture. First
PV-bearing cert in the cohort. Pinned residual is SAP +8 / PE −61 /
CO2 +0.19 — spec-version drift not a code bug (cert was scored by
SAP10.2 software using Table 12 PV export 5.59 p/kWh = £194 credit
→ SAP 82; calc targets RdSAP10 Table 32 = 13.19 p/kWh = £457 credit
→ SAP 90). Both internally consistent against their own price table.
The PE residual is amplified because PV gen also offsets PE via
inputs.other_primary_factor, which scales with gen kWh independently
of the export-credit price.

930/930 Elmhurst cascade green. 14/14 golden cohort + 1 new
cert_to_inputs unit test green. Pyright net-zero (49 errors before
and after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:49:04 +00:00
Khalim Conn-Kowlessar
6a6811e548 Slice 38: add LN12 cert as 8th golden fixture
End-terrace + 1 extension, TFA 80 m², gas combi (PCDB index 18119),
no PV, no secondary, postcode LN12 (PCDB Table 172 match). Schema-
21.0.1 / SAP 10.2 — the cleanest bread-and-butter cert in the cohort.

Residuals post sap_ventilation mapper fix:
  SAP  +1  (calc 66 vs lodged 65)
  PE   -10.6249 kWh/m²
  CO2  -0.1059 t/yr

Residual floor reflects remaining mapper gaps — notably schema-21
not carrying led_/cfl_fixed_lighting_bulbs_count for this cert, so
the §5 lighting efficacy falls back to defaults.

Also added to PCDB chain test — index 18119 flows through to
inputs.main_heating_efficiency (winter eff lookup deferred,
expected_winter_eff=None per the existing non-oil convention).

12/12 golden cohort green. Pyright net-zero.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:35:46 +00:00
Khalim Conn-Kowlessar
3ac07bd04a Slice 37: sap_ventilation mapper fix (21.0.1) + per-cert golden pin
The 21.0.1 mapper produced EpcPropertyData with sap_ventilation=None,
so the cert→inputs cascade defaulted every ventilation count to zero
even when the cert lodged extract fans (most schema-21 certs do).
extract_fans_count was double-mapped — surfaced as a top-level field
the calculator never reads, but missing from the SapVentilation slice
the cascade does read.

Fix: populate sap_ventilation in from_rdsap_schema_21_0_1 with
extract_fans_count. Drives ~⅓ of the rating-cohort drift on a clean
no-PV no-secondary gas-combi cert.

Refactored test_golden_fixtures.py from global tolerance ceilings
(±13 SAP / ±35 PE) to per-cert pinned residuals at abs SAP=0,
PE=0.01 kWh/m², CO2=0.001 t/yr. Each cert's _GoldenExpectation now
records the actual current residual (SAP/PE/CO2 — CO2 newly pinned
via the postcode-cascade environmental section). Drift in either
direction fires the test: tighten the pin on improvement, document
on regression.

Recorded residuals reflect known remaining mapper gaps (RR room-in-
roof extraction on cert 0240, oil cascade on 0390, etc.) — tracked
in each cert's notes: field, not acceptance bounds.

930/930 Elmhurst cascade pins unchanged (site-notes EPCs already
populate sap_ventilation). 257/257 mapper tests green. 10/10 golden
cohort green under the new pins. Pyright net-zero (34 errors before
and after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:30:12 +00:00
Khalim Conn-Kowlessar
d44af109a9 Docs: SAP calculator module README + API integration test handover
The SAP 10.2 / RdSAP 10 calculator is closed at 930/930 pin tests green.
Tidying the docs for hand-off to the API-integration agent.

New: docs/sap-spec/SAP_CALCULATOR.md
  Canonical module overview — public API surface, two-cascade
  architecture (Rating UK-avg, Demand postcode), simulator-use-case
  example, file map, validation contract + hard rules, fixture cohort
  notes, spec page references. Replaces the scattered "what's the
  shape" knowledge that was previously only in commit messages.

Rewritten: docs/sap-spec/HANDOVER_NEXT.md
  Old handover (work queue for slices 26-36) is obsolete. Replaced
  with the next agent's brief: build an API → SAP scoring integration
  test using the 6 Elmhurst fixtures. Includes a copy-paste reference
  scoring path, expected outputs per fixture, list of files to read
  on day 1, and scope guardrails.

Refreshed module docstrings:
  - cert_to_inputs.py: now describes both cascades, the deferred-edge-
    case list reflects current state (RR/secondary/§15 living-area
    rounding all DONE; thermal-mass and control-temp adjustment still
    deferred).
  - calculator.py: per-end-use CO2/PE factor machinery documented;
    stale "single-fuel approximation" claim removed (closed in slice 32).
  - sap/README.md: validation paragraph now says "930/930 green" and
    points to SAP_CALCULATOR.md instead of the obsolete HANDOVER_NEXT.

Verified the API examples in both docs produce the expected per-fixture
outputs (SAP=62, EI=60, Carbon=3104.1222, PE=16931.7227 for 000474).
Wider regression: 1585/1585 PASS, zero failures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:04:34 +00:00
Khalim Conn-Kowlessar
4da8a4703d Slice 36: §12 + §13a demand cascade closure (96/96 EPC Block 2 pins)
Pins the EPC's published "Current Carbon" + "Current Primary Energy"
values against the U985 Block 2 (postcode-climate cascade via PCDB
Table 172) for all 6 Elmhurst fixtures at abs=1e-4.

Adds:
- `PrimaryEnergySection` dataclass exposing §13a line refs (275)..(286).
- `primary_energy_section_from_cert(epc, postcode_climate=...)` —
  composes §9a per-system fuel kWh × Table 12 (gas) / Table 12e
  (electricity, monthly) PE factors. Handles (279) excludes (278a)
  electric-shower PE convention (mirrors §12 (265) excludes (264a)).
- Real postcode on each Elmhurst fixture (bd3 8aq / bd3 9DR / bd5 8dn /
  bd3 9JZ / bd19 3TF / BD4 7JR) via new `postcode` kwarg on
  `make_minimal_sap10_epc`.
- DEMAND_LINE_* constants per fixture for §9a annual kWh, §12 CO2 line
  refs (261..272), §13a PE line refs (275..286).
- 16 cascade pins per fixture × 6 fixtures = 96 demand pins.

EXACT match (000474, the canonical test):
  EPC Current Carbon (LINE_272) = 3104.1222 kg/yr ✓ (Summary PDF: 3.104t)
  EPC Current PE     (LINE_286) = 16931.7227 kWh/yr ✓

Reference: SAP 10.2 Appendix U paragraph 1 (p.124) — "For ratings (SAP
rating and environmental impact rating) the calculations are done with
UK average weather. Other calculations (such as for energy use and
costs on EPCs) are done using local weather. Weather data for each
postcode district are taken from the PCDB."

Full scoreboard: 840 rating-cascade pins + 96 demand-cascade pins +
existing 5 postcode-weather unit tests = 941 total pins. Wider
regression: 1585/1585 PASS — zero failures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 09:53:06 +00:00
Khalim Conn-Kowlessar
8cfeba8e2a Slice 35: Plumb postcode climate through cert_to_inputs (demand cascade)
Adds an optional `postcode_climate: Optional[PostcodeClimate]` parameter
to every cert→inputs section helper that touches climate:
- `cert_to_inputs(epc, postcode_climate=...)`
- `ventilation_from_cert` (overrides UK-avg wind tuple)
- `mean_internal_temperature_section_from_cert`
- `space_heating_section_from_cert`
- `space_cooling_section_from_cert`
- `solar_gains_section_from_cert`
- `energy_requirements_section_from_cert`
- `fuel_cost_section_from_cert`
- `environmental_section_from_cert`

`_climate_source(postcode_climate)` returns `int | PostcodeClimate`
(region 0 = UK-avg fallback). The four Appendix U lookup functions
(`external_temperature_c`, `wind_speed_m_per_s`, `horizontal_solar_
irradiance_w_per_m2`, `_latitude_deg`) now accept the union and
dispatch on isinstance — region path is unchanged, postcode path reads
directly from `PostcodeClimate`.

CalculatorInputs gains `monthly_external_temp_c_override` so the
calculator's per-month solve uses the postcode tuple computed in
cert_to_inputs instead of looking up `external_temperature_c(region, m)`
(which would always be UK-avg).

Adds two public helpers:
- `local_climate_for_cert(epc)` — postcode lookup with None fallback
- `cert_to_demand_inputs(epc)` — convenience: cert_to_inputs with
  postcode climate from the cert's postcode field

Verification (000474 with postcode "bd3 8aq" injected — fixtures
currently lodge placeholder "A1 1AA"; real postcodes land in slice 36):
  Rating  main_1_fuel = 11964.8924  (PDF Block 1: 11964.8924 ✓)
  Demand  main_1_fuel = 12288.0014  (PDF Block 2: 12288.0014 ✓ EXACT)
  Rating  ext_temp Jan = 4.3°C (UK-avg)
  Demand  ext_temp Jan = 4.2°C (BD3)

840/840 existing pins still pass — refactor is backward-compatible.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 09:40:03 +00:00
Khalim Conn-Kowlessar
20b2bfa11d Slice 34: PCDB Table 172 postcode weather lookup (data layer)
Per SAP 10.2 Appendix U (p.124): "Weather data for each postcode district
are taken from the PCDB" — Table 172 of pcdb10.dat lodges ~3138 postcode
districts × monthly (temp, wind, solar). This is the data source for the
EPC's demand-side cascade (Current Carbon, Current Primary Energy, Fuel
Bill) — distinct from the rating-side cascade which uses UK-average
climate per the same Appendix U paragraph.

Adds:
- `PostcodeClimate` dataclass: area, district, region (1-21 fallback),
  country, height, lat/lon, monthly temp/wind/solar tuples.
- `_parse_table_172_rows(text)`: parser over the pcdb10.dat row format
  (45 comma-separated fields: 9 metadata + 12 T + 12 W + 12 R).
- `_split_postcode(postcode)`: outward-code splitter handling 1-2 letter
  area + 1-2 digit district (e.g. "bd19 3tf" → ("BD", 19)).
- `postcode_climate(postcode)`: cached lookup with None fallback for
  unknown postcodes (callers fall back to Appendix U region tables).

Verified BD3 (the Bradford district for Elmhurst fixture 000474) reproduces
U985 Block 2 wind exactly: (5.2, 5.2, 5.0, 4.4, 4.3, 3.9, 4.0, 3.8, 4.1,
4.4, 4.6, 4.9). 5 unit tests pinning the lookup, postcode parsing
(including 2-digit districts), case insensitivity, and graceful None
returns for unknown/malformed postcodes.

Data layer only — slice 35 plumbs this through cert_to_inputs as the
demand-side cascade. No changes to existing tests (1490/1490 still pass).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 09:07:50 +00:00
Khalim Conn-Kowlessar
729229ed61 Slice 33: §13a Primary Energy — Table 12e monthly cascade wiring
Adds Table 12e (p.195) monthly PE factors for electricity to
`tables/table_12.py` + `pe_monthly_factors_kwh_per_kwh(fuel_code)`
helper. Mirrors slice 32's CO2 cascade — same spec text, same
shape: electricity end-uses use Σ(kWh_m × PE_m); non-electricity
fuels keep the annual Table 12 / RdSAP10 Table 32 (p.95) factor.

Calculator now consumes per-end-use PE factors on `CalculatorInputs`
(`secondary_heating_primary_factor`, `pumps_fans_primary_factor`,
`lighting_primary_factor`, `electric_shower_primary_factor`). Defaults
to None → fall back to the global `space_heating_primary_factor` /
`other_primary_factor` (synthetic path). Fixes the stale 1.969 default
to RdSAP10 Table 32 standard-electricity PE = 1.501.

`_effective_monthly_factor(monthly_kwh, monthly_factors)` generalises
the slice-32 weighting helper; `_effective_monthly_co2_factor` and the
new `_effective_monthly_pe_factor` are thin wrappers over it.

Includes the electric-shower kWh in the PE total — closes the audit
loop opened by slice 30 (electric shower had fuel cost + CO2 but no PE
contribution).

§13a cascade pins NOT added — §13a appears only in the Demand-SAP
block (postcode climate); our cascade pins live against the Rating-SAP
block (UK-average climate). The Demand-SAP postcode cascade is a
separate scope, intentionally deferred. The calculator's existing
`primary_energy_kwh_per_yr` SapResult output now uses the spec-correct
PE factors but stays UK-average climate.

Verification (000474):
  pumps_fans  effective PE factor = 1.5128 (PDF: 1.5128 ✓)
  lighting    effective PE factor = 1.5338 (PDF: 1.5338 ✓)
  pumps_fans  PE = 242.0480 kWh (PDF: 242.0480 ✓)
  lighting    PE = 214.6527 kWh (PDF: 214.6527 ✓)

Wider regression: 1490/1490 PASS — zero failures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 08:36:07 +00:00
Khalim Conn-Kowlessar
fc1b009bf9 Slice 32: §12 environmental closure (84/84) — Table 12d + per-end-use CO2
FULL CLOSURE. Cascade 768/768 + e2e 72/72 across all 6 Elmhurst fixtures.

Adds Table 12d (p.194) monthly CO2 emission factors for electricity to
`tables/table_12.py` + `co2_monthly_factors_kg_per_kwh(fuel_code)` helper.
Per the spec text: "Where electricity is the fuel used, the relevant set
of factors in the table below should be used to calculate the monthly
CO2 emissions INSTEAD the annual average factor given in Table 12."

Calculator now consumes per-end-use CO2 factors on `CalculatorInputs`
(`main_heating_co2_factor_kg_per_kwh`, `secondary_heating_co2_factor_
kg_per_kwh`, `hot_water_co2_factor_kg_per_kwh`, `pumps_fans_co2_factor_
kg_per_kwh`, `lighting_co2_factor_kg_per_kwh`, `electric_shower_kwh_
per_yr`, `electric_shower_co2_factor_kg_per_kwh`). Defaults to None →
falls back to the global `co2_factor_kg_per_kwh` (legacy synthetic
path); cert_to_inputs supplies real values.

`_effective_monthly_co2_factor(monthly_kwh, fuel_code)` translates the
Table 12d monthly cascade into the calculator's annual×factor shape:
effective = Σ(kWh_m × CO2_m) / Σ(kWh_m). Used for the 4 electricity
end-uses (secondary, pumps/fans, lighting, electric shower). Gas end-
uses keep the annual Table 12 factor.

Adds `environmental_section_from_cert(epc) -> EnvironmentalSection`
exposing (261)..(274) line refs.

Worksheet display conventions:
- (265) excludes (264a) — electric shower CO2 contributes to (272)
  total but not the "space + water heating" subtotal.
- (273) is rounded to 2 d.p. half-up — the PDF displays with trailing
  zeros to 4 d.p. but precision is 2 d.p. throughout.

§12 LINE_ constants added to all 6 fixtures: (261), (262), (263),
(264), (264a), (265), (266), (267), (268), (269), (272), (273),
EI continuous, (274). 000487 (electric shower) has non-zero (264a).

FINAL SCOREBOARD:
- Cascade pins: 684/684 → 768/768 (§7..§12 all closed, 100%)
- e2e SapResult: 66/66 → 72/72 (all CO2 + sap + ecf + fuel cost)
- Wider regression: 1490/1490 PASS — zero failures anywhere

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 08:22:45 +00:00
Khalim Conn-Kowlessar
2bfecad272 Slice 31: §11a SAP rating cascade pin (24/24)
Adds `sap_rating_section_from_cert(epc) -> SapRatingSection`. Composes
§1 TFA + §10a (255) total fuel cost via `fuel_cost_section_from_cert`,
then runs the SAP rating equations (`energy_cost_factor`, `sap_rating`,
`sap_rating_integer`).

Pins (256) deflator, (257) ECF, SAP continuous, (258) SAP integer for
all 6 fixtures — 24/24 PASS.

Existing e2e pins on `ecf`, `sap_score_continuous`, `sap_score`
already verified these outputs; cascade pins formalise §11a for the
worksheet-conformance test surface.

Cascade scoreboard: 660/660 → 684/684 (§7..§11a closed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 08:06:52 +00:00
Khalim Conn-Kowlessar
74bfac049a Slice 30: §10a fuel costs cascade pin (192/192) + electric-shower plumb
Adds `fuel_cost_section_from_cert(epc)` (delegates to `cert_to_inputs`
which already wires `_fuel_cost` with full upstream context). Pins
(240a)..(255) — 32 line refs × 6 fixtures = 192 cascade pins, all PASS.

Three calculator changes needed for closure:

1. Electric shower (247a) — for 000487 the cert lodges 1 electric shower
   and the PDF reports (247a) = 79.3036 GBP (= (64a)m × std electricity
   price). The §4 cascade already computes electric-shower kWh via
   App J step 8 (slice 25d); now exposed on `WaterHeatingResult` as
   `electric_shower_kwh_per_yr` and plumbed into `_fuel_cost`. The
   instant-shower input was previously hardcoded to 0.

2. (241a/241b) main 2 + (242a/242b) secondary fractions — when a row's
   kWh is zero the PDF reports BOTH high/low fractions as 0 (not 1/0).
   `_split` in fuel_cost now zeros both fractions when kwh_per_yr <= 0.
   Cost columns already collapse via multiplication, so this is
   presentation-only.

3. (242a/242b) secondary fractions for 000474 — same pattern: when no
   secondary system is lodged, both fractions = 0.

Adds §10a LINE_ constants to all 6 fixtures. Extracted from
`sap worksheets/U985-0001-NNNNNN.txt` PDF blocks.

Cascade scoreboard: 468/468 → 660/660 (§7..§10a closed).
e2e SapResult: 6 remaining failures (all `co2_kg_per_yr`, await §12).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 00:42:52 +00:00
Khalim Conn-Kowlessar
049694e1e6 Slice 29: §9a energy requirements cascade pin (72/72)
Adds `energy_requirements_section_from_cert(epc)` to the cert→inputs
cascade. Composes §8 (98c)m + Table 11 secondary fraction + per-system
efficiencies into (201)..(221) line refs via the existing
`space_heating_fuel_monthly_kwh` orchestrator.

Extracts `_main_heating_efficiency(epc)` as a shared helper — same eff
derivation as the inline `cert_to_inputs` flow (PCDB winter override →
Table 4a/4b seasonal → heat-network 1/DLF override). Single source of
truth for §4 and §9a.

Worksheet display convention: when no secondary system is lodged the
PDF displays (208) = 0 (not the fallback 100% electric efficiency). The
per-system fuel formula already collapses to 0 via fraction_201 = 0, so
this is presentation-only; the helper zeros (208) when
`secondary_fraction == 0`. 000474 (no secondary) now matches exactly.

Adds §9a LINE_ constants to all 6 fixtures — (201), (202), (206), (207),
(208), (211)m, (211), (213)m, (213), (215)m, (215), (221). Extracted
from `sap worksheets/U985-0001-NNNNNN.txt` PDF blocks.

Cascade scoreboard: 396/396 → 468/468 (§7..§9a closed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 00:16:12 +00:00
Khalim Conn-Kowlessar
13719e010a Slice 28: §8c + §8f cascade pins (48/48)
Adds `space_cooling_section_from_cert(epc)` and
`fabric_energy_efficiency_from_cert(epc)` to the cert→inputs cascade.

§8c (lines 100..108) — all 6 Elmhurst fixtures have
`has_fixed_air_conditioning=False` so f_C=0 collapses (107)/(108) to
zero, (101) η_loss=1 for every month (γ=0 branch), (103) gains=0, and
(106) intermittency follows the spec Jun-Aug mask 0.25. (100), (102),
(104) depend on H × (24 − T_e) per fixture and are not asserted in the
cascade (covered by `test_space_cooling.py` synthetic-positive case).
42/42 §8c pins PASS.

§8f (line 109) — Fabric Energy Efficiency = (98a)/(4) + (108). For all
6 fixtures (98b) solar space heating = 0 and (108) = 0, so (109) = (99)
exactly. 6/6 §8f pins PASS.

Cascade scoreboard: 348/348 → 396/396 (§7..§8f closed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 00:01:30 +00:00
Khalim Conn-Kowlessar
ac6dd250a2 Slice 27: §8 space heating cascade pin (36/36) + worksheet annual rule
Adds `space_heating_section_from_cert(epc)` to the cert→inputs cascade
mirroring `mean_internal_temperature_section_from_cert`. Composes §1
(dim) + §2 (ventilation) + §3 (HLC) + §5+§6 (gains) + §7 (MIT + η_whole)
+ climate and threads through `space_heating_monthly_kwh`.

Pins (95)/(97)/(98a)/(98c) monthly + (98c) annual + (99) per-m² against
the U985 PDF at abs=1e-4 for all 6 fixtures — 36/36 PASS.

Worksheet annual rule: the U985 PDF lodges (98a)_m / (98c)_m at 4 d.p.
half-up and reports the annual as the Σ of those displayed monthlies. The
full-precision Σ diverges from the lodged annual by up to ~1.4e-4
(accumulated 4-d.p. display rounding over 8 heating months) — e.g. 000490
= -0.000132. Empirically, `sum(round_half_up(monthly, 4))` reproduces the
lodged annual EXACTLY for all 6 fixtures (residual = 0 by construction).
The full-precision residuals are randomly distributed in ±1.4e-4 with no
bias — 5/6 cancel below 1e-4 by luck, 000490 lost the lottery.

SAP10.2 Table 9c step 10 (p.184) defines (98a)_m without an explicit
annual aggregation rounding rule; matching the worksheet display
convention is the only consistent interpretation that satisfies the
abs=1e-4 pin bar. The 1.2e-8 relative shift on downstream calcs is
negligible.

Cascade scoreboard: 312/312 → 348/348 (§7 60/60 + §8 36/36 now closed).
e2e SapResult: 56/66 unchanged (downstream §10a/§11a/§12 + 000487
defects await later slices).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:57:55 +00:00
Khalim Conn-Kowlessar
cd94da4d2e Slice 26: §7 LINE_92/93 closure — RdSAP §15 area rounding on living area
LINE_91 in the worksheet is `living_area / (4)`, where living_area itself
is the §15-rounded materialisation of `Table 27 fraction × TFA`. RdSAP
§9.2 (p.52): "The living area is then the fraction multiplied by the
total floor area." §15 (p.66) lists "All internal floor areas and living
area: 2 d.p." So the actual LINE_91 fed to the §7 zone blend is
`round_half_up(Table_27 × TFA, 2) / TFA`, not the raw Table 27 entry.

The roundtrip explains why the 4 holdout fixtures lodge LINE_91 = 0.3001
or 0.2501 instead of the Table 27 values 0.30 / 0.25:
  000474: 0.30 × 56.79 → 17.04 / 56.79 = 0.3001
  000477: 0.25 × 77.58 → 19.40 / 77.58 = 0.2501
  000490: 0.25 × 66.06 → 16.52 / 66.06 = 0.2501

`_living_area_fraction` now takes TFA and materialises + rounds + divides;
`_living_area_fraction_default` retains the bare Table 27 lookup. Existing
`_round_half_up` from heat_transmission is the right utility (same §15
boundary, same half-up convention).

Scoreboard: §7 cascade pins 52/60 → 60/60 (closes LINE_92/93 on 000474,
000477, 000480, 000490 — and tightens the already-passing 000487/000516
combinations). Full cascade: 304/312 → 312/312 (100%).

e2e SapResult: 27/66 → 56/66 (continuous SAP, ECF, fuel cost, space
heating kWh now close on 5/6 fixtures; 000487 still has unrelated
downstream defects, all 6 CO2 fails await §12).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:39:20 +00:00
Khalim Conn-Kowlessar
144f08533f Docs: rewrite HANDOVER_NEXT.md for fresh agent pickup post-slice-25d
§1-§6 fully close (252/252). §7 closes 52/60 (LINE_92/93 marginal on 4
fixtures). §8-§12 not yet pinned. Handover now reads top-to-bottom with
current scoreboard, per-section work queue, spec page reference index,
and the section helper map for the new agent to extend.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:17:43 +00:00
Khalim Conn-Kowlessar
147da90a5a Slice 25d: 000487 §4 LINE_65 closure — derive LINE_64A from cert (App J step 8)
Closes the final §4 cascade fail. SAP10.2 Appendix J step 8 (p.82)
specifies the electric-shower kWh formula:

  N_ES = N_shower / N_outlets             (eq J16)
  EES,j,m = N_ES × f_beh × P_ES,j × 0.1 × n_m   (eq J17)
  EES,m = Σ EES,j,m                       (eq J18)

where P_ES,j defaults to Table J4 (p.83) row "Instantaneous electric
shower" = 9.3 kW for assessments of existing dwellings, and 0.1 = the
6-minute shower duration in hours.

For 000487 (N=2.492, has_bath, 1 electric shower, 0 mixer outlets):
  N_shower = 0.45 × 2.492 + 0.65 = 1.7714
  N_outlets = 1 (just the electric)
  N_ES = 1.7714 / 1 = 1.7714
  Jan: 1.7714 × 1.035 × 9.3 × 0.1 × 31 = 52.86 kWh ≈ PDF LINE_64A[1] = 52.8566 ✓

LINE_65 (heat gains from water heating) was undercounting by 25% of
the missing LINE_64A (the recovery factor for instantaneous electric
showers per the heat-gains formula); deriving LINE_64A from cert
closes it.

Changes:
- water_heating.py: new `electric_shower_monthly_kwh` function +
  `electric_shower_count` parameter to `water_heating_from_cert`.
  When count > 0 and no override, derives LINE_64A from N_outlets +
  Table J4 default P_ES.
- cert_to_inputs.py: `_electric_shower_count_from_cert` helper +
  plumb through both the §4 section helper and internal cascade.

Per-fixture cluster status (was/now):
  §3   24/24 → 24/24  ✓ all 6 fixtures
  §4   53/54 → 54/54  ✓ all 6 fixtures
  §5   52/54 → 54/54  ✓ all 6 fixtures
  §6   11/12 → 12/12  ✓ all 6 fixtures
  §7   45/60 → 52/60  (000487 cascade closed; LINE_92/93 marginal on
                       000474/477/480/490 remains)

Scoreboard:
  section_cascade_pins: 293 → 304 PASS (+11; 97.4% closure)
  e2e SapResult:         32 →  33 PASS (+1, water_heating closure cascades)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:08:32 +00:00
Khalim Conn-Kowlessar
8520a52ee9 Slice 25c: 000477 §4/§5/§6 closure — Table 3c (p.162) M+L lower bound
Fixed a single-character spec adherence bug: SAP10.2 Table 3c (p.162)
specifies the M+L profile's DVF lower bound as `V_d,m < 100.2`, not
`< 100.0`. The 0.2 L/day window matters when V_d,m sits between 100.0
and 100.2 — exactly where 000477's May lodgement lands (100.16 L/day).

For V_d,m = 100.16:
  Spec:    DVF = 0 → (61) = E × r1 × fu = 134.84 × 0.015 × 1.0 = 2.0225 ✓
  Buggy:   DVF = 100.2 - 100.16 = 0.04 → (61) = 2.0233 (off by 0.0008)

The cascade through the missing 0.0008 W on May LINE_61 propagated to
LINE_62/64/65 and then §5 LINE_72/73 + §6 LINE_84 — clearing one
constant unblocks the entire 000477 §4-§6 cluster.

Per-fixture cluster status (was/now):
  §3   24/24 → 24/24
  §4   46/54 → 53/54   (only 000487 LINE_65 remains)
  §5   50/54 → 52/54   (only 000487 LINE_72/73)
  §6   10/12 → 11/12   (only 000487 LINE_84)

All remaining cascade failures cluster on 000487 (slice 25d — derive
LINE_64A electric-shower kWh from cert per Appendix J step 8) plus §7
LINE_92/93 marginal residuals on 4 fixtures (precision artefact).

Scoreboard:
  section_cascade_pins: 286 → 293 PASS (+7)
  e2e SapResult:         32 →  32 PASS (still cascade-blocked by 000487
    LINE_65 + downstream §8-§12 pins not yet asserted)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:04:23 +00:00
Khalim Conn-Kowlessar
ca56fdee5b Slice 25b: 000487 §4 closure (7/8) — has_electric_shower routes Nbath
Closes §4 LINE_43 + LINE_44/45/46/61/62/64 for 000487 (7 of 8 fails).
LINE_65 still fails — needs Appendix J step 8 (electric-shower kWh
derivation from cert) to land before LINE_65 heat gains close.

Spec citation: SAP10.2 Appendix J (p.81) step 2a: `Nbath = 0.13N + 0.19
if shower also present; = 0.35N + 0.50 if no shower present`. The
"shower also present" branch fires when ANY shower is lodged — mixer OR
electric — per the implicit reading that step 1a's Noutlets includes
electric showers in the count.

Changes:
- SapHeating gains `electric_shower_count` + `mixer_shower_count`.
- `water_heating_from_cert` gains `has_electric_shower: bool = False`;
  combined with mixer-flow-rate presence to drive `has_shower`.
- `_mixer_shower_flow_rates_from_cert` honors `mixer_shower_count`
  (default 1 vented when unlodged — preserves legacy behaviour).
- `_has_electric_shower_from_cert` new helper.
- `water_heating_section_from_cert` plumbs `has_electric_shower`
  through bootstrap + final call (and the internal cert_to_inputs path).
- 000487 fixture: `electric_shower_count=1, mixer_shower_count=0`.

§4 per-fixture:
  fixture | LINE_42 | LINE_43 | LINE_44-46 | LINE_61-65
  000474  |   ✓     |   ✓     |    ✓       |   ✓ (9/9)
  000477  |   ✓     |   ✓     |    ✓       |   ✗ LINE_61/62/64/65 (slice 25c)
  000480  |   ✓     |   ✓     |    ✓       |   ✓ (9/9)
  000487  |   ✓     |   ✓     |    ✓       |   ✓ except LINE_65 (8/9)
  000490  |   ✓     |   ✓     |    ✓       |   ✓ (9/9)
  000516  |   ✓     |   ✓     |    ✓       |   ✓ (9/9)

Scoreboard:
  section_cascade_pins: 279 → 286 PASS (+7)
  e2e SapResult:         32 →  32 PASS (unchanged — LINE_65 cascade still
    open, blocks downstream §5 LINE_72/73 + §6 LINE_84 + §7 + downstream)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 22:44:40 +00:00
Khalim Conn-Kowlessar
015144361a Slice 25a: 000487 §3 full closure — RR detailed surfaces + gable_wall_external + roof-area-as-max + half-up rounding
§3 cascade pins now close at abs=1e-4 for all 6 fixtures (was 5 of 6 with
000487 the holdout). Five spec-grounded changes:

1. SapRoomInRoofSurface gains optional `u_value` override + new kind
   `gable_wall_external` per RdSAP10 Table 4 (p.22) row 1 (exposed gable,
   U "as common wall" with assessor-lodged override). Routes to (29a)
   walls + LINE_31 external area.

2. SapAlternativeWall gains optional `u_value` override — assessor-lodged
   measured U bypasses the Table 6 cascade. 000487 Ext1 has a 9-mm
   TimberWallOneLayer at U=1.90 outside the Table 6 buckets.

3. _part_geometry uses MAX of floor areas (not top) for roof area, per
   RdSAP10 §3.8 (p.20): "Roof area is the greatest of the floor areas
   on each level". Fixes 000487 Ext1 where ground=7.13 m² > first=5.63.

4. Replace Python `round()` (banker's) with `_round_half_up` for §15
   element-area rounding. Banker's rounds 17.125 → 17.12; SAP convention
   rounds half-up → 17.13. Boundary case appears in 000487 Ext1 party
   wall area (party_length 6.25 × height 2.74 = 17.125).

5. 000487 fixture lodges 5 detailed RR surfaces (party gable, external
   gable @ U=0.86, flat ceiling, stud wall, slope), roof_insulation_
   thickness=300 (both parts → U=0.14), is_exposed_floor=True on Ext1
   floor 0, and u_value=1.90 on the Ext1 alt wall.

§3 cascade per-fixture:
  field    | 474 | 477 | 480 | 487 | 490 | 516
  LINE_31  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_33  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_36  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_37  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓

Scoreboard:
  section_cascade_pins: 274 → 279 PASS (+5: §3 +4 for 000487, §7 +1
    cascade)
  e2e SapResult:         32 →  32 PASS (unchanged — downstream §8-§12
    pins not yet asserted)

§4 (000487) deferred to slice 25b — needs has_electric_shower routing
through the §4 cascade so Nbath uses the "0.13N+0.19" branch when only
electric showers are present.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 22:32:41 +00:00
Khalim Conn-Kowlessar
6e6bba7e67 Slice 26c: §7 mean internal temperature cascade pin (44/60 PASS)
Added `mean_internal_temperature_section_from_cert` composing §1 (dim)
+ §2 (effective_monthly_ach) + §3 (total HLC) + §5 (internal gains)
+ §6 (solar gains) + climate (external temp) and threading them through
the §7 orchestrator — exact mirror of the cert_to_inputs internal
cascade.

Added 60 strict pin cases for §7 worksheet lines (85)..(94): T_h1
scalar, living_area_fraction scalar, η_living + T_living + T_h2 +
η_elsewhere + T_elsewhere + T_92 + T_93 + η_whole monthly tuples.

§7 per-fixture monthly pin status:
  fixture | passing
  000474  | 6 of 8  (LINE_92/93 ~0.0001 K residual)
  000477  | 6 of 8  (LINE_92/93 ~0.0002 K residual)
  000480  | 6 of 8  (LINE_92/93 ~0.0001 K residual)
  000487  | 0 of 8  (cascade from §3 RR + §4 HW defects)
  000490  | 6 of 8  (LINE_92/93 ~0.0001 K residual)
  000516  | 8 of 8  ✓

LINE_92/93 marginal fails on 4 fixtures: weighted-sum of T_living +
T_elsewhere drifts by ~1e-4 K from PDF despite the per-zone temps
matching at 1e-4 individually. Likely a PDF intermediate-precision
artefact (analogous to U_eff at 5 dp in §3 windows); investigation
deferred — no widening per project policy.

Scoreboard:
  section_cascade_pins: 230 → 274 PASS (+44; 60 new tests, 16 fail)
  e2e SapResult:         32 →  32 PASS (unchanged — §7 cascade was
    already running internally, pin tests just surface the line refs)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 21:50:12 +00:00
Khalim Conn-Kowlessar
1e9654ce28 Slice 26b: §6 solar gains cascade pin + SapRoofWindow solar attrs
Added `solar_gains_section_from_cert` and 12 strict pin cases for §6
LINE_83 (total solar W) and LINE_84 (total internal + solar gains).

Extended SapRoofWindow with the solar attrs needed for line (82) roof-
window monthly gain: `orientation` (SAP10.2 code 1..8), `pitch_deg`,
`g_perpendicular`, `frame_factor`. Defaults match the modal RdSAP roof
window (45° pitch, DG g⊥=0.76, PVC FF=0.70, N). 000516 lodges
orientation=2 (NE) + pitch=45 from the U985 cert.

Plumbed `_roof_windows_for_solar_gains` through both `solar_gains_
section_from_cert` and the internal `cert_to_inputs` cascade so the
production §6 cascade now picks up 000516's NE roof window contribution
to (82). Exposed `ORIENTATION_BY_SAP10_CODE` from solar_gains for the
SAP10.2 code → Orientation enum mapping the cascade needs.

§6 cascade (LINE_83 monthly):
  fixture | LINE_83 | LINE_84
  000474  |    ✓    |    ✓
  000477  |    ✓    |    ✗ (cascaded §4 LINE_65 → §5 LINE_72/73)
  000480  |    ✓    |    ✓
  000487  |    ✓    |    ✗ (cascaded HW lodgement defect, slice 25)
  000490  |    ✓    |    ✓
  000516  |    ✓    |    ✓ (roof window now feeding (82))

Scoreboard:
  section_cascade_pins: 220 → 230 PASS (+10; 12 new tests, 2 fail)
  e2e SapResult:        30 →  32 PASS (+2, downstream of §6 closure)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 21:41:58 +00:00
Khalim Conn-Kowlessar
9cb79d9c98 Slice 26: §5 internal gains cascade pin (50/54 PASS) + rooflight daylight plumb
Added `internal_gains_section_from_cert` helper composing §1 (volume) +
§4 (heat_gains line 65)m → §5 orchestrator, and 54 strict pin cases for
worksheet lines (66)..(73) monthly + (232) annual lighting kWh.

Also fixed a missing input plumb: cert_to_inputs was passing
`rooflight_total_area_m2=0` to `internal_gains_from_cert`, so the
000516 roof window (lodged on `epc.sap_roof_windows` since slice 24)
wasn't contributing to the L2a daylight factor. Added
`_rooflight_total_area_m2_from_cert` and routed it through both the
public cert→inputs cascade and the new §5 section helper.

§5 cascade:
  field    | 474 | 477 | 480 | 487 | 490 | 516
  LINE_66  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_67  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  (rooflight plumb)
  LINE_68  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_69  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_70  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_71  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_72  |  ✓  |  ✗  |  ✓  |  ✗  |  ✓  |  ✓
  LINE_73  |  ✓  |  ✗  |  ✓  |  ✗  |  ✓  |  ✓
  LINE_232 |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓

Remaining failures are 000477 + 000487 LINE_72/73 — cascaded from §4
LINE_65 heat_gains residuals (000477 combi loss, 000487 HW lodgement
defect). Both fixtures are slice 25 territory.

Scoreboard:
  section_cascade_pins: 170 → 220 PASS (+50; 54 new tests, 4 fail)
  e2e SapResult:        29 →  30 PASS (+1, downstream from rooflight plumb)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 21:21:32 +00:00
Khalim Conn-Kowlessar
d4c090fc7c Slice 27b: §3 element-area rounding to 2 d.p. per RdSAP10 §15 (p.66)
Spec text (RdSAP 10 §15, 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. / [...]"

Applied 2-d.p. rounding to every per-element gross area inside
heat_transmission_from_cert: gross_wall + party_wall (in _part_geometry),
window total area, door area, top_floor (roof) area, ground_floor area,
roof-window area, alt-wall area, RR-detailed-surface area. U-values
already came from table lookups at 2 d.p.

§3 cascade pins (LINE_31/33/36/37) now close at abs=1e-4 for 5 of 6
fixtures. 000487 remains failing on the RR defect (slice 25).

Scoreboard:
  section_cascade_pins: 151 → 170 PASS (+19)
  e2e SapResult:        27 →  29 PASS (+2)

Per-fixture §3 status:
  field    | 474 | 477 | 480 | 487 | 490 | 516
  LINE_31  |  ✓  |  ✓  |  ✓  |  ✗  |  ✓  |  ✓
  LINE_33  |  ✓  |  ✓  |  ✓  |  ✗  |  ✓  |  ✓
  LINE_36  |  ✓  |  ✓  |  ✓  |  ✗  |  ✓  |  ✓
  LINE_37  |  ✓  |  ✓  |  ✓  |  ✗  |  ✓  |  ✓

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 09:13:57 +00:00
Khalim Conn-Kowlessar
1821f3fef3 Slice 27: round BS EN ISO 13370 floor U to 2 d.p. per RdSAP10 §5.12
Spec text (RdSAP 10 §5.12, p.46): "Unless provided by the assessor the
floor U-value is calculated according to BS EN ISO 13370 using its area
(A) and exposed perimeter (P) and rounded to two decimal places." Our
u_floor returned the raw formula output — that's a 0.0040 W/m²K precision
gap vs the PDF that was costing 0.03–0.13 W/K on §3 LINE_33 for 4 fixtures.

§3 LINE_33 residuals collapsed:
  000474: 0.0296 → 0.0032
  000477: 0.1246 → 0.0013
  000480: 0.0168 → 0.0075
  000490: 0.0282 → 0.0013
  000516: 0.0038 → 0.0038 (exposed floor, Table 20 — unaffected)
  000487: 37.88 (RR defect, slice 25)

+3 SapResult pin closures (000474/477/490 ECF now pass at abs=1e-4).
Pin counts: section_cascade 151/35 unchanged (residuals shrunk but still
> 1e-4); e2e SapResult 24→27 PASS.

Remaining LINE_33 0.001–0.0075 W/K is wall + party-wall area precision —
PDF stores 2-d.p.-rounded element areas (slice 27b).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 08:50:33 +00:00
Khalim Conn-Kowlessar
af51be1780 Slice 24: rooflight line (27a) for 000516 — SapRoofWindow datatype + cascade
Closes 000516's §3 LINE_33 0.8215 W/K rooflight gap. Adds SapRoofWindow to
EpcPropertyData (area + raw U from RdSAP10 Table 24 "Roof window" column,
p.50/113) and iterates them in heat_transmission_from_cert alongside vertical
windows — same SAP10.2 §3.2 curtain transform R=0.04. Rooflight area is
subtracted from the main part's roof gross so net (30) + (27a) = original
gross, leaving (31) area aggregate invariant.

000516 LINE_33 residual: 0.8215 W/K → 0.0038 W/K. Remaining 0.0038 is the
same pre-existing wall-perimeter + per-window curtain precision drift biting
000474/477/480/490 (slice 27).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 08:28:32 +00:00
Khalim Conn-Kowlessar
1ac22f3a58 Doc rot cleanup: delete 4 stale SAP-spec docs, refresh sap/README
Documents deleted (pre-implementation or superseded):

- `docs/sap-spec/CALCULATOR_DESIGN_SKETCH.md` — pre-implementation
  design sketch referencing SAP 10.3 PDF. Status field said "sketch
  only — not implemented" but the calculator IS implemented and the
  active spec target is SAP 10.2 per ADR-0010. Served its purpose.

- `docs/sap-spec/HANDOVER_SECTION_6.md` — §6 handover from when §6
  was being built. §6 is now Full (per closed cascade pins).
  Superseded by HANDOVER_NEXT.md.

- `docs/sap-spec/PARITY_FINDINGS.md` — log of MAE/RMSE measurements
  against 100-cert sample. The project has since moved to strict
  abs=1e-4 per-line-ref pins on 6 deterministic test vectors; MAE/
  RMSE on a random sample doesn't carry information value any more.
  Superseded by the cascade pin scoreboard in HANDOVER_NEXT.md.

- `docs/sap-spec/SPEC_COVERAGE.md` — coverage map with status table
  per-section. Stale: said §3 "Full (non-RR)" but RR detailed is
  implemented; said §4 "Table 3c pending" but Table 3c landed in
  slices 6-7; said §14 CO2/primary energy partial — current state
  lives in HANDOVER_NEXT.md cascade pin scoreboard. Maintenance
  burden of keeping a static status table in sync with reality made
  it net-negative.

`packages/domain/src/domain/sap/README.md` updates:

- Spec reference repointed to SAP 10.2 (14-03-2025) per ADR-0010
  (was sap-10-3-full-specification-2026-01-13.pdf).
- Added validation contract section pointing to test_section_
  cascade_pins.py + test_e2e_elmhurst_sap_score.py with the
  abs=1e-4 rule.
- Window lodgement section: documented per-window u_value path
  (slice 22) instead of legacy single-avg-U.
- §3 "currently only checks invariants" claim removed — all four §3
  aggregates pinned at abs=1e-4.
- Room-in-roof "one big known gap" claim removed — §3.10 detailed
  surfaces implemented across slices 13/16/23. U=0.86 external
  gable variant flagged as the remaining open item.
- "Worksheet lines to capture" guidance points at the cascade pin
  approach + capturing every line through §12.

Also added §A.4 to HANDOVER_NEXT.md: the user prefers the
fixture × line-ref matrix format for scoreboard reporting (with ✓
for within abs=1e-4 or numeric Δ for finer granularity). Following
sections renumbered A.5/A.6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 07:42:58 +00:00
Khalim Conn-Kowlessar
61e369faf7 HANDOVER_NEXT: rewrite for strict zero-error cascade pin closure
Replaces the previous handover. The previous one framed the work as
"close three tickets to integer Δ=0" — a weak gate. The user has
since made clear the real requirement is **abs=1e-4 on every line ref
of every output for every fixture**, and that previous agents have
repeatedly made the following mistakes:

1. Treated SAP integer Δ=0 as "closed" (it hides ±0.5 continuous
   drift).
2. Widened tolerances (rel=0.15 / rel=0.05 / <=0.5) to make tests
   green — masking real residuals.
3. Tested sections in isolation using PDF values as INPUTS — that
   verifies the section formula but not the cascade.
4. Diagnosed downstream first when upstream sections still drift.
5. Missed fixture-lodgement defects (bulbs / windows / sap_heating /
   detailed RR / exposed_floor / door_count / per-window u_value) —
   the cascade pin failure was the fixture, not the calculator.
6. Labelled code "SAP 10.3" when implementing 10.2.

The new handover front-loads these anti-patterns (§A.3), then states
the current cascade-pin scoreboard, the work queue in priority order
(rooflight, 000487 RR + U=0.86 gable, then §5/§6/§7/§8/§9a/§10a/§11a/
§12 pins in worksheet order), the diagnostic loop, and the spec page
anchors the user has already given.

Three new memories were also written:
- feedback-zero-error-strict (abs=1e-4, no widening)
- feedback-cascade-pin-methodology (test the cascade, not isolation)
- feedback-fixture-defects-common (audit fixture first)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 07:35:25 +00:00
Khalim Conn-Kowlessar
ac68cf88a0 Slice 23: 000516 detailed RR + exposed_floor + door_count fixture lodgement
Mirrors S16a for 000516 — the second Simplified-Type-1 fallback
fixture in the cohort. PDF lodges detailed §3.10 RR + exposed Main
floor + 2 doors; fixture previously lodged only `SapRoomInRoof(
floor_area=19.02)` Simplified fallback + `is_exposed_floor=False` +
`door_count=1`.

Lodgement changes:

- `detailed_surfaces` on the Main RR: 7 surfaces per PDF §3 lines
  (30)/(32) — 1 flat ceiling 3.56 m² uninsulated, 2 stud walls 3.88
  m² @ 100mm mineral_wool (Table 17 col 3a → U=0.36), 2 slopes 6.41
  m² uninsulated (U=2.30), 2 gable walls 13.11 m² treated as party
  at U=0.25.
- `is_exposed_floor=True` on Main floor=0 (28b "Exposed floor Main
  35.76 × U=1.20"). Floor sits over an unheated space, not earth.
- `roof_insulation_thickness=0` on Main — PDF (30) "External roof
  Main 15.56 × U=2.30" UNINSULATED Table 16 "none" row.
- `door_count` 1 → 2 to match PDF (26) total area 3.70 m² = 2 × 1.85.

Impact on §3 cascade pins:

  pin       | before slice 23 | after slice 23
  ----------|-----------------|---------------
  LINE_31   | +20.37 m² Δ     | +0.0025 m² Δ (sub-display)
  LINE_33   | -6.75 W/K Δ     | -0.82 W/K Δ (rooflight gap, slice 25)
  LINE_36   | +3.06 W/K Δ     | +0.0004 W/K Δ (sub-display)
  LINE_37   | -6.75 W/K Δ     | -0.82 W/K Δ

Remaining 0.82 W/K LINE_33 gap is the rooflight: PDF lodges a 1.18 m²
roof window on line (27a) at U_eff=2.9930 (Table 24 metal-frame
pre-2002 raw 3.4 + curtain). Our §3 cascade doesn't yet incorporate
roof windows — they're defined in SECTION_6_ROOF_WINDOWS for solar
gains but not in the heat-transmission path. Slice 25 will add (27a)
line-ref handling.

§3 cascade pin count unchanged at 23 FAIL / 1 PASS — the 000516
residuals dropped 10× but still > abs=1e-4. The downstream §4-§12
cascade for 000516 likely tightens once §3 closes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 23:37:28 +00:00
Khalim Conn-Kowlessar
6be8fdb7b6 Slice 22: per-window curtain resistance — fixes mixed-glazing window U
SAP 10.2 §3.2 applies the 0.04 m²K/W curtain resistance per window;
the worksheet's (27) column shows it that way. Our calc had been
applying it ONCE to the area-weighted-avg raw U across all windows.
That's correct when all windows share a U but biased when a dwelling
has mixed glazing types (typical Elmhurst fixture lodges 2 types):

  U_eff(weighted_avg(U_i)) ≠ weighted_avg(U_eff(U_i))

because 1/(1/U + 0.04) is non-linear. The drift was ~0.05-0.10 W/K
on `windows_w_per_k` for 000474, 000477, 000487 (mixed-glazing
fixtures).

Fix: when sap_windows have per-window u_value lodged (the spec-
faithful path), iterate them computing per-window U_eff × area and
sum. Falls back to the legacy single-avg-U path when window U isn't
lodged (back-compat for synthetic tests that pass
`window_avg_u_value=...` directly).

Per-window LINE_27 numbers now match PDF exactly:

  fixture | windows W/K calc → PDF | LINE_33 Δ before → after
  --------|------------------------|---------------------------
  000474  | 25.4243 → 25.3674 ✓    |   +0.0864 → +0.0296  (-66%)
  000477  | 17.8550 → 17.8349 ✓    |   -0.1045 → -0.1246  (small
                                       widening — exposes
                                       upstream floor-U drift)
  000487  | (cascading)            |   +37.88 (RR defect, slice 23)
  000480  | unchanged              |   -0.0168 → -0.0168  (single U)
  000490  | unchanged              |   +0.0282 → +0.0282  (single U)
  000516  | (cascading)            |   -6.75 (RR defect, slice 23)

Total cascade pin failure count unchanged at 83 (pins still above
abs=1e-4 floor by 0.03-0.13 W/K — sub-display-precision drift left
in floor-U cascades + the two RR fixture defects).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 23:33:23 +00:00
Khalim Conn-Kowlessar
778b150c98 Slice 21e: §4 water heating cascade pins (42/54 PASS)
Extracts `water_heating_section_from_cert(epc) -> WaterHeatingResult`
helper to expose the full §4 cascade output for tests (mirrors the
existing private `_water_heating_worksheet_and_gains`, drops unused
args).

§4 pins at abs=1e-4:
  scalar (2 line refs × 6 = 12): (42) occupancy, (43) annual avg L/day
  monthly (7 line refs × 6 × 12 months = 504 assertions across 42
   parametrized cases): (44)m daily, (45)m energy content,
   (46)m distribution loss, (61)m combi loss, (62)m total demand,
   (64)m output, (65)m heat gains

Per-fixture results:
  000474:    9/9 PASS  ✓
  000477:    5/9       — combi loss (61)m diverges → cascades to
                          62/64/65 monthly
  000480:    9/9 PASS  ✓
  000487:    1/9       — LINE_43 + every monthly fails (HW lodgement
                          defect: number_baths=1 but PDF arithmetic
                          suggests different shower/bath profile)
  000490:    9/9 PASS  ✓
  000516:    9/9 PASS  ✓

4/6 fixtures close §4 fully — strong cascade floor. The 000477 combi
loss residual is a specific Table 3c sub-row issue; the 000487 §4 gap
is part of its broader cert lodgement defect (RR + HW lodgement).

Cumulative scoreboard:
  §1: 12 PASS / 0 FAIL
  §2: 96 PASS / 0 FAIL
  §3:  1 PASS / 23 FAIL  (precision residuals + 000487 RR)
  §4: 42 PASS / 12 FAIL
  ---
  total: 151 PASS / 35 FAIL

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 23:19:21 +00:00
Khalim Conn-Kowlessar
024244ec59 Slice 21d: §3 cascade pins + heat_transmission_section_from_cert helper
Extracts `heat_transmission_section_from_cert(epc)` wrapping the §3
inline call in cert_to_inputs (window-area/window-U/dwelling-exposure
plumbing). Replaces the inline call. Adds §3 cascade pins for the
four aggregate line refs:

  (31) total_external_element_area_m2
  (33) fabric_heat_loss_w_per_k
  (36) thermal_bridging_w_per_k
  (37) total_w_per_k

Results at abs=1e-4 (1/24 PASS):

  fixture | LINE_31 diff | LINE_33 diff | LINE_36 diff | LINE_37 diff
  --------|--------------|--------------|--------------|-------------
  000474  |     0.0014   |     0.086    |     0.0002   |     0.086
  000477  |     0.0004   |     0.105    |     ✓        |     0.104
  000480  |     0.006    |     0.017    |     0.0009   |     0.018
  000487  |     8.82     |    37.88     |     1.32     |    39.21
  000490  |     0.000    |     0.064    |     0.000    |     0.064
  000516  |     0.012    |     0.183    |     0.002    |     0.184

Three buckets:
- 000487 (RR fixture defect): large gaps — fixture lodges Simplified
  Type 1 RR but PDF has detailed §3.10 lodgement including a U=0.86
  external gable. Slice 22 closes (mirrors S16a).
- 000474/000477/000480/000490/000516 (precision residuals): LINE_33
  drifts 0.02-0.18 W/K — sub-display-precision (PDF lodges to 4 d.p.
  per element, our calc combines full-precision per-storey perimeters
  + 4-d.p. U values). The aggregate diff of ~0.1 W/K is just over the
  abs=1e-4 floor but well under the worksheet's display granularity.

Cascade pins now: §1 (12 PASS) + §2 (96 PASS) + §3 (1 PASS, 23 FAIL).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 23:13:48 +00:00