Commit graph

172 commits

Author SHA1 Message Date
Daniel Roth
e84de954fb define MagicPlanConfig class to get environment variables 2026-06-05 15:46:32 +00:00
Daniel Roth
198d2afdb1 Merge branch 'main' into feature/handle-new-magicplan-response-structure 2026-06-05 14:35:56 +00:00
Daniel Roth
8e349704b1 move magic plan handler to applications/ 2026-06-05 14:33:26 +00:00
Jun-te Kim
6778c427bc
Merge pull request #1181 from Hestia-Homes/feature/landlord_data
property override
2026-06-05 15:16:06 +01:00
Daniel Roth
37b5a3a6e5 move domain code out of datatypes/domain 2026-06-05 14:07:28 +00:00
Daniel Roth
db3477d6bb Extract door height from API response into height_mm 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:49:57 +00:00
Daniel Roth
5797ddbda6 Persist window and door ventilation via SQLModel tables 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:06:15 +00:00
Daniel Roth
192a3cf20f Persist window and door ventilation via SQLModel tables 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:02:47 +00:00
Daniel Roth
0211fb8092 Migrate all MagicPlan tests to single new-format fixture 🟪
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:59:56 +00:00
Jun-te Kim
b07db1ef6b property override 2026-06-05 12:18:13 +00:00
Daniel Roth
5a582bbff0 Merge branch 'main' into feature/handle-new-magicplan-response-structure 2026-06-05 11:01:28 +00:00
KhalimCK
3bdfa0287c
Merge pull request #1169 from Hestia-Homes/feature/per-cert-mapper-validation
Feature/per cert mapper validation
2026-06-05 11:50:11 +01:00
Daniel Roth
ebd6f1623f Merge branch 'main' into feature/handle-new-magicplan-response-structure 2026-06-05 10:16:14 +00:00
Khalim Conn-Kowlessar
77f90e144e review: store epc_building_part.wall_insulation_thickness as JSONB
PR feedback (dancafc): the SQLModel column was Optional[str], but the
domain `SapBuildingPart.wall_insulation_thickness` is Optional[Union[str,
int]] — `_api_resolve_wall_insulation_thickness` returns an int mm when the
API lodges `wall_insulation_thickness == "measured"` (SAP 10.2 §5.7 /
Table 8). The plain str column round-trips that int back as the string
"100", corrupting the Table 8 insulated-wall U-value lookup.

