mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
968c53e299
commit
2adff08210
6 changed files with 219 additions and 1 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue