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