Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/osm-equity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ tests/test_osm_equity.py FFL-compile + unit + end-to-end signal recovery
| 2 | Data acquisition | `ResolveStudyTracts`, `FetchRegionOSM`, `FetchRegionFootprints` (region-level, **update-gated**) |
| 3 | Metric calculation | per-tract `foreach`: `ClipTractOSM` (local), `FetchCensusEquity`, `ComputeAttributeQuality` (intrinsic), `ComputeExtrinsicQuality` (extrinsic, gated) |
| 4 | Statistical & spatial | `SpearmanCorrelation`, `MoransI`, `GeographicallyWeightedRegression`, `TemporalEvolution` — run concurrently over the full tract set |
| 5 | Actionable reporting | `BuildEquityReport` → HTML + choropleth GeoJSON + ranked data deserts |
| 5 | Actionable reporting | `BuildEquityReport` → interactive MapLibre heat map + HTML findings report + ranked data deserts |

## Design choices (folded-in improvements)

Expand Down
113 changes: 108 additions & 5 deletions examples/osm-equity/handlers/shared/equity_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -649,14 +649,114 @@ def data_deserts(records: list[dict]) -> list[str]:
return [g for g, _ in scored[:cut]]


def render_maplibre_map(
feats: list[dict], title: str, stats_res: dict, deserts: list[str], synthetic: bool = True
) -> str:
"""Self-contained MapLibre GL choropleth ("heat map") of tract OSM
attribute completeness, styled to match the facetwork-maps gallery:
inline GeoJSON, CARTO Voyager raster basemap, click popups, a legend,
and a findings banner. Data-desert tracts get a red outline."""
fc = json.dumps({"type": "FeatureCollection", "features": feats})
xs, ys = [], []
for f in feats:
for ring in f["geometry"]["coordinates"]:
for x, y in ring:
xs.append(x)
ys.append(y)
Comment on lines +660 to +665

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

The current coordinate extraction logic assumes all geometries are single Polygons. If any tract geometry is a MultiPolygon (which is common for real-world census tracts), f["geometry"]["coordinates"] will have an extra level of nesting, causing for x, y in ring: to raise a ValueError: too many values to unpack.

Since shapely.geometry.shape is already imported, we can compute the bounding box robustly for any geometry type (including Polygon and MultiPolygon) by using the .bounds property of the shapely shape.

    xs, ys = [], []
    for f in feats:
        geom = f.get("geometry")
        if geom:
            try:
                sh = shape(geom)
                if not sh.is_empty:
                    minx, miny, maxx, maxy = sh.bounds
                    xs.extend([minx, maxx])
                    ys.extend([miny, maxy])
            except Exception:
                pass

bounds = [[min(xs), min(ys)], [max(xs), max(ys)]] if xs else [[-124.4, 32.5], [-114.1, 42.0]]
rho = stats_res.get("spearman_rho", 0.0)
moran = stats_res.get("morans_i", 0.0)
trend = stats_res.get("temporal_trend", "n/a")
synth = (
'<div class="synth">&#9888; SYNTHETIC DEMONSTRATION DATA - methodology demo, '
"not real OSM/Census values</div>"
if synthetic
else ""
)
return f"""<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel="stylesheet">
<style>
html,body{{margin:0;height:100%;font-family:system-ui,sans-serif}}
#map{{position:absolute;inset:0}}
.panel{{position:absolute;top:12px;left:12px;z-index:2;background:rgba(255,255,255,.94);
padding:12px 14px;border-radius:8px;box-shadow:0 1px 6px rgba(0,0,0,.3);max-width:320px;font-size:13px}}
.panel h1{{font-size:15px;margin:0 0 6px}}
.synth{{color:#b00;font-weight:600;font-size:11px;margin-bottom:6px}}
.legend{{position:absolute;bottom:20px;left:12px;z-index:2;background:rgba(255,255,255,.94);
padding:8px 10px;border-radius:8px;box-shadow:0 1px 6px rgba(0,0,0,.3);font-size:12px}}
.bar{{height:10px;width:180px;background:linear-gradient(90deg,#d73027,#fc8d59,#fee08b,#91cf60,#1a9850);
border-radius:2px;margin:4px 0}}
.lab{{display:flex;justify-content:space-between;width:180px}}
.maplibregl-popup-content{{font-size:12px}}
</style></head><body>
<div id="map"></div>
<div class="panel">
{synth}
<h1>{title}</h1>
<div>Tract OSM <b>attribute completeness</b> (green = well-mapped, red = under-mapped).
Red-outlined tracts are <b>data deserts</b> (bottom quartile).</div>
<div style="margin-top:6px;color:#333">
Spearman income~POI diversity <b>&rho;={rho:.2f}</b> &middot;
Moran's I <b>{moran:.2f}</b> &middot; divide <b>{trend}</b>
</div>
</div>
<div class="legend">
<div>Attribute completeness</div>
<div class="bar"></div>
<div class="lab"><span>0.0</span><span>0.5</span><span>1.0</span></div>
</div>
<script>
const data = {fc};
const map = new maplibregl.Map({{
container:'map',
style:{{version:8,sources:{{carto:{{type:'raster',
tiles:['https://a.basemaps.cartocdn.com/rastertiles/voyager/{{z}}/{{x}}/{{y}}.png',
'https://b.basemaps.cartocdn.com/rastertiles/voyager/{{z}}/{{x}}/{{y}}.png'],
tileSize:256,attribution:'&copy; OpenStreetMap &copy; CARTO'}}}},
layers:[{{id:'carto',type:'raster',source:'carto'}}]}},
bounds:{json.dumps(bounds)}, fitBoundsOptions:{{padding:30}}
}});
map.addControl(new maplibregl.NavigationControl());
map.on('load',()=>{{
map.addSource('tracts',{{type:'geojson',data}});
map.addLayer({{id:'fill',type:'fill',source:'tracts',paint:{{
'fill-color':['interpolate',['linear'],['get','attr_completeness'],
0.0,'#d73027',0.25,'#fc8d59',0.5,'#fee08b',0.75,'#91cf60',1.0,'#1a9850'],
'fill-opacity':0.72}}}});
map.addLayer({{id:'outline',type:'line',source:'tracts',paint:{{
'line-color':'#333','line-width':0.4}}}});
map.addLayer({{id:'desert',type:'line',source:'tracts',
filter:['==',['get','data_desert'],true],
paint:{{'line-color':'#b00','line-width':2.2}}}});
map.on('click','fill',(e)=>{{
const p=e.features[0].properties;
const fc = p.has_reference==='true'||p.has_reference===true
? Number(p.footprint_completeness).toFixed(2) : 'N/A';
new maplibregl.Popup().setLngLat(e.lngLat).setHTML(
`<b>Tract ${{p.geoid}}</b><br>Median income: $${{Number(p.median_income).toLocaleString()}}<br>`+
`Attr completeness: ${{Number(p.attr_completeness).toFixed(2)}}<br>`+
`POI diversity: ${{Number(p.poi_diversity).toFixed(2)}}<br>`+
`Footprint completeness: ${{fc}}<br>`+
`Data desert: ${{p.data_desert==='true'||p.data_desert===true?'YES':'no'}}`).addTo(map);
}});
map.on('mouseenter','fill',()=>map.getCanvas().style.cursor='pointer');
map.on('mouseleave','fill',()=>map.getCanvas().style.cursor='');
}});
</script></body></html>"""


