Commit graph

794 commits

Author SHA1 Message Date
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
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
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
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
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
b7455aabe5 fix(ventilation): MVHR takes lodged 0 intermittent fans, not the Table 5 default
Extends the dMEV intermittent-fan fix (4db05e84) to MVHR. A balanced
whole-house MVHR system IS the dwelling's ventilation, so the lodged (7a)
intermittent-extract-fan count is explicit — a lodged 0 means 0, not the
RdSAP 10 Table 5 age-band "unknown" default. The cascade was substituting
the default (here 20 m³/h) into worksheet line (8) openings, inflating
(16/18) infiltration → (21) → (22b) → (25) effective ach → (38)
ventilation heat loss → the space-heating demand.

Worksheet-proven on simulated case 49 (000565 + Vent Axia 500140 MVHR,
lodged (7a)=0): our (8) openings 0.0723 -> 0.0000, (18) 0.7223 -> 0.6500,
(25)m Jan 0.9423 -> 0.8571, all now matching Elmhurst exactly; space-
heating demand 7857 -> 7528 kWh (worksheet 7546). SAP 70.90 -> 71.43
continuous. (The residual to the worksheet's 72 is its own continuous SAP
71.69 rounding up, driven by a separate gas-combi water-heating-loss gap,
not ventilation.)

Scoped to EXTRACT_OR_PIV_OUTSIDE + MVHR only — MV-without-HR
(mechanical_ventilation=1) stays on the default-substitution path
(forcing its lodged 0 regressed 47 Howsman / 18 Jutland and is not
worksheet-validated). Corpus within-0.5 holds 72.7%, MAE 0.782 -> 0.781.

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 20:15:32 +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
23ccfb0d0b Skip a landlord override that targets a building part the EPC lacks 🟩
A Landlord Override can reference a building part the lodged or predicted EPC
never carried (e.g. an extension_1 override on a property whose EPC has only
main). apply_simulations indexed parts_by_id[identifier] unguarded, raising
KeyError and aborting the whole property's modelling. Now the orphaned part is
skipped. Recovers 14 of the 22 modelling_e2e failures in portfolio 796.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:36:37 +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
Khalim Conn-Kowlessar
fd0530159f feat(tariff): complete §12 Unknown-meter clause — GSHP/WSHP main triggers off-peak (RdSAP 10 PDF p.62)
Closes the follow-up from 22fe4f41. RdSAP 10 §12's third Unknown-meter
exception bullet — "main heating is ground source or water source heat pump" —
was unimplemented. Add `_GROUND_OR_WATER_SOURCE_HEAT_PUMP_CODES` (Table 4a,
SAP 10.2 PDF p.176-177: ground 211/215/221/225 + warm-air 521/525; water
213/216/223/226 + warm-air 523/526) to the Unknown-meter off-peak triggers;
once dual, Rules 1-4 (Rule 3) resolve it to 10-hour.

AIR-source heat pumps (214/217/224/227, 524/527) are deliberately EXCLUDED —
the spec names only ground/water source. Verified the only Unknown-meter heat
pumps in the corpus are "3/10 Bedford House" (main 214 = AIR source), which
correctly KEEP STANDARD. 0 corpus certs carry a GSHP/WSHP on an Unknown meter,
so this is a spec-completeness forward guard (gauge unchanged 72.5% / 0.789),
same family as the Scotland-J wall / rafters-M roof 0-impact spec fixes.
Coverage gap noted in-code: a database-index heat pump without a 211/213-style
SAP code can't have its source type read from the code alone (rare).

Spec-pinned (test_unknown_meter_ground_or_water_source_heat_pump_triggers_off_
peak). Gates green: corpus 72.5%/0.789, batch worksheet 0 raised/0 diverge,
suite 2989 passed (2 known pre-existing fails). pyright not installed locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:09:59 +00:00
Khalim Conn-Kowlessar
22fe4f41b8 fix(tariff): Unknown meter + dual electric immersion → off-peak per §12 (RdSAP 10 PDF p.62)
Supersedes the previous "verified non-fix" doc (3548f1f3): the spec DOES make
this a fix — Khalim was right that the Unknown-meter branch is driven by the
heating/water system, not a blanket STANDARD.

