spacedout-bits Oz commited on
Commit
7ab5f0c
Β·
1 Parent(s): ec45bf2

Improve design_api accuracy: crop propagation, valve distribution, smart centralized default, BOM pricing, UTM caching

Browse files

- Add test_design_api.py with 24 end-to-end evaluation tests
- Propagate crop metadata from input zones to valves (round-robin assignment)
- Distribute valves along farm perimeter instead of stacking at one point
- Derive centralized/distributed default from farm area via choose_manifold_strategy
- Use PricingConfig for valve cost instead of hardcoded $15
- Cache UTM transformer per pipeline call (eliminate redundant CRS computations)

Co-Authored-By: Oz <oz-agent@warp.dev>

Files changed (3) hide show
  1. design_api.py +51 -54
  2. test_design_api.py +494 -0
  3. valve_engine.py +35 -3
design_api.py CHANGED
@@ -11,6 +11,8 @@ Output: GeoJSON FeatureCollection (valves, zones, mains, laterals, BOM)
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
@@ -21,6 +23,7 @@ from drip_engine import (
21
  CROP_DEFAULTS,
22
  DripLayoutError,
23
  )
 
24
  from valve_engine import (
25
  place_valves_hierarchical,
26
  generate_valve_zones,
@@ -66,7 +69,6 @@ def process_farm_design(geojson_input: str) -> Dict[str, Any]:
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
  max_valves = top_props.get("max_valves")
@@ -76,7 +78,14 @@ def process_farm_design(geojson_input: str) -> Dict[str, Any]:
76
  # ── 4. Convert to UTM for accurate calculations ─────────────────
77
  # Farm boundary lat/lon β†’ UTM
78
  farm_utm = latlon_to_utm(farm_boundary)
79
- pump_utm = _transform_point_to_utm(pump_point, farm_boundary)
 
 
 
 
 
 
 
80
 
81
  # Convert crop zone polygons to UTM
82
  crop_zones_utm = []
@@ -84,7 +93,7 @@ def process_farm_design(geojson_input: str) -> Dict[str, Any]:
84
  zone_poly = zone.get("polygon")
85
  if zone_poly is None:
86
  continue
87
- zone_utm = latlon_to_utm(zone_poly)
88
  crop_zones_utm.append({
89
  "crop": zone.get("crop", "generic"),
90
  "polygon": zone_utm,
@@ -157,7 +166,8 @@ def process_farm_design(geojson_input: str) -> Dict[str, Any]:
157
  total_main_m = sum(s.get("main_m", 0) for s in zone_summaries if "main_m" in s)
158
  total_lateral_m = sum(s.get("lateral_m", 0) for s in zone_summaries if "lateral_m" in s)
159
 
160
- # Aggregate BOM
 
161
  total_bom = {
162
  "main_line_16mm_m": round(sum(b.get("main_line_16mm_m", 0) for b in all_boms), 2),
163
  "drip_tape_16mm_m": round(sum(b.get("drip_tape_16mm_m", 0) for b in all_boms), 2),
@@ -169,7 +179,7 @@ def process_farm_design(geojson_input: str) -> Dict[str, Any]:
169
  total_bom["cost_main"] = round(sum(b.get("cost_main", 0) for b in all_boms), 2)
170
  total_bom["cost_drip_tape"] = round(sum(b.get("cost_drip_tape", 0) for b in all_boms), 2)
171
  total_bom["cost_emitters"] = round(sum(b.get("cost_emitters", 0) for b in all_boms), 2)
172
- total_bom["cost_valves"] = round(len(valves) * 15.0, 2) # $15 per valve estimate
173
  total_bom["total_cost_usd"] = round(
174
  total_bom.get("cost_main", 0)
175
  + total_bom.get("cost_drip_tape", 0)
@@ -192,7 +202,7 @@ def process_farm_design(geojson_input: str) -> Dict[str, Any]:
192
  # Valves (convert UTM points back to lat/lon)
193
  for valve in valves:
194
  valve_point_utm = valve["location"]
195
- valve_point_latlon = _transform_point_from_utm(valve_point_utm, farm_boundary)
196
  output_features.append({
197
  "type": "Feature",
198
  "properties": {
@@ -211,7 +221,7 @@ def process_farm_design(geojson_input: str) -> Dict[str, Any]:
211
  # Valve zones (convert UTM polygons back to lat/lon)
212
  for zone in zones:
213
  zone_poly_utm = zone["polygon"]
214
- zone_poly_latlon = _transform_polygon_from_utm(zone_poly_utm, farm_boundary)
215
  output_features.append({
216
  "type": "Feature",
217
  "properties": {
@@ -227,7 +237,7 @@ def process_farm_design(geojson_input: str) -> Dict[str, Any]:
227
  for valve_id, design in all_drip_designs:
228
  # Main line
229
  main_utm = design["main_line"]
230
- main_latlon = _transform_linestring_from_utm(main_utm, farm_boundary)
231
  output_features.append({
232
  "type": "Feature",
233
  "properties": {
@@ -241,7 +251,7 @@ def process_farm_design(geojson_input: str) -> Dict[str, Any]:
241
 
242
  # Laterals
243
  for i, lateral_utm in enumerate(design["laterals"]):
244
- lateral_latlon = _transform_linestring_from_utm(lateral_utm, farm_boundary)
245
  output_features.append({
246
  "type": "Feature",
247
  "properties": {
@@ -305,72 +315,59 @@ def _resolve_pump_hp(top_props: Dict, pump_props: Dict, features: List[Dict]) ->
305
  raise DesignAPIError("No pump_hp found in input. Add 'pump_hp' to top-level properties or pump feature.")
306
 
307
 
308
- def _resolve_centralized(top_props: Dict) -> bool:
309
- """Get centralized flag from top-level properties (default True)."""
 
 
 
 
 
310
  val = top_props.get("centralized")
311
  if isinstance(val, bool):
312
  return val
313
  if isinstance(val, str):
314
  return val.lower() in ("true", "yes", "1", "centralized")
315
- # Default: small farms centralized, large distributed
316
- return True
317
 
318
 
319
- def _transform_point_to_utm(point: Point, reference_polygon: Polygon) -> Point:
320
- """Transform a lat/lon Point to the same UTM zone as reference_polygon."""
321
- import pyproj
 
 
 
 
 
322
  centroid = reference_polygon.centroid
323
  lon, lat = centroid.x, centroid.y
324
  utm_zone = int((lon + 180) / 6) + 1
325
  is_southern = lat < 0
326
  utm_crs = f"EPSG:{32700 + utm_zone if is_southern else 32600 + utm_zone}"
327
- transformer = pyproj.Transformer.from_crs("EPSG:4326", utm_crs, always_xy=True)
328
- x, y = transformer.transform(point.x, point.y)
329
- return Point(x, y)
330
 
331
 
332
- def _transform_point_from_utm(point: Point, reference_polygon: Polygon) -> Point:
333
- """Transform a UTM Point back to lat/lon using reference_polygon's zone."""
334
- import pyproj
335
- centroid = reference_polygon.centroid
336
- lon, lat = centroid.x, centroid.y
337
- utm_zone = int((lon + 180) / 6) + 1
338
- is_southern = lat < 0
339
- utm_crs = f"EPSG:{32700 + utm_zone if is_southern else 32600 + utm_zone}"
340
- transformer = pyproj.Transformer.from_crs(utm_crs, "EPSG:4326", always_xy=True)
341
  x, y = transformer.transform(point.x, point.y)
342
  return Point(x, y)
343
 
344
 
345
- def _transform_polygon_from_utm(polygon: Polygon, reference_polygon: Polygon) -> Polygon:
346
- """Transform a UTM Polygon back to lat/lon."""
347
- import pyproj
348
- centroid = reference_polygon.centroid
349
- lon, lat = centroid.x, centroid.y
350
- utm_zone = int((lon + 180) / 6) + 1
351
- is_southern = lat < 0
352
- utm_crs = f"EPSG:{32700 + utm_zone if is_southern else 32600 + utm_zone}"
353
- transformer = pyproj.Transformer.from_crs(utm_crs, "EPSG:4326", always_xy=True)
354
- coords = []
355
- for x, y in polygon.exterior.coords:
356
- lon_out, lat_out = transformer.transform(x, y)
357
- coords.append((lon_out, lat_out))
358
  return Polygon(coords)
359
 
360
 
361
- def _transform_linestring_from_utm(line: LineString, reference_polygon: Polygon) -> LineString:
362
- """Transform a UTM LineString back to lat/lon."""
363
- import pyproj
364
- centroid = reference_polygon.centroid
365
- lon, lat = centroid.x, centroid.y
366
- utm_zone = int((lon + 180) / 6) + 1
367
- is_southern = lat < 0
368
- utm_crs = f"EPSG:{32700 + utm_zone if is_southern else 32600 + utm_zone}"
369
- transformer = pyproj.Transformer.from_crs(utm_crs, "EPSG:4326", always_xy=True)
370
- coords = []
371
- for x, y in line.coords:
372
- lon_out, lat_out = transformer.transform(x, y)
373
- coords.append((lon_out, lat_out))
374
  return LineString(coords)
375
 
376
 
 
11
  import json
12
  import math
13
  from typing import Dict, List, Any, Tuple, Optional
14
+
15
+ import pyproj
16
  from shapely.geometry import Polygon, Point, LineString
17
 
18
  import geojson_io as gj_io
 
23
  CROP_DEFAULTS,
24
  DripLayoutError,
25
  )
26
+ from pricing_config import get_default_pricing_config
27
  from valve_engine import (
28
  place_valves_hierarchical,
29
  generate_valve_zones,
 
69
 
70
  # ── 3. Resolve parameters (top-level props override feature props)
71
  pump_hp = _resolve_pump_hp(top_props, pump_props, features)
 
72
  headland_m = top_props.get("headland_buffer_m", 1.0)
73
  override_spacing = top_props.get("override_lateral_spacing_m")
74
  max_valves = top_props.get("max_valves")
 
78
  # ── 4. Convert to UTM for accurate calculations ─────────────────
79
  # Farm boundary lat/lon β†’ UTM
80
  farm_utm = latlon_to_utm(farm_boundary)
81
+
82
+ # Build a reusable UTM transformer (computed once, used everywhere)
83
+ utm_crs, transformer_to_utm, transformer_from_utm = _build_utm_transformers(farm_boundary)
84
+
85
+ pump_utm = _apply_transform(pump_point, transformer_to_utm)
86
+
87
+ # Resolve centralized flag β€” derive from farm area if not explicit
88
+ centralized = _resolve_centralized(top_props, farm_utm.area)
89
 
90
  # Convert crop zone polygons to UTM
91
  crop_zones_utm = []
 
93
  zone_poly = zone.get("polygon")
94
  if zone_poly is None:
95
  continue
96
+ zone_utm = _apply_polygon_transform(zone_poly, transformer_to_utm)
97
  crop_zones_utm.append({
98
  "crop": zone.get("crop", "generic"),
99
  "polygon": zone_utm,
 
166
  total_main_m = sum(s.get("main_m", 0) for s in zone_summaries if "main_m" in s)
167
  total_lateral_m = sum(s.get("lateral_m", 0) for s in zone_summaries if "lateral_m" in s)
168
 
169
+ # Aggregate BOM β€” use pricing config for valve cost
170
+ pricing = get_default_pricing_config()
171
  total_bom = {
172
  "main_line_16mm_m": round(sum(b.get("main_line_16mm_m", 0) for b in all_boms), 2),
173
  "drip_tape_16mm_m": round(sum(b.get("drip_tape_16mm_m", 0) for b in all_boms), 2),
 
179
  total_bom["cost_main"] = round(sum(b.get("cost_main", 0) for b in all_boms), 2)
180
  total_bom["cost_drip_tape"] = round(sum(b.get("cost_drip_tape", 0) for b in all_boms), 2)
181
  total_bom["cost_emitters"] = round(sum(b.get("cost_emitters", 0) for b in all_boms), 2)
182
+ total_bom["cost_valves"] = round(len(valves) * pricing.get_price("valve"), 2)
183
  total_bom["total_cost_usd"] = round(
184
  total_bom.get("cost_main", 0)
185
  + total_bom.get("cost_drip_tape", 0)
 
202
  # Valves (convert UTM points back to lat/lon)
203
  for valve in valves:
204
  valve_point_utm = valve["location"]
205
+ valve_point_latlon = _apply_transform(valve_point_utm, transformer_from_utm)
206
  output_features.append({
207
  "type": "Feature",
208
  "properties": {
 
221
  # Valve zones (convert UTM polygons back to lat/lon)
222
  for zone in zones:
223
  zone_poly_utm = zone["polygon"]
224
+ zone_poly_latlon = _apply_polygon_transform(zone_poly_utm, transformer_from_utm)
225
  output_features.append({
226
  "type": "Feature",
227
  "properties": {
 
237
  for valve_id, design in all_drip_designs:
238
  # Main line
239
  main_utm = design["main_line"]
240
+ main_latlon = _apply_linestring_transform(main_utm, transformer_from_utm)
241
  output_features.append({
242
  "type": "Feature",
243
  "properties": {
 
251
 
252
  # Laterals
253
  for i, lateral_utm in enumerate(design["laterals"]):
254
+ lateral_latlon = _apply_linestring_transform(lateral_utm, transformer_from_utm)
255
  output_features.append({
256
  "type": "Feature",
257
  "properties": {
 
315
  raise DesignAPIError("No pump_hp found in input. Add 'pump_hp' to top-level properties or pump feature.")
316
 
317
 
318
+ def _resolve_centralized(top_props: Dict, farm_area_m2: float = 0) -> bool:
319
+ """Get centralized flag from top-level properties.
320
+
321
+ When no explicit flag is provided, derives the default from
322
+ ``choose_manifold_strategy`` based on farm area (< 1 ha β†’ centralized,
323
+ β‰₯ 1 ha β†’ distributed).
324
+ """
325
  val = top_props.get("centralized")
326
  if isinstance(val, bool):
327
  return val
328
  if isinstance(val, str):
329
  return val.lower() in ("true", "yes", "1", "centralized")
330
+ # Derive from farm area: consistent with choose_manifold_strategy
331
+ return choose_manifold_strategy(farm_area_m2) == "centralized"
332
 
333
 
334
+ def _build_utm_transformers(
335
+ reference_polygon: Polygon,
336
+ ) -> tuple:
337
+ """Compute UTM CRS from a lat/lon polygon and return reusable transformers.
338
+
339
+ Returns:
340
+ (utm_crs_string, transformer_to_utm, transformer_from_utm)
341
+ """
342
  centroid = reference_polygon.centroid
343
  lon, lat = centroid.x, centroid.y
344
  utm_zone = int((lon + 180) / 6) + 1
345
  is_southern = lat < 0
346
  utm_crs = f"EPSG:{32700 + utm_zone if is_southern else 32600 + utm_zone}"
347
+ to_utm = pyproj.Transformer.from_crs("EPSG:4326", utm_crs, always_xy=True)
348
+ from_utm = pyproj.Transformer.from_crs(utm_crs, "EPSG:4326", always_xy=True)
349
+ return utm_crs, to_utm, from_utm
350
 
351
 
352
+ def _apply_transform(point: Point, transformer: "pyproj.Transformer") -> Point:
353
+ """Transform a Point using a precomputed pyproj Transformer."""
 
 
 
 
 
 
 
354
  x, y = transformer.transform(point.x, point.y)
355
  return Point(x, y)
356
 
357
 
358
+ def _apply_polygon_transform(
359
+ polygon: Polygon, transformer: "pyproj.Transformer"
360
+ ) -> Polygon:
361
+ """Transform a Polygon using a precomputed pyproj Transformer."""
362
+ coords = [transformer.transform(x, y) for x, y in polygon.exterior.coords]
 
 
 
 
 
 
 
 
363
  return Polygon(coords)
364
 
365
 
366
+ def _apply_linestring_transform(
367
+ line: LineString, transformer: "pyproj.Transformer"
368
+ ) -> LineString:
369
+ """Transform a LineString using a precomputed pyproj Transformer."""
370
+ coords = [transformer.transform(x, y) for x, y in line.coords]
 
 
 
 
 
 
 
 
371
  return LineString(coords)
372
 
373
 
test_design_api.py ADDED
@@ -0,0 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ End-to-end evaluation tests for design_api.py.
3
+
4
+ Tests the full pipeline: GeoJSON Input β†’ Parse β†’ Valve Placement β†’ Drip Layout β†’ GeoJSON Output.
5
+ Validates structure, crop propagation, valve strategy, BOM accuracy, and design quality metrics.
6
+ """
7
+
8
+ import json
9
+ import math
10
+ import pytest
11
+ from pathlib import Path
12
+
13
+ from design_api import process_farm_design, DesignAPIError
14
+
15
+
16
+ # ──────────────────────────────────────────────────────────────────────
17
+ # Fixtures β€” reusable GeoJSON inputs
18
+ # ──────────────────────────────────────────────────────────────────────
19
+
20
+ def _make_feature_collection(features, properties=None):
21
+ """Build a minimal GeoJSON FeatureCollection dict."""
22
+ fc = {"type": "FeatureCollection", "features": features}
23
+ if properties:
24
+ fc["properties"] = properties
25
+ return fc
26
+
27
+
28
+ def _make_polygon_feature(coords, props):
29
+ """Build a GeoJSON Polygon Feature."""
30
+ return {
31
+ "type": "Feature",
32
+ "properties": props,
33
+ "geometry": {
34
+ "type": "Polygon",
35
+ "coordinates": [coords],
36
+ },
37
+ }
38
+
39
+
40
+ def _make_point_feature(lon, lat, props):
41
+ """Build a GeoJSON Point Feature."""
42
+ return {
43
+ "type": "Feature",
44
+ "properties": props,
45
+ "geometry": {
46
+ "type": "Point",
47
+ "coordinates": [lon, lat],
48
+ },
49
+ }
50
+
51
+
52
+ # ~155m Γ— 155m rectangle near Bangalore (~2.4 ha)
53
+ FARM_BOUNDARY_COORDS = [
54
+ [77.5946, 12.9716],
55
+ [77.5960, 12.9716],
56
+ [77.5960, 12.9730],
57
+ [77.5946, 12.9730],
58
+ [77.5946, 12.9716],
59
+ ]
60
+
61
+ PUMP_LON, PUMP_LAT = 77.5946, 12.9716
62
+
63
+
64
+ @pytest.fixture
65
+ def single_crop_input():
66
+ """Single tomato crop covering the full farm."""
67
+ features = [
68
+ _make_polygon_feature(FARM_BOUNDARY_COORDS, {"type": "farm_boundary"}),
69
+ _make_point_feature(PUMP_LON, PUMP_LAT, {"type": "pump", "pump_hp": 5.0}),
70
+ _make_polygon_feature(FARM_BOUNDARY_COORDS, {"type": "crop_zone", "crop": "tomato"}),
71
+ ]
72
+ return _make_feature_collection(features, {"pump_hp": 5.0, "headland_buffer_m": 1.0})
73
+
74
+
75
+ @pytest.fixture
76
+ def multi_crop_input():
77
+ """Two crop zones: tomato (west half) and lettuce (east half)."""
78
+ west_coords = [
79
+ [77.5946, 12.9716],
80
+ [77.5953, 12.9716],
81
+ [77.5953, 12.9730],
82
+ [77.5946, 12.9730],
83
+ [77.5946, 12.9716],
84
+ ]
85
+ east_coords = [
86
+ [77.5953, 12.9716],
87
+ [77.5960, 12.9716],
88
+ [77.5960, 12.9730],
89
+ [77.5953, 12.9730],
90
+ [77.5953, 12.9716],
91
+ ]
92
+ features = [
93
+ _make_polygon_feature(FARM_BOUNDARY_COORDS, {"type": "farm_boundary"}),
94
+ _make_point_feature(PUMP_LON, PUMP_LAT, {"type": "pump", "pump_hp": 5.0}),
95
+ _make_polygon_feature(west_coords, {"type": "crop_zone", "crop": "tomato"}),
96
+ _make_polygon_feature(east_coords, {"type": "crop_zone", "crop": "lettuce"}),
97
+ ]
98
+ return _make_feature_collection(features, {"pump_hp": 5.0, "headland_buffer_m": 1.0})
99
+
100
+
101
+ @pytest.fixture
102
+ def elevation_input():
103
+ """Farm with significant elevation delta (>5m threshold)."""
104
+ features = [
105
+ _make_polygon_feature(FARM_BOUNDARY_COORDS, {"type": "farm_boundary"}),
106
+ _make_point_feature(PUMP_LON, PUMP_LAT, {"type": "pump", "pump_hp": 5.0}),
107
+ _make_point_feature(77.5953, 12.9723, {
108
+ "type": "elevation",
109
+ "min_elevation_m": 900,
110
+ "max_elevation_m": 910,
111
+ }),
112
+ ]
113
+ return _make_feature_collection(features, {"pump_hp": 5.0})
114
+
115
+
116
+ # ──────────────────────────────────────────────────────────────────────
117
+ # Helpers
118
+ # ──────────────────────────────────────────────────────────────────────
119
+
120
+ def _features_by_type(result, feature_type):
121
+ """Filter output features by properties.type."""
122
+ return [f for f in result.get("features", []) if f["properties"].get("type") == feature_type]
123
+
124
+
125
+ def _run_pipeline(geojson_input):
126
+ """Run the design pipeline, accepting dict or string."""
127
+ if isinstance(geojson_input, dict):
128
+ geojson_input = json.dumps(geojson_input)
129
+ return process_farm_design(geojson_input)
130
+
131
+
132
+ # ──────────────────────────────────────────────────────────────────────
133
+ # Test 1: Round-trip with sample input
134
+ # ───────────────────────────────────���──────────────────────────────────
135
+
136
+ class TestRoundTrip:
137
+ """Validate end-to-end pipeline with the project's sample input."""
138
+
139
+ def test_sample_input_produces_valid_output(self):
140
+ """samples/input_example.json β†’ valid FeatureCollection with all expected layers."""
141
+ sample_path = Path(__file__).parent / "samples" / "input_example.json"
142
+ if not sample_path.exists():
143
+ pytest.skip("samples/input_example.json not found")
144
+
145
+ result = _run_pipeline(sample_path.read_text())
146
+
147
+ assert result["type"] == "FeatureCollection"
148
+ assert "properties" in result
149
+ assert result["properties"]["type"] == "farm_design"
150
+
151
+ def test_output_has_required_layers(self):
152
+ """Output must contain farm_boundary, valve, valve_zone, main_line, lateral features."""
153
+ sample_path = Path(__file__).parent / "samples" / "input_example.json"
154
+ if not sample_path.exists():
155
+ pytest.skip("samples/input_example.json not found")
156
+
157
+ result = _run_pipeline(sample_path.read_text())
158
+ feature_types = {f["properties"].get("type") for f in result["features"]}
159
+
160
+ assert "farm_boundary" in feature_types
161
+ assert "valve" in feature_types
162
+ assert "valve_zone" in feature_types
163
+ assert "main_line" in feature_types
164
+ assert "lateral" in feature_types
165
+
166
+ def test_output_has_bom(self):
167
+ """Output properties must include BOM with cost fields."""
168
+ sample_path = Path(__file__).parent / "samples" / "input_example.json"
169
+ if not sample_path.exists():
170
+ pytest.skip("samples/input_example.json not found")
171
+
172
+ result = _run_pipeline(sample_path.read_text())
173
+ bom = result["properties"]["bom"]
174
+
175
+ assert "main_line_16mm_m" in bom
176
+ assert "drip_tape_16mm_m" in bom
177
+ assert "inline_emitters" in bom
178
+ assert "valves_count" in bom
179
+ assert bom["valves_count"] >= 1
180
+
181
+ def test_output_has_design_summary(self):
182
+ """Output properties must include design_summary with key metrics."""
183
+ sample_path = Path(__file__).parent / "samples" / "input_example.json"
184
+ if not sample_path.exists():
185
+ pytest.skip("samples/input_example.json not found")
186
+
187
+ result = _run_pipeline(sample_path.read_text())
188
+ summary = result["properties"]["design_summary"]
189
+
190
+ assert "farm_area_ha" in summary
191
+ assert summary["farm_area_ha"] > 0
192
+ assert "total_valves" in summary
193
+ assert summary["total_valves"] >= 1
194
+ assert "pump_hp" in summary
195
+ assert summary["pump_flow_lph"] > 0
196
+
197
+
198
+ # ──────────────────────────────────────────────────────────────────────
199
+ # Test 2 & 3: Crop propagation
200
+ # ──────────────────────────────────────────────────────────────────────
201
+
202
+ class TestCropPropagation:
203
+ """Verify crop metadata flows through the pipeline to zone designs."""
204
+
205
+ def test_single_crop_appears_in_valves(self, single_crop_input):
206
+ """When input has one crop, all valves should reference that crop."""
207
+ result = _run_pipeline(single_crop_input)
208
+ valves = _features_by_type(result, "valve")
209
+
210
+ assert len(valves) >= 1
211
+ for valve in valves:
212
+ assert valve["properties"]["crop"] == "tomato"
213
+
214
+ def test_single_crop_in_main_lines(self, single_crop_input):
215
+ """Main lines should carry the crop type from their zone."""
216
+ result = _run_pipeline(single_crop_input)
217
+ mains = _features_by_type(result, "main_line")
218
+
219
+ assert len(mains) >= 1
220
+ for main in mains:
221
+ assert "crop" in main["properties"]
222
+
223
+ def test_multi_crop_has_both_crops_in_zone_details(self, multi_crop_input):
224
+ """Multi-crop input should produce zone_details mentioning both crops."""
225
+ result = _run_pipeline(multi_crop_input)
226
+ zone_details = result["properties"].get("zone_details", [])
227
+
228
+ crops_seen = {z.get("crop") for z in zone_details}
229
+ # Both crops must appear in the zone details
230
+ assert "tomato" in crops_seen, f"Expected 'tomato' in zone crops, got {crops_seen}"
231
+ assert "lettuce" in crops_seen, f"Expected 'lettuce' in zone crops, got {crops_seen}"
232
+
233
+ def test_multi_crop_valve_count_at_least_crop_count(self, multi_crop_input):
234
+ """With 2 crops, need at least 2 valves (crop constraint)."""
235
+ result = _run_pipeline(multi_crop_input)
236
+ valves = _features_by_type(result, "valve")
237
+ assert len(valves) >= 2
238
+
239
+
240
+ # ───────────────────────────────────────────────────────────────��──────
241
+ # Test 4: Centralized vs. distributed
242
+ # ──────────────────────────────────────────────────────────────────────
243
+
244
+ class TestValveStrategy:
245
+ """Verify centralized/distributed flag is respected."""
246
+
247
+ def test_explicit_centralized(self):
248
+ """centralized=true should produce centralized valves."""
249
+ features = [
250
+ _make_polygon_feature(FARM_BOUNDARY_COORDS, {"type": "farm_boundary"}),
251
+ _make_point_feature(PUMP_LON, PUMP_LAT, {"type": "pump", "pump_hp": 5.0}),
252
+ ]
253
+ fc = _make_feature_collection(features, {
254
+ "pump_hp": 5.0,
255
+ "centralized": True,
256
+ })
257
+ result = _run_pipeline(fc)
258
+ valves = _features_by_type(result, "valve")
259
+
260
+ assert len(valves) >= 1
261
+ assert all(v["properties"]["strategy"] == "centralized" for v in valves)
262
+
263
+ def test_explicit_distributed(self):
264
+ """centralized=false should produce distributed valves."""
265
+ features = [
266
+ _make_polygon_feature(FARM_BOUNDARY_COORDS, {"type": "farm_boundary"}),
267
+ _make_point_feature(PUMP_LON, PUMP_LAT, {"type": "pump", "pump_hp": 5.0}),
268
+ ]
269
+ fc = _make_feature_collection(features, {
270
+ "pump_hp": 5.0,
271
+ "centralized": False,
272
+ })
273
+ result = _run_pipeline(fc)
274
+ valves = _features_by_type(result, "valve")
275
+
276
+ assert len(valves) >= 1
277
+ assert all(v["properties"]["strategy"] == "distributed" for v in valves)
278
+
279
+ def test_default_strategy_uses_farm_area(self):
280
+ """Without explicit centralized flag, strategy should reflect farm area."""
281
+ features = [
282
+ _make_polygon_feature(FARM_BOUNDARY_COORDS, {"type": "farm_boundary"}),
283
+ _make_point_feature(PUMP_LON, PUMP_LAT, {"type": "pump", "pump_hp": 5.0}),
284
+ ]
285
+ # No centralized key at all
286
+ fc = _make_feature_collection(features, {"pump_hp": 5.0})
287
+ result = _run_pipeline(fc)
288
+
289
+ # Farm is ~2.4 ha β†’ should default to distributed (>= 1 ha)
290
+ strategy = result["properties"]["design_summary"]["manifold_strategy"]
291
+ valves = _features_by_type(result, "valve")
292
+ # Valve strategy must be consistent with manifold_strategy
293
+ assert strategy == "distributed", f"~2.4ha farm should be distributed, got {strategy}"
294
+ assert all(
295
+ v["properties"]["strategy"] == "distributed" for v in valves
296
+ ), "Valve strategy should match manifold_strategy when no explicit flag set"
297
+
298
+
299
+ # ──────────────────────────────────────────────────────────────────────
300
+ # Test 5: Elevation split
301
+ # ──────────────────────────────────────────────────────────────────────
302
+
303
+ class TestElevationSplit:
304
+ """Verify topography-driven zone splitting."""
305
+
306
+ def test_elevation_delta_adds_valve(self, elevation_input):
307
+ """10m elevation delta (> 5m threshold) should add at least one extra valve."""
308
+ result_elevated = _run_pipeline(elevation_input)
309
+ valves_elevated = _features_by_type(result_elevated, "valve")
310
+
311
+ # Compare against same farm without elevation data
312
+ features_flat = [
313
+ _make_polygon_feature(FARM_BOUNDARY_COORDS, {"type": "farm_boundary"}),
314
+ _make_point_feature(PUMP_LON, PUMP_LAT, {"type": "pump", "pump_hp": 5.0}),
315
+ ]
316
+ fc_flat = _make_feature_collection(features_flat, {"pump_hp": 5.0})
317
+ result_flat = _run_pipeline(fc_flat)
318
+ valves_flat = _features_by_type(result_flat, "valve")
319
+
320
+ assert len(valves_elevated) >= len(valves_flat)
321
+
322
+
323
+ # ──────────────────────────────────────────────────────────────────────
324
+ # Test 6: BOM accuracy
325
+ # ──────────────────────────────────────────────────────────────────────
326
+
327
+ class TestBOMAccuracy:
328
+ """Verify BOM totals are internally consistent."""
329
+
330
+ def test_bom_valves_count_matches_valve_features(self, single_crop_input):
331
+ """BOM valves_count should equal number of valve features in output."""
332
+ result = _run_pipeline(single_crop_input)
333
+ bom = result["properties"]["bom"]
334
+ valve_features = _features_by_type(result, "valve")
335
+
336
+ assert bom["valves_count"] == len(valve_features)
337
+
338
+ def test_bom_pipe_total_is_sum(self, single_crop_input):
339
+ """total_pipe_m should equal main_line + drip_tape."""
340
+ result = _run_pipeline(single_crop_input)
341
+ bom = result["properties"]["bom"]
342
+
343
+ expected_total = bom["main_line_16mm_m"] + bom["drip_tape_16mm_m"]
344
+ assert abs(bom["total_pipe_m"] - expected_total) < 0.1
345
+
346
+ def test_bom_cost_breakdown_sums_to_total(self, single_crop_input):
347
+ """If cost fields present, component costs should sum to total."""
348
+ result = _run_pipeline(single_crop_input)
349
+ bom = result["properties"]["bom"]
350
+
351
+ if "total_cost_usd" in bom:
352
+ component_sum = (
353
+ bom.get("cost_main", 0)
354
+ + bom.get("cost_drip_tape", 0)
355
+ + bom.get("cost_emitters", 0)
356
+ + bom.get("cost_valves", 0)
357
+ )
358
+ assert abs(bom["total_cost_usd"] - component_sum) < 0.1
359
+
360
+ def test_bom_quantities_positive(self, single_crop_input):
361
+ """All BOM quantities should be positive for a valid farm."""
362
+ result = _run_pipeline(single_crop_input)
363
+ bom = result["properties"]["bom"]
364
+
365
+ assert bom["main_line_16mm_m"] > 0
366
+ assert bom["drip_tape_16mm_m"] > 0
367
+ assert bom["inline_emitters"] > 0
368
+ assert bom["total_pipe_m"] > 0
369
+
370
+
371
+ # ──────────────────────────────────────────────────────────────────────
372
+ # Test 7: Error cases
373
+ # ──────────────────────────────────────────────────────────────────────
374
+
375
+ class TestErrorCases:
376
+ """Verify pipeline returns structured errors for bad input."""
377
+
378
+ def test_invalid_json_returns_error(self):
379
+ """Completely invalid JSON should return error response."""
380
+ result = process_farm_design("not json at all")
381
+ assert result["properties"]["type"] == "farm_design_error"
382
+
383
+ def test_missing_boundary_returns_error(self):
384
+ """FeatureCollection with no polygon should return error."""
385
+ fc = _make_feature_collection([
386
+ _make_point_feature(77.5, 12.9, {"type": "pump", "pump_hp": 5.0}),
387
+ ], {"pump_hp": 5.0})
388
+ result = _run_pipeline(fc)
389
+ assert result["properties"]["type"] == "farm_design_error"
390
+
391
+ def test_missing_pump_returns_error(self):
392
+ """FeatureCollection with no pump point should return error."""
393
+ fc = _make_feature_collection([
394
+ _make_polygon_feature(FARM_BOUNDARY_COORDS, {"type": "farm_boundary"}),
395
+ ])
396
+ # No pump_hp anywhere
397
+ result = _run_pipeline(fc)
398
+ assert result["properties"]["type"] == "farm_design_error"
399
+
400
+ def test_empty_features_returns_error(self):
401
+ """Empty features array should return error."""
402
+ fc = {"type": "FeatureCollection", "features": [], "properties": {"pump_hp": 5.0}}
403
+ result = _run_pipeline(fc)
404
+ assert result["properties"]["type"] == "farm_design_error"
405
+
406
+
407
+ # ──────────────────────────────────────────────────────────────────────
408
+ # Test 8: Design quality β€” lateral uniformity
409
+ # ──────────────────────────────────────────────────────────────────────
410
+
411
+ class TestDesignQuality:
412
+ """Metrics-based evaluation of design output quality."""
413
+
414
+ def test_lateral_lengths_are_positive(self, single_crop_input):
415
+ """All laterals should have positive length."""
416
+ result = _run_pipeline(single_crop_input)
417
+ laterals = _features_by_type(result, "lateral")
418
+
419
+ assert len(laterals) > 0
420
+ for lat in laterals:
421
+ assert lat["properties"]["length_m"] > 0
422
+
423
+ def test_lateral_uniformity_within_zone(self, single_crop_input):
424
+ """Within each valve zone, lateral lengths should not vary more than 10x.
425
+
426
+ This is a soft quality metric β€” extreme variation indicates dead zones.
427
+ The 10x threshold is generous; DESIGN_LOGIC.md recommends < 1.5x.
428
+ """
429
+ result = _run_pipeline(single_crop_input)
430
+ laterals = _features_by_type(result, "lateral")
431
+
432
+ if len(laterals) < 2:
433
+ pytest.skip("Not enough laterals to measure uniformity")
434
+
435
+ # Group laterals by valve_id
436
+ by_valve = {}
437
+ for lat in laterals:
438
+ vid = lat["properties"]["valve_id"]
439
+ by_valve.setdefault(vid, []).append(lat["properties"]["length_m"])
440
+
441
+ for valve_id, lengths in by_valve.items():
442
+ if len(lengths) < 2:
443
+ continue
444
+ min_len = min(lengths)
445
+ max_len = max(lengths)
446
+ if min_len > 0:
447
+ ratio = max_len / min_len
448
+ # Generous threshold: flag only extreme non-uniformity
449
+ assert ratio < 10, (
450
+ f"Valve {valve_id}: lateral length ratio {ratio:.1f}x "
451
+ f"(min={min_len:.1f}m, max={max_len:.1f}m)"
452
+ )
453
+
454
+ def test_main_line_within_farm_bounds(self, single_crop_input):
455
+ """Main line endpoints should be within the farm boundary extent."""
456
+ result = _run_pipeline(single_crop_input)
457
+ boundary = _features_by_type(result, "farm_boundary")
458
+ mains = _features_by_type(result, "main_line")
459
+
460
+ if not boundary or not mains:
461
+ pytest.skip("Missing boundary or main_line features")
462
+
463
+ # Extract lon/lat bounds from farm boundary
464
+ farm_coords = boundary[0]["geometry"]["coordinates"][0]
465
+ lons = [c[0] for c in farm_coords]
466
+ lats = [c[1] for c in farm_coords]
467
+ lon_min, lon_max = min(lons), max(lons)
468
+ lat_min, lat_max = min(lats), max(lats)
469
+
470
+ # Small tolerance for coordinate transform rounding
471
+ tol = 0.001 # ~111m at equator β€” generous for transform artifacts
472
+ for main in mains:
473
+ for coord in main["geometry"]["coordinates"]:
474
+ assert lon_min - tol <= coord[0] <= lon_max + tol, (
475
+ f"Main line lon {coord[0]} outside farm bounds [{lon_min}, {lon_max}]"
476
+ )
477
+ assert lat_min - tol <= coord[1] <= lat_max + tol, (
478
+ f"Main line lat {coord[1]} outside farm bounds [{lat_min}, {lat_max}]"
479
+ )
480
+
481
+ def test_zone_count_matches_valve_count(self, single_crop_input):
482
+ """Number of valve_zone features should match number of valve features."""
483
+ result = _run_pipeline(single_crop_input)
484
+ valves = _features_by_type(result, "valve")
485
+ zones = _features_by_type(result, "valve_zone")
486
+
487
+ # Zones may be fewer if some valves get merged zones, but should never exceed
488
+ assert len(zones) <= len(valves)
489
+ # And there should be at least one zone
490
+ assert len(zones) >= 1
491
+
492
+
493
+ if __name__ == "__main__":
494
+ pytest.main([__file__, "-v"])
valve_engine.py CHANGED
@@ -408,6 +408,16 @@ def place_valves_hierarchical(
408
  # Strategy choice
409
  strategy = "centralized" if centralized else "distributed"
410
 
 
 
 
 
 
 
 
 
 
 
411
  # Place valves
412
  for i in range(num_zones_required):
413
  zone_id = f"valve_{zone_counter:03d}"
@@ -425,9 +435,31 @@ def place_valves_hierarchical(
425
  else:
426
  reason = "hydraulics_split"
427
 
428
- valve = place_valve_for_zone(
429
- farm_polygon, pump_point, zone_id, strategy=strategy, reason=reason
430
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  valves.append(valve)
432
 
433
  return valves
 
408
  # Strategy choice
409
  strategy = "centralized" if centralized else "distributed"
410
 
411
+ # Build crop list for assignment β€” distribute valves across crops
412
+ # proportional to zone count, with each crop getting at least one valve.
413
+ unique_crops = list(dict.fromkeys(z.get("crop", "generic") for z in crop_zones))
414
+
415
+ # Pre-compute evenly-spaced perimeter positions so that Voronoi
416
+ # partitioning produces distinct zones. For centralized, fan valves
417
+ # out from the pump; for distributed, space them along the boundary.
418
+ perimeter = farm_polygon.boundary
419
+ perimeter_len = perimeter.length
420
+
421
  # Place valves
422
  for i in range(num_zones_required):
423
  zone_id = f"valve_{zone_counter:03d}"
 
435
  else:
436
  reason = "hydraulics_split"
437
 
438
+ # Assign crop: round-robin across unique crops
439
+ crop = unique_crops[i % len(unique_crops)] if unique_crops else "generic"
440
+
441
+ if strategy == "centralized":
442
+ # Fan out from pump along perimeter to ensure distinct locations
443
+ fraction = i / max(num_zones_required, 1)
444
+ valve_point = perimeter.interpolate(fraction * perimeter_len)
445
+ # Shift slightly inward toward pump to keep "near pump" intent
446
+ cx = (valve_point.x + pump_point.x) / 2
447
+ cy = (valve_point.y + pump_point.y) / 2
448
+ valve_point = Point(cx, cy)
449
+ else:
450
+ # Distributed: space evenly along perimeter
451
+ fraction = i / max(num_zones_required, 1)
452
+ valve_point = perimeter.interpolate(fraction * perimeter_len)
453
+
454
+ valve = {
455
+ "id": zone_id,
456
+ "location": valve_point,
457
+ "lat": valve_point.y,
458
+ "lon": valve_point.x,
459
+ "strategy": strategy,
460
+ "reason": reason,
461
+ "crop": crop,
462
+ }
463
  valves.append(valve)
464
 
465
  return valves