def build_report(records: list[dict], stats_res: dict, title: str) -> tuple[str, str, list[str]]:
"""Phase 5 - HTML report + a simple GeoJSON choropleth artifact +
the ranked data-desert list, with intervention recommendations."""
"""Phase 5 - interactive MapLibre heat map + an HTML findings report +
the ranked data-desert list, with intervention recommendations.
Returns (report_html_path, map_html_path, deserts)."""
recs = _as_list(records)
deserts = data_deserts(recs)
n_ref = sum(1 for r in recs if r["extrinsic"].get("has_reference"))

# choropleth artifact: tract polygons coloured by completeness
# choropleth features: tract polygons + per-tract properties
feats = []
for r in recs:
feats.append(
Expand All @@ -674,8 +774,11 @@ def build_report(records: list[dict], stats_res: dict, title: str) -> tuple[str,
"geometry": mapping(shapely_wkt.loads(r["geometry_wkt"])),
}
)
map_path = out_dir() / "equity_choropleth.geojson"
_write_geojson(map_path, feats)
_write_geojson(out_dir() / "equity_choropleth.geojson", feats)

# the interactive heat map (this is the returned map_path)
map_path = out_dir() / "equity_map.html"
map_path.write_text(render_maplibre_map(feats, title, stats_res, deserts))

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

When writing text files in Python, it is a best practice to explicitly specify encoding="utf-8". Otherwise, Python will fall back to the platform's default encoding (e.g., cp1252 on Windows), which can lead to UnicodeEncodeError if the HTML content contains non-ASCII characters.

Suggested change
map_path.write_text(render_maplibre_map(feats, title, stats_res, deserts))
map_path.write_text(render_maplibre_map(feats, title, stats_res, deserts), encoding="utf-8")


rho = stats_res.get("spearman_rho", 0.0)
moran = stats_res.get("morans_i", 0.0)
Expand Down
1 change: 1 addition & 0 deletions examples/osm-equity/tests/test_osm_equity.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ def test_full_pipeline_runs_and_reports(self, tmp_path, monkeypatch):
assert len(records) >= 9
assert os.path.exists(report["report_html"])
assert os.path.exists(report["map_path"])
assert "maplibre" in open(report["map_path"]).read().lower() # interactive heat map

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

Using open().read() without a with statement leaves the file handle open until garbage collection runs, which can cause resource leaks or file locking issues (especially on Windows). Additionally, specifying encoding="utf-8" ensures the test runs consistently across different platforms.

Suggested change
assert "maplibre" in open(report["map_path"]).read().lower() # interactive heat map
with open(report["map_path"], encoding="utf-8") as f:
assert "maplibre" in f.read().lower() # interactive heat map

html = open(report["report_html"]).read()
assert "Mapping Equity" in html or "Digital Divide" in html or "data desert" in html.lower()
assert isinstance(report["data_deserts"], list) and len(report["data_deserts"]) >= 1
Expand Down
Loading