Commit graph

476 commits

Author SHA1 Message Date
Jun-te Kim
e312aae95d Generalise to a 'broken schema type' gate keyed on assessment_type
Per review (Khalim): lift the full-SAP detection out of the SAP-Schema-16.x
branch into a single top-level gate in from_api_response, and key it on the
gov-API's own assessment_type declaration rather than the structural
sap_opening_types proxy.

- _is_full_sap_assessment(data): assessment_type == "SAP" — the authoritative
  SAP-vs-RdSAP classification. Verified to separate the entire fixture corpus:
  every full-SAP schema (SAP-Schema-17.x/18.x + the broken LIG 16.x) is "SAP";
  every reduced cert (RdSAP-Schema-* and reduced SAP-Schema-16.x, incl.
  sap_16_0.json) is "RdSAP". data_type / sap_opening_types agree but are derived
  shape artifacts; assessment_type is the meaning.
- A cert that is full-SAP by assessment_type but whose schema_type LABEL is not a
  recognised full-SAP schema is a *broken schema type* (label disagrees with the
  assessment). _record_broken_schema_type logs it — visible, not silently
  rerouted — so unreliable labels surface and coverage grows as new mislabels
  appear. Generalises beyond 16.x to any future mislabel.
- _from_full_sap maps it via the full-SAP 17.1 mapper (real measured openings,
  no defaulting; only `tenure` defaulted). Correctly-labelled full-SAP certs keep
  their dedicated branches (one-mapper-per-schema convention); reduced certs are
  unchanged.

Tests: broken cert routed AND recorded; correctly-labelled full-SAP not recorded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:39:16 +00:00
Jun-te Kim
5e0963593a Route full-SAP certs lodged under SAP-Schema-16.x to the full-SAP mapper
A full-SAP cert (assessment_type=SAP, the as-designed LIG-* new-builds) lodged
under a SAP-Schema-16.x version failed to map with:

  ValueError: RdSapSchema17_1: missing required field 'door_count'

— crashing the property's modelling_e2e subtask. 89 distinct properties in the
2026-06-24 run hit this; ~22 of every 30 sampled are this full-SAP shape.

Root cause: from_api_response dispatches on the schema_type STRING only, and the
SAP-Schema-16.x branch assumed a single shape — "reduced-field (RdSAP-shaped)" —
sending every 16.x cert through _normalize_sap_schema_16_x -> RdSapSchema17_1.
But the SAP-Schema-16.x name covers two structurally different certs:

  * reduced RdSAP (assessment_type=RdSAP): top-level door_count / glazed_area
    band / construction-code building parts.
  * full SAP    (assessment_type=SAP): measured shape — sap_opening_types +
    structured sap_building_parts carrying measured U-values and door/window
    OPENINGS, with NO top-level door_count (the door is an opening). These omit
    the reduced-only count fields, so the reduced normaliser failed loud on the
    first one it checks (door_count) — and 14 others behind it.

Force-fitting a full-SAP cert to the reduced RdSAP schema was the bug: the data
was never missing, it was being validated against the wrong schema.

Fix: discriminate on shape (_is_full_sap_cert: assessment_type=SAP AND
sap_opening_types present) and route full-SAP 16.x certs to the existing
full-SAP 17.1 mapper, which reads the real measured openings (the lodged
0.9x2.1m door -> door_count 1) — no reduced-field defaulting. The only field the
16.x full-SAP shape omits that SapSchema17_1 needs is `tenure` (register
metadata, no SAP effect), defaulted. Reduced 16.x certs (assessment_type=RdSAP,
no opening types) are untouched — all pinned reduced 16.x fixtures stay on the
RdSAP path.

Regression test pins a real full-SAP-16.0 cert (0293-...-8795, lodged 83) mapping
via the full-SAP path + the reduced 16.2 cert staying on the RdSAP path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:16:51 +00:00
Daniel Roth
40a079e464 21.0.1 mapper coalesces omitted led_fixed_lighting_bulbs_count to 0 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 13:19:10 +00:00
Daniel Roth
e906bbecf8 21.0.1 mapper coalesces omitted led_fixed_lighting_bulbs_count to 0 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 13:17:19 +00:00
Daniel Roth
71269637ef 21.0.1 mapper coalesces omitted cfl_fixed_lighting_bulbs_count to 0 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 13:16:29 +00:00
Daniel Roth
d36fa2b5cd 21.0.1 mapper coalesces omitted cfl_fixed_lighting_bulbs_count to 0 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 13:15:37 +00:00
Daniel Roth
a01a940df2 21.0.1 mapper coalesces omitted open_chimneys_count to 0 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 13:14:36 +00:00
Daniel Roth
6efbd4649d 21.0.1 mapper coalesces omitted open_chimneys_count to 0 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 13:13:43 +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
e9a8ad9cd9
Merge pull request #1290 from Hestia-Homes/fix/building-part-int-identifier
Tolerate non-string building-part identifier (fix TypeError, cohort cert)
2026-06-24 11:59:31 +01:00
Jun-te Kim
170fd1bf71
Merge pull request #1291 from Hestia-Homes/fix/full-sap-has-hot-water-cylinder
Fall back to nested has_hot_water_cylinder for full-SAP certs (cohort cert)
2026-06-24 11:59:03 +01:00
Jun-te Kim
e4a79648ab
Merge pull request #1292 from Hestia-Homes/fix/sap-opening-type-6
Map full-SAP opening-type 6 (rooflight) as a roof window (5 cohort certs)
2026-06-24 11:58:53 +01:00
Jun-te Kim
789103af27 Map full-SAP opening-type 6 (rooflight) as a roof window
5 modelling_e2e cohort certs (full-SAP 19.1.0) were skipped with
"unmapped API sap_opening_type code: 6". Code 6 is "Ext Rooflight"; SAP 10.2
treats roof windows and rooflights as the same inclined-glazing family.

