File size: 10,277 Bytes
5a966bd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ec45bf2
5a966bd
 
 
 
ec45bf2
 
 
5a966bd
 
 
 
 
 
ec45bf2
5a966bd
ec45bf2
 
 
5a966bd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
"""
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"])