Slice S0380.75: Wire Appendix H orchestrator into cascade; cert 000565 HW +272 → −69

Per SAP 10.2 §4 line (64)m: `(64)m = max(0, (62)m + (63a)m + (63b)m
+ (63c)m + (63d)m)` where (63c)m is the solar HW credit lodged as a
negative quantity. The cascade hardcoded (63c)m = 0 since S0380.66
when the Appendix H orchestrator landed without integration, pending
the 1.81× over-count resolution (closed in S0380.74).

This slice plumbs the orchestrator into `water_heating_from_cert`
via a new `solar_water_heating_monthly_kwh_override` parameter, and
adds `_solar_hw_monthly_override` in cert_to_inputs.py that drives
the orchestrator from RdSAP 10 §10.11 Table 29 defaults +
cert-lodged collector geometry on Elmhurst Summary §16.0.

RdSAP 10 §10.11 Table 29 row "Solar panel" (p.58, verbatim):
  "If solar panel present, the parameters for the calculation not
   provided in the RdSAP data set are:
   - panel aperture area 3 m²
   - flat panel, η₀ = 0.80, a₁ = 4.0, a₂ = 0.01
   - facing South, pitch 30°, modest overshading
   - …
   - pump for solar-heated water is electric (75 kWh/year)
   - showers are both electric and non-electric"

Lodged collector orientation / pitch / overshading on the Summary
§16.0 ("Are details known? Yes" branch) override South / 30° /
Modest. Aperture, η₀, a₁, a₂, IAM stay at Table 29 defaults — the
deeper thermal parameter lodgement (P960 worksheet) isn't yet in
the Summary extractor surface.

For (H17)m to include storage + primary + combi losses, the cascade
runs a `demand_pass` call without solar (gets (62)m) before sizing
the solar credit. The final call then uses all overrides.

Files:
- datatypes/epc/surveys/elmhurst_site_notes.py: Renewables gains
  `solar_hw_collector_orientation` / `_pitch_deg` / `_overshading`
  optional fields.
- datatypes/epc/domain/epc_property_data.py: same three fields
  added at the end of the dataclass.
- datatypes/epc/domain/mapper.py: from_elmhurst_site_notes
  propagates the three new fields.
- backend/documents_parser/elmhurst_extractor.py: §16.0 section
  parsing reads "Collector orientation" / "Collector elevation" /
  "Overshading" rows; `_parse_solar_pitch_deg` strips the degree
  glyph.
- domain/sap10_calculator/worksheet/water_heating.py: new
  `solar_water_heating_monthly_kwh_override` param on
  `water_heating_from_cert`; threaded into `output_from_water_
  heater_monthly_kwh(solar_monthly_kwh=...)`.
- domain/sap10_calculator/rdsap/cert_to_inputs.py: Table 29
  constants + `_solar_hw_monthly_override` helper +
  `_orientation_from_summary_string` mapper. Added the demand_pass
  intermediate call so (H17)m sees the full (62)m. Negates the
  orchestrator output at the boundary (spec convention: heat
  displaced from boiler is negative on line (63c)m).

Cert 000565 cascade pin shifts:
- hot_water_kwh_per_yr: +271.84 → −68.96 (4× closer)
- sap_score_continuous: +0.6334 → +0.7732 (drift downstream of HW)
- ecf: −0.0643 → −0.0784 (drift)
- total_fuel_cost: −56.08 → −68.36 (drift)
- co2: −19.77 → −22.66 (drift)
- sap_score (int): 29 EXACT (unchanged)
- space_heating / main_heating_fuel / lighting / pumps_fans:
  unchanged

