File size: 10,385 Bytes
10fe5f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c22a41
 
10fe5f1
 
 
 
 
 
 
 
 
 
 
3c22a41
10fe5f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b4bc313
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10fe5f1
 
 
 
 
 
 
 
 
 
 
 
 
3c22a41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Right-of-way estimation geometry β€” the street-aware extension and its fallbacks.

`build_estimation_geometry` is pure; we drive it with synthetic street/neighbor
callbacks over a real Omaha UTM square so the WGS84<->UTM math is faithful. These
lock in: exact extension area, corner-lot dual frontage, phantom removal via
neighbor subtraction (no-street fallback), and no bleed into an attached unit.
"""

from __future__ import annotations

import pytest
from pyproj import Transformer
from shapely.geometry import LineString
from shapely.ops import transform as shp_tf

from conftest import (
    CENTER_LAT,
    CENTER_LON,
    OMAHA_CX,
    OMAHA_CY,
    SQFT_PER_SQM,
    rect_utm,
    square_utm,
    utm_to_wgs,
)
from lawn_estimator.geometry import (
    area_per_pixel_sqft,
    build_estimation_geometry,
    geometry_area_sqft,
    geometry_exceeds_frame,
)

CRS = "EPSG:26914"
BUFFER_FT = 12.0
DIST_M = BUFFER_FT * 0.3048  # 3.6576 m
SIZE_M = 30.0


def _street(name, line_utm):
    return (name, utm_to_wgs(line_utm))


def _streets_fn(*named_lines):
    return lambda bounds: list(named_lines)


def _neighbors_fn(*polys_wgs):
    return lambda bounds, exclude_id: list(polys_wgs)


# A horizontal centerline 10 m south of the parcel's south edge (within the 22 m
# street-detection window), and a vertical one 10 m east of the east edge.
SOUTH_STREET = _street("South St", LineString([(OMAHA_CX - 50, OMAHA_CY - 25),
                                               (OMAHA_CX + 50, OMAHA_CY - 25)]))
EAST_STREET = _street("East St", LineString([(OMAHA_CX + 25, OMAHA_CY - 50),
                                             (OMAHA_CX + 25, OMAHA_CY + 50)]))


def test_single_frontage_extension_exact_area():
    parcel = utm_to_wgs(square_utm(OMAHA_CX, OMAHA_CY, SIZE_M))
    _, est_utm, area_sqft, extensions = build_estimation_geometry(
        parcel, BUFFER_FT, CRS,
        fetch_neighbors=_neighbors_fn(),  # no neighbors
        fetch_streets=_streets_fn(SOUTH_STREET),
    )

    assert len(extensions) == 1
    ext = extensions[0]
    assert ext["street"] == "South St"
    assert ext["street_facing"] is True
    # One edge (30 m) extended by 12 ft (3.6576 m).
    expected_ext = SIZE_M * DIST_M * SQFT_PER_SQM  # ~1181.1 sqft
    assert ext["area_sqft"] == pytest.approx(expected_ext, rel=2e-3)
    expected_total = (SIZE_M * SIZE_M + SIZE_M * DIST_M) * SQFT_PER_SQM
    assert area_sqft == pytest.approx(expected_total, rel=2e-3)


def test_corner_lot_gets_two_named_frontages_only():
    parcel = utm_to_wgs(square_utm(OMAHA_CX, OMAHA_CY, SIZE_M))
    _, _, area_sqft, extensions = build_estimation_geometry(
        parcel, BUFFER_FT, CRS,
        fetch_neighbors=_neighbors_fn(),
        fetch_streets=_streets_fn(SOUTH_STREET, EAST_STREET),
    )

    # Two street-facing edges β†’ two frontages; the north/west edges get nothing.
    assert len(extensions) == 2
    assert {e["street"] for e in extensions} == {"South St", "East St"}
    per_edge = SIZE_M * DIST_M * SQFT_PER_SQM
    assert sum(e["area_sqft"] for e in extensions) == pytest.approx(2 * per_edge, rel=2e-3)


def test_no_street_data_extends_all_then_neighbor_subtraction_removes_phantom():
    parcel = utm_to_wgs(square_utm(OMAHA_CX, OMAHA_CY, SIZE_M))
    north_neighbor = utm_to_wgs(rect_utm(OMAHA_CX, OMAHA_CY + SIZE_M, SIZE_M, SIZE_M))

    # Fallback path: no street source β†’ extend every edge (~4 strips).
    _, _, area_no, ext_no = build_estimation_geometry(
        parcel, BUFFER_FT, CRS, fetch_neighbors=_neighbors_fn(), fetch_streets=None)
    _, est_with, area_with, ext_with = build_estimation_geometry(
        parcel, BUFFER_FT, CRS, fetch_neighbors=_neighbors_fn(north_neighbor), fetch_streets=None)

    one_strip = SIZE_M * DIST_M * SQFT_PER_SQM  # ~1181 sqft
    # The north neighbor carves one phantom strip out of both the area and the report.
    assert area_no - area_with == pytest.approx(one_strip, rel=5e-2)
    assert (sum(e["area_sqft"] for e in ext_no) - sum(e["area_sqft"] for e in ext_with)
            == pytest.approx(one_strip, rel=5e-2))

    # And the estimate never bleeds into the neighbor lot.
    to_utm = Transformer.from_crs("EPSG:4326", CRS, always_xy=True).transform
    assert est_with.intersection(shp_tf(to_utm, north_neighbor)).area == pytest.approx(0.0, abs=1.0)
    # No-street extensions are reported unverified (street_facing is None).
    assert all(e["street"] is None and e["street_facing"] is None for e in ext_with)


def test_attached_unit_no_bleed_into_neighbor_parcel():
    # A duplex is one parcel per unit; a narrow lot with an adjoining unit to the
    # east must not extend into that unit even in the extend-all fallback.
    parcel = utm_to_wgs(rect_utm(OMAHA_CX, OMAHA_CY, 10.0, 30.0))
    east_unit_utm = rect_utm(OMAHA_CX + 10.0, OMAHA_CY, 10.0, 30.0)  # shares the east edge
    east_unit = utm_to_wgs(east_unit_utm)

    _, est_utm, _, _ = build_estimation_geometry(
        parcel, BUFFER_FT, CRS,
        fetch_neighbors=_neighbors_fn(east_unit), fetch_streets=None)

    to_utm = Transformer.from_crs("EPSG:4326", CRS, always_xy=True).transform
    neighbor_utm = shp_tf(to_utm, east_unit)
    overlap = est_utm.intersection(neighbor_utm).area
    assert overlap == pytest.approx(0.0, abs=1.0)  # no measurable bleed


# ---------------------------------------------------------------------------
# Adaptive ROW-to-curb (row_to_curb flag): extend a deep-setback frontage toward
# the actual centerline (β‰ˆ the curb) instead of the flat 12 ft, floored at 12 ft
# (never shrinks a frontage) and capped at 25 ft (wide arterials can't blow up).
# ---------------------------------------------------------------------------
HALF_ROAD_FT = 15.0
CAP_FT = 25.0
HALF_ROAD_M = HALF_ROAD_FT * 0.3048  # 4.572 m β€” half a typical residential road
CAP_M = CAP_FT * 0.3048              # 7.62 m


def _south_street_at(offset_m, name="South St"):
    """Horizontal centerline `offset_m` south of the 30 m parcel's south edge, so
    the south frontage's measured centerline distance is exactly `offset_m`. The
    line spans only Β±12 m (< the 15 m half-width) so the side edges' nearest point
    is an inward endpoint β†’ cleanly rejected by the outward-side test, isolating a
    single south frontage regardless of how close the street sits."""
    y = OMAHA_CY - SIZE_M / 2 - offset_m
    return _street(name, LineString([(OMAHA_CX - 12, y), (OMAHA_CX + 12, y)]))


def _front_ext(offset_m, *, row_to_curb):
    """(front extension sqft, total estimation sqft) for a lone south frontage
    whose centerline sits `offset_m` out."""
    parcel = utm_to_wgs(square_utm(OMAHA_CX, OMAHA_CY, SIZE_M))
    _, _, area_sqft, extensions = build_estimation_geometry(
        parcel, BUFFER_FT, CRS,
        fetch_neighbors=_neighbors_fn(),
        fetch_streets=_streets_fn(_south_street_at(offset_m)),
        row_to_curb=row_to_curb,
        road_half_width_ft=HALF_ROAD_FT,
        row_curb_cap_ft=CAP_FT,
    )
    assert len(extensions) == 1  # only the south edge faces the street
    return extensions[0]["area_sqft"], area_sqft


def test_row_to_curb_reaches_curb_on_deep_setback():
    # Centerline 10 m out β†’ curb β‰ˆ 10 βˆ’ 4.572 = 5.428 m past the property line,
    # well beyond the flat 12 ft (3.6576 m) strip.
    ext, _ = _front_ext(10.0, row_to_curb=True)
    expected = SIZE_M * (10.0 - HALF_ROAD_M) * SQFT_PER_SQM  # ~1752.8 sqft
    assert ext == pytest.approx(expected, rel=3e-3)
    flat, _ = _front_ext(10.0, row_to_curb=False)
    assert ext > flat  # adaptive reaches farther than the flat strip


def test_row_to_curb_capped_on_very_deep_setback():
    # Centerline 16 m out β†’ 16 βˆ’ 4.572 = 11.428 m would overshoot; capped at 25 ft.
    ext, _ = _front_ext(16.0, row_to_curb=True)
    expected = SIZE_M * CAP_M * SQFT_PER_SQM  # ~2460.7 sqft
    assert ext == pytest.approx(expected, rel=3e-3)


def test_row_to_curb_floors_at_flat_strip_on_shallow_setback():
    # Centerline 5 m out β†’ 5 βˆ’ 4.572 = 0.428 m would shrink the strip; floored at
    # 12 ft, so a shallow lot is unchanged from the flat behavior.
    ext_on, _ = _front_ext(5.0, row_to_curb=True)
    ext_off, _ = _front_ext(5.0, row_to_curb=False)
    assert ext_on == pytest.approx(ext_off, rel=1e-6)


def test_row_to_curb_off_leaves_deep_setback_at_flat_strip():
    # The flag gates the change: with it off, a deep-setback frontage stays the
    # flat 12 ft strip (the estimation path must not move when off).
    ext_off, area_off = _front_ext(10.0, row_to_curb=False)
    flat = SIZE_M * DIST_M * SQFT_PER_SQM  # ~1181.1 sqft
    assert ext_off == pytest.approx(flat, rel=2e-3)
    _, area_on = _front_ext(10.0, row_to_curb=True)
    assert area_on > area_off  # the flag, and only the flag, moves the number


# ---------------------------------------------------------------------------
# Pure projection/area helpers
# ---------------------------------------------------------------------------
def test_area_per_pixel_scales_with_scale_factor():
    # A scale-2 image has 4x the pixels, so each pixel covers 1/4 the ground area.
    at_scale_1 = area_per_pixel_sqft(41.26, 20, 1)
    at_scale_2 = area_per_pixel_sqft(41.26, 20, 2)
    assert at_scale_1 == pytest.approx(4 * at_scale_2, rel=1e-9)


def test_geometry_area_of_known_square():
    parcel = utm_to_wgs(square_utm(OMAHA_CX, OMAHA_CY, SIZE_M))
    assert geometry_area_sqft(parcel, CRS) == pytest.approx(SIZE_M * SIZE_M * SQFT_PER_SQM, rel=2e-3)


# ---------------------------------------------------------------------------
# Frame guard (H-3): a parcel larger than the fixed imagery window is measured on
# truncated masks; the pipeline flags it rather than return a confident wrong number.
# ---------------------------------------------------------------------------
_FRAME_KW = dict(center_latitude=CENTER_LAT, center_longitude=CENTER_LON,
                 zoom=20, image_width_px=1280, image_height_px=1280, scale=2)


def test_normal_lot_fits_the_frame():
    parcel = utm_to_wgs(square_utm(OMAHA_CX, OMAHA_CY, 30.0))  # ~30 m, well inside
    assert geometry_exceeds_frame(parcel, **_FRAME_KW) is False


def test_oversized_parcel_exceeds_frame():
    # The frame is ~71.8 m across at zoom 20; a 120 m lot overflows it.
    parcel = utm_to_wgs(square_utm(OMAHA_CX, OMAHA_CY, 120.0))
    assert geometry_exceeds_frame(parcel, **_FRAME_KW) is True