RdSAP 10 §12 (PDF p.62): "If the electricity meter is unknown, treat as single
meter EXCEPT where main heating OR WATER HEATING are intended to run off an
off-peak tariff (per systems listed in the text box above) ... If that results
in a dual meter, assign tariff per rules 1 to 4." The text-box off-peak systems
include DUAL ELECTRIC IMMERSION. Our `rdsap_tariff_for_cert` only triggered the
Unknown→off-peak exception on a storage/CPSU MAIN — it ignored the
dual-electric-immersion WATER-heating trigger, so an Unknown-meter dwelling
with a non-storage main (e.g. room heaters) + dual immersion was billed
STANDARD (13.19p flat) when it should be dual → Rules 1-4 on the main.

Fix: thread `water_is_off_peak_dual_immersion` (whc 903 + immersion lodged dual
via `_immersion_is_single is False`) into the Unknown-meter branch; when any
text-box trigger is present, resolve via the same Rules 1-4 dispatch (room
heaters → Rule 3 → 10-hour). Single-immersion / instantaneous (whc 909) certs
correctly stay STANDARD (no text-box system).

Worksheet-validated on "simulated case 48" (main 691 + Unknown meter + 903 dual
immersion): Elmhurst 10-Hour Off Peak, SAP 57; ours 45 → 55 (7-hour gives 45,
confirming 10-hour). Flips exactly ONE corpus cert — Apartment 241 (the genuine
-5.38 under-rater, main 691 + dual immersion) -5.38 → -1.05; every other
Unknown+dual-immersion cert already has a storage main (Rule 2). Corpus
within-0.5 holds 72.5%, MAE 0.793 → 0.789 (improved). CO2/PE unchanged.

GSHP/WSHP-main trigger (the other §12 Unknown exception bullet) is a separate
follow-up. Gates green: corpus 72.5%/0.789, batch worksheet 0 raised/0 diverge,
000565 e2e 11/11, suite 2987 passed (2 known pre-existing fails). 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 09:58:02 +00:00
Khalim Conn-Kowlessar
3548f1f31a docs(tariff): record Unknown-meter off-peak as a verified non-fix (RdSAP 10 §12 Rules, table_12a)
Khalim's hand-built "simulated case 48" Elmhurst worksheet (main 691 room
heaters + Unknown meter + 903 dual electric immersion + cylinder) proves
Elmhurst resolves an Unknown meter + Rule-3 room-heater main to 10-Hour Off
Peak — Elmhurst SAP 57; ours (STANDARD) 45; routing Unknown+Rule3 to off-peak
(10h) gives 55, 7h gives 45 (so Elmhurst uses 10-hour, RdSAP 10 §12 Rule 3,
PDF p.62). So the prior "Rule 3 is not off-peak evidence" comment was wrong
about Elmhurst's behaviour.

But adopting it REGRESSES the lodged-register corpus 72.5%->71.8% / MAE
0.793->0.827: of 11 Unknown+Rule-3 corpus certs only Apartment 241 improves
(-5.38->-1.05); the other 10 overshoot +2.7..+9.1 (Flat 2 +9.11). The
register's meter_type=3 certs were lodged with STANDARD-tariff costing — the
gov-API "Unknown" is lossy and does not mean off-peak. North star is
reproducing the lodged register, not Elmhurst's deliberate-Unknown worksheet,
so KEEP STANDARD (same "Elmhurst != noisy register" family as
roof-windows/shutters).

Comment-only; no behaviour change (corpus gauge unchanged 72.5% / 0.793).
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 09:48:21 +00:00
Khalim Conn-Kowlessar
2ac5ec6eb5 chore(tooling): add median column to corpus profiler; document shutter non-implementation
profile_corpus_error.py: print median signed error alongside the mean in each
feature bucket. The mean is dragged by fat-tail register anomalies (e.g. electric
room heaters mean -1.09 but median -0.01) — median is the outlier-resistant lens
for finding TRUE systematic slices, so hunt by |median|, not |signed|.

