Model/domain/sap10_calculator
Khalim Conn-Kowlessar 49de18e83a Slice S0380.45: wire β-split into PE cascade per SAP 10.2 Appendix M1 §8
The PE cascade in calculator.py was crediting ALL PV generation at the
IMPORT PEF (Table 12 ~1.501) instead of splitting per Appendix M1
§4/§8 — onsite-consumed E_PV,dw at the IMPORT PEF and exported E_PV,ex
at the EXPORT PEF (Table 12 code 60 = 0.501). The over-credit on the
exported portion was the primary driver of the ASHP-cohort PE Δ -7..-15
kWh/m² under-count.

Wiring (cert_to_inputs.py):
  - `_pv_array_monthly_generation_kwh(array, climate)` — per-array E_PV,m
    via Appendix M1 §2 (p.92) apportioning: 0.8 × kWp × ZPV × monthly
    solar radiation. Reuses ORIENTATION/PITCH/Z lookups already in
    `_pv_array_generation_kwh_per_yr`. Annual sum equals the existing
    helper to float precision.
  - `_pv_monthly_generation_kwh(epc, climate)` — sums per-array monthlies;
    falls back to the same §11.1 b) percent-roof-area synthesis as the
    annual helper for certs without per-array detail.
  - `_pv_battery_capacity_kwh(epc)` — total usable battery capacity =
    per-battery capacity × pv_battery_count. The 15 kWh cap per §3c is
    applied inside `pv_beta_coefficients` and not duplicated here.
  - `_pv_eligible_demand_monthly_kwh(...)` — assembles D_PV,m per §3a
    p.93: lighting + appliances + cooking + electric showers + pumps
    & fans, plus E_space,m when main fuel is Table-12 {30, 32, 34, 35,
    38} (electricity not at off-peak) and E_water,m when water heating
    fuel is Table-12 30 (standard electricity). Off-peak immersion ×
    (243) and the Appendix G4 PV-diverter branch are deferred —
    current cohort fixtures don't exercise them.
  - In `cert_to_inputs`: assemble monthly EPV + DPV + battery, call
    `pv_split_monthly`, pass `pv_dwelling_kwh_per_yr` +
    `pv_exported_kwh_per_yr` through to CalculatorInputs.

Wiring (calculator.py):
  - New fields: `pv_dwelling_kwh_per_yr: Optional[float]`,
    `pv_exported_kwh_per_yr: Optional[float]`,
    `pv_export_primary_factor: float = 0.501` (Table 12 code 60).
  - PE cascade now does:
      pv_offset = E_PV,dw × IMPORT_PEF + E_PV,ex × EXPORT_PEF
    when both split fields are set. Legacy fall-through to all-IMPORT
    when either is None (preserves synthetic CalculatorInputs
    constructions in unit tests).

Test impact (golden-fixture residual shifts — all expected, re-pinned):

  Pre-Slice 45 → Post-Slice 45:
    - 0330 (no PV):                  +0.44 → +0.44   (unchanged ✓)
    - 0350 (PV + 5 kWh battery):     -7.78 → +2.73
    - 0380 (PV + 5 kWh battery):    -14.60 → +8.09
    - 2130 (PV + gas combi):        -38.63 → -9.70   (also SAP +1 shift)
    - 2225 (PV + 5 kWh battery):    -11.77 → +4.48
    - 2636 (PV + 5 kWh battery):     -9.65 → +3.42
    - 3800 (PV + 5 kWh battery):     -9.61 → +3.58
    - 9285 (PV + 5 kWh battery):     -7.96 → +3.20
    - 9418 (PV + 5 kWh battery):     -7.30 → +4.67
    - 9501 (PV, no battery):         -8.28 → +0.25   (CLOSED ✓)

  Cert 9501 closing to +0.25 with the β-split alone confirms the
  implementation is spec-correct. The 7-cert 5-kWh-battery cohort
  now over-shoots in the positive direction because the cascade's
  E_PV magnitude is ~3× the worksheet's (cert 0380 cascade 2570 kWh/yr
  vs worksheet 831 kWh/yr — peak_power=3 interpreted as 3 kWp while
  worksheet uses ~1 kWp). With E_PV overestimated, R_PV = E_PV / D_PV
  is too high → β_m from §3d formula too low → not enough credit
  shifts to the IMPORT factor. Slice S0380.46 audits the cascade's
  E_PV magnitude (kWp interpretation, S lookup, or ZPV mapping).

  Chain tests (cohort-1 + cohort-2 SAP-rating-vs-worksheet) all stay
  <1e-4 — Slice 45 only touches the PE cascade; SAP rating uses the
  cost cascade which is still on the old all-export path.

Test suite: 763 pass + 0 fail. Pyright net-zero on touched files.

Spec citations:
  - SAP 10.2 specification Appendix M1 §3a (p.93) — D_PV,m assembly.
  - SAP 10.2 specification Appendix M1 §3c-d (p.94) — β formula.
  - SAP 10.2 specification Appendix M1 §4 (p.94) — E_PV,dw / E_PV,ex.
  - SAP 10.2 specification Appendix M1 §8 (p.94) — PE factor split.
  - SAP 10.2 Table 12 code 60 — EXPORT PEF = 0.501.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:34:56 +00:00
