feat(conservatory): §6.1 solar gains + TFA-occupancy (demand-side)

Close the §6.1 conservatory demand cascade per RdSAP 10 §6.1 + Table 25.

Solar gains (§6, solar_gains.py) — Table 25 note (PDF p.51): "The
orientation of windows in a conservatory is not recorded, thus solar
gains are calculated using the default solar flux (East/West orientation,
with 20° pitch for roof windows)." The glazed wall bills onto the (76)
East line (vertical, average-overshading Z); the glazed roof onto the
(82) roof-window line (20° pitch, Z=1.0), both at Table 25 g=0.76, FF=0.70.

TFA-occupancy (mapper) — §6.1: the conservatory floor area is added to the
dwelling total floor area. TFA drives occupancy → §5 internal gains + §4
hot-water demand, so the non-separated conservatory's floor area now
enters `EpcPropertyData.total_floor_area_m2` (the worksheet's (4) = 95.38
carries it). Separated conservatories (§6.2) stay excluded.

Pinned against the case-44 P960 demand cascade at abs=1e-4: (73) internal
gains 625.1759, (83) solar gains 495.8655, (95) useful gains 1079.6510,
(99) space heating per m² 89.8073 — the full §6.1 chain reproduces EXACTLY.

The whole-dwelling SAP (72.9517) / CO2 (3241.8656) are not pinned: the
case-44 Summary omits the House-Coal secondary heater (SAP 633) the P960
descriptor carries (cf. case 43), so the cascade computes no secondary —
the entire residual (+349.77 kg CO2). A Summary-input defect, independent
of §6.1; every conservatory-affected line ref is exact. Worksheet harness
stays 47/47 0-raised; corpus unchanged (API path; mirror is the next slice).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-16 23:21:08 +00:00
parent fe3bf4eaed
commit 2a4d67e396
4 changed files with 127 additions and 0 deletions

View file

@ -416,6 +416,18 @@ class EpcPropertyDataMapper:
ext.room_in_roof.floor_area_m2
for ext in survey.extensions
if ext.room_in_roof is not None
)
# RdSAP 10 §6.1 (PDF p.49) — a non-separated conservatory's
# floor area is added to the dwelling total floor area. TFA
# drives occupancy → §5 internal gains + §4 hot-water demand,
# so it must include the conservatory (the worksheet's (4) =
# 95.38 carries it). Separated conservatories (§6.2) are
# disregarded.
+ (
survey.conservatory.floor_area_m2
if survey.conservatory is not None
and not survey.conservatory.thermally_separated
else 0.0
),
2,
),

View file

