spacedout-bits commited on
Commit
10bd203
Β·
1 Parent(s): b9e0ad7

Phase 1.5 complete: GeoJSON pipeline + design API + dual-mode Gradio UI

Browse files

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

Files changed (2) hide show
  1. app.py +212 -152
  2. design_api.py +425 -0
app.py CHANGED
@@ -1,15 +1,21 @@
1
  """
2
- Farm Drip Irrigation Design Tool - Phase 1 (Geometry Engine Only)
3
 
4
- Simple Gradio UI to test the geometry engine before LLM integration.
5
- Accepts a farm boundary (geofence), generates drip layout, and displays BOM.
 
 
 
 
 
 
 
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: str, crop: str, headland_m: float, override_spacing: float):
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: dict, polygon_px: Polygon) -> Image.Image:
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
- # Add title and stats
130
- title = f"Farm Drip Layout - {design['crop'].title()}"
131
- area_text = f"Area: {design['farm_area_ha']:.2f} ha"
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
- # Build Gradio UI
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 test**: Input your farm boundary, get an irrigation layout with cost estimates.
148
 
149
- **Input format**: Geofence coordinates as text, e.g., `0,0;100,0;100,100;0,100` (pixel or meter coords)
150
-
151
- *Note: This is a pure geometry tool. LLM integration coming in Phase 2.*
152
  """
153
  )
154
 
155
- with gr.Row():
156
- with gr.Column(scale=1):
157
- gr.Markdown("### Farm Boundary Input")
158
- geofence_input = gr.Textbox(
159
- label="Geofence (x,y;x,y;x,y...)",
160
- value=DEFAULT_GEOFENCE,
161
- lines=3,
162
- info="Comma-separated coords, semicolon-separated points",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  )
164
 
165
- crop_dropdown = gr.Dropdown(
166
- label="Crop Type",
167
- choices=[
168
- "tomato",
169
- "pepper",
170
- "lettuce",
171
- "cucumber",
172
- "orchard",
173
- "generic",
174
  ],
175
- value=DEFAULT_CROP,
176
- info="Determines default lateral spacing & emitter discharge",
 
 
177
  )
178
 
179
- headland_slider = gr.Slider(
180
- label="Headland Buffer (m)",
181
- minimum=0.0,
182
- maximum=10.0,
183
- value=DEFAULT_HEADLAND,
184
- step=0.5,
185
- info="Inward buffer from field edge (for turning)",
186
- )
187
 
188
- spacing_slider = gr.Slider(
189
- label="Override Lateral Spacing (m)",
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
- with gr.Column(scale=1):
200
- gr.Markdown("### Design Visualization")
201
- design_image = gr.Image(label="Drip Layout", type="pil")
202
- status_text = gr.Textbox(label="Status", interactive=False)
203
 
204
- with gr.Row():
205
- with gr.Column():
206
- gr.Markdown("### Bill of Materials (USD)")
207
- bom_json = gr.Code(label="BOM (JSON)", language="json")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
 
209
- with gr.Column():
210
- gr.Markdown("### Design Summary")
211
- summary_text = gr.Textbox(label="Summary", lines=15, interactive=False)
 
 
212
 
213
- # Wire up button
214
- generate_btn.click(
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
- # Example geofences for quick testing
221
- gr.Markdown("### Example Geofences (copy & paste)")
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)