"""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