..
climate refactor: lift-and-shift packages/domain/src/domain/sap → domain/sap10_calculator 2026-05-26 12:22:37 +00:00
docs docs: handover after S0380.39..S0380.43 — cohort-2 API path 38/38 closed 2026-05-28 17:22:55 +00:00
rdsap Slice S0380.45: wire β-split into PE cascade per SAP 10.2 Appendix M1 §8 2026-05-28 18:34:56 +00:00
tables Slice S0380.28: SAP 10.2 Appendix N footnote 43 reciprocal η interpolation — closes the +0.03..+0.06 ASHP precision-floor cluster 2026-05-28 11:44:11 +00:00
tests Slice S0380.28: SAP 10.2 Appendix N footnote 43 reciprocal η interpolation — closes the +0.03..+0.06 ASHP precision-floor cluster 2026-05-28 11:44:11 +00:00
validation refactor: lift-and-shift packages/domain/src/domain/sap → domain/sap10_calculator 2026-05-26 12:22:37 +00:00
worksheet Slice S0380.44: SAP 10.2 Appendix M1 §3-4 PV β-factor calculator (no wiring) 2026-05-28 18:11:56 +00:00
__init__.py refactor: lift-and-shift packages/domain/src/domain/sap → domain/sap10_calculator 2026-05-26 12:22:37 +00:00
calculator.py Slice S0380.45: wire β-split into PE cascade per SAP 10.2 Appendix M1 §8 2026-05-28 18:34:56 +00:00
README.md refactor: move docs/sap-spec/ contents into domain/sap10_calculator/ 2026-05-26 13:17:18 +00:00

SAP calculation domain

Per-section worksheet calculators for SAP 10.2 / RdSAP 10. Each file mirrors a numbered section of the spec; tests live alongside under worksheet/tests/ and tests/.

sap/
├── calculator.py            # top-level orchestrator → SapResult
├── worksheet/
│   ├── dimensions.py         # §1 Overall dwelling dimensions
│   ├── ventilation.py        # §2 Ventilation rate (+ RdSAP10 §4.1)
│   ├── heat_transmission.py  # §3 Heat losses & HLP
│   ├── ...                   # §4 onward
│   └── tests/
│       ├── _xlsx_loader.py
│       ├── _elmhurst_fixtures.py        # registry of Elmhurst conformance fixtures
│       ├── _elmhurst_worksheet_NNNNNN.py # one per worksheet pair
│       └── test_*.py
├── rdsap/                   # cert → SapInputs cascade (RdSAP10 §5)
└── tables/                  # Table U2 wind, Table 6 walls, Table 21 bridging, …

Spec references: domain/sap10_calculator/docs/specs/sap-10-2-full-specification-2025-03-14.pdf (SAP 10.2, the active target per ADR-0010), domain/sap10_calculator/docs/specs/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, 768 rating + 90 demand pins) and test_e2e_elmhurst_sap_score.py::test_sap_result_pin (top-level SapResult fields). Tolerances are never widened. Current state: 930/930 pins green. The public API + architecture overview lives in domain/sap10_calculator/docs/SAP_CALCULATOR.md.

Adding a new Elmhurst conformance fixture

Each Elmhurst fixture is a real-cert ground-truth: we encode the cert as EpcPropertyData, then assert our §1/§2/§3 output matches the lodged worksheet line-by-line. The fixtures act as a regression net for every cert-shape variation (RR, extension, party-wall code, sheltered sides, …) we've seen in the wild.

Input: one PDF pair per cert

The assessor exports two PDFs from Elmhurst's RdSAP tool:

  1. Summary_NNNNNN.pdf — the assessor's RdSAP Inputs form: property type, age band, dimensions, walls, roof, floors, windows, heating, ventilation. This is what we encode as EpcPropertyData.
  2. UXXX-XXXX-NNNNNN.pdf — the calculator's full worksheet output: every populated line ref (1a)..(486) for the Energy Rating, EPC Costs, and Improved Dwelling variants. The Energy Rating variant (the first section) is canonical for line-ref tests.

NNNNNN is the cert's Full RefNo — both PDFs must match. Always capture from the Energy Rating section, not EPC Costs (the latter uses slightly different wind speeds for the BEDF fuel-price calc).

Steps

  1. Drop a new fixture module at worksheet/tests/_elmhurst_worksheet_NNNNNN.py. Copy the closest existing fixture as a starting template:

    • 3-storey with room-in-roof → start from _elmhurst_worksheet_000487.py (RR + extension + alt wall) or _elmhurst_worksheet_000477.py (RR main-only)
    • 2-storey with extension(s) → _elmhurst_worksheet_000474.py (Main + 2 ext, no RR) or _elmhurst_worksheet_000480.py (Main + 1 ext, with RR)
  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 cascade pin test (test_section_cascade_pins.py) parametrizes over ALL_FIXTURES and asserts each line individually at abs=1e-4 against the actual <section>_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.

  5. Run the conformance tests:

    python -m pytest domain/sap10_calculator/worksheet/tests/ \
        -k elmhurst --no-cov -v
    

    Each fixture appears 3× (one parametrize per section), pytest id = the cert ref number.

