Model/scripts/hyde/mapping_decisions.md
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

110 lines
6.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Full-SAP 17.1 → EpcPropertyData mapper — decisions log
Running log of load-bearing mapping decisions made during the grilling session.
Each becomes an ADR (`docs/adr/`) when the mapper lands. Corpus evidence:
`backend/epc_api/json_samples/SAP-Schema-17.1/corpus.jsonl` (1,000 real certs).
**Scope (locked):** map full-SAP 17.1 → a valid `EpcPropertyData` so the existing
RdSAP `Sap10Calculator` runs end-to-end. Accurate-enough geometry/inputs is the
bar; **score reconciliation vs lodged is a later, expert-led step** — do NOT tune
the mapping to hit the lodged rating, and do NOT widen tolerances.
---
## D1 — Heat-loss perimeter & party walls (from measured wall areas)
**Decision:** full-SAP lodges no perimeter; derive it from measured per-wall areas
classified by `wall_type`:
- `{1,2,3}` exposed → `heat_loss_perimeter_m` = Σarea ÷ storey_height
- `4` party → `party_wall_length_m` = Σarea ÷ storey_height
- `5` internal partition → discarded
- any other code → `raise UnmappedApiCode("wall_type", code)` (**fail loud**)
`wall_type` is a ~99%-clean classifier (corpus). Accept the ~1% tail where a party
wall is mis-typed as 2 (`Common wall`/`Stair Wall`); revisit with name hints only
if the later score comparison flags it. Geometric `4·√(footprint)` is fallback only
(no usable walls). Detail + worked examples: `perimeter_decision.md`.
## D2 — Window / door / roof opening collapse
**Decision:** join `sap_openings``sap_opening_types` by name (100% join in corpus),
then route by opening-type `.type`:
- `4` vertical window → `sap_windows` (`SapWindow`: width, height, `u_value`
`window_transmission_details`, orientation, glazing_type, frame_factor)
- `5` roof window / rooflight → `sap_roof_windows` (`SapRoofWindow`: area=w×h,
u_value, orientation, pitch, frame_factor, g⊥←solar_transmittance)
- `1,2,3` doors → `door_count` = #door-openings; `insulated_door_u_value` =
area-weighted door U (only 46/1000 certs lodge >1 distinct door U); doors treated
insulated (new-build)
- any other opening-type code → `raise UnmappedApiCode("sap_opening_type", code)`
Lower-risk than the handoff implied: the engine has first-class fields for all three
opening kinds, and full-SAP supplies richer per-opening geometry than RdSAP.
## D3 — Living-area fraction (back-solve room count)
**Decision:** full-SAP measures `living_area` but the engine only reads it via
`habitable_rooms_count` → Table 27. **Mapper-only:** set `habitable_rooms_count` to
the room count whose Table 27 fraction is closest to the measured `living_area/TFA`,
so the existing engine path reproduces the measured fraction.
**Known caveat (to go back to):** `habitable_rooms_count` is *also* read for the
Table 5 extract-fan minimum (`max(measured_fans, room_count_default)`,
`cert_to_inputs.py:4534`). A back-solved count can raise that floor. Bounded
(fans 14) and **moot for the 445/1000 mechanically-ventilated certs**; bites only
in the natural-vent subset where measured fans < default. **v1: accept** (keeps the
decision mapper-only). Principled future fix: suppress the Table-5 "unknown" default
for full-SAP, since its ventilation is measured, not unknown.
---
## D4 — Fabric U-values: carry descriptions through; age_band is fallback only
**Pivotal finding:** the engine's `u_wall`/`u_floor`/`u_roof` first parse the element
`description` for `"Average thermal transmittance X W/m²K"` and return that measured U
directly, bypassing the age-band cascade (`rdsap_uvalues.py:546`; wired at
`heat_transmission.py:589,779`). The parser docstring: *"On full-SAP certs the
assessor enters the BS EN ISO 6946 result directly here in lieu of using the cascade."*
Corpus: walls parse to measured U **984/1000**; floors 692 (rest = "(other premises
below)" internal, no loss); roofs 715 (rest = "(other premises above)" internal).
**Decision:** map full-SAP top-level `walls`/`floors`/`roofs` energy-element lists via
the existing `_map_energy_elements` (preserves `description`). The measured fabric U then
flows into the engine it is NOT discarded. This reverses the initial "RdSAP engine
re-derives from fabricated band-M and over-rates" concern for ~98% of walls.
`construction_age_band` is therefore a **low-stakes fallback**, not the primary fabric
driver. Fabricate band **M** from `construction_year`/`assessment_date` (new-build era);
used only for the ~2% of walls with no parseable U and for secondary age-band logic
(cylinder-insulation default, suspended-timber-floor sealed/unsealed branch, Table 5 fan
default). **Fail loud** (`UnmappedApiCode`) only if neither a year nor a usable band can
be established. *(Note: this also shrinks the D3 living-area physics-gap framing the
score is far more faithful than the early no-engine estimate suggested.)*
## Corpus validation (1,000 certs, full chain through Sap10Calculator)
Run after slices 17 + D5/D6 landed (`from_api_response` `Sap10Calculator`):
- **727/1000 (72.7%) run end-to-end; 0 calculator failures** where a cert maps,
it calculates cleanly (robust mapping, no engine crashes).
- **Initial 273 map-failures** were all one variant: code-based heating
(`main_heating_category` 6/10 community heating, heat pumps) lodging
`main_heating_code` instead of explicit `heat_emitter_type`/`main_fuel_type`.
**RESOLVED (slice D6b):** mapped `main_heating_code → sap_main_heating_code`
(a known variant the calculator already consumes), defaulting the absent
explicit fields. **Corpus mappability now 1000/1000 (100%); calc 1000/1000.**
- **Score divergence (calc lodged, full mapping, n=1000):** median **8**,
mean **11.4**; within ±10 = 55%. The engine **systematically under-rates**
full-SAP certs. This is **deferred to a separate SAP-calc verification task**
(mapping fidelity is the deliverable here; the score is not). Known drivers,
in rough order: (a) measured **MEV** ventilation priced as added extract loss
(4 isolated on the sample); (b) **code-based heating** (276 certs) currently
defaults the absent fuel/emitter to 0 these systems map (no crash) but are
not yet fully modelled, so their scores are unreliable *by mapping*, not calc;
(c) fabricated RdSAP proxies (age band, back-solved room count). The mapper is
**never tuned to the lodged value**; pins are the observed score.
## Open / not yet grilled
- D5 `ventilation_type` (1/5/6/8…) `MechanicalVentilationKind` (NATURAL/MEV/MVHR/PIV); `air_permeability` AP4
- D6 heating: full-SAP `sap_heating.main_heating_details` shape vs RdSAP field names
- D7 wall/roof/floor `construction` code mapping (full-SAP `wall_type` domain `wall_construction` for U-derivation)
- D8 `_clear_basement_flag_when_system_built` applicability
- D9 dwelling_type/built_form/property_type/tenure code mapping; 11/1000 null `built_form`
- D10 schema dataclass optionality (corpus presence: `sap_flat_details` 44%, `design_water_use` 94%, etc.)