|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +verify_sewer_corridor.py |
| 4 | +------------------------ |
| 5 | +Verify the Estimated Sewer Service Corridor area calculation. |
| 6 | +
|
| 7 | +This script: |
| 8 | +1. Loads all wastewater and combined sewer features |
| 9 | +2. Projects to UTM Zone 18 for accurate calculations |
| 10 | +3. Buffers them by 300 feet (91.4 meters) on both sides |
| 11 | +4. Unions (dissolves) overlapping buffers |
| 12 | +5. Clips to Vermont town boundaries |
| 13 | +6. Calculates total area in square miles |
| 14 | +
|
| 15 | +Expected: ~111.34 square miles |
| 16 | +
|
| 17 | +Run from repo root: |
| 18 | + python scripts/verify_sewer_corridor.py |
| 19 | +""" |
| 20 | + |
| 21 | +import json |
| 22 | +from pathlib import Path |
| 23 | + |
| 24 | +from shapely.geometry import shape |
| 25 | +from shapely.ops import unary_union |
| 26 | +import geopandas as gpd |
| 27 | +from geopandas import GeoSeries, GeoDataFrame |
| 28 | + |
| 29 | +REPO = Path(__file__).resolve().parent.parent |
| 30 | +LINEAR_DIR = REPO / "data" / "linear_by_rpc" |
| 31 | +TOWNS_FILE = REPO / "data" / "Vermont_Town_GEOID_RPC_County.geojson" |
| 32 | + |
| 33 | + |
| 34 | +def load_linear_features(): |
| 35 | + """Load all linear features from RPC split files.""" |
| 36 | + paths = sorted(LINEAR_DIR.glob("Vermont_Linear_*.geojson")) |
| 37 | + features = [] |
| 38 | + for path in paths: |
| 39 | + with path.open() as f: |
| 40 | + gj = json.load(f) |
| 41 | + features.extend(gj.get("features", [])) |
| 42 | + return features |
| 43 | + |
| 44 | + |
| 45 | +def load_vermont_boundary(): |
| 46 | + """Load Vermont town boundaries and union them into one polygon.""" |
| 47 | + with TOWNS_FILE.open() as f: |
| 48 | + towns_gj = json.load(f) |
| 49 | + |
| 50 | + polygons = [] |
| 51 | + for feature in towns_gj.get("features", []): |
| 52 | + geom = feature.get("geometry") |
| 53 | + if geom: |
| 54 | + polygons.append(shape(geom)) |
| 55 | + |
| 56 | + if not polygons: |
| 57 | + raise ValueError("No Vermont town polygons loaded") |
| 58 | + |
| 59 | + vermont_boundary = unary_union(polygons) |
| 60 | + return vermont_boundary |
| 61 | + |
| 62 | + |
| 63 | +def verify_corridor(): |
| 64 | + """Calculate the sewer service corridor area.""" |
| 65 | + features = load_linear_features() |
| 66 | + vermont_boundary_wgs84 = load_vermont_boundary() |
| 67 | + |
| 68 | + # Filter to wastewater and combined only |
| 69 | + ww_features = [ |
| 70 | + f for f in features |
| 71 | + if (f.get("properties") or {}).get("SystemType") in ("Wastewater", "Combined") |
| 72 | + ] |
| 73 | + |
| 74 | + print(f"Total linear features: {len(features):,}") |
| 75 | + print(f"Wastewater + Combined features: {len(ww_features):,}") |
| 76 | + |
| 77 | + if not ww_features: |
| 78 | + print("No wastewater/combined features found") |
| 79 | + return |
| 80 | + |
| 81 | + # Create GeoDataFrame with wastewater/combined features |
| 82 | + geos = [{"geometry": shape(f.get("geometry"))} for f in ww_features] |
| 83 | + gdf = GeoDataFrame(geos, crs="EPSG:4326") |
| 84 | + |
| 85 | + # Project to UTM Zone 18 for accurate buffering and area calculation |
| 86 | + print("Projecting to UTM Zone 18...") |
| 87 | + gdf_utm = gdf.to_crs("EPSG:32618") # UTM Zone 18N (covers Vermont) |
| 88 | + |
| 89 | + # 300 feet = 91.4432 meters |
| 90 | + BUFFER_DISTANCE_M = 91.4432 |
| 91 | + print(f"Buffering by {BUFFER_DISTANCE_M} meters ({BUFFER_DISTANCE_M / 0.3048:.1f} feet)...") |
| 92 | + gdf_buffered = gdf_utm.copy() |
| 93 | + gdf_buffered["geometry"] = gdf_utm.geometry.buffer(BUFFER_DISTANCE_M) |
| 94 | + |
| 95 | + # Dissolve (union) all buffers using shapely for efficiency |
| 96 | + print("Unioning overlapping buffers (this may take a minute)...") |
| 97 | + buffered_geoms = [geom for geom in gdf_buffered.geometry] |
| 98 | + corridor_utm = unary_union(buffered_geoms) |
| 99 | + |
| 100 | + # Load and project Vermont boundary |
| 101 | + print("Projecting Vermont boundary to UTM...") |
| 102 | + vt_boundary_gdf = GeoDataFrame({"geometry": [vermont_boundary_wgs84]}, crs="EPSG:4326") |
| 103 | + vt_boundary_utm = vt_boundary_gdf.to_crs("EPSG:32618").iloc[0].geometry |
| 104 | + |
| 105 | + # Clip corridor to Vermont boundary |
| 106 | + print("Clipping corridor to Vermont boundary...") |
| 107 | + clipped_corridor_utm = corridor_utm.intersection(vt_boundary_utm) |
| 108 | + |
| 109 | + # Calculate area in square meters |
| 110 | + area_sq_meters = clipped_corridor_utm.area |
| 111 | + |
| 112 | + # Convert to square miles: 1 mile = 1609.34 meters |
| 113 | + sq_miles_per_sq_meter = 1 / (1609.34 ** 2) |
| 114 | + area_sq_miles = area_sq_meters * sq_miles_per_sq_meter |
| 115 | + |
| 116 | + print(f"\n{'='*60}") |
| 117 | + print(f"Sewer Service Corridor Area: {area_sq_miles:.2f} square miles") |
| 118 | + print(f"{'='*60}") |
| 119 | + print(f"\nExpected (from index.html): 111.34 square miles") |
| 120 | + print(f"Difference: {abs(area_sq_miles - 111.34):.2f} square miles ({abs(area_sq_miles - 111.34)/111.34*100:.1f}%)") |
| 121 | + |
| 122 | + |
| 123 | +if __name__ == "__main__": |
| 124 | + verify_corridor() |
0 commit comments