Commit graph

207 commits

Author SHA1 Message Date
Daniel Roth
e55c2262c8 handle 50 rows in new template file 2026-06-10 16:18:46 +00:00
Daniel Roth
0edeeaefa6 populate_sheet writes to new Sero template column layout 🟩 2026-06-10 13:21:50 +00:00
Daniel Roth
8ff58bd645 tell subtask_handler whether to send TaskOrchestrator to handler, defaulting to True 2026-06-10 12:43:24 +00:00
Daniel Roth
51cf545776 Two-parameter subtask handler completes without TypeError 🟥 2026-06-10 08:22:43 +00:00
Daniel Roth
dcd5204b54 put db engine construction inside handler to avoid import errors in test 2026-06-09 15:18:42 +00:00
Daniel Roth
236f33c25f move spreadsheet population logic to domain 2026-06-09 14:43:24 +00:00
Daniel Roth
94f8ef5458 Merge branch 'main' into feature/generate-ventilation-audit-from-magicplan 2026-06-09 14:22:57 +00:00
Daniel Roth
82156fac8f Audit generator populates XLSX, uploads to S3, and records UploadedFile row 🟪
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:05:55 +00:00
Daniel Roth
a1d09aa880 Audit generator populates XLSX, uploads to S3, and records UploadedFile row 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:59:09 +00:00
Daniel Roth
53f0da8666 UploadedFilePostgresRepository returns latest uploaded file by deal ID and type 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:53:25 +00:00
Daniel Roth
5178cd02c5 UploadedFile, FileTypeEnum, FileSourceEnum importable from infrastructure.postgres.uploaded_file_table 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:50:51 +00:00
Daniel Roth
41b282042f UploadedFile, FileTypeEnum, FileSourceEnum importable from infrastructure.postgres.uploaded_file_table 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:42:53 +00:00
Daniel Roth
f8fcf38886 get_plan_by_uploaded_file_id 🟩 2026-06-08 15:37:52 +00:00
Daniel Roth
648e726ca2 fix orchestration tests 2026-06-08 14:40:14 +00:00
Daniel Roth
bd4ad9022c Merge branch 'main' into feature/handle-new-magicplan-response-structure 2026-06-08 12:36:27 +00:00
KhalimCK
1b94da16d0
Merge pull request #1189 from Hestia-Homes/feature/per-cert-mapper-validation
Feature/per cert mapper validation
2026-06-08 13:19:58 +01:00
Daniel Roth
c22ee3821b Merge branch 'main' into feature/handle-new-magicplan-response-structure 2026-06-08 09:57:26 +00:00
Daniel Roth
3f5b3cf172 Window carries no opening_type — ventilation table is the sole persistence point 🟥 2026-06-08 09:43:41 +00:00
Khalim Conn-Kowlessar
8741fbdfac fix(floor): floor_heat_loss=3 → above partially heated space, U=0.7 (RdSAP §3.12)
The API `floor_heat_loss` code is authoritative — confirmed by joining each
single-BP cert's code to its independent `floors[].description` (which the
gov register publishes alongside the code):

  code 1 ↔ "To external air"        (exposed,        9/9)
  code 2 ↔ "To unheated space"      (semi-exposed,   6/6)
  code 3 ↔ "(other premises below)" (partially htd,  9/9)
  code 6 ↔ "(another dwelling below)" (party,      176/176)
  code 7 ↔ "Solid"/"Suspended …"    (ground,     all)

Code 3 was mis-mapped to "To unheated space" (semi-exposed) and, on
mid-/top-floor flats, had its floor area zeroed entirely by the
dwelling-level exposure heuristic. RdSAP 10 §3.12 (PDF p.25) classes a
flat's floor over non-domestic "other premises … heated, but at different
times" as "above a partially heated space" → the §5.14 (PDF p.47) constant
U=0.7 W/m²K — distinct from semi-exposed (Table 20) and party (no loss).

