Spaces:
Sleeping
Fix zigzag zone boundaries: correct strip generation coordinate math
Browse filesRoot 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>
- design_api.py +24 -2
- valve_engine.py +57 -40
|
@@ -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 = []
|
|
@@ -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
|
| 565 |
-
|
| 566 |
-
|
| 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 |
-
|
| 577 |
-
|
| 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 |
-
#
|
| 602 |
-
#
|
| 603 |
corner_offsets = [
|
| 604 |
-
(strip_min, -
|
| 605 |
-
(strip_max, -
|
| 606 |
-
(strip_max,
|
| 607 |
-
(strip_min,
|
| 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 |
-
|
| 753 |
-
|
| 754 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 755 |
|
| 756 |
# Assign each strip to a valve (in order along the main axis)
|
| 757 |
result = []
|
| 758 |
-
for index
|
|
|
|
|
|
|
| 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
|
| 768 |
zone_dict["crop"] = crop_aware_strips[index].get("crop", "generic")
|
| 769 |
result.append(zone_dict)
|
| 770 |
return result
|
| 771 |
else:
|
| 772 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|