| 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 |
|
|