mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
- 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>
110 lines
6.8 KiB
Markdown
110 lines
6.8 KiB
Markdown
# 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 1–4) 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 1–7 + 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.)
|