heat_transmission.py: document why permanent-shutter R is deliberately NOT applied
(Elmhurst uses R=0.04 curtains on every window incl. insulated shutters, proven on
case 46; API-path trial worsens MAE). Comment-only.

pyright not installed locally — strict type gate not run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:46:00 +00:00
Khalim Conn-Kowlessar
6a4539f26b fix(fuel): close case 47 — correct the second main heating system's fuel (off-peak pricing + Elmhurst Summary solid-fuel)
Two second-main fuel errors mis-cost a dual-main dwelling whose two main systems
burn different fuels (SAP 10.2 §10a worksheet (213) bills main 2 at its own fuel):

1. Off-peak/legacy scalar cost path (calculator.py + cert_to_inputs.py): main 2's
   kWh was priced at main 1's `space_heating_fuel_cost_gbp_per_kwh` scalar. Split
   main 1 vs main 2 and price main 2 at its OWN rate via the new
   `_main_2_space_heating_fuel_cost_gbp_per_kwh` (+ CalculatorInputs field).
   Scoped to a NON-electric second main (wood/oil/coal) — an electric second
   main keeps main 1's scalar (its off-peak Table 12a split is the deferred §10a
   slice; per-system splitting it regresses the off-peak electric cohort, certs
   13 Parkers Hill / 34 Dunley Road). 0 corpus impact (no corpus cert has a
   non-electric second main on an off-peak meter).

2. Elmhurst Summary mapper (mapper.py): when §14.1 omits the Fuel Type cell, a
   fuel-fired second main (room-heater SAP code) inherited main 1's fuel. Derive
   it from the SAP code's Table 4a category (solid 631-636 -> house coal, gas ->
   mains gas, liquid -> oil) before the main-1 inherit, mirroring
   `_elmhurst_secondary_fuel_from_sap_code` (same modal sub-fuel caveat). Boiler
   codes (<601) still inherit main 1 (case 6 oil rads+UFH).

simulated case 47 (electric room heaters + solid room heaters 633): our SAP
37.81 -> 55.09 vs Elmhurst current 57 (residual is the wood-vs-coal sub-fuel the
Summary export does not carry). Corpus unchanged 72.5% / MAE 0.793; batch 0
raised / 0 diverge; 000565 e2e green. (mapper.py also carries an earlier,
behaviour-free roof-window doc comment.) Spec-cited unit pins added (AAA).
pyright not installed locally — strict type gate not run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:46:00 +00:00
Khalim Conn-Kowlessar
44fff76722 fix(fuel): raise UnpricedFuelCode for unrecognised fuels instead of silently defaulting to mains gas
`table_32.unit_price_p_per_kwh` silently returned the mains-gas default
(3.48 p/kWh) for any fuel code it could not resolve to a Table 32 price or a
translatable gov-API enum. An unhandled fuel billed at the gas rate mis-costs
the dwelling (same failure mode as the dual-main wood-vs-electric over-cost).
Raise `UnpricedFuelCode` (new, mirrors MissingMainFuelType / UnmappedSapCode)
so the gap surfaces at the price boundary. `None` (no fuel lodged) still
defaults — callers resolve "no system" upstream.

0 corpus impact: all 1000 certs compute (every lodged fuel resolves), so this
is a forward guard against future/unmapped fuels. Unit pin added; existing
None-default test docstring tightened. pyright not installed locally — strict
type gate not run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:31:04 +00:00
Khalim Conn-Kowlessar
702150002f fix(fuel): cost main heating system 2 at its own fuel price, not main 1's (SAP 10.2 §10a worksheet line 213)
Main heating system 2's space-heating fuel cost (worksheet (213)) was billed
at main system 1's Table 32 unit price (`main_2_high_rate_gbp_per_kwh` reused
`main_1_high_rate_gbp_per_kwh`). For a dual-FUEL pair this grossly mis-costs the
second main: cert 10032957680 "Copse Cottage" (main 1 electric room heaters
fuel 30, main 2 wood logs fuel 6) charged its 9481 kWh of wood at 13.19 p/kWh
instead of 4.23 p/kWh — +£850/yr → SAP 21.75 vs lodged 45.

