Eishaan's picture
Build site intelligence studio prototype
1b141db
Raw
History Blame Contribute Delete
6.6 kB
from __future__ import annotations
import json
import math
import uuid
from typing import Any
from .models import SiteSelection
EARTH_RADIUS_M = 6_371_008.8
def polygon_area_local(points: list[tuple[float, float]]) -> float:
pts = _closed(points)
if len(pts) < 4:
return 0.0
return abs(
sum(x1 * y2 - x2 * y1 for (x1, y1), (x2, y2) in zip(pts, pts[1:]))
) / 2
def polygon_perimeter_local(points: list[tuple[float, float]]) -> float:
pts = _closed(points)
return sum(math.dist(a, b) for a, b in zip(pts, pts[1:]))
def bbox_local(points: list[tuple[float, float]]) -> tuple[float, float, float, float]:
xs = [p[0] for p in points]
ys = [p[1] for p in points]
return min(xs), min(ys), max(xs), max(ys)
def centroid_local(points: list[tuple[float, float]]) -> tuple[float, float]:
if not points:
raise ValueError("Cannot compute centroid of empty point list")
pts = points[:-1] if len(points) > 1 and points[0] == points[-1] else points
return sum(x for x, _ in pts) / len(pts), sum(y for _, y in pts) / len(pts)
def calculate_site_metrics(geometry_geojson: dict[str, Any]) -> dict[str, Any]:
points_lonlat = _geojson_polygon_points(geometry_geojson)
if len(points_lonlat) < 4:
raise ValueError("A polygon needs at least three vertices")
lat0 = sum(lat for _, lat in points_lonlat) / len(points_lonlat)
projected = [_lonlat_to_local(lon, lat, lat0) for lon, lat in points_lonlat]
centroid_lon, centroid_lat = centroid_local(points_lonlat)
return {
"area_sqm": polygon_area_local(projected),
"perimeter_m": polygon_perimeter_local(projected),
"centroid": (centroid_lat, centroid_lon),
"bbox": bbox_local(points_lonlat),
}
def normalize_map_state(state: str | dict[str, Any]) -> SiteSelection:
parsed = json.loads(state) if isinstance(state, str) and state.strip() else state
if not isinstance(parsed, dict):
raise ValueError("Map state is missing. Draw, pin, or enter coordinates first.")
mode = parsed.get("mode")
if mode in {"polygon", "rectangle"}:
geometry = parsed.get("geometry")
if not isinstance(geometry, dict):
raise ValueError("Map polygon geometry is missing.")
metrics = calculate_site_metrics(geometry)
selection_type = "rectangle" if mode == "rectangle" else "drawn_polygon"
return SiteSelection(
id=f"S-{uuid.uuid4().hex[:8]}",
selection_type=selection_type,
coordinate_mode="wgs84",
geometry_geojson=geometry,
local_geometry=None,
anchor_lat=metrics["centroid"][0],
anchor_lon=metrics["centroid"][1],
radius_m=None,
area_sqm=metrics["area_sqm"],
perimeter_m=metrics["perimeter_m"],
centroid=metrics["centroid"],
bbox=metrics["bbox"],
unit_source="WGS84 drawn map",
accuracy_label="user-drawn approximate boundary",
limitations=[
"Drawn map boundaries are approximate.",
"Upload CAD, KML, or GeoJSON for better boundary accuracy.",
],
)
if mode == "pin_radius":
lat = _float(parsed.get("lat"))
lon = _float(parsed.get("lon"))
radius_m = _float(parsed.get("radius_m")) or 250.0
if lat is None or lon is None:
raise ValueError("Pin latitude/longitude is missing.")
area = math.pi * radius_m * radius_m
perimeter = 2 * math.pi * radius_m
return SiteSelection(
id=f"S-{uuid.uuid4().hex[:8]}",
selection_type="pin_radius",
coordinate_mode="wgs84",
geometry_geojson=None,
local_geometry=None,
anchor_lat=lat,
anchor_lon=lon,
radius_m=radius_m,
area_sqm=area,
perimeter_m=perimeter,
centroid=(lat, lon),
bbox=_bbox_from_radius(lat, lon, radius_m),
unit_source="meters",
accuracy_label="pin-radius approximation",
limitations=[
"This is approximate context analysis, not exact boundary analysis.",
"Use an uploaded or drawn boundary for plot-level analysis.",
],
)
raise ValueError("Unsupported map mode. Draw, pin, or enter coordinates first.")
def selection_from_lat_lon(lat: float, lon: float, radius_m: float = 250) -> SiteSelection:
return normalize_map_state(
{"mode": "pin_radius", "lat": lat, "lon": lon, "radius_m": radius_m}
)
def _float(value: Any) -> float | None:
try:
if value is None or value == "":
return None
return float(value)
except (TypeError, ValueError):
return None
def _closed(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
if not points:
return []
return points if points[0] == points[-1] else points + [points[0]]
def _geojson_polygon_points(geometry: dict[str, Any]) -> list[tuple[float, float]]:
if geometry.get("type") == "Feature":
geometry = geometry.get("geometry") or {}
if geometry.get("type") == "FeatureCollection":
features = geometry.get("features") or []
if not features:
raise ValueError("GeoJSON FeatureCollection has no features")
geometry = features[0].get("geometry") or {}
if geometry.get("type") == "MultiPolygon":
coords = geometry.get("coordinates") or []
if not coords:
raise ValueError("GeoJSON MultiPolygon has no coordinates")
ring = coords[0][0]
elif geometry.get("type") == "Polygon":
coords = geometry.get("coordinates") or []
if not coords:
raise ValueError("GeoJSON Polygon has no coordinates")
ring = coords[0]
else:
raise ValueError("Only Polygon and MultiPolygon GeoJSON are supported")
return [(float(lon), float(lat)) for lon, lat in ring]
def _lonlat_to_local(lon: float, lat: float, lat0: float) -> tuple[float, float]:
x = math.radians(lon) * EARTH_RADIUS_M * math.cos(math.radians(lat0))
y = math.radians(lat) * EARTH_RADIUS_M
return x, y
def _bbox_from_radius(lat: float, lon: float, radius_m: float) -> tuple[float, float, float, float]:
delta_lat = math.degrees(radius_m / EARTH_RADIUS_M)
cos_lat = max(math.cos(math.radians(lat)), 0.01)
delta_lon = math.degrees(radius_m / (EARTH_RADIUS_M * cos_lat))
return lon - delta_lon, lat - delta_lat, lon + delta_lon, lat + delta_lat