diff --git a/docs/sap-spec/CALCULATOR_DESIGN_SKETCH.md b/docs/sap-spec/CALCULATOR_DESIGN_SKETCH.md deleted file mode 100644 index b11b8c30..00000000 --- a/docs/sap-spec/CALCULATOR_DESIGN_SKETCH.md +++ /dev/null @@ -1,224 +0,0 @@ -# Sap10Calculator — design sketch - -**Source specs:** `sap-10-3-full-specification-2026-01-13.pdf` (315pp, worksheet) + `rdsap-10-specification-2025-06-10.pdf` (114pp, cert→input rules). Both in this directory. - -**Status:** sketch only — not implemented. Drives ADR-0009 Session A scope. - ---- - -## Core insight: SAP is a monthly loop - -The worksheet (§§5–9) iterates over 12 months. Each month produces its own: -- mean external temperature (Table U1 by region) -- wind speed (Table U2) -- horizontal solar irradiance (Table U3) → converted per orientation via Table 6d -- mean internal temperature (Table 9 + HLP from worksheet 40) -- gains (internal Table 5 + solar §6.1) × utilisation factor η (Table 9a) -- losses (HLC × ΔT) -- useful space-heating demand -- delivered fuel demand (useful / efficiency) - -Annual quantities are the **sum across months**. The current `demand.py` collapses all of this into a single annual `HDH × HLC / η_heating`. That's a 1-step crude approximation of a 12-step loop. The single biggest physics gain in moving to the calculator is unrolling that loop. - ---- - -## Inputs (EpcPropertyData mostly already there) - -Already mapped in `EpcPropertyData`: -- `total_floor_area_m2`, `sap_building_parts[*].sap_floor_dimensions[*]` (room_height, perimeter, party-wall length) -- `sap_windows[*]` (orientation, dimensions, frame_material, glazing_type, draught_proofed, window_transmission_details) -- `door_count`, `insulated_door_count`, `insulated_door_u_value` -- `sap_building_parts[*]` (wall/roof/floor construction, age band, insulation thickness/type) -- `sap_heating.main_heating_details[*]` (sap_main_heating_code, fuel, emitter, controls, fraction) -- `sap_heating.water_heating_*`, `cylinder_size`, `cylinder_insulation_thickness_mm` -- `sap_energy_source.photovoltaic_arrays`, `solar_water_heating` -- `open_chimneys_count`, `blocked_chimneys_count`, `flueless_gas_fires_count` (on SapVentilation) -- `region_code` (1–22; SAP10.3 uses regions 0–21 with 0=UK avg — confirm mapping) -- `country_code` for ENG/SCT/WAL/NIR table overrides - -Missing / null in current corpus (likely needs slice 18e mapper fix): -- `pressure_test` (100% null per HANDOFF §7-D) — SAP10.3 §2.3: when populated, overrides worksheet lines 9–16 entirely -- `sap_ventilation.*` (mostly null) — fans, passive vents, AP4 -- `mechanical_ventilation` (100% null) - ---- - -## Output: SapResult - -```python -@dataclass(frozen=True) -class MonthlyBreakdown: - external_temp_c: tuple[float, ...] # 12 entries - internal_temp_c: tuple[float, ...] - heat_loss_kwh: tuple[float, ...] - solar_gain_kwh: tuple[float, ...] - internal_gain_kwh: tuple[float, ...] - utilisation_factor: tuple[float, ...] - useful_demand_kwh: tuple[float, ...] - delivered_main_kwh: tuple[float, ...] - delivered_secondary_kwh: tuple[float, ...] - delivered_hot_water_kwh: tuple[float, ...] - - -@dataclass(frozen=True) -class SapResult: - sap_score: float # 1-100+ rating - sap_band: str # A-G - co2_emissions_kgco2_per_m2: float - peui_kwh_per_m2: float - space_heating_kwh_per_yr: float - hot_water_kwh_per_yr: float - pumps_fans_kwh_per_yr: float - lighting_kwh_per_yr: float - total_fuel_cost_gbp: float - pv_export_credit_gbp: float - monthly: MonthlyBreakdown - worksheet: dict[int, float] # SAP10.3 worksheet line → value, for audit - notes: tuple[str, ...] # spec-decision provenance per cert -``` - -`worksheet[line]` lets product / surveyor UIs show the SAP worksheet line-by-line. Every named quantity in §§1-14 lands here. - ---- - -## Module layout (proposed under `packages/domain/src/domain/sap/`) - -``` -sap/ - __init__.py # exports Sap10Calculator, SapResult, MonthlyBreakdown - calculator.py # Sap10Calculator orchestrator: input -> 12-month loop -> SapResult - worksheet/ - dimensions.py # §1: floor area, storey height, volume; porch/conservatory inclusion - ventilation.py # §2: Table 2.1 + AP50/AP4 override + structural + mech vent - heat_transmission.py # §3: U×A per element, thermal bridging (Table R2 or global y) - hot_water.py # §4 + Appendix J: SAP §J port already shipped; folds in - internal_gains.py # §5 + Table 5/5a; Appendix L lighting - solar_gains.py # §6: per orientation, per month, with Z overshading - utilisation_factor.py # §6.4 + Table 9a η from gain/loss ratio - mean_internal_temp.py # §7 + Table 9/9a/9b: living area + rest, HLP, controls - space_heating.py # §9: useful demand, system efficiency, secondary fraction - fuel_cost.py # §12: monthly fuel use × Table 12 prices, PV export credit - energy_cost_rating.py # §13: ECF → SAP rating piecewise formula - co2_primary_energy.py # §14: CO2 and primary energy worksheets - tables/ - table_2_1.py # ventilation rates (chimney 80, flue 35, fan 10, etc.) - table_4a_4b.py # heating-system seasonal efficiency - table_4c.py # low-temp emitter adjustments - table_4e.py # controls bonus / mean-internal-temp adjustment - table_5.py # internal gains by floor area - table_5a.py # additional gains: pumps, MVHR fans - table_6b.py # glazing solar transmittance g_⊥ - table_6c.py # frame factors FF - table_6d.py # solar access factor Z + over-shading categories - table_6e.py # window U-value defaults - table_9.py # mean internal temp (living + rest of dwelling) - table_9a.py # utilisation factor η formula - table_11.py # main/secondary heating split fraction - table_12.py # fuel prices (SAP10.3 replacement for SAP10.2 Table 32) - table_R2.py # default Ψ for non-repeating thermal bridges - climate/ - appendix_u.py # U1 external temp, U2 wind speed, U3 solar irradiance (each 22 regions × 12 months) + solar declination - postcode_to_region.py # Table U6: postcode prefix → region code - orientation_flux.py # convert horizontal solar U3 → per-orientation per-tilt surface flux - rdsap/ - cert_to_inputs.py # RdSAP 10 cert → calculator input mapping - cascade_defaults.py # current rdsap_uvalues.py logic, moved here -``` - -Modules in `domain.ml.*` stay in place during Session A (live ML pipeline keeps running). Session B promotes the deterministic logic out of `domain.ml.{envelope,demand,ecf,ventilation,sap_efficiencies,rdsap_uvalues}.py` into `domain.sap.*` once Session A reaches parity. The ML transform then imports calculator outputs as a single `predicted_sap_score` feature for residual learning (per ADR-0009). - ---- - -## Sap10Calculator.calculate — orchestration sketch - -```python -class Sap10Calculator: - def __init__(self, climate: ClimateData = APPENDIX_U, pcdb: Optional[PcdbLookup] = None) -> None: ... - - def calculate(self, epc: EpcPropertyData) -> SapResult: - inputs = cert_to_inputs(epc) # RdSAP 10 mapping; fills defaults - - # Static, region-independent quantities - dim = compute_dimensions(inputs) # §1 — TFA, volume, storey heights - hlc_T = heat_transmission_w_per_k(inputs) # §3 — Σ U·A + thermal bridging - n_inf = ventilation_ach(inputs, dim) # §2 — infiltration ACH (excl. AP override) - eff = heating_efficiencies(inputs, self.pcdb) # §9.2 — winter/water/secondary - - # 12-month loop - months = [] - for m in MONTHS: - ext_t = self.climate.external_temp(m, inputs.region) - wind = self.climate.wind_speed(m, inputs.region) - n_total = apply_wind_shelter(n_inf, wind, inputs.sheltered_sides) - hlc_V = n_total * dim.volume * 0.33 - hlc = hlc_T + hlc_V # W/K - int_gain = internal_gain_w(dim.tfa, inputs.lighting, m) # §5 + Appendix L - sol_gain = sum(solar_gain_w(w, m, inputs.region) for w in inputs.windows) # §6 - int_t = mean_internal_temp(hlc, dim.tfa, eff, inputs.controls, m) - losses_w = hlc * (int_t - ext_t) - gains_w = int_gain + sol_gain - eta = utilisation_factor(gains_w, losses_w, dim.tmp) # §6.4 Table 9a - useful_w = max(0.0, losses_w - eta * gains_w) - useful_kwh = useful_w * HOURS_PER_MONTH[m] * 1e-3 - fuel_kwh = useful_kwh / eff.winter - months.append(MonthlyEntry(...)) - - annual = aggregate(months) # sum + lighting + pumps/fans + PV - cost = fuel_costs(annual, inputs.fuel_prices) - ecf = (cost - pv_export) / dim.tfa - sap = sap_rating(ecf) # §13: 117 - 121*log10(ECF) etc. - co2 = co2_emissions(annual, inputs.carbon_factors) - peui = (annual.delivered_total) / dim.tfa - - return SapResult(sap, ..., monthly=MonthlyBreakdown(...), worksheet=ws_dict, notes=...) -``` - ---- - -## What we already have vs what we need to write - -| Worksheet area | Status | Module today | Action | -|---|---|---|---| -| §1 Dimensions | ~70% | implicit in `envelope.py:_part_geometry` | extract into `worksheet/dimensions.py`; handle porch/conservatory/RIR inclusion rules | -| §2 Ventilation | ~25% | `ventilation.py` (slice 20a tracer; wrong rates) | rewrite per Table 2.1 + worksheet lines 9-16 + AP override + mech vent | -| §3 Heat transmission | ~80% | `envelope.py`, `rdsap_uvalues.py` | move into `worksheet/heat_transmission.py`; add Table R2 thermal bridging | -| §4 Hot water | ~70% | `demand.py:predicted_hot_water_kwh` (SAP §J port from slice 17b) | move to `worksheet/hot_water.py`; verify against §4 + Appendix J | -| §5 Internal gains | 0% | nothing | new — Table 5 by floor area + Table 5a additions | -| §6 Solar gains | 0% | nothing | new — per-orientation per-month with Table 6b/6c/6d + Appendix U3 | -| §6.4 Utilisation factor | 0% | nothing | new — Table 9a η from gain-to-loss ratio | -| §7 Mean internal temp | 0% | nothing | new — Table 9 + HLP + Table 4e controls | -| §8 Climate | ~10% | `demand.py:_HDH_BY_REGION` (annual HDH) | replace with 12-month tables U1/U2/U3 | -| §9 Space heating | ~50% | `demand.py:predicted_space_heating_kwh` (annual), `sap_efficiencies.py` (Tables 4a/4b) | rewrite as monthly loop; add Table 4c low-temp adjustments, MCS installation factors, secondary heating fraction | -| §12 Fuel cost | ~80% | `ecf.py`, `sap_efficiencies.py` (Table 32→SAP10.3 Table 12) | verify Table 12 prices vs Table 32; add lighting+pumps/fans energy use; PV export credit already in slice 17a | -| §13 SAP rating | 0% | nothing (ML emits it) | new — §13 piecewise log formula 117 − 121·log10(ECF) etc. | -| §14 CO2 + primary | 0% | nothing | new — Table 12 carbon factors + primary-energy factors | -| Climate (Appendix U) | 0% | `_HDH_BY_REGION` only | new — Tables U1/U2/U3 module | -| RdSAP cert mapping | ~60% | scattered across `transform.py` + `envelope.py` + `demand.py` | consolidate into `rdsap/cert_to_inputs.py`; clean separation between extraction and physics | - -**Rough effort estimate (re-validating HANDOFF §High-value next slices):** - -- Session A — bring up the monthly loop end-to-end on typical certs: ~4-5 hrs. - - 1.5 hrs: Appendix U tables, dimensions, ventilation rewrite per Table 2.1 + worksheet lines 9-16 - - 1.5 hrs: solar gains (per-orientation per-month) + internal gains + utilisation factor - - 1 hr: mean internal temp + monthly loop wiring - - 1 hr: SAP rating formula + CO2/primary-energy worksheets + end-to-end test on 5 sample certs -- Session B — edge cases + 1000-cert parity validation: ~4 hrs. -- Session C — PCDB lookups + residual head training: ~3 hrs. - -Total ~11-12 hours of Claude-time, matching HANDOFF estimate. - ---- - -## Open questions for the user (before Session A) - -1. **Heat pump COP source.** SAP10.3 §9.2.7 says PCDB preferred, else Table 4a. PCDB integration is Session C. For Session A do we use Table 4a defaults only, accepting a ~1 SAP-point penalty on PCDB-listed heat pumps? - -2. **MCS installation factors.** Worth applying (×1.39 GSHP space etc.) when MCS-certified? The cert doesn't carry MCS certification explicitly — would need a feature flag. - -3. **Thermal bridging.** Two paths: global y factor (current) or per-junction Table R2 sum. Per-junction needs junction-count inputs that aren't on the cert. Recommendation: stay with global y for the cert-driven calculator; offer per-junction as an override path for new-build / Site Notes inputs. - -4. **Living area fraction.** SAP10.3 §7.1 says "the largest public room"; the cert doesn't carry this. RdSAP 10 spec probably defaults it — confirm in the RdSAP read. - -5. **Secondary heating allocation.** Table 11 splits main/secondary; the cert carries `secondary_fuel_type` but no explicit fraction. RdSAP 10 likely has a default split per main-system type — confirm. - -6. **Validation cohort.** 1000 random certs from `data/ml_training/runs/2025_2026_n250000_v18a/data.parquet`, or filter to "clean" subset first (drop catastrophic-tail noise where cert SAP itself looks anomalous)? Recommend first pass on the full random sample so we measure raw parity, then quantify how much of the residual comes from cert-data anomalies. diff --git a/docs/sap-spec/HANDOVER_NEXT.md b/docs/sap-spec/HANDOVER_NEXT.md index 315d5c93..02804dd4 100644 --- a/docs/sap-spec/HANDOVER_NEXT.md +++ b/docs/sap-spec/HANDOVER_NEXT.md @@ -68,7 +68,33 @@ The user is frustrated with previous agents because: If you find yourself about to widen a tolerance, add an xfail, or skip a fixture — **stop and ask the user.** Those are anti-patterns for this project. -### A.4 Workflow rules +### A.4 Reporting format — use the matrix + +The user prefers the **per-(fixture × line-ref) matrix** for cohort scoreboard +updates. Example shape (use this exactly when reporting cascade-pin status): + +``` +field | 000474 | 000477 | 000480 | 000487 | 000490 | 000516 +sap_score (int) | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ +sap_score_continuous | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ +ecf | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ +... +``` + +Or with numeric residuals when finer granularity helps: + +``` +fixture | LINE_31 Δ | LINE_33 Δ | LINE_36 Δ | LINE_37 Δ +000474 | 0.0014 | 0.0296 | 0.0002 | 0.0294 +000477 | 0.0004 | 0.1246 | ✓ | 0.1244 +... +``` + +✓ = within `abs=1e-4`. Numeric value = the actual diff. This format lets the +user scan visually and spot per-fixture vs per-line patterns. Use it instead +of prose summaries when reporting scoreboard state. + +### A.5 Workflow rules - **Don't scan >50 lines of spec PDF without checking with the user** for the specific page/table range. Spec PDFs are big and the user has the page diff --git a/docs/sap-spec/HANDOVER_SECTION_6.md b/docs/sap-spec/HANDOVER_SECTION_6.md deleted file mode 100644 index c4132861..00000000 --- a/docs/sap-spec/HANDOVER_SECTION_6.md +++ /dev/null @@ -1,148 +0,0 @@ -# Handover — SAP 10.2 §6 Solar gains - -**For the agent picking up §6.** Read this BEFORE invoking `/grill-me`. Read all of it. - -Owner: `khalim@domna.homes`. Branch: `ara-backend-design-prd`. - -## 1. Mission - -Implement §6 Solar gains (xlsx rows ~332-371) to **6-fixture worksheet conformance** with the same shape and tolerances we landed for §4 and §5. - -Worksheet lines you must close: -- **(74)** Z access factor (Table 6d, FIRST column — solar heating; NOT the lighting Z_L from §5) -- **(75)-(82)** per-orientation solar gain per month (N, NE, E, SE, S, SW, W, NW) -- **(83)** Total solar gains per month = Σ (75)..(82) -- **(84)** Total gains = (73) internal + (83) solar - -**Success criterion (non-negotiable):** all 6 Elmhurst U985 fixtures conform to **≤5e-3 W on every line**, every month. Tolerances in the parametrised tests must be tight — `abs=5e-3` or tighter. **Do not loosen tests to make them pass.** If a fixture won't close to that tolerance, pause and ask the user. - -The user expects "0 error". Anything looser means we missed a Table/formula. - -## 2. Codebase state (read first) - -- **Existing §6 file:** [packages/domain/src/domain/sap/worksheet/solar_gains.py](../../packages/domain/src/domain/sap/worksheet/solar_gains.py) — already has `Orientation` enum + `surface_solar_flux_w_per_m2()` leaf + `window_solar_gain_w()` leaf. **DO NOT assume these are correct or complete.** §5 had the same "looks done" appearance and was a 4-of-8 stub. Audit before reusing. -- **Existing §6 tests:** [test_solar_gains.py](../../packages/domain/src/domain/sap/worksheet/tests/test_solar_gains.py) — 7 leaf tests, **no per-fixture conformance**. Likely needs rewrite. -- **Existing cert→inputs window mapping:** [cert_to_inputs._window_inputs](../../packages/domain/src/domain/sap/rdsap/cert_to_inputs.py) at L379 already aggregates windows with `pitch_deg=90`, `g_perpendicular` from Table 6b, `frame_factor` from Table 6c, `overshading_factor=0.77` hardcoded (AVERAGE solar Z). Reuse the table lookups. -- **6 Elmhurst fixtures:** `_elmhurst_worksheet_NNNNNN.py` — each carries `LINE_4_TFA_M2`, `LINE_5_VOLUME_M3`, and worksheet outputs through §5. You will append: - - `SECTION_6_WINDOW_AREAS_BY_ORIENTATION_M2: dict[Orientation, tuple[float, ...]]` - - `SECTION_6_ROOFLIGHT_AREAS_M2: tuple[float, ...]` (or merge into the dict at `Orientation.HORIZONTAL`) - - `LINE_74_Z_ACCESS_FACTOR: float = 0.77` (default AVERAGE) - - `LINE_75_M_*` through `LINE_84_M_TOTAL_GAINS_W` 12-tuples -- **Calculator wiring:** [calculator.py:_solar_gains_w](../../packages/domain/src/domain/sap/calculator.py) currently calls the leaf `_solar_gains_w` per-month inside `_solve_month`. After §6 rebuild, the orchestrator should produce a 12-tuple that `CalculatorInputs.solar_gains_monthly_w` carries forward (mirror the §5 wiring pattern). - -## 3. Implementation pattern — mirror §5 EXACTLY - -Follow the §4/§5 slice precedent. **One slice = one test → one impl → one commit.** Slice commit message format: `§6 slice N: `. - -Suggested slice ordering (build in this order, tracer first): - -1. **Tracer:** simplest line. Probably `(74) z_access_factor` lookup from `OvershadingCategory` (mirror `_Z_L_BY_OVERSHADING` from internal_gains). -2. **Per-orientation flux leaves:** revalidate existing `surface_solar_flux_w_per_m2` against one worksheet's (75)..(82) column values. If wrong, fix; if right, keep. -3. **Per-window gain leaf:** `window_solar_gain_w` — formula `(75)m = A_w × S × g_⊥ × FF × Z`. Validate against worksheet to 4 d.p. -4. **Sum per orientation per month:** Σ over windows of the same orientation. -5. **(83) total solar gains:** Σ across orientations for each month. -6. **`SolarGainsResult` frozen dataclass** holding 9 line tuples ((75)..(83)). -7. **`solar_gains_from_cert` orchestrator** — signature mirrors `internal_gains_from_cert`: - ```python - def solar_gains_from_cert( - *, - epc: EpcPropertyData, - region: int, - overshading: OvershadingCategory = OvershadingCategory.AVERAGE, - rooflight_windows: tuple[RooflightInput, ...] = (), # if needed - ) -> SolarGainsResult: ... - ``` -8. **Extract LINE_74_*..LINE_84_* from all 6 U985 PDFs** via a `/tmp/extract_section6.py` script. Mirror the `/tmp/extract_section4.py` / `extract_section5.py` precedent. -9. **Populate fixtures + add ALL_FIXTURES-parametrized e2e test** with abs=5e-3 tolerance. -10. **Wire calculator.py** — add `solar_gains_monthly_w: tuple[float, ...]` to `CalculatorInputs`, replace per-month `_solar_gains_w` call with index lookup, update `cert_to_inputs` to call `solar_gains_from_cert`. Update synthetic test constructors. -11. **Delete legacy `_solar_gains_w` from calculator.py** + any superseded leaf fns. -12. **Update [SPEC_COVERAGE.md](SPEC_COVERAGE.md)** §6 row + add `## §6 — slice progress` table. - -## 4. Lessons from §5 that the §6 agent must absorb - -These cost time during §5. Don't repeat. - -1. **Table 6d has THREE columns.** First = solar heating Z (0.77 AVERAGE), second = solar cooling Z (0.9 AVERAGE), third = lighting Z_L (0.83 AVERAGE). For §6 you use the FIRST column. (§5 used the third — different value.) -2. **Table 6d note 2: rooflights use Z = 1.0** regardless of overshading bucket. Handle this in your orchestrator; do not assume a single Z applies to every window. 000516 has a 1.18 m² rooflight. -3. **Worksheet rounds intermediate values at 4 d.p.** When your formula gives `52.0001` and the worksheet shows `52.0000`, that's worksheet display rounding — your code is right. -4. **Existing modules are not trusted.** §5's was labelled "Full" in SPEC_COVERAGE but was 4-of-8 lines + a broken lighting fallback. Audit the existing `solar_gains.py` against worksheet (75)..(83) for 000490 before reusing it. -5. **`cert_to_inputs._window_inputs` hardcodes Z_solar=0.77 (AVERAGE).** Replace with the overshading enum in the orchestrator. The hardcode is a known shortcut. - -## 5. **CRITICAL: stop and ask, do not scan** - -§5 wasted ~30-60 min scanning the SAP10.2 spec PDF looking for the missing reconciliation factor on lighting. The factor was a single table column the agent kept missing. - -**Hard rule for §6:** if a formula or table lookup doesn't reconcile within **15 minutes** of normal debugging, **STOP** and ask the user (`AskUserQuestion`) for a worked example or pointer to the right table/page. The user has the worksheets, the spec PDFs, and domain knowledge. Asking is cheap; rabbit-holing is expensive. - -**Specific triggers to ask the user, not scan:** -- A formula gives the wrong value and you've checked the obvious inputs. -- You're not sure which Table or column applies (e.g. "Z for solar heating vs cooling vs lighting"). -- A cert field's value doesn't map cleanly to a SAP code. -- You're considering reading more than ~50 lines of spec text. - -Format the question with the data: "I'm getting X, worksheet says Y, for fixture 000NNN. The formula I'm using is Z. Which table/factor am I missing?" - -## 6. Pre-grilling — unknowns the agent must surface - -The user will run `/grill-me` before implementation. The grilling session should tease out at least these branches; the agent should propose recommended answers for each: - -1. **Scope of rebuild.** Audit existing `solar_gains.py` against 000490 worksheet (75)..(83). If it's exact, extend with orchestrator + fixture wiring. If it's wrong, rebuild like §5. (Likely rebuild; see lesson 4.) -2. **Orchestrator API shape.** Mirror §5 — takes `epc`, `region`, `overshading`, optional rooflight metadata. Returns `SolarGainsResult` (frozen dataclass with 9 line tuples). -3. **Multi-orientation handling.** Worksheet shows (75)..(82) per cardinal+inter-cardinal direction. How does the orchestrator group/aggregate windows? Likely via `Orientation` enum already in solar_gains.py. -4. **Region / Table U3 solar flux.** Each fixture has a region code (000490 = East Pennines). Confirm `region` field exists on EPC and feeds into surface_solar_flux_w_per_m2. -5. **Rooflight handling.** 000516 has a rooflight. Per Table 6d note 2: Z=1.0 + horizontal pitch → Table U3 horizontal flux. Decide: separate `rooflight_windows` arg or `pitch_deg=0` in the SapWindow. -6. **`cert_to_inputs._window_inputs` reconciliation.** That helper already maps SapWindow → WindowInput at pitch_deg=90 with Z=0.77. Decide: reuse + extend, or replace. -7. **Calculator.py wiring scope.** Same as §5: add `solar_gains_monthly_w` 12-tuple to `CalculatorInputs`, replace inline `_solar_gains_w` call. -8. **E2e SAP-score test impact.** Wiring §6 may shift 000490 / 000474 SAP scores. Decide whether to update expected values or pin to current. -9. **Column A vs Column B (Table 5 analogue).** §6 uses the solar HEATING column always for rating + DPER/TPER. The COOLING column (second) only applies to cooling-load calcs, which we're not doing. Document explicitly. -10. **Tolerance.** 5e-3 W on every line. Do not propose looser. - -## 7. References — bounded, do not browse - -Use these. **Do not scan more than ~50 lines per reference without checking with the user first.** - -- **SAP 10.2 spec PDF**: [docs/sap-spec/sap-10-2-full-specification-2025-03-14.pdf](sap-10-2-full-specification-2025-03-14.pdf). §6 prose is around page 12-15; Table 6b/6c/6d around page 178-181; Appendix U (solar flux Tables U3-U5) is the climate-data section. -- **RdSAP 10 spec PDF**: [rdsap-10-specification-2025-06-10.pdf](rdsap-10-specification-2025-06-10.pdf). Window data lodging conventions. -- **Canonical worked example xlsx**: [`2026-05-19-17-18 RdSap10Worksheet.xlsx`](../../2026-05-19-17-18%20RdSap10Worksheet.xlsx) at repo root. Cells in sheet `NonRegionalWeather` cover (74)..(84). -- **6 Elmhurst U985 worksheets**: `/workspaces/model/sap worksheets/U985-0001-NNNNNN.pdf`. These are the ground truth for conformance. -- **6 Elmhurst Summary PDFs**: `/workspaces/model/sap worksheets/Summary_NNNNNN.pdf`. Cert-input source. -- **§5 implementation as exemplar**: `packages/domain/src/domain/sap/worksheet/internal_gains.py` + `test_internal_gains.py` + `_elmhurst_worksheet_*.py` (search for `SECTION_5_*` and `LINE_66_M`..`LINE_73_M`). **This is the pattern to clone.** -- **§5 commit chain** (clone the shape): `git log --oneline --grep '§5 slice'` → 13 commits from `3ec56216` through `380115e2`. - -## 8. Test commands - -```bash -# §6 alone -python -m pytest packages/domain/src/domain/sap/worksheet/tests/test_solar_gains.py --no-header --no-cov - -# Full SAP suite (regression check) -python -m pytest packages/domain/src/domain/sap/ --no-header --no-cov -q - -# Per-fixture error diagnostic (write a temp script like the §5 one I used) -# Expect every line ≤5e-3 W on every fixture. -``` - -## 9. Commit conventions - -- Stage by name, never `git add -A` (user has untracked `sap worksheets/` PDFs that must not be committed). -- AAA test convention (`# Arrange / # Act / # Assert` literal headers). -- `Co-Authored-By: Claude Opus 4.7 ` trailer on every slice commit. -- One slice = one commit. Commit messages: `§6 slice N: ...`. - -## 10. When you finish - -- 6 fixtures conforming end-to-end on §6 to ≤5e-3 W. -- `solar_gains_from_cert` orchestrator wired into `cert_to_inputs` + `calculator.py`. -- Legacy per-month `_solar_gains_w` deleted. -- SPEC_COVERAGE.md §6 row flipped + slice progress table added. -- E2e SAP-score tests still passing (may need to update expected values; flag this to the user before changing them). -- A handover doc for §7 if you stop mid-flight. - -## 11. Definitely do NOT - -- **Do not** delete or modify the user's untracked `sap worksheets/` folder. -- **Do not** loosen test tolerances to make tests pass. Ask the user. -- **Do not** scan more than ~50 lines of a spec PDF before asking the user for the specific table/page. -- **Do not** modify existing fixture `build_epc()` functions unless asked — they're pinned for §1-§5 conformance and the e2e SAP-score regression. -- **Do not** assume the existing `solar_gains.py` is correct. Verify against the 000490 worksheet first. -- **Do not** invoke `/ultrareview` yourself (user-triggered, billed). diff --git a/docs/sap-spec/PARITY_FINDINGS.md b/docs/sap-spec/PARITY_FINDINGS.md deleted file mode 100644 index 38b476e9..00000000 --- a/docs/sap-spec/PARITY_FINDINGS.md +++ /dev/null @@ -1,107 +0,0 @@ -# Sap10Calculator parity probe — findings log - -100-cert sample from `data/ml_training/runs/2025_2026_n250000_v18a/data.parquet`. Each dated section is a separate measurement; the calculator and/or sample window evolve between them, so direct deltas are noted explicitly. - -## P5 baseline — 2026-05-19 (post-P5.14) - -Re-run after P5 (`SapResult.intermediate` trace exposure, 11 slices P5.1–P5.14). 100 certs, seed=7, `sap_score` window **5..99** (widened from 20..95 since the 2026-05-18 entry). 0 errors. Elapsed 71s. - -| Metric | 2026-05-18 (sap 20–95) | 2026-05-19 P5 (sap 5–99) | Δ | -|---|---|---|---| -| MAE | 8.41 | **4.29** | −4.12 | -| RMSE | 13.98 | **6.83** | −7.15 | -| Bias | −2.65 | **−2.15** | +0.50 | -| Within ±1 | 18.0% | **34.0%** | +16 pp | -| Within ±3 | 36.0% | **62.0%** | +26 pp | -| Within ±5 | 57.0% | **77.0%** | +20 pp | -| Within ±10 | 84.0% | **91.0%** | +7 pp | -| Worst residual | −56 | **−33** | +23 | - -**Attribution caveat.** The sample window changed (5..99 vs 20..95) — both ends were widened, so the delta blends "calculator improved" with "sample distribution shifted". The 2026-05-18 calculator state isn't reproducible from current main (the previous session's intermediate-population work landed before that probe but other intervening changes may have moved numbers too). P5 itself was pure trace exposure with one local refactor (P5.13 lifted the CO2 sum into named locals) — should be **numerically neutral on SAP score**, so most of the MAE drop is upstream-of-P5, not P5 itself. Treat this as the **post-P5 reference baseline**, not "P5 reduced MAE by 4 points." - -**Primary energy (kWh/m² TFA).** PE MAE **44.40**, PE bias **+39.66** (systematic over-prediction). Cohort mean: ours 231.7 vs cert 192.0 (+40). End-use split (ours): space 168.6, HW 49.6, lighting 10.6, pumps 2.9, PV 0.0. The space-heating PE is the dominant residual — bigger than the cert delta, suggesting the **fabric heat-loss + heating-efficiency cascade still over-counts** for typical mid-band stock. - -**PE bias stratified.** Worst PE bias by main_heating_category: cat 4 (range cookers, n=1) +85; cat 10 (electric storage, n=2) +94; cat 2 (gas boilers, n=88) +42. By age band: tightens monotonically newer→older (B/C/D ≈ +42-57, J/K/L ≈ +18-33). By dwelling: end-terrace bungalow +85 (n=2), mid-terrace bungalow +70 (n=3), end-terrace house +53 (n=16), mid-floor flat +7 (n=5). Mid-floor flats no longer dominate the residual — the S-B-flat-surfaces fix appears to have landed since 2026-05-18. - -**Worst-15 SAP residuals (this run).** Dominated by end-terrace houses and mid-terrace bungalows (over-prediction) plus one 32 m² mid-terrace bungalow with main_cat=10 electric-storage where actual=37, predicted=11 (−26; the S-B-electric-storage-tariff issue from 2026-05-18 is still open). - -**Next iteration priorities (P5 vantage):** -1. **Primary-energy over-prediction** is the dominant signal now (PE bias +40). Was visible in the 2026-05-18 data but masked behind larger SAP residuals; with SAP MAE halved, PE is the clearest target. Hypothesis: too-high space-heating PE from either over-counting fabric heat loss or too-low main-heating efficiency cascade. -2. **Electric storage tariff (S-B-electric-storage-tariff)** still open — the worst single residual is on a main_cat=10 cert. -3. **Bungalow over-prediction** persists (S-B-bungalow-investigation from 2026-05-18). Not flats-shape related; thermal-bridging y-factor × storey-count interaction worth probing. - -P5's actual contribution: **none on accuracy; full visibility on diagnosis.** Every PE bias number above is now a separate diagnostic on `intermediate` keys — `space_heating_pe_kwh_per_m2`, `hot_water_pe_kwh_per_m2`, etc. — so the next session can localise the over-prediction without re-instrumenting. - ---- - -## 2026-05-18 entry (historical, pre-P5 trace) - -100-cert random sample, filtered to cert sap-score 20-95 (typical band). 0 errors — calculator runs end-to-end on every cert. - -## Headline - -| Metric | Value | -|---|---| -| MAE | 8.41 SAP-points | -| RMSE | 13.98 | -| Bias | -2.65 (slight under-prediction) | -| Within ±1 | 18.0% | -| Within ±3 | 36.0% | -| Within ±5 | 57.0% | -| Within ±10 | 84.0% | -| Worst residual | -56 SAP-points | - -Session B success criterion is MAE ≤ 1.0 on the typical subset; we're 8× that on the first pass, which roughly matches ADR-0009's expectation that the first run shakes out spec-interpretation gaps. - -## Dominant failure shape: flats and bungalows under-predicted - -10 of the 15 worst residuals are flats or bungalows. **Pattern**: calculator charges floor + roof heat loss to dwellings that don't have exposed floor / roof surfaces (mid-floor flats, top-floor flats with party ceiling, etc.). - -Worst 15 (residual = predicted − actual): - -| Cert | actual | predicted | residual | TFA | dwelling | -|---|---|---|---|---|---| -| 0320-2756-7670-2196-2035 | 78 | 22 | -56 | 57 | Semi-detached bungalow | -| 0036-1125-8600-0165-2206 | 63 | 18 | -45 | 42 | Mid-floor flat | -| 0340-2394-5510-2925-4421 | 75 | 35 | -40 | 73 | Mid-floor flat | -| 9360-2179-9590-2495-2615 | 78 | 39 | -39 | 54 | Ground-floor flat | -| 0036-0529-1500-0700-8276 | 75 | 36 | -39 | 47 | Top-floor flat | -| 0350-2182-9590-2526-7841 | 43 | 4 | -39 | 119 | Top-floor flat | -| 2148-3061-6204-0016-7204 | 81 | 44 | -37 | 67 | Mid-floor flat | -| 0800-1364-0922-4522-3963 | 71 | 37 | -34 | 70 | Detached bungalow | -| 2110-6453-5050-8205-9605 | 63 | 31 | -32 | 43 | Ground-floor maisonette | -| 2903-8339-6962-6004-0725 | 75 | 47 | -28 | 11 | Top-floor flat | -| 0320-2850-3380-2125-1661 | 70 | 48 | -22 | 45 | Semi-detached bungalow | -| 8035-9023-1500-0237-3226 | 43 | 63 | +20 | 64 | Detached bungalow | -| 9590-7751-0022-0599-3953 | 51 | 69 | +18 | 74 | Detached house | -| 2118-1198-2619-1711-7960 | 62 | 46 | -16 | 42 | Mid-floor flat | -| 3336-3822-5500-0437-9202 | 70 | 59 | -11 | 73 | Mid-floor maisonette | - -## Session B iteration backlog (priority order) - -1. **S-B-flat-surfaces** — Map `dwelling_type` to exposed floor/roof flags. Mid/top flats lose their `u_floor × ground_floor_area`; mid/ground flats lose their `u_roof × top_floor_area`. Expected impact: closes most of the −20 to −56 residuals. -2. **S-B-heating-eff-fallback** — When `sap_main_heating_code` is None, fall back through `main_heating_category` + age band to a modern-condensing-boiler efficiency, not the legacy 0.80. ~28% of our 100-cert sample had a null code with category=2. -3. **S-B-electric-storage-tariff** — Electric storage heaters (codes 401-409) should price space-heating fuel at Economy-7 low rate (Table 32 code 31, ~5.5 p/kWh), not standard rate 30. This is a 2× cost reduction on those certs. -4. **S-B-wall-uvalue-cascade-review** — Worst non-flat residuals suggest the wall U-value cascade is too conservative for recently-built / well-insulated stock. Review `domain.ml.rdsap_uvalues.u_wall` against RdSAP 10 Table 5. -5. **S-B-bungalow-investigation** — Bungalow residuals don't fit the flat-surfaces pattern (bungalows have full floor+roof). Hypothesis: thermal-bridging y-factor + storey-count interaction over-counts envelope. Probe specifically before deciding. -6. **S-B-pump-fan-default** — We default to 130 kWh/yr; SAP 10.3 Table 4f says higher for systems with mechanical ventilation. Marginal but consistent. - -## RdSAP10 Table 32 Energy Cost Deflator drift (P5 finding, 2026-05-19) - -Surfaced while transcribing the SAP 10.2 worksheet into [test_bre_worked_examples.py](../../packages/domain/src/domain/sap/tests/test_bre_worked_examples.py) for P5. The calculator uses **`ENERGY_COST_DEFLATOR = 0.36`** (SAP 10.2 Table 12, item (256) on worksheet page 146 of [sap-10-2-full-specification-2025-03-14.pdf](sap-10-2-full-specification-2025-03-14.pdf)). The newer **RdSAP 10 specification Table 32** (page 95 of [rdsap-10-specification-2025-06-10.pdf](rdsap-10-specification-2025-06-10.pdf)) states the deflator is **0.42**, noting *"this table is equivalent to Table 12 in SAP10.2 specification"* — i.e., it supersedes the SAP 10.2 value for RdSAP assessments. - -**Why it matters.** The deflator scales the ECF numerator, which drives every SAP rating. Switching 0.36 → 0.42 changes every SAP rating numerically; in the linear regime the rating shifts by `−16.21 × ΔECF`, in the log regime by `−120.5 × Δ(log10 ECF)`. For a typical dwelling this is roughly **−2 to −4 SAP points** across the cohort. - -**What it doesn't explain.** The session-B residuals above are dominated by per-dwelling shape errors (mid-floor flats, bungalows) measured in tens of SAP points — a uniform 2-4 point shift won't move that needle. The deflator is a calibration call, not a model-shape fix. - -**Decision needed.** Whether the calculator targets SAP 10.2 ratings (keep 0.36, used for full SAP / new-build EPCs) or RdSAP 10 ratings (switch to 0.42, used for existing-dwelling EPCs derived from reduced data). ADR-0010 sets the target as SAP 10.3; the rdSAP10 publication is more recent than ADR-0010's reference points and may be the operative spec for the cohort we probe against. **Not changed in P5** — flagged for ADR-level resolution. - -## How to reproduce - -```bash -python adhoc/sap_calculator/probe_n.py # 100 certs, seed=7 -python adhoc/sap_calculator/probe_n.py 500 13 # bigger sample -python adhoc/sap_calculator/probe_worst.py # detailed cert-by-cert dump -``` - -`probe_n.py` runs in ~80s. Errors: 0/100. Mapper handles every real cert shape encountered. diff --git a/docs/sap-spec/SPEC_COVERAGE.md b/docs/sap-spec/SPEC_COVERAGE.md deleted file mode 100644 index 18c2f4e3..00000000 --- a/docs/sap-spec/SPEC_COVERAGE.md +++ /dev/null @@ -1,358 +0,0 @@ -# SAP 10.2 / RdSAP 10 Coverage Map - -Tracks which sections of the SAP 10.2 specification are implemented in `packages/domain/src/domain/sap/`. Per ADR-0009 the calculator is built from the spec, not reverse-engineered from cert data. This doc is the worksheet-driven roadmap for what remains. - -Updated 2026-05-21 after §8c (slices `cf28eec4`…`f3797066`), §8f (`43cc16bc`), §9a single-main slices (`2b5fc6a5`…`380b6781`), PCDB Table 105 integration (`fe04cd3a`…`a104dd55`), PCDB fixture lodgement (`1b43c95c`…`15d6b781`), §10a Fuel costs (`0f255165`…`adfa7f60`), and §4 HW PCDB Table 3b + Equation D1 (`760e25de`, `02fc9e4d`). - -The canonical SAP10.2 algorithm lives in [`2026-05-19-17-18 RdSap10Worksheet.xlsx`](../../2026-05-19-17-18%20RdSap10Worksheet.xlsx) at the repo root — each line ref `(1)..(486)` maps to a cell. The worksheet sub-modules under `packages/domain/src/domain/sap/worksheet/` implement those line refs directly; Elmhurst worksheets validate end-to-end via `tests/_elmhurst_worksheet_*.py`. - -## Sections §§1–13 (the SAP worksheet) - -| § | xlsx rows | Topic | Module | Status | Notes / gaps | -|---|---|---|---|---|---| -| 1 | 1–120 (approx) | Dimensions | `worksheet/dimensions.py` | **Full** | Porches, conservatories, RIR deferred per ADR-0009 | -| 2 | 121–206 (approx) | Ventilation | `worksheet/ventilation.py` | Partial | No mechanical ventilation (MVHR/MEV), no wind-shelter factor, no pressure-test override (worksheet lines 17-18), no AP4 override (worksheet line 19) | -| 3 | 121–207 | Heat transmission | `worksheet/heat_transmission.py` | **Full (non-RR)** | LINE_31/33/36/37 exact for both non-RR Elmhurst fixtures (000474, 000490). Suspended-timber + Table 20 exposed-floor routes wired. RR sub-areas (gable/slope/stud-wall) deferred until `SapRoomInRoof` carries them. Global y-factor (Table R2 per-junction deferred). | -| 4 | 207–304 | Hot water + Appendix J + Appendix D | `worksheet/water_heating.py` + cert_to_inputs `_water_heating_worksheet_and_gains` + `_apply_water_efficiency` | **Closed for combi-gas (Table 3b row 1) — Table 3c two-profile pending.** Worksheet line refs (42)..(65) + Appendix D §D2.1 (2) Equation D1 (`water_efficiency_monthly_via_equation_d1`) + Appendix J Table 3b row 1 (`combi_loss_monthly_kwh_table_3b_row_1_instantaneous`). 000474 + 000490 HW kWh match PDF to ≤0.1% (both lodge PCDB records with `separate_dhw_tests=1`). cert_to_inputs splits the §4 worksheet from the efficiency divisor: (45..65) runs early so §5 has (65)m heat gains; HW fuel kWh computed after §8 produces (98c)m for the Eq D1 cascade. PCDB Table 105 parser exposes 5 new combi-loss fields (separate_dhw_tests, r1, F1, F2, F3 + subsidiary_type + store_type) per BRE PCDF Spec v1.0 §7.11. **Table 3c two-profile combi loss not implemented**: PCDB records with `separate_dhw_tests=2` (Vaillant ecoTEC sustain 24/28 — affects 000477, 000480, 000487, 000516 from the Elmhurst cohort) fall through to Table 3a "keep-hot time-clock" 600 kWh/yr default, 25× over spec-faithful ~24 kWh/yr. Next ticket — see HANDOVER_NEXT.md. **Deferred**: Cylinder + solar + WWHRS + PV diverter + FGHRS branches (no fixture yet); Table 3b storage / FGHRS rows (no fixture yet); Electric CPSU Appendix F path (no fixture yet). | -| 5 | Internal gains + Appendix L | `worksheet/internal_gains.py` | **Full** | Worksheet-driven (66)..(73), Table 5 Column A, Table 5a 9-row dispatch + heating-season mask, Appendix L L1-L12 with RdSAP §12-1 bulb defaults + Table 6d Z_L (light access factor). Wired into `calculator.py` via `cert_to_inputs`. Six Elmhurst fixtures conform end-to-end to ≤0.6% lighting / ≤0.2 W (73). **Appendix L slice update**: `annual_lighting_kwh` surfaced as a public leaf returning the worksheet-lodged (232) value (Σ L11 monthly distribution; cosine integral 0.998539). `InternalGainsResult.lighting_kwh_per_yr` exposes the same value so `cert_to_inputs` populates `inputs.lighting_kwh_per_yr` from the cascade — single source of truth shared with §5 (67). New worksheet-level per-component pin: `internal_gains_from_cert(...).lighting_kwh_per_yr` matches U985 (232) to abs=1e-4 for all 6 Elmhurst fixtures (000474:139.9452, 000477:201.6754, 000480:212.5531, 000487:227.6861, 000490:171.4217, 000516:230.8853). | -| 6 | Solar gains + Tables 6b/6c/6d + Appendix U | `worksheet/solar_gains.py` | **Full** | Worksheet-driven (74)..(83). Table 6b g⊥ via manufacturer `window_transmission_details` first, Table 6b code lookup fallback; Table 6c FF by frame_material substring; Table 6d Z (heating column) by `OvershadingCategory`; roof windows pitched at RdSAP10 Table 24 default 45°; rooflights horizontal per §U3.2 p128. `solar_gains_from_cert` wired into `cert_to_inputs` + `calculator.py`. Six Elmhurst fixtures conform end-to-end to ≤5e-3 W on (83) + (84). | -| 7 | Mean internal temperature | `worksheet/mean_internal_temperature.py` | **Full** | Worksheet-driven (85)..(94) via `mean_internal_temperature_monthly`. Table 9c steps 1-9 sequential (per-zone η: (86) η_living at Ti=T_h1, (89) η_elsewhere at Ti=T_h2, (94) η_whole at Ti=(93)). Table 9b u-formula consumes weighted R for two-main case 1 (single-main is default). Wired into `calculator.py` + `cert_to_inputs` via two new `CalculatorInputs` fields. Six Elmhurst fixtures conform end-to-end to ≤5e-3 °C on all 9 line tuples + 2 scalars per month (588 assertions). Table 4e adj defaults 0 (cert-side mapping deferred — all 6 fixtures = 0); two-main case 2 (different parts heated separately) deferred. | -| 8 | Off-period temperature reduction | inline in `mean_internal_temperature.py` | Full | Table 9b implemented | -| 8 | Space heating requirement | `worksheet/space_heating.py` | **Full** | Worksheet-driven (95)..(99) via `space_heating_monthly_kwh`. Includes the Table 9c step 10 spec inclusion rule (Jun..Sep zeroed) on top of the < 1 kWh value clamp. (98b) Appendix H solar space heating defaulted to 0 (no Elmhurst fixture lodges a solar space heating system). Wired into `calculator.py` + `cert_to_inputs` via `CalculatorInputs.space_heating_monthly_kwh`. Six Elmhurst fixtures conform end-to-end on (95)/(97)/(98a)/(98c)/(99)/annual @ 5e-2..1e-1 kWh (looser than §6/§7's 5e-3 because LINE_84/93/94 fixture pins are 4-d.p. display-rounded and §8's 0.024·n_m·(L−ηG) amplifies that rounding). | -| 8c | Space cooling requirement | `worksheet/space_cooling.py` | **Full (no-AC zero-branch)** | Worksheet-driven (100)..(108) via `space_cooling_monthly_kwh`. Table 10a η_loss with 8-dp γ rounding + L=0 sentinel; Table 10b Q_cool with Jun-Aug inclusion mask + post-f_C × f_intermittent 1-kWh clamp per spec line 10321. Internal temp hardcoded 24 °C. Wired into `calculator.py` (`MonthlyEntry.space_cool_requirement_kwh` + `SapResult.space_cooling_kwh_per_yr`) + `cert_to_inputs` via `CalculatorInputs.space_cooling_monthly_kwh`. Six Elmhurst fixtures all `has_fixed_air_conditioning=False` → (107), (108) ≡ 0 — ALL_FIXTURES asserts (101)/(103)/(106)/(107)/(108) per fixture; synthetic positive test (γ=1 closed-form) covers the algebra. **Deferred**: RdSAP cooled-area defaulting rule + `cooling_gains_from_cert` (drops Table 5a items per spec 10280) + Table 10c SEER → cooling fuel kWh + fuel cost cascade (first non-zero-cooling cert triggers this slice). | -| 8f | Fabric Energy Efficiency (line ref (109)) | `worksheet/fabric_energy_efficiency.py` | **Full (rating-conditions transparency)** | Spec line 7898: (109) = (98a) ÷ (4) + (108). `fabric_energy_efficiency_kwh_per_m2_yr(...)` is a single-scalar free function — no dataclass. Σ(98a) (pre Appendix H solar) added as `SpaceHeatingResult.space_heating_requirement_kwh_per_yr` so spec literal is honoured; for our corpus (98b)=0 so Σ(98a) = Σ(98c). `cert_to_inputs` precomputes FEE from local SpaceHeatingResult + SpaceCoolingResult; calculator passes through to `SapResult.fabric_energy_efficiency_kwh_per_m2_yr`. Six Elmhurst fixtures all (108)=0 → LINE_109 = LINE_99 exactly. **§11 compliance conditions** (different ventilation / HW / lighting / gains column) are deferred — current FEE is a rating-conditions transparency output, not a §11 compliance figure. Future §11 slice invokes the same function with §11-conditions upstream values. | -| 9 | Energy requirements per heating system (§9a worksheet block) | `worksheet/energy_requirements.py` | **Full (single-main + Table 11 secondary)** | Worksheet-driven (201)..(215) via `space_heating_fuel_monthly_kwh`. (211)m = (98c)m × (204) × 100 / (206) and (215)m mirror; Σ → annuals. EnergyRequirementsResult dataclass mirrors the full §9a worksheet shape with 16 fields incl. (203)/(205)/(207)/(209)/(213)/(221) zero-branch placeholders. `cert_to_inputs` precomputes and stashes on `CalculatorInputs.energy_requirements` composite slot; calculator's `_solve_month` reads precomputed (211)m/(215)m directly (stops doing q/η inline). SapResult adds `main_2_heating_fuel_kwh_per_yr` and `space_cooling_fuel_kwh_per_yr` flat scalars (both zero in scope A). **Deferred**: (203)/(205)/(207)/(213) two-main system (first multi-main cert) + (209)/(221) cooling SEER (Table 10c lookup — first fixed-AC cert) + (230a)-(230h)/(231) Table 4f pumps/fans breakdown + (236)/(237) Appendix Q items + ALL_FIXTURES LINE_206/(211)/(215) PDF-derived pins (**blocked on PCDB** — see Boiler / heat-pump efficiency Manufacturer override in Prioritised gap list). | -| 10 | Cooling (spec heading — same content as §8c worksheet block) | `worksheet/space_cooling.py` | **Full (no-AC zero-branch)** | See §8c row above. | -| 11 | FEE compliance conditions | `worksheet/fabric_energy_efficiency.py` (the function exists; §11 conditions don't run yet) | Partial | (109) formula exposed via `fabric_energy_efficiency_kwh_per_m2_yr`. Spec §11 conditions (lines 2152-2164: 2-4 extract fans, instantaneous-electric shower, 125 l/day water, 185 lm/m² lighting at 66.9 lm/W, column (B) heating gains, column (A) cooling gains, etc.) not implemented — only relevant for new-build compliance. | -| 10a | Fuel costs incl. micro-CHP | `worksheet/fuel_cost.py` + `tables/table_32.py` + `tables/table_12a.py` | **Full (single-main + standard tariff)** | Worksheet-driven (240)..(255) via `fuel_cost(...)`. 32-field `FuelCostResult` mirrors the §10a worksheet shape: (240a-e) main 1 + (241a-e) main 2 + (242a-e) secondary off-peak splits, (243-247) water heating, (247a) instant shower, (248) cooling, (249) pumps/fans, (250) lighting, (251) standing charges (Table 12 note (a) gating: gas standing + off-peak electricity standing), (252) PV credit (negative), (253-254) Appendix Q, (255) total clamped to ≥ 0. RdSAP10 Table 32 prices per ADR-0010 amendment (overrides SAP10.2 Table 12 for §10a/§10b). `Tariff` + `Table12aSystem` + `OtherUse` enums in `table_12a.py` for off-peak high-rate-fraction lookups (synthetic-tested; unreachable from RdSAP cert flow until Table12aSystem cert→row mapping lands). `cert_to_inputs._fuel_cost` precomputes for STANDARD-tariff certs; off-peak certs return zero sentinel so the calculator's legacy scalar `_*_fuel_cost_gbp_per_kwh` fallback fires (deferred). 000490 SAP rating ceiling tightened 6 → 2; 000474 ceiling loosened 2 → 4 reflecting upstream §4 HW + Appendix L lighting overestimates the pre-§10a wrong-table-but-cancels-kWh had masked. **Deferred**: per-row (252) PV/wind/hydro/μCHP split, Table 13 immersion fractions, Table 12a Table12aSystem cert→row mapping for off-peak electric mains, (230a)-(230g) per-row pumps/fans split, (247a) instant shower wiring, scalar fuel-cost-per-kWh field cleanup from CalculatorInputs. | -| 12 | Total energy + fuel costs (legacy heading — folded into §10a) | `calculator.py` | **Folded into §10a row above** | Pre-§10a §12 row covered the inline cost arithmetic; that block was rewritten into the §10a orchestrator + cert_to_inputs precompute. Calculator delegates `total_fuel_cost_gbp` to `inputs.fuel_cost.total_cost_gbp`. | -| 13 | SAP rating | `worksheet/rating.py` | Full | Equations 7-9 verified against SAP 10.2 §13 | -| 14 | CO2 + primary energy | `calculator.py` SapResult.co2_kg_per_yr | Partial | Single CO2 factor on main fuel; no per-end-use CO2 mixing; **no primary energy calculation** | -| 15 | Building regs | n/a | n/a | Not relevant to ratings | - -## Appendices - -| Appendix | Topic | Status | Notes | -|---|---|---|---| -| A | Main + secondary heating identification | **Not implemented** | First main only; Table 11 secondary fraction missing — high-value gap | -| B | Gas/oil boilers + boiler interlock | Partial | Via Table 4b in `domain.ml.sap_efficiencies.seasonal_efficiency` | -| C | Community heating | Not implemented | Heat network certs handled by Table 4a codes 301-304 (efficiency only) | -| D | PCDB | Stubbed | Per ADR-0009 grill: `NoOpPcdbLookup` returning None; Session C bundles a CSV PCDB extract | -| E | Heat pumps | Partial | Category fallback to 2.30 for cat=4; no MCS installation factor (×1.39 GSHP); no flow temp adjustment | -| F | Electric CPSU | Not implemented | Rare | -| G | FGHRS / WWHRS / PV-diverter | Not implemented | Rare | -| H | Solar water heating | Partial | Boolean `has_solar_water_heating` reduces HW by 250 kWh; no actual collector calc | -| J | Hot water demand | Partial | See §4 row above | -| K | Thermal bridging | Partial | Using global y per age band (per ADR-0009 grill: per-junction Table R2 deferred) | -| L | Lighting | **Full (cost + gains)** | Existing-dwelling L1-L12 cascade + RdSAP §12-1 bulb defaults + Table 6d Z_L. **Closed both sides**: §5 (67) gains side (`lighting_monthly_w`) + cost side (`annual_lighting_kwh` → `InternalGainsResult.lighting_kwh_per_yr` → `inputs.lighting_kwh_per_yr`). Replaces the legacy `predicted_lighting_kwh` heuristic which over-counted ~3× on the Elmhurst cohort. 000474 SAP integer closes to delta=0 vs PDF. Legacy heuristic kept in `domain/ml/demand.py` with deprecation note for the unmigrated `domain.ml.ecf` + `domain.ml.transform` callsites — see ADR-0010 amendment 2026-05-22. | -| M | PV / wind / hydro generation | Partial | PV ✓ (S-B19); wind / hydro / micro-CHP not implemented | -| N | Micro-CHP | Not implemented | Rare | -| P | Electric storage heaters detail | Partial | Identified via codes 401-409; Table 12a high-rate fractions not exact (we use 100% off-peak per cert-calibration heuristic) | -| Q | Special features | Not implemented | One-off energy-use additions; rare | -| R | Reference dwelling | Not implemented | Only needed for compliance, not ratings | -| S | RdSAP procedures | Separate PDF (114pp) | Cert→inputs mapper in `domain.sap.rdsap.cert_to_inputs` ports key rules; many sections of RdSAP 10 not yet read | -| T | Improvement measures | Not relevant | Recommendation engine, not rating | -| U | Climate data | Full | Tables U1/U2/U3 + solar declination + Table U5 k1-k9 | - -## Prioritised gap list (by likely MAE impact) - -1. **Boiler / heat-pump efficiency Manufacturer override (PCDB integration) — Table 105 done; Table 362 heat pumps + equation D1 monthly water + Appendix N HP factor remain.** Gas/oil boilers (Table 105) now flow via PCDB: when a cert lodges `main_heating_index_number`, `domain.sap.tables.pcdb.gas_oil_boiler_record(...)` is consulted and the PCDB winter efficiency overrides `seasonal_efficiency(...)` for space heating + summer overrides Table 4a scalar for water heating (per Appendix D2.1). Two of the six golden corpus PCDB-listed certs drifted +1 SAP / -1.5 kWh/m² PE under the spec-faithful override (tolerance widened accordingly). **Remaining**: (a) Table 362 heat pumps still resolve via `seasonal_efficiency(main_category=4)` → 2.30 SCOP fallback; Appendix N in-use factor (0.95) + MCS factor (×1.39 GSHP) + design-flow-temp adjustment all deferred. (b) Equation D1 monthly water heating cascade (currently scalar approximation); ~single-digit-percent HW kWh under-precision for combi boilers. (c) Table 313 FGHRS, Table 353 WWHRS, Table 391 storage heaters, Table 506 HIU records are parsed into JSONL but unconsumed — wait for first cert that needs them. -2. **Table 11 Secondary heating allocation** — most boiler-main certs allocate 10% of space heating to a secondary system (often a less-efficient room heater on a different fuel). We model 0%. Likely +1-2 SAP-point bias on affected certs. -3. **Wind-shelter factor on infiltration** (§2 worksheet lines 19-21) — multiplies infiltration by `1 - 0.075 × sheltered_sides`. We have no shelter input; assume 2 sheltered sides default. Net effect on infiltration ACH probably ~10%. -4. **Table 12a high-rate fraction for off-peak dwellings** — we currently bill 100% of E7 space heating at the low rate. Real spec says e.g. heat pumps on 7h tariff at 80% high-rate. Affects ~5% of certs. -5. **Cylinder-loss factor cascade** — currently uses simplified buckets in `domain.ml.demand._STORAGE_LOSS_FACTOR`. Spec has more precise interpolation rules from cylinder volume + insulation thickness. -6. **Standing charges in cost** — Table 12 note (a) gives the rule for when standing charges are included (energy use vs rating). May affect bias. -7. **Per-junction thermal bridging (Table R2)** — only relevant when assessor lodged junction-count data, otherwise global y is the spec answer for RdSAP-driven assessments. - -Status now: 100-cert MAE 4.49, 300-cert MAE 5.45, bias near zero (±0.2). Worksheet-driven phase begins with **Secondary heating Table 11** as the next slice. - -## §4 — slice progress (xlsx rows 207–304) - -| Line ref | Description | Status | Commit | -|---|---|---|---| -| (42) | Assumed occupancy N from Appendix J | ✅ | `aff678e8` | -| (42a)m | Mixer showers monthly | ✅ | `1dcbdb28` | -| (42b)m | Baths monthly | ✅ | `dad7fbf3` | -| (42c)m | Other uses monthly | ✅ | `5cc68ab3` | -| (43) | Annual avg | ✅ | `702b1c6c` | -| (44)m | Daily monthly total | ✅ | `702b1c6c` | -| (45)m | Energy content | ✅ | `a3c687f1` | -| (46)m | Distribution loss | ✅ | `a3c687f1` | -| (47)–(56) | Storage / HIU loss | Combi zero-branch only; cylinder paths deferred | — | -| (57)m | Dedicated solar storage | Combi zero-branch only; solar HW deferred | — | -| (59)m | Primary loss | Combi zero-branch only; boiler+cylinder deferred | — | -| (61)m | Combi loss | Table 3a default + PCDB Table 3b row 1 (instantaneous non-storage) override | `bfba610b`, `760e25de` | -| (62)m | Total demand | ✅ | `bfba610b` | -| (63a-d) | WWHRS/PV/Solar/FGHRS reductions | Zero-only — non-zero paths deferred | `feef8198` | -| (64)m | Output from water heater | ✅ (with max-clamp) | `feef8198` | -| (64a)m | Electric shower energy | Zero-only — non-zero path deferred | — | -| (65)m | Heat gains | ✅ | `43da3ea0` | - -**Happy path closes both non-RR Elmhurst fixtures to <1e-3 kWh end-to-end on lines (42)..(65)**. (61)m PCDB Table 3b row 1 lands the 000474 combi loss within 0.02% of PDF arithmetic. **§4 HW slice 2 update:** Equation D1 monthly cascade (Appendix D §D2.1 (2)) wired post-§8 closes HW *fuel* kWh — 000474: 2622 → 2292 (matches PDF 2292 to ≤0.1%); 000490: 3028 → 2847 (matches PDF 2851 to ≤0.1%). Closes the residual §10a "section 4 next ticket" debt named in `project_section_4_hw_next_ticket` memory + ADR-0010 §4-amendment of §10a slice 3. - -### Remaining §4 work - -1. **Cylinder + solar + renewables paths** for full population coverage (cylinders, FGHRS, WWHRS, PV-diverter, solar HW). Currently zero-branch placeholders. -2. **PCDB Table 3b storage / FGHRS rows** (other than row 1) — the impl exposes profile_flag + r1/F1/F2/F3 fields but only row 1 (instantaneous non-storage) computes. Storage variants raise `NotImplementedError`-equivalent (fall through to Table 3a) until a fixture exercises. -3. **PCDB Table 3c** (two-profile boilers, separate_dhw_tests = 2 or 3) — Table 3c uses r1, F2, F3, DVF (daily volume factor); parser exposes the fields, formula pending. -4. **Electric CPSU → Appendix F** path for water-eff cascade. -5. **(64a) Instant electric shower** kWh routing — line currently always 0 from `_hot_water_fuel_kwh_per_yr`; wire when an electric-shower cert lodges. -6. **Appendix L lighting predictor** (`domain.ml.demand.predicted_lighting_kwh`) — separate ticket per memory `project_section_4_hw_next_ticket` "secondary upstream". 000474 lights 528 kWh/yr vs ~169 back-derived from PDF cost; tightening drops the 000474 cost residual from +9.2% closer to zero. - -## §5 — slice progress (xlsx rows 305–332) - -| Line ref | Description | Status | Commit | -|---|---|---|---| -| (66)m | Metabolic = 60 × N (Table 5 Col A) | ✅ | `3ec56216` | -| (71)m | Losses = -40 × N | ✅ | `021f43ba` | -| (69)m | Cooking = 35 + 7 × N | ✅ | `984a5b18` | -| (72)m | Water heating bridge (65)m × 1000/(24 × n_m) | ✅ | `a4d6321f` | -| (68)m | Appliances (L13/L14/L16a) | ✅ | `0bc9eac3` | -| (67)m | Lighting (L1-L12 with Z_L from Table 6d) | ✅ | `50fd940a` | -| (70)m | Pumps + fans (Table 5a 9-row dispatch) | ✅ | `f77229e4` | -| (73)m | Total internal gains + InternalGainsResult | ✅ | `53aba133` | -| — | `internal_gains_from_cert` orchestrator | ✅ | `f81e744b` | -| — | 6-fixture ALL_FIXTURES conformance | ✅ | `99e5c2cd` | -| — | `cert_to_inputs` wiring + calculator.py wiring | ✅ | `bf6a7e04` | -| — | Rooflight Z_L=1.0 (Table 6d note 2) | ✅ | `380115e2` | - -**Six Elmhurst fixtures conform end-to-end on §5 to ≤5e-3 W on every line** — display-rounding territory across the board. - -### Remaining §5 work - -1. **Table 5a fan / PIV / HIU branches** are implemented as leaf functions but not yet derived from cert inputs in `internal_gains_from_cert`. Pump dispatch is wired; non-pump rows always emit 0 W. MVHR / MEV correctly produces zero gain. -2. **Per-window `frame_material` / `glazing_type` strings** — current orchestrator uses cert numeric codes; needs a robust mapping for site-notes-sourced certs. -3. **Rooflight derivation from cert** — orchestrator accepts `rooflight_total_area_m2` but `cert_to_inputs` doesn't yet detect rooflights (defaults to 0). Needs the `SapWindow.window_location` enum that's TODO'd in the domain model. -4. **Column B reduced-gain forms** (L12a / L16) for new-build DPER/TPER calculations — deferred until we onboard a new-build cert. - -## §6 — slice progress (xlsx rows 332–371) - -| Line ref | Description | Status | Commit | -|---|---|---|---| -| — | Table 6d Z-solar lookup (winter / heating column) | ✅ | `da5909de` | -| (74)..(81) | Per-orientation solar gain (N..NW) | ✅ | `4b83e702` | -| (82) | Roof windows — RdSAP10 Table 24 pitch 45° default, Z=1.0 | ✅ | `4b83e702` | -| (82a) | Rooflights — SAP10.2 §U3.2 horizontal, Z=1.0 | ✅ | `4b83e702` | -| (83) | Total solar gains 12-tuple | ✅ | `4b83e702` | -| — | `RoofWindowInput` + `RooflightInput` + `SolarGainsResult` | ✅ | `4b83e702` | -| — | `solar_gains_from_cert` orchestrator | ✅ | `4b83e702` | -| — | Manufacturer `window_transmission_details` cascade | ✅ | `d56fef4d` | -| — | 6-fixture ALL_FIXTURES conformance (83) + (84) ≤5e-3 W | ✅ | `377caea2` | -| — | `CalculatorInputs.solar_gains_monthly_w` + per-month lookup | ✅ | `376cdb6b` | -| — | `cert_to_inputs` swap legacy `_solar_gains_w` → orchestrator | ✅ | `cd2bd9ce` | -| — | Delete `_solar_gains_w` + `WindowInput` + `_window_inputs` | ✅ | `a0ce45c9` | - -**Six Elmhurst fixtures conform end-to-end on §6 to ≤5e-3 W on every month of (83) total solar gains and (84) total gains** (the latter cross-checks §5 LINE_73 plus §6 LINE_83). 144 monthly assertions across 6 fixtures, all GREEN. - -### Remaining §6 work - -1. **Rooflight derivation from cert** — `cert_to_inputs` passes `rooflights=()` because Elmhurst summaries lodge rooflights as `window_location = "External wall"`; needs the `SapWindow.window_location` enum work (also blocking §5 rooflight detection — common ticket). -2. **Roof window detection from cert** — same constraint as rooflights; orchestrator-side support is there (`RoofWindowInput` with pitch default), but the cert→inputs mapper has no signal to populate it. -3. **Multi-window-type per orientation** — orchestrator sums all windows of one orientation regardless of glazing type, matching the Elmhurst worksheet's per-orientation Σ. The xlsx repeats (74)..(82a) blocks per "window type" for diagnostic display; we collapse to per-orientation totals. Faithful per-type printout would need a richer result dataclass. -4. **Table 6c metal frame factor** — orchestrator ships 0.8 (spec); the deleted legacy lookup had 0.83 (silent bug). Frame-material strings outside `metal`/`aluminium`/`wood`/`pvc`/`upvc`/`composite` fall through to 0.7 default. - -## §7 — slice progress (xlsx rows 372–401) - -| Line ref | Description | Status | Commit | -|---|---|---|---| -| — | Per-zone η fix + `MeanInternalTemperatureResult` + `mean_internal_temperature_monthly` orchestrator | ✅ | `fa49d7b9` | -| (85) | T_h1 = 21 °C scalar | ✅ | `fa49d7b9` | -| (86)m | η_living utilisation at Ti=T_h1 | ✅ | `fa49d7b9` | -| (87)m | MIT living = T_h1 − u1 − u2 | ✅ | `fa49d7b9` | -| (88)m | T_h2 elsewhere heating temp (Table 9) | ✅ | `fa49d7b9` | -| (89)m | η_elsewhere utilisation at Ti=T_h2 | ✅ | `fa49d7b9` | -| (90)m | MIT elsewhere = T_h2 − u1 − u2 | ✅ | `fa49d7b9` | -| (91) | f_LA living area fraction | ✅ | `fa49d7b9` | -| (92)m | Blended MIT = f_LA·T_1 + (1−f_LA)·T_2 | ✅ | `fa49d7b9` | -| (93)m | Adjusted MIT (+ Table 4e adj, default 0) | ✅ | `fa49d7b9` | -| (94)m | η_whole utilisation at Ti=(93) — feeds §8 | ✅ | `fa49d7b9` | -| — | Two-main case 1 weighted-R via secondary_fraction | ✅ | `13c2c651` | -| — | 6-fixture ALL_FIXTURES conformance (85)..(94) ≤5e-3 | ✅ | `ff5d8c70` | -| — | `CalculatorInputs` + cert_to_inputs wiring; drop η iteration | ✅ | `8ec9da47` | -| — | Delete legacy single-η `mean_internal_temperature_c` | ✅ | `a7f39685` | - -**Six Elmhurst fixtures conform end-to-end on §7 to ≤5e-3 °C / unitless** on every per-zone line ref every month — 588 monthly assertions across 6 fixtures, all GREEN. The pre-rebuild single-η path drifted by 6.6e-3 °C on 000490 Jan (92)m; the per-zone fix closes that gap by ~70×. - -### Remaining §7 work - -1. **Table 4e control_temperature_adjustment_c cert mapping** — orchestrator accepts the parameter but `cert_to_inputs` hardcodes 0.0 (all 6 Elmhurst fixtures = 0 anyway). Wire when a non-zero corpus emerges. -2. **Two-main case 2** (different parts heated separately) — needs (203) > 1−(91) branch + conditional T_2 averaging + per-system Table 4e adj weighting. Case 1 (both heat whole house) wired with synthetic test; case 2 deferred. -3. **Auto-detect `secondary_fraction` from `epc.sap_heating.main_heating_details`** — currently always 0 in `cert_to_inputs`. Wire when a multi-main cert lands. - -## §8 — slice progress (xlsx rows 401–435) - -| Line ref | Description | Status | Commit | -|---|---|---|---| -| — | `SpaceHeatingResult` + `space_heating_monthly_kwh` orchestrator | ✅ | `9113f30a` | -| — | Table 9c step 10 summer clamp (Jun..Sep = 0) | ✅ | `9113f30a` | -| (95)m | Useful gains ηG, W | ✅ | `9113f30a` | -| (97)m | Heat loss rate L_m = H·(T_int − T_ext), W | ✅ | `9113f30a` | -| (98a)m | Space heating requirement per month, kWh | ✅ | `9113f30a` | -| (98b)m | Solar space heating (Appendix H) — hardcoded 0 | ⏸ deferred | — | -| (98c)m | Total = (98a) + (98b), kWh | ✅ | `9113f30a` | -| Σ(98c) | Annual total kWh | ✅ | `9113f30a` | -| (99) | Annual per-m² = Σ(98c) / TFA | ✅ | `9113f30a` | -| — | 6-fixture ALL_FIXTURES conformance on (95)..(99) | ✅ | `1f078af7` | -| — | `CalculatorInputs.space_heating_monthly_kwh` + cert_to_inputs wiring | ✅ | `f6ab7626` | - -**Six Elmhurst fixtures conform end-to-end on §8 to 5e-2..1e-1 kWh** on every per-month line ref (looser than §6/§7's 5e-3 because LINE_84/93/94 fixture pins are 4-d.p. display-rounded and §8's 0.024·n_m·(L−ηG) propagates that rounding into the per-month kWh band; the orchestrator computes in full precision). - -**E2e SAP-score impact:** the spec-correct summer clamp removed a ~+14% over-prediction that was previously masking residual upstream §3/§5 precision drift. The 000490 SAP score moved 57 → 60 (target 57; tolerance updated to "within 3 points" with the full delta breakdown in `test_elmhurst_000490_end_to_end_sap_score_currently_within_3_points`). 000474 / 000490-kWh tests still pass. - -### Remaining §8 work - -1. **Appendix H solar space heating (98b)** — orchestrator emits `solar_space_heating_monthly_kwh = (0,)·12` always. No Elmhurst fixture exercises it. Wire when a solar-space-heating cert lands. -2. **000490 SAP-score gap to 57 (currently 60)** — needs upstream §3 (transmission HLC), §5 (internal gains), and the §4 water heating cert path tightened. Likely closes incrementally as the §9–§14 fuel/cost/rating chain is rebuilt + the legacy `predicted_hot_water_kwh` is replaced by `water_heating_from_cert`. - -## §8c — slice progress (xlsx rows 435–466) - -| Line ref | Description | Status | Commit | -|---|---|---|---| -| — | `SpaceCoolingResult` + `space_cooling_monthly_kwh` orchestrator | ✅ | `cf28eec4` | -| — | Table 10a `utilisation_factor_loss` leaf (γ=G/L, 8-dp rounding, L=0 sentinel, γ≤0 / γ=1 / γ>0∧≠1 branches) | ✅ | `cf28eec4` | -| (100)m | Heat loss rate L_m = H·(24 − T_e), W (Ti hardcoded 24 °C) | ✅ | `cf28eec4` | -| (101)m | η_loss per month, Table 10a | ✅ | `cf28eec4` | -| (102)m | Useful loss η·L, W | ✅ | `cf28eec4` | -| (103)m | Cooling gains W (Table 5a exclusions deferred — see below) | ✅ pass-through; Table 5a exclusion ⏸ deferred | `cf28eec4` | -| (104)m | Q_whole continuous = 0.024·(G − η·L)·n_m, kWh; Jun-Aug only | ✅ | `cf28eec4` | -| Σ(104) | Annual Q_whole total kWh | ✅ | `cf28eec4` | -| (105) | Cooled-area fraction f_C — hardcoded 0 in cert_to_inputs | ✅ (zero-branch); RdSAP cooled-area defaulting ⏸ deferred | `cf28eec4` | -| (106)m | Intermittency factor 0.25, Jun-Aug only | ✅ | `cf28eec4` | -| (107)m | Q_cool = (104)·(105)·(106) with negative-or-< 1 kWh clamp per spec 10321 | ✅ | `cf28eec4` | -| Σ(107) | Annual Q_cool kWh | ✅ | `cf28eec4` | -| (108) | Annual per-m² = Σ(107) / TFA | ✅ | `cf28eec4` | -| — | 6-fixture ALL_FIXTURES conformance on (101)/(103)/(106)/(107)/(108) | ✅ | `3b9fa936` | -| — | `CalculatorInputs.space_cooling_monthly_kwh` + `MonthlyEntry.space_cool_requirement_kwh` + `SapResult.space_cooling_kwh_per_yr` + cert_to_inputs wiring | ✅ | `f3797066` | -| (105) Table 10c SEER → cooling fuel kWh | ⏸ deferred | — | - -**Six Elmhurst fixtures conform end-to-end on §8c to exact equality** on (101)/(103)/(106)/(107)/(108) — no tolerance needed because every fixture has `has_fixed_air_conditioning=False` → f_C=0 → (107), (108) ≡ 0 and (101) ≡ 1.0 (γ=0 every month). (100)/(102)/(104) carry computed non-zero values per fixture and are not pinned in the ALL_FIXTURES test; the algebra is exercised by the synthetic-positive leaf/orchestrator tests in `test_space_cooling.py` (γ=1 closed-form branch with hand-computed 4.65 kWh end-to-end + γ ≤ 0 / γ=1 / γ>0∧≠1 / γ≈1 boundary / L=0 sentinel leaf tests). - -**E2e SAP-score impact:** none. All Elmhurst fixtures lodge `has_fixed_air_conditioning=False`; cooling contribution to the rating is structurally zero. The wiring is in place so that the first non-zero-cooling cert triggers the (103) Table 5a exclusion + (105) RdSAP cooled-area defaulting slice atomically. - -### Remaining §8c work - -1. **Table 5a exclusion in cooling gains (103)** — `cert_to_inputs` currently passes `monthly_total_gains_w = (0,)*12` to the orchestrator. Spec line 10280 requires cooling G to drop Table 5a items (pumps/fans, intermittent appliances) and use column (A) of Table 5 throughout. Needs a `cooling_gains_from_cert` helper mirroring `internal_gains_from_cert` + `solar_gains_from_cert`. Triggered by the first cooling-enabled cert. -2. **RdSAP cooled-area defaulting (105)** — `cert_to_inputs` currently passes `cooled_area_fraction = 0.0` always. For `has_fixed_air_conditioning=True` certs the RdSAP 10 spec gives a defaulting rule (cooled area not lodged; cert side derives from dwelling type or assumes whole-dwelling). Needs PDF lookup. Triggered by the first cooling-enabled cert. -3. **Table 10c SEER + cooling fuel kWh + fuel cost cascade** — Q_cool (107) needs to drive an electricity-fuel-kWh path (Q_cool ÷ SEER) and through Table 12 onto `SapResult.total_fuel_cost_gbp`. Currently the cooling kWh sits on `MonthlyEntry.space_cool_requirement_kwh` but doesn't propagate into fuel costs or CO2. Triggered by the first cooling-enabled cert. -4. **§8f Fabric Energy Efficiency (109) = (98a)/TFA + (108)** — only relevant for new-build compliance (FEE replaces SAP rating in some contexts). Picked up by §8f slice. - -## §8f — slice progress (xlsx rows 466–470) - -| Line ref | Description | Status | Commit | -|---|---|---|---| -| — | `fabric_energy_efficiency_kwh_per_m2_yr` free function (no dataclass) | ✅ | `43cc16bc` | -| — | `SpaceHeatingResult.space_heating_requirement_kwh_per_yr` (Σ(98a) — pre Appendix H) | ✅ | `43cc16bc` | -| (109) | FEE = (98a)/(4) + (108), kWh/m²/yr | ✅ | `43cc16bc` | -| — | 6-fixture ALL_FIXTURES conformance (LINE_109 = LINE_99 since (98b)=0 + (108)=0) | ✅ | `43cc16bc` | -| — | `CalculatorInputs.fabric_energy_efficiency_kwh_per_m2_yr` + `SapResult.fabric_energy_efficiency_kwh_per_m2_yr` + cert_to_inputs wiring | ✅ | `43cc16bc` | -| — | §11 compliance conditions (separate worksheet re-run with §11 ventilation / HW / lighting / gains-column assumptions) | ⏸ deferred | — | - -**Six Elmhurst fixtures conform end-to-end on §8f to ≤5e-3 kWh/m²** on LINE_109. The tolerance inherits from the 4-d.p. display-rounding floor of the LINE_98C_ANNUAL_KWH fixture pins (§8 conformance is at 1e-1 kWh on annual, which divides by TFA to ~5e-3 kWh/m²). - -**E2e SAP-score impact:** none. (109) is a transparency output for rating runs — it doesn't feed ECF, fuel costs, or CO2. The wiring lets §11 compliance code consume FEE later via `SapResult.fabric_energy_efficiency_kwh_per_m2_yr` once §11 conditions are implemented. - -### Remaining §8f work - -1. **§11 FEE compliance conditions** — spec lines 2152-2164 require a separate worksheet run with: natural ventilation + 2-4 extract fans by TFA, instantaneous-electric shower + bath, 125 l/day water-use target, lighting capacity 185 lm/m² at 66.9 lm/W, column (B) of Table 5 for heating gains, column (A) for cooling gains, etc. This is a new-build compliance path; existing-dwelling ratings don't trigger it. Lands as `fabric_energy_efficiency_under_section_11_conditions(...)` when the first compliance use case emerges. -2. **Σ(98a) ≠ Σ(98c) regression coverage** — when Appendix H solar space heating lands (currently (98b) ≡ 0), Σ(98a) will diverge from Σ(98c). The FEE function consumes Σ(98a) per spec; a new fixture with non-zero (98b) would assert that distinction holds. - -## §9a — slice progress (xlsx rows 470–614) - -| Line ref | Description | Status | Commit | -|---|---|---|---| -| — | `space_heating_fuel_monthly_kwh` orchestrator + `EnergyRequirementsResult` dataclass (16 fields, full worksheet shape) | ✅ | `2b5fc6a5` | -| (201) | Secondary heating fraction (Table 11) | ✅ | `2b5fc6a5` | -| (202) | Main heating total fraction = 1 − (201) | ✅ | `2b5fc6a5` | -| (203) | Main 2 of main fraction | ⏸ zero-branch (no two-main cert yet) | — | -| (204) | Main 1 of total fraction = (202) × (1 − (203)) | ✅ | `2b5fc6a5` | -| (205) | Main 2 of total fraction = (202) × (203) | ⏸ zero-branch | — | -| (206) | Main 1 efficiency % | ✅ pass-through; cert source is SAP10 Table 4a category default (PCDB-blocked) | `2b5fc6a5` | -| (207) | Main 2 efficiency % | ⏸ zero-branch | — | -| (208) | Secondary efficiency % | ✅ | `2b5fc6a5` | -| (209) | Cooling SEER (Table 10c) | ⏸ deferred (see §8c remaining work) | — | -| (211)m | Main 1 fuel kWh per month = (98c)m × (204) × 100 / (206) | ✅ | `2b5fc6a5` | -| (211) | Σ(211)m | ✅ | `2b5fc6a5` | -| (213)m | Main 2 fuel kWh per month | ⏸ zero-branch | — | -| (213) | Σ(213)m | ⏸ zero-branch | — | -| (215)m | Secondary fuel kWh per month = (98c)m × (201) × 100 / (208) | ✅ | `2b5fc6a5` | -| (215) | Σ(215)m | ✅ | `2b5fc6a5` | -| (221) | Cooling fuel kWh per yr = (107) ÷ (209) | ⏸ deferred (Table 10c SEER) | — | -| — | `CalculatorInputs.energy_requirements` composite slot + `SapResult.main_2_heating_fuel_kwh_per_yr` + `SapResult.space_cooling_fuel_kwh_per_yr` + `_solve_month` refactor + cert_to_inputs wiring (atomic) | ✅ | `380b6781` | -| — | 6-fixture ALL_FIXTURES PDF-derived pins on (206)/(211)/(215) | ⏸ **blocked on PCDB** (see Prioritised gap list item 1) | — | -| (230a)-(230h) | Table 4f pumps/fans breakdown (warm-air fans, oil aux, gas aux, keep-hot, solar pump, WWHRS pump) | ⏸ deferred | — | -| (231) | Σ(230a)-(230h) — Total electricity for pumps/fans/keep-hot | ⏸ deferred — currently `inputs.pumps_fans_kwh_per_yr` is an opaque scalar | — | -| (236)/(237) | Appendix Q items energy saved/used | ⏸ deferred (no Q-item cert in corpus) | — | -| (238) | Total delivered energy kWh/yr | Partial — computed in `intermediate["delivered_fuel_kwh_per_yr"]`; not yet promoted to SapResult field | — | - -**Four synthetic tests on the orchestrator** cover: single-main no-secondary 80% efficiency, Table 11 secondary fraction split (211)+(215), summer-clamp zero propagation, and scope-A two-main / cooling-fuel placeholders. Cert round-trip test pins `inputs.energy_requirements.main_1_fuel_kwh_per_yr == result.main_heating_fuel_kwh_per_yr` to float equality — confirms the refactor preserves existing behaviour. - -**E2e SAP-score impact:** zero (refactor only — `_solve_month` now reads precomputed (211)m/(215)m instead of doing q/η inline). The 000490 +3 SAP-score gap stays parked behind PCDB integration. - -### Remaining §9a work - -1. **PCDB integration** — see Prioritised gap list item 1. Blocks PDF-derived ALL_FIXTURES pinning + closes 000490 e2e gap. ADR-0010 §4 prerequisite. -2. **Two-main system** (203)/(205)/(207)/(213) — populate from `epc.sap_heating.main_heating_details[1]` when a multi-main cert appears in fixtures. EnergyRequirementsResult already exposes the fields as zeros; first multi-main slice flips them from placeholders to real values. -3. **Table 10c SEER → (209)/(221)** — cooling fuel kWh from (107) ÷ SEER. Same trigger as §8c remaining work (first fixed-AC cert); add Table 10c lookup table + wire `energy_requirements.cooling_fuel_kwh_per_yr` + SapResult.space_cooling_fuel_kwh_per_yr from zero to real value. -4. **Table 4f pumps/fans breakdown** (230a)-(230h) → (231) — replace opaque `CalculatorInputs.pumps_fans_kwh_per_yr` scalar with per-source sub-lines. Likely a separate sweep slice once aux electricity becomes load-bearing for ranking. -5. **Appendix Q items** (236)/(237) — placeholder until a Q-item cert lands. -6. **(238) total delivered energy on SapResult** — promote from `intermediate` dict when §10a or §13 requires it as a named output. - -## §10a — slice progress (xlsx rows ~614–740) - -Per ADR-0010 amendment, §10a costs source from RdSAP10 **Table 32** (PDF page 95), not SAP10.2 Table 12. Three new modules + one composite slot on `CalculatorInputs` + one calculator delegation. - -| Slice | What landed | Commit | -|---|---|---| -| 1 | `tables/table_32.py` (28 fuel-row unit prices + 11-row standing charges + note (a) gating fn) + `tables/table_12a.py` (`Tariff` + `Table12aSystem` + `OtherUse` enums + SH/WH/other-use fraction lookups + `tariff_from_meter_type` cert resolver) + `worksheet/fuel_cost.py` (32-field `FuelCostResult` + kwargs `fuel_cost(...)` orchestrator with `_split` off-peak helper). 130 synthetic unit tests. | `0f255165` | -| 2 | `CalculatorInputs.fuel_cost` composite slot (default zero sentinel) + `cert_to_inputs._fuel_cost` precompute (Table 32 prices + note (a) standing-charge gating; off-peak certs return zero sentinel → calculator falls back to legacy scalar `_*_fuel_cost_gbp_per_kwh` helpers, deferred). Calculator delegates `total_fuel_cost_gbp` to `inputs.fuel_cost.total_cost_gbp`. 2 cert-round-trip conformance tests (000474 within e2e 15% tolerance; 000490 within 5%). e2e ceilings adjusted: 000490 6 → 2 (tightened — marquee close-out), 000474 2 → 4 (loosened — exposes upstream §4 HW + Appendix L), golden corpus ±7 → ±11 (oil price +55% Table 12 → Table 32). | `adfa7f60` | -| 3 | Docs — ADR-0010 amendment, this SPEC_COVERAGE row, slice progress table. | _this commit_ | - -### Line-ref status - -| Line ref | Status | Notes | -|---|---|---| -| (240a)-(240e) | Full | Main 1 off-peak split. STANDARD-tariff certs lodge `high_rate_fraction=1.0` so (240c) = kWh × price + (240d) = 0. Off-peak certs defer to legacy scalar fallback (Table12aSystem cert→row mapping deferred). | -| (241a)-(241e) | Zero-branch placeholder | Main 2 — no multi-main fixture in corpus. | -| (242a)-(242e) | Full (zero-valued) | Secondary off-peak split. All 6 fixtures lodge zero `secondary_fuel_kwh_per_yr`. | -| (243)-(247) | Full (single-rate path) | Water heating off-peak split. STANDARD-tariff path active; Table 12a immersion / heat-pump-DHW (Table 13) deferred. | -| (247a) | Zero-branch placeholder | Instant electric shower kWh routing deferred (no fixture lodges one). | -| (248) | Full | Cooling cost at `other_uses_gbp_per_kwh`. Zero in 6 fixtures (f_C=0). | -| (249) | Full (aggregate) | Pumps/fans single-rate. Per-row (230a)-(230g) Table 12a split deferred (spec line 8076). | -| (250) | Full (aggregate) | Lighting single-rate. Per-row off-peak split deferred when first off-peak fixture lands. | -| (251) | Full | Standing charges via Table 12 note (a) — gas (mains gas £120, LPG £70) added when gas used for space/water; off-peak electricity standing added when off-peak meter in use; std electricity standing always omitted. | -| (252) | Partial | Single `pv_credit_gbp` scalar. Per-row PV / wind / hydro / micro-CHP split deferred. | -| (253)/(254) | Zero-branch placeholder | Appendix Q items — no Q-item cert in corpus. | -| (255) | Full | `max(0, Σ all rows)` clamp. | - -**Six Elmhurst fixtures route through the new precompute** (all `meter_type="Single"` → STANDARD tariff). 000490 cost closes to PDF within ~4%; 000474 widens to +10.7% (upstream §4 HW + Appendix L — see Remaining work). - -### Remaining §10a work - -1. **§4 HW worksheet tightening — next ticket.** 000474 HW kWh overestimates +14.4% (2622 vs 2292 PDF), Appendix L lighting overestimates ~3x. Pre-§10a wrong-table-but-cancels-kWh masked these. See project memory `project_section_4_hw_next_ticket`. -2. **Table 12a cert→Table12aSystem mapping** for off-peak electric mains. Currently `cert_to_inputs._fuel_cost` returns the zero sentinel for non-STANDARD tariff certs so the calculator's legacy scalar fallback fires. Off-peak split awaits a real off-peak fixture + the row-mapper. -3. **Table 13 immersion + HP-DHW-only WH fractions** — `Table12aSystem.IMMERSION_OR_HP_DHW_ONLY` raises `NotImplementedError`; populate when first immersion fixture lands. -4. **Electric CPSU → Appendix F fractions** — `Table12aSystem.ELECTRIC_CPSU` raises; populate when first CPSU fixture lands. -5. **Per-row (230a)-(230g) pumps/fans split** for off-peak tariffs (spec line 8076: "if off-peak tariff, list each of (230a) to (230g) separately and apply fuel price according to Table 12a"). Requires §9a Table 4f pumps/fans breakdown (see §9a remaining work item 4) as a prerequisite. -6. **(247a) Instant electric shower** kWh routing — (64a) currently always 0 from `_hot_water_fuel_kwh_per_yr`. Wire when electric-shower cert lodges. -7. **(252) per-row Appendix M/N split** — populate (233a)..(235d) when wind / hydro / micro-CHP fixtures land. -8. **(253)/(254) Appendix Q** — zero placeholders; populate when a Q-item cert lands. -9. **Drop legacy scalar fuel-cost fields** from `CalculatorInputs` (`space_heating_fuel_cost_gbp_per_kwh`, `hot_water_fuel_cost_gbp_per_kwh`, `other_fuel_cost_gbp_per_kwh`, `secondary_heating_fuel_cost_gbp_per_kwh`, `pv_export_credit_gbp_per_kwh`) — currently retained as a synthetic-test fallback. Drops when the ~33-occurrence test corpus migrates to `fuel_cost=...`. - -## PCDB — slice progress (BRE pcdb10.dat ingestion) - -| Stage | Description | Status | Commit | -|---|---|---|---| -| ETL parser + 8 tests | `domain.sap.tables.pcdb.parser`: typed `GasOilBoilerRecord` + `RawPcdbRecord`. Ground-truth verified against ncm-pcdb.org.uk for Baxi 000098 / Potterton 000619 / Saunier 000732. Handles latin-1 encoding (degree-sign in addresses), `'obsolete'` status string, `'>70kW'` range indicator. | ✅ | `fe04cd3a` | -| ETL `run_etl` writes 8 JSONL files | One newline-delimited JSON file per table (105 typed; 122/143/313/353/362/391/506 raw). 17MB total. Runnable via `PYTHONPATH=packages/domain/src python -m domain.sap.tables.pcdb.etl`. Idempotent; commit JSONL alongside source `pcdb10.dat`. | ✅ | `fe04cd3a` | -| Runtime lookup `gas_oil_boiler_record(pcdb_id)` | `domain.sap.tables.pcdb` loads Table 105 NDJSON at import; ~50ms one-off, O(1) lookups thereafter. Returns None for unknown PCDB IDs → caller falls back to Table 4a/4b cascade. | ✅ | `23678228` | -| cert_to_inputs precedence (Table 105 only) | Appendix D2.1: PCDB winter overrides `main_heating_efficiency`; PCDB summer overrides `water_efficiency` scalar. Heat-network DLF override still wins where applicable. None of the 6 Elmhurst fixtures lodge a PCDB pointer initially; corpus golden certs that do see real efficiency changes (golden tolerance widened ±5 → ±7). | ✅ | `a104dd55` | -| Elmhurst fixture PCDB lodgement: 000490 + 000474 | `_elmhurst_worksheet_000490.build_epc()` lodges `main_heating_index_number=10328` (Vaillant Ecotec Pro 28, winter 88.2%); `_elmhurst_worksheet_000474.build_epc()` lodges `16839` (Vaillant ecoTEC pro 28 VUW GB 286/5-3, winter 88.7%). 000474 e2e ceiling tightens 7 → 2 SAP points; 000490 widens 3 → 6 (spec-version drift on fuel cost — pre-amendment cert). `make_main_heating_detail` extended with `main_heating_index_number` / `main_heating_data_source` / `sap_main_heating_code` kwargs. | ✅ | `1b43c95c`, `7d4f3d78` | -| API → domain mapper-chain regression test | `test_api_to_domain_mapper_preserves_main_heating_index_number` parametrised over the 4 PCDB-listed golden corpus certs. Pins that `EpcPropertyDataMapper.from_api_response` → `cert_to_inputs` chain surfaces the PCDB pointer + applies PCDB winter efficiency. Confirms no domain-model changes were needed — `MainHeatingDetail.main_heating_index_number` has existed since schema 17_1 and all mapper paths from 17_1+ pass it through verbatim. | ✅ | `15d6b781` | -| Heat pump Appendix N cascade via Table 362 | Apply Appendix N in-use factor (×0.95), MCS installation factor (×1.39 for GSHP MCS-installed), design flow temperature adjustment. Replace SCOP 2.30 Table 4a fallback for `main_category=4`. | ⏸ deferred (typed Table 362 parser + Appendix N cascade) | — | -| Equation D1 monthly water heating cascade | Spec D2.1 (2): η_water_monthly = (Q_space + Q_water) / (Q_space/winter + Q_water/summer). Promotes water_eff scalar → 12-tuple. Refactor of `_hot_water_fuel_kwh_per_yr`. | ⏸ deferred (single-digit-% HW kWh precision for combi boilers) | — | -| Solid fuel boiler precedence via Table 122 | PCDB override for `main_category=3` (solid fuel) — typed parser + wiring. | ⏸ deferred | — | -| Micro-CHP precedence via Table 143 | PCDB override for micro-CHP systems (Appendix N path). | ⏸ deferred | — | -| Ancillary cascades: Table 313 (FGHRS), 353 (WWHRS), 391 (HHR storage), 506 (HIU) | Typed parsers + cert-side wiring per spec rules. JSONL files exist; consumers don't. | ⏸ deferred | — | -| Table D1/D2/D3 condensing-boiler control-class corrections | Apply Ecodesign control-class + design-flow-temp adjustments on top of PCDB winter efficiency. Requires cert lodgement of control class + flow temp. | ⏸ deferred (no fixture lodges these yet) | — | - -**Impact:** Closes the ADR-0010 §4 prerequisite for gas/oil boilers. Heat pumps remain on SCOP 2.30 Table 4a fallback — ~19 SAP-point MAE on HP certs per ADR-0010 §4 persists until the Table 362 + Appendix N slice lands. §9a ALL_FIXTURES PDF-derived LINE_206/(211)/(215) pinning is *still blocked*: the 6 Elmhurst fixtures don't lodge `main_heating_index_number`, so adding PDF-grounded efficiency pins requires either (a) verifying each fixture's actual boiler against ncm-pcdb.org.uk + adding the PCDB ID to fixture builders, or (b) waiting for a real-corpus golden cert to validate against. diff --git a/packages/domain/src/domain/sap/README.md b/packages/domain/src/domain/sap/README.md index be8304a4..f6ba20ab 100644 --- a/packages/domain/src/domain/sap/README.md +++ b/packages/domain/src/domain/sap/README.md @@ -19,7 +19,9 @@ sap/ └── tables/ # Table U2 wind, Table 6 walls, Table 21 bridging, … ``` -Spec references: `docs/sap-spec/sap-10-3-full-specification-2026-01-13.pdf` (SAP), `docs/sap-spec/rdsap-10-specification-2025-06-10.pdf` (RdSAP cascade). Canonical worked example: `2026-05-19-17-18 RdSap10Worksheet.xlsx` at repo root — loaded by `_xlsx_loader.py`. +Spec references: `docs/sap-spec/sap-10-2-full-specification-2025-03-14.pdf` (SAP 10.2, the active target per ADR-0010), `docs/sap-spec/RdSAP 10 Specification 10-06-2025.pdf` (RdSAP cascade). Canonical worked example: `2026-05-19-17-18 RdSap10Worksheet.xlsx` at repo root — loaded by `_xlsx_loader.py`. + +**Validation contract.** Per `[[feedback-zero-error-strict]]` the 6 Elmhurst U985 fixtures are deterministic test vectors: every line ref of every output must pin against the U985 PDF at `abs=1e-4`. See `worksheet/tests/test_section_cascade_pins.py` (per-section line refs) and `test_e2e_elmhurst_sap_score.py::test_sap_result_pin` (top-level SapResult fields). Tolerances are never widened. The current work queue + scoreboard lives in `docs/sap-spec/HANDOVER_NEXT.md`. ## Adding a new Elmhurst conformance fixture @@ -42,7 +44,7 @@ The assessor exports two PDFs from Elmhurst's RdSAP tool: 2. **Mirror the Summary PDF into `build_epc()`** — one `SapBuildingPart` per Main/Extension. Field-by-field correspondence; the docstring at the top of the fixture should call out the source PDF date and the cert's distinguishing features. -3. **Capture every populated worksheet line** as `LINE_NN_*` module-level constants. The §1/§2 tests parametrize over `ALL_FIXTURES` and assert each line individually; §3 currently only checks invariants (the room-in-roof breakdown isn't yet computed by our code — see *Known gaps*). +3. **Capture every populated worksheet line** as `LINE_NN_*` module-level constants. The cascade pin test (`test_section_cascade_pins.py`) parametrizes over `ALL_FIXTURES` and asserts each line individually at `abs=1e-4` against the actual `
_from_cert(epc)` output. Capture every line, scalar and monthly, all the way through §12 — the strict-pin sweep is the work in progress. 4. **Register the fixture** in `_elmhurst_fixtures.py`: add the import and append the module to `ALL_FIXTURES`. @@ -68,7 +70,7 @@ The assessor exports two PDFs from Elmhurst's RdSAP tool: | `Floors.Type/Insulation` | top-level `epc.floors[0]` | similar pattern | | `Rooms in Roof` block | `SapBuildingPart.sap_room_in_roof = SapRoomInRoof(floor_area=...)` | see *Room-in-roof handling* | | `Total Number of Doors` | `door_count=` on `make_minimal_sap10_epc` | | -| `Windows` table (each W×H + area) | not currently structured per-window; we pass `window_total_area_m2` + area-weighted `window_avg_u_value` straight to `heat_transmission_from_cert(...)` | | +| `Windows` table (each W×H + area) | one `SapWindow` per row in `epc.sap_windows`, with per-window `u_value` lodged when the cert names a U-value (mixed-glazing fixtures need this for the per-window curtain-resistance transform — slice 22). `make_window(..., u_value=...)` is the canonical helper. | | | `Intermittent fans` | fixture constant `INTERMITTENT_FANS` (consumed by §2 test) | | | `Draught Lobby` / `Draught Proofing %` | fixture constants `HAS_DRAUGHT_LOBBY`, `WINDOW_PCT_DRAUGHT_PROOFED` | | | `Sheltered Sides` | fixture constant `LINE_19_SHELTERED_SIDES` (also asserted) | | @@ -90,7 +92,7 @@ From `3. Heat losses and heat loss parameter`: - `LINE_36_THERMAL_BRIDGING_W_PER_K` ← `(36)` = y × (31) - `LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K` ← `(37)` = (33) + (36) -The §3 test currently only checks invariants ((33) = Σ per-element, (37) = (33) + (36), area > 0). The `LINE_3*` constants are still worth capturing — they're the ground truth for when the room-in-roof gap closes. +All four §3 aggregates are now pinned by `test_section_cascade_pins.py::test_section_3_line_refs_match_pdf` at `abs=1e-4`. RR detailed surfaces lodged via `SapRoomInRoof.detailed_surfaces` (slices 13–23) close the room-in-roof breakdown end-to-end for every fixture with detailed §3.10 lodgement (000477, 000480, 000516; 000487 still has the U=0.86 external-gable variant pending spec input). ## Gotchas @@ -103,8 +105,9 @@ So a 2.91 m upper-storey internal height appears on the worksheet as 3.16 m. Mir ### Room-in-roof - §1 RdSAP `2.45 m` storey-height convention is hardcoded in `dimensions.py` regardless of any height the RR cert input claims. The worksheet line `(2d)` for an RR storey shows 2.45. -- We encode it as `SapBuildingPart.sap_room_in_roof = SapRoomInRoof(floor_area=...)`, NOT as a third `SapFloorDimension`. The dimensions calculator treats the RR as +1 storey, +floor_area to TFA, +floor_area × 2.45 to volume. -- Our §3 currently under-counts RR fixtures because `SapRoomInRoof` only carries `floor_area` — the gables, slopes, stud walls, flat ceiling that the worksheet lists individually are NOT yet modelled. This is the *one big known gap* (see `_elmhurst_worksheet_000487.py` comments). +- We encode it as `SapBuildingPart.sap_room_in_roof = SapRoomInRoof(floor_area=..., detailed_surfaces=[...])`, NOT as a third `SapFloorDimension`. The dimensions calculator treats the RR as +1 storey, +floor_area to TFA, +floor_area × 2.45 to volume. +- §3.10 Detailed RR is implemented (slices 13, 16, 23). `SapRoomInRoofSurface` carries `kind` ∈ {`slope`, `flat_ceiling`, `stud_wall`, `gable_wall`}, `area_m2`, optional `insulation_thickness_mm` + `insulation_type`. Slope/flat_ceiling/stud_wall route to roof per Table 17; gable_wall routes to party at U=0.25 per Table 4 "as common wall". The U=0.86 "external gable" variant (000487) is NOT yet implemented — open ticket. +- Simplified Type 1 (RR lodged with only `floor_area`) still works via the spec's `A_RR = 12.5 × √(A_RR_floor/1.5)` formula at `u_rr_default_all_elements` (Table 18 col 4). Detailed lodgement supersedes when present. ### Party wall U mapping `party_wall_construction` integer codes resolve via `domain.ml.rdsap_uvalues.u_party_wall`: