Commit graph

5858 commits

Author SHA1 Message Date
Jun-te Kim
714478a99a clean up sanitise postcode 2026-05-20 17:51:45 +00:00
Jun-te Kim
e5583aac1f some excel files are formatted differently 2026-05-20 17:36:20 +00:00
Khalim Conn-Kowlessar
74b2c1131f §4 conformance: extend Elmhurst fixtures to 6/6 across (42)..(65)
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>
2026-05-20 17:29:10 +00:00
Khalim Conn-Kowlessar
3c2f975c6d cert_to_inputs: wire §4 worksheet orchestrator into HW kWh derivation
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>
2026-05-20 16:35:53 +00:00
Khalim Conn-Kowlessar
171cb97c6e e2e: SAP-score regression test against both Elmhurst worksheets
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>
2026-05-20 16:27:04 +00:00
Daniel Roth
4e21dda328 rename files in sharepoint to desired structure 2026-05-20 16:26:07 +00:00
Khalim Conn-Kowlessar
d6e2c99f5b §4 orchestrator: water_heating_from_cert + WaterHeatingResult
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>
2026-05-20 16:23:56 +00:00
Khalim Conn-Kowlessar
6a3552a50d docs: §4 slice progress — happy path closes both non-RR fixtures
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>
2026-05-20 16:15:18 +00:00
Khalim Conn-Kowlessar
43da3ea064 §4 slice 9: line (65)m heat gains from water heating
(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>
2026-05-20 16:12:53 +00:00
Khalim Conn-Kowlessar
feef819814 §4 slice 8: line (64)m output from water heater
(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>
2026-05-20 16:08:18 +00:00
Khalim Conn-Kowlessar
bfba610b70 §4 slice 7: combi loss (Table 3a time clock) + (62)m total demand
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>
2026-05-20 16:06:05 +00:00
Khalim Conn-Kowlessar
a3c687f1b0 §4 slice 6: lines (45)m energy content + (46)m distribution loss
(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>
2026-05-20 15:58:40 +00:00
Khalim Conn-Kowlessar
702b1c6ce6 §4 slice 5: lines (43) annual avg + (44)m monthly total
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>
2026-05-20 15:56:23 +00:00
Khalim Conn-Kowlessar
1dcbdb28e6 §4 slice 4: hot_water_mixer_showers_monthly_l_per_day (line (42a)m)
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>
2026-05-20 15:51:14 +00:00
Khalim Conn-Kowlessar
dad7fbf31f §4 slice 3: hot_water_baths_monthly_l_per_day (line (42b)m)
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>
2026-05-20 15:48:26 +00:00
Jun-te Kim
97947c9e32
Merge pull request #1111 from Hestia-Homes/feature/address2uprn_quick_fix
epc token added
2026-05-20 16:46:58 +01:00
Khalim Conn-Kowlessar
5cc68ab3fd §4 slice 2: hot_water_other_uses_monthly_l_per_day (line (42c)m)
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>
2026-05-20 15:43:56 +00:00
Jun-te Kim
53b211e951 epc token added 2026-05-20 15:43:41 +00:00
Khalim Conn-Kowlessar
aff678e8eb §4 slice 1: assumed_occupancy (worksheet line (42), Appendix J)
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>
2026-05-20 15:27:03 +00:00
Jun-te Kim
bd36f203e8
Merge pull request #1109 from Hestia-Homes/feature/rewrite_task_handler
actually deploy postcode splitter
2026-05-20 16:26:07 +01:00
Jun-te Kim
78c1d150fa added smoke test 2026-05-20 15:25:42 +00:00
Khalim Conn-Kowlessar
d90827446a docs: sweep stale handover, mark §3 Full, scaffold §4 slice plan
§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>
2026-05-20 15:18:46 +00:00
Jun-te Kim
8610a0c875 actually deploy postcode splitter 2026-05-20 15:17:55 +00:00
Jun-te Kim
9ad4f3359f
Merge pull request #1107 from Hestia-Homes/feature/rewrite_task_handler
Feature/rewrite task handler and postcode splitter
2026-05-20 15:56:02 +01:00
Jun-te Kim
154b820b29 pytest.ini 2026-05-20 14:26:46 +00:00
Khalim Conn-Kowlessar
cf244762d5 Elmhurst 000474: §3 LINE_33 + LINE_37 close exactly
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>
2026-05-20 14:14:08 +00:00
Jun-te Kim
f10947699e pytest.ini 2026-05-20 14:13:04 +00:00
Khalim Conn-Kowlessar
4479fc69ac Elmhurst 000490: §3 LINE_33 + LINE_37 close exactly
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>
2026-05-20 14:11:07 +00:00
Jun-te Kim
00f0cb5442
Merge pull request #1106 from Hestia-Homes/claude/Model-p3
Refactor postcode_splitter into the DDD layout (project #3)
2026-05-20 15:01:29 +01:00
Jun-te Kim
dc159e0b45 tests framework completed 2026-05-20 14:00:19 +00:00
Khalim Conn-Kowlessar
269dd991b5 Elmhurst 000490 fixture: tag Ext1 floor as exposed timber
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>
2026-05-20 13:28:23 +00:00
Khalim Conn-Kowlessar
6b99ad0a55 heat_transmission: route exposed/semi-exposed floors through Table 20
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>
2026-05-20 13:22:44 +00:00
Jun-te Kim
d0cf3d14ad get rid of comments 2026-05-20 13:21:11 +00:00
Khalim Conn-Kowlessar
e2c37300ec u_exposed_floor: Table 20 lookup for exposed/semi-exposed upper floors
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>
2026-05-20 13:19:46 +00:00
Khalim Conn-Kowlessar
344a9c9d5e u_floor: route age A,B unknowns to suspended-timber branch (Table 19 fn 1)
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>
2026-05-20 13:17:35 +00:00
Khalim Conn-Kowlessar
49e8c65ae8 Handover: replace stale docs with focused §3-close + Table-11 brief
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>
2026-05-20 13:03:09 +00:00
Jun-te Kim
8bb90a5aa5 sanitisation of postcode 2026-05-20 12:57:03 +00:00
Khalim Conn-Kowlessar
2fd0fe1c08 §3 exact conformance: non-RR LINE_31 + LINE_36 match Elmhurst worksheets
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>
2026-05-20 12:47:01 +00:00
Khalim Conn-Kowlessar
a374bd075e P6.1 follow-on: use BuildingPartIdentifier enum in ml/transform + tests
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>
2026-05-20 12:46:47 +00:00
Jun-te Kim
914a8ed51e postcode splliter working e2e 2026-05-20 11:07:40 +00:00
Khalim Conn-Kowlessar
e6c768c356 Wall + party-wall area = Σ (perim_i × height_i), not ground × avg × count
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>
2026-05-20 10:33:14 +00:00
Khalim Conn-Kowlessar
6ea5727a4e Dimensions: storey_count is dwelling height (max across parts), not sum
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>
2026-05-20 10:27:38 +00:00
Khalim Conn-Kowlessar
883028c89e P6.1 follow-on: unbox BuildingPartIdentifier at backend boundaries
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>
2026-05-20 09:58:23 +00:00
Khalim Conn-Kowlessar
a8b443f669 SAP calculator entry point + cert→inputs adapter + strict P6.1 identifiers
Lands the production code that the just-committed Elmhurst conformance
fixtures (6455d48b) exercise: the SAP10.3 calculator orchestrator
(domain.sap.calculator.Sap10Calculator), the RdSAP-driven cert→inputs
mapper (domain.sap.rdsap.cert_to_inputs), and the EpcPropertyData
strict-type pass that P6.1 starts.

calculator.py is the entry point. Two surfaces depending on the caller's
shape:
- Sap10Calculator().calculate(epc) — full RdSAP mapper + worksheet loop
- calculate_sap_from_inputs(inputs) — pure physics over typed inputs

P6.1 introduces BuildingPartIdentifier as a strictly-typed replacement
for bare-string matching on SapBuildingPart.identifier (motivated by
the pain point at worksheet/dimensions.py:74-82). Two boundary factories
canonicalise raw inputs: from_api_string for the gov-EPC API, and
extension(n) for site-notes / construction id flows.

Also catches up two transitive deps that 6455d48b implicitly required
but I missed:
- ml/rdsap_uvalues.py — party-wall U-value rows that heat_transmission
  resolves; the U=0.0 branch the 000516 fixture exercises lands here.
- ml/tests/_fixtures.py — make_minimal_sap10_epc that every Elmhurst
  fixture imports. Without this catch-up, checking out 6455d48b in
  isolation would ImportError.

Out of scope (will commit separately): ml/transform.py legacy envelope
drift; backend/ FastAPI + documents_parser layer; etl/ scratch.

824 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 09:54:30 +00:00
Khalim Conn-Kowlessar
6455d48b9d Elmhurst SAP10.2 worksheet conformance: §1/§2/§3 + 6 fixtures + README
Lands real-cert ground-truth conformance tests for the SAP10.2 worksheet,
asserting our §1 dimensions, §2 ventilation, and §3 heat-transmission
output line-by-line against six Elmhurst-lodged worksheets (000474,
000477, 000480, 000487, 000490, 000516). Each fixture covers a distinct
shape: with/without room-in-roof, single-part vs main+extensions, age
A and B, party-wall U=0.0 vs U=0.25, 1/2/3 sheltered sides, varying
draught-proofing %, and the (12) suspended-timber quirk.

§1/§2/§3 module updates back the new line-refs (LINE_31 external-element
area, LINE_33 fabric loss, LINE_37 total fabric loss; per-fixture (12)
floor / (15) window / (21) shelter-adjusted ach; SapRoomInRoof storey
contribution via the 2.45 m §3.9.1 convention).

The §3 test currently asserts invariants only ((33) = Σ per-element,
(37) = (33) + (36)) because SapRoomInRoof only carries floor_area —
gable/slope/stud/flat-ceiling sub-areas the worksheet itemizes are not
yet modelled. LINE_3* constants capture the worksheet ground truth for
when that gap closes.

Adds a SAP-domain README with a step-by-step guide for adding new
Elmhurst fixtures from the assessor's PDF pair (Summary + worksheet),
including the field-by-field cert → EpcPropertyData mapping table and
the gotchas surfaced across the six fixtures (storey-height +0.25
convention, party-wall U code mapping, has_suspended_timber_floor flag
truth table, (25) effective-ach formula, Energy Rating vs EPC Costs
wind-speed trap).

366 tests pass (was 360 pre-pairs 5-6).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 09:48:30 +00:00
Jun-te Kim
0a04448217 applications/postcode_splitter: PostcodeSplitterOrchestrator + Lambda entrypoint slice
Wires slice 1-5 primitives into a deployable splitter:

- orchestration/postcode_splitter_orchestrator.py: PostcodeSplitterOrchestrator
  loads addresses via UserAddressRepository, groups by postcode via
  iter_postcode_grouped_batches, persists each batch under
  ara_postcode_splitter_batches/{task_id}/{subtask_id}/, creates a WAITING
  child SubTask, and publishes an address2UPRN SQS message per batch.

- applications/postcode_splitter/: Lambda entrypoint. handler.py is decorated
  with @subtask_handler() so the parent SubTask lifecycle is decorator-owned;
  PostcodeSplitterTriggerBody validates the body. Dockerfile is the
  python:3.11 Lambda base with the DDD-shaped source layers and no pandas.

- tests/orchestration/test_postcode_splitter_orchestrator.py: integration
  test using moto S3 + moto SQS + in-memory SQLite that exercises the full
  wiring against a fixture CSV spanning three postcode groups (one
  oversize) and asserts child count, persisted inputs, queue bodies, and
  dispatch order.

backend/postcode_splitter/ and .github/workflows/deploy_terraform.yml are
intentionally unchanged: the dockerfile_path flip is deferred until the
companion backend/address2UPRN/ migration is also ready.
2026-05-19 17:46:12 +00:00
Jun-te Kim
708f1b5d18 repositories: UserAddressRepository + UserAddressCsvS3Repository (CSV-on-S3 adapter)
Adds the persistence layer for UserAddress batches:

- Abstract UserAddressRepository with load_batch / save_batch.
- Concrete UserAddressCsvS3Repository over CsvS3Client:
  - load_batch reads canonical upload columns (Address 1/2/3, Postcode,
    Internal Reference), comma-joins non-empty address parts, and
    passes Internal Reference through (None when missing/empty).
  - save_batch writes a 3-column CSV (user_address,postcode,
    internal_reference) to {path_prefix}/{ISO datetime}_{uuid8}.csv
    and returns the s3://bucket/key URI.
- Postcode sanitisation flows through UserAddress.__post_init__; the
  repo never calls sanitise_postcode directly.

Tests (moto-backed) cover: three-line address load, Address-1-only
load, missing Internal Reference, save->reload round trip, and
unique-filename-per-save. pyright --strict clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:37:02 +00:00
Jun-te Kim
d70e8a9e53 utilities/aws_lambda: @subtask_handler injects TaskOrchestrator as third positional arg
The wrapped function now receives the decorator-owned TaskOrchestrator as
a third positional argument so handlers can compose their own use-case
orchestrator that shares the session, instead of opening a second Postgres
connection per invocation.

Both existing callers (backend/ordnanceSurvey/main.py and
backend/bulk_address2uprn_combiner/main.py) have their signatures extended
to accept the new positional argument (typed Optional[TaskOrchestrator] so
the legacy backend.utils.subtasks.subtask_handler — which only passes two
args — keeps working until the migration to the new decorator lands).

@task_handler is intentionally unchanged in this slice; symmetry is
deferred per issue #1103.
2026-05-19 17:31:27 +00:00
Jun-te Kim
d7f14033ba orchestration: add TaskOrchestrator.create_child_subtask primitive
Adds a primitive for creating a new WAITING SubTask under an existing
parent Task, routing all SubTask creation through the orchestrator
(replacing the legacy SubTaskInterface path used by the splitter).
Skips _cascade because a new WAITING child against an IN_PROGRESS
parent is a no-op under Task.recalculate_from_subtasks.
2026-05-19 17:19:41 +00:00
Jun-te Kim
7b00a33cd2 infrastructure: typed S3/SQS clients (S3Client, CsvS3Client, SqsClient, Address2UprnQueueClient)
Slice 3/6 of the postcode_splitter refactor (Hestia-Homes/Model#1101).
Introduces a thin typed infrastructure layer wrapping boto3 for the AWS
side of the splitter. S3Client/SqsClient are bucket-/queue-bound byte
adapters; CsvS3Client subclasses S3Client to round-trip CSV row dicts
via the existing parse_s3_uri helper in utils/s3.py; Address2UprnQueueClient
subclasses SqsClient to publish the typed {task_id, sub_task_id, s3_uri}
fan-out body the downstream consumer expects. moto[s3,sqs] is pulled into
test.requirements.txt and the new tests/infrastructure/ suite exercises
each client against the moto backend (S3 round-trip, CSV round-trip,
SQS send + body inspection, typed publish + body inspection). pyright
--strict is clean on the new modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:12:21 +00:00