From 4ca8cfb65de8f1e20c5706c7adbdebcd9ed8d4d5 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Wed, 17 Nov 2021 16:47:34 +0200 Subject: [PATCH 01/49] Remove debug print --- Catchment/ui/maindialog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Catchment/ui/maindialog.py b/Catchment/ui/maindialog.py index d3963d4..b49c984 100644 --- a/Catchment/ui/maindialog.py +++ b/Catchment/ui/maindialog.py @@ -84,7 +84,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: From 6a6cb73c5225ebfcae5fb041a0770105d8e351a0 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Wed, 17 Nov 2021 16:48:00 +0200 Subject: [PATCH 02/49] Fix __int__ DeprecationWarning --- Catchment/ui/maindialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Catchment/ui/maindialog.py b/Catchment/ui/maindialog.py index b49c984..0ce7065 100644 --- a/Catchment/ui/maindialog.py +++ b/Catchment/ui/maindialog.py @@ -91,8 +91,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 From 12ccc6fdfe87e4db5b714d307cbb863a97d09adf Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 19 Nov 2021 15:16:18 +0200 Subject: [PATCH 03/49] Add feature to intersect isochrones with input polygons --- Catchment/core/isochrone_creator.py | 105 ++++++++++++++--- Catchment/resources/ui/main.ui | 18 ++- Catchment/test/conftest.py | 106 ++++++++++++++++- Catchment/test/fixtures/isochrones.json | 140 ++++------------------- Catchment/test/test_isochrone_creator.py | 25 +++- Catchment/ui/catchment_area_panel.py | 14 +++ Catchment/ui/maindialog.py | 2 + 7 files changed, 264 insertions(+), 146 deletions(-) diff --git a/Catchment/core/isochrone_creator.py b/Catchment/core/isochrone_creator.py index 75472c0..478a9cd 100644 --- a/Catchment/core/isochrone_creator.py +++ b/Catchment/core/isochrone_creator.py @@ -21,6 +21,7 @@ QgsTask, QgsVectorFileWriter, QgsVectorLayer, + QgsWkbTypes ) from ..definitions.constants import Profile, Unit @@ -39,6 +40,7 @@ class IsochroneOpts: url: str = "" api_key: str = "" layer: Optional[QgsVectorLayer] = None + polygon_layer: Optional[QgsVectorLayer] = None selected_only: bool = False distance: Optional[int] = None unit: Optional[Unit] = None @@ -59,7 +61,8 @@ class IsochroneCreator(QgsTask): def __init__(self, opts: IsochroneOpts) -> None: self.opts = opts 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 +87,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 +107,19 @@ 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 +127,52 @@ 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 "" + self.name = (f"{self.opts.distance} {self.opts.unit.value} {direction_string}" + f" {selected_string}{self.opts.layer.name()}{profile_string}{limited_string}") # type: ignore # noqa super().__init__(description=f"Fetching GraphHopper isochrones: {self.name}") self.setProgress(0.0) @@ -217,7 +274,8 @@ 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()) @@ -230,20 +288,31 @@ def __add_isochrones_to_layer(self, layer: QgsVectorLayer) -> None: distance = (bucket + 1) * ( self.opts.distance / self.opts.buckets # type: ignore ) - feature.setAttribute("isochrone_distance", distance) + feature["isochrone_distance"] = distance - feature.setGeometry( - QgsGeometry.fromPolygonXY( - [ - [ - QgsPointXY(pt[0], pt[1]) - for pt in polygon_in_bucket["geometry"]["coordinates"][ - 0 - ] - ] - ] + isochrone = QgsGeometry.fromMultiPolygonXY( + [[[QgsPointXY(pt[0], pt[1]) + for pt in polygon_in_bucket["geometry"]["coordinates"][0] + ]]] ) - ) + 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( @@ -269,7 +338,11 @@ def create_isochrone_layer(self) -> QgsVectorLayer: 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() diff --git a/Catchment/resources/ui/main.ui b/Catchment/resources/ui/main.ui index cbe0faa..c1494c3 100644 --- a/Catchment/resources/ui/main.ui +++ b/Catchment/resources/ui/main.ui @@ -112,13 +112,13 @@ 0 0 500 - 450 + 520 500 - 450 + 520 @@ -180,14 +180,24 @@ - + + + + Limit areas to polygon boundaries + + + + + + + Distance divisions - + 1 diff --git a/Catchment/test/conftest.py b/Catchment/test/conftest.py index 2872452..86be4b3 100644 --- a/Catchment/test/conftest.py +++ b/Catchment/test/conftest.py @@ -15,7 +15,9 @@ QgsField, QgsFields, QgsGeometry, + QgsLineString, QgsPointXY, + QgsPolygon, QgsVectorLayer, ) @@ -70,7 +72,29 @@ 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 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") @@ -84,14 +108,41 @@ def fields() -> None: @pytest.fixture(scope="function") def point_feature(fields, point) -> None: feature = QgsFeature(fields) - feature.setGeometry(QgsGeometry.fromPointXY(point)) + feature.setGeometry(point) feature.setAttribute("id", 1) feature.setAttribute("name", "school") yield feature @pytest.fixture(scope="function") -def vector_layer(fields, point_feature) -> None: +def square_feature(fields, square) -> None: + feature = QgsFeature(fields) + feature.setGeometry(square) + feature.setAttribute("id", 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("id", 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("id", 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 +153,55 @@ def vector_layer(fields, point_feature) -> None: @pytest.fixture(scope="function") -def isochrone_opts(vector_layer) -> None: +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, 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..ae81eaf 100644 --- a/Catchment/test/test_isochrone_creator.py +++ b/Catchment/test/test_isochrone_creator.py @@ -6,10 +6,26 @@ 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 @@ -17,6 +33,13 @@ def test_isochrone_layer_isochrone_created(isochrone_opts, mock_fetch): assert feature.attribute("original_fid") == 1 assert feature.attribute("name") == "school" assert feature.attribute("isochrone_distance") == 30 + if not boundary_layer: + assert feature.attribute("boundary_fids") == "" + else: + assert feature.attribute("boundary_fids") == ",".join([ + str(feature["id"]) for feature in boundary_layer.getFeatures() + ]) + assert len(feature.geometry().asMultiPolygon()) == part_count def test_isochrone_layer_empty(isochrone_opts, mock_fetch): diff --git a/Catchment/ui/catchment_area_panel.py b/Catchment/ui/catchment_area_panel.py index 1bbc6ee..a01da16 100644 --- a/Catchment/ui/catchment_area_panel.py +++ b/Catchment/ui/catchment_area_panel.py @@ -25,6 +25,8 @@ 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.__update_duration_label() # connect the signals, since pyqt slot decorator cannot be used @@ -36,9 +38,15 @@ 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.spinbox_distance.valueChanged.connect( self.on_spinbox_distance_valueChanged ) @@ -109,9 +117,15 @@ def on_radiobtn_driving_clicked(self) -> None: def on_combobox_layer_layerChanged(self) -> None: # noqa self.__update_duration_label() + 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_spinbox_distance_valueChanged(self) -> None: # noqa self.__update_duration_label() diff --git a/Catchment/ui/maindialog.py b/Catchment/ui/maindialog.py index 0ce7065..7323e10 100644 --- a/Catchment/ui/maindialog.py +++ b/Catchment/ui/maindialog.py @@ -67,6 +67,8 @@ 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.distance = self.spinbox_distance.value() opts.buckets = self.spinbox_buckets.value() From f4d504f0f7b31323e81874193b4634cb06eb9704 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 19 Nov 2021 15:17:09 +0200 Subject: [PATCH 04/49] Use pytest-qgis in tests --- Catchment/test/conftest.py | 13 ++----------- Catchment/test/test_plugin.py | 12 +++++++----- requirements-dev.txt | 1 + 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/Catchment/test/conftest.py b/Catchment/test/conftest.py index 86be4b3..95047c3 100644 --- a/Catchment/test/conftest.py +++ b/Catchment/test/conftest.py @@ -25,21 +25,12 @@ 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. @@ -210,8 +201,8 @@ def isochrone_opts(point_layer, request) -> 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/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/requirements-dev.txt b/requirements-dev.txt index 694e40d..e2d789b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,6 @@ pre-commit>=2.12.1 pytest~=6.2.3 pytest-mock~=3.6.1 +pytest-qgis~=1.0.2 pytest-qt~=3.3.0 qgis_plugin_ci~=1.8.4 From f3bae8bc67cffcf5892424c5793b1cb1eea131e2 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 19 Nov 2021 15:25:09 +0200 Subject: [PATCH 05/49] Move boundary layer selection under point layer selection --- Catchment/resources/ui/main.ui | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Catchment/resources/ui/main.ui b/Catchment/resources/ui/main.ui index c1494c3..5cb5261 100644 --- a/Catchment/resources/ui/main.ui +++ b/Catchment/resources/ui/main.ui @@ -154,14 +154,24 @@ - + + + + Limit areas to polygon boundaries + + + + + + + Distance - + 5 @@ -180,16 +190,6 @@ - - - - Limit areas to polygon boundaries - - - - - - From 357e517c1f0f517fe55f8cfa18eb6cee6f77d11f Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 19 Nov 2021 16:59:27 +0200 Subject: [PATCH 06/49] Update qgis_plugin_tools --- Catchment/qgis_plugin_tools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From ae72aea29e0729b65e5235e891138ec1cdd1d45e Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 19 Nov 2021 17:01:15 +0200 Subject: [PATCH 07/49] Fix tests to use default fid string --- Catchment/test/conftest.py | 10 +++++----- Catchment/test/test_isochrone_creator.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Catchment/test/conftest.py b/Catchment/test/conftest.py index 95047c3..5e290e7 100644 --- a/Catchment/test/conftest.py +++ b/Catchment/test/conftest.py @@ -91,7 +91,7 @@ def triangle() -> None: @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)) yield fields @@ -100,7 +100,7 @@ def fields() -> None: def point_feature(fields, point) -> None: feature = QgsFeature(fields) feature.setGeometry(point) - feature.setAttribute("id", 1) + feature.setAttribute("fid", 1) feature.setAttribute("name", "school") yield feature @@ -109,7 +109,7 @@ def point_feature(fields, point) -> None: def square_feature(fields, square) -> None: feature = QgsFeature(fields) feature.setGeometry(square) - feature.setAttribute("id", 1) + feature.setAttribute("fid", 1) feature.setAttribute("name", "square_school_area_boundary") yield feature @@ -118,7 +118,7 @@ def square_feature(fields, square) -> None: def multipolygon_feature(fields, multipolygon) -> None: feature = QgsFeature(fields) feature.setGeometry(multipolygon) - feature.setAttribute("id", 1) + feature.setAttribute("fid", 1) feature.setAttribute("name", "multipolygon_school_area_boundary") yield feature @@ -127,7 +127,7 @@ def multipolygon_feature(fields, multipolygon) -> None: def triangle_feature(fields, triangle) -> None: feature = QgsFeature(fields) feature.setGeometry(triangle) - feature.setAttribute("id", 1) + feature.setAttribute("fid", 1) feature.setAttribute("name", "triangular_school_area_boundary") yield feature diff --git a/Catchment/test/test_isochrone_creator.py b/Catchment/test/test_isochrone_creator.py index ae81eaf..cbc4265 100644 --- a/Catchment/test/test_isochrone_creator.py +++ b/Catchment/test/test_isochrone_creator.py @@ -37,7 +37,7 @@ def test_isochrone_layer_isochrone_created(isochrone_opts, mock_fetch, boundary_ assert feature.attribute("boundary_fids") == "" else: assert feature.attribute("boundary_fids") == ",".join([ - str(feature["id"]) for feature in boundary_layer.getFeatures() + str(feature["fid"]) for feature in boundary_layer.getFeatures() ]) assert len(feature.geometry().asMultiPolygon()) == part_count From 29e9cc8e04e29c833410dd534ca3652154d7e66b Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 19 Nov 2021 17:34:08 +0200 Subject: [PATCH 08/49] Add flake8-qgis --- Catchment/__init__.py | 2 +- Catchment/core/isochrone_creator.py | 4 ++-- Catchment/definitions/gui.py | 2 +- Catchment/plugin.py | 21 ++++++++++----------- Catchment/test/conftest.py | 4 ++-- Catchment/ui/about_panel.py | 2 +- Catchment/ui/base_panel.py | 2 +- Catchment/ui/catchment_area_panel.py | 6 ++++-- Catchment/ui/maindialog.py | 4 ++-- Catchment/ui/settings_panel.py | 2 +- requirements-dev.txt | 2 +- requirements-test.txt | 1 + 12 files changed, 27 insertions(+), 25 deletions(-) diff --git a/Catchment/__init__.py b/Catchment/__init__.py index 34f2e79..5593d2c 100644 --- a/Catchment/__init__.py +++ b/Catchment/__init__.py @@ -15,4 +15,4 @@ def classFactory(iface: QgisInterface): # noqa N802 from .plugin import Plugin - return Plugin(iface) + return Plugin() diff --git a/Catchment/core/isochrone_creator.py b/Catchment/core/isochrone_creator.py index 478a9cd..c68c526 100644 --- a/Catchment/core/isochrone_creator.py +++ b/Catchment/core/isochrone_creator.py @@ -5,8 +5,6 @@ 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, @@ -23,6 +21,8 @@ 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 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/plugin.py b/Catchment/plugin.py index 871b2d0..a3bb4c8 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 qgis.utils import iface from .core.isochrone_creator import IsochroneCreator from .qgis_plugin_tools.tools.custom_logging import ( @@ -20,9 +20,8 @@ class Plugin: """QGIS Plugin Implementation.""" - def __init__(self, iface: QgisInterface) -> None: + def __init__(self) -> None: - self.iface = iface # store the task here so it survives garbage collection after run method returns self.creator: Optional[IsochroneCreator] = None @@ -105,10 +104,10 @@ def add_action( if add_to_toolbar: # Adds plugin icon to Plugins toolbar - self.iface.addToolBarIcon(action) + iface.addToolBarIcon(action) if add_to_menu: - self.iface.addPluginToMenu(self.menu, action) + iface.addPluginToMenu(self.menu, action) self.actions.append(action) @@ -120,7 +119,7 @@ def initGui(self) -> None: # noqa N802 "", text=tr(plugin_name()), callback=self.run, - parent=self.iface.mainWindow(), + parent=iface.mainWindow(), ) def onClosePlugin(self) -> None: # noqa N802 @@ -130,8 +129,8 @@ def onClosePlugin(self) -> None: # noqa N802 def unload(self) -> None: """Removes the plugin menu item and icon from QGIS GUI.""" for action in self.actions: - self.iface.removePluginMenu(tr(plugin_name()), action) - self.iface.removeToolBarIcon(action) + iface.removePluginMenu(tr(plugin_name()), action) + iface.removeToolBarIcon(action) teardown_logger(plugin_name()) teardown_logger(f"{plugin_name()}_task") diff --git a/Catchment/test/conftest.py b/Catchment/test/conftest.py index 5e290e7..b5e27a7 100644 --- a/Catchment/test/conftest.py +++ b/Catchment/test/conftest.py @@ -201,8 +201,8 @@ def isochrone_opts(point_layer, request) -> None: @pytest.fixture(scope="function") -def new_plugin(qgis_iface, isochrone_opts) -> None: - plugin = Plugin(qgis_iface) +def new_plugin(isochrone_opts) -> None: + plugin = Plugin() plugin.initGui() # mock options, since mock QgisInterface does not support QgsMapLayerComboBox plugin.dlg.read_isochrone_options = lambda: isochrone_opts diff --git a/Catchment/ui/about_panel.py b/Catchment/ui/about_panel.py index 407338e..2e55d9e 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 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 a01da16..59434e2 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.PyQt.QtWidgets import QDialog from ..definitions.constants import Profile, Unit from ..definitions.gui import Panels @@ -124,7 +124,9 @@ 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()) + self.dlg.combobox_polygon_layer.setEnabled( + not self.dlg.combobox_polygon_layer.isEnabled() + ) def on_spinbox_distance_valueChanged(self) -> None: # noqa self.__update_duration_label() diff --git a/Catchment/ui/maindialog.py b/Catchment/ui/maindialog.py index 7323e10..4edc465 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 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/requirements-dev.txt b/requirements-dev.txt index e2d789b..77d4f97 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ +flake8-qgis~=1.0.0 pre-commit>=2.12.1 pytest~=6.2.3 pytest-mock~=3.6.1 -pytest-qgis~=1.0.2 pytest-qt~=3.3.0 qgis_plugin_ci~=1.8.4 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 From c0cd7aba7bfd32681a2f6888b4ebeed11c51e3d1 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 19 Nov 2021 17:52:29 +0200 Subject: [PATCH 09/49] Update pre-commit config --- .pre-commit-config.yaml | 16 ++++++++-------- Catchment/core/isochrone_creator.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e99cab6..7bfe624 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ # 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 - id: end-of-file-fixer @@ -13,18 +13,18 @@ repos: hooks: - id: isort - repo: https://github.com/psf/black - rev: 20.8b1 + rev: 21.5b1 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: 3.9.2 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/Catchment/core/isochrone_creator.py b/Catchment/core/isochrone_creator.py index c68c526..0deb360 100644 --- a/Catchment/core/isochrone_creator.py +++ b/Catchment/core/isochrone_creator.py @@ -19,7 +19,7 @@ QgsTask, QgsVectorFileWriter, QgsVectorLayer, - QgsWkbTypes + QgsWkbTypes, ) from qgis.PyQt.QtCore import QVariant from qgis.PyQt.QtNetwork import QNetworkReply From 1708dfed2e87e39bf1be67eab7677f0dccc0021e Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 19 Nov 2021 18:03:47 +0200 Subject: [PATCH 10/49] Fix black --- Catchment/core/isochrone_creator.py | 72 ++++++++++++++++-------- Catchment/test/conftest.py | 45 ++++++++++++--- Catchment/test/test_isochrone_creator.py | 27 +++++---- Catchment/ui/catchment_area_panel.py | 2 +- Catchment/ui/maindialog.py | 7 ++- 5 files changed, 108 insertions(+), 45 deletions(-) diff --git a/Catchment/core/isochrone_creator.py b/Catchment/core/isochrone_creator.py index 0deb360..a188d79 100644 --- a/Catchment/core/isochrone_creator.py +++ b/Catchment/core/isochrone_creator.py @@ -109,8 +109,10 @@ def __init__(self, opts: IsochroneOpts) -> None: 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.") + ( + f"Limit polygon layer in {polygon_layer.crs().authid()}," + f" reprojecting to WGS 84 first." + ) ) alg_params = { "INPUT": polygon_layer, @@ -118,7 +120,8 @@ def __init__(self, opts: IsochroneOpts) -> None: "TARGET_CRS": wgs84, } polygon_layer = qgis.processing.run( - "native:reprojectlayer", alg_params)["OUTPUT"] + "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? @@ -147,32 +150,41 @@ def __init__(self, opts: IsochroneOpts) -> None: # we only save the ids in the end boundary_polygon = QgsFeature(polygon) boundary_polygon.setFields(fields) - boundary_polygon['fids'] = str(polygon['fid']) + boundary_polygon["fids"] = str(polygon["fid"]) else: - intersection_parts = boundary_polygon.geometry().\ - intersection(polygon.geometry()).\ - asGeometryCollection() + intersection_parts = ( + boundary_polygon.geometry() + .intersection(polygon.geometry()) + .asGeometryCollection() + ) intersection_geometry = QgsGeometry.fromMultiPolygonXY( - [geometry.asPolygon() + [ + geometry.asPolygon() for geometry in intersection_parts - if geometry.wkbType() == QgsWkbTypes.Polygon] + if geometry.wkbType() == QgsWkbTypes.Polygon + ] ) boundary_polygon.setGeometry(intersection_geometry) - boundary_polygon['fids'] += f",{polygon['fid']}" + 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] + 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 "" - limited_string = f" limited by {self.opts.polygon_layer.name()}" if \ - self.opts.polygon_layer else "" - self.name = (f"{self.opts.distance} {self.opts.unit.value} {direction_string}" - f" {selected_string}{self.opts.layer.name()}{profile_string}{limited_string}") # type: ignore # noqa + limited_string = ( + f" limited by {self.opts.polygon_layer.name()}" + if self.opts.polygon_layer + else "" + ) + self.name = ( + f"{self.opts.distance} {self.opts.unit.value} {direction_string}" + f" {selected_string}{self.opts.layer.name()}{profile_string}{limited_string}" + ) # type: ignore # noqa super().__init__(description=f"Fetching GraphHopper isochrones: {self.name}") self.setProgress(0.0) @@ -275,7 +287,8 @@ 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, boundary) in enumerate( - zip(self.points, self.limiting_polygons)): + zip(self.points, self.limiting_polygons) + ): bucketed_isochrones = self.__fetch_bucketed_isochrones(point) for polygon_in_bucket in bucketed_isochrones: feature = QgsFeature(layer.fields()) @@ -291,23 +304,34 @@ def __add_isochrones_to_layer(self, layer: QgsVectorLayer) -> None: feature["isochrone_distance"] = distance isochrone = QgsGeometry.fromMultiPolygonXY( - [[[QgsPointXY(pt[0], pt[1]) - for pt in polygon_in_bucket["geometry"]["coordinates"][0] - ]]] - ) + [ + [ + [ + QgsPointXY(pt[0], pt[1]) + for pt in polygon_in_bucket["geometry"]["coordinates"][ + 0 + ] + ] + ] + ] + ) if boundary: feature["boundary_fids"] = boundary["fids"] - isochrone_parts = boundary.geometry().\ - intersection(isochrone).asGeometryCollection() + 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() + [ + geometry.asPolygon() for geometry in isochrone_parts if geometry.wkbType() == QgsWkbTypes.Polygon - ] + ] ) else: feature["boundary_fids"] = "" diff --git a/Catchment/test/conftest.py b/Catchment/test/conftest.py index b5e27a7..931b909 100644 --- a/Catchment/test/conftest.py +++ b/Catchment/test/conftest.py @@ -69,15 +69,38 @@ def point() -> None: @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)]] + [ + [ + 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)]]] + [ + [ + [ + 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), + ] + ], + ] ) @@ -145,7 +168,9 @@ def point_layer(fields, point_feature) -> None: @pytest.fixture(scope="function") def square_layer(fields, square_feature) -> None: - layer = QgsVectorLayer("Polygon?crs=epsg:4326&index=yes", "test_boundaries", "memory") + layer = QgsVectorLayer( + "Polygon?crs=epsg:4326&index=yes", "test_boundaries", "memory" + ) provider = layer.dataProvider() provider.addAttributes(fields) layer.updateFields() @@ -156,7 +181,9 @@ def square_layer(fields, square_feature) -> None: @pytest.fixture(scope="function") def multipolygon_layer(fields, multipolygon_feature) -> None: - layer = QgsVectorLayer("Polygon?crs=epsg:4326&index=yes", "test_boundaries", "memory") + layer = QgsVectorLayer( + "Polygon?crs=epsg:4326&index=yes", "test_boundaries", "memory" + ) provider = layer.dataProvider() provider.addAttributes(fields) layer.updateFields() @@ -167,7 +194,9 @@ def multipolygon_layer(fields, multipolygon_feature) -> None: @pytest.fixture(scope="function") def triangle_layer(fields, triangle_feature) -> None: - layer = QgsVectorLayer("Polygon?crs=epsg:4326&index=yes", "test_boundaries", "memory") + layer = QgsVectorLayer( + "Polygon?crs=epsg:4326&index=yes", "test_boundaries", "memory" + ) provider = layer.dataProvider() provider.addAttributes(fields) layer.updateFields() @@ -178,7 +207,9 @@ def triangle_layer(fields, triangle_feature) -> None: @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") + layer = QgsVectorLayer( + "Polygon?crs=epsg:4326&index=yes", "test_boundaries", "memory" + ) provider = layer.dataProvider() provider.addAttributes(fields) layer.updateFields() diff --git a/Catchment/test/test_isochrone_creator.py b/Catchment/test/test_isochrone_creator.py index cbc4265..418d821 100644 --- a/Catchment/test/test_isochrone_creator.py +++ b/Catchment/test/test_isochrone_creator.py @@ -12,14 +12,19 @@ # 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): +@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") @@ -36,9 +41,9 @@ def test_isochrone_layer_isochrone_created(isochrone_opts, mock_fetch, boundary_ 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 feature.attribute("boundary_fids") == ",".join( + [str(feature["fid"]) for feature in boundary_layer.getFeatures()] + ) assert len(feature.geometry().asMultiPolygon()) == part_count diff --git a/Catchment/ui/catchment_area_panel.py b/Catchment/ui/catchment_area_panel.py index 59434e2..f17cb03 100644 --- a/Catchment/ui/catchment_area_panel.py +++ b/Catchment/ui/catchment_area_panel.py @@ -126,7 +126,7 @@ def on_checkbox_selected_only_clicked(self) -> None: def on_checkbox_limit_to_polygon_clicked(self) -> None: self.dlg.combobox_polygon_layer.setEnabled( not self.dlg.combobox_polygon_layer.isEnabled() - ) + ) def on_spinbox_distance_valueChanged(self) -> None: # noqa self.__update_duration_label() diff --git a/Catchment/ui/maindialog.py b/Catchment/ui/maindialog.py index 4edc465..28ee9e8 100644 --- a/Catchment/ui/maindialog.py +++ b/Catchment/ui/maindialog.py @@ -67,8 +67,11 @@ 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.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.distance = self.spinbox_distance.value() opts.buckets = self.spinbox_buckets.value() From d8feea56a046decfbf08a0449b5bcbd7bd04fb74 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 19 Nov 2021 18:11:06 +0200 Subject: [PATCH 11/49] Update workflows --- .github/workflows/release.yml | 1 + .github/workflows/test-and-pre-release.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c90971e..82c3a04 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,7 @@ jobs: - name: Install qgis-plugin-ci run: pip3 install qgis-plugin-ci + # 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 diff --git a/.github/workflows/test-and-pre-release.yml b/.github/workflows/test-and-pre-release.yml index 314e851..3d840f4 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, release-3_22, latest] fail-fast: false # Steps represent a sequence of tasks that will be executed as part of the job From cee7fe410204a58320ef467b7218c7315d96a78f Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 19 Nov 2021 18:15:13 +0200 Subject: [PATCH 12/49] Fix flake8 that black broke --- Catchment/core/isochrone_creator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Catchment/core/isochrone_creator.py b/Catchment/core/isochrone_creator.py index a188d79..fe0facd 100644 --- a/Catchment/core/isochrone_creator.py +++ b/Catchment/core/isochrone_creator.py @@ -183,8 +183,8 @@ def __init__(self, opts: IsochroneOpts) -> None: ) self.name = ( f"{self.opts.distance} {self.opts.unit.value} {direction_string}" - f" {selected_string}{self.opts.layer.name()}{profile_string}{limited_string}" - ) # type: ignore # noqa + f" {selected_string}{self.opts.layer.name()}{profile_string}{limited_string}" # type: ignore # noqa + ) super().__init__(description=f"Fetching GraphHopper isochrones: {self.name}") self.setProgress(0.0) From 235e517adca0e8c457265207ad3d5e9b717fead7 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Tue, 23 Nov 2021 17:42:02 +0200 Subject: [PATCH 13/49] Revert removing iface from plugin call to fix 3.16 tests --- Catchment/__init__.py | 2 +- Catchment/plugin.py | 15 ++++++++------- Catchment/test/conftest.py | 4 ++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Catchment/__init__.py b/Catchment/__init__.py index 5593d2c..34f2e79 100644 --- a/Catchment/__init__.py +++ b/Catchment/__init__.py @@ -15,4 +15,4 @@ def classFactory(iface: QgisInterface): # noqa N802 from .plugin import Plugin - return Plugin() + return Plugin(iface) diff --git a/Catchment/plugin.py b/Catchment/plugin.py index a3bb4c8..2fe1eee 100644 --- a/Catchment/plugin.py +++ b/Catchment/plugin.py @@ -3,7 +3,7 @@ from qgis.PyQt.QtCore import QCoreApplication, QTranslator from qgis.PyQt.QtGui import QIcon from qgis.PyQt.QtWidgets import QAction, QWidget -from qgis.utils import iface +from qgis.gui import QgisInterface from .core.isochrone_creator import IsochroneCreator from .qgis_plugin_tools.tools.custom_logging import ( @@ -20,8 +20,9 @@ class Plugin: """QGIS Plugin Implementation.""" - def __init__(self) -> None: + def __init__(self, iface: QgisInterface) -> None: # noqa + self.iface = iface # store the task here so it survives garbage collection after run method returns self.creator: Optional[IsochroneCreator] = None @@ -104,10 +105,10 @@ def add_action( if add_to_toolbar: # Adds plugin icon to Plugins toolbar - iface.addToolBarIcon(action) + self.iface.addToolBarIcon(action) if add_to_menu: - iface.addPluginToMenu(self.menu, action) + self.iface.addPluginToMenu(self.menu, action) self.actions.append(action) @@ -119,7 +120,7 @@ def initGui(self) -> None: # noqa N802 "", text=tr(plugin_name()), callback=self.run, - parent=iface.mainWindow(), + parent=self.iface.mainWindow(), ) def onClosePlugin(self) -> None: # noqa N802 @@ -129,8 +130,8 @@ def onClosePlugin(self) -> None: # noqa N802 def unload(self) -> None: """Removes the plugin menu item and icon from QGIS GUI.""" for action in self.actions: - iface.removePluginMenu(tr(plugin_name()), action) - iface.removeToolBarIcon(action) + self.iface.removePluginMenu(tr(plugin_name()), action) + self.iface.removeToolBarIcon(action) teardown_logger(plugin_name()) teardown_logger(f"{plugin_name()}_task") diff --git a/Catchment/test/conftest.py b/Catchment/test/conftest.py index 931b909..53a303e 100644 --- a/Catchment/test/conftest.py +++ b/Catchment/test/conftest.py @@ -232,8 +232,8 @@ def isochrone_opts(point_layer, request) -> None: @pytest.fixture(scope="function") -def new_plugin(isochrone_opts) -> None: - plugin = Plugin() +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 From c6b30dde16144ad19184ce427cfbaab5718432da Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Tue, 23 Nov 2021 17:43:53 +0200 Subject: [PATCH 14/49] Fix isort --- Catchment/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Catchment/plugin.py b/Catchment/plugin.py index 2fe1eee..efe2ef1 100644 --- a/Catchment/plugin.py +++ b/Catchment/plugin.py @@ -1,9 +1,9 @@ from typing import Callable, List, Optional +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 qgis.gui import QgisInterface from .core.isochrone_creator import IsochroneCreator from .qgis_plugin_tools.tools.custom_logging import ( From f74f829d866a88d330efb2611ae9b8851d8ef90d Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Tue, 23 Nov 2021 18:45:00 +0200 Subject: [PATCH 15/49] Only run our own tests in CI --- .github/workflows/test-and-pre-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-and-pre-release.yml b/.github/workflows/test-and-pre-release.yml index 3d840f4..bd59462 100644 --- a/.github/workflows/test-and-pre-release.yml +++ b/.github/workflows/test-and-pre-release.yml @@ -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 @@ -62,7 +62,7 @@ jobs: $env:PATH="C:\Program Files\QGIS 3.16\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" From e10e0db43520f166ae6ccca2a677178b191751b5 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Wed, 24 Nov 2021 12:12:37 +0200 Subject: [PATCH 16/49] Try fixing qgis-plugin-ci repo address --- .github/workflows/test-and-pre-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-and-pre-release.yml b/.github/workflows/test-and-pre-release.yml index bd59462..667f808 100644 --- a/.github/workflows/test-and-pre-release.yml +++ b/.github/workflows/test-and-pre-release.yml @@ -107,9 +107,9 @@ jobs: # When Transifex is wanted: --transifex-token ${{ secrets.TRANSIFEX_TOKEN }} - name: Deploy plugin if: ${{ github.event.pull_request }} - run: qgis-plugin-ci release dev-pr --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update + run: qgis-plugin-ci release dev-pr --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update --plugin-repo-url $GITHUB_SERVER_URL/$GITHUB_REPOSITORY # When Transifex is wanted: --transifex-token ${{ secrets.TRANSIFEX_TOKEN }} - name: Deploy plugin if: ${{ github.event.after != github.event.before }} - run: qgis-plugin-ci release dev --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update + run: qgis-plugin-ci release dev --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update --plugin-repo-url $GITHUB_SERVER_URL/$GITHUB_REPOSITORY From 71503ad30e671fffc6cb2fe58a5907d6d897c044 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Wed, 24 Nov 2021 12:50:56 +0200 Subject: [PATCH 17/49] Failed fixing qgis-plugin-ci repo address --- .github/workflows/test-and-pre-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-and-pre-release.yml b/.github/workflows/test-and-pre-release.yml index 667f808..bd59462 100644 --- a/.github/workflows/test-and-pre-release.yml +++ b/.github/workflows/test-and-pre-release.yml @@ -107,9 +107,9 @@ jobs: # When Transifex is wanted: --transifex-token ${{ secrets.TRANSIFEX_TOKEN }} - name: Deploy plugin if: ${{ github.event.pull_request }} - run: qgis-plugin-ci release dev-pr --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update --plugin-repo-url $GITHUB_SERVER_URL/$GITHUB_REPOSITORY + run: qgis-plugin-ci release dev-pr --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update # When Transifex is wanted: --transifex-token ${{ secrets.TRANSIFEX_TOKEN }} - name: Deploy plugin if: ${{ github.event.after != github.event.before }} - run: qgis-plugin-ci release dev --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update --plugin-repo-url $GITHUB_SERVER_URL/$GITHUB_REPOSITORY + run: qgis-plugin-ci release dev --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update From 59cbabcf87a93ea8fa32c8c2224ae9806b0c41c6 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Wed, 24 Nov 2021 14:37:31 +0200 Subject: [PATCH 18/49] Fix project_slug in qgis-plugin-ci --- .qgis-plugin-ci | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 3d4983df529e4cf4ad75e6209b8e081e1d384f06 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Wed, 24 Nov 2021 15:32:12 +0200 Subject: [PATCH 19/49] Update version to 0.2.0 --- CHANGELOG.md | 13 ++++++++++++- Catchment/metadata.txt | 18 +++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e305f95..f88acc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # CHANGELOG +## [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/metadata.txt b/Catchment/metadata.txt index e40d5d0..f5544fc 100644 --- a/Catchment/metadata.txt +++ b/Catchment/metadata.txt @@ -1,16 +1,20 @@ [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.2.0 +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 From 49f3e6d4af06549c4099ddcf583f9369f289585f Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Wed, 24 Nov 2021 15:42:56 +0200 Subject: [PATCH 20/49] Exclude metadata.txt from pre-commit --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7bfe624..fbde601 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,6 +5,7 @@ repos: 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 From b0f2844776cf7aed90c5f9bee059d2440fb7a3e6 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Wed, 24 Nov 2021 15:58:56 +0200 Subject: [PATCH 21/49] Remove extra space from workflow --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82c3a04..5a5851c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,4 +28,4 @@ jobs: # 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 ${GITHUB_REF/refs\/tags\//} --osgeo-username ${{ secrets.OSGEO_USER }} --osgeo-password ${{ secrets.OSGEO_PASSWORD }} --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update From 3b3b6986a7522a016ca97bc9eaa48309409e216e Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Wed, 24 Nov 2021 16:00:24 +0200 Subject: [PATCH 22/49] Remove another extra space from workflow --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a5851c..2bd92f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,4 +28,4 @@ jobs: # 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 ${GITHUB_REF/refs\/tags\//} --osgeo-username ${{ secrets.OSGEO_USER }} --osgeo-password ${{ secrets.OSGEO_PASSWORD }} --github-token ${{ secrets.GITHUB_TOKEN }} --disable-submodule-update From 35735703696c0bf64763b7c583394babf0036b72 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Wed, 24 Nov 2021 16:04:29 +0200 Subject: [PATCH 23/49] Update CHANGELOG.md --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f88acc0..8d0600d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## [0.2.1] - 2021-11-24 + +### Changed + +- Fixes to release workflow + ## [0.2.0] - 2021-11-24 ### Added From 23f5ceaf9f2fe39ca0c2b46dbe3ddabd4bf6b09f Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Wed, 24 Nov 2021 16:05:19 +0200 Subject: [PATCH 24/49] Update version to 0.2.1 --- Catchment/metadata.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Catchment/metadata.txt b/Catchment/metadata.txt index f5544fc..3d15223 100644 --- a/Catchment/metadata.txt +++ b/Catchment/metadata.txt @@ -1,7 +1,7 @@ [general] name=Catchment description=Calculate point catchment areas (isochrones) using Graphhopper on OpenStreetMap roads, e.g. for educational planning. -version=0.2.0 +version=0.2.1 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). From 5f13224dd818dcf2a178100267f2b8b1254ab6cd Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Thu, 25 Nov 2021 19:01:13 +0200 Subject: [PATCH 25/49] Add feature to merge isochrones by field value --- CHANGELOG.md | 11 +++ Catchment/core/isochrone_creator.py | 79 ++++++++++++++++++++- Catchment/metadata.txt | 2 +- Catchment/resources/ui/main.ui | 69 ++++++++++++------- Catchment/test/conftest.py | 87 ++++++++++++++++++++---- Catchment/test/test_isochrone_creator.py | 31 ++++++++- Catchment/ui/catchment_area_panel.py | 15 ++++ Catchment/ui/maindialog.py | 5 ++ 8 files changed, 256 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f88acc0..c294d6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # CHANGELOG +## [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.0] - 2021-11-24 ### Added diff --git a/Catchment/core/isochrone_creator.py b/Catchment/core/isochrone_creator.py index fe0facd..f406ecc 100644 --- a/Catchment/core/isochrone_creator.py +++ b/Catchment/core/isochrone_creator.py @@ -2,6 +2,8 @@ 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 @@ -42,6 +44,7 @@ class IsochroneOpts: layer: Optional[QgsVectorLayer] = None polygon_layer: Optional[QgsVectorLayer] = None selected_only: bool = False + merge_by_field: Optional[QgsField] = None distance: Optional[int] = None unit: Optional[Unit] = None buckets: int = 1 @@ -349,6 +352,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.asPolygon(): + 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( @@ -356,9 +408,28 @@ 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" ) @@ -372,6 +443,8 @@ def create_isochrone_layer(self) -> QgsVectorLayer: 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/metadata.txt b/Catchment/metadata.txt index f5544fc..00cfacd 100644 --- a/Catchment/metadata.txt +++ b/Catchment/metadata.txt @@ -1,7 +1,7 @@ [general] name=Catchment description=Calculate point catchment areas (isochrones) using Graphhopper on OpenStreetMap roads, e.g. for educational planning. -version=0.2.0 +version=0.3.0 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). diff --git a/Catchment/resources/ui/main.ui b/Catchment/resources/ui/main.ui index 5cb5261..fdf4562 100644 --- a/Catchment/resources/ui/main.ui +++ b/Catchment/resources/ui/main.ui @@ -112,13 +112,13 @@ 0 0 500 - 520 + 550 500 - 520 + 550 @@ -147,31 +147,14 @@ - - - - Use only selected features - - - - - - - Limit areas to polygon boundaries - - - - - - - + Distance - + 5 @@ -190,14 +173,14 @@ - + Distance divisions - + 1 @@ -281,6 +264,46 @@ + + + + Extra options + + + + + + + + Use only selected points + + + + + + + Limit areas to polygon boundaries + + + + + + + + + + Merge areas with same value in field + + + + + + + + + + + diff --git a/Catchment/test/conftest.py b/Catchment/test/conftest.py index 53a303e..4afb31d 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 @@ -33,27 +34,54 @@ @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) @@ -66,6 +94,11 @@ def point() -> None: 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( @@ -116,6 +149,7 @@ def fields() -> None: fields = QgsFields() fields.append(QgsField("fid", QVariant.Int)) fields.append(QgsField("name", QVariant.String)) + fields.append(QgsField("extra_info", QVariant.String)) yield fields @@ -125,6 +159,17 @@ def point_feature(fields, point) -> None: feature.setGeometry(point) feature.setAttribute("fid", 1) feature.setAttribute("name", "school") + feature.setAttribute("extra_info", "first_feature") + yield feature + + +@pytest.fixture(scope="function") +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") yield feature @@ -166,6 +211,18 @@ def point_layer(fields, point_feature) -> None: yield layer +@pytest.fixture(scope="function") +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( diff --git a/Catchment/test/test_isochrone_creator.py b/Catchment/test/test_isochrone_creator.py index 418d821..65983d3 100644 --- a/Catchment/test/test_isochrone_creator.py +++ b/Catchment/test/test_isochrone_creator.py @@ -35,8 +35,9 @@ def test_isochrone_layer_isochrone_created( 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") == "" @@ -47,6 +48,34 @@ def test_isochrone_layer_isochrone_created( 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()["name"] + ] + 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("name") == "school" + 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): mock_fetch(isochrone_opts.url + "/isochrone", "error.json", error=True) assert isochrone_opts.layer.featureCount() == 1 diff --git a/Catchment/ui/catchment_area_panel.py b/Catchment/ui/catchment_area_panel.py index f17cb03..16d8f91 100644 --- a/Catchment/ui/catchment_area_panel.py +++ b/Catchment/ui/catchment_area_panel.py @@ -27,7 +27,9 @@ 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.__update_duration_label() + self.__update_field_selector() # connect the signals, since pyqt slot decorator cannot be used self.dlg.radiobtn_mins.clicked.connect(self.on_radiobtn_mins_clicked) @@ -47,6 +49,9 @@ def setup_panel(self) -> None: 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.spinbox_distance.valueChanged.connect( self.on_spinbox_distance_valueChanged ) @@ -116,6 +121,7 @@ def on_radiobtn_driving_clicked(self) -> None: def on_combobox_layer_layerChanged(self) -> None: # noqa self.__update_duration_label() + self.__update_field_selector() def on_combobox_polygon_layer_layerChanged(self) -> None: # noqa pass @@ -128,6 +134,11 @@ def on_checkbox_limit_to_polygon_clicked(self) -> None: 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_spinbox_distance_valueChanged(self) -> None: # noqa self.__update_duration_label() @@ -175,3 +186,7 @@ def __update_duration_label(self) -> None: "settings may take several hours or days." ) self.dlg.duration_label.setStyleSheet("color: red") + + def __update_field_selector(self) -> None: + opts = self.dlg.read_isochrone_options() + self.dlg.combobox_layer_field.setLayer(opts.layer) diff --git a/Catchment/ui/maindialog.py b/Catchment/ui/maindialog.py index 28ee9e8..d7e77f6 100644 --- a/Catchment/ui/maindialog.py +++ b/Catchment/ui/maindialog.py @@ -73,6 +73,11 @@ def read_isochrone_options(self) -> IsochroneOpts: else None ) opts.selected_only = self.checkbox_selected_only.isChecked() + opts.merge_by_field = ( + self.combobox_layer_field.currentField() + if self.checkbox_combine_by_field.isChecked() + else None + ) opts.distance = self.spinbox_distance.value() opts.buckets = self.spinbox_buckets.value() From 418dddcbe77071fa48d02df400e5f12832354832 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 26 Nov 2021 10:39:24 +0200 Subject: [PATCH 26/49] Add missing test fixture --- .../test/fixtures/another_isochrone.json | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 Catchment/test/fixtures/another_isochrone.json 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 + } +} From 797cfd127ff8f034e73c9b7e8ed2318b77010641 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 26 Nov 2021 11:45:17 +0200 Subject: [PATCH 27/49] Fix bug that returns field name instead of field --- Catchment/core/isochrone_creator.py | 7 ++++++- Catchment/ui/maindialog.py | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Catchment/core/isochrone_creator.py b/Catchment/core/isochrone_creator.py index f406ecc..c12eb5a 100644 --- a/Catchment/core/isochrone_creator.py +++ b/Catchment/core/isochrone_creator.py @@ -184,9 +184,14 @@ def __init__(self, opts: IsochroneOpts) -> None: if self.opts.polygon_layer else "" ) + merged_string = ( + f" combined by {self.opts.merge_by_field.name()}" + if self.opts.merge_by_field + else "" + ) self.name = ( f"{self.opts.distance} {self.opts.unit.value} {direction_string}" - f" {selected_string}{self.opts.layer.name()}{profile_string}{limited_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}") diff --git a/Catchment/ui/maindialog.py b/Catchment/ui/maindialog.py index d7e77f6..3d9b33a 100644 --- a/Catchment/ui/maindialog.py +++ b/Catchment/ui/maindialog.py @@ -74,7 +74,13 @@ def read_isochrone_options(self) -> IsochroneOpts: ) opts.selected_only = self.checkbox_selected_only.isChecked() opts.merge_by_field = ( - self.combobox_layer_field.currentField() + # 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 ) From 5d9a4f48e2b8f84cb078f4710bfc8e3400f0cbb9 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 26 Nov 2021 14:40:47 +0200 Subject: [PATCH 28/49] Always report exceptions to the user --- Catchment/core/isochrone_creator.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/Catchment/core/isochrone_creator.py b/Catchment/core/isochrone_creator.py index c12eb5a..4d00ed7 100644 --- a/Catchment/core/isochrone_creator.py +++ b/Catchment/core/isochrone_creator.py @@ -63,6 +63,7 @@ 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: list[QgsFeature] = [] self.limiting_polygons: list[QgsFeature] = [] @@ -209,11 +210,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( @@ -234,13 +235,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", From 2a1cb7637ab212c9704ac193d27ca5c7d55e8cd9 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 26 Nov 2021 14:52:34 +0200 Subject: [PATCH 29/49] Fix windows tests --- Catchment/resources/ui/main.ui | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Catchment/resources/ui/main.ui b/Catchment/resources/ui/main.ui index fdf4562..dc05ed7 100644 --- a/Catchment/resources/ui/main.ui +++ b/Catchment/resources/ui/main.ui @@ -665,6 +665,11 @@ QComboBox
qgsmaplayercombobox.h
+ + QgsFieldComboBox + QComboBox +
qgsfieldcombobox.h
+
QgsSpinBox QSpinBox From 1c3df059a144c64e019d3d741187ae3d3d139f79 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 26 Nov 2021 17:02:03 +0200 Subject: [PATCH 30/49] Use our qgis-plugin-ci to get changelog for now --- .github/workflows/release.yml | 4 ++-- .github/workflows/test-and-pre-release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bd92f8..c5ca433 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,9 +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 bd59462..38ba4f1 100644 --- a/.github/workflows/test-and-pre-release.yml +++ b/.github/workflows/test-and-pre-release.yml @@ -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 From 300cfc2ccdae9cac3735bb02cd01600da75d85f2 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Tue, 30 Nov 2021 17:57:34 +0200 Subject: [PATCH 31/49] Fix bugs when merging isochrones --- CHANGELOG.md | 14 ++++++++++---- Catchment/core/isochrone_creator.py | 13 ++++++++++--- Catchment/metadata.txt | 2 +- Catchment/test/conftest.py | 6 ++++++ Catchment/test/test_isochrone_creator.py | 4 ++-- 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eca3a9..3d25abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # CHANGELOG -## [0.3.0] - 2021-11-25 +## 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 @@ -11,13 +17,13 @@ - 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 +## 0.2.1 - 2021-11-24 ### Changed - Fixes to release workflow -## [0.2.0] - 2021-11-24 +## 0.2.0 - 2021-11-24 ### Added @@ -28,6 +34,6 @@ - Updated test and development dependencies - Isochrones are now multipolygons (due to boundary intersections) instead of simple polygons -## [0.1.0] - 2021-06-11 +## 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 4d00ed7..d212cf0 100644 --- a/Catchment/core/isochrone_creator.py +++ b/Catchment/core/isochrone_creator.py @@ -307,9 +307,16 @@ def __add_isochrones_to_layer(self, layer: QgsVectorLayer) -> None: 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"] @@ -389,7 +396,7 @@ def __merge_isochrones_in_layer(self, layer: QgsVectorLayer) -> None: else: merged_geometry = merged_geometry.combine(feature.geometry()) # now, the result may be polygon *or* multipolygon - if merged_geometry.asPolygon(): + if merged_geometry.wkbType() == QgsWkbTypes.Polygon: merged_geometry = QgsGeometry.fromMultiPolygonXY( [merged_geometry.asPolygon()] ) diff --git a/Catchment/metadata.txt b/Catchment/metadata.txt index 00cfacd..ab5dd04 100644 --- a/Catchment/metadata.txt +++ b/Catchment/metadata.txt @@ -1,7 +1,7 @@ [general] name=Catchment description=Calculate point catchment areas (isochrones) using Graphhopper on OpenStreetMap roads, e.g. for educational planning. -version=0.3.0 +version=0.3.1 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). diff --git a/Catchment/test/conftest.py b/Catchment/test/conftest.py index 4afb31d..1c294c8 100644 --- a/Catchment/test/conftest.py +++ b/Catchment/test/conftest.py @@ -150,6 +150,8 @@ def fields() -> None: 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 @@ -160,6 +162,8 @@ def point_feature(fields, point) -> None: 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 @@ -170,6 +174,8 @@ def another_point_feature(fields, another_point) -> None: 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 diff --git a/Catchment/test/test_isochrone_creator.py b/Catchment/test/test_isochrone_creator.py index 65983d3..439077c 100644 --- a/Catchment/test/test_isochrone_creator.py +++ b/Catchment/test/test_isochrone_creator.py @@ -61,14 +61,14 @@ def test_isochrone_layer_isochrones_merged( ) isochrone_opts.layer = two_point_layer isochrone_opts.merge_by_field = two_point_layer.fields()[ - two_point_layer.dataProvider().fieldNameMap()["name"] + 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("name") == "school" + 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 From b213c7f77888088fbd36ea738cdba0cba9840da6 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Wed, 8 Dec 2021 11:13:55 +0200 Subject: [PATCH 32/49] Add walking distance feature --- CHANGELOG.md | 5 +++ Catchment/core/isochrone_creator.py | 46 ++++++++++++++++++++++++++-- Catchment/metadata.txt | 2 +- Catchment/resources/ui/main.ui | 16 ++++++++-- Catchment/ui/catchment_area_panel.py | 25 ++++++++++----- Catchment/ui/maindialog.py | 11 +++++++ 6 files changed, 91 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d25abc..c3aa7bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ # CHANGELOG +## 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 diff --git a/Catchment/core/isochrone_creator.py b/Catchment/core/isochrone_creator.py index d212cf0..43367ed 100644 --- a/Catchment/core/isochrone_creator.py +++ b/Catchment/core/isochrone_creator.py @@ -45,6 +45,7 @@ class IsochroneOpts: 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 @@ -190,8 +191,11 @@ def __init__(self, opts: IsochroneOpts) -> None: 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} {direction_string}" + 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 ) @@ -267,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: diff --git a/Catchment/metadata.txt b/Catchment/metadata.txt index ab5dd04..8e3718b 100644 --- a/Catchment/metadata.txt +++ b/Catchment/metadata.txt @@ -1,7 +1,7 @@ [general] name=Catchment description=Calculate point catchment areas (isochrones) using Graphhopper on OpenStreetMap roads, e.g. for educational planning. -version=0.3.1 +version=0.4.0 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). diff --git a/Catchment/resources/ui/main.ui b/Catchment/resources/ui/main.ui index dc05ed7..37d6153 100644 --- a/Catchment/resources/ui/main.ui +++ b/Catchment/resources/ui/main.ui @@ -112,13 +112,13 @@ 0 0 500 - 550 + 580 500 - 550 + 580 @@ -186,7 +186,7 @@ 1 - 8 + 16 1 @@ -299,6 +299,16 @@ + + + + Add indoor walking distances + + + + + + diff --git a/Catchment/ui/catchment_area_panel.py b/Catchment/ui/catchment_area_panel.py index 16d8f91..19ea427 100644 --- a/Catchment/ui/catchment_area_panel.py +++ b/Catchment/ui/catchment_area_panel.py @@ -2,7 +2,7 @@ from math import floor, pow from typing import Optional -from qgis.core import QgsMapLayerProxyModel +from qgis.core import QgsFieldProxyModel, QgsMapLayerProxyModel from qgis.PyQt.QtWidgets import QDialog from ..definitions.constants import Profile, Unit @@ -28,8 +28,10 @@ def setup_panel(self) -> None: 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_selector() + 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) @@ -52,6 +54,9 @@ def setup_panel(self) -> None: 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 ) @@ -80,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 @@ -121,7 +126,7 @@ def on_radiobtn_driving_clicked(self) -> None: def on_combobox_layer_layerChanged(self) -> None: # noqa self.__update_duration_label() - self.__update_field_selector() + self.__update_field_selectors() def on_combobox_polygon_layer_layerChanged(self) -> None: # noqa pass @@ -139,6 +144,11 @@ def on_checkbox_combine_by_field_clicked(self) -> None: 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() @@ -154,8 +164,8 @@ def __update_unit_selector(self, selected_unit: Unit) -> None: max_ = 120 default = 30 elif selected_unit == Unit.METERS: - step = 500 - min_ = 500 + step = 200 + min_ = 200 max_ = 10000 default = 2000 @@ -187,6 +197,7 @@ def __update_duration_label(self) -> None: ) self.dlg.duration_label.setStyleSheet("color: red") - def __update_field_selector(self) -> None: + 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 3d9b33a..7a18eec 100644 --- a/Catchment/ui/maindialog.py +++ b/Catchment/ui/maindialog.py @@ -84,6 +84,17 @@ def read_isochrone_options(self) -> IsochroneOpts: 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() From b218dff050d242a972baf7785ddcac68b5581756 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 17 Dec 2021 11:52:54 +0200 Subject: [PATCH 33/49] Decrease increments; make about links clickable --- CHANGELOG.md | 7 +++++++ Catchment/resources/ui/main.ui | 23 +++++++++++++++++++---- Catchment/ui/catchment_area_panel.py | 8 ++++---- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3aa7bf..4551c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ # CHANGELOG +## 0.4.1 - 2021-12-08 + +### 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 diff --git a/Catchment/resources/ui/main.ui b/Catchment/resources/ui/main.ui index 37d6153..333b6f3 100644 --- a/Catchment/resources/ui/main.ui +++ b/Catchment/resources/ui/main.ui @@ -157,13 +157,13 @@ - 5 + 1 120 - 5 + 1 30 @@ -505,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 + @@ -536,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 + @@ -548,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 + @@ -606,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 + @@ -621,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 + diff --git a/Catchment/ui/catchment_area_panel.py b/Catchment/ui/catchment_area_panel.py index 19ea427..18cc91f 100644 --- a/Catchment/ui/catchment_area_panel.py +++ b/Catchment/ui/catchment_area_panel.py @@ -159,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 = 200 - min_ = 200 + step = 100 + min_ = 100 max_ = 10000 default = 2000 From 2dc3911857fcff9410e17990f35d17fe71febc62 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Fri, 17 Dec 2021 11:55:24 +0200 Subject: [PATCH 34/49] Fix changelog date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4551c94..4c74ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ # CHANGELOG -## 0.4.1 - 2021-12-08 +## 0.4.1 - 2021-12-17 ### Changed From a979eba10dbfd14c0ac274d76e121fbd7c35eee4 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Mon, 31 Oct 2022 17:27:43 +0200 Subject: [PATCH 35/49] Add IIEP logo to metadata --- Catchment/metadata.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Catchment/metadata.txt b/Catchment/metadata.txt index 8e3718b..71b6995 100644 --- a/Catchment/metadata.txt +++ b/Catchment/metadata.txt @@ -1,7 +1,7 @@ [general] name=Catchment description=Calculate point catchment areas (isochrones) using Graphhopper on OpenStreetMap roads, e.g. for educational planning. -version=0.4.0 +version=0.4.1 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). @@ -18,3 +18,4 @@ tracker=https://github.com/iiepdev/school-catchment-plugin/issues category=Plugins experimental=False deprecated=False +icon=resources/icons/iiep_logo.svg From 8f114164adee96d8ea2fc51ed6019fa958f366fe Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Mon, 31 Oct 2022 17:29:04 +0200 Subject: [PATCH 36/49] Add iiep_logo.svg --- Catchment/resources/icons/iiep_logo.svg | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 Catchment/resources/icons/iiep_logo.svg diff --git a/Catchment/resources/icons/iiep_logo.svg b/Catchment/resources/icons/iiep_logo.svg new file mode 100644 index 0000000..adc194a --- /dev/null +++ b/Catchment/resources/icons/iiep_logo.svg @@ -0,0 +1,21 @@ + + + + + + + + From 561f3b818d776007a00b2a567633a1614f8690da Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Mon, 31 Oct 2022 17:35:24 +0200 Subject: [PATCH 37/49] Try updating pre-commit action to fix black --- .github/workflows/code-style.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 3a7e3f6c2bdd657096d211714f7a1fd513ba7845 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Mon, 31 Oct 2022 17:44:35 +0200 Subject: [PATCH 38/49] Update version number --- Catchment/metadata.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Catchment/metadata.txt b/Catchment/metadata.txt index 71b6995..ab44de4 100644 --- a/Catchment/metadata.txt +++ b/Catchment/metadata.txt @@ -1,7 +1,7 @@ [general] name=Catchment description=Calculate point catchment areas (isochrones) using Graphhopper on OpenStreetMap roads, e.g. for educational planning. -version=0.4.1 +version=0.4.2 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). From 5aad2173aea13a2971101af89cfbbf8dfd7f7200 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Mon, 31 Oct 2022 18:08:29 +0200 Subject: [PATCH 39/49] Update pre-commit hooks to fix black --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fbde601..663cad6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,11 +10,11 @@ repos: - id: check-yaml - id: check-added-large-files - repo: https://github.com/PyCQA/isort - rev: 5.8.0 + rev: 5.10.1 hooks: - id: isort - repo: https://github.com/psf/black - rev: 21.5b1 + rev: 22.10.0 hooks: - id: black # - repo: https://github.com/pre-commit/mirrors-mypy @@ -22,7 +22,7 @@ repos: # hooks: # - id: mypy - repo: https://github.com/PyCQA/flake8 - rev: 3.9.2 + rev: 5.0.4 hooks: - id: flake8 additional_dependencies: From 6c82d69ed2ba900815a2548505ddd30b4a9893bc Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Mon, 31 Oct 2022 18:19:58 +0200 Subject: [PATCH 40/49] Fix black --- Catchment/ui/about_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Catchment/ui/about_panel.py b/Catchment/ui/about_panel.py index 2e55d9e..d370ec4 100644 --- a/Catchment/ui/about_panel.py +++ b/Catchment/ui/about_panel.py @@ -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) From 323e0592c148e38fd3339938d96bb5fa6bd0505f Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Mon, 31 Oct 2022 18:24:42 +0200 Subject: [PATCH 41/49] Downgrade flake8 to fix crash --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 663cad6..2c12260 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: # hooks: # - id: mypy - repo: https://github.com/PyCQA/flake8 - rev: 5.0.4 + rev: 4.0.1 hooks: - id: flake8 additional_dependencies: From b3ace10c298e9f8920e98cd1522dccf1cbb7a3f0 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Mon, 31 Oct 2022 18:32:23 +0200 Subject: [PATCH 42/49] Fix 3.22 test crash due to qgis/QGIS#50729 --- .github/workflows/test-and-pre-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-and-pre-release.yml b/.github/workflows/test-and-pre-release.yml index 38ba4f1..28f8e43 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, release-3_22, 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 From de7ade001cd00bc68a298251a0a1b203f7fa555a Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Mon, 31 Oct 2022 18:42:34 +0200 Subject: [PATCH 43/49] Update windows test LTR version to current --- .github/workflows/test-and-pre-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-and-pre-release.yml b/.github/workflows/test-and-pre-release.yml index 28f8e43..93d4550 100644 --- a/.github/workflows/test-and-pre-release.yml +++ b/.github/workflows/test-and-pre-release.yml @@ -59,7 +59,7 @@ 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 Catchment/test From b4d626fcd205b45f29cc0d73f1f8178e0b5e8ff3 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Mon, 31 Oct 2022 18:52:09 +0200 Subject: [PATCH 44/49] Update CHANGELOG.md --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c74ae9..b47a8d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ # CHANGELOG +## 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 From 456c184b052865986ce041ea73fd4bbcec0db234 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Tue, 1 Nov 2022 12:15:51 +0200 Subject: [PATCH 45/49] Use correct size iiep icon --- Catchment/resources/icons/iiep_logo.svg | 30 ++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Catchment/resources/icons/iiep_logo.svg b/Catchment/resources/icons/iiep_logo.svg index adc194a..8792fe3 100644 --- a/Catchment/resources/icons/iiep_logo.svg +++ b/Catchment/resources/icons/iiep_logo.svg @@ -1,21 +1,21 @@ - + + viewBox="0 0 141.7 141.7" style="enable-background:new 0 0 141.7 141.7;" xml:space="preserve"> - - - + + + + + From 7d64b3b379f7f699824cfde047530528c840ce00 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Tue, 1 Nov 2022 12:17:28 +0200 Subject: [PATCH 46/49] Update CHANGELOG.md --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b47a8d4..109564c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ # CHANGELOG +## 0.4.3 - 2021-11-01 + +### Changed + +- Changed IIEP logo to correct size + ## 0.4.2 - 2021-10-31 ### Changed From 91fd0d02f51d17b55f08432b0eeea059bd55b117 Mon Sep 17 00:00:00 2001 From: Riku Oja Date: Tue, 1 Nov 2022 12:17:50 +0200 Subject: [PATCH 47/49] Update version number --- Catchment/metadata.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Catchment/metadata.txt b/Catchment/metadata.txt index ab44de4..c334a24 100644 --- a/Catchment/metadata.txt +++ b/Catchment/metadata.txt @@ -1,7 +1,7 @@ [general] name=Catchment description=Calculate point catchment areas (isochrones) using Graphhopper on OpenStreetMap roads, e.g. for educational planning. -version=0.4.2 +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). From da44d377be511afe0310ac8529295ed03bf6f8f1 Mon Sep 17 00:00:00 2001 From: msorvoja Date: Mon, 24 Jul 2023 08:35:35 +0300 Subject: [PATCH 48/49] Disable isort --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2c12260..a4a3549 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,10 +9,10 @@ repos: - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - - repo: https://github.com/PyCQA/isort - rev: 5.10.1 - hooks: - - id: isort + # - repo: https://github.com/PyCQA/isort + # rev: 5.10.1 + # hooks: + # - id: isort - repo: https://github.com/psf/black rev: 22.10.0 hooks: From e3fd6724766f29118be4e0e36a075a5aa769f3f3 Mon Sep 17 00:00:00 2001 From: msorvoja Date: Mon, 24 Jul 2023 08:36:04 +0300 Subject: [PATCH 49/49] Update README --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 ![Settings panel](imgs/settings.png) -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.