Commit graph

78 commits

Author SHA1 Message Date
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
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
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
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
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
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
Jun-te Kim
140ad39898 Map full-SAP code-based heating systems via sap_main_heating_code 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:40:59 +00:00
Jun-te Kim
345154c6b7 Map full-SAP measured ventilation: air permeability, MV kind, sheltered sides 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:37:52 +00:00
Jun-te Kim
acd0ed485d Map full-SAP energy source, mains-gas inference and lighting bulbs 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:23:31 +00:00
Jun-te Kim
cb4d080da2 Map full-SAP heating systems onto the domain SapHeating model 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:18:01 +00:00
Jun-te Kim
125ff6f4dd Merge remote-tracking branch 'origin/main' into feature/hyde_make_it_more_accurate_with_tests
# Conflicts:
#	datatypes/epc/domain/mapper.py
2026-06-15 14:12:38 +00:00
Jun-te Kim
5ebeb71090 Back-solve habitable-room count from full-SAP measured living area 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:58:03 +00:00
Jun-te Kim
af26688846 Derive heat-loss perimeter and party-wall length from full-SAP measured wall areas 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:56:31 +00:00
Jun-te Kim
dde98fb684 Collapse full-SAP roof-window openings onto sap_roof_windows 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:46:32 +00:00
Jun-te Kim
70460935b8 Collapse full-SAP window openings onto the engine's sap_windows model 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:32:20 +00:00
Jun-te Kim
0eaf87b106 Carry full-SAP measured fabric U-value descriptions into the domain model 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:10:05 +00:00
Jun-te Kim
c3fd9a6872 Map full-SAP cert identity and scalar fields to EpcPropertyData 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:05:38 +00:00
Jun-te Kim
0079752eab inviestigation with hyde values 2026-06-15 12:13:11 +00:00
Jun-te Kim
5923f8d072 Parse full-SAP SAP-Schema-17.1 certificate payloads 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:11:26 +00:00
Khalim Conn-Kowlessar
361abc1202 fix(mapper): handle 'ND' multiple_glazing_type on RdSAP-Schema-20.0.0
`_synthesise_20_0_0_sap_windows` passed `schema.multiple_glazing_type`
straight into `_api_cascade_glazing_type`, which raised UnmappedApiCode on
the "ND" (Not Defined) string that the 20.0.0 corpus lodges alongside the
1-8 integer codes — failing the mapper-coverage guard on every ND-glazed
20.0.0 cert. Mirror the existing 18.0/19.0/17.x seams: route integer codes
through the cascade, fall the "ND" string back to the DG-modal default
(cascade code 2 → daylight g_L 0.80). Also corrects the 20.0.0 schema
field type `int` → `Union[int, str]` to match the data (as 18.0 already
does), which keeps the isinstance guard pyright-clean.

Pre-existing failure (present before this branch's recent commits), not in
the handover regression gate. Fixes all 15 RdSAP-Schema-20.0.0 ND certs;
test_mapper_corpus 6002/6002 pass. pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 06:43:55 +00:00
Khalim Conn-Kowlessar
5b2cf5edc7 Merge remote-tracking branch 'origin/main' into feature/per-cert-mapper-validation
# Conflicts:
#	datatypes/epc/domain/epc_property_data.py
#	datatypes/epc/domain/mapper.py
#	datatypes/epc/domain/tests/test_from_rdsap_schema.py
2026-06-13 22:20:15 +00:00
Jun-te Kim
26651ca71c Map RdSAP-Schema-17.0 certs to EpcPropertyData 🟩
Dispatch RdSAP-Schema-17.0 through from_api_response, parse-fix the schema
(data-driven required->optional, validated against the 1000-cert 17.0 corpus
per ADR-0028 — incl. SapHeating.cylinder_insulation_type and the
has_hot_water_cylinder / has_fixed_air_conditioning / has_heated_separate_
conservatory flags), and port the defensive mapper reads (dwelling_type
str/dict/number, photovoltaic_supply guard, sap_floor_dimensions guard). All
1000 corpus certs now parse and map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:45:19 +00:00
Jun-te Kim
792f76f2fa Map RdSAP-Schema-19.0 certs to EpcPropertyData 🟩
Dispatch RdSAP-Schema-19.0 through from_api_response, parse-fix the schema
(data-driven required->optional, validated against the 1000-cert 19.0 corpus
per ADR-0028), and port 18.0's defensive mapper reads (dwelling_type str/dict/
number, photovoltaic_supply guard, sap_room_in_roof Measurement coercion).
All 1000 corpus certs now parse and map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:29:36 +00:00
Khalim Conn-Kowlessar
6884ec9fda fix(fabric): honour the gov-EPC lodged per-element U-values (RdSAP §5.1)
The gov-EPC API surfaces the assessor's RdSAP-assessed per-element U-values
as `roof_u_value` / `wall_u_value` / `floor_u_value` on each building part.
These were undeclared on the RdSAP 21.0.0/21.0.1 schemas, so `from_dict`
silently dropped them, and `heat_transmission` re-derived each U from the §5.6
/§5.7/§5.11 construction-default cascade. The gov OPEN data routinely redacts
the backing insulation thickness, so that re-derivation mis-bills an insulated
element as uninsulated.

RdSAP 10 §5.1: a known element U-value (documentary evidence / the lodged
RdSAP output) is used directly in place of the construction-default cascade.
Per [[project_per_cert_mapper_validation_state]] the gov API carries RdSAP
OUTPUT, so the lodged U reproduces the official's element heat loss exactly.

Worst case in the 2026 sample: cert 7921-0052-0940-5007-0663, an age-C
"Pitched, sloping ceiling" (rc=8) top-floor flat lodging roof_u_value=0.2 with
no thickness. The cascade returned the uninsulated 2.30 W/m²K → SAP 56.9 vs
lodged 80 (-23.09, the single largest error in the sample). The roof override
alone recovers ~15 SAP; the wall override (lodged 0.34 vs cascade) closes the
rest of this cohort.

Override applies to the MAIN wall only (alt-wall sub-areas keep their own
per-area U) and the part's floor=0. Fires only when the rare field is present
(9 of 909 computed certs), so the Summary path — which never lodges these
API fields — is untouched.

API gauge: 67.1% → 67.7% within-0.5, mean|err| 1.024 → 0.992.
Worksheet harness: 47/47, 0 divergers (unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:51:26 +00:00
Jun-te Kim
25e550ce3c Dispatch and map RdSAP-Schema-17.1 certs end-to-end 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:21:17 +00:00
Jun-te Kim
fb14f03873 Map Not-Defined glazing code to a valid type for windowless 18.0 certs 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:55:46 +00:00
Jun-te Kim
162259b255 Use lodged window geometry for rich RdSAP-Schema-18.0 certs 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:45:12 +00:00
Jun-te Kim
51309328e6 Parse and map all RdSAP-Schema-18.0 corpus certs 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:39:07 +00:00
Jun-te Kim
4fca5d7fed Dispatch and map RdSAP-Schema-18.0 certs end-to-end 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:18:15 +00:00
Khalim Conn-Kowlessar
a97ff60b01 fix(water-heating): complete RdSAP Table 28 cylinder-size map (codes 5 + 6)
`_CYLINDER_SIZE_CODE_TO_LITRES` held only codes 2/3/4 (Normal/Medium/Large →
110/160/210 L); codes 5 (Inaccessible) and 6 (Exact) fell through to None,
so the Table-13 high-rate fraction AND the cylinder storage loss were skipped
for those certs (20 code-6 certs in the API sample).

Per RdSAP 10 Specification (10-06-2025) §10.5 Table 28 (PDF p.55):
- Code 6 "Exact": use the lodged measured volume. The gov API carries it in
  `cylinder_size_measured` (e.g. 150 L) — now plumbed through the 21.0.0/21.0.1
  schema → mapper → `SapHeating.cylinder_volume_measured_l`.
- Code 5 "Inaccessible": 210 L if off-peak electric dual immersion, 160 L from
  a solid-fuel boiler, otherwise 110 L (n=0 in the current sample, but
  spec-complete).

New `_cylinder_volume_l_from_code` centralises Table 28 resolution and replaces
the three raw-dict call sites (`_hot_water_cylinder_volume_l`, the cylinder
storage-loss path, and the PCDB performance check) so all three honour codes
5/6 identically. `_cylinder_inaccessible_volume_l` applies the code-5 context
rule via the existing immersion/off-peak-meter/solid-fuel-boiler detectors.

Worksheet harness UNAFFECTED (47/47, 0 divergers): the Summary path lodges
neither code 5/6 nor a measured volume. API gauge: within-0.5 64.4% -> 65.1%
(mean|err| 1.085 -> 1.075) — the 20 code-6 certs now size their cylinder from
the measured volume. 4 AAA tests (code 6 measured; code 5 solid-fuel/default/
off-peak-dual-immersion). pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:02:39 +00:00
Jun-te Kim
12ff15e55b Parse 20.0.0 conservatory building parts so all 1000 certs map 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:21:00 +00:00
Jun-te Kim
3352f11be3 Synthesise 20.0.0 window geometry from glazed-area band and floor area 🟩
ADR-0027 Reduced-Field Synthesis: certs with no per-window array now get total
glazing = 0.148 x TFA x band-multiplier (median + quartile multipliers fit from
the 1000 real 21.0.1 certs), split 4-way across N/E/S/W with width=area/4,
height=1.0; glazing_type routed through the verified 21.0.1 cascade. Also guard
optional PhotovoltaicSupply.none_or_no_details (a parse straggler). Corpus maps
983/1000, up from 974.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:29:13 +00:00
Jun-te Kim
8074f4152c Map RdSAP 20.0.0 certs that omit reduced fields or lodge localised text 🟩
Required->optional defaults (kw_only + data-driven from corpus presence) so
993/1000 certs that omit sap_windows parse, and honest Union[str, DescriptionV1]
typing for description/dwelling_type which the corpus lodges as localised dicts
in ~half the certs. The never-run 20.0.0 mapper path now produces EpcPropertyData;
974/1000 corpus certs map (xpass), up from 7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:14:18 +00:00
Khalim Conn-Kowlessar
3e05c95e65 fix(wall-U): apply RdSAP Table 4 "Sheltered" R=0.5 to alternative walls
Audit of the API-SAP error (53% within 0.5) localised the systematic
under-rate to ELECTRIC FLATS (houses sit at 60-66% within 0.5; electric
flats 13-19%). Decomposing the flat error showed it tracks space-heating
demand per m² — the worst certs reach 130-289 kWh/m² (accurate certs sit
at 14-110), i.e. a grossly over-stated fabric heat loss, amplified ~4x by
the electricity unit price and the steep low-band SAP log curve.

Root cause: the gov-EPC API lodges `sheltered_wall="Y"` on alternative
wall sub-areas (a sub-area adjacent to an unheated buffer — stair core,
adjoining structure), but the field was dropped by the schema + domain
dataclasses and the calculator billed the alt sub-area at its full
exposed U. RdSAP 10 Table 4 (PDF p.22) "Sheltered": such a wall carries
an added external surface resistance R=0.5 m²K/W → U_sheltered =
1/(1/U + 0.5) — the SAME adjustment the main wall already applies for
`gable_wall_type=2` (`gable_wall_sheltered`,
`_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W`). Cert 0340-2976 (band-A flat,
42 m² sheltered timber-frame alt) over-stated its wall channel by
~58 W/K → walls 128 -> 70 W/K.

Threads the field end-to-end: schema dataclasses (21.0.0/21.0.1) +
domain `SapAlternativeWall.is_sheltered` (default False — the Summary/
Elmhurst path leaves it False, sheltering rides through its lodged
U-value there, so goldens are untouched) + `from_api_response` mapping
`"Y"->True` + `_alt_wall_w_per_k` applying the 0.5 resistance on the
cascade path (lodged-U and basement alt-walls return before it).

140 certs (15% of the corpus) carry a sheltered alt-wall; they under-
rated at median -0.82 / mean signed -1.33 / 23% within 0.5. Eval: 102
improved, 38 regressed (offsetting-error cases — fix is spec-uniform per
[[feedback_software_no_special_handling]]); within-0.5 53.14% -> 54.24%,
within-1.0 67.99% -> 69.64%, within-2.0 81.85% -> 83.50%, mean|err|
1.312 -> 1.248, 909 computed / 0 raises. Goldens (6035, 000565) and full
calc/epc/parser regression green; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:49:42 +00:00
Khalim Conn-Kowlessar
bb8307413f fix(mapper): read sloping_ceiling_insulation_thickness for roof code 8
A "Pitched, sloping ceiling" (roof_construction == 8) lodges its
insulation in the dedicated `sloping_ceiling_insulation_thickness` field,
not `roof_insulation_thickness` (which stays None — the loft-joist field
is meaningless for a slope-following ceiling). The schema dataclasses
dropped that field, so `from_dict` discarded it and the cascade treated
the slope as uninsulated; worse, the pre-1950 None-fallback forced 0 mm
(U=2.30), over-stating roof heat loss ~74%.

Surface the field on SapBuildingPart (schemas 21.0.0 / 21.0.1) and prefer
it in `_api_resolve_sloping_ceiling_thickness` when it carries a NUMERIC
thickness: "100mm" now reaches Table 17 column (1a) "Insulated slope –
sloping ceiling, mineral wool/EPS" (RdSAP 10 §5.11.3 p.44 — 100 mm →
U=0.40) instead of 2.30. Categorical lodgements ("AB" As Built / "NI")
are not measured thicknesses, so they fall through to the existing
as-built rule (Table 18 col (3) via is_pitched_sloping_ceiling).

Cert 9884-3059-9202-7506 (code 8, age B, sloping 100 mm): SAP −5.54 → +0.06.
Cert 8036-2925-6600-0202: −4.94 → +1.55. No regressions in the roof-8
cohort (the "AB" certs are unchanged). Eval headline 43.8% → 44.3% within
0.5; golden fixtures incl. 6035 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:37:17 +00:00
Khalim Conn-Kowlessar
9521d52403 S0380.234: PV diverter (Appendix G4) — diverts surplus PV to the cylinder
SAP 10.2 Appendix G4 (PDF p.72-73). A PV diverter routes surplus PV
generation (the would-be export EPV,m × (1 − βm)) to an immersion heater
in the hot-water cylinder. Per G4 step 4:

    SPV,diverter,m = EPV,m × (1 − βm) × 0.8 × fPV,diverter,storageloss

(0.8 = cylinder heat-acceptance; fPV,diverter,storageloss = 0.9 for the
higher storage temperature), clamped to ≤ (62)m + (63a)m, and entered as
the negative worksheet (63b)m (step 5). The β factor is computed on the
PRE-diverter (219) per the §3a note (lines 5485-5486). Effects:
  - (64)m = (62)m + (63b)m → less main-system water-heating fuel (219);
  - export drops to EPV,ex,m = EPV,m(1 − βm) + (63b)m / 0.9 (§4 p.94
    line 5501); the onsite dwelling portion EPV,m × βm is unchanged.

