diff --git a/CONTEXT.md b/CONTEXT.md index 99aed3a5..944d79c7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -246,6 +246,10 @@ _Avoid_: restricted measures (legacy collapsed conservation/listed/heritage into The rule fixing which single roof Measure the main-roof **Recommendation** offers, by roof type. One Measure per roof — never a menu the Optimiser chooses between (ADR-0021): **pitched with an accessible loft** (incl. **thatch** — the covering doesn't block insulating the loft floor) → **loft insulation** (laid flat at the ceiling joists, 300 mm); **pitched with a sloping ceiling** → **sloping-ceiling insulation** (at the rafters, 100 mm); **flat roof** → **flat-roof insulation** (200 mm); **pitched, no access** → none (can't reach the void). A **room-in-roof** takes neither loft nor sloping-ceiling insulation — it is insulated at its own slopes/stud-walls (rafter-area, not floor-area, quantity) as a distinct **room-in-roof insulation** Measure, currently **deferred** (pending retrofit-specialist examples). A Measure is offered only when the roof is genuinely uninsulated ("As Built" / "None" / 0 mm). _Avoid_: "roof insulation" (name the specific Measure — loft / sloping-ceiling / flat-roof / room-in-roof); "joist insulation" (use **loft insulation**, the established Measure Type) +**Glazing Eligibility**: +The rule fixing the single glazing Measure the **Windows** Recommendation offers. We upgrade **only single-glazed windows** (a pragmatic scope — already-double/secondary/triple windows are left alone), **all of them together** as one Measure. Planning status **hard-picks** the Measure (not a choice the Optimiser makes — ADR-0022): unrestricted → **double glazing** (replace the units); a **conservation area** / **listed** / **heritage** building → **secondary glazing** (an internal second pane, since the external units can't be replaced). Priced at a flat **average price per window** × the count of single-glazed windows (we have per-window areas but no size-varying prices, so size is ignored). When the dwelling has no single-glazed windows, no Recommendation is offered. +_Avoid_: "windows" as a Measure (name **double glazing** / **secondary glazing**); pricing glazing by area (it's per-window count × average) + ### Valuation **Property Valuation**: diff --git a/docs/adr/0022-glazing-eligibility-and-overlay.md b/docs/adr/0022-glazing-eligibility-and-overlay.md new file mode 100644 index 00000000..47a216d6 --- /dev/null +++ b/docs/adr/0022-glazing-eligibility-and-overlay.md @@ -0,0 +1,37 @@ +# Glazing Eligibility, Overlay, and Pricing + +The glazing Recommendation Generator offers a windows upgrade. Three decisions are load-bearing and non-obvious: how planning status picks the Measure, how the Simulation Overlay models "replace the windows" (which is *not* what you'd guess from how Elmhurst works), and how the upgrade is priced. + +## Decision + +**Single dispatching generator, one planning-picked Measure, all single-glazed windows together.** `recommend_glazing(epc, products, restrictions)` emits one "Windows" Recommendation with a **single** Measure Option: + +- **Scope:** only **single-glazed** windows (`glazing_type == 1`, SAP10.2 Table U2 "Single"; the Elmhurst mapper sends `"Single"`/`"Single glazing"` → 1 and the API passes int 1, so it's path-consistent). Already-double/secondary/triple windows are left untouched. No single-glazed windows → no Recommendation (this subsumes legacy's "skip if already well-glazed"). +- **Planning hard-picks the Measure** (not competing Options for the Optimiser, unlike wall EWI/IWI in ADR-0019): unrestricted → `double_glazing`; any of conservation-area / listed / heritage → `secondary_glazing` (an internal second pane — the external units can't be replaced on a protected/over-looked building). Reuses the `PlanningRestrictions` value object from the wall work. +- **All single-glazed windows in one overlay**, upgraded as a block — the Optimiser takes or leaves the whole glazing job, matching how it's quoted/installed. + +**The overlay writes *lodged U-values and g-values directly*, because our calculator consumes them as inputs — it does not derive them from the glazing type.** This is the surprising part. Elmhurst *derives* a window's U-value from its glazing type as a UI convenience, then lodges the resulting U and g as manufacturer data on the cert. Our calculator, when every window carries a per-window `WindowTransmissionDetails.u_value` (the manufacturer-lodged case — `heat_transmission.py:490`), reads **each window's lodged `u_value` directly** for heat loss and its lodged `solar_transmittance` for gains (`solar_gains.py:300`); `glazing_type` is *not* used for U or g in that path. So changing `glazing_type` alone would move nothing. The `WindowOverlay` therefore sets three fields: + +```python +@dataclass(frozen=True) +class WindowOverlay: # all-optional partial of one SapWindow + glazing_type: Optional[int] = None # → SapWindow.glazing_type (drives the §5 daylight g_L only) + u_value: Optional[float] = None # → WindowTransmissionDetails.u_value (heat loss) + solar_transmittance: Optional[float] = None # → WindowTransmissionDetails.solar_transmittance (gains) +``` + +`EpcSimulation` gains a `windows: Mapping[int, WindowOverlay]` surface keyed by `sap_windows` index; `apply_simulations` folds `windows[i]` onto `sap_windows[i]`, routing `u_value`/`solar_transmittance` into its `WindowTransmissionDetails` (creating one if absent). `glazing_type` is still set so the §5 daylight/lighting factor (`internal_gains.py:544`, which *does* key on the code) matches the after-cert at 1e-4. Target values are pinned from the Elmhurst before→after cert (cert 001431: double → U 4.80→1.40, g 0.85→0.72; secondary → the lodged "Secondary glazing" spec). + +**Pricing: flat average price per window × count of single-glazed windows.** We now have per-window areas, but no size-varying prices, so size is ignored: `Cost.total = (number of single-glazed sap_windows entries) × product.unit_cost_per_m2`, reusing that field as a per-window price exactly as `mechanical_ventilation` reuses it per-unit. Double vs secondary price differently for free, via their distinct `measure_type` catalogue rows. One `sap_windows` entry counts as one window (deterministic; a grouped-row undercount is a catalogue calibration concern, not a model one). + +## Considered options + +- **Set `glazing_type` only and let the calculator derive U/g.** Rejected: our calculator reads the lodged per-window U/g directly when present (the manufacturer-lodged case), so a glazing-type-only overlay would leave the SAP unchanged. We must write the U/g. +- **Offer double and secondary as competing Options** (like EWI/IWI). Rejected: planning *determines* the Measure, so there's no choice to optimise — a single planning-picked Option is honest. +- **Per-window Recommendations / area-based pricing.** Rejected: glazing is quoted and installed as a whole-job block, and we have no size-varying prices — a flat per-window average over the single-glazed count is the faithful model. + +## Consequences + +- `EpcSimulation` grows a third overlay surface (`windows`), and the applicator gains its first **nested** write (into `WindowTransmissionDetails`) — deeper than the flat building-part fold, but required because that's where the cascade reads U/g. +- Cascade pins depend on the glazing-label mapper coverage (cert 001431 lodges several unmapped glazing labels — `"Secondary glazing"`, `"Secondary glazing - Normal emissivity"`, `"Triple pre 2002"`, truncated `"Double…"`/`"Triple…"` variants); those must be mapped before the before→after cert parses. +- Because planning hard-picks the Measure, the glazing generator needs the Property's `PlanningRestrictions` threaded in — the same input the solid-wall generator already takes.