Route main 2 through its own fuel code (`_main_fuel_code(details[1])`), mirroring
the existing secondary-fuel handling. Copse Cottage 21.75 -> 45.94. Corpus
within-0.5 holds 72.5%, SAP MAE 0.815 -> 0.793 (ratcheted ceiling 0.82 -> 0.80);
CO2/PE unchanged. Same-fuel dual mains (gas+gas) unaffected. Off-peak-tariff
dual-fuel mains still defer to the legacy scalar path (separate slice).

Spec-cited unit pin added (AAA). pyright not installed locally — strict type
gate not run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:31:04 +00:00
Daniel Roth
25c695186b measured excluded plus get geospatial nearby stuff working 2026-06-22 16:12:16 +00:00
Daniel Roth
5ecb47d46c Merge branch 'main' into feature/trigger-e2e-lamnda 2026-06-22 14:55:16 +00:00
Jun-te Kim
9c89a0e680 neighbouring properties added 2026-06-22 14:38:00 +00:00
Daniel Roth
d05e5bd1f3 new application to trigger e2e for a single property and scenario 2026-06-22 12:54:53 +00:00
Jun-te Kim
2afa7acea4
Merge pull request #1251 from Hestia-Homes/feature/hyde_make_it_more_accurate_with_tests
Feature/hyde make it more accurate with tests
2026-06-22 10:04:28 +01:00
Khalim Conn-Kowlessar
a9632937d5 fix(ventilation): use lodged extract-fan count when known, not max(lodged, age default) (RdSAP 10 §4.1 Table 5, PDF p.28)
Table 5 reads "Number of extract fans if known; if number is unknown:
[age-band default]" — the default is an UNKNOWN-fallback, NOT a floor. The
cascade applied `max(lodged, table_5_default)`, flooring a genuinely-lodged
count up to the age-band minimum: e.g. an age H-M dwelling lodging 2 extract
fans was billed at the 6-8-room default of 3, over-counting ventilation line
(8) and the heat-loss coefficient. Fixed to `lodged if lodged > 0 else
default` (a lodged 0 is the Elmhurst/RdSAP "unknown" form → default; any
positive count is taken literally).

Surfaced by Khalim's Elmhurst stress worksheet (simulated case 46): this was
its last ventilation residual — our Jan effective ACH 9.14 -> 9.0748 (exact
match to the accredited worksheet), SAP 29 -> 30 = Elmhurst, cost £1496 vs
£1493. Corpus IMPROVED: within-0.5 71.6% -> 72.5%, MAE 0.819 -> 0.815 (the
max-flooring over-counted ventilation on every cert lodging fans below its
age default). Floor ratcheted 0.71 -> 0.72. pyright not installed locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:22:26 +00:00
Khalim Conn-Kowlessar
34e52a893c fix(heating): assume portable-electric secondary for unheated habitable rooms (SAP 10.2 Appendix A.2.2)
When the main heating system does not heat every habitable room (heated rooms
< habitable rooms), SAP 10.2 Appendix A.2.2 assumes the unheated rooms are
served by a portable-electric secondary heater, so the Table 11 secondary
fraction (0.10 for a boiler main) must be costed at the electricity tariff —
even when the cert lodges no explicit secondary system.

`_secondary_fraction` previously returned 0 unless a secondary was lodged or
the main was a forced-secondary electric-storage code, dropping the assumed
secondary and billing 100% of space heat to the (cheaper) main fuel — an
over-rate. Added an `unheated_habitable_rooms` trigger plus
`_has_unheated_habitable_rooms(epc)`, which prefers the lodged
`any_unheated_rooms` flag and guards the gov-API `heated_rooms_count == 0`
"not provided" sentinel. The secondary fuel/efficiency cascade already
defaults to portable electric (code 693) when no secondary code is lodged.

