mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice 101b: HP cert 0380 — cavity+EWI wall U + Table 11 cat-4 secondary
Two HP-specific cascade gaps blocking cert 0380:
(a) Cavity wall + filled cavity + external insulation:
Cert 0380's `walls[0].description="Cavity wall, filled cavity and
external insulation"` with `wall_insulation_type=6` +
`wall_insulation_thickness="100mm"`. RdSAP 10 §4-4 (page 73) lists
"cavity plus external" as a distinct insulation type code (6 in
the API schema; 7 is "cavity plus internal"). The U-value is the
composite U = 1 / (1/U_filled + R_ins) per §5.8 page 40 + Table 14
R-value lookup, with the cascade-2-d.p. round matching the dr87
worksheet's column display.
For cert 0380: U_filled (age D)=0.7 + R_ins (100mm @ λ=0.04)=2.5
→ U_unrounded=0.2545 → rounded 0.25 (worksheet exact). Walls HLC
14.87 → 11.6150 (= worksheet 11.6150). (37) total fabric heat
loss 99.34 → **96.0889** (= worksheet 96.0889 EXACT).
Added `WALL_INSULATION_CAVITY_PLUS_EXTERNAL: Final[int] = 6` and
`WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7` constants
+ `_WALL_INSULATION_LAMBDA_W_PER_MK = 0.04` default thermal
conductivity. New `u_wall` branch fires when cavity + composite
insulation type + non-zero thickness.
(b) SAP 10.2 Table 11 secondary fraction — missing cat-4 entry:
The dict `_SECONDARY_HEATING_FRACTION_BY_CATEGORY` had entries
for cats 1/2/3/5/6/7/10 but DID NOT include cat 4 (heat pump),
despite the inline comment explicitly noting "Cat 4 (heat pump):
0.00 (HP eff includes any secondary)". Cert 0380 lodges
`secondary_heating_type=691` + `main_heating_category=4` (HP,
PCDB idx 104568), so the cascade fell through to the DEFAULT
fraction 0.10 — billing 547 kWh × 13.19 p/kWh = £72 as
"secondary heating" that the worksheet correctly shows as £0.
Added `4: 0.00` to the dict.
Effect on cert 0380 API path:
- walls HLC 14.87 → 11.62 (worksheet exact)
- (37) total HLC 99.34 → 96.09 (worksheet exact)
- main_heating_cost £282 → £314 (worksheet £316)
- secondary_heating £72 → £0 (worksheet £0)
- sap_continuous 87.62 → 90.48 (Δ -0.89 → +1.97 — over-correcting
because hot-water cascade is still cascade-£66 vs worksheet £204
including electric shower; HP HW-COP + electric-shower cost are
the next slices).
No golden cert residual shifts (cohort certs don't lodge HP cat 4
or composite cavity+EWI walls).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
7874374bcf
commit
a736db3f4a
3 changed files with 103 additions and 2 deletions
|
|
@ -586,6 +586,70 @@ def test_api_0380_glazing_type_14_resolves_to_post_2022_dg_u_value() -> None:
|
|||
assert abs(td.solar_transmittance - 0.72) <= 1e-4
|
||||
|
||||
|
||||
def test_api_0380_wall_with_external_insulation_routes_to_filled_cavity_u() -> None:
|
||||
# Arrange — cert 0380's top-level walls[0].description lodges
|
||||
# "Cavity wall, filled cavity and external insulation". The
|
||||
# worksheet uses U=0.25 for the (29a) external-walls entry — the
|
||||
# very-low-U "filled cavity + external insulation" composite that
|
||||
# RdSAP 10 §5 routes through Table 6's filled-cavity row (with a
|
||||
# further EWI reduction). Our cascade was computing U=0.32 via
|
||||
# the as-built Table 13 bucketed cascade because
|
||||
# `_described_as_insulated` only matches the past-participle
|
||||
# "insulated" — "insulation" (noun) on its own falls through to
|
||||
# False. Cert 0380's lodgement uses the noun form.
|
||||
#
|
||||
# Fix: `_described_as_insulated` should also match the noun
|
||||
# "insulation" (excluding the existing "no insulation" hard
|
||||
# negation), so cavity walls described as carrying insulation
|
||||
# route to the cascade's Filled-cavity branch.
|
||||
doc = json.loads(_API_0380_JSON.read_text())
|
||||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||||
|
||||
# Act
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
heat_transmission_section_from_cert,
|
||||
)
|
||||
ht = heat_transmission_section_from_cert(epc)
|
||||
|
||||
# Assert — main-wall HLC ≈ 46.46 m² × 0.25 = 11.62 W/K (worksheet
|
||||
# exact). Tolerance 1e-2 absorbs sub-component rounding; the
|
||||
# 1e-4 chain test downstream tightens to the cascade floor.
|
||||
worksheet_walls_w_per_k = 11.62
|
||||
assert abs(ht.walls_w_per_k - worksheet_walls_w_per_k) <= 1e-2
|
||||
|
||||
|
||||
def test_api_0380_heat_pump_no_secondary_heating_per_table_11() -> None:
|
||||
# Arrange — SAP 10.2 Table 11 explicitly notes "Cat 4 (heat pump):
|
||||
# 0.00 (HP eff includes any secondary)" — heat pumps don't apply a
|
||||
# Table 11 secondary fraction even when the cert lodges a secondary
|
||||
# heating type, because the HP efficiency already incorporates any
|
||||
# supplementary heat source. The `_SECONDARY_HEATING_FRACTION_BY_
|
||||
# CATEGORY` dict in cert_to_inputs.py had entries for categories
|
||||
# 1/2/3/5/6/7/10 but DID NOT include cat 4 — so HP certs with a
|
||||
# lodged secondary fell through to the DEFAULT 0.10, billing 10%
|
||||
# of space-heating cost as "secondary" (cert 0380: £72 secondary
|
||||
# vs worksheet £0).
|
||||
#
|
||||
# Cert 0380 lodges secondary_heating_type=691 + main_heating_
|
||||
# category=4 (HP, PCDB idx 104568). Worksheet line (242) "Space
|
||||
# heating - secondary" shows 0.0 kWh; cascade was producing
|
||||
# 547.30 kWh. Fix: dict entry `4: 0.0`.
|
||||
doc = json.loads(_API_0380_JSON.read_text())
|
||||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||||
|
||||
# Act
|
||||
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
cert_to_inputs, SAP_10_2_SPEC_PRICES,
|
||||
)
|
||||
result = calculate_sap_from_inputs(
|
||||
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
)
|
||||
|
||||
# Assert — secondary heating contributes 0 kWh / £0 on HP certs.
|
||||
assert result.secondary_heating_fuel_kwh_per_yr == 0.0
|
||||
|
||||
|
||||
def test_api_9501_room_in_roof_surfaces_populated() -> None:
|
||||
# Arrange — cert 9501's API JSON lodges measured RR detail under
|
||||
# `sap_room_in_roof.room_in_roof_details`: two gable walls
|
||||
|
|
|
|||
|
|
@ -265,6 +265,12 @@ _SECONDARY_HEATING_FRACTION_BY_CATEGORY: Final[dict[int, float]] = {
|
|||
1: 0.10,
|
||||
2: 0.10,
|
||||
3: 0.10,
|
||||
4: 0.00, # Heat pump: HP eff includes any secondary contribution
|
||||
# per SAP 10.2 Table 11 explicit footnote; supersedes the
|
||||
# 0.10 DEFAULT below which would erroneously bill 10% of
|
||||
# space-heating cost as secondary on HP certs that lodge
|
||||
# a secondary_heating_type code (cert 0380: 547 kWh @
|
||||
# 13.19 p/kWh = £72 vs worksheet £0).
|
||||
5: 0.10,
|
||||
6: 0.10,
|
||||
7: 0.15,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ evidence" rule in spec section 6.2.3.
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from enum import Enum
|
||||
from math import log, pi
|
||||
from typing import Final, Optional
|
||||
|
|
@ -125,9 +126,16 @@ WALL_UNKNOWN: Final[int] = 10
|
|||
# 5 = none specified (rare)
|
||||
# 6 = filled cavity + external insulation
|
||||
# 7 = filled cavity + internal insulation
|
||||
# Only the filled-cavity dispatch is wired here; the other codes will be
|
||||
# handled in subsequent slices.
|
||||
WALL_INSULATION_FILLED_CAVITY: Final[int] = 2
|
||||
WALL_INSULATION_CAVITY_PLUS_EXTERNAL: Final[int] = 6
|
||||
WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7
|
||||
|
||||
# RdSAP 10 §4-6 (page 73): default thermal conductivity of insulation when
|
||||
# no documentary evidence is available. Used to convert the lodged
|
||||
# `wall_insulation_thickness` (mm) into thermal resistance (m²K/W) via
|
||||
# R = (thickness_mm / 1000) / λ for composite wall U-value calc
|
||||
# (cavity + external/internal insulation).
|
||||
_WALL_INSULATION_LAMBDA_W_PER_MK: Final[float] = 0.04
|
||||
|
||||
|
||||
_AGE_BANDS: Final[tuple[str, ...]] = tuple("ABCDEFGHIJKLM")
|
||||
|
|
@ -353,6 +361,29 @@ def u_wall(
|
|||
wall_type = construction
|
||||
else:
|
||||
wall_type = _wall_type_from_description(description) or _DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY)
|
||||
if wall_type == WALL_CAVITY and wall_insulation_type in (
|
||||
WALL_INSULATION_CAVITY_PLUS_EXTERNAL,
|
||||
WALL_INSULATION_CAVITY_PLUS_INTERNAL,
|
||||
) and insulation_thickness_mm is not None and insulation_thickness_mm > 0:
|
||||
# RdSAP 10 §4-4 + §4-6 (page 73): composite "filled cavity plus
|
||||
# external/internal insulation" — U_total = 1 / (1/U_filled +
|
||||
# R_ins) where R_ins = (thickness_mm / 1000) / λ. λ defaults to
|
||||
# 0.04 W/m·K when no documentary evidence is lodged. Cert 0380
|
||||
# lodges code 6 + 100mm → U_filled (age D)=0.7 + R=2.5 →
|
||||
# U_total ≈ 0.2545 → rounded to 2 d.p. = 0.25 (worksheet).
|
||||
#
|
||||
# The 2-d.p. round matches dr87 / Elmhurst tool behaviour
|
||||
# (worksheet displays "0.2500" = 2-d.p. value padded to 4 d.p.
|
||||
# for column alignment). Cascade-internal HLC then uses the
|
||||
# rounded U so net wall HLC matches `A × U_2dp` exactly.
|
||||
u_filled = _CAVITY_FILLED_ENG[age_idx]
|
||||
r_ins = (insulation_thickness_mm / 1000.0) / _WALL_INSULATION_LAMBDA_W_PER_MK
|
||||
u_unrounded = 1.0 / (1.0 / u_filled + r_ins)
|
||||
# Half-up 2-d.p. round so 0.2545 → 0.25, matching the dr87
|
||||
# worksheet's column-display behaviour (used downstream in A×U).
|
||||
return float(
|
||||
Decimal(str(u_unrounded)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
)
|
||||
if wall_type == WALL_CAVITY and (
|
||||
wall_insulation_type == WALL_INSULATION_FILLED_CAVITY
|
||||
or _described_as_insulated(description)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue