Commit graph

6197 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
a97ff60b01 fix(water-heating): complete RdSAP Table 28 cylinder-size map (codes 5 + 6)
`_CYLINDER_SIZE_CODE_TO_LITRES` held only codes 2/3/4 (Normal/Medium/Large →
110/160/210 L); codes 5 (Inaccessible) and 6 (Exact) fell through to None,
so the Table-13 high-rate fraction AND the cylinder storage loss were skipped
for those certs (20 code-6 certs in the API sample).

Per RdSAP 10 Specification (10-06-2025) §10.5 Table 28 (PDF p.55):
- Code 6 "Exact": use the lodged measured volume. The gov API carries it in
  `cylinder_size_measured` (e.g. 150 L) — now plumbed through the 21.0.0/21.0.1
  schema → mapper → `SapHeating.cylinder_volume_measured_l`.
- Code 5 "Inaccessible": 210 L if off-peak electric dual immersion, 160 L from
  a solid-fuel boiler, otherwise 110 L (n=0 in the current sample, but
  spec-complete).

New `_cylinder_volume_l_from_code` centralises Table 28 resolution and replaces
the three raw-dict call sites (`_hot_water_cylinder_volume_l`, the cylinder
storage-loss path, and the PCDB performance check) so all three honour codes
5/6 identically. `_cylinder_inaccessible_volume_l` applies the code-5 context
rule via the existing immersion/off-peak-meter/solid-fuel-boiler detectors.

Worksheet harness UNAFFECTED (47/47, 0 divergers): the Summary path lodges
neither code 5/6 nor a measured volume. API gauge: within-0.5 64.4% -> 65.1%
(mean|err| 1.085 -> 1.075) — the 20 code-6 certs now size their cylinder from
the measured volume. 4 AAA tests (code 6 measured; code 5 solid-fuel/default/
off-peak-dual-immersion). pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:02:39 +00:00
Khalim Conn-Kowlessar
781efd75c0 fix(heat-transmission): apply dry-lining Table 14 R=0.17 to the main wall
The main-wall `u_wall(...)` call dropped the `dry_lined` kwarg, so the RdSAP 10
§5.7/§5.8 (PDF p.40-41) Table 14 dry-lining adjustment — U_adj = 1/(1/U₀ +
0.17) for a dry-lined (incl. lath-and-plaster) uninsulated wall — was never
applied to any main wall, even when the cert lodged `wall_dry_lined=Y`. The
ALTERNATIVE-wall path already passes `dry_lined` (line 1367); this one-sided
omission billed every dry-lined main wall at the un-adjusted (too-high) U →
wall heat loss too high → SAP under-rated.

Per-cert: a solid-brick (construction 3) band-A 230 mm main wall computes
U₀=1.70; dry-lined it is 1/(1/1.70+0.17)=1.32 — we were 22% too high. Across
the API gov-EPC sample the dry-lined `wall_construction=3` (solid brick)
sub-cohort sat at 10% within-0.5 / signed -1.33.

Fix: pass `dry_lined=bool(part.wall_dry_lined)` to the main-wall `u_wall`
call, mirroring the alt-wall path. `part.wall_dry_lined` is already plumbed
(Optional[bool], None → False). The three dry-lining branches in `u_wall`
(stone §5.6, solid-brick-by-thickness §5.7, generic uninsulated bucket §5.8)
are all spec-correct and already worksheet-validated (the bucket-0 cavity
case against cert 7700 age-C → 1.20).

Worksheet harness UNAFFECTED (47/47, 0 divergers): the Elmhurst/Summary
extractor only captures dry-lining for ALTERNATIVE walls (Summary §7), never
the main wall, so `part.wall_dry_lined` stays None on that path — this is a
pure API-path improvement. API gauge: within-0.5 60.1% -> 64.4% (mean|err|
1.163 -> 1.085, signed -0.097 -> +0.049). Both affected buckets improved
with no overshoot: solid brick (wc=3) 50% -> 57% within-0.5; cavity (wc=4,
dry-lined via the §5.8 bucket-0 path) 68% -> 72%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 09:43:50 +00:00
Khalim Conn-Kowlessar
b7d283cd3a docs(profile-case34): mark the space-demand residual closed (450e33e1)
The §2 (13) draught-lobby fix landed the +46.3 kWh space-heating over-count
on the worksheet; the tracked diagnostic's header and run-banner now reflect
the closed state (Δ +0.0036 SAP, sub-2dp-rounding) instead of the open gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 09:01:41 +00:00
Khalim Conn-Kowlessar
450e33e15d fix(ventilation): corridor flat assumes a draught lobby, zeroing §2 (13)
A flat accessed via an unheated corridor/stairwell assumes a draught lobby
is present, so SAP 10.2 §2 line (13) = 0.0 rather than the 0.05 no-lobby
infiltration penalty. Per RdSAP 10 Specification (10-06-2025, p.30, "Draught
lobby"): "add infiltration 0.05 if draught lobby is not present, or use 0.0
if present. ... Flat or maisonette: Assume draught lobby if entrance door is
facing corridor (heated or unheated) or stairwell."

Signal: a SHELTERED alternative wall (the RdSAP §5.9 wall-to-unheated-corridor
surface) is the evidence that the flat's entrance faces a corridor — the same
evidence the corridor door (Table 26 U=1.4) rides on. New helper
`_has_sheltered_corridor_wall` factors that check out of `_corridor_door_count`
and gates `_has_draught_lobby`. Houses and exposed-gable flats (no sheltered
alt wall) keep the lodged value / "assume no lobby if cannot be determined"
default, so the §2 cascade is unchanged for every non-corridor dwelling.

The cascade previously added the 0.05 penalty unconditionally, over-counting
(16)/(18)/(21) by 0.05 ACH. On simulated case 34 (cert 001431 storage flat)
this lifted effective air change (25)m from the worksheet's monthly 0.572-0.638
to 0.574-0.668, over-counting space-heating demand (98) by +46.3 kWh/yr
(+0.41%) -> SAP -0.18. Closing it lands (25)m exactly on the worksheet (avg
0.6024) and (98) at 11356.3 vs ws 11357.2:

  case 34 SAP 35.1325 -> 35.3130 vs ws 35.3094  (Δ -0.1769 -> +0.0036)

Guard-rails held (both improved): worksheet harness 47/47, 0 divergers (the
other corridor flat, cert 2474, -0.32 -> -0.02); API gauge 60.0% -> 60.1%
within 0.5, mean|err| 1.167 -> 1.163.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 09:00:54 +00:00
Khalim Conn-Kowlessar
c10881ae7a feat(heat-transmission): door to unheated corridor uses Table 26 U=1.4 on the sheltered wall
A door opening to an unheated corridor/stairwell takes U=1.4 W/m²K (RdSAP 10
Table 26, p.51 — any age band) instead of the 3.0 external-door default, and
its area deducts from the SHELTERED wall, not the main wall (RdSAP §3.7,
p.18: "the door of a flat/maisonette to an unheated stairwell or corridor
... is deducted from the sheltered wall area"). The cascade previously
billed every door at the external U on the main wall.

Signal: a SHELTERED alternative wall (`is_sheltered`, the RdSAP §5.9
wall-to-unheated-corridor surface, already modelled) is the evidence that
the dwelling is accessed via an unheated corridor, so one lodged door opens
to it. `_corridor_door_count` returns 1 when a sheltered alt wall is present
and >=1 door is lodged, else 0 — so the door channel is unchanged for every
non-corridor dwelling (houses, exposed-gable flats). `heat_transmission_
from_cert` gains a `corridor_door_count` param (default 0): it splits the
door area into external (main wall, age-default U) + corridor (sheltered
alt wall, U=1.4), threading the corridor door's area into that wall's
opening deduction and billing it at 1.4.

Validated on TWO faithful worksheets: simulated case 34 (cert 001431
storage flat — doors 8.14 exact, fabric 207.47 ≈ ws 207.48) and the
long-standing worksheet-harness diverger cert 2474 (−0.87 → −0.32, the
"space-demand thread" was the dropped corridor door). The worksheet harness
is now 47/47 with ZERO divergers.

API SAP gauge: 57.6% → 60.0% within 0.5; mean|err| 1.185 → 1.167; signed
−0.165 → −0.115 — ~22 sheltered-corridor flats were a systematic gap.
Regression gate green (3 pre-existing fails unrelated); pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:03:06 +00:00
Khalim Conn-Kowlessar
06989d6b0f fix(elmhurst-extractor): allocate single-glazed alt-wall windows to the alternative wall
The §11 layout parser keys a window's wall Location on the glazing-prefix /
orientation tokens around its data row. An alt-wall window lodges its
"Alternative wall 1" Location wrapped across the lines bracketing the W×H×A
row. For a DOUBLE-glazed alt window the prefix line also carries the glazing
phrase ("Double between 2002   Alternative wall"), so the partition breaks
there and the location survives into the window's pre-data slice. For a
SINGLE-glazed alt window the "Alternative wall" line stands alone with no
glazing-type word, so _partition_after_manuf scanned past it and swallowed
it into the PREVIOUS window's suffix — the window then defaulted to
"External wall" and its opening deducted from the wrong wall.

Fix: treat a standalone wall-location line ("Alternative wall" / "External
wall" / "Party wall") as a window boundary in _partition_after_manuf, so it
attaches to the following window's prefix. Surfaced by simulated case 34
(cert 001431 electric-storage flat): 2 of 4 single-glazed alt-wall windows
were mis-allocated, splitting 2.75/10.78 m² instead of the worksheet's
4.63/8.90 corridor/external opening areas.

Elmhurst-extractor only; API gauge unaffected. Regression gate green (3
pre-existing fails unrelated); worksheet harness 47/47 unchanged. Case 34's
alt-wall opening area now matches the worksheet; the corridor wall net area
is correct (the cert's residual is now isolated to the unheated-corridor
door, a separate slice).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:54:06 +00:00
Khalim Conn-Kowlessar
48b36d3d7e fix(elmhurst-mapper): carry §7 alternative-wall "Sheltered Wall" flag
The Elmhurst Summary §7 lodges "Alternative Wall N Sheltered Wall: Yes" for
a sub-area adjacent to an unheated buffer (e.g. a flat's corridor wall),
but the extractor dropped it and _map_elmhurst_alternative_wall never set
SapAlternativeWall.is_sheltered — so the cascade billed the sub-area at its
full exposed U instead of the RdSAP 10 Table 4 (p.22) sheltered U =
1/(1/U + 0.5).

The calculator already applies is_sheltered (_alt_wall_w_per_k) and the
gov-API path already wires sheltered_wall=="Y"; this brings the Elmhurst
front-end to parity. Three-part change: AlternativeWall.sheltered field +
_alternative_walls_from_lines parse ("Alternative Wall N Sheltered Wall") +
_map_elmhurst_alternative_wall is_sheltered=a.sheltered.

Surfaced by simulated case 34 (cert 001431 electric-storage flat): the
6.02 m² corridor wall billed at full U=1.50 (9.03 W/K) instead of the
sheltered 0.86 (5.18 W/K) — +3.85 W/K, -1.61 SAP. Post-fix the alt wall
matches the worksheet's (29a) 5.177 and case 34 closes from -1.61 to -0.30
(remaining residual is a separate window/wall area-allocation thread).

Elmhurst-mapper only: API SAP gauge unchanged (57.6% within 0.5); worksheet
harness 47/47 unaffected; regression gate green (3 pre-existing fails
unrelated); pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:35:46 +00:00
Khalim Conn-Kowlessar
f3dcd7b43e fix(elmhurst-mapper): single-storey flat with exposed roof is Top-floor, not Ground-floor
The Elmhurst dwelling-type classifier keyed "Top-floor flat" on a "dwelling
below" floor lodgement. A single-storey flat exposed BOTH top (a real
external roof) AND bottom (floor over partially-heated space, no dwelling
below) therefore fell through to "Ground-floor flat" — which the cascade's
_dwelling_exposure maps to has_exposed_roof=False, dropping the external
roof entirely.

Surfaced by simulated case 34 (cert 001431 reconfigured as a slimline
electric-storage flat): the worksheet bills (30) External roof = 39.98 m²
x U=2.30 = 91.95 W/K — the dominant heat-loss element — but the cascade
dropped it, under-stating space-heating demand by 42% (6550 vs 11357
kWh/yr) and over-predicting SAP by +21.76 (57.07 vs worksheet 35.31).

Fix: an exposed (non-party) roof puts the flat on the top storey
regardless of what is below it. Classify as "Top-floor flat" whenever the
roof is exposed; the flat's exposed floor is recovered downstream by the
existing per-BP is_above_partially_heated_space / is_exposed_floor override
in heat_transmission (§3). Party-roof flats ("another dwelling above") are
unaffected and stay Ground-/Mid-floor.

This is an Elmhurst-mapper (dwelling_type) bug, NOT a calculator bug: the
calculator correctly trusts dwelling_type, and the gov-API path supplies
the position directly (cert 0036 — a genuine ground-floor flat whose API
data lodges a "Pitched, no access" roof construction under another dwelling
— stays party, 2.51 W/K). API SAP gauge unchanged (57.6% within 0.5);
worksheet harness 47/47 unaffected; case 34 roof now exact (residual -1.61
is a separate flat-corridor wall-U thread). Regression gate green (3
pre-existing fails unrelated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:23:56 +00:00
Khalim Conn-Kowlessar
b0a47cda05 fix(elmhurst-mapper): strip interleaved Alternative-wall fragments from glazing label
When a property lodges an Alternative Wall, pdftotext interleaves the §11
"Location" column ("Alternative wall 1") into the wrapped glazing-TYPE cell,
producing labels like "Double between 2002 Alternative wall and 2021 1
Alternative wall" (cert 001431 storage-heater variants, simulated case 34).

The existing greedy trailing-suffix strip (\s+Alternative wall.*$) truncates
at the FIRST "Alternative wall", losing "and 2021" and yielding the
unmatchable "Double between 2002". Added a fallback that removes EVERY
"<External|Alternative|Party> wall [n]" fragment and any stray 1-2 digit
location index from the raw label, then retries the lookup. Loss-free: no
glazing-type key contains a wall-location phrase or a bare 1-2 digit number
(install-date years are 4 digits).

Unblocks the Summary cascade for any property with an Alternative Wall;
Summary-path only (the API path receives structured glazing codes, so the
API gauge is unaffected). Regression gate green (1 pre-existing fail
unrelated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:07:08 +00:00
Khalim Conn-Kowlessar
85d6f8468c feat(elmhurst-extractor): capture section 15.1 Immersion Heater (Dual/Single)
The Elmhurst Summary section 15.1 "Hot Water Cylinder" block lodges
"Immersion Heater: Dual" / "Single"; the extractor dropped it, so the
Summary path left immersion_heating_type = None while the API path already
captured it. Capturing it drives SAP Table 13's high-rate-fraction
DHW-cost split (RdSAP 10 section 10.5 p.54: 1 = dual, 2 = single) and
brings the two front-ends to parity.

Three-file change: WaterHeating.immersion_type field +
_extract_water_heating parse (scoped to the 15.1..15.2 slice) +
_elmhurst_immersion_type_code mapper (strict-raise on an unmapped label,
mirroring _elmhurst_cylinder_insulation_code).

Safe to land now that the preceding commit zeroes the high-rate fraction
for 18-/24-hour tariffs: the 20 solid-fuel corpus certs (solid fuel 4-11:
WHC 903 dual immersion, 18-hour meter, 110 L) carry a dual immersion, but
their 18-hour tariff bills 100% low-rate per Table 12a's 7-/10-hour scope
— so they stay EXACT instead of regressing to the 10-hour-column ~0.10.
7-/10-hour Summary immersion certs now correctly cost the Table 13
high-rate fraction instead of falling to the immersion=None 100%-low
default.

Regression gate green (3 pre-existing fails unrelated); API gauge
unchanged (Summary-path-only): 57.6% within 0.5, mean|err| 1.185.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:16:21 +00:00
Khalim Conn-Kowlessar
0202b045de fix(water-heating): 18-/24-hour immersion DHW bills 100% low-rate (Table 12a scope)
SAP 10.2 Table 12a (PDF p.191) is titled "High-rate fractions for systems
using 7-hour and 10-hour tariffs"; its "Immersion water heater" row lists
the tariff as "7-hour or 10-hour" only, routing to Table 13. An 18-hour or
24-hour tariff is OUTSIDE the table's scope — it provides at least 18
hours/day at the low rate, more than enough to heat any immersion cylinder
off-peak, so the high-rate fraction is 0 (all DHW billed at the low rate).

`electric_dhw_high_rate_fraction` previously mapped 18-/24-hour to the
10-hour equations (returning ~0.10 for a 110 L dual immersion) on an
over-literal reading of Table 13 Note 1 ("at least 10 hours"). The Elmhurst
dr87 worksheet for solid fuel 5 (cert 001431: 18-hour meter, 110 L dual
immersion, WHC 903) refutes that: HW (245) high-rate = 0.0 kWh, (246)
low-rate = 100%. Table 12a's title bounds the table to the two named
tariffs; 18-/24-hour fall outside it.

Resolves the Table-13 blocker on the immersion-extractor fix: once the
Summary extractor captures the dual immersion, the 18-hour solid-fuel
corpus certs stay at high_frac=0 (matching their worksheets) instead of
regressing to the 10-hour-column 0.10.

API SAP eval unchanged: 57.6% within 0.5, mean|err| 1.185, signed -0.165
(the cached sample has no 18-hour WHC-903 certs; one 24-hour cert shifts
sub-threshold). Regression gate green (3 pre-existing fails unrelated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:01:35 +00:00
Khalim Conn-Kowlessar
020ac6f220 fix(elmhurst-mapper): strip wrapped building-part fragment from glazing label
pdftotext can wrap the §11 building-part column onto the glazing-TYPE
token without an intervening glazing-gap descriptor, e.g. "Double between
2002 and 2021 1st" (the "1st" marks the 1st Extension). The existing
trailing-gap fallback only strips the fragment when preceded by "N mm";
the bare ordinal raised UnmappedElmhurstLabel.

New `_ELMHURST_GLAZING_LABEL_TRAILING_BP_RE` strips a trailing ordinal
("1st"/"2nd"/…) or "Main" and retries the lookup. No glazing-type key
ends in an ordinal or "Main", so it is loss-free. Surfaced by worksheet
`simulated case 33` (direct-acting electric boiler + immersion), which
previously could not be routed through the Summary cascade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:25:42 +00:00
Khalim Conn-Kowlessar
3cb2711418 fix(water-heating): assume cylinder thermostat present for electric/immersion/heat-network DHW (SAP 9.4.9)
SAP 10.2 §9.4.9 (PDF p.32) verbatim: "A cylinder thermostat should be
assumed to be present when the domestic hot water is obtained from a heat
network, an immersion heater, a thermal store, a combi boiler or a CPSU."
RdSAP 10 Table 29 (p.56) points the no-access default at this rule.

The storage-loss Table 2b temperature factor previously read only the
lodged `cylinder_thermostat` ("Y") — so an unlodged thermostat always took
the ×1.3 absent-penalty, over-stating storage loss by 30%. New
`_cylinder_thermostat_present` assumes it present when DHW is from a heat
network, WHC 903 (immersion), or a direct-acting electric boiler (SAP code
191 — electric-resistance, immersion-equivalent).

Found via the worksheet-folder harness: cert 2474-3059-4202-4496-3200
(Summary path: WHC 901, main SAP 191, electric, no lodged cylinder stat)
diverged −1.86 from its dr87 worksheet. The worksheet lodges (53)
temperature factor 0.6000 (present) and "add cylinder thermostat (SAP
increase too small)" — already assumed present. Fix lands HW output (64)
2701.99 → 2323.88, EXACT to the worksheet; 2474 −1.86 → −0.87 (residual is
a separate space-demand fabric thread). No other worksheet in the 47-cert
harness moved.

API eval within-0.5 56.9% → 57.6%; mean|err| 1.197 → 1.185; signed
−0.202 → −0.165. Regression green (only pre-existing fails); goldens +
heating corpus unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:01:05 +00:00
Khalim Conn-Kowlessar
00921f71e8 fix(water-heating): heat-network primary loss uses Table 3 h=3 all months
SAP 10.2 Table 3 (PDF p.160) verbatim: "For heat networks apply the
formula above with p = 1.0 and h = 3 for all months." The primary
circulation hours for a heat-network main are fixed at h=3 winter and
summer, independent of the cylinder-thermostat / separate-timing
lodgement that selects the h=5/h=11 rows for boiler systems.

`primary_loss_monthly_kwh` / `primary_circuit_hours_per_day_table_3` gain
a `heat_network` flag (→ (3, 3)); `_primary_loss_override` passes
`_is_heat_network_main(main)`. p=1.0 was already pinned via
`_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION`; only the hours were wrong.

Before, cert 8536 routed through the h=5/3 row because its community
biomass DHW fuel (31) collides with electricity code 31, so
`_separately_timed_dhw` returned False. The Table 3 heat-network rule
overrides that path: 8536 primary loss (59) 335.81 → 273.90, EXACT to
the faithful case-32 worksheet (storage (56) 376.58 also matches 376.94).

API eval within-0.5 57.0% → 56.9% (one offsetting-error cert crosses
out; signed err −0.205 → −0.202). Applied spec-uniformly per the
determinism principle — the heat-network primary hours are unambiguous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:12:49 +00:00
Khalim Conn-Kowlessar
e6543c76ca fix(water-heating): heat-network DHW with no cylinder uses SAP 10.2 HIU default store, not combi keep-hot
A heat-network main with DHW from the network and no lodged cylinder was
billed the Table 3a keep-hot 600 kWh/yr combi loss (cat 6 sat in
`_TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES`). A heat network is not a
combi boiler — SAP 10.2 §4 line 7702 says combi loss is 0 for non-combi
systems.

SAP 10.2 p.24 "Heat networks" (c): when neither a PCDB Heat Interface
Unit nor a lodged cylinder applies, "a measured loss of 1.72 kWh/day
should be used, corrected using Table 2b. This is equivalent to a
cylinder of 110 litres and a factory insulation thickness of 50 mm".
RdSAP 10 Table 29 (p.56): a cylinder thermostat is assumed present when
DHW is from a heat network (Table 2b temperature factor 0.60).

New `_apply_heat_network_hiu_default_store` rebinds the 110 L / 50 mm-
factory store (thermostat present) onto a heat-network DHW cert with no
cylinder and no PCDB index, mirroring `_apply_rdsap_no_water_heating_
system_default`. The injected store routes storage loss (56) ≈ 376.7
kWh/yr (= 1.72 × 0.60 × 365) + primary loss (59) through the existing
machinery and zeroes the combi (61) loss via the has_hot_water_cylinder
gate. Verified against the user's faithful case-32 worksheet: storage
(56) 376.58 vs worksheet 376.94.

Cert 8536 storage 0→376.6, combi 600→0. API eval within-0.5 56.8% →
57.0%; signed err −0.218 → −0.205. Reworked
`test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh` to
assert the DLF scaling directly (fuel ÷ §4 output = 1.41) since the old
two-cert baseline premise (both combi-600) no longer holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 19:59:21 +00:00
Khalim Conn-Kowlessar
ba56647401 fix(heat-network): derive dwelling age band from first non-empty building part
The GOV.UK API lodges a junk empty leading building part (all fields
None) ahead of the real Main Dwelling on some certs. Four sites in
cert_to_inputs.py read `sap_building_parts[0].construction_age_band` →
got None → silently dropped the dwelling age band. New `_dwelling_age_band`
helper takes the first part that lodges a band (a no-op for normal certs
where [0] is the Main part).

Closes two age-band-keyed defects on the 5 affected certs:

- SAP 10.2 Table 12c (p.193): the heat-network Distribution Loss Factor
  defaulted to the K-or-newer 1.50 instead of the dwelling's true band
  (cert 8536-0929-6500-0815-7206 is age A → 1.20), inflating distribution
  loss by 30%.
- RdSAP 10 §4.1 Table 5 (p.28): the empty band ("") fell through the
  age-band branches to the H–M habitable-rooms branch, defaulting in
  phantom extract fans. The true band A correctly yields 0 fans
  (bands A–E → 0).

Cert 8536: 31.76 → 41.12 vs lodged 39 (was −7.24, now +2.12). API eval
mean|err| 1.197 → 1.192, signed −0.229 → −0.218; headline within-0.5
holds at 56.8% (8536 lands at +2.1, a documented overshoot vs the
faithful case-31 worksheet — separate slice).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:43:17 +00:00
Khalim Conn-Kowlessar
e89b4041c7 test(efficiency): lock solid-fuel room-heater space eff to Table 4a column (B)
An API audit flagged the solid-fuel room-heater space efficiencies
(_SPACE_EFF_BY_CODE 631-636) as reading the "Water" column of SAP 10.2
Table 4a. That was a misread: the two room-heater columns are (A)
minimum-for-HETAS-approved and (B) other appliances — BOTH are space
efficiency, not space/water. RdSAP defaults to column (B) when HETAS
approval is not lodged, which is what these values already hold and what
the reference software produces (Elmhurst worksheet "solid fuel 9", SAP
code 636 → (206) space efficiency = 70 = column B; flipping to column A
75 broke that pin and three sibling solid-fuel corpus pins).

No value change — add a pin test + spec-cited comment so the column-(A)/
(B) distinction is explicit and this misread can't recur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:09:12 +00:00
Khalim Conn-Kowlessar
5a74897fed fix(water-heating): gate DHW separate-timing on programmer + boiler age (RdSAP 10 §10.5)
`_separately_timed_dhw` returned True for any boiler+cylinder+from-main
cert, applying the SAP 10.2 Table 2b note b) ×0.9 temperature-factor
reduction unconditionally. For the lpg-boiler "before" worksheet (pre-
1998 LPG boiler SAP code 115 + 210 L cylinder, NO cylinder thermostat,
control 2113 "Room thermostat and TRVs" — no programmer) this dropped
the (53) temperature factor to 0.702 (= 0.60 × 1.3 × 0.9) where the
worksheet lodges 0.78 (= 0.60 × 1.3), under-counting cylinder storage
loss (55) by ~119 kWh/yr and over-rating SAP by ~0.25.

RdSAP 10 §10.5 (PDF p.57) "Hot water separately timed":
    No programmer, pre-1998 boiler → No
    Programmer, pre-1998 boiler    → Yes
    Post-1998 boiler               → Yes
DHW is therefore NOT separately timed only when a pre-1998 boiler is
paired with a no-programmer control. Add the two SAP 10.2 Table 4c(2) /
Table 4b lookups (controls without a programmer = {2101, 2103, 2111,
2113}; pre-1998 gas/LPG boilers 110-119 + oil 124/125/128) and return
False for that combination; every other boiler+cylinder cert keeps the
separately-timed default, so the change is confined to old low-control
stock and the heating corpus + goldens are unchanged.

Effect: the full chain (Summary PDF → extractor → mapper → cert_to_inputs
→ calculator) now reproduces the lpg-boiler worksheet's §11a unrounded
SAP -6.6499 at abs < 1e-4 (was -6.4013). Full regression suite green bar
the 3 pre-existing unrelated fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:07:27 +00:00
Khalim Conn-Kowlessar
90de1fc976 fix(elmhurst-mapper): map "Bottled gas" main fuel to bottled LPG, not mains gas
An LPG-boiler dwelling on the Summary → from_elmhurst_site_notes path
mapped to main_fuel_type=26 (mains gas), making it indistinguishable
from a mains-gas boiler downstream — wrong Table 12/32 cost / CO2 / PE
(bottled LPG is ~10.30 p/kWh vs mains gas 3.48), and it defeats any
"non-gas → gas only with a mains-gas connection" gate (an LPG dwelling
looks already-gas).

Root cause: the recommendation worksheets lodge the boiler carrier as
§15.0 "Water Heating Fuel Type: Bottled gas" (§14.0 carries only SAP
code 115, a Table 4b gas-family row, + "Main gas: Yes" in §14.2 — a
mains-gas CONNECTION, not the heating fuel). "Bottled gas" was absent
from `_ELMHURST_MAIN_FUEL_TO_SAP10`, so the §15.0 fuel resolved to None
and `_elmhurst_gas_boiler_main_fuel` fell through priority-1 to the
mains-gas meter flag → 26.

Map "Bottled gas" → 3 (bottled LPG MAIN heating): code 3 routes via
`API_FUEL_TO_TABLE_32`/`API_FUEL_TO_TABLE_12` → Table-code 3 (10.30 /
9.46 p/kWh). NOT the legacy "LPG bottled": 5 entry — API code 5 =
anthracite, and `canonical_fuel_code` resolves the same-valued Table-32
code 5 to anthracite (3.64 p/kWh), so a 5 here mis-prices the dwelling
as cheap solid fuel (verified: a 5 mapping moved SAP the WRONG way,
42.33 → 45.11; code 3 moves it to -6.40 vs the worksheet's -6.6499).
Also add 3 to `_GAS_LPG_MAIN_FUEL_CODES` so the §15.0-lodged bottled-LPG
water fuel is adopted as the boiler's space-heating carrier (priority 1)
instead of the meter flag.

Effect: main_fuel_type=3 (bottled LPG) and water_heating_fuel=3 (was
None). Mains-gas certs still → 26 (full regression suite green bar the 3
pre-existing unrelated fails); the MissingMainFuelType tripwire still
fires for genuinely-undeterminable carriers.

Spec: SAP 10.2 Table 12 / RdSAP 10 Table 32 (PDF p.95) — bottled LPG
main heating fuel code 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:48:15 +00:00
Khalim Conn-Kowlessar
b473f6a1ec fix(elmhurst-mapper): classify top-floor flat from roof type, not room-in-roof
`_elmhurst_dwelling_type` derived a flat's roof exposure from
`room_in_roof is not None`, so a top-floor flat whose roof is a plain
external "PS Pitched, sloping ceiling" (no room-in-roof) fell through to
"Mid-floor flat". The cascade's `_dwelling_exposure` then treats a
mid-floor flat's roof as a party ceiling (RdSAP 10 §5 / §3 — party
surfaces carry no heat loss) and drops the entire roof term: cert
001431's 105 m² roof at U=2.3 = 241.68 W/K (30) vanished, collapsing
(33) fabric heat loss 320.06 → 78.38 and over-rating SAP by ~5 points
(on top of the age-band roof-U bug — see prior commit).

Read the roof TYPE instead — the dual of the floor's "Another dwelling
below" signal. A flat's roof is a party ceiling only when its Elmhurst
code is S / A / NR (Same/Another dwelling or Non-residential space
above); F / PN / PA / PS are exposed external roofs, so the dwelling is
on the top storey. `has_exposed_roof = room_in_roof present OR
_elmhurst_roof_is_exposed(roof)` — which is exactly what the function's
own docstring already described as the intent ("RR present or external
roof"), now implemented.

With both upstream fixes the full chain (Summary PDF → extractor →
mapper → cert_to_inputs → calculator) reproduces the worksheet's §11a
unrounded SAP 56.3649 at abs < 1e-4, with (30)/(33)/(37) matching to
the decimal. Only flat fixture reclassified; 000784 (top-floor, RR) and
000910 (ground-floor) unchanged. Regression suite green bar the 3
pre-existing unrelated fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:18:51 +00:00
Khalim Conn-Kowlessar
1033526812 fix(elmhurst-extractor): read Main Property age band from §3.0 Date Built block
The Elmhurst Summary §3.0 "Date Built" lodges the per-building-part age
bands; the Main row reads "Main Property" / "C 1930-1949". But "Main
Property" ALSO heads the §4.0 Dimensions table, so the global
`_str_val("Main Property")` collides with it: when pdftotext renders
"3.0 Date Built:" glued onto its "Main Property" row token on one
layout line (as the recommendation worksheets do), the first standalone
"Main Property" match is the §4 dimensions header — returning its next
token "Floor" as the "age band".

That garbage age propagated to `u_roof`: for a "Pitched, sloping
ceiling" (PS) roof with no lodged insulation thickness, `u_roof` returns
the spec uninsulated U=2.3 for the correct age C but U=0.4 for the
unparseable "Floor" — collapsing the roof heat-loss term and inflating
SAP by ~14 points on the affected cert.

Scope the read to the Date-Built block (between "3.0 Date Built" and
"4.0 Dimensions") and take the first age row — a line beginning with a
single A-M band letter + space ("C 1930-1949", "A before 1900",
"J 2003-2006"). Building-part name rows never start that way, and the
Main row precedes any extension / room-in-roof rows.

Regression: full sap10_calculator + documents_parser suite green bar the
3 pre-existing unrelated fails (2 stone-wall U tests, test_total_floor_
area); the multi-bp / "A before 1900" fixtures (000516, 001431_case*,
6035) keep their age bands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:41:00 +00:00
Khalim Conn-Kowlessar
590cb97ef6 docs: session-9 close-out + session-10 handover (summary-report-based audit)
Session 9 ran five independent data-driven audits (profiler, dropped-field scan,
CO2/PE reconciliation, cross-provider LIG parity, HW-demand reconciliation) — all
converged on diffuse remaining gap — and shipped glazing Table-24 (+16 certs) +
HW-only heat-network DLF, taking 54.90% -> 56.8% within-0.5. The data-driven seam
is exhausted; session 10 switches to worksheet-level ground truth via the
summary-report-based per-cert audit. New agent prompt at HANDOVER_SUMMARY_AUDIT.md
with method, starter candidate certs, ruled-out list, and conventions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:54:08 +00:00
Khalim Conn-Kowlessar
872bc585f7 fix(hot-water): apply Table 12c distribution loss to HW-only heat networks (whc 950/951/952)
The heat-network HW distribution-loss override fired only when the MAIN was
a heat network AND whc inherited from main ({901,902,914}). Water-heating-only
heat networks (SAP 10.2 Table 4a HW codes 950 boilers / 951 CHP / 952 heat
pump) were missed entirely: their Table 4a plant efficiency applied with NO
distribution loss, so the HW fuel was under-counted by the Table 12c DLF
(1.33-1.48x) → under-cost → over-rate.

RdSAP 10 §10 (spec p.36): a water-heating-only heat network is calculated 'for
plant efficiency, distribution loss and pumping energy - see Table 12c'. Added
a whc-gated branch (independent of the main) applying water_eff = plant_eff /
DLF — the per-kWh-generated cost model (q_generated = q_useful x DLF). Fires on
the WHC alone so a HW-only heat network with a non-network main (cert 9093, whc
950 + warm-air main 502) is covered.

The 3 corpus whc=950 certs all improve in |err|: 2153 +2.62->-0.48 (now within
0.5), 7220 +1.27->-0.97, 9093 +6.04->+3.60 (residual is its warm-air main, a
separate cause). within-0.5 56.66->56.79%, within-1.0 71.9->72.2%, mean|err|
down; only those 3 certs change. New AAA test pins the DLF scaling fires on the
WHC independent of the main. Goldens + gate green, pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:40:17 +00:00
Khalim Conn-Kowlessar
a7990edb8c robustness: strict-raise on unmapped glazing + heating/HW efficiency codes
Forcing-function guards so a lodged-but-unmapped code surfaces loudly instead
of silently taking a wrong-but-plausible default (the class that hid single
glazing as U=2.5 until this session). Four silent fallbacks converted to raise
on PRESENT-but-unmapped codes, while keeping the legitimate ABSENT (None)
defaults:

- _api_glazing_transmission: unmapped glazing_type -> UnmappedApiCode (was
  None -> u_window all-None default 2.5).
- _api_cascade_glazing_type: unmapped glazing_type -> UnmappedApiCode (was
  pass-through -> wrong g-value slot).
- seasonal_efficiency: a lodged code/category resolving in neither
  _SPACE_EFF_BY_CODE nor the category/room-heater fallbacks -> UnmappedSapCode
  (was blind 0.80 gas-boiler default, which 'catastrophically misrates heat
  pumps and storage' per the table comment). Data-free calls keep 0.80.
- water_heating_efficiency: WHC in no SAP 10.2 Table 4a HW row ->
  UnmappedSapCode (was blind 0.78). Absent code keeps 0.78.

Zero current-corpus impact (909 computed / 0 raises, 56.66% within-0.5
unchanged) — the code/efficiency tables are complete for today's data, so
these are guards for the ongoing audit + future data refreshes. Verified the
WHC table already covers 908 (multi-point gas) and 950 (HW heat network), so
those are NOT unmapped-code bugs. 8 AAA tests, goldens + gate green, pyright
net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:21:13 +00:00
Khalim Conn-Kowlessar
32bbb92be3 docs: session-9 cont.3 — glazing g remap + post-glazing re-profile (solid brick verified spec-faithful)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:46:26 +00:00
Khalim Conn-Kowlessar
49fb6c1b8e fix(glazing): remap divergent RdSAP-21 glazing codes 4/5 to cascade g slots
The g-value tables (_G_PERPENDICULAR_BY_GLAZING_TYPE solar g⊥,
_G_LIGHT_BY_GLAZING_CODE daylight g_L) are keyed on the SAP 10.2 Table 6b
cascade enum, but _api_cascade_glazing_type only translated code 1. Codes 4
and 5 sit in the 1-6 range where RdSAP-21 and the cascade enum disagree
(RdSAP-21 4=secondary/5=single vs cascade 4=double-low-E/5=secondary), so an
API single-glazed window read the cascade-5 secondary g (0.76/0.80) instead
of single (0.85/0.90), and a secondary window read cascade-4 double-low-E
(0.63). Added the {4:5, 5:1} remap entries the existing design comment
already anticipated ("only divergent codes need a remap").

Correctness fix: solar/daylight gains are second-order, so eval is unchanged
(56.66% within-0.5, 0 certs flip) — the dominant single-glazing error was the
U-value, closed in a0432977's Table 24 transmission map. This closes the
keying inconsistency to prevent future drift. 4 AAA tests, goldens + gate
green, pyright net-zero (38=38).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:42:16 +00:00
Khalim Conn-Kowlessar
8e1e746a3e docs: session-9 cont.2 — glazing Table-24 win (54.9->56.7%) + ranked known bugs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:24:21 +00:00
Khalim Conn-Kowlessar
a04329770d fix(glazing): map single/secondary/triple glazing per RdSAP 10 Table 24
The API glazing-transmission table mapped only the double-glazing codes
[1,2,3,13,14]; single (5/15), secondary (4/11/12) and triple (6/8/9/10)
glazing codes returned None from _api_glazing_transmission, so the cascade
silently routed them to the u_window all-None default U=2.5 instead of their
RdSAP 10 Table 24 (spec p.50) value. Single glazing (U=4.8) was the worst:
modelled at half its true heat loss → systematic over-rate (cert 0370-2933,
7 single-glazed windows, +17 SAP).

Extended _API_GLAZING_TYPE_TO_TRANSMISSION + the gap-keyed override table
with the Table 24 (U, g, frame-factor) rows for every RdSAP-21 glazing code
(single 4.8/g0.85; secondary normal-E 2.9 / low-E 2.2 /g0.85; triple
pre-2002 2.4/2.1/2.0 by gap, 2002-2022 2.0, all g0.68/0.72; known-data
codes 7/8 alias their family default). 94 corpus certs carry an unmapped
glazing code (code 5 = 79); they sat at 32% within-0.5 vs 54.9% baseline.

Eval: within-0.5 54.90% -> 56.66% (net +16 certs: 22 in, 6 offsetting-error
out), within-1.0 70.2 -> 71.9%, mean|err| 1.224 -> 1.203, 909 computed / 0
raises. Spec-applied uniformly per the determinism principle. 7 AAA tests,
goldens + gate green, pyright net-zero (38=38).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:23:25 +00:00
Khalim Conn-Kowlessar
c8f0753142 docs: session-9 cont. — silent-fallback audit + Tier-1 strict-raise shipped
Record the 4-agent audit, the shipped Tier-1 fuel-code strict-raise (7878a969),
and the un-actioned Tier 2/3 candidates (glazing codes 4-12, window orientation,
age-band swallows) for a future robustness slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:06:54 +00:00
Khalim Conn-Kowlessar
7878a96900 fix(fuel): strict-raise on unmapped Table-12 factor fuel codes
Tier-1 finding of the silent-fallback audit. The fuel-type helpers fed the
SAP 10.2 Table 12/32 cost/CO2/PE lookups via a silent
`API_FUEL_TO_TABLE_12.get(fuel, fuel)` passthrough at 5 sites
(_heat_network_factor_fuel_code, HW CO2/PE, _secondary_fuel_code, PV). A fuel
code in NEITHER the API enum map NOR the Table-12 numbering passed straight
through to the mains-gas default baked into unit_price_p_per_kwh /
co2_factor_kg_per_kwh / primary_energy_factor (table_12.py:233/274/287,
table_32.py:190) — silently mis-pricing a novel/colliding fuel as grid gas.
This is the class that mis-priced cert 8536's community biomass as
electricity (-17 SAP) before a7761ea8.

New _table_12_factor_fuel_code mirrors .get(fuel, fuel) EXACTLY for every
recognised input (union of the CO2/PE/price/monthly table keys +
API_FUEL_TO_TABLE_12 values) and raises UnmappedSapCode only when the
resolved code is recognised by no table — surfacing the gap loudly per the
strict-raise principle (reference_unmapped_sap_code). Verified behaviour-
preserving: 0/909 corpus certs hit the new raise; eval unchanged at 54.9%
within-0.5 / 909 computed / 0 raises.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:05:57 +00:00
Khalim Conn-Kowlessar
ddb9fdbec5 docs: session-9 sweep — six API-error candidates ruled out (no shipped fix)
Profile-driven re-sweep at HEAD da094feb. Every biased/error-carrying bucket
chased field-by-field resolved to proxy / already-deproven / already-fixed:
roof code 5/8 (same u_roof as code 4), per-bp age mapping (correct),
'(same dwelling above)' roofs (5 certs, 4 fine), index-less MEV gas
(centred by e6dda705 to signed +0.09), wit=4 cavity -0.25 (tail-driven),
community whc=903 HW (= deproven meter_type=3). The +32 outlier 2958 is
per-cert (twin 3420 is +0.18). Residual is a broad per-cert fabric+HW tail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:24:40 +00:00
Khalim Conn-Kowlessar
da094feb62 docs: session-8c handover — Table 4g default SFP for index-less MEV (within-0.5 trade-off)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:01:37 +00:00
Khalim Conn-Kowlessar
e6dda705f4 fix(ventilation): apply Table 4g default SFP to index-less MEV fan electricity
Completes the MEV fan-electricity thread. The PCDB-index slice closed the
9 MEV certs carrying a Table 322 record; the other 11 (mostly gas houses)
lodge mechanical_ventilation=2 with NO PCDB index, so
`_mev_decentralised_kwh_per_yr_from_cert` returned 0 and billed no fan
running cost — a tight +2.2 SAP over-rate (signed +1.23, median +2.19).

SAP 10.2 §2.6.3 / Table 4g note 1 (PDF p.176) prescribes a DEFAULT
specific fan power of 0.8 W/(l/s) for an MEV system whose fans are not in
the PCDB, used directly as SFPav in the §5 Table 4f (230a) formula
(SFPav × 1.22 × V). Restructure the helper: when no Table 322 record
resolves, fall back to the default for a mechanical-extract system
(`mechanical_ventilation_kind == EXTRACT_OR_PIV_OUTSIDE`); natural /
balanced (MVHR / MV) systems still contribute nothing.

Index-less extract cohort closed +1.23 -> +0.18 signed (each gains
~1.1 SAP of fan electricity). This is a spec-correct fix that improves
the aggregate but is a HEADLINE TRADE-OFF: within-2.0 83.6% -> 84.6%,
within-1.0 70.08% -> 70.19%, mean|err| 1.232 -> 1.224, but within-0.5
55.12% -> 54.90% (-2) — the fan energy is only ~half each cert's
over-rate, so the cohort lands at ~+1.0 (still outside 0.5) while two
borderline certs with offsetting errors cross out. Applied uniformly per
the determinism principle ([[feedback_software_no_special_handling]]):
the unmasked residual (~+1.0 on gas-house MEV) is the next lead.

1 AAA test (default SFP 0.8 × 1.22 × V for index-less MEV, 0 for
natural). Goldens + full calc/epc regression green (000565 MEV uses its
resolvable PCDB record, unaffected); pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:00:54 +00:00
Khalim Conn-Kowlessar
01ebc9ac1e docs: session-8b handover — MEV fan electricity (PCDB index plumbed)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:17:17 +00:00
Khalim Conn-Kowlessar
e7af6fda66 fix(ventilation): map API mechanical_ventilation_index_number for MEV fan electricity
Follow-up to the §2 MV-kind slice. Once MEV dwellings stopped
under-stating their ventilation HEAT loss, a +0.9 SAP over-rate residual
remained — the MEV FAN ELECTRICITY (§5 Table 4f line (230a),
`SFPav × 1.22 × V`, PCDB Tables 322 decentralised-MEV + 329 in-use
factors). `_mev_decentralised_kwh_per_yr_from_cert` already composes it,
but reads `epc.mechanical_ventilation_index_number` +
`epc.mechanical_vent_duct_type`, and the API builder
(`from_rdsap_schema_21_0_1`) never set either — so `pcdf_id is None`
short-circuited the fan energy to 0 on every API cert (the Summary/
Elmhurst path set them, so cert 000565 already billed it).

Wire both schema fields through the 21.0.1 API construction (the corpus
schema). Eval: the 9 MEV certs carrying a PCDB index closed +0.90 ->
+0.13 signed (fan electricity now billed); headline within-0.5 55.01% ->
55.12%, mean|err| 1.233 -> 1.232, 909 computed / 0 raises. Only those 9
certs move (clean diff). The 11 index-less MEV certs still sit at +1.36 —
they need the SAP Table 4h DEFAULT specific fan power (no PCDB record), a
separate slice.

New end-to-end test + fixture (cert 1300, Titon-class dMEV index 500777,
Flexible duct): from_api_response preserves the index + duct type and
(230a) resolves to a positive fan-energy contribution. Goldens + full
calc/epc regression green; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:16:34 +00:00
Khalim Conn-Kowlessar
dfba20babf docs: session-8 handover — API mechanical_ventilation enum → §2 MV-kind dispatch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:02:51 +00:00
Khalim Conn-Kowlessar
71b378b9e5 fix(ventilation): map API mechanical_ventilation enum to §2 MV-kind dispatch
The profiler flagged `mechanical_ventilation=2` as a clean systematic
over-rate: 20 certs, signed +1.90 SAP, only 5% within 0.5 (every one
positive). Root cause: the API path (`from_api_response`) dropped the
doc-level `mechanical_ventilation` field, so `sap_ventilation.
mechanical_ventilation_kind` was always None and the §2 cascade
defaulted to NATURAL — under-stating the ventilation air-change rate
(and hence heat loss) for every mechanical system. (Only the Elmhurst/
Summary path mapped it, via `_ELMHURST_MV_TYPE_TO_KIND`.)

RdSAP-Schema-21 `mechanical_ventilation` enum (epc_codes.csv) →
MechanicalVentilationKind picking the SAP 10.2 §2 (24a..d) effective-ach
formula:
  0 natural                      -> NATURAL (24d)
  1 MV (no heat recovery)        -> MV (24b)
  2 mechanical extract, dc (MEV) -> EXTRACT_OR_PIV_OUTSIDE (24c)
  3 mechanical extract, c (MEV)  -> EXTRACT_OR_PIV_OUTSIDE (24c)
  5 positive input from loft     -> NATURAL (loft-sourced PIV adds no
                                    system air change per RdSAP 10 §2.6)
  6 positive input from outside  -> EXTRACT_OR_PIV_OUTSIDE (24c)
Code 4 (MVHR, 24a) is DEFERRED — its formula needs the lodged
heat-recovery efficiency (PCDB Table 326) the API→cascade path doesn't
yet plumb; mapping it to MVHR with a null efficiency would mis-model it
as MV, so it stays NATURAL (3 scattered certs, accurate at the median).
Unmapped integers raise `UnmappedApiCode` (mirror of `_api_sheltered_
sides` / `_api_type_1_gable_kind`).

Eval: the extract cohort (mech_vent 2/3/6) moved +1.90 -> +0.9 median
(within-0.5 5% -> 35%); 20 improved / 3 regressed (offsetting). Headline
within-0.5 54.24% -> 55.01%, within-1.0 69.64% -> 70.08%, mean|err|
1.248 -> 1.233, 909 computed / 0 raises. The +0.9 residual on MEV is the
fan electricity (§2.6.4 SFP, PCDB Table 322) — a separate follow-up.
2 AAA tests; goldens + full calc/epc/parser regression green; pyright
net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:02:04 +00:00
Khalim Conn-Kowlessar
943f83ed01 docs: session-7 handover — sheltered alternative walls (RdSAP Table 4 R=0.5)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:50:55 +00:00
Khalim Conn-Kowlessar
3e05c95e65 fix(wall-U): apply RdSAP Table 4 "Sheltered" R=0.5 to alternative walls
Audit of the API-SAP error (53% within 0.5) localised the systematic
under-rate to ELECTRIC FLATS (houses sit at 60-66% within 0.5; electric
flats 13-19%). Decomposing the flat error showed it tracks space-heating
demand per m² — the worst certs reach 130-289 kWh/m² (accurate certs sit
at 14-110), i.e. a grossly over-stated fabric heat loss, amplified ~4x by
the electricity unit price and the steep low-band SAP log curve.

Root cause: the gov-EPC API lodges `sheltered_wall="Y"` on alternative
wall sub-areas (a sub-area adjacent to an unheated buffer — stair core,
adjoining structure), but the field was dropped by the schema + domain
dataclasses and the calculator billed the alt sub-area at its full
exposed U. RdSAP 10 Table 4 (PDF p.22) "Sheltered": such a wall carries
an added external surface resistance R=0.5 m²K/W → U_sheltered =
1/(1/U + 0.5) — the SAME adjustment the main wall already applies for
`gable_wall_type=2` (`gable_wall_sheltered`,
`_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W`). Cert 0340-2976 (band-A flat,
42 m² sheltered timber-frame alt) over-stated its wall channel by
~58 W/K → walls 128 -> 70 W/K.

Threads the field end-to-end: schema dataclasses (21.0.0/21.0.1) +
domain `SapAlternativeWall.is_sheltered` (default False — the Summary/
Elmhurst path leaves it False, sheltering rides through its lodged
U-value there, so goldens are untouched) + `from_api_response` mapping
`"Y"->True` + `_alt_wall_w_per_k` applying the 0.5 resistance on the
cascade path (lodged-U and basement alt-walls return before it).

140 certs (15% of the corpus) carry a sheltered alt-wall; they under-
rated at median -0.82 / mean signed -1.33 / 23% within 0.5. Eval: 102
improved, 38 regressed (offsetting-error cases — fix is spec-uniform per
[[feedback_software_no_special_handling]]); within-0.5 53.14% -> 54.24%,
within-1.0 67.99% -> 69.64%, within-2.0 81.85% -> 83.50%, mean|err|
1.312 -> 1.248, 909 computed / 0 raises. Goldens (6035, 000565) and full
calc/epc/parser regression green; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:49:42 +00:00
Khalim Conn-Kowlessar
e1adc8d3d5 docs: session-6 handover — community fuel collision (waste/biomass/biogas) fixed gated on heat-network
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:56:56 +00:00
Khalim Conn-Kowlessar
a7761ea83f fix(fuel): map gov-API community fuels 30/31/32 (waste/biomass/biogas) to Table-12 community rows, gated on heat-network context
The gov-API `main_fuel_type`/`water_heating_fuel` enum (epc_codes.csv)
codes 30="waste combustion (community)", 31="biomass (community)",
32="biogas (community)" collide in VALUE with the Table-32 electricity
codes 30 (standard rate), 31 (7-hour low) and 32 (7-hour high). All three
sit in `_ELECTRIC_FUEL_CODES`, so `is_electric_fuel_code` flagged a
community-scheme main as electric and `_is_electric_main` routed its cost
through the off-peak electricity branch — BYPASSING the heat-network rate
in `_heat_network_factor_fuel_code`. Cert 8536 (biomass community, SAP
code 301) was billing at 5.5 p/kWh grid electricity instead of the 4.24
p/kWh heat-network rate → -17.2 SAP.

Per RdSAP 10 §C / SAP 10.2 Table 12 (PDF p.191) the community
waste/biomass/biogas rows are codes 42/43/44 (the same rows the
backwards-compat enum codes 11/12/13 already map to). Add 30->42, 31->43,
32->44 to both API fuel-translation tables.

The remap CANNOT be global (`canonical_fuel_code`): the cascade uses the
bare Table-32 code 30 internally as `_STANDARD_ELECTRICITY_FUEL_CODE`
(the RdSAP no-water-heating immersion default writes
`water_heating_fuel=30`), so a blanket remap mis-prices genuine grid
electricity as community waste (cert 2211 regressed +16 SAP in a
prototype). Instead `_heat_network_community_fuel_code` translates only
when `_is_heat_network_main` is true, at the `_main_fuel_code` /
`_water_heating_fuel_code` fuel-TYPE boundary, where the community
meaning is unambiguous.

Per the strict-raise principle ([[reference-unmapped-sap-code]]), a
heat-network main lodging a colliding community fuel the table doesn't
cover raises `UnmappedSapCode` rather than silently falling through to
the same-numbered electricity code.

Eval (API SAP vs lodged): cert 8536 -17.25 -> -6.51, cert 5036 -6.29 ->
+1.36; mean|err| 1.329 -> 1.312, within-1.0 67.88% -> 67.99%,
within-2.0 81.74% -> 81.85%, within-0.5 held at 53.14%, 909 computed /
0 raises. No golden / calculator regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:55:48 +00:00
Khalim Conn-Kowlessar
87485bbe3d docs: session-5 handover — fuel-code collision fix (anthracite/coal)
Records the fuel-type-boundary canonicalisation, the goldens-caught
constraint (code 33 is also the electricity-10h tariff code), and the
deferred dual-fuel/community/fabric follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:32:32 +00:00
Khalim Conn-Kowlessar
19235d1144 fix(fuel): canonicalise colliding gov-API solid-fuel codes (anthracite/coal) at the fuel-type boundary
A coal main (gov-API main_fuel_type=33) was priced at the electricity
10-hour low rate (7.5 p) and anthracite (5) at the bulk-LPG rate
(12.19 p), because the shared price/CO2/PE lookups check Table-32/12-code
membership BEFORE translating the API enum — and codes 5/33 collide with
a different-fuel Table code. This drove the cohort's single worst cert
(2100 anthracite, -61 SAP). `is_electric_fuel_code(33)` also wrongly
classified the coal main as electric.

The gov-API fuel enum (confirmed by description-vs-code audit on
main_heating[].description): 5=anthracite, 33=coal, 9=dual-fuel,
20/25/31=community. The collision can't be resolved inside the shared
table functions — code 33 is ALSO the electricity-10h TARIFF code used by
the dual-rate CO2/PE split (golden 000565), so normalising there breaks
electricity certs. Instead `canonical_fuel_code` normalises the colliding
SOLID-fuel enums (5->15 anthracite, 33->11 house coal) at the fuel-TYPE
boundary in `_main_fuel_code` / `_water_heating_fuel_code`, where the code
is known to be a fuel type (never a tariff code).

Scoped to anthracite (5) + coal (33) — the unambiguous large mispricings.
Dual-fuel (9, 0.45 p delta) and community (20/25/31, heat-network path)
are deferred (noted in `_GOV_API_COLLISION_FUELS`).

API SAP eval: mean|err| 1.424 -> 1.329 (the -61 anthracite outlier 2100
-> -11, residual now fabric); within-0.5 53.1% (flat); 909 computed, 0
raises. Golden + Elmhurst regression green (the shared table functions
are unchanged, so the electricity-tariff CO2/PE path is untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:31:43 +00:00
Khalim Conn-Kowlessar
d90b6f5643 docs: session-5 handover — flat-roof fix + the unknown-insulation principle
Adds the flat-roof slice (52.1 → 53.1%) and records the unifying principle
("unknown insulation → as-built age default, not uninsulated") plus the
cross-element review confirming all element types now conform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:30:52 +00:00
Khalim Conn-Kowlessar
58cff932e6 fix(roof-U): flat roof, undetermined thickness, "no/limited insulation" → Table 18 age default
A flat roof lodged "Flat, no insulation" / "Flat, limited insulation" with
an undetermined insulation thickness ('ND'/'AB' → parsed None) was given the
Table 16 row-0/12mm U (2.30 / 1.50) from the description marker, regardless
of age band. Per RdSAP 10 §5.11.4 (PDF p.44) "U-values in Table 18 are used
when thickness of insulation cannot be determined" — the column (3)
flat-roof age-band default applies. The "no/limited insulation" text is
RdSAP's as-built rendering: at old bands (A-D) the column (3) default IS
2.30 (so those certs are unchanged), but a newer-band flat roof carries the
age-band insulation as built (band H = 0.35, F = 0.68, not 2.30).

Confirmed by the description-vs-rating audit: cert 0390-2753 (top-floor
flat, band H, "Flat, no insulation", thickness 'ND') lodges roof
energy_efficiency_rating = 3 (moderate U), NOT the rating-1 that 2.30
implies — and drove a -31.78 SAP error (roof 202 W/K over 88 m²). Same
masked-at-old-bands structure as the cavity-U fix: accurate at A-D where the
default coincides with 2.30, catastrophic only where it diverges.

Pitched roofs are deliberately NOT rerouted (their "no insulation" text is
load-bearing — the broad 'ND'→Table-18 reroute was empirically net-negative
for pitched lofts).

API SAP eval: 52.1% -> 53.1% within 0.5; <1.0 67.2% -> 68.0%; median |err|
0.475 -> 0.467; mean|err| 1.497 -> 1.424; flat-roof bucket within-0.5
23% -> 35% (11 improved, 2 regressed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:26:03 +00:00
Khalim Conn-Kowlessar
898dcfda18 docs: session-5 handover — as-built cavity-U fix (48.6 → 52.1%)
Adds the cavity wall-U slice to the SESSION-5 block + headline table;
records the by-age-band re-split method that surfaced the G/H spike.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:53:01 +00:00
Khalim Conn-Kowlessar
2e466ed1e6 fix(wall-U): as-built "insulated (assumed)" cavity uses Cavity-as-built row, not Filled cavity
An as-built cavity wall (wall_insulation_type=4) lodged "Cavity wall, as
built, insulated (assumed)" was routed to RdSAP 10 Table 6's "Filled
cavity" row. Per Table 6 (England, PDF p.41) the Filled-cavity row carries
the "†" footnote ("assumed as built") only at age bands I-M, where it is
numerically identical to "Cavity as built"; at bands A-H the Filled-cavity
row represents a GENUINE fill, not the as-built assumption. So an as-built
cavity must use the "Cavity as built" row at all bands (band G/H = 0.60,
not the filled 0.35).

This is the same latent A-H bug slice S0380.210 fixed for the "partial
insulation (assumed)" variant but left in place for "insulated (assumed)"
by a legacy production convention. The API SAP-accuracy cohort over-rated
"Cavity wall, as built, insulated (assumed)" certs at bands G/H by a clean
+1.38 / +1.61 SAP median (n=37 / n=18); bands I-M were unaffected (rows
coincide), confirming the spec mechanism per-band.

Retires the `_cavity_described_as_filled` description sniffer — as-built
cavities now always use the as-built row regardless of the rendered
insulation adjective; a genuine retrofit fill is still caught by the
explicit wall_insulation_type=2 branch.

API SAP eval: 48.6% -> 52.1% within 0.5; <1.0 63.8% -> 67.2%; median |err|
0.548 -> 0.475; mean|err| 1.561 -> 1.497; 909 computed, 0 raises.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:51:17 +00:00
Khalim Conn-Kowlessar
152682d802 docs: session-5 handover — WHC-903 immersion off-peak HW (Table 13) closed
47.6% → 48.6% within 0.5; immersion code mapping corrected (1=dual,
2=single); next robust leads are under-rating flat/party-fabric scatter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 15:48:25 +00:00
Khalim Conn-Kowlessar
43d4c67d12 fix(hw-cost): WHC-903 immersion off-peak HW bills at Table 13 high-rate fraction
Electric immersion water heating (WHC 903) on an off-peak tariff billed
100% at the low rate, under-costing the dwelling and over-rating it
(median +0.98 SAP across the off-peak WHC-903 API cohort, n=57).

SAP 10.2 Table 12a "Immersion water heater" row (PDF p.191) routes the
water-heating column to Table 13 (PDF p.197): the high-rate fraction is
a function of cylinder volume V, assumed occupancy N (Appendix J Table
1b) and single-/dual-immersion. The remainder bills at the low rate.
Table 13 Note 2 supplies exact equations equivalent to the rounded grid;
`electric_dhw_high_rate_fraction` evaluates them (validated against the
published 110 L grid cells). Per Note 1 the 10-hour equations cover any
tariff with >=10 hours/day low-rate (so 18-/24-hour use that column).

Immersion code mapping CONFIRMED 1=dual, 2=single via RdSAP 10 §10.5
(PDF p.54 — an immersion is "assumed dual" on a dual/off-peak meter)
cross-checked against the API cohort (code 1 sits 3.6:1 on dual meters;
code 2 on single meters). This INVERTS an earlier handover's unverified
"1=single, 2=dual" note — the dual code carries Table 13's small
fraction, matching the cohort over-rating direction; the single mapping
overshot in a prototype.

API SAP eval: 47.6% -> 48.6% within 0.5; <1.0 62.6% -> 63.8%;
mean|err| 1.586 -> 1.561; 909 computed, 0 raises.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 15:45:41 +00:00
Khalim Conn-Kowlessar
d83c431c7d docs: session-4 handover — interlock + secondary fixes, robust-audit method, open leads
Updates the headline (45.1 → 47.6%), records the four shipped fixes + the
roof-8 false-lead closure, documents the two methods that worked
(description-vs-code audit + outlier-robust categorical sweep by net skew +
median), and lists the open robust leads (whc=903 immersion HW, cat-7 storage,
dual immersion) with the scatter buckets to avoid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 14:47:31 +00:00