Inclusion (G4 step 1) requires ALL of: a PV system connected to the
dwelling; a cylinder larger than (43) average daily HW use; no solar
water heating; no battery — else the diverter is disregarded.

Three layers:
  - extractor reads Summary §19 "Diverter present"; schema 21.0.0/21.0.1
    SapEnergySource gains `pv_diverter` (API `sap_energy_source.pv_diverter`);
  - `Renewables.pv_diverter_present` + domain `SapEnergySource.pv_diverter_present`,
    set in both the Elmhurst and API mapper paths;
  - `_pv_diverter_monthly_kwh` applies the G4 math after the β split;
    `cert_to_inputs` recomputes (219) and the PV export.

On simulated case 19 (electric storage heaters, 7-hour, PV + diverter):
SAP continuous 50.33 → 51.34 (worksheet 51.2221; both round to the
lodged 51), cost (255) 1847.5 → 1812.3 (ws 1816.6), CO2 (272) 3331 →
3120 (ws 3126), with (233a) dwelling 1280.6 (ws 1280.4). The residual
+0.11 SAP is an upstream winter Appendix-M monthly-EPV-shape gap +
fabric (33) +1.0, tracked as the next case-19 cause. Suite: 2412 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:59:12 +00:00
Khalim Conn-Kowlessar
f895dd3ab7 S0380.217: capture wall_insulation_thermal_conductivity (was dropped)
Second silently-dropped field from the 2130 audit: the schema-21
SapBuildingPart never declared `wall_insulation_thermal_conductivity`, so
`from_dict` discarded it. Captured it through schema 21.0.0/21.0.1 → domain
SapBuildingPart → API mapper, and wired it into u_wall's RdSAP 10 §5.8
documentary-evidence R-value calc (both the solid-brick §5.7/§5.8 path and
the cavity-composite path), replacing the bare 0.04 λ constant with a
resolved λ.

Resolver: absent / "Unknown" → the §5.8 default 0.04 W/m·K (mineral wool /
EPS); a mapped code → its λ; an unmapped integer code RAISES so the enum is
confirmed against a worksheet rather than silently mis-factored (same
incremental-coverage discipline as the glazing-type map). Only code 1
(= the default 0.04) is mapped — the sole observed value (cert 2130 Ext1).

Zero cascade effect today: the λ path fires only for solid-brick/cavity
walls with a *measured* wall thickness, and 2130 Ext1 lodges no wall
thickness, so its conductivity is captured-but-unused; all existing §5.8
certs lodge no conductivity → 0.04 default unchanged. The point is to stop
dropping lodged data and make λ correct when a future cert exercises it.

Suite: 2523 passed (1 pre-existing TFA fail); sap10_ml 237 passed (2
pre-existing stone-formula fails). Zero new pyright errors (46=46).

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:30:49 +00:00
Khalim Conn-Kowlessar
8861dac694 S0380.196: API Simplified Type 1 RR gables deduct from A_RR shell (RdSAP 10 §3.9.1(e) p.21)
Golden cert 6035's residual (SAP -2 / PE +19.16 / CO2 +0.42t) was a real
API-mapper bug, NOT lodged divergence (prior claim retracted).

The API `room_in_roof_type_1` block lodges gable walls by length only (no
height). The mapper carried just the scalar `gable_*_length_m`, and the
cascade's `_part_geometry` gable formula silently drops height-less gables
(needs a height) -> the whole A_RR shell `12.5√(A_RR_floor/1.5)` billed as
roof at U_RR=2.30 instead of the §3.9.1(e) residual
`A_RR − Σ gables`. On 6035 that over-counted roof by 22.78 m² × 2.30 =
+52.4 W/K (roof 130.73 -> 78.33, matching the site-notes case-4 replica at
1e-4 — cross-mapper parity).

RdSAP 10 §3.9.1(e) (PDF p.21): "the area of the room-in-roof gable walls
... is deducted from A_RR to give the residual roof area." Fix: route the
Type 1 gables through `detailed_surfaces` (gable area = L × the §3.9.1
default RR storey height 2.45 m; gable_wall_type 0=Party->gable_wall U=0.25,
1=Exposed->gable_wall_external "as common wall" per Table 4 p.22) so the
cascade's Detailed-RR residual fires — the same path the site-notes mapper
already uses.

Re-pinned golden residuals:
- 6035: SAP -2 -> +0 (exact), PE +19.16 -> +1.84, CO2 +0.42 -> +0.01
- 0240: same fix applies (2 Party gables L=6.4); PE +5.80 -> +3.91,
  CO2 +0.32 -> +0.22, SAP integer unchanged

Also corrected the stale "gable_wall_type 0 = external" schema comment
(6035's Summary proves 0=Party, 1=Exposed) and added a strict
UnmappedApiCode raise for unknown gable_wall_type codes.

Suite: 2342 passed, 1 skipped. New code: 0 pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:37:26 +00:00
Jun-te Kim
616744a606 Merge remote-tracking branch 'origin/main' into feature/landlord_data
# Conflicts:
#	datatypes/epc/schema/rdsap_schema_21_0_0.py
#	datatypes/epc/schema/rdsap_schema_21_0_1.py
2026-06-01 17:02:20 +00:00
Jun-te Kim
bdf703ea00 updated rdsap option; seperated s3 location in infrastrucutre; added open ai api 2026-06-01 16:33:14 +00:00
Khalim Conn-Kowlessar
2805e13d4d Slice S0380.48: surface real-API pv_batteries[].battery_capacity (5 kWh)
The 7-cert ASHP+battery PE cluster was overshooting by +2.7..+8.1 kWh/m²
after the PE β-split landed in S0380.45. The handover hypothesised an
E_PV magnitude bug ("cascade thinks 2570 kWh/yr vs worksheet 831"). The
worksheet PDF for cert 0380 (dr87-0001-000899.pdf line 233) was
verified to show **-2563.3692** kWh/yr — matching our cascade. The
real bug was different: the **5-kWh battery wasn't reaching the
cascade**, so β-coefficients used the no-battery branch (C1=1.61,
β≈0.36) instead of the 5-kWh branch (C1=1.12, β≈0.75).

Per SAP 10.2 Appendix M1 §3c-d (p.94): "C_bat is the usable capacity
of the battery in kWh, limited to a maximum value of 15 kWh. C_bat=0
if no battery present." Cert 0380 lodges `pv_battery_count: 1` and
`pv_batteries: [{"battery_capacity": 5}]` — but the schema's
`PvBatteries` dataclass had only `pv_battery: Optional[PvBattery]`,
matching the older synthetic fixture shape (nested
`{"pv_battery": {"battery_capacity": 5}}`). The real-API payload's
flat `battery_capacity: 5` was silently dropped during `from_dict`.

Two surgical changes:
- `datatypes/epc/schema/rdsap_schema_21_0_1.py`: add
  `battery_capacity: Optional[float] = None` as a sibling to
  `pv_battery` on `PvBatteries`. Synthetic-shape certs continue to
  populate the nested form; real-API certs now populate the flat form.
- `datatypes/epc/domain/mapper.py:_first_pv_battery`: prefer nested
  when present, fall back to the flat lifted field. Domain still
  exposes a single uniform `PvBatteries(pv_battery=PvBattery(...))`
  shape downstream.

Cohort impact (PE residual kWh/m² vs worksheet):

| Cert | Pre-S0380.48 | Post-S0380.48 |
|---|---:|---:|
| 0350 | +2.73 | -3.58 |
| 0380 | +8.09 | -4.01 |
| 2225 | +4.48 | -4.50 |
| 2636 | +3.42 | -4.14 |
| 3800 | +3.58 | -4.01 |
| 9285 | +3.20 | -3.46 |
| 9418 | +4.67 | -3.76 |

Cluster magnitude dropped from +2.7..+8.1 to -3.5..-4.5 — the cascade
now over-credits PV by ~4 kWh/m² (vs previously under-crediting by
~5 kWh/m²). The residual flipped sign because cascade β=0.75-0.81
slightly exceeds worksheet β=0.74 (read from page-3 line 233a/233b
ratio 1903.39/2563.37 = 0.7426). The remaining ~4 kWh/m² under-shoot
traces to two structural factors deferred until a fresh closure
slice ships:

1. The synthetic-default `pv_export_primary_factor = 0.501` is the
   annual Table 12 code-60 value. The worksheet uses the effective
   monthly Table 12e factor weighted by E_PV,ex,m (cert 0380: 0.4268
   = -0.074 differential). The cascade's `_effective_monthly_pe_
   factor` already computes the same weighting for PV — but the
   calculator's PV PE credit reads `inputs.other_primary_factor`
   (=1.501) and `inputs.pv_export_primary_factor` (=0.501) directly,
   bypassing the per-end-use effective-monthly cascade.
2. Cascade β slightly higher than worksheet (0.751 vs 0.7426 on
   cert 0380) — likely a monthly-distribution detail in D_PV.

SAP scores remain exact across the cohort (residual +0 every cert).
CO2 residuals all <0.11 t/yr (well within the 0.001-tolerance pin
range after re-pin). 9501 (PV no battery) preserved at +0.255 PE /
-0.047 CO2 — no regression. Re-pins all 7 golden fixtures in the
same slice per [[feedback-commit-per-slice]].

Pyright net-zero on touched files (32 errors before, 32 after).
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
16845604e2 Slice 100c: API path — surface PV arrays + gap-aware glazing lookup
Two final API gaps to close cert 9501 at 1e-4:

(a) PV array surfacing — third shape variant:
    Schema-21 EPCs carry `photovoltaic_supply` as one of three shapes:
    - legacy `{"none_or_no_details": {...}}` (PV absent / roof-only)
    - nested list `[[{...}], ...]` (cohort cert 2130)
    - dict wrapper `{"pv_arrays": [{...}]}` (cert 9501)
    The schema's `PhotovoltaicSupply` modelled only `none_or_no_details`
    — cert 9501's measured arrays under `pv_arrays` were silently
    dropped (Δ -£250 PV credit → -9.32 SAP). Added
    `SchemaPhotovoltaicArray` dataclass + `pv_arrays:
    Optional[List[...]]` sibling field on `PhotovoltaicSupply`; updated
    `_map_schema_21_pv` to dispatch on the new shape.

(b) Gap-aware glazing lookup (RdSAP 10 Table 24 row 2):
    DG pre-2002 spec U varies by gap: 6mm=3.1 / 12mm=2.8 / 16+=2.7.
    The mapper's flat `_API_GLAZING_TYPE_TO_TRANSMISSION[3]` returned
    U=2.8 unconditionally — cert 9501 lodges `glazing_gap="16+"` so
    the worksheet uses 2.7. Added `_API_GLAZING_TYPE_GAP_TO_
    TRANSMISSION` keyed by (type, gap) with the spec-table values for
    code 3; `_api_glazing_transmission` consults the per-gap dict
    first, falling back to type-only when no gap entry exists.
    Refactored the inline `SapWindow(...)` build into
    `_api_sap_window` helper (also nets one pyright error: net-zero
    actually improved 33 → 32 on mapper.py).

Effect on cert 9501 API path:
- sap_continuous 59.20 → **68.525161** (= worksheet 68.5252 exact;
  Δ -0.000039 — well within 1e-4)
- total_fuel_cost £1101 → £849.21 (= worksheet 849.21 exact)
- pv_export_credit £0 → £250.02 (= worksheet 250.02 exact)

Re-pinned residuals (5 cohort certs with glazing_gap="16+" or 6 now
pick up the spec-correct DG-pre-2002 U):
- 0300: PE +8.44 → +8.28, CO2 -0.23 → -0.25
- 6035: PE +48.30 → +47.85, CO2 +1.10 → +1.09
- 7536: PE -6.51 → -7.08, CO2 -0.17 → -0.19
- 8135: PE -5.31 → -3.66 (gap=6 spec U=3.1), CO2 -0.07 → -0.04
- 2130: PE -38.18 → -38.63, CO2 +0.30 → +0.30

Layer 4 chain test `test_api_9501_full_chain_sap_matches_worksheet
_pdf_exactly` added — third production gate after cert 001479 +
cert 0330. First flat-shaped cert in the production gate set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:45 +00:00
Khalim Conn-Kowlessar
8e74b6b8b8 Slice 100a: API path — surface Detailed-RR per-surface areas
Two RR shapes coexist in real-API JSON: cohort certs (6035, 0240,
schema test 21_0_1.json) lodge `room_in_roof_type_1` (RdSAP §3.9.1
Simplified Type 1 — gable lengths only, cascade applies the 2.45 m
default storey height); cert 9501 lodges `room_in_roof_details`
(RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-
ceiling detail). The schema only modelled the Simplified-Type-1
wrapper, so `from_dict` parsed cert 9501's Detailed-RR block as
None and the API mapper built `SapRoomInRoof` with `detailed_
surfaces=None`. The cascade then defaulted to Simplified Type 2
"all elements" (RR floor area × Table 18 col(4) age-B U=2.30) for
the whole RR → roof HLC 149.43 W/K vs worksheet 18.10 (Δ +131.32).

Changes:
- Add `RoomInRoofDetails` dataclass to both schema 21.0.0 and 21.0.1
  with the 10 fields the JSON lodges: gable_wall_type_{1,2} +
  gable_wall_length_{1,2} + gable_wall_height_{1,2} + flat_ceiling_
  length_1 + flat_ceiling_height_1 + flat_ceiling_insulation_
  type_1 + flat_ceiling_insulation_thickness_1. `SapRoomInRoof`
  gains a sibling `room_in_roof_details` field next to the legacy
  `room_in_roof_type_1`; both shapes are now lossless.
- Extract `_api_build_room_in_roof` mapper helper that reads from
  whichever block is present and populates
  `SapRoomInRoof.detailed_surfaces` from the Detailed-RR block.
  Gables route to `gable_wall_external` for flats (top-floor flats
  with RR sit at the end of the building, no neighbour above) and
  to `gable_wall` (party at U=0.25) otherwise — mirrors the Summary
  mapper's `_map_elmhurst_rir_surface` heuristic.
- Replace both inline `SapRoomInRoof(...)` builds in
  `from_rdsap_schema_21_0_0` and `from_rdsap_schema_21_0_1` with
  the helper.

Effect on cert 9501 API path:
- roof HLC 149.43 → 18.10 (= worksheet 18.10 exact)
- walls HLC 168.74 → 218.81 (= worksheet 218.81 exact)
- (37) total HLC 382.19 → 297.54 (worksheet 296.68; Δ +0.86)
- sap_continuous still -9.27 vs worksheet because TFA on the API
  path is still 81.28 (missing the 31.8 m² RR floor area) — next
  slice closes that.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:45 +00:00