File size: 13,694 Bytes
257fce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e350ba
257fce1
 
 
 
 
 
 
 
c9f28a3
257fce1
 
7e350ba
 
 
 
 
 
 
 
 
 
 
257fce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e350ba
257fce1
 
c9f28a3
 
 
 
 
 
257fce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e350ba
 
 
 
 
 
 
257fce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e350ba
257fce1
 
 
 
 
 
 
 
 
 
 
 
 
c9f28a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257fce1
cac8f85
257fce1
 
 
 
 
c9f28a3
 
 
 
 
 
 
 
 
257fce1
 
c9f28a3
 
 
 
 
 
 
 
 
 
 
257fce1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9f28a3
 
 
 
 
 
 
 
 
 
 
257fce1
 
c9f28a3
 
 
 
257fce1
c9f28a3
257fce1
 
 
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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
"""
REST API layer for the Farm Layout Model.

Why this exists:
    The underlying engine (`design_api.process_farm_design`) only accepts a
    GeoJSON FeatureCollection string. Callers prefer a flatter, intent-driven
    schema:

        { farm, plots[], water_sources[] }

    This module owns the translation between that caller schema and the
    internal GeoJSON representation, and exposes a single one-shot endpoint
    ``POST /api/v1/design`` (plus a tiny health probe).

The Gradio UI is unaffected β€” `app.py` mounts this router alongside the
existing Gradio Blocks app.
"""
from __future__ import annotations

import json
from enum import Enum
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from shapely.geometry import MultiPolygon, Polygon
from shapely.ops import unary_union

from design_api import process_farm_design
from unit_converter import m2_to_area, area_unit_label, supported_area_units, UnitError


# ──────────────────────────────────────────────────────────────────────────────
# Enums
# ──────────────────────────────────────────────────────────────────────────────


class DesignType(str, Enum):
    """Irrigation design type: centralized (valves at pump) or distributed (valves at zones)."""
    CENTRALIZED = "centralized"
    DISTRIBUTED = "distributed"


# ──────────────────────────────────────────────────────────────────────────────
# Request models β€” mirror the caller's schema 1:1 so docs/422s read naturally.
# ──────────────────────────────────────────────────────────────────────────────


class LatLon(BaseModel):
    latitude: float
    longitude: float


class FarmLocation(BaseModel):
    address: Optional[str] = None
    latitude: float
    longitude: float


class FarmSize(BaseModel):
    value: float
    unit: str


class Farm(BaseModel):
    name: str
    location: FarmLocation
    size: Optional[FarmSize] = None
    crop_name: str


class Plot(BaseModel):
    plot_id: str
    name: Optional[str] = None
    area_sq_m: Optional[float] = None
    centroid: Optional[LatLon] = None
    boundaries: List[LatLon] = Field(..., min_length=3)


class WaterSource(BaseModel):
    water_source_id: str
    type: Optional[str] = None
    name: Optional[str] = None
    location: LatLon
    plot_id: Optional[str] = None


class DesignRequest(BaseModel):
    farm: Farm
    plots: List[Plot] = Field(..., min_length=1)
    water_sources: List[WaterSource] = Field(..., min_length=1)
    # Engine-tuning knobs are optional with sensible defaults so callers can
    # ignore them entirely.
    pump_hp: float = 5.0
    design_type: Optional[DesignType] = None  # If None, will default based on farm size
    headland_buffer_m: float = 1.0
    override_lateral_spacing_m: Optional[float] = None
    area_unit: str = Field(
        default="ha",
        description="Unit for area values in the response. "
        "Accepts: ha, acres, m2, bigha, guntha, cent, and 20+ others. "
        "See GET /rest/v1/units for the full list.",
    )


# ──────────────────────────────────────────────────────────────────────────────
# Caller-schema  β†’  GeoJSON FeatureCollection
# ──────────────────────────────────────────────────────────────────────────────


def _ring_lonlat(boundary: List[LatLon]) -> List[List[float]]:
    """Convert {lat, lon} points to a closed [lon, lat] ring (RFC 7946 order)."""
    ring = [[p.longitude, p.latitude] for p in boundary]
    if ring[0] != ring[-1]:
        ring.append(ring[0])
    return ring


