Geospatial models#
modelly.space.geo starts a model from real maps: census zones as polygons, land cover as a
raster, streets as a routable network, and a synthetic population drawn from census tables.
pip install "modelly-abm[geo]" # GeoPandas, Shapely, pyproj, rasterio
pip install "modelly-abm[geo,osm]" # and OSMnx, to download OpenStreetMap streets
GeoPandas, Shapely, pyproj and rasterio ship wheels for Windows, macOS and Linux that bundle
GDAL and PROJ, so nothing has to be installed on the system. Importing modelly.space.geo
without them raises an ImportError that names the extra. Writing GeoParquet also needs
pyarrow.
One coordinate reference system, set at the space#
A GeoSpace works in one projected CRS, so that distances are in metres (or feet), not
degrees. The CRS is chosen once, when the space is created:
GeoSpace(regions, crs="EPSG:2154")uses the CRS you name and reprojectsregions;GeoSpace(regions)uses the CRS ofregions, or, when that is geographic (latitude and longitude, such as EPSG:4326), the UTM zone that covers them.
Everything that comes in later can carry its own CRS and is reprojected: GeoAgent(space, model, point, crs="EPSG:4326"), space.move_to(agent, point, crs=...),
space.project(geometry, crs=...) and space.project_points(xy, crs=...).
import geopandas as gpd
from modelly import Model
from modelly.space.geo import GeoAgent, GeoSpace
zones = gpd.read_file("zones.gpkg").set_index("zone_id") # any CRS
class Resident(GeoAgent):
pass
model = Model(rng=42)
space = GeoSpace(zones, random=model.random)
home = space.random_points("Z042", 1, model.rng)[0]
resident = Resident(space, model, home)
resident.region # "Z042"
resident.move_to((2.35, 48.85), crs="EPSG:4326") # a longitude and latitude
Agents as points, polygons as regions#
GeoSpace is a ContinuousSpace: every agent’s position is a row of one numpy array
(space.agent_positions), and the continuous space’s KD-tree queries work in metres:
get_agents_in_radius, neighbors_within(radius) (every agent’s neighbours in one call) and
get_k_nearest_agents. The regions are the rows of space.regions, a GeoDataFrame indexed
by region label, with one STRtree over their polygons.
Question |
Call |
|---|---|
Which region is this point in? |
|
Which agents are in region |
|
Which agents are inside this polygon, or within 500 m of this line? |
|
Which regions border |
|
Which regions are within 2 km of |
|
Region adjacency as a matrix |
|
Region membership of the agents is computed for the whole population at once and cached until an agent moves.
Export#
space.to_geodataframe() gives one point per agent with its unique_id, its region and any
attributes you name; space.to_geodataframe("regions") gives the regions with an n_agents
column. space.to_file(path) writes either one, chosen by suffix: .parquet for GeoParquet,
.geojson for GeoJSON (in WGS 84, as the format requires). Both open in QGIS and in GeoPandas.
space.to_file("residents.parquet", attributes=("age", "income"))
space.to_file("zones.geojson", what="regions")
Rasters as property layers#
A RasterLayer holds one band with its affine transform, CRS and nodata value. Its values
are shaped like an orthogonal grid, (width, height), with y increasing northward, so the
band can be a grid’s property layer: the grid and the raster then share one array.
from modelly.space.geo import RasterLayer
land = RasterLayer.from_file("landcover.tif")
grid = land.to_grid("cover", random=model.random) # an OrthogonalMooreGrid
grid[(10, 4)].cover # the pixel under cell (10, 4)
land.sample_agents(space) # the value under every GeoAgent, nan outside or on nodata
land.to_file("landcover-after.tif") # writes the band back as a GeoTIFF
land.sample(xy, crs=...) samples at any points, reprojecting them if needed;
land.reproject("EPSG:3035") warps the band into another CRS.
Road networks and routing#
A RoadNetwork wraps any NetworkX graph whose nodes have x and y coordinates, the OSMnx
convention, and gives every node a cell of a Network space (roads.space), so agents can
also stand on intersections as CellAgents. Edges without a length get the straight-line
length.
from modelly.space.geo import RoadNetwork, download_osm
roads = RoadNetwork(graph, random=model.random) # graph.graph["crs"] is honoured
roads.add_travel_times(speed=13.9) # length / speed, unless an edge has its own speed
path = roads.shortest_path(home, work, weight="travel_time") # nodes or points
minutes = roads.travel_time(home, work) / 60
# scipy, many sources at once
matrix = roads.distance_matrix(origins, weight="travel_time")
near = roads.agents_within(space, origin, 1000) # agents within 1 km along the streets
# needs the osm extra and a connection
lyon = download_osm("Lyon, France", random=model.random)
download_osm downloads; cache the graph (osmnx.save_graphml) instead of calling it on
every run. After changing edge attributes yourself, call roads.invalidate() so the next
distance_matrix is rebuilt.
Synthetic populations#
Census tables give, per region, the number of people in each level of each attribute, not
the joint table. ipf(seed, marginals) fits a joint table to the marginals by iterative
proportional fitting, keeping the seed’s zeros; synthesize_population does it per region
and returns one row per person:
from modelly.space.geo import synthesize_population
people = synthesize_population(
{"age": ["0-17", "18-64", "65+"], "car": [True, False]},
{"Z042": {"age": [210, 600, 190], "car": [700, 300]}, ...},
model.rng,
)
for zone, group in people.groupby("region"):
for point, age in zip(space.random_points(zone, len(group), model.rng), group.age):
Resident(space, model, point).age = age
Each region gets exactly its total and its first attribute’s counts; the other marginals are
met in expectation (the fitted table is rounded to whole people at random, from model.rng,
so a seed gives the same people). This is the small, census-style helper; a fuller toolkit is
planned.
Checkpoints#
GeoSpace, RasterLayer and RoadNetwork are saved by model.save and restored by
Model.load like any other part of the model (see Checkpoints). The STRtree,
the contiguity matrices, the cached region membership and the routing matrices are not saved
but rebuilt on first use. A raster attached to a grid stays shared with it after a load.
Example#
Commuting City puts it together on generated data: zones of a synthetic city, residents from a synthetic population, workplaces by a gravity rule over travel times, and congested routing on a street lattice. The API reference lists every class and function.