Skip to content

Commit c8bff76

Browse files
Merge pull request #276 from arup-group/snap-facilities
facility link snapping
2 parents 3f817a6 + fda1ccc commit c8bff76

File tree

5 files changed

+136
-0
lines changed

5 files changed

+136
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424
### Fixed
2525

2626
### Added
27+
* Facility link snapping (#276).
2728

2829
### Changed
2930

src/pam/cli.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pam import read, write
1010
from pam.operations.combine import pop_combine
1111
from pam.operations.cropping import simplify_population
12+
from pam.operations.snap import run_facility_link_snapping
1213
from pam.report.benchmarks import benchmarks as bms
1314
from pam.report.stringify import stringify_plans
1415
from pam.report.summary import pretty_print_summary, print_summary
@@ -667,3 +668,29 @@ def plan_filter(plan):
667668

668669
logger.info("Population wipe complete")
669670
logger.info(f"Output saved at {path_population_output}")
671+
672+
673+
@cli.command()
674+
@click.argument("path_population_in", type=click.Path(exists=True))
675+
@click.argument("path_population_out", type=click.Path(exists=False, writable=True))
676+
@click.argument("path_network_geometry", type=click.Path(exists=True))
677+
@click.option(
678+
"--link_id_field",
679+
"-f",
680+
type=str,
681+
default="id",
682+
help="The link ID field to use in the network shapefile. Defaults to 'id'.",
683+
)
684+
def snap_facilities(
685+
path_population_in: str,
686+
path_population_out: str,
687+
path_network_geometry: int,
688+
link_id_field: Optional[str],
689+
):
690+
"""Snap facilities to a network geometry."""
691+
run_facility_link_snapping(
692+
path_population_in=path_population_in,
693+
path_population_out=path_population_out,
694+
path_network_geometry=path_network_geometry,
695+
link_id_field=link_id_field,
696+
)

src/pam/operations/snap.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
""" Methods for snapping elements to the network or facilities. """
2+
3+
from pathlib import Path
4+
5+
import geopandas as gp
6+
import numpy as np
7+
from scipy.spatial import cKDTree
8+
9+
from pam.core import Population
10+
from pam.read import read_matsim
11+
from pam.write import write_matsim
12+
13+
14+
def snap_facilities_to_network(
15+
population: Population, network: gp.GeoDataFrame, link_id_field: str = "id"
16+
) -> None:
17+
"""Snaps activity facilities to a network geometry (in-place).
18+
19+
Args:
20+
population (Population): A PAM population.
21+
network (gp.GeoDataFrame): A network geometry shapefile.
22+
link_id_field (str, optional): The link ID field to use in the network shapefile. Defaults to "id".
23+
"""
24+
if network.geometry.geom_type[0] == "Point":
25+
coordinates = np.array(list(zip(network.geometry.x, network.geometry.y)))
26+
else:
27+
coordinates = np.array(list(zip(network.geometry.centroid.x, network.geometry.centroid.y)))
28+
29+
tree = cKDTree(coordinates)
30+
link_ids = network[link_id_field].values
31+
32+
for _, _, person in population.people():
33+
for act in person.activities:
34+
point = act.location.loc
35+
distance, index = tree.query([(point.x, point.y)])
36+
act.location.link = link_ids[index[0]]
37+
38+
39+
def run_facility_link_snapping(
40+
path_population_in: str,
41+
path_population_out: str,
42+
path_network_geometry: str,
43+
link_id_field: str = "id",
44+
) -> None:
45+
"""Reads a population, snaps activity facilities to a network geometry, and saves the results.
46+
47+
Args:
48+
path_population_in (str): Path to a PAM population.
49+
path_population_out (str): The path to save the output population.
50+
path_network_geometry (str): Path to the network geometry file.
51+
link_id_field (str, optional): The link ID field to use in the network shapefile. Defaults to "id".
52+
"""
53+
population = read_matsim(path_population_in)
54+
if ".parquet" in Path(path_network_geometry).suffixes:
55+
network = gp.read_parquet(path_network_geometry)
56+
else:
57+
network = gp.read_file(path_network_geometry)
58+
snap_facilities_to_network(population=population, network=network, link_id_field=link_id_field)
59+
write_matsim(population=population, plans_path=path_population_out)

tests/test_29_snap.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import os
2+
3+
import geopandas as gp
4+
import pytest
5+
from pam.operations.snap import run_facility_link_snapping, snap_facilities_to_network
6+
from pam.read import read_matsim
7+
8+
9+
def test_add_snapping_adds_link_attribute(population_heh):
10+
network = gp.read_file(pytest.test_data_dir / "test_link_geometry.geojson")
11+
for _, _, person in population_heh.people():
12+
for act in person.activities:
13+
assert act.location.link is None
14+
15+
snap_facilities_to_network(population=population_heh, network=network)
16+
for _, _, person in population_heh.people():
17+
for act in person.activities:
18+
assert act.location.link is not None
19+
20+
# check that the link is indeed the nearest one
21+
link_distance = (
22+
network.set_index("id")
23+
.loc[act.location.link, "geometry"]
24+
.distance(act.location.loc)
25+
)
26+
min_distance = network.distance(act.location.loc).min()
27+
assert link_distance == min_distance
28+
29+
30+
def test_links_resnapped(tmpdir):
31+
path_out = os.path.join(tmpdir, "pop_snapped.xml")
32+
run_facility_link_snapping(
33+
path_population_in=pytest.test_data_dir / "1.plans.xml",
34+
path_population_out=path_out,
35+
path_network_geometry=pytest.test_data_dir / "test_link_geometry.geojson",
36+
)
37+
assert os.path.exists(path_out)
38+
pop_snapped = read_matsim(path_out)
39+
for _, _, person in pop_snapped.people():
40+
for act in person.activities:
41+
assert "link-" in act.location.link
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"type": "FeatureCollection",
3+
"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:EPSG::2157" } },
4+
"features": [
5+
{ "type": "Feature", "properties": { "id": "link-1" }, "geometry": { "type": "LineString", "coordinates": [ [ 10000.0, 5000.0 ], [ 10000.0, 10000.0 ] ] } },
6+
{ "type": "Feature", "properties": { "id": "link-2" }, "geometry": { "type": "LineString", "coordinates": [ [ 10000.0, 10000.0 ], [ 5000.0, 5000.0 ] ] } }
7+
]
8+
}

0 commit comments

Comments
 (0)