Commit graph

397 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
0f79de7a35 fix(exposure): honour determined roof_insulation_location for flats 🟩
A gov-API flat can lodge dwelling_type="Mid-floor flat" while carrying its
own exposed roof — a top-floor flat mislabelled mid-floor. _dwelling_exposure
keyed roof exposure on the dwelling_type label alone, dropping the roof
heat-loss term: space-heating demand under-read ~32%, SAP over-read +7.

Fix: when the main building part lodges a *determined* roof_insulation_location
(an RdSAP integer code, not the "ND" Not-Defined party-ceiling sentinel),
expose the roof regardless of a contradictory label. Structured field, not a
description string and not roof_construction (which the gov-API lodges
building-wide on every unit, so it is not a per-unit signal).

On the RdSAP-21.0.1 corpus roof_insulation_location separates the classes with
zero disagreement: all 190 party-ceiling flats lodge "ND"; the 4 mid/ground
flats this exposes all move toward lodged, 0 away. within-0.5 73.3% -> 73.6%,
MAE 0.774 -> 0.761 (ratchets tightened). Verified end-to-end on the same
block: 715363 (location 6, RHI 2694) 81 -> 74 = lodged; genuine mid-floor
sibling 715395 (location ND, RHI 1024) stays party at 83 = lodged.

The override is additive (only ever exposes a label-dropped roof) and reads
the main part, so multi-part flats with a party main ceiling stay party.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 21:34:33 +00:00
Khalim Conn-Kowlessar
7a74aaecb9 test(exposure): mid-floor-labelled flat with a lodged exposed roof 🟥
A gov-API cert can lodge dwelling_type="Mid-floor flat" while carrying a
real exposed roof element (roof_construction != 7 "dwelling above") over a
"(another dwelling below)" floor — i.e. a top-floor flat mislabelled
mid-floor. Property 715363 (uprn 6027561) + sibling 715395 (6027563) do
exactly this; the correctly-labelled top-floor sibling 715871 (6027574),
same block + same flat roof, already computes the lodged SAP 74.

_dwelling_exposure keys roof exposure on the dwelling_type label alone, so
it drops the roof heat-loss term, under-reading space-heating demand ~32%
(calc 1833 vs lodged RHI 2694) and over-reading SAP +7 (81 vs 74).

Pins the fix: a mid-floor label + lodged exposed roof must expose the roof
(floor stays party). Also corrects the existing mid-floor fixture to lodge
the party-ceiling code 7 (the default 4 is an exposed pitched roof).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:59:31 +00:00
KhalimCK
6303343575
Merge pull request #1341 from Hestia-Homes/fix/coherent-gas-boiler-overlay
fix(overlay): coherent gas-boiler heating system on Landlord Override
2026-06-26 14:18:56 +01:00
Jun-te Kim
a680d65188 fix(overlay): synthesise a coherent gas-boiler heating system on Landlord Override
A Landlord heating-system override was applied as a sparse patch, so the
replaced system's fields bled through. A storage flat reclassified as a gas
combi (property 728513) kept mains_gas=False, heating category 7, the 2401
storage charge control, a Dual meter and an electric-immersion cylinder — an
incoherent record that gated out the gas-boiler-upgrade Measure and made the
heating Generator read the dwelling as off-gas (offering HHRSH storage).