Fix: the mapper sets `is_above_partially_heated_space` on the floor=0
dimension for code 3 (string → "(other premises below)" for fidelity), and
the heat-transmission step lets that per-BP lodgement override the flat
suppression upward (mirroring the existing exposed / "another dwelling
below" overrides). The cascade already routes is_above_partial → U=0.7.

Re-pins golden cert 7536-3827: its Ext2 (bp3) lodges code 3, but the cert's
lossy `floors[]` summary dropped that description, so a prior agent guessed
"code 3 = ground" (U=1.12) and concluded the residual was an irreducible
"register-rounding" artifact. It was this bug: Ext2 floor U 1.12 → 0.70,
PE -6.1952 → -5.6414, CO2 -0.1639 → -0.1492 (both toward 0), SAP unchanged.

Eval: 909 computed, 45.1% → 45.3% within 0.5, mean|err| 1.702 → 1.659,
<1.0 59.5% → 60.2%. 13 code-3 certs improve (0380 +3.71 → -0.63, 0350
+7.82 → +0.83, 2610 +7.47 → -1.29); the few that overshoot were already
failing and carry independent fabric bugs (9763's walls = 8 W/K for 60 m²).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:25:04 +00:00
Khalim Conn-Kowlessar
b40e0f67b8 fix(floor): exposed floor on a flat carries heat loss (RdSAP §3.12)
A mid-/top-floor flat whose lowest floor is lodged as an exposed floor
(API floor_heat_loss=1) had its floor area zeroed by the dwelling-level
exposure heuristic, which keys only on the flat label and defaults
has_exposed_floor=False (assuming the floor sits over another *heated*
dwelling). RdSAP 10 §3.12 (PDF p.25) is explicit:

  "Otherwise the floor area of the flat ... is:
     - an exposed floor if there is an open space below"

i.e. a flat cantilevered over a passageway IS a heat-loss floor on
Table 20. The per-BP `is_exposed_floor` lodgement is authoritative and
now overrides the dwelling-level suppression upward, mirroring the
existing "another dwelling below" party override (which suppresses
downward). The code-1↔"E To external air" enum is confirmed by the
paired API+Summary worksheet certs (0350, 3800).

Eval: 45.1% → 45.3% within 0.5 (909 computed); cert 3836 +6.79 → +0.77,
5717 +1.31 → -0.07 and 0997 +0.76 → +0.05 cross into <0.5. Two
already-failing under-rated certs (7636, 2241) shift further — both are
dominated by independent cost-side over-counts the exposed floor merely
unmasks (7636 walls = 8.98 W/K for 33.87 m² is the real defect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:47:52 +00:00
Khalim Conn-Kowlessar
a8e5563ace fix(warm-air): Table 11 secondary fraction for category 9 → 0.10
main_heating_category=9 (warm-air systems, NOT heat pump) had no entry
in _SECONDARY_HEATING_FRACTION_BY_CATEGORY, so a warm-air main with a
lodged secondary raised UnmappedSapCode in
_secondary_heating_fraction_for_category — the last calc_raise in the
API sample (cert 0380-2197-2590-2996-2715: warm air mains gas code 506 +
electric room-heater secondary).

SAP 10.2 Table 11 (p.188): a gas/oil warm-air unit falls under "All gas,
liquid and solid fuel systems" (0.10), and electric warm air under
"Other electric systems" (also 0.10) — so 0.10 regardless of fuel. The
warm-air efficiency (Table 4a code→eff: 506→0.70) and Table 4f fan
energy were already wired; this was the only missing dispatch entry.

0380 now computes: SAP 78.1 vs lodged 77 (+1.1; the residual is per-cert
fabric/PV, not the warm-air dispatch — a faithful 0380 worksheet isn't
available, sim case 28 diverges at SAP 57 / code 502 / condensing unit).
Eval: zero raises remain, computed 908→909; mean|err| 1.703→1.702.
Regression green (2448 pass incl. golden 6035 + cohort); pyright
net-zero (44=44).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 20:26:32 +00:00
Khalim Conn-Kowlessar
449d8c5b95 fix(hw): direct-acting electric boiler (191) → zero primary circuit loss
SAP 10.2 Table 3 (PDF p.160) names "Direct-acting electric boiler"
verbatim in the primary-loss zero list (alongside electric immersion,
combi, CPSU, integral-vessel heat pump). RdSAP 10 §12 (p.62) classifies
SAP code 191 as the direct-acting electric boiler. Its cylinder is
immersion-heated with no primary pipework, so no primary circuit loss
applies — but `_primary_loss_applies` had no 191 branch, so a 191 main
(main_heating_category 2, "Boiler and radiators, electric") fell through
to the cat-{1,2} boiler branch and accrued ~1177 kWh/yr of phantom
primary loss on the electric-flat segment.

Validated against the cert-2474 worksheet: §4 (59) primary loss = 0,
(64) HW output 1760 (cylinder) + (64a) shower 581. Cert 2474 HW kWh
3585 → 2408; SAP 64.66 → 70.35 (the residual to the lodged 78 is an
Unknown-meter data-fidelity artifact — the register recorded meter_type=3
"Unknown" but the lodged rating used an 18-hour off-peak meter, per RdSAP
§12 / the example worksheets).

Eval mean|err| 1.720 → 1.708 (headline 45.0%, flat ±1 cert — the
electric-flat segment is dominated by the meter data-fidelity artifact).
Regression green (2448 pass incl. golden 6035 + ASHP cohort 1e-4);
pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:31:27 +00:00
Khalim Conn-Kowlessar
2bc73fb08d fix(cost): HP-DHW from PCDB heat pump bills Table 12a ASHP_APP_N WH split
When DHW is heated by the main heat pump (WHC 901/902/914 = "from main
system") and the main carries a PCDB Table 362 record,
`_hot_water_fuel_cost_gbp_per_kwh` billed the electric HW at 100% off-peak
low rate (its long-standing TODO). SAP 10.2 Table 12a Grid 1 WH column
(PDF p.191) puts HP-DHW on the ASHP/GSHP-from-database row: 0.70
high-rate fraction at 7-hour and 10-hour → 0.70×14.68 + 0.30×7.50 =
12.526 p/kWh (10-hour), not 7.50 p. The low-rate collapse over-credited
the cat-4 HP-DHW cluster.

Fix: pass the cert WHC into the helper and, for HP-DHW (WHC ∈ {901,902,
914} + PCDB-HP main), bill at the ASHP_APP_N WH blended rate. Electric
IMMERSION (WHC 903) is a different Table 12a row (off-peak immersion 0.17
/ Table 13) and stays on the 100%-low-rate fallback until that slice
lands.

cat-4 cluster (20 certs): mean|err| 2.43→2.11, mean signed +0.06→-0.52
(now per-cert scatter, no systematic bias); cert 9472 +6.4→+3.2, 2789
+6.8→+4.0, 4135 +2.7→within 0.5. Headline mean|err| 1.727→1.720.
Regression green (2447 pass incl. golden 6035 + ASHP cohort at 1e-4);
pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:54:01 +00:00
Khalim Conn-Kowlessar
e41a0bc0d7 fix(cost): PCDB heat pump without SAP code bills Table 12a ASHP_APP_N split
A heat pump that resolves via its PCDB Table 362 index alone (API path,
data_source=1, no Table-4a SAP code) had sap_main_heating_code=None, so
`_table_12a_system_for_main` fell through the 211-227/521-524 code-range
gate to None → the "100% off-peak low-rate" fallback. On a Dual meter
(RdSAP §12 Rule 3 routes heat pumps to the 10-hour tariff) this billed
space heating at 7.50 p/kWh instead of the SAP 10.2 Table 12a Grid 1
(PDF p.191) ASHP/GSHP-from-database row: 0.80 high-rate fraction →
0.80×14.68 + 0.20×7.50 = 13.244 p/kWh. The collapse over-credited the
whole cat-4 heat-pump cluster.

Fix: route any main with a PCDB heat-pump record to ASHP_APP_N regardless
of SAP code (a Table 362 record IS an Appendix-N heat pump by
definition). ASHP_APP_N and GSHP_APP_N share the 0.80 SH fraction at
7h/10h, so ASHP_APP_N is the canonical Appendix-N row for the SH split.

cat-4 cluster (20 certs): within-0.5 45%→50%, mean signed +1.43→+0.06,
mean|err| 3.81→2.43; cert 9472 +15.0→+6.4, 2789 +13.4→+6.8. Headline
45.0%→45.1%, mean|err| 1.757→1.727. Regression green (only the
pre-existing test_total_floor_area fails); pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:48:37 +00:00
Khalim Conn-Kowlessar
4d1a58b828 fix(tariff): Unknown meter + storage/CPSU main → off-peak (§12)
Electric storage heaters (and CPSU) charge overnight and cannot run
economically on a single rate, so their presence is physical evidence the
dwelling is on an off-peak tariff. RdSAP 10 §12 (PDF p.62) applied Rules
1-4 only for a Dual meter; an "Unknown" (code 3) meter returned STANDARD
without consulting the heating type, so a cat-7 storage main billed its
overnight charge at the standard 13.19 p/kWh instead of the 7-hour low
rate (5.50 p/kWh) — ~2.4x too high → large under-rate.

Two coupled fixes:
- `rdsap_tariff_for_cert`: for an Unknown meter, infer the off-peak tariff
  from a Rule-1 CPSU (→10-hour) or Rule-2 storage (→7-hour) main; keep
  STANDARD otherwise. Direct-acting/room heaters/heat pumps (Rule 3) are
  NOT off-peak evidence (run on demand, exist on single-rate meters) so
  they stay STANDARD — billing them 100% at the low rate over-credits.
- `_fuel_cost` now resolves its tariff via the §12-aware `_rdsap_tariff`
  (not the raw `tariff_from_meter_type`), so the off-peak branch fires for
  these storage certs and the legacy scalar fields bill the low rate.

Mirrors `_is_off_peak_meter`'s existing Unknown+electric heuristic (which
already routes HW/secondary off-peak), closing the main-space-heating gap.
Meter-3 electric cluster: mean |err| 11.18 → 6.52, within-1.0 3 → 5 (cert
7336 -26.1 → -0.16, 0380 -19.9 → +1.0). Eval headline 44.9% → 45.0%, mean
|err| 1.82 → 1.76, mean signed -0.08 → +0.02. A few storage certs overshoot
(other residuals the standard rate was masking).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:02:34 +00:00
Khalim Conn-Kowlessar
678aa7affd fix(cascade): main-roof U ignores Room-in-Roof "no insulation" leak
The main pitched/flat roof U-value was derived from the JOINED text of
every roofs[] entry. A room-in-roof carries its own §3.9/§3.10 shell
area + U-value cascade (Table 17 / Table 18 col 4), so a multi-roof cert
lodged "Pitched, insulated (assumed) | Roof room(s), no insulation
(assumed)" leaked the RR's "no insulation" marker into the main roof's
u_roof → U=2.30 applied to the WHOLE main roof, ~3x over-stating its heat
loss. This is the 4700-family regular-roof-U leak.

`_joined_main_roof_descriptions` drops "Roof room(s)" entries before the
main-roof u_roof, falling back to the unfiltered join only for pure-RR
dwellings (every entry an RR) to preserve their prior behaviour. The RR
shell U is unaffected (computed separately) — golden 6035 stays green.

RR-leak cluster (18 certs, RR "no insulation" + a non-RR primary roof):
mean |err| 6.14 → 4.85, within-1.0 0 → 8, within-0.5 0 → 3. Eval headline
44.8% → 44.9%, mean |err| 1.851 → 1.824, mean signed -0.152 → -0.081. Two
certs overshoot (other residuals the leak was masking); the spec rule is
applied uniformly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:27:41 +00:00
Khalim Conn-Kowlessar
3aed8f858a fix(cascade): suppress floor heat loss for "another dwelling below" (code 6)
A floor lodged API floor_heat_loss=6 ("another dwelling below") sits over
another heated dwelling, so it is a party floor with no heat loss (RdSAP
10 §3). The mapper mapped code 6 → None and the heat-transmission step
drove floor exposure solely from the dwelling-level `has_exposed_floor`
flag — which is keyed only on the dwelling_type label and defaults a
"Ground-floor flat" to an exposed floor. So a ground-floor flat above a
basement dwelling kept its full ground-floor heat-loss area.

Map code 6 → "(another dwelling below)" (still != "Ground floor", so the
§5 (12) suspended-timber rule stays inert) and have the cascade suppress
that BP's floor when its floor_type carries the signal, mirroring the
roof's existing "another dwelling above" per-BP party override.

Cert 2115-4121-4711-9361-3686 (ground-floor flat, floor_heat_loss=6):
floor_w_per_k 47.85 → 0; SAP -23.44 → -4.41. Cert 0350-…-6435 -12.38 →
-0.55; 0926-…-9024 -2.35 → -0.82. Eval mean |err| 1.982 → 1.944.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:05:33 +00:00
Khalim Conn-Kowlessar
27375d93a4 fix(u-value): solid brick as-built U by thickness — §5.7 Table 13
A 440 mm (>420 mm) solid brick AS-BUILT wall computed U = 1.70 (the
220 mm bucket default) instead of the RdSAP-correct 1.10. The §5.7
Table 13 thickness path only fired for *insulated* brick (external/
internal + thickness > 0); the as-built case fell through to the
Table 6 cavity/solid age-band default.

Spec: RdSAP 10 Specification (9th June 2025), §5.7 "U-values for
uninsulated brick walls, age bands A to E", Table 13 (PDF p.40):
  ≤200 mm → 2.5, 200–280 mm → 1.7, 280–420 mm → 1.4, >420 mm → 1.1.
Table 6 footnote (b) on the "Solid brick as built" row (PDF p.40):
"Or from 5.7 if wall thickness is other than 200mm to 280mm" — the
thickness table supersedes the flat 1.7 default whenever a documentary
wall thickness is lodged (200–280 mm gives 1.7 either way). The §5.8 /
Table 14 dry-lining R is added on top only when the wall is dry-lined,
per the §5.7 closing sentence.

Validated against the user-generated Elmhurst worksheet "simulated
case 21" (replica of API cert 2818-3053-3203-2655-9204: mid-terrace,
age band B, solid brick as-built 440 mm, room-in-roof). New §3 cascade
pin `test_section_3_wall_u_by_thickness_case21_match_pdf` routes the
Summary through the real extractor + mapper and pins:
  (31) 155.1000, (33) 175.6208, (36) 23.2650, (37) 198.8858 — all 1e-4.
External walls Main U → 1.1000; Sheltered RR gable → 1/(1/1.10+0.5) =
0.71 (was 0.92). Pinned on §3 only (case-6 precedent): its code-908
instantaneous multi-point gas water heater has a separate §4 (219) gap.

Cross-check: sim case 20 (220 mm) stays at 1.70 — unchanged.

API SAP accuracy (scripts/eval_api_sap_accuracy.py, 896 computed certs):
% |err| < 0.5 SAP vs lodged: 42.6% → 43.8%; mean |err| 2.045 → 2.010.

Regression: tests/domain/sap10_calculator/ (1861), backend/
documents_parser/tests/ (574), datatypes/epc/ + rdsap golden fixtures
all green (pre-existing test_total_floor_area excepted). pyright strict
net-zero. No solid-brick fixture pin shifted (200–280 mm unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:40:06 +00:00
Khalim Conn-Kowlessar
cdf211393c feat(mapper): map API gable_wall_type 2/3 (Sheltered/Connected) — clears 14 raises
The 2026 API sample raised UnmappedApiCode on `gable_wall_type` 2 (10 certs)
and 3 (4 certs) — the two RR gable variants beyond Party(0)/Exposed(1).
Sim case 21 (an Elmhurst replica of API cert 2818-3053-3203-2655-9204:
gable_wall_type_1=2, gable_wall_type_2=3) lodges them as "Sheltered" and
"Connected", confirming **2=Sheltered, 3=Connected**.

- Mapper: `_API_TYPE_1_GABLE_TYPE_TO_KIND` gains 2 → `gable_wall_sheltered`,
  3 → `connected_wall` (U=0, area deducts — already handled).
- Calculator: new `gable_wall_sheltered` branch. The API path lodges no
  per-gable U, so the cascade DERIVES it as RdSAP 10 Table 4 (p.22)
  Sheltered = 1/(1/U_wall + 0.5) — back-solved + validated against case 21
  (U_wall 1.10 → 0.71) and case 20 (1.70 → 0.92). A lodged U (Summary path)
  still rides through as an override.

API sample: 14 raises clear → `computed` 882 → 896, `raise:ValueError` 16 → 2.
Summary path unchanged (Sheltered stays `gable_wall_external` + lodged U, so
cert 000487's hand-built fixture is untouched). 2861 pass (lone
test_total_floor_area pre-existing); pyright strict net-zero (32=32 / 12=12).

NOTE: the derived Sheltered U on cert 2818 lands at 0.92 not 0.71 because the
cascade computes its 440 mm solid-brick wall U as 1.70 (the 220 mm default) —
a SEPARATE wall-U-vs-thickness bug (next slice, validated by case 21's 1.10).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 11:59:50 +00:00
Khalim Conn-Kowlessar
7dfe3f2c99 feat(test): case-20 cascade fixture + close its CO2 via E7 per-end-use codes
Locks sim case 20 (storage heaters + Detailed RR + loose-jacket cylinder)
as a golden vector: _elmhurst_worksheet_001431_case20.build_epc() routes the
Summary PDF through extractor → mapper → calculator, registered in
test_e2e_elmhurst_sap_score with all 11 SapResult headline pins at 1e-4.
10 pinned exact off slices 1-2 (window extractor, RR stud walls); this slice
closes the last one, co2_kg_per_yr (was 3797.62 vs (272) 3815.4060).

Root cause: on a dual-rate (E7) meter the CO2 path ignored the tariff's
high/low Table-12 electricity codes that the cost path already uses:
  - Secondary (direct-acting portable heaters, on-peak) keyed the monthly
    Table 12d cascade on standard code 30 (0.15405) instead of the E7 HIGH
    code 32 → (263) 0.1616. SAP 10.2 Table 12a Grid 1 direct-acting electric
    is 100% high-rate; mirrors the cost side billing it at 15.29 p/kWh.
  - Main storage heaters fell through `_table_12a_system_for_main`=None to
    the FLAT annual factor (0.136) rather than the dual-rate LOW code: per
    the Table 12a design intent ("storage … 100% low rate") they charge
    off-peak → E7 LOW code 31 → (261) 0.1357.

case-20 co2 now EXACT. 2433 calculator + 112 golden + documents_parser tests
pass — no dual-meter/storage cohort regression; pyright strict net-zero (32=32).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 11:23:10 +00:00
Jun-te Kim
98297f803a
Merge pull request #1186 from Hestia-Homes/feature/landlord_data
fix
2026-06-05 20:03:55 +01:00
Khalim Conn-Kowlessar
d559298de2 feat(baseline): sap_code_to_fuel normalizes via the calculator's own helper
The fuel codes the calculator now puts on SapResult are its own codes — raw
gov-API enums or already-Table-32, depending on the source mapper (ADR-0015).
sap_code_to_fuel now runs the code through table_32.to_table_32_code
(promoted from private _to_table_32_code) — T32-first, then API-translate,
the SAME normalization the calculator's pricing/CO2 helpers use — before the
Table-32 -> Fuel dispatch, so the bill's carrier matches what the calculator
billed (incl. the API/T32 collision codes, e.g. 20 = wood-logs not heat-net).

Falls back to the raw code for billing fuels the price table omits (the 41-58
heat-network range), which resolve to HEAT_NETWORK -> UnpricedFuel — stricter
than, and intentionally divergent from, the calculator's lossy
default-to-mains-gas for an unpriced code (ADR-0014 §5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:59:25 +00:00
Khalim Conn-Kowlessar
3c0ac98122 feat(calculator): thread per-end-use fuel codes + PV export onto SapResult
ADR-0014 BillDerivation attributes each end-use (HEATING / HOT_WATER /
SECONDARY / APPLIANCES / COOKING) to a fuel carrier and credits PV
export. SapResult already carried the per-end-use kWh but not WHICH
fuel each end-use burns, nor the annual exported kWh — so a downstream
SapResult->EnergyBreakdown adapter could not pick the right tariff.

Surfaces five output-only fields, threaded exactly like the recently
merged appliances/cooking change (2f039aeb):
  main_heating_fuel_code      RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
  main_2_heating_fuel_code    code column (the lodged fuel code, e.g.
  secondary_heating_fuel_code mains gas 26). None when the corresponding
  hot_water_fuel_code         system is absent / fuel not resolvable.
  pv_exported_kwh_per_yr      SAP 10.2 Appendix M1 §3-4 annual export kWh
                              (0.0 when no PV).

cert_to_inputs.py populates the four fuel codes from the existing
resolvers the cost/CO2 cascade already uses — `_main_fuel_code`,
`_secondary_fuel_code`, `_water_heating_fuel_code` (not reinvented);
Main 2 is the second `main_heating_details` entry, guarded for length.
There is a single CalculatorInputs construction site (cert_to_demand_
inputs delegates to cert_to_inputs). `pv_exported_kwh_per_yr` already
existed on CalculatorInputs; SapResult collapses its Optional to 0.0.

HARD CONSTRAINT honoured — output-only, zero rating drift. These fields
do NOT feed ECF / total_fuel_cost_gbp / co2_kg_per_yr / primary_energy_*
/ sap_score / any monthly value. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical: calculator suite 1658 -> 1661 passed (+3 new tests),
4 skipped, 0 failed before and after. pyright net-zero (51 -> 51 in
domain/; no new errors in the touched test files).

New tests: a synthetic threading test (four fuel codes + PV export pass
unchanged through calculate_sap_from_inputs; None PV collapses to 0.0)
and a cert-level pin (mains-gas combi cert 000516 -> main fuel code 26,
no Main 2, secondary 30, HW 26). Synthetic CalculatorInputs / SapResult
fixtures updated for the new SapResult fields (defaults cover Inputs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:59:24 +00:00
Jun-te Kim
8b9dcc73f2 fix 2026-06-05 17:24:17 +00:00
Daniel Roth
cf6c63f059 correct orchestrator tests 2026-06-05 16:13:04 +00:00
Daniel Roth
e84de954fb define MagicPlanConfig class to get environment variables 2026-06-05 15:46:32 +00:00
Daniel Roth
198d2afdb1 Merge branch 'main' into feature/handle-new-magicplan-response-structure 2026-06-05 14:35:56 +00:00
Daniel Roth
8e349704b1 move magic plan handler to applications/ 2026-06-05 14:33:26 +00:00
Jun-te Kim
6778c427bc
Merge pull request #1181 from Hestia-Homes/feature/landlord_data
property override
2026-06-05 15:16:06 +01:00
Daniel Roth
37b5a3a6e5 move domain code out of datatypes/domain 2026-06-05 14:07:28 +00:00
Daniel Roth
db3477d6bb Extract door height from API response into height_mm 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:49:57 +00:00
Daniel Roth
5797ddbda6 Persist window and door ventilation via SQLModel tables 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:06:15 +00:00
Daniel Roth
192a3cf20f Persist window and door ventilation via SQLModel tables 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:02:47 +00:00
Daniel Roth
0211fb8092 Migrate all MagicPlan tests to single new-format fixture 🟪
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:59:56 +00:00
Jun-te Kim
b07db1ef6b property override 2026-06-05 12:18:13 +00:00
Daniel Roth
5a582bbff0 Merge branch 'main' into feature/handle-new-magicplan-response-structure 2026-06-05 11:01:28 +00:00
KhalimCK
3bdfa0287c
Merge pull request #1169 from Hestia-Homes/feature/per-cert-mapper-validation
Feature/per cert mapper validation
2026-06-05 11:50:11 +01:00
Daniel Roth
ebd6f1623f Merge branch 'main' into feature/handle-new-magicplan-response-structure 2026-06-05 10:16:14 +00:00
Khalim Conn-Kowlessar
77f90e144e review: store epc_building_part.wall_insulation_thickness as JSONB
PR feedback (dancafc): the SQLModel column was Optional[str], but the
domain `SapBuildingPart.wall_insulation_thickness` is Optional[Union[str,
int]] — `_api_resolve_wall_insulation_thickness` returns an int mm when the
API lodges `wall_insulation_thickness == "measured"` (SAP 10.2 §5.7 /
Table 8). The plain str column round-trips that int back as the string
"100", corrupting the Table 8 insulated-wall U-value lookup.

This column was missed in the round-trip-fidelity §1 JSONB sweep
(#1129) — its `Union[str, int]` sibling `roof_insulation_thickness` was
converted, but `wall_insulation_thickness` was not, and no 21.0.0/21.0.1
fixture lodges "measured" so the gap stayed latent. Convert to JSONB
(matching `roof_insulation_thickness` / `flat_roof_insulation_thickness`),
align the column type to Optional[Union[str, int]] (also removes a pyright
type-mismatch), record it in the migration doc §1, and add a round-trip
guard test asserting an int survives as an int (fails as '100' == 100 on
the old str column).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:07:24 +00:00
Khalim Conn-Kowlessar
9521d52403 S0380.234: PV diverter (Appendix G4) — diverts surplus PV to the cylinder
SAP 10.2 Appendix G4 (PDF p.72-73). A PV diverter routes surplus PV
generation (the would-be export EPV,m × (1 − βm)) to an immersion heater
in the hot-water cylinder. Per G4 step 4:

    SPV,diverter,m = EPV,m × (1 − βm) × 0.8 × fPV,diverter,storageloss

(0.8 = cylinder heat-acceptance; fPV,diverter,storageloss = 0.9 for the
higher storage temperature), clamped to ≤ (62)m + (63a)m, and entered as
the negative worksheet (63b)m (step 5). The β factor is computed on the
PRE-diverter (219) per the §3a note (lines 5485-5486). Effects:
  - (64)m = (62)m + (63b)m → less main-system water-heating fuel (219);
  - export drops to EPV,ex,m = EPV,m(1 − βm) + (63b)m / 0.9 (§4 p.94
    line 5501); the onsite dwelling portion EPV,m × βm is unchanged.

Inclusion (G4 step 1) requires ALL of: a PV system connected to the
dwelling; a cylinder larger than (43) average daily HW use; no solar
water heating; no battery — else the diverter is disregarded.

Three layers:
  - extractor reads Summary §19 "Diverter present"; schema 21.0.0/21.0.1
    SapEnergySource gains `pv_diverter` (API `sap_energy_source.pv_diverter`);
  - `Renewables.pv_diverter_present` + domain `SapEnergySource.pv_diverter_present`,
    set in both the Elmhurst and API mapper paths;
  - `_pv_diverter_monthly_kwh` applies the G4 math after the β split;
    `cert_to_inputs` recomputes (219) and the PV export.

On simulated case 19 (electric storage heaters, 7-hour, PV + diverter):
SAP continuous 50.33 → 51.34 (worksheet 51.2221; both round to the
lodged 51), cost (255) 1847.5 → 1812.3 (ws 1816.6), CO2 (272) 3331 →
3120 (ws 3126), with (233a) dwelling 1280.6 (ws 1280.4). The residual
+0.11 SAP is an upstream winter Appendix-M monthly-EPV-shape gap +
fabric (33) +1.0, tracked as the next case-19 cause. Suite: 2412 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:59:12 +00:00