This column was missed in the round-trip-fidelity §1 JSONB sweep
(#1129) — its `Union[str, int]` sibling `roof_insulation_thickness` was
converted, but `wall_insulation_thickness` was not, and no 21.0.0/21.0.1
fixture lodges "measured" so the gap stayed latent. Convert to JSONB
(matching `roof_insulation_thickness` / `flat_roof_insulation_thickness`),
align the column type to Optional[Union[str, int]] (also removes a pyright
type-mismatch), record it in the migration doc §1, and add a round-trip
guard test asserting an int survives as an int (fails as '100' == 100 on
the old str column).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:07:24 +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
f326e4eb53 mapper: Elmhurst path populates roof_construction (int) for cross-mapper parity
The gov-EPC API mapper sets BOTH roof_construction (int) and
roof_construction_type (str, derived via _API_ROOF_CONSTRUCTION_TO_STR),
but the Elmhurst mapper set only the string — leaving roof_construction
None on every site-notes cert. The SAP cascade reads the STRING (so SAP
cross-mapper parity always held), but consumers of the int (e.g.
domain/sap10_ml/transform.py ML aggregates `main_dwelling_roof_
construction`) silently saw None on the Elmhurst path.

New `_elmhurst_roof_construction_int` maps the Elmhurst roof-type code to
the same SAP10 int the API lodges (F→1, PN→3, PA→4, PS→8, S/A→7),
harvested from the committed Summary fixtures. Unlike the wall map it
returns None (not a strict-raise) for unmapped codes: the int is not
cascade-load-bearing, so an unknown roof must not block the cert (vaulted
5 / thatched 6 / NR omitted until a fixture surfaces them).

The 6 hand-built U985 reference fixtures gain the matching
roof_construction int (4/4/3 etc.) so test_from_elmhurst_site_notes_
matches_hand_built_* still asserts structural parity. SAP output is
unchanged (cascade reads the string). §4 suite green (2407 passed); the
two pre-existing stone-§5.6 sap10_ml failures are unrelated/out of scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:16:20 +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
bd25a3c774 mapper: disambiguate SY system-built from B basement wall (both share code 6)
RdSAP10 `wall_construction == 6` is canonically WALL_SYSTEM_BUILT — a
WALL TYPE — but the gov-EPC basement heuristic hijacked it: Elmhurst
lodges both "SY System build" and "B Basement wall" as code 6, and the
API lodges basements as code 6 too, so a system-built wall was
mis-flagged `main_wall_is_basement` → wrong RdSAP §5.17 / Table 23
u_basement_wall/u_basement_floor overrides, and downstream the solid-wall
Recommendation Generator couldn't offer EWI/IWI on system-built walls.

System-built stays the wall type on its canonical code 6; the basement
signal moves OFF code 6 to a dedicated `is_basement` (SapAlternativeWall)
/ `wall_is_basement` (SapBuildingPart) Optional[bool] flag:
- Elmhurst: `_elmhurst_wall_is_basement` sets it from the distinct
  "SY"/"B" labels (False for SY, True for B, None otherwise).
- gov-EPC API: per-wall code 6 can't be told apart at lodging time, so
  `from_api_response` post-processes via `_clear_basement_flag_when_
  system_built` — when the cert addendum marks the dwelling system-built,
  the code-6 basement heuristic is cleared. A genuine basement (no
  addendum signal) keeps the code-6 fallback.
- `main_wall_is_basement` / `is_basement_wall` honour the flag when set,
  else fall back to the code-6 heuristic — so untouched API basements and
  the cert 000565 "B" cohort are unchanged.

`EpcPropertyData.system_build` is a derived property over the wall type:
the MAIN wall is system-built iff `wall_construction == 6` and it is not
flagged basement. System-built lives on `wall_construction`; the basement
attribute is separate.

Acceptance: a system-built main wall (Elmhurst SY, or API addendum
system_build) → wall_construction == 6, main_wall_is_basement is False,
system_build is True; a genuine basement main wall → main_wall_is_basement
is True, system_build is False. Full §4 suite green (2404 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 19:39:18 +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
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
846952f7cd S0380.191: pin simulated 001431 gas-combi end-to-end at 1e-4 (e2e harness)
Adds the user-simulated 001431 case (the cert that drove S0380.189/.190)
as an Elmhurst-only e2e fixture: Summary PDF → extractor → mapper →
calculator, every Block-1 SapResult field pinned against the
P960-0001-001431 worksheet at abs=1e-4. All 11 pins pass with zero
residual — the case is clean, confirming the S0380.190 gas-combi fuel
derivation closes the Summary path natively.

Verified the handover's flagged "+0.0007 SAP" was a target artifact, not
a cascade gap: the worksheet displays ECF (257) rounded to 1.6047 and
integer SAP (258)=78; the cascade's continuous SAP is computed from the
UNROUNDED ECF = (255)*(256)/((4)+45) = 660.9750*0.4200/173.0, giving
77.6147 — which matches the worksheet's own unrounded value. Pinning the
continuous SAP from the display-rounded ECF (→ 77.6144) was the wrong
target. Block-1 line refs all match exactly: (211) 10699.7225, (219)
3327.1592, (231) 86.0, (232) 283.2229, (255) 660.9750, (272) 3000.1664,
Σ(98) 8987.7669.

Summary mirrored into the tracked fixtures dir as
Summary_001431_gas_combi.pdf (distinct name — the corpus reuses cert
001431 across every heating variant); source Summary + worksheet tracked
under sap worksheets/golden fixture debugging/ as the pin ground truth.

2302 passed (+11), 0 failed; pyright net-zero on new/changed files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00
Khalim Conn-Kowlessar
306dd4c0c9 S0380.190: derive gas-combi main fuel from §15.0 when §14.0 Fuel Type is empty
The newer Elmhurst Summary export lodges a gas combi as §14.0 "Fuel Type"
empty + "Main Heating SAP Code" 104 (EES "BGW"), with no fuel string. The
site-notes mapper left `main_fuel_type=''`, so `cert_to_inputs` raised
`MissingMainFuelType` — blocking the whole gas-combi Summary path
(reproduced on the simulated 001431 case).

SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas boilers (including
mains gas, LPG and biogas)": the code fixes the boiler type/efficiency but
NOT the carrier, so 104 alone can't distinguish mains gas from LPG. The
disambiguator is §15.0 "Water Heating Fuel Type" — a combi/boiler heats
space + water from one appliance — exactly mirroring the existing
liquid-fuel (codes 120-141) fallback. `_elmhurst_gas_boiler_main_fuel`
adopts the §15.0 carrier only when the SAP code is in 101-119 AND §15.0
resolves to a gas/LPG fuel, so a regular boiler + electric immersion
(§15.0 = "Electricity") still strict-raises rather than mis-billing gas
as electric.

2291 passed (+1), 0 failed; pyright net-zero on both files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00
Khalim Conn-Kowlessar
49247d390d 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-04 15:40:26 +00:00
Khalim Conn-Kowlessar
0e5f5b7d4a S0380.188: D_PV,m uses lighting ELECTRICITY (L10) not the L12 gain — closes PV cohort to 1e-4
SAP 10.2 Appendix M1 §3a (p.93) defines PV-eligible demand as
  D_PV,m = E_L,m + E_A,m + E_cook,m + E_ES,m + (231)·n_m/365 + E_space,m + E_water,m
where E_L,m is the lighting ELECTRICITY (Appendix L eq L10, = line (232)).
The cascade fed `internal_gains_result.lighting_monthly_w` — the L12 internal
heat GAIN G_L,m = E_L,m × 0.85 ("assuming 15%" of lighting energy does not
become internal heat) — into D_PV, understating it by 15% of lighting on
every PV cert. That depressed the monthly β onsite/export split and
under-credited PV primary energy uniformly across the year.

Same gain-vs-electricity class as the cooking fix S0380.73 (L18 gain vs L20
electricity). Fix: scale the (shape-identical) lighting gain profile to the
annual E_L `lighting_kwh_per_yr` (= (232)), mirroring the (219)m hot-water
scale-to-annual. Magnitude-only, so the shape-weighted lighting CO2/PE
effective factor (Σkwh×f/Σkwh, magnitude-invariant) is unchanged; appliances
need no scaling (G_A = E_A, no 0.85). Diagnosis was empirical first (calc
lighting D_PV 95.1 vs worksheet (232) 111.88, ratio exactly 0.85) then
confirmed against the spec text (L9d/L10/L12, M1 §3a).

Impact (calc − full-precision dr87 worksheet): ALL 47 worksheet certs now
match at <1e-4 on BOTH PE (max |Δ| 0.0000 kWh/m²) and CO2 (max |Δ| 0.0000 kg)
— the convergence target, met cohort-wide. Combined with S0380.187 this
closes the entire gas+PV + ASHP PV residual. Re-pinned 47 worksheet residuals
to 0.0000 and 31 drifted lodged residuals (PV certs). SAP integers unchanged;
chain SAP 1e-4 intact (164 pass). 2273 pass, 0 regressions; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00
Khalim Conn-Kowlessar
fdd2f60ef2 S0380.187: include electric secondary heating in Appendix M1 D_PV,m — closes gas+PV PE/CO2 gap
The PV onsite/export β-split (SAP 10.2 Appendix M1 §3a, p.93) divides PV
generation by the monthly PV-eligible electricity demand D_PV,m. The cascade
included main and water electricity (when those fuels are electric) but had
no term for SECONDARY space heating. For the 10 cohort-2 gas-main +
electric-secondary + PV certs, the (215)m secondary electric fuel was dropped
from D_PV,m — understating demand in the heating months only, depressing the
monthly β, and under-crediting onsite PV primary energy.

Spec: Appendix M1 §3a counts E_space,m as the dwelling's TOTAL electric
space-heating demand; for a gas-main/electric-secondary dwelling that is the
secondary fuel. Diagnosis was decisive: E_PV (generation) matched the
worksheet exactly every month, the onsite (233a) split diverged ONLY in
heating months (Jun-Sep near-exact), and all 10 affected certs have PV while
all clean gas certs have none. Empirically adding (215)m to D_PV closed cert
3136 onsite 726.9 → 790.3 (worksheet 792.1).

Impact (calc − full-precision dr87 worksheet), the 10 certs:
  PE +0.5..+1.5 → +0.02..+0.046 kWh/m²; CO2 −0.5..−1.1 → +0.002..+0.0095 kg.
The whole 47-cert cohort now matches at PE <0.05 / CO2 <0.025. SAP integers
unchanged; chain SAP 1e-4 pins intact (164 pass). The uniform ~0.03 PE remnant
on PV certs is the separate (233a)/(233b) summer-month D_PV discrepancy.

Re-pinned the 10 worksheet + 9 lodged golden residuals (improvements).
2273 pass, 0 regressions; pyright net-zero (file's 32 errors pre-existing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00
Khalim Conn-Kowlessar
b4c04210f4 S0380.186: pin golden PE/CO2 against full-precision dr87 worksheets (47 certs)
The existing golden test compares calc PE/CO2 against the integer-rounded
lodged register values (energy_consumption_current / co2_emissions_current),
which conflates real calculator gaps with register rounding. This adds a
parallel pin against each cert's Elmhurst dr87 worksheet (286)/(272) at full
precision — a clean calculator-vs-Elmhurst signal for the 47 worksheet-backed
certs (9 ASHP + 38 cohort-2).

Findings at capture (calc − worksheet, on the worksheet's own decimal TFA):
  - 37/47 exact on both PE (<0.05 kWh/m²) and CO2 (<0.02 kg).
  - 10 higher-consumption gas certs carry PE +0.5..+1.5 kWh/m² AND
    CO2 -0.5..-1.1 kg simultaneously. PE-over + CO2-under on the same
    certs is the fingerprint of a small gas→electricity fuel-split
    difference (elec PE 1.51 > gas 1.13, but elec CO2 0.136 < gas 0.21),
    not a factor-value error — next slice candidate.

An earlier "41/47 PE gaps" reading was a JSON-integer-TFA division artifact;
comparing on the worksheet's decimal TFA (which the calculator also uses)
collapses it to the real 10. Worksheet values frozen as literals (the dr87
PDFs are untracked, so not parsed at test time) per the worksheet_unrounded_sap
convention. Also replaced a pre-existing pytest.approx with abs-diff to keep
the file at zero pyright errors (feedback_abs_diff_over_pytest_approx).

106 passed (was 59); pyright 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00
Khalim Conn-Kowlessar
18a337b3a1 S0380.184: community electric-HP network CO2/PE uses monthly Table 12d/12e — closes CH3
SAP 10.2 worksheet block 12b/13b (367)/(467) for a community heating
electric heat pump (Table 4a code 304 → Table 12 fuel 41 "heat from
electric heat pump"). The HP meters grid electricity, so per Table 12
note (s)/(t) + block 12b/13b footnote (a) its emission/PE factor is the
MONTHLY Table 12d/12e cascade (fuel 41 = standard-electricity profile),
weighted by the network heat profile, then × 1/heat-source-eff (1/COP):

  (367)/(467) = [(307)+(310)] / COP × Σ((307+310)_m × factor_m)/Σ(...)

Per-line walk of CH3 (the displayed (367) 0.1535 / (467) 1.5717 are PDF
artifacts; the (373)/(473) totals reconcile only with):
  CO2 factor = 0.15040 (monthly Table 12d wtd) vs cascade annual 0.136
  PE  factor = 1.55692 (monthly Table 12e wtd) vs cascade annual 1.501

Pre-slice the cascade routed code 304 through the non-electric branch
(`_co2_factor_kg_per_kwh(main) × 1/COP` = annual × scaling). New
`_is_heat_network_electric_main` (heat-network main whose fuel has a
Table 12d monthly set — i.e. fuel 41) routes all four factor helpers
(main + HW, CO2 + PE) through the monthly cascade × 1/COP. Non-electric
heat networks (gas 51 / oil 53 / coal 54) have no monthly set → annual
path unchanged (CH1, CH6 untouched).

Closure (CH3 was already SAP+cost EXACT):
  CH3 (HP/Elec)  CO2 −75.32→+0.0000 (= [(307+310)/3]×(0.1504−0.136)),
                 PE −249.32→−0.0000 (× (1.5569−1.501))  — FULLY EXACT

Corpus now 40/41 EXACT on all four metrics. Only CH6 remains: its
worksheet lodges a manual DLF=1.0 ("two adjoining dwellings") absent
from the Summary PDF (byte-identical to CH4 bar fuel type) — an
architectural limit, not a cascade gap. 2226 pass + 1 skip + 0 fail
(tolerances 1e-4 all metrics); pyright net-zero 43→43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00
Khalim Conn-Kowlessar
035303e9f8 S0380.182: community-heating CHP+boilers CO2/PE credit (§12b/13b) — closes CH2/CH4 CO2+PE
SAP 10.2 worksheet block 12b (CO2) / 13b (PE) for community heating
"CHP and boilers" (SAP code 302). Per unit of network heat fuel
H = (307)+(310) the effective generation factor is:

  chp×100/(362)×f_fuel − chp×(361)/(362)×f_disp + (1−chp)×100/(367)×f_fuel

  (363)/(463) CHP fuel      = chp_frac × 100/heat_eff × f_fuel
  (364)/(464) less credit   = −chp_frac × elec_eff/heat_eff × f_disp
  (368)/(468) boiler fuel   = (1−chp_frac) × 100/boiler_eff × f_fuel

f_fuel = Table 12 heat-network fuel factor (the CHP unit and the back-up
boilers burn the same community fuel — verified vs CH2 gas / CH4 oil /
CH6 coal worksheets (363)/(368)); f_disp = Table 12f (PDF p.196) credit
for the CHP-generated electricity. RdSAP 10 §C (p.58) defaults: heat eff
50% (362), electrical eff 25% (361), boiler eff 80% (367); CHP heat frac
0.35 per-cert via community_heating_chp_fraction.

New `_heat_network_code_302_effective_factor` + Table 12f flexible
constants (0.420 CO2 / 2.369 PE) + RdSAP §C efficiency constants, wired
into all four factor helpers (main + HW, CO2 + PE) ahead of the existing
single-fuel / 1-over-heat-source-eff path. The worksheet (368)/(468)
boiler emissions DISPLAY rounded/mis-aligned in the PDF, but the
(373)/(473)/(386)/(486) totals reconcile only with the boiler at the
full Table 12 factor — verified EXACT.

Two spec citations applied:
- Table 12f flexible-operation default for RdSAP community CHP is an
  Elmhurst engine choice (Table 12f notes make "standard" the default);
  mirrored per [[feedback-software-no-special-handling]] and documented
  in SAP_CALCULATOR.md §8.3.
- Table 12 heat-network oil/biodiesel CO2 (codes 53/56) corrected
  0.298 → 0.335 per Table 12 (p.189) "assumes 'gas oil'"; the code-302
  oil cascade (CH4) was the first to exercise it. PE 1.180 was already
  correct. No other variant uses these codes (no regression).

Closures (CO2 + PE only — the CHP credit does not touch cost/SAP):
  CH2 (CHP/Gas)  CO2 −1411.49→+0.0000, PE +1331.23→+0.0000  EXACT
  CH4 (CHP/Oil)  CO2 −4378.24→−0.0000, PE  +319.81→−0.0000  EXACT
  CH6 (CHP/Coal) CO2/PE re-pinned (+2411.54 / +5023.48) — its worksheet
                 lodges a manual DLF=1.0 the Summary doesn't carry, so
                 cascade DLF=1.45 over-scales H; same root as the CH6
                 SAP −7.49 / cost +£172 (separate DLF front).

CH2/CH4 are now CO2+PE-exact but still carry the heat-network cost/SAP
residual (+0.5277 SAP / −£12.16 cost, exposed by S0380.175 — cost-side,
untouched here). CH3 unchanged (code 304 community-HP COP front).

Corpus state: 37 variants EXACT on all four metrics (incl. CH1);
remaining residuals are CH2/CH4 cost+SAP, CH3 CO2+PE (HP COP), CH6
all-metric (DLF quirk). 2223 pass + 1 skip + 0 fail (tolerances 1e-4 all
metrics per S0380.181); pyright net-zero 43→43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00
Khalim Conn-Kowlessar
fe527e3a23 S0380.180: heat-network distribution pumping electricity (§C3.2) — closes CH1
SAP 10.2 Appendix C §C3.2 (PDF p.51), verbatim: "CO2 emissions and
Primary Energy associated with the electricity used for pumping water
through the distribution system are allowed for by adding electrical
energy equal to 1% of the energy required for space and water heating."

Worksheet line (313) = 0.01 × [(307)+(310)]; its CO2 (372) and PE (472)
bill on the Table 12d/12e monthly factors for fuel code 50 ("electricity
for pumping in distribution network"), weighted by the monthly heat
profile per worksheet footnote (a). (307)m/(310)m = (space_demand +
hw_output) / efficiency (the cascade models a heat network's generator
efficiency as 1/DLF).

This un-defers the (372)/(472) front the post-S0380.179 handover flagged
"don't guess until the factor source is identified": the source is
§C3.2 + Table 12d/12e code 50, NOT an empirical constant. The apparent
0.1994/0.2114 "factor" is an Elmhurst DISPLAY artifact — the worksheet
shows the (372) energy column as 0.01×(307) (space only) while computing
emissions on 0.01×(307+310) per the §C3.2 text. Verified EXACT line-by-
line against the CH2 corpus worksheet: (372)=23.6007 CO2 (rating),
(472)=208.2267 PE (demand).

New `_heat_network_distribution_electricity` helper (gated on
`_is_heat_network_main`) precomputes the energy + effective CO2/PE
factors; three new CalculatorInputs fields + calculator.py CO2/PE
summation terms (0.0/None → no-op for individually-heated certs).

Closures:
  CH1 (Boilers/Gas)  CO2 −23.60→−0.00, PE −208.23→+0.00  — FULLY EXACT
  CH3 (HP/Elec)      CO2 −98.92→−75.32, PE −457.54→−249.32 (distribution
                     component closed; code-304 community-HP COP remains)
  CH2/CH4/CH6        gain their (372)/(472) component (CO2 +23.6, PE
                     +208.2); dominant CHP displaced-electricity credit
                     residual (Table 12f + block 12b/13b) is next slice.

No regression on the other 36 corpus variants (helper returns None off
heat-network mains) + golden + U985 fixtures. 2223 pass + 1 skip + 0
fail; pyright net-zero 43→43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00
Khalim Conn-Kowlessar
a2bcc2c8af Move sap10_calculator tests to tests/domain/sap10_calculator/ for CI
The calculator tests lived under domain/sap10_calculator/{tests,worksheet/
tests,rdsap/tests,climate/tests,validation/tests}, none of which are in
pytest.ini testpaths — so CI (which collects tests/) never ran them. Relocate
all five dirs to tests/domain/sap10_calculator/{,worksheet,rdsap,climate,
validation}, mirroring the tests/domain/property_baseline/ convention, so the
cascade-pin / golden / e2e conformance suites run in CI.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00
Khalim Conn-Kowlessar
298755fbe0 refactor(baseline): SapCalculator ABC replaces the Calculator Protocol
PR feedback: prefer an abstract base the calculator inherits from over a
structural Protocol. Define `SapCalculator(ABC)` in the calculator package
(the engine owns its own contract) and have `Sap10Calculator` inherit it;
a future methodology is another subclass. Placing the ABC with the engine —
not in property_baseline — keeps the dependency pointing consumer -> engine
(sap10_calculator imports nothing from property_baseline). Consistent with
the repo's existing port convention (FuelRatesRepository(ABC)).

CalculatorRebaseliner keeps its reference to SapCalculator type-only (under
TYPE_CHECKING), so the module still does not import the calculator at
runtime. Test fakes now inherit the ABC since structural conformance no
longer applies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00
Khalim Conn-Kowlessar
899532a6d6 feat(baseline): CalculatorRebaseliner — calculator goes load-bearing (ADR-0013 amend)
Slice 5a: the promotion. Replaces StubRebaseliner in production and collapses the
shadow runner into the rebaseliner (ADR-0013 amendment).

- CalculatorRebaseliner runs Sap10Calculator on every Property:
  * sap_version < 10.2 -> Effective Performance IS the calculator output
    (band via Epc.from_sap_score, CO2 kg->t, PEUI rounded), reason "pre_sap10".
  * sap_version >= 10.2 -> Effective = lodged (API figures on-target), and the
    calculator only logs divergence (SAP>0.5, PEUI/CO2 1%) as a validation signal.
  * a calculator raise propagates -> batch aborts (ADR-0012); fix the cert at once.
- Rebaseliner.rebaseline gains property_id (for the divergence log).
- LoggingCalculatorShadow / the calculator_shadow seam removed from the
  orchestrator; its divergence-comparison logic now lives in the rebaseliner.
- StubRebaseliner kept (signature updated) for orchestrator/repo unit tests.

The SapResult->EnergyBreakdown adapter + BillDerivation wiring (to populate the
bill block) follow once the appliances/cooking SapResult fields land.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00
Khalim Conn-Kowlessar
bb1029c0d8 feat(baseline): SAP fuel-code -> Fuel mapping for billing (ADR-0014)
Slice 3 of Bill Derivation. sap_code_to_fuel(code) maps a SAP 10.2 / Table 32
fuel code to the canonical billing Fuel — bounded to the ~47 Table 32 codes (the
carrier, orthogonal to the PCDB product index, so all PCDB heat pumps share one
electricity code). Mains gas / LPG / oil+bioliquids / coal / smokeless / wood /
electricity (standard + off-peak) / heat-network groupings; an unmapped code
(dual fuel, grid-export) raises UnmappedSapCode rather than guessing.

Also: ADR-0014 deferred/TODO section records the stubbed appliances+cooking
(pending the SapResult fields), the off-peak day/night split, the heat-network
rate gap, and regional rates / ETL.

The SapResult -> EnergyBreakdown adapter (next slice) is gated on the
appliances/cooking fields landing on SapResult.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00
Khalim Conn-Kowlessar
c7ad26f07b feat(baseline): BillDerivation prices an energy breakdown at Fuel Rates (ADR-0014)
Slice 2 of Bill Derivation. BillDerivation(fuel_rates).derive(breakdown) takes a
delivered-energy breakdown (per-section EnergyLine(section, fuel, kwh) +
exported_kwh) and produces a Bill: per-section kWh + cost, standing charges,
SEG credit, and total.

- Each end-use line billed at its fuel's unit rate.
- Standing charge added ONCE per distinct fuel used (a meter, not an end use);
  off-gas fuels carry 0 so contribute nothing — no metered/unmetered special case.
- SEG export credit subtracted.
- Deterministic (ADR-0006); raises UnpricedFuel (via FuelRates) on an unpriced
  fuel (e.g. heat network) rather than billing at a wrong default.

Pure domain — no calculator dependency; the SapResult->EnergyBreakdown adapter
is slice 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00
Khalim Conn-Kowlessar
d53108fbfd feat(fuel-rates): FuelRates snapshot + repository foundation (ADR-0014)
Slice 1 of Bill Derivation — the reference-data foundation that later slices
price the calculator's per-end-use kWh against:

- Fuel enum (canonical billing fuels; the join key between the calculator's
  SAP-code fuels and the rates snapshot). COAL + HEAT_NETWORK are members with
  no national rate.
- FuelRates value object: unit_rate_p_per_kwh / standing_charge_p_per_day /
  seg_export_p_per_kwh; raises UnpricedFuel on a fuel it has no rate for rather
  than billing at a wrong default.
- FuelRatesRepository port (ADR-0011 Repo-reads-stored-reference-data) +
  StaticFileFuelRatesRepository reading a committed JSON snapshot.
- Snapshot fuel_rates_2026_q2.json: GB national, Apr-Jun 2026 Ofgem cap
  (gas/electricity) + DESNZ/NEP May 2026 (off-gas). Carries the full researched
  data; the value object exposes single-rate fuels this slice. Off-peak
  (day/night), house coal and heat network raise UnpricedFuel until later slices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +00:00
Khalim Conn-Kowlessar
741993270e feat(baseline): run Sap10Calculator in shadow on Property Baseline (ADR-0013)
Wire Sap10Calculator into PropertyBaselineOrchestrator as a non-load-bearing
shadow runner. For each property it scores the Effective EPC beside the
load-bearing Lodged/Effective write, catches any strict-raise -> log.error
(never aborts the batch), and on success log.warning's divergence from Lodged:
SAP |continuous - lodged| > 0.5; PEUI/CO2 > 1% relative (CO2 after kg->tonnes).
Every line is tagged with sap_version so SAP-10.2 signal separates from
older-spec drift (ADR-0010 Validation Cohort).

Per ADR-0013, Calculated SAP10 Performance is not a persisted third value-set:
effective = calculated in every baselining scenario, so the calculator IS the
mechanism that produces Effective Performance (the Rebaseliner). It runs in
shadow only while being hardened; when overrides/estimation land it is promoted
to drive Effective and the failure posture flips to abort (ADR-0012, calculator
now load-bearing). No table change.

- ADR-0013 + CONTEXT (Calculated SAP10 Performance / Effective Performance /
  Rebaselining) record the decision.
- CalculatorShadow port + LoggingCalculatorShadow + Calculator protocol.
- FakeCalculatorShadow for orchestrator unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:40:26 +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
Jun-te Kim
261fae2e79 reformatted to be DDD structure 2026-06-04 14:50:04 +00:00
Khalim Conn-Kowlessar
6d9ef1143c S0380.218: golden fixtures for 2 fresh API+Summary+worksheet triples
Two certs fetched fresh from the GOV.UK EPB register, each with an
Elmhurst Summary PDF (input) and a dr87 worksheet PDF (the (1)..(286)
ground truth):

  0340-2467-9260-2006-6521  (Summary_000922 / dr87-0001-000922)
  5500-5070-0822-0201-3663  (Summary_000920 / dr87-0001-000920)

Both run through BOTH front-ends — from_api_response and
from_elmhurst_site_notes — and through the rating + demand cascades.
Cross-mapper parity holds: the two paths agree to <1e-4 on continuous
SAP, fuel cost, CO2 and PE. Both paths reproduce the worksheet exactly:

  0340: (255) cost 776.4295, (272) CO2 2875.0498, (286) PE 16474.5616;
        fabric (33) 171.6188, (37) 205.9358; SAP int 70 = lodged.
  5500: (255) cost 751.8295, (272) CO2 2423.4547, (286) PE 14397.0118;
        fabric (33) 141.1226, (37) 167.3696; SAP int 66 = lodged.

Pinned in two tables of test_golden_fixtures.py:
  - _EXPECTATIONS / test_golden_cert_residual_matches_pin — SAP/PE/CO2
    residual vs the integer-rounded lodged register (SAP resid +0 both).
  - _WORKSHEET_PE_CO2 / test_golden_cert_pe_co2_matches_worksheet —
    PE (286)/(4) and CO2 (272) vs the worksheet at +0.0000 (the
    load-bearing 1e-4 check; lodged register is integer-rounded).

Dropped-field audit (raw JSON keys vs the schema-21.0.1 dataclass
fields consumed by from_dict) re-run on both fresh JSONs: no new
silently-dropped fields — only created_at metadata and the
shower_outlet_type/shower_wwhrs keys already handled by
_normalize_shower_outlets (mapper.py:2047). No calculator or mapper
change required; this is pure validation + regression-pinning.

Full §4 suite: 2392 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:35:50 +00:00
Khalim Conn-Kowlessar
6b4f1aec44 docs: finalise 2130 as 0240-like — PV β-split proven exact
Updated 2130's pin notes with the case-18 finding: our cascade reproduces
the worksheet PV split to the decimal (gen 2684.17 / onsite 970.77 / export
1713.40), so the Appendix M1 β-split is exact, not the suspected bug. With
the gas PE factor also exact (1.13) and the wall measurement now wired
(S0380.215), 2130's +2/-11.72 is the irreducible API-only lodged residual
(0240-like), not a closable calculator bug. Notes-only; pin unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:04:47 +00:00
Khalim Conn-Kowlessar
2f5ca85854 S0380.215: capture dropped measured wall insulation thickness
The schema-21 SapBuildingPart never declared `wall_insulation_thickness_
measured`, so `from_dict` silently discarded it. When a cert lodges
`wall_insulation_thickness == "measured"` the actual value (mm) lives in
that dropped field, so the cascade fell back to the 50 mm "insulation
present, unknown thickness" default instead of the lodged measurement.

Cert 2130 Ext1 lodges solid brick band B + INTERNAL insulation
"measured"/100 mm. Per RdSAP 10 §5.7 Table 8 (insulated-wall U by age band
+ insulation thickness) the 100 mm row gives U=0.32; the unknown-thickness
fallback gave 0.55. New `_api_resolve_wall_insulation_thickness` substitutes
the measured value for the "measured" sentinel; the existing
`_insulation_bucket`/Table-8 path then computes the correct U. Field added
to schema 21.0.0/21.0.1 SapBuildingPart; domain field widened to
Union[str, int] to match `roof_insulation_thickness`. Isolated: 2130 Ext1
is the only bp lodging "measured" across all 47 fixtures.

This spec-correct fix EXPOSED an offsetting under-count it had been masking
(per the repo's no-special-handling rule — the pre-fix +1 was two bugs
cancelling): 2130 cont SAP 83.35 → 83.78 (resid +1 → +2), PE -7.56 →
-11.72, CO2 -0.045 → -0.095. The exposed -11.72 PE (~-746 kWh/yr) is the
deferred gas-combi-PE + PV-β-credit under-count from S0380.45/.49, now
un-masked — the next slice. Re-pinned 2130 with the cause documented.

Suite: 2391 passed, 1 skipped. Zero new pyright errors (mapper 32=32).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:30:49 +00:00
Khalim Conn-Kowlessar
e097ce2cef docs: finalise 7536 as 0240-like (resid +1, do not chase)
After three faithful-worksheet iterations (simulated cases 15/16/17), the
7536 +1 SAP residual is confirmed 0240-like — an Elmhurst register-rounding
residual not reproducible from the API-only JSON, not a calculator bug.

Case 17 is faithful on windows (Main 16.98 / Ext1 13.59 / Ext2 1.89) and
ground floors; every per-element value matches our cascade: walls
0.70/0.28/0.40, roofs 0.40/0.18/0.68 (S0380.214), window U-eff
2.4368/1.8519, ground floors 0.97/0.26/1.12. The only worksheet divergences
were manual-entry artifacts: case-16 inverted the floor order (put the
50.98 m² upper floor as ground), and case-17 auto-derived spurious "to
external air" exposed floors from the small-ground/big-upper geometry —
real 7536 lodges floor_heat_loss 2/7/3 (unheated-space / ground), none is
code 1 (exposed). Our spec-correct cont SAP is 68.924; lodged 68 carries
Elmhurst's own residual.

Notes-only; pin unchanged (resid +1, PE -6.1952, CO2 -0.1639). Suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:41:48 +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