diff --git a/CONTEXT.md b/CONTEXT.md index 04116f87..f92c5b53 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,6 +39,14 @@ _Avoid_: schema version, EPC format An EPC issued for a residential dwelling, as opposed to a commercial one. _Avoid_: residential EPC, home EPC +**Non-Separated Conservatory**: +A conservatory joined to the dwelling without an external-quality thermal separation (open archway or only an internal-quality door). RdSAP treats it as **part of the dwelling** (§6.1): the assessor measures its glazed perimeter, floor area, glazing type and storey height, and its floor area, glazed walls (as a window), glazed roof (as a rooflight) and ground-loss all fold into the dwelling's score. On the gov register this is `conservatory_type = 4`, lodged as a glazed "building part" carrying only those four measurements — never fabric or floor dimensions. It is the only conservatory kind that affects the score, so it must be **carried as a distinct part of the dwelling picture**, not treated as a generic fabric building part (which mis-scores it and lacks the construction fields the register requires). +_Avoid_: "floor-only building part" / "degenerate part" (it is a fully-measured glazed conservatory — it only *looks* empty when a schema drops its four fields); "heated conservatory" (heating is the question asked of a **Separated Conservatory**, not this one) + +**Separated Conservatory**: +A conservatory partitioned from the dwelling by an external-quality door (substantial, draught-sealed). RdSAP **disregards** it entirely (§6.2) — no measurements are taken and it contributes nothing to the score; the register records only whether it has fixed heaters (`conservatory_type = 2` unheated / `3` heated) and lodges no glazed building part. `conservatory_type = 1` means no conservatory at all. +_Avoid_: conflating with a **Non-Separated Conservatory** (the separated one is scored as if absent); "thermally separated" as a synonym for "heated" (separation is about the door, heating is a further question asked only once separated) + ### Properties and addresses **Property**: diff --git a/docs/adr/0036-non-separated-conservatory-mapper-split-and-round-trip-persistence.md b/docs/adr/0036-non-separated-conservatory-mapper-split-and-round-trip-persistence.md new file mode 100644 index 00000000..ac5c0147 --- /dev/null +++ b/docs/adr/0036-non-separated-conservatory-mapper-split-and-round-trip-persistence.md @@ -0,0 +1,78 @@ +--- +Status: accepted +--- + +# Non-separated conservatory: mapper-owned §6.1 split, persisted 1:1, with round-trip fidelity as the contract + +Decided in a `/grill-with-docs` session (2026-06-24) while fixing the gov-API +conservatory path (repro cert 718138, `RdSAP-Schema-19.0`). Extends +[ADR-0015](0015-mappers-own-cert-normalization.md) (mappers own cert +normalization) and echoes [ADR-0035](0035-coherent-heating-system-synthesis.md) +(coherence is a synthesis-time obligation, never a calculator normalisation). + +## Context + +The gov register lodges a **Non-Separated Conservatory** (`conservatory_type = 4`) +as a glazed "building part" carrying only `{floor_area, room_height, +double_glazed, glazed_perimeter}` — no construction age band, no wall/roof, no +floor dimensions. Treated as a generic fabric building part it is **mis-scored** +(its floor heat-loss is counted as a normal part) and **cannot persist** (the +`epc_building_part` columns `construction_age_band` / `wall_construction` are +NOT-NULL). The correct RdSAP treatment folds it into the dwelling under §6.1; a +**Separated Conservatory** (`type 2/3`) is disregarded under §6.2 and lodges no +glazed part. See CONTEXT.md for both terms. + +Two placements were available for the §6.1 "split" (detect the glazed part, lift +it out of the fabric parts, carry it as the dwelling's conservatory): + +- in the **mapper**, producing a normalized `EpcPropertyData` with + `sap_conservatory` set and the glazed part excluded from `sap_building_parts` + (the existing 21.0.1 choice, commit `d501535c`); +- at the **calculator / persistence-reload boundary**, re-deriving the split + each time the picture is scored. + +The production flow is *map → save → reload-from-DB → score*. The mapper split +alone is invisible to the scored picture, because the reload (`_compose`) did not +reconstruct `sap_conservatory` and the glazed part was excluded from the +persisted `sap_building_parts` — so the conservatory was silently dropped on the +path that actually scores. This is the same latent gap the 21.0.1 fix carried. + +## Decision + +1. **The §6.1 split lives in the mapper.** It is cert-shape knowledge, not + physics (ADR-0015). `EpcPropertyData` — `sap_conservatory` populated, glazed + part excluded from `sap_building_parts` — is the canonical normalized shape. + We do **not** re-derive the split in the calculator or at reload; a global + coherence pass over the assembled picture is rejected for the same reason + ADR-0035 rejected one for heating. +2. **Persist the conservatory 1:1 on `epc_property`.** `SapConservatory` is a + single optional value per dwelling, so it is five nullable columns on + `epc_property` (alongside the existing scalar conservatory flags), not a child + table. Save writes them; `_compose` rebuilds `SapConservatory` from them. The + columns are FE/Drizzle-owned — documented in + `docs/migrations/epc-property-round-trip-fidelity.md`, applied there, never by + this repo. +3. **Round-trip fidelity is the contract that makes the split correct.** Because + the split lives only in the mapper, the *only* thing guaranteeing the scored + (reloaded) picture matches the mapped one is that `EpcPropertyData` survives + `save → get` unchanged. We treat that as an enforced invariant: the + deep-equality round-trip test (Model#1129) gains a non-separated-conservatory + case, and a structural field-coverage guard fails if any `EpcPropertyData` + field is neither reconstructed by `_compose` nor on a documented allow-list — + turning "latent until a fixture exercises it" into an explicit decision. + +## Consequences + +- A future reader sees a conservatory — a "building part" in the source — + persisted as flat columns on `epc_property` and absent from the dwelling's + building parts. That is deliberate: it is a §6.1 dwelling attribute, not a + fabric part. +- The persistence + guard are schema-version-agnostic, so they close the same + latent gap for the already-merged 21.0.1 path, not only 19.0. +- The structural guard documents `sap_roof_windows` and the `solar_hw_collector_*` + fields as known round-trip gaps (their FE columns are tracked follow-ups), so + they are visible rather than silently dropped. +- The per-schema mapper piece (declare the four glazed fields + filter + fold) + still has to be ported to the remaining gov mappers (17.0/17.1/18.0/20.0.0/ + 21.0.0) one validated cert at a time; this ADR covers the shared shape, not + that rollout. diff --git a/docs/migrations/epc-property-round-trip-fidelity.md b/docs/migrations/epc-property-round-trip-fidelity.md index 53590a2f..a23d0f62 100644 --- a/docs/migrations/epc-property-round-trip-fidelity.md +++ b/docs/migrations/epc-property-round-trip-fidelity.md @@ -48,11 +48,21 @@ and **still require the matching DB migration** wherever the physical tables liv - **§2.1 `epc_renewable_heat_incentive` table** (#1137) — now created on the SQLModel and wired into save/get; the round-trip test asserts **full deep-equality** (no exclusion). DB migration still required. +- **§3.1 conservatory geometry** (ADR-0036) — the five `conservatory_*` columns are now on the + SQLModel and wired into save/`_compose`; the round-trip test gains a non-separated-conservatory + case (deep-equality). DB migration still required. This closes the §6.1 conservatory round-trip + for **every** gov schema, not just the 19.0 cert that surfaced it. + +**Structural guard (ADR-0036):** `tests/repositories/epc/test_epc_persistence_field_coverage.py` +now fails if any `EpcPropertyData` field is neither reconstructed by `_compose` nor on a documented +allow-list — so the gaps below are no longer "latent until a fixture exercises them", they are +explicit and tracked. **Still open (follow-up issues):** the remaining §2 structural tables (room-in-roof detail, PV -arrays, roof windows) + §3 nested-wall fields (`SapAlternativeWall.u_value`/`wall_thickness_mm`) + -`SapFloorDimension` exposed-floor flags — none populated in the 21.0.0/21.0.1 fixtures, so latent -until a richer fixture exercises them. +arrays, **roof windows** — `sap_roof_windows`) + §3 nested-wall fields +(`SapAlternativeWall.u_value`/`wall_thickness_mm`) + `SapFloorDimension` exposed-floor flags + the +solar-HW collector columns (`solar_hw_collector_orientation` / `_pitch_deg` / `_overshading`, §3.1). +Each is on the structural-guard allow-list with a reason until its columns land. --- @@ -124,6 +134,11 @@ the Type-2 geometry and the Detailed-measurement surfaces. Replace with a child ### 3.1 `epc_property` additions | Column | Type | Source | Pri | |---|---|---|---| +| `conservatory_floor_area_m2` | float (double precision), null | `sap_conservatory.floor_area_m2` | **P0** | +| `conservatory_glazed_perimeter_m` | float (double precision), null | `sap_conservatory.glazed_perimeter_m` | **P0** | +| `conservatory_double_glazed` | bool, null | `sap_conservatory.double_glazed` | **P0** | +| `conservatory_thermally_separated` | bool, null | `sap_conservatory.thermally_separated` | **P0** | +| `conservatory_room_height_storeys` | float (double precision), null | `sap_conservatory.room_height_storeys` | **P0** | | `addendum_stone_walls` | bool, null | `addendum.stone_walls` | P2 | | `addendum_system_build` | bool, null | `addendum.system_build` | P2 | | `addendum_numbers` | JSONB, null | `addendum.addendum_numbers` (`List[int]`) | P2 |