Commit graph

831 commits

Author SHA1 Message Date
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
Khalim Conn-Kowlessar
fc5f10ea92 fix(rating): floor the continuous SAP score at 1 (SAP 10.2 §13 / RdSAP 10 §13)
The SAP rating is spec-floored at 1 ("if the result of the calculation is
less than 1, the rating is 1"). `sap_rating_integer` already clamps, but the
continuous `sap_score_continuous` did not — so a degenerate dwelling could
emit a physically impossible negative SAP. Apply the same max(1, …) floor to
the continuous value (the un-rounded part is for sensitivity near real
ratings, not for negative ratings).

Removes a -12.3 accuracy outlier on the committed corpus (cert 422000111926,
lodged at the floor of 1, was computing -11.3): within-0.5 70.2% -> 70.3%,
MAE 0.845 -> 0.833. Ratcheted the corpus MAE ceiling to 0.84. Unit-pinned in
test_calculator.

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:03:13 +00:00
Jun-te Kim
7d70c1d298 Resolve a landlord from-main-gas water-heating override to its codes 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:03:03 +00:00
Jun-te Kim
8f7a344707 Resolve a landlord from-main-gas water-heating override to its codes 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:02:48 +00:00
Jun-te Kim
cb6a335382 Classify the landlord Age column into a construction-age-band category 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:44:40 +00:00
Jun-te Kim
406365753b Resolve a landlord age-band override onto the main building part 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:41:35 +00:00
Jun-te Kim
0135f0f27b Resolve a landlord age-band override onto the main building part 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:41:20 +00:00
Jun-te Kim
db95205fc5 Classify the landlord Glazing column into a glazing category 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:36:34 +00:00
Jun-te Kim
608e96bb09 Remap every window's glazing from a landlord glazing override 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:21:42 +00:00
Jun-te Kim
88a4badbf7 Decode single/double/triple glazing overrides to SAP codes 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:19:44 +00:00
Jun-te Kim
0fb86771dd Resolve a landlord double-glazing override to its glazing code 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:19:02 +00:00
Jun-te Kim
f1c6825cae Resolve a landlord double-glazing override to its glazing code 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:18:45 +00:00
Jun-te Kim
dbfe9820a5 Classify the landlord Main Fuel column into a fuel category 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:44:56 +00:00
Jun-te Kim
07aea7ef9b Map community mains gas to its distinct fuel code 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:23:06 +00:00