spacedout-bits Oz commited on
Commit
af2a5ec
·
1 Parent(s): 093e740

Fix zigzag zone boundaries: correct strip generation coordinate math

Browse files

Root cause: _generate_strip_zones used bounding-box diagonal (~94m) as
the lateral extent for strip rectangles, but UTM coordinates are large
(~781000, ~1435000), so the actual lateral projection values are ~-796000.
The strip rectangles were constructed far from the farm polygon, producing
zero intersections and triggering the legacy Voronoi grid fallback — which
creates the jagged, grid-cell zone boundaries visible in the output.

Fixes:
1. valve_engine._generate_strip_zones: Project polygon onto BOTH the main
and lateral axes to get the true coordinate ranges. Use the actual
lateral projection range (with padding) instead of the bbox diagonal.

2. design_api.py: When crop zones don't cover the full farm, compute the
uncovered area and add it as a 'generic' zone. This ensures strip
generation can tile the entire farm and valve counts stay consistent.

3. valve_engine.generate_valve_zones: Replace the legacy grid fallback
with graceful strip count reconciliation — split the largest strip(s)
when too few, or keep the N largest when too many.

Result: Zones are now clean rectangular strips (5 vertices each) instead
of jagged grid-cell mosaics (9-27 vertices). All 140 tests pass.

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

Files changed (2) hide show
  1. design_api.py +24 -2
  2. valve_engine.py +57 -40
design_api.py CHANGED
@@ -13,7 +13,7 @@ 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
19
  from drip_engine import (
@@ -109,13 +109,35 @@ def process_farm_design(geojson_input: str) -> Dict[str, Any]:
109
  "area_m2": zone_utm.area,
110
  })
111
 
112
- # If no explicit crop zones, treat entire farm as single generic zone
 
 
 
113
  if not crop_zones_utm:
114
  crop_zones_utm = [{
115
  "crop": "generic",
116
  "polygon": farm_utm,
117
  "area_m2": farm_utm.area,
118
  }]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
  # ── 5. Run valve placement engine with multi-source orchestration ───
121
  source_contexts = []
 
13
  from typing import Dict, List, Any, Tuple, Optional
14
 
15
  import pyproj
16
+ from shapely.geometry import Polygon, Point, LineString, MultiPolygon
17
 
18
  import geojson_io as gj_io
19
  from drip_engine import (
 
109
  "area_m2": zone_utm.area,
110
  })
111
 
112
+ # If no explicit crop zones, treat entire farm as single generic zone.
113
+ # If crop zones exist but don't cover the full farm, add a generic
114
+ # zone for the uncovered area so strip generation can tile the
115
+ # entire farm and valve counts stay consistent.
116
  if not crop_zones_utm:
117
  crop_zones_utm = [{
118
  "crop": "generic",
119
  "polygon": farm_utm,
120
  "area_m2": farm_utm.area,
121
  }]
122
+ else:
123
+ from shapely.ops import unary_union
124
+ crop_union = unary_union([z["polygon"] for z in crop_zones_utm])
125
+ uncovered = farm_utm.difference(crop_union)
126
+ if not uncovered.is_empty and uncovered.area > farm_utm.area * 0.05:
127
+ if isinstance(uncovered, MultiPolygon):
128
+ for part in uncovered.geoms:
129
+ if part.area > farm_utm.area * 0.02:
130
+ crop_zones_utm.append({
131
+ "crop": "generic",
132
+ "polygon": part,
133
+ "area_m2": part.area,
134
+ })
135
+ elif isinstance(uncovered, Polygon):
136
+ crop_zones_utm.append({
137
+ "crop": "generic",
138
+ "polygon": uncovered,
139
+ "area_m2": uncovered.area,
140
+ })
141
 
142
  # ── 5. Run valve placement engine with multi-source orchestration ───
143
  source_contexts = []
