SAP 10.2 Table 5a (PDF p.177) row "Central heating pump in heated
space" only applies to mains with a water-loop circulation pump.
Footnote a) names two exclusions verbatim ("Does not apply if a
heating system used solely for domestic hot water. ... Not applicable
for electric heat pumps from database."), and the row's name carries
the implicit third: dry mains with no central heating pump (electric
storage heaters, electric direct-acting, solid-fuel room heaters
without back-boilers) — the row simply doesn't list them.
Pre-slice `internal_gains_from_cert` gated only on Note a) (HP
exclusion), applying `central_heating_pump_w(date_category=...)` to
every non-HP main. The default UNKNOWN-date branch added 7 W of pump
gain to (70)m for every dry-system fixture in the controlled-variable
corpus, even though the worksheet (70)m = 0 every month.
Per-line walk on electric 3 (SAP code 401 "Manual charge control"):
cascade (73)[Jan] = 640.21 W
worksheet (73)[Jan] = 633.21 W delta = +7.00 W
cascade (70)[Jan] = 7.00 W
worksheet (70)[Jan] = 0.00 W Table 5a inapplicable
The +7 W winter-month gain lowered cascade SH demand by ~38 kWh/yr
(cascade 11050 vs worksheet 11088). At Table 32 18-hour low-rate
~7.4 p/kWh that's £2.50/yr under-charging — matching the cluster's
uniform Δcost = -£1.96..-£2.80 pattern. Continuous SAP rose ~+0.10
because cost dominates the ECF.
Fix: new `_any_main_system_has_central_heating_pump(epc)` predicate
in `internal_gains.py`, mirroring `cert_to_inputs._is_wet_boiler_main`
(S0380.149 — Table 4f kWh side). Wet if any non-HP main lodges:
- sap_main_heating_code in {101-141, 151-161, 191-196} (gas/oil/
solid-fuel/electric boilers per Table 4a/4b),
- main_heating_index_number (PCDB Table 322 record),
- main_heating_category in {1, 2} (RdSAP central heating), OR
- heat_emitter_type in {1, 3} (radiators / fan-coil per Table 4d).
Dead `_all_main_systems_are_heat_pumps` helper removed (the new
predicate subsumes its role).
Cluster closures (10 variants):
electric 3: SAP +0.1215 → -0.0000, cost -£2.80 → -£0.00
electric 5: SAP +0.1081 → -0.0000, cost -£2.49 → -£0.00
electric 6: SAP +0.1081 → -0.0000, cost -£2.49 → -£0.00
electric 7: SAP +0.1017 → -0.0000, cost -£2.34 → -£0.00
electric 8: SAP +0.0941 → -0.0000, cost -£2.17 → -£0.00
electric 9: SAP +0.1199 → -0.0000, cost -£2.76 → -£0.00
solid fuel 4: SAP +0.0850 → -0.0000, cost -£1.96 → -£0.00
solid fuel 9: SAP +0.1072 → -0.0000, cost -£2.47 → -£0.00
solid fuel 10: SAP +0.1134 → +0.0000, cost -£2.61 → -£0.00
solid fuel 11: SAP +0.0912 → +0.0000, cost -£2.10 → +£0.00
Σ |ΔSAP_c| across 25-variant cohort: 1.24 → 0.18. All 10 cluster
variants now join the lighting-PE +48.66 / CO2 +11.95 deferred
cohort (Elmhurst-vs-spec monthly factor quirk, same shape as
electric 1 + solid fuel 5/6/7/8 from prior closures).
Verbatim spec quote (SAP 10.2 Table 5a row 1, PDF p.177):
"Central heating pump in heated space, 2013 or later 3 a)"
"Central heating pump in heated space, 2012 or earlier 10 a)"
"Central heating pump in heated space, unknown date 7 a)"
The row name ("Central heating pump") gates by construction: dry
systems have no central heating pump and the row's three sub-rows
don't apply.
No regressions on the other 31 variants or any golden fixture; the
6 Elmhurst U985 fixtures lodge PCDB index → the new predicate
returns True → pump_w unchanged.
Tests: 904 pass (+1), 0 fail. Pyright net-zero (35 → 35).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4a (PDF p.166) Cat 7 "Electric storage heaters"
splits the responsiveness R between two sub-tables:
Off-peak tariff:
Slimline storage heaters ... R = 0.2 402
Convector storage heaters ... R = 0.2 403
Slimline + Celect-type control ... R = 0.4 405
Convector + Celect-type ctrl ... R = 0.4 406
24-hour heating tariff:
Slimline storage heaters ... R = 0.4 402
Convector storage heaters ... R = 0.4 403
Slimline + Celect-type control ... R = 0.6 405
Convector + Celect-type ctrl ... R = 0.6 406
Per SAP 10.2 §12.4.3 (PDF p.36) the 18-hour tariff has electricity
at low rate for 18 hours per day with at most 6h of interruption /
2h max each — operationally equivalent to 24-hour for storage-heater
charging. The cascade therefore routes EIGHTEEN_HOUR + TWENTY_FOUR_
HOUR through the 24-hour Table 4a sub-row.
Pre-slice `_responsiveness` keyed on `sap_main_heating_code` only
and returned R=0.2 for code 402 regardless of tariff. The existing
docstring already flagged the gap:
402: 0.20, # Slimline storage heaters (24-hr tariff: 0.40)
... "promote to (sap_code, tariff) lookup when 24-hour fixture
surfaces; until then the off-peak default applies (under-shoots
R for the 24-hour case)."
Per-line walk on electric 5 (sap_main_heating_code=402 +
meter_type="18 Hour"): cascade T_living (87)[Jan] = 20.1213 vs
worksheet 19.6519, (92)[Jan] = 18.6996 vs worksheet 18.2063, (93)
[Jan] = 19.0996 vs worksheet 18.6063 (cascade +0.4933 K throughout
the cascade). Back-solve from worksheet T_living=19.6519 via the
Table 9b Tsc formula:
Tsc(R=0.4) = 0.6 × (21-2) + 0.4 × (4.3 + 0.9933 × 705.4/210.23)
= 11.4 + 0.4 × 7.6325 = 14.4528
ΔT = 21 - 14.4528 = 6.5472
u_sum = 0.5 × 6.5472 × (7² + 8²) / (24 × 11.43) = 1.3481
T_living = 21 - 1.3481 = 19.6519 EXACT match.
Adds:
- `_CONTINUOUS_CHARGING_TARIFFS: frozenset[Tariff]` = {EIGHTEEN_
HOUR, TWENTY_FOUR_HOUR} — the tariffs treated as "24-hour
heating" for Table 4a R selection.
- `_RESPONSIVENESS_24_HOUR_OVERRIDE_BY_SAP_CODE: dict[int, float]`
— the override table for codes 402/403/405/406 (404, 407, 409
keep the same R in both sub-tables).
- `tariff: Optional[Tariff]` parameter to `_responsiveness`, with
the override consulted before the off-peak default.
- Tariff threaded through both call sites of MIT cascade (rating
+ demand paths) via `tariff_from_meter_type`.
Closures electric 5:
ΔSAP −1.1759 → +0.1081 (91% reduction)
Δcost +£27.09 → −£2.49
ΔCO2 +62.72 → +7.30 kg
ΔPE +438.03 → +0.07 kWh (essentially EXACT)
Electric 5 now joins the same residual cluster as electric 3/6/7/8/
9 (+0.09..+0.12 SAP, −£2..−£3 cost, +£7 CO2) — the cluster that
the prior handovers suspected was a shared shave-the-residual gap.
No regressions on the other 24 cohort variants. Extended handover
suite: 903 pass / 0 fail (was 902 — +1 from the new AAA test).
Pyright net-zero (43 → 43).
Σ |ΔSAP_c| across the 25-variant cohort: 2.30 → 1.24 (~46%
reduction from this slice).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 4f (PDF p.174) row "Warm air heating system fans"
+ footnote e) — verbatim:
Warm air heating system fans e) SFP × 0.4 × V
e) SFP is the specific fan power from the database record for the
warm air unit if applicable; otherwise 1.5 W/(l/s). These values
of SFP include the in-use factor.
If the heating system is a warm air unit and there is balanced
whole house mechanical ventilation, the electricity for warm
air circulation should not be included in addition to the
electricity for mechanical ventilation. However it is included
for a warm air system and MEV or PIV from outside.
V is the volume of the dwelling in m³.
Per Table 4a (PDF p.165-166), warm-air systems are:
- Category 5: heat pumps with warm-air distribution (codes 521,
523, 524 electric; 525, 526, 527 gas-fired)
- Category 9: warm-air systems NOT heat pump (501-511, 520 gas-
fired; 512-514 liquid-fired; 515 Electricaire electric)
Pre-slice the cascade's `_table_4f_additive_components` docstring
explicitly listed "(230b) Warm-air heating fans + (230c) for warm-
air pump" as "Not yet wired" — every Cat 5 / Cat 9 warm-air corpus
variant resolved `pumps_fans_kwh_per_yr` to 0. For electric 2 (code
524 Cat 5 air-source warm-air HP, no MV, V = 227.25 m³), the P960
worksheet block 11a (249) lodges 136.35 kWh × 13.67 p/kWh = £18.64
where the cascade computed 0.
New `_TABLE_4A_WARM_AIR_SAP_CODES` frozenset (22 codes) + leaf helper
`_table_4f_warm_air_heating_fans_kwh(main, dwelling_volume_m3,
has_balanced_mv)` wired at the orchestrator pumps_fans summation
alongside the existing circulation-pump and gas-flue-fan helpers.
Footnote-e balanced-MV omission reads `epc.sap_ventilation.
mechanical_ventilation_kind` via the new
`_has_balanced_mechanical_ventilation` predicate (returns True for
MVHR / MV; False for MEV / PIV / NATURAL).
Per-line walk evidence: cascade `pumps_fans_kwh_per_yr` = 0.0000 vs
worksheet (249) = 136.3500 = 1.5 × 0.4 × 227.25 exactly. Default SFP
from footnote e matches; PCDB warm-air-unit SFP lookup deferred
until a fixture exercises it.
Closures electric 2:
pumps_fans_kwh_per_yr: 0 → 136.35 (EXACT match to worksheet)
ΔSAP +0.7002 → −0.1087 (residual swung past worksheet — the +0.70
pre-slice was an under-counted-fan offset; spec-correct fix lands
just past zero, exposing a small upstream SH cascade gap likely
in the Cat 5 warm-air HP Table 4a SH efficiency or Table 9c MIT
cascade for warm-air mains — follow-up slice)
Δcost −£16.14 → +£2.50
ΔCO2 −2.37 → +16.54 kg
ΔPE −108.58 → +97.69 kWh
No regressions on the other 24 cohort variants — the warm-air-code
gate fires only when `sap_main_heating_code` is in the new frozenset
and only electric 2 has a warm-air SAP code in the corpus. Extended
handover suite: 902 pass / 0 fail (was 901 — +1 from the new AAA
test). Pyright net-zero (43 → 43).
Σ |ΔSAP_c| across the 25-variant cohort: 2.87 → 2.30 (~20%
reduction from this slice).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 2b note b) (PDF p.159) — verbatim:
Multiply Temperature Factor by 0.9 if there is separate time
control of domestic hot water (boiler systems, warm air systems
and heat pump systems).
The parenthetical list restricts the rule to systems where the heat
generator (boiler / warm-air / HP) is the device heating the
cylinder. Electric immersion is NOT in that list because the
immersion isn't a heat-generator system feeding DHW — it sits inside
the cylinder. The ×0.9 multiplier reflects shorter cylinder-heating
periods when a boiler / HP / warm-air operates on a separate timer
for DHW vs SH; if the heat generator doesn't feed the cylinder at all
(because the immersion does), there's no such timing effect.
Pre-slice `_separately_timed_dhw` returned True for any Cat 4 HP
main BEFORE consulting WHC (line 3872 `if main.main_heating_category
== 4: return True`). For electric 2 (sap_main_heating_code=524 Cat 5
warm-air ASHP, main_heating_category=4 per Elmhurst mapper, WHC=903
electric immersion + cylinder + cylinder thermostat lodged), the
cat-4 branch fired before the existing `_is_electric_water` check
could route the cert to False. The cascade applied ×0.9 to the
Temperature Factor (53), pulling (55) from 1.2294 → 1.1064 → cascade
annual (56) = 403.87 vs worksheet (56) annual = 448.73.
Same WHC=903 principle as the prior slice S0380.156 (Table 3 zero-
loss list for electric immersion): when HW is independent of the
main heating, main-heating-specific DHW rules don't apply — even
when the main happens to be a HP / boiler / warm-air system.
Fix: new top-of-function `if epc.sap_heating.water_heating_code ==
_WHC_ELECTRIC_IMMERSION: return False` guard in
`_separately_timed_dhw`. Reuses the constant introduced in S0380.156.
Closures electric 2:
Cylinder (56) storage loss annual 403.87 → 448.73 (matches
worksheet 1.2294 × 365 = 448.73 EXACT within rounding)
HW kWh demand 2339.24 → 2384.12 (matches worksheet (62)/(64) =
2384.116 EXACT)
ΔSAP +0.8118 → +0.7002
Δcost −£18.71 → −£16.14
ΔCO2 −7.21 → −2.37 kg
ΔPE −161.68 → −108.58 kWh
The remaining +0.70 SAP residual is a separate upstream gap (likely
warm-air-HP SH cascade or Table 4a SH efficiency for code 524) —
follow-up slice.
No regressions on the other 24 cohort variants. Cohort-1 ASHP certs
(Cat 4 HP + WHC=901 = HW from HP + cylinder) keep ×0.9 as before
because their WHC=901 doesn't trigger the new guard. Extended
handover suite: 901 pass / 0 fail (was 900 — +1 from the new AAA
test). Pyright net-zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SAP 10.2 Table 3 (PDF p.160) verbatim:
Primary loss is set to zero for the following:
Electric immersion heater
Combi boiler ...
CPSU ...
Boiler and thermal store within a single casing
Separate boiler and thermal store connected by no more than 1.5
m of insulated pipework
Direct-acting electric boiler
Heat pump (...) with hot water vessel integral to package
The Elmhurst WHC=903 lodging signals exactly the first row: "HW from
a separate electric immersion heater" — the cylinder is heated by an
immersion element inside the tank, no primary pipework between any
heat generator and the cylinder. The rule is universal: regardless
of what main heating exists for space heating, electric immersion
means no primary circuit means no primary loss.
Pre-slice `_primary_loss_applies` only consulted `water_heating_code`
in the Table 4a wet-boiler branch (codes 151-161 / 191-196). The Cat
4 HP branch returned True unconditionally when no PCDB record was
lodged; the Cat 1/2 boiler branch returned True unconditionally; the
PCDB Table 322 + Table 4b non-PCDB branches likewise. For the
electric 2 corpus variant (sap_main_heating_code=524 Cat 5 warm-air
ASHP, main_heating_category=4 per Elmhurst mapper, no PCDB record,
WHC=903 + cylinder), the Cat-4 branch falsely returned True and the
cascade added ~510 kWh/yr primary loss to a system with no primary
circuit at all.
Per-line walk discipline applied: cascade `water_heating_from_cert`
output dump showed `primary_loss_monthly_kwh_annual = 509.98` while
worksheet (59)m = 0 every month → spec lookup found Table 3 verbatim
"Electric immersion heater" zero-loss line.
Adds `_WHC_ELECTRIC_IMMERSION: Final[int] = 903` constant + a
top-of-function `if water_heating_code == _WHC_ELECTRIC_IMMERSION:
return False` guard that fires before any of the system-type-keyed
branches.
Closures electric 2:
HW kWh 2849.22 → 2339.24 (matches worksheet (62)/(64) = 2384.12
within the residual ~45 kWh storage-loss gap)
ΔSAP −0.4584 → +0.8118 (cascade swung past the worksheet by +1.27
— the pre-slice 'near-correct' value was offsetting cascade bugs
per [[feedback-software-no-special-handling]]; the +0.81 residual
exposes a separate upstream gap to chase in a follow-up slice)
Δcost +£10.56 → −£18.71
ΔCO2 +47.89 → −7.21 kg
ΔPE +443.13 → −161.68 kWh
No regressions on the other 24 cohort variants — only electric 2 has
the (Cat 4 HP, no PCDB, WHC=903) combination in the corpus.
Extended handover suite: 900 pass / 0 fail (was 899 — +1 from the
new AAA test). Pyright net-zero (43 → 43).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pin the FE-facing aggregate_portfolio_recommendations (previously untested): it
sums a Scenario's default Recommendations onto the Scenario row, joining
Recommendation → Plan on recommendation.plan_id. Locks the m2m→plan_id read cut
for the FE-critical path, now testable thanks to the full-parity ScenarioModel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the scenario and installed_measure tables into
infrastructure/postgres/modelling/ as full-parity SQLModel definitions
(ScenarioModel, InstalledMeasureModel + MeasureType), completing the cluster
consolidation. backend/app/db/models/recommendations.py is now a pure
re-export shim.
ScenarioModel.goal is the PortfolioGoal enum (legacy planning branches on it),
sourced from domain/modelling/portfolio_goal.py; the repo's to_domain maps it to
its value string, so domain Scenario.goal is now the value ("Increasing EPC")
consistent with the orchestrator's check — fixing the latent name-vs-value
inconsistency the old str column masked (the scenario repo test stored the enum
*name*). Parity columns are nullable (mirror convention; live NOT-NULLs owned by
Drizzle).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PortfolioGoal is domain vocabulary (a Scenario's goal — legacy planning branches
on PortfolioGoal.INCREASING_EPC), so it belongs in domain/ co-located with
scenario.py, mirroring how domain/epc/wall_type.py holds an enum that
infrastructure/ imports. This lets the consolidated ScenarioModel (next slice)
source the goal enum from domain without an infra→backend dependency.
portfolio.py keeps a re-export so every existing
`from ...portfolio import PortfolioGoal` caller is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Standardise the modelling persistence classes on the …Model suffix (PlanModel,
RecommendationModel, RecommendationMaterialModel) — matching the epc_property
precedent and the legacy names the rest of backend/ already imports, so the
shim's plan re-export becomes literal (no alias) and the eventual shim deletion
needs zero renames. The …Row→…Model sweep for the non-cluster tables
(Property/Task/Material/…) waits until their live legacy …Model counterparts
are retired, to avoid reintroducing dual-definition collisions. No behaviour
change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stop writing the m2m (remove create_plan_recommendations + its call, the bulk
link insert and the now-dead plan_ids_by_index, and the plan_recommendations
delete in delete_property_batch) and remove the PlanRecommendationRow model +
its shim alias and the test_export fixture inserts. Measures now link to their
Plan solely via recommendation.plan_id (writers set it, readers join on it).
The live drop of the plan_recommendations table is the FE-owned Drizzle
migration documented in docs/migrations/recommendation-plan-id.md, sequenced
after the read-cut + backfill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite the three structurally-identical m2m-join readers
(portfolio_functions.aggregate_portfolio_recommendations,
Outputs.get_recommendations_from_db, export get_recommendations) to join
PlanModel directly via recommendation.plan_id, dropping the plan_recommendations
join and its now-unused import. The writers set plan_id (prior slice), so the
rows resolve. test_export pins the export reader through the cut (its fixtures
now set recommendation.plan_id). A portfolio_functions DB characterization test
lands with the scenario consolidation (which provides the full-parity scenario
table the aggregation writes to).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
upload_recommendations and bulk_upload_recommendations_and_materials now set
plan_id on each recommendation row (the plan id is already in scope), while
still writing the plan_recommendations m2m — the dual-write that lets readers
move onto plan_id with no breakage during the transition (ADR-0017 amendment /
docs/migrations/recommendation-plan-id.md). The m2m write is removed in a later
slice once no reader depends on it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the live plan, recommendation, recommendation_materials and (retiring)
plan_recommendations tables into a new infrastructure/postgres/modelling/
subpackage as single SQLModel definitions (the epc_property pattern), absorbing
the rebuild's partial PlanRow/RecommendationRow mirrors and carrying full
legacy column parity plus recommendation.plan_id. Out-of-cluster references are
plain indexed ints (mirror convention); the live FKs are owned by the Drizzle
schema. backend/app/db/models/recommendations.py becomes a re-export shim
(ScenarioModel/InstalledMeasure stay for a later slice).
Fix the export conftest to create SQLModel-first (so Base funding_package's FK
to the now-SQLModel plan resolves) and skip the redundant drop_all on its
function-scoped throwaway DB (the epc enum type is now shared across both
metadatas). Resolves the pre-existing dual-definition collision: the rebuild
and legacy export suites are now co-runnable. No behaviour change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds simulated case 7: case 6 (P960-0001-001431) with the heating swapped
to a CONDENSING OIL COMBI (SAP code 130, Table 4b 82/73) and the cylinder
removed — combi instantaneous DHW (WHC 901), Table 3a keep-hot combi loss
(61) = 600 kWh/yr, no primary/storage loss, boiler interlock PRESENT (no
−5pp). This is the heating archetype golden cert 0240-0200-5706-2365-8010
uses, which case 6 (SAP code 127, a *regular* condensing oil boiler +
cylinder) never exercised.
The cascade reproduces the case-7 worksheet EXACTLY at abs=1e-4 on every
top-level SapResult output with ZERO calculator changes:
(211) 7865.4304 (213) 7556.9821 (219) 3496.8121 (98c) 12646.3783
(255) 1123.3372 (257) 1.9631 (272) 5738.9315 (258) 73
This validates the SAP 10.2 Appendix D Eq D1 combi efficiency blend +
Table 3a keep-hot combi loss + Table 4b code 130 (82/73) path, and
exonerates the combi mechanism as the source of 0240's API-path residual
— which therefore lives in 0240's fabric/demand or the API mapper.
Test-only slice (no impl change). New fixture file: 0 pyright errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prerequisite for the SAP 10.2 p.186 two-systems-different-parts MIT.
When two main systems heat different parts of a dwelling, §14.1 Main
Heating2 lodges its OWN "Heat Emitter" + "Main Heating Controls Sap"
(simulated case 6: Main 1 radiators / control 2106 serving the living
area, Main 2 underfloor / control 2110 serving elsewhere). The extractor
+ mapper dropped both — `MainHeatingDetail.heat_emitter_type` and
`main_heating_control` came through as empty-string sentinels, so the
cascade saw system 2 as having no responsiveness (defaulted R=1.0) and no
control type.
- `MainHeating2` datatype gains `heat_emitter` + `heating_controls_sap`.
- The extractor reads them from the §14.1 block.
- `_map_elmhurst_main_heating_2` maps them via the same helpers as Main 1
(`_elmhurst_heat_emitter_int` → underfloor-in-screed = emitter 2, Table
4d R=0.75; `_elmhurst_sap_control_code` → 2110, Table 4e type 3),
threading the dwelling floor + age band for the underfloor subtype.
Empty-string fallback preserved for the legacy DHW-only Main 2 (cert
000565 §14.1 omits emitter/control). No cascade output changes yet — the
MIT consumer lands in S0380.205. Full suite 2358 pass + 0 fail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Elmhurst extractor crashed parsing simulated-case-6's room-in-roof
window rows: the §11 "Location" cell "Roof of Room in Roof" wraps across
the layout prefix/suffix blocks and leaked into the glazing-type phrase
("Double between 2002 Roof of Room and 2021 in Roof" → UnmappedElmhurst-
Label). Fix (`_parse_window_from_anchors`): detect the roof-of-room
location tokens, strip them from the before/after blocks so the glazing
phrase reconstructs cleanly, and set location="Roof of Room".
Mapper: `_is_elmhurst_roof_window` gains a "Roof of Room" location branch
(highest-confidence rooflight signal, above the BP-roof-type / U>3.0
gates); `_ELMHURST_ROOF_WINDOW_U_BY_GLAZING` gains "Double between 2002
and 2021" → 2.30 (case 6 lodges the already-inclined roof-window U, so
the +0.30 inclination adjustment must not double-apply).
This is the site-notes mirror of S0380.198 (API window_wall_type=4):
both paths now route room-in-roof rooflights to (27a) at the inclined U.
Validated against the case-6 P960 worksheet at abs=1e-4:
(27) Windows = 22.7408 (cascade 22.7407)
(27a) Roof Windows = 13.0375 (cascade 13.0375, EXACT)
(31) ext area = 336.13
Case 6 is pinned only on the §3 window line refs (new standalone test,
not added to the section-pin `_FIXTURES`) because its DUAL main heating
(51% rads + 49% underfloor, oil) makes the §10/§12 per-system lines
non-comparable to SapResult's aggregated fields — documented in the
fixture module. Summary mirrored to Summary_001431_case6.pdf.
Suite: 2355 passed, 1 skipped. New code: 0 pyright errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Promotes user-simulated "case 5" (detached, sandstone-walled, room-in-roof
cousin of golden cert 0240) to an e2e worksheet fixture pinning the WHOLE
extractor → mapper → calculator pipeline at abs=1e-4 on all 11 Block-1
line refs. Its worksheet prints the exact RR-gable routing S0380.196
implements, validating that fix against ground truth:
Roof room Main Gable Wall 1 15.68 U=0.35 (29a) Exposed → walls @ main-wall U
Roof room Main remaining area 61.73 U=0.30 (30) A_RR shell − Σ gables
External roof Main 14.52 U=0.11 (30) loft residual
Roof room Main Gable Wall 2 15.68 U=0.25 (32) Party → party @ 0.25
gable area = 6.40 × 2.45 (§3.9.1 default RR storey height); A_RR remaining
= 12.5√(83.2/1.5) − 2×15.68 = 93.09 − 31.36 = 61.73 (RdSAP 10 §3.9.1(e)).
Confirms a DETACHED dwelling can lodge a Party RR gable (Table 4 p.22
row 2) — so my S0380.196 mapping (gable_wall_type 0=Party, 1=Exposed) is
correct; do not flip it.
Two extractor/mapper gaps surfaced and fixed (case 5 is the forcing test):
- Sandstone wall label "SS Stone: sandstone or limestone" had no
`_ELMHURST_WALL_CODE_TO_SAP10` entry (raised UnmappedElmhurstLabel).
Added "SS" → 2 (WALL_STONE_SANDSTONE), matching 0240's API
wall_construction=2 (cross-mapper parity).
- Roof "Insulation Thickness 400+ mm" was silently dropped: the four
thickness parsers used `.split()[0].isdigit()`, which rejects the
trailing "+" → None → u_roof fell back to the age-J default 0.16
instead of 0.11 (+1.09 W/K roof, the whole 0.12 SAP gap). Added
`_parse_thickness_mm` (strips to leading digits) and applied it at all
four sites (walls / alt-wall / roof / floor). The only existing fixture
with "400+ mm" (000565 Stud Wall) routes via the RIR regex, unaffected.
Result: case 5 cascade ≡ worksheet at 1e-4 on SAP/ECF/cost/CO2 + every
energy stream. Neither gap affects 0240 (its API path captures both the
sandstone code and "400mm+"); 0240's residual is therefore non-fabric.
Suite: 2353 passed, 1 skipped. New code: 0 pyright errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the user-simulated case-4 worksheet as e2e fixture `001431_6035` —
reproduces golden cert 6035's full floor geometry (Main ground-floor HLP
15.99 + first-floor HLP 8.32, the asymmetric upper storey) and 8 windows.
All 11 Block-1 line refs pin at abs=1e-4 against the worksheet (SAP 68,
ECF 2.2802, cost 937.2341, CO2 4682.3494, space 15745.3260, main fuel
18744.4357).
This is the 4th independent 1e-4 confirmation across the 6035 archetype
(sim cases 1-4). Case 4 matches 6035 on floors + window areas; the
residual ~50 kWh / £11 cascade delta vs 6035 is two lodged inputs only
(largest window orientation N vs S; meter type "Dual" vs API 2), not
calculator behaviour.
Conclusion: the cascade reproduces the spec engine exactly for 6035's
geometry, so 6035's +19 PE vs the lodged register is lodged-register
divergence (the gov.uk register's rounded value vs the spec-exact
worksheet), NOT a calculator gap. 6035 is a "pin-forever" lodged-only
cert. Bugs surfaced + fixed along the way: S0380.192 (Simplified-RR
remaining area) and S0380.193 (suspended-floor sealed rule).
2341 passed (+11), 0 failed; pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the user-simulated case-3 worksheet as e2e fixture `001431_rr8` —
Main + Extension + Simplified room-in-roof with 8 windows (≈14.15 m²,
reproducing golden cert 6035's glazing) and Main ground-floor HLP 15.99.
All 11 Block-1 line refs pin at abs=1e-4 against the worksheet (SAP 68,
cost 951.3425, CO2 4767.4862, space 16086.3557, main fuel 19150.4235,
HW 3307.2639, lighting 262.0885).
This is the third independent 1e-4 confirmation that the cascade
reproduces the spec engine for the 6035 archetype (after S0380.192
Simplified-RR + S0380.193 suspended-floor). It differs from 6035 in one
input only — the Main first-floor HLP (15.99 here vs 6035's 8.32) — so
6035's +19 PE vs the lodged register is lodged-register divergence, not
a calculator gap. A byte-identical 6035 replica (first-floor HLP 8.32)
would let 6035 itself be pinned directly to close that out.
2330 passed (+11), 0 failed; pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>