Fix: add 6 to the known opening-type taxonomy and route it onto the roof-window
path (`sap_roof_windows`) alongside code 5, via a `_SAP_ROOF_WINDOW_TYPES`
{5, 6} set. Genuinely-unknown codes (e.g. 99) still raise.

Cert 9878-3908-6309-6714-8200 now maps + calculates (sap 81). Regression test:
a type-6 opening maps onto sap_roof_windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:47:33 +00:00
Jun-te Kim
18063a2d7f Fall back to nested has_hot_water_cylinder for full-SAP certs
A SAP-Schema-17.0 full-SAP cohort cert (8265-7433-3220-9736-7902) omits the
top-level `has_hot_water_cylinder` and lodges it only under `sap_heating`. The
required top-level field on SapSchema17_1 made `from_dict` raise and skip the
cert.

Fix: make the top-level field Optional, and have the full-SAP mapper
(`from_sap_schema_17_1`, also used by 17.0/18.0.0 via delegation) fall back to
`sap_heating.has_hot_water_cylinder`. RdSAP mappers are unchanged (they keep the
top-level required field; their SapHeating has no such attribute).

Cert now maps + calculates (sap 76). Regression tests for the nested true/false
fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:43:18 +00:00
Jun-te Kim
6d98ecf375 Tolerate non-string building-part identifier (fix TypeError)
A SAP-16.x cohort cert (9258-4062-7265-2844-7954) lodges a bare int building-
part identifier (the second part as `1` after "Main Dwelling").
`BuildingPartIdentifier.from_api_string` regex-matched it assuming a string and
raised "TypeError: expected string or bytes-like object, got 'int'", failing the
whole property.

Fix: guard the match on `isinstance(api_identifier, str)` so a non-string
identifier falls to OTHER, matching the documented "anything unrecognised ->
OTHER" contract. The baseline SAP fabric sums all building parts regardless of
identifier, so OTHER is SAP-neutral; the identifier only labels parts for
measure targeting. Fixes every mapper (all route through from_api_string).

Cert now maps + calculates (sap 63). Regression test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:33:23 +00:00
Jun-te Kim
eefeccf0bc
Merge pull request #1283 from Hestia-Homes/fix/rdsap-21-0-0-cohort-cert-skips
Align RdSAP 21.0.0 schema optionality with 21.0.1 (unblock ~35 cohort certs)
2026-06-24 09:43:17 +01:00
Jun-te Kim
7489e5ac89 Scope 21.0.0 widening to fields the skipped certs actually omit
Review follow-up (Khalim): the first pass made far more optional than needed —
notably the whole SapBuildingPart block — and a buggy 21.0.0↔21.0.1 diff also
MISSED open_chimneys_count / cfl_/led_fixed_lighting_bulbs_count / suggested_
improvements, so the original change actually mapped only 3 of the 33 skipped
certs (the rest still failed on open_chimneys_count).

Re-derived the exact set empirically from all 33 skipped cohort certs:
widen only fields that are (a) required in 21.0.0, (b) already optional in
21.0.1, AND (c) genuinely omitted by ≥1 of those certs. Result:

- KEEP optional: the 4 SapWindow refinements, the top-level vent/lighting/
  door/pressure-test block (incl. the 3 previously-missed fields), 2
  SapEnergySource fields, Addendum.addendum_numbers, PhotovoltaicSupply.
  none_or_no_details, and exactly ONE building-part field
  (SapBuildingPart.roof_insulation_thickness — omitted by 7 certs).
- REVERT to required: the other 12 SapBuildingPart fields (construction_age_
  band, wall_construction, …), MainHeatingDetail.emitter_temperature,
  PvBatteries.pv_battery, ShowerOutlets.shower_outlet — none of the 33 certs
  omit these, so they stay strict.

Mapper: coalesce the count fields (wet_rooms_count, open_chimneys_count,
cfl_/led_fixed_lighting_bulbs_count) to 0 like every other mapper, so the now-
optional values can't reach a NOT-NULL column (also drops 4 pyright ignores).

Now maps 32/33 (up from 3); the last cert hits a pre-existing pv_batteries-
shape AttributeError and degrades via the ADR-0031 skip path. pyright net
unchanged (43, no new errors); regression test rewritten to the real omitted set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:35:36 +00:00
Jun-te Kim
4fe59b8e78 Default RdSAP 21.0.1 wet_rooms_count to 0 (fix NOT-NULL violation)
37 modelling_e2e properties failed on the 2026-06-23 run with
`NotNullViolation: null value in column "wet_rooms_count" of relation
"epc_property"`.

