File size: 6,603 Bytes
1b141db | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | 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
|