feat(examples): osm-equity — Digital Divide & OSM Mapping Equity study#23
Conversation
A 5-phase FFL workflow (namespace osm.equity) encoding a tract-level study of how socioeconomic disparity shapes OSM data completeness/quality, with real handler implementations. Design: - Region OSM fetched ONCE and clipped per tract LOCALLY (no per-tract remote calls / rate limits); region fetches are UPDATE-GATED (reuse cache unless update=true). - Tract geometry carried into Phase 4 so Moran's I / GWR build real spatial weights; POI diversity (Shannon) measured and correlated with income. - Extrinsic footprint benchmark is pluggable and GATED — has_reference=false with an explicit N/A sentinel where no authoritative layer exists. - Provenance (acs_year + content-derived OSM snapshot) for reproducibility. Handlers do real geometry (shapely) + statistics (scipy Spearman, pure-numpy permutation Moran's I, Gaussian-kernel local GWR) over a deterministic offline data generator, so the pipeline runs with no network; TODO(real) markers show where TIGER / ACS API / Overpass / Open Buildings plug in. 13 tests pass: FFL compile+emit, util determinism, update-gating, extrinsic gating, and an end-to-end run that recovers the built-in digital-divide signal (Spearman rho>0.3 p<0.05, Moran's I>0) and flags low-income data deserts. ruff format + check clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive 5-phase workflow and associated handlers for studying OpenStreetMap (OSM) mapping equity and the digital divide at a census-tract scale. It includes FFL definitions, modular Python handlers for data acquisition, metrics calculation, spatial/statistical analysis, and reporting, as well as a robust test suite. The review feedback focuses on enhancing the robustness of the shared utility functions in equity_utils.py for real-world deployments. Key recommendations include using centroids to safely extract coordinates from polygon geometries, securing temporary directory paths to prevent multi-user conflicts, explicitly specifying UTF-8 encoding for GeoJSON operations, gracefully handling invalid geometries, and guarding against potential nan values or empty arrays in statistical calculations.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| ref_pts = [shape(f["geometry"]) for f in ref_buildings] | ||
| dists = [] | ||
| for b in osm_buildings[:80]: # cap for speed | ||
| p = shape(b["geometry"]) | ||
| nn = min(_haversine_km((p.x, p.y), (r.x, r.y)) for r in ref_pts[:120]) | ||
| dists.append(nn * 1000.0) |
There was a problem hiding this comment.
In real-world deployments, building footprints are represented as Polygon or MultiPolygon geometries rather than Point objects. Calling .x and .y directly on a Polygon will raise an AttributeError (e.g., 'Polygon' object has no attribute 'x'). To ensure compatibility with real spatial data, we should use the .centroid property of the geometries, which is available on all Shapely geometry types and safely exposes .x and .y coordinates.
| ref_pts = [shape(f["geometry"]) for f in ref_buildings] | |
| dists = [] | |
| for b in osm_buildings[:80]: # cap for speed | |
| p = shape(b["geometry"]) | |
| nn = min(_haversine_km((p.x, p.y), (r.x, r.y)) for r in ref_pts[:120]) | |
| dists.append(nn * 1000.0) | |
| ref_centroids = [shape(f["geometry"]).centroid for f in ref_buildings] | |
| dists = [] | |
| for b in osm_buildings[:80]: # cap for speed | |
| p = shape(b["geometry"]).centroid | |
| nn = min(_haversine_km((p.x, p.y), (c.x, c.y)) for c in ref_centroids[:120]) | |
| dists.append(nn * 1000.0) |
| def out_dir() -> Path: | ||
| base = ( | ||
| os.environ.get("FW_CACHE_ROOT") or os.environ.get("FW_OUTPUT_BASE") or tempfile.gettempdir() | ||
| ) | ||
| d = Path(base) / "osm_equity" | ||
| d.mkdir(parents=True, exist_ok=True) | ||
| return d |
There was a problem hiding this comment.
Using a fixed, predictable directory name like osm_equity directly under the shared temporary directory (/tmp or equivalent) can lead to permission conflicts (e.g., if another user on the same machine has already created it) or security vulnerabilities (CWE-377: Insecure Temporary File). To make this robust and secure on multi-user systems, we should append the current user's name to the temporary directory path when falling back to the system temp directory.
| def out_dir() -> Path: | |
| base = ( | |
| os.environ.get("FW_CACHE_ROOT") or os.environ.get("FW_OUTPUT_BASE") or tempfile.gettempdir() | |
| ) | |
| d = Path(base) / "osm_equity" | |
| d.mkdir(parents=True, exist_ok=True) | |
| return d | |
| def out_dir() -> Path: | |
| base = os.environ.get("FW_CACHE_ROOT") or os.environ.get("FW_OUTPUT_BASE") | |
| if base: | |
| d = Path(base) / "osm_equity" | |
| else: | |
| import getpass | |
| try: | |
| username = getpass.getuser() | |
| except Exception: | |
| username = "default" | |
| d = Path(tempfile.gettempdir()) / f"osm_equity_{username}" | |
| d.mkdir(parents=True, exist_ok=True) | |
| return d |
| def _write_geojson(path: Path, features: list[dict]) -> None: | ||
| path.write_text(json.dumps({"type": "FeatureCollection", "features": features})) | ||
|
|
||
|
|
||
| def _read_geojson(path: str) -> list[dict]: | ||
| return json.loads(Path(path).read_text()).get("features", []) |
There was a problem hiding this comment.
Reading and writing GeoJSON files without specifying an explicit encoding can lead to platform-dependent encoding issues (e.g., on Windows where the default encoding is often not UTF-8). Since the GeoJSON specification (RFC 7946) requires UTF-8 encoding, we should explicitly specify encoding="utf-8" in read_text and write_text calls.
| def _write_geojson(path: Path, features: list[dict]) -> None: | |
| path.write_text(json.dumps({"type": "FeatureCollection", "features": features})) | |
| def _read_geojson(path: str) -> list[dict]: | |
| return json.loads(Path(path).read_text()).get("features", []) | |
| def _write_geojson(path: Path, features: list[dict]) -> None: | |
| path.write_text(json.dumps({"type": "FeatureCollection", "features": features}), encoding="utf-8") | |
| def _read_geojson(path: str) -> list[dict]: | |
| return json.loads(Path(path).read_text(encoding="utf-8")).get("features", []) |
| for f in _read_geojson(region_osm_path): | ||
| geom = shape(f["geometry"]) | ||
| rep = geom if geom.geom_type == "Point" else geom.representative_point() | ||
| if poly.covers(rep): | ||
| kept.append(f) |
There was a problem hiding this comment.
Real-world OpenStreetMap data often contains invalid geometries (e.g., self-intersecting polygons). Performing spatial operations like .covers() or .representative_point() on invalid geometries can raise a TopologicalError and crash the entire pipeline. To ensure robustness when transitioning to real data, we should gracefully handle invalid geometries by attempting to repair them with .buffer(0) and wrapping the parsing in a try-except block.
| for f in _read_geojson(region_osm_path): | |
| geom = shape(f["geometry"]) | |
| rep = geom if geom.geom_type == "Point" else geom.representative_point() | |
| if poly.covers(rep): | |
| kept.append(f) | |
| for f in _read_geojson(region_osm_path): | |
| try: | |
| geom = shape(f["geometry"]) | |
| if not geom.is_valid: | |
| geom = geom.buffer(0) | |
| rep = geom if geom.geom_type == "Point" else geom.representative_point() | |
| if poly.covers(rep): | |
| kept.append(f) | |
| except Exception: | |
| continue |
| dmat = np.array( | ||
| [[_haversine_km(tuple(coords[i]), tuple(coords[j])) for j in range(n)] for i in range(n)] | ||
| ) | ||
| bw = float(np.median(dmat[dmat > 0])) or 1.0 |
There was a problem hiding this comment.
If the study area contains only one tract or all tract centroids are identical, dmat[dmat > 0] will be empty. Calling np.median on an empty array raises a RuntimeWarning and returns nan, which propagates and causes downstream calculations to fail. We should guard against this by checking if there are any non-zero distances before computing the median.
dmat = np.array(
[[_haversine_km(tuple(coords[i]), tuple(coords[j])) for j in range(n)] for i in range(n)]
)
nonzero_dists = dmat[dmat > 0]
bw = float(np.median(nonzero_dists)) if nonzero_dists.size > 0 else 1.0| rho, p = stats.spearmanr(inc, div) | ||
| return float(round(rho, 4)), float(round(p, 5)) |
There was a problem hiding this comment.
If the input data has zero variance (e.g., all incomes or POI diversities are identical), stats.spearmanr will return nan values. This propagates to the HTML report and causes formatting issues. We should explicitly handle nan values and return sensible defaults (0.0 for correlation, 1.0 for p-value) to ensure robustness.
| rho, p = stats.spearmanr(inc, div) | |
| return float(round(rho, 4)), float(round(p, 5)) | |
| rho, p = stats.spearmanr(inc, div) | |
| rho_val = 0.0 if np.isnan(rho) else float(round(rho, 4)) | |
| p_val = 1.0 if np.isnan(p) else float(round(p, 5)) | |
| return rho_val, p_val |
A 5-phase FFL workflow (
namespace osm.equity) that encodes a census-tract-level study of how socioeconomic disparity shapes OpenStreetMap completeness/quality, with real handler implementations. The FFL is the methodology; the runtime schedules it by data flow.Phases → FFL
DefineStudyArea+ workflow paramsResolveStudyTracts,FetchRegionOSM,FetchRegionFootprints(region-level, update-gated)foreach)ClipTractOSM(local),FetchCensusEquity,ComputeAttributeQuality,ComputeExtrinsicQuality(gated)SpearmanCorrelation,MoransI,GeographicallyWeightedRegression,TemporalEvolution(concurrent)BuildEquityReport→ HTML + choropleth + data desertsImprovements folded in
update=true.has_reference=false, explicit N/A — no silent zeroing).acs_year+ content-derived OSMsnapshot).Implementation & tests
Handlers do real geometry (shapely) + statistics (scipy Spearman, pure-numpy permutation Moran's I, Gaussian-kernel local GWR) over a deterministic offline data generator (
TODO(real)marks where TIGER/ACS/Overpass/Open-Buildings plug in). 13 tests pass — FFL compile+emit, determinism, update-gating, extrinsic gating, and an end-to-end run recovering the built-in digital-divide signal.ruffclean.🤖 Generated with Claude Code