Root cause: 21.0.1 lodges `wet_rooms_count` as Optional, and
`from_rdsap_schema_21_0_1` passed it straight through
(`wet_rooms_count=schema.wet_rooms_count`). A cert omitting it mapped to
`EpcPropertyData.wet_rooms_count=None`. When a predicted EPC (which deep-copies
a comparable template's EpcPropertyData) inherited that None and was persisted,
it violated the `epc_property.wet_rooms_count` NOT-NULL column — and the calc's
`wet_rooms_count > 0` check would also raise `TypeError` on None.

Fix: coalesce to 0, matching every other mapper (RdSAP "not lodged" → the
calc's minimum 1 wet room). Regression test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:42:34 +00:00
Jun-te Kim
44991bed0f Align RdSAP 21.0.0 schema optionality with 21.0.1 (unblock cohort certs)
The modelling_e2e cohort skipped ~35 real RdSAP-Schema-21.0.0 certs with
"SapWindow: missing required field 'frame_factor'" (and a cascade of
wet_rooms_count, the mechanical-vent duct block, wind_turbine_details, &c.).

These are complete certs — the `RdSapSchema21_0_0` dataclass was simply
modelled stricter than `RdSapSchema21_0_1`, which already treats every one of
these fields as optional. This aligns 21.0.0's optionality with 21.0.1 (the
proven path) so the certs map into the prediction donor pool instead of being
dropped:

- SapWindow: pvc_frame / glazing_gap / frame_factor / window_transmission_details
  → Optional (calc already falls back: Table 6c frame factor, SAP10 glazing
  lookup, type-only U).
- 20 nested fields across Addendum / MainHeatingDetail / PhotovoltaicSupply /
  PvBatteries / SapBuildingPart / SapEnergySource → Optional, mirroring 21.0.1
  verbatim (classes made kw_only so defaults sit in place; parsed only via
  from_dict, keyword construction).
- from_rdsap_schema_21_0_0: guard the window_transmission_details dereference
  for None (mirrors the 21.0.1 `_api_sap_window` guard).

Existing 21.0.0 certs are unaffected (relaxing optionality does not change
parsing of certs that carry the fields); mapper-corpus + accuracy gates green.
Verified end-to-end against real cohort cert 2205-3036-3484-0400-5718:
maps + calculates (SAP 68). Regression test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:27:51 +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
Jun-te Kim
6ee2f6257a fix(mapper): close modelling_e2e cohort mapping gaps (built_form, PV-list, windows-dict)
Closes the mapper-coverage gaps surfaced by the modelling_e2e prediction-cohort
failures (portfolio 796):

- built_form (SAP-16.0): derive from dwelling_type in _normalize_sap_schema_16_x
  (Mid-terrace->4, End-terrace->3, Semi-detached->2, Detached->1; flats->modal 4).
  ML-only field (SAP calc never reads it) so SAP- and gate-neutral. 5 flat certs
  that omitted built_form now map.
- photovoltaic_supply as a measured-array LIST: routed all pre-21 RdSAP mappers
  (17.0/17.1/18.0/19.0/20.0.0) through _map_schema_21_pv, whose list branch is now
  dict-tolerant (_pv_array_field reads dict OR dataclass). They capture the PV
  arrays like 21.0.x instead of raising "'list' object has no attribute
  none_or_no_details" and sinking the whole cohort.
- windows-as-dict (16.x): handled in the normalizer (not just windows-as-list).

Genuinely-sparse certs (omit door_count/habitable/glazed_area) remain fail-loud;
the gate-regressing multiple_glazed_proportion default and the recursive
RdSAP-21.0.0 ADR-0028 alignment are left fail-loud + flagged for review (worklist).

+5 regression tests; component-accuracy gate 26/26; 0 new pyright errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:51:40 +00:00
Jun-te Kim
2b0cb180bf update air permebility 2026-06-23 13:21:31 +00:00
Jun-te Kim
666cf30437 gated propertly 2026-06-23 13:16:34 +00:00
Jun-te Kim
00af7b5a54 data types 2026-06-23 12:42:53 +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
Jun-te Kim
210ca6397f updated sap scaema to take in inputs 2026-06-23 10:22:40 +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
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
d05fdbe6f2 fix(mapper): map truncated Elmhurst glazing label "Double between 2002" (RdSAP 10 Table 24 code 3)
The full RdSAP-Schema-21 label is "Double between 2002 and 2021" (double
glazing installed 2002-2021, SAP 10.2 Table 24 code 3). When the Elmhurst
Summary PDF wraps the trailing "and 2021" into an adjacent table cell that
the extractor joins away, the surfaced label truncates to "Double between
2002" — the same artifact already handled for "Triple post or during".
`_elmhurst_glazing_type_code` raised UnmappedElmhurstLabel on it, blocking
the whole Summary (surfaced on the simulated-case-46 multi-attribute
worksheet). Added the truncated form as a code-3 alias.

pyright not installed in this container.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:15:25 +00:00
Jun-te Kim
e6a829aaea more examples 2026-06-19 09:51:49 +00:00
Jun-te Kim
d87718f316 Merge remote-tracking branch 'origin/main' into feature/hyde_make_it_more_accurate_with_tests
# Conflicts:
#	datatypes/epc/domain/mapper.py
2026-06-17 09:05:37 +00:00
Jun-te Kim
74c1aff530
Merge pull request #1245 from Hestia-Homes/feature/per-cert-mapper-validation
Feature/per cert mapper validation
2026-06-17 09:49:36 +01:00
Khalim Conn-Kowlessar
edf1003dcf fix(epc): hydrate recorded performance, RHI, and dates on read
The Baseline stage is the first consumer to read these off a persisted EPC
end-to-end, surfacing three gaps that only manifest on real API data:

- Only the 21.0.1 mapper copied through the recorded current-performance
  scalars (SAP rating, CO2, PEUI) and *no* mapper mapped the EPC band, so
  Lodged Performance raised for 17.x/18.0/19.0/20.0.0 certs. Overlay all four
  from the raw payload in `from_api_response`, once, for every schema version.
- Likewise the `renewable_heat_incentive` block (baseline space/water-heating
  kWh) was only mapped by the 21.x paths. Gap-fill it centrally from the raw
  payload when a mapper left it unset.
- The FE-owned `epc_property` date columns are Postgres `timestamp`s while the
  SQLModel mirror types them `str`, so a read hands back a `datetime` and
  `date.fromisoformat()` raised. Normalise via `_as_date()`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 01:45:18 +00:00
Khalim Conn-Kowlessar
d501535cbc fix(mapper): map dropped §6.1 non-separated conservatory (API)
The gov API lodges a NON-SEPARATED conservatory (conservatory_type=4) as a
glazed "building part" carrying only {floor_area, room_height,
double_glazed, glazed_perimeter} — no fabric, no floor dimensions. The
four fields were undeclared on the 21.0.1 SapBuildingPart, so `from_dict`
dropped them and the conservatory was silently lost: it billed no §6.1
window/rooflight/floor and added nothing to TFA (5 corpus certs over-rated
— too little heat loss → SAP too high).

Fix (21.0.1 schema + mapper):
- declare the four glazed fields on `SapBuildingPart`;
- `_api_sap_conservatory` builds `EpcPropertyData.sap_conservatory` from
  the glazed BP (identified by a lodged `glazed_perimeter`; only type-4
  conservatories lodge it — separated ones, §6.2, lodge nothing);
- exclude the glazed BP from the fabric building-part loop (it is billed
  by the §6.1 cascade, not as a dwelling part);
- `_total_floor_area_from_building_parts` adds the conservatory floor area
  to TFA (drives occupancy → §4/§5 demand).

Validation is cross-mapper parity, NOT a corpus back-solve: the API mapper
feeds the SAME worksheet-validated §6.1 cascade (`conservatory_geometry`,
pinned to 1e-4 against the case-44 Summary) as the Elmhurst path — so the
API conservatory fabric is correct by construction. `from_api_response`
on an injected type-4 cert reproduces the glazed wall (perimeter × ground-
floor room height = 22.05), glazed roof (floor/cos20 = 12.77) and Table 25
double U_eff (2.758 wall / 2.993 roof); a separated (type 2/3) cert lodges
no glazed BP → disregarded per §6.2.

Gauges: corpus within-0.5 67.9% → 68.6% (MAE 0.959 → 0.942; floor 0.67→0.68,
ceiling 0.97→0.95); /tmp eval mean|err| 0.822 → 0.817. Harness 47/47
0-raised; regression = the 3 pre-existing fails; pyright net-zero (65=65).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 23:37:25 +00:00
Khalim Conn-Kowlessar
2a4d67e396 feat(conservatory): §6.1 solar gains + TFA-occupancy (demand-side)
Close the §6.1 conservatory demand cascade per RdSAP 10 §6.1 + Table 25.

Solar gains (§6, solar_gains.py) — Table 25 note (PDF p.51): "The
orientation of windows in a conservatory is not recorded, thus solar
gains are calculated using the default solar flux (East/West orientation,
with 20° pitch for roof windows)." The glazed wall bills onto the (76)
East line (vertical, average-overshading Z); the glazed roof onto the
(82) roof-window line (20° pitch, Z=1.0), both at Table 25 g=0.76, FF=0.70.

TFA-occupancy (mapper) — §6.1: the conservatory floor area is added to the
dwelling total floor area. TFA drives occupancy → §5 internal gains + §4
hot-water demand, so the non-separated conservatory's floor area now
enters `EpcPropertyData.total_floor_area_m2` (the worksheet's (4) = 95.38
carries it). Separated conservatories (§6.2) stay excluded.

Pinned against the case-44 P960 demand cascade at abs=1e-4: (73) internal
gains 625.1759, (83) solar gains 495.8655, (95) useful gains 1079.6510,
(99) space heating per m² 89.8073 — the full §6.1 chain reproduces EXACTLY.

The whole-dwelling SAP (72.9517) / CO2 (3241.8656) are not pinned: the
case-44 Summary omits the House-Coal secondary heater (SAP 633) the P960
descriptor carries (cf. case 43), so the cascade computes no secondary —
the entire residual (+349.77 kg CO2). A Summary-input defect, independent
of §6.1; every conservatory-affected line ref is exact. Worksheet harness
stays 47/47 0-raised; corpus unchanged (API path; mirror is the next slice).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 23:21:08 +00:00
Khalim Conn-Kowlessar
fe3bf4eaed fix(ventilation): read Blower Door AP50 pressure test (Summary)
SAP 10.2 §2 (17)-(18): a measured/design air permeability at 50 Pa from a
Blower Door test routes infiltration via `(18) = AP50/20 + (8)`, in
preference to the components-based (16) estimate. The Elmhurst extractor
read only the AP4 ("Pulse") column of §12.2, so a Blower Door result
(§12.2 "Pressure Test Result (AP50)") fell through to the structural-
infiltration default — over-counting ventilation heat loss.

Surfaced by simulated case 44 (AP50 4.50): effective air change rate was
0.81 vs the worksheet's 0.58 (+38% ventilation loss). The cascade already
supports `air_permeability_ap50` (preferred over AP4); this wires the read
end to end (extractor → ElmhurstSiteNotes → SapVentilation → cert_to_inputs).

Pinned against the case-44 P960 §2 at abs=1e-4: (18) infiltration 0.3417
(= 4.5/20 + 0.1167) and (25) Jan effective ach 0.5812. Worksheet harness
stays 47/47 0-raised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 23:18:17 +00:00
Jun-te Kim
05e3a25d25 Sanitise millimetre-lodged full-SAP opening dimensions to metres
Some SAP-Schema-17.x/18.0.0 certs lodge sap_openings width/height in MILLIMETRES
mixed with metre rows in the same array (e.g. a 2025x2100 mm window beside a
3.06x1 m one). The 17.1 mapper read them all as metres → a 4.25M m2 window →
HTC in the millions → SAP clamped to 1.

Fix (TDD, datatypes/epc/domain/mapper.py): _sanitise_opening_dimension_m treats
any dimension > 50 m as mm and divides by 1000; _sap_opening_area_m2 applies it
to areas. Wired into the window, roof-window, and door-area-weighting paths.
The 3 broken certs (uprn_10093117227 / 10090317693 / 10091636031) now score
90 / 81 / 79 instead of 1.

3 RED->GREEN slices + refactor; new test class
TestFromSapSchema17_1OpeningUnitSanitisation + sap_17_1_mm_openings.json fixture;
0 new pyright errors; no regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:16:27 +00:00
Jun-te Kim
d41798f1c1 Add SAP-18.0.0 + SAP-16.0 schema coverage; autonomous-run triage findings
Schema coverage (datatypes/epc/domain/mapper.py):
- SAP-Schema-18.0.0: full-SAP shape ≡ 17.1 → from_sap_schema_17_1, no normalisation.
- SAP-Schema-16.0: same reduced-field 16.x path; default the omitted `tenure`
  field in _normalize_sap_schema_16_x (metadata; SAP cascade never reads it).
  Genuinely sparse 16.x certs (missing core fabric fields) still fail loud.
- Regression tests + sap_18_0_0.json / sap_16_0.json fixtures; 0 new pyright errors.

Autonomous triage of the worklist (scripts/hyde/autonomous_run_findings.md):
- Found + diagnosed 2 bugs (flagged, NOT fixed): (1) MAPPER — full-SAP openings
  lodged in mm read as m → multi-million-m2 windows → SAP clamps to 1 (uprn_
  10093117227 / 10090317693 / 10091636031); (2) CALCULATOR — database heat-pump
  fuel code 39 mis-priced as gas, over-rates ~14 (uprn_10093114053).
- Most certs map within +/-4 of lodged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:04:43 +00:00
Jun-te Kim
0b32d9fcee Add SAP-16.3 + SAP-17.0 schema coverage (completes the e2e UPRN set)
- SAP-Schema-16.3: same reduced-field RdSAP shape as 16.2 — generalise the
  normaliser to _normalize_sap_schema_16_x and route both 16.2/16.3 through it.
  uprn_44012843 maps → SAP 79 (lodged 81).
- SAP-Schema-17.0: structurally identical to the full-SAP 17.1 schema (measured
  sap_opening_types), so it parses with the 17.1 dataclass and reuses
  from_sap_schema_17_1 with no normalisation. uprn_10023444324 → 80, uprn_
  10023444320 → 81.
- Regression tests (16.3 dispatch, 17.0 dispatch) + sap_16_3.json / sap_17_0.json
  fixtures; 0 new pyright errors. All 7 e2e UPRNs now map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:24:50 +00:00
Jun-te Kim
4d14607e7e Add SAP-16.2 schema coverage + single-glazing fix; flat party-wall fix; pin 2 certs
SAP-Schema-16.2 (datatypes/epc/domain/mapper.py):
- 16.2 is structurally an RdSAP-17.1 cert under a different name; add
  _normalize_sap_schema_16_2 (field renames + defaults) and dispatch to the
  tested from_rdsap_schema_17_1 mapper. uprn_100020933699 maps → SAP 71.
- Honour a "Single glazed" windows description when multiple_glazing_type="ND"
  (was defaulting to double) → RdSAP-21 code 5; eng 72→71 (lodged 70).
- 4 regression tests + sap_16_2.json fixture; 0 new pyright errors.

Flat party-wall fix (domain/sap10_calculator/worksheet/heat_transmission.py):
- Full-SAP flats carry flatness in dwelling_type, not property_type, so the
  party-wall default fell through to the 0.25 house value instead of the RdSAP
  Table-15 flat 0.0. Add _is_flat_or_maisonette_dwelling fallback + regression
  test. uprn_10093116529 80→81 (matches the cert's lodged party u_value 0).

Accuracy corpus pins (tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py):
- uprn_10093116543 (SAP-17.1 gas-combi semi): engine 81 (Elmhurst 77; documented
  full-SAP→RdSAP residual — measured wall/floor U + PCDB boiler vs RdSAP defaults).
- uprn_10093116529 (SAP-17.1 g/f flat): engine 81 (Elmhurst 78).

devcontainer: add poppler-utils (pdfinfo) for the documents-parser PDF fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:53:00 +00:00
Khalim Conn-Kowlessar
fa131cca0b feat(conservatory): read §6.1 geometry through extractor + mapper
RdSAP 10 §6.1 (PDF p.49) models a non-separated (heated) conservatory as
part of the dwelling. Until now the Summary §5 block was reduced to an
inert `has_conservatory` bool and the geometry (floor area, glazed
perimeter, glazing, storey height) was dropped on both paths.

Plumbing only — no cascade consumer yet (Slices B/C/D wire §3/§6):
- ElmhurstSiteNotesExtractor reads the §5 Conservatory block into a new
  `Conservatory` site-notes record (scoped to §5 so the generic
  "Floor Area"/"Room Height" labels can't collide with §4 dimensions);
- domain gains a frozen `SapConservatory` (floor area, glazed perimeter,
  double/single glazing, thermally-separated guard, equivalent storey
  count) on `EpcPropertyData.sap_conservatory`;
- the Elmhurst mapper threads it through, dropping SEPARATED
  conservatories per §6.2 ("A separated conservatory ... is disregarded").

Verified against the simulated case-44 Summary (RefNo 001431): extracts
floor_area=12.0, glazed_perimeter=9.0, double_glazed=True, 1 storey.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:37:05 +00:00
Jun-te Kim
928fbbc33a Merge remote-tracking branch 'origin/main' into feature/hyde_make_it_more_accurate_with_tests
# Conflicts:
#	applications/sharepoint_renamer/handler.py
#	domain/sap10_calculator/worksheet/heat_transmission.py
2026-06-16 15:23:52 +00:00
Jun-te Kim
2f0eb49eee Checkpoint: UPRN 10093116543 Elmhurst build + devcontainer VNC/Playwright + perms
- Add SAP-accuracy sample for uprn_10093116543 (epc.json, elmhurst_inputs.md,
  summary/worksheet PDFs)
- Persist hyde viewer stack (xvfb/fluxbox/x11vnc/novnc/websockify) and Playwright
  chromium in the backend devcontainer; forward noVNC 6080
- Broaden .claude/settings.local.json allowlist (display/python/grep/tail)
- In-progress campaign mapper/cert_to_inputs work carried from prior cert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:21:56 +00:00
Khalim Conn-Kowlessar
6385a0be85 fix(mapper): map dropped §3.9.2 Simplified Type-2 room-in-roof (API)
The gov API lodges a §3.9.2 Simplified Type-2 RR (a room-in-roof bounded by
continuous common walls) under `room_in_roof_type_2` — gable + common-wall
lengths AND heights. The block was undeclared → `from_dict` dropped it →
neither the Type-1 nor Detailed path fired → the cascade's Simplified branch
billed the WHOLE A_RR shell (12.5√(floor/1.5)) at the Table-18-col-4 default
with no gable/common-wall deduction (over-count → under-rate; 7 corpus certs
at signed −5.02).

Fix: declare `RoomInRoofType2` on rdsap_schema_21_0_0/_21_0_1 + SapRoomInRoof,
and build `detailed_surfaces` by MIRRORING the worksheet-validated Summary
path (`_map_elmhurst_rir_surface`, is_simplified) rather than back-solving:
  common wall → L × (0.25 + H)                    (billed at the main-wall U)
  gable       → L × (0.25 + H) − Σ (H − H_cw)²/2  (RdSAP 10 §3.9.2 + Table 4)
The gable correction sums all common walls (exposed/party/sheltered, incl.
the H=0 absent-gable negative-area case that deducts from the A_RR residual);
a Connected gable sums only the common walls it overtops. The
`gable_wall_type_*` code routes the kind (0/1/2/3 = Party/Exposed/Sheltered/
Connected). A raw-L×H prototype scattered; the §3.9.2 quadratic is the
missing piece.

Validation is cross-mapper parity, NOT a corpus back-solve: `_api_type_2_
surfaces` produces surfaces IDENTICAL to the Summary path on cohort cert
000565 (connected_wall 3.68, gable_wall_external 16.08/27.68, common walls,
and the −0.17 absent-gable quadratic), and 000565 is pinned to 1e-4 in the
harness — so the API RR fabric is now correct by construction. The remaining
type-2 cohort SAP scatter is unrelated per-cert causes (stone walls,
secondary fuel), not the RR.

Gauges: corpus within-0.5 67.6% → 67.9% (MAE 0.979 → 0.959); /tmp 71.7% →
71.8% (MAE 0.838 → 0.822). Harness 47/47 (000565 unchanged); regression =
the 3 pre-existing fails; pyright net-zero (65=65).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:01:11 +00:00
Khalim Conn-Kowlessar
a33707f851 fix(elmhurst): read main-wall dry-lining + fix last-RR-row U over-read
Two compensating Summary-extractor bugs surfaced by simulated case 43 (a
2-BP mid-terrace with a detailed room-in-roof + a dry-lined extension wall).
Their fabric errors nearly cancelled (walls net −0.76 W/K), hiding both
behind a deceptively small +0.05 SAP delta.

Bug 1 — main/extension wall dry-lining never read. The §7 "Dry-lining:
Yes/No" line was parsed only for ALTERNATIVE walls; the main/extension
WallDetails dropped it, so a dry-lined solid wall was billed at its
un-adjusted base U. RdSAP 10 §5.8 + Table 14: a dry-lined uninsulated wall
adds R=0.17 → U = 1/(1/U_base + 0.17). Case 43 Ext1: solid brick 1.70 →
1.32. Added `WallDetails.dry_lined`, read it in the extractor (both the
main-wall builder and the As-Main copy), threaded it to the domain
`wall_dry_lined` (emit None when undried — cascade-equivalent to False,
keeps the field absent for the non-dry-lined majority).

Bug 2 — the LAST room-in-roof surface row's U over-read. The per-row token
scan stops at the next RIR-row name; the final surface (no successor) over-
read into the following section, shifting the trailing-token slotting and
silently zeroing its `default_u` (case 43 Common Wall 2: 1.90 → 0.00 → the
2.4 m² common wall billed at U=0 instead of the main-wall 1.90). Stop the
scan at the row's natural end — the "Yes"/"No" u_value_known flag plus the
trailing u_value numeric.

Case 43 now reproduces the P960 EXACTLY: (29a) walls 74.5800, (33) fabric
172.7844, continuous SAP 73.2332 = (258), CO2 3518.30 = (272), all <1e-4
(was SAP +0.0455 / CO2 −8.04). Harness 47/47 0 raised; regression = the 3
pre-existing fails; pyright net-zero (51=51).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 07:51:33 +00:00
Khalim Conn-Kowlessar
26998152a7 fix(mapper): read dropped detailed room-in-roof common-wall surfaces
Follow-on to the slope/stud slice. A Detailed RR (RdSAP 10 §3.9.2) can also
lodge `common_wall_*` — the wall separating the room-in-roof from the rest of
the cold roof void. Those fields were undeclared → `from_dict` dropped them →
`_api_rir_detailed_surfaces` omitted the common walls → the RR undercounted
wall heat loss → over-rate.

Fix: declare `common_wall_length/height_1/2` on `RoomInRoofDetails`
(21_0_0 + 21_0_1) and build `kind="common_wall"` surfaces (raw L × H area to
2 d.p.). The cascade's Detailed-RR branch already bills common walls at the
storey-below main-wall U (Table 4 p.22 "Common wall") and deducts their area
from the §3.10.1 residual roof — no calculator change. No insulation thickness
is read: common walls take the main-wall U, not a Table 17 RR-element U.

6 /tmp certs carry detailed `common_wall_length_1`: cohort mean|err| 2.43 ->
1.25 (all were over-rating; e.g. 2877-3059 +4.55 -> +2.79). Gauges: corpus
within-0.5 67.5% -> 67.6% (MAE 0.987 -> 0.979); /tmp 71.6% -> 71.7%
(MAE 0.846 -> 0.838). Harness 47/47 0 raised; regression = the 3 pre-existing
fails; pyright net-zero (65=65).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 06:22:45 +00:00
Khalim Conn-Kowlessar
363f14fbb2 fix(mapper): read dropped detailed room-in-roof slope + stud-wall surfaces
The gov-EPC API lodges a Detailed RR (RdSAP 10 §3.9, Figure 4) with up to
two sloping ceilings (`slope_*`) and two vertical stud/knee walls
(`stud_wall_*`) in addition to the gable + flat-ceiling surfaces. Those
slope/stud fields were undeclared on the 21.0.x schema, so `from_dict`
silently dropped them and `_api_rir_detailed_surfaces` built ONLY the gable
+ flat-ceiling surfaces. The (large) sloping roof and the knee walls
contributed ZERO heat loss → undercounted RR fabric loss → a systematic
over-rate.

Fix: declare `slope_*`/`stud_wall_*` on `RoomInRoofDetails`
(rdsap_schema_21_0_0 + _21_0_1) and build `kind="slope"` / `kind="stud_wall"`
surfaces in the mapper. The cascade's Detailed-RR branch already routes both
to the roof aggregate via `u_rr_slope` (Table 17 col 1) and `u_rr_stud_wall`
(Table 17 col 3) — RdSAP 10 §5.11.3, p.43-44 — so no calculator change is
needed (Summary path worksheet-validated by the 000565 detailed-RR fixtures).
insulation_type is left None to defer to the Table 17 col-(a) mineral-wool
default, mirroring the existing flat_ceiling branch.

15 /tmp certs carry `slope_height_1`: cohort mean|err| 4.26 -> 2.05, signed
+4.09 -> centred (14/15 were over-rating; e.g. 0390-2538 +5.95 -> +3.56).
Gauges: corpus within-0.5 67.3% -> 67.5% (MAE 1.020 -> 0.987); /tmp 71.4% ->
71.6% (MAE 0.882 -> 0.846). Harness 47/47 0 raised; regression = the 3
pre-existing fails; pyright net-zero (65=65).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 05:56:47 +00:00
Khalim Conn-Kowlessar
b55b969b84 fix(water-heating): use lodged cylinder_heat_loss declared-loss factor
The gov API lodges a manufacturer's declared cylinder loss factor
(kWh/day) in `sap_heating.cylinder_heat_loss`, in which case it leaves
the cylinder volume / insulation type / thickness None. That field was
undeclared on the 21.0.x schemas, so `from_dict` dropped it — then
`_cylinder_storage_loss_override` hit its insulation-None / volume-None
guards and returned None, dropping the §4 storage loss ENTIRELY. The
dwelling over-rated (the declared loss is typically ~1.5 kWh/day ≈
550 kWh/yr).

SAP 10.2 §4 branch a) (PDF p.136): when the declared loss factor is
known, storage loss (50) = (48) declared loss × (49) Table-2b
temperature factor — replacing the Table 2 V×L×VF computation.

- declare `cylinder_heat_loss` on RdSapSchema21_0_0/21_0_1.SapHeating +
  EpcPropertyData.SapHeating; thread through the 21.0.x mappers.
- `cylinder_storage_loss_monthly_kwh` gains `declared_loss_kwh_per_day`:
  when set, combined_55 = declared × TF (volume/insulation unused).
- `_cylinder_storage_loss_override` resolves the declared loss BEFORE the
  insulation/volume guards (the gov omits those when the loss is lodged).

12 /tmp certs carry it (mean |err| 3.00 -> 2.51; the clean ones close
hard, e.g. 2360 2.65 -> 0.30, 0245 2.25 -> 0.53). Corpus within-0.5
67.0% -> 67.3% (MAE 1.025 -> 1.020); /tmp 71.2% -> 71.4% (0.889 ->
0.882). Worksheet harness 47/47; regression = only the 3 pre-existing
fails; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 05:27:47 +00:00
Khalim Conn-Kowlessar
7cfd54129b fix(mapper): read the dropped rafter_insulation_thickness API field
Roofs lodged insulated at rafters carry their thickness in a DEDICATED
gov-EPC API field, `rafter_insulation_thickness` (e.g. "225mm"), while
`roof_insulation_thickness` stays None (rafters aren't loft joists). That
field was undeclared on the 21.0.x schemas, so `from_dict` silently
dropped it — the rafter certs only *looked* redacted (roof EER 2-4 =
insulated, yet no thickness), and the cascade fell to the Table 18 col (2)
unknown default (2.30), badly under-rating them.

- declare `rafter_insulation_thickness` on RdSapSchema21_0_0/21_0_1 +
  EpcPropertyData.SapBuildingPart (mirrors the existing
  sloping_ceiling_/flat_roof_insulation_thickness dropped-field handling).
- thread it through `from_rdsap_schema_21_0_0/21_0_1` (older schemas get
  None via getattr).
- `heat_transmission` prefers `rafter_insulation_thickness` over
  `roof_insulation_thickness` when the part is at-rafters, so the measured
  RdSAP 10 §5.11.2 Table 16 column (2) row applies (225 mm → 0.25).

Completes the rafters roof fix: with the real thickness read, the rafter
certs are recovered rather than over-stated — cert 3100-8675-0922-8628
(band E, rafters 225mm) +8.93 → +0.43 SAP. Corpus within-0.5 67.0%
(MAE 1.025) and /tmp 71.2% (MAE 0.889) — both NET ABOVE the pre-rafters
baseline (66.9% / 70.6%). Worksheet harness 47/47; regression = only the
3 pre-existing fails; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 05:04:39 +00:00
Khalim Conn-Kowlessar
b2b6f8e954 fix(mapper): map Elmhurst "Value known" cylinder to measured volume (code 6)
The Elmhurst Summary §15.1 lodges "Cylinder Size: Value known" with the
measured volume in the "Cylinder Volume (l)" line — the Summary-path
equivalent of the gov-API "Exact" descriptor. The mapper had no entry for
"Value known" so `_elmhurst_cylinder_size_code` raised UnmappedElmhurstLabel,
and even once mapped the measured volume was never threaded through, so the
cascade dropped the cylinder storage loss (~468 kWh/yr) from (219) water
heating on every measured-volume-cylinder Summary.

Per RdSAP 10 §10.5 Table 28 (p.55) a measured cylinder volume is used
directly. Map "Value known" → cascade code 6 (Exact) and thread the §15.1
"Cylinder Volume (l)" value into SapHeating.cylinder_volume_measured_l, which
`_cylinder_volume_l_from_code` (cert_to_inputs.py:5281) already reads for
code 6 — mirroring the gov-API path (mapper.py:1575/1885).

Pins simulated case 39 (P960-0001-001431): an age-A mid-terrace on direct-
acting electric room heaters (SAP code 691, cat 10, control 2602) with
electric-immersion DHW off a 117 L "Value known" cylinder. The full
extractor→mapper→calculator cascade now reproduces the worksheet's SAP-rating
block EXACTLY — SAP value 36.6365 (band F) and (272) CO2 2056.0731 kg/yr,
with (219) water heating 2637.5049 and (255) total energy cost 1802.0039.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:57:25 +00:00