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.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(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue