Source code for modelly.space.geo.space

"""A vector geospatial space: agents as points, polygons as regions, one CRS.

``GeoSpace`` is a ``ContinuousSpace`` whose coordinates are those of a projected
coordinate reference system (metres for UTM), so the continuous space's KD-tree
queries (``get_agents_in_radius``, ``neighbors_within``, ``get_k_nearest_agents``)
measure real distances. Agent positions stay one numpy array; regions are the rows
of a GeoDataFrame, indexed by an STRtree for point-in-polygon and distance lookups.

The CRS is set once, at the space. Inputs that carry another CRS (regions, points,
geometries) are reprojected into it; a geographic CRS (latitude and longitude) is
replaced by the UTM zone that covers the regions, since degrees are not distances.
"""

from __future__ import annotations

from collections.abc import Sequence
from pathlib import Path
from random import Random
from typing import TYPE_CHECKING, Any

import geopandas as gpd
import numpy as np
import pandas as pd
import pyproj
import shapely

from modelly.space.continuous.space import ContinuousSpace
from modelly.space.geo import export
from modelly.space.geo.regions import NO_REGION, RegionQueriesMixin

if TYPE_CHECKING:
    from modelly.core.agent import Agent

# Candidate points drawn per accepted point and the cap on rejection rounds in random_points.
_OVERSAMPLE = 4
_MAX_ROUNDS = 1000


[docs] class GeoSpace(RegionQueriesMixin, ContinuousSpace): """Agents as points and polygons as regions, in one projected CRS. Attributes: crs: the space's ``pyproj.CRS``, or ``None`` for plain planar coordinates. regions: a GeoDataFrame of the region polygons in ``crs``; its index labels the regions. """ def __init__( self, regions: gpd.GeoDataFrame | None = None, crs: Any = None, random: Random | None = None, *, bounds: Sequence[float] | None = None, n_agents: int = 100, ) -> None: """Create a geospatial space. Args: regions: polygons that make up the study area, in any CRS; reprojected to ``crs``. crs: the space's CRS (anything ``pyproj.CRS`` accepts). Defaults to the CRS of ``regions``, or to its UTM zone when that CRS is geographic. random: a seeded ``random.Random``, usually ``model.random``. bounds: ``(minx, miny, maxx, maxy)`` in ``crs``; defaults to the regions' bounds. n_agents: the expected number of agents, to size the position array. """ self.crs = _resolve_crs(regions, crs) self.regions = _prepare_regions(regions, self.crs) if bounds is None: if self.regions.empty: raise ValueError("GeoSpace needs regions or explicit bounds.") bounds = self.regions.total_bounds minx, miny, maxx, maxy = (float(b) for b in bounds) super().__init__([[minx, maxx], [miny, maxy]], random=random, n_agents=n_agents) self._reset_caches() def _reset_caches(self) -> None: """Forget the spatial indices; they are rebuilt on first use.""" self._region_tree_cache = None self._contiguity_cache: dict[str, Any] = {} self._agent_regions_cache: tuple[int, np.ndarray] | None = None self._neighbors_cache = None def __getstate__(self) -> dict: """Drop the STRtree and the caches; a loaded space rebuilds them when queried.""" state = super().__getstate__() for name in ( "_region_tree_cache", "_contiguity_cache", "_agent_regions_cache", ): state.pop(name, None) return state def __setstate__(self, state: dict) -> None: """Restore the position view and start with empty spatial indices.""" super().__setstate__(state) self._reset_caches()
[docs] def project(self, geometry: Any, crs: Any = None) -> Any: """Return ``geometry`` in the space's CRS. Args: geometry: a Shapely geometry, a GeoSeries or a GeoDataFrame. crs: the CRS ``geometry`` is in; GeoPandas objects default to their own CRS. ``None`` for a Shapely geometry means it is already in the space's CRS. """ if isinstance(geometry, gpd.GeoSeries | gpd.GeoDataFrame): if geometry.crs is None or self.crs is None: return geometry if crs is None else geometry.set_crs(crs) return geometry.to_crs(self.crs) if crs is None or self.crs is None: return geometry transformer = pyproj.Transformer.from_crs(crs, self.crs, always_xy=True) return shapely.transform( geometry, lambda xy: np.column_stack(transformer.transform(*xy.T)) )
[docs] def project_points(self, xy: np.ndarray, crs: Any = None) -> np.ndarray: """Return an ``(n, 2)`` array of coordinates in ``crs`` reprojected to the space's CRS.""" xy = np.atleast_2d(np.asarray(xy, dtype=float)) if crs is None or self.crs is None: return xy transformer = pyproj.Transformer.from_crs(crs, self.crs, always_xy=True) return np.column_stack(transformer.transform(xy[:, 0], xy[:, 1]))
def _as_xy(self, point: Any, crs: Any = None) -> np.ndarray: """A point (Shapely ``Point`` or ``(x, y)``) as a length-2 array in the space's CRS.""" if isinstance(point, shapely.Point): point = (point.x, point.y) return self.project_points(point, crs)[0]
[docs] def move_to(self, agent: Agent, point: Any, crs: Any = None) -> None: """Move ``agent`` to ``point`` (a Shapely ``Point`` or ``(x, y)``, in ``crs``).""" agent.position = self._as_xy(point, crs)
[docs] def region_of(self, point: Any, crs: Any = None) -> Any: """Return the label of the region containing ``point``, or ``None`` outside all regions.""" row = int(self.regions_of(self._as_xy(point, crs)[np.newaxis, :])[0]) return None if row == NO_REGION else self.regions.index[row]
[docs] def regions_of(self, xy: np.ndarray) -> np.ndarray: """Region row (position in ``regions``) of every point in ``xy``; ``-1`` outside. A point on a shared border belongs to the region listed first. """ xy = np.atleast_2d(np.asarray(xy, dtype=float)) out = np.full(xy.shape[0], len(self.regions), dtype=np.intp) if xy.shape[0] and len(self.regions): points, rows = self._region_tree.query( shapely.points(xy), predicate="intersects" ) np.minimum.at(out, points, rows) out[out == len(self.regions)] = NO_REGION return out
[docs] def agent_regions(self) -> np.ndarray: """Region row of every agent, in ``active_agents`` order; cached until an agent moves.""" positions = self.agent_positions key = hash((positions.shape, positions.tobytes())) cached = self._agent_regions_cache if cached is None or cached[0] != key: cached = (key, self.regions_of(positions)) self._agent_regions_cache = cached return cached[1]
[docs] def agents_in_region(self, label: Any) -> list[Agent]: """The agents whose position lies in the region labelled ``label``.""" row = self.regions.index.get_loc(label) hits = np.flatnonzero(self.agent_regions() == row) return [self.active_agents[i] for i in hits]
[docs] def region_counts(self) -> pd.Series: """Number of agents in every region, indexed by region label.""" rows = self.agent_regions() counts = np.bincount(rows[rows != NO_REGION], minlength=len(self.regions)) return pd.Series(counts, index=self.regions.index, name="n_agents")
[docs] def agents_within( self, target: Any, distance: float | None = None, crs: Any = None ) -> list[Agent]: """The agents inside ``target``, or within ``distance`` of it. Args: target: a Shapely geometry or an ``(x, y)`` point. distance: in the space's units; ``None`` means inside (or on) ``target`` itself. crs: the CRS of ``target``, if not the space's. """ if not isinstance(target, shapely.Geometry): target = shapely.Point(self._as_xy(target, crs)) else: target = self.project(target, crs) x, y = self.agent_positions[:, 0], self.agent_positions[:, 1] if distance is None: mask = shapely.intersects_xy(target, x, y) else: mask = shapely.dwithin(target, shapely.points(x, y), distance) return [self.active_agents[i] for i in np.flatnonzero(mask)]
[docs] def random_points(self, label: Any, n: int, rng: np.random.Generator) -> np.ndarray: """``n`` points drawn uniformly inside region ``label``, as an ``(n, 2)`` array. Args: label: the region label. n: the number of points. rng: a numpy generator, usually ``model.rng``. """ polygon = self.regions.geometry.loc[label] minx, miny, maxx, maxy = polygon.bounds accepted: list[np.ndarray] = [] found = 0 for _ in range(_MAX_ROUNDS): if found >= n: break size = max(n - found, 1) * _OVERSAMPLE xy = rng.uniform((minx, miny), (maxx, maxy), size=(size, 2)) inside = xy[shapely.contains_xy(polygon, xy[:, 0], xy[:, 1])] accepted.append(inside) found += inside.shape[0] if found < n: raise ValueError(f"Could not place {n} points inside region {label!r}.") return np.concatenate(accepted)[:n]
[docs] def to_geodataframe( self, what: str = "agents", attributes: Sequence[str] = () ) -> gpd.GeoDataFrame: """The agents (as points, with ``attributes``) or the regions (with agent counts).""" if what == "agents": return export.agents_frame(self, attributes) if what == "regions": return export.regions_frame(self) raise ValueError(f"what must be 'agents' or 'regions', not {what!r}.")
[docs] def to_file( self, path: str | Path, what: str = "agents", attributes: Sequence[str] = () ) -> Path: """Write ``to_geodataframe(what, attributes)`` as GeoParquet or GeoJSON, by suffix. ``.parquet`` and ``.geoparquet`` write GeoParquet (needs ``pyarrow``); ``.geojson`` and ``.json`` write GeoJSON in WGS 84, as the GeoJSON standard asks. """ return export.write_frame(self.to_geodataframe(what, attributes), Path(path))
def _resolve_crs(regions: gpd.GeoDataFrame | None, crs: Any) -> pyproj.CRS | None: """The space's CRS: the one asked for, else the regions', projected if geographic.""" if crs is not None: return pyproj.CRS.from_user_input(crs) if regions is None or regions.crs is None: return None if regions.crs.is_geographic: return regions.estimate_utm_crs() return regions.crs def _prepare_regions( regions: gpd.GeoDataFrame | None, crs: pyproj.CRS | None ) -> gpd.GeoDataFrame: """The regions reprojected to ``crs``; an empty frame when there are none.""" if regions is None: return gpd.GeoDataFrame(geometry=gpd.GeoSeries([], crs=crs), crs=crs) if regions.crs is None or crs is None: return regions.set_crs(crs, allow_override=True) if crs else regions.copy() return regions.to_crs(crs)