valve_engine.py CHANGED
@@ -561,50 +561,35 @@ def _generate_strip_zones(
561
  # Perpendicular direction (rotate 90 degrees)
562
  lateral_direction = (-main_direction[1], main_direction[0])
563
 
564
- # Project farm onto main axis to get span
565
- min_proj, max_proj = _project_polygon_onto_axis(farm_polygon, main_direction)
566
- axis_span = max_proj - min_proj
567
-
 
 
 
 
568
  if axis_span <= 0:
569
  return []
570
 
 
 
 
571
  # Divide into N equal strips along the main axis
572
  strip_width = axis_span / num_zones
573
 
574
  strips = []
575
  for i in range(num_zones):
576
- # Define the strip's bounds along the main axis
577
- strip_min = min_proj + i * strip_width
578
- strip_max = min_proj + (i + 1) * strip_width
579
-
580
- # Create cutting lines perpendicular to main axis
581
- # at strip boundaries. Extend them far enough to cross the farm.
582
- bounds = farm_polygon.bounds
583
- extent = math.sqrt((bounds[2] - bounds[0]) ** 2 + (bounds[3] - bounds[1]) ** 2)
584
-
585
- # Two cutting lines (perpendicular to main_direction)
586
- def create_cutting_line(proj_val):
587
- center = (
588
- proj_val * main_direction[0],
589
- proj_val * main_direction[1],
590
- )
591
- p1 = (
592
- center[0] - lateral_direction[0] * extent,
593
- center[1] - lateral_direction[1] * extent,
594
- )
595
- p2 = (
596
- center[0] + lateral_direction[0] * extent,
597
- center[1] + lateral_direction[1] * extent,
598
- )
599
- return LineString([p1, p2])
600
 
601
- # Create a bounding box in 2D for this strip
602
- # Using a polygon with 4 corners
603
  corner_offsets = [
604
- (strip_min, -extent),
605
- (strip_max, -extent),
606
- (strip_max, extent),
607
- (strip_min, extent),
608
  ]
609
  strip_corners = [
610
  (
@@ -748,14 +733,40 @@ def generate_valve_zones(
748
  strips = [item["polygon"] for item in crop_aware_strips]
749
  else:
750
  strips = _generate_strip_zones(farm_polygon, main_direction, num_zones)
 
751
 
752
- if len(strips) != num_zones:
753
- # Fallback to legacy if strip generation failed
754
- return _generate_valve_zones_legacy(farm_polygon, valves)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
755
 
756
  # Assign each strip to a valve (in order along the main axis)
757
  result = []
758
- for index, (valve, strip) in enumerate(zip(valves, strips)):
 
 
759
  zone_dict = {
760
  "valve_id": valve["id"],
761
  "polygon": strip,
@@ -764,12 +775,18 @@ def generate_valve_zones(
764
  # Propagate crop from valve if available
765
  if "crop" in valve:
766
  zone_dict["crop"] = valve["crop"]
767
- elif crop_zones:
768
  zone_dict["crop"] = crop_aware_strips[index].get("crop", "generic")
769
  result.append(zone_dict)
770
  return result
771
  else:
772
- # Legacy grid-based Voronoi fallback
 
 
 
 
 
 
773
  return _generate_valve_zones_legacy(farm_polygon, valves)
774
 
775
 
 
561
  # Perpendicular direction (rotate 90 degrees)
562
  lateral_direction = (-main_direction[1], main_direction[0])
563
 
564
+ # Project farm onto both axes to get actual coordinate ranges.
565
+ # Using the bounding-box diagonal as lateral extent fails when UTM
566
+ # coordinates are large — the strip rectangles end up far from the
567
+ # actual polygon. Projecting onto both axes gives the true ranges.
568
+ min_main, max_main = _project_polygon_onto_axis(farm_polygon, main_direction)
569
+ min_lat, max_lat = _project_polygon_onto_axis(farm_polygon, lateral_direction)
570
+
571
+ axis_span = max_main - min_main
572
  if axis_span <= 0:
573
  return []
574
 
575
+ # Pad the lateral range so the strip rectangle fully covers the polygon
576
+ lateral_pad = (max_lat - min_lat) * 0.1 + 10
577
+
578
  # Divide into N equal strips along the main axis
579
  strip_width = axis_span / num_zones
580
 
581
  strips = []
582
  for i in range(num_zones):
583
+ strip_min = min_main + i * strip_width
584
+ strip_max = min_main + (i + 1) * strip_width
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
 
586
+ # Build strip rectangle in the (main, lateral) projection space,
587
+ # then reconstruct world coordinates.
588
  corner_offsets = [
589
+ (strip_min, min_lat - lateral_pad),
590
+ (strip_max, min_lat - lateral_pad),
591
+ (strip_max, max_lat + lateral_pad),
592
+ (strip_min, max_lat + lateral_pad),
593
  ]
594
  strip_corners = [
595
  (
 
733
  strips = [item["polygon"] for item in crop_aware_strips]
734
  else:
735
  strips = _generate_strip_zones(farm_polygon, main_direction, num_zones)
736
+ crop_aware_strips = None
737
 
738
+ # Reconcile strip count with valve count instead of falling back
739
+ # to the legacy grid (which produces jagged zone boundaries).
740
+ if len(strips) == 0:
741
+ # Complete failure — generate strips over the whole farm
742
+ strips = _generate_strip_zones(farm_polygon, main_direction, num_zones)
743
+ crop_aware_strips = None
744
+
745
+ if len(strips) < num_zones:
746
+ # Fewer strips than valves: split the largest strip(s)
747
+ while len(strips) < num_zones:
748
+ largest_idx = max(range(len(strips)), key=lambda i: strips[i].area)
749
+ largest = strips.pop(largest_idx)
750
+ halves = _generate_strip_zones(largest, main_direction, 2)
751
+ if len(halves) == 2:
752
+ strips.insert(largest_idx, halves[0])
753
+ strips.insert(largest_idx + 1, halves[1])
754
+ else:
755
+ strips.insert(largest_idx, largest)
756
+ break # can't split further
757
+ elif len(strips) > num_zones:
758
+ # More strips than valves: keep only the N largest
759
+ strips.sort(key=lambda p: p.area, reverse=True)
760
+ strips = strips[:num_zones]
761
+
762
+ # Final guard: if we still can't match, truncate valves to strips
763
+ effective_count = min(len(strips), num_zones)
764
 
765
  # Assign each strip to a valve (in order along the main axis)
766
  result = []
767
+ for index in range(effective_count):
768
+ valve = valves[index]
769
+ strip = strips[index]
770
  zone_dict = {
771
  "valve_id": valve["id"],
772
  "polygon": strip,
 
775
  # Propagate crop from valve if available
776
  if "crop" in valve:
777
  zone_dict["crop"] = valve["crop"]
778
+ elif crop_aware_strips and index < len(crop_aware_strips):
779
  zone_dict["crop"] = crop_aware_strips[index].get("crop", "generic")
780
  result.append(zone_dict)
781
  return result
782
  else:
783
+ # No direction provided — fall back to strip generation over whole farm
784
+ strips = _generate_strip_zones(farm_polygon, (1, 0), len(valves))
785
+ if strips:
786
+ return [
787
+ {"valve_id": v["id"], "polygon": s, "area_m2": s.area}
788
+ for v, s in zip(valves, strips)
789
+ ]
790
  return _generate_valve_zones_legacy(farm_polygon, valves)
791
 
792