def _farm_boundary_polygon(plots: List[Plot]) -> Polygon:
    """
    Build a single Polygon that bounds every plot.

    `geojson_io.extract_farm_boundary` only accepts a Polygon. If the plots
    don't overlap, the union is a MultiPolygon β€” fall back to its convex hull
    so we still hand the engine something sane.
    """
    polys = [Polygon(_ring_lonlat(p.boundaries)) for p in plots]
    merged = unary_union(polys)
    if isinstance(merged, MultiPolygon):
        merged = merged.convex_hull
    if not isinstance(merged, Polygon):
        # Degenerate input (e.g. all collinear points) β€” fail fast with a
        # readable error instead of letting shapely surface a cryptic one.
        raise ValueError("Could not derive a single farm boundary polygon from plots")
    return merged


def _polygon_to_coords(poly: Polygon) -> List[List[List[float]]]:
    """Shapely Polygon β†’ GeoJSON Polygon coordinates (single outer ring)."""
    return [[list(xy) for xy in poly.exterior.coords]]


def to_geojson_feature_collection(req: DesignRequest) -> str:
    """
    Translate a `DesignRequest` into the GeoJSON FeatureCollection shape that
    `process_farm_design` understands. Returns the serialized JSON string the
    engine expects.
    """
    crop = (req.farm.crop_name or "generic").strip().lower() or "generic"
    farm_boundary = _farm_boundary_polygon(req.plots)
    
    # Resolve design_type: explicit override or derive from farm size
    design_type = req.design_type
    if design_type is None:
        # Default: centralized for < 1ha, distributed for >= 1ha
        farm_area_ha = farm_boundary.area / 10000
        design_type = DesignType.DISTRIBUTED if farm_area_ha >= 1.0 else DesignType.CENTRALIZED

    features: List[Dict[str, Any]] = []

    # 1) Farm boundary (single Polygon β€” required by extract_farm_boundary).
    features.append({
        "type": "Feature",
        "properties": {
            "type": "farm_boundary",
            "name": req.farm.name,
        },
        "geometry": {
            "type": "Polygon",
            "coordinates": _polygon_to_coords(farm_boundary),
        },
    })

    # 2) Pump β€” engine consumes only the first source today; extras carried
    #    through as metadata-only Points so they round-trip in the response.
    primary_ws = req.water_sources[0]
    features.append({
        "type": "Feature",
        "properties": {
            "type": "pump",
            "name": primary_ws.name or primary_ws.water_source_id,
            "pump_hp": float(req.pump_hp),
            "horsepower": float(req.pump_hp),
            "water_source_id": primary_ws.water_source_id,
            "source_type": primary_ws.type,
        },
        "geometry": {
            "type": "Point",
            "coordinates": [primary_ws.location.longitude, primary_ws.location.latitude],
        },
    })
    for extra in req.water_sources[1:]:
        features.append({
            "type": "Feature",
            "properties": {
                "type": "water_source_extra",
                "name": extra.name or extra.water_source_id,
                "water_source_id": extra.water_source_id,
                "source_type": extra.type,
                "plot_id": extra.plot_id,
            },
            "geometry": {
                "type": "Point",
                "coordinates": [extra.location.longitude, extra.location.latitude],
            },
        })

    # 3) Crop zones β€” one per plot, all inheriting the farm's crop_name.
    for plot in req.plots:
        features.append({
            "type": "Feature",
            "properties": {
                "type": "crop_zone",
                "crop": crop,
                "name": plot.name or plot.plot_id,
                "plot_id": plot.plot_id,
            },
            "geometry": {
                "type": "Polygon",
                "coordinates": [_ring_lonlat(plot.boundaries)],
            },
        })

    fc = {
        "type": "FeatureCollection",
        "properties": {
            "farm_name": req.farm.name,
            "farm_address": req.farm.location.address,
            "pump_hp": float(req.pump_hp),
            "design_type": design_type.value,
            "headland_buffer_m": float(req.headland_buffer_m),
            "override_lateral_spacing_m": req.override_lateral_spacing_m,
        },
        "features": features,
    }
    return json.dumps(fc)