@ -36,6 +36,10 @@ from math import cos, radians, sin
from typing import Final
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow
from domain.sap10_calculator.worksheet.conservatory import (
CONSERVATORY_ROOF_PITCH_DEG,
conservatory_geometry,
)
from domain.sap10_calculator.tables.pcdb.postcode_weather import PostcodeClimate
from domain.sap10_calculator.climate.appendix_u import (
horizontal_solar_irradiance_w_per_m2,
@ -435,6 +439,48 @@ def solar_gains_from_cert(
o: _sum_tuples(*per_orientation[o]) for o in Orientation
}
# RdSAP 10 §6.1 (PDF p.49) + Table 25 note (p.51): "The orientation of
# windows in a conservatory is not recorded, thus solar gains are
# calculated using the default solar flux (East/West orientation, with
# 20° pitch for roof windows)." Average overshading per §7 (Table 6d).
# The glazed wall bills onto the (76) East line (vertical, Z=z_vertical);
# the glazed roof onto the (82) roof-window line (20° pitch, Z=1.0).
cons = conservatory_geometry(epc)
if cons is not None:
cons_wall_monthly = tuple(
window_solar_gain_w(
area_m2=cons.glazed_wall_area_m2,
surface_flux_w_per_m2=surface_solar_flux_w_per_m2(
orientation=Orientation.E, pitch_deg=90.0,
region=region, month=m,
),
g_perpendicular=cons.g_value,
frame_factor=cons.frame_factor,
overshading_factor=z_vertical,
)
for m in _MONTHS
)
cons_roof_monthly = tuple(
window_solar_gain_w(
area_m2=cons.glazed_roof_area_m2,
surface_flux_w_per_m2=surface_solar_flux_w_per_m2(
orientation=Orientation.E,
pitch_deg=CONSERVATORY_ROOF_PITCH_DEG,
region=region, month=m,
),
g_perpendicular=cons.g_value,
frame_factor=cons.frame_factor,
overshading_factor=_HORIZONTAL_Z,
)
for m in _MONTHS
)
per_orientation_summed[Orientation.E] = _sum_tuples(
per_orientation_summed[Orientation.E], cons_wall_monthly,
)
roof_windows_monthly = _sum_tuples(
roof_windows_monthly, cons_roof_monthly,
)
total = _sum_tuples(
*per_orientation_summed.values(),
roof_windows_monthly,

View file

@ -75,6 +75,31 @@ LINE_31_EXTERNAL_AREA_M2: Final[float] = 294.2900
LINE_33_FABRIC_W_PER_K: Final[float] = 207.3274
LINE_36_THERMAL_BRIDGING_W_PER_K: Final[float] = 23.5432
# Demand-side line refs (Jan column, UK-average rating block). These
# integrate the WHOLE §6.1 conservatory chain end-to-end:
# - (73) internal gains — the conservatory floor area enters TFA (4),
# which drives occupancy → §5 appliance/cooking/metabolic gains;
# - (83) solar gains — the glazed wall (E/W flux, 90° pitch) + glazed
# roof (E/W flux, 20° pitch) at Table 25 g=0.76, FF=0.70;
# - (95) useful gains = (84) total gains × the §7 utilisation factor —
# matches only when fabric (33), ventilation (38) AND gains (84) all
# agree, so it is the single tightest end-to-end conservatory pin;
# - (99) space heating per m² = (98c)/(4) — the integrated demand.
LINE_73_INTERNAL_GAINS_JAN_W: Final[float] = 625.1759
LINE_83_SOLAR_GAINS_JAN_W: Final[float] = 495.8655
LINE_95_USEFUL_GAINS_JAN_W: Final[float] = 1079.6510
LINE_99_SPACE_HEATING_PER_M2_KWH: Final[float] = 89.8073
# NB — the full SAP value (72.9517) + (272) CO2 (3241.8656) are NOT pinned
# here. The case-44 Summary PDF omits the House-Coal secondary heater
# (SAP 633, 60% eff) that the P960 worksheet's descriptor block carries
# (the same secondary as case 43); routed through the extractor the
# Summary therefore yields NO secondary system, and the residual SAP/CO2
# gap is exactly that missing secondary (main+secondary CO2 1927.31 +
# 563.92 = 2491.23 vs cascade 2141.46 → +349.77 ≈ the 350 kg deficit).
# This is a Summary-input defect, independent of §6.1 — every
# conservatory-affected line ref above reproduces the P960 EXACTLY.
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
"""Convert a Summary PDF into the per-page text format the

View file

@ -427,6 +427,50 @@ def test_case44_non_separated_conservatory_fabric_matches_pdf() -> None:
)
def test_case44_conservatory_demand_side_matches_pdf() -> None:
"""End-to-end §6.1 conservatory demand pin for simulated case 44.
Beyond the §3 fabric, the conservatory ripples through the demand
cascade: its floor area enters TFA (4) occupancy §5 internal
gains (73); its glazing contributes §6 solar gains (83) at the
default E/W flux (Table 25 g=0.76, FF=0.70, 20° roof pitch); fabric
+ ventilation + gains combine into the §7 useful gains (95) and the
space-heating demand (99). Every line ref reproduces the P960 to 1e-4.
The full SAP/CO2 is NOT asserted: the case-44 Summary omits the
House-Coal secondary heater the P960 carries (see the provider's NB) —
a Summary-input defect downstream of, and independent of, §6.1."""
# Arrange
epc = _w001431_case44.build_epc()
# Act
ig = internal_gains_section_from_cert(epc)
sg = solar_gains_section_from_cert(epc)
sh = space_heating_section_from_cert(epc)
assert ig is not None # TFA present ⇒ §5 helper returns a result
# Assert — §5/§6/§7 demand line refs, each at abs=1e-4.
_pin(
ig.total_internal_gains_monthly_w[0],
_w001431_case44.LINE_73_INTERNAL_GAINS_JAN_W,
"§5 (73) case44",
)
_pin(
sg.total_solar_gains_monthly_w[0],
_w001431_case44.LINE_83_SOLAR_GAINS_JAN_W,
"§6 (83) case44",
)
_pin(
sh.useful_gains_monthly_w[0],
_w001431_case44.LINE_95_USEFUL_GAINS_JAN_W,
"§7 (95) case44",
)
_pin(
sh.space_heating_per_m2_kwh,
_w001431_case44.LINE_99_SPACE_HEATING_PER_M2_KWH,
"§7 (99) case44",
)
def test_case44_blower_door_pressure_test_matches_pdf() -> None:
"""Simulated case 44 lodges a Blower Door air-pressure test
(§12.2 "Pressure Test Result (AP50) 4.50"). SAP 10.2 §2 (17)-(18):