Implements Table 5a row-by-row leaf functions:
central_heating_pump_w pump install-date bucket (3/7/10 W)
liquid_fuel_boiler_pump_w 10 W when oil-fuel pump inside dwelling
liquid_fuel_warm_air_pump_w 10 W for liquid-fuel warm-air systems
warm_air_heating_fan_w SFP × 0.04 × V (heating-season)
piv_fan_w IUF × SFP × 0.12 × V (year-round)
balanced_mv_no_hr_fan_w IUF × SFP × 0.06 × V (year-round)
heat_interface_unit_w PCDB kWh/day × 1000 / 24 (year-round)
Plus pumps_fans_monthly_w(heating_season_w, year_round_w) which applies
the Table 5a footnote-a seasonal mask (Jun-Sep = 0 W heating-season
contribution per Elmhurst worksheet convention).
PumpDateCategory enum maps from EpcPropertyData.central_heating_pump_age_str
("Pre 2013" / "Post 2013" / "Unknown" / etc.) at the orchestrator layer.
MVHR and MEV systems intentionally have no leaf fn — gains are zero per
Table 5a notes (MVHR effect is in MVHR efficiency; MEV simply omitted).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implements the full SAP10.2 Appendix L lighting calculation: Λ_B (L1)
→ Λ_req (L3) → Λ_prov (L6) → Λ_topup (L7) → E_L,fixed/topup/portable
(L9a-d) → monthly cosine modulation (L10) → 0.85 × 1000 / (24 × n_m)
heat-gain bridge (L12).
Critical detail uncovered while reconciling against the 000490
worksheet: C_daylight uses Z_L from Table 6d's **third column** (light
access factor), NOT the 0.77 first column used for §6 solar gains. For
"Average" overshading Z_L = 0.83. Conflating the two columns gives a
~2% lighting-energy bias.
Verified against Elmhurst U985-0001-000490 (67)m to ≤5e-3 W/month
(0.14% on E_L) using worksheet bulb table (8 LEL × 80 lm/W × 15 W)
and Table 6b/6c/6d defaults for the window inputs.
The orchestrator slice will derive C_L,fixed + ε_fixed from RdSAP §12-1
per-lamp-type defaults (LED 100 lm/W, CFL 55 lm/W, LEL 80 lm/W,
incandescent 11.2 lm/W) and C_daylight from the cert's window data.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pure unit conversion: G_WH,m = 1000 × (65)m / (n_m × 24). The §4
heat_gains_from_water_heating_monthly_kwh output already encodes the
25%/80% spec-recovery factors for delivered-heat vs pipe-side losses;
this bridge just lands the kWh/month into watts for the §5 sum.
Verified against Elmhurst U985-0001-000490 (72)m row — exact to 4 d.p.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP10.2 Table 5 Column A row "Cooking": G_C = 35 + 7 × N watts,
year-round. Fuel-agnostic (gas/electric same gain — fuel matters only
for §12 cost). Verified against Elmhurst U985-0001-000490 worksheet
(69)m row.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tracer bullet for §5 internal-gains rebuild. New 12-tuple monthly API
lands alongside the legacy scalar internal_gains_w stub; calculator.py
keeps building until the §5 wiring slice. SAP10.2 Table 5 Column A is
the rating + cooling default — Column B (new-build DPER/TPER) deferred.
Deletes the legacy SAP-10.3-flavoured test_internal_gains.py per the
rebuild plan; new tests will accrete slice-by-slice.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Populates §4 LINE_42..LINE_65 + per-fixture HW inputs (HAS_BATH,
MIXER_SHOWER_FLOW_RATES_L_PER_MIN, COLD_WATER_TEMPS_C, LOW_WATER_USE,
COMBI_LOSS_OVERRIDE, ELECTRIC_SHOWER_OVERRIDE) in 000477, 000480,
000487, 000516 — values extracted from the Elmhurst U985 worksheets
supplied 2026-05-20. 000474 + 000490 get the same input constants for
uniform parametrization.
Adds electric_shower_monthly_kwh_override to water_heating_from_cert
to unlock 000487 (instantaneous electric shower, no mixer). The
orchestrator's has_shower flag now also accounts for the electric path.
Extends 6 parametrized §4 tests from (000474, 000490) to ALL_FIXTURES
and adds a new ALL_FIXTURES-parametrized e2e test exercising the
orchestrator end-to-end through (42)..(65) for every Elmhurst fixture.
Tolerance on (43)/(44) loosened to 5e-3 to absorb Elmhurst's 4-d.p.
display rounding.
Result: 150/150 tests pass; §1-§4 conform at ≤1e-2 kWh / 5e-3 L for
every fixture. Deferred branches surfaced via overrides:
- PCDB Table 3b combi loss (000474, 000477, 000516)
- Non-time-clock Table 3a combi loss rows (000480, 000487)
- Electric-shower (64a)m derivation from cert codes (000487)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the legacy `predicted_hot_water_kwh` cascade with a call into
`water_heating_from_cert` for the modal combi-gas-mains population. The
new helper `_hot_water_fuel_kwh_per_yr` chains the §4 cascade end-to-end
(occupancy → daily hot water → energy content → distribution + combi
loss → (62)m total → (64)m output) then divides by water-heater
efficiency to land annual fuel kWh — the slot CalculatorInputs expects.
Section-by-section validation across all 6 Elmhurst fixtures shows:
§1 dimensions exact (≤ 1e-4) on all 6
§2 ventilation exact (≤ 1e-4) on all 6
§3 heat trans exact on non-RR (000474, 000490) within 0.04 W/K
(display-rounding); RR fixtures under-count per the
formal SapRoomInRoof sub-area deferral.
§4 hot water exact on the 2 fixtures with LINE_42/LINE_64 lodged
(000474 PCDB override + 000490 cascade-default); 4 RR
fixtures emit plausible orchestrator values.
End-to-end SAP impact (legacy → new):
000490 57=57 (cont 56.72 → 56.92, closer to worksheet 57.40)
000474 55→56 (cont 55.39 → 55.59, expected 62, still 6pt under)
Caveats / future slices:
- Cold water source defaults to mains (no domain-model field yet).
- Shower flow rate defaults to 7 L/min vented (no shower_outlet_type
plumbing yet); both fixtures actually lodge this so no false drift.
- Cylinder + solar + WWHRS / PV / FGHRS branches default to zero.
- PCDB Table 3b combi loss not implemented; orchestrator accepts a
`combi_loss_monthly_kwh_override` for now but cert_to_inputs always
falls to Table 3a row "time-clock keep-hot".
- water_efficiency variable misnamed "pct" — it's a decimal (0.0-1.0).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First end-to-end test running EpcPropertyData → cert_to_inputs →
calculate_sap_from_inputs → SapResult and comparing against the
Elmhurst worksheet's headline SAP rating (line 258).
Current state:
000490 mid-terrace gas combi, time-clock keep-hot
SAP rating: 57 = 57 ✓ exact integer match
Continuous: 56.72 vs 57.40 → 0.7 points off (rounding noise)
000474 end-terrace gas combi, PCDB Vaillant ecoTEC pro
SAP rating: 55 vs 62 → 7 points UNDER
Space heating: 12299.6 vs 10612.9 (+16%)
Hot water: 3020.0 vs 2291.8 (+32%)
The 000474 gap localises to (a) the legacy hot-water cascade not
knowing about PCDB Table 3b combi loss (over-estimates HW by 32%) and
(b) likely a downstream space-heating-efficiency consequence. Both will
shrink once the §4 worksheet orchestrator + Table 3b are wired into
cert_to_inputs.
Tolerances set at the CURRENT gap so subsequent improvements show up
as tightening, not silent drift. The 000474 ceiling drops to ≤2 SAP
points once the worksheet §4 path lands in the mapper.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Chains every leaf function landed in slices 1-9 into a single call that
takes an EpcPropertyData + the few site-notes inputs that aren't on the
domain object yet (shower flow rates, has_bath, cold-water source, low-
water-use flag). Mirrors heat_transmission_from_cert's shape from §3.
WaterHeatingResult exposes the line refs (42), (43), (44)m, (45)m, (46)m,
(61)m, (62)m, (64)m, (65)m plus the annual sum of (64)m as
`output_kwh_per_yr` — that's the slot calculator.py's CalculatorInputs
expects for `hot_water_kwh_per_yr` (modulo division by water heater
efficiency, handled by the caller).
`combi_loss_monthly_kwh_override` accepts a (61)m array for PCDB-tested
boilers (Table 3b/3c) since those need r1+F1 parameters we haven't
implemented. Defaulting to Table 3a row "time-clock keep-hot" suits the
modal non-PCDB combi lodging.
Validated end-to-end against both Elmhurst non-RR fixtures:
- 000490: cascade-default combi loss, output matches annual to 0.01 kWh
- 000474: PCDB-derived (61)m injected, output matches to 0.01 kWh
Cylinder + solar + WWHRS/PV/FGHRS + electric-shower branches default to
zero — extension slices land them when needed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Updates SPEC_COVERAGE.md with the 9 §4 slices landed since the last doc
sweep, and lays out the remaining work in priority order:
1. §4 orchestrator (water_heating_from_cert)
2. Wire calculator.py to the new worksheet module
3. End-to-end SAP score validation against Elmhurst worksheets
4. Cylinder + solar + renewables branches (population coverage)
5. PCDB-backed Table 3b/3c combi loss (000474 sits here)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
(65)m = 0.25 × [0.85 × (45)m + (61)m + (64a)m]
+ 0.80 × [(46)m + (57)m + (59)m]
First bracket recovers 25% of delivered-heat losses (hot water at the
tap + combi cycling + electric-shower waste heat); second bracket
recovers 80% of pipe-side losses (distribution + solar storage +
primary circuit) since pipework typically sits inside the heated
envelope. Per spec footnote on xlsx row 302, callers should zero (57)m
when the hot water store is OUTSIDE the heated space (e.g. communal
heat networks).
Validated against both Elmhurst fixtures to <1e-3 kWh:
000490 Jan: 0.25×(0.85×187.86 + 50.96 + 0) + 0.80×(28.18 + 0 + 0)
= 0.25×210.64 + 0.80×28.18 = 52.66 + 22.54 = 75.20 ✓
000474 Jan: 0.25×(0.85×174.40 + 28.72 + 0) + 0.80×(26.16 + 0 + 0)
= 0.25×176.96 + 0.80×26.16 = 44.24 + 20.93 = 65.17 ✓
LINE_64A_M and LINE_65_M lodged on both fixtures.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
(64)m = max(0, (62)m + (63a)m + (63b)m + (63c)m + (63d)m)
The four (63 a-d) inputs are WWHRS, PV-diverter, solar HW and FGHRS
contributions — entered as negative quantities so the formula uses +,
not −. The max-clamp guards "if (64)m < 0 then set to 0" per the spec
worksheet text: a renewable-heavy summer can't show negative delivered
heat.
Both Elmhurst non-RR fixtures lodge zero for all four (no WWHRS, no PV
diverter, no solar, no FGHRS), so (64)m = (62)m for every month.
Validated end-to-end on both with abs=1e-3 kWh tolerance.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new public functions:
combi_loss_monthly_kwh_table_3a_keep_hot_time_clock()
Table 3a row "Instantaneous, with keep-hot facility controlled by
time clock" → 600 × n_m / 365 kWh/month (flat 600 kWh/year prorated
by month length, no fu adjustment).
total_water_heating_demand_monthly_kwh(...)
Spec formula (62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m.
(56)m storage loss is intentionally absent — folded into storage-
system efficiency at the (64)m stage. (46)m distribution loss
appears here AND in (65)m heat gains (weight 0.8), per spec.
000490 close end-to-end through (62)m: combi with time-clock keep-hot,
no storage, no solar, no primary loss → Jan = 0.85×187.86 + 28.18 + 0 +
0 + 50.96 = 238.82 matching the worksheet to 1e-3.
000474 deferred: its PCDF-listed Vaillant boiler uses Table 3b (tested
to EN 13203-2) which needs PCDB-backed r1 + F1 parameters. The (61)m
implementation for that branch lands in a future slice along with the
PCDB stub plumbing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
(45)m = 4.18 × V_d,m × n_m × (52 − Tcold[m]) / 3600 [kWh/month]
Appendix J equation J14
(46)m = 0.15 × (45)m spec §4 step 7 (normal systems)
= 0 (instantaneous at point of use,
hot water codes 907 / 909)
4.18 J/(g·K) is the specific heat of water; / 3600 converts to kWh. The
J14 transform converts daily L of hot water at delivery temperature into
the monthly sensible-heat requirement.
Both Elmhurst non-RR fixtures use a combi boiler from a central system
(neither 907 nor 909), so distribution loss is the full 15 % of (45)m.
Lodged LINE_45_M and LINE_46_M arrays on both fixtures for forward use.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two thin wrappers landing the aggregation step:
(44)m = (42a)m + (42b)m + (42c)m Appendix J equation J13
(43) = V_d,shower,ave + V_d,bath,ave + V_d,other,ave J12
A subtle spec point caught here: (43) is the SUM OF THE COMPONENT
ANNUAL AVERAGES (per the J12 text), not the days-weighted mean of (44)m.
The two are arithmetically different because Table J2's days-weighted
mean is 0.99973 rather than 1.0 — the "other uses" term contributes its
unmodulated baseline (9.8N+14), and only the showers + baths terms get
the days-weighted reduction. Spec-following the J12 wording matches the
Elmhurst (43) values to 1e-3 L/day on both fixtures.
annual_average_hot_water_other_uses_l_per_day exposes V_d,other,ave
annual_average_hot_water_l_per_day composes the J12 sum
total_hot_water_monthly_l_per_day J13 (44)m sum
LINE_43 + LINE_44_M lodged on 000474 and 000490 fixtures.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Appendix J equations J1–J3. Per-day hot water draw for mixer showers
combines the per-day shower count (rising with N, depressed slightly
when a bath is also present) with each outlet's flow × 6 min × Table J5
behavioural factor, then multiplied by the cold-water-dependent hot
fraction (41 °C delivery vs 52 °C hot supply, Tcold from J1).
Multi-outlet handling: N_shower is split across outlets so a dwelling
with two identical mixers produces the same (42a)m total as a single
outlet — the count only matters when outlets have different flow rates.
Instantaneous electric showers belong in (64a)m and must be excluded
from the input.
Validated against the Elmhurst non-RR fixtures (both 1 vented mixer at
7 L/min, mains Tcold):
- 000490 N=2.1468 → Jan V_d,hot = 52.6878
- 000474 N=1.8896 → Jan V_d,hot = 48.9139
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Appendix J equations J6, J7, J8. Daily hot water for bath fills depends
on N, presence of bath and/or shower, and monthly Tcold:
N_bath = 0 if no bath but a shower exists
= 0.13×N + 0.19 if bath + shower
= 0.35×N + 0.50 otherwise
V_d,bath[m] = N_bath × 73 × J5_fbeh[m] × (42−Tcold[m])/(52−Tcold[m])
Tables J1 (mains + header tank Tcold) and J5 (behavioural factor) are
exported as module constants for reuse by (42a)m showers next.
Validated against the Elmhurst non-RR fixtures, both with bath + shower
and "Cold Water Source: From mains":
- 000490 N=2.1468 → Jan V_d,bath = 27.3868
- 000474 N=1.8896 → Jan V_d,bath = 25.4345
Also covers the zero-bath branch and the 5% low-water-use reduction.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Appendix J equation J11 — daily hot water use for non-shower / non-bath
purposes (sinks, dishwashers, etc.) is annual-avg V_d,other,ave = 9.8 ×
N + 14, modulated month-by-month by the Table J2 monthly factors and
reduced by 5% when the dwelling meets the 125 L/person/day water-use
target.
Validated against both Elmhurst non-RR fixtures to better than 1e-3 L:
- 000490 N=2.1468 → V_d,other,ave ≈ 35.04, Jan = 38.5426
- 000474 N=1.8896 → V_d,other,ave ≈ 32.52, Jan = 35.7697
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First slice of the §4 worksheet-driven rewrite (xlsx rows 207-304).
New module `domain/sap/worksheet/water_heating.py` lands the line-ref
mapped functions; subsequent slices append below.
`assumed_occupancy(tfa)` implements the SAP10.2 Appendix J Table 1b
piecewise formula. Validated against:
- canonical xlsx worked example (TFA Q23 → N U209)
- Elmhurst U985-0001-000474 (TFA 56.79 → N 1.8896)
- Elmhurst U985-0001-000490 (TFA 66.06 → N 2.1468)
- boundary case TFA ≤ 13.9 (N=1 floor)
The legacy `domain.ml.demand._default_occupants_sap_j` mirror stays in
place until the §4 worksheet rewrite is complete; both sources will be
reconciled in a later slice once dependent callers move over.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
§3 close (LINE_31/33/36/37 exact for both non-RR Elmhurst worksheets) is
now landed across slices 344a9c9d..cf244762. HANDOVER_S3_CLOSE.md was
written as a mid-stream working brief; with §3 done it now creates doc
rot, so it's removed in favour of SPEC_COVERAGE.md as the single source
of truth.
SPEC_COVERAGE.md updates:
- §3 marked Full (non-RR); RR sub-area deferral noted
- §4 carries the ordered slice plan for the worksheet-driven rewrite
(xlsx rows 207–304, line refs (42)..(65))
- Hierarchy callout: the canonical SAP10.2 algorithm lives in the
repo-root xlsx, not in any handover doc
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the second non-RR Elmhurst worksheet (mid-terrace, 3 parts).
LINE_33 (209.1084) and LINE_37 (232.1169) reproduce to 0.1 W/K.
Cert inputs lodged on the fixture:
- Ext1 SapFloorDimension(is_exposed_floor=True) — Table 20 route
- Ext2 ground floor (tiny 1.35 m², P=3.30) stays on Table 19 fn 1
suspended-timber default for age B (cascade → U≈1.25, worksheet 1.25)
- door_count=2 → 3.70 m² total door area
- WINDOW_TOTAL_AREA_M2=11.72 split across two glazing types
(Type 1: 6.22 m² post-2002 raw U=2.0, Type 2: 5.50 m² pre-2002 raw
U=2.8). Area-weighted aggregate raw U=2.37 reproduces the worksheet's
25.37 W/K through the curtain-resistance transform.
Non-RR §3 scope closed:
- LINE_31 exact (existing test)
- LINE_33 exact ← this slice + the 000490 slice
- LINE_36 exact (existing test, y × LINE_31)
- LINE_37 exact ← this slice + the 000490 slice
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
End-to-end §3 fabric heat loss now matches the Elmhurst worksheet to
0.1 W/K (the worksheet displays per-element U-values to 2 d.p.; our
cascade keeps full precision so the totals differ at the third decimal).
Cert inputs lodged on the fixture:
- roof_insulation_thickness=300 mm on Main and Ext1 → Table 16 U=0.14
- door_count=2 (cascade default 1.85 m²/door → 3.70 m² worksheet area)
- WINDOW_TOTAL_AREA_M2=9.03 with WINDOW_AVG_RAW_U_VALUE=2.8 (pre-2002
double-glazed PVC, 12mm gap; Table 24 row → U_eff=2.518)
Per-part window/door apportionment cancels in the §3 line totals — net
wall sums to the same value whether openings sit on Main or Ext1 — so a
single aggregate area/U pair reproduces (33) exactly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per the worksheet docstring on this fixture, Extension 1 hangs off the
main from the first storey upward — its lowest dimension is an exposed
timber floor (over outside air), not a ground floor on soil. Set
is_exposed_floor=True so heat_transmission_from_cert routes Ext1 through
the Table 20 lookup (U=1.20 W/m²K at age B unknown insulation) instead
of BS EN ISO 13370.
Combined with the Table 19 fn 1 default that routes Main to the
suspended-timber branch (U≈0.71), §3 LINE_28A floor sum lands at
≈32.4 W/K — matching the worksheet's 0.71×14.85 + 1.20×18.18.
A new floor-sum regression test pins the combined behaviour; the existing
LINE_31/36 parametrised test still passes (the exposed-floor route
contributes its area to LINE_31 the same way the ground-floor route did).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SapFloorDimension gains an is_exposed_floor flag (default False) signalling
that the floor sits over outside air or unheated space rather than soil —
typical for an extension that hangs off the main from the first storey
upward (Elmhurst 000490 Extension 1 is exactly this shape).
heat_transmission_from_cert now consults the flag on the part's ground
SapFloorDimension and dispatches to u_exposed_floor (Table 20) instead
of the BS EN ISO 13370 / Table 19 cascade. Basement floor still wins
priority (Table 23 § 5.17 overrides everything else for that part).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RdSAP10 §5.13 Table 20 (page 47) gives U-values for upper floors that
sit over outside air (exposed) or enclosed unheated space (semi-exposed) —
e.g. an extension hanging off the main from the first storey upward.
The spec collapses both into the same lookup: keyed on age band ×
insulation thickness, no geometry needed.
Elmhurst worksheet U985-0001-000490 Extension 1 records U=1.20 W/m²K
for its exposed timber floor (age B, no insulation). Table 20 row
"A to G, insulation unknown or as built" returns 1.20 exactly.
Caller wiring (heat_transmission_from_cert routing on a floor_position
discriminator) lands in the next slice.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RdSAP10 §5.12 Table 19 footnote (1): when floor_construction is unknown,
age bands A and B default to suspended timber, not solid. Previously
u_floor always used the BS EN ISO 13370 solid-floor formula, which
under-counted ~14% on pre-1929 dwellings.
Elmhurst worksheet U985-0001-000490 Main Dwelling (A=14.85, P=7.42,
w=0.400, age B) records floor U=0.71 W/m²K — the suspended-floor formula
on §5.12 page 46 reproduces this exactly. The solid branch returned 0.66.
Description prefixes "Solid, ..." / "Suspended, ..." take precedence over
the age-band default since they're explicit assessor observations.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Delete HANDOVER_FRESH_REVIEW (22-slice, MAE-5.34 era) and
HANDOVER_SYSTEMATIC_REVIEW (pre-Elmhurst-conformance). Both described
a state the Elmhurst worksheet work has since superseded.
Add HANDOVER_S3_CLOSE.md with:
- Accurate §3 status: §1/§2 fully done; LINE_31/LINE_36 exact for
non-RR fixtures; LINE_33 gap diagnosed as missing floor_construction
codes (not a window-area problem as previously assumed)
- Concrete investigation steps to close LINE_33 for 000474 + 000490
- Table 11 Secondary Heating framed as next slice after §3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
LINE_31 (total external element area) = Σ_parts (gross_wall + roof +
floor). Window and door areas cancel in the net-wall expansion, so LINE_31
is independent of the window/door split. This lets us assert the exact
Elmhurst worksheet (31) for the two non-RR fixtures (000474, 000490)
without needing window-area input data.
LINE_36 = y × LINE_31 follows for free. Both 000474 and 000490 use age
band B throughout (y = 0.15), giving:
000474: 0.15 × 153.39 = 23.0085
000490: 0.15 × 164.85 = 24.7275
The per-storey-perimeter fix (e6c768c3) was the prerequisite; without it,
upper storeys with a smaller perimeter than the ground floor were
over-counted (e.g. 000474 Main: 7.07 m ground vs 5.27 m first storey).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the string literal "Main Dwelling" / "Extension 1" comparisons
in `_building_part_aggregates` and the four affected tests with the
typed `BuildingPartIdentifier.MAIN` / `.EXTENSION_1` enum values, so
the transform is consistent with the typed domain introduced in the P6.1
cert→inputs adapter. Fixes a latent mismatch that would silently return
`main=None` if the string ever drifted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SAP §3 wall heat-loss area sums each storey individually:
`Σ (heat_loss_perimeter_i × room_height_i)`. Pre-fix used the short-cut
`ground_perimeter × avg_height × storey_count`, which over-counts upper
storeys whenever they have a smaller perimeter than the ground (set-back
top floors, ground-floor additions, etc.). RdSAP §5.10 party-wall area
follows the same per-storey-sum convention.
Surfaced by Elmhurst 000474 Main (ground perim 7.07, first 5.27): our
gross-wall over-counted by ~10 m², the (29a) W/K downstream by ~15 W/K
on this cert. Documented at the time as follow-up #2; this slice closes
it. The §3 partial-conformance test's gap-#2 entry is removed; gap #1
(RR sub-areas) remains.
Fix lives in two parallel code paths:
- dimensions.py: per-storey accumulation inside the existing fd loop
- heat_transmission.py: _part_geometry now emits gross_wall_area_m2 and
party_wall_area_m2 directly, dropping the avg_height + storey_count
intermediate fields (no other consumer)
Tests:
- New: gross_wall_area_sums_per_storey_perimeter_times_height_…
(2-storey main, ground 10 m / first 6 m, same height — expects
Σ=40 m² not ground×avg×count=50)
- New: party_wall_area_sums_per_storey_party_length_… (same shape,
ground party 5 / first party 3 → Σ=20 not 25)
- New: walls_w_per_k_uses_sum_of_per_storey_perimeter_… (heat-
transmission counterpart: 0.6 × 40 = 24 W/K not 30)
829 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP §2 (9) "ns" is the dwelling height — the tallest part — which drives
the (10) additional-infiltration adjustment. Pre-fix code summed
`len(sap_floor_dimensions)` across parts and incremented for every
sap_room_in_roof block, so a 2-storey main + 1-storey side extension
returned ns=3 instead of 2, and a 2-part RR-bearing cert could return
ns=4 or 5. The (10) ach output overstated by 0.1 per spurious storey.
Fix tracks per-part `(floor_count + 1 if RR else 0)` and emits
`max(per_part)`. TFA and volume sums on §1 are unaffected — those are
genuine Σ per RdSAP §3.9.1.
Surfaced by Elmhurst 000474 (2-storey + 2 side extensions): worksheet
says ns=2; we previously had to pass `storey_count=fixture.LINE_9_STOREYS`
explicitly in the §2 Elmhurst conformance test. With the fix, the test
now derives `storey_count` from `dims.storey_count` and the
`LINE_9_STOREYS` field cross-checks the derivation against (9).
Tests:
- New: dwelling_storey_count_is_max_across_parts_not_sum (2-storey main
+ 1-storey ext expects ns=2)
- New: room_in_roof_on_main_adds_one_to_dwelling_storey_count_only_once
(main with RR + ext without RR expects ns=3, not 5)
- Updated: main_plus_extension_sums_areas_perimeters_and_walls assertion
ns==2 → ns==1 (both parts single-storey)
- Updated: all_rir_shapes_apply_section_1_2_45m_convention_uniformly —
storey_delta is now ≤1 not len(parts_with_rr); TFA/volume deltas
remain Σ per the spec
- Updated: §2 Elmhurst test consumes dims.storey_count + asserts
dims.storey_count == fixture.LINE_9_STOREYS as an Arrange precondition
826 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Threads the strict BuildingPartIdentifier type (introduced in a8b443f6)
through the two remaining backend touchpoints:
- EpcBuildingPartModel.from_*: SQLModel column expects a string, so
unbox the enum with .identifier.value before binding to the DB.
- documents_parser end-to-end tests: swap bare-string equality
("main" / "extension_1") for identity checks against the enum
members (BuildingPartIdentifier.MAIN / EXTENSION_1).
Documents_parser test pack passes (105/105). No dedicated SQLModel test
covers EpcBuildingPartModel.from_*; the .value line is exercised
transitively via db_writer.py / local_runner.py in production.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>