mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
edce0f46af
commit
0a2ed67e94
4 changed files with 71 additions and 11 deletions
|
|
@ -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.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.modelling.products import Products, SolarCostInputs
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
|
|
@ -338,11 +338,29 @@ def recommend_solar(
|
|||
def _dwelling_roof_area_m2(epc: EpcPropertyData) -> Optional[float]:
|
||||
"""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
|
||||
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:
|
||||
return roof_area(epc, BuildingPartIdentifier.MAIN)
|
||||
footprint: float = ground_floor_area(epc, BuildingPartIdentifier.MAIN)
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -124,12 +124,16 @@ class SolarPotential:
|
|||
or "panelCapacityWatts" not in solar_potential
|
||||
):
|
||||
return None
|
||||
# Per-segment centre + area live on the top-level `roofSegmentStats`,
|
||||
# keyed by `segmentIndex`; the per-config `roofSegmentSummaries` carry
|
||||
# only the panel/orientation fields. Build the lookup once.
|
||||
# Per-segment centre + area live on the top-level `roofSegmentStats`;
|
||||
# the per-config `roofSegmentSummaries` carry only the panel/orientation
|
||||
# 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]] = {
|
||||
int(stats["segmentIndex"]): stats
|
||||
for stats in solar_potential.get("roofSegmentStats", [])
|
||||
index: stats
|
||||
for index, stats in enumerate(
|
||||
solar_potential.get("roofSegmentStats", [])
|
||||
)
|
||||
}
|
||||
|
||||
def _segment(summary: Mapping[str, Any]) -> SolarRoofSegment:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,14 @@ import json
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
BuildingPartIdentifier,
|
||||
EpcPropertyData,
|
||||
SapBuildingPart,
|
||||
SapFloorDimension,
|
||||
)
|
||||
from domain.modelling.generators.solar_recommendation import (
|
||||
_dwelling_roof_area_m2,
|
||||
select_conservative_configs,
|
||||
)
|
||||
from domain.modelling.solar_potential import (
|
||||
|
|
@ -20,6 +27,37 @@ from domain.modelling.solar_potential import (
|
|||
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 = (
|
||||
Path(__file__).resolve().parent
|
||||
/ "fixtures"
|
||||
|
|
|
|||
|
|
@ -122,9 +122,9 @@ def test_projection_enriches_segments_with_centre_and_area() -> None:
|
|||
# carry its centre + area — sourced from the top-level roofSegmentStats,
|
||||
# keyed by segmentIndex (the per-config roofSegmentSummaries omit them).
|
||||
insights = _insights()
|
||||
# roofSegmentStats are positional — segmentIndex refers to the array index.
|
||||
stats_by_index = {
|
||||
int(s["segmentIndex"]): s
|
||||
for s in insights["solarPotential"]["roofSegmentStats"]
|
||||
i: s for i, s in enumerate(insights["solarPotential"]["roofSegmentStats"])
|
||||
}
|
||||
|
||||
potential = SolarPotential.from_building_insights(insights)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue