Spaces:
Running
Running
| """ | |
| Unit tests for drip_engine module. | |
| """ | |
| import pytest | |
| import math | |
| from shapely.geometry import Polygon, LineString | |
| from drip_engine import ( | |
| parse_geofence_to_polygon, | |
| validate_polygon, | |
| polygon_area_hectares, | |
| generate_drip_layout, | |
| estimate_bom, | |
| design_summary, | |
| DripLayoutError, | |
| latlon_to_utm, | |
| ) | |
| class TestParseGeofence: | |
| """Test geofence parsing.""" | |
| def test_parse_valid_rectangle(self): | |
| """Parse valid rectangular polygon.""" | |
| geofence = "0,0;100,0;100,100;0,100" | |
| poly = parse_geofence_to_polygon(geofence) | |
| assert poly.is_valid | |
| assert len(list(poly.exterior.coords)) == 5 # 4 + closing point | |
| def test_parse_triangle(self): | |
| """Parse valid triangle.""" | |
| geofence = "0,0;100,0;50,100" | |
| poly = parse_geofence_to_polygon(geofence) | |
| assert poly.is_valid | |
| assert poly.area > 0 | |
| def test_parse_with_whitespace(self): | |
| """Handle extra whitespace.""" | |
| geofence = " 0, 0 ; 100 , 0 ; 100 , 100 ; 0 , 100 " | |
| poly = parse_geofence_to_polygon(geofence) | |
| assert poly.is_valid | |
| def test_parse_invalid_format(self): | |
| """Reject invalid format.""" | |
| with pytest.raises(DripLayoutError): | |
| parse_geofence_to_polygon("0,0;100") # Missing y coord | |
| def test_parse_too_few_points(self): | |
| """Reject polygon with < 3 points.""" | |
| with pytest.raises(DripLayoutError): | |
| parse_geofence_to_polygon("0,0;100,100") # Only 2 points | |
| def test_parse_zero_area(self): | |
| """Reject collinear points (zero area).""" | |
| with pytest.raises(DripLayoutError): | |
| parse_geofence_to_polygon("0,0;50,50;100,100") # Collinear | |
| def test_parse_float_coordinates(self): | |
| """Parse float coordinates.""" | |
| geofence = "0.5,0.5;100.5,0.5;100.5,100.5;0.5,100.5" | |
| poly = parse_geofence_to_polygon(geofence) | |
| assert poly.is_valid | |
| class TestValidatePolygon: | |
| """Test polygon validation.""" | |
| def test_validate_valid_rectangle(self): | |
| """Validate a valid rectangle.""" | |
| poly = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)]) | |
| is_valid, msg = validate_polygon(poly) | |
| assert is_valid | |
| assert msg == "Valid" | |
| def test_validate_self_intersecting(self): | |
| """Reject self-intersecting polygon.""" | |
| # Bowtie shape: self-intersecting | |
| poly = Polygon([(0, 0), (100, 100), (100, 0), (0, 100)]) | |
| is_valid, msg = validate_polygon(poly) | |
| assert not is_valid | |
| def test_validate_too_small(self): | |
| """Reject polygon with tiny area.""" | |
| poly = Polygon([(0, 0), (0.1, 0), (0.1, 0.1), (0, 0.1)]) | |
| is_valid, msg = validate_polygon(poly) | |
| assert not is_valid | |
| assert "too small" in msg.lower() | |
| class TestAreaCalculation: | |
| """Test area calculation in hectares.""" | |
| def test_area_100x100_meters(self): | |
| """100m x 100m = 1 hectare.""" | |
| poly_utm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)]) | |
| area_ha = polygon_area_hectares(poly_utm) | |
| assert abs(area_ha - 1.0) < 0.01 | |
| def test_area_50x50_meters(self): | |
| """50m x 50m = 0.25 hectares.""" | |
| poly_utm = Polygon([(0, 0), (50, 0), (50, 50), (0, 50)]) | |
| area_ha = polygon_area_hectares(poly_utm) | |
| assert abs(area_ha - 0.25) < 0.01 | |
| def test_area_large_field(self): | |
| """Large field: 500m x 200m = 10 hectares.""" | |
| poly_utm = Polygon([(0, 0), (500, 0), (500, 200), (0, 200)]) | |
| area_ha = polygon_area_hectares(poly_utm) | |
| assert abs(area_ha - 10.0) < 0.01 | |
| class TestDripLayout: | |
| """Test drip layout generation.""" | |
| def test_generate_layout_rectangle(self): | |
| """Generate layout for a rectangular field.""" | |
| # 100m x 50m field (0.5 ha) | |
| poly_utm = Polygon([(0, 0), (100, 0), (100, 50), (0, 50)]) | |
| design = generate_drip_layout( | |
| poly_utm, crop="generic", headland_buffer_m=0 | |
| ) | |
| assert design["farm_area_ha"] > 0 | |
| assert design["total_main_length_m"] > 0 | |
| assert len(design["laterals"]) > 0 | |
| assert design["total_drip_tape_m"] > 0 | |
| assert design["emitter_count"] > 0 | |
| def test_generate_layout_with_headland(self): | |
| """Headland buffer reduces area and pipe length.""" | |
| poly_utm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)]) | |
| design_no_buffer = generate_drip_layout( | |
| poly_utm, crop="generic", headland_buffer_m=0 | |
| ) | |
| design_with_buffer = generate_drip_layout( | |
| poly_utm, crop="generic", headland_buffer_m=5 | |
| ) | |
| # Area should be smaller with buffer | |
| assert design_with_buffer["farm_area_ha"] < design_no_buffer["farm_area_ha"] | |
| # Total pipe should be less | |
| assert ( | |
| design_with_buffer["total_drip_tape_m"] | |
| < design_no_buffer["total_drip_tape_m"] | |
| ) | |
| def test_generate_layout_crop_tomato(self): | |
| """Tomato has tighter spacing than orchard.""" | |
| poly_utm = Polygon([(0, 0), (200, 0), (200, 100), (0, 100)]) | |
| design_tomato = generate_drip_layout( | |
| poly_utm, crop="tomato", headland_buffer_m=0 | |
| ) | |
| design_orchard = generate_drip_layout( | |
| poly_utm, crop="orchard", headland_buffer_m=0 | |
| ) | |
| # Tomato should have tighter spacing (more laterals, more tape) | |
| assert design_tomato["total_drip_tape_m"] > design_orchard["total_drip_tape_m"] | |
| def test_generate_layout_override_spacing(self): | |
| """Override lateral spacing parameter.""" | |
| poly_utm = Polygon([(0, 0), (200, 0), (200, 100), (0, 100)]) | |
| design_default = generate_drip_layout( | |
| poly_utm, crop="generic", headland_buffer_m=0 | |
| ) | |
| design_tight = generate_drip_layout( | |
| poly_utm, crop="generic", headland_buffer_m=0, override_spacing_m=0.5 | |
| ) | |
| design_loose = generate_drip_layout( | |
| poly_utm, crop="generic", headland_buffer_m=0, override_spacing_m=2.0 | |
| ) | |
| # Tighter spacing = more tape | |
| assert design_tight["total_drip_tape_m"] > design_loose["total_drip_tape_m"] | |
| def test_generate_layout_irregular_polygon(self): | |
| """L-shaped field should still generate layout.""" | |
| # L-shape: rectangle + extension | |
| poly_utm = Polygon( | |
| [ | |
| (0, 0), | |
| (100, 0), | |
| (100, 100), | |
| (50, 100), | |
| (50, 50), | |
| (0, 50), | |
| ] | |
| ) | |
| design = generate_drip_layout(poly_utm, crop="generic", headland_buffer_m=0) | |
| assert design["farm_area_ha"] > 0 | |
| assert len(design["laterals"]) > 0 | |
| def test_headland_too_large_raises_error(self): | |
| """Headland larger than field dimensions should raise error.""" | |
| poly_utm = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]) | |
| with pytest.raises(DripLayoutError): | |
| generate_drip_layout( | |
| poly_utm, crop="generic", headland_buffer_m=20 | |
| ) # Larger than field | |
| class TestBOM: | |
| """Test bill of materials estimation.""" | |
| def test_bom_calculation(self): | |
| """BOM should have all required fields.""" | |
| poly_utm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)]) | |
| design = generate_drip_layout(poly_utm, crop="generic", headland_buffer_m=0) | |
| bom = estimate_bom(design, unit="inr") | |
| assert "main_line_16mm_m" in bom | |
| assert "drip_tape_16mm_m" in bom | |
| assert "inline_emitters" in bom | |
| assert "total_cost_inr" in bom or "total_cost" in bom | |
| assert bom.get("total_cost_inr", bom.get("total_cost")) > 0 | |
| assert bom["currency"] == "INR" | |
| def test_bom_cost_breakdown(self): | |
| """Total cost should equal sum of components.""" | |
| poly_utm = Polygon([(0, 0), (200, 0), (200, 100), (0, 100)]) | |
| design = generate_drip_layout(poly_utm, crop="generic", headland_buffer_m=0) | |
| bom = estimate_bom(design, unit="inr") | |
| total = bom["cost_main"] + bom["cost_drip_tape"] + bom["cost_emitters"] + bom["cost_valves"] | |
| total_cost = bom.get("total_cost_inr", bom.get("total_cost")) | |
| assert abs(total_cost - total) < 0.01 | |
| def test_bom_metric_mode(self): | |
| """Metric mode should not include costs.""" | |
| poly_utm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)]) | |
| design = generate_drip_layout(poly_utm, crop="generic", headland_buffer_m=0) | |
| bom = estimate_bom(design, unit="metric") | |
| assert "total_cost_usd" not in bom | |
| assert "main_line_16mm_m" in bom | |
| class TestDesignSummary: | |
| """Test human-readable design summary.""" | |
| def test_summary_format(self): | |
| """Summary should contain key metrics.""" | |
| poly_utm = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)]) | |
| design = generate_drip_layout(poly_utm, crop="tomato", headland_buffer_m=1.0) | |
| bom = estimate_bom(design, unit="usd") | |
| summary = design_summary(design, bom) | |
| assert "Drip Irrigation Design Summary" in summary | |
| assert "Farm Area" in summary | |
| assert "Crop" in summary | |
| assert "Tomato" in summary | |
| assert "Main Line Length" in summary | |
| assert "Total Cost" in summary | |
| class TestEdgeCases: | |
| """Test edge cases and boundary conditions.""" | |
| def test_very_small_polygon(self): | |
| """Small polygon (1m x 1m) should still work.""" | |
| poly_utm = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]) | |
| design = generate_drip_layout(poly_utm, crop="generic", headland_buffer_m=0) | |
| assert design["farm_area_ha"] > 0 | |
| def test_long_thin_polygon(self): | |
| """Long, thin field (strip) should still work.""" | |
| poly_utm = Polygon([(0, 0), (1000, 0), (1000, 5), (0, 5)]) | |
| design = generate_drip_layout(poly_utm, crop="generic", headland_buffer_m=0) | |
| assert len(design["laterals"]) > 0 | |
| def test_triangle_polygon(self): | |
| """Triangular field should work.""" | |
| poly_utm = Polygon([(0, 0), (100, 0), (50, 100)]) | |
| design = generate_drip_layout(poly_utm, crop="generic", headland_buffer_m=0) | |
| assert design["farm_area_ha"] > 0 | |
| if __name__ == "__main__": | |
| pytest.main([__file__, "-v"]) | |