farm-layout-model / test_pipe_network.py
spacedout-bits's picture
Phase 5: Drip Manifold Alignment - valve-proximity manifold selection
7e350ba
Raw
History Blame Contribute Delete
14.7 kB
"""
Unit tests for pipe_network module.
Tests orthogonal routing, trunk main generation, and sub-main path calculation
for both centralized and distributed irrigation designs.
"""
import pytest
import math
from shapely.geometry import Point, LineString, Polygon
from pipe_network import (
route_orthogonal,
generate_pipe_network,
calculate_pipe_lengths,
PipeNetworkError,
)
class TestOrthogonalRouting:
"""Test orthogonal path routing with axis alignment."""
def test_orthogonal_same_main_axis(self):
"""Points on same main axis should create straight line."""
main_axis = (1, 0)
lateral_axis = (0, 1)
start = Point(0, 10)
end = Point(100, 10)
route = route_orthogonal(start, end, main_axis, lateral_axis)
assert len(route.coords) == 2
assert route.length == pytest.approx(100, rel=1e-2)
def test_orthogonal_same_lateral_axis(self):
"""Points on same lateral axis should create straight line."""
main_axis = (1, 0)
lateral_axis = (0, 1)
start = Point(50, 0)
end = Point(50, 100)
route = route_orthogonal(start, end, main_axis, lateral_axis)
assert len(route.coords) == 2
assert route.length == pytest.approx(100, rel=1e-2)
def test_orthogonal_requires_one_bend(self):
"""Misaligned points require one 90-degree bend."""
main_axis = (1, 0)
lateral_axis = (0, 1)
start = Point(0, 0)
end = Point(100, 100)
route = route_orthogonal(start, end, main_axis, lateral_axis)
# Should have 3 points (start, corner, end)
assert len(route.coords) == 3
# Verify corner is axis-aligned
corner = Point(route.coords[1])
assert math.isclose(corner.x, 100) or math.isclose(corner.y, 0)
def test_orthogonal_total_length_greater_than_direct(self):
"""Orthogonal path should be >= direct distance."""
main_axis = (1, 0)
lateral_axis = (0, 1)
start = Point(0, 0)
end = Point(100, 100)
route = route_orthogonal(start, end, main_axis, lateral_axis)
direct = start.distance(end)
# Orthogonal path should be longer or equal (Manhattan distance >= Euclidean)
assert route.length >= direct - 1e-6
def test_orthogonal_tilted_axes(self):
"""Orthogonal routing works with tilted farm axes."""
# 45-degree rotated axes
sqrt2 = math.sqrt(2) / 2
main_axis = (sqrt2, sqrt2)
lateral_axis = (-sqrt2, sqrt2)
start = Point(0, 0)
# Use point not aligned on 45-degree diagonal to trigger bend
end = Point(100, 50)
route = route_orthogonal(start, end, main_axis, lateral_axis)
# Should create a path with 3 points (requires a bend)
assert len(route.coords) == 3
class TestPipeNetworkDistributed:
"""Test pipe network generation for distributed layouts."""
def test_distributed_has_trunk_main(self):
"""Distributed design should generate a trunk main."""
farm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump = Point(0, 50)
zones = [
{
"valve_id": "valve_000",
"polygon": Polygon([(0, 0), (50, 0), (50, 100), (0, 100)]),
"area_m2": 5000,
"valve_location": Point(25, 50),
}
]
main_axis = (1, 0)
network = generate_pipe_network(farm, pump, zones, main_axis, design_type="distributed")
assert network["trunk_main"] is not None
assert network["trunk_main"].length > 0
assert network["total_trunk_length_m"] > 0
def test_distributed_sub_mains_from_trunk(self):
"""Distributed sub-mains should originate from trunk."""
farm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump = Point(0, 50)
zones = [
{
"valve_id": "valve_000",
"polygon": Polygon([(0, 0), (50, 0), (50, 100), (0, 100)]),
"area_m2": 5000,
"valve_location": Point(25, 50),
}
]
main_axis = (1, 0)
network = generate_pipe_network(farm, pump, zones, main_axis, design_type="distributed")
assert "valve_000" in network["sub_mains"]
sub_main = network["sub_mains"]["valve_000"]
# Sub-main should start at a point on the trunk
start_pt = Point(sub_main.coords[0])
dist_to_trunk = network["trunk_main"].distance(start_pt)
assert dist_to_trunk < 1e-6 # Should be on trunk (within numerical tolerance)
def test_distributed_multiple_zones(self):
"""Multiple zones should each have a sub-main."""
farm = Polygon([(0, 0), (200, 0), (200, 100), (0, 100)])
pump = Point(0, 50)
zones = [
{
"valve_id": "valve_000",
"polygon": Polygon([(0, 0), (100, 0), (100, 100), (0, 100)]),
"area_m2": 5000,
"valve_location": Point(50, 80), # Offset from trunk to create non-zero length
},
{
"valve_id": "valve_001",
"polygon": Polygon([(100, 0), (200, 0), (200, 100), (100, 100)]),
"area_m2": 5000,
"valve_location": Point(150, 20), # Offset from trunk
}
]
main_axis = (1, 0)
network = generate_pipe_network(farm, pump, zones, main_axis, design_type="distributed")
assert len(network["sub_mains"]) == 2
assert network["total_submain_length_m"] > 0
def test_distributed_sub_main_ends_at_valve(self):
"""Sub-main should end at the anchored valve location."""
farm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump = Point(0, 50)
valve_loc = Point(80, 60)
zones = [
{
"valve_id": "valve_000",
"polygon": Polygon([(0, 0), (100, 0), (100, 100), (0, 100)]),
"area_m2": 10000,
"valve_location": valve_loc,
}
]
main_axis = (1, 0)
network = generate_pipe_network(farm, pump, zones, main_axis, design_type="distributed")
sub_main = network["sub_mains"]["valve_000"]
end_pt = Point(sub_main.coords[-1])
# End should be close to valve_location
assert end_pt.distance(valve_loc) < 1e-6
def test_distributed_sub_main_is_orthogonal(self):
"""Sub-main paths should be axis-aligned (orthogonal)."""
farm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump = Point(0, 50)
zones = [
{
"valve_id": "valve_000",
"polygon": Polygon([(0, 0), (100, 0), (100, 100), (0, 100)]),
"area_m2": 10000,
"valve_location": Point(80, 80),
}
]
main_axis = (1, 0)
lateral_axis = (0, 1)
network = generate_pipe_network(farm, pump, zones, main_axis, design_type="distributed")
sub_main = network["sub_mains"]["valve_000"]
coords = list(sub_main.coords)
# Each segment should be either horizontal or vertical
for i in range(len(coords) - 1):
p1 = coords[i]
p2 = coords[i + 1]
# Either x or y should be the same
is_horizontal = math.isclose(p1[1], p2[1])
is_vertical = math.isclose(p1[0], p2[0])
assert is_horizontal or is_vertical, f"Segment not axis-aligned: {p1} -> {p2}"
class TestPipeNetworkCentralized:
"""Test pipe network generation for centralized layouts."""
def test_centralized_no_trunk_main(self):
"""Centralized design should NOT generate a trunk main."""
farm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump = Point(50, 50)
zones = [
{
"valve_id": "valve_000",
"polygon": Polygon([(0, 0), (50, 0), (50, 100), (0, 100)]),
"area_m2": 5000,
"valve_location": Point(25, 50),
}
]
main_axis = (1, 0)
network = generate_pipe_network(farm, pump, zones, main_axis, design_type="centralized")
assert network["trunk_main"] is None
assert network["total_trunk_length_m"] == 0
def test_centralized_sub_mains_from_pump(self):
"""Centralized sub-mains should originate from pump."""
farm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump = Point(50, 50)
zones = [
{
"valve_id": "valve_000",
"polygon": Polygon([(0, 0), (50, 0), (50, 100), (0, 100)]),
"area_m2": 5000,
"valve_location": Point(25, 50),
}
]
main_axis = (1, 0)
network = generate_pipe_network(farm, pump, zones, main_axis, design_type="centralized")
assert "valve_000" in network["sub_mains"]
sub_main = network["sub_mains"]["valve_000"]
start_pt = Point(sub_main.coords[0])
# Should start at pump (within tolerance)
assert start_pt.distance(pump) < 1e-6
def test_centralized_multiple_zones(self):
"""Centralized layout with multiple zones."""
farm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump = Point(50, 50)
zones = [
{
"valve_id": "valve_000",
"polygon": Polygon([(0, 0), (50, 0), (50, 100), (0, 100)]),
"area_m2": 5000,
"valve_location": Point(25, 75),
},
{
"valve_id": "valve_001",
"polygon": Polygon([(50, 0), (100, 0), (100, 100), (50, 100)]),
"area_m2": 5000,
"valve_location": Point(75, 25),
}
]
main_axis = (1, 0)
network = generate_pipe_network(farm, pump, zones, main_axis, design_type="centralized")
assert len(network["sub_mains"]) == 2
assert network["total_submain_length_m"] > 0
# Centralized should not have trunk
assert network["total_trunk_length_m"] == 0
def test_centralized_sub_main_is_orthogonal(self):
"""Centralized sub-mains should also be orthogonal."""
farm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump = Point(50, 50)
zones = [
{
"valve_id": "valve_000",
"polygon": Polygon([(0, 0), (100, 0), (100, 100), (0, 100)]),
"area_m2": 10000,
"valve_location": Point(10, 10),
}
]
main_axis = (1, 0)
network = generate_pipe_network(farm, pump, zones, main_axis, design_type="centralized")
sub_main = network["sub_mains"]["valve_000"]
coords = list(sub_main.coords)
# Each segment should be axis-aligned
for i in range(len(coords) - 1):
p1 = coords[i]
p2 = coords[i + 1]
is_horizontal = math.isclose(p1[1], p2[1])
is_vertical = math.isclose(p1[0], p2[0])
assert is_horizontal or is_vertical
class TestPipeNetworkGeneral:
"""General pipe network tests."""
def test_empty_zones_returns_empty_network(self):
"""Empty zone list should return empty network."""
farm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump = Point(50, 50)
zones = []
main_axis = (1, 0)
network = generate_pipe_network(farm, pump, zones, main_axis)
assert network["trunk_main"] is None
assert len(network["sub_mains"]) == 0
assert network["total_trunk_length_m"] == 0
assert network["total_submain_length_m"] == 0
def test_fallback_to_centroid_if_no_valve_location(self):
"""Zone without valve_location should use centroid as fallback."""
farm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump = Point(50, 50)
zone_poly = Polygon([(0, 0), (50, 0), (50, 100), (0, 100)])
zones = [
{
"valve_id": "valve_000",
"polygon": zone_poly,
"area_m2": 5000,
# No valve_location provided
}
]
main_axis = (1, 0)
network = generate_pipe_network(farm, pump, zones, main_axis, design_type="distributed")
assert "valve_000" in network["sub_mains"]
sub_main = network["sub_mains"]["valve_000"]
# Should end at zone centroid (fallback)
end_pt = Point(sub_main.coords[-1])
assert end_pt.distance(zone_poly.centroid) < 1
def test_pipe_lengths_calculation(self):
"""Pipe length calculation should match geometry."""
farm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump = Point(0, 50)
zones = [
{
"valve_id": "valve_000",
"polygon": Polygon([(0, 0), (100, 0), (100, 100), (0, 100)]),
"area_m2": 10000,
"valve_location": Point(100, 50),
}
]
main_axis = (1, 0)
network = generate_pipe_network(farm, pump, zones, main_axis, design_type="distributed")
# Sum should match totals
total = (
network["total_trunk_length_m"] +
network["total_submain_length_m"]
)
assert total > 0
# Trunk should be 100m (from pump at x=0 to x=100)
assert network["total_trunk_length_m"] == pytest.approx(100, rel=1e-2)
def test_no_negative_lengths(self):
"""All pipe lengths should be non-negative."""
farm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump = Point(50, 50)
zones = [
{
"valve_id": "valve_000",
"polygon": Polygon([(0, 0), (100, 0), (100, 100), (0, 100)]),
"area_m2": 10000,
"valve_location": Point(75, 75),
}
]
main_axis = (1, 0)
network = generate_pipe_network(farm, pump, zones, main_axis)
assert network["total_trunk_length_m"] >= 0
assert network["total_submain_length_m"] >= 0
for sub_main in network["sub_mains"].values():
assert sub_main.length >= 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])