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