Skip to content

feat(examples): osm-equity — Digital Divide & OSM Mapping Equity study#23

Merged
rlemke merged 1 commit into
mainfrom
feat/osm-equity-example
Jul 14, 2026
Merged

feat(examples): osm-equity — Digital Divide & OSM Mapping Equity study#23
rlemke merged 1 commit into
mainfrom
feat/osm-equity-example

Conversation

@rlemke

@rlemke rlemke commented Jul 14, 2026

Copy link
Copy Markdown
Owner

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

Phase FFL
1 Study design DefineStudyArea + workflow params
2 Acquisition ResolveStudyTracts, FetchRegionOSM, FetchRegionFootprints (region-level, update-gated)
3 Metrics (per-tract foreach) ClipTractOSM (local), FetchCensusEquity, ComputeAttributeQuality, ComputeExtrinsicQuality (gated)
4 Stats & spatial SpearmanCorrelation, MoransI, GeographicallyWeightedRegression, TemporalEvolution (concurrent)
5 Reporting BuildEquityReport → HTML + choropleth + data deserts

Improvements folded in

  1. Fetch OSM once, clip per tract locally — no per-tract Overpass/ohsome calls.
  2. Update-gated fetch — reuse cache unless update=true.
  3. Geometry carried into Phase 4 so Moran's I / GWR build real spatial weights.
  4. POI diversity (Shannon) measured + correlated with income.
  5. Extrinsic benchmark pluggable + gated (has_reference=false, explicit N/A — no silent zeroing).
  6. Provenance (acs_year + content-derived OSM snapshot).

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. ruff clean.

🤖 Generated with Claude Code

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>
@rlemke rlemke merged commit 8f09803 into main Jul 14, 2026
17 of 20 checks passed
@rlemke rlemke deleted the feat/osm-equity-example branch July 14, 2026 00:08

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +459 to +464
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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)

Comment on lines +76 to +82
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

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.

Suggested change
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

Comment on lines +91 to +96
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", [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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", [])

Comment on lines +358 to +362
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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

Comment on lines +571 to +574
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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

Comment on lines +506 to +507
rho, p = stats.spearmanr(inc, div)
return float(round(rho, 4)), float(round(p, 5))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant