"""Turn Close API replies into GeoDataFrames.
The client does this for you in the default ``spatial`` output mode; this module
is the machinery, and :func:`to_geopandas` is also usable by hand. It builds on
the row-shaping in :mod:`closecity.tabular` and adds geometry. Three response
shapes are recognised automatically:
* **POI rows** (``lat`` / ``lon``) from :meth:`Client.pois_search`,
:meth:`block_pois`, :meth:`point_pois`, :meth:`poi`, and :meth:`places` become
**point** geometry, built offline.
* **Isochrone** replies with ``format=geojson`` from :meth:`Client.isochrone` become
**polygon** geometry, built offline from the ``features`` envelope (a custom
object, not a bare FeatureCollection).
* **Block rows** (``geoid`` only) from :meth:`blocks_query`, :meth:`place_blocks`,
:meth:`poi_catchment`, and isochrone ``format=blocks`` become **polygons** by
joining census-block boundaries. Pass them as ``block_geometry`` (a GeoDataFrame
keyed on ``geoid_col``), or let ``fetch=True`` download them with ``pygris``
(the ``tiger`` extra).
"""
from __future__ import annotations
from typing import Any
__all__ = ["to_geopandas"]
def _require_geopandas():
# geopandas is a required dependency; this import always succeeds.
import geopandas as gpd
from shapely.geometry import Point, shape
return gpd, Point, shape
def _isochrone_gdf(data: dict, gpd, shape, crs):
geoms, records = [], []
for feature in data.get("features") or []:
geom = feature.get("geometry")
geoms.append(shape(geom) if geom else None)
records.append(dict(feature.get("properties") or {}))
return gpd.GeoDataFrame(records, geometry = geoms, crs = crs)
def _points_gdf(rows: list[dict], gpd, Point, crs):
geoms = [
Point(r["lon"], r["lat"])
if r.get("lon") is not None and r.get("lat") is not None
else None
for r in rows
]
return gpd.GeoDataFrame(list(rows), geometry = geoms, crs = crs)
def _fetch_blocks(geoids: list[str], geoid_col: str):
try:
import pandas as pd
import pygris
except ImportError as exc:
raise ImportError(
"Mapping blocks needs pygris. Install: pip install 'closecity[tiger]', "
"or use output='tabular' for the same rows without geometry."
) from exc
pairs = sorted({(g[:2], g[2:5]) for g in geoids if g})
frames = [pygris.blocks(state = s, county = c, year = 2020, cache = True)
for s, c in pairs]
gdf = pd.concat(frames, ignore_index = True)
if geoid_col not in gdf.columns and "GEOID20" in gdf.columns:
gdf = gdf.rename(columns = {"GEOID20": geoid_col})
return gdf
def _blocks_gdf(rows, block_geometry, geoid_col, crs, fetch, gpd):
import pandas as pd
df = pd.DataFrame(list(rows))
if block_geometry is None:
if fetch:
block_geometry = _fetch_blocks([r.get("geoid") for r in rows], geoid_col)
else:
raise ValueError(
"Block replies carry only GEOIDs. Pass block_geometry=<GeoDataFrame> "
"(joined on `geoid_col`, default 'GEOID20'), or fetch=True to pull TIGER "
"blocks with pygris."
)
geo = block_geometry.rename(columns = {geoid_col: "geoid"})
# TIGER blocks arrive in NAD83 (EPSG:4269); reproject so they match the POI and
# isochrone geometry (EPSG:4326) and can be combined without a CRS mismatch.
if crs is not None and geo.crs is not None and str(geo.crs) != str(crs):
geo = geo.to_crs(crs)
merged = df.merge(geo[["geoid", "geometry"]], on = "geoid", how = "left")
out = gpd.GeoDataFrame(merged, geometry = "geometry", crs = crs)
# Drop rows whose GEOID had no TIGER match (e.g. water blocks); they have no
# geometry and would break plotting and spatial joins.
return out[out.geometry.notna()].reset_index(drop = True)
[docs]
def to_geopandas(
source: Any,
*,
block_geometry: Any = None,
geoid_col: str = "GEOID20",
crs: Any = "EPSG:4326",
fetch: bool = False,
):
"""Convert a Close API reply into a :class:`geopandas.GeoDataFrame`.
``source`` is a :class:`~closecity.Reply`, a :class:`~closecity.Paginator`
(all pages are collected), or a raw response body. The geometry kind is
detected from the payload; see the module docstring. ``block_geometry`` /
``geoid_col`` / ``fetch`` only apply to block replies. Metering and envelope
metadata land on ``.attrs``.
"""
from . import tabular
gpd, Point, shape = _require_geopandas()
data, meta = tabular._collect(source)
if tabular._is_isochrone(data):
gdf = _isochrone_gdf(data, gpd, shape, crs)
tabular._stamp_attrs(gdf, data, meta)
return gdf
rows, envelope = tabular._rows_and_envelope(data)
first = rows[0] if rows else None
if first is not None and "lat" in first and "lon" in first:
gdf = _points_gdf(rows, gpd, Point, crs)
elif first is not None and "geoid" in first:
gdf = _blocks_gdf(rows, block_geometry, geoid_col, crs, fetch, gpd)
elif isinstance(data, dict) and data.get("lat") is not None \
and data.get("lon") is not None:
# A single POI detail body: the whole body is one point.
gdf = _points_gdf([data], gpd, Point, crs)
elif isinstance(data, dict) and isinstance(data.get("block"), dict) \
and data["block"].get("geoid"):
# A summary body whose origin block carries the geometry.
gdf = _blocks_gdf([data["block"]], block_geometry, geoid_col, crs, fetch, gpd)
else:
raise ValueError(
"This reply has no lat/lon, isochrone features, or block GEOIDs to build "
"geometry from."
)
tabular._stamp_attrs(gdf, envelope, meta)
return gdf