# ──────────────────────────────────────────────────────────────────────────────
# Router
# ──────────────────────────────────────────────────────────────────────────────


def _convert_area_fields(data: Any, unit: str) -> Any:
    """Recursively convert *_ha and area_m2 fields to the requested unit.

    Walks dicts and lists. For every key ending in ``_ha`` it adds a
    sibling ``_<unit>`` key with the converted value.  ``area_m2`` fields
    get a sibling ``area_<unit>`` key.
    """
    if isinstance(data, dict):
        out: Dict[str, Any] = {}
        for k, v in data.items():
            out[k] = _convert_area_fields(v, unit)
            # Convert hectare fields β†’ requested unit
            if k.endswith("_ha") and isinstance(v, (int, float)):
                m2 = v * 10_000
                label = area_unit_label(unit)
                new_key = k.replace("_ha", f"_{unit}")
                out[new_key] = round(m2_to_area(m2, unit), 4)
                out[k.replace("_ha", "_unit")] = label
            # Convert mΒ² fields β†’ requested unit
            if k == "area_m2" and isinstance(v, (int, float)):
                out[f"area_{unit}"] = round(m2_to_area(v, unit), 4)
        return out
    if isinstance(data, list):
        return [_convert_area_fields(item, unit) for item in data]
    return data


def build_router() -> APIRouter:
    router = APIRouter(prefix="/rest/v1", tags=["design"])

    @router.get("/health")
    def health() -> Dict[str, str]:
        return {"status": "ok", "schema_version": "v1"}

    @router.get("/units")
    def units() -> Dict[str, Any]:
        """List all supported area units for the area_unit parameter."""
        return {
            "area_units": {
                k: label for k, label in supported_area_units().items()
            },
        }

    @router.post("/design")
    def design(req: DesignRequest) -> Dict[str, Any]:
        # Validate area_unit early so callers get a clear error.
        area_unit = req.area_unit.strip().lower() or "ha"
        try:
            area_unit_label(area_unit)  # raises UnitError if invalid
        except UnitError:
            raise HTTPException(
                status_code=422,
                detail=f"Unknown area_unit '{req.area_unit}'. "
                f"Use GET /rest/v1/units to see supported values.",
            )

        # Translate caller schema β†’ GeoJSON the engine understands.
        try:
            geojson_str = to_geojson_feature_collection(req)
        except ValueError as e:
            raise HTTPException(status_code=422, detail=str(e))

        # Run the engine. It returns a GeoJSON FeatureCollection; on a
        # business-rule failure it tags the result with type=farm_design_error.
        try:
            result = process_farm_design(geojson_str)
        except Exception as e:  # noqa: BLE001 β€” surface engine errors verbatim
            raise HTTPException(status_code=500, detail=f"design pipeline failed: {e}")

        props = result.get("properties", {}) or {}
        if props.get("type") == "farm_design_error":
            err = props.get("error", {}) or {}
            raise HTTPException(
                status_code=422,
                detail={
                    "code": err.get("code", "design_error"),
                    "message": err.get("message", "design pipeline returned an error"),
                    "geojson": result,
                },
            )

        warnings: List[str] = []
        if len(req.water_sources) > 1:
            warnings.append(
                "Only the first water_source was used as the pump; "
                "multi-pump designs are not yet supported."
            )

        # Convert area fields if caller requested a non-default unit.
        design_summary = props.get("design_summary", {})
        bom = props.get("bom", {})
        zone_details = props.get("zone_details", [])
        geojson_out = result

        if area_unit != "ha":
            design_summary = _convert_area_fields(design_summary, area_unit)
            zone_details = _convert_area_fields(zone_details, area_unit)
            geojson_out = _convert_area_fields(result, area_unit)

        return {
            "farm_name": req.farm.name,
            "area_unit": area_unit,
            "design_summary": design_summary,
            "bom": bom,
            "zone_details": zone_details,
            "warnings": warnings,
            "geojson": geojson_out,
        }

    return router