Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

facility link snapping #276

Merged
merged 7 commits into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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 .cruft.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"template": "https://github.com/arup-group/cookiecutter-pypackage.git",
"commit": "fdc37ca81ad3f267199b04856722ea68648b9109",
"commit": "b7724521f898ab28f541d492aff8e2be2ed76a01",
"checkout": null,
"context": {
"cookiecutter": {
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

### Added
* Facility link snapping (#276).

### Changed

Expand Down
4 changes: 2 additions & 2 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
cruft >= 2, < 3
jupyter < 2
mike >= 2, < 3
mkdocs < 2
mkdocs < 1.6
mkdocs-material >= 9.4, < 10
mkdocs-click < 0.7
mkdocs-jupyter < 0.25
mkdocs-jupyter < 0.24.7
mkdocstrings-python < 2
nbmake >= 1.5.1, < 2
pre-commit < 4
Expand Down
27 changes: 27 additions & 0 deletions src/pam/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from pam import read, write
from pam.operations.combine import pop_combine
from pam.operations.cropping import simplify_population
from pam.operations.snap import run_facility_link_snapping
from pam.report.benchmarks import benchmarks as bms
from pam.report.stringify import stringify_plans
from pam.report.summary import pretty_print_summary, print_summary
Expand Down Expand Up @@ -667,3 +668,29 @@ def plan_filter(plan):

logger.info("Population wipe complete")
logger.info(f"Output saved at {path_population_output}")


@cli.command()
@click.argument("path_population_in", type=click.Path(exists=True))
@click.argument("path_population_out", type=click.Path(exists=False, writable=True))
@click.argument("path_network_geometry", type=click.Path(exists=True))
@click.option(
"--link_id_field",
"-f",
type=str,
default="id",
help="The link ID field to use in the network shapefile. Defaults to 'id'.",
)
def snap_facilities(
path_population_in: str,
path_population_out: str,
path_network_geometry: int,
link_id_field: Optional[str],
):
"""Snap facilities to a network geometry."""
run_facility_link_snapping(
path_population_in=path_population_in,
path_population_out=path_population_out,
path_network_geometry=path_network_geometry,
link_id_field=link_id_field,
)
69 changes: 69 additions & 0 deletions src/pam/operations/snap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
""" Methods for snapping elements to the network or facilities. """

from pathlib import Path

import geopandas as gp
import numpy as np

from pam.core import Population
from pam.read import read_matsim
from pam.write import write_matsim
from scipy.spatial import cKDTree


def snap_facilities_to_network(
population: Population, network: gp.GeoDataFrame, link_id_field: str = "id"
) -> None:
"""Snaps activity facilities to a network geometry (in-place).

Args:
population (Population): A PAM population.
network (gp.GeoDataFrame): A network geometry shapefile.
link_id_field (str, optional): The link ID field to use in the network shapefile. Defaults to "id".
"""
if network.geometry.geom_type[0] == 'Point':
coordinates = np.array(list(zip(network.geometry.x, network.geometry.y)))
else:
coordinates = np.array(list(zip(network.geometry.centroid.x, network.geometry.centroid.y)))

tree = cKDTree(coordinates)
link_ids = network[link_id_field].values

activity_points = []
activities_info = []
for _, _, person in population.people():
for act in person.activities:
point = act.location.loc
if not hasattr(point, 'x') or not hasattr(point, 'y'):
point = point.centroid
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this needed? Loc should always be a point with x and y coordinates (or None).
See here: https://github.com/arup-group/pam/blob/3f817a662317d44fca2126295f07d89317db707c/src/pam/location.py#L7-L18.
If that is not the case in the input data, then the inputs should be corrected rather than fixing them silently here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed this.

activity_points.append((point.x, point.y))
activities_info.append(act)

activity_points = np.array(activity_points)
distances, indices = tree.query(activity_points)

for act, index in zip(activities_info, indices):
act.location.link = link_ids[index]


def run_facility_link_snapping(
path_population_in: str,
path_population_out: str,
path_network_geometry: str,
link_id_field: str = "id",
) -> None:
"""Reads a population, snaps activity facilities to a network geometry, and saves the results.

Args:
path_population_in (str): Path to a PAM population.
path_population_out (str): The path to save the output population.
path_network_geometry (str): Path to the network geometry file.
link_id_field (str, optional): The link ID field to use in the network shapefile. Defaults to "id".
"""
population = read_matsim(path_population_in)
if ".parquet" in Path(path_network_geometry).suffixes:
network = gp.read_parquet(path_network_geometry)
else:
network = gp.read_file(path_network_geometry)
snap_facilities_to_network(population=population, network=network, link_id_field=link_id_field)
write_matsim(population=population, plans_path=path_population_out)
41 changes: 41 additions & 0 deletions tests/test_29_snap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import os

import geopandas as gp
import pytest
from pam.operations.snap import run_facility_link_snapping, snap_facilities_to_network
from pam.read import read_matsim


def test_add_snapping_adds_link_attribute(population_heh):
network = gp.read_file(pytest.test_data_dir / "test_link_geometry.geojson")
for _, _, person in population_heh.people():
for act in person.activities:
assert act.location.link is None

snap_facilities_to_network(population=population_heh, network=network)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is a def missing here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it just calls the functionsnap_facilities_to_network here.

for _, _, person in population_heh.people():
for act in person.activities:
assert act.location.link is not None
Theodore-Chatziioannou marked this conversation as resolved.
Show resolved Hide resolved

# check that the link is indeed the nearest one
link_distance = (
network.set_index("id")
.loc[act.location.link, "geometry"]
.distance(act.location.loc)
)
min_distance = network.distance(act.location.loc).min()
assert link_distance == min_distance


def test_links_resnapped(tmpdir):
path_out = os.path.join(tmpdir, "pop_snapped.xml")
run_facility_link_snapping(
path_population_in=pytest.test_data_dir / "1.plans.xml",
path_population_out=path_out,
path_network_geometry=pytest.test_data_dir / "test_link_geometry.geojson",
)
assert os.path.exists(path_out)
pop_snapped = read_matsim(path_out)
for _, _, person in pop_snapped.people():
for act in person.activities:
assert "link-" in act.location.link
8 changes: 8 additions & 0 deletions tests/test_data/test_link_geometry.geojson
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"type": "FeatureCollection",
"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:EPSG::2157" } },
"features": [
{ "type": "Feature", "properties": { "id": "link-1" }, "geometry": { "type": "LineString", "coordinates": [ [ 10000.0, 5000.0 ], [ 10000.0, 10000.0 ] ] } },
{ "type": "Feature", "properties": { "id": "link-2" }, "geometry": { "type": "LineString", "coordinates": [ [ 10000.0, 10000.0 ], [ 5000.0, 5000.0 ] ] } }
]
}
Loading