Mapping the Summary PDF to EpcPropertyData

Summary field EpcPropertyData location Notes
Property type epc.property_type via make_minimal_sap10_epc(...) drives mid/end/detached defaults
Date Built (per part) SapBuildingPart.construction_age_band one-letter A..M
Storeys NOT a stored field — sum across sap_floor_dimensions + 1 if RR §2 (9) uses dwelling height, not Σ across parts (LINE_9_STOREYS captures this)
Floor Area / Room Height / Heat Loss Wall Perimeter / Party Wall Length one SapFloorDimension per storey of the part see Storey height convention below
Walls.Type wall_construction 3=solid brick, 4=cavity, 5=timber frame, 6=system built
Walls.Insulation wall_insulation_type 4=as-built; 2=filled cavity
Party Wall Type party_wall_construction see Party wall U mapping below
Roof.Type/Insulation/Thickness top-level epc.roofs[0] EnergyElement RdSAP cascade reads description string
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) 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)
Mechanical Ventilation fixture constant MV_KIND default MechanicalVentilationKind.NATURAL

Worksheet lines to capture

From the Energy Rating section's 1. Overall dwelling characteristics:

  • LINE_4_TFA_M2 ← line (4) Total floor area
  • LINE_5_VOLUME_M3 ← line (5) Dwelling volume

From 2. Ventilation rate:

  • Scalars: LINE_8 through LINE_21 — every (N) line, including the pressure-test override (18) and shelter (19)/(20)/(21)
  • Monthly tuples: LINE_22_WIND_SPEED_M_S, LINE_22A_WIND_FACTOR, LINE_22B_WIND_ADJUSTED_ACH, LINE_25_EFFECTIVE_ACH — twelve floats Jan..Dec

From 3. Heat losses and heat loss parameter:

  • LINE_31_TOTAL_EXTERNAL_AREA_M2(31) Σ A external elements (excludes party wall)
  • LINE_33_FABRIC_HEAT_LOSS_W_PER_K(33) Σ (A × U) without bridging
  • LINE_36_THERMAL_BRIDGING_W_PER_K(36) = y × (31)
  • LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K(37) = (33) + (36)

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 1323) 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

Storey height convention (SapFloorDimension.room_height_m)

The worksheet's (2x) height column includes a +0.25 m floor-structure allowance on every storey above the lowest:

  • floor=0 (lowest): internal room height as measured
  • floor=1 / floor=2 / …: internal room height + 0.25

So a 2.91 m upper-storey internal height appears on the worksheet as 3.16 m. Mirror the worksheet number into the fixture, not the surveyor's tape measurement.

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=..., 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.sap10_ml.rdsap_uvalues.u_party_wall:

  • 0 (Unknown / "Unable to determine") → 0.25 W/m²K
  • 1 (Stone granite) / 3 (Solid brick) / 5 (Timber frame) / 6 (System built) → 0.0
  • 4 (Cavity, unfilled) → 0.5

Cross-check against the worksheet's Party walls Main row in §3 — that's the authoritative U for the cert.

Sheltered sides drives shelter factor

(19) varies per cert and the chain (20) = 1 - 0.075 × (19), (21) = (18) × (20) propagates through every monthly (22b)/(25). Read straight from the cert's Sheltered Sides field; not derivable from property type alone.

(12) suspended-timber-floor quirk

Some Elmhurst certs list a suspended timber floor on the inputs but lodge (12) = 0.0 in the worksheet. Mirror the worksheet, not the cert input: set HAS_SUSPENDED_TIMBER_FLOOR=False to get (12)=0. The SUSPENDED_TIMBER_FLOOR_SEALED flag only switches between 0.2 (unsealed) and 0.1 (sealed); it does not zero out the contribution. The =True/=False mapping in ventilation.py:185:

has_suspended_timber_floor ..._sealed resulting (12)
False (any) 0.0
True False 0.2
True True 0.1

Effective monthly ACH (25) formula

Not equal to (22b) when (22b) < 1.0:

(25) = (22b)                              if (22b) ≥ 1.0
(25) = 0.5 + (22b)² × 0.5                  otherwise

Don't try to compute it — read both (22b) and (25) straight off the worksheet and assert on both. The formula's here just so you recognise why they differ on tightly-sealed homes.

Wind speeds: Energy Rating vs EPC Costs

The same cert prints two Wind speed (22) tables — one in CALCULATION OF ENERGY RATING, one in CALCULATION OF EPC COSTS, EMISSIONS AND PRIMARY ENERGY. They differ (the latter is the BEDF-prices variant). Always capture from the Energy Rating section; that's what ventilation_from_inputs(...) calibrates against. The non-regional Table U2 default values are 5.1, 5.0, 4.9, 4.4, 4.3, 3.8, 3.8, 3.7, 4.0, 4.3, 4.5, 4.7.