Extend the ADR-0035 drag-along to gas boilers (Table 4b 102/104/120): the
overlay now sets the whole coherent companion set — mains_gas, gas main fuel,
heating category 2, fanned flue, full modern controls (2106), a single-rate
meter, and hot water from the main system with the cylinder set from the boiler
type (combi → none, regular/CPSU → cylinder). The main_fuel overlay also flips
mains_gas=True for a "mains gas" fuel. Non-off-peak archetypes now drag an
explicit Single meter so a system switch never leaves a stale Dual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:48:33 +00:00
Jun-te Kim
706e0072d0
Merge pull request #1337 from Hestia-Homes/fix/predicted-property-display-unknowns
fix(epc-prediction): populate Heating-Control + Ventilation display for predicted properties
2026-06-26 13:45:22 +01:00
Khalim Conn-Kowlessar
ac94a543b1 Set main_heating on the test_validation partial EPC builder 🟩
The heating-donor display synthesis reads donor.epc.main_heating, which has no
dataclass default — so a partial object.__new__ EpcPropertyData must set it.
test_validation's _comparable builder didn't, failing the two leave-one-out
scorer tests in CI (the full epc_prediction suite wasn't run pre-push).
main_heating_controls / sap_ventilation default to None via class attributes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:35:01 +00:00
Khalim Conn-Kowlessar
0a2ed67e94 Harden Dwelling-Roof Cap on real data: positional segments, ground-floor basis 🟩
Three corrections found by re-running property 742003 end-to-end:
- roofSegmentStats are POSITIONAL — real responses omit the segmentIndex field
  the fixture happened to carry; key the centre/area lookup by array position.
- Base the cap on ground_floor_area (the footprint the roof covers), not the
  greatest per-storey area; roof_area is the fallback.
- Clamp the basis by total_floor_area: predicted EPCs borrow the structural
  template's geometry (742003: a 118.62 m² MAIN ground floor) decoupled from
  the predicted 55 m² (ADR-0029), so without the clamp the cap reads the
  template's larger footprint.

Result: 742003 plan A/92.4 (16 kWp) -> C/74.4 (6.4 kWp). 29 solar tests +
orchestration threading + products green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:24:52 +00:00
Khalim Conn-Kowlessar
904c205e3a Dwelling-Roof Cap bounds the PV array to the dwelling's own roof 🟥
select_conservative_configs must accept the dwelling's roof area and cap panels
to its usable roof (ADR-0038) — bounding a 55m² dwelling to ~16 panels under
Google footprint conflation, while staying a no-op on correctly-matched homes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:15:28 +00:00
Khalim Conn-Kowlessar
cf2780ed77 SolarPotential carries panel dims + per-segment centre/area 🟥
The Dwelling-Roof Cap (ADR-0038) sizes by usable roof area and ranks segments
by distance from the dwelling, so the projection must carry each panel's
footprint and each segment's centre + area (from roofSegmentStats).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:12:12 +00:00
Khalim Conn-Kowlessar
80c5ad0c6c Predict ventilation kind from the cohort mode 🟥
Prediction never synthesises ventilation — it keeps the size-template's
sap_ventilation, so a predicted dwelling in an MEV/MVHR neighbourhood is scored
+ displayed as natural (predicted property 721167 follow-up). Mode the
mechanical_ventilation_kind across the cohort like glazing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:49:37 +00:00
Khalim Conn-Kowlessar
5cf5b67420 Carry donor's display heating + control into predicted EPC 🟥
_apply_heating_donor copies the donor's calc sap_heating but leaves the
display rows (main_heating, main_heating_controls) on the structural template
— incoherent, and 'Heating Control: Unknown' when the template lodged no
control (predicted property 721167, ADR-0029 follow-up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:37:36 +00:00
Daniel Roth
04b3cb240a
Merge pull request #1333 from Hestia-Homes/fix/baseline-downgrades
fix(epc-mapper): complete full-SAP 17.1 mapper so pre-SAP10 certs rebaseline (no more impossible downgrade)
2026-06-26 10:44:52 +01:00
Khalim Conn-Kowlessar
e405928516 Re-baseline prediction component-accuracy gate for full-SAP donors (ADR-0037)
Full-SAP certs mapped property_type=None, so the hard cohort filter silently
excluded them as comparables. Correctly typing them admits real lodged EPCs as
donors — a ground-truth-method change (cf #1245). Net over the n=36 fixture: 16
components better, 4 worse, 6 unchanged; gains concentrated in the physical
characteristics full-SAP certs measure (window_count 3.83->1.69, building_parts,
total_window_area, floor_construction, construction_age_band, glazing, walls).
The 4 that fell are new-build-vs-old-stock service mismatch on 1-2 targets each
(heating/water fuel, cylinder insulation) + floor_area. Tighten 16, loosen 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:34:27 +00:00
Khalim Conn-Kowlessar
f568ec00fc Full-SAP certs rebaseline off the lodged SAP-2012 value (downgrade fix) 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:43:31 +00:00
Daniel Roth
a19b3dada1 _secondary_heating_fraction_for_category returns 0.10 for category 11 (electric ceiling heating) 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 15:59:14 +00:00
Khalim Conn-Kowlessar
1da86a6366 Pin end-to-end off-peak day/night bill regression 🟩
An Economy-7 storage dwelling now prices heating at the 0.20-day/0.80-
night blend through cert -> calculator -> bill, instead of raising
UnpricedFuel and aborting the modelling_e2e batch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:05:33 +00:00
Khalim Conn-Kowlessar
81d5429b60 Source every end use's off-peak high-rate fraction from the cert 🟩
Surface the hot-water (Table 13 / HP-DHW), secondary (direct-acting),
main-2 and ALL_OTHER_USES High-Rate Fractions on CalculatorInputs from
the same Table 12a helpers the SAP cost path uses, so Bill Derivation's
day/night split matches the rating's exactly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:57:23 +00:00
Khalim Conn-Kowlessar
b58e27f46e Source the off-peak meter flag and main-heating fraction from the cert 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:52:39 +00:00
Khalim Conn-Kowlessar
3f5cd550cb Thread the off-peak meter flag and high-rate fractions onto SapResult 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:45:29 +00:00
Khalim Conn-Kowlessar
d3a4426ca4 Lock whole-meter off-peak routing and standard-meter no-op 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:39:58 +00:00
Khalim Conn-Kowlessar
05977ee3ce Route an off-peak meter's electric end uses to the day/night carrier 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:38:37 +00:00
Khalim Conn-Kowlessar
dc55d3b899 Price an off-peak meter line day/night by its high-rate fraction 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:24:50 +00:00
Khalim Conn-Kowlessar
bda8a3f276 Expose an off-peak meter's standing charge and full day/night blend range 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:14:51 +00:00
Khalim Conn-Kowlessar
4641717c90 Blend an off-peak meter's day/night rate by high-rate fraction 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:55:10 +00:00
Jun-te Kim
e2915d8042 Prefer a MAIN-bearing prediction template so EPC-less dwellings predict 🟩
A predicted EPC is seeded by deep-copying one representative neighbour's
structure. _template chose the member whose floor area was closest to the
cohort median, ignoring building-part labels. When that member's only part
was lodged with a null identifier (mapped to OTHER), the prediction had no
MAIN part and the modelling_e2e handler rejected it as "not predictable" —
discarding an otherwise-rich same-type cohort.

Restrict the template to MAIN-bearing members (median still over the whole
cohort); fall back to closest-on-size only when none are MAIN-bearing, so an
all-unlabelled cohort is left for the handler's MAIN-part guard to reject
rather than silently relabelling real data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:20:43 +00:00
Jun-te Kim
abb822c1c7 Prefer a MAIN-bearing prediction template so EPC-less dwellings predict 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:16:54 +00:00
Jun-te Kim
fc7cfc70e8
Merge pull request #1295 from Hestia-Homes/fix/fuel-code-39-electricity
Map fuel code 39 (electricity, any tariff) to standard electricity
2026-06-24 12:37:25 +01:00
Jun-te Kim
782c686969 Map fuel code 39 (electricity, any tariff) to standard electricity
modelling_e2e properties with main fuel 39 failed at the price boundary
(UnpricedFuelCode since #44fff767; previously mis-rated as non-electric →
the ~14-SAP over-rating flagged in earlier review).

Code 39 is SAP Table 12 "electricity, any tariff" (epc_codes.csv main_fuel 39 =
"electricity, unspecified tariff"; spec footnote (j): defines an electric system,
cost/CO2/PE = standard electricity). It was absent from API_FUEL_TO_TABLE_32, so
to_table_32_code(39) was None → is_electric_fuel_code(39) False and pricing
raised.

Fix: map API_FUEL_TO_TABLE_32[39] = 30 (standard electricity) — the canonical
place Khalim's fuel work added codes. One line makes classification, pricing,
CO2/PE and the billing carrier all agree (39 → 30 → ELECTRICITY).

Tests: to_table_32_code(39)==30, is_electric_fuel_code(39) True, price == standard
electricity, and the billing carrier resolves to ELECTRICITY. 0 corpus impact
(no lodged corpus cert uses 39); accuracy + mapper-corpus gates green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:20:32 +00:00
Khalim Conn-Kowlessar
f6ec96fbcd Merge remote-tracking branch 'origin/main' into feature/e2e-runs
# Conflicts:
#	domain/sap10_calculator/tables/table_12a.py
2026-06-24 11:07:42 +00:00
KhalimCK
f7f74ea72b
Merge pull request #1288 from Hestia-Homes/feature/per-cert-mapper-validation
Feature/per cert mapper validation
2026-06-24 12:04:00 +01:00
Jun-te Kim
46fc8f338c
Merge pull request #1289 from Hestia-Homes/fix/solar-missing-max-array-panels
Treat solar block without array sizing as no-solar (fix KeyError maxArrayPanelsCount, 2 e2e failures)
2026-06-24 11:59:57 +01:00
Jun-te Kim
41ebf01969
Merge pull request #1287 from Hestia-Homes/fix/dual-fuel-billing-carrier
Add dual-fuel (mineral+wood) billing carrier (fix UnmappedSapCode fuel_code 10, 10 e2e failures)
2026-06-24 11:49:18 +01:00
Khalim Conn-Kowlessar
db3bf00602 fix(synthesis): Coherent Heating System — synthesisers own the whole boundary
A dwelling's heating is one conceptual system, but its fields are scattered
across EpcPropertyData (a gov-API schema mirror): the cluster on sap_heating, the
electricity tariff on sap_energy_source.meter_type, hot-water flags loose at top
level. Three places synthesise a heating system — Measure Options, Landlord
Overrides, EPC Prediction's donor — and each hand-copied a different ad-hoc
subset. The override and donor both dropped meter_type, so an electric-storage
system landed on the template's single-rate meter and billed overnight heat at
the peak rate: property 713406 scored SAP 13 (G) vs ~50 (E), inflating the HHRSH
measure to +45.8 and overshooting the plan to band A.

Establish a single Coherent Heating System boundary (CONTEXT.md) that every
synthesiser must cover, with a source-appropriate fill policy (ADR-0035):

- Override overlay *completes* the partial system the landlord named. Companion
  fields are now DERIVED from the SAP code, not hand-attached per archetype: the
  off-peak meter from the calculator's single off-peak classification (new
  OFF_PEAK_IMPLYING_HEATING_CODES = SAP §12 Rules 1-2), and an unobserved storage
  charge control defaults to the conservative manual control (Table 4e 2401). So
  adding a heating archetype is just adding its code — companions can't be
  forgotten. A contract test guards it (every off-peak code drags a Dual meter).
- Prediction's heating donor now *carries* the donor's meter_type alongside its
  sap_heating cluster — the donor is already coherent.

Coherence is a synthesis-time obligation only; the calculator still scores a real
lodged cert exactly as lodged.

Verified on 713406: baseline 13 -> 47.8 (E), matching its recorded rating; the
phantom HHRSH recommendation is gone and the plan no longer overshoots to A.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:46:11 +00:00
Jun-te Kim
79f89d872e Treat solar block without array sizing as no-solar (fix KeyError)
2 modelling_e2e properties failed with KeyError: 'maxArrayPanelsCount'.

Google returns a `solarPotential` block with no array-level sizing fields
(`maxArrayPanelsCount` / `panelCapacityWatts`) for buildings with no usable
solar estimate. `SolarPotential.from_building_insights` hard-indexed those keys
and crashed the whole property.

Fix: the projection now returns Optional and yields None when those fields are
absent — the established "no solar potential" outcome (the orchestrator and
recommendation path already type it Optional and skip solar on None). Existing
callers (`_solar_potential_for`, harness) already assign to Optional.

Regression test + `assert is not None` narrowing on the valid-fixture tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:22:06 +00:00
Jun-te Kim
d97b8e87a4 Add dual-fuel (mineral+wood) billing carrier (fix UnmappedSapCode 10)
10 modelling_e2e properties failed with "unmapped SAP code in fuel_code: 10":
the billing layer (`sap_code_to_fuel`) had no carrier for Table-32 code 10
(dual fuel, mineral + wood) and raised rather than guess one.

SAP 10.2 treats dual fuel as its OWN fuel (its own Table-12 factors), so model
it as its own billing carrier rather than collapsing onto wood or coal:

- New `Fuel.DUAL_FUEL_MINERAL_AND_WOOD`.
- `_CODE_TO_FUEL[10]` -> that carrier.
- Fuel Rates snapshot prices it at 7.69 p/kWh — the midpoint of the COAL proxy
  (7.13) and WOOD_LOGS (8.25). This mirrors SAP's own construction: Table-32
  dual fuel (3.99) ~= midpoint of house coal (3.67) and wood logs (4.23).
  Marked `derived` with a documented _note/_gap/_assumption (like the COAL and
  HEAT_NETWORK proxies), since there is no retail blend price.

A dedicated carrier + rate (vs a one-line map to an existing carrier) keeps the
fuel identity faithful to SAP and avoids mispricing dual fuel as pure wood/coal.

Tests: code 10 -> DUAL_FUEL carrier; snapshot prices it at 7.69; grid-export
codes (36/60) still raise (the genuine no-carrier case).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:07:57 +00:00
Khalim Conn-Kowlessar
4e2f2bdcc7 test(worksheet): pin simulated case 50 — MVHR + dual-immersion all-electric
Adds the mapper-driven e2e cascade pin for "simulated case 50" (000565 semi,
electric storage main SAP 402 + portable electric secondary + MVHR + whc-903
DUAL electric immersion + 160 L cylinder, Economy-7). Routes the Summary PDF
through extractor + mapper + calculator like the other 000565 fixtures.

Locks in two off-peak fixes this case ground-truthed:
- the Table 13 HW high/low split applied to CO2/PE (commit 39ae2cf0), and
- the Table 12a Grid 2 MVHR fan fraction 0.71/0.58 (commit cd5113ab).

All 11 SAP-result fields reconcile to the U985 worksheet EXACTLY, including
the (272) rating CO2 2397.1237 — SAP 38.8426 (=39), cost £1317.0116, water
1668.0788 kWh, fans 315.6384 kWh.

Summary mirrored to the tracked fixtures dir so the test doesn't depend on
the unstaged `sap worksheets/` workspace.

pyright strict gate not run locally (pyright not installed in this container).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:07:16 +00:00
Khalim Conn-Kowlessar
39ae2cf0c2 fix(water-heating): split whc-903 immersion HW CO2/PE on off-peak tariffs
SAP 10.2 Table 12d/12e: electric water heating on a 7-/10-hour tariff bills
CO2/PE at the high-rate code (32/34) and low-rate code (31/33), kWh-weighted
by the Table 13 high-rate fraction. The cost path already applied this split;
the CO2/PE factors did not — they used the flat annual Table 12 figure
(0.136 CO2 / 1.501 PE) for ALL dual-rate electric HW.

That flat-annual behaviour (slice S0380.163) was validated only against
HW-from-main "low-rate cost" certs (100% low, no high-rate split). It is NOT
how Elmhurst bills a whc-903 ELECTRIC IMMERSION: the hand-built case-50
worksheet (000565 + dual immersion, 7-hour) splits HW CO2/PE into "high rate
cost" (CO2 0.1475 / PE 1.5514) + "low rate cost" (CO2 0.1238 / PE 1.4429)
weighted by the Table 13 fraction 0.1009. So flat-0.136 for immersion HW was
a spec gap on our side, not an Elmhurst divergence.

Fix: `_electric_immersion_hw_high_rate_fraction` threads the Table 13 fraction
(scoped to whc-903, 7-/10-hour, cylinder data present) into the HW CO2 + PE
factor helpers, which then blend the Table 12d/12e high/low codes. The flat
rule is unchanged for HW-from-main and 18-/24-hour (no Table 12d split), so
the S0380.163 41-variant cases and the existing pin are untouched.

Case 50: rating CO2 2413.48 -> 2397.1237 = Elmhurst EXACT; demand CO2 2007.1384
EXACT; demand PE +111 -> +32.5 residual (within corpus PE noise). Corpus
unchanged 73.3% / MAE 0.774 / CO2 0.08 / PE 3.4 (62 whc-903 off-peak certs;
aggregate gauges hold). SAP unaffected (cost-based).

Pin: test_whc903_immersion_hw_co2_pe_factors_split_high_low_on_off_peak; doc
updated in SAP_CALCULATOR.md §8.1.
pyright strict gate not run locally (pyright not installed in this container).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:05:21 +00:00
Khalim Conn-Kowlessar
f3e3494bf7 test(worksheet): pin simulated case 52 — regular gas boiler + cylinder
Adds the mapper-driven e2e cascade pin for "simulated case 52" (000565
semi + regular non-combi mains-gas boiler SAP 102 + 160 L foam cylinder
heated from the main, no cylinder stat, uninsulated primary pipework,
standard tariff). Routes the Summary PDF through extractor + mapper +
calculator like the other 000565 / 001431_case* fixtures.

This closes the last untested branch of the cylinder/water chain: the
SAP 10.2 §4 cylinder storage loss (Table 2/2a/2b lines 51-55) + the
Table 3 PRIMARY circuit loss (59, uninsulated pipework + no stat) that
combi/immersion fixtures don't reach. All 11 SAP-result fields reconcile
to the U985 worksheet EXACTLY with no calculator change — SAP 57.2904
(=57), cost £911.1973, water 3929.7635 kWh — confirming the cylinder-loss
derivation is correct.

Summary mirrored to the tracked fixtures dir so the test doesn't depend
on the unstaged `sap worksheets/` workspace.

pyright strict gate not run locally (pyright not installed in this container).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:50:19 +00:00
Khalim Conn-Kowlessar
80d617aa5b modelling: glazing overlay models draught-proofing + frame-factor re-lodge
Fitting sealed glazing units changes two things beyond the pane's U/g
that the cascade reads, which the overlay didn't model — leaving the
double/secondary before→after pins ~0.7 SAP short (xfail):

1. Draught-proofing (RdSAP 10 §8.1). Sealed units draught-proof the panes
   they replace, re-lodging the dwelling-level `percent_draughtproofed`
   (cert 001431: 84 → 100). The §2 cascade reads that dwelling-level
   value, so the overlay now carries it. `_recompute_percent_draughtproofed`
   anchors on the lodged before-% — `after = round((round(before%/100 × N)
   + flips) / N × 100)`, N = openable windows (vertical + roof) + doors,
   flips = upgraded panes that were not draught-proofed — so it's robust
   to incomplete window extraction (unchanged openings are already in the
   aggregate). ~0.3 SAP.

2. Frame factor (§6 solar gains). A replacement unit re-lodges its own
   FF=0.70, overriding the pane it replaced — the two "single glazing,
   known data" panes lodge FF 1.00 / 0.50 (one is 6.6 m²), so leaving them
   unchanged understated solar gains by ~+150 kWh space heating. `WindowOverlay`
   now carries `frame_factor`, written flat onto the window. ~0.4 SAP.

Wiring: `EpcSimulation.percent_draughtproofed` + `WindowOverlay.frame_factor`
new fields; `apply_simulations` / `_fold_window` write them; the glazing
generator computes both from the upgraded set and cert 001431's after.

Un-xfails `test_{double,secondary}_glazing_overlay_reproduces_the_relodged_after`
— both now pin SAP/CO2/PE to the relodged after within tolerance. Updates
the two `test_glazing_recommendation` overlay expectations for the new
`frame_factor`. 96 modelling tests pass; zero new pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:25:08 +00:00
Khalim Conn-Kowlessar
cd5113abf2 fix(tariff): include MVHR fan electricity in the off-peak Grid 2 fan split
SAP 10.2 Table 12a Grid 2 (PDF p.191) bills "Fans for mechanical ventilation
systems" at 0.71 (7-hour) / 0.58 (10-hour), distinct from "All other uses"
(0.90 / 0.80) which covers circulation pumps, flue fans and the solar HW pump.

The cost-split mech-vent kWh (`mev_kwh_for_cost_split`) only summed the
decentralised-MEV (230b) fans, not the (230a) MVHR fan electricity — even
though the total pumps/fans bucket adds both. So an MVHR dwelling on an
off-peak tariff billed its fan electricity at the 0.90/0.80 "all other uses"
rate instead of 0.71/0.58. The comment already said "MEV/MVHR-fan portion";
only the MEV term was wired when MVHR landed. Fixed to mirror both
mechanical-ventilation fan terms summed into the total.

Worksheet-proven on simulated case 50 (000565 semi + MVHR Vent Axia + dual
electric immersion, Unknown meter -> 7-hour via the §12 dual-immersion
trigger): the fan bucket (315.64 kWh, 100% MVHR per worksheet line 230a) was
billing at 14.311 p/kWh (0.90) vs Elmhurst's 12.451 p/kWh (0.71) — +£5.87/yr,
-0.23 SAP. After the fix our existing-dwelling rating reconciles to Elmhurst
EXACTLY: SAP value 38.8426 (=Elmhurst 38.8426 -> 39), total cost £1317.0116
(=Elmhurst £1317.0116 to the penny).

Same `mev_kwh_for_cost_split` feeds the CO2 + PE cascades, so all three split
consistently. 0 corpus impact (all 3 corpus MVHR certs are standard tariff);
gauge unchanged 73.3% / MAE 0.774 / CO2 0.08 / PE 3.4.

Pin: test_mvhr_fan_electricity_bills_at_grid2_fan_fraction_on_off_peak.
pyright strict gate not run locally (pyright not installed in this container).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:16:58 +00:00
Khalim Conn-Kowlessar
eea5d3a5a8 fix(tariff): map electric boilers 191/193-196 to their Table 12a Grid 1 rows
SAP 10.2 Table 4a electric boilers (PDF p.170) split across three distinct
Table 12a Grid 1 SH rows (PDF p.191), not one "direct-acting" family as the
stale TODO in `_table_12a_system_for_main` implied:

  - 191 Direct-acting electric boiler   -> "Direct-acting electric boiler (a)"
    row: 7-hour 0.90, 10-hour 0.50 (NOT the 1.00/0.50 "Other direct-acting
    electric heating" room-heater row).
  - 193/194/195/196 Electric dry core / water storage boiler -> "Electric dry
    core or water storage boiler" row: 7-hour 0.00 (charged wholly off-peak =
    100% low rate, identical to the None fallback).
  - 192 Electric CPSU -> Appendix F; left falling through to None (off-peak
    low) until the Appendix-F high-rate cascade is implemented.

The enum + fractions already existed in table_12a.py; only the code->enum
mapping was missing. Resolves the TODO and pins the spec-correct 0.00 for the
storage boilers so 195 can't be mis-"fixed" up to a direct-acting fraction.

Forward guard, 0 corpus impact: storage boilers already billed 100% low via
the None fallback, and all corpus 191 certs are on standard tariff (Table 12a
off-peak split never fires). Corpus gauge unchanged 73.3% / MAE 0.774.

Pin: test_electric_boilers_191_195_map_to_distinct_table_12a_grid1_rows.
pyright strict gate not run locally (pyright not installed in this container).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:48:59 +00:00
Khalim Conn-Kowlessar
9694650abe fix(water-heating): derive combi keep-hot from the PCDB record, default no-keep-hot
SAP 10.2 Table 3a (PDF p.160) additional combi loss (61)m. Two coupled
defects, both surfaced by simulated case 49 (000565 + gas combi, U985
"Combi keep hot type = None") sitting at SAP 71.43 vs the worksheet's 72:

1. The cascade defaulted EVERY non-PCDB combi to the flat keep-hot
   time-clock row (600 × n/365). A combi WITHOUT a keep-hot facility uses
   row 1 (600 × fu × n/365, fu = V_d/100 when daily HW < 100 L/day) —
   over-counting (61)m for the no-keep-hot cohort. `water_heating_from_
   cert` now defaults to the "without keep-hot" row.

2. `pcdb_combi_loss_override` returned None for keep_hot_facility=1/
   timer=1, leaning on the OLD flat-600 default. So flipping the default
   silently turned 190 corpus PCDB keep-hot-time-clock combis into
   no-keep-hot. Fixed to return the flat keep-hot row EXPLICITLY.

Key insight (the Summary is the input echo; the U985 keep-hot line is a
computed OUTPUT, so it must be derivable): keep-hot rides on the PCDB
boiler record (Table 105 keep_hot_facility/timer), resolved by
`pcdb_combi_loss_override`. A generic SAP-code combi with no PCDB record
(case 49, PCDF ref 0) has no keep-hot by construction → row 1. So the
default is not a guess — it is the spec-correct value for non-PCDB combis.

Worksheet-proven: case 49 → cost £726.696, SAP 72 — matching the
accredited worksheet to the digit (continuous 71.6945 = the worksheet's
own 71.6945). 000516 (keep-hot None) also exact (£860.716, SAP 63);
000490 (PCDB 10328, keep_hot_facility=1/timer=1) keeps its flat-600 via
the PCDB path. Masked until now because every prior combi-loss worksheet
fixture was keep-hot (000490/000474/000480 time-clock) or had V_d >= 100
every month (001431, rows coincide); case 49 is the first no-keep-hot one.

Corpus within-0.5 72.7% -> 73.3%, MAE 0.781 -> 0.774, PE 3.5 -> 3.4;
ratcheted _MAX_SAP_MAE 0.785 -> 0.775, _MAX_PE_PER_M2_MAE 3.6 -> 3.5.

Note: pyright strict type gate not run locally (pyright not installed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:22:02 +00:00
Khalim Conn-Kowlessar
7b30b464e5 feat(ventilation): credit MVHR (24a) heat recovery via PCDB Table 323 + 329
MVHR (24a) heat-recovery support, part 2: the mapper + cascade wiring.

Both source paths now resolve balanced whole-house MV with heat recovery
to the MVHR kind:
- gov-API: `_API_MECHANICAL_VENTILATION_TO_KIND` 4 → "MVHR" (was None /
  treated as natural — under-stated ventilation heat loss, over-rating).
- Elmhurst Summary: `_ELMHURST_MV_TYPE_TO_KIND` "Mechanical ventilation
  with heat recovery (MVHR)" → "MVHR" (was UnmappedElmhurstLabel, which
  blocked the whole Summary for MVHR dwellings).

cert_to_inputs resolves the in-use heat-recovery efficiency + SFP for an
MVHR cert (`_mvhr_system_values`): pick the PCDB Table 323 data point by
the lodged wet-room count (SAP 10.2 §2.6.4), multiply the raw efficiency
by the Table 329 ducts-inside-envelope in-use factor (0.90) and the raw
SFP by the per-duct-type factor (rigid 1.4), and feed:
- the §2.6.6 eq (2) effective-air-change credit (23c) → (24a)/(25)m;
- the (230a) fan electricity (in-use SFP × 1.22 × V), costed but NOT
  added to the Table 5a gains (its effect is in the efficiency).
An MVHR lodged with no PCDF index falls back to the SAP 10.2 Table 4g
default (raw efficiency 66% × 0.70, raw SFP 2.0 × 2.5).

Worksheet-proven on simulated case 49 (000565 semi + Vent Axia Sentinel
Kinetic B 500140 + gas combi → Elmhurst Current SAP 72): every MVHR line
matches Elmhurst exactly — (33) fabric heat loss 100.5923, (23c) in-use
efficiency 81.9% = 91 × 0.90, (25)m Jan 0.8571, (230a) fan electricity
415.9325, (231) total pumps/fans 501.9325. The residual SAP 71 vs 72 is
the known 000565-family space-heating-demand artifact (same -1/-2 seen on
cases 47/48), not the MVHR logic.

Corpus: within-0.5 72.6% -> 72.7%, MAE 0.788 -> 0.782, PE 3.6 -> 3.5.
The 3 gov-API MVHR certs: Flat 1 +6 -> 0 (Table 4g default path) and
12a Princes Gate +3 -> +1 (heat-recovery credit); Apartment 707 -4 -> -6
is a separate baseline under-rate (it under-rated as natural too — the
MVHR credit correctly adds ventilation loss per Elmhurst's method).
Ratcheted _MAX_SAP_MAE 0.79 -> 0.785, _MAX_PE_PER_M2_MAE 3.7 -> 3.6.

Note: pyright strict type gate not run locally (pyright not installed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:45:37 +00:00
Khalim Conn-Kowlessar
34cbd7d66c feat(pcdb): parse Table 323 (Centralised MEV / MVHR) + Table 329 efficiency IUF
MVHR (24a) heat-recovery support, part 1: the PCDB data layer.

PCDB Table 323 (PCDF Spec Rev 6b §A.18, Format 426; pcdb10.dat carries
Format 431, header `$323,431,...`) holds the per-wet-room SFP + heat-
exchanger efficiency for centralised MEV / MVHR units. Added
`MvhrRecord` / `MvhrDataPoint`, `parse_centralised_mv_row` /
`parse_table_323`, the ETL step, the committed jsonl, and the
`mvhr_record(pcdb_id)` runtime lookup (mirrors Table 322).

SAP 10.2 §2.6.4/§2.6.6: "MVHR ... SFP is a single value depending on the
number of wet rooms" — each test group's leading field is the wet-room
count; callers select the group matching the dwelling lodgement.
Worksheet-proven on simulated case 49 (000565, 2 wet rooms, Vent Axia
Sentinel Kinetic B 500140 → flow 21.0, SFP 0.88, efficiency 91%).

Also decoded the MVHR heat-recovery efficiency in-use factor from Table
329 (Format 432): system_type 3 ducts-inside-envelope = 0.90 (case-49
(23c) = 91 × 0.90 = 81.9%), cross-checked against system_type 10 = 0.70
(= SAP 10.2 Table 4g default heat-recovery in-use factor). "Table 4h is
no longer used – data now stored in the PCDB" (SAP 10.2 p.176).

The outside-envelope efficiency columns + with-scheme SFP blocks are
preserved verbatim in `raw` (no fixture exercises them yet).

Note: pyright strict type gate not run locally (pyright not installed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:29:17 +00:00
Jun-te Kim
9956df07ff Land an override on the EPC's part at its position when the label is absent 🟩
A Landlord Override's building_part is a positional index (0=main, 1=extension
1…, ADR-0004), but the gov-API EPC can label that slot differently (e.g. lodge
the 2nd part as 'other', not 'extension_1'). The previous fix skipped such
orphaned overrides, silently discarding the landlord's correction. Now the
override falls back onto the EPC's part at that position (via _resolve_part), so
the correction lands; only a position the EPC models no part at is skipped
(no geometry to model a wholly-absent part). Replaces the skip-only behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:09:11 +00:00
Jun-te Kim
8f0432721c test(accuracy): pin RdSAP-20.0.0 PV semi uprn_22086693 (PV-list fix corpus)
Corpus validation of the modelling_e2e photovoltaic_supply-as-list fix. Cert
6102-6227-8000-0083-2292 (RdSAP-20.0.0 semi, gas combi + 2× 1.14 kW PV arrays)
crashed from_rdsap_schema_20_0_0 on the measured-array list; the fix routes it
through the dict-tolerant _map_schema_21_pv. PV correctly credited: engine 61
(no PV) → 66 (+5). Built in Elmhurst (evidence: epc.json + summary + worksheet,
fabric+heating; the PV "New Technologies" Panel-details grid deferred): worksheet
55 = engine-on-Elmhurst-inputs 55 exactly → calculator faithful. The +6 engine-vs-
Elmhurst base-dwelling residual is the documented RdSAP-default gap (band-C cavity-
uninsulated suspended-floor semi). Pinned engine 66.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:13:34 +00:00
Jun-te Kim
539f661430 Skip a landlord override that targets a building part the EPC lacks 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:35:19 +00:00
Jun-te Kim
17b1d63f0e test(accuracy): pin SAP-16.0 storage-flat uprn_10070004512 (built_form fix corpus)
Corpus validation of the modelling_e2e built_form fix. Cert 8742-6624-9300-2780-4926
(SAP-Schema-16.0, ground-floor electric-storage-heater flat) omits built_form; the
mapper now derives it from dwelling_type. built_form is ML-only so the fix is
SAP-neutral: engine 66 = lodged 66 exactly. Built in Elmhurst (evidence: epc.json +
summary + worksheet): worksheet 54, engine-on-Elmhurst-inputs 53 ≈ 54 → calculator
faithful. The +12 engine-vs-Elmhurst is a build/input gap (cert size-1 small cylinder
unrepresentable in Elmhurst's Normal/110L-minimum entry → higher HW + reduced-field
16.0 defaults). Pinned engine 66.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:35:06 +00:00
Jun-te Kim
666cf30437 gated propertly 2026-06-23 13:16:34 +00:00
Khalim Conn-Kowlessar
4db05e843c fix(ventilation): dMEV takes lodged 0 intermittent fans, not the Table 5 default (SAP 10.2 §2)
Chasing the space-heating demand gap on "simulated case 48" (main 691 + Unknown
meter + 903 dual immersion): our SAP 55 vs Elmhurst 57. Every §10a cost line
already matched to the penny; the residual was demand — our space-heating
energy 3849.8 kWh vs Elmhurst 3513.8 (+9.6%). Traced through the worksheet: our
ventilation heat loss (38) ran ~35.5 W/K vs Elmhurst 27.76 — we were adding 20
m3/h of intermittent extract fans (the Table 5 age-band default) on a dwelling
with a decentralised mechanical extract (dMEV) system that lodges 0 fans.

SAP 10.2 §2 (PDF p.13): a whole-house mechanical EXTRACT system provides
extraction via the (23a) 0.5 system air-change rate; the lodged intermittent
extract-fan count (7a) is then explicit — a lodged 0 means 0 (the dMEV is the
ventilation), NOT "unknown". The Table 5 default is an unknown-fallback for
NATURALLY ventilated dwellings only, so it must not be substituted here.

Fix: for EXTRACT_OR_PIV_OUTSIDE, take vc.intermittent_fans as-is (no age-band
default). Worksheet-proven on two dMEV builds of cert 000565: "case 48" lodges
(7a)=0 -> our SAP 55 -> 57 EXACT; the original 000565 fixture lodges (7a)=2 and
keeps 2 (its e2e pins are unchanged). An earlier draft that forced fans=0 broke
000565 (which legitimately has 2) — corrected to "lodged as-is".

within-0.5 72.5% -> 72.6%, MAE 0.789 -> 0.788; CO2/PE unchanged. The fix also
reduces a systematic under-rating bias in the 21-cert dMEV cohort (median dSAP
-0.22 -> -0.08). Scoped to EXTRACT_OR_PIV_OUTSIDE; balanced MVHR/MV kinds left
untouched pending their own worksheet. SAP-schema regression
test_18_0_0 pin 80 -> 81 (closer to its lodged 84, same cause). Spec-pinned in
test_cert_to_inputs (dMEV-lodged-0 vs natural-default). pyright not installed
in this container -- strict type gate not run locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:33:07 +00:00