Worksheet-validated on simulated case 46 (heated 4 < habitable 7, no lodged
secondary): the assumed 10% electric secondary (2289 kWh, ~£260) lifted our
SAP 39 -> 29.35 vs accredited Elmhurst 30 (cost £1502 vs £1493, within 0.6%).

Corpus UNCHANGED (71.6% / MAE 0.819): all 17 corpus certs with heated <
habitable already lodge an explicit secondary description, so the gov-API
path was already costing it; this only adds the assumed secondary where none
is lodged (Elmhurst / reduced-field path). pyright not installed locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:15:37 +00:00
Khalim Conn-Kowlessar
d7a60efcdf fix(uvalues): thread glazing gap into pre-2002 window U fallback (RdSAP 10 Table 24, PDF p.50)
`u_window` hard-coded the 12 mm gap row for pre-2002 double/triple glazing
(double 2.8, triple 2.1), ignoring the lodged glazing gap. Table 24 splits
the pre-2002 rows by gap: double 6mm=3.1 / 12mm=2.8 / 16mm+=2.7; triple
6mm=2.4 / 12mm=2.1 / 16mm+=2.0 (PVC/wooden), with a metal-frame column
(+0.5/+0.5/+0.5 ish). Added a `glazing_gap` parameter + `_glazing_gap_row`
helper and wired `w.glazing_gap` through the synthesised-window caller in
heat_transmission.

Corpus impact nil by design: the gov-API mapper already resolves per-window
U gap-aware via `_API_GLAZING_TYPE_GAP_TO_TRANSMISSION` (e.g. code 3 + gap
"16+" → 2.7), so corpus certs use that lodged per-window U, not this fallback.
This aligns the reduced-field / worksheet fallback path with the mapper and
Table 24. Unknown gap still defaults to the 12 mm row.

(Metal frames are not distinguishable on the gov-API path — only a `pvc_frame`
boolean exists and Table 24 groups PVC+wooden — so the PVC/wooden U stands
there; the metal column applies only where frame material is lodged.)

Spec-pinned: pre-2002 double + triple gap-row tests. pyright not installed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:14:31 +00:00
Khalim Conn-Kowlessar
600684f5df fix(uvalues): apply metal-frame U 1.6 for 2022-or-later windows (RdSAP 10 Table 24, PDF p.51)
Table 24 "Double or triple glazed, 2022 or later" row gives U = 1.4 for
PVC/wooden frames and 1.6 for metal frames. `u_window` returned 1.4 for both,
ignoring the metal-frame variant and under-counting metal-frame heat loss on
post-2022 windows. The 2002-2021 and pre-2002 rows already split PVC/metal
correctly; this aligns the 2022+ row.

Spec-pinned in test_u_window_post_2022_metal_returns_table24_1_6_not_pvc_1_4.
pyright not installed in this container.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:09:21 +00:00
Khalim Conn-Kowlessar
b22b27c0ff fix(uvalues): correct rafter-roof age-M default U 0.18->0.15 (RdSAP 10 Table 18 col 2, PDF p.46)
A full row-by-row audit of the roof U-value tables (16, 17, 18) and floor
tables (19, 20) against the PDF found one numeric error: Table 18 column (2)
"Pitched, insulation at rafters" band M is 0.15 W/m²K (footnote (1) only — no
country variation; the whole M row converges to 0.15), but _ROOF_RAFTERS_BY_AGE
carried 0.18. The rafters column diverges above the joist column at H-L
(0.35/0.35/0.20/0.20/0.18) and rejoins it at M=0.15.

Everything else in the roof/floor tables is exact: Table 16 joist + rafter
thickness ladders, Table 17 room-in-roof (all 6 columns), Table 18 cols (1)
joists / (3) flat / (4) room-in-roof, Table 19 (England & Wales) floor
insulation defaults, and Table 20 (England) exposed-floor U-values.

Known remaining gaps (NOT fixed — zero England-corpus reach, would need a new
roof country-override mechanism): Scotland Table 18 footnote (2) K=0.20 (flat/
RR/thatch) and (3) joists-L=0.15; Scotland/NI Table 19 thicknesses; Scotland/
Wales Table 20 L/M overrides. Logged for a future country pass.

Corpus unchanged (band-M rafter roofs essentially absent from the 1000-cert
England corpus). pyright not installed in this container.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:57:29 +00:00
Khalim Conn-Kowlessar
381ecfda4c fix(uvalues): add missing Scotland band-J 0.30 wall override for all 7 as-built types (RdSAP 10 Table 7, PDF p.35)
A full code-vs-spec sweep of unknown-thickness as-built U-values across all
seven wall types found exactly one divergence: Scotland age band J. Table 7
(Scotland) gives 0.30 for every uninsulated wall type, but the England base
(Table 6) is 0.35 and the _COUNTRY_KLM_OVERRIDES[SCT] dicts listed H/K/L/M
while omitting J — so a Scotland band-J wall wrongly returned England's 0.35.

Added 'J': 0.30 to all 7 SCT as-built entries (granite, sandstone, solid
brick, cavity, timber frame, system built, cob).

The audit's companion finding — Scotland sandstone age E = 1.5 — needs NO
table override: the §5.6 stone branch intercepts all sandstone bands A-E and
already caps age E at 1.5 for Scotland (e_default in the prior commit), so a
table-E override would be dead code. England base tables and Isle of Man
(= England) are confirmed correct by the same sweep.

Corpus unchanged (England-only corpus has no Scotland band-J certs).
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-20 13:29:54 +00:00
Khalim Conn-Kowlessar
54ae05d04b fix(uvalues): default unknown-thickness stone to §3.5 Table 3 thickness, not flat 1.7 (RdSAP 10 Table 3, PDF p.20)
When a stone wall lodges no documentary thickness, RdSAP 10 §3.5 Table 3
supplies the default thickness to feed the §5.6 formula — it does NOT fall
to a flat 1.7. Table 3 stone row: A-D = 500 mm, E = 450, F-H = 420;
Scotland footnote (*) adds 200 mm for bands A,B and 100 mm otherwise.

This matches accredited Elmhurst: an England age-B granite/whinstone wall,
as built, unknown thickness defaults to 500 mm →
  U = 45.315 × 500^(-0.513) = 1.87   (sandstone → 1.68).
The previous flat-1.7 fallback was a setup error — Table 6 reads "According
to 5.6" for bands A-D with no 1.7 entry, so the formula (not 1.7) is the
spec target. The age-E footnote-(a) cap still applies on top.

Adds `_table_3_stone_thickness(band, country)`. Updated the stale
`..._returns_table_6_age_a_default` pin (was 1.7 → now 1.87 via Table-3)
and added sandstone (1.68) + Scotland-+200mm (700 mm → 1.39) pins.

Corpus: within-0.5 71.6% unchanged, SAP MAE 0.821 → 0.819, PE MAE 3.7 → 3.6.
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-20 13:26:36 +00:00
Khalim Conn-Kowlessar
7e187078b9 fix(uvalues): cap as-built stone U at age E only, not A-E; drop insulation-state gate (RdSAP 10 Tables 6-7 footnote a, PDF p.33-35)
Commit 034d4b7c corrected two stone cases but via the wrong mechanism: it
removed the 1.7 cap for ALL age bands and gated the §5.6 formula on
`wall_insulation_type is not None`. Both diverge from RdSAP 10 Tables 6-10:

- The stone rows read "According to 5.6" for bands A-D (uncapped formula)
  and "1.7 a" at band E, where footnote (a) = "Or from equations in 5.6 if
  the calculated U-value is less than 1.7" → U_E = min(formula, 1.7). The
  cap belongs ONLY at age E. Removing it for E let an as-built 50 mm granite
  age-E wall return the formula's 6.09 instead of 1.70.
- Scotland sandstone/limestone age E defaults to 1.5 (Table 7, PDF p.35);
  granite/whinstone stays 1.7.