The remaining −69 kWh HW residual is the gap between Table 29
defaults (H12 = 75 L separate tank) and cert 000565's lodged H12 =
53 L + combined cylinder 160 L. Closing this requires extracting
solar storage volume + combined-cylinder routing from the cert (P960
worksheet block lodges these explicitly; Summary doesn't). That's
the follow-on slice.

Test baseline: 547 pass + 9 expected `test_sap_result_pin[000565-*]`
fails preserved. Cohort-2 + ASHP cohort + all golden fixtures
untouched (no certs other than 000565 lodge `solar_water_heating =
True`).

Pyright net-zero on touched files (68 errors at baseline = 68 errors
post-change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-29 18:37:56 +00:00 committed by Jun-te Kim
parent 968c53e299
commit 2adff08210
6 changed files with 219 additions and 1 deletions

View file

@ -29,6 +29,15 @@ from datatypes.epc.surveys.elmhurst_site_notes import (
)
def _parse_solar_pitch_deg(raw: Optional[str]) -> Optional[int]:
"""Parse the §16.0 "Collector elevation" lodgement (e.g. "30°", "60°",
or a bare integer). Returns None when absent or unparseable."""
if not raw:
return None
m = re.search(r"(\d+)", raw)
return int(m.group(1)) if m else None
class ElmhurstSiteNotesExtractor:
def __init__(self, pages: List[str]) -> None:
self._text = "\n".join(pages)
@ -1279,6 +1288,22 @@ class ElmhurstSiteNotesExtractor:
# None so downstream can distinguish "no PV" from "PV via %
# roof area path".
pv_pct = self._int_val("Proportion of roof area")
# Solar HW collector geometry — Summary §16.0. Only populated
# when the cert lodges "Are details known? Yes" in the solar
# block. Cert 000565 lodges West / 30° / Modest. When absent
# (cert says no, or no solar HW at all) → None and the cascade
# falls back to RdSAP 10 §10.11 Table 29 defaults (South / 30°
# / Modest).
solar_lines = self._section_lines(
"16.0 Solar water heating",
"17.0 Waste Water Heat Recovery System",
)
solar_orientation = self._local_val(
solar_lines, "Collector orientation",
)
solar_pitch_raw = self._local_val(solar_lines, "Collector elevation")
solar_pitch = _parse_solar_pitch_deg(solar_pitch_raw)
solar_overshading = self._local_val(solar_lines, "Overshading")
return Renewables(
solar_water_heating=self._bool_val("Solar Water Heating"),
wwhrs_present=self._bool_val("Is WWHRS present in the property?"),
@ -1290,6 +1315,9 @@ class ElmhurstSiteNotesExtractor:
hydro_electricity_generated_kwh=hydro,
pv_arrays=self._extract_pv_arrays(),
pv_percent_roof_area=pv_pct if pv_pct > 0 else None,
solar_hw_collector_orientation=solar_orientation,
solar_hw_collector_pitch_deg=solar_pitch,
solar_hw_overshading=solar_overshading,
)
def _extract_pv_arrays(self) -> List[ElmhurstPvArray]:

View file

@ -634,3 +634,12 @@ class EpcPropertyData:
waste_water_heat_recovery: Optional[str] = None
hydro: Optional[bool] = None
photovoltaic_array: Optional[bool] = None
# Solar HW collector geometry lodged in Summary §16.0 when
# "Are details known? Yes". Optional — when absent (cert lodges
# no detail, or no solar HW), the Appendix H cascade falls back
# to RdSAP 10 §10.11 Table 29 defaults (South / 30° / Modest).
# Orientation strings: "North"..."NW" (the compass names used in
# the Elmhurst Summary).
solar_hw_collector_orientation: Optional[str] = None
solar_hw_collector_pitch_deg: Optional[int] = None
solar_hw_overshading: Optional[str] = None

View file

@ -359,6 +359,9 @@ class EpcPropertyDataMapper:
survey, is_flat=property_type.lower() == "flat",
),
solar_water_heating=survey.renewables.solar_water_heating,
solar_hw_collector_orientation=survey.renewables.solar_hw_collector_orientation,
solar_hw_collector_pitch_deg=survey.renewables.solar_hw_collector_pitch_deg,
solar_hw_overshading=survey.renewables.solar_hw_overshading,
has_hot_water_cylinder=survey.water_heating.hot_water_cylinder_present,
has_fixed_air_conditioning=survey.ventilation.fixed_space_cooling,
wet_rooms_count=0,

View file

@ -315,6 +315,15 @@ class Renewables:
# = 40". The cascade then synthesizes a single PV array with
# kWp = 0.12 × PV area, defaulting to South / 30° / Modest.
pv_percent_roof_area: Optional[int] = None
# Solar HW collector lodgement (Summary §16.0). Populated only
# when the cert lodges "Are details known? Yes" — the cert can
# carry orientation / pitch / overshading without the deeper
# thermal parameters (η₀, a₁, a₂) which fall back to RdSAP 10
# §10.11 Table 29 defaults. Cert 000565 lodges West / 30° /
# Modest in this block.
solar_hw_collector_orientation: Optional[str] = None
solar_hw_collector_pitch_deg: Optional[int] = None
solar_hw_overshading: Optional[str] = None
@dataclass

View file

@ -123,11 +123,15 @@ from domain.sap10_calculator.worksheet.internal_gains import (
)
from domain.sap10_calculator.worksheet.solar_gains import (
ORIENTATION_BY_SAP10_CODE,
Orientation,
RoofWindowInput,
SolarGainsResult,
solar_gains_from_cert,
surface_solar_flux_w_per_m2,
)
from domain.sap10_calculator.worksheet.appendix_h_solar import (
solar_water_heating_input_monthly_kwh,
)
from domain.sap10_calculator.worksheet.heat_transmission import (
DwellingExposure,
HeatTransmission,
@ -2975,6 +2979,140 @@ def _primary_loss_applies(
return main.main_heating_category in {1, 2}
# RdSAP 10 §10.11 Table 29 "Heating and hot water parameters" row
# "Solar panel" (p.58) — the spec defaults to use when the cert
# lodges "Solar collector details known: No". Verbatim:
#
# "If solar panel present, the parameters for the calculation not
# provided in the RdSAP data set are:
# - panel aperture area 3 m²
# - flat panel, η₀ = 0.80, a₁ = 4.0, a₂ = 0.01
# - facing South, pitch 30°, modest overshading
# - …
# - pump for solar-heated water is electric (75 kWh/year)
# - showers are both electric and non-electric"
#
# Lodged collector orientation / pitch / overshading on the Summary
# §16.0 (when "Are details known? Yes") override the South / 30° /
# Modest defaults. The remaining parameters (aperture, η₀, a₁, a₂)
# always take the Table 29 default unless a separate SAP-style
# detailed lodgement is present (not exposed by the Summary today;
# follow-on slice when the P960 detail extraction lands).
_TABLE_29_APERTURE_M2: Final[float] = 3.0
_TABLE_29_ETA_0: Final[float] = 0.8
_TABLE_29_A1: Final[float] = 4.0
_TABLE_29_A2: Final[float] = 0.01
_TABLE_29_LOOP_EFF: Final[float] = 0.9
_TABLE_29_IAM_FLAT_PLATE: Final[float] = 0.94
_TABLE_29_DEDICATED_SOLAR_STORAGE_L: Final[float] = 75.0
_TABLE_29_DEFAULT_ORIENTATION: Final[Orientation] = Orientation.S
_TABLE_29_DEFAULT_PITCH_DEG: Final[float] = 30.0
# SAP 10.2 Table H2 (p.78) — overshading factor (H8). RdSAP uses the
# string lodgement on Summary §16.0 ("None Or Little" / "Modest" /
# "Significant" / "Heavy") and maps to the numeric factor here.
_TABLE_H2_OVERSHADING_FACTOR: Final[dict[str, float]] = {
"None Or Little": 1.0,
"Modest": 0.8,
"Significant": 0.65,
"Heavy": 0.5,
}
# SAP 10.2 Appendix U §U3.1 (p.124) Table U1 — monthly average external
# air temperature for region 0 (UK average, Block 1 SAP rating). Used
# by Appendix H (H20)m/(H21)m. The demand-cascade uses postcode-PCDB
# climate instead; this constant is only the SAP-rating fallback.
_APPENDIX_U_REGION_0_EXT_TEMP_C: Final[tuple[float, ...]] = (
4.3, 4.9, 6.5, 8.9, 11.7, 14.6, 16.6, 16.4, 14.1, 10.6, 7.1, 4.2,
)
def _solar_hw_monthly_override(
*,
epc: EpcPropertyData,
hw_demand_monthly_kwh: tuple[float, ...],
) -> Optional[tuple[float, ...]]:
"""SAP 10.2 Appendix H — (63c)m / (H24)m solar HW contribution.
Returns None when the cert doesn't lodge solar HW; otherwise calls
the Appendix H orchestrator with RdSAP 10 §10.11 Table 29 defaults
for the parameters the Summary doesn't carry (aperture, η₀, a₁,
a₂, loop efficiency, IAM, dedicated solar storage) and the cert-
lodged collector orientation / pitch / overshading. Falls back to
South / 30° / Modest when the Summary doesn't lodge those either.
Block 1 SAP rating uses region 0 (UK average) per Appendix U §U3.1;
the demand cascade's postcode-climate override is wired in a
follow-on slice.
"""
if not epc.solar_water_heating:
return None
orientation = _orientation_from_summary_string(
epc.solar_hw_collector_orientation
) or _TABLE_29_DEFAULT_ORIENTATION
pitch_deg = (
float(epc.solar_hw_collector_pitch_deg)
if epc.solar_hw_collector_pitch_deg is not None
else _TABLE_29_DEFAULT_PITCH_DEG
)
overshading = _TABLE_H2_OVERSHADING_FACTOR.get(
epc.solar_hw_overshading or "Modest",
_TABLE_H2_OVERSHADING_FACTOR["Modest"],
)
h24_kwh_positive = solar_water_heating_input_monthly_kwh(
collector_orientation=orientation,
collector_pitch_deg=pitch_deg,
region=0,
aperture_area_m2=_TABLE_29_APERTURE_M2,
zero_loss_efficiency=_TABLE_29_ETA_0,
linear_heat_loss_a1=_TABLE_29_A1,
second_order_heat_loss_a2=_TABLE_29_A2,
loop_efficiency=_TABLE_29_LOOP_EFF,
incidence_angle_modifier=_TABLE_29_IAM_FLAT_PLATE,
overshading_factor=overshading,
dedicated_solar_storage_volume_l=_TABLE_29_DEDICATED_SOLAR_STORAGE_L,
combined_cylinder_total_volume_l=None,
hot_water_demand_monthly_kwh=hw_demand_monthly_kwh,
wwhrs_monthly_kwh=(0.0,) * 12,
cold_water_temperatures_monthly_c=TABLE_J1_TCOLD_FROM_MAINS_C,
external_temperatures_monthly_c=_APPENDIX_U_REGION_0_EXT_TEMP_C,
solar_hot_water_only=True,
)
# SAP 10.2 §4 line (64)m sign convention: heat displaced from the
# boiler is entered NEGATIVE (so the line sums to delivered HW).
# The Appendix H orchestrator returns positive (H24)m kWh of solar
# contribution; negate at the boundary.
return tuple(-v for v in h24_kwh_positive)
# Compass strings as lodged on the Summary §16.0 "Collector orientation"
# row. SAP 10.2 §6 ORIENTATION_BY_SAP10_CODE indexes by integer code;
# this dict maps the surveyor-typed strings.
_SUMMARY_ORIENTATION_BY_STRING: Final[dict[str, Orientation]] = {
"North": Orientation.N,
"North East": Orientation.NE,
"NE": Orientation.NE,
"East": Orientation.E,
"South East": Orientation.SE,
"SE": Orientation.SE,
"South": Orientation.S,
"South West": Orientation.SW,
"SW": Orientation.SW,
"West": Orientation.W,
"North West": Orientation.NW,
"NW": Orientation.NW,
}
def _orientation_from_summary_string(raw: Optional[str]) -> Optional[Orientation]:
"""Look up a §16.0 / §19.0 compass-string lodgement against
`_SUMMARY_ORIENTATION_BY_STRING`. Returns None when absent.
"""
if raw is None:
return None
return _SUMMARY_ORIENTATION_BY_STRING.get(raw)
def _table_3a_combi_loss_default_applies(main: Optional[MainHeatingDetail]) -> bool:
"""Gate for the Table 3a keep-hot 600 kWh/yr fall-through per SAP 10.2
§4 line 7702. Returns True only when the main heating system is in the
@ -3039,6 +3177,30 @@ def _water_heating_worksheet_and_gains(
# (59)m. Only fires for indirect cylinders; HPs with integral
# vessels and combi boilers are in the spec's zero list.
primary_loss_override = _primary_loss_override(epc, main, primary_age)
# SAP 10.2 Appendix H — solar HW contribution (63c)m. Only fires
# when the cert lodges solar HW; orchestrator drives off lodged
# collector geometry + RdSAP 10 §10.11 Table 29 defaults for
# parameters the Summary doesn't carry (aperture, η₀, a₁, a₂,
# IAM, storage). See `_solar_hw_monthly_override` for the spec
# breakdown. The orchestrator's (H17)m = (62)m must include the
# storage / primary / combi losses, so we re-run the cascade
# *without* solar to land (62)m before sizing the solar credit.
demand_pass = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
has_bath=_has_bath_from_cert(epc),
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
combi_loss_monthly_kwh_override=combi_loss_override,
solar_storage_monthly_kwh_override=storage_loss_override,
primary_loss_monthly_kwh_override=primary_loss_override,
has_electric_shower=has_electric_shower,
electric_shower_count=electric_shower_count,
)
solar_hw_override = _solar_hw_monthly_override(
epc=epc,
hw_demand_monthly_kwh=demand_pass.total_demand_monthly_kwh,
)
wh_result = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
@ -3048,6 +3210,7 @@ def _water_heating_worksheet_and_gains(
combi_loss_monthly_kwh_override=combi_loss_override,
solar_storage_monthly_kwh_override=storage_loss_override,
primary_loss_monthly_kwh_override=primary_loss_override,
solar_water_heating_monthly_kwh_override=solar_hw_override,
has_electric_shower=has_electric_shower,
electric_shower_count=electric_shower_count,
)

View file

@ -839,6 +839,7 @@ def water_heating_from_cert(
combi_loss_monthly_kwh_override: Optional[tuple[float, ...]] = None,
solar_storage_monthly_kwh_override: Optional[tuple[float, ...]] = None,
primary_loss_monthly_kwh_override: Optional[tuple[float, ...]] = None,
solar_water_heating_monthly_kwh_override: Optional[tuple[float, ...]] = None,
electric_shower_monthly_kwh_override: Optional[tuple[float, ...]] = None,
has_electric_shower: bool = False,
electric_shower_count: int = 0,
@ -936,11 +937,16 @@ def water_heating_from_cert(
primary_loss_monthly_kwh=primary_loss,
combi_loss_monthly_kwh=combi,
)
solar_hw = (
solar_water_heating_monthly_kwh_override
if solar_water_heating_monthly_kwh_override is not None
else zero12
)
output = output_from_water_heater_monthly_kwh(
total_demand_monthly_kwh=total_demand,
wwhrs_monthly_kwh=zero12,
pv_diverter_monthly_kwh=zero12,
solar_monthly_kwh=zero12,
solar_monthly_kwh=solar_hw,
fghrs_monthly_kwh=zero12,
)
if electric_shower_monthly_kwh_override is not None: