Spaces:
Sleeping
Sleeping
Commit Β·
10bd203
1
Parent(s): b9e0ad7
Phase 1.5 complete: GeoJSON pipeline + design API + dual-mode Gradio UI
Browse filesTested: 51 unit tests pass, end-to-end pipeline verified, error handling confirmed.
New:
- design_api.py: orchestrates valve_engine + drip_engine pipeline
- GeoJSON input β parse β UTM transform β valve placement β per-zone drip layout β GeoJSON output
- Aggregated BOM across all zones with cost breakdown
- Structured error responses as valid GeoJSON FeatureCollections
Updated app.py:
- Tab 1: Quick Test (text geofence mode, kept for dev/debug)
- Tab 2: GeoJSON Pipeline (file upload or paste raw JSON)
- Runs full design_api.py pipeline
- Shows output GeoJSON, design summary, feature counts
- Embedded sample input/output for reference
- app.py +212 -152
- design_api.py +425 -0
app.py
CHANGED
|
@@ -1,15 +1,21 @@
|
|
| 1 |
"""
|
| 2 |
-
Farm Drip Irrigation Design Tool - Phase 1 (Geometry
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import gradio as gr
|
| 9 |
import json
|
| 10 |
from shapely.geometry import Polygon
|
| 11 |
from PIL import Image, ImageDraw
|
| 12 |
-
import numpy as np
|
| 13 |
from drip_engine import (
|
| 14 |
parse_geofence_to_polygon,
|
| 15 |
validate_polygon,
|
|
@@ -18,38 +24,22 @@ from drip_engine import (
|
|
| 18 |
design_summary,
|
| 19 |
DripLayoutError,
|
| 20 |
)
|
|
|
|
| 21 |
|
| 22 |
-
# Default example geofence (100m x 100m square)
|
| 23 |
DEFAULT_GEOFENCE = "0,0;100,0;100,100;0,100"
|
| 24 |
DEFAULT_CROP = "generic"
|
| 25 |
DEFAULT_HEADLAND = 1.0
|
| 26 |
|
| 27 |
|
| 28 |
-
def generate_design(geofence_text
|
| 29 |
-
"""
|
| 30 |
-
Main function: parse geofence, generate layout, return visualizations and BOM.
|
| 31 |
-
|
| 32 |
-
Args:
|
| 33 |
-
geofence_text: Polygon coordinates as text (e.g., "0,0;100,0;100,100;0,100")
|
| 34 |
-
crop: Crop type (tomato, pepper, lettuce, etc.)
|
| 35 |
-
headland_m: Headland buffer in meters
|
| 36 |
-
override_spacing: Override lateral spacing (if > 0)
|
| 37 |
-
|
| 38 |
-
Returns:
|
| 39 |
-
(design_image, bom_json, summary_text, error_message)
|
| 40 |
-
"""
|
| 41 |
try:
|
| 42 |
-
# Parse geofence
|
| 43 |
polygon_px = parse_geofence_to_polygon(geofence_text)
|
| 44 |
is_valid, msg = validate_polygon(polygon_px)
|
| 45 |
if not is_valid:
|
| 46 |
return None, "{}", msg, f"β Invalid polygon: {msg}"
|
| 47 |
|
| 48 |
-
# For this test, assume pixel coords directly map to meters (1:1 scale)
|
| 49 |
-
# In production, you'd convert from lat/lon or map projection
|
| 50 |
polygon_utm = polygon_px
|
| 51 |
-
|
| 52 |
-
# Generate layout
|
| 53 |
override_sp = override_spacing if override_spacing > 0 else None
|
| 54 |
design = generate_drip_layout(
|
| 55 |
polygon_utm,
|
|
@@ -57,19 +47,10 @@ def generate_design(geofence_text: str, crop: str, headland_m: float, override_s
|
|
| 57 |
headland_buffer_m=headland_m,
|
| 58 |
override_spacing_m=override_sp,
|
| 59 |
)
|
| 60 |
-
|
| 61 |
-
# Estimate BOM
|
| 62 |
bom = estimate_bom(design, unit="usd")
|
| 63 |
-
|
| 64 |
-
# Generate summary
|
| 65 |
summary = design_summary(design, bom)
|
| 66 |
-
|
| 67 |
-
# Render visualization
|
| 68 |
image = _render_design_image(design, polygon_px)
|
| 69 |
-
|
| 70 |
-
# BOM as JSON
|
| 71 |
bom_json = json.dumps(bom, indent=2)
|
| 72 |
-
|
| 73 |
return image, bom_json, summary, "β
Design generated successfully"
|
| 74 |
|
| 75 |
except DripLayoutError as e:
|
|
@@ -78,29 +59,14 @@ def generate_design(geofence_text: str, crop: str, headland_m: float, override_s
|
|
| 78 |
return None, "{}", "", f"β Unexpected error: {str(e)}"
|
| 79 |
|
| 80 |
|
| 81 |
-
def _render_design_image(design
|
| 82 |
-
"""
|
| 83 |
-
Render the drip layout on a canvas image.
|
| 84 |
-
|
| 85 |
-
Args:
|
| 86 |
-
design: Output from generate_drip_layout()
|
| 87 |
-
polygon_px: Original polygon (for bounds)
|
| 88 |
-
|
| 89 |
-
Returns:
|
| 90 |
-
PIL Image showing the design
|
| 91 |
-
"""
|
| 92 |
-
# Get bounding box
|
| 93 |
minx, miny, maxx, maxy = polygon_px.bounds
|
| 94 |
-
width_px = int(maxx - minx) + 20
|
| 95 |
-
height_px = int(maxy - miny) + 20
|
| 96 |
-
width_px = max(width_px, 400)
|
| 97 |
-
height_px = max(height_px, 300)
|
| 98 |
|
| 99 |
-
# Create image (white background)
|
| 100 |
image = Image.new("RGB", (width_px, height_px), color="white")
|
| 101 |
draw = ImageDraw.Draw(image)
|
| 102 |
-
|
| 103 |
-
# Scale to fit image
|
| 104 |
scale = min((width_px - 40) / (maxx - minx), (height_px - 40) / (maxy - miny))
|
| 105 |
offset_x = 20 - minx * scale
|
| 106 |
offset_y = 20 - miny * scale
|
|
@@ -108,146 +74,240 @@ def _render_design_image(design: dict, polygon_px: Polygon) -> Image.Image:
|
|
| 108 |
def scale_point(x, y):
|
| 109 |
return (x * scale + offset_x, y * scale + offset_y)
|
| 110 |
|
| 111 |
-
# Draw field boundary (green)
|
| 112 |
boundary_coords = list(polygon_px.exterior.coords)
|
| 113 |
scaled_boundary = [scale_point(x, y) for x, y in boundary_coords]
|
| 114 |
if len(scaled_boundary) > 2:
|
| 115 |
draw.polygon(scaled_boundary, outline="green", width=3)
|
| 116 |
|
| 117 |
-
# Draw main line (red, thicker)
|
| 118 |
main_line = design["main_line"]
|
| 119 |
main_coords = [scale_point(x, y) for x, y in main_line.coords]
|
| 120 |
if len(main_coords) > 1:
|
| 121 |
draw.line(main_coords, fill="red", width=4)
|
| 122 |
|
| 123 |
-
# Draw laterals (blue, thinner)
|
| 124 |
for lateral in design["laterals"]:
|
| 125 |
lateral_coords = [scale_point(x, y) for x, y in lateral.coords]
|
| 126 |
if len(lateral_coords) > 1:
|
| 127 |
draw.line(lateral_coords, fill="blue", width=2)
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
emitter_text = f"Emitters: {design['emitter_count']}"
|
| 133 |
-
|
| 134 |
-
draw.text((10, 10), title, fill="black")
|
| 135 |
-
draw.text((10, 30), area_text, fill="black")
|
| 136 |
-
draw.text((10, 50), emitter_text, fill="black")
|
| 137 |
|
| 138 |
return image
|
| 139 |
|
| 140 |
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
with gr.Blocks(title="Farm Drip Irrigation Designer") as demo:
|
| 143 |
gr.Markdown(
|
| 144 |
"""
|
| 145 |
-
# πΎ Farm Drip Irrigation Designer (Phase 1)
|
| 146 |
|
| 147 |
-
**Geometry engine
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
*
|
| 152 |
"""
|
| 153 |
)
|
| 154 |
|
| 155 |
-
with gr.
|
| 156 |
-
with gr.
|
| 157 |
-
gr.
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
)
|
| 164 |
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
"tomato",
|
| 169 |
-
"
|
| 170 |
-
"
|
| 171 |
-
"cucumber",
|
| 172 |
-
"orchard",
|
| 173 |
-
"generic",
|
| 174 |
],
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
| 177 |
)
|
| 178 |
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
value=DEFAULT_HEADLAND,
|
| 184 |
-
step=0.5,
|
| 185 |
-
info="Inward buffer from field edge (for turning)",
|
| 186 |
-
)
|
| 187 |
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
minimum=0.0,
|
| 191 |
-
maximum=5.0,
|
| 192 |
-
value=0.0,
|
| 193 |
-
step=0.1,
|
| 194 |
-
info="Set to 0 to use crop default",
|
| 195 |
-
)
|
| 196 |
-
|
| 197 |
-
generate_btn = gr.Button("Generate Design", variant="primary")
|
| 198 |
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
| 212 |
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
fn=generate_design,
|
| 216 |
-
inputs=[geofence_input, crop_dropdown, headland_slider, spacing_slider],
|
| 217 |
-
outputs=[design_image, bom_json, summary_text, status_text],
|
| 218 |
-
)
|
| 219 |
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
gr.Examples(
|
| 223 |
-
examples=[
|
| 224 |
-
[
|
| 225 |
-
"0,0;100,0;100,100;0,100",
|
| 226 |
-
"tomato",
|
| 227 |
-
1.0,
|
| 228 |
-
0.0,
|
| 229 |
-
], # 100x100m square
|
| 230 |
-
[
|
| 231 |
-
"0,0;200,0;200,50;0,50",
|
| 232 |
-
"lettuce",
|
| 233 |
-
1.0,
|
| 234 |
-
0.0,
|
| 235 |
-
], # Long rectangle
|
| 236 |
-
[
|
| 237 |
-
"0,0;100,0;100,100;50,100;50,50;0,50",
|
| 238 |
-
"pepper",
|
| 239 |
-
1.5,
|
| 240 |
-
0.0,
|
| 241 |
-
], # L-shape
|
| 242 |
-
],
|
| 243 |
-
inputs=[geofence_input, crop_dropdown, headland_slider, spacing_slider],
|
| 244 |
-
fn=generate_design,
|
| 245 |
-
outputs=[design_image, bom_json, summary_text, status_text],
|
| 246 |
-
cache_examples=False,
|
| 247 |
-
)
|
| 248 |
|
| 249 |
|
| 250 |
if __name__ == "__main__":
|
| 251 |
-
# HF Spaces handles public access based on Space visibility settings
|
| 252 |
-
# Make the Space public in HF UI: Settings -> Change visibility -> Public
|
| 253 |
demo.launch()
|
|
|
|
| 1 |
"""
|
| 2 |
+
Farm Drip Irrigation Design Tool - Phase 1.5 (Geometry + Valve Engine)
|
| 3 |
|
| 4 |
+
Two modes:
|
| 5 |
+
1. Quick test: Type geofence coordinates, select crop, get design
|
| 6 |
+
2. Full pipeline: Upload GeoJSON from user app, run valve + drip layout, download result
|
| 7 |
+
|
| 8 |
+
Supports:
|
| 9 |
+
- GeoJSON input/output (RFC 7946 standard)
|
| 10 |
+
- Valve placement with hierarchical decision matrix
|
| 11 |
+
- Drip layout generation per valve zone
|
| 12 |
+
- BOM estimation
|
| 13 |
"""
|
| 14 |
|
| 15 |
import gradio as gr
|
| 16 |
import json
|
| 17 |
from shapely.geometry import Polygon
|
| 18 |
from PIL import Image, ImageDraw
|
|
|
|
| 19 |
from drip_engine import (
|
| 20 |
parse_geofence_to_polygon,
|
| 21 |
validate_polygon,
|
|
|
|
| 24 |
design_summary,
|
| 25 |
DripLayoutError,
|
| 26 |
)
|
| 27 |
+
from design_api import process_farm_design
|
| 28 |
|
|
|
|
| 29 |
DEFAULT_GEOFENCE = "0,0;100,0;100,100;0,100"
|
| 30 |
DEFAULT_CROP = "generic"
|
| 31 |
DEFAULT_HEADLAND = 1.0
|
| 32 |
|
| 33 |
|
| 34 |
+
def generate_design(geofence_text, crop, headland_m, override_spacing):
|
| 35 |
+
"""Quick test mode with text geofence."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
try:
|
|
|
|
| 37 |
polygon_px = parse_geofence_to_polygon(geofence_text)
|
| 38 |
is_valid, msg = validate_polygon(polygon_px)
|
| 39 |
if not is_valid:
|
| 40 |
return None, "{}", msg, f"β Invalid polygon: {msg}"
|
| 41 |
|
|
|
|
|
|
|
| 42 |
polygon_utm = polygon_px
|
|
|
|
|
|
|
| 43 |
override_sp = override_spacing if override_spacing > 0 else None
|
| 44 |
design = generate_drip_layout(
|
| 45 |
polygon_utm,
|
|
|
|
| 47 |
headland_buffer_m=headland_m,
|
| 48 |
override_spacing_m=override_sp,
|
| 49 |
)
|
|
|
|
|
|
|
| 50 |
bom = estimate_bom(design, unit="usd")
|
|
|
|
|
|
|
| 51 |
summary = design_summary(design, bom)
|
|
|
|
|
|
|
| 52 |
image = _render_design_image(design, polygon_px)
|
|
|
|
|
|
|
| 53 |
bom_json = json.dumps(bom, indent=2)
|
|
|
|
| 54 |
return image, bom_json, summary, "β
Design generated successfully"
|
| 55 |
|
| 56 |
except DripLayoutError as e:
|
|
|
|
| 59 |
return None, "{}", "", f"β Unexpected error: {str(e)}"
|
| 60 |
|
| 61 |
|
| 62 |
+
def _render_design_image(design, polygon_px):
|
| 63 |
+
"""Render the drip layout on a canvas image."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
minx, miny, maxx, maxy = polygon_px.bounds
|
| 65 |
+
width_px = max(int(maxx - minx) + 20, 400)
|
| 66 |
+
height_px = max(int(maxy - miny) + 20, 300)
|
|
|
|
|
|
|
| 67 |
|
|
|
|
| 68 |
image = Image.new("RGB", (width_px, height_px), color="white")
|
| 69 |
draw = ImageDraw.Draw(image)
|
|
|
|
|
|
|
| 70 |
scale = min((width_px - 40) / (maxx - minx), (height_px - 40) / (maxy - miny))
|
| 71 |
offset_x = 20 - minx * scale
|
| 72 |
offset_y = 20 - miny * scale
|
|
|
|
| 74 |
def scale_point(x, y):
|
| 75 |
return (x * scale + offset_x, y * scale + offset_y)
|
| 76 |
|
|
|
|
| 77 |
boundary_coords = list(polygon_px.exterior.coords)
|
| 78 |
scaled_boundary = [scale_point(x, y) for x, y in boundary_coords]
|
| 79 |
if len(scaled_boundary) > 2:
|
| 80 |
draw.polygon(scaled_boundary, outline="green", width=3)
|
| 81 |
|
|
|
|
| 82 |
main_line = design["main_line"]
|
| 83 |
main_coords = [scale_point(x, y) for x, y in main_line.coords]
|
| 84 |
if len(main_coords) > 1:
|
| 85 |
draw.line(main_coords, fill="red", width=4)
|
| 86 |
|
|
|
|
| 87 |
for lateral in design["laterals"]:
|
| 88 |
lateral_coords = [scale_point(x, y) for x, y in lateral.coords]
|
| 89 |
if len(lateral_coords) > 1:
|
| 90 |
draw.line(lateral_coords, fill="blue", width=2)
|
| 91 |
|
| 92 |
+
draw.text((10, 10), f"Farm Drip Layout - {design['crop'].title()}", fill="black")
|
| 93 |
+
draw.text((10, 30), f"Area: {design['farm_area_ha']:.2f} ha", fill="black")
|
| 94 |
+
draw.text((10, 50), f"Emitters: {design['emitter_count']}", fill="black")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
return image
|
| 97 |
|
| 98 |
|
| 99 |
+
def process_geojson_input(geojson_text):
|
| 100 |
+
"""Full pipeline: accept GeoJSON string, return output + summary."""
|
| 101 |
+
try:
|
| 102 |
+
result = process_farm_design(geojson_text)
|
| 103 |
+
|
| 104 |
+
props = result.get("properties", {})
|
| 105 |
+
if props.get("type") == "farm_design_error":
|
| 106 |
+
err = props.get("error", {})
|
| 107 |
+
return json.dumps(result, indent=2), "", None, f"β {err.get('code')}: {err.get('message')}"
|
| 108 |
+
|
| 109 |
+
summary_lines = []
|
| 110 |
+
ds = props.get("design_summary", {})
|
| 111 |
+
summary_lines.append("=== Farm Design Summary ===")
|
| 112 |
+
summary_lines.append(f"Farm Area: {ds.get('farm_area_ha', 'N/A')} ha")
|
| 113 |
+
summary_lines.append(f"Total Valves: {ds.get('total_valves', 'N/A')}")
|
| 114 |
+
summary_lines.append(f"Drip Tape: {ds.get('total_drip_tape_m', 'N/A')} m")
|
| 115 |
+
summary_lines.append(f"Main Line: {ds.get('total_main_line_m', 'N/A')} m")
|
| 116 |
+
summary_lines.append(f"Emitters: {ds.get('total_emitters', 'N/A')}")
|
| 117 |
+
summary_lines.append(f"Pump: {ds.get('pump_hp', 'N/A')} HP ({ds.get('pump_flow_lph', 'N/A')} L/h)")
|
| 118 |
+
summary_lines.append(f"Strategy: {ds.get('manifold_strategy', 'N/A')}")
|
| 119 |
+
summary_lines.append("")
|
| 120 |
+
|
| 121 |
+
bom = props.get("bom", {})
|
| 122 |
+
summary_lines.append("=== Bill of Materials ===")
|
| 123 |
+
summary_lines.append(f"Main Pipe (16mm): {bom.get('main_line_16mm_m', 'N/A')} m")
|
| 124 |
+
summary_lines.append(f"Drip Tape (16mm): {bom.get('drip_tape_16mm_m', 'N/A')} m")
|
| 125 |
+
summary_lines.append(f"Inline Emitters: {bom.get('inline_emitters', 'N/A')}")
|
| 126 |
+
summary_lines.append(f"Valves: {bom.get('valves_count', 'N/A')}")
|
| 127 |
+
if "total_cost_usd" in bom:
|
| 128 |
+
summary_lines.append(f"Total Cost: ${bom.get('total_cost_usd', 'N/A')}")
|
| 129 |
+
summary_lines.append("")
|
| 130 |
+
|
| 131 |
+
zones = props.get("zone_details", [])
|
| 132 |
+
if zones:
|
| 133 |
+
summary_lines.append("=== Per-Zone Breakdown ===")
|
| 134 |
+
for z in zones:
|
| 135 |
+
if "error" in z:
|
| 136 |
+
summary_lines.append(f" {z['valve_id']}: {z['crop']} β ERROR: {z['error']}")
|
| 137 |
+
else:
|
| 138 |
+
summary_lines.append(
|
| 139 |
+
f" {z['valve_id']}: {z['crop']} | "
|
| 140 |
+
f"{z.get('area_ha', 0):.2f} ha | "
|
| 141 |
+
f"{z.get('emitters', 0)} emitters | "
|
| 142 |
+
f"{z.get('lateral_m', 0):.0f} m tape"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
summary = "\n".join(summary_lines)
|
| 146 |
+
|
| 147 |
+
feature_types = {}
|
| 148 |
+
for f in result.get("features", []):
|
| 149 |
+
t = f.get("properties", {}).get("type", "unknown")
|
| 150 |
+
feature_types[t] = feature_types.get(t, 0) + 1
|
| 151 |
+
visual_text = "Features generated:\n" + "\n".join(f" {k}: {v}" for k, v in feature_types.items())
|
| 152 |
+
|
| 153 |
+
output_json = json.dumps(result, indent=2)
|
| 154 |
+
return output_json, summary, visual_text, "β
Design generated from GeoJSON"
|
| 155 |
+
|
| 156 |
+
except Exception as e:
|
| 157 |
+
return "{}", "", "", f"β Error: {str(e)}"
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def process_geojson_file(file_obj):
|
| 161 |
+
"""Accept uploaded JSON file, run pipeline."""
|
| 162 |
+
if file_obj is None:
|
| 163 |
+
return "{}", "", None, "Please upload a GeoJSON file."
|
| 164 |
+
try:
|
| 165 |
+
with open(file_obj.name, "r", encoding="utf-8") as f:
|
| 166 |
+
content = f.read()
|
| 167 |
+
return process_geojson_input(content)
|
| 168 |
+
except Exception as e:
|
| 169 |
+
return "{}", "", None, f"β File error: {str(e)}"
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# Load sample files for display
|
| 173 |
+
with open("samples/input_example.json", "r") as f:
|
| 174 |
+
SAMPLE_INPUT = f.read()
|
| 175 |
+
with open("samples/output_example.json", "r") as f:
|
| 176 |
+
SAMPLE_OUTPUT = f.read()
|
| 177 |
+
|
| 178 |
+
|
| 179 |
with gr.Blocks(title="Farm Drip Irrigation Designer") as demo:
|
| 180 |
gr.Markdown(
|
| 181 |
"""
|
| 182 |
+
# πΎ Farm Drip Irrigation Designer (Phase 1.5)
|
| 183 |
|
| 184 |
+
**Geometry engine + Valve placement**: Test layouts with geofence text or GeoJSON from your app.
|
| 185 |
|
| 186 |
+
Two modes:
|
| 187 |
+
- **Quick Test**: Type coordinates, get instant design
|
| 188 |
+
- **GeoJSON Pipeline**: Upload real farm data, get full valve + drip layout
|
| 189 |
"""
|
| 190 |
)
|
| 191 |
|
| 192 |
+
with gr.Tabs():
|
| 193 |
+
with gr.TabItem("Quick Test"):
|
| 194 |
+
with gr.Row():
|
| 195 |
+
with gr.Column(scale=1):
|
| 196 |
+
gr.Markdown("### Farm Boundary Input")
|
| 197 |
+
geofence_input = gr.Textbox(
|
| 198 |
+
label="Geofence (x,y;x,y;x,y...)",
|
| 199 |
+
value=DEFAULT_GEOFENCE,
|
| 200 |
+
lines=3,
|
| 201 |
+
info="Comma-separated coords, semicolon-separated points",
|
| 202 |
+
)
|
| 203 |
+
crop_dropdown = gr.Dropdown(
|
| 204 |
+
label="Crop Type",
|
| 205 |
+
choices=["tomato", "pepper", "lettuce", "cucumber", "orchard", "generic"],
|
| 206 |
+
value=DEFAULT_CROP,
|
| 207 |
+
)
|
| 208 |
+
headland_slider = gr.Slider(
|
| 209 |
+
label="Headland Buffer (m)",
|
| 210 |
+
minimum=0.0, maximum=10.0, value=DEFAULT_HEADLAND, step=0.5,
|
| 211 |
+
)
|
| 212 |
+
spacing_slider = gr.Slider(
|
| 213 |
+
label="Override Lateral Spacing (m)",
|
| 214 |
+
minimum=0.0, maximum=5.0, value=0.0, step=0.1,
|
| 215 |
+
info="Set to 0 to use crop default",
|
| 216 |
+
)
|
| 217 |
+
generate_btn = gr.Button("Generate Design", variant="primary")
|
| 218 |
+
|
| 219 |
+
with gr.Column(scale=1):
|
| 220 |
+
gr.Markdown("### Design Visualization")
|
| 221 |
+
design_image = gr.Image(label="Drip Layout", type="pil")
|
| 222 |
+
status_text = gr.Textbox(label="Status", interactive=False)
|
| 223 |
+
|
| 224 |
+
with gr.Row():
|
| 225 |
+
with gr.Column():
|
| 226 |
+
gr.Markdown("### Bill of Materials (USD)")
|
| 227 |
+
bom_json = gr.Code(label="BOM (JSON)", language="json")
|
| 228 |
+
with gr.Column():
|
| 229 |
+
gr.Markdown("### Design Summary")
|
| 230 |
+
summary_text = gr.Textbox(label="Summary", lines=15, interactive=False)
|
| 231 |
+
|
| 232 |
+
generate_btn.click(
|
| 233 |
+
fn=generate_design,
|
| 234 |
+
inputs=[geofence_input, crop_dropdown, headland_slider, spacing_slider],
|
| 235 |
+
outputs=[design_image, bom_json, summary_text, status_text],
|
| 236 |
)
|
| 237 |
|
| 238 |
+
gr.Markdown("### Example Geofences (copy & paste)")
|
| 239 |
+
gr.Examples(
|
| 240 |
+
examples=[
|
| 241 |
+
["0,0;100,0;100,100;0,100", "tomato", 1.0, 0.0],
|
| 242 |
+
["0,0;200,0;200,50;0,50", "lettuce", 1.0, 0.0],
|
| 243 |
+
["0,0;100,0;100,100;50,100;50,50;0,50", "pepper", 1.5, 0.0],
|
|
|
|
|
|
|
|
|
|
| 244 |
],
|
| 245 |
+
inputs=[geofence_input, crop_dropdown, headland_slider, spacing_slider],
|
| 246 |
+
fn=generate_design,
|
| 247 |
+
outputs=[design_image, bom_json, summary_text, status_text],
|
| 248 |
+
cache_examples=False,
|
| 249 |
)
|
| 250 |
|
| 251 |
+
with gr.TabItem("GeoJSON Pipeline"):
|
| 252 |
+
gr.Markdown(
|
| 253 |
+
"""
|
| 254 |
+
### Upload farm data as GeoJSON
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
+
Upload a GeoJSON FeatureCollection with your farm boundary, pump location, and crop zones.
|
| 257 |
+
The engine will place valves, generate drip layouts per zone, and return a complete design.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
|
| 259 |
+
**Required**: `farm_boundary` (Polygon), `pump` (Point with pump_hp), crop zones (Polygons)
|
| 260 |
+
**Output**: GeoJSON with valves, valve_zones, main_lines, laterals + BOM
|
| 261 |
+
"""
|
| 262 |
+
)
|
| 263 |
|
| 264 |
+
with gr.Row():
|
| 265 |
+
with gr.Column(scale=1):
|
| 266 |
+
gr.Markdown("### Input")
|
| 267 |
+
geojson_file = gr.File(
|
| 268 |
+
label="Upload GeoJSON (.json)",
|
| 269 |
+
file_types=[".json", ".geojson"],
|
| 270 |
+
)
|
| 271 |
+
geojson_text = gr.Textbox(
|
| 272 |
+
label="Or paste GeoJSON here",
|
| 273 |
+
lines=10,
|
| 274 |
+
placeholder='{"type": "FeatureCollection", "features": [...]}',
|
| 275 |
+
)
|
| 276 |
+
with gr.Row():
|
| 277 |
+
run_file_btn = gr.Button("Run from File", variant="primary")
|
| 278 |
+
run_text_btn = gr.Button("Run from Text")
|
| 279 |
+
|
| 280 |
+
with gr.Column(scale=1):
|
| 281 |
+
gr.Markdown("### Output")
|
| 282 |
+
output_status = gr.Textbox(label="Status", interactive=False)
|
| 283 |
+
output_visual = gr.Textbox(label="Features Generated", lines=6, interactive=False)
|
| 284 |
+
|
| 285 |
+
with gr.Row():
|
| 286 |
+
with gr.Column():
|
| 287 |
+
gr.Markdown("### Output GeoJSON")
|
| 288 |
+
output_geojson = gr.Code(label="Result (GeoJSON)", language="json", lines=20)
|
| 289 |
+
with gr.Column():
|
| 290 |
+
gr.Markdown("### Design Summary")
|
| 291 |
+
output_summary = gr.Textbox(label="Summary", lines=20, interactive=False)
|
| 292 |
+
|
| 293 |
+
run_file_btn.click(
|
| 294 |
+
fn=process_geojson_file,
|
| 295 |
+
inputs=[geojson_file],
|
| 296 |
+
outputs=[output_geojson, output_summary, output_visual, output_status],
|
| 297 |
+
)
|
| 298 |
|
| 299 |
+
run_text_btn.click(
|
| 300 |
+
fn=process_geojson_input,
|
| 301 |
+
inputs=[geojson_text],
|
| 302 |
+
outputs=[output_geojson, output_summary, output_visual, output_status],
|
| 303 |
+
)
|
| 304 |
|
| 305 |
+
gr.Markdown("### Example Input")
|
| 306 |
+
gr.Code(SAMPLE_INPUT, language="json", label="Sample Input")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
+
gr.Markdown("### Example Output")
|
| 309 |
+
gr.Code(SAMPLE_OUTPUT, language="json", label="Sample Output")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
|
| 311 |
|
| 312 |
if __name__ == "__main__":
|
|
|
|
|
|
|
| 313 |
demo.launch()
|
design_api.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Design API β End-to-end farm irrigation design pipeline.
|
| 3 |
+
|
| 4 |
+
Orchestrates:
|
| 5 |
+
GeoJSON Input β Parse β Valve Placement β Drip Layout β GeoJSON Output
|
| 6 |
+
|
| 7 |
+
Input: GeoJSON FeatureCollection (farm_boundary, pump, crop_zones, elevation)
|
| 8 |
+
Output: GeoJSON FeatureCollection (valves, zones, mains, laterals, BOM)
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import math
|
| 13 |
+
from typing import Dict, List, Any, Tuple, Optional
|
| 14 |
+
from shapely.geometry import Polygon, Point, LineString
|
| 15 |
+
|
| 16 |
+
import geojson_io as gj_io
|
| 17 |
+
from drip_engine import (
|
| 18 |
+
generate_drip_layout,
|
| 19 |
+
estimate_bom,
|
| 20 |
+
latlon_to_utm,
|
| 21 |
+
CROP_DEFAULTS,
|
| 22 |
+
DripLayoutError,
|
| 23 |
+
)
|
| 24 |
+
from valve_engine import (
|
| 25 |
+
place_valves_hierarchical,
|
| 26 |
+
generate_valve_zones,
|
| 27 |
+
calculate_pump_flow_lph,
|
| 28 |
+
calculate_total_emitter_flow,
|
| 29 |
+
calculate_num_zones,
|
| 30 |
+
choose_manifold_strategy,
|
| 31 |
+
ValveEngineError,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class DesignAPIError(Exception):
|
| 36 |
+
"""Top-level exception for design pipeline errors."""
|
| 37 |
+
pass
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def process_farm_design(geojson_input: str) -> Dict[str, Any]:
|
| 41 |
+
"""
|
| 42 |
+
Main entry point: parse GeoJSON input, run design pipeline, return GeoJSON output.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
geojson_input: GeoJSON FeatureCollection string (or dict)
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
Dict that is a GeoJSON FeatureCollection with:
|
| 49 |
+
- properties: design_summary, bom
|
| 50 |
+
- features: farm_boundary, valves, valve_zones, main_lines, laterals
|
| 51 |
+
|
| 52 |
+
Raises:
|
| 53 |
+
DesignAPIError: On any pipeline failure (with structured error info)
|
| 54 |
+
"""
|
| 55 |
+
try:
|
| 56 |
+
# ββ 1. Parse input ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
+
fc = gj_io.parse_geojson_feature_collection(geojson_input)
|
| 58 |
+
features = fc.get("features", [])
|
| 59 |
+
top_props = fc.get("properties", {})
|
| 60 |
+
|
| 61 |
+
# ββ 2. Extract geometries βββββββββββββββββββββββββββββββββββββββ
|
| 62 |
+
farm_boundary, _ = gj_io.extract_farm_boundary(fc)
|
| 63 |
+
pump_point, pump_props = gj_io.extract_pump_location(fc)
|
| 64 |
+
crop_zones = gj_io.extract_crop_zones(fc)
|
| 65 |
+
elevation_data = gj_io.extract_elevation_data(fc)
|
| 66 |
+
|
| 67 |
+
# ββ 3. Resolve parameters (top-level props override feature props)
|
| 68 |
+
pump_hp = _resolve_pump_hp(top_props, pump_props, features)
|
| 69 |
+
centralized = _resolve_centralized(top_props)
|
| 70 |
+
headland_m = top_props.get("headland_buffer_m", 1.0)
|
| 71 |
+
override_spacing = top_props.get("override_lateral_spacing_m")
|
| 72 |
+
|
| 73 |
+
# ββ 4. Convert to UTM for accurate calculations βββββββββββββββββ
|
| 74 |
+
# Farm boundary lat/lon β UTM
|
| 75 |
+
farm_utm = latlon_to_utm(farm_boundary)
|
| 76 |
+
pump_utm = _transform_point_to_utm(pump_point, farm_boundary)
|
| 77 |
+
|
| 78 |
+
# Convert crop zone polygons to UTM
|
| 79 |
+
crop_zones_utm = []
|
| 80 |
+
for zone in crop_zones:
|
| 81 |
+
zone_poly = zone.get("polygon")
|
| 82 |
+
if zone_poly is None:
|
| 83 |
+
continue
|
| 84 |
+
zone_utm = latlon_to_utm(zone_poly)
|
| 85 |
+
crop_zones_utm.append({
|
| 86 |
+
"crop": zone.get("crop", "generic"),
|
| 87 |
+
"polygon": zone_utm,
|
| 88 |
+
"area_m2": zone_utm.area,
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
# If no explicit crop zones, treat entire farm as single generic zone
|
| 92 |
+
if not crop_zones_utm:
|
| 93 |
+
crop_zones_utm = [{
|
| 94 |
+
"crop": "generic",
|
| 95 |
+
"polygon": farm_utm,
|
| 96 |
+
"area_m2": farm_utm.area,
|
| 97 |
+
}]
|
| 98 |
+
|
| 99 |
+
# ββ 5. Run valve placement engine ββββββββββββββββββββββββββββββ
|
| 100 |
+
valves = place_valves_hierarchical(
|
| 101 |
+
farm_polygon=farm_utm,
|
| 102 |
+
pump_point=pump_utm,
|
| 103 |
+
crop_zones=crop_zones_utm,
|
| 104 |
+
pump_hp=pump_hp,
|
| 105 |
+
centralized=centralized,
|
| 106 |
+
elevation_data=elevation_data,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
zones = generate_valve_zones(farm_utm, valves)
|
| 110 |
+
|
| 111 |
+
# ββ 6. Run drip layout per zone ββββββββββββββββββββββββββββββββ
|
| 112 |
+
all_drip_designs = []
|
| 113 |
+
all_boms = []
|
| 114 |
+
zone_summaries = []
|
| 115 |
+
|
| 116 |
+
for zone in zones:
|
| 117 |
+
zone_poly = zone["polygon"]
|
| 118 |
+
valve_id = zone["valve_id"]
|
| 119 |
+
|
| 120 |
+
# Determine crop for this zone (from valve metadata or default)
|
| 121 |
+
valve_meta = next((v for v in valves if v["id"] == valve_id), None)
|
| 122 |
+
crop = valve_meta.get("crop", "generic") if valve_meta else "generic"
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
design = generate_drip_layout(
|
| 126 |
+
polygon_utm=zone_poly,
|
| 127 |
+
crop=crop,
|
| 128 |
+
headland_buffer_m=headland_m,
|
| 129 |
+
override_spacing_m=override_spacing if override_spacing else None,
|
| 130 |
+
)
|
| 131 |
+
bom = estimate_bom(design, unit="usd")
|
| 132 |
+
all_drip_designs.append((valve_id, design))
|
| 133 |
+
all_boms.append(bom)
|
| 134 |
+
zone_summaries.append({
|
| 135 |
+
"valve_id": valve_id,
|
| 136 |
+
"crop": crop,
|
| 137 |
+
"area_ha": design["farm_area_ha"],
|
| 138 |
+
"emitters": design["emitter_count"],
|
| 139 |
+
"main_m": design["total_main_length_m"],
|
| 140 |
+
"lateral_m": design["total_drip_tape_m"],
|
| 141 |
+
})
|
| 142 |
+
except DripLayoutError as e:
|
| 143 |
+
# Zone too small after headland β skip with warning
|
| 144 |
+
zone_summaries.append({
|
| 145 |
+
"valve_id": valve_id,
|
| 146 |
+
"crop": crop,
|
| 147 |
+
"error": str(e),
|
| 148 |
+
})
|
| 149 |
+
|
| 150 |
+
# ββ 7. Aggregate totals βββββββββββββββββββββββββββββββββββββββββ
|
| 151 |
+
total_area_ha = sum(s.get("area_ha", 0) for s in zone_summaries if "area_ha" in s)
|
| 152 |
+
total_emitters = sum(s.get("emitters", 0) for s in zone_summaries if "emitters" in s)
|
| 153 |
+
total_main_m = sum(s.get("main_m", 0) for s in zone_summaries if "main_m" in s)
|
| 154 |
+
total_lateral_m = sum(s.get("lateral_m", 0) for s in zone_summaries if "lateral_m" in s)
|
| 155 |
+
|
| 156 |
+
# Aggregate BOM
|
| 157 |
+
total_bom = {
|
| 158 |
+
"main_line_16mm_m": round(sum(b.get("main_line_16mm_m", 0) for b in all_boms), 2),
|
| 159 |
+
"drip_tape_16mm_m": round(sum(b.get("drip_tape_16mm_m", 0) for b in all_boms), 2),
|
| 160 |
+
"inline_emitters": sum(b.get("inline_emitters", 0) for b in all_boms),
|
| 161 |
+
"total_pipe_m": round(sum(b.get("total_pipe_m", 0) for b in all_boms), 2),
|
| 162 |
+
"valves_count": len(valves),
|
| 163 |
+
}
|
| 164 |
+
if all_boms and "cost_main" in all_boms[0]:
|
| 165 |
+
total_bom["cost_main"] = round(sum(b.get("cost_main", 0) for b in all_boms), 2)
|
| 166 |
+
total_bom["cost_drip_tape"] = round(sum(b.get("cost_drip_tape", 0) for b in all_boms), 2)
|
| 167 |
+
total_bom["cost_emitters"] = round(sum(b.get("cost_emitters", 0) for b in all_boms), 2)
|
| 168 |
+
total_bom["cost_valves"] = round(len(valves) * 15.0, 2) # $15 per valve estimate
|
| 169 |
+
total_bom["total_cost_usd"] = round(
|
| 170 |
+
total_bom.get("cost_main", 0)
|
| 171 |
+
+ total_bom.get("cost_drip_tape", 0)
|
| 172 |
+
+ total_bom.get("cost_emitters", 0)
|
| 173 |
+
+ total_bom.get("cost_valves", 0),
|
| 174 |
+
2,
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
# ββ 8. Convert back to lat/lon for GeoJSON output βββββββββββββββ
|
| 178 |
+
# Build output features in UTM, then transform all coordinates back
|
| 179 |
+
output_features = []
|
| 180 |
+
|
| 181 |
+
# Farm boundary (echo input)
|
| 182 |
+
output_features.append({
|
| 183 |
+
"type": "Feature",
|
| 184 |
+
"properties": {"type": "farm_boundary", "area_ha": round(total_area_ha, 2)},
|
| 185 |
+
"geometry": _polygon_to_geojson(farm_boundary),
|
| 186 |
+
})
|
| 187 |
+
|
| 188 |
+
# Valves (convert UTM points back to lat/lon)
|
| 189 |
+
for valve in valves:
|
| 190 |
+
valve_point_utm = valve["location"]
|
| 191 |
+
valve_point_latlon = _transform_point_from_utm(valve_point_utm, farm_boundary)
|
| 192 |
+
output_features.append({
|
| 193 |
+
"type": "Feature",
|
| 194 |
+
"properties": {
|
| 195 |
+
"type": "valve",
|
| 196 |
+
"id": valve["id"],
|
| 197 |
+
"strategy": valve["strategy"],
|
| 198 |
+
"reason": valve["reason"],
|
| 199 |
+
"crop": valve.get("crop", "generic"),
|
| 200 |
+
},
|
| 201 |
+
"geometry": {
|
| 202 |
+
"type": "Point",
|
| 203 |
+
"coordinates": [valve_point_latlon.x, valve_point_latlon.y],
|
| 204 |
+
},
|
| 205 |
+
})
|
| 206 |
+
|
| 207 |
+
# Valve zones (convert UTM polygons back to lat/lon)
|
| 208 |
+
for zone in zones:
|
| 209 |
+
zone_poly_utm = zone["polygon"]
|
| 210 |
+
zone_poly_latlon = _transform_polygon_from_utm(zone_poly_utm, farm_boundary)
|
| 211 |
+
output_features.append({
|
| 212 |
+
"type": "Feature",
|
| 213 |
+
"properties": {
|
| 214 |
+
"type": "valve_zone",
|
| 215 |
+
"valve_id": zone["valve_id"],
|
| 216 |
+
"area_m2": round(zone["area_m2"], 2),
|
| 217 |
+
"area_ha": round(zone["area_m2"] / 10000, 4),
|
| 218 |
+
},
|
| 219 |
+
"geometry": _polygon_to_geojson(zone_poly_latlon),
|
| 220 |
+
})
|
| 221 |
+
|
| 222 |
+
# Drip layout: main lines and laterals per zone
|
| 223 |
+
for valve_id, design in all_drip_designs:
|
| 224 |
+
# Main line
|
| 225 |
+
main_utm = design["main_line"]
|
| 226 |
+
main_latlon = _transform_linestring_from_utm(main_utm, farm_boundary)
|
| 227 |
+
output_features.append({
|
| 228 |
+
"type": "Feature",
|
| 229 |
+
"properties": {
|
| 230 |
+
"type": "main_line",
|
| 231 |
+
"valve_id": valve_id,
|
| 232 |
+
"length_m": round(main_utm.length, 2),
|
| 233 |
+
"crop": design["crop"],
|
| 234 |
+
},
|
| 235 |
+
"geometry": _linestring_to_geojson(main_latlon),
|
| 236 |
+
})
|
| 237 |
+
|
| 238 |
+
# Laterals
|
| 239 |
+
for i, lateral_utm in enumerate(design["laterals"]):
|
| 240 |
+
lateral_latlon = _transform_linestring_from_utm(lateral_utm, farm_boundary)
|
| 241 |
+
output_features.append({
|
| 242 |
+
"type": "Feature",
|
| 243 |
+
"properties": {
|
| 244 |
+
"type": "lateral",
|
| 245 |
+
"index": i,
|
| 246 |
+
"valve_id": valve_id,
|
| 247 |
+
"length_m": round(lateral_utm.length, 2),
|
| 248 |
+
"spacing_m": design["design_params"]["lateral_spacing_m"],
|
| 249 |
+
},
|
| 250 |
+
"geometry": _linestring_to_geojson(lateral_latlon),
|
| 251 |
+
})
|
| 252 |
+
|
| 253 |
+
# ββ 9. Build output FeatureCollection ββββββββββββββββββββββββββββ
|
| 254 |
+
output = {
|
| 255 |
+
"type": "FeatureCollection",
|
| 256 |
+
"properties": {
|
| 257 |
+
"type": "farm_design",
|
| 258 |
+
"farm_id": top_props.get("farm_id", "unknown"),
|
| 259 |
+
"generated_at": _iso_timestamp(),
|
| 260 |
+
"design_summary": {
|
| 261 |
+
"farm_area_ha": round(total_area_ha, 2),
|
| 262 |
+
"total_valves": len(valves),
|
| 263 |
+
"total_drip_tape_m": round(total_lateral_m, 2),
|
| 264 |
+
"total_main_line_m": round(total_main_m, 2),
|
| 265 |
+
"total_emitters": total_emitters,
|
| 266 |
+
"pump_hp": pump_hp,
|
| 267 |
+
"pump_flow_lph": round(calculate_pump_flow_lph(pump_hp), 2),
|
| 268 |
+
"manifold_strategy": choose_manifold_strategy(farm_utm.area),
|
| 269 |
+
},
|
| 270 |
+
"bom": total_bom,
|
| 271 |
+
"zone_details": zone_summaries,
|
| 272 |
+
},
|
| 273 |
+
"features": output_features,
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
return output
|
| 277 |
+
|
| 278 |
+
except (gj_io.GeoJSONError, ValveEngineError, DripLayoutError) as e:
|
| 279 |
+
# Structured error response (still valid GeoJSON)
|
| 280 |
+
return _error_response(str(e), type(e).__name__)
|
| 281 |
+
except Exception as e:
|
| 282 |
+
return _error_response(str(e), "INTERNAL_ERROR")
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 286 |
+
# Helpers
|
| 287 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 288 |
+
|
| 289 |
+
def _resolve_pump_hp(top_props: Dict, pump_props: Dict, features: List[Dict]) -> float:
|
| 290 |
+
"""Get pump HP from top-level, pump feature, or feature scan."""
|
| 291 |
+
# Top-level property takes precedence
|
| 292 |
+
if "pump_hp" in top_props and top_props["pump_hp"] is not None:
|
| 293 |
+
return float(top_props["pump_hp"])
|
| 294 |
+
# Pump feature property
|
| 295 |
+
if "pump_hp" in pump_props and pump_props["pump_hp"] is not None:
|
| 296 |
+
return float(pump_props["pump_hp"])
|
| 297 |
+
# Scan all features
|
| 298 |
+
hp = gj_io.validate_pump_hp(features)
|
| 299 |
+
if hp is not None:
|
| 300 |
+
return hp
|
| 301 |
+
raise DesignAPIError("No pump_hp found in input. Add 'pump_hp' to top-level properties or pump feature.")
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _resolve_centralized(top_props: Dict) -> bool:
|
| 305 |
+
"""Get centralized flag from top-level properties (default True)."""
|
| 306 |
+
val = top_props.get("centralized")
|
| 307 |
+
if isinstance(val, bool):
|
| 308 |
+
return val
|
| 309 |
+
if isinstance(val, str):
|
| 310 |
+
return val.lower() in ("true", "yes", "1", "centralized")
|
| 311 |
+
# Default: small farms centralized, large distributed
|
| 312 |
+
return True
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def _transform_point_to_utm(point: Point, reference_polygon: Polygon) -> Point:
|
| 316 |
+
"""Transform a lat/lon Point to the same UTM zone as reference_polygon."""
|
| 317 |
+
import pyproj
|
| 318 |
+
centroid = reference_polygon.centroid
|
| 319 |
+
lon, lat = centroid.x, centroid.y
|
| 320 |
+
utm_zone = int((lon + 180) / 6) + 1
|
| 321 |
+
is_southern = lat < 0
|
| 322 |
+
utm_crs = f"EPSG:{32700 + utm_zone if is_southern else 32600 + utm_zone}"
|
| 323 |
+
transformer = pyproj.Transformer.from_crs("EPSG:4326", utm_crs, always_xy=True)
|
| 324 |
+
x, y = transformer.transform(point.x, point.y)
|
| 325 |
+
return Point(x, y)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def _transform_point_from_utm(point: Point, reference_polygon: Polygon) -> Point:
|
| 329 |
+
"""Transform a UTM Point back to lat/lon using reference_polygon's zone."""
|
| 330 |
+
import pyproj
|
| 331 |
+
centroid = reference_polygon.centroid
|
| 332 |
+
lon, lat = centroid.x, centroid.y
|
| 333 |
+
utm_zone = int((lon + 180) / 6) + 1
|
| 334 |
+
is_southern = lat < 0
|
| 335 |
+
utm_crs = f"EPSG:{32700 + utm_zone if is_southern else 32600 + utm_zone}"
|
| 336 |
+
transformer = pyproj.Transformer.from_crs(utm_crs, "EPSG:4326", always_xy=True)
|
| 337 |
+
x, y = transformer.transform(point.x, point.y)
|
| 338 |
+
return Point(x, y)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def _transform_polygon_from_utm(polygon: Polygon, reference_polygon: Polygon) -> Polygon:
|
| 342 |
+
"""Transform a UTM Polygon back to lat/lon."""
|
| 343 |
+
import pyproj
|
| 344 |
+
centroid = reference_polygon.centroid
|
| 345 |
+
lon, lat = centroid.x, centroid.y
|
| 346 |
+
utm_zone = int((lon + 180) / 6) + 1
|
| 347 |
+
is_southern = lat < 0
|
| 348 |
+
utm_crs = f"EPSG:{32700 + utm_zone if is_southern else 32600 + utm_zone}"
|
| 349 |
+
transformer = pyproj.Transformer.from_crs(utm_crs, "EPSG:4326", always_xy=True)
|
| 350 |
+
coords = []
|
| 351 |
+
for x, y in polygon.exterior.coords:
|
| 352 |
+
lon_out, lat_out = transformer.transform(x, y)
|
| 353 |
+
coords.append((lon_out, lat_out))
|
| 354 |
+
return Polygon(coords)
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def _transform_linestring_from_utm(line: LineString, reference_polygon: Polygon) -> LineString:
|
| 358 |
+
"""Transform a UTM LineString back to lat/lon."""
|
| 359 |
+
import pyproj
|
| 360 |
+
centroid = reference_polygon.centroid
|
| 361 |
+
lon, lat = centroid.x, centroid.y
|
| 362 |
+
utm_zone = int((lon + 180) / 6) + 1
|
| 363 |
+
is_southern = lat < 0
|
| 364 |
+
utm_crs = f"EPSG:{32700 + utm_zone if is_southern else 32600 + utm_zone}"
|
| 365 |
+
transformer = pyproj.Transformer.from_crs(utm_crs, "EPSG:4326", always_xy=True)
|
| 366 |
+
coords = []
|
| 367 |
+
for x, y in line.coords:
|
| 368 |
+
lon_out, lat_out = transformer.transform(x, y)
|
| 369 |
+
coords.append((lon_out, lat_out))
|
| 370 |
+
return LineString(coords)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def _polygon_to_geojson(polygon: Polygon) -> Dict:
|
| 374 |
+
"""Convert Shapely Polygon to GeoJSON Polygon dict."""
|
| 375 |
+
coords = []
|
| 376 |
+
for x, y in polygon.exterior.coords:
|
| 377 |
+
coords.append([x, y])
|
| 378 |
+
return {"type": "Polygon", "coordinates": [coords]}
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def _linestring_to_geojson(line: LineString) -> Dict:
|
| 382 |
+
"""Convert Shapely LineString to GeoJSON LineString dict."""
|
| 383 |
+
coords = []
|
| 384 |
+
for x, y in line.coords:
|
| 385 |
+
coords.append([x, y])
|
| 386 |
+
return {"type": "LineString", "coordinates": coords}
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def _iso_timestamp() -> str:
|
| 390 |
+
"""Return current ISO timestamp string."""
|
| 391 |
+
from datetime import datetime, timezone
|
| 392 |
+
return datetime.now(timezone.utc).isoformat()
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def _error_response(message: str, code: str) -> Dict:
|
| 396 |
+
"""Return a valid GeoJSON FeatureCollection containing error info."""
|
| 397 |
+
return {
|
| 398 |
+
"type": "FeatureCollection",
|
| 399 |
+
"properties": {
|
| 400 |
+
"type": "farm_design_error",
|
| 401 |
+
"error": {
|
| 402 |
+
"code": code,
|
| 403 |
+
"message": message,
|
| 404 |
+
},
|
| 405 |
+
},
|
| 406 |
+
"features": [],
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 411 |
+
# Convenience functions
|
| 412 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 413 |
+
|
| 414 |
+
def process_from_file(file_path: str) -> str:
|
| 415 |
+
"""Read GeoJSON from file, run pipeline, return JSON string."""
|
| 416 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 417 |
+
geojson_str = f.read()
|
| 418 |
+
result = process_farm_design(geojson_str)
|
| 419 |
+
return json.dumps(result, indent=2)
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def process_from_string(geojson_str: str) -> str:
|
| 423 |
+
"""Run pipeline on GeoJSON string, return JSON string."""
|
| 424 |
+
result = process_farm_design(geojson_str)
|
| 425 |
+
return json.dumps(result, indent=2)
|