- The insulation-state gate is not a spec rule. It sent age-A-D stone with
  "insulation Unknown" (wall_insulation_type None) to the flat-1.7 table
  instead of the §5.6 formula; it only "worked" for fixture 000565 Ext1
  because that wall is age E, where 1.7 is correct anyway. Removed: the
  age-E cap, not the gate, is what produces 000565 Ext1's worksheet 1.70.

Bands A-D stay uncapped (sandstone 400 mm → 1.90, granite 120 mm → 3.89).
Unknown-thickness handling unchanged in this commit (still flat-table; the
§3.5 Table-3 default follows separately).

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-20 13:24:31 +00:00
Jun-te Kim
a3e2566378 landlord override data added 2026-06-20 12:57:54 +00:00
Jun-te Kim
abd4bbc2d0 Merge branch 'feautre/hyde_upload_and_extend_landlord_overrides' into feature/hyde_make_it_more_accurate_with_tests 2026-06-20 07:26:35 +00:00
Jun-te Kim
3044c70202 sap score and elmhirst mapper optimsaiation 2026-06-20 07:25:42 +00:00
Jun-te Kim
3a0a122b7f Group landlord property-override enums under domain/epc/property_overrides 🟪
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:49:58 +00:00
Khalim Conn-Kowlessar
034d4b7cd5 fix(uvalues): bill known-insulation stone walls by the §5.6 thickness formula, uncapped (RdSAP 10 §5.6)
An uninsulated stone wall of lodged thickness, age bands A-E, is billed by
the RdSAP 10 §5.6 Table-12 formula on its measured thickness — sandstone /
limestone U = 54.876·W^-0.561, granite / whinstone U = 45.315·W^-0.513. Two
bugs suppressed it:

1. CAP: the as-built branch capped the formula result at the Table-6
   typical-thickness default (`if u0 >= 1.7: return 1.7`). But the formula
   only dips below 1.7 past ~488 mm (sandstone) / ~640 mm (granite), so the
   cap nullified §5.6 for essentially every real-thickness stone wall,
   under-counting fabric loss and over-rating. A measured 400 mm sandstone
   age-B wall is 1.90 (Elmhurst-confirmed), not 1.70.

2. NO INSULATION-STATE GATE: the branch fired for any stone wall with a
   lodged thickness, including ones whose insulation is "Unknown". RdSAP
   treats an unknown-insulation wall via the Table-6 typical-thickness
   default, NOT as bare stone of the lodged thickness — so a 50 mm granite
   wall with Unknown insulation must read 1.70 (worksheet), not the formula's
   6.09. Gated on `wall_insulation_type is not None`.

Fixes the 2 long-standing stone-U unit tests (granite 120 mm → 3.8871,
sandstone 120 mm → 3.7408 — they were correct red tests, not "known fails").
Corpus within-0.5 70.3% -> 71.6% (MAE 0.833 -> 0.822); ratcheted floors to
0.71 / 0.83. Worksheet fixture 000565 (granite 50 mm Unknown → 1.70) still
passes via the insulation gate. The two stone groups (sandstone/limestone vs
granite/whinstone) keep their distinct §5.6 coefficients.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:32:31 +00:00
Jun-te Kim
2dbbcf0c7b Decode old/slimline/convector storage-heater heating overrides 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:21:01 +00:00
Jun-te Kim
d8ab3f6ec7 Decode from-main oil/LPG/coal and gas boiler-circulator water heating 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:20:24 +00:00
Jun-te Kim
21bb55f414 Decode bottled/special LPG, community electric/biomass, dual-fuel, smokeless coal 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:18:55 +00:00
Jun-te Kim
cc228b3da5 Classify the landlord Hot Water and Heating columns into categories 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:09:05 +00:00
Jun-te Kim
10c6700366 Decode regular/CPSU/storage/direct-electric heating overrides 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:05:50 +00:00
Jun-te Kim
d6bde062ed Resolve a landlord gas-combi heating override to its SAP code 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:05:14 +00:00
Jun-te Kim
21afbefa9d Resolve a landlord gas-combi heating override to its SAP code 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:05:00 +00:00
Jun-te Kim
c6ab9ba383 Decode more water-heating system/fuel overrides to their codes 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:03:36 +00:00