farm-layout-model / test_valve_engine.py
spacedout-bits's picture
Phase 5: Drip Manifold Alignment - valve-proximity manifold selection
7e350ba
"""
Unit tests for valve_engine module.
"""
import pytest
import math
from shapely.geometry import Point, Polygon
from valve_engine import (
calculate_pump_flow_lph,
calculate_total_emitter_flow,
calculate_num_zones,
should_split_by_topography,
choose_manifold_strategy,
place_valves_hierarchical,
generate_valve_zones,
anchor_valves_to_zones,
valve_layout_summary,
ValveEngineError,
)
class TestPumpFlow:
"""Test pump horsepower to flow rate conversion."""
def test_known_hp_values(self):
"""Exact lookups for known HP values."""
assert calculate_pump_flow_lph(1.0) == 5000
assert calculate_pump_flow_lph(2.0) == 15000
assert calculate_pump_flow_lph(10.0) == 80000
def test_interpolation(self):
"""Interpolate between known values."""
flow_1_2 = calculate_pump_flow_lph(1.2)
# Between 1.0 (5000 lph) and 1.5 (8000 lph)
# 1.2 should be ~6200
assert 6000 < flow_1_2 < 7000
def test_invalid_hp(self):
"""Reject zero or negative HP."""
with pytest.raises(ValveEngineError):
calculate_pump_flow_lph(0)
with pytest.raises(ValveEngineError):
calculate_pump_flow_lph(-1)
class TestTotalFlow:
"""Test total emitter flow calculation."""
def test_single_crop_zone(self):
"""Calculate flow for one crop zone."""
crop_zones = [
{"crop": "tomato", "area_m2": 1000}
]
# tomato: 4.17 emitters/m2, 4 lph each
# flow = 1000 * 4.17 * 4 = 16,680 lph
flow = calculate_total_emitter_flow(crop_zones)
assert abs(flow - 16680) < 5
def test_multiple_crop_zones(self):
"""Sum flow across multiple crops."""
crop_zones = [
{"crop": "tomato", "area_m2": 1000}, # 1000 * 4.17 * 4 = 16,680 lph
{"crop": "lettuce", "area_m2": 500}, # 500 * 12.5 * 2 = 12,500 lph
]
flow = calculate_total_emitter_flow(crop_zones)
assert abs(flow - 29180) < 5
def test_polygon_area_fallback(self):
"""Use polygon area if area_m2 not provided."""
poly = Polygon([(0, 0), (10, 0), (10, 100), (0, 100)]) # 1000 m²
crop_zones = [
{"crop": "tomato", "polygon": poly}
]
flow = calculate_total_emitter_flow(crop_zones)
assert abs(flow - 16680) < 5
def test_missing_area_raises_error(self):
"""Raise error if no area_m2 or polygon provided."""
crop_zones = [
{"crop": "tomato"} # No area!
]
with pytest.raises(ValveEngineError):
calculate_total_emitter_flow(crop_zones)
class TestNumZones:
"""Test zone count calculation."""
def test_no_split_needed(self):
"""Pump capacity >= total flow -> 1 zone."""
num_zones = calculate_num_zones(total_emitter_flow_lph=500, pump_flow_lph=1000)
assert num_zones == 1
def test_single_split(self):
"""Total flow = 1.5x pump capacity -> 2 zones."""
num_zones = calculate_num_zones(total_emitter_flow_lph=1500, pump_flow_lph=1000)
assert num_zones == 2
def test_multiple_splits(self):
"""Total flow = 3.2x pump capacity -> 4 zones."""
num_zones = calculate_num_zones(total_emitter_flow_lph=3200, pump_flow_lph=1000)
assert num_zones == 4
def test_zero_flow(self):
"""Zero emitter flow -> 1 zone (degenerate case)."""
num_zones = calculate_num_zones(total_emitter_flow_lph=0, pump_flow_lph=1000)
assert num_zones == 1
class TestTopographySplit:
"""Test topography-based zone splitting."""
def test_flat_field(self):
"""No elevation data -> no split."""
poly = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
should_split, delta = should_split_by_topography(poly, None)
assert should_split is False
assert delta == 0.0
def test_small_elevation_delta(self):
"""Elevation delta < 5m -> no split."""
elevation_data = {"min_elevation_m": 100, "max_elevation_m": 103}
poly = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
should_split, delta = should_split_by_topography(poly, elevation_data)
assert should_split is False
assert delta == 3
def test_large_elevation_delta(self):
"""Elevation delta > 5m -> split."""
elevation_data = {"min_elevation_m": 100, "max_elevation_m": 107}
poly = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
should_split, delta = should_split_by_topography(poly, elevation_data)
assert should_split is True
assert delta == 7
class TestManifoldStrategy:
"""Test centralized vs. distributed valve choice."""
def test_small_farm_centralized(self):
"""Farm < 1 ha -> centralized."""
area_m2 = 5000 # 0.5 ha
strategy = choose_manifold_strategy(area_m2)
assert strategy == "centralized"
def test_large_farm_distributed(self):
"""Farm >= 1 ha -> distributed."""
area_m2 = 10000 # 1.0 ha
strategy = choose_manifold_strategy(area_m2)
assert strategy == "distributed"
def test_boundary_case(self):
"""Farm at 1 ha boundary."""
area_m2 = 10000 # Exactly 1 ha
strategy = choose_manifold_strategy(area_m2)
# At boundary, should be "distributed"
assert strategy == "distributed"
class TestValvePlacement:
"""Test valve placement hierarchy (capacity + crop + area-density + topography)."""
def test_area_density_generic(self):
"""Generic crop: ~5 valves/ha rule of thumb."""
# 1 ha farm, generic crop, very high pump (capacity not limiting)
farm_poly = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump_point = Point(0, 0)
crop_zones = [{"crop": "generic", "area_m2": 10000}]
pump_hp = 20.0 # 160,000 lph — well above demand
valves = place_valves_hierarchical(
farm_poly, pump_point, crop_zones, pump_hp, centralized=False
)
# 1 ha * 5/ha = 5 valves
assert len(valves) == 5
def test_area_density_orchard_lower(self):
"""Orchard: sparser density (2/ha) means fewer valves."""
farm_poly = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)]) # 1 ha
pump_point = Point(0, 0)
crop_zones = [{"crop": "orchard", "area_m2": 10000}]
pump_hp = 20.0
valves = place_valves_hierarchical(
farm_poly, pump_point, crop_zones, pump_hp, centralized=False
)
# 1 ha * 2/ha = 2 valves
assert len(valves) == 2
def test_simple_farm_area_density_floor(self):
"""Small farm: area-density floor dominates over low capacity demand."""
# 100m x 50m = 0.5 ha; tomato @ 6/ha -> floor = ceil(3) = 3 valves
farm_poly = Polygon([(0, 0), (100, 0), (100, 50), (0, 50)])
pump_point = Point(0, 0)
crop_zones = [
{"crop": "tomato", "area_m2": 100} # Very small zone -> capacity = 1
]
pump_hp = 10.0 # 80,000 lph — well above demand
valves = place_valves_hierarchical(
farm_poly,
pump_point,
crop_zones,
pump_hp,
centralized=True,
)
# Area floor (0.5 ha * 6/ha = 3) should dominate over capacity floor (1)
assert len(valves) >= 3
assert valves[0]["id"] == "valve_000"
def test_high_demand_multiple_valves(self):
"""Large farm, high demand -> capped at agronomic maximum."""
farm_poly = Polygon([(0, 0), (500, 0), (500, 500), (0, 500)])
pump_point = Point(0, 0)
crop_zones = [
{"crop": "tomato", "area_m2": 50000} # 50,000 * 4.17 * 4 = 834,000 lph
]
pump_hp = 10.0 # 80,000 lph
valves = place_valves_hierarchical(
farm_poly,
pump_point,
crop_zones,
pump_hp,
centralized=False,
)
# 500x500m = 25 ha, tomato @ 6/ha -> floor = 150;
# cap (>= 10ha) = 100 → capped at 100
assert len(valves) <= 100
assert len(valves) >= 10
def test_multiple_crops_separate_valves(self):
"""Multiple crops -> at least as many valves as crops."""
farm_poly = Polygon([(0, 0), (200, 0), (200, 200), (0, 200)])
pump_point = Point(0, 0)
crop_zones = [
{"crop": "tomato", "area_m2": 5000},
{"crop": "lettuce", "area_m2": 5000},
{"crop": "orchard", "area_m2": 5000},
]
pump_hp = 100.0 # Very high capacity
valves = place_valves_hierarchical(
farm_poly,
pump_point,
crop_zones,
pump_hp,
centralized=False,
)
# 200x200m = 4 ha, tomato @ 6/ha -> area floor = 24;
# cap (< 5ha) = 35 → expect 24 valves
assert len(valves) >= 3 # at minimum one per crop
assert len(valves) <= 35 # cap for 4 ha
def test_topography_forces_extra_valve(self):
"""Elevation split adds a valve."""
farm_poly = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump_point = Point(0, 0)
crop_zones = [
{"crop": "tomato", "area_m2": 1000}
]
pump_hp = 10.0
elevation_data = {"min_elevation_m": 100, "max_elevation_m": 110}
valves_flat = place_valves_hierarchical(
farm_poly,
pump_point,
crop_zones,
pump_hp,
centralized=True,
elevation_data=None,
)
valves_sloped = place_valves_hierarchical(
farm_poly,
pump_point,
crop_zones,
pump_hp,
centralized=True,
elevation_data=elevation_data,
)
# Sloped should have at least one more valve
assert len(valves_sloped) >= len(valves_flat)
def test_centralized_vs_distributed_locations(self):
"""Centralized places near pump; distributed places at zone boundary."""
farm_poly = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump_point = Point(10, 10)
crop_zones = [
{"crop": "tomato", "area_m2": 1000}
]
pump_hp = 10.0
valves_centralized = place_valves_hierarchical(
farm_poly,
pump_point,
crop_zones,
pump_hp,
centralized=True,
)
valves_distributed = place_valves_hierarchical(
farm_poly,
pump_point,
crop_zones,
pump_hp,
centralized=False,
)
# Both should have at least one valve
assert len(valves_centralized) >= 1
assert len(valves_distributed) >= 1
# Centralized valve should have 'centralized' strategy
assert valves_centralized[0]["strategy"] == "centralized"
assert valves_distributed[0]["strategy"] == "distributed"
class TestValveZoneGeneration:
"""Test zone polygon generation."""
def test_zones_generated_for_valves(self):
"""Each valve should have a zone polygon."""
farm_poly = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump_point = Point(50, 50)
crop_zones = [
{"crop": "tomato", "area_m2": 5000}
]
pump_hp = 50.0 # Very high capacity
valves = place_valves_hierarchical(
farm_poly,
pump_point,
crop_zones,
pump_hp,
centralized=False,
)
zones = generate_valve_zones(farm_poly, len(valves))
# Should have zones generated (not necessarily 1:1 with valves due to merging)
assert len(zones) > 0
# Each zone should have a valid polygon
for zone in zones:
assert zone["polygon"].is_valid
assert zone["area_m2"] > 0
def test_total_zone_area_equals_farm_area(self):
"""Sum of all zone areas should equal farm area (approximately)."""
farm_poly = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump_point = Point(50, 50)
crop_zones = [
{"crop": "tomato", "area_m2": 5000}
]
pump_hp = 1.0 # 5000 lph
valves = place_valves_hierarchical(
farm_poly,
pump_point,
crop_zones,
pump_hp,
centralized=False,
)
zones = generate_valve_zones(farm_poly, len(valves))
total_zone_area = sum(z["area_m2"] for z in zones)
# Should be close to farm area (within 5% tolerance)
farm_area = farm_poly.area
assert abs(total_zone_area - farm_area) < 0.05 * farm_area
class TestSummary:
"""Test human-readable summary generation."""
def test_summary_format(self):
"""Summary should contain valve info."""
farm_poly = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
pump_point = Point(50, 50)
crop_zones = [
{"crop": "tomato", "area_m2": 5000}
]
pump_hp = 10.0 # 80,000 lph
valves = place_valves_hierarchical(
farm_poly,
pump_point,
crop_zones,
pump_hp,
centralized=True,
)
zones = generate_valve_zones(farm_poly, len(valves))
summary = valve_layout_summary(valves, zones)
assert "Valve Placement Summary" in summary
assert "Total Valves" in summary
assert "Valve Details" in summary
class TestValveAnchoring:
"""Test valve anchoring to zones (Phase 3)."""
def test_anchor_adds_valve_location_to_zones(self):
"""anchor_valves_to_zones should add 'valve_location' to each zone."""
zones = [
{"polygon": Polygon([(0, 0), (50, 0), (50, 50), (0, 50)]), "area_m2": 2500},
{"polygon": Polygon([(50, 0), (100, 0), (100, 50), (50, 50)]), "area_m2": 2500},
]
pump_location = Point(25, 25)
anchored = anchor_valves_to_zones(zones, pump_location, "distributed")
assert len(anchored) == 2
for zone in anchored:
assert "valve_location" in zone
assert isinstance(zone["valve_location"], Point)
assert zone["polygon"].is_valid
assert zone["area_m2"] > 0
def test_centralized_valves_cluster_near_pump(self):
"""Centralized design should place all valve_locations near pump."""
zones = [
{"polygon": Polygon([(0, 0), (50, 0), (50, 50), (0, 50)]), "area_m2": 2500},
{"polygon": Polygon([(50, 0), (100, 0), (100, 50), (50, 50)]), "area_m2": 2500},
{"polygon": Polygon([(0, 50), (50, 50), (50, 100), (0, 100)]), "area_m2": 2500},
]
pump_location = Point(25, 25)
anchored = anchor_valves_to_zones(zones, pump_location, "centralized")
assert len(anchored) == 3
# All valves should be within ~20m of pump (10m offset + some margin)
for zone in anchored:
dist = zone["valve_location"].distance(pump_location)
assert dist <= 15, f"Centralized valve too far from pump: {dist}m"
def test_distributed_valves_on_zone_edges(self):
"""Distributed design should place valve_locations on zone boundaries."""
zones = [
{"polygon": Polygon([(0, 0), (50, 0), (50, 50), (0, 50)]), "area_m2": 2500},
{"polygon": Polygon([(50, 0), (100, 0), (100, 50), (50, 50)]), "area_m2": 2500},
]
pump_location = Point(25, 25)
anchored = anchor_valves_to_zones(zones, pump_location, "distributed")
assert len(anchored) == 2
# Each valve_location should be on the zone boundary
for zone in anchored:
valve_loc = zone["valve_location"]
zone_boundary = zone["polygon"].boundary
# Distance from valve to boundary should be ~0 (allow small numerical error)
dist_to_boundary = valve_loc.distance(zone_boundary)
assert dist_to_boundary < 0.1, (
f"Distributed valve not on zone boundary: {dist_to_boundary}m"
)
def test_preserves_zone_metadata(self):
"""Anchoring should preserve existing zone properties (polygon, area, crop)."""
zones = [
{
"polygon": Polygon([(0, 0), (50, 0), (50, 50), (0, 50)]),
"area_m2": 2500,
"crop": "tomato",
},
]
pump_location = Point(25, 25)
anchored = anchor_valves_to_zones(zones, pump_location, "distributed")
assert len(anchored) == 1
zone = anchored[0]
assert zone["polygon"] is not None
assert zone["area_m2"] == 2500
assert zone["crop"] == "tomato"
assert "valve_location" in zone
def test_empty_zones_list(self):
"""Anchoring empty zone list should return empty list."""
zones = []
pump_location = Point(50, 50)
anchored = anchor_valves_to_zones(zones, pump_location, "distributed")
assert anchored == []
def test_valve_count_unchanged(self):
"""Anchoring should not change the number of zones."""
zones = [
{"polygon": Polygon([(0, 0), (40, 0), (40, 40), (0, 40)]), "area_m2": 1600},
{"polygon": Polygon([(40, 0), (80, 0), (80, 40), (40, 40)]), "area_m2": 1600},
{"polygon": Polygon([(0, 40), (40, 40), (40, 80), (0, 80)]), "area_m2": 1600},
{"polygon": Polygon([(40, 40), (80, 40), (80, 80), (40, 80)]), "area_m2": 1600},
]
pump_location = Point(40, 40)
anchored_cent = anchor_valves_to_zones(zones, pump_location, "centralized")
anchored_dist = anchor_valves_to_zones(zones, pump_location, "distributed")
# Both should have same number of zones as input
assert len(anchored_cent) == len(zones)
assert len(anchored_dist) == len(zones)
def test_design_type_drives_placement_strategy(self):
"""Design type should determine valve placement approach."""
zones = [
{"polygon": Polygon([(0, 0), (100, 0), (100, 100), (0, 100)]), "area_m2": 10000},
]
pump_location = Point(50, 50) # Center of zone
# Centralized: valve near pump
anchored_cent = anchor_valves_to_zones(zones, pump_location, "centralized")
valve_cent = anchored_cent[0]["valve_location"]
dist_cent = valve_cent.distance(pump_location)
# Distributed: valve on boundary (further from pump at corner)
anchored_dist = anchor_valves_to_zones(zones, pump_location, "distributed")
valve_dist = anchored_dist[0]["valve_location"]
dist_dist = valve_dist.distance(pump_location)
# Centralized should be closer to pump
assert dist_cent < dist_dist
if __name__ == "__main__":
pytest.main([__file__, "-v"])