Harden Dwelling-Roof Cap on real data: positional segments, ground-floor basis 🟩

Three corrections found by re-running property 742003 end-to-end:
- roofSegmentStats are POSITIONAL — real responses omit the segmentIndex field
  the fixture happened to carry; key the centre/area lookup by array position.
- Base the cap on ground_floor_area (the footprint the roof covers), not the
  greatest per-storey area; roof_area is the fallback.
- Clamp the basis by total_floor_area: predicted EPCs borrow the structural
  template's geometry (742003: a 118.62 m² MAIN ground floor) decoupled from
  the predicted 55 m² (ADR-0029), so without the clamp the cap reads the
  template's larger footprint.

Result: 742003 plan A/92.4 (16 kWp) -> C/74.4 (6.4 kWp). 29 solar tests +
orchestration threading + products green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-26 12:24:52 +00:00
parent edce0f46af
commit 0a2ed67e94
4 changed files with 71 additions and 11 deletions

View file

@ -24,7 +24,7 @@ from datatypes.epc.domain.epc_property_data import (
) )
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
from domain.building_geometry import roof_area from domain.building_geometry import ground_floor_area, roof_area
from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.products import Products, SolarCostInputs from domain.modelling.products import Products, SolarCostInputs
from domain.modelling.measure_type import MeasureType from domain.modelling.measure_type import MeasureType
@ -338,11 +338,29 @@ def recommend_solar(
def _dwelling_roof_area_m2(epc: EpcPropertyData) -> Optional[float]: def _dwelling_roof_area_m2(epc: EpcPropertyData) -> Optional[float]:
"""The dwelling's own roof plan area for the Dwelling-Roof Cap (ADR-0038), """The dwelling's own roof plan area for the Dwelling-Roof Cap (ADR-0038),
or None when the EPC has no MAIN building part to measure (the cap then or None when the EPC has no MAIN building part to measure (the cap then
falls back to Google's `maxArrayPanelsCount` cap).""" falls back to Google's `maxArrayPanelsCount` cap).
The roof covers the **ground-floor footprint**, so the basis is
`ground_floor_area` (not the greatest per-storey area); `roof_area` is the
fallback when no ground (floor 0) dimension is lodged.
Clamped by total floor area: a footprint can never exceed the whole
dwelling's floor area (= footprint × storeys), so the clamp is a no-op on a
consistent lodged EPC. It matters for a **predicted** EPC, whose building-part
geometry is the structural template's — decoupled from the predicted floor
area (ADR-0029) so without it the cap would read the template neighbour's
(larger) footprint rather than the predicted dwelling's size."""
try: try:
return roof_area(epc, BuildingPartIdentifier.MAIN) footprint: float = ground_floor_area(epc, BuildingPartIdentifier.MAIN)
except StopIteration: except StopIteration:
return None try:
footprint = roof_area(epc, BuildingPartIdentifier.MAIN)
except StopIteration:
return None
total: Optional[float] = epc.total_floor_area_m2
if total is not None and total > 0.0:
return min(footprint, total)
return footprint
def _solar_eligible( def _solar_eligible(

View file

@ -124,12 +124,16 @@ class SolarPotential:
or "panelCapacityWatts" not in solar_potential or "panelCapacityWatts" not in solar_potential
): ):
return None return None
# Per-segment centre + area live on the top-level `roofSegmentStats`, # Per-segment centre + area live on the top-level `roofSegmentStats`;
# keyed by `segmentIndex`; the per-config `roofSegmentSummaries` carry # the per-config `roofSegmentSummaries` carry only the panel/orientation
# only the panel/orientation fields. Build the lookup once. # fields. `roofSegmentSummaries[].segmentIndex` refers to the POSITION in
# `roofSegmentStats` (the entries are positional — Google omits an
# explicit `segmentIndex` field on them), so key the lookup by position.
stats_by_index: dict[int, Mapping[str, Any]] = { stats_by_index: dict[int, Mapping[str, Any]] = {
int(stats["segmentIndex"]): stats index: stats
for stats in solar_potential.get("roofSegmentStats", []) for index, stats in enumerate(
solar_potential.get("roofSegmentStats", [])
)
} }
def _segment(summary: Mapping[str, Any]) -> SolarRoofSegment: def _segment(summary: Mapping[str, Any]) -> SolarRoofSegment:

View file

@ -11,7 +11,14 @@ import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
SapBuildingPart,
SapFloorDimension,
)
from domain.modelling.generators.solar_recommendation import ( from domain.modelling.generators.solar_recommendation import (
_dwelling_roof_area_m2,
select_conservative_configs, select_conservative_configs,
) )
from domain.modelling.solar_potential import ( from domain.modelling.solar_potential import (
@ -20,6 +27,37 @@ from domain.modelling.solar_potential import (
SolarRoofSegment, SolarRoofSegment,
) )
def _epc_with_roof(per_storey_areas: tuple[float, ...], total_floor_area: float) -> EpcPropertyData:
epc: EpcPropertyData = object.__new__(EpcPropertyData)
epc.total_floor_area_m2 = total_floor_area
part: SapBuildingPart = object.__new__(SapBuildingPart)
part.identifier = BuildingPartIdentifier.MAIN
dims: list[SapFloorDimension] = []
for floor, area in enumerate(per_storey_areas):
fd: SapFloorDimension = object.__new__(SapFloorDimension)
fd.floor = floor # 0 = ground
fd.total_floor_area_m2 = area
dims.append(fd)
part.sap_floor_dimensions = dims
epc.sap_building_parts = [part]
return epc
def test_dwelling_roof_basis_is_ground_floor_clamped_by_total_floor_area() -> None:
# ADR-0038/0029: the basis is the ground-floor footprint, clamped by total
# floor area. A predicted EPC's building-part geometry is the structural
# template's (here a 118 m² ground floor), decoupled from the predicted
# floor area (55 m²); a footprint can't exceed total floor area, so the cap
# basis clamps to 55 — not the borrowed template's 118.
predicted = _epc_with_roof(per_storey_areas=(118.0,), total_floor_area=55.0)
assert _dwelling_roof_area_m2(predicted) == 55.0
# A consistent 2-storey house — ground 55, upper 50, total 105 — uses the
# GROUND floor (55), not the greatest per-storey area, and the clamp is inert.
lodged = _epc_with_roof(per_storey_areas=(55.0, 50.0), total_floor_area=105.0)
assert _dwelling_roof_area_m2(lodged) == 55.0
_FIXTURE: Path = ( _FIXTURE: Path = (
Path(__file__).resolve().parent Path(__file__).resolve().parent
/ "fixtures" / "fixtures"

View file

@ -122,9 +122,9 @@ def test_projection_enriches_segments_with_centre_and_area() -> None:
# carry its centre + area — sourced from the top-level roofSegmentStats, # carry its centre + area — sourced from the top-level roofSegmentStats,
# keyed by segmentIndex (the per-config roofSegmentSummaries omit them). # keyed by segmentIndex (the per-config roofSegmentSummaries omit them).
insights = _insights() insights = _insights()
# roofSegmentStats are positional — segmentIndex refers to the array index.
stats_by_index = { stats_by_index = {
int(s["segmentIndex"]): s i: s for i, s in enumerate(insights["solarPotential"]["roofSegmentStats"])
for s in insights["solarPotential"]["roofSegmentStats"]
} }
potential = SolarPotential.from_building_insights(insights) potential = SolarPotential.from_building_insights(insights)