Commit graph

498 commits

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:18:58 +00:00
Khalim Conn-Kowlessar
218840db98 docs: handover for the open window-extraction work on the double_glazing fixture
Captures the diagnosis so the next agent doesn't re-derive it: what's done
(S0380.235-237), what's confirmed correct (calculator U-adjustment, party
wall, glazing labels), the worksheet pin targets, and the two open causes —
crucially the 000516 trap (byte-identical Summary data classified as a roof
window there but a wall window here, so flipping the U>3 rule regresses
000516). Includes a rebuildable tracer recipe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:47:29 +00:00
Khalim Conn-Kowlessar
6d4fa5dd3b docs: handover — fold in S0380.232-234 (case-19 PV closure) + open causes
Records the three PV slices shipped (D_PV off-peak exclusion, weighted
dwelling import price, Appendix G4 diverter), the resulting case-19 state
(SAP 50.33→51.34, rounds to lodged 51), and the two remaining case-19
causes (winter Appendix-M EPV monthly shape; fabric (33) +1.0). Adds the
`2100-5421` worst-offender diagnosis (a 352 m² uninsulated solid-wall
dwelling on the as-built-insulated-assumed roof-U front, not a flats bug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:06:20 +00:00
Khalim Conn-Kowlessar
9521d52403 S0380.234: PV diverter (Appendix G4) — diverts surplus PV to the cylinder
SAP 10.2 Appendix G4 (PDF p.72-73). A PV diverter routes surplus PV
generation (the would-be export EPV,m × (1 − βm)) to an immersion heater
in the hot-water cylinder. Per G4 step 4:

    SPV,diverter,m = EPV,m × (1 − βm) × 0.8 × fPV,diverter,storageloss

(0.8 = cylinder heat-acceptance; fPV,diverter,storageloss = 0.9 for the
higher storage temperature), clamped to ≤ (62)m + (63a)m, and entered as
the negative worksheet (63b)m (step 5). The β factor is computed on the
PRE-diverter (219) per the §3a note (lines 5485-5486). Effects:
  - (64)m = (62)m + (63b)m → less main-system water-heating fuel (219);
  - export drops to EPV,ex,m = EPV,m(1 − βm) + (63b)m / 0.9 (§4 p.94
    line 5501); the onsite dwelling portion EPV,m × βm is unchanged.

Inclusion (G4 step 1) requires ALL of: a PV system connected to the
dwelling; a cylinder larger than (43) average daily HW use; no solar
water heating; no battery — else the diverter is disregarded.

Three layers:
  - extractor reads Summary §19 "Diverter present"; schema 21.0.0/21.0.1
    SapEnergySource gains `pv_diverter` (API `sap_energy_source.pv_diverter`);
  - `Renewables.pv_diverter_present` + domain `SapEnergySource.pv_diverter_present`,
    set in both the Elmhurst and API mapper paths;
  - `_pv_diverter_monthly_kwh` applies the G4 math after the β split;
    `cert_to_inputs` recomputes (219) and the PV export.

On simulated case 19 (electric storage heaters, 7-hour, PV + diverter):
SAP continuous 50.33 → 51.34 (worksheet 51.2221; both round to the
lodged 51), cost (255) 1847.5 → 1812.3 (ws 1816.6), CO2 (272) 3331 →
3120 (ws 3126), with (233a) dwelling 1280.6 (ws 1280.4). The residual
+0.11 SAP is an upstream winter Appendix-M monthly-EPV-shape gap +
fabric (33) +1.0, tracked as the next case-19 cause. Suite: 2412 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:59:12 +00:00
Khalim Conn-Kowlessar
d4a8c02b54 S0380.233: PV self-consumption credited at Table 12a weighted import rate
SAP 10.2 Appendix M1 §6 (PDF p.94, lines 5510-5513): "apply the normal
import electricity price to PV energy used within the dwelling and the
'electricity sold to grid, PV' price from Table 12 to the energy
exported. In the case of the former, use a weighted average of high and
low rates (Table 12a)."

`_pv_dwelling_import_price_gbp_per_kwh` was returning the bare off-peak
LOW rate (5.50 p/kWh on a 7-hour tariff) for the PV-used-in-dwelling
credit. PV self-consumption displaces the dwelling's "all other uses"
electricity (lighting / appliances / pumps), which on an off-peak tariff
bills at the Table 12a Grid 2 ALL_OTHER_USES weighted blend, not the low
rate. On simulated case 19 the worksheet (252)/(269) credits
PV-used-in-dwelling at 14.3110 p/kWh = 0.90 × 15.29 + 0.10 × 5.50; we
credited it at 5.50, under-crediting onsite PV by ~£0.088/kWh on every
off-peak PV cert.

Fix delegates to `_other_fuel_cost_gbp_per_kwh(tariff, prices)` (the same
ALL_OTHER_USES rate): STANDARD tariff still returns the flat Table 32
code 30 13.19 p/kWh (golden cohort unchanged — all 2412 tests pass);
off-peak returns the weighted high/low blend. Call sites now pass the
resolved `_rdsap_tariff(epc)`. The now-unused
`_off_peak_low_rate_gbp_per_kwh_via_meter_heuristic` (its only caller)
is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:57:40 +00:00
Khalim Conn-Kowlessar
212b0c92ab S0380.232: D_PV excludes low-rate portion of off-peak electric main heating
SAP 10.2 Appendix M1 §3a (PDF p.93, lines 5470-5476): "E_space,m =
(211)m + (213)m + (215)m, where (211), (213) and/or (215) should be
included only where the fuel code applied to them in Section 10a of the
SAP worksheet is 30, 32, 34, 35 or 38 (i.e. electricity not at the
low-rate)."

The PV-eligible demand D_PV,m was adding 100% of the main space-heating
fuel (211)m whenever the main's Table-12 code was in the eligible set
(30, …), ignoring the off-peak high/low split that §10a already bills
via `_space_heating_fuel_cost_gbp_per_kwh`. Electric STORAGE heaters on
a 7-hour tariff are charged wholly at the low rate (Table 12a Grid 1 SH
fraction 0.00; worksheet (240) high-rate cost = 0), so none of (211)
may enter D_PV — but the cascade counted it all, inflating R_PV,m =
E_PV,m / D_PV,m and therefore the β onsite-PV split in the heating
months.

Fix mirrors the cost-side rate split: `_main_space_heating_high_rate_
fraction(main, tariff)` returns the high-rate portion (1.0 for
non-electric / STANDARD, the published Grid 1 SH fraction otherwise,
0.0 when the Grid 1 SH row is unwired → 100% low rate), and
`_pv_eligible_demand_monthly_kwh` scales the (211)m contribution by it.
Backward-compatible: STANDARD-tariff electric mains and the gas-main /
electric-secondary PV cohort are unchanged (fraction 1.0).

On simulated case 19 (electric storage heaters, 7-hour, PV) this takes
β_Jan 0.894 → 0.792, matching the worksheet 0.791, and the summer months
(no main heating) already pinned exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:07:04 +00:00
Khalim Conn-Kowlessar
2a29b29aa5 docs: handover — fold in S0380.227-231 + mapper parity work; refresh error landscape + worksheet asks
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:23:07 +00:00
Khalim Conn-Kowlessar
3684a142ac S0380.231: Dual-meter electric room heaters resolve to 10-hour tariff (RdSAP 10 §12 Rule 3)
RdSAP 10 §12 (PDF p.62) Dual-meter dispatch: "the choice between 7-hour
and 10-hour is made by the main heating type ... if the main system is a
direct-acting electric boiler (191), or electric room heaters ... it is
10-hour tariff." The electric room-heater codes — Table 4a 691 (panel/
convector/radiant), 692 (fan), 693 (portable), 694 (water-/oil-filled),
699 (assumed) — were missing from `_RULE_3_TEN_HOUR_CODES` (the long-
standing TODO there), so a Dual-meter room-heater cert fell through to
Rule 4 (7-hour default).

Compounded with S0380.230 (which routes room heaters to Table 12a
OTHER_DIRECT_ACTING_ELECTRIC): at 7-hour the high-rate fraction is 1.00
(all at 15.29 p), but at the correct 10-hour it is 0.50 split over the
10-hour rates (14.68 / 7.50 p) → blended ~11 p. Without this fix .230
over-charged and flipped the cluster from over- to under-rating.

1,000-cert 2026 API sample: cat-10 mean |err| 7.11 → 5.26, signed mean
+5.08 → -0.86 (now balanced, 22 over / 26 under — the systematic
directional bias is gone). Overall mean |err| 2.16 → 2.04. Full §4 suite
green (2406 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:51:09 +00:00
Khalim Conn-Kowlessar
0476b4b235 S0380.230: electric room heaters (cat 10) on off-peak bill at Table 12a direct-acting high rate
SAP 10.2 Table 12a Grid 1 (PDF p.191): an electric room heater (RdSAP
main_heating_category 10, e.g. SAP code 691) is direct-acting electric,
so it sits on the "Other systems including direct-acting electric" row —
7-hour high-rate fraction 1.00, 10-hour 0.50. It runs on demand, mostly
at the HIGH rate; it does NOT earn the 100%-low-rate of overnight storage
charging (which is category 7).

`_table_12a_system_for_main` only mapped ASHP, so an electric room heater
fell through to the "100% low-rate" fallback (5.50 p, £0.0550), under-
charging space heating by ~9.79 p/kWh and systematically OVER-rating the
cluster. Now maps electric cat-10 mains to OTHER_DIRECT_ACTING_ELECTRIC
(gated on `_is_electric_main`, so gas/solid-fuel cat-10 room heaters are
excluded). The same Table 12a fraction flows through cost, CO2 (Table
12d) and PE (Table 12e) — all three callers already pre-gate on electric.
Mirror of S0380.228 (same fallback bug for electric SECONDARY heating).

1,000-cert 2026 API sample (no worksheet for this cluster — ±0.5-vs-lodged
fallback bar): cat-10 mean |err| 9.49 → 7.11, %<0.5 10.4% → 16.7%;
headline %<0.5 42.5% → 42.9%, overall mean |err| 2.29 → 2.16. cat-7
(storage) and cat-2 (gas) unchanged. Full §4 suite green (2405 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:39:15 +00:00
Khalim Conn-Kowlessar
9a483b8711 docs: handover — fold in S0380.227-229 + PV diverter (G4) as the case-19 next slice
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:36:13 +00:00
Khalim Conn-Kowlessar
0f6b402345 S0380.229: primary loss applies for a dedicated water-heating boiler/circulator (WHS 911-931)
SAP 10.2 Table 3 (PDF p.160) row 1: primary circuit loss applies when
"hot water is heated by a heat generator (e.g. boiler) connected to a
hot water storage vessel via insulated or uninsulated pipes". The Table
4a hot-water-only codes (PDF p.166) 911 gas / 912 liquid / 913 solid
boiler-circulator + 921-931 range cooker with boiler are each a heat
generator feeding the cylinder through a primary loop.

`_primary_loss_applies` keyed only off the resolved DHW `main` — but for
these certs `_water_heating_main` returns the SPACE main (e.g. electric
storage heaters, SAP code 402, which has no primary loop), so every
boiler branch missed the gas water-boiler's primary circuit and (59)m
went to zero. New branch keys off `water_heating_code` ∈
`_WATER_HEATING_BOILER_CIRCULATOR_CODES`. 941 (electric HP for water
only) is excluded — HP DHW vessels follow the Table 3 integral-vessel
rules.

Simulated case 19 (electric storage main + WHS 911 + 210 L cylinder):
(62)m total HW demand 2493.30 → 3169.98 kWh/yr, matching the worksheet
(the missing 676.68 kWh/yr = the worksheet's (59) primary-loss annual
sum, h=5/p=0). The remaining (64)/(219) gap is the PV diverter (63b),
deferred to its own slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:35:12 +00:00
Khalim Conn-Kowlessar
4911c56200 S0380.228: electric secondary on off-peak bills at Table 12a direct-acting high rate
SAP 10.2 Table 12a Grid 1 (PDF p.191): secondary heating is a direct-
acting electric room heater (RdSAP 10 §A.2.2 default), on the "Other
systems including direct-acting electric" row — 7-hour high-rate fraction
1.00, 10-hour 0.50. A room heater runs on demand, mostly at the high
rate; it does NOT earn the 100%-low-rate of overnight storage charging.

`_secondary_fuel_cost_gbp_per_kwh` previously returned the flat off-peak
LOW rate (5.50 p, £0.0550) for every off-peak electric secondary, under-
charging by 9.79 p/kWh. New `_secondary_off_peak_rate_gbp_per_kwh` mirrors
`_space_heating_fuel_cost_gbp_per_kwh`: it blends the Table 12a high-rate
fraction (OTHER_DIRECT_ACTING_ELECTRIC) against the Table 32 high/low
rates, with the 18-/24-hour fallback to the low rate.

Simulated case 19 (electric storage main + electric secondary, Dual/7-hour
meter) is the worksheet case (242): "Space heating - secondary
(1.00*15.29 + 0.00*5.50)" → 15.29 p/kWh = £0.1529. This was the primary
cat-7-cluster cost driver: total cost 1485.68 → 1835.53 (worksheet
1816.58), SAP cont 60.11 → 50.67 (worksheet ~51.22). Remaining +19 cost
is HW/space-heating kWh (next slices).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:00:38 +00:00
Khalim Conn-Kowlessar
5d4b55d7f9 S0380.227: dedicated DHW-only system is not separately timed (Table 2b note b)
SAP 10.2 Table 2b note b (PDF p.159) applies the ×0.9 temperature-factor
reduction only when DHW is "separately timed" relative to space heating
on a SHARED heat generator ("boiler systems, warm air systems and heat
pump systems"). Per RdSAP 10 §10.5.1 (PDF p.55) a separate boiler/
circulator providing DHW only (water-heating code 911 = "Gas boiler/
circulator for water heating only") is NOT the main space-heating system
— so there is no shared timer to apply the ×0.9 against. `_separately_
timed_dhw` now returns False when water_heating_code is not "from main /
2nd-main system" ({901,902,914}), mirroring the existing WHC 903 electric-
immersion carve-out.

Simulated case 19 (electric storage main SAP 402 + WHS 911 + 210 L
loose-jacket cylinder) is the worksheet case. The single flag drives both:
- (53) Temperature factor: 0.54 → 0.6000 (worksheet base, no ×0.9)
- (55) storage loss/day: → 3.4531; (56)/(57)m Jan → 107.0456 (1e-4)
- (59)m primary loss: h=3 (43.31) → h=5 (Jan 64.5792), worksheet-exact

This also worksheet-pins S0380.224's loose-jacket storage loss magnitude
at 1e-4, previously only direction-validated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:44:11 +00:00
Khalim Conn-Kowlessar
796dce9d69 docs: handover — fold in S0380.224-226 + simulated case 19 debug state
Bump HEAD/next-slice/baseline, note the committed scripts toolkit, and add
the active "simulated case 19" section: the electric-storage-heater +
loose-jacket worksheet the user generated, what S0380.226 unblocked, and
the prioritised cluster bugs it exposed (cost (255) -334 = the +9 SAP
driver; Table 2b TF x0.9; WHS-911 storage-vs-combi routing; fabric +1.0).
Updated the "what to generate" ask to the two highest-value follow-ups
(electric room heaters; Sheltered/Adjacent RR gables).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:14:05 +00:00
Khalim Conn-Kowlessar
19ed29e13c docs: handover — 1000-cert API accuracy study + next-steps + worksheet ask
Captures the wide-scale 2026-register study (41.8% <0.5, heating-driven
cluster table), the 7 slices shipped (S0380.219-225), the prioritised
remaining work (electric-heating clusters + worksheet-backed raises), and
the single highest-ROI worksheet to generate: an electric-storage-heater
house with a loose-jacket cylinder + a room-in-roof with Sheltered/
Adjacent gables + an extension — one document that validates the #1
accuracy cluster, pins the S0380.224 loose-jacket fix at 1e-4, closes the
gable_wall_type Table 4 raise, and exercises multi-bp fabric.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:37:03 +00:00
Khalim Conn-Kowlessar
9c0a373f7d S0380.225: §10.7 no-water-heating default — A-F → 12mm loose jacket
The §10.7 no-water-heating default cylinder raised UnmappedSapCode for
age bands A-F (2 certs in a 2026 sample, bands B + C) because Table 29's
"A to F: 12 mm loose jacket" row wasn't plumbed — the loose-jacket
storage-loss branch didn't exist. S0380.224 added it, so this slice
completes the Table 29 lookup.

Restructure _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE to carry
(cylinder_insulation_type, thickness_mm) per band — A-F → (loose jacket,
12), G/H → (factory, 25), I-M → (factory, 38) per RdSAP 10 Table 29
(PDF p.56) — and have the default read both, setting the loose-jacket
type for A-F instead of hardcoding factory. The strict-raise is retained
only for an absent / out-of-A-M age band (no Table 29 row).

Validated: certs 2211 (band B, SAP 49.8 vs lodged 52) and 3420 (band C,
11.2 vs 11) now compute. §4 + golden suite 2395 passed — the corpus
"no system" cert (age G, 25 mm factory) is unchanged. cert_to_inputs.py
pyright unchanged at 32; new test suppresses reportPrivateUsage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:28:25 +00:00
Khalim Conn-Kowlessar
2e351be957 S0380.224: compute storage loss for loose-jacket cylinders (Table 2 Note 1)
`_cylinder_storage_loss_override` returned None for any cylinder whose
cylinder_insulation_type wasn't 1 (factory), so a loose-jacket cylinder
(code 2, RdSAP 10 field 7-11) fell to the cascade's zero-storage-loss
combi/instantaneous default — its real storage loss vanished. SAP 10.2
Table 2 Note 1 gives loose jacket a SEPARATE, ~2× higher loss factor
(L = 0.005 + 1.76/(t+12.8) vs factory 0.005 + 0.55/(t+4)); the
cylinder_storage_loss_factor_table_2 helper already implements it — only
the dispatch was missing.

Fix: a `_cylinder_storage_loss_insulation_label` resolver maps the lodged
code to the Table 2 branch (1 → factory_insulated, 2 → loose_jacket;
None/0/unknown → None, keeping the conservative no-loss default). The
override and the HW storage call now route through it instead of
hardcoding "factory_insulated".

Evidence + validation: a random 2026 register sample has 22 loose-jacket
certs that over-predicted SAP by +2.29 mean (18/22 too high, 1/22 within
0.5) — the exact signature of under-counted HW storage loss. After the
fix their mean error collapses to +0.45 and 11/22 land within 0.5, with
ZERO regression across the worksheet-validated cohort (§4 + golden suite
2394 passed — no validated cert lodges loose jacket, so none shifts).
Also unblocks the §10.7 A-F no-water-heating default (next slice) which
needs the loose-jacket branch. cert_to_inputs.py pyright unchanged at 32.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:33:52 +00:00
Khalim Conn-Kowlessar
d3def1e254 docs: handover — S0380.218 closed the "with api 3" pair (both clean)
Record S0380.218 shipped, bump HEAD/next-slice, note both certs are
0-residual cross-validated golden fixtures and flag the optional
Summary-path regression guard as the cheap follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:36:23 +00:00
Khalim Conn-Kowlessar
1085842395 docs: handover — flagged numbers were stale (different branch), Part 1 is the task
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:11:12 +00:00
Khalim Conn-Kowlessar
8bd8ff8e5c docs: handover — fresh-API cross-comparison + flagged-cert debugging
Next-agent brief: fetch certs fresh from the EPC API (two new API+Summary+
worksheet triples for cross-mapper parity, plus six dashboard-flagged certs).
Flags the critical reconciliation: the user's flagged numbers don't match the
golden-fixtures cascade (0390-2954-3640 pinned +0 but flagged -6.85; 7536/2130
flags are pre-this-session), so fresh-raw-JSON-vs-curated-fixture or a
different engine must be reconciled before debugging. Documents the EPC API
fetch mechanism, the dropped-field audit method, this session's 4 fixes, and
the conventions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:08:28 +00:00
Khalim Conn-Kowlessar
f895dd3ab7 S0380.217: capture wall_insulation_thermal_conductivity (was dropped)
Second silently-dropped field from the 2130 audit: the schema-21
SapBuildingPart never declared `wall_insulation_thermal_conductivity`, so
`from_dict` discarded it. Captured it through schema 21.0.0/21.0.1 → domain
SapBuildingPart → API mapper, and wired it into u_wall's RdSAP 10 §5.8
documentary-evidence R-value calc (both the solid-brick §5.7/§5.8 path and
the cavity-composite path), replacing the bare 0.04 λ constant with a
resolved λ.

Resolver: absent / "Unknown" → the §5.8 default 0.04 W/m·K (mineral wool /
EPS); a mapped code → its λ; an unmapped integer code RAISES so the enum is
confirmed against a worksheet rather than silently mis-factored (same
incremental-coverage discipline as the glazing-type map). Only code 1
(= the default 0.04) is mapped — the sole observed value (cert 2130 Ext1).

Zero cascade effect today: the λ path fires only for solid-brick/cavity
walls with a *measured* wall thickness, and 2130 Ext1 lodges no wall
thickness, so its conductivity is captured-but-unused; all existing §5.8
certs lodge no conductivity → 0.04 default unchanged. The point is to stop
dropping lodged data and make λ correct when a future cert exercises it.

Suite: 2523 passed (1 pre-existing TFA fail); sap10_ml 237 passed (2
pre-existing stone-formula fails). Zero new pyright errors (46=46).

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:58:37 +00:00
Khalim Conn-Kowlessar
ec64c39d74 docs: correct 2130/7536 drive-to-zero diagnosis (distinct causes, not shared)
Per each cert's notes: 7536 is a glazing-U gap (S0380.97 glazing_type=2
Table 24 default vs the cert's higher lodged U on multi-age bps) — the
tractable target; 2130's SAP +1 is a PV-β cohort cascade interaction, not
a fabric line. The earlier "multi-part wall, shared cause" label was wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:14:10 +00:00
Khalim Conn-Kowlessar
f50195ac54 docs: handover — golden SAP drive-to-zero priority (2130/7536); 0240 architectural
Add a CURRENT PRIORITY section: of 53 pinned golden certs, 3 have non-zero
SAP residual. 2130 (+0.85 cont) + 7536 (+0.57 cont) are real multi-part-wall
fabric over-predictions (the drive-to-zero targets, possibly one shared
cause). 0240 (-1) is architectural — the lodged 73 needs an unpreserved
2013+ pump; document the cause, do NOT re-pin (user decision).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:00:23 +00:00
Khalim Conn-Kowlessar
b9bbcecb42 docs: Thread 2 cost +4 closed by S0380.213 heat-network standing charge
Record the £120 standing-charge fix (Table 12 note (l) + §C3.2, case 14
(351)), the corrected diagnosis (standing charge, not cost scaling — the
4.24 p/kWh heat price was already right), the double-count avoidance, and
the remaining ~7% demand over-count (SAP -2). Bump HEAD/baseline/next-slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:46:32 +00:00
Khalim Conn-Kowlessar
ee484d9f4a S0380.213: heat-network standing charge (£120) — fixes 9390 cost under-count
Cert 9390 (community mains-gas boiler, API main_fuel_type=20) drew £0
standing charge → fuel cost under-counted → SAP read +4 high (71 vs 67).

Root cause: the standing-charge logic (`additional_standing_charges_gbp`)
only knows the GAS branch (`_is_gas_code`) and the off-peak-electric branch.
A heat-network community fuel is not a Table-32 gas code — EPC 20 = "mains
gas (community)" normalises to Table-32 code 20 (biomass), so
`_is_gas_code(20)` is False and the standing came out £0. The Summary path
masks this because it lodges community gas as Table-32 code 1 (ordinary
mains gas), which IS gas-recognised and already draws the £120 gas standing
— so the CH1-6 corpus was unaffected while the API path lost the charge.

Spec basis (verified against SAP 10.2 spec PDF):
- Table 12 (p.191) "Heat networks" row standing charge = £120/yr, note (k).
- Note (l): "Include half this value if only DHW is provided by a heat
  network."
- §C3.2 (p.58): the full charge applies when the space heating is also a
  heat network.
Worksheet-validated: simulated case 14 (community boilers + mains gas,
space + water) → worksheet (351) Additional standing charges = £120.

Fix: new `_heat_network_standing_charge_gbp(epc, main)` returns the
heat-network standing (£120 full when the space main is a heat network;
£60 when only DHW is on the network) or None otherwise. Applied at both
fuel-cost call sites, REPLACING the fuel-based `additional_standing_charges
_gbp` for heat-network mains (NOT additive) so a Summary-path community-gas
main — already £120 via the gas branch — is not double-counted to £240. The
CH1-6 community corpus stays exactly £120 (59 corpus tests pass).

9390 SAP +4 → -2 (cont 65.39 vs lodged 67): the spec-correct £120 standing
EXPOSES a separate ~7% demand over-count (also visible as PE 220 vs lodged
205) — a heat-source-efficiency-default / fabric residual, follow-up scope.
9390 is unpinned (retired P2.2 per ADR-0010 §10); helper locked by 2 unit
tests. Full suite 2386 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:45:24 +00:00
Khalim Conn-Kowlessar
f658f7ce71 docs: Thread 2 CO2/PE collision fixed by S0380.212; cost +4 tail open
Record the heat-network fuel-code collision fix (EPC 20 'mains gas
(community)' → Table-12 51), case-14 validation, and the remaining
cost-scaling gap (heat-network cost path missing 1/heat_source_eff).
Bump HEAD/next-slice; update shipped + audit tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:27:45 +00:00
Khalim Conn-Kowlessar
08dd0b4c73 S0380.212: fix community fuel-code collision in heat-network CO2/PE/cost
Cert 9390-2722-3520 (community mains-gas boiler scheme, sap_main_heating_
code=301, main_fuel_type=20) emitted CO2 0.44 t vs lodged 2.8 t — 6.4x low.

Root cause: the EPC `main_fuel_type` enum and the SAP Table 12 / Table 32
fuel-code numbering COLLIDE in the 18-25 range. Per
`datatypes/epc/domain/epc_codes.csv` (RdSAP-Schema-17.0) EPC fuel
20 = "mains gas (community)", but Table 12/32 code 20 is a solid biomass
fuel (CO2 0.028, PE 1.046, wood-logs price). The factor lookups
(`co2_factor_kg_per_kwh` / `primary_energy_factor` / `unit_price_p_per_kwh`)
check the Table-12/32 dict FIRST, so the EPC community fuel 20 silently
returned the biomass factor instead of translating 20 -> Table 12 code 51
(community mains gas: CO2 0.210, PE 1.130, mains-gas price).

Fix: new `_heat_network_factor_fuel_code(main)` translates the EPC community
fuel to its Table-12 code via `API_FUEL_TO_TABLE_12`, but ONLY for
heat-network mains (`_is_heat_network_main`) — a genuine biomass boiler
(non-community) keeps its raw Table-12 factor. Applied at the five
heat-network factor sites: space-heating CO2 / PE / unit-price and
water-heating (WHC 901) CO2 / PE. The Summary path is unaffected (it maps
"Mains gas - community" to code 1, no collision), so the community-heating
corpus (CH1-6) is untouched.

Worksheet-validated against simulated case 14 (community boilers + mains
gas, SAP code 301): worksheet (367) CO2 factor 0.2100, (467) PE factor
1.1300 — exactly the Table-12 code-51 values the translator now reaches.
9390 CO2 0.44 -> 3.03 t (lodged 2.8; spec-correct factors over the API-only
register residual per [[feedback-worksheet-not-api-reference]]), PE 204 ->
220 (the spec-correct 1.13 factor; the prior 204≈205 match was the
collision coinciding with the register residual). 9390 is unpinned (retired
at P2.2 per ADR-0010 §10); the translator is locked by two unit tests.

REMAINING (separate follow-up): 9390 SAP +4 is a cost-side gap — the
heat-network cost path does not apply the 1/heat_source_eff (1/0.80)
scaling that the CO2/PE paths do, so community fuel cost under-counts.

Suite: 2616 passed, 1 skipped (community corpus green); the 2
test_rdsap_uvalues stone-formula failures are pre-existing (HEAD 58ff7d88).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:25:53 +00:00
Khalim Conn-Kowlessar
c3c44fa3d0 docs: Thread 2 unblocked — case 14 (code-301 boiler+gas) arbitrates 9390
Record the community mains-gas BOILER worksheet (case 14): target (386)
heat-network CO2 factor 0.2640, distribution loss 1.49, code 301. 9390
decomposition: PE matches (204 vs 205), CO2 6.5x low (collision), SAP +4
separate cost gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:07:05 +00:00
Khalim Conn-Kowlessar
55af4ee2d7 docs: Thread 1 (roof) CLOSED by S0380.211; Thread 2 needs code-301 gas ws
Record the roof closure (vaulted NI → Table 18 col 1 0.16, cohort-arbitrated
not the guessed 0.25), the AGENT_GUIDE suite-command gap (sap10_ml/tests/ not
run) + pre-existing stone failures, cases 11/12/13 now available, and the
fuel-20 = community-gas (Table 12 code 51) note. Thread 2 still needs a
code-301 community-boiler + mains-gas worksheet (case 13 is code-302 CHP).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:57:22 +00:00
Khalim Conn-Kowlessar
0add6b6a59 docs: mark Thread 3 (cert 0390) CLOSED by S0380.210
Update the mapper-bugs handover: Thread 3 closed via the cavity
"partial insulation (assumed)" → "Cavity as built" routing fix; record
the latent open question about the unvalidated "insulated (assumed)" →
filled-cavity test (slice S-B25). Bump HEAD/baseline/next-slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:58:37 +00:00
Khalim Conn-Kowlessar
c75ef6417f S0380.210: cert 0390 cavity "partial insulation (assumed)" → as-built row, not filled
Golden cert 0390-2954-3640 (detached, TFA 360, age F) carried a +7 SAP /
-28 kWh/m² PE residual the audit attributed to a demand-side fabric gap.
Walking the §3 cascade localised it to the Main wall: lodged
wall_construction=4 (cavity), wall_insulation_type=4 (as-built / assumed),
description "Cavity wall, as built, partial insulation (assumed)". The
cascade mis-routed it to the Table 6 "Filled cavity" row (band F = 0.40)
because `_described_as_insulated` matches the "partial insulation"
substring.

RdSAP 10 Specification (10-06-2025) Table 6 — Wall U-values, England
distinguishes two cavity rows:
  "Cavity as built"  A-E 1.5, F 1.0, G 0.60, H 0.60, I 0.45, J 0.35, ...
  "Filled cavity"    A-E 0.7, F 0.40, G 0.35, H 0.35, I 0.45†, J 0.35†, ...
An "as built ... partial insulation (assumed)" cavity is the as-built
partial fill of the age band, NOT a retrofit cavity fill (a genuine fill
lodges the distinct "Cavity wall, filled cavity", wall_insulation_type=2).
It therefore routes to "Cavity as built" (band F = 1.0), mirroring the
worksheet-validated solid-brick rule in S0380.209 (cases 9/10: "as built,
insulated (assumed)" → as-built age-band row, not retrofit).

New `_cavity_described_as_filled` predicate is used only in u_wall's
cavity filled-row branch; it excludes the "partial insulation" substring
while keeping "insulated (assumed)" → filled (the unrelated, separately
asserted test_cavity_as_built_insulated_assumed_uses_filled_cavity_row is
unchanged). The shared `_described_as_insulated` (also consumed by the
roof/floor paths) is left untouched.

Wall HLC +53.6 W/K (U 0.40 → 1.0 over ~268 m²) lifts all four metrics
together — the signature of a real fabric bug, not a tuned offset:
  SAP  +7      → +0
  PE   -27.9745 → +0.5281 kWh/m²
  CO2  -2.7134  → -0.1189 t/yr
Bands I-M are unaffected (the two rows coincide per the † footnote), so
golden certs 0535 (band M) / 7536 (band L) with "insulated (assumed)"
cavities continue to pin at 0. Full suite 2384 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:57:00 +00:00
Khalim Conn-Kowlessar
58ff7d8881 docs: handover for golden-cert mapper/cascade bugs (roof S0380.210 + community fuel collision)
Records post-S0380.209 state: 0240 verdict (true SAP 72, register 73 = unpreserved
2013+ pump, proven 0=Unknown via 13 pairs), and three open threads — roof Ext1
"insulated (assumed)" U over-count (needs case 11 worksheet), community fuel-code
collision (API 18-25 vs Table-12 biomass 18-25; cert 9390 CO2 6x low; needs 9390
worksheet), and 0390 +7 demand-side gap. Plus the audit table of all 5 non-zero-SAP
golden certs.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:42:18 +00:00
Khalim Conn-Kowlessar
6ac67a4c6f docs: add full 0240 worksheet input spec to the closure handover
Adds a "build THIS in Elmhurst" specification — dwelling, dual condensing
oil-combi (code 130) heating, combi/no-cylinder DHW (Table 3a keep-hot
600), per-element fabric W/K targets, room-in-roof gables, the 5 vertical
+ 6 roof-of-room windows, lighting (8 LED), no PV — so a generated
worksheet reproduces cert 0240 as closely as possible. Flags the three
load-bearing differences vs case 6 (combi code 130, no cylinder, boiler
interlock PRESENT → no -5pp) that the new worksheet must capture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:13:46 +00:00
Khalim Conn-Kowlessar
d4817ccdc7 docs: handover for closing golden cert 0240 to 1e-4
Records why case 6 (worksheet-validated dual-oil archetype) did not close
0240's residual: 0240 is API-only with an INTEGER-rounded register target
(PE 122, CO2 6.0), so 0 residual at 1e-4 is not well-posed without a
worksheet. 0240's unvalidated path vs case 6 is the condensing-combi
(code 130) + no-cylinder HW (Table 3a keep-hot 600 kWh) — case 6 used a
regular boiler + cylinder. Recommends generating an exact-0240 worksheet
(or a 'case 7' = case 6 with the combi swapped in) to get a 1e-4 target.
Notes the lodged RHI water_heating 2842.82 already matches the cascade
HW output exactly (HW demand is right; any residual is in efficiency).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:05:58 +00:00
Khalim Conn-Kowlessar
d1ae87c7e9 S0380.206: Eq D1 Q_space uses the DHW boiler's own (204) share, not (202)
SAP 10.2 Appendix D §D2.1(2) Equation D1 blends the monthly water-heater
efficiency by the ratio of the boiler's space-heating load to its water
load. On a dual-main cert the DHW boiler does only its OWN share of space
heating ((204) for Main 1, (205) for Main 2), but the cascade fed Eq D1
the dwelling total ((202) = 1 − secondary). That over-weighted η_winter
and under-stated HW fuel — simulated case 6 (Main 1 serves DHW + 51% of
space heat) was HW −78 kWh vs the worksheet.

New `_water_heating_main_space_fraction` returns the DHW main's total-
space share via `_water_heating_main` (WHC-901 → Main 1 (204); WHC-914 →
Main 2 (205)); single-main / WHC-901 single systems get (202) = 1 −
(201), so they are unchanged. Case 6 (219) HW now 4902.8601 EXACT.

With S0380.205 (demand exact), case 6 now closes to 1e-4 on EVERY metric:
SAP cont 71.6597, ECF 2.0316, cost 1162.5374, (211)+(213) 14736.9564,
(219) 4902.8601, (231) 356, (232) 357.6571, CO2 5953.6679 (rating) /
4895.2137 (demand).

Re-pin: 0240 (dual combi, WHC 901, Main 1 51%) HW rises slightly → PE
+1.6893 → +1.8687, CO2 +0.0815 → +0.0907 (SAP 72 unchanged). Single-main
certs unchanged (2360 pass + 0 fail).

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:35:08 +00:00
Khalim Conn-Kowlessar
4ed691603f docs: correct S0380.200 handover — interlock was already done; S0380.201 closed pumps
The flagged "priority" (per-main boiler interlock −5pp) was already
implemented (S0380.141 cylinder-thermostat path + S0380.177 room-
thermostat path); case 6 already produces (206)=79/(207)=84 exactly and
0240 is a combi with no cylinder. Records that S0380.201 closed the
secondary dual-system pump item and the remaining case-6 gaps (space
demand +1.28%, HW −1.6%) for full-SapResult promotion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:52:13 +00:00
Khalim Conn-Kowlessar
963db2ae23 S0380.201: SAP 10.2 Table 4f note c) second main-system circulation pump
Simulated case 6 (P960-0001-001431, dual oil boiler 51% rads + 49%
underfloor) worksheet (231) = 356 = (230c) central-heating pump 156 +
(230d) oil boiler pump 200. (230c) decomposes per SAP 10.2 Table 4f
note c) (PDF p.175): "Where there are two main heating systems include
two figures from this table" — Main 1 41 kWh (pump age "2013 or later")
+ Main 2 115 kWh (pump age unknown). The cascade summed only Main 1's
circulation pump, giving (231) = 241.

cert_to_inputs now adds the second main's circulation pump, gated on a
lodged main_heating_fraction > 0 (a genuine second SPACE-heating main —
the same test §9a uses to split space-heating demand). This excludes
DHW-only second mains (cert 000565 Main 2 = gas combi via WHC 914,
fraction 0); without the gate 000565's worksheet pins regressed +115 kWh.

Re-pin: golden 0240 (dual-main oil combi, API-only, no worksheet) gains
its Main 2 pump too (pumps_fans 315 → 430). Spec-correct per
note c and validated by the case-6 worksheet; SAP cont 72.55 → 72.18
(integer 73 → 72, resid +0 → -1), PE +1.9459 → +2.8092, CO2 +0.1226 →
+0.1385. The lodged 73 carries Elmhurst's own residual; the worksheet-
backed case 6 is the spec authority for the archetype.

Note: the boiler-interlock −5pp per-main determination the prior
handover flagged as the priority is already implemented (S0380.141
cylinder-thermostat path + S0380.177 room-thermostat path) — case 6
already produces (206)=79 / (207)=84 exactly, and 0240 is a combi with
no cylinder so correctly unpenalised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:51:13 +00:00
Khalim Conn-Kowlessar
558aaf6d09 docs: handover post S0380.200 — 6035+0240 closed; boiler-interlock −5pp OPEN
Captures the session's window/RR/dual-main work (S0380.196–200) and the
open priority: a spec-accurate per-system boiler-interlock −5pp (Table
4c(2)) adjustment. Root cause for case 6's remaining deltas (sys-1 eff 79
not 84 + HW 4824 vs 4902) is the "room thermostat present but no cylinder
thermostat → no interlock" path that the current {2101,2102} no-interlock
rule misses. 0240 shares the controls + cylinder_thermostat=N so it will
re-pin (apply spec uniformly). Secondary: dual-system Table 4f pumps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:21:49 +00:00
Khalim Conn-Kowlessar
8ae978a646 S0380.200: SAP 10.2 §9a two-main-heating split (203)/(205)/(207)/(213)
The cascade lumped a dwelling with two main heating systems into one:
`space_heating_fuel_monthly_kwh` hard-coded (203)=0 (a documented
scope-A placeholder) and the calculator's per-month fuel read only
main_1, so the full §8 space-heat demand billed against system 1's
efficiency. Simulated case 6 (one oil boiler feeding radiators 51% +
underfloor 49%) exposed it: main fuel ≈ demand/eff1 instead of the
worksheet's (211)+(213) per-system split.

Implements the SAP 10.2 §9a two-main model:
  (204) = (202) × (1 − (203))   → system 1 share of total heat
  (205) = (202) × (203)         → system 2 share of total heat
  (211)m = (98c)m × (204) × 100 / (206)
  (213)m = (98c)m × (205) × 100 / (207)
(203) = the second system's lodged `main_heating_fraction`; (207) = its
own seasonal efficiency via the new per-detail `_main_heating_detail_
efficiency` (the core of `_main_heating_efficiency`, now reused for
system 2). Calculator `_solve_month` aggregates main_1 + main_2 into
`main_heating_fuel_kwh`. Cost (§10a 241), CO2 (§12 262) and PE (§13 276)
main_2 paths were already wired and now activate.

Site-notes gap also fixed: §14.1 Main Heating2 omits the "Fuel Type"
cell when the second system shares Main 1's fuel (case 6: one oil boiler,
two emitters). `_map_elmhurst_main_heating_2` now inherits Main 1's
resolved fuel as a fallback.

Blast radius: only dual-main certs. 0240 (2× oil code 130, identical
Eq-D1 efficiency) is unchanged — its split collapses to the lumped total.
Suite: 2355 passed, 1 skipped. New code: 0 pyright errors.

NOTE: case 6 is not yet fully pinnable end-to-end — its two systems have
DIFFERENT efficiencies (radiators 55°C → 79%, underfloor 35°C → 84%), a
flow-temperature boiler-efficiency adjustment not yet modelled, and its
dual-system auxiliary pumps ((230c)+(230d)=356) differ from the cascade.
Both are separate follow-on features; this slice is the §9a fuel split.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:09:43 +00:00
Khalim Conn-Kowlessar
af477678c2 docs: handover post S0380.195 — 6035 OPEN, API-mapper roof/RR over-count lead
Retracts the premature "6035 = lodged divergence" claim (S0380.195 commit
msg + fixture docstring). The golden residual SAP -2 / PE +19.16 / CO2
+0.42t is REAL and exceeds the fallback bar. Section-level diff of 6035
(API) vs sim case 4 (site-notes, pins @1e-4) localised it to a
cross-mapper parity break: roof W/K 78.33 (site-notes) vs 130.73 (API),
a +52 over-count from the API RR scalar path + roof_construction=4. Next
agent starts there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:14:17 +00:00
Khalim Conn-Kowlessar
62fc27a5cc S0380.193: suspended-floor (12) sealed rule fires only on a SUPPLIED U-value
RdSAP 10 §5 (PDF p.29) "Floor infiltration (suspended timber ground
floor only)", age band A-E, splits on whether a floor U-value is
supplied:
  a) [U-value supplied] if floor U-value < 0.5 → "sealed", (12) = 0.1
  b) [no U-value supplied] retro-fitted insulation → "sealed" 0.1;
     otherwise "unsealed", (12) = 0.2

`_has_suspended_timber_floor_per_spec` fed the cascade's COMPUTED default
U into rule (a), so an as-built/uninsulated suspended-timber floor whose
default U happens to be < 0.5 was marked "sealed" (0.1) where Elmhurst
uses "unsealed" (0.2). That dropped (18) infiltration 0.85 → 0.75, (25)
effective ACH, HTC, and understated space heating ~450 kWh.

Fix: gate rule (a) on `floor_u_value_known` — a computed default U is not
a supplied value, so it falls through to (b). Verified against the
cert 001431 sim-case-2 worksheet: floor "As built", U=0.43 (matches the
worksheet's (28a) 0.4300 exactly), (12)=0.2 unsealed. Golden cert 6035
(also a suspended uninsulated floor) is unaffected — its U=0.63 ≥ 0.5
already routed to unsealed.

Promotes sim case 2 to the e2e harness as `001431_rr` (Main + Extension
+ Simplified room-in-roof — the 6035 archetype). All 11 Block-1 line
refs pin at abs=1e-4, locking BOTH this fix and S0380.192 (Simplified-RR
remaining area) end-to-end: SAP 69, cost 920.5046, CO2 4566.7090, space
15269.8593, main fuel 18178.4039. 2319 passed (+11), 0 failed; pyright
net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:16:25 +00:00
Khalim Conn-Kowlessar
e63d046b9d docs: handover post S0380.189 — TMP/Table 22 + the two open follow-ups
Point-in-time note for the next agent: what S0380.185-189 shipped (worksheet
PE/CO2 pins, the two D_PV electricity-vs-gain fixes, and the thermal-mass-
parameter Table 22 fix), the per-line diagnosis template, the two worksheet-
block / gains-vs-solar traps, and the ranked open slices (Summary-path fuel
derivation first, then pin the simulated 001431 case, then cert 6035).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:05:41 +00:00
Khalim Conn-Kowlessar
e03f08cdc8 S0380.189: thermal mass parameter per RdSAP 10 §5.16 Table 22, not hardcoded 250
The §7 mean-internal-temperature cascade hardcoded the thermal mass parameter
(TMP) to 250 kJ/m²K at all 5 call sites, ignoring construction. RdSAP 10
§5.16 Table 22 (PDF p.48) makes TMP construction-dependent:

  100 kJ/m²K — timber frame, cob, park home (regardless of internal
               insulation); OR masonry (stone/solid brick/cavity/system
               built) WITH internal insulation.
  250 kJ/m²K — masonry WITHOUT internal insulation.

A too-high TMP inflates the §7 time constant τ = Cm/(3.6·H) (e.g. 40 h vs
16 h), under-cuts the temperature reduction between heating periods, and
over-states mean internal temperature → over-states space heating.

`_thermal_mass_parameter_kj_per_m2_k(epc)` classifies the MAIN building's
wall via the RdSAP `wall_construction` codes (5/7/8 = timber/cob/park) and
`wall_insulation_type` codes (3/7 = internal); unknown/curtain fall back to
the masonry 250 (no regression on unlisted classes). 17-case parametrised
test covers every Table 22 branch.

Diagnosis (per-line walk vs the user-simulated 001431 worksheet, same
archetype as golden cert 6035): fabric (26-37), internal gains (73), climate
(96)m and HTC (39) all EXACT; the entire +8.78 PE / -1.76 SAP gap was §7 MIT
(92) +0.71 °C, traced to TMP 250 vs Table 22's 100 (solid brick WITH internal
insulation). Fix closes the simulated case to 1e-4 on PE and CO2.

Blast radius: only golden cert 6035 re-pins (solid brick + internal
insulation) — SAP resid -6 → -2, PE +46.42 → +19.16, CO2 +1.07 → +0.42. The
47 dr87 cohort, 6 U985 fixtures and 41-variant heating corpus are all
masonry-no-internal → TMP unchanged at 250, all still pass. 2290 pass
(+17 new), 0 fail; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:01:35 +00:00