diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml
index 7da22cd..b811f2e 100644
--- a/.github/workflows/code-style.yml
+++ b/.github/workflows/code-style.yml
@@ -11,4 +11,4 @@ jobs:
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- - uses: pre-commit/action@v2.0.2
+ - uses: pre-commit/action@v3.0.0
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c90971e..c5ca433 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -23,8 +23,9 @@ jobs:
# run: sudo apt-get update && sudo apt-get install qt5-default qttools5-dev-tools
- name: Install qgis-plugin-ci
- run: pip3 install qgis-plugin-ci
+ run: pip3 install git+https://github.com/GispoCoding/qgis-plugin-ci.git
+ # When osgeo upload is wanted: --osgeo-username usrname --osgeo-password ${{ secrets.OSGEO_PASSWORD }}
# When Transifex is wanted: --transifex-token ${{ secrets.TRANSIFEX_TOKEN }}
- name: Deploy plugin
- run: qgis-plugin-ci release ${GITHUB_REF/refs\/tags\//} --osgeo-username ${{ secrets.OSGEO_USER }} --osgeo-password ${{ secrets.OSGEO_PASSWORD }} --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update
+ run: qgis-plugin-ci release latest --osgeo-username ${{ secrets.OSGEO_USER }} --osgeo-password ${{ secrets.OSGEO_PASSWORD }} --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update
diff --git a/.github/workflows/test-and-pre-release.yml b/.github/workflows/test-and-pre-release.yml
index 314e851..93d4550 100644
--- a/.github/workflows/test-and-pre-release.yml
+++ b/.github/workflows/test-and-pre-release.yml
@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
# Remove unsupported versions and add more versions. Use LTR version in the cov_tests job
- docker_tags: [release-3_16, latest]
+ docker_tags: [release-3_16, final-3_22_9, latest]
fail-fast: false
# Steps represent a sequence of tasks that will be executed as part of the job
@@ -31,7 +31,7 @@ jobs:
# Runs all tests
- name: Run tests
- run: docker run --rm --net=host --volume `pwd`:/app -w=/app -e QGIS_PLUGIN_IN_CI=1 qgis/qgis:${{ matrix.docker_tags }} sh -c "pip3 install -q -r requirements-test.txt pytest-cov && xvfb-run -s '+extension GLX -screen 0 1024x768x24' pytest -v --cov --cov-report=xml"
+ run: docker run --rm --net=host --volume `pwd`:/app -w=/app -e QGIS_PLUGIN_IN_CI=1 qgis/qgis:${{ matrix.docker_tags }} sh -c "pip3 install -q -r requirements-test.txt pytest-cov && xvfb-run -s '+extension GLX -screen 0 1024x768x24' pytest -v --cov --cov-report=xml Catchment/test"
# Upload coverage report. Will not work if the repo is private
- name: Upload coverage to Codecov
@@ -59,10 +59,10 @@ jobs:
- name: Run tests
shell: pwsh
run: |
- $env:PATH="C:\Program Files\QGIS 3.16\bin;$env:PATH"
+ $env:PATH="C:\Program Files\QGIS 3.22.11\bin;$env:PATH"
$env:QGIS_PLUGIN_IN_CI=1
python-qgis-ltr.bat -m pip install -q -r requirements-test.txt
- python-qgis-ltr.bat -m pytest -v
+ python-qgis-ltr.bat -m pytest -v Catchment/test
pre-release:
name: "Pre Release"
@@ -102,7 +102,7 @@ jobs:
# run: sudo apt-get update && sudo apt-get install qt5-default qttools5-dev-tools
- name: Install qgis-plugin-ci
- run: pip3 install qgis-plugin-ci
+ run: pip3 install git+https://github.com/GispoCoding/qgis-plugin-ci.git
# When Transifex is wanted: --transifex-token ${{ secrets.TRANSIFEX_TOKEN }}
- name: Deploy plugin
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index e99cab6..a4a3549 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,29 +2,30 @@
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v3.4.0
+ rev: v4.0.1
hooks:
- id: trailing-whitespace
+ exclude: Catchment/metadata.txt
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- - repo: https://github.com/PyCQA/isort
- rev: 5.8.0
- hooks:
- - id: isort
+ # - repo: https://github.com/PyCQA/isort
+ # rev: 5.10.1
+ # hooks:
+ # - id: isort
- repo: https://github.com/psf/black
- rev: 20.8b1
+ rev: 22.10.0
hooks:
- id: black
- - repo: https://github.com/pre-commit/mirrors-mypy
- rev: v0.812
- hooks:
- - id: mypy
+ # - repo: https://github.com/pre-commit/mirrors-mypy
+ # rev: v0.812
+ # hooks:
+ # - id: mypy
- repo: https://github.com/PyCQA/flake8
- rev: 3.9.1
+ rev: 4.0.1
hooks:
- id: flake8
additional_dependencies:
- flake8-bugbear~=21.4.3
- pep8-naming~=0.11.1
- - flake8-annotations~=2.6.2
+ - flake8-qgis>=0.1.3
diff --git a/.qgis-plugin-ci b/.qgis-plugin-ci
index 54cf3da..0d6c3bf 100644
--- a/.qgis-plugin-ci
+++ b/.qgis-plugin-ci
@@ -1,5 +1,5 @@
plugin_path: Catchment
github_organization_slug: GispoCoding
-project_slug: catchment-plugin
+project_slug: school-catchment-plugin
transifex_coordinator: replace-me
transifex_organization: replace-me
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e305f95..109564c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,64 @@
# CHANGELOG
+## 0.4.3 - 2021-11-01
+### Changed
+- Changed IIEP logo to correct size
-###
+## 0.4.2 - 2021-10-31
+
+### Changed
+
+- IIEP logo added
+- Test runners updated to latest versions and fixed
+
+## 0.4.1 - 2021-12-17
+
+### Changed
+
+- Fix bug that made links in About panel non-clickable
+- Decrease time and distance increments to allow calculating 100 meter and 1 minute intervals
+
+## 0.4.0 - 2021-12-08
+
+### Added
+
+- Option to subtract indoor walking distance per point before calculating point isochrone
+
+## 0.3.1 - 2021-11-30
+### Changed
+
+- Fix bug merging isochrones for layers with several attribute fields
+- Fix bug when merged isochrones are multipolygons
+
+## 0.3.0 - 2021-11-25
+
+### Added
+
+- Option to merge isochrones by field value, e.g. merging isochrones of multiple entrances of the same building
+
+### Changed
+
+- Isochrone original_fid field is now string (due to possible merging of isochrones)
+- UI checkboxes moved to separate Extra options field to indicate they are optional
+
+## 0.2.1 - 2021-11-24
+
+### Changed
+
+- Fixes to release workflow
+
+## 0.2.0 - 2021-11-24
+
+### Added
+
+- Option to limit areas to polygon boundaries, i.e. limiting isochrones to given polygons around each point
+
+### Changed
+
+- Updated test and development dependencies
+- Isochrones are now multipolygons (due to boundary intersections) instead of simple polygons
+
+## 0.1.0 - 2021-06-11
+
+- First official version published
diff --git a/Catchment/core/isochrone_creator.py b/Catchment/core/isochrone_creator.py
index 75472c0..43367ed 100644
--- a/Catchment/core/isochrone_creator.py
+++ b/Catchment/core/isochrone_creator.py
@@ -2,11 +2,11 @@
import logging
import os
from dataclasses import dataclass
+from itertools import groupby
+from operator import itemgetter
from typing import Dict, List, Optional
import qgis.processing
-from PyQt5.QtCore import QVariant
-from PyQt5.QtNetwork import QNetworkReply
from qgis.core import (
QgsCoordinateReferenceSystem,
QgsCoordinateTransformContext,
@@ -21,7 +21,10 @@
QgsTask,
QgsVectorFileWriter,
QgsVectorLayer,
+ QgsWkbTypes,
)
+from qgis.PyQt.QtCore import QVariant
+from qgis.PyQt.QtNetwork import QNetworkReply
from ..definitions.constants import Profile, Unit
from ..qgis_plugin_tools.tools.exceptions import QgsPluginNetworkException
@@ -39,7 +42,10 @@ class IsochroneOpts:
url: str = ""
api_key: str = ""
layer: Optional[QgsVectorLayer] = None
+ polygon_layer: Optional[QgsVectorLayer] = None
selected_only: bool = False
+ merge_by_field: Optional[QgsField] = None
+ add_walking_field: Optional[QgsField] = None
distance: Optional[int] = None
unit: Optional[Unit] = None
buckets: int = 1
@@ -58,8 +64,10 @@ def check_if_opts_set(self) -> bool:
class IsochroneCreator(QgsTask):
def __init__(self, opts: IsochroneOpts) -> None:
self.opts = opts
+ self.error: Optional[Exception] = None
self.result_layer: Optional[QgsVectorLayer] = None
- self.points = []
+ self.points: list[QgsFeature] = []
+ self.limiting_polygons: list[QgsFeature] = []
# no type checking needed, since we check if options are set
if self.opts.check_if_opts_set():
self.base_url = self.opts.url
@@ -84,8 +92,9 @@ def __init__(self, opts: IsochroneOpts) -> None:
else:
self.params["time_limit"] = 60 * self.opts.distance # type: ignore
- # reproject layer if needed
+ # reproject layers if needed
layer: QgsVectorLayer = self.opts.layer
+ polygon_layer: Optional[QgsVectorLayer] = self.opts.polygon_layer
wgs84 = QgsCoordinateReferenceSystem("EPSG:4326")
if layer.crs() != wgs84:
selected_ids = [
@@ -103,6 +112,22 @@ def __init__(self, opts: IsochroneOpts) -> None:
"OUTPUT"
]
layer.select(selected_ids)
+ if polygon_layer and polygon_layer.crs() != wgs84:
+ MAIN_LOGGER.info(
+ (
+ f"Limit polygon layer in {polygon_layer.crs().authid()},"
+ f" reprojecting to WGS 84 first."
+ )
+ )
+ alg_params = {
+ "INPUT": polygon_layer,
+ "OUTPUT": QgsProcessing.TEMPORARY_OUTPUT,
+ "TARGET_CRS": wgs84,
+ }
+ polygon_layer = qgis.processing.run(
+ "native:reprojectlayer", alg_params
+ )["OUTPUT"]
+
# QgsVectorLayer from main thread may not be used in other threads?
# How about the QgsFeatures we list here, seems to work fine?
self.points = (
@@ -110,12 +135,69 @@ def __init__(self, opts: IsochroneOpts) -> None:
if self.opts.selected_only
else list(layer.getFeatures())
)
+ if polygon_layer:
+ # Store one boundary polygon per point. Store all original polygon ids.
+ fields = QgsFields(polygon_layer.fields())
+ boundary_fid_field = QgsField(
+ name="fids", type=QVariant.String, typeName="varchar"
+ )
+ fields.append(boundary_fid_field)
+ for point in self.points:
+ # - In case a point is located inside multiple polygons, consider
+ # all of them, i.e. their intersection.
+ # - In case a point has no boundary polygon, do not limit it.
+ boundary_polygon = None
+ for polygon in polygon_layer.getFeatures():
+ if point.geometry().intersects(polygon.geometry()):
+ if not boundary_polygon:
+ # Here the boundary polygon will get all other fields
+ # from the *first* polygon. Doesn't matter as long as
+ # we only save the ids in the end
+ boundary_polygon = QgsFeature(polygon)
+ boundary_polygon.setFields(fields)
+ boundary_polygon["fids"] = str(polygon["fid"])
+ else:
+ intersection_parts = (
+ boundary_polygon.geometry()
+ .intersection(polygon.geometry())
+ .asGeometryCollection()
+ )
+ intersection_geometry = QgsGeometry.fromMultiPolygonXY(
+ [
+ geometry.asPolygon()
+ for geometry in intersection_parts
+ if geometry.wkbType() == QgsWkbTypes.Polygon
+ ]
+ )
+ boundary_polygon.setGeometry(intersection_geometry)
+ boundary_polygon["fids"] += f",{polygon['fid']}"
+ self.limiting_polygons.append(boundary_polygon)
+ else:
+ # no limiting polygons for any of the points
+ self.limiting_polygons = len(self.points) * [None]
+
profile_string = (
f" by {self.opts.profile.value}" if self.opts.unit == Unit.MINUTES else "" # type: ignore # noqa
)
direction_string = "to" if self.params["reverse_flow"] else "from"
selected_string = "selected " if self.opts.selected_only else ""
- self.name = f"{self.opts.distance} {self.opts.unit.value} {direction_string} {selected_string}{self.opts.layer.name()}{profile_string}" # type: ignore # noqa
+ limited_string = (
+ f" limited by {self.opts.polygon_layer.name()}"
+ if self.opts.polygon_layer
+ else ""
+ )
+ merged_string = (
+ f" combined by {self.opts.merge_by_field.name()}"
+ if self.opts.merge_by_field
+ else ""
+ )
+ walking_string = (
+ "with added walking distance " if self.opts.add_walking_field else ""
+ )
+ self.name = (
+ f"{self.opts.distance} {self.opts.unit.value} {walking_string}{direction_string}" # type: ignore # noqa
+ f" {selected_string}{self.opts.layer.name()}{profile_string}{limited_string}{merged_string}" # type: ignore # noqa
+ )
super().__init__(description=f"Fetching GraphHopper isochrones: {self.name}")
self.setProgress(0.0)
@@ -132,11 +214,11 @@ def run(self) -> bool:
"""
try:
self.result_layer = self.create_isochrone_layer()
- except QgsPluginNetworkException as e:
- TASK_LOGGER.error(
- f"Network request failed, aborting run. Error: {e.message}" # noqa
- )
+ except Exception as e:
+ TASK_LOGGER.error(f"Isochrone task failed, aborting run: {repr(e)}") # noqa
+ self.error = e
return False
+
count = self.result_layer.featureCount()
TASK_LOGGER.info(f"Total of {count} isochrones generated.")
TASK_LOGGER.info(
@@ -157,13 +239,19 @@ def finished(self, result: bool) -> None:
result is the return value from self.run.
"""
if not result:
- if not self.result_layer:
- MAIN_LOGGER.error(
- f"Graphhopper request to {self.base_url} failed",
- extra={
- "details": "Please check your Graphhopper url and your Internet connection." # noqa
- },
- )
+ if self.error:
+ if isinstance(self.error, QgsPluginNetworkException):
+ MAIN_LOGGER.error(
+ f"Graphhopper request to {self.base_url} failed",
+ extra={
+ "details": "Please check your Graphhopper url and your Internet connection." # noqa
+ },
+ )
+ else:
+ MAIN_LOGGER.error(
+ "Isochrone task failed and returned exception",
+ extra={"details": repr(self.error)},
+ )
elif len(self.points):
MAIN_LOGGER.error(
"No results, no roads found close to any of the points",
@@ -183,14 +271,50 @@ def finished(self, result: bool) -> None:
root = QgsProject.instance().layerTreeRoot()
root.insertChildNode(1, QgsLayerTreeLayer(self.result_layer))
- def __fetch_bucketed_isochrones(self, point: QgsFeature) -> List[Dict]:
+ def __add_walking_distance(
+ self, isochrone_params: Dict, walking_distance: int
+ ) -> Dict:
+ # Each point may have fixed internal walking distance in meters that has to
+ # be traversed before reaching the entrance, i.e. the Graphhopper network.
+ # This is taken into account to determine the distance to fetch. Note that
+ # this will result in very ugly bucket divisions, so this is best used without
+ # buckets.
+ if not walking_distance:
+ return isochrone_params
+ if self.opts.unit == Unit.METERS:
+ distance = isochrone_params["distance_limit"] - walking_distance
+ if distance < 0:
+ distance = 0
+ TASK_LOGGER.info(
+ f"Added walking distance {walking_distance} m. Fetching isochrone"
+ f" for distance {distance} m."
+ )
+ return {**isochrone_params, "distance_limit": distance}
+ elif self.opts.unit == Unit.MINUTES:
+ # distance in seconds, walking distance in meters, walking speed 5 km/h
+ time = int(
+ isochrone_params["time_limit"] - walking_distance / (5000 / 3600)
+ )
+ if time < 0:
+ time = 0
+ TASK_LOGGER.info(
+ f"Added walking time corresponding to {walking_distance} m. Fetching"
+ f" isochrone for time {time} s."
+ )
+ return {**isochrone_params, "time_limit": time}
+
+ def __fetch_bucketed_isochrones(self, point_feature: QgsFeature) -> List[Dict]:
# the API may return multiple isochrones for a single point (buckets)
- geometry = point.geometry()
+ geometry = point_feature.geometry()
isochrones = []
# the geometry may be multipoint, handle each point
for point in geometry.parts():
isochrone_params = self.params
isochrone_params["point"] = f"{point.y()},{point.x()}"
+ if self.opts.add_walking_field:
+ isochrone_params = self.__add_walking_distance(
+ isochrone_params, point_feature[self.opts.add_walking_field.name()]
+ )
try:
isochrone_json = fetch(self.base_url, params=isochrone_params)
except QgsPluginNetworkException as e:
@@ -217,23 +341,32 @@ def __fetch_bucketed_isochrones(self, point: QgsFeature) -> List[Dict]:
def __add_isochrones_to_layer(self, layer: QgsVectorLayer) -> None:
TASK_LOGGER.info("Starting isochrone fetch...")
- for idx, point in enumerate(self.points):
+ for idx, (point, boundary) in enumerate(
+ zip(self.points, self.limiting_polygons)
+ ):
bucketed_isochrones = self.__fetch_bucketed_isochrones(point)
for polygon_in_bucket in bucketed_isochrones:
feature = QgsFeature(layer.fields())
- # save the original feature id separately
+ # when merging, we have to discard extra attributes
+ if self.opts.merge_by_field:
+ attributes = [
+ point.id(),
+ point.attribute(self.opts.merge_by_field.name()),
+ ]
+ else:
+ attributes = point.attributes()
# setAttributes cannot be used, will destroy any extra fields!!
- for index, attribute in enumerate(point.attributes()):
+ for index, attribute in enumerate(attributes):
feature.setAttribute(index, attribute)
# set the added distance field separately
bucket = polygon_in_bucket["properties"]["bucket"]
distance = (bucket + 1) * (
self.opts.distance / self.opts.buckets # type: ignore
)
- feature.setAttribute("isochrone_distance", distance)
+ feature["isochrone_distance"] = distance
- feature.setGeometry(
- QgsGeometry.fromPolygonXY(
+ isochrone = QgsGeometry.fromMultiPolygonXY(
+ [
[
[
QgsPointXY(pt[0], pt[1])
@@ -242,8 +375,30 @@ def __add_isochrones_to_layer(self, layer: QgsVectorLayer) -> None:
]
]
]
- )
+ ]
)
+ if boundary:
+ feature["boundary_fids"] = boundary["fids"]
+ isochrone_parts = (
+ boundary.geometry()
+ .intersection(isochrone)
+ .asGeometryCollection()
+ )
+ # After intersecting with the boundary, the isochrone may be a
+ # GeometryCollection of Polygons, LineStrings and Points. We are
+ # only interested in 2D areas, so collect all the Polygons to a
+ # MultiPolygon.
+ isochrone = QgsGeometry.fromMultiPolygonXY(
+ [
+ geometry.asPolygon()
+ for geometry in isochrone_parts
+ if geometry.wkbType() == QgsWkbTypes.Polygon
+ ]
+ )
+ else:
+ feature["boundary_fids"] = ""
+
+ feature.setGeometry(isochrone)
layer.dataProvider().addFeature(feature)
if idx and idx % 10 == 0:
TASK_LOGGER.info(
@@ -256,6 +411,55 @@ def __add_isochrones_to_layer(self, layer: QgsVectorLayer) -> None:
break
self.setProgress(100 * (idx / len(self.points)))
+ def __merge_isochrones_in_layer(self, layer: QgsVectorLayer) -> None:
+ field_name = self.opts.merge_by_field.name()
+ if field_name == "fid":
+ # group by original fid, just in case it is not unique for some reason
+ field_name = "original_fid"
+ TASK_LOGGER.info(f"Merging isochrones by {field_name} value...")
+ # merge isochrones with same field value *and* same distance
+ merge_criterion = itemgetter(
+ layer.dataProvider().fieldNameIndex(field_name),
+ layer.dataProvider().fieldNameIndex("isochrone_distance"),
+ )
+ sorted_features = sorted(layer.getFeatures(), key=merge_criterion)
+ grouped_features = groupby(sorted_features, key=merge_criterion)
+ merged_features = []
+ for group in grouped_features:
+ merged_feature = QgsFeature(layer.fields())
+ merged_geometry = None
+ merged_ids = set()
+ merged_boundary_ids = set()
+ for feature in group[1]:
+ if not merged_geometry:
+ merged_geometry = feature.geometry()
+ else:
+ merged_geometry = merged_geometry.combine(feature.geometry())
+ # now, the result may be polygon *or* multipolygon
+ if merged_geometry.wkbType() == QgsWkbTypes.Polygon:
+ merged_geometry = QgsGeometry.fromMultiPolygonXY(
+ [merged_geometry.asPolygon()]
+ )
+ merged_ids.add(feature["original_fid"])
+ # boundary fids may be the same, only save different boundary ids
+ merged_boundary_ids.update(feature["boundary_fids"].split(","))
+
+ field_value = group[0][0]
+ distance_value = group[0][1]
+ # finally, sort the ids
+ merged_ids = sorted(list(merged_ids))
+ merged_boundary_ids = sorted(list(merged_boundary_ids))
+ merged_feature.setAttribute("original_fid", ",".join(merged_ids))
+ merged_feature.setAttribute(field_name, field_value)
+ merged_feature.setAttribute("isochrone_distance", distance_value)
+ merged_feature.setAttribute("boundary_fids", ",".join(merged_boundary_ids))
+ merged_feature.setGeometry(merged_geometry)
+ merged_features.append(merged_feature)
+
+ # empty the layer and add new features
+ layer.dataProvider().truncate()
+ layer.dataProvider().addFeatures(merged_features)
+
def create_isochrone_layer(self) -> QgsVectorLayer:
"""Creates a polygon QgsVectorLayer containing isochrones for points"""
isochrone_layer = QgsVectorLayer(
@@ -263,18 +467,43 @@ def create_isochrone_layer(self) -> QgsVectorLayer:
)
# add all the required fields to the new layer
- fields = QgsFields(self.opts.layer.fields()) # type: ignore
- # save original feature id separate from new feature id
- fields.rename(0, "original_fid")
+ fields = QgsFields()
+ # save original fid(s) as string to support multiple point ids
+ original_fid_field = QgsField(
+ name="original_fid", type=QVariant.String, typeName="varchar"
+ )
+ fields.append(original_fid_field)
+
+ if self.opts.merge_by_field:
+ # If isochrones are merged, they will lose all their attributes
+ # other than the one that is used to merge
+ fields.append(self.opts.merge_by_field)
+ else:
+ # We must make a copy of original fields, then edit it, then iterate it
+ # to get the desired final field ordering. Yeah, fields can only be
+ # added to the end, go figure.
+ original_fields = QgsFields(self.opts.layer.fields()) # type: ignore
+ # remove the fid field
+ original_fields.remove(0)
+ for field in original_fields:
+ fields.append(field)
+
+ # add our extra fields last
distance_field = QgsField(
name="isochrone_distance", type=QVariant.Double, typeName="double"
)
+ boundary_fid_field = QgsField(
+ name="boundary_fids", type=QVariant.String, typeName="varchar"
+ )
fields.append(distance_field)
+ fields.append(boundary_fid_field)
provider = isochrone_layer.dataProvider()
provider.addAttributes(fields)
isochrone_layer.updateFields()
self.__add_isochrones_to_layer(isochrone_layer)
+ if self.opts.merge_by_field:
+ self.__merge_isochrones_in_layer(isochrone_layer)
# update layer's extent when new features have been added
isochrone_layer.updateExtents()
diff --git a/Catchment/definitions/gui.py b/Catchment/definitions/gui.py
index 8a08f79..cda7fa0 100644
--- a/Catchment/definitions/gui.py
+++ b/Catchment/definitions/gui.py
@@ -1,8 +1,8 @@
"""Definitions for GUI concepts."""
import enum
-from PyQt5.QtGui import QIcon
from qgis.core import QgsApplication
+from qgis.PyQt.QtGui import QIcon
from ..qgis_plugin_tools.tools.resources import resources_path
diff --git a/Catchment/metadata.txt b/Catchment/metadata.txt
index e40d5d0..c334a24 100644
--- a/Catchment/metadata.txt
+++ b/Catchment/metadata.txt
@@ -1,16 +1,21 @@
[general]
name=Catchment
-description=Calculate point catchment areas (isochrones) using Graphhopper on OpenStreetMap roads
-about=This plugin requires access to a Graphhopper API (https://www.graphhopper.com), or your own Graphhopper deployment (https://github.com/GispoCoding/graphhopper-docker).
-version=0.1.0
+description=Calculate point catchment areas (isochrones) using Graphhopper on OpenStreetMap roads, e.g. for educational planning.
+version=0.4.3
+about=This is a plugin to calculate travel time to a point (e.g. school), from any polygon, to better understand patterns of access and improve educational planning.
+
+ The plugin is using Graphhopper on OpenStreetMap roads, and requires access to a Graphhopper API (https://www.graphhopper.com), or your own Graphhopper deployment (e.g. https://github.com/GispoCoding/graphhopper-docker).
+
+ Was this plugin useful? Let us know by sending a message to development@iiep.unesco.org
qgisMinimumVersion=3.16
-author=Gispo Ltd.
+author=IIEP-UNESCO and Gispo Ltd.
email=riku@gispo.fi
changelog=
tags=catchment area,graphhopper,catchment,isochrone
-repository=https://github.com/GispoCoding/catchment-plugin
-homepage=https://github.com/GispoCoding/catchment-plugin
-tracker=https://github.com/GispoCoding/catchment-plugin/issues
+repository=https://github.com/iiepdev/school-catchment-plugin
+homepage=https://github.com/iiepdev/school-catchment-plugin
+tracker=https://github.com/iiepdev/school-catchment-plugin/issues
category=Plugins
experimental=False
deprecated=False
+icon=resources/icons/iiep_logo.svg
diff --git a/Catchment/plugin.py b/Catchment/plugin.py
index 871b2d0..efe2ef1 100644
--- a/Catchment/plugin.py
+++ b/Catchment/plugin.py
@@ -1,9 +1,9 @@
from typing import Callable, List, Optional
-from PyQt5.QtCore import QCoreApplication, QTranslator
-from PyQt5.QtGui import QIcon
-from PyQt5.QtWidgets import QAction, QWidget
from qgis.gui import QgisInterface
+from qgis.PyQt.QtCore import QCoreApplication, QTranslator
+from qgis.PyQt.QtGui import QIcon
+from qgis.PyQt.QtWidgets import QAction, QWidget
from .core.isochrone_creator import IsochroneCreator
from .qgis_plugin_tools.tools.custom_logging import (
@@ -20,7 +20,7 @@
class Plugin:
"""QGIS Plugin Implementation."""
- def __init__(self, iface: QgisInterface) -> None:
+ def __init__(self, iface: QgisInterface) -> None: # noqa
self.iface = iface
# store the task here so it survives garbage collection after run method returns
diff --git a/Catchment/qgis_plugin_tools b/Catchment/qgis_plugin_tools
index a16fe27..33d7603 160000
--- a/Catchment/qgis_plugin_tools
+++ b/Catchment/qgis_plugin_tools
@@ -1 +1 @@
-Subproject commit a16fe27a8e2c3512bf9dffe234323bc1d6d57870
+Subproject commit 33d7603671267d56b4bcfd18439cc3bdddc6e767
diff --git a/Catchment/resources/icons/iiep_logo.svg b/Catchment/resources/icons/iiep_logo.svg
new file mode 100644
index 0000000..8792fe3
--- /dev/null
+++ b/Catchment/resources/icons/iiep_logo.svg
@@ -0,0 +1,21 @@
+
+
+
diff --git a/Catchment/resources/ui/main.ui b/Catchment/resources/ui/main.ui
index cbe0faa..333b6f3 100644
--- a/Catchment/resources/ui/main.ui
+++ b/Catchment/resources/ui/main.ui
@@ -112,13 +112,13 @@
0
0
500
- 450
+ 580
500
- 450
+ 580
@@ -147,30 +147,23 @@
-
- -
-
-
- Use only selected features
-
-
-
- -
+
-
Distance
- -
+
-
- 5
+ 1
120
- 5
+ 1
30
@@ -180,20 +173,20 @@
- -
+
-
Distance divisions
- -
+
-
1
- 8
+ 16
1
@@ -271,6 +264,56 @@
+ -
+
+
+ Extra options
+
+
+
-
+
+
-
+
+
+ Use only selected points
+
+
+
+ -
+
+
+ Limit areas to polygon boundaries
+
+
+
+ -
+
+
+ -
+
+
+ Merge areas with same value in field
+
+
+
+ -
+
+
+ -
+
+
+ Add indoor walking distances
+
+
+
+ -
+
+
+
+
+
+
+
@@ -462,9 +505,12 @@
-
- <html><head/><body><p><a href="https://www.gispo.fi/en/home/"><span style=" text-decoration: underline; color:#0000ff;">Gispo Ltd.</span></a></p></body></html>
+ <html><head/><body><p><a href="http://www.iiep.unesco.org/geo"><span style=" text-decoration: underline; color:#0000ff;">IIEP-UNESCO</span></a> and <a href="https://www.gispo.fi/en/home/"><span style=" text-decoration: underline; color:#0000ff;">Gispo Ltd.</span></a></p></body></html>
+
+ true
+
@@ -493,6 +539,9 @@
<html><head/><body><p><a href="https://github.com/graphhopper/graphhopper"><span style=" text-decoration: underline; color:#0000ff;">https://github.com/graphhopper/graphhopper</span></a></p></body></html>
+
+ true
+
-
@@ -505,9 +554,12 @@
-
- <html><head/><body><p><a href="https://github.com/GispoCoding/catchment-plugin"><span style=" text-decoration: underline; color:#0000ff;">https://github.com/GispoCoding/catchment-plugin</span></a></p></body></html>
+ <html><head/><body><p><a href="https://github.com/GispoCoding/school-catchment-plugin"><span style=" text-decoration: underline; color:#0000ff;">https://github.com/GispoCoding/catchment-plugin</span></a></p></body></html>
+
+ true
+
@@ -563,6 +615,9 @@
<html><head/><body><p><a href="https://www.apache.org/licenses/LICENSE-2.0"><span style=" text-decoration: underline; color:#0000ff;">Apache License 2.0</span></a></p></body></html>
+
+ true
+
-
@@ -578,6 +633,9 @@
<html><head/><body><p><a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><span style=" text-decoration: underline; color:#0000ff;">GNU General Public License v3.0</span></a></p></body></html>
+
+ true
+
@@ -632,6 +690,11 @@
QComboBox
+
+ QgsFieldComboBox
+ QComboBox
+
+
QgsSpinBox
QSpinBox
diff --git a/Catchment/test/conftest.py b/Catchment/test/conftest.py
index 2872452..1c294c8 100644
--- a/Catchment/test/conftest.py
+++ b/Catchment/test/conftest.py
@@ -3,8 +3,9 @@
"""
This class contains fixtures and common helper function to keep the test files shorter
"""
+import json
import os
-from typing import Callable, Dict, Optional
+from typing import Callable, Dict, List, Optional, Union
import pytest
from PyQt5.QtCore import QVariant
@@ -15,7 +16,9 @@
QgsField,
QgsFields,
QgsGeometry,
+ QgsLineString,
QgsPointXY,
+ QgsPolygon,
QgsVectorLayer,
)
@@ -23,44 +26,62 @@
from Catchment.definitions.constants import Profile, Unit
from Catchment.plugin import Plugin
-from ..qgis_plugin_tools.testing.utilities import get_qgis_app
from ..qgis_plugin_tools.tools.exceptions import QgsPluginNetworkException
from ..qgis_plugin_tools.tools.i18n import tr
-QGIS_APP, CANVAS, IFACE, PARENT = get_qgis_app()
MOCK_URL = "http://mock.url"
-@pytest.fixture(autouse=True)
-def new_project() -> None:
- """Initializes the QGIS project by removing layers and relations etc.""" # noqa E501
- # yields nothing
- yield IFACE.newProject()
-
-
@pytest.fixture(scope="function")
def mock_fetch(mocker, request) -> None:
- """Makes fetch return JSON for a specified URL, exception otherwise.
- Use by calling mock_fetch(desired_url, json_file_name, error_desired) in a test.
+ """Makes fetch return JSON(s) (and optional error(s)) for specified URL(s).
+ Use by calling mock_fetch(desired_url(s), json_file_name(s), error_desired, required_params(s)) in a test.
"""
def _mock_fetch(
- url: str,
- json_to_return: str = "isochrones.json",
- error: bool = False,
+ desired_url: Union[str, List[str]],
+ json_to_return: Union[str, List[str]] = "isochrones.json",
+ error: Union[bool, List[bool]] = False,
+ required_params: Union[
+ Optional[Dict[str, str]], List[Optional[Dict[str, str]]]
+ ] = None,
) -> Callable:
+ if isinstance(desired_url, str):
+ desired_url = [desired_url]
+ if isinstance(json_to_return, str):
+ json_to_return = len(desired_url) * [json_to_return]
+ if isinstance(error, bool):
+ error = len(desired_url) * [error]
+ if isinstance(required_params, dict) or not required_params:
+ required_params = len(desired_url) * [required_params]
+
def mocked_fetch(
- incoming_url: str,
+ url: str,
params: Optional[Dict[str, str]] = None,
) -> str:
- if incoming_url == url:
- with open(
- os.path.join(request.fspath.dirname, "fixtures", json_to_return)
- ) as f:
- # mock error if desired
- if error:
- raise QgsPluginNetworkException(f.read(), error=302)
- return f.read()
+ indices = [
+ idx
+ for idx, matching_url in enumerate(desired_url)
+ if url == matching_url
+ ]
+ # return first matching url+parameter combination
+ for index in indices:
+ # only check parameters if we require specific params
+ if not required_params[index] or all(
+ [
+ params.get(key, None) == required_params[index][key]
+ for key in required_params[index].keys()
+ ]
+ ):
+ with open(
+ os.path.join(
+ request.fspath.dirname, "fixtures", json_to_return[index]
+ )
+ ) as f:
+ # mock error if desired
+ if error[index]:
+ raise QgsPluginNetworkException(f.read(), error=302)
+ return f.read()
raise QgsPluginNetworkException(tr("Request failed"))
mocker.patch("Catchment.core.isochrone_creator.fetch", new=mocked_fetch)
@@ -70,28 +91,123 @@ def mocked_fetch(
@pytest.fixture(scope="function")
def point() -> None:
- yield QgsPointXY(1.0, 1.0)
+ yield QgsGeometry.fromPointXY(QgsPointXY(1.0, 1.0))
+
+
+@pytest.fixture(scope="function")
+def another_point() -> None:
+ yield QgsGeometry.fromPointXY(QgsPointXY(0.9, 0.9))
+
+
+@pytest.fixture(scope="function")
+def square() -> None:
+ yield QgsGeometry.fromPolygonXY(
+ [
+ [
+ QgsPointXY(0.0, 0.0),
+ QgsPointXY(2.0, 0.0),
+ QgsPointXY(2.0, 2.0),
+ QgsPointXY(0.0, 2.0),
+ ]
+ ]
+ )
+
+
+@pytest.fixture(scope="function")
+def multipolygon() -> None:
+ yield QgsGeometry.fromMultiPolygonXY(
+ [
+ [
+ [
+ QgsPointXY(0.0, 0.0),
+ QgsPointXY(2.0, 0.0),
+ QgsPointXY(2.0, 2.0),
+ QgsPointXY(0.0, 2.0),
+ ]
+ ],
+ [
+ [
+ QgsPointXY(4.0, 4.0),
+ QgsPointXY(6.0, 4.0),
+ QgsPointXY(6.0, 6.0),
+ QgsPointXY(4.0, 6.0),
+ ]
+ ],
+ ]
+ )
+
+
+@pytest.fixture(scope="function")
+def triangle() -> None:
+ yield QgsGeometry.fromPolygonXY(
+ [[QgsPointXY(-1.0, -1.0), QgsPointXY(3.0, -1.0), QgsPointXY(1.0, 2.0)]]
+ )
@pytest.fixture(scope="function")
def fields() -> None:
fields = QgsFields()
- fields.append(QgsField("id", QVariant.Int))
+ fields.append(QgsField("fid", QVariant.Int))
fields.append(QgsField("name", QVariant.String))
+ fields.append(QgsField("extra_info", QVariant.String))
+ fields.append(QgsField("extra_field_1", QVariant.Int))
+ fields.append(QgsField("extra_field_2", QVariant.Int))
yield fields
@pytest.fixture(scope="function")
def point_feature(fields, point) -> None:
feature = QgsFeature(fields)
- feature.setGeometry(QgsGeometry.fromPointXY(point))
- feature.setAttribute("id", 1)
+ feature.setGeometry(point)
+ feature.setAttribute("fid", 1)
feature.setAttribute("name", "school")
+ feature.setAttribute("extra_info", "first_feature")
+ feature.setAttribute("extra_field_1", 2)
+ feature.setAttribute("extra_field_2", 3)
yield feature
@pytest.fixture(scope="function")
-def vector_layer(fields, point_feature) -> None:
+def another_point_feature(fields, another_point) -> None:
+ feature = QgsFeature(fields)
+ feature.setGeometry(another_point)
+ feature.setAttribute("fid", 2)
+ feature.setAttribute("name", "school")
+ feature.setAttribute("extra_info", "second_feature")
+ feature.setAttribute("extra_field_1", 2)
+ feature.setAttribute("extra_field_2", 4)
+ yield feature
+
+
+@pytest.fixture(scope="function")
+def square_feature(fields, square) -> None:
+ feature = QgsFeature(fields)
+ feature.setGeometry(square)
+ feature.setAttribute("fid", 1)
+ feature.setAttribute("name", "square_school_area_boundary")
+ yield feature
+
+
+@pytest.fixture(scope="function")
+def multipolygon_feature(fields, multipolygon) -> None:
+ feature = QgsFeature(fields)
+ feature.setGeometry(multipolygon)
+ feature.setAttribute("fid", 1)
+ feature.setAttribute("name", "multipolygon_school_area_boundary")
+ yield feature
+
+
+@pytest.fixture(scope="function")
+def triangle_feature(fields, triangle) -> None:
+ feature = QgsFeature(fields)
+ feature.setGeometry(triangle)
+ feature.setAttribute("fid", 1)
+ feature.setAttribute("name", "triangular_school_area_boundary")
+ yield feature
+
+
+@pytest.fixture(scope="function")
+def point_layer(fields, point_feature) -> None:
layer = QgsVectorLayer("Point?crs=epsg:4326&index=yes", "test_points", "memory")
provider = layer.dataProvider()
provider.addAttributes(fields)
@@ -102,10 +218,75 @@ def vector_layer(fields, point_feature) -> None:
@pytest.fixture(scope="function")
-def isochrone_opts(vector_layer) -> None:
+def two_point_layer(fields, point_feature, another_point_feature) -> None:
+ layer = QgsVectorLayer("Point?crs=epsg:4326&index=yes", "test_points", "memory")
+ provider = layer.dataProvider()
+ provider.addAttributes(fields)
+ layer.updateFields()
+ provider.addFeature(point_feature)
+ provider.addFeature(another_point_feature)
+ layer.updateExtents()
+ yield layer
+
+
+@pytest.fixture(scope="function")
+def square_layer(fields, square_feature) -> None:
+ layer = QgsVectorLayer(
+ "Polygon?crs=epsg:4326&index=yes", "test_boundaries", "memory"
+ )
+ provider = layer.dataProvider()
+ provider.addAttributes(fields)
+ layer.updateFields()
+ provider.addFeature(square_feature)
+ layer.updateExtents()
+ yield layer
+
+
+@pytest.fixture(scope="function")
+def multipolygon_layer(fields, multipolygon_feature) -> None:
+ layer = QgsVectorLayer(
+ "Polygon?crs=epsg:4326&index=yes", "test_boundaries", "memory"
+ )
+ provider = layer.dataProvider()
+ provider.addAttributes(fields)
+ layer.updateFields()
+ provider.addFeature(multipolygon_feature)
+ layer.updateExtents()
+ yield layer
+
+
+@pytest.fixture(scope="function")
+def triangle_layer(fields, triangle_feature) -> None:
+ layer = QgsVectorLayer(
+ "Polygon?crs=epsg:4326&index=yes", "test_boundaries", "memory"
+ )
+ provider = layer.dataProvider()
+ provider.addAttributes(fields)
+ layer.updateFields()
+ provider.addFeature(triangle_feature)
+ layer.updateExtents()
+ yield layer
+
+
+@pytest.fixture(scope="function")
+def square_plus_triangle_layer(fields, square_feature, triangle_feature) -> None:
+ layer = QgsVectorLayer(
+ "Polygon?crs=epsg:4326&index=yes", "test_boundaries", "memory"
+ )
+ provider = layer.dataProvider()
+ provider.addAttributes(fields)
+ layer.updateFields()
+ provider.addFeature(square_feature)
+ provider.addFeature(triangle_feature)
+ layer.updateExtents()
+ yield layer
+
+
+@pytest.fixture(scope="function")
+def isochrone_opts(point_layer, request) -> None:
opts = IsochroneOpts(
url=MOCK_URL,
- layer=vector_layer,
+ layer=point_layer,
distance=30,
unit=Unit.MINUTES,
profile=Profile.WALKING,
@@ -114,8 +295,8 @@ def isochrone_opts(vector_layer) -> None:
@pytest.fixture(scope="function")
-def new_plugin(isochrone_opts) -> None:
- plugin = Plugin(IFACE)
+def new_plugin(qgis_iface, isochrone_opts) -> None:
+ plugin = Plugin(qgis_iface)
plugin.initGui()
# mock options, since mock QgisInterface does not support QgsMapLayerComboBox
plugin.dlg.read_isochrone_options = lambda: isochrone_opts
diff --git a/Catchment/test/fixtures/another_isochrone.json b/Catchment/test/fixtures/another_isochrone.json
new file mode 100644
index 0000000..c99265b
--- /dev/null
+++ b/Catchment/test/fixtures/another_isochrone.json
@@ -0,0 +1,44 @@
+{
+ "polygons": [
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [
+ -1.0,
+ -1.0
+ ],
+ [
+ 3.0,
+ -1.0
+ ],
+ [
+ 3.0,
+ 0.0
+ ],
+ [
+ -1.0,
+ 0.0
+ ],
+ [
+ -1.0,
+ -1.0
+ ]
+ ]
+ ]
+ },
+ "properties": {
+ "bucket": 0
+ }
+ }
+ ],
+ "info": {
+ "copyrights": [
+ "GraphHopper",
+ "OpenStreetMap contributors"
+ ],
+ "took": 0
+ }
+}
diff --git a/Catchment/test/fixtures/isochrones.json b/Catchment/test/fixtures/isochrones.json
index a8804a1..caf5f52 100644
--- a/Catchment/test/fixtures/isochrones.json
+++ b/Catchment/test/fixtures/isochrones.json
@@ -7,144 +7,44 @@
"coordinates": [
[
[
- -77.50273519,
- 18.37374303
+ -1.0,
+ -1.0
],
[
- -77.50273549,
- 18.37374245
+ 0.0,
+ -1.0
],
[
- -77.5027349,
- 18.37374245
+ 0.0,
+ 1.0
],
[
- -77.49271445,
- 18.37399578
+ 1.0,
+ 0.0
],
[
- -77.48894865,
- 18.37444887
+ 2.0,
+ 1.0
],
[
- -77.48636292,
- 18.37487504
+ 2.0,
+ -1.0
],
[
- -77.48734985,
- 18.37570233
+ 3.0,
+ -1.0
],
[
- -77.48635985,
- 18.3763402
+ 3.0,
+ 2.0
],
[
- -77.4863158,
- 18.37638108
+ -1.0,
+ 2.0
],
[
- -77.48628665,
- 18.37642318
- ],
- [
- -77.48572804,
- 18.37710286
- ],
- [
- -77.48526582,
- 18.37723967
- ],
- [
- -77.48516049,
- 18.37723781
- ],
- [
- -77.48414265,
- 18.37705452
- ],
- [
- -77.48350237,
- 18.37815479
- ],
- [
- -77.48352919,
- 18.37820247
- ],
- [
- -77.48352574,
- 18.37828079
- ],
- [
- -77.48345403,
- 18.37830687
- ],
- [
- -77.48339778,
- 18.37828517
- ],
- [
- -77.48337096,
- 18.37823749
- ],
- [
- -77.48302432,
- 18.37630993
- ],
- [
- -77.48340784,
- 18.37919898
- ],
- [
- -77.48338111,
- 18.37856773
- ],
- [
- -77.48343736,
- 18.37858943
- ],
- [
- -77.48352127,
- 18.37865751
- ],
- [
- -77.48416556,
- 18.37881919
- ],
- [
- -77.48419229,
- 18.37945044
- ],
- [
- -77.48479309,
- 18.37960187
- ],
- [
- -77.48512035,
- 18.37968076
- ],
- [
- -77.48530708,
- 18.37974418
- ],
- [
- -77.48543784,
- 18.37976076
- ],
- [
- -77.4857286,
- 18.37982176
- ],
- [
- -77.49011969,
- 18.37755278
- ],
- [
- -77.49388549,
- 18.37709969
- ],
- [
- -77.50273519,
- 18.37374303
+ -1.0,
+ -1.0
]
]
]
diff --git a/Catchment/test/test_isochrone_creator.py b/Catchment/test/test_isochrone_creator.py
index dcb8bd7..439077c 100644
--- a/Catchment/test/test_isochrone_creator.py
+++ b/Catchment/test/test_isochrone_creator.py
@@ -6,17 +6,74 @@
from ..qgis_plugin_tools.tools.exceptions import QgsPluginNetworkException
-def test_isochrone_layer_isochrone_created(isochrone_opts, mock_fetch):
+# Test the plugin with
+# 1) no school area boundaries => should yield original isochrone
+# 2) square school area boundary => should yield single polygon intersection with isochrone.json
+# 3) multipolygon school area boundary => should yield single polygon intersection with isochrone.json
+# 4) triangular school area boundary => should yield multipolygon intersection with isochrone.json
+# 5) square+triangular school area boundary => should yield single polygon intersection with isochrone.json
+@pytest.mark.parametrize(
+ "boundary_layer, part_count",
+ [
+ (None, 1),
+ ("square_layer", 1),
+ ("multipolygon_layer", 1),
+ ("triangle_layer", 3),
+ ("square_plus_triangle_layer", 1),
+ ],
+)
+def test_isochrone_layer_isochrone_created(
+ isochrone_opts, mock_fetch, boundary_layer, part_count, request
+):
+ if boundary_layer:
+ boundary_layer = request.getfixturevalue(boundary_layer)
mock_fetch(isochrone_opts.url + "/isochrone")
assert isochrone_opts.layer.featureCount() == 1
assert isochrone_opts.check_if_opts_set()
+ isochrone_opts.polygon_layer = boundary_layer
isochrone_layer = IsochroneCreator(isochrone_opts).create_isochrone_layer()
assert isochrone_layer.featureCount() == 1
assert isochrone_layer.geometryType() == QgsWkbTypes.PolygonGeometry
for feature in isochrone_layer.getFeatures():
- assert feature.attribute("original_fid") == 1
+ assert feature.attribute("original_fid") == "1"
assert feature.attribute("name") == "school"
+ assert feature.attribute("extra_info") == "first_feature"
assert feature.attribute("isochrone_distance") == 30
+ if not boundary_layer:
+ assert feature.attribute("boundary_fids") == ""
+ else:
+ assert feature.attribute("boundary_fids") == ",".join(
+ [str(feature["fid"]) for feature in boundary_layer.getFeatures()]
+ )
+ assert len(feature.geometry().asMultiPolygon()) == part_count
+
+
+def test_isochrone_layer_isochrones_merged(
+ isochrone_opts, mock_fetch, point, another_point, two_point_layer
+):
+ mock_fetch(
+ [isochrone_opts.url + "/isochrone", isochrone_opts.url + "/isochrone"],
+ ["isochrones.json", "another_isochrone.json"],
+ required_params=[
+ {"point": f"{point.asPoint().y()},{point.asPoint().x()}"},
+ {"point": f"{another_point.asPoint().y()},{another_point.asPoint().x()}"},
+ ],
+ )
+ isochrone_opts.layer = two_point_layer
+ isochrone_opts.merge_by_field = two_point_layer.fields()[
+ two_point_layer.dataProvider().fieldNameMap()["extra_field_1"]
+ ]
+ isochrone_layer = IsochroneCreator(isochrone_opts).create_isochrone_layer()
+ assert isochrone_layer.featureCount() == 1
+ assert isochrone_layer.geometryType() == QgsWkbTypes.PolygonGeometry
+ for feature in isochrone_layer.getFeatures():
+ assert feature.attribute("original_fid") == "1,2"
+ assert feature.attribute("extra_field_1") == 2
+ assert feature.attribute("isochrone_distance") == 30
+ assert feature.attribute("boundary_fids") == ""
+ # the two isochrones will merge to a polygon with two inner rings
+ assert len(feature.geometry().asMultiPolygon()) == 1
+ assert len(feature.geometry().asMultiPolygon()[0]) == 3
def test_isochrone_layer_empty(isochrone_opts, mock_fetch):
diff --git a/Catchment/test/test_plugin.py b/Catchment/test/test_plugin.py
index 3103f23..30aea07 100644
--- a/Catchment/test/test_plugin.py
+++ b/Catchment/test/test_plugin.py
@@ -7,18 +7,19 @@
from Catchment.plugin import Plugin
from Catchment.qgis_plugin_tools.tools.settings import get_setting
-from .conftest import MOCK_URL, QGIS_APP
+from .conftest import MOCK_URL
-def test_plugin(new_plugin, mock_fetch, qtbot):
+def test_plugin(qgis_app, new_plugin, new_project, mock_fetch, qtbot):
mock_fetch(MOCK_URL + "/isochrone")
new_plugin.run()
dialog = new_plugin.dlg
+ qtbot.add_widget(dialog)
buttonbox = dialog.buttonbox_main
for button in buttonbox.buttons():
if buttonbox.buttonRole(button) == QDialogButtonBox.AcceptRole:
qtbot.mouseClick(button, Qt.LeftButton)
- action = QGIS_APP.taskManager().activeTasks()[0]
+ action = qgis_app.taskManager().activeTasks()[0]
blocker = qtbot.waitSignal(action.taskCompleted, timeout=10000)
blocker.wait()
# check result layer
@@ -28,15 +29,16 @@ def test_plugin(new_plugin, mock_fetch, qtbot):
assert layer.featureCount() == 1
-def test_plugin_fail(new_plugin, mock_fetch, qtbot):
+def test_plugin_fail(qgis_app, new_plugin, new_project, mock_fetch, qtbot):
mock_fetch(MOCK_URL + "/isochrone", "error.json", error=True)
new_plugin.run()
dialog = new_plugin.dlg
+ qtbot.add_widget(dialog)
buttonbox = dialog.buttonbox_main
for button in buttonbox.buttons():
if buttonbox.buttonRole(button) == QDialogButtonBox.AcceptRole:
qtbot.mouseClick(button, Qt.LeftButton)
- action = QGIS_APP.taskManager().activeTasks()[0]
+ action = qgis_app.taskManager().activeTasks()[0]
# check that the task is *not* completed
blocker = qtbot.waitSignal(action.taskTerminated, timeout=10000)
blocker.wait()
diff --git a/Catchment/ui/about_panel.py b/Catchment/ui/about_panel.py
index 407338e..d370ec4 100644
--- a/Catchment/ui/about_panel.py
+++ b/Catchment/ui/about_panel.py
@@ -1,6 +1,6 @@
import logging
-from PyQt5.QtWidgets import QDialog
+from qgis.PyQt.QtWidgets import QDialog
from ..definitions.gui import Panels
from ..qgis_plugin_tools.tools.i18n import tr
@@ -18,5 +18,5 @@ def __init__(self, dialog: QDialog) -> None:
def setup_panel(self) -> None:
v = version()
- LOGGER.info(tr(u"Plugin version is {}", v))
+ LOGGER.info(tr("Plugin version is {}", v))
self.dlg.label_version.setText(v)
diff --git a/Catchment/ui/base_panel.py b/Catchment/ui/base_panel.py
index 4023381..2f82951 100644
--- a/Catchment/ui/base_panel.py
+++ b/Catchment/ui/base_panel.py
@@ -1,7 +1,7 @@
"""Panel core base class."""
from typing import Dict, Optional
-from PyQt5.QtWidgets import QDialog
+from qgis.PyQt.QtWidgets import QDialog
from ..definitions.gui import Panels
from ..qgis_plugin_tools.tools.exceptions import QgsPluginNotImplementedException
diff --git a/Catchment/ui/catchment_area_panel.py b/Catchment/ui/catchment_area_panel.py
index 1bbc6ee..18cc91f 100644
--- a/Catchment/ui/catchment_area_panel.py
+++ b/Catchment/ui/catchment_area_panel.py
@@ -2,8 +2,8 @@
from math import floor, pow
from typing import Optional
-from PyQt5.QtWidgets import QDialog
-from qgis.core import QgsMapLayerProxyModel
+from qgis.core import QgsFieldProxyModel, QgsMapLayerProxyModel
+from qgis.PyQt.QtWidgets import QDialog
from ..definitions.constants import Profile, Unit
from ..definitions.gui import Panels
@@ -25,7 +25,13 @@ def __init__(self, dialog: QDialog) -> None:
def setup_panel(self) -> None:
self.dlg.combobox_layer.setFilters(QgsMapLayerProxyModel.PointLayer)
+ self.dlg.combobox_polygon_layer.setFilters(QgsMapLayerProxyModel.PolygonLayer)
+ self.dlg.combobox_polygon_layer.setEnabled(False)
+ self.dlg.combobox_layer_field.setEnabled(False)
+ self.dlg.combobox_add_walking_field.setFilters(QgsFieldProxyModel.Int)
+ self.dlg.combobox_add_walking_field.setEnabled(False)
self.__update_duration_label()
+ self.__update_field_selectors()
# connect the signals, since pyqt slot decorator cannot be used
self.dlg.radiobtn_mins.clicked.connect(self.on_radiobtn_mins_clicked)
@@ -36,9 +42,21 @@ def setup_panel(self) -> None:
self.dlg.combobox_layer.layerChanged.connect(
self.on_combobox_layer_layerChanged
)
+ self.dlg.combobox_polygon_layer.layerChanged.connect(
+ self.on_combobox_polygon_layer_layerChanged
+ )
self.dlg.checkbox_selected_only.clicked.connect(
self.on_checkbox_selected_only_clicked
)
+ self.dlg.checkbox_limit_to_polygon.clicked.connect(
+ self.on_checkbox_limit_to_polygon_clicked
+ )
+ self.dlg.checkbox_combine_by_field.clicked.connect(
+ self.on_checkbox_combine_by_field_clicked
+ )
+ self.dlg.checkbox_add_walking.clicked.connect(
+ self.on_checkbox_add_walking_clicked
+ )
self.dlg.spinbox_distance.valueChanged.connect(
self.on_spinbox_distance_valueChanged
)
@@ -67,7 +85,7 @@ def _get_duration(self) -> Optional[int]:
# assuming walking speed 5 km/h = 83.3 m/min
distance_in_minutes_by_foot = opts.distance / 83.3 # type: ignore
elif opts.profile == Profile.CYCLING:
- # assuming biking speed 25 km/h
+ # the biking speed varies greatly, assume average of 15
distance_in_minutes_by_foot = 3 * distance_in_minutes_by_foot # type: ignore # noqa
elif opts.profile == Profile.DRIVING:
# assuming driving speed 50 km/h
@@ -108,10 +126,29 @@ def on_radiobtn_driving_clicked(self) -> None:
def on_combobox_layer_layerChanged(self) -> None: # noqa
self.__update_duration_label()
+ self.__update_field_selectors()
+
+ def on_combobox_polygon_layer_layerChanged(self) -> None: # noqa
+ pass
def on_checkbox_selected_only_clicked(self) -> None:
self.__update_duration_label()
+ def on_checkbox_limit_to_polygon_clicked(self) -> None:
+ self.dlg.combobox_polygon_layer.setEnabled(
+ not self.dlg.combobox_polygon_layer.isEnabled()
+ )
+
+ def on_checkbox_combine_by_field_clicked(self) -> None:
+ self.dlg.combobox_layer_field.setEnabled(
+ not self.dlg.combobox_layer_field.isEnabled()
+ )
+
+ def on_checkbox_add_walking_clicked(self) -> None:
+ self.dlg.combobox_add_walking_field.setEnabled(
+ not self.dlg.combobox_add_walking_field.isEnabled()
+ )
+
def on_spinbox_distance_valueChanged(self) -> None: # noqa
self.__update_duration_label()
@@ -122,13 +159,13 @@ def __update_unit_selector(self, selected_unit: Unit) -> None:
"""Sets unit spinbox min, max, and step values
based on currently selected unit"""
if selected_unit == Unit.MINUTES:
- step = 5
- min_ = 5
+ step = 1
+ min_ = 1
max_ = 120
default = 30
elif selected_unit == Unit.METERS:
- step = 500
- min_ = 500
+ step = 100
+ min_ = 100
max_ = 10000
default = 2000
@@ -159,3 +196,8 @@ def __update_duration_label(self) -> None:
"settings may take several hours or days."
)
self.dlg.duration_label.setStyleSheet("color: red")
+
+ def __update_field_selectors(self) -> None:
+ opts = self.dlg.read_isochrone_options()
+ self.dlg.combobox_layer_field.setLayer(opts.layer)
+ self.dlg.combobox_add_walking_field.setLayer(opts.layer)
diff --git a/Catchment/ui/maindialog.py b/Catchment/ui/maindialog.py
index d3963d4..7a18eec 100644
--- a/Catchment/ui/maindialog.py
+++ b/Catchment/ui/maindialog.py
@@ -1,13 +1,13 @@
import logging
-from PyQt5.QtWidgets import (
+from qgis.core import QgsApplication
+from qgis.PyQt.QtWidgets import (
QDesktopWidget,
QDialog,
QDialogButtonBox,
QRadioButton,
QWidget,
)
-from qgis.core import QgsApplication
from ..core.isochrone_creator import IsochroneCreator, IsochroneOpts
from ..definitions.constants import Profile, Unit
@@ -67,7 +67,34 @@ def read_isochrone_options(self) -> IsochroneOpts:
opts.write_to_directory = self.checkbox_file.isChecked()
opts.directory = self.file_widget.filePath()
opts.layer = self.combobox_layer.currentLayer()
+ opts.polygon_layer = (
+ self.combobox_polygon_layer.currentLayer()
+ if self.checkbox_limit_to_polygon.isChecked()
+ else None
+ )
opts.selected_only = self.checkbox_selected_only.isChecked()
+ opts.merge_by_field = (
+ # While the QgsLayerCombobox returns layer directly, the
+ # QgsFieldCombobox only returns field *name*. Go figure
+ opts.layer.fields()[
+ opts.layer.fields().indexFromName(
+ self.combobox_layer_field.currentField()
+ )
+ ]
+ if self.checkbox_combine_by_field.isChecked()
+ else None
+ )
+ opts.add_walking_field = (
+ # While the QgsLayerCombobox returns layer directly, the
+ # QgsFieldCombobox only returns field *name*. Go figure
+ opts.layer.fields()[
+ opts.layer.fields().indexFromName(
+ self.combobox_add_walking_field.currentField()
+ )
+ ]
+ if self.checkbox_add_walking.isChecked()
+ else None
+ )
opts.distance = self.spinbox_distance.value()
opts.buckets = self.spinbox_buckets.value()
@@ -84,7 +111,6 @@ def read_isochrone_options(self) -> IsochroneOpts:
opts.profile = Profile.CYCLING
elif profile == "radiobtn_driving":
opts.profile = Profile.DRIVING
- print(opts)
return opts
def _set_window_location(self) -> None:
@@ -92,8 +118,8 @@ def _set_window_location(self) -> None:
sg = QDesktopWidget().screenGeometry()
widget = self.geometry()
- x = (ag.width() - widget.width()) / 1.5
- y = 2 * ag.height() - sg.height() - 1.2 * widget.height()
+ x = int((ag.width() - widget.width()) / 1.5)
+ y = int(2 * ag.height() - sg.height() - 1.2 * widget.height())
self.move(x, y)
@staticmethod
diff --git a/Catchment/ui/settings_panel.py b/Catchment/ui/settings_panel.py
index 2453a16..39057c4 100644
--- a/Catchment/ui/settings_panel.py
+++ b/Catchment/ui/settings_panel.py
@@ -1,8 +1,8 @@
import logging
import webbrowser
-from PyQt5.QtWidgets import QDialog
from qgis.gui import QgsFileWidget
+from qgis.PyQt.QtWidgets import QDialog
from ..definitions.gui import Panels
from ..qgis_plugin_tools.tools.custom_logging import (
diff --git a/README.md b/README.md
index b55f1cf..2bcebe8 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,10 @@ This material has been partly funded by UK aid from the UK government; however i
---
+### Install the plugin
+
+To install the Catchment Plugin, simply open the Plugin Manager (`Plugins > Manage and Install Plugins...`), search for Catchment and click Install Plugin. The Plugin will then appear in Plugins toolbar.
+
### How to get started
Currently, the plugin calculates catchment areas (isochrones) for a specified layer of schools with a selected mode of transport (walking/hiking, cycling, driving) and a selected distance in metres or duration in minutes (e.g. 1500 metres, 30 minutes of transit).
@@ -46,7 +50,7 @@ See the [graphhopper-docker repository](https://github.com/GispoCoding/graphhopp

-1. Once you know your Graphhopper address, start the plugin and select the Settings tab. Fill in the address in Graphhopper URL field.
+1. Once you know your Graphhopper address, start the plugin and select the Settings tab. Fill in the address in Graphhopper URL field. If you are running GraphHopper using Docker (see instructions above), the default address is http://localhost:8989/.
2. If your Graphhopper subscription requires an API key, fill in the API key field.
3. If you wish to save the result layers automatically, select the checkbox and pick the directory you want to save the results into. Otherwise, the layer stays only in memory.
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 694e40d..77d4f97 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,3 +1,4 @@
+flake8-qgis~=1.0.0
pre-commit>=2.12.1
pytest~=6.2.3
pytest-mock~=3.6.1
diff --git a/requirements-test.txt b/requirements-test.txt
index 5fad5f2..1e9e00a 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,3 +1,4 @@
pytest~=6.2.3
pytest-mock~=3.6.1
+pytest-qgis~=1.0.3
pytest-qt~=3.3.0